Compare commits

..

419 Commits

Author SHA1 Message Date
c8b83272a3 Merge pull request 'test' (#420) from test into main
Reviewed-on: #420
2026-05-01 06:40:58 +00:00
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
49 changed files with 853 additions and 2365 deletions

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

@@ -13,10 +13,10 @@ import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
@RestController @RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audio-content") @RequestMapping("/admin/audio-content")
class AdminContentController(private val service: AdminContentService) { class AdminContentController(private val service: AdminContentService) {
@GetMapping("/list") @GetMapping("/list")
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
fun getAudioContentList( fun getAudioContentList(
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?, @RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
pageable: Pageable pageable: Pageable
@@ -28,7 +28,6 @@ class AdminContentController(private val service: AdminContentService) {
) )
@GetMapping("/search") @GetMapping("/search")
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
fun searchAudioContent( fun searchAudioContent(
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?, @RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
@RequestParam(value = "search_word") searchWord: String, @RequestParam(value = "search_word") searchWord: String,
@@ -42,14 +41,12 @@ class AdminContentController(private val service: AdminContentService) {
) )
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasRole('ADMIN')")
fun modifyAudioContent( fun modifyAudioContent(
@RequestPart("request") requestString: String, @RequestPart("request") requestString: String,
@RequestPart("coverImage", required = false) coverImage: MultipartFile? = null @RequestPart("coverImage", required = false) coverImage: MultipartFile? = null
) = ApiResponse.ok(service.updateAudioContent(coverImage, requestString)) ) = ApiResponse.ok(service.updateAudioContent(coverImage, requestString))
@GetMapping("/main/tab") @GetMapping("/main/tab")
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
fun getContentMainTabList() = ApiResponse.ok(service.getContentMainTabList()) 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 kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository { interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository
fun findByEmail(email: String?): Member?
}
interface AdminMemberQueryRepository { interface AdminMemberQueryRepository {
fun getMemberTotalCount(role: MemberRole? = null): Int 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.CREATOR -> messageSource.getMessage("admin.member.role.creator", langContext.lang).orEmpty()
MemberRole.AGENT -> messageSource.getMessage("admin.member.role.agent", 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.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) { val loginType = when (it.provider) {

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.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService 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.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.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail 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.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -44,7 +46,7 @@ class ChatCharacterController(
private val characterCommentService: CharacterCommentService, private val characterCommentService: CharacterCommentService,
private val curationQueryService: CharacterCurationQueryService, private val curationQueryService: CharacterCurationQueryService,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler, private val translationService: PapagoTranslationService,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val langContext: LangContext, private val langContext: LangContext,
@@ -210,11 +212,89 @@ class ChatCharacterController(
tags = payload.tags tags = payload.tags
) )
} else { } else {
resourceTranslationJobScheduler.scheduleResourceTranslation( val texts = mutableListOf<String>()
resourceType = LanguageTranslationTargetType.CHARACTER, texts.add(character.name)
resourceId = character.id!!, texts.add(character.description)
targetLanguage = langContext.lang.code 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 package kr.co.vividnext.sodalive.chat.original.service
import kr.co.vividnext.sodalive.chat.original.OriginalWork 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.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@Service @Service
class OriginalWorkTranslationService( class OriginalWorkTranslationService(
private val translationRepository: OriginalWorkTranslationRepository, private val translationRepository: OriginalWorkTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler private val papagoTranslationService: PapagoTranslationService
) { ) {
private val log = LoggerFactory.getLogger(javaClass)
/** /**
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다. * 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
* - 기존 번역이 있으면 그대로 사용 * - 기존 번역이 있으면 그대로 사용
* - 없으면 누락 번역 job 예약 후 null 반환 * - 없으면 파파고 번역 수행 후 저장
* - 실패/불필요 시 null 반환 * - 실패/불필요 시 null 반환
*/ */
@Transactional @Transactional
@@ -49,11 +55,70 @@ class OriginalWorkTranslationService(
} }
} }
resourceTranslationJobScheduler.scheduleResourceTranslation( // 파파고 번역 수행
resourceType = LanguageTranslationTargetType.ORIGINAL_WORK, return try {
resourceId = originalWork.id!!, val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
targetLanguage = target val texts = buildList {
) add(originalWork.title)
return null add(originalWork.contentType)
add(originalWork.category)
add(originalWork.description)
addAll(tags)
}
val response = papagoTranslationService.translate(
TranslateRequest(
texts = texts,
sourceLanguage = source,
targetLanguage = target
)
)
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

@@ -74,7 +74,6 @@ class SecurityConfig(
.antMatchers("/member/login/kakao").permitAll() .antMatchers("/member/login/kakao").permitAll()
.antMatchers("/member/login/apple").permitAll() .antMatchers("/member/login/apple").permitAll()
.antMatchers("/member/login/line").permitAll() .antMatchers("/member/login/line").permitAll()
.antMatchers("/admin/member/login").permitAll()
.antMatchers("/creator-admin/member/login").permitAll() .antMatchers("/creator-admin/member/login").permitAll()
.antMatchers("/member/forgot-password").permitAll() .antMatchers("/member/forgot-password").permitAll()
.antMatchers("/stplat/terms_of_service").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.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository 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.ContentTranslationRepository
import kr.co.vividnext.sodalive.content.translation.TranslatedContent import kr.co.vividnext.sodalive.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository 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.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType 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.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
@@ -67,7 +70,7 @@ class AudioContentService(
private val audioContentLikeRepository: AudioContentLikeRepository, private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository, private val pinContentRepository: PinContentRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler, private val translationService: PapagoTranslationService,
private val contentTranslationRepository: ContentTranslationRepository, private val contentTranslationRepository: ContentTranslationRepository,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
@@ -767,7 +770,7 @@ class AudioContentService(
* TranslatedContent로 가공한다 * TranslatedContent로 가공한다
* *
* 번역 콘텐츠가 없으면 * 번역 콘텐츠가 없으면
* 누락 번역 job을 예약하고 원문 응답을 유지한다. * 파파고 API를 통해 번역한 후 저장한다.
* *
* 번역 대상: title, detail, tags * 번역 대상: title, detail, tags
* *
@@ -789,11 +792,49 @@ class AudioContentService(
tags = payload.tags tags = payload.tags
) )
} else { } else {
resourceTranslationJobScheduler.scheduleResourceTranslation( val texts = mutableListOf<String>()
resourceType = LanguageTranslationTargetType.CONTENT, texts.add(audioContent.title)
resourceId = audioContent.id!!, texts.add(audioContent.detail)
targetLanguage = langContext.lang.code 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 seriesRepository: ContentSeriesRepository,
private val originalWorkRepository: OriginalWorkRepository, private val originalWorkRepository: OriginalWorkRepository,
private val categoryRepository: CategoryRepository, private val categoryRepository: CategoryRepository,
private val languageDetectionCacheService: LanguageDetectionCacheService,
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
@@ -117,7 +116,7 @@ class LanguageDetectListener(
return return
} }
val langCode = detectLanguageCode(event, characterId) ?: return val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
character.languageCode = langCode character.languageCode = langCode
chatCharacterRepository.save(character) chatCharacterRepository.save(character)
@@ -155,7 +154,7 @@ class LanguageDetectListener(
return return
} }
val langCode = detectLanguageCode(event, contentId) ?: return val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
audioContent.languageCode = langCode audioContent.languageCode = langCode
@@ -195,7 +194,7 @@ class LanguageDetectListener(
return return
} }
val langCode = detectLanguageCode(event, commentId) ?: return val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
comment.languageCode = langCode comment.languageCode = langCode
audioContentCommentRepository.save(comment) audioContentCommentRepository.save(comment)
@@ -227,7 +226,7 @@ class LanguageDetectListener(
return return
} }
val langCode = detectLanguageCode(event, commentId) ?: return val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
comment.languageCode = langCode comment.languageCode = langCode
characterCommentRepository.save(comment) characterCommentRepository.save(comment)
@@ -258,7 +257,7 @@ class LanguageDetectListener(
return return
} }
val langCode = detectLanguageCode(event, cheersId) ?: return val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
cheers.languageCode = langCode cheers.languageCode = langCode
creatorCheersRepository.save(cheers) creatorCheersRepository.save(cheers)
@@ -289,7 +288,7 @@ class LanguageDetectListener(
return return
} }
val langCode = detectLanguageCode(event, seriesId) ?: return val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
series.languageCode = langCode series.languageCode = langCode
seriesRepository.save(series) seriesRepository.save(series)
@@ -327,7 +326,7 @@ class LanguageDetectListener(
return return
} }
val langCode = detectLanguageCode(event, originalWorkId) ?: return val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
originalWork.languageCode = langCode originalWork.languageCode = langCode
originalWorkRepository.save(originalWork) originalWorkRepository.save(originalWork)
@@ -353,7 +352,7 @@ class LanguageDetectListener(
val category = categoryRepository.findByIdOrNull(categoryId) ?: return val category = categoryRepository.findByIdOrNull(categoryId) ?: return
if (!category.languageCode.isNullOrBlank()) return if (!category.languageCode.isNullOrBlank()) return
val langCode = detectLanguageCode(event, categoryId) ?: return val langCode = requestPapagoLanguageCode(event.query, categoryId) ?: return
category.languageCode = langCode category.languageCode = langCode
categoryRepository.save(category) 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? { private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
return try { return try {
val headers = HttpHeaders().apply { 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.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType 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.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
@@ -24,7 +25,7 @@ class CategoryService(
private val langContext: LangContext, private val langContext: LangContext,
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler private val translationService: PapagoTranslationService
) { ) {
@Transactional @Transactional
fun createCategory(request: CreateCategoryRequest, member: Member) { fun createCategory(request: CreateCategoryRequest, member: Member) {
@@ -147,7 +148,7 @@ class CategoryService(
.findByCategoryIdInAndLocale(categoryIds, locale) .findByCategoryIdInAndLocale(categoryIds, locale)
.associateBy { it.categoryId } .associateBy { it.categoryId }
// 각 항목에 대해 번역 적용. 없으면 누락 번역 job만 예약하고 원문을 반환한다. // 각 항목에 대해 번역 적용. 없으면 Papago로 번역 저장 후 적용
val result = mutableListOf<GetCategoryListResponse>() val result = mutableListOf<GetCategoryListResponse>()
for (item in baseList) { for (item in baseList) {
val entity = entityMap[item.categoryId] val entity = entityMap[item.categoryId]
@@ -164,11 +165,38 @@ class CategoryService(
continue continue
} }
resourceTranslationJobScheduler.scheduleResourceTranslation( // 번역본이 없으면 Papago 번역 후 저장
resourceType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY, val texts = listOf(entity.title)
resourceId = entity.id!!, val response = translationService.translate(
targetLanguage = locale 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.ContentSeriesContentRepository
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse 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.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.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository 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.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
@@ -39,7 +41,7 @@ class ContentSeriesService(
private val seriesTranslationRepository: SeriesTranslationRepository, private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository, private val contentTranslationRepository: ContentTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler, private val translationService: PapagoTranslationService,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String private val coverImageHost: String
@@ -89,7 +91,7 @@ class ContentSeriesService(
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> { fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
/** /**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환 * langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환 * 번역이 없으면 번역 API 호출 후 저장하고 반환
*/ */
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType) val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
@@ -118,12 +120,32 @@ class ContentSeriesService(
// 미번역 항목 수집 // 미번역 항목 수집
val untranslated = genres.filter { existingMap[it.id] == null } val untranslated = genres.filter { existingMap[it.id] == null }
untranslated.forEach { item -> if (untranslated.isNotEmpty()) {
resourceTranslationJobScheduler.scheduleResourceTranslation( val texts = untranslated.map { it.genre }
resourceType = LanguageTranslationTargetType.SERIES_GENRE, val response = translationService.translate(
resourceId = item.id, request = TranslateRequest(
targetLanguage = targetLocale 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로 가공한다 * TranslatedSeries로 가공한다
* *
* 번역 콘텐츠가 없으면 * 번역 콘텐츠가 없으면
* 누락 번역 job을 예약하고 원문 응답을 유지한다. * 파파고 API를 통해 번역한 후 저장한다.
* *
* 번역 대상: title, introduction, keywordList * 번역 대상: title, introduction, keywordList
* *
@@ -287,11 +309,54 @@ class ContentSeriesService(
keywords = kws keywords = kws
) )
} else { } else {
resourceTranslationJobScheduler.scheduleResourceTranslation( val texts = mutableListOf<String>()
resourceType = LanguageTranslationTargetType.SERIES, texts.add(series.title)
resourceId = seriesId, texts.add(series.introduction)
targetLanguage = locale // 키워드는 개별 항목으로 번역 요청하여 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.ContentType
import kr.co.vividnext.sodalive.content.SortType import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse 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.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -21,7 +22,7 @@ class AudioContentThemeService(
private val contentRepository: AudioContentRepository, private val contentRepository: AudioContentRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler, private val papagoTranslationService: PapagoTranslationService,
private val langContext: LangContext private val langContext: LangContext
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -50,7 +51,7 @@ class AudioContentThemeService(
/** /**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환 * langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환 * 번역이 없으면 번역 API 호출 후 저장하고 반환
*/ */
val currentLang = langContext.lang val currentLang = langContext.lang
if (currentLang == Lang.EN || currentLang == Lang.JA) { if (currentLang == Lang.EN || currentLang == Lang.JA) {
@@ -65,14 +66,43 @@ class AudioContentThemeService(
val existingMap = existingTranslations.associateBy { it.contentThemeId } val existingMap = existingTranslations.associateBy { it.contentThemeId }
// 2) 미번역 항목은 조회 스레드에서 번역하지 않고 job만 예약 // 2) 미번역 항목만 수집하여 한 번에 번역 요청
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null } val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
untranslatedPairs.forEach { pair ->
resourceTranslationJobScheduler.scheduleResourceTranslation( if (untranslatedPairs.isNotEmpty()) {
resourceType = LanguageTranslationTargetType.CONTENT_THEME, val texts = untranslatedPairs.map { it.theme }
resourceId = pair.id,
targetLanguage = targetLocale 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) // 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)

View File

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

View File

@@ -1044,11 +1044,6 @@ class SodaMessageSource {
Lang.KO to "", Lang.KO to "",
Lang.EN to "Bot", Lang.EN to "Bot",
Lang.JA to "ボット" 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 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.context.event.EventListener
import org.springframework.data.repository.findByIdOrNull
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Propagation
@@ -29,7 +58,24 @@ class LanguageTranslationEvent(
@Component @Component
class LanguageTranslationListener( 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 @Async
@EventListener(condition = "!#event.waitTransactionCommit") @EventListener(condition = "!#event.waitTransactionCommit")
@@ -46,6 +92,424 @@ class LanguageTranslationListener(
} }
private fun processTranslation(event: LanguageTranslationEvent) { 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}") @Value("\${cloud.naver.papago-client-secret}")
private val papagoClientSecret: String private val papagoClientSecret: String
) : TranslationProvider { ) {
private val restTemplate: RestTemplate = RestTemplate() private val restTemplate: RestTemplate = RestTemplate()
private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation" private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation"
override val providerName: String = "papago" fun translate(request: TranslateRequest): TranslateResult {
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) { if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) {
return TranslateResult(emptyList()) 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 { enum class MemberRole {
ADMIN, BOT, USER, CREATOR, AGENT, CONTENT_MANAGER ADMIN, BOT, USER, CREATOR, AGENT
} }
enum class MemberProvider { enum class MemberProvider {

View File

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

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

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