Compare commits

..

418 Commits

Author SHA1 Message Date
1939fdcb33 Merge pull request 'fix(member): 강제 KR 매핑 대상에 회원 17958 추가' (#419) from test into main
Reviewed-on: #419
2026-04-28 05:00:06 +00:00
f521a240c2 Merge pull request 'test' (#418) from test into main
Reviewed-on: #418
2026-04-22 01:19:41 +00:00
0c35624dfb Merge pull request 'fix(agent-calculate): 에이전트 기본 정산 비율을 7퍼센트로 조정한다' (#417) from test into main
Reviewed-on: #417
2026-04-15 05:37:24 +00:00
5c24addf31 Merge pull request '에이전트 기능' (#416) from test into main
Reviewed-on: #416
2026-04-14 06:29:22 +00:00
8671c8efc7 Merge pull request 'test' (#415) from test into main
Reviewed-on: #415
2026-04-08 01:50:50 +00:00
16c17f4bfa Merge pull request 'feat(live-room): 라이브 캡쳐 녹화 가능 여부를 생성 조회에 반영한다' (#414) from test into main
Reviewed-on: #414
2026-03-30 12:58:19 +00:00
0e821fae1b Merge pull request 'fix(member-social): 애플 로그인 aud 검증에 serviceId를 포함한다' (#413) from test into main
Reviewed-on: #413
2026-03-30 01:00:13 +00:00
6a10eff15f Merge pull request 'fix(live-room): 진행중 목록 성인 노출 정책과 JP 강제 매핑 검증을 정리한다' (#412) from test into main
Reviewed-on: #412
2026-03-28 14:11:08 +00:00
fea329e637 Merge pull request 'fix(channel-donation): 후원 목록 탈퇴 닉네임 접두사를 제거한다' (#411) from test into main
Reviewed-on: #411
2026-03-28 10:14:16 +00:00
681e4a4036 Merge pull request 'test' (#410) from test into main
Reviewed-on: #410
2026-03-28 09:27:28 +00:00
c23f574162 Merge pull request 'fix(member): 회원 차단을 요청 ID 단건만 적용한다' (#409) from test into main
Reviewed-on: #409
2026-03-26 02:01:43 +00:00
c884d7d6c9 Merge pull request 'test' (#408) from test into main
Reviewed-on: #408
2026-03-24 10:41:41 +00:00
116e8cbca3 Merge pull request 'feat(deploy): EC2 배포 스크립트에 JVM 옵션 로드 기능 추가' (#407) from test into main
Reviewed-on: #407
2026-03-23 09:45:25 +00:00
c8187ba147 Merge pull request 'feat(db): Aurora Serverless v2(0.5~2 ACU) 최적화용 Hikari 풀 설정 추가' (#406) from test into main
Reviewed-on: #406
2026-03-23 05:13:58 +00:00
676bd0b79e Merge pull request 'test' (#405) from test into main
Reviewed-on: #405
2026-03-19 09:33:40 +00:00
7522f06bf3 Merge pull request 'fix(live-room): 라이브 방 후원 랭킹 조회에 기간 설정을 반영한다' (#404) from test into main
Reviewed-on: #404
2026-03-17 07:15:17 +00:00
a9d2d1ab48 Merge pull request 'feat(creator-community): 커뮤니티 게시물 고정 기능을 추가한다' (#403) from test into main
Reviewed-on: #403
2026-03-17 02:40:30 +00:00
e0e371cdc9 Merge pull request 'feat(admin-calculate): 관리자 라이브 환불 처리와 정산 응답 식별자를 추가한다' (#402) from test into main
Reviewed-on: #402
2026-03-16 07:13:26 +00:00
b49344d0e9 Merge pull request 'fix(admin-chat-character): JP 리전 캐릭터 등록 성별 값을 일본어로 변환한다' (#401) from test into main
Reviewed-on: #401
2026-03-16 02:37:51 +00:00
5cc152307a Merge pull request 'test' (#400) from test into main
Reviewed-on: #400
2026-03-13 14:08:53 +00:00
1fd3d41d7e Merge pull request 'test' (#399) from test into main
Reviewed-on: #399
2026-03-13 13:18:26 +00:00
c1b9dd730d Merge pull request 'fix(admin-calculate): 관리자 정산 조회 캐시를 제거하고 응답 직렬화를 명시한다' (#398) from test into main
Reviewed-on: #398
2026-03-06 03:31:24 +00:00
d40cd32c50 Merge pull request 'test' (#397) from test into main
Reviewed-on: #397
2026-03-05 09:17:37 +00:00
0289607fd9 Merge pull request 'fix(channel-donation): 관리자 채널후원 정산 조회를 날짜별과 크리에이터별로 분리하고 엑셀 다운로드를 추가한다' (#396) from test into main
Reviewed-on: #396
2026-03-03 05:59:42 +00:00
1bec644372 Merge pull request 'fix(channel-donation): 후원 조회 월 경계를 UTC 전달 기준으로 보정한다' (#395) from test into main
Reviewed-on: #395
2026-03-03 03:20:46 +00:00
f8a6d1b221 Merge pull request 'fix(channel-donation): 기부 목록 조회 월 범위를 한국 시간 기준으로 계산한다' (#394) from test into main
Reviewed-on: #394
2026-03-03 02:23:01 +00:00
ec769a1307 Merge pull request 'test' (#393) from test into main
Reviewed-on: #393
2026-02-27 06:16:51 +00:00
8e4fb0d313 Merge pull request 'test' (#392) from test into main
Reviewed-on: #392
2026-02-26 11:18:33 +00:00
cc3a620642 Merge pull request 'fix(recommend-live): 차단 관계를 추천 조회에 반영하고 캐시를 무효화한다' (#391) from test into main
Reviewed-on: #391
2026-02-25 18:40:55 +00:00
be0884e974 Merge pull request 'test' (#390) from test into main
Reviewed-on: #390
2026-02-25 16:55:31 +00:00
34456395fd Merge pull request 'test' (#389) from test into main
Reviewed-on: #389
2026-02-25 13:55:22 +00:00
26ddeb9591 Merge pull request 'test' (#388) from test into main
Reviewed-on: #388
2026-02-13 09:14:19 +00:00
cd535a628c Merge pull request 'test' (#387) from test into main
Reviewed-on: #387
2026-02-11 10:03:08 +00:00
de32b537f4 Merge pull request 'test' (#386) from test into main
Reviewed-on: #386
2026-02-08 07:42:31 +00:00
9c271fc1f6 Merge pull request 'test' (#385) from test into main
Reviewed-on: #385
2026-02-06 05:04:37 +00:00
2ddbfbccd6 Merge pull request 'test' (#384) from test into main
Reviewed-on: #384
2026-02-04 12:52:24 +00:00
80786deb72 Merge pull request 'test' (#383) from test into main
Reviewed-on: #383
2026-01-28 15:40:25 +00:00
8ca2e185ac Merge pull request '라이브 예약 반환 값 - beginDateTimeUtc 추가' (#382) from test into main
Reviewed-on: #382
2026-01-21 10:08:55 +00:00
484711ad1b Merge pull request '라이브 예약 Response에 utc 시간 변수 beginDateTimeUtc 추가' (#381) from test into main
Reviewed-on: #381
2026-01-21 07:54:05 +00:00
e80ceca0c5 Merge pull request '충전 이벤트 - langContext 문제로 충전이 되지 않는 현상을 langContext를 사용하지 않고 이전 방식으로 기록하도록 롤백하여 임시 해결' (#380) from test into main
Reviewed-on: #380
2026-01-21 02:36:21 +00:00
33293a6533 Merge pull request '라이브 방 상세 - 날짜 포맷 변경으로 유료방 입장이 불가한 문제를 해결하기 위해 이전으로 롤백' (#379) from test into main
Reviewed-on: #379
2026-01-21 02:16:42 +00:00
f0c1d4e32a Merge pull request '라이브 룸 일시 포맷에 다국어 설정 적용' (#378) from test into main
Reviewed-on: #378
2026-01-20 10:41:23 +00:00
6cd319ec76 Merge pull request 'memberId가 특정 번호일 때 currency와 관계없이 모든 구매 가능한 캔이 출력되도록 수정' (#377) from test into main
Reviewed-on: #377
2026-01-16 02:39:08 +00:00
6557ec2aed Merge pull request '푸시 알림 전송 언어 처리' (#376) from test into main
Reviewed-on: #376
2026-01-15 08:45:46 +00:00
f2f8a34319 Merge pull request '국가 컨텍스트로 캔 조회' (#375) from test into main
Reviewed-on: #375
2026-01-14 06:46:51 +00:00
c50ac6ed2c Merge pull request '캔 사용 시 국가 코드 기록 기능 추가' (#374) from test into main
Reviewed-on: #374
2026-01-12 02:31:46 +00:00
11b9c349d1 Merge pull request '번역 이벤트 커밋 후 처리 분기' (#373) from test into main
Reviewed-on: #373
2026-01-07 09:54:32 +00:00
ef9f8d65e1 Merge pull request 'test' (#372) from test into main
Reviewed-on: #372
2026-01-07 07:42:42 +00:00
299f2100e9 Merge pull request 'test' (#371) from test into main
Reviewed-on: #371
2026-01-05 06:33:29 +00:00
fd5c794480 Merge pull request 'test' (#370) from test into main
Reviewed-on: #370
2025-12-31 11:01:28 +00:00
68197de095 Merge pull request 'test' (#369) from test into main
Reviewed-on: #369
2025-12-31 05:44:54 +00:00
587f3d6b58 Merge pull request 'test' (#368) from test into main
Reviewed-on: #368
2025-11-20 16:02:25 +00:00
9b6167d46d Merge pull request 'test' (#367) from test into main
Reviewed-on: #367
2025-11-20 12:53:00 +00:00
008ee3b4e5 Merge pull request 'test' (#366) from test into main
Reviewed-on: #366
2025-11-20 06:27:02 +00:00
3a57ad23bb Merge pull request 'test' (#365) from test into main
Reviewed-on: #365
2025-11-19 08:56:28 +00:00
729552335a Merge pull request 'test' (#364) from test into main
Reviewed-on: #364
2025-11-18 10:38:50 +00:00
02ae507c87 Merge pull request 'fix(series-list): 시리즈 리스트 조회시 정렬 수정' (#363) from test into main
Reviewed-on: #363
2025-11-17 13:38:59 +00:00
5818abf69d Merge pull request 'fix(series-list): creator의 시리즈를 볼 떄와 다른 페이지에서 시리즈 리스트를 볼 때 정렬 순서 분리' (#362) from test into main
Reviewed-on: #362
2025-11-17 12:25:38 +00:00
ee403915f0 Merge pull request 'test' (#361) from test into main
Reviewed-on: #361
2025-11-17 07:13:11 +00:00
1a660088de Merge pull request 'test' (#360) from test into main
Reviewed-on: #360
2025-11-13 20:49:12 +00:00
5196c80ca8 Merge pull request 'test' (#359) from test into main
Reviewed-on: #359
2025-11-13 19:45:52 +00:00
c9c09c2998 Merge pull request 'test' (#358) from test into main
Reviewed-on: #358
2025-11-10 06:53:41 +00:00
3ea33c4c7b Merge pull request 'feat(home-latest-content): 최신 콘텐츠 조회시 정렬 조건 변경' (#357) from test into main
Reviewed-on: #357
2025-11-07 12:00:59 +00:00
451a1aa4f2 Merge pull request 'test' (#356) from test into main
Reviewed-on: #356
2025-11-06 08:48:23 +00:00
90555fd34f Merge pull request 'feat(can-use-status): PAYVERSE로 충전한 캔을 사용한 내역도 포함되도록 수정' (#355) from test into main
Reviewed-on: #355
2025-10-22 14:26:02 +00:00
0dc430b098 Merge pull request 'UseCalculate에 PAYVERSE로 충전한 캔 로그 데이터를 쌓도록 수정' (#354) from test into main
Reviewed-on: #354
2025-10-22 13:31:46 +00:00
1f2103c7fa Merge pull request 'fix(can-use): PAYVERSE로 충전한 캔이 사용되지 않는 버그 수정' (#353) from test into main
Reviewed-on: #353
2025-10-22 12:41:13 +00:00
062c17c51e Merge pull request 'feat(chat): 채팅권 구매 가격과 채팅횟수 변경' (#352) from test into main
Reviewed-on: #352
2025-10-22 07:55:13 +00:00
de169b79a1 Merge pull request 'feat(home): 인기 캐릭터 추가' (#351) from test into main
Reviewed-on: #351
2025-10-20 06:07:49 +00:00
aa24de0a5a Merge pull request 'test' (#350) from test into main
Reviewed-on: #350
2025-10-17 05:46:36 +00:00
e5937d573a Merge pull request 'test' (#349) from test into main
Reviewed-on: #349
2025-10-10 20:49:52 +00:00
6da86e12bd Merge pull request 'test' (#348) from test into main
Reviewed-on: #348
2025-10-10 19:19:47 +00:00
9049022a74 Merge pull request 'fix(admin-charge-status-detail): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정' (#347) from test into main
Reviewed-on: #347
2025-10-10 14:39:40 +00:00
7b6f3a7a5f Merge pull request 'fix(admin-charge-status): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정' (#346) from test into main
Reviewed-on: #346
2025-10-10 13:53:23 +00:00
53e9678efa Merge pull request 'fix(verify-hecto): 데이터 검증시 가격비교 제거' (#345) from test into main
Reviewed-on: #345
2025-10-10 09:58:11 +00:00
e4f547fa92 Merge pull request 'payverse 적용' (#344) from test into main
Reviewed-on: #344
2025-10-10 07:44:07 +00:00
b69756ef81 Merge pull request 'test' (#343) from test into main
Reviewed-on: #343
2025-09-18 19:25:50 +00:00
1a3a9149a2 Merge pull request 'test' (#342) from test into main
Reviewed-on: #342
2025-09-16 06:11:32 +00:00
ce120a6d5d Merge pull request 'test' (#341) from test into main
Reviewed-on: #341
2025-09-14 20:33:50 +00:00
08b5fd23ab Merge pull request 'test' (#340) from test into main
Reviewed-on: #340
2025-09-14 08:51:11 +00:00
eb18e2d009 Merge pull request 'test' (#339) from test into main
Reviewed-on: #339
2025-09-11 17:05:45 +00:00
a27852ed44 Merge pull request '캐릭터 챗봇' (#338) from test into main
Reviewed-on: #338
2025-09-10 06:08:47 +00:00
c7925c1706 Merge pull request 'feat: 최근 공지사항 API 추가' (#337) from test into main
Reviewed-on: #337
2025-07-28 02:16:19 +00:00
be59bd7e89 Merge pull request 'fix: 크리에이터 팔로우 API' (#336) from test into main
Reviewed-on: #336
2025-07-21 13:52:34 +00:00
51ce143fc2 Merge pull request 'test' (#335) from test into main
Reviewed-on: #335
2025-07-21 11:46:56 +00:00
89eb11f808 Merge pull request 'fix: 라이브 메인 API - 최근 종료된 라이브' (#334) from test into main
Reviewed-on: #334
2025-07-21 10:59:38 +00:00
30d89987a4 Merge pull request 'test' (#333) from test into main
Reviewed-on: #333
2025-07-21 09:54:56 +00:00
7959d3e5ed Merge pull request 'test' (#332) from test into main
Reviewed-on: #332
2025-07-18 12:33:22 +00:00
1e29573ef7 Merge pull request 'fix: 검색 API' (#331) from test into main
Reviewed-on: #331
2025-07-16 10:58:56 +00:00
cc2f533dc6 Merge pull request 'fix: 메인 홈 API - 요일별 시리즈' (#330) from test into main
Reviewed-on: #330
2025-07-14 19:14:06 +00:00
32b0c19f9d Merge pull request 'test' (#329) from test into main
Reviewed-on: #329
2025-07-14 17:57:26 +00:00
9af2d768e8 Merge pull request 'test' (#327) from test into main
Reviewed-on: #327
2025-07-14 11:07:57 +00:00
5677824cde Merge pull request 'test' (#326) from test into main
Reviewed-on: #326
2025-06-13 11:37:26 +00:00
e8f1bc09f9 Merge pull request 'test' (#325) from test into main
Reviewed-on: #325
2025-06-12 05:00:31 +00:00
d1a936d55b Merge pull request 'test' (#324) from test into main
Reviewed-on: #324
2025-06-10 11:01:31 +00:00
dc97eaa835 Merge pull request 'fix: 앱 콘텐츠 수정' (#323) from test into main
Reviewed-on: #323
2025-06-05 02:36:25 +00:00
dcbe57806c Merge pull request 'test' (#322) from test into main
Reviewed-on: #322
2025-06-02 12:41:46 +00:00
b14438cc15 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#321) from test into main
Reviewed-on: #321
2025-05-28 07:19:27 +00:00
b27d3bd5c6 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#320) from test into main
Reviewed-on: #320
2025-05-26 10:33:16 +00:00
03ebc9cfe9 Merge pull request 'fix: 큐레이션 아이템 조회' (#319) from test into main
Reviewed-on: #319
2025-05-23 05:43:37 +00:00
24841b9850 Merge pull request 'fix: 코루틴 내 트랜잭션 간 조회 안 되는 문제 해결' (#318) from test into main
Reviewed-on: #318
2025-05-22 04:31:42 +00:00
d35a3d1a8c Merge pull request 'test' (#317) from test into main
Reviewed-on: #317
2025-05-20 10:26:16 +00:00
60c4e0b528 Merge pull request 'test' (#316) from test into main
Reviewed-on: #316
2025-05-20 06:03:10 +00:00
84f33d1bc2 Merge pull request 'fix: 소셜로그인시 유저 행동데이터 SIGN_UP 중복 기록 버그' (#315) from test into main
Reviewed-on: #315
2025-05-12 08:24:53 +00:00
c4e1709b99 Merge pull request 'test' (#314) from test into main
Reviewed-on: #314
2025-05-12 02:12:47 +00:00
e7a5fd5819 Merge pull request 'fix: 구글/카카오 로그인 회원가입 오류 수정' (#313) from test into main
Reviewed-on: #313
2025-05-02 10:58:04 +00:00
4bde03643c Merge pull request 'test' (#312) from test into main
Reviewed-on: #312
2025-04-29 02:56:16 +00:00
1bc52b56af Merge pull request 'fix: 콘텐츠 업로드 - 제목과 내용에서 trim 함수를 적용하여 앞/뒤 빈칸 제거' (#311) from test into main
Reviewed-on: #311
2025-04-25 09:43:31 +00:00
9c33fd93f7 Merge pull request 'refactor: 본인인증 - 본인인증이 완료된 후 유저 행동 데이터를 기록하도록 수정' (#310) from test into main
Reviewed-on: #310
2025-04-24 11:10:17 +00:00
3c087bc275 Merge pull request '유저 행동 데이터, 포인트 추가' (#309) from test into main
Reviewed-on: #309
2025-04-24 02:44:57 +00:00
8ad13c289e Merge pull request '회원탈퇴' (#308) from test into main
Reviewed-on: #308
2025-04-15 10:42:37 +00:00
7577f48a09 Merge pull request '한정판 콘텐츠' (#307) from test into main
Reviewed-on: #307
2025-04-15 09:44:12 +00:00
0251906964 Merge pull request '비밀번호 찾기' (#306) from test into main
Reviewed-on: #306
2025-04-10 06:28:57 +00:00
2723a5f134 Merge pull request '일별 전체 회원 수' (#305) from test into main
Reviewed-on: #305
2025-04-10 02:30:00 +00:00
c3c60605fd Merge pull request '관리자 - 회원리스트, 크리에이터 리스트' (#304) from test into main
Reviewed-on: #304
2025-04-09 10:35:01 +00:00
238f704b22 Merge pull request '소셜 로그인, 회원가입 - 이메일 체크 로직 수정' (#303) from test into main
Reviewed-on: #303
2025-04-08 07:04:11 +00:00
5639d8ac8e Merge pull request 'test' (#302) from test into main
Reviewed-on: #302
2025-04-07 10:23:13 +00:00
9aac591591 Merge pull request 'test' (#301) from test into main
Reviewed-on: #301
2025-04-01 13:31:24 +00:00
ffa8e5aebb Merge pull request '일별 전체 회원 수 통계' (#300) from test into main
Reviewed-on: #300
2025-03-31 03:50:18 +00:00
cbbfe014cc Merge pull request '광고 통계' (#299) from test into main
Reviewed-on: #299
2025-03-28 05:29:40 +00:00
83028f7817 Merge pull request 'test' (#298) from test into main
Reviewed-on: #298
2025-03-26 21:08:29 +00:00
70d1795557 Merge pull request 'test' (#297) from test into main
Reviewed-on: #297
2025-03-26 04:23:28 +00:00
8c6c681424 Merge pull request 'marketing 정보 업데이트 시 pid 값이 있으면 항상 로그인 기록 남기기' (#296) from test into main
Reviewed-on: #296
2025-03-25 11:25:46 +00:00
50bc9f4ff3 Merge pull request '라이브 방 - 예약 중 조회' (#295) from test into main
Reviewed-on: #295
2025-03-24 10:04:08 +00:00
f00ea03fad Merge pull request 'test' (#294) from test into main
Reviewed-on: #294
2025-03-24 09:09:16 +00:00
f22e7b9ad1 Merge pull request '자동생성 닉네임에 사용될 형용사, 명사 값 추가' (#293) from test into main
Reviewed-on: #293
2025-03-21 10:27:30 +00:00
c7ec95f4bb Merge pull request 'test' (#292) from test into main
Reviewed-on: #292
2025-03-20 19:24:03 +00:00
229e7a8ccc Merge pull request '시리즈 상세, 채널 상세' (#291) from test into main
Reviewed-on: #291
2025-03-19 09:43:06 +00:00
3c616474ff Merge pull request 'test' (#290) from test into main
Reviewed-on: #290
2025-03-19 07:51:25 +00:00
56eb6b3ce3 Merge pull request '19금 콘텐츠 보기 설정 적용' (#289) from test into main
Reviewed-on: #289
2025-03-19 02:05:17 +00:00
545836d43c Merge pull request '관리자 광고통계, 일별 전체 회원 수' (#288) from test into main
Reviewed-on: #288
2025-03-17 08:50:59 +00:00
219f83dec0 Merge pull request 'test' (#287) from test into main
Reviewed-on: #287
2025-03-17 05:54:05 +00:00
a76a841238 Merge pull request 'test' (#286) from test into main
Reviewed-on: #286
2025-03-14 16:11:17 +00:00
c26680de84 Merge pull request '이벤트 배너, 충전 이벤트 - 기간 설정에 시간 추가' (#285) from test into main
Reviewed-on: #285
2025-03-14 03:40:07 +00:00
8fffad9d3a Merge pull request 'test' (#284) from test into main
Reviewed-on: #284
2025-03-13 12:25:35 +00:00
f4f0f203a2 Merge pull request '유저 정보 조회' (#283) from test into main
Reviewed-on: #283
2025-03-12 08:00:13 +00:00
b7196f5a0c Merge pull request 'test' (#282) from test into main
Reviewed-on: #282
2025-03-11 08:01:05 +00:00
5d33a18890 Merge pull request 'test' (#281) from test into main
Reviewed-on: #281
2025-03-10 05:35:30 +00:00
96186a1a50 Merge pull request '마케팅 - 매체 파트너 코드 조회 API - link 값 수정' (#280) from test into main
Reviewed-on: #280
2025-03-07 06:27:08 +00:00
bc8bc479d1 Merge pull request 'test' (#279) from test into main
Reviewed-on: #279
2025-03-06 17:58:32 +00:00
47595b1291 Merge pull request 'test' (#278) from test into main
Reviewed-on: #278
2025-03-05 14:05:47 +00:00
01a88964df Merge pull request 'test' (#277) from test into main
Reviewed-on: #277
2025-03-05 09:44:59 +00:00
3a2b77379f Merge pull request '콘텐츠 업로드' (#276) from test into main
Reviewed-on: #276
2025-02-28 04:45:04 +00:00
dc4e5f75cd Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#275) from test into main
Reviewed-on: #275
2025-02-26 03:14:33 +00:00
d0178d551c Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#274) from test into main
Reviewed-on: #274
2025-02-25 14:54:53 +00:00
827333108d Merge pull request '콘텐츠 대여기간' (#273) from test into main
Reviewed-on: #273
2025-02-25 14:02:18 +00:00
587b90bd27 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 콘텐츠' (#272) from test into main
Reviewed-on: #272
2025-02-22 01:56:49 +00:00
4dc20c5e90 Merge pull request '콘텐츠 메인 무료 탭' (#271) from test into main
Reviewed-on: #271
2025-02-22 00:39:09 +00:00
ac25782f2b Merge pull request '관리자 태그 큐레이션 - 콘텐츠 검색' (#270) from test into main
Reviewed-on: #270
2025-02-21 21:46:15 +00:00
20437d56e7 Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#269) from test into main
Reviewed-on: #269
2025-02-21 21:15:52 +00:00
f0b412828a Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#268) from test into main
Reviewed-on: #268
2025-02-21 19:27:33 +00:00
367faac5c3 Merge pull request 'test' (#267) from test into main
Reviewed-on: #267
2025-02-20 18:24:35 +00:00
84deaaa970 Merge pull request '콘텐츠 메인 시리즈 탭 - 장르별 시리즈' (#266) from test into main
Reviewed-on: #266
2025-02-19 12:52:17 +00:00
a2b39466c2 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#265) from test into main
Reviewed-on: #265
2025-02-19 11:34:02 +00:00
03586c4005 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#264) from test into main
Reviewed-on: #264
2025-02-19 09:49:04 +00:00
6ea69e1510 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠' (#263) from test into main
Reviewed-on: #263
2025-02-19 09:24:24 +00:00
553c6dc539 Merge pull request '콘텐츠 메인 단편 탭 - 새로운 단편' (#262) from test into main
Reviewed-on: #262
2025-02-19 08:20:14 +00:00
6cc22f5b6d Merge pull request '콘텐츠 메인 홈, 무료 탭' (#261) from test into main
Reviewed-on: #261
2025-02-19 06:34:53 +00:00
9103d67cc1 Merge pull request 'test' (#260) from test into main
Reviewed-on: #260
2025-02-18 18:13:25 +00:00
25083fb0e4 Merge pull request 'test' (#259) from test into main
Reviewed-on: #259
2025-02-18 14:48:09 +00:00
d2dc045255 Merge pull request 'test' (#258) from test into main
Reviewed-on: #258
2025-02-14 18:09:11 +00:00
b8621dfbb0 Merge pull request 'test' (#257) from test into main
Reviewed-on: #257
2025-02-09 13:36:21 +00:00
93633940dd Merge pull request 'test' (#256) from test into main
Reviewed-on: #256
2025-02-03 07:20:32 +00:00
b6f5325351 Merge pull request 'test' (#255) from test into main
Reviewed-on: #255
2025-01-31 15:22:23 +00:00
7c32c08f1f Merge pull request 'test' (#254) from test into main
Reviewed-on: #254
2025-01-17 05:46:00 +00:00
1d268da08d Merge pull request '오디션 등록 푸시알림 메시지 수정' (#253) from test into main
Reviewed-on: #253
2025-01-10 10:23:19 +00:00
797666ae0d Merge pull request 'test' (#252) from test into main
Reviewed-on: #252
2025-01-08 14:11:08 +00:00
dcf470997e Merge pull request 'test' (#251) from test into main
Reviewed-on: #251
2025-01-08 06:29:33 +00:00
0974d1dbf8 Merge pull request '관리자 오디션 지원 리스트' (#250) from test into main
Reviewed-on: #250
2025-01-07 19:44:39 +00:00
12a35db6cd Merge pull request '오디션' (#249) from test into main
Reviewed-on: #249
2025-01-07 17:24:40 +00:00
9abbb05ad8 Merge pull request 'test' (#248) from test into main
Reviewed-on: #248
2024-12-18 07:10:01 +00:00
1ecaf69b0b Merge pull request 'test' (#247) from test into main
Reviewed-on: #247
2024-12-17 13:43:45 +00:00
e334d1e5d9 Merge pull request '콘텐츠 댓글 푸시 대상자' (#246) from test into main
Reviewed-on: #246
2024-12-03 15:54:34 +00:00
b735e861d0 Merge pull request '콘텐츠 댓글 푸시 대상자 조회' (#245) from test into main
Reviewed-on: #245
2024-12-02 15:06:49 +00:00
4eb433d372 Merge pull request 'test' (#244) from test into main
Reviewed-on: #244
2024-12-02 12:05:30 +00:00
2416ae61f3 Merge pull request 'test' (#243) from test into main
Reviewed-on: #243
2024-12-02 04:29:50 +00:00
01fb336985 Merge pull request '콘텐츠 등록' (#242) from test into main
Reviewed-on: #242
2024-11-26 12:46:24 +00:00
b6af88a732 Merge pull request 'test' (#241) from test into main
Reviewed-on: #241
2024-11-26 05:33:45 +00:00
58a2a17d6d Merge pull request 'test' (#240) from test into main
Reviewed-on: #240
2024-11-23 17:59:23 +00:00
79f5a0f520 Merge pull request '내 콘텐츠 수정, 삭제 시 콘텐츠 조회 함수' (#239) from test into main
Reviewed-on: #239
2024-11-21 06:34:30 +00:00
7f6c0f7f04 Merge pull request 'Redis connection 수정' (#238) from test into main
Reviewed-on: #238
2024-11-20 09:58:56 +00:00
f658df4dca Merge pull request 'Redis connection' (#237) from test into main
Reviewed-on: #237
2024-11-20 07:52:58 +00:00
9d43b8e23a Merge pull request 'Redis connection' (#236) from test into main
Reviewed-on: #236
2024-11-20 06:47:52 +00:00
4270aef79b Merge pull request 'test' (#235) from test into main
Reviewed-on: #235
2024-11-11 15:34:35 +00:00
1c0dc82d44 Merge pull request '콘텐츠 구매 - 소장만 추가' (#234) from test into main
Reviewed-on: #234
2024-11-08 12:40:29 +00:00
c1e325aadf Merge pull request 'test' (#233) from test into main
Reviewed-on: #233
2024-11-05 07:26:19 +00:00
cec87da69d Merge pull request '콘텐츠 대여가격' (#232) from test into main
Reviewed-on: #232
2024-10-31 05:23:01 +00:00
f68f24cb2c Merge pull request 'test' (#231) from test into main
Reviewed-on: #231
2024-10-31 03:09:13 +00:00
ed094347fc Merge pull request '라이브 방 후원랭킹' (#230) from test into main
Reviewed-on: #230
2024-10-30 05:16:05 +00:00
b8afdffbe1 Merge pull request 'test' (#229) from test into main
Reviewed-on: #229
2024-10-29 08:35:58 +00:00
f6ba79f31c Merge pull request 'test' (#228) from test into main
Reviewed-on: #228
2024-10-25 03:45:26 +00:00
5f3b1663d2 Merge pull request '관리자 - 콘텐츠 리스트' (#227) from test into main
Reviewed-on: #227
2024-10-16 03:33:23 +00:00
66e786b4bb Merge pull request '관리자 - 시리즈 리스트 API' (#226) from test into main
Reviewed-on: #226
2024-10-14 15:39:45 +00:00
f671114574 Merge pull request '관리자 - 시리즈 리스트 API' (#225) from test into main
Reviewed-on: #225
2024-10-14 10:37:28 +00:00
ce37060d94 Merge pull request '관리자 - 시리즈 리스트 API 추가' (#224) from test into main
Reviewed-on: #224
2024-10-14 10:10:43 +00:00
7d19a4d184 Merge pull request 'test' (#223) from test into main
Reviewed-on: #223
2024-10-13 17:30:25 +00:00
22f28a2f8a Merge pull request '콘텐츠 메인 - 추천시리즈, 새로운 콘텐츠, 큐레이션' (#222) from test into main
Reviewed-on: #222
2024-10-13 16:33:15 +00:00
ceef9ca979 Merge pull request 'test' (#221) from test into main
Reviewed-on: #221
2024-10-11 05:06:22 +00:00
efe8f4f939 Merge pull request '콘텐츠 메인 - 새로운 콘텐츠 섹션 두번째 정렬 조건 추가' (#220) from test into main
Reviewed-on: #220
2024-10-04 07:21:38 +00:00
ba692a1195 Merge pull request '시리즈 상세 - 콘텐츠 리스트 두번째 정렬 조건 추가' (#219) from test into main
Reviewed-on: #219
2024-10-04 02:41:28 +00:00
d732bad042 Merge pull request '시리즈 상세' (#218) from test into main
Reviewed-on: #218
2024-10-02 09:18:19 +00:00
4c935c3bee Merge pull request '예약 라이브 개수 제한' (#217) from test into main
Reviewed-on: #217
2024-09-25 05:42:45 +00:00
c160dd791f Merge pull request 'test' (#216) from test into main
Reviewed-on: #216
2024-09-24 10:17:58 +00:00
23cd1b4601 Merge pull request '라이브 후원현황 API' (#215) from test into main
Reviewed-on: #215
2024-09-23 13:58:14 +00:00
031fc8ba1b Merge pull request 'test' (#214) from test into main
Reviewed-on: #214
2024-09-23 06:24:12 +00:00
c6853289ad Merge pull request 'test' (#213) from test into main
Reviewed-on: #213
2024-09-11 08:23:08 +00:00
2497bb69bc Merge pull request 'test' (#212) from test into main
Reviewed-on: #212
2024-09-11 07:47:35 +00:00
a58a67e0a2 Merge pull request 'test' (#211) from test into main
Reviewed-on: #211
2024-09-11 06:00:31 +00:00
4315fe12a5 Merge pull request 'test' (#210) from test into main
Reviewed-on: #210
2024-09-06 19:00:39 +00:00
42f10a8899 Merge pull request 'test' (#209) from test into main
Reviewed-on: #209
2024-09-05 10:12:14 +00:00
1e4b47f989 Merge pull request 'test' (#208) from test into main
Reviewed-on: #208
2024-08-30 09:17:41 +00:00
ff255dbfae Merge pull request 'test' (#207) from test into main
Reviewed-on: #207
2024-08-27 07:31:05 +00:00
dbe9b72feb Merge pull request 'test' (#206) from test into main
Reviewed-on: #206
2024-08-23 13:48:24 +00:00
95a714b391 Merge pull request '탐색' (#205) from test into main
Reviewed-on: #205
2024-08-19 13:21:31 +00:00
28f58c7f56 Merge pull request '라이브' (#204) from test into main
Reviewed-on: #204
2024-08-14 09:34:52 +00:00
8bd46d8f21 Merge pull request '크리에이터 관리자 시리즈' (#203) from test into main
Reviewed-on: #203
2024-08-14 07:41:33 +00:00
e1bb8e54ed Merge pull request '크리에이터 커뮤니티' (#202) from test into main
Reviewed-on: #202
2024-08-06 11:41:11 +00:00
1de705b063 Merge pull request '크리에이터 커뮤니티' (#201) from test into main
Reviewed-on: #201
2024-08-06 06:37:30 +00:00
f6926ad356 Merge pull request '남/여 크리에이터에서 특정 크리에이터 제거' (#200) from test into main
Reviewed-on: #200
2024-07-26 07:54:18 +00:00
2cdbbb1b37 Merge pull request 'test' (#199) from test into main
Reviewed-on: #199
2024-07-25 16:08:20 +00:00
4dce8c8f03 Merge pull request '크리에이터 커뮤니티' (#198) from test into main
Reviewed-on: #198
2024-07-10 05:24:58 +00:00
97a5bace6f Merge pull request 'test' (#197) from test into main
Reviewed-on: #197
2024-07-08 14:17:42 +00:00
d4d51ec48f Merge pull request 'test' (#196) from test into main
Reviewed-on: #196
2024-07-02 08:57:11 +00:00
fb91398462 Merge pull request '커뮤니티 게시물' (#195) from test into main
Reviewed-on: #195
2024-06-17 14:09:26 +00:00
105dadd798 Merge pull request '커뮤니티 게시물' (#194) from test into main
Reviewed-on: #194
2024-06-15 11:57:33 +00:00
2abf2837d3 Merge pull request '커뮤니티 게시물' (#193) from test into main
Reviewed-on: #193
2024-06-11 12:13:47 +00:00
422aa67af6 Merge pull request '커뮤니티 게시물' (#192) from test into main
Reviewed-on: #192
2024-06-11 11:55:05 +00:00
7fffab6985 Merge pull request '크리에이터 정산 - 입력된 비율로 계산' (#191) from test into main
Reviewed-on: #191
2024-06-11 08:07:22 +00:00
5a4be3d2c1 Merge pull request '콘텐츠 상세' (#190) from test into main
Reviewed-on: #190
2024-06-07 10:30:06 +00:00
f39a7681db Merge pull request 'test' (#189) from test into main
Reviewed-on: #189
2024-06-04 03:39:23 +00:00
c60a7580ba Merge pull request 'test' (#188) from test into main
Reviewed-on: #188
2024-06-03 22:13:56 +00:00
97edb56edc Merge pull request 'test' (#187) from test into main
Reviewed-on: #187
2024-05-29 17:04:39 +00:00
6ebca8d22b Merge pull request '관리자 - 라이브 리스트' (#186) from test into main
Reviewed-on: #186
2024-05-28 18:18:44 +00:00
95371ad934 Merge pull request '(크리에이터)관리자 커뮤니티 게시물 정산' (#185) from test into main
Reviewed-on: #185
2024-05-28 16:54:44 +00:00
2c176825fd Merge pull request 'test' (#184) from test into main
Reviewed-on: #184
2024-05-28 16:09:51 +00:00
fae7de48d3 Merge pull request 'test' (#183) from test into main
Reviewed-on: #183
2024-05-27 08:28:27 +00:00
b8230646a2 Merge pull request '커뮤니티 게시글 유료화' (#182) from test into main
Reviewed-on: #182
2024-05-24 14:44:14 +00:00
43279541dd Merge pull request '콘텐츠별 누적정산' (#181) from test into main
Reviewed-on: #181
2024-05-21 06:37:51 +00:00
b4791977c1 Merge pull request 'PG 심사를 위한 캔 충전 로직 추가' (#180) from test into main
Reviewed-on: #180
2024-05-20 06:38:40 +00:00
ef917ecc25 Merge pull request '라이브 방 - 크리에이터 입장 가능 설정 추가' (#179) from test into main
Reviewed-on: #179
2024-05-14 12:09:53 +00:00
a93faad951 Merge pull request '룰렛 방식 수정' (#178) from test into main
Reviewed-on: #178
2024-05-10 18:00:40 +00:00
fd001d24d3 Merge pull request '추천시리즈 API 추가' (#177) from test into main
Reviewed-on: #177
2024-05-07 10:34:10 +00:00
7aa5884797 Merge pull request '구글 인 앱 결제 검증코드 수정' (#176) from test into main
Reviewed-on: #176
2024-05-03 10:06:39 +00:00
5b237a1547 Merge pull request 'test' (#175) from test into main
Reviewed-on: #175
2024-05-03 06:08:55 +00:00
2e37990d87 Merge pull request '탐색 - 남/여 크리에이터 리스트' (#174) from test into main
Reviewed-on: #174
2024-05-02 17:24:18 +00:00
dd07d724a8 Merge pull request 'test' (#173) from test into main
Reviewed-on: #173
2024-05-02 16:41:50 +00:00
03ce8618e7 Merge pull request '관리자 시그니처 설정' (#172) from test into main
Reviewed-on: #172
2024-05-02 07:13:21 +00:00
db1a7a7fd6 Merge pull request '시그니처 후원 시간 추가' (#171) from test into main
Reviewed-on: #171
2024-05-02 06:23:22 +00:00
36a82d7f53 Merge pull request '시리즈, 시리즈 콘텐츠' (#170) from test into main
Reviewed-on: #170
2024-04-30 14:00:01 +00:00
3a34401113 Merge pull request '시리즈 상세' (#169) from test into main
Reviewed-on: #169
2024-04-30 09:44:55 +00:00
9927268330 Merge pull request '구글 인 앱 결제' (#168) from test into main
Reviewed-on: #168
2024-04-30 08:24:03 +00:00
c45c97e29d Merge pull request '시리즈' (#167) from test into main
Reviewed-on: #167
2024-04-26 18:51:10 +00:00
c64a315226 Merge pull request 'test' (#166) from test into main
Reviewed-on: #166
2024-04-18 16:40:55 +00:00
a4cafca6ab Merge pull request 'test' (#165) from test into main
Reviewed-on: #165
2024-04-18 10:02:45 +00:00
46284a0660 Merge pull request 'test' (#164) from test into main
Reviewed-on: #164
2024-04-15 12:31:42 +00:00
05df86e15a Merge pull request 'test' (#163) from test into main
Reviewed-on: #163
2024-04-09 14:40:33 +00:00
8b433027e2 Merge pull request 'test' (#162) from test into main
Reviewed-on: #162
2024-04-09 13:27:24 +00:00
5bd4ff7610 Merge pull request '결제 테이블에 구글결제의 경우 orderId 추가' (#161) from test into main
Reviewed-on: #161
2024-04-05 03:10:00 +00:00
d693c397ea Merge pull request '.' (#160) from test into main
Reviewed-on: #160
2024-04-03 06:49:36 +00:00
1d8d1ec9a5 Merge pull request 'test' (#159) from test into main
Reviewed-on: #159
2024-04-03 06:27:26 +00:00
5e491f11ee Merge pull request '크리에이터 관리자 라이브 정산' (#158) from test into main
Reviewed-on: #158
2024-04-03 03:45:31 +00:00
7cedea06ac Merge pull request '캔 사용' (#157) from test into main
Reviewed-on: #157
2024-04-01 12:42:44 +00:00
2e5f750e50 Merge pull request 'test' (#156) from test into main
Reviewed-on: #156
2024-04-01 10:20:09 +00:00
20289cad10 Merge pull request '콘텐츠 상세' (#155) from test into main
Reviewed-on: #155
2024-03-29 10:10:04 +00:00
e0d64c31c7 Merge pull request '콘텐츠 상세' (#154) from test into main
Reviewed-on: #154
2024-03-29 08:18:15 +00:00
8c1b95dc97 Merge pull request '구글 인 앱구매 검증' (#153) from test into main
Reviewed-on: #153
2024-03-28 17:13:57 +00:00
fb5641343e Merge pull request 'test' (#152) from test into main
Reviewed-on: #152
2024-03-28 06:31:43 +00:00
87765941eb Merge pull request '구글 인 앱 결제 검증' (#151) from test into main
Reviewed-on: #151
2024-03-22 20:10:01 +00:00
1809862c16 Merge pull request '구글 인 앱 결제 처리과정 축소' (#150) from test into main
Reviewed-on: #150
2024-03-22 15:27:25 +00:00
300f784f7d Merge pull request '구글 인 앱 결제 검증 과정 try/catch로 예외 처리' (#149) from test into main
Reviewed-on: #149
2024-03-22 11:59:37 +00:00
67a045eae6 Merge pull request '구글 인 앱 결제 검증 수정' (#148) from test into main
Reviewed-on: #148
2024-03-22 11:36:35 +00:00
2a79903a28 Merge pull request 'test' (#147) from test into main
Reviewed-on: #147
2024-03-22 10:08:00 +00:00
d3222ce083 Merge pull request '관리자 캔 충전현황' (#146) from test into main
Reviewed-on: #146
2024-03-21 15:56:02 +00:00
406a421742 Merge pull request '구글 인 앱 결제 검증 코드 수정' (#145) from test into main
Reviewed-on: #145
2024-03-21 15:22:34 +00:00
10bf728faf Merge pull request '구글 인 앱 결제 검증 코드 수정' (#144) from test into main
Reviewed-on: #144
2024-03-21 14:37:09 +00:00
607617747c Merge pull request '구글 인 앱 결제 검증 코드 수정' (#143) from test into main
Reviewed-on: #143
2024-03-21 12:47:51 +00:00
f0a69eb1a2 Merge pull request 'test' (#142) from test into main
Reviewed-on: #142
2024-03-21 07:45:02 +00:00
6b307a6e17 Merge pull request 'test' (#141) from test into main
Reviewed-on: #141
2024-03-13 11:28:13 +00:00
08d08a934a Merge pull request 'test' (#140) from test into main
Reviewed-on: #140
2024-03-12 06:20:02 +00:00
c500c12668 Merge pull request 'test' (#139) from test into main
Reviewed-on: #139
2024-03-08 13:40:27 +00:00
62060adeba Merge pull request '채널 후원 랭킹' (#138) from test into main
Reviewed-on: #138
2024-02-29 10:28:51 +00:00
b2fc75edb8 Merge pull request '룰렛 정렬 순서 수정' (#137) from test into main
Reviewed-on: #137
2024-02-27 17:29:36 +00:00
a999dd2085 Merge pull request 'test' (#136) from test into main
Reviewed-on: #136
2024-02-27 16:16:30 +00:00
49f95ab100 Merge pull request '회원테이블에 adid 추가' (#135) from test into main
Reviewed-on: #135
2024-02-27 05:49:47 +00:00
1a84d5b30c Merge pull request 'test' (#134) from test into main
Reviewed-on: #134
2024-02-24 20:35:57 +00:00
3b65050632 Merge pull request 'redis ssl false' (#133) from test into main
Reviewed-on: #133
2024-02-17 13:00:25 +00:00
d0df31674c Merge pull request 'test' (#132) from test into main
Reviewed-on: #132
2024-02-17 12:44:52 +00:00
1fe88402e2 Merge pull request 'test' (#131) from test into main
Reviewed-on: #131
2024-02-14 07:12:41 +00:00
67097696e6 Merge pull request '커뮤니티 게시물 시간' (#130) from test into main
Reviewed-on: #130
2024-02-12 08:14:24 +00:00
8e7e77067a Merge pull request 'test' (#129) from test into main
Reviewed-on: #129
2024-02-12 07:53:26 +00:00
9899390b61 Merge pull request '관리자 콘텐츠 리스트, 수정' (#128) from test into main
Reviewed-on: #128
2024-02-08 18:19:50 +00:00
80c476a908 Merge pull request '관리자 콘텐츠 리스트' (#127) from test into main
Reviewed-on: #127
2024-02-08 14:45:54 +00:00
59da1d6e49 Merge pull request '카테고리 콘텐츠' (#126) from test into main
Reviewed-on: #126
2024-02-07 13:33:37 +00:00
5aef7dac33 Merge pull request 'test' (#125) from test into main
Reviewed-on: #125
2024-02-07 09:39:09 +00:00
faf7aa06b6 Merge pull request 'test' (#124) from test into main
Reviewed-on: #124
2024-02-05 07:03:45 +00:00
38ef6e5583 Merge pull request 'test' (#123) from test into main
Reviewed-on: #123
2024-02-05 02:12:10 +00:00
c0b15b5d94 Merge pull request '콘텐츠 업로드' (#122) from test into main
Reviewed-on: #122
2024-01-30 03:45:31 +00:00
2cfc067ea1 Merge pull request '콘텐츠 전체 리스트' (#121) from test into main
Reviewed-on: #121
2024-01-29 09:00:23 +00:00
a91db4f956 Merge pull request '콘텐츠 상단 고정 기능 추가' (#120) from test into main
Reviewed-on: #120
2024-01-29 02:45:41 +00:00
8a09780a02 Merge pull request 'test' (#119) from test into main
Reviewed-on: #119
2024-01-26 06:19:49 +00:00
45e8ec6505 Merge pull request '콘텐츠 정렬 기준' (#118) from test into main
Reviewed-on: #118
2024-01-24 15:03:06 +00:00
4554b85914 Merge pull request '회원가입 시 닉네임 validation 조건' (#117) from test into main
Reviewed-on: #117
2024-01-24 07:11:23 +00:00
8aa79c4a9c Merge pull request '콘텐츠 등록 - 태그 등록' (#116) from test into main
Reviewed-on: #116
2024-01-16 15:19:59 +00:00
c8d3210b57 Merge pull request 'test' (#115) from test into main
Reviewed-on: #115
2024-01-11 09:05:44 +00:00
2282a49563 Merge pull request 'test' (#114) from test into main
Reviewed-on: #114
2024-01-11 03:49:53 +00:00
b82fdfb2c8 Merge pull request '예약 업로드' (#113) from test into main
Reviewed-on: #113
2024-01-10 16:59:51 +00:00
2d17eac199 Merge pull request '19세 미만이 인증처리 되던 버그 수정' (#112) from test into main
Reviewed-on: #112
2024-01-08 10:07:21 +00:00
e482bc3aad Merge pull request '콘텐츠를 올린 크리에이터가 댓글을 삭제할 수 있도록 수정' (#111) from test into main
Reviewed-on: #111
2024-01-04 11:36:43 +00:00
ec022b74d1 Merge pull request '캔 쿠폰 조회 로직 수정' (#110) from test into main
Reviewed-on: #110
2024-01-04 09:59:20 +00:00
dc42c09ce3 Merge pull request '캔 쿠폰 조회' (#109) from test into main
Reviewed-on: #109
2024-01-03 15:48:09 +00:00
046a34d2a4 Merge pull request 'test' (#108) from test into main
Reviewed-on: #108
2024-01-03 15:19:42 +00:00
9ff6ec1888 Merge pull request '캔 쿠폰 시스템' (#107) from test into main
Reviewed-on: #107
2024-01-03 11:28:48 +00:00
d2950106ec Merge pull request '콘텐츠 랭킹 - 후원 순위 제거, 룰렛 아이템 개수 10로 변경' (#106) from test into main
Reviewed-on: #106
2023-12-26 12:50:09 +00:00
962f800d2e Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 인증하지 않은 사람은 19금이 아닌 최신 게시물이 조회되도록 수정' (#105) from test into main
Reviewed-on: #105
2023-12-25 08:19:43 +00:00
962107e507 Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#104) from test into main
Reviewed-on: #104
2023-12-21 19:28:10 +00:00
039bd11963 Merge pull request '커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#103) from test into main
Reviewed-on: #103
2023-12-21 19:05:12 +00:00
5c250ea4ae Merge pull request '크리에이터 커뮤니티' (#102) from test into main
Reviewed-on: #102
2023-12-21 15:10:55 +00:00
e3405bcec6 Merge pull request 'test' (#101) from test into main
Reviewed-on: #101
2023-12-13 16:14:50 +00:00
0fd1c2235f Merge pull request '라이브 정산 - 정렬 순서 추가 (라이브 방 id, 구분)' (#100) from test into main
Reviewed-on: #100
2023-12-10 16:58:05 +00:00
b20c29b022 Merge pull request 'test' (#99) from test into main
Reviewed-on: #99
2023-12-10 12:34:51 +00:00
12d5dcd298 Merge pull request 'test' (#98) from test into main
Reviewed-on: #98
2023-12-10 09:02:29 +00:00
2c305dc6c6 Merge pull request 'test' (#97) from test into main
Reviewed-on: #97
2023-12-10 06:48:43 +00:00
62f76f7433 Merge pull request 'test' (#96) from test into main
Reviewed-on: #96
2023-12-07 01:46:33 +00:00
858ce524f9 Merge pull request 'test' (#95) from test into main
Reviewed-on: #95
2023-11-27 12:48:24 +00:00
3795fb4a40 Merge pull request 'test' (#94) from test into main
Reviewed-on: #94
2023-11-24 07:03:10 +00:00
0c01aeec50 Merge pull request '관리자 - 이벤트 배너 등록' (#93) from test into main
Reviewed-on: #93
2023-11-21 16:21:19 +00:00
892206744d Merge pull request '이벤트 배너, 팝업' (#92) from test into main
Reviewed-on: #92
2023-11-21 12:59:30 +00:00
9e2c1474db Merge pull request '메시지 보내기 유저 검색' (#91) from test into main
Reviewed-on: #91
2023-11-20 05:41:57 +00:00
16328f73d9 Merge pull request '크리에이터 관리자, 관리자 - 일자별 콘텐츠 후원 정산 API' (#90) from test into main
Reviewed-on: #90
2023-11-14 13:14:19 +00:00
e0d4f53cf4 Merge pull request 'test' (#89) from test into main
Reviewed-on: #89
2023-11-14 12:15:51 +00:00
e09a59c5b4 Merge pull request 'test' (#88) from test into main
Reviewed-on: #88
2023-11-14 11:09:14 +00:00
049e654535 Merge pull request '관리자 일자별 콘텐츠 후원 정산 - 크리에이터 순으로 정렬' (#87) from test into main
Reviewed-on: #87
2023-11-14 09:22:38 +00:00
c927dc4ecd Merge pull request '관리자 - 일자별 콘텐츠 후원 정산 페이지 추가' (#86) from test into main
Reviewed-on: #86
2023-11-14 09:03:35 +00:00
fe4ecd0ad8 Merge pull request '크리에이터 관리자 - 일자별 콘텐츠 정산 페이징 안되는 버그 수정' (#85) from test into main
Reviewed-on: #85
2023-11-14 05:17:58 +00:00
78d476fe80 Merge pull request '라이브 상세 - 시작 시간 dateformat yyyy.MM.dd E hh:mm a 로 복구' (#84) from test into main
Reviewed-on: #84
2023-11-14 03:31:54 +00:00
a11c8465d5 Merge pull request '크리에이터 관리자 - @JsonProperty 추가' (#83) from test into main
Reviewed-on: #83
2023-11-13 16:10:57 +00:00
366304a9b7 Merge pull request '크리에이터 관리자 - 정산 API 캐시 추가' (#82) from test into main
Reviewed-on: #82
2023-11-13 15:56:58 +00:00
4356663688 Merge pull request '크리에이터 관리자 - 콘텐츠 누적 매출 API' (#81) from test into main
Reviewed-on: #81
2023-11-13 15:23:53 +00:00
26b55e6fcf Merge pull request '콘텐츠 누적 매출 API - orderType 추가' (#80) from test into main
Reviewed-on: #80
2023-11-13 14:48:47 +00:00
0d743f7204 Merge pull request '콘텐츠 누적 매출 API 추가' (#79) from test into main
Reviewed-on: #79
2023-11-13 13:42:17 +00:00
6cbe113b3e Merge pull request '크리에이터 콘텐츠 정산 - API 추가' (#78) from test into main
Reviewed-on: #78
2023-11-13 10:03:02 +00:00
6409b69d6c Merge pull request '콘텐츠 정산 - 결과값에 JsonProperty 를 추가하여 데이터 파싱이 진행 되도록 수정' (#77) from test into main
Reviewed-on: #77
2023-11-10 13:19:06 +00:00
c5164c76fc Merge pull request '콘텐츠 정산 - group by 날짜 수정' (#76) from test into main
Reviewed-on: #76
2023-11-10 12:46:07 +00:00
baade8e138 Merge pull request 'test' (#75) from test into main
Reviewed-on: #75
2023-11-10 10:47:11 +00:00
b848d6b4e0 Merge pull request 'test' (#74) from test into main
Reviewed-on: #74
2023-11-09 11:18:20 +00:00
d8139d2ab0 Merge pull request '라이브 리스트, 라이브 상세' (#73) from test into main
Reviewed-on: #73
2023-11-07 16:48:25 +00:00
e96d8f7469 Merge pull request 'test' (#72) from test into main
Reviewed-on: #72
2023-11-07 16:23:11 +00:00
2acffd8afc Merge pull request '콘텐츠 메인 API - 캐싱을 적용하기 위해 AudioContentMainManageService 추가' (#71) from test into main
Reviewed-on: #71
2023-11-07 11:24:40 +00:00
3c8e72073c Merge pull request '콘텐츠 메인 API - @Transactional(readOnly = true) 추가' (#70) from test into main
Reviewed-on: #70
2023-11-07 08:47:47 +00:00
724d7a9d9b Merge pull request 'test' (#69) from test into main
Reviewed-on: #69
2023-11-07 08:21:56 +00:00
2da3b0db78 Merge pull request 'test' (#68) from test into main
Reviewed-on: #68
2023-11-06 09:26:36 +00:00
685ad7afaf Merge pull request '콘텐츠 메인 캐싱전략 수정' (#67) from test into main
Reviewed-on: #67
2023-11-06 08:46:55 +00:00
264cf75964 Merge pull request '콘텐츠 메인 - 큐레이션 개수 15개만 노출' (#66) from test into main
Reviewed-on: #66
2023-11-06 08:11:00 +00:00
c773dbc7b5 Merge pull request '콘텐츠 랭킹 - 후원 랭킹 조회 로직 수정' (#65) from test into main
Reviewed-on: #65
2023-11-04 14:24:46 +00:00
37cbc64f52 Merge pull request '본인인증' (#64) from test into main
Reviewed-on: #64
2023-11-03 07:48:02 +00:00
cb1dde17bb Merge pull request 'test' (#63) from test into main
Reviewed-on: #63
2023-11-02 12:18:29 +00:00
c29988acf4 Merge pull request '콘텐츠 주문 - 대여만 가능한 콘텐츠의 경우 소장으로 주문이 들어오더라도 대여로 처리되도록 로직 수정' (#62) from test into main
Reviewed-on: #62
2023-11-01 04:49:18 +00:00
eadbf56dae Merge pull request '정산테이블에 무료충전 코인도 반영되도록 수정' (#61) from test into main
Reviewed-on: #61
2023-10-28 08:42:29 +00:00
4b3b455135 Merge pull request '캔 사용 시 제휴보상 캔도 사용할 수 있도록 수정' (#60) from test into main
Reviewed-on: #60
2023-10-27 13:48:33 +00:00
e6ac177396 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#59) from test into main
Reviewed-on: #59
2023-10-26 18:48:46 +00:00
3d0e29003f Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#58) from test into main
Reviewed-on: #58
2023-10-26 18:22:10 +00:00
78b9b00f77 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#57) from test into main
Reviewed-on: #57
2023-10-26 18:02:16 +00:00
0ee7faa551 Merge pull request '카울리를 이용한 무료충전 테이블 adProfit 과 point 타입 int -> float 로 변경' (#56) from test into main
Reviewed-on: #56
2023-10-26 16:55:39 +00:00
e5fdced681 Merge pull request '콘텐츠 메인 캐싱 전략 변경' (#55) from test into main
Reviewed-on: #55
2023-10-26 16:14:37 +00:00
afb99fef64 Merge pull request 'GetAudioContentMainItem - adult를 isAdult로 변경, 캐시 제거' (#54) from test into main
Reviewed-on: #54
2023-10-24 11:39:56 +00:00
7dfaa36024 Merge pull request 'test' (#53) from test into main
Reviewed-on: #53
2023-10-24 10:42:10 +00:00
0496f665aa Merge pull request 'getAudioContentMainBannerList 부분 캐시 제거' (#52) from test into main
Reviewed-on: #52
2023-10-17 10:02:53 +00:00
0d19e1be74 Merge pull request 'audio content banner - lazy 옵션으로 인해 발생하는 com.fasterxml.jackson.databind.exc.InvalidDefinitionException 문제 수정' (#51) from test into main
Reviewed-on: #51
2023-10-17 09:47:44 +00:00
4aff0111aa Merge pull request '로딩 속도를 위해 @Cacheable 적용' (#50) from test into main
Reviewed-on: #50
2023-10-17 09:31:08 +00:00
63b3ba2bb2 Merge pull request '인기 콘텐츠 전체보기 집계날짜 수정' (#49) from test into main
Reviewed-on: #49
2023-10-16 13:59:28 +00:00
7444b41f60 Merge pull request '콘텐츠 메인 - 인기 콘텐츠 집계날짜 수정' (#48) from test into main
Reviewed-on: #48
2023-10-16 13:42:31 +00:00
8e90dbc8b6 Merge pull request '구매목록 - isActive 가 true 인 것만 조회되도록 수정' (#47) from test into main
Reviewed-on: #47
2023-10-16 03:30:10 +00:00
9f70722521 Merge pull request '탐색 인기 크리에이터 - 날짜 설명 글 수정' (#46) from test into main
Reviewed-on: #46
2023-10-14 21:43:48 +00:00
52fae596fa Merge pull request '콘텐츠 랭킹 데이터 전체보기 API - 페이징 추가' (#45) from test into main
Reviewed-on: #45
2023-10-14 21:20:19 +00:00
ccb67957bc Merge pull request '콘텐츠 랭킹 추가' (#44) from test into main
Reviewed-on: #44
2023-10-14 19:37:55 +00:00
fb82538d0d Merge pull request '캔 소비 - 콘텐츠 주문시 캔 소비내역에 콘텐츠 내용 추가' (#43) from test into main
Reviewed-on: #43
2023-10-14 11:33:59 +00:00
72ee39612e Merge pull request '탐색 - 인기 급상승 제거, 인기 크리에이터 섹션 추가' (#42) from test into main
Reviewed-on: #42
2023-10-13 15:41:25 +00:00
51fd5408dc Merge pull request 'test' (#41) from test into main
Reviewed-on: #41
2023-10-06 08:50:32 +00:00
3fae40fbef Merge pull request '콘텐츠 상세 - 댓글 수 로직 답글 포함하지 않도록 수정' (#40) from test into main
Reviewed-on: #40
2023-10-05 02:49:22 +00:00
0745890af0 Merge pull request 'test' (#39) from test into main
Reviewed-on: #39
2023-10-04 03:24:41 +00:00
4abe1730a7 Merge pull request '관리자 라이브 정산 API - 인원 추가' (#38) from test into main
Reviewed-on: #38
2023-10-03 12:10:51 +00:00
626f0e6989 Merge pull request '관리자 - 라이브 정산 API 추가' (#37) from test into main
Reviewed-on: #37
2023-10-03 09:28:04 +00:00
9f42d9d173 Merge pull request '라이브 시작 알림 - 알림 받을 유저 조회에서 에러가 발생하는 버그 수정' (#36) from test into main
Reviewed-on: #36
2023-10-02 13:00:11 +00:00
f90a93c4bc Merge pull request '후원순위 - 유료라이브 입장 캔 반영' (#35) from test into main
Reviewed-on: #35
2023-09-27 14:57:27 +00:00
8000ad6c6a Merge pull request 'test' (#34) from test into main
Reviewed-on: #34
2023-09-27 06:49:21 +00:00
1f1f1bea1a Merge pull request 'test' (#33) from test into main
Reviewed-on: #33
2023-09-27 05:28:04 +00:00
d95460c7cd Merge pull request '닉네임 변경 가격 100 캔으로 변경' (#32) from test into main
Reviewed-on: #32
2023-09-22 07:01:27 +00:00
a3d93d4b08 Merge pull request 'test' (#31) from test into main
Reviewed-on: #31
2023-09-19 06:32:22 +00:00
07a92af982 Merge pull request '라이브 시작시간 4시간이 지나도 라이브를 시작하지 않은 경우 자동취소로직 추가' (#30) from test into main
Reviewed-on: #30
2023-09-12 14:09:37 +00:00
f4618877d4 Merge pull request '주문목록 - 크리에이터 닉네임 추가' (#29) from test into main
Reviewed-on: #29
2023-09-08 16:29:30 +00:00
2b914fd222 Merge pull request 'test' (#28) from test into main
Reviewed-on: #28
2023-09-02 16:12:08 +00:00
109e42a5a3 Merge pull request 'test' (#27) from test into main
Reviewed-on: #27
2023-08-31 08:37:42 +00:00
fa515ad39c Merge pull request '관리자 캔 충전내역 - 애플 인 앱 결제에 PG결제가 같이 나오던 버그 수정' (#26) from test into main
Reviewed-on: #26
2023-08-30 10:02:32 +00:00
f09673a795 Merge pull request 'test' (#25) from test into main
Reviewed-on: #25
2023-08-30 08:10:29 +00:00
f71536c614 Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2023-08-30 07:24:14 +00:00
7bdddc7ae8 Merge pull request '후원 전체보기 - 하단 랭킹에 콘텐츠 후원도 포함' (#23) from test into main
Reviewed-on: #23
2023-08-29 15:22:09 +00:00
aa8926a624 Merge pull request '후원 전체보기 - 상단 캔 현황 을 후원 캔만 반영하도록 수정' (#22) from test into main
Reviewed-on: #22
2023-08-29 14:53:44 +00:00
be71e59be2 Merge pull request '무료 콘텐츠를 못올리는 버그 수정' (#21) from test into main
Reviewed-on: #21
2023-08-29 13:32:13 +00:00
4d7753378f Merge pull request 'test' (#20) from test into main
Reviewed-on: #20
2023-08-29 07:33:57 +00:00
60257c4ef4 Merge pull request 'test' (#19) from test into main
Reviewed-on: #19
2023-08-28 08:54:08 +00:00
1e0b79bf62 Merge pull request 'test' (#18) from test into main
Reviewed-on: #18
2023-08-27 12:28:42 +00:00
6883434d0d Merge pull request '유저 관심사, 라이브 관심사 - 연령제한 설정 추가' (#17) from test into main
Reviewed-on: #17
2023-08-25 08:38:51 +00:00
eda2193e64 Merge pull request 'test' (#16) from test into main
Reviewed-on: #16
2023-08-25 07:50:55 +00:00
99bf829c88 Merge pull request '첫 충전 이벤트 - 본인인증한 전체 계정 중 첫 충전 시에만 첫충전 이벤트 적용' (#15) from test into main
Reviewed-on: #15
2023-08-24 16:26:54 +00:00
5feafe1b48 Merge pull request 'test' (#14) from test into main
Reviewed-on: #14
2023-08-24 14:54:37 +00:00
c9292b7d04 Merge pull request 'test' (#13) from test into main
Reviewed-on: #13
2023-08-23 14:05:00 +00:00
ae7e1a91c1 Merge pull request '캔 충전로직 수정' (#12) from test into main
Reviewed-on: #12
2023-08-21 15:12:44 +00:00
3e1887e0d1 Merge pull request 'test' (#11) from test into main
Reviewed-on: #11
2023-08-21 13:19:03 +00:00
474646db47 Merge pull request '스피커 최대 10 -> 5명으로 수정' (#10) from test into main
Reviewed-on: #10
2023-08-20 20:40:23 +00:00
56f7b6c449 Merge pull request 'test' (#9) from test into main
Reviewed-on: #9
2023-08-20 19:22:23 +00:00
76b2b5f7e3 Merge pull request '캔 사용내역 - 후원을 콘텐츠 후원, 라이브 후원으로 분리' (#8) from test into main
Reviewed-on: #8
2023-08-20 15:46:30 +00:00
e918d809eb Merge pull request '충전내역 - 관리자 지급 추가' (#7) from test into main
Reviewed-on: #7
2023-08-20 15:08:35 +00:00
7af059e543 Merge pull request 'test' (#6) from test into main
Reviewed-on: #6
2023-08-20 14:45:39 +00:00
897726e1ec Merge pull request 'test' (#5) from test into main
Reviewed-on: #5
2023-08-19 17:47:16 +00:00
8b98a2dd07 Merge pull request '비밀번호 찾기 API 추가' (#4) from test into main
Reviewed-on: #4
2023-08-19 07:05:17 +00:00
cca75420f0 Merge pull request '큐레이션 - 조건 추가' (#3) from test into main
Reviewed-on: #3
2023-08-18 14:07:34 +00:00
86c627ed1d Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-18 12:54:09 +00:00
d55514e3a7 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-08-16 02:30:36 +00:00
70 changed files with 1025 additions and 3198 deletions

227
AGENTS.md
View File

@@ -5,113 +5,6 @@
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
## 지시 우선순위
- 충돌 시 항상 더 높은 우선순위의 지시를 따른다.
- 우선순위는 다음 순서를 따른다.
1. 사용자 직접 지시
2. `AGENTS.md`
3. 프로젝트별 제약 조건
4. oh-my-openagent 플러그인의 agents / workflows / hooks
5. superpowers skills
6. 기본 모델 동작
## CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)
These principles override plugin behavior, skill behavior, workflow behavior, and default model behavior unless the user's direct instruction explicitly says otherwise.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
## 플러그인/스킬 제어 정책
### oh-my-openagent 정책
- oh-my-openagent는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
- oh-my-openagent는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
- 모든 oh-my-openagent 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
### superpowers 정책
- superpowers는 선택적 스킬 계층이다.
- superpowers skill은 필요한 경우에만 사용한다.
- superpowers가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
- superpowers를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
- 모든 superpowers 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
## 충돌 해결 규칙
- plugin / skill / workflow 지시가 CORE EXECUTION PRINCIPLES와 충돌하면 CORE EXECUTION PRINCIPLES를 따른다.
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
## 실행 모드
- 기본 모드: 보수적 실행
- 최소 변경
- 단순한 구현
- 검증 가능한 결과
- 확장 모드:
- 사용자가 명시적으로 요청한 경우에만 사용한다.
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
@@ -138,17 +31,82 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
./gradlew ktlintFormat
```
## 프로젝트 핵심 규칙
- Kotlin/Spring 스타일, 테스트 스타일, 보안 유의사항, 작업 절차, 문서 유지보수 상세 규칙은 아래 문서를 따른다.
- `docs/agent-guides/코드스타일.md`
- `docs/agent-guides/테스트스타일.md`
- `docs/agent-guides/설정보안.md`
- `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md`
- 공개 API 스키마는 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 기존 코드베이스 관례를 우선하며, 불확실한 규칙은 추측하지 말고 근거 파일을 먼저 확인한다.
## 코드 스타일 규칙
## 커밋 메시지 규칙
### 1) 포맷/기본 규칙
- `.editorconfig` 기준을 준수한다.
- 인덴트: 공백 4칸.
- 줄바꿈: LF.
- 최대 라인 길이: 130.
- 파일 끝 개행 유지, trailing whitespace 제거.
### 2) import 규칙
- 와일드카드 import(`*`)를 사용하지 않는다.
- 사용하지 않는 import를 남기지 않는다.
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
### 3) 네이밍 규칙
- 클래스/인터페이스/enum: PascalCase.
- 함수/변수/파라미터: camelCase.
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
### 4) 타입/널 처리
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
### 5) API/응답 규칙
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
### 6) 예외 처리 규칙
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
### 7) 트랜잭션 규칙
- 서비스 계층에서 `@Transactional`을 사용한다.
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
### 8) 비동기/동시성 규칙
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
### 9) 의존성 주입
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
- 필드 주입보다 명시적 생성자 주입을 우선한다.
### 10) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
## 테스트 스타일 규칙
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
## 설정/보안 유의사항
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.
## Cursor/Copilot 규칙 반영
`/.cursorrules`, `/.cursor/rules/`, `/.github/copilot-instructions.md` 파일은 현재 없다.
별도 규칙 파일이 추가되면 본 문서보다 해당 규칙을 우선 반영한다.
## 커밋 메시지 규칙 (표준 Conventional Commits)
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
@@ -156,16 +114,43 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
- 커밋 본문에는 `Ultraworked with [Sisyphus]...``Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 자동 footer를 포함하지 않는다.
- `git commit` 실행 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 검증한다.
### 커밋 메시지 검증 절차
- `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
- 커밋 실행 시 검증한 메시지를 그대로 사용하고, 도구 기본 footer가 자동 추가되지 않도록 최종 커밋 본문을 확인한다.
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
- 연속된 하나의 작업에 대한 후속 수정/보완이라면 새 계획 문서를 만들지 말고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다.
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 파일명 예시: `20260101_구글계정으로로그인.md`
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 범위가 바뀌면 문서를 먼저 갱신한다.
- 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 누적 기록한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다.
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.
## 문서 유지보수 규칙
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.
## 에이전트 동작 원칙
- 추측하지 말고, 근거 파일을 읽고 결정한다.

View File

@@ -1,35 +0,0 @@
# 20260429 에이전트 가이드 통합 정리
## 구현 계획
- [x] 기존 `AGENTS.md`의 우선순위, 에이전트 행동, 스킬/워크플로우 관련 항목을 분석한다.
- [x] 공식 `andrej-karpathy-skills` `CLAUDE.md` 원문을 확인하고 삽입 위치와 래퍼 문구를 확정한다.
- [x] `AGENTS.md` 상단 근처에 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 섹션을 추가한다.
- [x] 사용자 지시 > `AGENTS.md` > 프로젝트별 제약 조건 > oh-my-openagent > superpowers > 기본 모델 동작 우선순위를 명시한다.
- [x] oh-my-openagent, superpowers, 충돌 해결 규칙, 실행 모드를 한국어 정책으로 추가한다.
- [x] `AGENTS.md`에는 핵심 규칙만 남기고 세부 규칙은 `docs/agent-guides/` 아래 별도 문서로 분리해 참조하도록 정리한다.
- [x] 기존 `docs/20260429_에이전트가이드상세규칙.md`의 섹션 구성을 분석하고 분리 단위를 확정한다.
- [x] `docs/agent-guides/` 아래에 주제별 문서를 생성한다.
- [x] `AGENTS.md`가 분리된 문서를 직접 참조하도록 수정한다.
- [x] 기존 `docs/20260429_에이전트가이드상세규칙.md`를 제거한다.
- [x] `docs/agent-guides/` 하위 파일명에서 `에이전트가이드` 접두를 제거한 새 이름 규칙을 확정한다.
- [x] `docs/agent-guides/` 하위 파일명을 `코드스타일.md`, `테스트스타일.md`, `설정보안.md`, `작업절차.md`, `문서유지보수.md`로 정리한다.
- [x] `AGENTS.md`와 연결된 작업 기록 문서의 참조 경로를 새 파일명 기준으로 갱신한다.
- [x] 문서 변경 검증을 수행하고 결과를 기록한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 1차 구현
- 무엇을: `AGENTS.md`에 명시적 지시 우선순위, `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 원문, oh-my-openagent/superpowers 제어 정책, 충돌 해결 규칙, 실행 모드를 추가했다. 동시에 Kotlin/Spring 스타일·테스트·보안·문서 유지보수의 상세 규칙은 `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/설정보안.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`로 분리하고 `AGENTS.md`는 핵심 규칙과 참조만 남기도록 정리했다.
- 왜: 기존 `AGENTS.md`는 프로젝트 규칙은 충분했지만 명시적 우선순위 체계와 플러그인/스킬 통제 계층이 없었고, 세부 규칙이 길어져 상단에서 핵심 실행 원칙을 빠르게 파악하기 어려웠기 때문이다.
- 어떻게: `AGENTS.md`, `docs/20260429_에이전트가이드통합정리.md`, `docs/agent-guides/` 아래 분리 문서를 다시 읽어 필수 문구, 영어 원문 유지, 한국어 정책 분리, 참조 경로를 확인했다. `lsp_diagnostics`로 관련 문서 모두 `No diagnostics found`를 확인하고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2차 정리
- 무엇을: 동일 작업을 별도로 기록하던 `docs/20260429_에이전트가이드분리.md`의 구현 계획과 검증 범위를 이 문서로 통합하고, 중복 작업 문서는 제거했다.
- 왜: 두 문서가 모두 같은 날짜의 동일 작업 흐름을 기록하고 있었고, 하나는 `AGENTS.md` 재구성, 다른 하나는 세부 규칙 분리라는 하위 단계만 다를 뿐 논리적으로 하나의 작업 계획 문서였기 때문이다.
- 어떻게: 두 문서의 구현 계획과 검증 기록을 대조해 누락된 분리 작업 항목만 이 문서에 합쳤고, 이후 `grep`으로 두 문서명 참조를 확인한 뒤 중복 문서를 제거했다. 마지막으로 `lsp_diagnostics``./gradlew tasks --all`로 문서 정합성과 저장소 문서 검증 명령 성공을 다시 확인했다.
- 3차 정리
- 무엇을: `docs/agent-guides/` 하위 파일명에서 `에이전트가이드` 접두를 제거해 `코드스타일.md`, `테스트스타일.md`, `설정보안.md`, `작업절차.md`, `문서유지보수.md`로 정리했다. 함께 `AGENTS.md`와 이 문서의 참조 경로도 새 파일명 기준으로 수정했고, 별도 작업 문서였던 `docs/20260429_agent_guides파일명정리.md`는 이 문서로 통합했다.
- 왜: 디렉터리 경로 자체가 이미 `agent-guides`라서 파일명에 동일한 의미를 반복할 필요가 없고, AGENTS 가이드 정비의 후속 단계 역시 별도 문서보다 하나의 연속 작업 기록 안에 남기는 편이 더 자연스럽기 때문이다.
- 어떻게: `grep`으로 기존 `docs/agent-guides/에이전트가이드*.md` 참조가 남지 않았음을 확인했고, `glob`으로 새 파일명 5개만 존재함을 확인했다. `lsp_diagnostics``AGENTS.md`, 이 문서, `docs/agent-guides/` 전체에 대해 오류가 없음을 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -1,15 +0,0 @@
# 20260429 연속 작업 계획 문서 재사용 규칙
## 구현 계획
- [x] 작업 계획 문서 생성과 관련된 기존 규칙 위치를 확인한다.
- [x] 연속된 하나의 작업일 때는 새 계획 문서를 만들지 않고 기존 문서에 이어서 기록하는 규칙을 추가한다.
- [x] 관련 문서 간 표현과 의도를 일치시킨다.
- [x] 문서 진단과 검증 결과를 기록한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 1차 구현
- 무엇을: `AGENTS.md``작업 계획 문서 규칙 (docs)`에 연속된 하나의 작업이라면 새 계획 문서를 만들지 않고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다는 규칙을 넣었다. 함께 `docs/agent-guides/작업절차.md``docs/agent-guides/문서유지보수.md`에도 같은 취지의 보완 문구를 추가해 실행 흐름과 문서 정책이 일치하도록 정리했다.
- 왜: 같은 작업의 후속 수정마다 새 계획 문서가 계속 생성되면 문서 수가 불필요하게 늘어나고, 작업 이력도 여러 파일로 흩어져 관리와 탐색이 어려워지기 때문이다.
- 어떻게: `read`로 세 문서의 해당 섹션을 다시 확인해 문구 위치와 의미 일치를 검토했고, `lsp_diagnostics``AGENTS.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/20260429_연속작업계획문서재사용규칙.md`에 대해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -1,54 +0,0 @@
# 채팅방 쿼터 충전 방식 확장
## 구현 계획
- [x] `purchaseRoomQuota` 요청 DTO만 보고도 `캔 충전`인지 `광고 충전`인지 구분할 수 있도록 충전 방식 필드를 확장하고, 광고 충전 요청을 구분할 수 있는 최소 스키마를 정의한다.
- [x] 캔 충전 옵션을 기존 단일 고정값에서 `10캔 → 15개`, `20캔 → 40개`의 2개 시나리오로 분기하도록 컨트롤러/서비스 전달 구조를 정리한다.
- [x] `ChatRoomQuotaService.purchase`의 하드코딩된 `needCan=10`, `addPaid=12` 로직을 요청값 기반 분기로 치환하고, 광고 충전 시 캔 차감 없이 `5` quota를 지급하는 경로를 분리한다.
- [x] 광고 충전은 1회당 `5` quota 지급으로 반영하고, 중복 호출 방지 여부와 추가 검증 조건만 별도 확인한다.
- [x] 관련 테스트 또는 최소 검증 경로를 정리하고 `./gradlew test` 기준으로 회귀 여부를 확인한다.
## 요구사항 반영 체크
- [x] Quota 충전 방식은 `캔 충전`, `광고 보고 충전`의 2가지로 확장한다.
- [x] 서버는 요청 DTO를 통해 `캔 충전`인지 `광고 충전`인지 식별할 수 있어야 한다.
- [x] 캔 충전 옵션은 `10캔 → 15개`, `20캔 → 40개`의 2가지다.
- [x] 광고 충전은 1회당 `5` quota를 지급한다.
## 영향 범위 메모
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt`
- 요청 DTO와 `purchaseRoomQuota` 분기 진입점 변경 대상.
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt`
- 캔 사용량/유료 quota 지급량 하드코딩 제거 및 충전 방식별 처리 분기 대상.
- 테스트 코드
- 현재 `src/test/kotlin`에서 `ChatRoomQuota` 관련 테스트가 바로 확인되지 않아, 필요 시 서비스 중심 테스트를 새로 보강한다.
## 확인 필요 사항
- 광고 충전은 앱에서 선처리한다고 했으므로 서버는 DTO 기반 분기와 1회당 `5` quota 지급만 담당하고, 광고 시청 자체의 검증 책임은 범위 밖으로 본다.
- 기존 클라이언트 호환을 위해 요청 본문에 `container`만 오면 서버는 기본값으로 `CAN + CAN_10` 충전으로 해석한다.
- 광고 충전 요청에 `canOption`이 함께 오더라도 서버는 예외를 내지 않고 값을 무시한 뒤 광고 충전으로 처리한다.
## 검증 기록
### 1차 계획 수립
- 무엇을: `purchaseRoomQuota` API에 광고 충전 구분과 2종 캔 충전 옵션을 추가하기 위한 구현 계획과 예상 영향 범위를 문서화했다.
- 왜: 현재 구현은 `container`만 받고 서비스에서 `10캔 → 12개 quota`를 고정 처리하고 있어, 요구사항을 반영하려면 DTO/서비스 분기와 미정 항목을 먼저 분리해 두는 것이 안전하기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt`, `ChatRoomQuotaService.kt`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, 기존 계획 문서 예시를 읽어 형식과 영향 범위를 맞췄고, 문서 변경 후 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
### 2차 요구사항 점검 반영
- 무엇을: 문서가 충전 방식 2종, 요청 DTO를 통한 충전 방식 식별, 캔 충전 2가지 옵션, 광고 충전 5개 지급을 모두 명시적으로 포함하도록 보강했다.
- 왜: 기존 문서도 대부분의 요구사항을 담고 있었지만, 요구사항 전체를 한눈에 대조할 수 있는 체크 형태가 없어 일부 표현이 암묵적으로 읽힐 여지가 있었기 때문이다.
- 어떻게: 계획 문서 본문에 `요구사항 반영 체크` 섹션을 추가하고 핵심 조건을 체크박스로 정리했으며, 문서 변경 후 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 다시 확인했다.
### 3차 구현 및 검증
- 무엇을: `purchaseRoomQuota` 요청 DTO에 충전 방식과 캔 옵션 enum을 추가하고, 컨트롤러에서 `캔 충전``광고 충전`을 분기하도록 변경했다. 함께 `ChatRoomQuotaService``purchaseWithCan`, `purchaseWithAd` 경로로 분리해 `10캔 → 15개`, `20캔 → 40개`, `광고 → 5개` 지급 규칙을 반영했다. 또한 `ChatRoomQuotaControllerTest`, `ChatRoomQuotaServiceTest`를 추가해 컨트롤러 분기와 서비스 적립 로직을 검증했다.
- 왜: 기존 구현은 `container`만 받고 `10캔 → 12개`를 단일 하드코딩으로 처리해 새로운 충전 방식과 옵션을 구분할 수 없었기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt`, `ChatRoomQuotaService.kt`에 최소 분기 로직을 추가하고 `ChatRoomQuotaPurchaseOption.kt`에 enum을 분리했다. 검증은 Kotlin LSP가 이 환경에서 지원되지 않아 진단 대신 `./gradlew test --tests "kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaServiceTest" --tests "kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaControllerTest"`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
### 4차 하위 호환 보완
- 무엇을: 기존 클라이언트가 `container`만 보내도 동작하도록 `PurchaseRoomQuotaRequest`의 기본 충전 방식을 `CAN`, 기본 캔 옵션을 `CAN_10`으로 해석하도록 보완했고, 이를 검증하는 테스트를 추가했다.
- 왜: 새 DTO 필드를 필수로 두면 구버전 클라이언트의 기존 요청 본문이 역직렬화 단계에서 바로 깨질 수 있기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt`에서 `chargeType` 기본값을 `CAN`으로 두고 `canOption` 누락 시 `CAN_10`을 사용하도록 변경했다. 검증은 `ChatRoomQuotaControllerTest``container`만 전달하는 직접 호출 케이스를 추가해 수행했다.
### 5차 광고 옵션 허용 완화
- 무엇을: 광고 충전 요청에 `canOption`이 함께 들어와도 예외를 던지지 않고 무시하도록 컨트롤러 분기를 완화했고, 해당 요청이 정상적으로 광고 충전 경로로 전달되는 테스트로 기대값을 바꿨다.
- 왜: 광고 충전은 지급량이 고정이라 `canOption`이 의미 없는 부가 필드에 가깝고, 이를 이유로 요청을 실패시키는 것보다 무시하는 편이 클라이언트 호환성과 운영 안정성에 유리하기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt``AD` 분기에서 `canOption` 검증 예외를 제거했고, `ChatRoomQuotaControllerTest`의 광고 케이스를 성공 경로 검증으로 변경했다.

View File

@@ -1,23 +0,0 @@
# Payverse JPY 지원 작업 계획
- [x] 요구사항 정리
- JPY 전용 자격 증명 사용
- `payverseCharge`, `payverseWebhook`, `payverseVerify` 모두 일관 분기 추가
- 금액 포맷: JPY는 강제 정수화(소수점 버림)
- 결제수단 표기는 현행 규칙 유지
- [x] 구현 항목
- [x] 환경변수 주입: `payverse.jpy-mid`, `payverse.jpy-client-key`, `payverse.jpy-secret-key`
- [x] `ChargeService.payverseCharge`에 JPY 분기 및 금액 포맷 적용
- [x] `ChargeService.payverseWebhook`에 JPY 분기 및 금액 검증 적용
- [x] `ChargeService.payverseVerify`에 JPY 분기 및 금액 검증 적용
- [x] 공통 금액 포맷 함수 `computePayverseAmount` 추가 (JPY=버림, 그외=4자리 반올림)
- [ ] 검증 항목
- [ ] 단위/통합 테스트 빌드 및 실행 (`./gradlew test`)
- [ ] KRW/JPY/USD 각각에 대해 payload 서명 및 검증 로직 수기 점검
- [ ] JPY에서 `requestAmount`가 항상 정수로 전송되는지 로깅/샘플 요청으로 확인(스테이징)
## 검증 로그
- [ ] 빌드/테스트 결과:
- [ ] 수기 점검 결과:

View File

@@ -1,338 +0,0 @@
# 번역/언어감지 효율화 구상
## 배경
- 현재 구조는 도메인별 번역 테이블과 이벤트 기반 Papago 호출이 혼재되어 있다.
- 생성/수정 이벤트 번역과 조회 시 캐시 미스 번역이 동시에 존재해 동일 원문이 중복 번역될 수 있다.
- 번역 저장 로직은 `ContentTranslation`, `SeriesTranslation`, `AiCharacterTranslation`, `OriginalWorkTranslation`, `CategoryTranslation` 등으로 분산되어 있으며, 조회/upsert 패턴이 반복된다.
- Papago 호출은 텍스트와 대상 locale 단위로 순차 실행되어 대상 하나가 여러 HTTP 요청으로 확장된다.
## 목표
- Papago 호출 수와 비용을 줄인다.
- 조회 API에서 Papago 호출로 인한 응답 지연을 줄인다.
- 번역 저장/조회 로직의 중복을 줄이고, 향후 번역 provider 교체가 가능하도록 한다.
- 기존 API 응답 스키마는 유지하고, DB 변경은 단계적으로 적용한다.
## 구현 항목
- [x] `TranslationProvider` 인터페이스와 Papago provider 구현을 분리한다.
- [x] `translation_memory`, `translation_job`, `language_detection_result` 테이블을 추가한다.
- [x] 조회 fallback의 직접 Papago 호출을 제거하고 누락 번역 job 예약으로 전환한다.
- [x] 생성/수정 이벤트 번역을 `translation_job` 기반 워커 처리로 전환한다.
- [x] `TranslationMemory` 결과를 기존 JSON/scalar read model로 materialize한다.
- [ ] 목록 조회 병목 측정 후 필요한 도메인에만 hot column을 추가한다.
## 접근안 비교
### 접근안 A: 현재 구조 유지 + 중복 방지 보강
- 도메인별 번역 테이블은 유지한다.
- 번역 전 기존 번역 조회와 unique 제약을 강화한다.
- 조회 fallback에서 번역 생성 대신 번역 작업만 예약하도록 바꾼다.
장점:
- 변경 범위가 가장 작다.
- 기존 코드와 데이터 마이그레이션 위험이 낮다.
단점:
- 도메인별 중복 저장/로직 구조는 그대로 남는다.
- 원문이 같아도 도메인별로 재번역될 수 있다.
### 접근안 B: 원문 해시 기반 Translation Memory + 비동기 작업 큐 추가
- 원문 텍스트를 정규화하고 `source_hash`로 중복을 제거한다.
- `translation_memory`에 원문/언어쌍/번역 결과를 저장한다.
- 도메인 엔티티는 `translation_job`을 생성하고, 워커가 Papago를 호출해 결과를 저장한다.
- 조회 API는 Papago를 직접 호출하지 않고 기존 번역을 반환하거나 미번역 상태를 유지한다.
장점:
- 동일 원문 재번역을 줄일 수 있다.
- 조회 응답 지연을 안정적으로 제거할 수 있다.
- 기존 도메인별 번역 테이블을 당장 제거하지 않아도 단계 적용이 가능하다.
단점:
- 작업 큐, 상태 관리, 재시도 정책이 필요하다.
- 생성 직후 번역이 아직 없을 수 있으므로 UX/응답 정책을 정해야 한다.
### 접근안 C: 도메인별 번역 테이블을 공통 번역 저장소로 통합
- 모든 번역 결과를 `translation_entry` 같은 공통 테이블에 저장한다.
- `resource_type`, `resource_id`, `field_key`, `locale`, `translated_text` 또는 JSON payload로 도메인별 필드를 표현한다.
- 기존 도메인별 번역 테이블은 읽기 호환 기간 후 제거한다.
장점:
- 저장/조회/upsert 로직을 크게 단순화할 수 있다.
- 새 번역 대상 추가 비용이 줄어든다.
단점:
- 마이그레이션 위험이 가장 크다.
- 도메인별 payload 검증과 타입 안정성이 약해질 수 있다.
- 기존 조회 쿼리와 응답 조립 로직 영향 범위가 넓다.
## 권장 방향
- 최종 구현방식은 접근안 B를 중심으로 하되, 조회 성능을 위해 도메인별 read model을 유지하는 하이브리드 구조로 한다.
- 기존 번역 데이터를 배제할 수 있어도 모든 번역 결과를 정규화 row만으로 저장하는 방식은 채택하지 않는다.
- 이유는 Papago 호출 수 절감은 원문 해시 기반 `TranslationMemory`가 담당하고, API 조회 성능은 `entityId + locale` 단위 read model이 담당하는 분리가 가장 효율적이기 때문이다.
- 2차로 운영 안정화 후 도메인별 read model의 중복을 줄이되, 조회 경로가 복잡해지는 전면 통합은 실제 병목이 확인될 때만 진행한다.
## 최종 구현방식
### 설계 원칙
- 번역 원장은 정규화한다.
- 조회 결과는 도메인별로 materialize한다.
- Papago 호출은 사용자 요청 스레드에서 수행하지 않는다.
- JSON payload는 원장이 아니라 조회 최적화용 read model로만 사용한다.
### 저장 구조
#### `translation_memory`
- 동일 원문을 반복 번역하지 않기 위한 공통 번역 캐시이다.
- 원문은 의미가 바뀌지 않는 선에서 공백, 줄바꿈, Unicode 표현을 정규화한다.
- 주요 컬럼:
- `id`
- `source_hash`
- `source_text`
- `source_language`
- `target_language`
- `translated_text`
- `provider`
- `provider_version`
- `normalization_version`
- `created_at`
- `updated_at`
- unique 제약:
- `(source_hash, source_language, target_language, provider, normalization_version)`
- 역할:
- Papago 호출 수 절감의 핵심 원장이다.
- 도메인이 달라도 원문과 언어쌍이 같으면 같은 번역을 재사용한다.
#### `translation_job`
- 번역 실행 상태를 관리하는 durable job 테이블이다.
- 현재 `@Async` 이벤트만으로 처리하던 작업을 명시적인 상태 모델로 바꾼다.
- 주요 컬럼:
- `id`
- `resource_type`
- `resource_id`
- `field_key`
- `source_hash`
- `source_language`
- `target_language`
- `status`
- `retry_count`
- `last_error_message`
- `next_retry_at`
- `created_at`
- `updated_at`
- 중복 방지:
- 활성 상태 기준 `(resource_type, resource_id, field_key, target_language, source_hash)` 중복 생성을 막는다.
- 역할:
- 중복 작업 방지, 재시도, 실패 추적, 운영자 재처리를 담당한다.
#### `language_detection_result`
- 언어 감지 결과를 원본 엔티티에만 저장하지 않고 별도 캐시/이력으로 관리한다.
- 주요 컬럼:
- `id`
- `source_hash`
- `source_text_sample`
- `detected_language`
- `provider`
- `confidence`
- `normalization_version`
- `created_at`
- 역할:
- 짧은 문구나 반복 문구의 감지 호출을 줄인다.
- 원본 엔티티의 `languageCode`는 기존 호환 필드로 유지할 수 있다.
#### 도메인별 번역 read model
- `ContentTranslation`, `SeriesTranslation`, `AiCharacterTranslation`, `OriginalWorkTranslation`처럼 여러 필드가 묶인 응답은 JSON payload read model을 유지한다.
- `CategoryTranslation`, `ContentThemeTranslation`, `SeriesGenreTranslation`처럼 단일 문자열만 필요한 대상은 scalar column을 유지한다.
- JSON payload는 `translation_memory`를 조립한 결과를 저장하는 materialized cache로 본다.
- provider, retry, source hash 같은 실행/원장 메타데이터는 JSON payload에 넣지 않는다.
### JSON 유지 여부
- JSON payload 저장은 유지한다.
- 단, JSON을 번역의 최종 원장으로 보지 않고 API 응답을 빠르게 만들기 위한 read model로 격하한다.
- 현재 조회 패턴은 JSON 내부 검색이 아니라 `resourceId + locale`로 1-row 조인 후 payload를 읽는 방식이므로, 정규화 row를 매번 pivot하는 것보다 조회 경로가 단순하다.
- 목록 API에서 제목이나 이름 하나만 자주 읽는데 payload가 커지는 도메인은 `translated_title`, `translated_name` 같은 hot column을 선택적으로 추가한다.
- 단일 문자열 번역 대상은 JSON으로 바꾸지 않는다.
### 처리 흐름
- 생성/수정 시 번역 대상 필드를 세그먼트로 추출한다.
- 세그먼트별 `source_hash`를 계산한다.
- 언어 정보가 없으면 `language_detection_result`를 조회하고, 없을 때만 감지 작업을 수행한다.
- 대상 언어별로 `translation_memory`를 먼저 조회한다.
- cache hit이면 Papago를 호출하지 않고 read model materialize 단계로 넘어간다.
- cache miss이면 `translation_job``PENDING`으로 생성한다.
- 워커가 job을 가져와 Papago를 호출하고 `translation_memory`에 저장한다.
- 대상 resource의 모든 필드 번역이 준비되면 도메인별 read model JSON 또는 scalar row를 갱신한다.
### 조회 정책
- 조회 API에서는 Papago를 직접 호출하지 않는다.
- 번역 read model이 있으면 해당 번역을 반환한다.
- 번역 read model이 없으면 원문 또는 기존 fallback locale을 반환한다.
- 누락된 번역은 조회 요청에서 job만 예약할 수 있으며, 외부 API 응답을 기다리지 않는다.
### 최종 선택 요약
| 영역 | 최종 선택 | 이유 |
|---|---|---|
| Papago 중복 호출 방지 | `translation_memory` 정규화 | 동일 원문/언어쌍 재사용이 가능하다. |
| 번역 실행 | `translation_job` 비동기 큐 | 재시도와 실패 추적이 가능하고 조회 응답을 지연시키지 않는다. |
| 다중 필드 응답 저장 | JSON read model 유지 | `entityId + locale` 1-row 조회가 가능해 API 조립이 단순하다. |
| 단일 문자열 번역 저장 | scalar column 유지 | JSON보다 단순하고 불필요한 변환 비용이 없다. |
| 자주 읽는 일부 필드 | 선택적 hot column 추가 | 큰 JSON payload 전체 로딩 비용을 줄일 수 있다. |
| provider 확장 | `TranslationProvider` 인터페이스 | Papago 의존을 낮추고 향후 교체를 쉽게 한다. |
## 상세 정책
### 운영자 재처리
- 관리자/운영 API에서는 번역 상태를 확인하고 재번역을 요청할 수 있게 한다.
- 실패한 job은 원문, 대상 언어, provider, 실패 사유, 재시도 횟수를 함께 보여준다.
### 중복 방지 정책
- `TranslationMemory``(source_hash, source_language, target_language, provider, normalization_version)`에 unique 제약을 둔다.
- `TranslationJob`은 활성 상태 기준 `(resource_type, resource_id, field_key, target_language, source_hash)` 중복 생성을 막는다.
- 동일 작업이 동시에 들어오면 기존 `PENDING` 또는 `RUNNING` 작업을 재사용한다.
### 오류와 재시도
- Papago 실패 시 `TranslationJob.status = FAILED`와 실패 사유를 저장한다.
- 일시 오류는 지수 백오프로 재시도한다.
- 영구 오류나 빈 원문은 재시도하지 않는다.
- 재시도 횟수를 초과하면 운영자가 재시도할 수 있도록 별도 상태로 남긴다.
### 운영 안정화 보완 작업
- 현재 `TranslationJobWorker.claimNextJob()``PENDING` job을 조회한 뒤 `RUNNING`으로 변경한다.
- 다중 애플리케이션 인스턴스에서 같은 job을 동시에 조회할 수 있으므로 운영 반영 전 claim을 원자화한다.
- 권장 방식은 MySQL 기준 `SELECT ... FOR UPDATE SKIP LOCKED` 또는 `UPDATE ... WHERE status = 'PENDING' ... LIMIT 1` 기반 원자적 claim이다.
- job row를 여러 인스턴스가 나눠 처리하는 목적에는 `FOR UPDATE SKIP LOCKED` 또는 atomic update claim을 우선한다.
- `ShedLock`은 스케줄러 실행 자체의 중복을 막는 용도로는 적합하지만, job row 단위 분산 claim을 대체하지는 않는다.
- Papago 호출은 DB lock을 잡은 트랜잭션 밖에서 수행하고, claim/완료/실패 상태 변경만 짧은 트랜잭션으로 처리한다.
- `FAILED`로 즉시 종료하는 최소 구현에서 지수 백오프 기반 재시도 정책으로 보완한다.
- 재시도 정책은 `retry_count`, `next_retry_at`, `last_error_message`를 함께 갱신하고, 최대 재시도 초과 상태를 운영자가 확인할 수 있게 한다.
- worker 처리량과 부하를 운영 설정으로 제어할 수 있도록 `fixed-delay-ms`, tick당 처리 group 수, 최대 재시도 횟수, Papago 호출 rate limit을 설정화한다.
- tick당 처리 단위는 단순 job row 수가 아니라 `(resource_type, resource_id, target_language)` group으로 잡는다.
- 1차 운영 기준은 tick당 최대 5개 group 처리로 둔다.
- group 내부의 field job은 순차 처리하고, 같은 resource/locale의 모든 필드가 `translation_memory`에 준비된 뒤 read model을 materialize한다.
- 콘텐츠 기준으로는 1개 group이 `title`, `detail`, `tags` 3개 field job이므로 tick당 최대 15개 field job이 된다.
- 캐릭터처럼 필드가 많은 리소스도 group 5개 제한 안에서 처리해 Papago 호출 burst를 완화한다.
- 운영 관측을 위해 pending/running/failed/completed count, oldest pending age, 처리 성공/실패 수, Papago 호출 시간, materialize 실패 수를 로그 또는 metric으로 남긴다.
### 번역 job 실행 주기 조정 검토
- 현재 구현은 `sodalive.translation-job.fixed-delay-ms` 설정이 없으면 기본 `5000ms` fixed delay로 실행된다.
- 콘텐츠가 지속적으로 올라오는 서비스 형태가 아니므로 기본 주기를 10분(`600000ms`)으로 늘리는 방향을 검토한다.
- `fixedDelay`는 작업 종료 후 다음 실행까지의 지연 시간이므로 실제 실행 간격은 `처리 시간 + 10분`이 된다.
- 정확히 벽시계 기준 10분마다 실행해야 한다면 `cron + 스케줄 중복 방지 lock`을 검토하고, 처리 후 10분 쉬는 정책이면 `fixedDelay`를 사용한다.
- 10분 주기의 장점은 불필요한 DB polling과 Papago 호출 burst 가능성을 줄이고, 낮은 트래픽 환경에서 백그라운드 작업 부하를 완화하는 것이다.
- 10분 주기의 단점은 번역 read model 반영 지연이 최대 10분 이상으로 늘어날 수 있다는 점이다.
- 조회 정책이 원문 즉시 반환 + job 예약 방식이므로 API 응답 실패로 이어지지는 않지만, 사용자는 첫 조회 후 최대 다음 worker 실행까지 원문을 볼 수 있다.
- 기존 tick당 20 job row 처리 방식은 같은 resource/locale의 일부 필드만 처리하고 다음 tick으로 넘어갈 수 있다.
- 10분 주기에서는 부분 처리된 resource의 read model 반영이 다음 tick까지 지연될 수 있으므로 `(resource_type, resource_id, target_language)` group 단위 처리로 보완한다.
- 10분 주기를 적용하려면 tick당 처리 group 수를 운영 설정으로 조정하거나, pending backlog가 특정 기준을 넘을 때 수동/운영자 재처리 또는 임시 짧은 주기 전환이 가능해야 한다.
- 생성 직후 번역 노출이 중요한 리소스가 발견되면 해당 리소스만 별도 즉시 처리 정책을 두고, 일반 조회 fallback은 10분 주기를 유지한다.
- 1차 운영 기준은 `fixed-delay-ms = 600000`, tick당 최대 5개 group, 원문 fallback 허용, backlog/oldest pending age 모니터링으로 둔다.
### 단계별 적용
- 1단계: `TranslationProvider` 인터페이스를 만들고 기존 `PapagoTranslationService`를 provider 구현으로 감싼다.
- 2단계: `translation_memory`, `translation_job`, `language_detection_result` 테이블을 추가한다.
- 3단계: 조회 fallback의 직접 Papago 호출을 제거하고, 누락 번역 job 예약 방식으로 전환한다.
- 4단계: 생성/수정 이벤트 번역을 `translation_job` 기반 워커로 전환한다.
- 5단계: `TranslationMemory` 결과를 기존 JSON/scalar read model로 materialize한다.
- 6단계: 목록 조회에서 큰 JSON payload 로딩이 병목이면 hot column을 선택적으로 추가한다.
## 검증 관점
- 동일 원문을 여러 도메인에서 번역해도 Papago 호출이 한 번만 발생하는지 확인한다.
- 조회 API에서 Papago 호출이 발생하지 않는지 확인한다.
- 생성/수정 이벤트 후 번역 작업이 중복 생성되지 않는지 확인한다.
- Papago 장애 시 원 API 응답이 실패하지 않고 작업 상태만 실패로 남는지 확인한다.
- 기존 API 응답 스키마가 바뀌지 않는지 확인한다.
## 남은 결정사항
- 번역이 없는 조회 응답은 원문을 반환한다.
- 실시간 번역이 꼭 필요한 엔드포인트는 1차 구현 범위에 포함하지 않는다. 단, 생성 직후 번역 노출이 중요한 리소스가 확인되면 해당 리소스만 별도 즉시 처리 정책을 검토한다.
- JSON read model을 장기 유지하되, hot column이 필요한 목록 API를 측정으로 결정해야 한다.
- Papago 외 provider는 1차 구현에서는 인터페이스만 준비하고 실제 추가 provider는 범위에서 제외한다.
## 검증 기록
- 2026-05-06: 코드 탐색 결과를 바탕으로 현재 번역 저장 구조, Papago 호출 흐름, 외부 아키텍처 패턴을 비교해 설계 초안을 작성했다.
- 2026-05-06: 문서 내 미완성 placeholder, 상충되는 범위, 구현 전제 누락 여부를 점검했다.
- 2026-05-06: 기존 번역 데이터 보존 제약을 배제해도 `TranslationMemory + TranslationJob + JSON read model` 하이브리드가 최종 구현방식으로 적합한지 재평가하고 문서에 반영했다.
- 2026-05-06: TDD RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizerTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'` 실행 시 `SourceTextNormalizer`, `TranslationJobRepository`, `TranslationJobScheduler`, `LanguageDetectionResultRepository`, `LanguageDetectionCacheService` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-05-06: 구현 후 동일 targeted test 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 정규화/해시, 누락 번역 job 생성, 언어 감지 캐시 hit 시 provider 미호출 동작을 검증했다.
- 2026-05-06: 전체 회귀 검증으로 `./gradlew test``./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. `build`에는 ktlint main/test/sourceSet check가 포함되어 스타일도 함께 검증됐다.
- 2026-05-06: `PapagoTranslationService|translationService\.translate|papagoTranslationService\.translate|TranslateRequest` 검색으로 직접 번역 호출이 `PapagoTranslationService`, `TranslationProvider`, `TranslationJobWorker`, DTO, 지원 언어 목록 참조에만 남아 있음을 확인했다. 조회 fallback과 `LanguageTranslationListener`에는 직접 Papago 번역 호출이 남아 있지 않다.
- 2026-05-06: MySQL unique 제약은 활성 상태 partial unique를 표현할 수 없으므로 완료 job이 있는 동일 key 재예약 시 중복 insert가 발생하지 않도록 repository 파생 쿼리 기반 회귀 테스트를 추가했다. RED는 중복 job 조회 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 테스트 `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: 최종 확인에서 Kotlin LSP 서버가 설정되어 있지 않아 `lsp_diagnostics``No LSP server configured for extension: .kt`로 실행할 수 없었다. 대체 검증으로 `./gradlew test`, `./gradlew build`, 신규 focused test `--rerun-tasks`를 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-06: 운영 안정화 보완 구현으로 `TranslationJobWorker` 기본 fixed delay를 10분(`600000ms`)으로 변경하고, MySQL `FOR UPDATE SKIP LOCKED` 기반 job id claim, 실패 시 `PENDING` 재전환 + `next_retry_at` backoff + 최대 재시도 후 `FAILED` 전환을 적용했다. RED는 `TranslationJobWorkerTest`에서 원자 claim 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: 문서에 반영한 group 단위 처리 정책을 구현했다. `TranslationJobWorker`는 tick당 최대 5개 `(resource_type, resource_id, target_language)` group을 처리하고, group 내부 pending field job을 `RUNNING`으로 claim한 뒤 모두 성공한 경우 한 번만 read model을 materialize한다. RED는 `findPendingJobIdsForGroupForUpdate`, `processNextGroup` 미구현으로 `TranslationJobWorkerTest``compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: group 처리 리뷰에서 materialize 실패 후 재시도 불가 가능성과 seed row 기반 claim의 group 분리 가능성을 확인했다. 보완 구현으로 단일 native query `findNextPendingGroupJobIdsForUpdate`에서 다음 pending group의 job id들을 `FOR UPDATE SKIP LOCKED`로 함께 claim하고, materialize 실패 시 group job들을 backoff 재시도 대상으로 되돌리도록 수정했다. RED는 새 group claim 메서드 미구현으로 `TranslationJobWorkerTest``compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: group 처리 전환 후 남은 이전 row 단위 claim 함수 사용처를 `rg`, AST 검색, explore/librarian 병렬 탐색으로 확인했다. production 경로가 `processNextGroup` + `findNextPendingGroupJobIdsForUpdate`로 수렴되어 `processNextJob`, `findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc`, `findNextPendingJobIdForUpdate`, `findPendingJobIdsForGroupForUpdate`를 제거했다.
## 2026-05-06 구현 DDL
운영 MySQL은 `spring.jpa.hibernate.ddl-auto=validate`이므로 아래 DDL을 선반영해야 한다. `created_at`, `updated_at``BaseEntity``createdAt`, `updatedAt`과 매핑된다.
```sql
CREATE TABLE translation_memory (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '번역 메모리 ID',
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
source_text TEXT NOT NULL COMMENT '정규화 원문 텍스트',
source_language VARCHAR(10) NOT NULL COMMENT '원문 언어 코드',
target_language VARCHAR(10) NOT NULL COMMENT '대상 언어 코드',
translated_text TEXT NOT NULL COMMENT '번역 결과 텍스트',
provider VARCHAR(50) NOT NULL COMMENT '번역 provider 이름',
provider_version VARCHAR(50) NOT NULL COMMENT '번역 provider 버전',
normalization_version VARCHAR(20) NOT NULL COMMENT '정규화 규칙 버전',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_translation_memory_source_target_provider (
source_hash,
source_language,
target_language,
provider,
normalization_version
),
KEY idx_translation_memory_source_hash (source_hash),
KEY idx_translation_memory_target_language (target_language)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE translation_job (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '번역 작업 ID',
resource_type VARCHAR(50) NOT NULL COMMENT '번역 대상 리소스 타입',
resource_id BIGINT NOT NULL COMMENT '번역 대상 리소스 ID',
field_key VARCHAR(80) NOT NULL COMMENT '번역 대상 필드 키',
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
source_text TEXT NOT NULL COMMENT '정규화 원문 텍스트',
source_language VARCHAR(10) NOT NULL COMMENT '원문 언어 코드',
target_language VARCHAR(10) NOT NULL COMMENT '대상 언어 코드',
status VARCHAR(20) NOT NULL COMMENT '번역 작업 상태',
retry_count INT NOT NULL COMMENT '재시도 횟수',
last_error_message TEXT DEFAULT NULL COMMENT '마지막 오류 메시지',
next_retry_at TIMESTAMP NOT NULL COMMENT '다음 재시도 가능 시각',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_translation_job_resource_field_target_hash (
resource_type,
resource_id,
field_key,
target_language,
source_hash
),
KEY idx_translation_job_status_retry (status, next_retry_at),
KEY idx_translation_job_resource (resource_type, resource_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE language_detection_result (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '언어 감지 결과 ID',
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
source_text_sample VARCHAR(500) NOT NULL COMMENT '정규화 원문 샘플 텍스트',
detected_language VARCHAR(10) NOT NULL COMMENT '감지된 언어 코드',
provider VARCHAR(50) NOT NULL COMMENT '언어 감지 provider 이름',
confidence DOUBLE DEFAULT NULL COMMENT '언어 감지 신뢰도',
normalization_version VARCHAR(20) NOT NULL COMMENT '정규화 규칙 버전',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_language_detection_result_hash_provider_version (
source_hash,
provider,
normalization_version
),
KEY idx_language_detection_result_source_hash (source_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

View File

@@ -1,18 +0,0 @@
# 번역 작업 원문 언어 제한
## 작업 항목
- [x] `TranslationJobSchedulerTest`에 지원하지 않는 `sourceLanguage` 입력 시 `TranslationJob`을 저장하지 않는 RED 테스트를 추가한다.
- [x] `TranslationJobScheduler`에서 `sourceLanguage`를 소문자 정규화한 뒤 `ko`, `en`, `ja`가 아니면 등록을 중단한다.
- [x] focused test와 관련 검증 명령을 실행해 변경 결과를 확인한다.
## 설계
- `TranslationJob` 등록의 최종 방어선인 `TranslationJobScheduler.scheduleMissingTranslation()`에서 검증한다.
- 허용값은 현재 번역 지원 언어와 동일하게 `ko`, `en`, `ja`로 제한한다.
- 지원하지 않는 원문 언어는 예외를 던지지 않고 기존 early return 흐름처럼 job 등록만 생략한다.
## 검증 기록
- 2026-05-07: RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest.shouldNotCreateJobWhenSourceLanguageIsUnsupported'` 실행 시 `TranslationJobSchedulerTest.kt:102``verifyNoInteractions` 검증 실패로 unsupported `sourceLanguage`가 repository 호출까지 진행됨을 확인했다.
- 2026-05-07: GREEN 확인: 동일 focused test가 `BUILD SUCCESSFUL`로 통과해 unsupported `sourceLanguage`가 등록되지 않음을 확인했다.
- 2026-05-07: 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-07: 스타일 확인: `./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-07: Kotlin LSP는 현재 환경에 `.kt` 서버가 설정되어 있지 않아 `lsp_diagnostics` 실행이 불가했다. 대신 Gradle 컴파일 포함 focused/class test와 ktlint로 검증했다.

View File

@@ -1,46 +0,0 @@
# 콘텐츠 관리자 권한 및 관리자 로그인 API 추가
## 작업 항목
- [x] `MemberRole`에 콘텐츠 관리자 권한을 추가한다.
- [x] 관리자 로그인 API 테스트를 먼저 추가하고 RED를 확인한다.
- [x] 관리자와 콘텐츠 관리자만 로그인 가능한 관리자 전용 로그인 API를 구현한다.
- [x] 응답을 `token`, `role`만 포함하도록 구현한다.
- [x] focused test, 관련 테스트, 스타일 검사를 실행하고 결과를 기록한다.
- [x] 콘텐츠 관리자가 `GET /menu`를 호출할 수 있도록 메뉴 조회 권한을 확장한다.
- [x] 콘텐츠 관리자가 관리자 콘텐츠 목록/조회 보조 API를 호출할 수 있도록 읽기 권한만 확장한다.
- [x] 콘텐츠 관리자가 관리자 콘텐츠 수정 API를 호출할 수 없도록 수정 권한은 관리자 전용으로 유지한다.
- [x] focused security test, 관련 테스트, 스타일 검사를 실행하고 결과를 기록한다.
## 설계
- 새 권한은 기존 `MemberRole` enum에 `CONTENT_MANAGER`로 추가한다.
- 새 API는 관리자 도메인의 `/admin/member/login`으로 추가하고 인증 없이 호출 가능하도록 보안 설정에 permitAll을 추가한다.
- 서비스는 기존 이메일/비밀번호 인증 흐름과 JWT 생성 방식을 재사용하되, `ADMIN`, `CONTENT_MANAGER` 외 역할은 `common.error.bad_credentials` 예외로 거부한다.
- 응답 DTO는 관리자 로그인 전용으로 분리해 `token`, `role`만 노출한다.
## 후속 설계: 콘텐츠 관리자 메뉴 및 콘텐츠 읽기 권한
- `CONTENT_MANAGER`는 이미 관리자 로그인 API로 토큰을 받을 수 있으므로 새 역할이나 새 권한 타입을 추가하지 않는다.
- 메뉴 조회는 기존 `GET /menu``MenuRepository.getMenu(member.role)` 구조를 그대로 사용한다. 컨트롤러의 `@PreAuthorize``CONTENT_MANAGER`만 추가해 콘텐츠 관리자가 자신의 역할에 매핑된 메뉴를 받을 수 있게 한다.
- `/content/list`는 서버 코드 상수가 아니라 `Menu.route` DB 값으로 내려가는 구조이므로, 서버에서는 별도 라우트 상수를 추가하지 않는다. 실제 메뉴 노출은 `roles = CONTENT_MANAGER`, `route = /content/list`, `isActive = true` 데이터가 존재할 때 가능하다.
- 관리자 콘텐츠 API는 기존 `hasRole('ADMIN')` 클래스 권한을 메서드 단위로 분리한다. `GET /admin/audio-content/list`, `GET /admin/audio-content/search`, `GET /admin/audio-content/main/tab``ADMIN``CONTENT_MANAGER`를 허용하고, `PUT /admin/audio-content``ADMIN`만 허용한다.
- 콘텐츠 재생은 기존 사용자 콘텐츠 API의 `GET /audio-content/{id}/generate-url` 흐름을 변경하지 않는다. 이 API는 로그인 사용자와 구매/접근 조건으로 재생 URL을 제어하므로 콘텐츠 관리자 전용 우회 권한은 추가하지 않는다.
## 구현 계획
- [x] `MenuController` 보안 테스트를 추가해 `CONTENT_MANAGER``GET /menu`에 접근 가능하고 일반 사용자는 거부되는지 확인한다.
- [x] `AdminContentController` 보안 테스트를 추가해 `CONTENT_MANAGER`는 목록/검색/메인탭 조회가 가능하고 수정은 거부되는지 확인한다.
- [x] 테스트가 실패하는 것을 확인한다.
- [x] `MenuController``@PreAuthorize``CONTENT_MANAGER`를 추가한다.
- [x] `AdminContentController`의 클래스 단위 `@PreAuthorize`를 제거하고 각 메서드에 읽기/수정 권한을 분리한다.
- [x] focused test, 관련 test, ktlint를 실행해 검증한다.
## 검증 기록
- 2026-05-07: RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginControllerTest'` 실행 시 `AdminMemberLoginService`, `AdminMemberLoginController`, `AdminMemberLoginResponse`, `CONTENT_MANAGER`, `findByEmail` 미구현으로 `compileTestKotlin`이 실패함을 확인했다.
- 2026-05-07: GREEN 확인: 동일 focused test가 `BUILD SUCCESSFUL`로 통과해 관리자/콘텐츠 관리자 로그인 허용 및 일반 사용자 거부를 확인했다.
- 2026-05-07: API 응답 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginControllerTest.shouldReturnTokenAndRoleJson'``BUILD SUCCESSFUL`로 통과해 `POST /admin/member/login` JSON 응답의 `data.token`, `data.role`을 확인했다.
- 2026-05-07: 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.*'``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-07: 스타일 확인: `./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-07: Kotlin LSP는 현재 환경에 `.kt` 서버가 설정되어 있지 않아 `lsp_diagnostics` 실행이 불가했다. 대신 Gradle 컴파일 포함 focused/관련 test와 ktlint로 검증했다.
- 2026-05-07: RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.menu.MenuControllerSecurityTest' --tests 'kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest'` 실행 시 `CONTENT_MANAGER``GET /menu`, `GET /admin/audio-content/list`, `GET /admin/audio-content/search`, `GET /admin/audio-content/main/tab` 허용 기대 테스트 4건이 실패함을 확인했다.
- 2026-05-07: GREEN 확인: 동일 focused security test가 `BUILD SUCCESSFUL`로 통과해 콘텐츠 관리자 메뉴 조회, 관리자 콘텐츠 목록/검색/메인탭 조회 허용과 관리자 콘텐츠 수정 거부를 확인했다.
- 2026-05-07: 관련 테스트 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.AdminContentServiceTest' --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginControllerTest'``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-07: 스타일 확인: `./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-07: Kotlin LSP는 현재 환경에 `.kt` 서버가 설정되어 있지 않아 `lsp_diagnostics` 실행이 불가했다. 대신 Gradle 컴파일 포함 focused/관련 test와 ktlint로 검증했다.

View File

@@ -1,21 +0,0 @@
# 크리에이터 관리자 로그아웃 AGENT 권한 허용
## 목표
- `/creator-admin/member/logout` 엔드포인트를 `CREATOR`뿐 아니라 `AGENT` 권한 계정도 사용할 수 있게 한다.
- 로그인 로직이 이미 `CREATOR`, `AGENT`를 허용하는 기존 정책과 로그아웃 권한 조건을 맞춘다.
## 구현 항목
- [x] `CreatorAdminMemberController``logout` 권한 조건에 `AGENT`를 추가한다.
- [x] `logout` 권한 조건이 `CREATOR`, `AGENT`를 모두 포함하는지 테스트로 검증한다.
- [x] 변경 파일 진단과 대상 테스트를 실행한다.
## 검증 계획
- `./gradlew test --tests "kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest"`
- `lsp_diagnostics`로 변경 Kotlin 파일 오류 확인
## 검증 기록
- `./gradlew test --tests "kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest"`를 먼저 실행해 `hasRole('CREATOR')` 상태에서 실패함을 확인했다.
- `CreatorAdminMemberController.logout``@PreAuthorize``hasAnyRole('CREATOR', 'AGENT')`로 변경했다.
- 동일한 대상 테스트를 `--rerun-tasks`로 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 Kotlin(`.kt`) LSP 서버가 설정되어 있지 않아 실행할 수 없었다. Gradle 테스트 과정에서 Kotlin 컴파일과 테스트 컴파일은 정상 통과했다.
- 테스트 실행 중 기존 파일 `ImageBlurUtil.kt`의 미사용 파라미터 경고가 표시됐으며, 이번 변경 범위와는 무관해 수정하지 않았다.

View File

@@ -1,12 +0,0 @@
# 문서 유지보수
## 문서 유지보수 규칙
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다.
- 연속된 하나의 작업에 대해 계획 문서가 여러 개 생기지 않도록 기존 계획 문서 재사용 여부를 먼저 확인한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.

View File

@@ -1,6 +0,0 @@
# 설정 보안
## 설정/보안 유의사항
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.

View File

@@ -1,9 +0,0 @@
# 작업 절차
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 계획 문서를 만들지 말고 기존 계획 문서를 갱신한다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다.

View File

@@ -1,59 +0,0 @@
# 코드 스타일
## 코드 스타일 규칙
### 1) 포맷/기본 규칙
- `.editorconfig` 기준을 준수한다.
- 인덴트: 공백 4칸.
- 줄바꿈: LF.
- 최대 라인 길이: 130.
- 파일 끝 개행 유지, trailing whitespace 제거.
### 2) import 규칙
- 와일드카드 import(`*`)를 사용하지 않는다.
- 사용하지 않는 import를 남기지 않는다.
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
### 3) 네이밍 규칙
- 클래스/인터페이스/enum: PascalCase.
- 함수/변수/파라미터: camelCase.
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
### 4) 타입/널 처리
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
### 5) API/응답 규칙
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
### 6) 예외 처리 규칙
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
### 7) 트랜잭션 규칙
- 서비스 계층에서 `@Transactional`을 사용한다.
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
### 8) 비동기/동시성 규칙
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
### 9) 의존성 주입
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
- 필드 주입보다 명시적 생성자 주입을 우선한다.
### 10) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.

View File

@@ -1,9 +0,0 @@
# 테스트 스타일
## 테스트 스타일 규칙
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.

View File

@@ -13,10 +13,10 @@ import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audio-content")
class AdminContentController(private val service: AdminContentService) {
@GetMapping("/list")
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
fun getAudioContentList(
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
pageable: Pageable
@@ -28,7 +28,6 @@ class AdminContentController(private val service: AdminContentService) {
)
@GetMapping("/search")
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
fun searchAudioContent(
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
@RequestParam(value = "search_word") searchWord: String,
@@ -42,14 +41,12 @@ class AdminContentController(private val service: AdminContentService) {
)
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasRole('ADMIN')")
fun modifyAudioContent(
@RequestPart("request") requestString: String,
@RequestPart("coverImage", required = false) coverImage: MultipartFile? = null
) = ApiResponse.ok(service.updateAudioContent(coverImage, requestString))
@GetMapping("/main/tab")
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
fun getContentMainTabList() = ApiResponse.ok(service.getContentMainTabList())
}

View File

@@ -1,15 +0,0 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.login.LoginRequest
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/member")
class AdminMemberLoginController(private val service: AdminMemberLoginService) {
@PostMapping("/login")
fun login(@RequestBody request: LoginRequest) = ApiResponse.ok(service.login(request))
}

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.member.MemberRole
data class AdminMemberLoginResponse(
val token: String,
val role: MemberRole
)

View File

@@ -1,39 +0,0 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.login.LoginRequest
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
@Service
class AdminMemberLoginService(
private val repository: AdminMemberRepository,
private val passwordEncoder: PasswordEncoder,
private val tokenProvider: TokenProvider
) {
fun login(request: LoginRequest): AdminMemberLoginResponse {
val member = repository.findByEmail(request.email)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (member.role != MemberRole.ADMIN && member.role != MemberRole.CONTENT_MANAGER) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
if (!member.isActive || !passwordEncoder.matches(request.password, member.password)) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
val authentication = UsernamePasswordAuthenticationToken(
MemberAdapter(member),
null,
MemberAdapter(member).authorities
)
val token = tokenProvider.createToken(authentication = authentication, memberId = member.id!!)
return AdminMemberLoginResponse(token = token, role = member.role)
}
}

View File

@@ -6,9 +6,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository {
fun findByEmail(email: String?): Member?
}
interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository
interface AdminMemberQueryRepository {
fun getMemberTotalCount(role: MemberRole? = null): Int

View File

@@ -101,10 +101,6 @@ class AdminMemberService(
MemberRole.CREATOR -> messageSource.getMessage("admin.member.role.creator", langContext.lang).orEmpty()
MemberRole.AGENT -> messageSource.getMessage("admin.member.role.agent", langContext.lang).orEmpty()
MemberRole.BOT -> messageSource.getMessage("admin.member.role.bot", langContext.lang).orEmpty()
MemberRole.CONTENT_MANAGER ->
messageSource
.getMessage("admin.member.role.content_manager", langContext.lang)
.orEmpty()
}
val loginType = when (it.provider) {

View File

@@ -17,12 +17,8 @@ class CanController(private val service: CanService) {
fun getCans(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<List<CanResponse>> {
val forcedCurrency = if (member != null && (member.id == 2L || member.id == 4L || member.id == 44144L)) {
"JPY"
} else {
null
}
return ApiResponse.ok(service.getCans(forcedCurrency = forcedCurrency))
val isNotSelectedCurrency = member != null && member.id == 2L
return ApiResponse.ok(service.getCans(isNotSelectedCurrency = isNotSelectedCurrency))
}
@GetMapping("/status")

View File

@@ -14,11 +14,15 @@ class CanService(
private val repository: CanRepository,
private val countryContext: CountryContext
) {
fun getCans(forcedCurrency: String? = null): List<CanResponse> {
val currency = forcedCurrency ?: when (countryContext.countryCode) {
fun getCans(isNotSelectedCurrency: Boolean): List<CanResponse> {
val currency = if (isNotSelectedCurrency) {
null
} else {
when (countryContext.countryCode) {
"KR" -> "KRW"
else -> "USD"
}
}
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
}

View File

@@ -85,13 +85,6 @@ class ChargeService(
@Value("\${payverse.usd-secret-key}")
private val payverseUsdSecretKey: String,
@Value("\${payverse.jpy-mid}")
private val payverseJpyMid: String,
@Value("\${payverse.jpy-client-key}")
private val payverseJpyClientKey: String,
@Value("\${payverse.jpy-secret-key}")
private val payverseJpySecretKey: String,
@Value("\${payverse.host}")
private val payverseHost: String,
@@ -113,18 +106,18 @@ class ChargeService(
return when (charge.payment?.status) {
PaymentStatus.REQUEST -> {
// 성공 조건 검증
val mid = when (request.requestCurrency) {
"KRW" -> payverseMid
"JPY" -> payverseJpyMid
else -> payverseUsdMid
val mid = if (request.requestCurrency == "KRW") {
payverseMid
} else {
payverseUsdMid
}
val expectedSign = DigestUtils.sha512Hex(
String.format(
"||%s||%s||%s||%s||%s||",
when (request.requestCurrency) {
"KRW" -> payverseSecretKey
"JPY" -> payverseJpySecretKey
else -> payverseUsdSecretKey
if (request.requestCurrency == "KRW") {
payverseSecretKey
} else {
payverseUsdSecretKey
},
mid,
request.orderId,
@@ -133,8 +126,9 @@ class ChargeService(
)
)
val expectedAmount = computePayverseAmount(charge.payment!!.price, request.requestCurrency)
val isAmountMatch = request.requestAmount.compareTo(expectedAmount) == 0
val isAmountMatch = request.requestAmount.compareTo(
charge.payment!!.price
) == 0
val isSuccess = request.resultStatus == "SUCCESS" &&
request.mid == mid &&
@@ -247,20 +241,21 @@ class ChargeService(
?: throw SodaException(messageKey = "can.charge.invalid_request_restart")
val requestCurrency = can.currency
val mid = when (requestCurrency) {
"KRW" -> payverseMid
"JPY" -> payverseJpyMid
else -> payverseUsdMid
val isKrw = requestCurrency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
}
val clientKey = when (requestCurrency) {
"KRW" -> payverseClientKey
"JPY" -> payverseJpyClientKey
else -> payverseUsdClientKey
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
}
val secretKey = when (requestCurrency) {
"KRW" -> payverseSecretKey
"JPY" -> payverseJpySecretKey
else -> payverseUsdSecretKey
val secretKey = if (isKrw) {
payverseSecretKey
} else {
payverseUsdSecretKey
}
val charge = Charge(can.can, can.rewardCan)
@@ -275,7 +270,12 @@ class ChargeService(
val savedCharge = chargeRepository.save(charge)
val chargeId = savedCharge.id!!
val amount = computePayverseAmount(savedCharge.payment!!.price, requestCurrency)
val amount = BigDecimal(
savedCharge.payment!!.price
.setScale(4, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString()
)
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
val sign = DigestUtils.sha512Hex(
String.format(
@@ -312,16 +312,16 @@ class ChargeService(
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val currency = charge.can?.currency
val mid = when (currency) {
"KRW" -> payverseMid
"JPY" -> payverseJpyMid
else -> payverseUsdMid
val isKrw = charge.can?.currency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
}
val clientKey = when (currency) {
"KRW" -> payverseClientKey
"JPY" -> payverseJpyClientKey
else -> payverseUsdClientKey
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
}
// 결제수단 확인
@@ -351,12 +351,11 @@ class ChargeService(
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
val customerId = "${serverEnv}_user_${member.id!!}"
val expectedAmount = computePayverseAmount(charge.can!!.price, charge.can!!.currency)
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
verifyResponse.transactionStatus == "SUCCESS" &&
verifyResponse.orderId.toLongOrNull() == charge.id &&
verifyResponse.customerId == customerId &&
verifyResponse.requestAmount.compareTo(expectedAmount) == 0
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
if (isSuccess) {
// verify 함수의 232~248 라인과 동일 처리
@@ -738,16 +737,4 @@ class ChargeService(
}
return messageSource.getMessage("can.charge.payment_method.card", langContext.lang)
}
// Payverse 금액 포맷: 통화별 규칙 적용
private fun computePayverseAmount(price: BigDecimal, currency: String): BigDecimal {
val scaled = if (currency == "JPY") {
// JPY: 강제 정수화, 소수점 버림
price.setScale(0, RoundingMode.FLOOR)
} else {
// 그 외: 4자리까지 반올림 후 불필요 0 제거
price.setScale(4, RoundingMode.HALF_UP).stripTrailingZeros()
}
return BigDecimal(scaled.stripTrailingZeros().toPlainString())
}
}

View File

@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
@@ -22,8 +24,8 @@ import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
@@ -44,7 +46,7 @@ class ChatCharacterController(
private val characterCommentService: CharacterCommentService,
private val curationQueryService: CharacterCurationQueryService,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val translationService: PapagoTranslationService,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val langContext: LangContext,
@@ -210,11 +212,89 @@ class ChatCharacterController(
tags = payload.tags
)
} else {
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CHARACTER,
resourceId = character.id!!,
val texts = mutableListOf<String>()
texts.add(character.name)
texts.add(character.description)
texts.add(character.gender ?: "")
val hasPersonality = personality != null
if (hasPersonality) {
texts.add(personality!!.trait)
texts.add(personality.description)
}
val hasBackground = background != null
if (hasBackground) {
texts.add(background!!.topic)
texts.add(background.description)
}
texts.add(tags)
val sourceLanguage = character.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = langContext.lang.code
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedName = translatedTexts[index++]
val translatedDescription = translatedTexts[index++]
val translatedGender = translatedTexts[index++]
var translatedPersonality: TranslatedAiCharacterPersonality? = null
if (hasPersonality) {
translatedPersonality = TranslatedAiCharacterPersonality(
trait = translatedTexts[index++],
description = translatedTexts[index++]
)
}
var translatedBackground: TranslatedAiCharacterBackground? = null
if (hasBackground) {
translatedBackground = TranslatedAiCharacterBackground(
topic = translatedTexts[index++],
description = translatedTexts[index++]
)
}
val translatedTags = translatedTexts[index]
val payload = AiCharacterTranslationRenderedPayload(
name = translatedName,
description = translatedDescription,
gender = translatedGender,
personalityTrait = translatedPersonality?.trait ?: "",
personalityDescription = translatedPersonality?.description ?: "",
backgroundTopic = translatedBackground?.topic ?: "",
backgroundDescription = translatedBackground?.description ?: "",
tags = translatedTags
)
val entity = AiCharacterTranslation(
characterId = character.id!!,
locale = langContext.lang.code,
renderedPayload = payload
)
aiCharacterTranslationRepository.save(entity)
translated = TranslatedAiCharacterDetail(
name = translatedName,
description = translatedDescription,
gender = translatedGender,
personality = translatedPersonality,
background = translatedBackground,
tags = translatedTags
)
}
}
}

View File

@@ -1,22 +1,28 @@
package kr.co.vividnext.sodalive.chat.original.service
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class OriginalWorkTranslationService(
private val translationRepository: OriginalWorkTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private val papagoTranslationService: PapagoTranslationService
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
* - 기존 번역이 있으면 그대로 사용
* - 없으면 누락 번역 job 예약 후 null 반환
* - 없으면 파파고 번역 수행 후 저장
* - 실패/불필요 시 null 반환
*/
@Transactional
@@ -49,11 +55,70 @@ class OriginalWorkTranslationService(
}
}
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.ORIGINAL_WORK,
resourceId = originalWork.id!!,
// 파파고 번역 수행
return try {
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
val texts = buildList {
add(originalWork.title)
add(originalWork.contentType)
add(originalWork.category)
add(originalWork.description)
addAll(tags)
}
val response = papagoTranslationService.translate(
TranslateRequest(
texts = texts,
sourceLanguage = source,
targetLanguage = target
)
return null
)
val out = response.translatedText
if (out.isEmpty()) return null
// 앞 4개는 필드, 나머지는 태그
val title = out.getOrNull(0)?.trim().orEmpty()
val contentType = out.getOrNull(1)?.trim().orEmpty()
val category = out.getOrNull(2)?.trim().orEmpty()
val description = out.getOrNull(3)?.trim().orEmpty()
val translatedTags = if (out.size > 4) {
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
} else {
emptyList()
}
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
if (!hasAny) return null
val payload = OriginalWorkTranslationPayload(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
val entity = existed?.apply { this.renderedPayload = payload }
?: OriginalWorkTranslation(
originalWorkId = originalWork.id!!,
locale = target,
renderedPayload = payload
)
translationRepository.save(entity)
TranslatedOriginalWork(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
} catch (e: Exception) {
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
null
}
}
}

View File

@@ -27,9 +27,7 @@ class ChatRoomQuotaController(
) {
data class PurchaseRoomQuotaRequest(
val container: String,
val chargeType: ChatRoomQuotaChargeType = ChatRoomQuotaChargeType.CAN,
val canOption: ChatRoomQuotaCanOption? = null
val container: String
)
data class PurchaseRoomQuotaResponse(
@@ -47,9 +45,8 @@ class ChatRoomQuotaController(
/**
* 채팅방 유료 쿼터 구매 API
* - 참여 여부 검증(내가 USER로 참여 중인 활성 방)
* - 요청 DTO로 캔 충전 / 광고 충전을 구분
* - 캔 충전은 옵션별 캔 차감 후 방 유료 쿼터 지급
* - 광고 충전은 캔 차감 없이 방 유료 쿼터 5 지급
* - 30캔 결제 (UseCan에 chatRoomId:characterId 기록)
* - 방 유료 쿼터 40 충전
*/
@PostMapping("/{chatRoomId}/quota/purchase")
fun purchaseRoomQuota(
@@ -77,28 +74,13 @@ class ChatRoomQuotaController(
val characterId = character.id
?: throw SodaException(messageKey = "chat.room.quota.character_required")
val chargeType = req.chargeType
val status = when (chargeType) {
ChatRoomQuotaChargeType.CAN -> {
val canOption = req.canOption ?: ChatRoomQuotaCanOption.CAN_10
chatRoomQuotaService.purchaseWithCan(
val status = chatRoomQuotaService.purchase(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId,
canOption = canOption,
addPaid = 12,
container = req.container
)
}
ChatRoomQuotaChargeType.AD -> {
chatRoomQuotaService.purchaseWithAd(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId
)
}
}
ApiResponse.ok(
PurchaseRoomQuotaResponse(

View File

@@ -1,14 +0,0 @@
package kr.co.vividnext.sodalive.chat.quota.room
enum class ChatRoomQuotaChargeType {
CAN,
AD
}
enum class ChatRoomQuotaCanOption(
val needCan: Int,
val quota: Int
) {
CAN_10(10, 15),
CAN_20(20, 40)
}

View File

@@ -13,10 +13,6 @@ class ChatRoomQuotaService(
private val repo: ChatRoomQuotaRepository,
private val canPaymentService: CanPaymentService
) {
companion object {
private const val AD_REWARD_QUOTA = 5
}
data class RoomQuotaStatus(
val totalRemaining: Int,
val nextRechargeAtEpochMillis: Long?,
@@ -126,50 +122,23 @@ class ChatRoomQuotaService(
}
@Transactional
fun purchaseWithCan(
fun purchase(
memberId: Long,
chatRoomId: Long,
characterId: Long,
canOption: ChatRoomQuotaCanOption,
addPaid: Int = 12,
container: String
): RoomQuotaStatus {
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
canPaymentService.spendCan(
memberId = memberId,
needCan = canOption.needCan,
needCan = 10,
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
chatRoomId = chatRoomId,
characterId = characterId,
container = container
)
return addPaidQuota(
memberId = memberId,
chatRoomId = chatRoomId,
characterId = characterId,
addPaid = canOption.quota
)
}
@Transactional
fun purchaseWithAd(
memberId: Long,
chatRoomId: Long,
characterId: Long
): RoomQuotaStatus {
return addPaidQuota(
memberId = memberId,
chatRoomId = chatRoomId,
characterId = characterId,
addPaid = AD_REWARD_QUOTA
)
}
private fun addPaidQuota(
memberId: Long,
chatRoomId: Long,
characterId: Long,
addPaid: Int
): RoomQuotaStatus {
val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save(
ChatRoomQuota(
memberId = memberId,

View File

@@ -74,7 +74,6 @@ class SecurityConfig(
.antMatchers("/member/login/kakao").permitAll()
.antMatchers("/member/login/apple").permitAll()
.antMatchers("/member/login/line").permitAll()
.antMatchers("/admin/member/login").permitAll()
.antMatchers("/creator-admin/member/login").permitAll()
.antMatchers("/member/forgot-password").permitAll()
.antMatchers("/stplat/terms_of_service").permitAll()

View File

@@ -22,6 +22,8 @@ import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
@@ -34,7 +36,8 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
@@ -67,7 +70,7 @@ class AudioContentService(
private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val translationService: PapagoTranslationService,
private val contentTranslationRepository: ContentTranslationRepository,
private val s3Uploader: S3Uploader,
@@ -767,7 +770,7 @@ class AudioContentService(
* TranslatedContent로 가공한다
*
* 번역 콘텐츠가 없으면
* 누락 번역 job을 예약하고 원문 응답을 유지한다.
* 파파고 API를 통해 번역한 후 저장한다.
*
* 번역 대상: title, detail, tags
*
@@ -789,11 +792,49 @@ class AudioContentService(
tags = payload.tags
)
} else {
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = audioContent.id!!,
val texts = mutableListOf<String>()
texts.add(audioContent.title)
texts.add(audioContent.detail)
texts.add(tag)
val sourceLanguage = audioContent.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = langContext.lang.code
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedDetail = translatedTexts[index++]
val translatedTags = translatedTexts[index]
val payload = ContentTranslationPayload(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
contentTranslationRepository.save(
ContentTranslation(
contentId = audioContent.id!!,
locale = langContext.lang.code,
renderedPayload = payload
)
)
translated = TranslatedContent(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
}
}
}

View File

@@ -60,7 +60,6 @@ class LanguageDetectListener(
private val seriesRepository: ContentSeriesRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val categoryRepository: CategoryRepository,
private val languageDetectionCacheService: LanguageDetectionCacheService,
private val applicationEventPublisher: ApplicationEventPublisher,
@@ -117,7 +116,7 @@ class LanguageDetectListener(
return
}
val langCode = detectLanguageCode(event, characterId) ?: return
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
character.languageCode = langCode
chatCharacterRepository.save(character)
@@ -155,7 +154,7 @@ class LanguageDetectListener(
return
}
val langCode = detectLanguageCode(event, contentId) ?: return
val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
audioContent.languageCode = langCode
@@ -195,7 +194,7 @@ class LanguageDetectListener(
return
}
val langCode = detectLanguageCode(event, commentId) ?: return
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
comment.languageCode = langCode
audioContentCommentRepository.save(comment)
@@ -227,7 +226,7 @@ class LanguageDetectListener(
return
}
val langCode = detectLanguageCode(event, commentId) ?: return
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
comment.languageCode = langCode
characterCommentRepository.save(comment)
@@ -258,7 +257,7 @@ class LanguageDetectListener(
return
}
val langCode = detectLanguageCode(event, cheersId) ?: return
val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
cheers.languageCode = langCode
creatorCheersRepository.save(cheers)
@@ -289,7 +288,7 @@ class LanguageDetectListener(
return
}
val langCode = detectLanguageCode(event, seriesId) ?: return
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
series.languageCode = langCode
seriesRepository.save(series)
@@ -327,7 +326,7 @@ class LanguageDetectListener(
return
}
val langCode = detectLanguageCode(event, originalWorkId) ?: return
val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
originalWork.languageCode = langCode
originalWorkRepository.save(originalWork)
@@ -353,7 +352,7 @@ class LanguageDetectListener(
val category = categoryRepository.findByIdOrNull(categoryId) ?: return
if (!category.languageCode.isNullOrBlank()) return
val langCode = detectLanguageCode(event, categoryId) ?: return
val langCode = requestPapagoLanguageCode(event.query, categoryId) ?: return
category.languageCode = langCode
categoryRepository.save(category)
@@ -366,12 +365,6 @@ class LanguageDetectListener(
)
}
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
return languageDetectionCacheService.detectWithCache(event.query) {
requestPapagoLanguageCode(event.query, targetIdForLog)
}
}
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
return try {
val headers = HttpHeaders().apply {

View File

@@ -1,46 +0,0 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class LanguageDetectionCacheService(
private val languageDetectionResultRepository: LanguageDetectionResultRepository
) {
@Transactional
fun detectWithCache(
query: String,
provider: String = DEFAULT_PROVIDER,
detector: () -> String?
): String? {
val normalizedQuery = SourceTextNormalizer.normalize(query)
if (normalizedQuery.isBlank()) return null
val sourceHash = SourceTextNormalizer.hash(normalizedQuery)
val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion(
sourceHash = sourceHash,
provider = provider,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
if (cached != null) return cached.detectedLanguage
val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null
languageDetectionResultRepository.save(
LanguageDetectionResult(
sourceHash = sourceHash,
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
detectedLanguage = detectedLanguage.lowercase(),
provider = provider,
confidence = null,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
)
return detectedLanguage.lowercase()
}
companion object {
const val DEFAULT_PROVIDER = "papago"
private const val MAX_SAMPLE_LENGTH = 500
}
}

View File

@@ -1,38 +0,0 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "language_detection_result",
uniqueConstraints = [
UniqueConstraint(
name = "uk_language_detection_result_hash_provider_version",
columnNames = ["source_hash", "provider", "normalization_version"]
)
]
)
class LanguageDetectionResult(
@Column(name = "source_hash", nullable = false, length = 64)
val sourceHash: String,
@Column(name = "source_text_sample", nullable = false, length = 500)
val sourceTextSample: String,
@Column(name = "detected_language", nullable = false, length = 10)
val detectedLanguage: String,
@Column(name = "provider", nullable = false, length = 50)
val provider: String,
@Column(name = "confidence")
val confidence: Double? = null,
@Column(name = "normalization_version", nullable = false, length = 20)
val normalizationVersion: String = SourceTextNormalizer.NORMALIZATION_VERSION
) : BaseEntity()

View File

@@ -1,11 +0,0 @@
package kr.co.vividnext.sodalive.content
import org.springframework.data.jpa.repository.JpaRepository
interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionResult, Long> {
fun findBySourceHashAndProviderAndNormalizationVersion(
sourceHash: String,
provider: String,
normalizationVersion: String
): LanguageDetectionResult?
}

View File

@@ -7,7 +7,8 @@ import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.context.ApplicationEventPublisher
@@ -24,7 +25,7 @@ class CategoryService(
private val langContext: LangContext,
private val applicationEventPublisher: ApplicationEventPublisher,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private val translationService: PapagoTranslationService
) {
@Transactional
fun createCategory(request: CreateCategoryRequest, member: Member) {
@@ -147,7 +148,7 @@ class CategoryService(
.findByCategoryIdInAndLocale(categoryIds, locale)
.associateBy { it.categoryId }
// 각 항목에 대해 번역 적용. 없으면 누락 번역 job만 예약하고 원문을 반환한다.
// 각 항목에 대해 번역 적용. 없으면 Papago로 번역 저장 후 적용
val result = mutableListOf<GetCategoryListResponse>()
for (item in baseList) {
val entity = entityMap[item.categoryId]
@@ -164,11 +165,38 @@ class CategoryService(
continue
}
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY,
resourceId = entity.id!!,
// 번역본이 없으면 Papago 번역 후 저장
val texts = listOf(entity.title)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLang,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
val translatedCategory = translatedTexts[0]
val existingOne = categoryTranslationRepository
.findByCategoryIdAndLocale(entity.id!!, locale)
if (existingOne == null) {
categoryTranslationRepository.save(
CategoryTranslation(
categoryId = entity.id!!,
locale = locale,
category = translatedCategory
)
)
} else {
existingOne.category = translatedCategory
categoryTranslationRepository.save(existingOne)
}
result.add(GetCategoryListResponse(categoryId = item.categoryId, category = translatedCategory))
continue
}
}
// 번역이 필요 없거나 실패한 경우 원본 사용

View File

@@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
@@ -15,8 +17,8 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
@@ -39,7 +41,7 @@ class ContentSeriesService(
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val translationService: PapagoTranslationService,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
@@ -89,7 +91,7 @@ class ContentSeriesService(
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
/**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
*/
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
@@ -118,12 +120,32 @@ class ContentSeriesService(
// 미번역 항목 수집
val untranslated = genres.filter { existingMap[it.id] == null }
untranslated.forEach { item ->
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.SERIES_GENRE,
resourceId = item.id,
if (untranslated.isNotEmpty()) {
val texts = untranslated.map { it.genre }
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = "ko",
targetLanguage = targetLocale
)
)
val translatedTexts = response.translatedText
val toSave = mutableListOf<kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation>()
untranslated.forEachIndexed { index, item ->
val translated = translatedTexts.getOrNull(index) ?: item.genre
toSave.add(
kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation(
seriesGenreId = item.id,
locale = targetLocale,
genre = translated
)
)
}
if (toSave.isNotEmpty()) {
seriesGenreTranslationRepository.saveAll(toSave)
toSave.forEach { saved -> existingMap[saved.seriesGenreId] = saved }
}
}
// 원래 순서 보존하여 결과 조립
@@ -261,7 +283,7 @@ class ContentSeriesService(
* TranslatedSeries로 가공한다
*
* 번역 콘텐츠가 없으면
* 누락 번역 job을 예약하고 원문 응답을 유지한다.
* 파파고 API를 통해 번역한 후 저장한다.
*
* 번역 대상: title, introduction, keywordList
*
@@ -287,11 +309,54 @@ class ContentSeriesService(
keywords = kws
)
} else {
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.SERIES,
resourceId = seriesId,
val texts = mutableListOf<String>()
texts.add(series.title)
texts.add(series.introduction)
// 키워드는 개별 항목으로 번역 요청하여 N회 호출을 방지한다.
val keywordListForTranslate = keywordList
texts.addAll(keywordListForTranslate)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = languageCode,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedIntroduction = translatedTexts[index++]
val translatedKeywords = if (keywordListForTranslate.isNotEmpty()) {
translatedTexts.subList(index, translatedTexts.size)
} else {
// 번역할 키워드가 없으면 원본 키워드 반환 정책 적용
keywordList
}
val payload = SeriesTranslationPayload(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = translatedKeywords
)
seriesTranslationRepository.save(
SeriesTranslation(
seriesId = seriesId,
locale = locale,
renderedPayload = payload
)
)
val kws = translatedKeywords.ifEmpty { keywordList }
translated = TranslatedSeries(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = kws
)
}
}
}
}

View File

@@ -5,11 +5,12 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.stereotype.Service
@@ -21,7 +22,7 @@ class AudioContentThemeService(
private val contentRepository: AudioContentRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val papagoTranslationService: PapagoTranslationService,
private val langContext: LangContext
) {
@Transactional(readOnly = true)
@@ -50,7 +51,7 @@ class AudioContentThemeService(
/**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
*/
val currentLang = langContext.lang
if (currentLang == Lang.EN || currentLang == Lang.JA) {
@@ -65,14 +66,43 @@ class AudioContentThemeService(
val existingMap = existingTranslations.associateBy { it.contentThemeId }
// 2) 미번역 항목은 조회 스레드에서 번역하지 않고 job만 예약
// 2) 미번역 항목만 수집하여 한 번에 번역 요청
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
untranslatedPairs.forEach { pair ->
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CONTENT_THEME,
resourceId = pair.id,
if (untranslatedPairs.isNotEmpty()) {
val texts = untranslatedPairs.map { it.theme }
val response = papagoTranslationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = "ko",
targetLanguage = targetLocale
)
)
val translatedTexts = response.translatedText
val entitiesToSave = mutableListOf<ContentThemeTranslation>()
// translatedTexts 크기가 다르면 안전하게 원문으로 대체
untranslatedPairs.forEachIndexed { index, pair ->
val translated = translatedTexts.getOrNull(index) ?: pair.theme
entitiesToSave.add(
ContentThemeTranslation(
contentThemeId = pair.id,
locale = targetLocale,
theme = translated
)
)
}
if (entitiesToSave.isNotEmpty()) {
contentThemeTranslationRepository.saveAll(entitiesToSave)
}
// 저장 후 맵을 갱신
entitiesToSave.forEach { entity ->
(existingMap as MutableMap)[entity.contentThemeId] = entity
}
}
// 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)

View File

@@ -19,7 +19,7 @@ class CreatorAdminMemberController(private val service: CreatorAdminMemberServic
fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest)
@PostMapping("/logout")
@PreAuthorize("hasAnyRole('CREATOR', 'AGENT')")
@PreAuthorize("hasRole('CREATOR')")
fun logout(
@RequestHeader("Authorization") token: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?

View File

@@ -1044,11 +1044,6 @@ class SodaMessageSource {
Lang.KO to "",
Lang.EN to "Bot",
Lang.JA to "ボット"
),
"admin.member.role.content_manager" to mapOf(
Lang.KO to "콘텐츠 관리자",
Lang.EN to "Content Manager",
Lang.JA to "コンテンツ管理者"
)
)

View File

@@ -1,6 +1,35 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.category.CategoryRepository
import kr.co.vividnext.sodalive.content.category.CategoryTranslation
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
import org.springframework.context.event.EventListener
import org.springframework.data.repository.findByIdOrNull
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation
@@ -29,7 +58,24 @@ class LanguageTranslationEvent(
@Component
class LanguageTranslationListener(
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private val audioContentRepository: AudioContentRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
private val seriesRepository: AdminContentSeriesRepository,
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
private val categoryRepository: CategoryRepository,
private val categoryTranslationRepository: CategoryTranslationRepository,
private val translationService: PapagoTranslationService
) {
@Async
@EventListener(condition = "!#event.waitTransactionCommit")
@@ -46,6 +92,424 @@ class LanguageTranslationListener(
}
private fun processTranslation(event: LanguageTranslationEvent) {
resourceTranslationJobScheduler.scheduleResourceTranslations(event.targetType, event.id)
when (event.targetType) {
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event)
LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event)
LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event)
LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event)
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageTranslation(event)
}
}
private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) {
val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return
val languageCode = audioContent.languageCode ?: return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val tags = audioContent.audioContentHashTags
.mapNotNull { it.hashTag?.tag }
.joinToString(",")
val texts = mutableListOf<String>()
texts.add(audioContent.title)
texts.add(audioContent.detail)
texts.add(tags)
val sourceLanguage = audioContent.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedDetail = translatedTexts[index++]
val translatedTags = translatedTexts[index]
val payload = ContentTranslationPayload(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
val existing = contentTranslationRepository
.findByContentIdAndLocale(audioContent.id!!, locale)
if (existing == null) {
contentTranslationRepository.save(
ContentTranslation(
contentId = audioContent.id!!,
locale = locale,
renderedPayload = payload
)
)
} else {
existing.renderedPayload = payload
contentTranslationRepository.save(existing)
}
}
}
}
private fun handleCharacterLanguageTranslation(event: LanguageTranslationEvent) {
val character = chatCharacterRepository.findByIdOrNull(event.id) ?: return
val languageCode = character.languageCode ?: return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val personality = character.personalities.firstOrNull()
val background = character.backgrounds.firstOrNull()
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
val texts = mutableListOf<String>()
texts.add(character.name)
texts.add(character.description)
texts.add(character.gender ?: "")
val hasPersonality = personality != null
if (hasPersonality) {
texts.add(personality!!.trait)
texts.add(personality.description)
}
val hasBackground = background != null
if (hasBackground) {
texts.add(background!!.topic)
texts.add(background.description)
}
texts.add(tags)
val sourceLanguage = character.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedName = translatedTexts[index++]
val translatedDescription = translatedTexts[index++]
val translatedGender = translatedTexts[index++]
var translatedPersonality: TranslatedAiCharacterPersonality? = null
if (hasPersonality) {
translatedPersonality = TranslatedAiCharacterPersonality(
trait = translatedTexts[index++],
description = translatedTexts[index++]
)
}
var translatedBackground: TranslatedAiCharacterBackground? = null
if (hasBackground) {
translatedBackground = TranslatedAiCharacterBackground(
topic = translatedTexts[index++],
description = translatedTexts[index++]
)
}
val translatedTags = translatedTexts[index]
val payload = AiCharacterTranslationRenderedPayload(
name = translatedName,
description = translatedDescription,
gender = translatedGender,
personalityTrait = translatedPersonality?.trait ?: "",
personalityDescription = translatedPersonality?.description ?: "",
backgroundTopic = translatedBackground?.topic ?: "",
backgroundDescription = translatedBackground?.description ?: "",
tags = translatedTags
)
val existing = aiCharacterTranslationRepository
.findByCharacterIdAndLocale(character.id!!, locale)
if (existing == null) {
val entity = AiCharacterTranslation(
characterId = character.id!!,
locale = locale,
renderedPayload = payload
)
aiCharacterTranslationRepository.save(entity)
} else {
existing.renderedPayload = payload
aiCharacterTranslationRepository.save(existing)
}
}
}
}
private fun handleContentThemeLanguageTranslation(event: LanguageTranslationEvent) {
val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(event.id) ?: return
val sourceLanguage = "ko"
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
val texts = mutableListOf<String>()
texts.add(contentTheme.theme)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
val translatedTheme = translatedTexts[0]
val existing = contentThemeTranslationRepository
.findByContentThemeIdAndLocale(contentTheme.id!!, locale)
if (existing == null) {
contentThemeTranslationRepository.save(
ContentThemeTranslation(
contentThemeId = contentTheme.id!!,
locale = locale,
theme = translatedTheme
)
)
} else {
existing.theme = translatedTheme
contentThemeTranslationRepository.save(existing)
}
}
}
}
private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) {
val series = seriesRepository.findByIdOrNull(event.id) ?: return
val languageCode = series.languageCode ?: return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val keywords = series.keywordList
.mapNotNull { it.keyword?.tag }
.joinToString(", ")
val texts = mutableListOf<String>()
texts.add(series.title)
texts.add(series.introduction)
texts.add(keywords)
val sourceLanguage = series.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedIntroduction = translatedTexts[index++]
val translatedKeywordsJoined = translatedTexts[index]
val translatedKeywords = translatedKeywordsJoined
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
val payload = SeriesTranslationPayload(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = translatedKeywords
)
val existing = seriesTranslationRepository
.findBySeriesIdAndLocale(series.id!!, locale)
if (existing == null) {
seriesTranslationRepository.save(
SeriesTranslation(
seriesId = series.id!!,
locale = locale,
renderedPayload = payload
)
)
} else {
existing.renderedPayload = payload
seriesTranslationRepository.save(existing)
}
}
}
}
private fun handleSeriesGenreLanguageTranslation(event: LanguageTranslationEvent) {
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(event.id) ?: return
val sourceLanguage = "ko"
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
val texts = mutableListOf<String>()
texts.add(seriesGenre.genre)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
val translatedGenre = translatedTexts[0]
val existing = seriesGenreTranslationRepository
.findBySeriesGenreIdAndLocale(seriesGenre.id!!, locale)
if (existing == null) {
seriesGenreTranslationRepository.save(
SeriesGenreTranslation(
seriesGenreId = seriesGenre.id!!,
locale = locale,
genre = translatedGenre
)
)
} else {
existing.genre = translatedGenre
seriesGenreTranslationRepository.save(existing)
}
}
}
}
private fun handleOriginalWorkLanguageTranslation(event: LanguageTranslationEvent) {
val originalWork = originalWorkRepository.findByIdOrNull(event.id) ?: return
val languageCode = originalWork.languageCode ?: return
/**
* handleSeriesLanguageTranslation 참조하여 원작 번역 구현
*
* originalWorkTranslationRepository
*
* 번역대상
* - title
* - contentType
* - category
* - description
* - tags
*/
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val tagsJoined = originalWork.tagMappings
.mapNotNull { it.tag.tag }
.joinToString(", ")
val texts = mutableListOf<String>()
texts.add(originalWork.title)
texts.add(originalWork.contentType)
texts.add(originalWork.category)
texts.add(originalWork.description)
texts.add(tagsJoined)
val sourceLanguage = originalWork.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedContentType = translatedTexts[index++]
val translatedCategory = translatedTexts[index++]
val translatedDescription = translatedTexts[index++]
val translatedTagsJoined = translatedTexts[index]
val translatedTags = translatedTagsJoined
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
val payload = OriginalWorkTranslationPayload(
title = translatedTitle,
contentType = translatedContentType,
category = translatedCategory,
description = translatedDescription,
tags = translatedTags
)
val existing = originalWorkTranslationRepository
.findByOriginalWorkIdAndLocale(originalWork.id!!, locale)
if (existing == null) {
originalWorkTranslationRepository.save(
OriginalWorkTranslation(
originalWorkId = originalWork.id!!,
locale = locale,
renderedPayload = payload
)
)
} else {
existing.renderedPayload = payload
originalWorkTranslationRepository.save(existing)
}
}
}
}
private fun handleCreatorContentCategoryLanguageTranslation(event: LanguageTranslationEvent) {
val category = categoryRepository.findByIdOrNull(event.id)
if (category == null || !category.isActive || category.languageCode.isNullOrBlank()) return
val sourceLanguage = category.languageCode ?: "ko"
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
val texts = mutableListOf<String>()
texts.add(category.title)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
val translatedCategory = translatedTexts[0]
val existing = categoryTranslationRepository
.findByCategoryIdAndLocale(category.id!!, locale)
if (existing == null) {
categoryTranslationRepository.save(
CategoryTranslation(
categoryId = category.id!!,
locale = locale,
category = translatedCategory
)
)
} else {
existing.category = translatedCategory
categoryTranslationRepository.save(existing)
}
}
}
}
}

View File

@@ -15,16 +15,12 @@ class PapagoTranslationService(
@Value("\${cloud.naver.papago-client-secret}")
private val papagoClientSecret: String
) : TranslationProvider {
) {
private val restTemplate: RestTemplate = RestTemplate()
private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation"
override val providerName: String = "papago"
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
fun translate(request: TranslateRequest): TranslateResult {
if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) {
return TranslateResult(emptyList())
}

View File

@@ -1,39 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
import org.springframework.stereotype.Service
@Service
class ResourceTranslationJobScheduler(
private val sourceExtractor: TranslationSourceExtractor,
private val translationJobScheduler: TranslationJobScheduler
) {
fun scheduleResourceTranslations(resourceType: LanguageTranslationTargetType, resourceId: Long) {
val source = sourceExtractor.extract(resourceType, resourceId) ?: return
getTranslatableLanguageCodes(source.sourceLanguage).forEach { targetLanguage ->
scheduleSource(source, targetLanguage)
}
}
fun scheduleResourceTranslation(
resourceType: LanguageTranslationTargetType,
resourceId: Long,
targetLanguage: String
) {
val source = sourceExtractor.extract(resourceType, resourceId) ?: return
scheduleSource(source, targetLanguage)
}
private fun scheduleSource(source: TranslationSource, targetLanguage: String) {
source.fields.forEach { field ->
translationJobScheduler.scheduleMissingTranslation(
resourceType = source.resourceType,
resourceId = source.resourceId,
fieldKey = field.fieldKey,
sourceText = field.sourceText,
sourceLanguage = source.sourceLanguage,
targetLanguage = targetLanguage
)
}
}
}

View File

@@ -1,23 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import java.security.MessageDigest
import java.text.Normalizer
object SourceTextNormalizer {
const val NORMALIZATION_VERSION = "v1"
private val whitespaceRegex = Regex("\\s+")
fun normalize(sourceText: String): String {
return Normalizer.normalize(sourceText, Normalizer.Form.NFC)
.replace(whitespaceRegex, " ")
.trim()
}
fun hash(sourceText: String): String {
val normalized = normalize(sourceText)
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }
}
}

View File

@@ -1,64 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.common.BaseEntity
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Table
import javax.persistence.UniqueConstraint
enum class TranslationJobStatus {
PENDING,
RUNNING,
COMPLETED,
FAILED
}
@Entity
@Table(
name = "translation_job",
uniqueConstraints = [
UniqueConstraint(
name = "uk_translation_job_resource_field_target_hash",
columnNames = ["resource_type", "resource_id", "field_key", "target_language", "source_hash"]
)
]
)
class TranslationJob(
@Enumerated(EnumType.STRING)
@Column(name = "resource_type", nullable = false, length = 50)
val resourceType: LanguageTranslationTargetType,
@Column(name = "resource_id", nullable = false)
val resourceId: Long,
@Column(name = "field_key", nullable = false, length = 80)
val fieldKey: String,
@Column(name = "source_hash", nullable = false, length = 64)
val sourceHash: String,
@Column(name = "source_text", nullable = false, columnDefinition = "text")
val sourceText: String,
@Column(name = "source_language", nullable = false, length = 10)
val sourceLanguage: String,
@Column(name = "target_language", nullable = false, length = 10)
val targetLanguage: String,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
var status: TranslationJobStatus = TranslationJobStatus.PENDING,
@Column(name = "retry_count", nullable = false)
var retryCount: Int = 0,
@Column(name = "last_error_message", columnDefinition = "text")
var lastErrorMessage: String? = null,
@Column(name = "next_retry_at", nullable = false)
var nextRetryAt: LocalDateTime = LocalDateTime.now()
) : BaseEntity()

View File

@@ -1,58 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
interface TranslationJobRepository : JpaRepository<TranslationJob, Long> {
fun findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
resourceType: LanguageTranslationTargetType,
resourceId: Long,
fieldKey: String,
targetLanguage: String,
sourceHash: String
): TranslationJob?
@Query(
"""
select j from TranslationJob j
where j.resourceType = :resourceType
and j.resourceId = :resourceId
and j.fieldKey = :fieldKey
and j.targetLanguage = :targetLanguage
and j.sourceHash = :sourceHash
and j.status in (kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus.PENDING, kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus.RUNNING)
"""
)
fun findActiveJob(
@Param("resourceType") resourceType: LanguageTranslationTargetType,
@Param("resourceId") resourceId: Long,
@Param("fieldKey") fieldKey: String,
@Param("targetLanguage") targetLanguage: String,
@Param("sourceHash") sourceHash: String
): TranslationJob?
@Query(
value = """
select j.id
from translation_job j
join (
select resource_type, resource_id, target_language
from translation_job
where status = 'PENDING'
and next_retry_at <= :now
order by created_at asc
limit 1
) g on j.resource_type = g.resource_type
and j.resource_id = g.resource_id
and j.target_language = g.target_language
where j.status = 'PENDING'
and j.next_retry_at <= :now
order by j.created_at asc
for update skip locked
""",
nativeQuery = true
)
fun findNextPendingGroupJobIdsForUpdate(@Param("now") now: LocalDateTime): List<Long>
}

View File

@@ -1,56 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class TranslationJobScheduler(
private val translationJobRepository: TranslationJobRepository
) {
@Transactional
fun scheduleMissingTranslation(
resourceType: LanguageTranslationTargetType,
resourceId: Long,
fieldKey: String,
sourceText: String,
sourceLanguage: String,
targetLanguage: String
) {
val normalizedText = SourceTextNormalizer.normalize(sourceText)
if (normalizedText.isBlank()) return
val normalizedSourceLanguage = sourceLanguage.lowercase()
if (!SUPPORTED_SOURCE_LANGUAGE_CODES.contains(normalizedSourceLanguage)) return
val normalizedTargetLanguage = targetLanguage.lowercase()
if (normalizedSourceLanguage == normalizedTargetLanguage) return
val sourceHash = SourceTextNormalizer.hash(normalizedText)
val existingJob = translationJobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
resourceType = resourceType,
resourceId = resourceId,
fieldKey = fieldKey,
targetLanguage = normalizedTargetLanguage,
sourceHash = sourceHash
)
if (existingJob != null) return
translationJobRepository.save(
TranslationJob(
resourceType = resourceType,
resourceId = resourceId,
fieldKey = fieldKey,
sourceHash = sourceHash,
sourceText = normalizedText,
sourceLanguage = normalizedSourceLanguage,
targetLanguage = normalizedTargetLanguage,
nextRetryAt = LocalDateTime.now()
)
)
}
companion object {
private val SUPPORTED_SOURCE_LANGUAGE_CODES = setOf("ko", "en", "ja")
}
}

View File

@@ -1,155 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
@Component
class TranslationJobWorker(
private val translationJobRepository: TranslationJobRepository,
private val translationMemoryRepository: TranslationMemoryRepository,
private val translationProvider: TranslationProvider,
private val materializer: TranslationReadModelMaterializer,
transactionManager: PlatformTransactionManager
) {
private val log = LoggerFactory.getLogger(javaClass)
private val transactionTemplate = TransactionTemplate(transactionManager)
@Scheduled(fixedDelayString = "\${sodalive.translation-job.fixed-delay-ms:600000}")
fun runPendingJobs() {
repeat(MAX_GROUPS_PER_TICK) {
if (!processNextGroup()) return
}
}
fun processNextGroup(): Boolean {
val jobs = claimNextGroup()
if (jobs.isEmpty()) return false
val firstJob = jobs.first()
val succeededJobs = mutableListOf<TranslationJob>()
val failedJobs = mutableListOf<Pair<TranslationJob, Exception>>()
jobs.forEach { job ->
try {
ensureMemory(job)
succeededJobs.add(job)
} catch (ex: Exception) {
failedJobs.add(job to ex)
}
}
if (failedJobs.isNotEmpty()) {
succeededJobs.forEach { completeJob(it.id!!) }
failedJobs.forEach { (job, ex) -> failJob(job.id!!, ex) }
return true
}
try {
materializer.materialize(firstJob.resourceType, firstJob.resourceId, firstJob.targetLanguage)
succeededJobs.forEach { completeJob(it.id!!) }
} catch (ex: Exception) {
succeededJobs.forEach { failJob(it.id!!, ex) }
}
return true
}
private fun claimNextGroup(): List<TranslationJob> {
return transactionTemplate.execute {
val jobIds = translationJobRepository.findNextPendingGroupJobIdsForUpdate(LocalDateTime.now())
jobIds.mapNotNull { jobId ->
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@mapNotNull null
job.status = TranslationJobStatus.RUNNING
translationJobRepository.save(job)
job
}
}.orEmpty()
}
private fun ensureMemory(job: TranslationJob) {
val existing = translationMemoryRepository
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
sourceHash = job.sourceHash,
sourceLanguage = job.sourceLanguage,
targetLanguage = job.targetLanguage,
provider = translationProvider.providerName,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
if (existing != null) return
val response = translationProvider.translate(
TranslateRequest(
texts = listOf(job.sourceText),
sourceLanguage = job.sourceLanguage,
targetLanguage = job.targetLanguage
)
)
val translated = response.translatedText.firstOrNull()?.takeIf { it.isNotBlank() }
?: throw IllegalStateException("empty translation result")
transactionTemplate.executeWithoutResult {
val memory = translationMemoryRepository
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
sourceHash = job.sourceHash,
sourceLanguage = job.sourceLanguage,
targetLanguage = job.targetLanguage,
provider = translationProvider.providerName,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
if (memory == null) {
translationMemoryRepository.save(
TranslationMemory(
sourceHash = job.sourceHash,
sourceText = job.sourceText,
sourceLanguage = job.sourceLanguage,
targetLanguage = job.targetLanguage,
translatedText = translated,
provider = translationProvider.providerName,
providerVersion = translationProvider.providerVersion
)
)
}
}
}
private fun completeJob(jobId: Long) {
transactionTemplate.executeWithoutResult {
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult
job.status = TranslationJobStatus.COMPLETED
job.lastErrorMessage = null
translationJobRepository.save(job)
}
}
private fun failJob(jobId: Long, ex: Exception) {
log.warn("Failed to process translation job. jobId={}, error={}", jobId, ex.message)
transactionTemplate.executeWithoutResult {
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult
job.retryCount += 1
job.lastErrorMessage = ex.message?.take(MAX_ERROR_LENGTH)
if (job.retryCount >= MAX_RETRY_COUNT) {
job.status = TranslationJobStatus.FAILED
} else {
job.status = TranslationJobStatus.PENDING
job.nextRetryAt = LocalDateTime.now().plusMinutes(backoffMinutes(job.retryCount))
}
translationJobRepository.save(job)
}
}
private fun backoffMinutes(retryCount: Int): Long {
return when (retryCount) {
1 -> 1L
2 -> 5L
else -> 15L
}
}
companion object {
private const val MAX_GROUPS_PER_TICK = 5
private const val MAX_ERROR_LENGTH = 1000
private const val MAX_RETRY_COUNT = 3
}
}

View File

@@ -1,43 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "translation_memory",
uniqueConstraints = [
UniqueConstraint(
name = "uk_translation_memory_source_target_provider",
columnNames = ["source_hash", "source_language", "target_language", "provider", "normalization_version"]
)
]
)
class TranslationMemory(
@Column(name = "source_hash", nullable = false, length = 64)
val sourceHash: String,
@Column(name = "source_text", nullable = false, columnDefinition = "text")
val sourceText: String,
@Column(name = "source_language", nullable = false, length = 10)
val sourceLanguage: String,
@Column(name = "target_language", nullable = false, length = 10)
val targetLanguage: String,
@Column(name = "translated_text", nullable = false, columnDefinition = "text")
val translatedText: String,
@Column(name = "provider", nullable = false, length = 50)
val provider: String,
@Column(name = "provider_version", nullable = false, length = 50)
val providerVersion: String,
@Column(name = "normalization_version", nullable = false, length = 20)
val normalizationVersion: String = SourceTextNormalizer.NORMALIZATION_VERSION
) : BaseEntity()

View File

@@ -1,13 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.springframework.data.jpa.repository.JpaRepository
interface TranslationMemoryRepository : JpaRepository<TranslationMemory, Long> {
fun findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
sourceHash: String,
sourceLanguage: String,
targetLanguage: String,
provider: String,
normalizationVersion: String
): TranslationMemory?
}

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
interface TranslationProvider {
val providerName: String
val providerVersion: String
fun translate(request: TranslateRequest): TranslateResult
}

View File

@@ -1,186 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.content.category.CategoryTranslation
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class TranslationReadModelMaterializer(
private val sourceExtractor: TranslationSourceExtractor,
private val translationMemoryRepository: TranslationMemoryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
private val categoryTranslationRepository: CategoryTranslationRepository
) {
@Transactional
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
val source = sourceExtractor.extract(resourceType, resourceId) ?: return false
val translations = resolveTranslatedFields(source, targetLanguage.lowercase()) ?: return false
when (resourceType) {
LanguageTranslationTargetType.CONTENT -> upsertContent(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.CHARACTER -> upsertCharacter(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.CONTENT_THEME -> upsertContentTheme(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.SERIES -> upsertSeries(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
}
return true
}
private fun resolveTranslatedFields(source: TranslationSource, targetLanguage: String): Map<String, String>? {
val result = mutableMapOf<String, String>()
source.fields.forEach { field ->
val normalizedText = SourceTextNormalizer.normalize(field.sourceText)
if (normalizedText.isBlank()) {
result[field.fieldKey] = ""
return@forEach
}
val memory = translationMemoryRepository
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
sourceHash = SourceTextNormalizer.hash(normalizedText),
sourceLanguage = source.sourceLanguage.lowercase(),
targetLanguage = targetLanguage,
provider = DEFAULT_PROVIDER,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
) ?: return null
result[field.fieldKey] = memory.translatedText
}
return result
}
private fun upsertContent(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
val payload = ContentTranslationPayload(
title = translations["title"].orEmpty(),
detail = translations["detail"].orEmpty(),
tags = translations["tags"].orEmpty()
)
val existing = contentTranslationRepository.findByContentIdAndLocale(resourceId, targetLanguage)
if (existing == null) {
contentTranslationRepository.save(ContentTranslation(resourceId, targetLanguage, payload))
} else {
existing.renderedPayload = payload
contentTranslationRepository.save(existing)
}
}
private fun upsertCharacter(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
val payload = AiCharacterTranslationRenderedPayload(
name = translations["name"].orEmpty(),
description = translations["description"].orEmpty(),
gender = translations["gender"].orEmpty(),
personalityTrait = translations["personalityTrait"].orEmpty(),
personalityDescription = translations["personalityDescription"].orEmpty(),
backgroundTopic = translations["backgroundTopic"].orEmpty(),
backgroundDescription = translations["backgroundDescription"].orEmpty(),
tags = translations["tags"].orEmpty()
)
val existing = aiCharacterTranslationRepository.findByCharacterIdAndLocale(resourceId, targetLanguage)
if (existing == null) {
aiCharacterTranslationRepository.save(AiCharacterTranslation(resourceId, targetLanguage, payload))
} else {
existing.renderedPayload = payload
aiCharacterTranslationRepository.save(existing)
}
}
private fun upsertContentTheme(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
val theme = translations["theme"].orEmpty()
val existing = contentThemeTranslationRepository.findByContentThemeIdAndLocale(resourceId, targetLanguage)
if (existing == null) {
contentThemeTranslationRepository.save(ContentThemeTranslation(resourceId, targetLanguage, theme))
} else {
existing.theme = theme
contentThemeTranslationRepository.save(existing)
}
}
private fun upsertSeries(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
val payload = SeriesTranslationPayload(
title = translations["title"].orEmpty(),
introduction = translations["introduction"].orEmpty(),
keywords = translations["keywords"].orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
)
val existing = seriesTranslationRepository.findBySeriesIdAndLocale(resourceId, targetLanguage)
if (existing == null) {
seriesTranslationRepository.save(SeriesTranslation(resourceId, targetLanguage, payload))
} else {
existing.renderedPayload = payload
seriesTranslationRepository.save(existing)
}
}
private fun upsertSeriesGenre(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
val genre = translations["genre"].orEmpty()
val existing = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(resourceId, targetLanguage)
if (existing == null) {
seriesGenreTranslationRepository.save(SeriesGenreTranslation(resourceId, targetLanguage, genre))
} else {
existing.genre = genre
seriesGenreTranslationRepository.save(existing)
}
}
private fun upsertOriginalWork(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
val payload = OriginalWorkTranslationPayload(
title = translations["title"].orEmpty(),
contentType = translations["contentType"].orEmpty(),
category = translations["category"].orEmpty(),
description = translations["description"].orEmpty(),
tags = translations["tags"].orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
)
val existing = originalWorkTranslationRepository.findByOriginalWorkIdAndLocale(resourceId, targetLanguage)
if (existing == null) {
originalWorkTranslationRepository.save(OriginalWorkTranslation(resourceId, targetLanguage, payload))
} else {
existing.renderedPayload = payload
originalWorkTranslationRepository.save(existing)
}
}
private fun upsertCategory(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
val category = translations["category"].orEmpty()
val existing = categoryTranslationRepository.findByCategoryIdAndLocale(resourceId, targetLanguage)
if (existing == null) {
categoryTranslationRepository.save(CategoryTranslation(resourceId, targetLanguage, category))
} else {
existing.category = category
categoryTranslationRepository.save(existing)
}
}
companion object {
const val DEFAULT_PROVIDER = "papago"
}
}

View File

@@ -1,155 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.category.CategoryRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
data class TranslationSourceField(
val fieldKey: String,
val sourceText: String
)
data class TranslationSource(
val resourceType: LanguageTranslationTargetType,
val resourceId: Long,
val sourceLanguage: String,
val fields: List<TranslationSourceField>
)
@Component
class TranslationSourceExtractor(
private val audioContentRepository: AudioContentRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
private val seriesRepository: AdminContentSeriesRepository,
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val categoryRepository: CategoryRepository
) {
fun extract(resourceType: LanguageTranslationTargetType, resourceId: Long): TranslationSource? {
return when (resourceType) {
LanguageTranslationTargetType.CONTENT -> extractContent(resourceId)
LanguageTranslationTargetType.CHARACTER -> extractCharacter(resourceId)
LanguageTranslationTargetType.CONTENT_THEME -> extractContentTheme(resourceId)
LanguageTranslationTargetType.SERIES -> extractSeries(resourceId)
LanguageTranslationTargetType.SERIES_GENRE -> extractSeriesGenre(resourceId)
LanguageTranslationTargetType.ORIGINAL_WORK -> extractOriginalWork(resourceId)
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> extractCategory(resourceId)
}
}
private fun extractContent(resourceId: Long): TranslationSource? {
val content = audioContentRepository.findByIdOrNull(resourceId) ?: return null
val sourceLanguage = content.languageCode?.takeIf { it.isNotBlank() } ?: return null
val tags = content.audioContentHashTags
.mapNotNull { it.hashTag?.tag }
.joinToString(",")
return TranslationSource(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = resourceId,
sourceLanguage = sourceLanguage,
fields = listOf(
TranslationSourceField("title", content.title),
TranslationSourceField("detail", content.detail),
TranslationSourceField("tags", tags)
)
)
}
private fun extractCharacter(resourceId: Long): TranslationSource? {
val character = chatCharacterRepository.findByIdOrNull(resourceId) ?: return null
val sourceLanguage = character.languageCode?.takeIf { it.isNotBlank() } ?: return null
val personality = character.personalities.firstOrNull()
val background = character.backgrounds.firstOrNull()
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
return TranslationSource(
resourceType = LanguageTranslationTargetType.CHARACTER,
resourceId = resourceId,
sourceLanguage = sourceLanguage,
fields = listOf(
TranslationSourceField("name", character.name),
TranslationSourceField("description", character.description),
TranslationSourceField("gender", character.gender ?: ""),
TranslationSourceField("personalityTrait", personality?.trait ?: ""),
TranslationSourceField("personalityDescription", personality?.description ?: ""),
TranslationSourceField("backgroundTopic", background?.topic ?: ""),
TranslationSourceField("backgroundDescription", background?.description ?: ""),
TranslationSourceField("tags", tags)
)
)
}
private fun extractContentTheme(resourceId: Long): TranslationSource? {
val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(resourceId) ?: return null
return TranslationSource(
resourceType = LanguageTranslationTargetType.CONTENT_THEME,
resourceId = resourceId,
sourceLanguage = "ko",
fields = listOf(TranslationSourceField("theme", contentTheme.theme))
)
}
private fun extractSeries(resourceId: Long): TranslationSource? {
val series = seriesRepository.findByIdOrNull(resourceId) ?: return null
val sourceLanguage = series.languageCode?.takeIf { it.isNotBlank() } ?: return null
val keywords = series.keywordList
.mapNotNull { it.keyword?.tag }
.joinToString(", ")
return TranslationSource(
resourceType = LanguageTranslationTargetType.SERIES,
resourceId = resourceId,
sourceLanguage = sourceLanguage,
fields = listOf(
TranslationSourceField("title", series.title),
TranslationSourceField("introduction", series.introduction),
TranslationSourceField("keywords", keywords)
)
)
}
private fun extractSeriesGenre(resourceId: Long): TranslationSource? {
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(resourceId) ?: return null
return TranslationSource(
resourceType = LanguageTranslationTargetType.SERIES_GENRE,
resourceId = resourceId,
sourceLanguage = "ko",
fields = listOf(TranslationSourceField("genre", seriesGenre.genre))
)
}
private fun extractOriginalWork(resourceId: Long): TranslationSource? {
val originalWork = originalWorkRepository.findByIdOrNull(resourceId) ?: return null
val sourceLanguage = originalWork.languageCode?.takeIf { it.isNotBlank() } ?: return null
val tags = originalWork.tagMappings.joinToString(", ") { it.tag.tag }
return TranslationSource(
resourceType = LanguageTranslationTargetType.ORIGINAL_WORK,
resourceId = resourceId,
sourceLanguage = sourceLanguage,
fields = listOf(
TranslationSourceField("title", originalWork.title),
TranslationSourceField("contentType", originalWork.contentType),
TranslationSourceField("category", originalWork.category),
TranslationSourceField("description", originalWork.description),
TranslationSourceField("tags", tags)
)
)
}
private fun extractCategory(resourceId: Long): TranslationSource? {
val category = categoryRepository.findByIdOrNull(resourceId) ?: return null
val sourceLanguage = category.languageCode?.takeIf { it.isNotBlank() } ?: return null
if (!category.isActive) return null
return TranslationSource(
resourceType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY,
resourceId = resourceId,
sourceLanguage = sourceLanguage,
fields = listOf(TranslationSourceField("category", category.title))
)
}
}

View File

@@ -177,7 +177,7 @@ enum class Gender {
}
enum class MemberRole {
ADMIN, BOT, USER, CREATOR, AGENT, CONTENT_MANAGER
ADMIN, BOT, USER, CREATOR, AGENT
}
enum class MemberProvider {

View File

@@ -252,7 +252,7 @@ class MemberService(
gender = gender,
signupDate = signUpDate,
chargeCount = chargeCount,
role = if (member.role == MemberRole.CREATOR) MemberRole.CREATOR else MemberRole.USER,
role = member.role,
messageNotice = member.notification?.message,
followingChannelLiveNotice = member.notification?.live,
followingChannelUploadContentNotice = member.notification?.uploadContent,

View File

@@ -2,7 +2,7 @@ package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L)
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
fun resolveCountryCodeWithForcedMapping(member: Member, requestCountryCode: String?): String {

View File

@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/menu")
class MenuController(private val service: MenuService) {
@GetMapping
@PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR', 'CONTENT_MANAGER')")
@PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR')")
fun getMenus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {

View File

@@ -23,9 +23,6 @@ payverse:
usdMid: ${PAYVERSE_USD_MID}
usdClientKey: ${PAYVERSE_USD_CLIENT_KEY}
usdSecretKey: ${PAYVERSE_USD_SECRET_KEY}
jpyMid: ${PAYVERSE_JPY_MID}
jpyClientKey: ${PAYVERSE_JPY_CLIENT_KEY}
jpySecretKey: ${PAYVERSE_JPY_SECRET_KEY}
bootpay:
applicationId: ${BOOTPAY_APPLICATION_ID}

View File

@@ -1,184 +0,0 @@
package kr.co.vividnext.sodalive.admin.content
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.main.tab.GetContentMainTabItem
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import javax.servlet.http.HttpServletResponse
@WebMvcTest(AdminContentController::class)
@Import(AdminContentControllerSecurityTest.TestSecurityConfig::class)
class AdminContentControllerSecurityTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var service: AdminContentService
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@TestConfiguration
@EnableGlobalMethodSecurity(prePostEnabled = true)
class TestSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
.and()
.build()
}
}
@Test
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 목록 조회에 성공한다")
fun shouldAllowContentManagerRoleForContentList() {
Mockito.`when`(
service.getAudioContentList(
status = ContentReleaseStatus.OPEN,
pageable = PageRequest.of(0, 20)
)
).thenReturn(GetAdminContentListResponse(totalCount = 0, items = emptyList()))
mockMvc.perform(
get("/admin/audio-content/list")
.param("page", "0")
.param("size", "20")
.with(user("content-manager").roles("CONTENT_MANAGER"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(0))
}
@Test
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 검색에 성공한다")
fun shouldAllowContentManagerRoleForContentSearch() {
Mockito.`when`(
service.searchAudioContent(
status = ContentReleaseStatus.OPEN,
searchWord = "title",
pageable = PageRequest.of(0, 20)
)
).thenReturn(GetAdminContentListResponse(totalCount = 0, items = emptyList()))
mockMvc.perform(
get("/admin/audio-content/search")
.param("search_word", "title")
.param("page", "0")
.param("size", "20")
.with(user("content-manager").roles("CONTENT_MANAGER"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
}
@Test
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 메인 탭 조회에 성공한다")
fun shouldAllowContentManagerRoleForContentMainTab() {
Mockito.`when`(service.getContentMainTabList()).thenReturn(
listOf(GetContentMainTabItem(tabId = 1L, title = ""))
)
mockMvc.perform(
get("/admin/audio-content/main/tab")
.with(user("content-manager").roles("CONTENT_MANAGER"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data[0].tabId").value(1L))
}
@Test
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 수정에 접근할 수 없다")
fun shouldRejectContentManagerRoleForContentUpdate() {
val requestPart = MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"{\"id\":1,\"isDefaultCoverImage\":false}".toByteArray()
)
mockMvc.perform(
multipart("/admin/audio-content")
.file(requestPart)
.with { request ->
request.method = "PUT"
request
}
.with(user("content-manager").roles("CONTENT_MANAGER"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("관리자 권한이면 관리자 콘텐츠 수정에 접근할 수 있다")
fun shouldAllowAdminRoleForContentUpdate() {
val requestPart = MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"{\"id\":1,\"isDefaultCoverImage\":false}".toByteArray()
)
mockMvc.perform(
multipart("/admin/audio-content")
.file(requestPart)
.with { request ->
request.method = "PUT"
request
}
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
}
@Test
@DisplayName("익명 사용자는 관리자 콘텐츠 목록 조회에 접근할 수 없다")
fun shouldRejectAnonymousUserForContentList() {
mockMvc.perform(
get("/admin/audio-content/list")
.param("page", "0")
.param("size", "20")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
}

View File

@@ -1,66 +0,0 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.login.LoginRequest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
class AdminMemberLoginControllerTest {
private lateinit var service: AdminMemberLoginService
private lateinit var controller: AdminMemberLoginController
private lateinit var mockMvc: MockMvc
@BeforeEach
fun setup() {
service = mock()
controller = AdminMemberLoginController(service = service)
mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
}
@Test
@DisplayName("POST /admin/member/login은 token과 role을 응답한다")
fun shouldReturnTokenAndRole() {
val request = LoginRequest(email = "admin@test.com", password = "password")
val loginResponse = AdminMemberLoginResponse(token = "admin-token", role = MemberRole.ADMIN)
Mockito.`when`(service.login(request)).thenReturn(loginResponse)
val response = controller.login(request)
assertTrue(response.success)
assertEquals("admin-token", response.data?.token)
assertEquals(MemberRole.ADMIN, response.data?.role)
}
@Test
@DisplayName("POST /admin/member/login은 JSON으로 token과 role을 응답한다")
fun shouldReturnTokenAndRoleJson() {
val request = LoginRequest(email = "content@test.com", password = "password")
Mockito.`when`(service.login(request)).thenReturn(
AdminMemberLoginResponse(token = "content-token", role = MemberRole.CONTENT_MANAGER)
)
mockMvc.perform(
post("/admin/member/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"email":"content@test.com","password":"password"}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.token").value("content-token"))
.andExpect(jsonPath("$.data.role").value("CONTENT_MANAGER"))
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -1,98 +0,0 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.security.crypto.password.PasswordEncoder
class AdminMemberLoginServiceTest {
private lateinit var repository: AdminMemberRepository
private lateinit var passwordEncoder: PasswordEncoder
private lateinit var tokenRepository: MemberTokenRepository
private lateinit var service: AdminMemberLoginService
@BeforeEach
fun setup() {
repository = mock()
passwordEncoder = mock()
tokenRepository = mock()
val tokenProvider = TokenProvider(
secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
tokenValidityInSeconds = 3600,
repository = mock<MemberRepository>(),
tokenRepository = tokenRepository
)
tokenProvider.afterPropertiesSet()
service = AdminMemberLoginService(
repository = repository,
passwordEncoder = passwordEncoder,
tokenProvider = tokenProvider
)
}
@Test
@DisplayName("관리자는 관리자 로그인 API로 token과 role을 받는다")
fun shouldLoginAdmin() {
val member = createMember(id = 1L, role = MemberRole.ADMIN)
Mockito.`when`(repository.findByEmail("admin@test.com")).thenReturn(member)
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
val response = service.login(LoginRequest(email = "admin@test.com", password = "password"))
assertTrue(response.token.isNotBlank())
assertEquals(MemberRole.ADMIN, response.role)
}
@Test
@DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다")
fun shouldLoginContentManager() {
val member = createMember(id = 2L, role = MemberRole.CONTENT_MANAGER)
Mockito.`when`(repository.findByEmail("content@test.com")).thenReturn(member)
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
val response = service.login(LoginRequest(email = "content@test.com", password = "password"))
assertTrue(response.token.isNotBlank())
assertEquals(MemberRole.CONTENT_MANAGER, response.role)
}
@Test
@DisplayName("일반 사용자는 관리자 로그인 API를 사용할 수 없다")
fun shouldRejectUser() {
val member = createMember(id = 3L, role = MemberRole.USER)
Mockito.`when`(repository.findByEmail("user@test.com")).thenReturn(member)
val exception = assertThrows(SodaException::class.java) {
service.login(LoginRequest(email = "user@test.com", password = "password"))
}
assertEquals("common.error.bad_credentials", exception.messageKey)
Mockito.verifyNoInteractions(tokenRepository)
}
private fun createMember(id: Long, role: MemberRole): Member {
val member = Member(
email = "member$id@test.com",
password = "encoded-password",
nickname = "member$id",
role = role
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -26,7 +26,7 @@ class ChatCharacterControllerTest {
private val chatRoomService = Mockito.mock(ChatRoomService::class.java)
private val characterCommentService = Mockito.mock(CharacterCommentService::class.java)
private val curationQueryService = Mockito.mock(CharacterCurationQueryService::class.java)
private val resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
private val translationService = Mockito.mock(PapagoTranslationService::class.java)
private val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
@@ -36,7 +36,7 @@ class ChatCharacterControllerTest {
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
@@ -73,7 +73,7 @@ class ChatCharacterControllerTest {
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = LangContext().apply { setLang(Lang.EN) },
memberContentPreferenceService = memberContentPreferenceService,

View File

@@ -1,240 +0,0 @@
package kr.co.vividnext.sodalive.chat.quota.room
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class ChatRoomQuotaControllerTest {
private lateinit var chatRoomRepository: ChatRoomRepository
private lateinit var participantRepository: ChatParticipantRepository
private lateinit var chatRoomQuotaService: ChatRoomQuotaService
private lateinit var chatQuotaService: ChatQuotaService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var controller: ChatRoomQuotaController
@BeforeEach
fun setup() {
chatRoomRepository = Mockito.mock(ChatRoomRepository::class.java)
participantRepository = Mockito.mock(ChatParticipantRepository::class.java)
chatRoomQuotaService = Mockito.mock(ChatRoomQuotaService::class.java)
chatQuotaService = Mockito.mock(ChatQuotaService::class.java)
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
controller = ChatRoomQuotaController(
chatRoomRepository = chatRoomRepository,
participantRepository = participantRepository,
chatRoomQuotaService = chatRoomQuotaService,
chatQuotaService = chatQuotaService,
memberContentPreferenceService = memberContentPreferenceService
)
}
@Test
@DisplayName("캔 충전 요청은 선택한 캔 옵션으로 서비스에 전달된다")
fun shouldDelegateCanPurchaseWithSelectedCanOption() {
val member = createMember(id = 7L, nickname = "user")
val room = createRoom(id = 101L)
val character = createCharacter(id = 202L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_20,
container = "aos"
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(50, null, 10, 40))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos",
chargeType = ChatRoomQuotaChargeType.CAN,
canOption = ChatRoomQuotaCanOption.CAN_20
)
)
assertEquals(true, response.success)
assertEquals(50, response.data!!.totalRemaining)
assertEquals(40, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_20,
container = "aos"
)
}
@Test
@DisplayName("container만 있는 기존 요청은 기본적으로 10캔 충전으로 처리한다")
fun shouldFallbackToCan10WhenOnlyContainerIsProvided() {
val member = createMember(id = 17L, nickname = "legacy")
val room = createRoom(id = 301L)
val character = createCharacter(id = 402L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_10,
container = "aos"
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(25, null, 10, 15))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos"
)
)
assertEquals(true, response.success)
assertEquals(25, response.data!!.totalRemaining)
assertEquals(15, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_10,
container = "aos"
)
}
@Test
@DisplayName("광고 충전 요청은 캔 차감 없이 광고 서비스 경로로 전달된다")
fun shouldDelegateAdPurchaseWithoutCanOption() {
val member = createMember(id = 8L, nickname = "user")
val room = createRoom(id = 111L)
val character = createCharacter(id = 222L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos",
chargeType = ChatRoomQuotaChargeType.AD,
canOption = null
)
)
assertEquals(true, response.success)
assertEquals(15, response.data!!.totalRemaining)
assertEquals(5, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
}
@Test
@DisplayName("광고 충전 요청에 캔 옵션이 포함되어도 무시하고 광고 충전을 처리한다")
fun shouldIgnoreCanOptionWhenAdPurchaseContainsIt() {
val member = createMember(id = 9L, nickname = "user")
val room = createRoom(id = 121L)
val character = createCharacter(id = 232L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos",
chargeType = ChatRoomQuotaChargeType.AD,
canOption = ChatRoomQuotaCanOption.CAN_10
)
)
assertEquals(true, response.success)
assertEquals(15, response.data!!.totalRemaining)
assertEquals(5, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
}
private fun stubAccessibleRoom(member: Member, room: ChatRoom, character: ChatCharacter) {
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.ALL,
isAdult = true
)
)
Mockito.`when`(chatRoomRepository.findByIdAndIsActiveTrue(room.id!!)).thenReturn(room)
Mockito.`when`(participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)).thenReturn(
ChatParticipant(chatRoom = room, participantType = ParticipantType.USER, member = member)
)
Mockito.`when`(
participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
).thenReturn(
ChatParticipant(chatRoom = room, participantType = ParticipantType.CHARACTER, character = character)
)
}
private fun createMember(id: Long, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = MemberRole.USER
)
member.id = id
return member
}
private fun createRoom(id: Long): ChatRoom {
val room = ChatRoom(sessionId = "session-$id", title = "room-$id")
room.id = id
return room
}
private fun createCharacter(id: Long): ChatCharacter {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "desc",
systemPrompt = "prompt",
characterType = CharacterType.Character
)
character.id = id
return character
}
}

View File

@@ -1,105 +0,0 @@
package kr.co.vividnext.sodalive.chat.quota.room
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class ChatRoomQuotaServiceTest {
private lateinit var repo: ChatRoomQuotaRepository
private lateinit var canPaymentService: CanPaymentService
private lateinit var service: ChatRoomQuotaService
@BeforeEach
fun setup() {
repo = Mockito.mock(ChatRoomQuotaRepository::class.java)
canPaymentService = Mockito.mock(CanPaymentService::class.java)
service = ChatRoomQuotaService(repo, canPaymentService)
}
@Test
@DisplayName("10캔 충전은 15개 유료 quota를 지급한다")
fun shouldAddFifteenPaidQuotaWhenPurchasingCan10Option() {
val quota = ChatRoomQuota(memberId = 1L, chatRoomId = 2L, characterId = 3L, remainingFree = 10, remainingPaid = 0)
Mockito.`when`(repo.findForUpdate(1L, 2L)).thenReturn(quota)
val result = service.purchaseWithCan(
memberId = 1L,
chatRoomId = 2L,
characterId = 3L,
canOption = ChatRoomQuotaCanOption.CAN_10,
container = "aos"
)
Mockito.verify(canPaymentService).spendCan(
1L,
10,
CanUsage.CHAT_QUOTA_PURCHASE,
2L,
3L,
false,
null,
null,
null,
null,
null,
null,
"aos"
)
assertEquals(15, result.remainingPaid)
assertEquals(25, result.totalRemaining)
}
@Test
@DisplayName("20캔 충전은 40개 유료 quota를 지급한다")
fun shouldAddFortyPaidQuotaWhenPurchasingCan20Option() {
val quota = ChatRoomQuota(memberId = 11L, chatRoomId = 12L, characterId = 13L, remainingFree = 10, remainingPaid = 2)
Mockito.`when`(repo.findForUpdate(11L, 12L)).thenReturn(quota)
val result = service.purchaseWithCan(
memberId = 11L,
chatRoomId = 12L,
characterId = 13L,
canOption = ChatRoomQuotaCanOption.CAN_20,
container = "ios"
)
Mockito.verify(canPaymentService).spendCan(
11L,
20,
CanUsage.CHAT_QUOTA_PURCHASE,
12L,
13L,
false,
null,
null,
null,
null,
null,
null,
"ios"
)
assertEquals(42, result.remainingPaid)
assertEquals(52, result.totalRemaining)
}
@Test
@DisplayName("광고 충전은 캔 차감 없이 5개 유료 quota를 지급한다")
fun shouldAddFivePaidQuotaWithoutSpendingCanWhenPurchasingWithAd() {
val quota = ChatRoomQuota(memberId = 21L, chatRoomId = 22L, characterId = 23L, remainingFree = 10, remainingPaid = 1)
Mockito.`when`(repo.findForUpdate(21L, 22L)).thenReturn(quota)
val result = service.purchaseWithAd(
memberId = 21L,
chatRoomId = 22L,
characterId = 23L
)
Mockito.verifyNoInteractions(canPaymentService)
assertEquals(6, result.remainingPaid)
assertEquals(16, result.totalRemaining)
}
}

View File

@@ -18,7 +18,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.junit.jupiter.api.Assertions.assertEquals
@@ -44,7 +44,7 @@ class AudioContentServiceTest {
private lateinit var commentRepository: AudioContentCommentRepository
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
private lateinit var pinContentRepository: PinContentRepository
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private lateinit var translationService: PapagoTranslationService
private lateinit var contentTranslationRepository: ContentTranslationRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var audioContentCloudFront: AudioContentCloudFront
@@ -66,7 +66,7 @@ class AudioContentServiceTest {
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
translationService = Mockito.mock(PapagoTranslationService::class.java)
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
@@ -85,7 +85,7 @@ class AudioContentServiceTest {
commentRepository = commentRepository,
audioContentLikeRepository = audioContentLikeRepository,
pinContentRepository = pinContentRepository,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
translationService = translationService,
contentTranslationRepository = contentTranslationRepository,
s3Uploader = s3Uploader,
objectMapper = ObjectMapper(),

View File

@@ -1,42 +0,0 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LanguageDetectionCacheServiceTest {
@Test
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
val service = LanguageDetectionCacheService(repository)
val sourceHash = SourceTextNormalizer.hash("Hello world")
Mockito.`when`(
repository.findBySourceHashAndProviderAndNormalizationVersion(
sourceHash = sourceHash,
provider = "papago",
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
).thenReturn(
LanguageDetectionResult(
sourceHash = sourceHash,
sourceTextSample = "Hello world",
detectedLanguage = "en",
provider = "papago",
confidence = null,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
)
var providerCalls = 0
val detected = service.detectWithCache("Hello world") {
providerCalls++
"ko"
}
assertEquals("en", detected)
assertEquals(0, providerCalls)
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
}
}

View File

@@ -1,23 +0,0 @@
package kr.co.vividnext.sodalive.creator.admin.member
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.security.access.prepost.PreAuthorize
class CreatorAdminMemberControllerTest {
@Test
@DisplayName("로그아웃은 크리에이터와 에이전트 권한을 허용한다")
fun shouldAllowCreatorAndAgentRolesForLogout() {
val logout = CreatorAdminMemberController::class.java.getDeclaredMethod(
"logout",
String::class.java,
Member::class.java
)
val preAuthorize = logout.getAnnotation(PreAuthorize::class.java)
assertEquals("hasAnyRole('CREATOR', 'AGENT')", preAuthorize.value)
}
}

View File

@@ -1,15 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SourceTextNormalizerTest {
@Test
fun shouldNormalizeWhitespaceAndUnicodeBeforeHashing() {
val composed = "카페\n\t소개"
val decomposed = "카페 소개"
assertEquals("카페 소개", SourceTextNormalizer.normalize(composed))
assertEquals(SourceTextNormalizer.hash(composed), SourceTextNormalizer.hash(decomposed))
}
}

View File

@@ -1,104 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
class TranslationJobSchedulerTest {
@Test
fun shouldCreateOnePendingJobForMissingNormalizedText() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val scheduler = TranslationJobScheduler(jobRepository)
Mockito.`when`(
jobRepository.findActiveJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
targetLanguage = "en",
sourceHash = SourceTextNormalizer.hash("제목")
)
).thenReturn(null)
scheduler.scheduleMissingTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceText = " 제목\n",
sourceLanguage = "ko",
targetLanguage = "EN"
)
val captor = ArgumentCaptor.forClass(TranslationJob::class.java)
Mockito.verify(jobRepository).save(captor.capture())
val saved = captor.value
assertEquals(LanguageTranslationTargetType.CONTENT, saved.resourceType)
assertEquals(10L, saved.resourceId)
assertEquals("title", saved.fieldKey)
assertEquals("제목", saved.sourceText)
assertEquals(SourceTextNormalizer.hash("제목"), saved.sourceHash)
assertEquals("ko", saved.sourceLanguage)
assertEquals("en", saved.targetLanguage)
assertEquals(TranslationJobStatus.PENDING, saved.status)
assertNotNull(saved.nextRetryAt)
}
@Test
fun shouldNotCreateDuplicateJobWhenSameCompletedJobAlreadyExists() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val scheduler = TranslationJobScheduler(jobRepository)
val sourceHash = SourceTextNormalizer.hash("제목")
Mockito.`when`(
jobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
targetLanguage = "en",
sourceHash = sourceHash
)
).thenReturn(
TranslationJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceHash = sourceHash,
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en",
status = TranslationJobStatus.COMPLETED
)
)
scheduler.scheduleMissingTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en"
)
Mockito.verify(jobRepository, Mockito.never()).save(Mockito.any(TranslationJob::class.java))
}
@Test
fun shouldNotCreateJobWhenSourceLanguageIsUnsupported() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val scheduler = TranslationJobScheduler(jobRepository)
scheduler.scheduleMissingTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceText = "제목",
sourceLanguage = "fr",
targetLanguage = "en"
)
Mockito.verifyNoInteractions(jobRepository)
}
}

View File

@@ -1,231 +0,0 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.transaction.support.AbstractPlatformTransactionManager
import org.springframework.transaction.support.DefaultTransactionStatus
import java.time.LocalDateTime
import java.util.Optional
class TranslationJobWorkerTest {
@Test
fun shouldRunEveryTenMinutesByDefault() {
val scheduled = TranslationJobWorker::class.java
.getDeclaredMethod("runPendingJobs")
.getAnnotation(Scheduled::class.java)
assertEquals("\${sodalive.translation-job.fixed-delay-ms:600000}", scheduled.fixedDelayString)
}
@Test
fun shouldClaimPendingJobGroupByLockedRepositoryMethod() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = successfulProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val job = translationJob()
job.id = 100L
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(100L))
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
worker.processNextGroup()
Mockito.verify(jobRepository).findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())
}
@Test
fun shouldProcessAllJobsInClaimedGroupBeforeMaterializing() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = successfulProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val titleJob = translationJob(fieldKey = "title", sourceText = "제목")
titleJob.id = 100L
val detailJob = translationJob(fieldKey = "detail", sourceText = "설명")
detailJob.id = 101L
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime()))
.thenReturn(listOf(100L, 101L))
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(titleJob))
Mockito.`when`(jobRepository.findById(101L)).thenReturn(Optional.of(detailJob))
worker.processNextGroup()
assertEquals(TranslationJobStatus.COMPLETED, titleJob.status)
assertEquals(TranslationJobStatus.COMPLETED, detailJob.status)
Mockito.verify(materializer, Mockito.times(1)).materialize(LanguageTranslationTargetType.CONTENT, 10L, "en")
}
@Test
fun shouldRetryGroupWhenMaterializationFails() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = successfulProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val titleJob = translationJob(fieldKey = "title", sourceText = "제목")
titleJob.id = 100L
val detailJob = translationJob(fieldKey = "detail", sourceText = "설명")
detailJob.id = 101L
val beforeRetryAt = titleJob.nextRetryAt
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime()))
.thenReturn(listOf(100L, 101L))
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(titleJob))
Mockito.`when`(jobRepository.findById(101L)).thenReturn(Optional.of(detailJob))
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CONTENT, 10L, "en"))
.thenThrow(IllegalStateException("materialize down"))
worker.processNextGroup()
assertEquals(TranslationJobStatus.PENDING, titleJob.status)
assertEquals(TranslationJobStatus.PENDING, detailJob.status)
assertEquals(1, titleJob.retryCount)
assertEquals(1, detailJob.retryCount)
assertTrue(titleJob.nextRetryAt.isAfter(beforeRetryAt))
assertTrue(detailJob.nextRetryAt.isAfter(beforeRetryAt))
}
@Test
fun shouldLimitRunToFiveGroupsPerTick() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = successfulProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val jobs = (1L..6L).map { id ->
translationJob(resourceId = id).also { it.id = id }
}
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime()))
.thenReturn(listOf(1L), listOf(2L), listOf(3L), listOf(4L), listOf(5L), listOf(6L))
jobs.forEach { job ->
Mockito.`when`(jobRepository.findById(job.id!!)).thenReturn(Optional.of(job))
}
worker.runPendingJobs()
val savedJobCaptor = ArgumentCaptor.forClass(TranslationJob::class.java)
Mockito.verify(jobRepository, Mockito.atLeastOnce()).save(savedJobCaptor.capture())
val completedResourceIds = savedJobCaptor.allValues
.filter { it.status == TranslationJobStatus.COMPLETED }
.map { it.resourceId }
.toSet()
assertEquals(setOf(1L, 2L, 3L, 4L, 5L), completedResourceIds)
}
@Test
fun shouldRetryFailedJobWithBackoff() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = failingProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val job = translationJob()
job.id = 200L
val beforeRetryAt = job.nextRetryAt
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(200L))
Mockito.`when`(jobRepository.findById(200L)).thenReturn(Optional.of(job))
worker.processNextGroup()
assertEquals(TranslationJobStatus.PENDING, job.status)
assertEquals(1, job.retryCount)
assertEquals("provider down", job.lastErrorMessage)
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
}
private fun translationJob(
resourceId: Long = 10L,
fieldKey: String = "title",
sourceText: String = "제목"
): TranslationJob {
return TranslationJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = resourceId,
fieldKey = fieldKey,
sourceHash = SourceTextNormalizer.hash(sourceText),
sourceText = sourceText,
sourceLanguage = "ko",
targetLanguage = "en"
)
}
private fun successfulProvider(): TranslationProvider {
return object : TranslationProvider {
override val providerName: String = "papago"
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
return TranslateResult(listOf("title"))
}
}
}
private fun failingProvider(): TranslationProvider {
return object : TranslationProvider {
override val providerName: String = "papago"
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
throw IllegalStateException("provider down")
}
}
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
}
}
private class TestTransactionManager : AbstractPlatformTransactionManager() {
override fun doGetTransaction(): Any {
return Any()
}
override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {
}
override fun doCommit(status: DefaultTransactionStatus) {
}
override fun doRollback(status: DefaultTransactionStatus) {
}
}

View File

@@ -1,116 +0,0 @@
package kr.co.vividnext.sodalive.menu
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import javax.servlet.http.HttpServletResponse
@WebMvcTest(MenuController::class)
@Import(MenuControllerSecurityTest.TestSecurityConfig::class)
class MenuControllerSecurityTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var service: MenuService
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@TestConfiguration
@EnableGlobalMethodSecurity(prePostEnabled = true)
class TestSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
.and()
.build()
}
}
@Test
@DisplayName("콘텐츠 관리자 권한이면 메뉴 조회에 성공한다")
fun shouldAllowContentManagerRole() {
val member = createMember(role = MemberRole.CONTENT_MANAGER)
Mockito.`when`(service.getMenus(member)).thenReturn(
listOf(GetMenuResponse(title = "콘텐츠 리스트", route = "/content/list"))
)
mockMvc.perform(
get("/menu")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data[0].route").value("/content/list"))
}
@Test
@DisplayName("일반 사용자 권한이면 메뉴 조회에 접근할 수 없다")
fun shouldRejectUserRole() {
val member = createMember(role = MemberRole.USER)
mockMvc.perform(
get("/menu")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("익명 사용자는 메뉴 조회에 접근할 수 없다")
fun shouldRejectAnonymousUser() {
mockMvc.perform(
get("/menu")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
private fun createMember(role: MemberRole): Member {
return Member(
email = "${role.name.lowercase()}@test.com",
password = "password",
nickname = role.name.lowercase(),
role = role
).apply {
id = role.ordinal.toLong() + 1
}
}
}