Compare commits

...
474 Commits
Author SHA1 Message Date
klaus 41314ef797 Merge pull request 'Test' (#452) from test into main
Reviewed-on: #452
2026-09-18 05:56:04 +00:00
klaus 7c578972f7 fix(can): 회원 2의 강제 통화 예외를 제거한다 2026-09-18 14:21:35 +09:00
klaus 5173f17039 docs(can): 회원 2 통화 예외 제거 계획을 기록한다 2026-09-18 14:21:20 +09:00
klaus 200dc6264a Merge pull request 'Test' (#451) from test into main
Reviewed-on: #451
2026-09-18 02:15:25 +00:00
klaus 11f2e51c33 docs(content-preference): 노출 정책 통합 결과를 기록한다 2026-09-17 20:20:54 +09:00
klaus 17b7f0281e refactor(content-preference): Deprecated 노출 정책을 제거한다 2026-09-17 20:20:40 +09:00
klaus d82185e974 refactor(content-series): 시리즈 정책 호출을 통합한다 2026-09-17 20:20:12 +09:00
klaus ba6aaf391a refactor(content): 오디오 콘텐츠 정책 호출을 통합한다 2026-09-17 20:19:45 +09:00
klaus c5e9a47633 refactor(content-main): 메인·큐레이션·테마 정책 호출을 통합한다 2026-09-17 20:19:14 +09:00
klaus 190c63124b refactor(content-main): 홈·다시듣기·시리즈 탭 정책 호출을 통합한다 2026-09-17 20:18:45 +09:00
klaus 13821a33ea refactor(content-main): 알람·ASMR·콘텐츠·무료 탭 정책 호출을 통합한다 2026-09-17 20:18:17 +09:00
klaus 19c1c337a4 fix(content-preference): 일본 강제 국가 매핑을 갱신한다 2026-09-17 20:17:45 +09:00
klaus 243394acd7 feat(content-preference): 조회용 성인 노출 정책을 통합한다 2026-09-17 20:17:28 +09:00
klaus 8c87286acd docs(live-room): 입장 제한 검토 결과를 기록한다 2026-09-17 19:16:30 +09:00
klaus a8b16dca5a fix(live-room): 제한 사용자의 입장과 토큰 발급을 차단한다 2026-09-17 19:15:58 +09:00
klaus 7c5d3874db test(creator-channel): 제한 방 소유자 조회를 검증한다 2026-09-17 19:15:27 +09:00
klaus ccb00e3a08 fix(creator-channel): 조회자 역할로 라이브 제한을 판정한다 2026-09-17 19:14:42 +09:00
klaus 542bf81b58 feat(home-recommendation): 라이브 입장 제한 필터를 적용한다 2026-09-17 19:14:29 +09:00
klaus 123b9b1a0e docs(live-room): 입장 제한 요구사항과 계획을 기록한다 2026-09-17 19:14:14 +09:00
klaus 6f48aa156a Merge pull request '크리에이터가 먼저 DM을 보낼 수 있도록 요구조건 수정' (#450) from test into main
Reviewed-on: #450
2026-09-15 01:56:13 +00:00
klaus 62c834d28f test(user-creator-chat): 크리에이터 시작 DM 생성을 검증한다 2026-09-14 14:50:54 +09:00
klaus de1a9eb114 feat(user-creator-chat): DM 생성 수신자 필드를 일반화한다 2026-09-14 14:50:44 +09:00
klaus 70f717aa6d docs(user-creator-chat): DM 방 생성 요구사항을 기록한다 2026-09-14 14:50:37 +09:00
klaus ca1f9b9d00 feat(deployment): 배포 식별자 엔드포인트를 추가한다 2026-09-14 11:58:15 +09:00
klaus 7aeb070615 feat(deployment): actuator readiness 설정을 추가한다 2026-09-14 11:58:07 +09:00
klaus 0ec48d65ad docs(deployment): 배포 검증 요구사항과 계획을 기록한다 2026-09-14 11:58:00 +09:00
klaus c1000a6c68 Merge pull request 'test' (#449) from test into main
Reviewed-on: #449
2026-09-10 07:58:31 +00:00
klaus b978f5bc8d Merge pull request 'test' (#448) from test into main
Reviewed-on: #448
2026-08-20 10:04:02 +00:00
klaus 3f660e81f9 Merge pull request 'test' (#447) from test into main
Reviewed-on: #447
2026-08-19 08:56:01 +00:00
klaus c9b0bd13b5 Merge pull request 'test' (#446) from test into main
Reviewed-on: #446
2026-08-11 08:40:26 +00:00
klaus 92402419c5 Merge pull request 'test' (#445) from test into main
Reviewed-on: #445
2026-08-05 12:36:10 +00:00
klaus c4fa2fa2f8 Merge pull request 'test' (#444) from test into main
Reviewed-on: #444
2026-08-05 07:45:05 +00:00
klaus b4027fdee7 Merge pull request 'test' (#443) from test into main
Reviewed-on: #443
2026-08-03 06:20:48 +00:00
klaus eb262b8d53 Merge pull request 'test' (#442) from test into main
Reviewed-on: #442
2026-07-22 13:34:38 +00:00
klaus f5397df65e Merge pull request 'fix(community): 게시글 알림 딥링크를 보강한다' (#441) from test into main
Reviewed-on: #441
2026-07-16 09:26:27 +00:00
klaus 0c6aeb5c8a Merge pull request 'test' (#440) from test into main
Reviewed-on: #440
2026-07-15 12:24:50 +00:00
klaus d8083e34ea Merge pull request 'test' (#439) from test into main
Reviewed-on: #439
2026-07-12 14:05:39 +00:00
klaus 8eeade49d1 Merge pull request 'test' (#438) from test into main
Reviewed-on: #438
2026-07-12 10:42:33 +00:00
klaus b36764abff Merge pull request 'test' (#437) from test into main
Reviewed-on: #437
2026-07-12 08:54:14 +00:00
klaus 7844ea74d7 Merge pull request 'test' (#436) from test into main
Reviewed-on: #436
2026-07-12 04:18:00 +00:00
klaus e04fa67d1d Merge pull request 'test' (#435) from test into main
Reviewed-on: #435
2026-07-12 00:25:13 +00:00
klaus bd1ec54d36 Merge pull request 'chore(build): 테스트 JVM 메모리 설정을 조정한다' (#434) from test into main
Reviewed-on: #434
2026-07-10 08:06:41 +00:00
klaus 4442fa6d91 Merge pull request 'test' (#433) from test into main
Reviewed-on: #433
2026-07-10 07:20:06 +00:00
klaus 795c25159f Merge pull request 'test' (#432) from test into main
Reviewed-on: #432
2026-07-07 18:07:27 +00:00
klaus cdc7cb8612 Merge pull request 'test' (#431) from test into main
Reviewed-on: #431
2026-07-01 05:35:12 +00:00
klaus efefef51f4 Merge pull request 'test' (#430) from test into main
Reviewed-on: #430
2026-06-30 12:54:07 +00:00
klaus e2b9eb8bf3 Merge pull request 'test' (#429) from test into main
Reviewed-on: #429
2026-06-30 07:37:43 +00:00
klaus 1aa7a89f7b Merge pull request 'test' (#428) from test into main
Reviewed-on: #428
2026-06-29 08:06:51 +00:00
klaus 45aa26f1cb Merge pull request 'test' (#427) from test into main
Reviewed-on: #427
2026-06-27 02:30:26 +00:00
klaus 626f02b45e Merge pull request 'test' (#426) from test into main
Reviewed-on: #426
2026-06-27 00:35:29 +00:00
klaus 540c5cb317 Merge pull request 'test' (#425) from test into main
Reviewed-on: #425
2026-05-18 06:53:12 +00:00
klaus 3aa6a35adb Merge pull request 'fix(admin): 크리에이터 관리자 로그아웃 AGENT 권한을 허용한다' (#424) from test into main
Reviewed-on: #424
2026-05-08 10:44:04 +00:00
klaus 81c0d2586c Merge pull request '콘텐츠 관리자 권한 추가' (#423) from test into main
Reviewed-on: #423
2026-05-07 06:35:15 +00:00
klaus 965bb068fc Merge pull request 'fix(i18n): 지원하지 않는 원문 언어 번역 작업을 제외한다' (#422) from test into main
Reviewed-on: #422
2026-05-07 03:26:24 +00:00
klaus d082e0b745 Merge pull request '번역 작업 큐와 언어 감지 캐시를 도입한다' (#421) from test into main
Reviewed-on: #421
2026-05-07 01:31:02 +00:00
klaus c8b83272a3 Merge pull request 'test' (#420) from test into main
Reviewed-on: #420
2026-05-01 06:40:58 +00:00
klaus 1939fdcb33 Merge pull request 'fix(member): 강제 KR 매핑 대상에 회원 17958 추가' (#419) from test into main
Reviewed-on: #419
2026-04-28 05:00:06 +00:00
klaus f521a240c2 Merge pull request 'test' (#418) from test into main
Reviewed-on: #418
2026-04-22 01:19:41 +00:00
klaus 0c35624dfb Merge pull request 'fix(agent-calculate): 에이전트 기본 정산 비율을 7퍼센트로 조정한다' (#417) from test into main
Reviewed-on: #417
2026-04-15 05:37:24 +00:00
klaus 5c24addf31 Merge pull request '에이전트 기능' (#416) from test into main
Reviewed-on: #416
2026-04-14 06:29:22 +00:00
klaus 8671c8efc7 Merge pull request 'test' (#415) from test into main
Reviewed-on: #415
2026-04-08 01:50:50 +00:00
klaus 16c17f4bfa Merge pull request 'feat(live-room): 라이브 캡쳐 녹화 가능 여부를 생성 조회에 반영한다' (#414) from test into main
Reviewed-on: #414
2026-03-30 12:58:19 +00:00
klaus 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
klaus 6a10eff15f Merge pull request 'fix(live-room): 진행중 목록 성인 노출 정책과 JP 강제 매핑 검증을 정리한다' (#412) from test into main
Reviewed-on: #412
2026-03-28 14:11:08 +00:00
klaus fea329e637 Merge pull request 'fix(channel-donation): 후원 목록 탈퇴 닉네임 접두사를 제거한다' (#411) from test into main
Reviewed-on: #411
2026-03-28 10:14:16 +00:00
klaus 681e4a4036 Merge pull request 'test' (#410) from test into main
Reviewed-on: #410
2026-03-28 09:27:28 +00:00
klaus c23f574162 Merge pull request 'fix(member): 회원 차단을 요청 ID 단건만 적용한다' (#409) from test into main
Reviewed-on: #409
2026-03-26 02:01:43 +00:00
klaus c884d7d6c9 Merge pull request 'test' (#408) from test into main
Reviewed-on: #408
2026-03-24 10:41:41 +00:00
klaus 116e8cbca3 Merge pull request 'feat(deploy): EC2 배포 스크립트에 JVM 옵션 로드 기능 추가' (#407) from test into main
Reviewed-on: #407
2026-03-23 09:45:25 +00:00
klaus 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
klaus 676bd0b79e Merge pull request 'test' (#405) from test into main
Reviewed-on: #405
2026-03-19 09:33:40 +00:00
klaus 7522f06bf3 Merge pull request 'fix(live-room): 라이브 방 후원 랭킹 조회에 기간 설정을 반영한다' (#404) from test into main
Reviewed-on: #404
2026-03-17 07:15:17 +00:00
klaus a9d2d1ab48 Merge pull request 'feat(creator-community): 커뮤니티 게시물 고정 기능을 추가한다' (#403) from test into main
Reviewed-on: #403
2026-03-17 02:40:30 +00:00
klaus e0e371cdc9 Merge pull request 'feat(admin-calculate): 관리자 라이브 환불 처리와 정산 응답 식별자를 추가한다' (#402) from test into main
Reviewed-on: #402
2026-03-16 07:13:26 +00:00
klaus 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
klaus 5cc152307a Merge pull request 'test' (#400) from test into main
Reviewed-on: #400
2026-03-13 14:08:53 +00:00
klaus 1fd3d41d7e Merge pull request 'test' (#399) from test into main
Reviewed-on: #399
2026-03-13 13:18:26 +00:00
klaus c1b9dd730d Merge pull request 'fix(admin-calculate): 관리자 정산 조회 캐시를 제거하고 응답 직렬화를 명시한다' (#398) from test into main
Reviewed-on: #398
2026-03-06 03:31:24 +00:00
klaus d40cd32c50 Merge pull request 'test' (#397) from test into main
Reviewed-on: #397
2026-03-05 09:17:37 +00:00
klaus 0289607fd9 Merge pull request 'fix(channel-donation): 관리자 채널후원 정산 조회를 날짜별과 크리에이터별로 분리하고 엑셀 다운로드를 추가한다' (#396) from test into main
Reviewed-on: #396
2026-03-03 05:59:42 +00:00
klaus 1bec644372 Merge pull request 'fix(channel-donation): 후원 조회 월 경계를 UTC 전달 기준으로 보정한다' (#395) from test into main
Reviewed-on: #395
2026-03-03 03:20:46 +00:00
klaus f8a6d1b221 Merge pull request 'fix(channel-donation): 기부 목록 조회 월 범위를 한국 시간 기준으로 계산한다' (#394) from test into main
Reviewed-on: #394
2026-03-03 02:23:01 +00:00
klaus ec769a1307 Merge pull request 'test' (#393) from test into main
Reviewed-on: #393
2026-02-27 06:16:51 +00:00
klaus 8e4fb0d313 Merge pull request 'test' (#392) from test into main
Reviewed-on: #392
2026-02-26 11:18:33 +00:00
klaus cc3a620642 Merge pull request 'fix(recommend-live): 차단 관계를 추천 조회에 반영하고 캐시를 무효화한다' (#391) from test into main
Reviewed-on: #391
2026-02-25 18:40:55 +00:00
klaus be0884e974 Merge pull request 'test' (#390) from test into main
Reviewed-on: #390
2026-02-25 16:55:31 +00:00
klaus 34456395fd Merge pull request 'test' (#389) from test into main
Reviewed-on: #389
2026-02-25 13:55:22 +00:00
klaus 26ddeb9591 Merge pull request 'test' (#388) from test into main
Reviewed-on: #388
2026-02-13 09:14:19 +00:00
klaus cd535a628c Merge pull request 'test' (#387) from test into main
Reviewed-on: #387
2026-02-11 10:03:08 +00:00
klaus de32b537f4 Merge pull request 'test' (#386) from test into main
Reviewed-on: #386
2026-02-08 07:42:31 +00:00
klaus 9c271fc1f6 Merge pull request 'test' (#385) from test into main
Reviewed-on: #385
2026-02-06 05:04:37 +00:00
klaus 2ddbfbccd6 Merge pull request 'test' (#384) from test into main
Reviewed-on: #384
2026-02-04 12:52:24 +00:00
klaus 80786deb72 Merge pull request 'test' (#383) from test into main
Reviewed-on: #383
2026-01-28 15:40:25 +00:00
klaus 8ca2e185ac Merge pull request '라이브 예약 반환 값 - beginDateTimeUtc 추가' (#382) from test into main
Reviewed-on: #382
2026-01-21 10:08:55 +00:00
klaus 484711ad1b Merge pull request '라이브 예약 Response에 utc 시간 변수 beginDateTimeUtc 추가' (#381) from test into main
Reviewed-on: #381
2026-01-21 07:54:05 +00:00
klaus e80ceca0c5 Merge pull request '충전 이벤트 - langContext 문제로 충전이 되지 않는 현상을 langContext를 사용하지 않고 이전 방식으로 기록하도록 롤백하여 임시 해결' (#380) from test into main
Reviewed-on: #380
2026-01-21 02:36:21 +00:00
klaus 33293a6533 Merge pull request '라이브 방 상세 - 날짜 포맷 변경으로 유료방 입장이 불가한 문제를 해결하기 위해 이전으로 롤백' (#379) from test into main
Reviewed-on: #379
2026-01-21 02:16:42 +00:00
klaus f0c1d4e32a Merge pull request '라이브 룸 일시 포맷에 다국어 설정 적용' (#378) from test into main
Reviewed-on: #378
2026-01-20 10:41:23 +00:00
klaus 6cd319ec76 Merge pull request 'memberId가 특정 번호일 때 currency와 관계없이 모든 구매 가능한 캔이 출력되도록 수정' (#377) from test into main
Reviewed-on: #377
2026-01-16 02:39:08 +00:00
klaus 6557ec2aed Merge pull request '푸시 알림 전송 언어 처리' (#376) from test into main
Reviewed-on: #376
2026-01-15 08:45:46 +00:00
klaus f2f8a34319 Merge pull request '국가 컨텍스트로 캔 조회' (#375) from test into main
Reviewed-on: #375
2026-01-14 06:46:51 +00:00
klaus c50ac6ed2c Merge pull request '캔 사용 시 국가 코드 기록 기능 추가' (#374) from test into main
Reviewed-on: #374
2026-01-12 02:31:46 +00:00
klaus 11b9c349d1 Merge pull request '번역 이벤트 커밋 후 처리 분기' (#373) from test into main
Reviewed-on: #373
2026-01-07 09:54:32 +00:00
klaus ef9f8d65e1 Merge pull request 'test' (#372) from test into main
Reviewed-on: #372
2026-01-07 07:42:42 +00:00
klaus 299f2100e9 Merge pull request 'test' (#371) from test into main
Reviewed-on: #371
2026-01-05 06:33:29 +00:00
klaus fd5c794480 Merge pull request 'test' (#370) from test into main
Reviewed-on: #370
2025-12-31 11:01:28 +00:00
klaus 68197de095 Merge pull request 'test' (#369) from test into main
Reviewed-on: #369
2025-12-31 05:44:54 +00:00
klaus 587f3d6b58 Merge pull request 'test' (#368) from test into main
Reviewed-on: #368
2025-11-20 16:02:25 +00:00
klaus 9b6167d46d Merge pull request 'test' (#367) from test into main
Reviewed-on: #367
2025-11-20 12:53:00 +00:00
klaus 008ee3b4e5 Merge pull request 'test' (#366) from test into main
Reviewed-on: #366
2025-11-20 06:27:02 +00:00
klaus 3a57ad23bb Merge pull request 'test' (#365) from test into main
Reviewed-on: #365
2025-11-19 08:56:28 +00:00
klaus 729552335a Merge pull request 'test' (#364) from test into main
Reviewed-on: #364
2025-11-18 10:38:50 +00:00
klaus 02ae507c87 Merge pull request 'fix(series-list): 시리즈 리스트 조회시 정렬 수정' (#363) from test into main
Reviewed-on: #363
2025-11-17 13:38:59 +00:00
klaus 5818abf69d Merge pull request 'fix(series-list): creator의 시리즈를 볼 떄와 다른 페이지에서 시리즈 리스트를 볼 때 정렬 순서 분리' (#362) from test into main
Reviewed-on: #362
2025-11-17 12:25:38 +00:00
klaus ee403915f0 Merge pull request 'test' (#361) from test into main
Reviewed-on: #361
2025-11-17 07:13:11 +00:00
klaus 1a660088de Merge pull request 'test' (#360) from test into main
Reviewed-on: #360
2025-11-13 20:49:12 +00:00
klaus 5196c80ca8 Merge pull request 'test' (#359) from test into main
Reviewed-on: #359
2025-11-13 19:45:52 +00:00
klaus c9c09c2998 Merge pull request 'test' (#358) from test into main
Reviewed-on: #358
2025-11-10 06:53:41 +00:00
klaus 3ea33c4c7b Merge pull request 'feat(home-latest-content): 최신 콘텐츠 조회시 정렬 조건 변경' (#357) from test into main
Reviewed-on: #357
2025-11-07 12:00:59 +00:00
klaus 451a1aa4f2 Merge pull request 'test' (#356) from test into main
Reviewed-on: #356
2025-11-06 08:48:23 +00:00
klaus 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
klaus 0dc430b098 Merge pull request 'UseCalculate에 PAYVERSE로 충전한 캔 로그 데이터를 쌓도록 수정' (#354) from test into main
Reviewed-on: #354
2025-10-22 13:31:46 +00:00
klaus 1f2103c7fa Merge pull request 'fix(can-use): PAYVERSE로 충전한 캔이 사용되지 않는 버그 수정' (#353) from test into main
Reviewed-on: #353
2025-10-22 12:41:13 +00:00
klaus 062c17c51e Merge pull request 'feat(chat): 채팅권 구매 가격과 채팅횟수 변경' (#352) from test into main
Reviewed-on: #352
2025-10-22 07:55:13 +00:00
klaus de169b79a1 Merge pull request 'feat(home): 인기 캐릭터 추가' (#351) from test into main
Reviewed-on: #351
2025-10-20 06:07:49 +00:00
klaus aa24de0a5a Merge pull request 'test' (#350) from test into main
Reviewed-on: #350
2025-10-17 05:46:36 +00:00
klaus e5937d573a Merge pull request 'test' (#349) from test into main
Reviewed-on: #349
2025-10-10 20:49:52 +00:00
klaus 6da86e12bd Merge pull request 'test' (#348) from test into main
Reviewed-on: #348
2025-10-10 19:19:47 +00:00
klaus 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
klaus 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
klaus 53e9678efa Merge pull request 'fix(verify-hecto): 데이터 검증시 가격비교 제거' (#345) from test into main
Reviewed-on: #345
2025-10-10 09:58:11 +00:00
klaus e4f547fa92 Merge pull request 'payverse 적용' (#344) from test into main
Reviewed-on: #344
2025-10-10 07:44:07 +00:00
klaus b69756ef81 Merge pull request 'test' (#343) from test into main
Reviewed-on: #343
2025-09-18 19:25:50 +00:00
klaus 1a3a9149a2 Merge pull request 'test' (#342) from test into main
Reviewed-on: #342
2025-09-16 06:11:32 +00:00
klaus ce120a6d5d Merge pull request 'test' (#341) from test into main
Reviewed-on: #341
2025-09-14 20:33:50 +00:00
klaus 08b5fd23ab Merge pull request 'test' (#340) from test into main
Reviewed-on: #340
2025-09-14 08:51:11 +00:00
klaus eb18e2d009 Merge pull request 'test' (#339) from test into main
Reviewed-on: #339
2025-09-11 17:05:45 +00:00
klaus a27852ed44 Merge pull request '캐릭터 챗봇' (#338) from test into main
Reviewed-on: #338
2025-09-10 06:08:47 +00:00
klaus c7925c1706 Merge pull request 'feat: 최근 공지사항 API 추가' (#337) from test into main
Reviewed-on: #337
2025-07-28 02:16:19 +00:00
klaus be59bd7e89 Merge pull request 'fix: 크리에이터 팔로우 API' (#336) from test into main
Reviewed-on: #336
2025-07-21 13:52:34 +00:00
klaus 51ce143fc2 Merge pull request 'test' (#335) from test into main
Reviewed-on: #335
2025-07-21 11:46:56 +00:00
klaus 89eb11f808 Merge pull request 'fix: 라이브 메인 API - 최근 종료된 라이브' (#334) from test into main
Reviewed-on: #334
2025-07-21 10:59:38 +00:00
klaus 30d89987a4 Merge pull request 'test' (#333) from test into main
Reviewed-on: #333
2025-07-21 09:54:56 +00:00
klaus 7959d3e5ed Merge pull request 'test' (#332) from test into main
Reviewed-on: #332
2025-07-18 12:33:22 +00:00
klaus 1e29573ef7 Merge pull request 'fix: 검색 API' (#331) from test into main
Reviewed-on: #331
2025-07-16 10:58:56 +00:00
klaus cc2f533dc6 Merge pull request 'fix: 메인 홈 API - 요일별 시리즈' (#330) from test into main
Reviewed-on: #330
2025-07-14 19:14:06 +00:00
klaus 32b0c19f9d Merge pull request 'test' (#329) from test into main
Reviewed-on: #329
2025-07-14 17:57:26 +00:00
klaus 9af2d768e8 Merge pull request 'test' (#327) from test into main
Reviewed-on: #327
2025-07-14 11:07:57 +00:00
klaus 5677824cde Merge pull request 'test' (#326) from test into main
Reviewed-on: #326
2025-06-13 11:37:26 +00:00
klaus e8f1bc09f9 Merge pull request 'test' (#325) from test into main
Reviewed-on: #325
2025-06-12 05:00:31 +00:00
klaus d1a936d55b Merge pull request 'test' (#324) from test into main
Reviewed-on: #324
2025-06-10 11:01:31 +00:00
klaus dc97eaa835 Merge pull request 'fix: 앱 콘텐츠 수정' (#323) from test into main
Reviewed-on: #323
2025-06-05 02:36:25 +00:00
klaus dcbe57806c Merge pull request 'test' (#322) from test into main
Reviewed-on: #322
2025-06-02 12:41:46 +00:00
klaus b14438cc15 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#321) from test into main
Reviewed-on: #321
2025-05-28 07:19:27 +00:00
klaus b27d3bd5c6 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#320) from test into main
Reviewed-on: #320
2025-05-26 10:33:16 +00:00
klaus 03ebc9cfe9 Merge pull request 'fix: 큐레이션 아이템 조회' (#319) from test into main
Reviewed-on: #319
2025-05-23 05:43:37 +00:00
klaus 24841b9850 Merge pull request 'fix: 코루틴 내 트랜잭션 간 조회 안 되는 문제 해결' (#318) from test into main
Reviewed-on: #318
2025-05-22 04:31:42 +00:00
klaus d35a3d1a8c Merge pull request 'test' (#317) from test into main
Reviewed-on: #317
2025-05-20 10:26:16 +00:00
klaus 60c4e0b528 Merge pull request 'test' (#316) from test into main
Reviewed-on: #316
2025-05-20 06:03:10 +00:00
klaus 84f33d1bc2 Merge pull request 'fix: 소셜로그인시 유저 행동데이터 SIGN_UP 중복 기록 버그' (#315) from test into main
Reviewed-on: #315
2025-05-12 08:24:53 +00:00
klaus c4e1709b99 Merge pull request 'test' (#314) from test into main
Reviewed-on: #314
2025-05-12 02:12:47 +00:00
klaus e7a5fd5819 Merge pull request 'fix: 구글/카카오 로그인 회원가입 오류 수정' (#313) from test into main
Reviewed-on: #313
2025-05-02 10:58:04 +00:00
klaus 4bde03643c Merge pull request 'test' (#312) from test into main
Reviewed-on: #312
2025-04-29 02:56:16 +00:00
klaus 1bc52b56af Merge pull request 'fix: 콘텐츠 업로드 - 제목과 내용에서 trim 함수를 적용하여 앞/뒤 빈칸 제거' (#311) from test into main
Reviewed-on: #311
2025-04-25 09:43:31 +00:00
klaus 9c33fd93f7 Merge pull request 'refactor: 본인인증 - 본인인증이 완료된 후 유저 행동 데이터를 기록하도록 수정' (#310) from test into main
Reviewed-on: #310
2025-04-24 11:10:17 +00:00
klaus 3c087bc275 Merge pull request '유저 행동 데이터, 포인트 추가' (#309) from test into main
Reviewed-on: #309
2025-04-24 02:44:57 +00:00
klaus 8ad13c289e Merge pull request '회원탈퇴' (#308) from test into main
Reviewed-on: #308
2025-04-15 10:42:37 +00:00
klaus 7577f48a09 Merge pull request '한정판 콘텐츠' (#307) from test into main
Reviewed-on: #307
2025-04-15 09:44:12 +00:00
klaus 0251906964 Merge pull request '비밀번호 찾기' (#306) from test into main
Reviewed-on: #306
2025-04-10 06:28:57 +00:00
klaus 2723a5f134 Merge pull request '일별 전체 회원 수' (#305) from test into main
Reviewed-on: #305
2025-04-10 02:30:00 +00:00
klaus c3c60605fd Merge pull request '관리자 - 회원리스트, 크리에이터 리스트' (#304) from test into main
Reviewed-on: #304
2025-04-09 10:35:01 +00:00
klaus 238f704b22 Merge pull request '소셜 로그인, 회원가입 - 이메일 체크 로직 수정' (#303) from test into main
Reviewed-on: #303
2025-04-08 07:04:11 +00:00
klaus 5639d8ac8e Merge pull request 'test' (#302) from test into main
Reviewed-on: #302
2025-04-07 10:23:13 +00:00
klaus 9aac591591 Merge pull request 'test' (#301) from test into main
Reviewed-on: #301
2025-04-01 13:31:24 +00:00
klaus ffa8e5aebb Merge pull request '일별 전체 회원 수 통계' (#300) from test into main
Reviewed-on: #300
2025-03-31 03:50:18 +00:00
klaus cbbfe014cc Merge pull request '광고 통계' (#299) from test into main
Reviewed-on: #299
2025-03-28 05:29:40 +00:00
klaus 83028f7817 Merge pull request 'test' (#298) from test into main
Reviewed-on: #298
2025-03-26 21:08:29 +00:00
klaus 70d1795557 Merge pull request 'test' (#297) from test into main
Reviewed-on: #297
2025-03-26 04:23:28 +00:00
klaus 8c6c681424 Merge pull request 'marketing 정보 업데이트 시 pid 값이 있으면 항상 로그인 기록 남기기' (#296) from test into main
Reviewed-on: #296
2025-03-25 11:25:46 +00:00
klaus 50bc9f4ff3 Merge pull request '라이브 방 - 예약 중 조회' (#295) from test into main
Reviewed-on: #295
2025-03-24 10:04:08 +00:00
klaus f00ea03fad Merge pull request 'test' (#294) from test into main
Reviewed-on: #294
2025-03-24 09:09:16 +00:00
klaus f22e7b9ad1 Merge pull request '자동생성 닉네임에 사용될 형용사, 명사 값 추가' (#293) from test into main
Reviewed-on: #293
2025-03-21 10:27:30 +00:00
klaus c7ec95f4bb Merge pull request 'test' (#292) from test into main
Reviewed-on: #292
2025-03-20 19:24:03 +00:00
klaus 229e7a8ccc Merge pull request '시리즈 상세, 채널 상세' (#291) from test into main
Reviewed-on: #291
2025-03-19 09:43:06 +00:00
klaus 3c616474ff Merge pull request 'test' (#290) from test into main
Reviewed-on: #290
2025-03-19 07:51:25 +00:00
klaus 56eb6b3ce3 Merge pull request '19금 콘텐츠 보기 설정 적용' (#289) from test into main
Reviewed-on: #289
2025-03-19 02:05:17 +00:00
klaus 545836d43c Merge pull request '관리자 광고통계, 일별 전체 회원 수' (#288) from test into main
Reviewed-on: #288
2025-03-17 08:50:59 +00:00
klaus 219f83dec0 Merge pull request 'test' (#287) from test into main
Reviewed-on: #287
2025-03-17 05:54:05 +00:00
klaus a76a841238 Merge pull request 'test' (#286) from test into main
Reviewed-on: #286
2025-03-14 16:11:17 +00:00
klaus c26680de84 Merge pull request '이벤트 배너, 충전 이벤트 - 기간 설정에 시간 추가' (#285) from test into main
Reviewed-on: #285
2025-03-14 03:40:07 +00:00
klaus 8fffad9d3a Merge pull request 'test' (#284) from test into main
Reviewed-on: #284
2025-03-13 12:25:35 +00:00
klaus f4f0f203a2 Merge pull request '유저 정보 조회' (#283) from test into main
Reviewed-on: #283
2025-03-12 08:00:13 +00:00
klaus b7196f5a0c Merge pull request 'test' (#282) from test into main
Reviewed-on: #282
2025-03-11 08:01:05 +00:00
klaus 5d33a18890 Merge pull request 'test' (#281) from test into main
Reviewed-on: #281
2025-03-10 05:35:30 +00:00
klaus 96186a1a50 Merge pull request '마케팅 - 매체 파트너 코드 조회 API - link 값 수정' (#280) from test into main
Reviewed-on: #280
2025-03-07 06:27:08 +00:00
klaus bc8bc479d1 Merge pull request 'test' (#279) from test into main
Reviewed-on: #279
2025-03-06 17:58:32 +00:00
klaus 47595b1291 Merge pull request 'test' (#278) from test into main
Reviewed-on: #278
2025-03-05 14:05:47 +00:00
klaus 01a88964df Merge pull request 'test' (#277) from test into main
Reviewed-on: #277
2025-03-05 09:44:59 +00:00
klaus 3a2b77379f Merge pull request '콘텐츠 업로드' (#276) from test into main
Reviewed-on: #276
2025-02-28 04:45:04 +00:00
klaus dc4e5f75cd Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#275) from test into main
Reviewed-on: #275
2025-02-26 03:14:33 +00:00
klaus d0178d551c Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#274) from test into main
Reviewed-on: #274
2025-02-25 14:54:53 +00:00
klaus 827333108d Merge pull request '콘텐츠 대여기간' (#273) from test into main
Reviewed-on: #273
2025-02-25 14:02:18 +00:00
klaus 587b90bd27 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 콘텐츠' (#272) from test into main
Reviewed-on: #272
2025-02-22 01:56:49 +00:00
klaus 4dc20c5e90 Merge pull request '콘텐츠 메인 무료 탭' (#271) from test into main
Reviewed-on: #271
2025-02-22 00:39:09 +00:00
klaus ac25782f2b Merge pull request '관리자 태그 큐레이션 - 콘텐츠 검색' (#270) from test into main
Reviewed-on: #270
2025-02-21 21:46:15 +00:00
klaus 20437d56e7 Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#269) from test into main
Reviewed-on: #269
2025-02-21 21:15:52 +00:00
klaus f0b412828a Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#268) from test into main
Reviewed-on: #268
2025-02-21 19:27:33 +00:00
klaus 367faac5c3 Merge pull request 'test' (#267) from test into main
Reviewed-on: #267
2025-02-20 18:24:35 +00:00
klaus 84deaaa970 Merge pull request '콘텐츠 메인 시리즈 탭 - 장르별 시리즈' (#266) from test into main
Reviewed-on: #266
2025-02-19 12:52:17 +00:00
klaus a2b39466c2 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#265) from test into main
Reviewed-on: #265
2025-02-19 11:34:02 +00:00
klaus 03586c4005 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#264) from test into main
Reviewed-on: #264
2025-02-19 09:49:04 +00:00
klaus 6ea69e1510 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠' (#263) from test into main
Reviewed-on: #263
2025-02-19 09:24:24 +00:00
klaus 553c6dc539 Merge pull request '콘텐츠 메인 단편 탭 - 새로운 단편' (#262) from test into main
Reviewed-on: #262
2025-02-19 08:20:14 +00:00
klaus 6cc22f5b6d Merge pull request '콘텐츠 메인 홈, 무료 탭' (#261) from test into main
Reviewed-on: #261
2025-02-19 06:34:53 +00:00
klaus 9103d67cc1 Merge pull request 'test' (#260) from test into main
Reviewed-on: #260
2025-02-18 18:13:25 +00:00
klaus 25083fb0e4 Merge pull request 'test' (#259) from test into main
Reviewed-on: #259
2025-02-18 14:48:09 +00:00
klaus d2dc045255 Merge pull request 'test' (#258) from test into main
Reviewed-on: #258
2025-02-14 18:09:11 +00:00
klaus b8621dfbb0 Merge pull request 'test' (#257) from test into main
Reviewed-on: #257
2025-02-09 13:36:21 +00:00
klaus 93633940dd Merge pull request 'test' (#256) from test into main
Reviewed-on: #256
2025-02-03 07:20:32 +00:00
klaus b6f5325351 Merge pull request 'test' (#255) from test into main
Reviewed-on: #255
2025-01-31 15:22:23 +00:00
klaus 7c32c08f1f Merge pull request 'test' (#254) from test into main
Reviewed-on: #254
2025-01-17 05:46:00 +00:00
klaus 1d268da08d Merge pull request '오디션 등록 푸시알림 메시지 수정' (#253) from test into main
Reviewed-on: #253
2025-01-10 10:23:19 +00:00
klaus 797666ae0d Merge pull request 'test' (#252) from test into main
Reviewed-on: #252
2025-01-08 14:11:08 +00:00
klaus dcf470997e Merge pull request 'test' (#251) from test into main
Reviewed-on: #251
2025-01-08 06:29:33 +00:00
klaus 0974d1dbf8 Merge pull request '관리자 오디션 지원 리스트' (#250) from test into main
Reviewed-on: #250
2025-01-07 19:44:39 +00:00
klaus 12a35db6cd Merge pull request '오디션' (#249) from test into main
Reviewed-on: #249
2025-01-07 17:24:40 +00:00
klaus 9abbb05ad8 Merge pull request 'test' (#248) from test into main
Reviewed-on: #248
2024-12-18 07:10:01 +00:00
klaus 1ecaf69b0b Merge pull request 'test' (#247) from test into main
Reviewed-on: #247
2024-12-17 13:43:45 +00:00
klaus e334d1e5d9 Merge pull request '콘텐츠 댓글 푸시 대상자' (#246) from test into main
Reviewed-on: #246
2024-12-03 15:54:34 +00:00
klaus b735e861d0 Merge pull request '콘텐츠 댓글 푸시 대상자 조회' (#245) from test into main
Reviewed-on: #245
2024-12-02 15:06:49 +00:00
klaus 4eb433d372 Merge pull request 'test' (#244) from test into main
Reviewed-on: #244
2024-12-02 12:05:30 +00:00
klaus 2416ae61f3 Merge pull request 'test' (#243) from test into main
Reviewed-on: #243
2024-12-02 04:29:50 +00:00
klaus 01fb336985 Merge pull request '콘텐츠 등록' (#242) from test into main
Reviewed-on: #242
2024-11-26 12:46:24 +00:00
klaus b6af88a732 Merge pull request 'test' (#241) from test into main
Reviewed-on: #241
2024-11-26 05:33:45 +00:00
klaus 58a2a17d6d Merge pull request 'test' (#240) from test into main
Reviewed-on: #240
2024-11-23 17:59:23 +00:00
klaus 79f5a0f520 Merge pull request '내 콘텐츠 수정, 삭제 시 콘텐츠 조회 함수' (#239) from test into main
Reviewed-on: #239
2024-11-21 06:34:30 +00:00
klaus 7f6c0f7f04 Merge pull request 'Redis connection 수정' (#238) from test into main
Reviewed-on: #238
2024-11-20 09:58:56 +00:00
klaus f658df4dca Merge pull request 'Redis connection' (#237) from test into main
Reviewed-on: #237
2024-11-20 07:52:58 +00:00
klaus 9d43b8e23a Merge pull request 'Redis connection' (#236) from test into main
Reviewed-on: #236
2024-11-20 06:47:52 +00:00
klaus 4270aef79b Merge pull request 'test' (#235) from test into main
Reviewed-on: #235
2024-11-11 15:34:35 +00:00
klaus 1c0dc82d44 Merge pull request '콘텐츠 구매 - 소장만 추가' (#234) from test into main
Reviewed-on: #234
2024-11-08 12:40:29 +00:00
klaus c1e325aadf Merge pull request 'test' (#233) from test into main
Reviewed-on: #233
2024-11-05 07:26:19 +00:00
klaus cec87da69d Merge pull request '콘텐츠 대여가격' (#232) from test into main
Reviewed-on: #232
2024-10-31 05:23:01 +00:00
klaus f68f24cb2c Merge pull request 'test' (#231) from test into main
Reviewed-on: #231
2024-10-31 03:09:13 +00:00
klaus ed094347fc Merge pull request '라이브 방 후원랭킹' (#230) from test into main
Reviewed-on: #230
2024-10-30 05:16:05 +00:00
klaus b8afdffbe1 Merge pull request 'test' (#229) from test into main
Reviewed-on: #229
2024-10-29 08:35:58 +00:00
klaus f6ba79f31c Merge pull request 'test' (#228) from test into main
Reviewed-on: #228
2024-10-25 03:45:26 +00:00
klaus 5f3b1663d2 Merge pull request '관리자 - 콘텐츠 리스트' (#227) from test into main
Reviewed-on: #227
2024-10-16 03:33:23 +00:00
klaus 66e786b4bb Merge pull request '관리자 - 시리즈 리스트 API' (#226) from test into main
Reviewed-on: #226
2024-10-14 15:39:45 +00:00
klaus f671114574 Merge pull request '관리자 - 시리즈 리스트 API' (#225) from test into main
Reviewed-on: #225
2024-10-14 10:37:28 +00:00
klaus ce37060d94 Merge pull request '관리자 - 시리즈 리스트 API 추가' (#224) from test into main
Reviewed-on: #224
2024-10-14 10:10:43 +00:00
klaus 7d19a4d184 Merge pull request 'test' (#223) from test into main
Reviewed-on: #223
2024-10-13 17:30:25 +00:00
klaus 22f28a2f8a Merge pull request '콘텐츠 메인 - 추천시리즈, 새로운 콘텐츠, 큐레이션' (#222) from test into main
Reviewed-on: #222
2024-10-13 16:33:15 +00:00
klaus ceef9ca979 Merge pull request 'test' (#221) from test into main
Reviewed-on: #221
2024-10-11 05:06:22 +00:00
klaus efe8f4f939 Merge pull request '콘텐츠 메인 - 새로운 콘텐츠 섹션 두번째 정렬 조건 추가' (#220) from test into main
Reviewed-on: #220
2024-10-04 07:21:38 +00:00
klaus ba692a1195 Merge pull request '시리즈 상세 - 콘텐츠 리스트 두번째 정렬 조건 추가' (#219) from test into main
Reviewed-on: #219
2024-10-04 02:41:28 +00:00
klaus d732bad042 Merge pull request '시리즈 상세' (#218) from test into main
Reviewed-on: #218
2024-10-02 09:18:19 +00:00
klaus 4c935c3bee Merge pull request '예약 라이브 개수 제한' (#217) from test into main
Reviewed-on: #217
2024-09-25 05:42:45 +00:00
klaus c160dd791f Merge pull request 'test' (#216) from test into main
Reviewed-on: #216
2024-09-24 10:17:58 +00:00
klaus 23cd1b4601 Merge pull request '라이브 후원현황 API' (#215) from test into main
Reviewed-on: #215
2024-09-23 13:58:14 +00:00
klaus 031fc8ba1b Merge pull request 'test' (#214) from test into main
Reviewed-on: #214
2024-09-23 06:24:12 +00:00
klaus c6853289ad Merge pull request 'test' (#213) from test into main
Reviewed-on: #213
2024-09-11 08:23:08 +00:00
klaus 2497bb69bc Merge pull request 'test' (#212) from test into main
Reviewed-on: #212
2024-09-11 07:47:35 +00:00
klaus a58a67e0a2 Merge pull request 'test' (#211) from test into main
Reviewed-on: #211
2024-09-11 06:00:31 +00:00
klaus 4315fe12a5 Merge pull request 'test' (#210) from test into main
Reviewed-on: #210
2024-09-06 19:00:39 +00:00
klaus 42f10a8899 Merge pull request 'test' (#209) from test into main
Reviewed-on: #209
2024-09-05 10:12:14 +00:00
klaus 1e4b47f989 Merge pull request 'test' (#208) from test into main
Reviewed-on: #208
2024-08-30 09:17:41 +00:00
klaus ff255dbfae Merge pull request 'test' (#207) from test into main
Reviewed-on: #207
2024-08-27 07:31:05 +00:00
klaus dbe9b72feb Merge pull request 'test' (#206) from test into main
Reviewed-on: #206
2024-08-23 13:48:24 +00:00
klaus 95a714b391 Merge pull request '탐색' (#205) from test into main
Reviewed-on: #205
2024-08-19 13:21:31 +00:00
klaus 28f58c7f56 Merge pull request '라이브' (#204) from test into main
Reviewed-on: #204
2024-08-14 09:34:52 +00:00
klaus 8bd46d8f21 Merge pull request '크리에이터 관리자 시리즈' (#203) from test into main
Reviewed-on: #203
2024-08-14 07:41:33 +00:00
klaus e1bb8e54ed Merge pull request '크리에이터 커뮤니티' (#202) from test into main
Reviewed-on: #202
2024-08-06 11:41:11 +00:00
klaus 1de705b063 Merge pull request '크리에이터 커뮤니티' (#201) from test into main
Reviewed-on: #201
2024-08-06 06:37:30 +00:00
klaus f6926ad356 Merge pull request '남/여 크리에이터에서 특정 크리에이터 제거' (#200) from test into main
Reviewed-on: #200
2024-07-26 07:54:18 +00:00
klaus 2cdbbb1b37 Merge pull request 'test' (#199) from test into main
Reviewed-on: #199
2024-07-25 16:08:20 +00:00
klaus 4dce8c8f03 Merge pull request '크리에이터 커뮤니티' (#198) from test into main
Reviewed-on: #198
2024-07-10 05:24:58 +00:00
klaus 97a5bace6f Merge pull request 'test' (#197) from test into main
Reviewed-on: #197
2024-07-08 14:17:42 +00:00
klaus d4d51ec48f Merge pull request 'test' (#196) from test into main
Reviewed-on: #196
2024-07-02 08:57:11 +00:00
klaus fb91398462 Merge pull request '커뮤니티 게시물' (#195) from test into main
Reviewed-on: #195
2024-06-17 14:09:26 +00:00
klaus 105dadd798 Merge pull request '커뮤니티 게시물' (#194) from test into main
Reviewed-on: #194
2024-06-15 11:57:33 +00:00
klaus 2abf2837d3 Merge pull request '커뮤니티 게시물' (#193) from test into main
Reviewed-on: #193
2024-06-11 12:13:47 +00:00
klaus 422aa67af6 Merge pull request '커뮤니티 게시물' (#192) from test into main
Reviewed-on: #192
2024-06-11 11:55:05 +00:00
klaus 7fffab6985 Merge pull request '크리에이터 정산 - 입력된 비율로 계산' (#191) from test into main
Reviewed-on: #191
2024-06-11 08:07:22 +00:00
klaus 5a4be3d2c1 Merge pull request '콘텐츠 상세' (#190) from test into main
Reviewed-on: #190
2024-06-07 10:30:06 +00:00
klaus f39a7681db Merge pull request 'test' (#189) from test into main
Reviewed-on: #189
2024-06-04 03:39:23 +00:00
klaus c60a7580ba Merge pull request 'test' (#188) from test into main
Reviewed-on: #188
2024-06-03 22:13:56 +00:00
klaus 97edb56edc Merge pull request 'test' (#187) from test into main
Reviewed-on: #187
2024-05-29 17:04:39 +00:00
klaus 6ebca8d22b Merge pull request '관리자 - 라이브 리스트' (#186) from test into main
Reviewed-on: #186
2024-05-28 18:18:44 +00:00
klaus 95371ad934 Merge pull request '(크리에이터)관리자 커뮤니티 게시물 정산' (#185) from test into main
Reviewed-on: #185
2024-05-28 16:54:44 +00:00
klaus 2c176825fd Merge pull request 'test' (#184) from test into main
Reviewed-on: #184
2024-05-28 16:09:51 +00:00
klaus fae7de48d3 Merge pull request 'test' (#183) from test into main
Reviewed-on: #183
2024-05-27 08:28:27 +00:00
klaus b8230646a2 Merge pull request '커뮤니티 게시글 유료화' (#182) from test into main
Reviewed-on: #182
2024-05-24 14:44:14 +00:00
klaus 43279541dd Merge pull request '콘텐츠별 누적정산' (#181) from test into main
Reviewed-on: #181
2024-05-21 06:37:51 +00:00
klaus b4791977c1 Merge pull request 'PG 심사를 위한 캔 충전 로직 추가' (#180) from test into main
Reviewed-on: #180
2024-05-20 06:38:40 +00:00
klaus ef917ecc25 Merge pull request '라이브 방 - 크리에이터 입장 가능 설정 추가' (#179) from test into main
Reviewed-on: #179
2024-05-14 12:09:53 +00:00
klaus a93faad951 Merge pull request '룰렛 방식 수정' (#178) from test into main
Reviewed-on: #178
2024-05-10 18:00:40 +00:00
klaus fd001d24d3 Merge pull request '추천시리즈 API 추가' (#177) from test into main
Reviewed-on: #177
2024-05-07 10:34:10 +00:00
klaus 7aa5884797 Merge pull request '구글 인 앱 결제 검증코드 수정' (#176) from test into main
Reviewed-on: #176
2024-05-03 10:06:39 +00:00
klaus 5b237a1547 Merge pull request 'test' (#175) from test into main
Reviewed-on: #175
2024-05-03 06:08:55 +00:00
klaus 2e37990d87 Merge pull request '탐색 - 남/여 크리에이터 리스트' (#174) from test into main
Reviewed-on: #174
2024-05-02 17:24:18 +00:00
klaus dd07d724a8 Merge pull request 'test' (#173) from test into main
Reviewed-on: #173
2024-05-02 16:41:50 +00:00
klaus 03ce8618e7 Merge pull request '관리자 시그니처 설정' (#172) from test into main
Reviewed-on: #172
2024-05-02 07:13:21 +00:00
klaus db1a7a7fd6 Merge pull request '시그니처 후원 시간 추가' (#171) from test into main
Reviewed-on: #171
2024-05-02 06:23:22 +00:00
klaus 36a82d7f53 Merge pull request '시리즈, 시리즈 콘텐츠' (#170) from test into main
Reviewed-on: #170
2024-04-30 14:00:01 +00:00
klaus 3a34401113 Merge pull request '시리즈 상세' (#169) from test into main
Reviewed-on: #169
2024-04-30 09:44:55 +00:00
klaus 9927268330 Merge pull request '구글 인 앱 결제' (#168) from test into main
Reviewed-on: #168
2024-04-30 08:24:03 +00:00
klaus c45c97e29d Merge pull request '시리즈' (#167) from test into main
Reviewed-on: #167
2024-04-26 18:51:10 +00:00
klaus c64a315226 Merge pull request 'test' (#166) from test into main
Reviewed-on: #166
2024-04-18 16:40:55 +00:00
klaus a4cafca6ab Merge pull request 'test' (#165) from test into main
Reviewed-on: #165
2024-04-18 10:02:45 +00:00
klaus 46284a0660 Merge pull request 'test' (#164) from test into main
Reviewed-on: #164
2024-04-15 12:31:42 +00:00
klaus 05df86e15a Merge pull request 'test' (#163) from test into main
Reviewed-on: #163
2024-04-09 14:40:33 +00:00
klaus 8b433027e2 Merge pull request 'test' (#162) from test into main
Reviewed-on: #162
2024-04-09 13:27:24 +00:00
klaus 5bd4ff7610 Merge pull request '결제 테이블에 구글결제의 경우 orderId 추가' (#161) from test into main
Reviewed-on: #161
2024-04-05 03:10:00 +00:00
klaus d693c397ea Merge pull request '.' (#160) from test into main
Reviewed-on: #160
2024-04-03 06:49:36 +00:00
klaus 1d8d1ec9a5 Merge pull request 'test' (#159) from test into main
Reviewed-on: #159
2024-04-03 06:27:26 +00:00
klaus 5e491f11ee Merge pull request '크리에이터 관리자 라이브 정산' (#158) from test into main
Reviewed-on: #158
2024-04-03 03:45:31 +00:00
klaus 7cedea06ac Merge pull request '캔 사용' (#157) from test into main
Reviewed-on: #157
2024-04-01 12:42:44 +00:00
klaus 2e5f750e50 Merge pull request 'test' (#156) from test into main
Reviewed-on: #156
2024-04-01 10:20:09 +00:00
klaus 20289cad10 Merge pull request '콘텐츠 상세' (#155) from test into main
Reviewed-on: #155
2024-03-29 10:10:04 +00:00
klaus e0d64c31c7 Merge pull request '콘텐츠 상세' (#154) from test into main
Reviewed-on: #154
2024-03-29 08:18:15 +00:00
klaus 8c1b95dc97 Merge pull request '구글 인 앱구매 검증' (#153) from test into main
Reviewed-on: #153
2024-03-28 17:13:57 +00:00
klaus fb5641343e Merge pull request 'test' (#152) from test into main
Reviewed-on: #152
2024-03-28 06:31:43 +00:00
klaus 87765941eb Merge pull request '구글 인 앱 결제 검증' (#151) from test into main
Reviewed-on: #151
2024-03-22 20:10:01 +00:00
klaus 1809862c16 Merge pull request '구글 인 앱 결제 처리과정 축소' (#150) from test into main
Reviewed-on: #150
2024-03-22 15:27:25 +00:00
klaus 300f784f7d Merge pull request '구글 인 앱 결제 검증 과정 try/catch로 예외 처리' (#149) from test into main
Reviewed-on: #149
2024-03-22 11:59:37 +00:00
klaus 67a045eae6 Merge pull request '구글 인 앱 결제 검증 수정' (#148) from test into main
Reviewed-on: #148
2024-03-22 11:36:35 +00:00
klaus 2a79903a28 Merge pull request 'test' (#147) from test into main
Reviewed-on: #147
2024-03-22 10:08:00 +00:00
klaus d3222ce083 Merge pull request '관리자 캔 충전현황' (#146) from test into main
Reviewed-on: #146
2024-03-21 15:56:02 +00:00
klaus 406a421742 Merge pull request '구글 인 앱 결제 검증 코드 수정' (#145) from test into main
Reviewed-on: #145
2024-03-21 15:22:34 +00:00
klaus 10bf728faf Merge pull request '구글 인 앱 결제 검증 코드 수정' (#144) from test into main
Reviewed-on: #144
2024-03-21 14:37:09 +00:00
klaus 607617747c Merge pull request '구글 인 앱 결제 검증 코드 수정' (#143) from test into main
Reviewed-on: #143
2024-03-21 12:47:51 +00:00
klaus f0a69eb1a2 Merge pull request 'test' (#142) from test into main
Reviewed-on: #142
2024-03-21 07:45:02 +00:00
klaus 6b307a6e17 Merge pull request 'test' (#141) from test into main
Reviewed-on: #141
2024-03-13 11:28:13 +00:00
klaus 08d08a934a Merge pull request 'test' (#140) from test into main
Reviewed-on: #140
2024-03-12 06:20:02 +00:00
klaus c500c12668 Merge pull request 'test' (#139) from test into main
Reviewed-on: #139
2024-03-08 13:40:27 +00:00
klaus 62060adeba Merge pull request '채널 후원 랭킹' (#138) from test into main
Reviewed-on: #138
2024-02-29 10:28:51 +00:00
klaus b2fc75edb8 Merge pull request '룰렛 정렬 순서 수정' (#137) from test into main
Reviewed-on: #137
2024-02-27 17:29:36 +00:00
klaus a999dd2085 Merge pull request 'test' (#136) from test into main
Reviewed-on: #136
2024-02-27 16:16:30 +00:00
klaus 49f95ab100 Merge pull request '회원테이블에 adid 추가' (#135) from test into main
Reviewed-on: #135
2024-02-27 05:49:47 +00:00
klaus 1a84d5b30c Merge pull request 'test' (#134) from test into main
Reviewed-on: #134
2024-02-24 20:35:57 +00:00
klaus 3b65050632 Merge pull request 'redis ssl false' (#133) from test into main
Reviewed-on: #133
2024-02-17 13:00:25 +00:00
klaus d0df31674c Merge pull request 'test' (#132) from test into main
Reviewed-on: #132
2024-02-17 12:44:52 +00:00
klaus 1fe88402e2 Merge pull request 'test' (#131) from test into main
Reviewed-on: #131
2024-02-14 07:12:41 +00:00
klaus 67097696e6 Merge pull request '커뮤니티 게시물 시간' (#130) from test into main
Reviewed-on: #130
2024-02-12 08:14:24 +00:00
klaus 8e7e77067a Merge pull request 'test' (#129) from test into main
Reviewed-on: #129
2024-02-12 07:53:26 +00:00
klaus 9899390b61 Merge pull request '관리자 콘텐츠 리스트, 수정' (#128) from test into main
Reviewed-on: #128
2024-02-08 18:19:50 +00:00
klaus 80c476a908 Merge pull request '관리자 콘텐츠 리스트' (#127) from test into main
Reviewed-on: #127
2024-02-08 14:45:54 +00:00
klaus 59da1d6e49 Merge pull request '카테고리 콘텐츠' (#126) from test into main
Reviewed-on: #126
2024-02-07 13:33:37 +00:00
klaus 5aef7dac33 Merge pull request 'test' (#125) from test into main
Reviewed-on: #125
2024-02-07 09:39:09 +00:00
klaus faf7aa06b6 Merge pull request 'test' (#124) from test into main
Reviewed-on: #124
2024-02-05 07:03:45 +00:00
klaus 38ef6e5583 Merge pull request 'test' (#123) from test into main
Reviewed-on: #123
2024-02-05 02:12:10 +00:00
klaus c0b15b5d94 Merge pull request '콘텐츠 업로드' (#122) from test into main
Reviewed-on: #122
2024-01-30 03:45:31 +00:00
klaus 2cfc067ea1 Merge pull request '콘텐츠 전체 리스트' (#121) from test into main
Reviewed-on: #121
2024-01-29 09:00:23 +00:00
klaus a91db4f956 Merge pull request '콘텐츠 상단 고정 기능 추가' (#120) from test into main
Reviewed-on: #120
2024-01-29 02:45:41 +00:00
klaus 8a09780a02 Merge pull request 'test' (#119) from test into main
Reviewed-on: #119
2024-01-26 06:19:49 +00:00
klaus 45e8ec6505 Merge pull request '콘텐츠 정렬 기준' (#118) from test into main
Reviewed-on: #118
2024-01-24 15:03:06 +00:00
klaus 4554b85914 Merge pull request '회원가입 시 닉네임 validation 조건' (#117) from test into main
Reviewed-on: #117
2024-01-24 07:11:23 +00:00
klaus 8aa79c4a9c Merge pull request '콘텐츠 등록 - 태그 등록' (#116) from test into main
Reviewed-on: #116
2024-01-16 15:19:59 +00:00
klaus c8d3210b57 Merge pull request 'test' (#115) from test into main
Reviewed-on: #115
2024-01-11 09:05:44 +00:00
klaus 2282a49563 Merge pull request 'test' (#114) from test into main
Reviewed-on: #114
2024-01-11 03:49:53 +00:00
klaus b82fdfb2c8 Merge pull request '예약 업로드' (#113) from test into main
Reviewed-on: #113
2024-01-10 16:59:51 +00:00
klaus 2d17eac199 Merge pull request '19세 미만이 인증처리 되던 버그 수정' (#112) from test into main
Reviewed-on: #112
2024-01-08 10:07:21 +00:00
klaus e482bc3aad Merge pull request '콘텐츠를 올린 크리에이터가 댓글을 삭제할 수 있도록 수정' (#111) from test into main
Reviewed-on: #111
2024-01-04 11:36:43 +00:00
klaus ec022b74d1 Merge pull request '캔 쿠폰 조회 로직 수정' (#110) from test into main
Reviewed-on: #110
2024-01-04 09:59:20 +00:00
klaus dc42c09ce3 Merge pull request '캔 쿠폰 조회' (#109) from test into main
Reviewed-on: #109
2024-01-03 15:48:09 +00:00
klaus 046a34d2a4 Merge pull request 'test' (#108) from test into main
Reviewed-on: #108
2024-01-03 15:19:42 +00:00
klaus 9ff6ec1888 Merge pull request '캔 쿠폰 시스템' (#107) from test into main
Reviewed-on: #107
2024-01-03 11:28:48 +00:00
klaus d2950106ec Merge pull request '콘텐츠 랭킹 - 후원 순위 제거, 룰렛 아이템 개수 10로 변경' (#106) from test into main
Reviewed-on: #106
2023-12-26 12:50:09 +00:00
klaus 962f800d2e Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 인증하지 않은 사람은 19금이 아닌 최신 게시물이 조회되도록 수정' (#105) from test into main
Reviewed-on: #105
2023-12-25 08:19:43 +00:00
klaus 962107e507 Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#104) from test into main
Reviewed-on: #104
2023-12-21 19:28:10 +00:00
klaus 039bd11963 Merge pull request '커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#103) from test into main
Reviewed-on: #103
2023-12-21 19:05:12 +00:00
klaus 5c250ea4ae Merge pull request '크리에이터 커뮤니티' (#102) from test into main
Reviewed-on: #102
2023-12-21 15:10:55 +00:00
klaus e3405bcec6 Merge pull request 'test' (#101) from test into main
Reviewed-on: #101
2023-12-13 16:14:50 +00:00
klaus 0fd1c2235f Merge pull request '라이브 정산 - 정렬 순서 추가 (라이브 방 id, 구분)' (#100) from test into main
Reviewed-on: #100
2023-12-10 16:58:05 +00:00
klaus b20c29b022 Merge pull request 'test' (#99) from test into main
Reviewed-on: #99
2023-12-10 12:34:51 +00:00
klaus 12d5dcd298 Merge pull request 'test' (#98) from test into main
Reviewed-on: #98
2023-12-10 09:02:29 +00:00
klaus 2c305dc6c6 Merge pull request 'test' (#97) from test into main
Reviewed-on: #97
2023-12-10 06:48:43 +00:00
klaus 62f76f7433 Merge pull request 'test' (#96) from test into main
Reviewed-on: #96
2023-12-07 01:46:33 +00:00
klaus 858ce524f9 Merge pull request 'test' (#95) from test into main
Reviewed-on: #95
2023-11-27 12:48:24 +00:00
klaus 3795fb4a40 Merge pull request 'test' (#94) from test into main
Reviewed-on: #94
2023-11-24 07:03:10 +00:00
klaus 0c01aeec50 Merge pull request '관리자 - 이벤트 배너 등록' (#93) from test into main
Reviewed-on: #93
2023-11-21 16:21:19 +00:00
klaus 892206744d Merge pull request '이벤트 배너, 팝업' (#92) from test into main
Reviewed-on: #92
2023-11-21 12:59:30 +00:00
klaus 9e2c1474db Merge pull request '메시지 보내기 유저 검색' (#91) from test into main
Reviewed-on: #91
2023-11-20 05:41:57 +00:00
klaus 16328f73d9 Merge pull request '크리에이터 관리자, 관리자 - 일자별 콘텐츠 후원 정산 API' (#90) from test into main
Reviewed-on: #90
2023-11-14 13:14:19 +00:00
klaus e0d4f53cf4 Merge pull request 'test' (#89) from test into main
Reviewed-on: #89
2023-11-14 12:15:51 +00:00
klaus e09a59c5b4 Merge pull request 'test' (#88) from test into main
Reviewed-on: #88
2023-11-14 11:09:14 +00:00
klaus 049e654535 Merge pull request '관리자 일자별 콘텐츠 후원 정산 - 크리에이터 순으로 정렬' (#87) from test into main
Reviewed-on: #87
2023-11-14 09:22:38 +00:00
klaus c927dc4ecd Merge pull request '관리자 - 일자별 콘텐츠 후원 정산 페이지 추가' (#86) from test into main
Reviewed-on: #86
2023-11-14 09:03:35 +00:00
klaus fe4ecd0ad8 Merge pull request '크리에이터 관리자 - 일자별 콘텐츠 정산 페이징 안되는 버그 수정' (#85) from test into main
Reviewed-on: #85
2023-11-14 05:17:58 +00:00
klaus 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
klaus a11c8465d5 Merge pull request '크리에이터 관리자 - @JsonProperty 추가' (#83) from test into main
Reviewed-on: #83
2023-11-13 16:10:57 +00:00
klaus 366304a9b7 Merge pull request '크리에이터 관리자 - 정산 API 캐시 추가' (#82) from test into main
Reviewed-on: #82
2023-11-13 15:56:58 +00:00
klaus 4356663688 Merge pull request '크리에이터 관리자 - 콘텐츠 누적 매출 API' (#81) from test into main
Reviewed-on: #81
2023-11-13 15:23:53 +00:00
klaus 26b55e6fcf Merge pull request '콘텐츠 누적 매출 API - orderType 추가' (#80) from test into main
Reviewed-on: #80
2023-11-13 14:48:47 +00:00
klaus 0d743f7204 Merge pull request '콘텐츠 누적 매출 API 추가' (#79) from test into main
Reviewed-on: #79
2023-11-13 13:42:17 +00:00
klaus 6cbe113b3e Merge pull request '크리에이터 콘텐츠 정산 - API 추가' (#78) from test into main
Reviewed-on: #78
2023-11-13 10:03:02 +00:00
klaus 6409b69d6c Merge pull request '콘텐츠 정산 - 결과값에 JsonProperty 를 추가하여 데이터 파싱이 진행 되도록 수정' (#77) from test into main
Reviewed-on: #77
2023-11-10 13:19:06 +00:00
klaus c5164c76fc Merge pull request '콘텐츠 정산 - group by 날짜 수정' (#76) from test into main
Reviewed-on: #76
2023-11-10 12:46:07 +00:00
klaus baade8e138 Merge pull request 'test' (#75) from test into main
Reviewed-on: #75
2023-11-10 10:47:11 +00:00
klaus b848d6b4e0 Merge pull request 'test' (#74) from test into main
Reviewed-on: #74
2023-11-09 11:18:20 +00:00
klaus d8139d2ab0 Merge pull request '라이브 리스트, 라이브 상세' (#73) from test into main
Reviewed-on: #73
2023-11-07 16:48:25 +00:00
klaus e96d8f7469 Merge pull request 'test' (#72) from test into main
Reviewed-on: #72
2023-11-07 16:23:11 +00:00
klaus 2acffd8afc Merge pull request '콘텐츠 메인 API - 캐싱을 적용하기 위해 AudioContentMainManageService 추가' (#71) from test into main
Reviewed-on: #71
2023-11-07 11:24:40 +00:00
klaus 3c8e72073c Merge pull request '콘텐츠 메인 API - @Transactional(readOnly = true) 추가' (#70) from test into main
Reviewed-on: #70
2023-11-07 08:47:47 +00:00
klaus 724d7a9d9b Merge pull request 'test' (#69) from test into main
Reviewed-on: #69
2023-11-07 08:21:56 +00:00
klaus 2da3b0db78 Merge pull request 'test' (#68) from test into main
Reviewed-on: #68
2023-11-06 09:26:36 +00:00
klaus 685ad7afaf Merge pull request '콘텐츠 메인 캐싱전략 수정' (#67) from test into main
Reviewed-on: #67
2023-11-06 08:46:55 +00:00
klaus 264cf75964 Merge pull request '콘텐츠 메인 - 큐레이션 개수 15개만 노출' (#66) from test into main
Reviewed-on: #66
2023-11-06 08:11:00 +00:00
klaus c773dbc7b5 Merge pull request '콘텐츠 랭킹 - 후원 랭킹 조회 로직 수정' (#65) from test into main
Reviewed-on: #65
2023-11-04 14:24:46 +00:00
klaus 37cbc64f52 Merge pull request '본인인증' (#64) from test into main
Reviewed-on: #64
2023-11-03 07:48:02 +00:00
klaus cb1dde17bb Merge pull request 'test' (#63) from test into main
Reviewed-on: #63
2023-11-02 12:18:29 +00:00
klaus c29988acf4 Merge pull request '콘텐츠 주문 - 대여만 가능한 콘텐츠의 경우 소장으로 주문이 들어오더라도 대여로 처리되도록 로직 수정' (#62) from test into main
Reviewed-on: #62
2023-11-01 04:49:18 +00:00
klaus eadbf56dae Merge pull request '정산테이블에 무료충전 코인도 반영되도록 수정' (#61) from test into main
Reviewed-on: #61
2023-10-28 08:42:29 +00:00
klaus 4b3b455135 Merge pull request '캔 사용 시 제휴보상 캔도 사용할 수 있도록 수정' (#60) from test into main
Reviewed-on: #60
2023-10-27 13:48:33 +00:00
klaus e6ac177396 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#59) from test into main
Reviewed-on: #59
2023-10-26 18:48:46 +00:00
klaus 3d0e29003f Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#58) from test into main
Reviewed-on: #58
2023-10-26 18:22:10 +00:00
klaus 78b9b00f77 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#57) from test into main
Reviewed-on: #57
2023-10-26 18:02:16 +00:00
klaus 0ee7faa551 Merge pull request '카울리를 이용한 무료충전 테이블 adProfit 과 point 타입 int -> float 로 변경' (#56) from test into main
Reviewed-on: #56
2023-10-26 16:55:39 +00:00
klaus e5fdced681 Merge pull request '콘텐츠 메인 캐싱 전략 변경' (#55) from test into main
Reviewed-on: #55
2023-10-26 16:14:37 +00:00
klaus afb99fef64 Merge pull request 'GetAudioContentMainItem - adult를 isAdult로 변경, 캐시 제거' (#54) from test into main
Reviewed-on: #54
2023-10-24 11:39:56 +00:00
klaus 7dfaa36024 Merge pull request 'test' (#53) from test into main
Reviewed-on: #53
2023-10-24 10:42:10 +00:00
klaus 0496f665aa Merge pull request 'getAudioContentMainBannerList 부분 캐시 제거' (#52) from test into main
Reviewed-on: #52
2023-10-17 10:02:53 +00:00
klaus 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
klaus 4aff0111aa Merge pull request '로딩 속도를 위해 @Cacheable 적용' (#50) from test into main
Reviewed-on: #50
2023-10-17 09:31:08 +00:00
klaus 63b3ba2bb2 Merge pull request '인기 콘텐츠 전체보기 집계날짜 수정' (#49) from test into main
Reviewed-on: #49
2023-10-16 13:59:28 +00:00
klaus 7444b41f60 Merge pull request '콘텐츠 메인 - 인기 콘텐츠 집계날짜 수정' (#48) from test into main
Reviewed-on: #48
2023-10-16 13:42:31 +00:00
klaus 8e90dbc8b6 Merge pull request '구매목록 - isActive 가 true 인 것만 조회되도록 수정' (#47) from test into main
Reviewed-on: #47
2023-10-16 03:30:10 +00:00
klaus 9f70722521 Merge pull request '탐색 인기 크리에이터 - 날짜 설명 글 수정' (#46) from test into main
Reviewed-on: #46
2023-10-14 21:43:48 +00:00
klaus 52fae596fa Merge pull request '콘텐츠 랭킹 데이터 전체보기 API - 페이징 추가' (#45) from test into main
Reviewed-on: #45
2023-10-14 21:20:19 +00:00
klaus ccb67957bc Merge pull request '콘텐츠 랭킹 추가' (#44) from test into main
Reviewed-on: #44
2023-10-14 19:37:55 +00:00
klaus fb82538d0d Merge pull request '캔 소비 - 콘텐츠 주문시 캔 소비내역에 콘텐츠 내용 추가' (#43) from test into main
Reviewed-on: #43
2023-10-14 11:33:59 +00:00
klaus 72ee39612e Merge pull request '탐색 - 인기 급상승 제거, 인기 크리에이터 섹션 추가' (#42) from test into main
Reviewed-on: #42
2023-10-13 15:41:25 +00:00
klaus 51fd5408dc Merge pull request 'test' (#41) from test into main
Reviewed-on: #41
2023-10-06 08:50:32 +00:00
klaus 3fae40fbef Merge pull request '콘텐츠 상세 - 댓글 수 로직 답글 포함하지 않도록 수정' (#40) from test into main
Reviewed-on: #40
2023-10-05 02:49:22 +00:00
klaus 0745890af0 Merge pull request 'test' (#39) from test into main
Reviewed-on: #39
2023-10-04 03:24:41 +00:00
klaus 4abe1730a7 Merge pull request '관리자 라이브 정산 API - 인원 추가' (#38) from test into main
Reviewed-on: #38
2023-10-03 12:10:51 +00:00
klaus 626f0e6989 Merge pull request '관리자 - 라이브 정산 API 추가' (#37) from test into main
Reviewed-on: #37
2023-10-03 09:28:04 +00:00
klaus 9f42d9d173 Merge pull request '라이브 시작 알림 - 알림 받을 유저 조회에서 에러가 발생하는 버그 수정' (#36) from test into main
Reviewed-on: #36
2023-10-02 13:00:11 +00:00
klaus f90a93c4bc Merge pull request '후원순위 - 유료라이브 입장 캔 반영' (#35) from test into main
Reviewed-on: #35
2023-09-27 14:57:27 +00:00
klaus 8000ad6c6a Merge pull request 'test' (#34) from test into main
Reviewed-on: #34
2023-09-27 06:49:21 +00:00
klaus 1f1f1bea1a Merge pull request 'test' (#33) from test into main
Reviewed-on: #33
2023-09-27 05:28:04 +00:00
klaus d95460c7cd Merge pull request '닉네임 변경 가격 100 캔으로 변경' (#32) from test into main
Reviewed-on: #32
2023-09-22 07:01:27 +00:00
klaus a3d93d4b08 Merge pull request 'test' (#31) from test into main
Reviewed-on: #31
2023-09-19 06:32:22 +00:00
klaus 07a92af982 Merge pull request '라이브 시작시간 4시간이 지나도 라이브를 시작하지 않은 경우 자동취소로직 추가' (#30) from test into main
Reviewed-on: #30
2023-09-12 14:09:37 +00:00
klaus f4618877d4 Merge pull request '주문목록 - 크리에이터 닉네임 추가' (#29) from test into main
Reviewed-on: #29
2023-09-08 16:29:30 +00:00
klaus 2b914fd222 Merge pull request 'test' (#28) from test into main
Reviewed-on: #28
2023-09-02 16:12:08 +00:00
klaus 109e42a5a3 Merge pull request 'test' (#27) from test into main
Reviewed-on: #27
2023-08-31 08:37:42 +00:00
klaus fa515ad39c Merge pull request '관리자 캔 충전내역 - 애플 인 앱 결제에 PG결제가 같이 나오던 버그 수정' (#26) from test into main
Reviewed-on: #26
2023-08-30 10:02:32 +00:00
klaus f09673a795 Merge pull request 'test' (#25) from test into main
Reviewed-on: #25
2023-08-30 08:10:29 +00:00
klaus f71536c614 Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2023-08-30 07:24:14 +00:00
klaus 7bdddc7ae8 Merge pull request '후원 전체보기 - 하단 랭킹에 콘텐츠 후원도 포함' (#23) from test into main
Reviewed-on: #23
2023-08-29 15:22:09 +00:00
klaus aa8926a624 Merge pull request '후원 전체보기 - 상단 캔 현황 을 후원 캔만 반영하도록 수정' (#22) from test into main
Reviewed-on: #22
2023-08-29 14:53:44 +00:00
klaus be71e59be2 Merge pull request '무료 콘텐츠를 못올리는 버그 수정' (#21) from test into main
Reviewed-on: #21
2023-08-29 13:32:13 +00:00
klaus 4d7753378f Merge pull request 'test' (#20) from test into main
Reviewed-on: #20
2023-08-29 07:33:57 +00:00
klaus 60257c4ef4 Merge pull request 'test' (#19) from test into main
Reviewed-on: #19
2023-08-28 08:54:08 +00:00
klaus 1e0b79bf62 Merge pull request 'test' (#18) from test into main
Reviewed-on: #18
2023-08-27 12:28:42 +00:00
klaus 6883434d0d Merge pull request '유저 관심사, 라이브 관심사 - 연령제한 설정 추가' (#17) from test into main
Reviewed-on: #17
2023-08-25 08:38:51 +00:00
klaus eda2193e64 Merge pull request 'test' (#16) from test into main
Reviewed-on: #16
2023-08-25 07:50:55 +00:00
klaus 99bf829c88 Merge pull request '첫 충전 이벤트 - 본인인증한 전체 계정 중 첫 충전 시에만 첫충전 이벤트 적용' (#15) from test into main
Reviewed-on: #15
2023-08-24 16:26:54 +00:00
klaus 5feafe1b48 Merge pull request 'test' (#14) from test into main
Reviewed-on: #14
2023-08-24 14:54:37 +00:00
klaus c9292b7d04 Merge pull request 'test' (#13) from test into main
Reviewed-on: #13
2023-08-23 14:05:00 +00:00
klaus ae7e1a91c1 Merge pull request '캔 충전로직 수정' (#12) from test into main
Reviewed-on: #12
2023-08-21 15:12:44 +00:00
klaus 3e1887e0d1 Merge pull request 'test' (#11) from test into main
Reviewed-on: #11
2023-08-21 13:19:03 +00:00
klaus 474646db47 Merge pull request '스피커 최대 10 -> 5명으로 수정' (#10) from test into main
Reviewed-on: #10
2023-08-20 20:40:23 +00:00
klaus 56f7b6c449 Merge pull request 'test' (#9) from test into main
Reviewed-on: #9
2023-08-20 19:22:23 +00:00
klaus 76b2b5f7e3 Merge pull request '캔 사용내역 - 후원을 콘텐츠 후원, 라이브 후원으로 분리' (#8) from test into main
Reviewed-on: #8
2023-08-20 15:46:30 +00:00
klaus e918d809eb Merge pull request '충전내역 - 관리자 지급 추가' (#7) from test into main
Reviewed-on: #7
2023-08-20 15:08:35 +00:00
klaus 7af059e543 Merge pull request 'test' (#6) from test into main
Reviewed-on: #6
2023-08-20 14:45:39 +00:00
klaus 897726e1ec Merge pull request 'test' (#5) from test into main
Reviewed-on: #5
2023-08-19 17:47:16 +00:00
klaus 8b98a2dd07 Merge pull request '비밀번호 찾기 API 추가' (#4) from test into main
Reviewed-on: #4
2023-08-19 07:05:17 +00:00
klaus cca75420f0 Merge pull request '큐레이션 - 조건 추가' (#3) from test into main
Reviewed-on: #3
2023-08-18 14:07:34 +00:00
klaus 86c627ed1d Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-18 12:54:09 +00:00
klaus d55514e3a7 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-08-16 02:30:36 +00:00
61 changed files with 3393 additions and 196 deletions
+1
View File
@@ -27,6 +27,7 @@ repositories {
dependencies {
implementation("org.redisson:redisson-spring-data-27:3.19.2")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
@@ -0,0 +1,144 @@
# OCI Blue/Green 배포 검증 endpoint 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-09-14 |
| 요구사항 기준 | `docs/20260914_배포검증readiness및deployment엔드포인트/prd.md` |
| 현재 Phase | Phase 1 배포 검증 endpoint |
| 현재 활성 Goal | 없음 |
## 목표
배포 스크립트가 애플리케이션 포트에서 readiness를, management 포트에서 readiness와 배포 식별자를 확인할 수 있다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `4/4` | 없음 | 없음 |
## 범위
### 포함
- `spring-boot-starter-actuator` 의존성 추가.
- `application.yml`(main/test)의 포트·probe·exposure·deployment property 설정.
- `DeploymentEndpoint` custom Actuator endpoint 구현.
- `SecurityConfig`의 `/readyz` permitAll 최소 변경.
- 위 동작을 검증하는 통합 test.
### 제외
- Kubernetes 의존성·설정.
- 배포 스크립트(`scripts/`, `appspec.yml`) 변경.
- 신규 추상화/라이브러리, 기존 API 변경.
## 기술적 제약
- 기술 스택: Kotlin 1.6.21, Spring Boot 2.7.14, Gradle Kotlin DSL, JUnit 5.
- 배포 식별자는 property placeholder만 사용하고 별도 환경변수 파싱·fallback 코드를 만들지 않는다.
- 응답 필드명은 snake_case 고정(`application_artifact_version`, `config_commit`).
- 검증: 신규 test 우선 실행 후 `./gradlew test` 전체 회귀(공통 보안·설정 변경이므로 전체 회귀 필요).
## Phase 1 배포 검증 endpoint
**Phase 결과:** readiness와 deployment endpoint가 지정된 포트 경계에 맞게 동작한다.
**선행조건:** 없음.
**Phase 완료 조건:** `P1-T1`~`P1-T4` 완료, `./gradlew test`와 `./gradlew bootJar` 성공 기록.
### 구현 항목
#### Task 1.1 Actuator 의존성과 기본 설정
**Goal 실행 `P1-T1`:** Actuator를 추가하고 포트·exposure·deployment property 계약을 설정에 반영한다.
- **시작 조건:** PRD `DEPLOY-001`, `DEPLOY-002`, `DEPLOY-005`.
- **완료 증거:** 설정 파일 diff와 애플리케이션 컨텍스트 기동 test 통과.
- **범위 밖:** endpoint 구현, security 변경.
**Files:**
- Modify: `build.gradle.kts`
- Modify: `src/main/resources/application.yml`
- Modify: `src/test/resources/application.yml`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt`
- [x] **RED:** `DeploymentEndpointIntegrationTest`에서 management 포트 readiness/deployment 호출 test를 작성했다.
- [x] **RED 확인:** Actuator 부재로 `Could not resolve placeholder 'local.management.port'` 실패를 확인했다.
- [x] **GREEN:** `spring-boot-starter-actuator`와 포트·exposure·deployment 설정을 추가했다.
- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` 통과.
- [x] **REFACTOR:** deprecated `LocalServerPort` import 정리, `./gradlew ktlintCheck` 성공.
#### Task 1.2 readiness 접근 허용
**Goal 실행 `P1-T2`:** `/readyz`와 management readiness가 인증 없이 200을 반환한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** readiness test 통과.
- **범위 밖:** 기타 endpoint의 security 정책 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
- [x] **RED:** `/readyz` 200 기대 test 작성(`DeploymentEndpointIntegrationTest`).
- [x] **RED 확인:** Actuator/보안 미적용 상태 실패 확인.
- [x] **GREEN:** `SecurityConfig`에 `/readyz` permitAll과 `EndpointRequest.toAnyEndpoint()` permitAll 추가.
- [x] **GREEN 확인:** readiness test 2건 통과.
- [x] **REFACTOR:** 추가 변경 없음.
#### Task 1.3 deployment custom Actuator endpoint
**Goal 실행 `P1-T3`:** `/actuator/deployment`가 snake_case 배포 식별자를 반환한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 기본값·주입값 test 통과.
- **범위 밖:** 추가 필드.
**Files:**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
- [x] **RED:** 기본값(`local`, 40자리 zero)과 주입값 검증 test 작성.
- [x] **RED 확인:** endpoint 미구현 실패 확인.
- [x] **GREEN:** `@Endpoint(id = "deployment")` + `@ReadOperation` 구현.
- [x] **GREEN 확인:** 기본값·주입값 test 통과.
- [x] **REFACTOR:** `./gradlew ktlintCheck` 성공.
#### Task 1.4 포트 접근 경계 검증
**Goal 실행 `P1-T4`:** 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않음을 검증한다.
- **시작 조건:** `P1-T3` 완료.
- **완료 증거:** 애플리케이션 포트 접근 test 통과.
- **범위 밖:** 그 외 경로 노출 정책.
**Files:**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
- [x] **RED:** 애플리케이션 포트 접근이 200이 아님을 기대하는 test 작성.
- [x] **RED 확인:** 초기 404 기대 단정이 `401 UNAUTHORIZED`로 실패함을 확인.
- [x] **GREEN:** management 포트 분리로 애플리케이션 포트에서는 endpoint가 매핑되지 않고 401로 차단됨을 확인, 단정을 "200 아님 + 배포 식별자 미포함" 계약으로 정정.
- [x] **GREEN 확인:** 해당 test 통과.
- [x] **REFACTOR:** `./gradlew test` 전체 회귀 성공.
### Phase Gate `P1-GATE`
- [x] `./gradlew test` 성공.
- [x] `./gradlew bootJar` 성공 및 실행 가능한 JAR 생성 확인.
## 검증 기록
- 2026-09-14 `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` → RED 5 failed → GREEN 5 passed.
- 2026-09-14 `./gradlew ktlintCheck` → BUILD SUCCESSFUL.
- 2026-09-14 `./gradlew test` → BUILD SUCCESSFUL (전체 회귀).
- 2026-09-14 `./gradlew bootJar` → `build/libs/sodalive-0.0.1-SNAPSHOT.jar`(141MB), `Main-Class: org.springframework.boot.loader.JarLauncher` 확인.
- 참고: 기존 test 실행에는 `JWT_SECRET` 환경변수가 필요하며 이는 이번 변경 이전부터 동일한 전제다.
- 참고: 애플리케이션 포트에서 `/actuator/deployment`는 404가 아니라 401로 차단된다. `EndpointRequest` matcher가 management 포트 분리 시 애플리케이션 포트 요청에 매칭되지 않기 때문이며, 노출되지 않는다는 요구는 충족한다.
@@ -0,0 +1,101 @@
# OCI Blue/Green 배포 검증용 readiness 및 deployment endpoint PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-09-14 |
| 최종 수정일 | 2026-09-14 |
| 대상 제품 | sodalive server 배포 검증 endpoint |
| 작성자·결정권자 | 요청 사용자 |
| 관련 구현 계획 | `docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md` |
| 관련 review | 없음 |
## 1. Overview
OCI Blue/Green 배포에서 배포 스크립트가 새로 기동한 인스턴스의 기동 완료 여부와, 실제로 어떤 artifact/config가 올라갔는지를
HTTP로 확인할 수 있어야 한다. 이를 위해 Spring Boot Actuator의 readiness probe와 배포 식별 정보를 반환하는 custom Actuator
endpoint를 제공한다.
## 2. Problem Statement
- 현재 프로젝트에는 Actuator 의존성과 health endpoint가 없어 배포 스크립트가 기동 완료를 판정할 방법이 없다.
- 어떤 artifact 버전과 config commit이 배포되었는지 런타임에서 확인할 수 없다.
- 모든 요청이 Spring Security의 `anyRequest().authenticated()`로 보호되어 있어 인증 없는 probe 호출이 불가능하다.
해결 판단 기준: 배포 스크립트가 인증 없이 `GET :8080/readyz`로 기동을 판정하고, 운영 전용 포트에서
`GET :8082/actuator/deployment`로 배포 식별자를 확인할 수 있다.
## 3. Goals
- 애플리케이션 포트(8080)에서 인증 없이 readiness 확인 가능.
- Management 포트(8082)에서 readiness와 배포 식별 정보 확인 가능.
- 배포 식별자는 환경변수로 주입하고, 환경변수가 없어도 로컬/테스트 기동이 가능해야 한다.
## 4. Non-Goals
- Kubernetes 관련 의존성·설정 추가.
- liveness 기반 자동 재기동 정책, 배포 스크립트 자체 변경.
- health details/components 공개, 신규 추상화 계층 도입.
## 5. 권한
- 인증 주체: 없음(배포 스크립트/로드밸런서).
- `/readyz`: 애플리케이션 포트에서 인증 없이 허용.
- `/actuator/**`: management 포트(8082)에서만 노출. 애플리케이션 포트에서는 노출하지 않는다.
- `/readyz` 응답에는 민감 정보나 배포 식별자를 포함하지 않는다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `DEPLOY-001` | 확정 | `spring-boot-starter-actuator` 의존성을 추가한다(중복 추가 금지). | `build.gradle.kts`에 1회만 존재 | `P1-T1` |
| `DEPLOY-002` | 확정 | 애플리케이션 포트 8080, management 포트 8082로 분리한다. | `application.yml`에 `server.port: 8080`, `management.server.port: 8082` | `P1-T1` |
| `DEPLOY-003` | 확정 | health probes와 additional path를 활성화해 `/readyz`를 제공한다. | `GET :8080/readyz` → 200 | `P1-T2` |
| `DEPLOY-004` | 확정 | management 포트에서 readiness를 제공한다. | `GET :8082/actuator/health/readiness` → 200 | `P1-T2` |
| `DEPLOY-005` | 확정 | web exposure는 `health`, `deployment`만 포함하고 health details/components는 공개하지 않는다. | `management.endpoints.web.exposure.include: health,deployment`, `show-details: never`, `show-components: never` | `P1-T1` |
| `DEPLOY-006` | 확정 | `DEPLOY_ARTIFACT_VERSION`/`DEPLOY_CONFIG_COMMIT`을 property placeholder로 연결한다. | 환경변수 없으면 `local`, 40자리 zero commit | `P1-T3` |
| `DEPLOY-007` | 확정 | `/actuator/deployment`를 custom Actuator endpoint로 구현한다. | REST controller가 아닌 `@Endpoint` 구현체 존재 | `P1-T3` |
| `DEPLOY-008` | 확정 | 응답 필드는 정확히 `application_artifact_version`, `config_commit`이다. | snake_case JSON 검증 test 통과 | `P1-T3` |
| `DEPLOY-009` | 확정 | 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않는다. | `GET :8080/actuator/deployment` → 200 아님 | `P1-T4` |
| `DEPLOY-010` | 확정 | Security 정책 최소 변경으로 readiness가 401/403/redirect 되지 않는다. | `SecurityConfig`에 `/readyz` permitAll 추가 | `P1-T2` |
## 7. API 계약
### 7.1 `GET :8080/readyz`
- 인증 불필요. 응답 body는 Actuator 기본 health 응답(`{"status":"UP"}`), 세부 정보 비공개.
### 7.2 `GET :8082/actuator/health/readiness`
- 응답: `{"status":"UP"}`.
### 7.3 `GET :8082/actuator/deployment`
```json
{
"application_artifact_version": "local",
"config_commit": "0000000000000000000000000000000000000000"
}
```
## 8. 성공 기준
- [x] `GET :8080/readyz`가 200을 반환한다. (`DEPLOY-003`, `DEPLOY-010`)
- [x] `GET :8082/actuator/health/readiness`가 200을 반환한다. (`DEPLOY-004`)
- [x] `GET :8082/actuator/deployment`가 주입값을 snake_case JSON으로 반환한다. (`DEPLOY-006`~`DEPLOY-008`)
- [x] `GET :8080/actuator/deployment`가 200을 반환하지 않는다(401로 차단). (`DEPLOY-009`)
- [x] `./gradlew test`, `./gradlew bootJar` 성공.
## 9. Open Questions
없음.
## 10. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 |
|---|---|---|---|---|---|
| 2026-09-14 | `DEC-001` | 확정 | Kubernetes 의존성 없이 Actuator 기본 probe만 사용 | 사용자 요구사항 범위 제한 | `DEPLOY-003`, `DEPLOY-004` |
| 2026-09-14 | `DEC-002` | 확정 | 배포 식별자는 별도 파싱 코드 없이 Spring property placeholder로만 연결 | 사용자 요구사항, 단순성 유지 | `DEPLOY-006` |
| 2026-09-14 | `DEC-003` | 확정 | `/actuator/deployment`는 `@Endpoint` 기반 custom Actuator endpoint로 구현 | 사용자 요구사항(REST controller 금지) | `DEPLOY-007` |
@@ -0,0 +1,388 @@
# 크리에이터 시작 DM 방 생성 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `POST /api/v2/user-creator-chat/rooms/create`가 기존 리스너 시작 DM 생성과 새 크리에이터 시작 DM 생성을 모두 지원한다.
**Architecture:** 기존 endpoint와 응답 DTO를 유지하고 request DTO만 `recipientId` 중심으로 확장한다. Controller에서 `request.recipientMemberId()`로 단일 상대 회원 ID를 산출하고, 기존 `UserCreatorChatService.createOrGetRoom(member, recipientId)` 흐름과 `validateRecipient` 정책을 재사용한다. 메시지 저장/전달/푸시는 기존 WebSocket 및 메시지 발송 흐름에 맡긴다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper
---
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 중 |
| 작성일 | 2026-09-14 |
| 요구사항 기준 | `docs/20260914_크리에이터_리스너_DM방_생성/prd.md` |
| API 기준 | PRD §8 |
| 현재 Phase | Phase 1 |
| 현재 활성 Goal | 없음 |
## 목표
기존 `creatorId` 요청을 깨지 않으면서 신규 `recipientId` 요청으로 발신자 역할과 무관하게 유저-크리에이터 DM 방을 생성/조회할 수 있게 한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 진행 중 | `3/3` | `P1-GATE` | 전체 `./gradlew build`가 `:test` 단계에서 timeout 됨 |
## 범위
### 포함
- `POST /api/v2/user-creator-chat/rooms/create` request DTO 확장
- `recipientId` 표준 필드 추가
- 기존 `creatorId` alias 유지
- `recipientId`/`creatorId` 누락·불일치 validation
- 기존 방 재사용 및 새 방 생성 회귀 검증
- `/create`가 메시지를 만들지 않는다는 회귀 검증
### 제외
- 첫 메시지 발송 request 추가
- WebSocket 프로토콜 변경
- FCM 푸시 정책 변경
- DB 스키마 변경
- 새 endpoint 추가
- 기존 `creatorId` alias 제거
## 기술적 제약
- 공개 API 응답 `CreateUserCreatorChatRoomResponse(roomId)`는 변경하지 않는다.
- 신규 클라이언트는 `recipientId`를 사용하지만, 기존 클라이언트의 `creatorId` 요청도 허용한다.
- `recipientId`와 `creatorId`가 모두 있고 값이 다르면 `common.error.invalid_request`로 거부한다.
- 권한/상태 검증은 기존 `UserCreatorChatService.validateRecipient` 정책을 재사용한다.
- 새 공용 abstraction, 새 dependency, DB migration은 만들지 않는다.
- 구현 Task는 RED → GREEN → REFACTOR 순서로 수행한다.
## 파일 구조 계획
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
- `CreateUserCreatorChatRoomRequest`에 `recipientId` nullable 필드와 기존 `creatorId` nullable alias를 둔다.
- 단일 상대 ID 산출 함수를 DTO 내부에 둔다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
- `request.recipientMemberId()` 결과를 `service.createOrGetRoom(member, recipientId)`에 전달한다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
- 함수 파라미터명만 의미에 맞게 `recipientId`로 바꾸는 것을 검토한다. 동작은 유지한다.
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
- 새 방 생성, 기존 방 재사용, 메시지 미생성, validation 회귀를 검증한다.
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
- request alias 해석 단위 테스트 또는 controller mapping 테스트를 보강한다.
## Phase 1: `/create` request 일반화
**Phase 결과:** 기존 클라이언트와 신규 클라이언트 모두 같은 `/create` endpoint로 DM 방을 생성/조회할 수 있다.
**선행조건:** PRD `DEC-001`~`DEC-003` 확정.
**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 1.1 request DTO alias 계약 추가
**Goal 실행 `P1-T1`:** `CreateUserCreatorChatRoomRequest`가 `recipientId`와 기존 `creatorId` alias에서 하나의 상대 회원 ID를 산출한다.
- **시작 조건:** PRD `DMROOM-001~004`, `DEC-003` 확인.
- **완료 증거:** DTO/Controller focused test 통과와 request 계약 문서 일치.
- **범위 밖:** 방 생성 repository 동작 변경, 메시지 발송.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
**Interfaces:**
- Consumes: request JSON `{ "recipientId": Long? }`, `{ "creatorId": Long? }`
- Produces: `CreateUserCreatorChatRoomRequest.recipientMemberId(): Long`
- [x] **RED:** `UserCreatorChatControllerMappingTest`에 `recipientId`만 보낸 요청이 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
```kotlin
@Test
fun shouldCreateRoomWithRecipientId() {
val service = Mockito.mock(UserCreatorChatService::class.java)
val controller = UserCreatorChatController(service)
val member = Member(email = "creator@test.com", password = "pw", nickname = "creator")
member.id = 10L
Mockito.`when`(service.createOrGetRoom(member, 20L))
.thenReturn(CreateUserCreatorChatRoomResponse(roomId = 30L))
val response = controller.createOrGetRoom(
member,
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = null)
)
Mockito.verify(service).createOrGetRoom(member, 20L)
assertEquals(30L, response.data!!.roomId)
}
```
- [x] **RED:** 기존 `creatorId`만 보낸 요청도 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
```kotlin
@Test
fun shouldCreateRoomWithLegacyCreatorId() {
val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L)
assertEquals(20L, request.recipientMemberId())
}
```
- [x] **RED:** 두 필드가 모두 없거나 서로 다르면 `common.error.invalid_request`가 발생하는 실패 테스트를 작성한다.
```kotlin
@Test
fun shouldRejectMissingOrConflictingRecipientIds() {
val missing = assertThrows(SodaException::class.java) {
CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = null).recipientMemberId()
}
assertEquals("common.error.invalid_request", missing.messageKey)
val conflict = assertThrows(SodaException::class.java) {
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = 21L).recipientMemberId()
}
assertEquals("common.error.invalid_request", conflict.messageKey)
}
```
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest`를 실행해 `recipientId` 생성자 파라미터 또는 `recipientMemberId()` 부재로 실패하는 것을 확인한다.
- [x] **GREEN:** DTO를 최소 확장한다.
```kotlin
data class CreateUserCreatorChatRoomRequest(
val recipientId: Long? = null,
val creatorId: Long? = null
) {
fun recipientMemberId(): Long {
if (recipientId != null && creatorId != null && recipientId != creatorId) {
throw SodaException(messageKey = "common.error.invalid_request")
}
return recipientId ?: creatorId ?: throw SodaException(messageKey = "common.error.invalid_request")
}
}
```
- [x] **GREEN:** Controller에서 `request.recipientMemberId()`를 service에 전달한다.
```kotlin
ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId()))
```
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 테스트 helper 중 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
#### Task 1.2 service 방 생성 정책 회귀 보강
**Goal 실행 `P1-T2`:** 발신자 역할과 무관하게 기존 수신자 검증, 기존 방 재사용, 메시지 미생성 정책을 유지한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** `UserCreatorChatServiceTest` focused test 통과.
- **범위 밖:** `validateRecipient` 정책 변경, WebSocket 발송 구현.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
**Interfaces:**
- Consumes: `createOrGetRoom(member: Member, recipientId: Long)`
- Produces: `CreateUserCreatorChatRoomResponse(roomId: Long)`
- [x] **RED:** 크리에이터 역할 회원이 일반 회원에게 방을 생성할 수 있는 실패 테스트를 작성한다.
```kotlin
@Test
fun shouldCreateRoomWhenCreatorStartsDmToUser() {
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
val user = member(2L, "user")
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenReturn(room(10L))
val response = service.createOrGetRoom(creator, 2L)
assertEquals(10L, response.roomId)
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == creator })
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == user })
Mockito.verifyNoInteractions(messageRepository)
}
```
- [x] **RED:** 기존 활성 방이 있으면 새 방과 메시지를 만들지 않는 회귀 테스트를 작성한다.
```kotlin
@Test
fun shouldReturnExistingRoomWithoutCreatingMessage() {
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
val user = member(2L, "user")
val existingRoom = room(10L)
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(existingRoom)
val response = service.createOrGetRoom(creator, 2L)
assertEquals(10L, response.roomId)
Mockito.verify(roomRepository, Mockito.never()).save(Mockito.any(UserCreatorChatRoom::class.java))
Mockito.verifyNoInteractions(messageRepository)
}
```
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`를 실행해 누락된 동작 또는 helper compile 실패를 확인한다.
- [x] **GREEN:** 필요하면 `createOrGetRoom(member, creatorId)` 파라미터명을 `recipientId`로 변경한다. 함수 내부 로직은 기존 `memberRepository.findById`, `validateRecipient`, `findActiveRoomByParticipantMemberIds`, participant 저장 흐름을 유지한다.
```kotlin
@Transactional
fun createOrGetRoom(member: Member, recipientId: Long): CreateUserCreatorChatRoomResponse {
val recipient = memberRepository.findById(recipientId).orElseThrow {
SodaException(messageKey = "message.error.recipient_not_found")
}
validateRecipient(member, recipient)
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, recipient.id!!)
if (existingRoom != null) {
return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!)
}
val room = roomRepository.save(UserCreatorChatRoom())
participantRepository.save(UserCreatorChatParticipant(room, member))
participantRepository.save(UserCreatorChatParticipant(room, recipient))
return CreateUserCreatorChatRoomResponse(roomId = room.id!!)
}
```
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 파라미터명 변경으로 테스트/문서와 의미가 맞는지 확인하고 새 abstraction 없이 종료한다.
#### Task 1.3 통합 회귀 검증 보강
**Goal 실행 `P1-T3`:** 기존 리스너 시작 흐름과 신규 크리에이터 시작 흐름이 통합 환경에서 모두 동작한다.
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
- **완료 증거:** 통합 테스트와 focused 회귀 명령 통과.
- **범위 밖:** 전체 메시징 E2E, 푸시 발송 검증.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
- [x] **RED:** 리스너가 `creatorId` alias로 기존처럼 방을 만들 수 있는 통합 회귀 테스트를 작성한다.
- [x] **RED:** 크리에이터가 일반 회원 ID로 방을 만들 수 있는 통합 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`를 실행해 현재 계약 미지원 실패를 확인한다.
- [x] **GREEN:** `P1-T1`, `P1-T2` 구현만으로 통합 테스트를 통과시킨다. 추가 production 코드를 만들지 않는다.
- [x] **GREEN 확인:** 같은 통합 테스트를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 통합 테스트 fixture 중 이번 Task가 만든 중복만 정리한다.
### 완료 조건
- [x] `P1-T1`, `P1-T2`, `P1-T3`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] PRD `DMROOM-001~007`이 구현 또는 명시적 제외로 추적된다.
- [x] 기존 `creatorId` 요청과 신규 `recipientId` 요청의 차이가 문서와 테스트에 남아 있다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책을 최종 판정한다.
- **시작 조건:** Phase 1의 모든 Task goal 완료.
- **완료 증거:** 아래 명령 통과와 Progress 기록.
- **범위 밖:** 실패와 무관한 채팅방 목록, openRoom 응답, WebSocket 기능 수정.
```bash
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest
./gradlew ktlintCheck
```
**Expected:** 모든 명령이 `BUILD SUCCESSFUL`이고, `/create` 호출만으로 메시지 저장/전달/푸시가 발생하지 않는 테스트가 통과한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | PRD 확정 | 아니요 | request 계약 재확인 |
| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 service 정책 대조 |
| 3 | `P1-T3` | `P1-T1`, `P1-T2` | 아니요 | 통합 fixture 보정 |
| 4 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task로 회귀 수정 |
```text
P1-T1 → P1-T2 → P1-T3 → P1-GATE
```
## 변경 금지 항목
- `/create`에서 메시지를 저장하거나 발송하지 않는다.
- 기존 `creatorId` request를 제거하지 않는다.
- `CreateUserCreatorChatRoomResponse` 필드를 바꾸지 않는다.
- DB schema, WebSocket message type, FCM event 계약을 변경하지 않는다.
- test를 삭제·skip·완화하지 않는다.
- 요청 범위 밖 리팩터링과 공용 abstraction을 추가하지 않는다.
## Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
### P1-T1 1차 실행 — 2026-09-14
- 상태: 완료
- 무엇을: `CreateUserCreatorChatRoomRequest`에 `recipientId` 표준 필드와 `creatorId` alias를 추가하고, controller가 `recipientMemberId()` 결과를 service에 전달하도록 구현했다.
- 왜: `DMROOM-001~004`, `DEC-003` 기준으로 신규 클라이언트와 기존 클라이언트 request를 모두 지원하기 위해서다.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — RED 확인, `recipientId` 생성자 파라미터와 `recipientMemberId()` 부재로 `compileTestKotlin` 실패.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — GREEN 확인, `BUILD SUCCESSFUL`.
- 남은 항목: 없음.
- 다음 행동: `P1-T2` 진행.
### P1-T2 1차 실행 — 2026-09-14
- 상태: 완료
- 무엇을: 크리에이터 시작 DM 방 생성, 기존 방 재사용, `/create` 메시지 미생성 정책을 `UserCreatorChatServiceTest`로 고정했다. production service는 파라미터명만 `recipientId`로 정리했다.
- 왜: `DMROOM-005~007`, `DEC-001`, `DEC-002` 기준으로 기존 수신자 검증과 메시지 발송 분리 정책을 유지하기 위해서다.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` — `BUILD SUCCESSFUL`.
- 남은 항목: 없음.
- 다음 행동: `P1-T3` 진행.
### P1-T3 1차 실행 — 2026-09-14
- 상태: 완료
- 무엇을: 리스너 시작 기존 흐름과 크리에이터 시작 신규 흐름을 `UserCreatorChatServiceIntegrationTest`에 추가했다.
- 왜: 실제 JPA 통합 환경에서 양방향 방 생성과 메시지 미생성을 확인하기 위해서다.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`.
- 남은 항목: 없음.
- 다음 행동: `P1-GATE` 진행.
### P1-GATE 1차 실행 — 2026-09-14
- 상태: 차단 감사 중
- 무엇을: Phase 1 focused test와 lint를 검증하고 전체 build를 시도했다.
- 왜: `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책과 공통 품질 기준을 판정하기 위해서다.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`.
- `./gradlew ktlintCheck` — 최초 import 정렬 오류로 실패 후 수정, 재실행 `BUILD SUCCESSFUL`.
- `./gradlew build` — 120초 timeout, 재시도 600초 timeout. 두 번 모두 `:test` 단계에서 종료되지 않아 전체 build 성공 증거는 확보하지 못했다.
- 남은 항목: 전체 build timeout 원인 분리 또는 별도 승인.
- 다음 행동: 변경 범위 리뷰와 전체 suite hang 원인 보고.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리한다. | 사용자 선택 A | `P1-T2`, PRD `DEC-001` |
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole` 제한 없이 기존 수신자 검증 정책을 따른다. | 사용자 선택 B | `P1-T2`, PRD `DEC-002` |
| 2026-09-14 | `DEC-003` | 확정 | `recipientId`를 표준 필드로 추가하고 `creatorId` alias를 유지한다. | 기존 클라이언트 호환 필요 | `P1-T1`, PRD `DEC-003` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| 없음 | - | - | - | - | - |
@@ -0,0 +1,159 @@
# PRD: 크리에이터 시작 DM 방 생성
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-09-14 |
| 최종 수정일 | 2026-09-14 |
| 대상 제품 | 유저-크리에이터 DM |
| 작성자·결정권자 | 사용자 인터뷰 기준 |
| 관련 구현 계획 | `docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md` |
| 관련 review | 없음 |
## 1. Overview
현재 `POST /api/v2/user-creator-chat/rooms/create`는 일반 유저가 크리에이터에게 DM을 시작할 때 방을 생성하거나 기존 방을 반환한다. 이번 요구사항은 같은 DM 도메인에서 크리에이터도 상대 회원에게 먼저 DM 방을 만들 수 있게 하는 것이다.
첫 메시지 저장, 실시간 전달, 푸시는 이번 API에서 처리하지 않는다. 방 생성 후 메시지는 기존 WebSocket 텍스트 발송 또는 기존 메시지 발송 흐름을 사용한다.
## 2. Problem Statement
- 현재 생성 요청 DTO는 `creatorId`만 받기 때문에 API 의미가 “일반 유저가 크리에이터에게 DM 시작”에 고정되어 있다.
- 크리에이터가 먼저 상대 회원에게 연락하려면 같은 유저-크리에이터 DM 방을 만들 수 있는 서버 계약이 필요하다.
- 기존 클라이언트는 이미 `creatorId`를 보내고 있으므로, 새 필드만 강제하면 기존 리스너 시작 DM 생성 흐름이 깨진다.
문제 해결 여부는 신규 클라이언트가 `recipientId`로 방을 만들 수 있고, 기존 클라이언트가 `creatorId`로 같은 API를 계속 사용할 수 있는지로 판단한다.
## 3. Goals
- `POST /api/v2/user-creator-chat/rooms/create`가 발신자 역할과 무관하게 상대 회원 ID로 DM 방을 생성하거나 기존 방을 반환한다.
- 신규 표준 request 필드는 `recipientId`로 한다.
- 기존 클라이언트 호환을 위해 `creatorId` request 필드는 alias로 유지한다.
- 기존 리스너 → 크리에이터 방 생성 흐름은 계속 동작한다.
- 크리에이터 → 상대 회원 방 생성 흐름도 같은 참여자/중복 방 정책을 따른다.
## 4. Non-Goals
- `/create`에서 첫 텍스트 메시지를 저장하거나 발송하지 않는다.
- `/create`에서 WebSocket 메시지를 대신 보내지 않는다.
- FCM 푸시 발송 정책은 변경하지 않는다.
- DB 스키마를 변경하지 않는다.
- 새 endpoint를 만들지 않는다.
- 기존 `roomId` 응답 구조를 변경하지 않는다.
- `creatorId` alias 제거 시점이나 클라이언트 마이그레이션 일정은 이번 범위에서 정하지 않는다.
## 5. Target Users and Permissions
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| 리스너/일반 유저 | 크리에이터에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
| 크리에이터 | 상대 회원에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
권한과 거부 조건:
- 인증 주체: 로그인 `Member`
- 허용 역할: 별도 `MemberRole` 제한 없음
- 수신자 조건: 활성 회원, AI 캐릭터 아님, 자기 자신 아님, 차단 정책 통과
- 미인증: 기존처럼 `common.error.bad_credentials`
- 수신자 없음 또는 AI 캐릭터: 기존 정책에 맞춰 `message.error.recipient_not_found`
- 비활성 수신자: 기존처럼 `message.error.recipient_inactive`
- 자기 자신: 기존처럼 `common.error.invalid_request`
- 상대가 발신자를 차단한 경우: 기존처럼 `message.error.blocked_by_recipient`
## 6. 핵심 사용자 흐름
1. 인증 회원이 DM을 시작할 상대 회원을 선택한다.
2. 클라이언트는 `POST /api/v2/user-creator-chat/rooms/create`에 `recipientId`를 보낸다.
3. 서버는 기존 alias인 `creatorId`만 온 요청도 허용한다.
4. 서버는 발신자와 수신자 사이의 활성 DM 방이 있으면 기존 `roomId`를 반환한다.
5. 활성 DM 방이 없으면 방과 두 참여자를 생성하고 새 `roomId`를 반환한다.
6. 클라이언트는 반환된 `roomId`로 기존 방 입장 및 메시지 발송 흐름을 진행한다.
## 7. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DMROOM-001` | 확정 | `/create`는 `recipientId`를 표준 상대 회원 ID로 받아 방을 생성/조회한다. | `recipientId`만 보낸 요청이 기존 `roomId` 응답을 받는다. | `P1-T1` |
| `DMROOM-002` | 확정 | 기존 클라이언트 호환을 위해 `creatorId`만 보낸 요청도 계속 허용한다. | 기존 `creatorId` request가 깨지지 않고 동일한 서비스 흐름을 탄다. | `P1-T1` |
| `DMROOM-003` | 확정 | `recipientId`와 `creatorId`가 모두 없으면 잘못된 요청으로 거부한다. | `common.error.invalid_request`로 실패한다. | `P1-T1` |
| `DMROOM-004` | 확정 | 두 필드가 모두 있고 값이 다르면 모호한 요청으로 거부한다. | `common.error.invalid_request`로 실패하고 방을 만들지 않는다. | `P1-T1` |
| `DMROOM-005` | 확정 | 수신자 권한/상태 검증은 기존 `validateRecipient` 정책을 재사용한다. | 비활성, AI 캐릭터, 자기 자신, 차단 관계의 기존 오류 key가 유지된다. | `P1-T2` |
| `DMROOM-006` | 확정 | 기존 활성 방이 있으면 새 방을 만들지 않고 기존 `roomId`를 반환한다. | 같은 두 회원으로 두 번 호출해도 활성 방은 1개다. | `P1-T2` |
| `DMROOM-007` | 확정 | `/create`는 첫 메시지 저장/전달/푸시를 수행하지 않는다. | 방 생성 후 `UserCreatorChatMessage`가 생성되지 않는다. | `P1-T2` |
## 8. API 계약
### 8.1 Endpoint
- Method: `POST`
- Path: `/api/v2/user-creator-chat/rooms/create`
- Auth: 로그인 회원 필수
- Success envelope: 기존 `ApiResponse.ok(...)`
### 8.2 Request
```json
{
"recipientId": 200,
"creatorId": 200
}
```
- `recipientId`: 신규 표준 필드. 발신자 반대편 회원 ID다.
- `creatorId`: 기존 클라이언트 호환용 alias. 신규 클라이언트는 `recipientId`를 사용한다.
- 둘 중 하나만 보내는 요청을 허용한다.
- 둘 다 보내는 경우 값이 같으면 허용한다.
- 둘 다 보내는 경우 값이 다르면 `common.error.invalid_request`로 거부한다.
- 둘 다 없으면 `common.error.invalid_request`로 거부한다.
### 8.3 Response
```json
{
"roomId": 123
}
```
- 응답 DTO `CreateUserCreatorChatRoomResponse`의 필드는 변경하지 않는다.
## 9. 보안과 데이터 취급
- 수신자 ID는 요청 본문 외 로그에 별도 기록하지 않는다.
- 차단 정책은 기존 `BlockMemberRepository.isBlocked(blockedMemberId = sender.id, memberId = recipient.id)` 기준을 유지한다.
- AI 캐릭터용 `Member`와의 DM 방은 계속 생성하지 않는다.
- 공개 API 스키마에서 기존 필드 제거는 금지한다.
## 10. 성공 기준
- [x] 신규 `recipientId` 요청으로 크리에이터가 상대 회원과 DM 방을 만들 수 있다.
- [x] 기존 `creatorId` 요청으로 리스너가 크리에이터와 DM 방을 만들 수 있다.
- [x] 같은 두 회원의 중복 요청은 같은 활성 `roomId`를 반환한다.
- [x] `/create` 호출만으로 메시지, WebSocket 전달, FCM 푸시가 발생하지 않는다.
- [x] 오류 key는 기존 정책과 호환된다.
## 11. Open Questions
없음. 인터뷰로 아래 결정을 확정했다.
## 12. 요구사항 추적표
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---:|---|---|---|
| `DMROOM-001~004` | 1 | `P1-T1` | `UserCreatorChatControllerMappingTest`, `UserCreatorChatServiceTest` | request alias 계약 대조 |
| `DMROOM-005~007` | 1 | `P1-T2` | `UserCreatorChatServiceTest`, `UserCreatorChatServiceIntegrationTest` | 기존 메시지 발송 흐름 분리 확인 |
## 13. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리하고 첫 메시지는 발송하지 않는다. | 사용자 선택 A | `DMROOM-007`, `P1-T2` |
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole`로 제한하지 않고 활성/AI 아님/자기 자신 아님/차단 정책으로 제한한다. | 사용자 선택 B, 기존 `validateRecipient` 정책 | `DMROOM-005`, `P1-T2` |
| 2026-09-14 | `DEC-003` | 확정 | 신규 표준 필드는 `recipientId`로 하고 기존 `creatorId`는 alias로 유지한다. | 기능 의미는 A가 맞지만 기존 클라이언트 동작 보존 필요 | `DMROOM-001~004`, `P1-T1` |
## 14. 변경 관리
- 코드 구현 전 `plan-task.md`의 체크박스와 Goal 단위를 따른다.
- 구현 중 API 계약이 바뀌면 이 PRD의 Decision Log를 먼저 갱신한다.
- 기존 `creatorId` alias를 제거하려면 별도 PRD와 클라이언트 마이그레이션 계획을 작성한다.
@@ -0,0 +1,357 @@
# 라이브 크리에이터 입장 제한 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
| 문서 항목 | 내용 |
|---|---|
| 상태 | Phase 1~3 구현 및 검증 완료 |
| 작성일 | `2026-09-17` |
| 요구사항 기준 | `docs/20260917_라이브_크리에이터_입장제한/prd.md` |
| API 기준 | 기존 API 응답 스키마 변경 없음 |
| 현재 Phase | 완료 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 |
## 목표
다른 크리에이터가 `isAvailableJoinCreator = false` 라이브 방을 볼 수도, 직접 입장할 수도 없게 하고, 입장 가능 성별(`genderRestriction`)도 조회와 입장 경계에서 일관되게 적용한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 | 없음 |
| 2 | 완료 | `1/1` | 없음 | 없음 |
| 3 | 완료 | `2/2` | 없음 | 없음 |
## 범위
### 포함
- `v2` 홈 추천/온에어 라이브 조회의 `isAvailableJoinCreator` 및 `genderRestriction` 필터 누락 보정.
- `v2` 크리에이터 채널 라이브 탭의 조회자 크리에이터 판정 보정.
- `/live/room/enter`에서 다른 크리에이터의 제한 방 직접 입장 차단과 기존 성별 입장 차단 회귀 검증.
- focused test와 직접 영향 회귀 검증.
- 후속 리뷰 `REV-001`: `/live/room/info/{id}`의 토큰 발급 전 크리에이터·성별 제한 적용.
- 후속 리뷰 `REV-002`: 채널 소유자 본인의 제한 방 조회를 repository 회귀 테스트로 고정.
### 제외
- 공개 API DTO 필드 추가/삭제.
- DB schema 변경.
- 기존 성인/성별/차단/강퇴/비공개/결제 정책 재설계.
- 관리자용 라이브 조회 정책 변경.
## 기술적 제약
- 기술 스택: Kotlin, Spring Boot 2.7.14, QueryDSL, Gradle Wrapper.
- 기존 QueryDSL 조건 패턴을 재사용하고 새 abstraction은 만들지 않는다.
- 크리에이터 제한 조건은 `isViewerCreator && memberId != null`일 때만 적용하며, 방 생성자는 `liveRoom.member.id.eq(memberId)`로 예외 처리한다.
- 성별 제한 조건은 기존 조회 경로와 같은 정책을 따른다. `Gender.MALE`은 `ALL`, `MALE_ONLY`, `Gender.FEMALE`은 `ALL`, `FEMALE_ONLY`, `Gender.NONE` 또는 비로그인/null 유효 성별은 필터 없음이다.
- 방 생성자 본인은 성별 제한 조건에서도 `liveRoom.member.id.eq(memberId)`로 예외 처리한다.
- 모든 production 변경 Task는 `RED → GREEN → REFACTOR` 순서로 진행한다.
- 공개 API schema 변경은 금지한다.
## Phase 1: 리스트 노출 제한 보정
**Phase 결과:** 다른 크리에이터가 문제 의심 리스트 경로에서 제한 방을 받지 않고, 성별 제한에 맞지 않는 사용자가 v2 홈 추천/온에어 라이브에서 제한 방을 받지 않는다.
**선행조건:** PRD 확정.
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 1.1 v2 홈 추천/온에어 라이브 필터 적용
**Goal 실행 `P1-T1`:** `findLiveRecommendations()` 계열 호출에서 조회자 크리에이터 여부와 유효 성별을 전달하고 QueryDSL 필터를 적용한다.
- **시작 조건:** `LCR-001` 확정.
- **완료 증거:** 다른 크리에이터가 제한 방을 받지 않고, 성별 제한에 맞지 않는 사용자가 제한 방을 받지 않으며, 방 생성자 본인은 예외인 focused test 통과.
- **범위 밖:** `/live/room/enter` 입장 차단.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- Test: repository/service 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
**Interfaces:**
- `findLiveRecommendations(offset, limit, memberId, includeAdultLives, isViewerCreator, effectiveViewerGender)` 형태로 파라미터를 추가한다.
- `isViewerCreator` 기본값은 `false`로 둬 snapshot refresh 등 기존 호출을 보존한다.
- `effectiveViewerGender` 기본값은 `null`로 둬 비로그인/성별 미설정과 snapshot refresh 등 기존 호출을 보존한다.
- `HomeRecommendationFacade`와 `HomeOnAirLiveFacade`는 `member.auth?.gender` 우선, 없으면 `member.gender`로 기존 경로와 같은 유효 성별을 계산해 전달한다.
- [x] **RED:** 크리에이터 조회자에게 `isAvailableJoinCreator = false` 라이브가 제외되는 테스트, 성별 제한에 맞지 않는 조회자에게 `genderRestriction` 제한 라이브가 제외되는 테스트, 방 생성자 본인은 포함되는 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest'`를 실행해 필터 미구현으로 크리에이터 제한 방 또는 성별 제한 방이 포함되는 assertion 실패를 확인한다.
- [x] **GREEN:** `HomeRecommendationQueryPort`, `HomeRecommendationQueryService`, `DefaultHomeRecommendationQueryRepository`, `HomeRecommendationFacade`, `HomeOnAirLiveFacade`에 `isViewerCreator`, `effectiveViewerGender` 전달과 조건을 최소 추가한다.
- [x] **GREEN 확인:** `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest'`를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 및 focused test 결과를 Progress에 기록한다.
#### Task 1.2 v2 크리에이터 채널 라이브 탭 판정 보정
**Goal 실행 `P1-T2`:** 크리에이터 채널 라이브 탭에서 “조회자가 크리에이터인지” 기준으로 제한 방 노출을 막는다.
- **시작 조건:** `LCR-002` 확정.
- **완료 증거:** 다른 크리에이터는 제한 방을 받지 않고 방 생성자 본인은 받을 수 있는 focused test 통과.
- **범위 밖:** 크리에이터 채널 홈은 이미 `viewer.role == MemberRole.CREATOR`를 사용하므로 변경하지 않는다.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- Test: 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
**Interfaces:**
- `queryPort.findCurrentLive(..., isViewerCreator = viewer.role == MemberRole.CREATOR, ...)`로 전달한다.
- repository의 기존 `creatorJoinLiveCondition(viewerId, isViewerCreator)`와 본인 예외는 유지한다.
- [x] **RED:** 다른 크리에이터 viewer가 `isAvailableJoinCreator = false` 현재 라이브를 받지 않는 service test를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests '*CreatorChannelLiveQueryServiceTest'`를 실행해 기존 `viewerId == creatorId` 판정 때문에 제한 방이 반환되는 assertion 실패를 확인한다.
- [x] **GREEN:** `CreatorChannelLiveQueryService`의 `isViewerCreator` 계산을 `viewer.role == MemberRole.CREATOR`로 변경한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 중복 정리 없이 끝낼 수 있으면 그대로 두고 focused test 및 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 리스트 노출 제한과 성별 제한 경로가 PRD 요구사항과 일치하는지 판정한다.
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
- **완료 증거:** 아래 명령 통과와 수동 대조 기록.
- **범위 밖:** 입장 차단 구현.
```bash
./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'
./gradlew ktlintCheck
```
**Expected:** focused test와 ktlint가 0 exit code로 종료한다.
수동 대조:
- [x] `HomeFollowingQueryService`와 `CreatorChannelHomeQueryService`의 기존 정상 조건은 변경하지 않았음을 확인한다.
- [x] `HomeRecommendationFacade.getHomeRecommendations`, `HomeRecommendationFacade.getLives`, `HomeOnAirLiveFacade.getOnAirLives`가 모두 조회자 크리에이터 여부와 유효 성별을 전달함을 확인한다.
## Phase 2: 직접 입장 차단
**Phase 결과:** 다른 크리에이터가 제한 방 ID를 알고 있어도 `/live/room/enter`로 입장할 수 없고, 성별 제한에 맞지 않는 사용자의 직접 입장도 기존 정책대로 차단된다.
**선행조건:** `P1-GATE` 완료.
**Phase 완료 조건:** `P2-T1`, `P2-GATE` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 2.1 `/live/room/enter` 크리에이터 제한 차단 및 성별 제한 회귀 검증
**Goal 실행 `P2-T1`:** `LiveRoomService.enterLive()`에서 다른 크리에이터의 제한 방 입장을 결제/roomInfo 변경 전 차단하고, 기존 성별 제한 입장 차단을 회귀 테스트로 고정한다.
- **시작 조건:** `LCR-003` 확정, `P1-GATE` 완료.
- **완료 증거:** 다른 크리에이터는 예외, 성별 제한에 맞지 않는 사용자는 `live.room.gender_restricted` 예외, 방 생성자 본인과 일반 유저는 기존 정책 유지 test 통과.
- **범위 밖:** error envelope schema 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`
- Test: 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt`
- Optional Modify: 메시지 key 추가가 필요하다고 확인될 때만 `src/main/resources/messages*.properties` 계열 파일
**Implementation rule:**
`enterLive()`에서 room 조회 후, 결제나 `LiveRoomInfo` 변경 전에 아래 의미의 조건을 추가한다.
```kotlin
if (
member.role == MemberRole.CREATOR &&
room.member!!.id!! != member.id!! &&
!room.isAvailableJoinCreator
) {
throw SodaException(messageKey = "live.room.not_found")
}
```
기존 message key 재사용이 부적절하다고 확인되면 PRD와 plan-task를 먼저 갱신한 뒤 새 message key를 추가한다.
성별 제한은 현재 구현된 아래 의미의 조건을 유지한다. 이 작업에서는 해당 조건을 약화하지 말고 회귀 테스트로 고정한다.
```kotlin
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
throw SodaException(messageKey = "live.room.gender_restricted")
}
```
- [x] **RED:** 다른 크리에이터가 `isAvailableJoinCreator = false` 방에 입장하면 예외가 발생하고 `roomInfoRepository.save`와 `canPaymentService.spendCan`이 호출되지 않는 테스트를 작성한다. 성별 제한에 맞지 않는 사용자가 입장하면 `live.room.gender_restricted` 예외가 발생하는 회귀 테스트도 작성한다.
- [x] **RED 확인:** `./gradlew test --tests '*LiveRoomServiceTest'`를 실행해 기존 구현이 입장을 허용하거나 후속 저장을 호출하는 assertion 실패를 확인한다.
- [x] **GREEN:** `LiveRoomService.enterLive()`에 최소 조건을 추가한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 방 생성자 본인, 일반 유저, `Gender.NONE` 사용자 회귀 테스트를 함께 실행하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
### 검증 방법
#### Phase 2 Gate
**Goal 실행 `P2-GATE`:** 입장 차단, 리스트 노출 차단, 성별 제한 전체 흐름을 최종 판정한다.
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** 아래 명령 통과와 계획 문서 Progress 기록.
- **범위 밖:** 실패 test 삭제·완화, 무관한 리팩터링.
```bash
./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest'
./gradlew ktlintCheck
./gradlew test
```
**Expected:** focused test, ktlint, 전체 test가 0 exit code로 종료한다. 크리에이터 제한과 성별 제한이 모두 조회/입장 경계에서 검증된다. 전체 test가 환경 문제로 실패하면 실패 원인과 focused/영향 범위 대체 검증을 Progress에 기록한다.
## Phase 3: 후속 리뷰 항목 구현
**근거:** `reviews/현재변경사항-review.md`의 `REV-001`, `REV-002` 및 두 항목을 후속 구현사항으로 추가하라는 사용자 요청.
**Phase 결과:** 제한 사용자는 방 정보 API로 토큰을 발급받지 못하고, 채널 소유자 본인의 제한 방 조회는 실제 repository 테스트로 보장된다.
**선행조건:** Phase 1~2 완료 및 후속 리뷰 확인. 기존 완료 체크박스와 검증 기록은 유지한다.
**Phase 완료 조건:** `P3-T1`, `P3-T2`, `P3-GATE` 완료와 실행 증거 기록.
### Task 3.1 토큰 발급 경계의 입장 제한 보강
- [x] **Task 3.1 완료**
**Goal 실행 `P3-T1`:** `REV-001`에 따라 `getRoomInfo()`에서 토큰 생성 전에 크리에이터·성별 제한을 적용하고 허용 사용자 회귀를 검증한다.
- **시작 조건:** `REV-001`의 제한 검사 누락 확인. 실제 RTC 우회 접속은 아직 재현되지 않았으므로 서버의 토큰 생성 호출 여부부터 검증한다.
- **완료 증거:** 제한 사용자에 대한 실패 테스트 → 최소 수정 후 예외 key 및 RTC/RTM 생성기 무호출 검증 → 소유자·허용 사용자 회귀 통과.
- **범위 밖:** 새로운 입장 완료 여부 검사, 성인/비공개/결제 정책 재설계, 기존 발급 토큰 회수, Agora SDK 변경, API/DB 스키마 변경.
**Files:**
- Modify: `docs/20260917_라이브_크리에이터_입장제한/prd.md` — LCR-003 및 성공 기준에 방 정보 API의 토큰 발급 경계를 명시.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt` — `getRoomInfo()`.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt`.
- Read: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`.
**구현 기준:** 기존 방/roomInfo 존재 및 상호 차단 검사를 유지하고 첫 토큰 생성 호출 전에 `/enter`와 동일한 조건을 적용한다. 다른 크리에이터의 제한 방 접근은 `live.room.not_found`, 다른 사용자의 성별 불일치는 `live.room.gender_restricted`로 거절한다. 두 제한이 겹치면 크리에이터 제한을 먼저 판정한다. 생성자는 두 제한의 예외이며 성별은 `Member.canEnter()`를 재사용해 인증 성별 우선 및 `Gender.NONE` 허용 정책을 유지한다. RTC, RTM, v2v 토큰 모두 제한 검사를 통과한 뒤에만 생성한다.
- [x] **문서 정합성:** production 수정 전에 기존 PRD의 LCR-003, 관련 흐름·성공 기준에 토큰 발급 제한을 반영하고 후속 검증 항목은 미완료로 둔다.
- [x] **RED:** roomInfo와 room이 존재하고 차단 관계가 없는 fixture에서 다른 크리에이터의 제한 방 접근 및 성별 불일치 접근을 테스트한다. 토큰 생성기는 테스트용 문자열을 반환하도록 준비하고 정상 응답 구성에 필요한 의존성도 설정해, 무관한 null 오류가 아닌 예상 예외 미발생으로 실패하는지 확인한다.
- [x] **GREEN:** 위 조건을 최소 추가하고 예외 key, `rtcTokenBuilder`와 `rtmTokenBuilder`의 무호출을 검증한다. `/enter` 거절 후 `/info/{id}`에 해당하는 서비스 호출도 토큰을 생성하지 않는지 확인한다.
- [x] **회귀:** 소유자는 크리에이터·성별 제한이 모두 걸려도 허용되고, 일반 사용자와 허용 방의 다른 크리에이터는 성별이 맞으면 응답을 받는다. 인증 성별 우선, `Gender.NONE` 허용, 기존 상호 차단 거절을 확인한다.
- [x] **REFACTOR/기록:** 불필요한 추상화 없이 기존 스타일을 유지하고 아래 명령의 결과와 무엇을/왜/어떻게 검증했는지를 이 Task 아래에 누적한다.
```bash
./gradlew test --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'
```
**Expected:** RED 단계는 제한 검사 누락으로 실패하고, GREEN 이후는 exit 0. 차단 경로에서는 토큰이 생성되지 않는다.
**검증 기록 (2026-09-17):** `LiveRoomServiceTest`에 정상 응답 fixture와 제한·허용 회귀 10건을 추가했다. RED에서 다른 크리에이터, 성별 불일치, 중첩 제한, `/enter` 거절 후 정보 조회 테스트가 모두 예상 `SodaException` 미발생으로 실패했다. `getRoomInfo()`의 기존 상호 차단 검사 뒤와 모든 RTC/RTM/v2v 토큰 생성 앞에 `/enter`와 동일한 크리에이터·성별 조건을 추가한 뒤 `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'`가 20건 모두 통과하며 `BUILD SUCCESSFUL`로 종료했다. 거절 경로의 token builder 무호출과 소유자·성별 일치 사용자·허용 방 크리에이터·인증 성별 우선·`Gender.NONE`·기존 상호 차단 회귀를 확인했다. 실제 HTTP/RTC 연결은 검증하지 않았다. P3-T1 리뷰 결과 Blocker는 0건이다.
### Task 3.2 채널 소유자 제한 방 조회 회귀 테스트
- [x] **Task 3.2 완료**
**Goal 실행 `P3-T2`:** `REV-002`에 따라 채널 소유자 본인이 `isAvailableJoinCreator = false`인 현재 방을 조회할 수 있음을 H2 repository 테스트로 고정한다.
- **시작 조건:** `REV-002` 확인. 기본 실행 순서는 `P3-T1` 이후이며 파일 변경은 독립적이다.
- **완료 증거:** 실제 repository의 `findCurrentLive()` 결과가 생성한 제한 방 ID와 일치하고 기존 타인 필터 테스트도 통과한다.
- **범위 밖:** production 조회 조건 수정, 채널 API/DTO 변경, 조회 정책 재설계.
**Files:**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`.
- Read: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`.
**TDD 예외 사유:** 소유자 예외는 이미 구현돼 있고 이번 Task는 누락된 테스트만 추가한다. RED를 만들기 위해 정상 production 코드를 변경하지 않는다. 대체 검증은 실제 H2 조회 결과 assertion과 기존 타인 제외 테스트의 동시 통과다.
- [x] **테스트 추가:** 기존 fixture로 활성·비성인·유효 channelName의 현재 방을 생성하고 `isAvailableJoinCreator = false`로 설정한다. `flushAndClear()` 후 `viewerId == creatorId`, `isViewerCreator = true`, 성별 제한과 일치하는 유효 성별로 조회해 방 ID를 단정한다.
- [x] **GREEN:** 아래 focused test로 소유자 조회와 기존 타인 제외 동작이 함께 통과하는지 확인한다.
- [x] **REFACTOR/기록:** 기존 fixture를 재사용하고 이 Task 아래에 명령과 검증 결과를 누적한다. 예상 밖 실패는 원인을 판정한 뒤 production 수정이 필요하면 먼저 Task 범위를 갱신한다.
```bash
./gradlew test --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'
```
**Expected:** exit 0. 소유자에게 제한 방이 반환되고 다른 크리에이터에게는 기존대로 제외된다.
**검증 기록 (2026-09-17):** production 조건은 변경하지 않고 `DefaultCreatorChannelLiveQueryRepositoryTest.shouldReturnRestrictedCurrentLiveForChannelOwner`를 추가했다. 활성·비성인·유효 channelName·`isAvailableJoinCreator = false`·`MALE_ONLY` 방을 실제 H2에 저장하고 `viewerId == creatorId`, `isViewerCreator = true`, `Gender.MALE`로 조회해 생성한 방 ID를 단정했다. `./gradlew test --rerun-tasks --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'`가 기존 다른 크리에이터 제외 테스트와 함께 `BUILD SUCCESSFUL`로 종료했다. `git diff --check`도 통과했다.
### Phase 3 Gate
**Goal 실행 `P3-GATE`:** 후속 두 항목과 기존 목록·입장 정책의 회귀 검증을 완료한다.
- **시작 조건:** `P3-T1`, `P3-T2` 완료.
- **완료 증거:** 아래 명령 exit 0, 토큰 생성 전 제한 검사 수동 대조, 소유자 조회 assertion 확인, PRD·리뷰·계획의 상태 및 검증 기록 갱신.
- **범위 밖:** 실제 RTC 접속을 검증하지 않고 검증했다고 기록하는 행위, 무관한 리팩터링.
```bash
./gradlew test --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest'
./gradlew ktlintCheck
./gradlew tasks --all
```
- [x] 위 명령을 실행하고 각 Task의 완료 증거를 대조한다.
- [x] 실제 HTTP/RTC 연결 검증 여부와 환경 한계를 별도로 기록한다. 서비스 테스트 통과를 실제 RTC 접속 검증으로 간주하지 않는다.
- [x] 전체 회귀는 공통 인증·예외·설정까지 변경하거나 focused test로 영향 범위를 판단할 수 없는 경우 또는 사용자 요청 시 실행한다. 생략 시 이유와 위 대체 회귀 명령을 Progress에 남긴다.
- [x] `reviews/현재변경사항-review.md`에 수정 후 검증을 누적하고, PRD의 후속 성공 기준 및 Phase 3 상태를 실제 결과에 맞춰 갱신한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | PRD 확정 | 아니요 | 호출부/port signature 대조 후 문서 갱신 |
| 2 | `P1-T2` | PRD 확정 | 예, `P1-T1`과 파일 충돌 없음 | service test 작성 방식 확정 |
| 3 | `P1-GATE` | `P1-T1`, `P1-T2` | 아니요 | 실패 소유 Task로 회귀 수정 |
| 4 | `P2-T1` | `P1-GATE` | 아니요 | 오류 message key 결정 후 문서 갱신 |
| 5 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 Task로 회귀 수정 |
| 6 | `P3-T1` | `P2-GATE`, `REV-001` | 기본 순차 | 토큰 생성 전 거절 재현과 PRD 정합성 확인 |
| 7 | `P3-T2` | `REV-002` | `P3-T1`과 파일 독립 | 기존 fixture·현재 방 조건 확인 |
| 8 | `P3-GATE` | `P3-T1`, `P3-T2` | 아니요 | 실패 소유 Task로 회귀 수정 |
```text
P1-T1 ─┐
├→ P1-GATE → P2-T1 → P2-GATE
P1-T2 ─┘
P2-GATE → P3-T1 ─┐
P3-T2 ─┴→ P3-GATE
```
## 변경 금지 항목
- 공개 API 응답 DTO를 변경하지 않는다.
- `isAvailableJoinCreator`의 DB schema나 기본값을 변경하지 않는다.
- 기존 성인/성별/차단/강퇴/비공개/결제 조건을 완화하지 않는다.
- 테스트를 삭제·skip·완화하지 않는다.
- `as any`, 타입 억제, 불필요한 새 abstraction을 만들지 않는다.
## Progress
- 2026-09-17: PRD와 계획 문서 작성. 구현은 아직 시작하지 않음.
- 2026-09-17: v2 홈 추천/온에어 라이브의 `genderRestriction` 조회 필터와 `/live/room/enter` 성별 제한 회귀 검증을 PRD/계획에 반영. 구현은 아직 시작하지 않음.
- 2026-09-17: `P1-T1` RED 확인. 신규 인자 추가 전 `compileTestKotlin`에서 `isViewerCreator`/`effectiveViewerGender` 미정의로 실패했고, 시그니처 연결 후 repository focused test 79건 중 크리에이터 제한 1건과 성별 제한 2건이 assertion 실패했다. facade 전달 전 `HomeRecommendationControllerTest.shouldLogHomeRecommendationPageFailure`도 신규 인자 불일치로 실패했다.
- 2026-09-17: `P1-T1` GREEN 확인. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest'`가 `BUILD SUCCESSFUL`로 종료했다. 컴파일 경고는 기존 deprecated API 및 기존 테스트 경고이며 신규 실패는 없다.
- 2026-09-17: `P1-T1` REFACTOR 확인. 추가 추상화 없이 기존 QueryDSL 패턴을 재사용했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
- 2026-09-17: `P1-T2` RED 확인. 다른 ID의 `MemberRole.CREATOR` viewer 테스트에서 `currentLiveIsViewerCreator`가 기대값 `true`, 실제값 `false`로 실패했다.
- 2026-09-17: `P1-T2` GREEN 확인. `CreatorChannelLiveQueryService`가 `viewer.role == MemberRole.CREATOR`를 전달하도록 최소 수정한 뒤 `./gradlew test --rerun-tasks --tests '*CreatorChannelLiveQueryServiceTest'`가 `BUILD SUCCESSFUL`로 종료했다.
- 2026-09-17: `P1-T2` REFACTOR 확인. 별도 추상화 없이 기존 역할 enum을 사용했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
- 2026-09-17: `P1-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest' --tests '*CreatorChannelLiveQueryServiceTest'`와 `./gradlew ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 종료했다. 출력된 deprecated API 및 unchecked cast 경고는 변경 범위 밖의 기존 경고다.
- 2026-09-17: `P1-GATE` 수동 대조. `HomeFollowingQueryService`와 `CreatorChannelHomeQueryService`는 변경하지 않았고, 홈 추천·라이브·온에어 facade 세 경로가 조회자 역할과 유효 성별을 전달함을 확인했다.
- 2026-09-17: `P1-GATE` 리뷰 승인. Phase 1 전체 변경 범위의 PRD/계획 위반과 기존 필터 약화가 없음을 확인했으며 Blocker는 0건이다.
- 2026-09-17: `P2-T1` RED 확인. 다른 크리에이터의 제한 방 입장 테스트가 `SodaException`을 기대했지만 예외가 발생하지 않아 실패했다.
- 2026-09-17: `P2-T1` GREEN 확인. `LiveRoomService.enterLive()`의 결제 및 room-info 접근 전에 역할·방 생성자·`isAvailableJoinCreator` 조건을 추가한 뒤 `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest'`가 5건 모두 통과하며 `BUILD SUCCESSFUL`로 종료했다.
- 2026-09-17: `P2-T1` REFACTOR 확인. 방 생성자, 일반 사용자, `Gender.NONE`, 성별 불일치 회귀 테스트와 차단 경로의 무부수효과 검증을 유지했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
- 2026-09-17: `P2-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest'`, `./gradlew ktlintCheck`, `./gradlew test --rerun-tasks`가 모두 `BUILD SUCCESSFUL`로 종료했다.
- 2026-09-17: `P2-GATE` 최종 리뷰 승인. 구현·테스트·문서의 PRD/계획 일치와 API/스키마 유지 여부를 재검토했으며 Blocker는 0건이다.
- 2026-09-17: 현재 변경사항 후속 리뷰. focused 140건 및 직접 영향 회귀 42건, 총 182건 통과. `./gradlew ktlintCheck tasks --all` 성공(ktlint UP-TO-DATE). 소스 수정 없이 목록·입장 경계 중심의 검토이므로 전체 회귀는 재실행하지 않았다. 명시된 구현 범위는 적합하나, 기존 `/live/room/info/{id}` 토큰 발급 경계의 제한 누락과 채널 소유자 조회 테스트 보완점을 `reviews/현재변경사항-review.md`에 기록했다. 실제 HTTP/RTC 연결은 미검증이며, 제품 전체의 입장 차단 완결성 승인은 보류한다. 기존 완료 기록은 유지한다.
- 2026-09-17: 사용자 요청에 따라 `REV-001`을 토큰 발급 제한 보강 `P3-T1`, `REV-002`를 채널 소유자 조회 테스트 `P3-T2`로 구체화하고 `P3-GATE`를 추가했다. 파일·조건·예외 key·회귀 시나리오·명령·완료 증거를 명시했으며 기존 Phase 1~2 완료 기록은 유지했다. `./gradlew tasks --all`은 exit 0, `BUILD SUCCESSFUL`; `git diff --check`도 통과했다. 이번 변경은 계획 문서만 갱신하므로 코드 테스트는 실행하지 않았다. 후속 구현은 미착수이며 다음 Goal은 `P3-T1`이다.
- 2026-09-17: `P3-T1` 시작 전 문서 정합성 완료. PRD의 관련 review, 문제·목표·사용자 흐름, `LCR-003`, API 계약, 보안 기준과 미완료 성공 기준에 `/live/room/info/{id}` 토큰 발급 경계 및 `REV-002` 회귀 검증을 반영했다.
- 2026-09-17: `P3-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest'`가 119건, 실패·오류·skip 0으로 `BUILD SUCCESSFUL` 종료했다. `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`도 모두 성공했다.
- 2026-09-17: 토큰 발급 경계는 보안 관련 변경이므로 조건부 전체 회귀를 실행했다. `./gradlew test --rerun-tasks`가 `BUILD SUCCESSFUL`로 종료했다. 실제 HTTP 인증/직렬화 및 Agora RTC/RTM 연결은 환경상 실행하지 않았고, 실제 서비스 메서드 호출과 token builder 무호출 assertion 및 H2 repository 조회를 대체 실행 증거로 사용했다.
- 2026-09-17: `P3-GATE` 최종 reviewer gate 승인. Phase 3 구현·테스트·PRD·계획·후속 리뷰 해소 기록을 전체 diff 기준으로 검토했으며 Blocker는 0건이다.
@@ -0,0 +1,115 @@
# 라이브 크리에이터 입장 제한 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | Phase 1~3 구현 및 검증 완료 |
| 작성일 | `2026-09-17` |
| 최종 수정일 | `2026-09-17` |
| 대상 제품 | 라이브 방 목록 노출 및 입장 제한 |
| 작성자·결정권자 | Sisyphus / 사용자 |
| 관련 API Contract | 기존 API 응답 스키마 변경 없음 |
| 관련 구현 계획 | `docs/20260917_라이브_크리에이터_입장제한/plan-task.md` |
| 관련 review | `reviews/현재변경사항-review.md` (`REV-001`, `REV-002`) |
## 1. Overview
라이브 방 생성자가 `isAvailableJoinCreator = false`로 설정한 방은 다른 크리에이터 계정에게 노출되거나 입장 가능하면 안 된다. 또한 `genderRestriction`으로 입장 가능 성별이 제한된 방은 조회 단계에서도 입장 가능한 사용자에게만 노출돼야 한다. 기존 `/live/room` 계열 조회는 대부분 이 정책을 적용하지만, v2 홈 추천/온에어 라이브와 일부 크리에이터 채널 라이브 경로에서 정책 누락 가능성이 확인됐다. 후속 리뷰에서 `/live/room/info/{id}`가 같은 제한을 확인하지 않고 RTC/RTM 토큰을 발급하는 경로도 확인됐다.
## 2. Problem Statement
- `v2` 홈 추천/온에어 라이브 조회는 `findLiveRecommendations()`에서 조회자의 크리에이터 여부와 유효 성별을 받지 않아 `isAvailableJoinCreator = false`인 방도 크리에이터에게 노출될 수 있고, 성별 제한 방도 입장 불가능한 사용자에게 노출될 수 있다.
- `v2` 크리에이터 채널 라이브 탭은 조회자가 크리에이터인지가 아니라 조회자가 해당 채널 주인인지로만 `isViewerCreator`를 계산해, 다른 크리에이터에게 제한 방이 노출될 수 있다.
- `/live/room/enter`는 성인, 비공개, 차단, 강퇴, 성별, 정원, 결제 조건은 확인하지만 크리에이터 입장 제한을 최종 차단하지 않는다.
- `/live/room/info/{id}`는 방 정보와 상호 차단만 확인한 뒤 RTC/RTM 토큰을 생성하므로 `/enter`에서 거절된 사용자도 토큰 발급을 요청할 수 있다.
문제를 해결했다는 판단은 다른 크리에이터가 `isAvailableJoinCreator = false` 방을 리스트에서 보지 못하고, 성별 제한에 맞지 않는 사용자가 제한 방을 리스트에서 보지 못하며, 직접 `enter` 요청을 보내도 차단되는 것으로 한다.
## 3. Goals
- 다른 크리에이터 계정은 `isAvailableJoinCreator = false` 라이브 방을 문제 의심 경로에서 볼 수 없다.
- 다른 크리에이터 계정은 `isAvailableJoinCreator = false` 라이브 방에 직접 입장할 수 없다.
- 입장 가능 성별이 제한된 라이브 방은 성별이 맞는 사용자 또는 방 생성자 본인에게만 조회된다.
- 입장 가능 성별이 제한된 라이브 방은 성별이 맞는 사용자 또는 방 생성자 본인만 입장할 수 있다.
- 방을 만든 크리에이터 본인은 예외로 계속 조회 및 입장 가능하다.
- 제한 사용자는 `/live/room/info/{id}`에서 RTC/RTM/v2v 토큰을 발급받을 수 없다.
- 기존 응답 DTO와 공개 API 스키마는 변경하지 않는다.
## 4. Non-Goals
- `isAvailableJoinCreator` 필드명, 의미, DB 스키마 변경은 하지 않는다.
- 일반 유저, 관리자, 봇, 에이전트의 기존 노출/입장 정책은 변경하지 않는다.
- 기존 성인/성별/차단/강퇴/비공개/결제 정책은 재설계하지 않는다.
- `Gender.NONE` 사용자의 기존 정책은 변경하지 않는다. 기존 `Member.canEnter()`와 리스트 조건처럼 성별 미설정 사용자는 성별 제한을 통과한다.
- 새 API endpoint나 새 응답 필드는 만들지 않는다.
## 5. Target Users and Permissions
| 사용자 | 목표 | 정책 |
|---|---|---|
| 방 생성 크리에이터 | 본인이 만든 방 조회 및 입장 | `isAvailableJoinCreator = false`여도 허용 |
| 다른 크리에이터 | 크리에이터 입장 제한 방 접근 방지 | `isAvailableJoinCreator = false`면 목록 미노출 및 입장 차단 |
| 일반 유저 | 기존 라이브 조회 및 입장 | `genderRestriction`에 맞으면 허용, `Gender.NONE`은 기존처럼 허용 |
## 6. 핵심 사용자 흐름
1. 일반 유저 또는 방 생성자가 `isAvailableJoinCreator = false` 방을 조회하면 기존 정책에 따라 표시된다.
2. 성별 제한에 맞지 않는 사용자가 `genderRestriction` 제한 방을 v2 홈 추천/온에어 라이브에서 조회하면 표시되지 않는다.
3. 다른 크리에이터가 같은 방을 v2 홈 추천/온에어 라이브 또는 크리에이터 채널 라이브 경로에서 조회하면 표시되지 않는다.
4. 성별 제한에 맞지 않는 사용자가 방 ID를 알고 `/live/room/enter`를 호출하면 입장이 거부된다.
5. 다른 크리에이터가 방 ID를 알고 `/live/room/enter`를 호출하면 입장이 거부된다.
6. 방 생성 크리에이터가 본인 방에 입장하면 기존처럼 허용된다.
7. `/enter`에서 거절되는 다른 크리에이터 또는 성별 불일치 사용자가 `/live/room/info/{id}`를 호출하면 토큰 생성 전에 같은 정책으로 거절된다.
8. 방 생성자, 성별이 맞는 일반 사용자, 크리에이터 입장이 허용된 방의 다른 크리에이터는 기존처럼 방 정보와 토큰을 받는다.
## 7. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `LCR-001` | 확정 | v2 홈 추천/온에어 라이브 조회에서 다른 크리에이터에게 `isAvailableJoinCreator = false` 방을 숨기고, 성별 제한에 맞지 않는 사용자에게 `genderRestriction` 제한 방을 숨긴다. | `HomeRecommendationQueryService.findLiveRecommendations()` 호출 경로가 조회자 크리에이터 여부와 유효 성별을 전달한다. repository는 `liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(memberId))`와 `Gender.MALE -> ALL/MALE_ONLY`, `Gender.FEMALE -> ALL/FEMALE_ONLY`, `Gender.NONE/null -> 필터 없음` 조건을 적용한다. | `P1-T1` |
| `LCR-002` | 확정 | v2 크리에이터 채널 라이브 탭에서 다른 크리에이터에게 제한 방을 숨긴다. | `CreatorChannelLiveQueryService`가 `viewer.role == MemberRole.CREATOR` 기준으로 조회자 크리에이터 여부를 전달한다. 방 생성자 본인은 예외다. | `P1-T2` |
| `LCR-003` | 확정 | `/live/room/enter`와 `/live/room/info/{id}`에서 다른 크리에이터의 제한 방 입장·토큰 발급과 성별 제한에 맞지 않는 접근을 차단한다. | 다른 크리에이터 제한은 `live.room.not_found`, 성별 제한은 `live.room.gender_restricted`로 결제·입장 상태 변경 또는 RTC/RTM/v2v 토큰 생성 전에 거절한다. 생성자는 예외이며 성별 판정은 `Member.canEnter()`를 재사용한다. | `P2-T1`, `P3-T1` |
| `LCR-004` | 확정 | 기존 정상 경로는 유지한다. | 일반 유저와 방 생성자 본인의 조회/입장 동작은 유지되고 기존 테스트 또는 신규 회귀 테스트로 확인된다. | `P2-GATE` |
## 8. API 계약
- 변경되는 공개 request/response 필드는 없다.
- `/live/room/enter`는 기존 오류 envelope를 사용한다.
- `/live/room/info/{id}`도 기존 오류 envelope와 message key를 재사용하며 응답 필드는 변경하지 않는다.
- 신규 message key 추가 여부는 구현 단계에서 기존 `live.room.gender_restricted`, `common.error.invalid_request`, `live.room.not_found` 패턴을 확인해 결정한다. 새 key가 필요하면 메시지 리소스와 테스트를 함께 갱신한다.
## 9. 보안과 데이터 취급
- 숨김 정책은 클라이언트 표시만으로 끝내지 않고 서버 입장 경계에서 한 번 더 차단한다.
- 제한 방 존재 여부를 다른 크리에이터에게 과도하게 노출하지 않는 오류 메시지를 우선한다.
- 민감정보, 결제 정보, password, token은 로그나 테스트 fixture에 기록하지 않는다.
- 제한 검사는 RTC, RTM, v2v 토큰 생성 호출보다 먼저 수행한다.
## 10. 성공 기준
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방을 v2 홈 추천/온에어 라이브에서 받지 않는다. (`LCR-001`)
- [x] 성별 제한에 맞지 않는 사용자는 `genderRestriction` 제한 방을 v2 홈 추천/온에어 라이브에서 받지 않는다. (`LCR-001`)
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방을 v2 크리에이터 채널 라이브 탭에서 받지 않는다. (`LCR-002`)
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방에 직접 입장할 수 없다. (`LCR-003`)
- [x] 성별 제한에 맞지 않는 사용자는 제한 방에 직접 입장할 수 없다. (`LCR-003`)
- [x] 방 생성 크리에이터 본인은 본인 방을 조회하고 입장할 수 있다. (`LCR-001`~`LCR-003`)
- [x] 공개 API 스키마 변경 없이 focused test와 영향 범위 회귀가 통과한다.
- [x] 다른 크리에이터와 성별 불일치 사용자는 `/live/room/info/{id}`에서 토큰 생성 전에 거절되고 토큰 생성기가 호출되지 않는다. (`LCR-003`)
- [x] 방 생성자, 성별이 맞는 일반 사용자, 크리에이터 입장이 허용된 방의 다른 크리에이터, `Gender.NONE` 사용자는 기존처럼 방 정보와 토큰을 받는다. (`LCR-003`, `LCR-004`)
- [x] 채널 소유자 본인은 `isAvailableJoinCreator = false`인 현재 방을 repository 조회에서 받는다. (`LCR-002`, `LCR-004`)
## 11. Decision Log
| 일시 | 결정 | 근거 |
|---|---|---|
| 2026-09-17 | `isAvailableJoinCreator = false`는 다른 크리에이터만 제한하고 방 생성자 본인은 예외로 둔다. | 기존 QueryDSL 조건들이 `liveRoom.member.id.eq(viewerId)` 예외를 두는 패턴과 사용자 선택 A |
| 2026-09-17 | 이 작업은 문서가 필요한 구현 작업으로 분류한다. | 저장소 `AGENTS.md`와 `docs/agent-guides/작업절차.md`가 모든 구현 작업 전 PRD와 plan-task를 요구 |
| 2026-09-17 | v2 홈 추천/온에어 라이브에도 기존 조회 경로와 같은 `genderRestriction` 필터를 적용한다. | `/live/room`, 팔로잉, 크리에이터 채널 조회 경로가 이미 성별 조건을 조회 단계에서 적용하고 있으며 사용자가 문서 반영을 요청 |
| 2026-09-17 | `/live/room/enter`의 성별 제한은 기존 `Member.canEnter()` 정책을 유지한다. | 현재 `LiveRoomService.enterLive()`가 이미 `room.member.id != member.id && !member.canEnter(room.genderRestriction)`을 차단하고, `Gender.NONE`은 `canEnter()`에서 허용됨 |
| 2026-09-17 | `/live/room/info/{id}`에서도 `/enter`와 같은 크리에이터·성별 제한을 토큰 생성 전에 적용한다. | `REV-001`에서 `/enter` 거절 후에도 RTC/RTM/v2v 토큰 생성 경로가 열려 있음을 코드로 확인 |
| 2026-09-17 | 채널 소유자의 제한 방 조회는 production 변경 없이 H2 repository 회귀 테스트로 고정한다. | `REV-002`와 기존 `creatorJoinLiveCondition()`의 소유자 예외 |
## 12. 열린 질문
- 없음. 구현 중 기존 메시지 key 재사용이 부적절하다고 확인되면 plan-task 범위를 먼저 갱신한다.
@@ -0,0 +1,114 @@
# 라이브 크리에이터 입장 제한 현재 변경사항 리뷰
## 1. 리뷰 정보
- 일자: 2026-09-17
- 기준: `62c834d28f0d142e81f2d303273eb1220cab0611` 위의 현재 staged/working tree 변경사항. commit 자체에 대한 승인이 아니다.
- 소스 diff SHA-256 (`git diff HEAD -- src`): `7b951c79f1575832ac65870a479005909da22e640772c5609a3cbdd983209b98`
- 기준 문서: `../prd.md`, `../plan-task.md`
- 범위: LCR-001~004, 변경 production 7개 파일과 관련 테스트, 기존 조회/입장 정책.
- 상태: 판정 완료. production 및 테스트 코드는 수정하지 않았다.
## 2. 검토 기준 및 근거
- 홈 추천·라이브·온에어의 역할/인증 성별 전달, QueryDSL 필터와 생성자 예외를 대조한다.
- 크리에이터 채널 라이브의 역할 판정과 기존 repository 조건을 대조한다.
- 직접 입장 거부가 결제 및 roomInfo 접근보다 먼저 발생하는지 확인한다.
- 공개 DTO, 일반 사용자, Gender.NONE, 기존 성인/차단/결제 정책의 회귀 여부를 확인한다.
- `docs/sample/sample-review.md` 기준으로 확정 결함과 검증 한계를 구분한다.
## 3. 검토별 증거 기록
| 검토 | 기준 HEAD | 판정 | 근거 |
|---|---|---|---|
| 코드 품질 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/quality`: SQL WHERE 적용 후 페이지네이션, 호출 3곳 인자 전달, 기본값 및 생성자 예외, 부수효과 전 차단 확인. 이전 XML은 이번 실행 증거로 사용하지 않음. |
| 요구사항 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/requirements`: LCR-001~004 구현 일치. 채널 소유자 조회의 직접 회귀 테스트는 보완 여지 있음. |
| 주변 문맥 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/context`: 기존 팔로잉·채널 홈 조건 및 Member.canEnter와 일치. 기존 상세 조회의 제한 미검사는 이번 diff 밖임. |
| 보안 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | FAIL | `/root/security`: getRoomInfo의 기존 RTC 토큰 발급 경로가 입장 제한을 검사하지 않음. 코드 추적 근거이며 실제 RTC 연결은 미검증. |
| 자동 QA | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/qa`: focused 140건 + 인접 회귀 42건 통과. 실제 HTTP/RTC QA는 INCONCLUSIVE. |
| 런타임 감사 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | INCONCLUSIVE | H2 쿼리 결과, 서비스 거부 예외 및 무부수효과 assertion은 실행 확인. 실제 RTC 토큰을 사용한 연결 우회는 실행하지 않음. |
## 4. 실행 검증
환경: macOS, Java 17, Gradle Wrapper, repository 테스트 H2. 전체 회귀 대신 변경 파일 focused test 및 직접 영향받는 기능 회귀를 실행한다. 공개 DTO·공통 인증·설정 변경은 없으며 이 리뷰에서 소스를 수정하지 않아 전체 테스트를 재실행하지 않았다. 기존 계획의 전체 회귀 성공 기록은 이번 실행 결과로 간주하지 않는다.
```bash
./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest'
```
- `/root/qa` 실행: BUILD SUCCESSFUL, 140건, 실패/오류/skip 0, 3분 19초.
- `./gradlew ktlintCheck tasks --all`: BUILD SUCCESSFUL. ktlint 작업은 UP-TO-DATE이며 강제 재실행은 하지 않았다.
- `git diff --check`: exit 0.
- `./gradlew test --tests '*HomeRecommendationControllerTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*HomeOnAirLiveControllerTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'`: exit 0, 42건, 실패/오류/skip 0, 41초.
- 합계: 182건 통과. 추천 repository 81, 추천 service 35, 추천 facade 6, 온에어 facade 4, 채널 service 9, 입장 service 5, 추천 controller 27, 온에어 controller 2, 채널 repository 8, 기존 성인 정책 5.
- 실제 HTTP/RTC 연결은 미검증. localhost:8080에 실행 중 서버가 없고 운영 데이터·외부 서비스에 연결하지 않았다. 서비스 Mockito 테스트 및 H2 repository 테스트는 입장 거부/조회 필터의 실행 증거이며 실제 RTC 우회 재현 증거는 아니다.
## 5. 발견 사항 및 종료 판정
### REV-001 — 기존 방 정보 API의 토큰 발급 경계에 제한 검사 없음
- 심각도: High. 상태: 제한 검사 누락은 코드로 확정, 실제 RTC 접속 우회는 미검증.
- 관련 목표: PRD §3의 다른 크리에이터·성별 불일치 사용자의 입장 차단.
- 이번 diff가 도입한 결함은 아니며, 계획에 명시된 목록 및 `/enter` 수정 범위 밖의 기존 경로다.
- `LiveRoomController.kt:129`의 `GET /live/room/info/{id}`는 로그인 확인 후 `getRoomInfo()`를 호출한다.
- `LiveRoomService.kt:972`는 roomInfo·room 존재와 상호 차단만 검사한다. 크리에이터 제한, 성별 제한, 호출자의 입장 완료 여부는 확인하지 않는다.
- 같은 파일 `:991`부터 RTC 토큰 생성기를 호출하고 `:1065`부터 채널명과 토큰을 응답에 넣는다. `RtcTokenBuilder.kt:83`은 JoinChannel 권한을 추가한다.
- 확인 시나리오: roomInfo가 존재하는 제한 방에서, 호스트와 차단 관계가 없는 다른 크리에이터가 방 ID로 `/info/{id}`를 요청한다. `/enter`에서 거절됐더라도 이 경로의 토큰 발급 전 제한 검사가 없다.
- 권장 조치: 토큰 발급 경계에도 생성자 예외를 포함한 크리에이터·성별 제한을 적용하고, 제한 사용자는 토큰 생성기가 호출되지 않는 회귀 테스트로 고정한다. 전체 입장 정책 재설계는 별도 범위다.
### REV-002 — 채널 소유자 조회의 직접 회귀 테스트 보완
- 심각도: Low, 비차단 검증 보완점.
- `CreatorChannelLiveQueryServiceTest.kt:67`은 다른 크리에이터의 `isViewerCreator` 전달을 검증한다.
- `DefaultCreatorChannelLiveQueryRepositoryTest.kt:206`은 다른 크리에이터 조회 필터를 검증하지만, 소유자 본인의 제한 방 반환을 직접 검증하지 않는다.
- 소유자 허용 조건 자체는 `DefaultCreatorChannelLiveQueryRepository.kt:353`에 존재하므로 구현 누락으로 판정하지 않는다.
- 권장 조치: viewerId == creatorId이고 isAvailableJoinCreator == false인 현재 방이 반환되는 repository 회귀 테스트 1건으로 P1-T2 완료 증거를 보강한다.
## 6. 후속 처리
코드 수정은 수행하지 않았다. 명시된 구현 범위의 기능 결함은 발견하지 못했으나, 제품 전체의 입장 차단 완료 판정에는 REV-001 확인·보강이 필요하다. 구현을 진행할 경우 기존 계획에 토큰 발급 경계 보강 Task를 추가한 뒤 실패 재현 → 최소 수정 → 회귀 검증 순서로 진행한다. 기존 완료 체크박스와 검증 기록은 유지한다.
**종합 판정:** 명시된 목록·`/enter` 구현 범위는 적합하다. 보안 검토 FAIL 및 실제 API/RTC 검증 한계 때문에 제품 전체의 입장 차단에 대한 무조건 승인은 보류한다. 이번 diff에서 새로 도입된 확정 결함은 발견하지 못했다.
**다음 행동:** REV-001의 토큰 발급 경계를 후속 검증·수정 범위로 반영한다.
## 7. Phase 3 후속 구현 검증
- 일자: 2026-09-17
- 대상: `REV-001`, `REV-002` 후속 구현과 Phase 3 Gate.
- [x] `REV-001` 해소: `LiveRoomService.getRoomInfo()`가 기존 room/roomInfo 존재 및 상호 차단 검사 뒤, RTC/RTM/v2v 토큰 생성 전에 다른 크리에이터와 성별 불일치 사용자를 각각 `live.room.not_found`, `live.room.gender_restricted`로 거절한다.
- [x] `REV-001` 회귀: 제한 경로의 token builder 무호출, `/enter` 거절 후 정보 조회 거절, 소유자·성별 일치 사용자·허용 방 크리에이터·인증 성별 우선·`Gender.NONE` 허용, 기존 상호 차단 거절을 서비스 테스트로 확인했다.
- [x] `REV-002` 해소: 실제 H2 repository 테스트가 채널 소유자에게 `isAvailableJoinCreator = false`인 본인 현재 방 ID를 반환하고, 기존 다른 크리에이터 제외 테스트도 통과했다.
- [x] Phase 3 focused 회귀: 5개 테스트 클래스 119건이 실패·오류·skip 0으로 통과했다.
- [x] 품질 및 명령 검증: `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 성공했다.
- [x] 전체 회귀: 보안 경계 변경을 반영해 `./gradlew test --rerun-tasks`를 실행했고 `BUILD SUCCESSFUL`로 종료했다.
- [x] 검증 한계 기록: 실제 HTTP 인증/직렬화 및 Agora RTC/RTM 연결은 실행하지 않았으며 서비스 메서드 호출, token builder 무호출 assertion, 실제 H2 조회를 대체 실행 증거로 사용했다.
- [x] 최종 reviewer gate: Phase 3 구현·테스트·문서 정합성을 전체 diff 기준으로 재검토했고 Blocker 0건으로 승인됐다.
**후속 판정:** `REV-001`, `REV-002`는 구현 및 회귀 검증으로 해소됐다. 기존 리뷰의 보안 FAIL은 토큰 발급 전 서버 제한 검사 누락 기준에서 해소됐으며, 실제 RTC 연결 검증을 수행했다는 주장은 하지 않는다.
## 8. Phase 1~3 문서·코드 정적 재대조 — 2026-09-17
- 사용자 요청: 전체 자동 테스트는 이미 수행됐으므로 재실행하지 않고 문서와 실제 코드의 스펙 일치 여부를 확인한다.
- 기준 HEAD: `62c834d28f0d142e81f2d303273eb1220cab0611` 및 현재 staged/working tree 변경.
- 소스 diff SHA-256 (`git diff HEAD -- src`): `4d90f90b2a262d1ee081c8e9fc78085ce6d04199d9c50a3d5a7506ceaeed0fbe`.
- 범위: LCR-001~004, P1~P3, REV-001/002, 호출 경로와 테스트 assertion. 이번 검토에서 테스트·애플리케이션은 실행하지 않는다. 기존 실행 성공 기록과 이번 정적 검토 판정은 구분한다.
| 검토 | 정적 판정 | 근거 |
|---|---|---|
| 요구사항 | PASS | `/root/spec_v3`: 전체 호출 3곳 역할·성별 전달, 목록 필터 및 생성자 예외, 입장/토큰 발급 전 거절, 채널 소유자 repository assertion까지 LCR-001~004 구현 확인. |
| 보안 경계 | PASS | `/root/security_v3`: LiveRoomService.kt:986~995 검사 뒤 :1001/:1009/:1016 토큰 생성. 기존 존재·양방향 차단 검사 및 /enter의 결제 전 차단 유지. |
| 코드 품질 | PASS | `/root/quality_v3`: 모든 호출부 인자 전달, WHERE 필터 후 페이지네이션, 내부 기본값 및 공개 API/DB 스키마 유지. |
| 테스트 소스 | PASS | `/root/tests_v3`: LiveRoomServiceTest.kt:180~326 제한/허용/우선순위/무호출 assertion, :368 세 토큰 응답 및 호출 횟수, 채널 repository 테스트 :244~266 실제 소유자 방 ID assertion 확인. 실행 QA 판정은 아님. |
| 문서·주변 정책 | PASS | `/root/context_v3`: Member.canEnter 및 기존 채널 조건과 일치. 이전 FAIL/미착수는 보존된 이력이며 후속 완료 기록으로 해소됨. |
위 판정은 명시된 HEAD와 소스 diff 조합에 한정한다.
**요구사항 대조:**
- LCR-001: 홈 추천·라이브·온에어의 역할/유효 성별 전달과 조회 필터 일치. 생성자 예외와 NONE/null 정책 유지.
- LCR-002: 채널 조회자의 실제 CREATOR 역할 판정 및 생성자 예외 일치. REV-002의 소유자 H2 테스트 추가 확인.
- LCR-003: /enter는 결제·상태 변경 전, /info는 모든 토큰 생성 전에 동일 제한 검사. 예외 key·우선순위·생성자 예외·Member.canEnter 재사용 일치. REV-001 해소 확인.
- LCR-004: 허용 사용자·생성자·인증 성별 우선·Gender.NONE 회귀 assertion 존재. 공개 DTO/DB 스키마 및 기존 제한 조건 유지.
**최종 정적 판정: PASS.** 문서에 명시된 Phase 1~3 구현 및 후속 두 항목의 누락·스펙 위반은 발견하지 못했다. 새 수정 Task로 전환할 확정 항목은 없다. 코드·테스트는 변경하지 않았으며 이 절만 누적했다. 사용자 요청에 따라 테스트와 Gradle 작업을 재실행하지 않고 문서·소스·diff를 대조했다. 전체 자동 테스트 성공은 사용자 확인 및 기존 문서 기록으로 구분하며, 이번에 HTTP/RTC 실행 또는 과거 RED→GREEN을 재검증했다고 주장하지 않는다.
@@ -0,0 +1,299 @@
# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | Phase 1~3 완료, P3-R1 구현 완료·실행 검증 대기 |
| 작성일 | `2026-09-17` |
| 요구사항 기준 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/prd.md` |
| API 기준 | 기존 API 응답 스키마 변경 없음 |
| 현재 Phase | Phase 3 후속 실행 검증 대기 |
| 현재 활성 Goal | `P3-R1` 실행 검증 |
| 다음 Goal | 없음 |
## 목표
`MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)` Deprecated 경로를 `MemberContentPreferenceService` 정식 경로로 이전하고, Deprecated 함수와 전용 테스트를 제거한다. 사용자가 확정한 JP 강제 매핑의 `2L` 제거를 제외하고 성인 콘텐츠 노출 판정 결과는 유지한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `1/1` | 없음 | 없음 |
| 2 | 완료 | `4/4` | 없음 | 없음 |
| 3 | 완료 | `1/1` | 없음 | 없음 |
| 3 후속 | 진행 중 | `0/1` | 활성: `P3-R1` 실행 검증 | 회귀 테스트 구현·소스 검토 완료, 자동 실행 대기 |
## 범위
### 포함
- `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` 추가.
- production 12개 파일 27곳의 `isAdultVisibleByPolicy(...)` 호출 치환 및 `MemberContentPreferenceService` 생성자 주입.
- `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt` 제거와 고유 검증 이전.
- 영향 범위 focused test 및 ktlint 검증.
### 제외
- `isAdult` 판정 알고리즘 변경 및 JP 강제 매핑의 `2L` 제거 외 국가 강제 매핑 회원 ID 변경.
- 각 서비스 public 메서드 시그니처 변경, 공개 API 스키마 변경.
- `CanController`의 통화 강제 로직.
## 기술적 제약
- 기술 스택: Kotlin, Spring Boot 2.7.14, Gradle Wrapper, ktlint.
- 신규 abstraction을 만들지 않는다. 기존 `MemberContentPreferenceService`의 `resolveCountryCode` + `calculateIsAdultForQuery`를 재사용한다.
- `CountryContext`는 `@RequestScope`이므로 마이그레이션 대상은 웹 요청 경로에서만 호출되는 서비스로 한정한다(`RISK-001`).
- 모든 production 변경 Task는 `RED → GREEN → REFACTOR` 순서로 진행한다.
- 테스트 실행은 변경 범위 focused test를 우선하고, 전체 회귀는 실행하지 않으며 생략 근거를 검증 기록에 남긴다.
## Phase 1: 정식 경로 단일 진입점 확보
**Phase 결과:** `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 Deprecated 함수와 동일한 판정을 제공한다.
**선행조건:** PRD 확정.
**Phase 완료 조건:** `P1-T1` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 1.1 `isAdultVisibleForQuery` 추가
**Goal 실행 `P1-T1`:** `MemberContentPreferenceService`에 접속 국가 계산과 성인 노출 판정을 묶은 단일 메서드를 추가한다.
- **시작 조건:** `DEPREM-001` 확정.
- **완료 증거:** KR + 미인증 → `false`, KR + 인증 → 전달값, 비KR → 전달값, 강제 매핑 회원은 헤더보다 강제 국가 우선인 focused test 통과.
- **범위 밖:** 호출부 치환.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
**Interfaces:**
- `fun isAdultVisibleForQuery(member: Member, isAdultContentVisible: Boolean): Boolean`
- 구현은 `calculateIsAdultForQuery(member, resolveCountryCode(member), isAdultContentVisible)`로 한다. DB 조회를 추가하지 않는다.
- [x] **RED:** `MemberContentPreferenceServiceTest`에 `isAdultVisibleForQuery` 판정 테스트 4건(KR 미인증/KR 인증/비KR/강제 매핑)을 작성한다.
- [x] **RED 확인:** `MemberContentPreferenceServiceTest` 실행 결과 `Unresolved reference: isAdultVisibleForQuery` 컴파일 실패를 확인했다.
- [x] **GREEN:** `MemberContentPreferenceService`에 `isAdultVisibleForQuery`를 추가했다.
- [x] **GREEN 확인:** `MemberContentPreferenceServiceTest` 25/25 통과.
- [x] **REFACTOR:** `resolveCountryCode` + `calculateIsAdultForQuery` 재사용만으로 구현해 중복 계산이 없음을 확인했고 ktlint 통과.
## Phase 2: production 호출부 마이그레이션
**Phase 결과:** production 코드에서 `isAdultVisibleByPolicy(...)` 호출이 0건이 된다.
**선행조건:** Phase 1 완료.
**Phase 완료 조건:** `P2-T1`~`P2-T4`와 `P2-GATE` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 2.1 메인 탭 서비스 7개 치환
**Goal 실행 `P2-T1`:** 콘텐츠 메인 탭 서비스 7개가 `MemberContentPreferenceService`로 성인 노출 여부를 계산한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 7개 파일에서 `isAdultVisibleByPolicy` import/호출 0건, 컴파일 및 영향 범위 테스트 통과.
- **범위 밖:** `AudioContentService`, `ContentSeriesService`.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt`
**Interfaces:**
- 각 서비스 생성자에 `private val memberContentPreferenceService: MemberContentPreferenceService`를 추가한다.
- `isAdultVisibleByPolicy(member, isAdultContentVisible)` → `memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)`.
- `AudioContentMainTabHomeService`의 nullable member 처리(`member?.let { ... } ?: false`)는 유지한다.
- [x] **RED:** 7개 서비스가 각각 대응 `*Controller`에서만 주입됨을 확인해 `RISK-001` 위반 경로가 없음을 검증했다.
- [x] **GREEN:** 7개 파일의 호출부와 생성자를 치환했다.
- [x] **GREEN 확인:** 테스트 컴파일과 `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과로 확인했다.
- [x] **REFACTOR:** `isAdultVisibleByPolicy` import를 `MemberContentPreferenceService` import로 교체했고 ktlint 통과.
#### Task 2.2 메인/큐레이션/테마 서비스 치환
**Goal 실행 `P2-T2`:** `AudioContentMainService`, `AudioContentCurationService`, `AudioContentThemeService`를 정식 경로로 치환한다.
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** 3개 파일에서 `isAdultVisibleByPolicy` 0건, 컴파일 및 영향 범위 테스트 통과.
- **범위 밖:** 다른 Task 대상 파일.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt`
**Interfaces:**
- `P2-T1`과 동일한 치환 규칙을 적용한다.
- [x] **RED:** 3개 서비스의 치환 대상이 모두 `member`를 받는 웹 조회 메서드임을 확인했다.
- [x] **GREEN:** 3개 파일을 치환했다.
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 `content` 패키지 회귀 통과.
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
#### Task 2.3 `AudioContentService` 치환
**Goal 실행 `P2-T3`:** `AudioContentService`의 3곳을 정식 경로로 치환한다.
- **시작 조건:** `P2-T2` 완료.
- **완료 증거:** `AudioContentServiceTest`가 갱신된 생성자로 통과한다.
- **범위 밖:** `ContentSeriesService`.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
**Interfaces:**
- `P2-T1`과 동일한 치환 규칙을 적용하고, 테스트에서는 `MemberContentPreferenceService`를 mock으로 주입한다.
- [x] **RED:** `AudioContentServiceTest.kt:118`에서 `No value passed for parameter 'memberContentPreferenceService'` 컴파일 실패를 확인했다.
- [x] **GREEN:** `getDetail`, `getLatestCreatorAudioContent`, `getAudioContentList` 3곳을 치환하고 테스트에 mock을 주입했다.
- [x] **GREEN 확인:** `AudioContentServiceTest` 16/16 통과.
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
#### Task 2.4 `ContentSeriesService` 치환
**Goal 실행 `P2-T4`:** `ContentSeriesService`의 5곳을 정식 경로로 치환한다.
- **시작 조건:** `P2-T3` 완료.
- **완료 증거:** 시리즈 관련 영향 범위 테스트 통과, `isAdultVisibleByPolicy` 0건.
- **범위 밖:** Deprecated 함수 제거.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt`
- Test: 필요 시 `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt` 등 영향 테스트 갱신
**Interfaces:**
- `P2-T1`과 동일한 치환 규칙을 적용한다.
- [x] **RED:** 테스트 컴파일을 실행해 `ContentSeriesService`를 직접 생성하는 테스트가 없어 추가 수정이 필요 없음을 확인했다.
- [x] **GREEN:** 5곳(`getSeriesList`, `getSeriesListByGenre`, `getSeriesDetail`, `getSeriesContentList`, `getRecommendSeriesList`)을 치환했다.
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 Spring 컨텍스트 로딩 테스트 통과.
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
### Phase 2 Gate
**Goal 실행 `P2-GATE`:** production에서 Deprecated 호출이 0건임을 확인한다.
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`에서 0건이다.
- [x] Phase 2 대상 파일의 영향 범위 테스트가 통과한다(`content` 42/42).
## Phase 3: Deprecated 함수 제거와 테스트 정리
**Phase 결과:** Deprecated 함수와 전용 테스트가 제거되고 고유 검증이 정식 경로 테스트에 남는다.
**선행조건:** Phase 2 완료.
**Phase 완료 조건:** `P3-T1`, `P3-GATE` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 3.1 Deprecated 정의 제거와 검증 이전
**Goal 실행 `P3-T1`:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거하고 고유 검증을 이전한다.
- **시작 조건:** `P2-GATE` 완료.
- **완료 증거:** 두 파일이 삭제되고 `member/contentpreference` 테스트가 모두 통과한다.
- **범위 밖:** `MemberContentPreferenceCountryResolver.kt` 변경.
**Files:**
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
- Delete: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
**Interfaces:**
- 이전 대상 검증: 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시.
- 이미 정식 경로 테스트에 존재하는 검증은 중복 추가하지 않는다.
- [x] **RED:** 로그인 회원의 헤더 공백/소문자 정규화와 KR fallback 검증을 `MemberContentPreferenceServiceTest`에 추가했다. 나머지 항목(비로그인 정규화, 저장 `countryCode` 미사용)은 기존 테스트가 이미 커버함을 확인해 중복 추가하지 않았다.
- [x] **GREEN:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 삭제했다.
- [x] **GREEN 확인:** `member/contentpreference` 36/36 통과.
- [x] **REFACTOR:** ktlint 통과 및 잔여 참조 0건 확인.
### Phase 3 Gate
**Goal 실행 `P3-GATE`:** 전체 정리 상태를 확인한다.
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다.
- [x] `member/contentpreference` 테스트와 Phase 2 영향 범위 테스트가 통과한다.
- [x] `./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck`가 통과한다.
- [x] PRD 성공 기준 체크박스를 갱신했다.
## Phase 3 후속: 리뷰 검증 보강
### Task 3.R1 저장 국가 무시 검증 이전
**Goal 실행 `P3-R1`:** 저장 국가가 KR인 미인증 회원이 US에서 조회할 때 저장 국가를 무시하는 기존 정책을 정식 서비스 테스트로 검증한다.
- **시작 조건:** DEPREM-004 및 `reviews/문서대비구현-review.md`의 REV-002 확정. REV-001은 사용자 확인으로 결함 판정 철회; `2L` 제거 유지.
- **완료 증거:** 아래 테스트 추가 및 조건·assertion 소스 검토, 후속 실행이 허용된 경우 focused test 결과 기록. 실행하지 않았으면 실행 검증 대기로 남기고 완료 처리하지 않는다.
- **범위 밖:** production 정책·resolver·매핑 변경, API 변경, 테스트 helper 리팩터링, 전체 회귀 실행.
- **TDD 예외:** 현재 production은 올바르게 저장 국가를 무시하므로 신규 테스트는 처음부터 통과하는 것이 정상이다. 실패를 만들려고 정상 구현을 변경하지 않는다. 삭제된 테스트와 새 테스트의 입력·assertion 대조로 누락 검증 이전을 확인한다.
- **실행 제약:** 이번 문서 갱신에서는 테스트를 실행하지 않는다. 기존 자동 테스트 미실행 지시가 유지되는 동안 후속 작업도 소스 검토까지만 진행하고 실행 상태를 별도로 남긴다.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md`
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/reviews/문서대비구현-review.md`
**추가할 테스트:** 기존 `createMember`, `countryContext`, `service`, JUnit import를 재사용한다.
```kotlin
@Test
@DisplayName("비KR 요청에서는 회원의 저장 국가와 무관하게 전달한 성인 노출값을 사용한다")
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
val member = createMember(id = 2401L, withAuth = false).apply {
countryCode = "KR"
}
countryContext.setCountryCode("US")
assertEquals("US", service.resolveCountryCode(member))
assertTrue(service.isAdultVisibleForQuery(member, isAdultContentVisible = true))
assertFalse(service.isAdultVisibleForQuery(member, isAdultContentVisible = false))
}
```
- [x] **누락 확인:** 삭제된 정책 테스트와 비교해 저장 KR·요청 US·미인증 조건이 기존 정식 테스트에 없음을 확인했다(TDD 예외 적용).
- [x] **최소 구현:** 위 테스트 1건만 추가했다. 강제 매핑이 없는 ID 2401을 사용하고 production은 변경하지 않았다.
- [x] **소스 검토:** 국가 결과 US 및 노출 전달값 true/false 보존 assertion을 확인했다. 저장 국가 KR을 우선하면 국가 assertion과 `true` 전달값 assertion이 실패한다.
- [ ] **실행 검증:** 자동 테스트 실행이 허용되는 후속 단계에서 아래 focused test와 ktlint를 실행하고 결과를 기록한다. 미실행이면 완료 표시하지 않는다.
- [x] **기록 갱신:** P3-T1의 기존 완료 기록을 유지하고, REV-002 보강 결과와 실행 대기 상태를 본 Task와 리뷰에 누적했다.
**후속 실행용 명령(이번에는 미실행):** 저장소와 호환되는 JDK 환경에서 실행한다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest'
./gradlew ktlintTestSourceSetCheck
```
기대 결과: 신규 테스트를 포함한 서비스 테스트 및 테스트 소스 ktlint 성공. 테스트 한 파일의 검증 보강이므로 전체 회귀는 생략한다.
## 검증 기록
- 2026-09-17 Phase 3 후속 P3-R1: 삭제된 정책 테스트와 현재 서비스 테스트를 대조해 저장 KR·요청 US·미인증 조건의 누락을 확인하고 `MemberContentPreferenceServiceTest.shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr` 1건을 추가했다. 국가 결과 US와 전달값 true/false 보존 assertion을 소스로 검토했다. production은 변경하지 않았으며 기존 자동 테스트 미실행 지시에 따라 focused test와 ktlint는 실행하지 않아 Task 완료 처리는 보류했다.
- 2026-09-17 리뷰 후속 문서 갱신: 사용자가 JP 매핑의 `2L` 제거를 의도한 변경으로 확정했다. REV-002 검증 누락은 `P3-R1`로 추가하고 REV-003 호출 수는 27곳으로 정정했다. 문서와 테스트 소스의 식별자·조건을 대조했으며 사용자 지시에 따라 자동 테스트 및 Gradle 명령은 실행하지 않았다.
- 2026-09-17 Phase 1: `MemberContentPreferenceServiceTest` 실행 → RED에서 `Unresolved reference: isAdultVisibleForQuery` 확인, GREEN 후 25/25 통과. 검증 이유: 신규 판정 메서드가 Deprecated 함수와 동일한 결과를 내는지 확인.
- 2026-09-17 Phase 2: `AudioContentServiceTest.kt:118` 컴파일 실패로 생성자 변경 영향을 확인한 뒤 mock 주입. `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과, `AudioContentServiceTest` 16/16 통과.
- 2026-09-17 Phase 3: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference` 36/36 통과(Deprecated 테스트 5건 삭제, 신규 3건 추가 반영).
- 2026-09-17 빈 주입 회귀: `SpringBootIntegrationSampleTest` 1/1 통과로 12개 서비스에 추가된 `MemberContentPreferenceService` 주입이 애플리케이션 컨텍스트에서 정상 해석됨을 확인(순환 의존 없음).
- 2026-09-17 스타일: `JAVA_HOME=<Android Studio JBR 21> ./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck` BUILD SUCCESSFUL. JetBrains JBR 25는 Gradle 8.1.1과 비호환이어서 Java 21 JBR을 사용했다.
- 2026-09-17 전체 회귀 생략: `./gradlew test` 전체 회귀는 실행하지 않았다. 근거는 변경이 `isAdult` 계산 위임 경로 치환으로 한정되고 공개 스키마/DB/보안 설정 변경이 없으며, 대신 변경 범위 focused test(`content` 42, `member/contentpreference` 36, `AudioContentServiceTest` 16)와 컨텍스트 로딩 테스트, ktlint 전체 소스셋 검사를 실행했다.
@@ -0,0 +1,132 @@
# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | Phase 1~3 완료, 리뷰 후 고유 검증 보강 대기 |
| 작성일 | `2026-09-17` |
| 최종 수정일 | `2026-09-17` |
| 대상 제품 | 성인 콘텐츠 노출 여부 / 접속 국가 코드 계산 정책 |
| 작성자·결정권자 | Junie / 사용자 |
| 관련 API Contract | 기존 API 응답 스키마 변경 없음 |
| 관련 구현 계획 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md` |
| 관련 review | `reviews/문서대비구현-review.md` |
## 1. Overview
성인 콘텐츠 노출 여부(`isAdult`)와 접속 국가 코드 계산 정책은 현재 두 경로에 중복 구현되어 있다. 하나는 `@Deprecated`로 표시된 top-level 함수 `MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)`이고, 다른 하나는 정식 경로인 `MemberContentPreferenceService`다. Deprecated 경로가 production 12개 파일에서 여전히 27곳 호출되고 있어 정책 변경 시 두 곳을 모두 고쳐야 하는 위험이 남아 있다. 이 작업은 Deprecated 경로 호출부를 `MemberContentPreferenceService`로 이전하고 Deprecated 함수와 그 전용 테스트를 제거한다.
## 2. Problem Statement
- `MemberContentPreferencePolicy.kt`의 두 함수는 `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`에서 `@Deprecated` 처리만 되고 호출부 이전은 완료되지 않았다.
- 접속 국가 판정 기준이 두 경로에서 다르게 조달된다. Deprecated 경로는 `RequestContextHolder`에서 `CloudFront-Viewer-Country` 헤더를 직접 읽고, 정식 경로는 `CountryContext`(요청 스코프)에서 읽는다. 값 출처(같은 헤더, `CountryInterceptor`가 주입)는 같지만 경로가 둘이다.
- Deprecated 함수의 유일한 테스트인 `MemberContentPreferencePolicyTest`의 5개 검증은 `MemberContentPreferenceServiceTest` / `MemberContentPreferenceIntegrationTest`에 이미 동등하게 존재해, 회원 ID 강제 매핑 정책을 변경할 때마다 같은 상수를 3~4개 파일에서 함께 고쳐야 한다.
- 결과적으로 정책 변경(예: 국가 강제 매핑 회원 ID 추가/삭제)이 중복 지점 누락으로 회귀를 만들 수 있다.
문제를 해결했다는 판단은 `isAdultVisibleByPolicy` / `resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`과 `src/test/kotlin`에서 0건이 되고, 기존 legacy 콘텐츠 조회 API의 `isAdult` 계산 결과가 그대로 유지되는 것으로 한다.
## 3. Goals
- 성인 콘텐츠 노출 여부 계산의 단일 진입점을 `MemberContentPreferenceService`로 만든다.
- `MemberContentPreferencePolicy.kt`의 Deprecated 함수 2개와 파일, 전용 테스트를 제거한다.
- 기존 legacy 콘텐츠 조회 API의 `isAdult` / 국가 코드 계산 동작을 유지하되, 사용자가 확정한 JP 강제 매핑의 `2L` 제거는 예외로 반영한다.
- 공개 API request/response 스키마를 변경하지 않는다.
## 4. Non-Goals
- `isAdult` 판정 정책 자체(KR + 본인인증 여부, 해외 전달값 사용)를 변경하지 않는다.
- JP 강제 매핑의 `2L` 제거 외에는 회원 ID 국가 강제 매핑(`FORCED_KR_MEMBER_IDS`, `FORCED_JP_MEMBER_IDS`) 값을 변경하지 않는다.
- `isAdultContentVisible`을 서비스 파라미터에서 제거하거나 저장값 조회로 대체하는 리팩터링은 하지 않는다. 호출부 시그니처는 유지한다.
- `CanController`의 통화(currency) 강제 지정 로직은 국가 강제 매핑과 무관하므로 건드리지 않는다.
- `MemberContentPreferenceCountryResolver.kt`의 강제 매핑 알고리즘은 유지하고, JP 대상에서 `2L`만 제외한다.
## 5. 영향 범위
### 5.1 Deprecated 함수 정의
| 파일 | 대상 |
|---|---|
| `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt` | `resolveCountryCodeByPolicy(member)`, `isAdultVisibleByPolicy(member, isAdultContentVisible)` |
### 5.2 production 호출부
| 파일 | 호출 수 |
|---|---:|
| `content/series/ContentSeriesService.kt` | 5 |
| `content/AudioContentService.kt` | 3 |
| `content/main/AudioContentMainService.kt` | 2 |
| `content/main/curation/AudioContentCurationService.kt` | 2 |
| `content/theme/AudioContentThemeService.kt` | 2 |
| `content/main/tab/free/AudioContentMainTabFreeService.kt` | 4 |
| `content/main/tab/alarm/AudioContentMainTabAlarmService.kt` | 2 |
| `content/main/tab/content/AudioContentMainTabContentService.kt` | 2 |
| `content/main/tab/home/AudioContentMainTabHomeService.kt` | 2 |
| `content/main/tab/asmr/AudioContentMainTabAsmrService.kt` | 1 |
| `content/main/tab/replay/AudioContentMainTabLiveReplayService.kt` | 1 |
| `content/main/tab/series/AudioContentMainTabSeriesService.kt` | 1 |
12개 파일, 총 27곳이다. 어떤 파일도 현재 `MemberContentPreferenceService`를 주입받지 않는다.
### 5.3 테스트 호출부
| 파일 | 처리 |
|---|---|
| `src/test/kotlin/.../member/contentpreference/MemberContentPreferencePolicyTest.kt` | 삭제. 동등 검증이 `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`에 이미 존재하며, 부족한 항목은 `MemberContentPreferenceServiceTest`로 이전한다. |
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DEPREM-001` | 확정 | `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)`를 추가해 `resolveCountryCode(member)` + `calculateIsAdultForQuery(...)` 조합을 단일 메서드로 제공한다. | KR + `auth == null`이면 `false`, KR + `auth != null`이면 전달값, 비KR이면 전달값. 회원 ID 강제 매핑이 접속 국가 헤더보다 우선한다. | `P1-T1` |
| `DEPREM-002` | 확정 | production 12개 파일의 `isAdultVisibleByPolicy(...)` 27곳을 `memberContentPreferenceService.isAdultVisibleForQuery(...)`로 치환하고 `MemberContentPreferenceService`를 생성자 주입한다. | 각 파일에서 `isAdultVisibleByPolicy` import와 호출이 사라지고, `isAdult` 계산 결과가 기존과 동일하다. | `P2-T1`~`P2-T4` |
| `DEPREM-003` | 확정 | `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거한다. | `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다. | `P3-T1` |
| `DEPREM-004` | 확정 | Deprecated 테스트에서 제거되는 검증 항목 중 정식 경로 테스트에 없는 것은 `MemberContentPreferenceServiceTest`로 이전한다. | 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시 검증이 정식 경로 테스트에 존재한다. | `P3-T1` |
| `DEPREM-005` | 확정 | 기존 legacy 콘텐츠 조회 동작을 회귀 없이 유지한다. | `AudioContentServiceTest` 등 영향 범위 테스트와 `member/contentpreference` 테스트가 모두 통과하고 ktlint가 통과한다. | `P3-GATE` |
## 7. API 계약
- 변경되는 공개 request/response 필드는 없다.
- 신규 message key, 신규 endpoint는 없다.
- 각 서비스의 public 메서드 시그니처(`isAdultContentVisible` 파라미터 포함)는 유지한다. 변경되는 것은 생성자 의존성뿐이다.
## 8. 보안과 데이터 취급
- 접속 국가 판정은 계속 `CloudFront-Viewer-Country` 헤더를 `CountryInterceptor`가 `CountryContext`에 주입한 값만 사용한다.
- 성인 콘텐츠 노출은 KR에서 본인인증(`member.auth != null`)이 있는 경우에만 허용하는 기존 정책을 유지한다.
- 회원 ID, 이메일 등 개인정보는 로그에 추가하지 않는다.
## 9. 리스크
| ID | 리스크 | 대응 |
|---|---|---|
| `RISK-001` | `CountryContext`는 `@RequestScope`이므로 비웹 스레드에서 호출되면 예외가 발생할 수 있다. Deprecated 함수는 `RequestContextHolder`가 없을 때 `KR`로 fallback했다. | 대상 12개 서비스가 controller 요청 경로에서만 호출되는지 Phase 2에서 파일별로 확인한다. 스케줄러/비동기 호출 경로가 발견되면 해당 Task를 진행하지 않고 계획을 먼저 갱신한다. |
| `RISK-002` | 생성자 파라미터 추가로 기존 서비스 테스트가 컴파일 실패할 수 있다. | 해소됨. `AudioContentServiceTest` 1곳만 영향받아 mock 주입으로 해결했다. |
| `RISK-003` | 순환 의존 발생 가능성. | `MemberContentPreferenceService`는 `MemberContentPreferenceRepository`, `MemberRepository`, `CountryContext`, `CacheManager`만 의존하므로 콘텐츠 서비스와 순환이 발생하지 않음을 확인했다. |
## 10. 성공 기준
- [x] `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 기존 `isAdultVisibleByPolicy(...)`와 동일한 판정을 한다. (`DEPREM-001`)
- [x] production 12개 파일 27곳이 모두 정식 경로를 호출한다. (`DEPREM-002`)
- [x] `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt`가 제거되고 참조가 0건이다. (`DEPREM-003`)
- [x] Deprecated 테스트의 고유 검증이 `MemberContentPreferenceServiceTest`에 남아 있다. (`DEPREM-004`)
- [x] 영향 범위 focused test와 ktlint가 통과한다. (`DEPREM-005`)
## 11. Decision Log
기존 성공 기준 체크박스는 당시 완료 기록으로 유지한다. 후속 리뷰에서 DEPREM-004의 저장 국가 충돌 검증 누락을 확인했으며, `plan-task.md`의 `P3-R1` 완료 전까지 해당 검증 보강은 미완료다.
| 일시 | 결정 | 근거 |
|---|---|---|
| 2026-09-17 | Deprecated 함수 제거를 위해 production 호출부를 모두 마이그레이션하는 방식(선택지 3)을 택한다. | 사용자 선택. 테스트만 정식 경로로 바꾸면 production에서 쓰이는 Deprecated 경로가 커버리지 0이 되므로 부적절 |
| 2026-09-17 | 호출부 1:1 치환을 위해 `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` wrapper를 추가한다. | 호출부마다 `resolveCountryCode(...)` + `calculateIsAdultForQuery(...)` 2단계를 반복하면 중복이 27곳으로 늘어남 |
| 2026-09-17 | `MemberContentPreferencePolicyTest`는 삭제하고 고유 검증만 `MemberContentPreferenceServiceTest`로 이전한다. | 5개 검증 중 대부분이 정식 경로 테스트와 중복이며, 함수 제거 후에는 테스트 대상이 사라짐 |
| 2026-09-17 | Phase를 서비스 묶음 단위로 나눠 진행한다. | 27곳 동시 변경 시 실패 원인 추적이 어려움. 저장소 규칙("작은 단위로 안전하게 수정") 준수 |
| 2026-09-17 | `RISK-001`은 해소로 판정한다. | 치환한 27곳이 모두 controller 진입 조회 경로임을 파일별로 확인했고, 스케줄러(`Recommendation/Ranking/ChargeEvent`)는 이 경로를 호출하지 않음 |
- 2026-09-17 사용자 확정: JP 강제 매핑에서 `2L`을 제거한 것은 의도된 정책 변경이다. REV-001은 결함 판정을 철회하며 `2L`을 복원하지 않는다. 나머지 매핑은 유지한다.
- 2026-09-17 후속 계획: REV-002의 저장 국가 무시 검증을 `P3-R1`로 추가한다. 이번 작업은 문서 갱신이며 테스트 구현·실행은 하지 않는다.
## 12. 열린 질문
- 없음. Phase 2에서 비웹 호출 경로(`RISK-001`)가 발견되면 계획을 먼저 갱신하고 사용자에게 확인한다.
@@ -0,0 +1,65 @@
# 문서 대비 구현 리뷰
## 리뷰 정보와 범위
- 일자: 2026-09-17
- 기준: HEAD `8c87286acd69174f6024d30a267458e838e35cf5` 및 리뷰 시작 시 존재한 미커밋 변경
- 요구사항: `../prd.md`, `../plan-task.md`의 Phase 1~3
- 방법: 문서, production 코드, 테스트 소스, 현재 diff와 호출 경로 정적 대조
- 사용자 지시에 따라 자동 테스트·빌드·ktlint·애플리케이션 실행은 하지 않았다. 기존 실행 성공 기록을 재검증한 것으로 취급하지 않는다.
- 구현 및 기존 PRD/계획은 수정하지 않았다. 아래 판정은 현재 작업 트리에 대한 것이며 변경 작성자나 의도를 추정하지 않는다.
## 발견 사항
### REV-001 — 강제 JP 매핑 값이 변경됨
- 심각도: High / 상태: 확정
- 요구사항: PRD §3 기존 동작 유지, §4 강제 매핑 값 변경 금지; 계획 P3-T1의 resolver 변경 제외
- 근거: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt:6`에서 `FORCED_JP_MEMBER_IDS`의 `2L`이 제거됐다.
- 코드 추적: 회원 ID 2, 인증 없음, 요청 국가 KR 또는 헤더 없음, `isAdultContentVisible=true`이면 기존 JP 판정/성인 노출 true에서 KR 판정/false로 바뀐다.
- 관련 테스트도 `MemberContentPreferenceServiceTest.kt:64`, `MemberContentPreferenceIntegrationTest.kt:187`에서 ID 2를 29721로 교체해 기존 매핑 회귀를 검출하지 못한다.
- 권장 조치: 문서대로라면 기존 매핑과 해당 테스트 대상을 복원한다. 별도로 의도한 정책 변경이라면 해당 결정과 범위를 문서에 명시해야 한다.
### REV-002 — 저장 국가 무시 검증이 이전되지 않음
- 심각도: Medium / 상태: 확정
- 요구사항: DEPREM-004, P3-T1
- 근거: 삭제된 PolicyTest는 저장 `member.countryCode="KR"`, 요청 국가 US 조건을 검증했다. 현재 `MemberContentPreferenceServiceTest.kt:469`의 해외 노출 테스트와 `:536`의 회원 생성 함수는 저장 국가를 설정하지 않는다. IntegrationTest에도 이 충돌 조건이 없다.
- 영향: 현재 resolver는 저장 국가를 참조하지 않아 구현 자체는 맞지만, 문서에서 이전하도록 요구한 고유 회귀 검증이 빠졌다. `plan-task.md:224`의 기존 테스트가 이미 커버한다는 기록은 부정확하다.
- 권장 조치: 일반 미인증 회원의 저장 국가를 KR로 설정하고 요청 국가는 US로 두어, 정식 진입점의 국가 US/성인 노출 true를 확인하는 검증을 추가한다.
### REV-003 — 호출부 합계 오기
- 심각도: Low / 상태: 확정
- 근거: PRD §5.2 표의 합계와 실제 치환은 모두 12개 파일 27곳이다. PRD와 계획에는 반복해서 24곳으로 기록돼 있다.
- 영향: 호출 치환 누락은 없으며 문서 수치가 틀렸다.
- 권장 조치: 두 문서의 합계를 27곳으로 정정한다.
## 충족 항목과 한계
| 항목 | 정적 검토 결과 |
|---|---|
| DEPREM-001 단일 진입점 | 기존 resolveCountryCode + calculateIsAdultForQuery 재사용, 추가 DB 조회 없음 |
| DEPREM-002 호출부 이전 | 12개 파일 27곳 이전, 인자 및 홈의 member=null → false 유지 |
| DEPREM-003 정의/참조 제거 | 두 파일 삭제, src/main/kotlin 및 src/test/kotlin의 두 Deprecated 함수 이름 검색 0건 |
| DEPREM-004 고유 검증 이전 | 정규화/null 국가값 fallback은 존재, 저장 국가 충돌 검증 누락 |
| DEPREM-005 실행 검증 | 사용자 요청에 따라 재실행 제외 |
| API 계약 | 기존 공개 메서드 인자와 request/response 스키마 변경 없음 |
| 요청 경로/의존성 | 변경된 조회 메서드의 비웹 호출 및 새 순환 의존 발견 없음 |
국가값 null 검증은 요청 스코프 자체가 없는 상황과 다르다. 새 경로는 요청 스코프와 회원 ID를 요구하므로 모든 입력에서 구 함수와 동등하다고 일반화할 수 없다. 다만 검토한 변경 호출 경로에서 해당 비웹/ID 없는 입력은 확인하지 못해 별도 운영 결함으로 확정하지 않았다.
## 리뷰 종료 판정과 후속 작업
요구사항, 테스트 소스, 코드 품질, 보안, 주변 호출 맥락의 검토 결과를 종합했다. 확정 불일치 3건으로 문서와 완전히 일치하는 구현이라고 판정할 수 없다.
이번 요청은 검토이므로 수정 goal은 실행하지 않았다. 후속 구현 시 기존 완료 기록을 유지하면서 REV-001 매핑 일치, REV-002 고유 검증 이전, REV-003 문서 수치 정정을 별도 회귀 Task로 계획에 추가한다. 자동 검증은 이번 검토에서 수행하지 않았다.
## 후속 판정 정정 — 2026-09-17
위 내용은 최초 검토 당시 기록이며, 사용자 확인 이후 현재 판정은 다음과 같다.
- **REV-001: 결함 판정 철회.** 사용자가 `2L` 제거를 의도한 변경으로 확정했다. PRD·계획에 해당 예외를 반영했으며 매핑과 테스트 대상을 복원하지 않는다.
- **REV-002: 구현 완료 / 실행 검증 대기.** `MemberContentPreferenceServiceTest.shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr`를 추가해 저장 KR·요청 US·미인증 회원 조건에서 국가 결과 US와 전달값 true/false 보존을 검증한다. production은 변경하지 않았고, 기존 자동 테스트 미실행 지시에 따라 focused test와 ktlint는 실행하지 않았다.
- **REV-003: 문서 정정 완료.** PRD·계획의 24곳 표기를 실제 합계 27곳으로 정정했다.
- **현재 결론:** `P3-R1` 테스트 구현과 소스 검토는 완료했으며, 남은 항목은 focused test와 ktlint 실행 검증이다.
@@ -0,0 +1,62 @@
# 캔 조회 회원 2 강제 통화 제거 구현 계획
- 상태: 구현 완료
- 작성일: 2026-09-18
- 요구사항: [prd.md](prd.md)
- 현재 Phase: 1 완료
- 활성 Goal: 없음
- 실행 순서: CAN-P1-T1 → CAN-P1-GATE
### Phase 1: 회원 2의 기본 통화 적용과 회귀 확인
- [x] **Task 1.1: 회원 2의 강제 통화 제거** (`CAN-P1-T1`)
- Objective: 회원 2가 다른 일반 회원처럼 요청 국가에 따른 통화의 캔 상품을 조회한다.
- 시작 조건: PRD 확정, 기존 컨트롤러·서비스 및 테스트 확인 완료.
- 완료 증거: RED/GREEN 결과, 실제 컨트롤러·서비스·저장소 통합 테스트와 캔 패키지 회귀 통과.
- 범위 밖: 다른 회원 예외 제거, 국가 결정·API 스키마·서비스 리팩터링.
- 수정: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt`
- 생성: `src/test/kotlin/kr/co/vividnext/sodalive/can/CanControllerIntegrationTest.kt`
- 확인: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt`
- [x] **RED:** 회원 2의 KRW/USD, 유지할 예외·일반 회원·비로그인·국가 누락 통합 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest'`로 회원 2의 JPY 반환 assertion 실패를 확인한다.
- [x] **GREEN:** 컨트롤러 조건에서 `member.id == 2L ||`만 제거한다.
- [x] **GREEN 확인:** 동일 focused test가 통과하는지 확인한다.
- [x] **REFACTOR:** 불필요한 정리 없이 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*'` 및 변경 파일 lint로 회귀를 확인한다.
#### Task 검증 기록
- 2026-09-18: 기존 `getCans` 및 `/can` 직접 테스트가 없음을 확인했다. 새 테스트는 `@SpringBootTest`와 H2, 요청 범위의 실제 CountryContext를 사용하고 트랜잭션 롤백으로 상품 데이터를 격리한다.
- 2026-09-18 RED: 테스트 작성 중 `Member.password` 필수 인자 누락을 수정한 뒤 production/test 컴파일이 통과했다. 이후 공통 JWT 기본 키의 길이 부족으로 발생한 컨텍스트 로딩 실패는 테스트 실행에만 임시 키를 제공하여 해결했다. 이 두 실패는 동작 RED에 포함하지 않았다.
- 2026-09-18 RED 확인: 아래 환경 설정 후 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest' --console=plain` 실행. 13개 중 회원 2의 4개만 assertion 실패(exit 1): KR은 KRW, US/JP/국가 누락은 USD 기대였으나 실제 JPY. 나머지 9개 통과.
- 2026-09-18 GREEN 확인: 조건 제거 후 같은 focused 명령으로 13개 전부 통과(exit 0), production/test 컴파일 성공.
- 2026-09-18 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*' --console=plain` 성공(exit 0). 신규 13개와 기존 14개, 총 5개 클래스 27개 테스트 통과(실패·오류·skip 0). 기존 테스트를 완화하거나 수정할 필요가 없었다.
- [x] **Phase Gate: 변경 범위와 검증 기록 확인** (`CAN-P1-GATE`)
- Objective: 요구사항과 최소 변경, 검증 증거가 일치함을 확인한다.
- 시작 조건: CAN-P1-T1 완료.
- 완료 증거: 캔 패키지 테스트 통과, 문서 명령·diff 확인 및 PRD 체크박스 완료.
- 범위 밖: 전체 서비스 실행과 관련 없는 기능 수정.
- 확인 파일: 이 문서, `docs/20260918_캔조회_회원2_강제통화제거/prd.md`, 위 Task의 코드·테스트.
- TDD 예외 사유: 구현 없는 최종 검증 단계.
- 대체 검증 방법: `./gradlew tasks --all`, `git diff --check`, 변경 파일 대조.
## 검증 범위
- 단일 루트 프로젝트이며 변경은 컨트롤러의 조건 하나에 한정된다. 전체 회귀 실행 조건(공통 코드·여러 도메인 변경·영향 불명확 실패·사용자 명시 요청)이 발생하지 않으면 전체 테스트는 생략하고 캔 패키지 전체를 실행한다.
- 테스트 실행으로 production/test 컴파일을 함께 확인한다. 새 API나 실행 환경 변경이 없어 별도 애플리케이션 구동은 하지 않는다.
## Progress
- 2026-09-18: 요구사항 및 TDD 계획 작성, CAN-P1-T1 준비.
- 2026-09-18: CAN-P1-T1 RED 확인 완료, 회원 2 조건 제거 진행.
- 2026-09-18: CAN-P1-T1 완료. 조건 한 항만 제거했고 신규 13개 및 기존 14개 테스트가 통과했다.
- 2026-09-18: CAN-P1-GATE 완료. API 스키마·다른 회원 예외가 유지되는 diff와 PRD 수용 기준, 검증 기록을 대조했다. 남은 작업 없음.
## 공통 검증 기록
- 셸에 기본 Java가 없어 기존 Gradle 캐시의 Java 17을 사용했다. 설정 파일은 변경하지 않았다.
- 테스트 JWT 기본값은 디코딩 후 키 길이가 부족하므로 실행 프로세스에만 임시 키를 전달했다. 키 값은 저장·출력하지 않는다. 아래 환경 설정 뒤 문서의 검증 명령을 실행한다.
```bash
export JAVA_HOME=~/.gradle/jdks/eclipse_adoptium-17-x86_64-os_x.2/jdk-17.0.19+10/Contents/Home
export JWT_SECRET="$(openssl rand -base64 64 | tr -d '\n')"
```
- 2026-09-18: `./gradlew tasks --all --console=plain`, `git diff --check` 성공(exit 0). 변경 Kotlin 파일의 IDE 정적 검사에서 오류 없음. 신규 테스트는 경고도 없으며 기존 컨트롤러의 수정하지 않은 인증 SpEL 식에는 변수 해석 경고 8개가 있다.
- 2026-09-18: 전체 테스트·전체 ktlint 및 별도 서버 실행은 생략했다. 변경은 컨트롤러 조건 한 곳이고 다른 도메인·공통 코드·하위 모듈 변경이 없어 focused 통합 테스트, 캔 패키지 전체 회귀, 변경 파일 정적 검사로 검증했다.
@@ -0,0 +1,27 @@
# 캔 조회 회원 2 강제 통화 제거
## 문서 정보
- 상태: 구현 완료
- 작성일: 2026-09-18
- 관련 계획: [plan-task.md](plan-task.md)
## 문제와 목표
`GET /can`의 `getCans`는 회원 2·4·44144에게 JPY를 강제 적용한다.
사용자 요청에 따라 회원 2만 제외하여 기존 국가별 통화 선택을 따르게 한다.
## 확정 요구사항과 수용 기준
| ID | 요구사항 | 수용 기준 | Goal |
|---|---|---|---|
| CAN-CURRENCY-001 | 회원 2의 JPY 강제 조건 제거 | KR 요청은 KRW, 그 외 국가는 USD 상품 반환 | CAN-P1-T1 |
| CAN-CURRENCY-002 | 나머지 동작 유지 | 회원 4·44144는 JPY, 일반 회원·비로그인은 기존 국가별 통화 적용 | CAN-P1-T1 |
| CAN-CURRENCY-003 | 관련 테스트 검증 | 변경 전 실패 재현, 변경 후 캔 패키지 테스트 통과 | CAN-P1-GATE |
## 범위 밖
- 다른 진입점의 회원 ID 조건, 국가 판정 및 성인콘텐츠 정책 변경.
- 서비스의 KR → KRW / 그 외 → USD 정책 변경.
- 공개 API 요청·응답 스키마, 인증 및 상품 판매 상태 필터 변경.
## 성공 기준
- [x] 회원 2에 대한 회귀 테스트가 변경 전 실패하고 변경 후 통과한다.
- [x] 회원 4·44144, 일반 회원, 비로그인 및 국가 누락의 기존 동작이 유지된다.
- [x] 관련 기존 테스트가 통과하고 검증 결과를 계획 문서에 기록한다.
@@ -17,7 +17,7 @@ class CanController(private val service: CanService) {
fun getCans(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<List<CanResponse>> {
val forcedCurrency = if (member != null && (member.id == 2L || member.id == 4L || member.id == 44144L)) {
val forcedCurrency = if (member != null && (member.id == 4L || member.id == 44144L)) {
"JPY"
} else {
null
@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
@@ -156,6 +157,8 @@ class SecurityConfig(
JwtFilter::class.java
)
.authorizeRequests()
.antMatchers("/readyz").permitAll()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.antMatchers("/member/check/email").permitAll()
.antMatchers("/member/check/nickname").permitAll()
.antMatchers("/member/signup").permitAll()
@@ -38,7 +38,7 @@ import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
@@ -90,6 +90,7 @@ class AudioContentService(
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.s3.content-bucket}")
private val audioContentBucket: String,
@@ -616,7 +617,7 @@ class AudioContentService(
isAdultContentVisible: Boolean,
timezone: String
): GetAudioContentDetailResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
// 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH)
val audioContent = repository.findByIdOrNull(id)
@@ -977,7 +978,7 @@ class AudioContentService(
member: Member,
isAdultContentVisible: Boolean
): GetAudioContentListItem? {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
return null
@@ -1051,7 +1052,7 @@ class AudioContentService(
offset: Long,
limit: Long
): GetAudioContentListResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val isCreator = member.id == creatorId
if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
@@ -13,7 +13,7 @@ import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.domain.Pageable
@@ -33,6 +33,8 @@ class AudioContentMainService(
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -69,7 +71,7 @@ class AudioContentMainService(
} else {
emptyList()
},
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
contentType = contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -88,7 +90,7 @@ class AudioContentMainService(
* - AS-IS theme은 한글만 처리하도록 되어 있음
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
*/
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val themeListRaw = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdult,
@@ -6,7 +6,7 @@ import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -17,6 +17,7 @@ class AudioContentCurationService(
private val repository: AudioContentCurationQueryRepository,
private val contentRepository: AudioContentRepository,
private val blockMemberRepository: BlockMemberRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
@@ -31,14 +32,14 @@ class AudioContentCurationService(
): GetCurationContentResponse {
val totalCount = repository.findTotalCountByCurationId(
curationId = curationId,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
contentType = contentType
)
val audioContentList = repository.findByCurationId(
curationId = curationId,
cloudfrontHost = cloudFrontHost,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
contentType = contentType,
sortType = sortType,
offset = pageable.offset,
@@ -8,7 +8,7 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationQueryR
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.DayOfWeek
@@ -21,14 +21,15 @@ class AudioContentMainTabAlarmService(
private val contentRepository: AudioContentRepository,
private val rankingService: RankingService,
private val eventService: EventService,
private val curationRepository: AudioContentCurationQueryRepository
private val curationRepository: AudioContentCurationQueryRepository,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member
): GetContentMainTabAlarmResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val memberId = member.id!!
val contentBannerList = bannerService.getBannerList(
@@ -106,7 +107,7 @@ class AudioContentMainTabAlarmService(
}
val memberId = member.id!!
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val totalCount = contentRepository.totalAlarmCountByTheme(
memberId = memberId,
@@ -9,7 +9,7 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -20,14 +20,15 @@ class AudioContentMainTabAsmrService(
private val contentRepository: AudioContentRepository,
private val rankingService: RankingService,
private val eventService: EventService,
private val curationRepository: AudioContentCurationQueryRepository
private val curationRepository: AudioContentCurationQueryRepository,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member
): GetContentMainTabAsmrResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val memberId = member.id!!
val theme = "ASMR"
val tabId = 5L
@@ -10,7 +10,7 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@@ -23,7 +23,8 @@ class AudioContentMainTabContentService(
private val rankingService: RankingService,
private val eventService: EventService,
private val tagCurationService: ContentMainTabTagCurationService,
private val curationRepository: AudioContentCurationQueryRepository
private val curationRepository: AudioContentCurationQueryRepository,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
fun fetchData(
isAdultContentVisible: Boolean,
@@ -31,7 +32,7 @@ class AudioContentMainTabContentService(
member: Member
): GetContentMainTabContentResponse {
val memberId = member.id!!
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val tabId = 3L
// 단편 배너
@@ -191,7 +192,7 @@ class AudioContentMainTabContentService(
contentType: ContentType,
member: Member
): List<GetAudioContentMainItem> {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val themeList = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.main.tab.RecommendSeriesRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -24,14 +24,15 @@ class AudioContentMainTabFreeService(
private val contentRepository: AudioContentRepository,
private val rankingService: RankingService,
private val audioContentRepository: AudioContentRepository,
private val audioContentThemeRepository: AudioContentThemeQueryRepository
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member
): GetContentMainTabFreeResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val memberId = member.id!!
val tabId = 7L
@@ -135,7 +136,7 @@ class AudioContentMainTabFreeService(
offset: Long,
limit: Long
): List<GetAudioContentMainItem> {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val memberId = member.id!!
val introduceCreatorCuration = curationRepository.findByContentMainTabIdAndTitle(
@@ -172,7 +173,7 @@ class AudioContentMainTabFreeService(
listOf(theme)
} else {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
isFree = true,
contentType = contentType
).filter {
@@ -186,7 +187,7 @@ class AudioContentMainTabFreeService(
it != "자기소개"
}
},
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
contentType = contentType,
offset = offset,
limit = limit,
@@ -5,7 +5,7 @@ import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.notice.ServiceNoticeService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -19,7 +19,8 @@ class AudioContentMainTabHomeService(
private val noticeService: ServiceNoticeService,
private val bannerService: AudioContentBannerService,
private val rankingService: RankingService,
private val eventService: EventService
private val eventService: EventService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
fun fetchData(
isAdultContentVisible: Boolean,
@@ -43,7 +44,9 @@ class AudioContentMainTabHomeService(
val formattedLastMonday = startDate.format(startDateFormatter)
val formattedLastSunday = endDate.format(endDateFormatter)
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
val isAdult = member
?.let { memberContentPreferenceService.isAdultVisibleForQuery(it, isAdultContentVisible) }
?: false
// 최근 공지사항
val latestNotice = noticeService.getLatestNotice()
@@ -131,7 +134,9 @@ class AudioContentMainTabHomeService(
contentType: ContentType,
member: Member?
): List<GetAudioContentRankingItem> {
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
val isAdult = member
?.let { memberContentPreferenceService.isAdultVisibleForQuery(it, isAdultContentVisible) }
?: false
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
@@ -9,7 +9,7 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -20,14 +20,15 @@ class AudioContentMainTabLiveReplayService(
private val contentRepository: AudioContentRepository,
private val rankingService: RankingService,
private val eventService: EventService,
private val curationRepository: AudioContentCurationQueryRepository
private val curationRepository: AudioContentCurationQueryRepository,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member
): GetContentMainTabLiveReplayResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val memberId = member.id!!
val theme = "다시듣기"
val tabId = 6L
@@ -8,7 +8,7 @@ import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.DayOfWeek
@@ -24,14 +24,15 @@ class AudioContentMainTabSeriesService(
private val rankingService: RankingService,
private val recommendSeriesRepository: RecommendSeriesRepository,
private val eventService: EventService,
private val curationRepository: AudioContentCurationQueryRepository
private val curationRepository: AudioContentCurationQueryRepository,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member
): GetContentMainTabSeriesResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val memberId = member.id!!
// 메인 배너 (시리즈)
@@ -19,7 +19,7 @@ import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -40,6 +40,7 @@ class ContentSeriesService(
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
@@ -147,7 +148,7 @@ class ContentSeriesService(
offset: Long = 0,
limit: Long = 20
): GetSeriesListResponse {
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val totalCount = repository.getSeriesTotalCount(
creatorId = creatorId,
@@ -185,7 +186,7 @@ class ContentSeriesService(
offset: Long = 0,
limit: Long = 20
): GetSeriesListResponse {
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val totalCount = repository.getSeriesByGenreTotalCount(
genreId = genreId,
@@ -219,7 +220,7 @@ class ContentSeriesService(
): GetSeriesDetailResponse {
val series = repository.getSeriesDetail(
seriesId = seriesId,
isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
contentType = contentType
) ?: throw SodaException(messageKey = "series.error.invalid_series_retry")
@@ -364,7 +365,7 @@ class ContentSeriesService(
offset: Long,
limit: Long
): GetSeriesContentListResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
val totalCount = seriesContentRepository.getContentCount(seriesId, isAdult = isAdult, contentType = contentType)
val contentList = seriesContentRepository.getContentList(
@@ -427,7 +428,7 @@ class ContentSeriesService(
contentType: ContentType,
member: Member
): List<GetSeriesListResponse.SeriesListItem> {
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
return repository.getRecommendSeriesListV2(
imageHost = coverImageHost,
isAuth = isAuth,
@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -22,7 +22,8 @@ class AudioContentThemeService(
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val langContext: LangContext
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@Transactional(readOnly = true)
fun getThemes(): List<GetAudioContentThemeResponse> {
@@ -100,7 +101,7 @@ class AudioContentThemeService(
val totalCount = contentRepository.totalCountByTheme(
memberId = member.id!!,
theme = listOf(theme.theme),
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
contentType = contentType
)
@@ -108,7 +109,7 @@ class AudioContentThemeService(
memberId = member.id!!,
theme = listOf(theme.theme),
sortType = sortType,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
contentType = contentType,
offset = offset,
limit = limit
@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.deployment
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.actuate.endpoint.annotation.Endpoint
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation
import org.springframework.stereotype.Component
@Component
@Endpoint(id = "deployment")
class DeploymentEndpoint(
@Value("\${deployment.artifact-version}")
private val artifactVersion: String,
@Value("\${deployment.config-commit}")
private val configCommit: String
) {
@ReadOperation
fun deployment(): Map<String, String> = mapOf(
"application_artifact_version" to artifactVersion,
"config_commit" to configCommit
)
}
@@ -812,6 +812,14 @@ class LiveRoomService(
)
}
if (
member.role == MemberRole.CREATOR &&
room.member!!.id!! != member.id!! &&
!room.isAvailableJoinCreator
) {
throw SodaException(messageKey = "live.room.not_found")
}
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
throw SodaException(messageKey = "live.room.gender_restricted")
}
@@ -975,6 +983,18 @@ class LiveRoomService(
throw SodaException(messageKey = "live.room.info_not_found")
}
if (
member.role == MemberRole.CREATOR &&
room.member!!.id!! != member.id!! &&
!room.isAvailableJoinCreator
) {
throw SodaException(messageKey = "live.room.not_found")
}
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
throw SodaException(messageKey = "live.room.gender_restricted")
}
val currentTimeStamp = Date().time
val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000
@@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
private val FORCED_JP_MEMBER_IDS = setOf(29721L, 32050L, 37543L, 40850L)
fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String {
val memberId = member?.id
@@ -1,21 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
fun resolveCountryCodeByPolicy(member: Member): String {
val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes
val requestCountryCode = requestAttributes?.request?.getHeader("CloudFront-Viewer-Country")
return resolveCountryCodeWithForcedMapping(member, requestCountryCode)
}
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
fun isAdultVisibleByPolicy(member: Member, isAdultContentVisible: Boolean): Boolean {
return if (resolveCountryCodeByPolicy(member) == "KR") {
member.auth != null && isAdultContentVisible
} else {
isAdultContentVisible
}
}
@@ -157,6 +157,14 @@ class MemberContentPreferenceService(
return getStoredPreference(member).isAdult
}
fun isAdultVisibleForQuery(member: Member, isAdultContentVisible: Boolean): Boolean {
return calculateIsAdultForQuery(
member = member,
countryCode = resolveCountryCode(member),
isAdultContentVisible = isAdultContentVisible
)
}
fun resolveCountryCode(member: Member?): String {
if (member != null) requireMemberId(member)
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
@@ -2,7 +2,9 @@ package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeActiveCreatorItem
@@ -53,7 +55,9 @@ class HomeRecommendationFacade(
lives = queryService.findLiveRecommendations(
limit = HOME_LIVE_LIMIT,
memberId = member?.id,
includeAdultLives = includeAdult
includeAdultLives = includeAdult,
isViewerCreator = member?.role == MemberRole.CREATOR,
effectiveViewerGender = member?.effectiveGender()
).map { it.toItem() },
banners = queryService.findHomeBanners(
limit = HOME_BANNER_LIMIT,
@@ -125,7 +129,9 @@ class HomeRecommendationFacade(
offset = page.toOffset(size),
limit = size + 1,
memberId = member.id,
includeAdultLives = resolveAdultVisibility(member)
includeAdultLives = resolveAdultVisibility(member),
isViewerCreator = member.role == MemberRole.CREATOR,
effectiveViewerGender = member.effectiveGender()
)
fetched.toPage(page, size) { it.toItem() }
}.onSuccess {
@@ -209,6 +215,10 @@ class HomeRecommendationFacade(
return memberContentPreferenceService.canViewAdultContent(member)
}
private fun Member.effectiveGender(): Gender {
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
}
private fun Int.toOffset(size: Int): Long = this.toLong() * size
private fun <S, T> List<S>.toPage(
@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.v2.api.home.live.application
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse
@@ -24,7 +26,9 @@ class HomeOnAirLiveFacade(
offset = normalizedPage.toLong() * PAGE_SIZE,
limit = PAGE_SIZE + 1,
memberId = member.id,
includeAdultLives = memberContentPreferenceService.canViewAdultContent(member)
includeAdultLives = memberContentPreferenceService.canViewAdultContent(member),
isViewerCreator = member.role == MemberRole.CREATOR,
effectiveViewerGender = member.effectiveGender()
)
val items = fetched.take(PAGE_SIZE).map { it.toResponse() }
@@ -57,6 +61,10 @@ class HomeOnAirLiveFacade(
return atOffset(ZoneOffset.UTC).toInstant().toString()
}
private fun Member.effectiveGender(): Gender {
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
}
companion object {
private const val PAGE_SIZE = 20
private const val MAX_PAGE = 10_000
@@ -58,7 +58,7 @@ class CreatorChannelLiveQueryService(
validateCreatorRole(creator)
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val isViewerCreator = viewerId == creatorId
val isViewerCreator = viewer.role == MemberRole.CREATOR
val effectiveViewerGender = viewer.effectiveGender()
val fetchedContents = queryPort.findLiveReplayAudioContents(
creatorId = creatorId,
@@ -22,7 +22,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
@@ -58,7 +60,9 @@ class DefaultHomeRecommendationQueryRepository(
offset: Long,
limit: Int,
memberId: Long?,
includeAdultLives: Boolean
includeAdultLives: Boolean,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): List<HomeLiveRecommendationRecord> {
return queryFactory
.select(
@@ -79,6 +83,8 @@ class DefaultHomeRecommendationQueryRepository(
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
includeAdultLiveCondition(includeAdultLives),
genderLiveCondition(memberId, effectiveViewerGender),
creatorJoinLiveCondition(memberId, isViewerCreator),
notBlockedCreatorCondition(memberId, member.id),
member.isActive.isTrue
)
@@ -1294,6 +1300,21 @@ class DefaultHomeRecommendationQueryRepository(
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
}
private fun genderLiveCondition(viewerId: Long?, effectiveViewerGender: Gender?): BooleanExpression? {
if (effectiveViewerGender == null || effectiveViewerGender == Gender.NONE) return null
val genderCondition = when (effectiveViewerGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> return null
}
return viewerId?.let { genderCondition.or(liveRoom.member.id.eq(it)) } ?: genderCondition
}
private fun creatorJoinLiveCondition(viewerId: Long?, isViewerCreator: Boolean): BooleanExpression? {
if (!isViewerCreator || viewerId == null) return null
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
}
private fun excludeAiCharacterCondition(excludeCharacterIds: List<Long>): BooleanExpression? {
return if (excludeCharacterIds.isEmpty()) null else chatCharacter.id.notIn(excludeCharacterIds)
}
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
@@ -41,9 +42,18 @@ class HomeRecommendationQueryService(
offset: Long = 0,
limit: Int = DEFAULT_LIVE_LIMIT,
memberId: Long? = null,
includeAdultLives: Boolean = false
includeAdultLives: Boolean = false,
isViewerCreator: Boolean = false,
effectiveViewerGender: Gender? = null
): List<HomeLiveRecommendationRecord> {
return queryPort.findLiveRecommendations(offset, limit, memberId, includeAdultLives)
return queryPort.findLiveRecommendations(
offset,
limit,
memberId,
includeAdultLives,
isViewerCreator,
effectiveViewerGender
)
}
fun findHomeBanners(
@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.recommendation.port.out
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import java.time.LocalDateTime
@@ -9,7 +10,9 @@ interface HomeRecommendationQueryPort {
offset: Long = 0,
limit: Int,
memberId: Long? = null,
includeAdultLives: Boolean = false
includeAdultLives: Boolean = false,
isViewerCreator: Boolean = false,
effectiveViewerGender: Gender? = null
): List<HomeLiveRecommendationRecord>
fun findHomeBanners(
@@ -28,7 +28,7 @@ class UserCreatorChatController(
@RequestBody request: CreateUserCreatorChatRoomRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.createOrGetRoom(member, request.creatorId))
ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId()))
}
@GetMapping("/{roomId}/open")
@@ -1,8 +1,18 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.dto
import kr.co.vividnext.sodalive.common.SodaException
data class CreateUserCreatorChatRoomRequest(
val creatorId: Long
)
val recipientId: Long? = null,
val creatorId: Long? = null
) {
fun recipientMemberId(): Long {
if (recipientId != null && creatorId != null && recipientId != creatorId) {
throw SodaException(messageKey = "common.error.invalid_request")
}
return recipientId ?: creatorId ?: throw SodaException(messageKey = "common.error.invalid_request")
}
}
data class CreateUserCreatorChatRoomResponse(
val roomId: Long
@@ -59,20 +59,20 @@ class UserCreatorChatService(
private val cloudFrontHost: String
) {
@Transactional
fun createOrGetRoom(member: Member, creatorId: Long): CreateUserCreatorChatRoomResponse {
val creator = memberRepository.findById(creatorId).orElseThrow {
fun createOrGetRoom(member: Member, recipientId: Long): CreateUserCreatorChatRoomResponse {
val recipient = memberRepository.findById(recipientId).orElseThrow {
SodaException(messageKey = "message.error.recipient_not_found")
}
validateRecipient(member, creator)
validateRecipient(member, recipient)
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, creator.id!!)
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, recipient.id!!)
if (existingRoom != null) {
return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!)
}
val room = roomRepository.save(UserCreatorChatRoom())
participantRepository.save(UserCreatorChatParticipant(room, member))
participantRepository.save(UserCreatorChatParticipant(room, creator))
participantRepository.save(UserCreatorChatParticipant(room, recipient))
return CreateUserCreatorChatRoomResponse(roomId = room.id!!)
}
+20
View File
@@ -1,8 +1,28 @@
server:
port: 8080
shutdown: graceful
env: ${SERVER_ENV}
forward-headers-strategy: framework
management:
server:
port: 8082
endpoints:
web:
exposure:
include: health,deployment
endpoint:
health:
probes:
enabled: true
add-additional-paths: true
show-details: never
show-components: never
deployment:
artifact-version: ${DEPLOY_ARTIFACT_VERSION:local}
config-commit: ${DEPLOY_CONFIG_COMMIT:0000000000000000000000000000000000000000}
logging:
level:
com:
@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import javax.persistence.EntityManager
@SpringBootTest(properties = ["spring.cache.type=none"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Transactional
class CanControllerIntegrationTest @Autowired constructor(
private val controller: CanController,
private val countryContext: CountryContext,
private val entityManager: EntityManager
) {
@BeforeEach
fun setUp() {
listOf("KRW", "USD", "JPY").forEach { currency ->
entityManager.persist(
Can(
title = "test-$currency",
can = 100,
rewardCan = 0,
price = BigDecimal("100"),
currency = currency,
status = CanStatus.SALE
)
)
}
entityManager.flush()
entityManager.clear()
}
@ParameterizedTest
@CsvSource(
"2, KR, KRW",
"2, US, USD",
"2, JP, USD",
"2, , USD",
"4, KR, JPY",
"4, US, JPY",
"44144, KR, JPY",
"44144, US, JPY",
"3, KR, KRW",
"3, US, USD",
", KR, KRW",
", US, USD",
", , USD"
)
@DisplayName("회원 2는 국가별 통화를 사용하고 기존 JPY 예외와 일반·비로그인 조회는 유지한다")
fun shouldSelectCurrencyByCountryExceptRemainingForcedMembers(
memberId: Long?,
countryCode: String?,
expectedCurrency: String
) {
val member = memberId?.let { id ->
Member(nickname = "test-$id", password = "").apply { this.id = id }
}
countryContext.setCountryCode(countryCode)
val response = controller.getCans(member)
assertEquals(true, response.success)
assertEquals(listOf(expectedCurrency), response.data?.map { it.currency })
}
}
@@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import org.junit.jupiter.api.Assertions.assertEquals
@@ -63,6 +64,7 @@ class AudioContentServiceTest {
private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var service: AudioContentService
@@ -88,6 +90,7 @@ class AudioContentServiceTest {
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
service = AudioContentService(
repository = repository,
@@ -113,6 +116,7 @@ class AudioContentServiceTest {
langContext = LangContext(),
contentThemeTranslationRepository = contentThemeTranslationRepository,
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
memberContentPreferenceService = memberContentPreferenceService,
audioContentBucket = "audio-bucket",
coverImageBucket = "cover-bucket",
coverImageHost = "https://cdn.test"
@@ -0,0 +1,50 @@
package kr.co.vividnext.sodalive.deployment
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalManagementPort
import org.springframework.http.HttpStatus
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"management.server.port=0",
"deployment.artifact-version=1.2.3-20260914",
"deployment.config-commit=1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d",
"spring.datasource.url=jdbc:h2:mem:deployment-endpoint-injected-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class DeploymentEndpointInjectedValueIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate
) {
@LocalManagementPort
private var managementPort: Int = 0
@Test
@DisplayName("배포 식별자가 주입되면 /actuator/deployment는 주입된 값을 반환한다")
fun shouldReturnInjectedDeploymentIdentity() {
val response = restTemplate.getForEntity(
"http://localhost:$managementPort/actuator/deployment",
Map::class.java
)
assertEquals(HttpStatus.OK, response.statusCode)
assertEquals(
mapOf(
"application_artifact_version" to "1.2.3-20260914",
"config_commit" to "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d"
),
response.body
)
}
}
@@ -0,0 +1,85 @@
package kr.co.vividnext.sodalive.deployment
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalManagementPort
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"management.server.port=0",
"spring.datasource.url=jdbc:h2:mem:deployment-endpoint-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class DeploymentEndpointIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate
) {
@LocalServerPort
private var serverPort: Int = 0
@LocalManagementPort
private var managementPort: Int = 0
@Test
@DisplayName("애플리케이션 포트의 /readyz는 인증 없이 200을 반환한다")
fun shouldReturnOkWhenReadyzIsCalledOnServerPort() {
val response = restTemplate.getForEntity("http://localhost:$serverPort/readyz", String::class.java)
assertEquals(HttpStatus.OK, response.statusCode)
}
@Test
@DisplayName("management 포트의 /actuator/health/readiness는 인증 없이 200을 반환한다")
fun shouldReturnOkWhenReadinessProbeIsCalledOnManagementPort() {
val response = restTemplate.getForEntity(
"http://localhost:$managementPort/actuator/health/readiness",
String::class.java
)
assertEquals(HttpStatus.OK, response.statusCode)
}
@Test
@DisplayName("환경변수가 없으면 /actuator/deployment는 기본 배포 식별자를 snake_case로 반환한다")
fun shouldReturnDefaultDeploymentIdentityWhenEnvironmentVariablesAreNotProvided() {
val response = restTemplate.getForEntity(
"http://localhost:$managementPort/actuator/deployment",
Map::class.java
)
assertEquals(HttpStatus.OK, response.statusCode)
assertEquals(
mapOf(
"application_artifact_version" to "local",
"config_commit" to "0000000000000000000000000000000000000000"
),
response.body
)
}
@Test
@DisplayName("애플리케이션 포트에서는 /actuator/deployment에 접근할 수 없다")
fun shouldNotExposeDeploymentEndpointOnServerPort() {
val response = restTemplate.getForEntity(
"http://localhost:$serverPort/actuator/deployment",
String::class.java
)
assertNotEquals(HttpStatus.OK, response.statusCode)
assertFalse(response.body?.contains("application_artifact_version") ?: false)
}
}
@@ -0,0 +1,429 @@
package kr.co.vividnext.sodalive.live.room
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.agora.RtcTokenBuilder
import kr.co.vividnext.sodalive.agora.RtmTokenBuilder
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
import kr.co.vividnext.sodalive.fcm.PushTokenRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository
import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService
import kr.co.vividnext.sodalive.live.room.menu.LiveRoomMenuService
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService
import kr.co.vividnext.sodalive.live.roulette.NewRouletteRepository
import kr.co.vividnext.sodalive.live.signature.SignatureCanRepository
import kr.co.vividnext.sodalive.live.tag.LiveTagRepository
import kr.co.vividnext.sodalive.member.Gender
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.auth.Auth
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.Mockito
import java.time.LocalDateTime
import java.util.Optional
class LiveRoomServiceTest {
private lateinit var repository: LiveRoomRepository
private lateinit var roomInfoRepository: LiveRoomInfoRedisRepository
private lateinit var canPaymentService: CanPaymentService
private lateinit var memberRepository: MemberRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var explorerQueryRepository: ExplorerQueryRepository
private lateinit var rouletteRepository: NewRouletteRepository
private lateinit var pushTokenRepository: PushTokenRepository
private lateinit var rtcTokenBuilder: RtcTokenBuilder
private lateinit var rtmTokenBuilder: RtmTokenBuilder
private lateinit var service: LiveRoomService
@BeforeEach
fun setup() {
repository = mock()
roomInfoRepository = mock()
canPaymentService = mock()
memberRepository = mock()
blockMemberRepository = mock()
explorerQueryRepository = mock()
rouletteRepository = mock()
pushTokenRepository = mock()
rtcTokenBuilder = mock()
rtmTokenBuilder = mock()
service = LiveRoomService(
menuService = mock<LiveRoomMenuService>(),
messageSource = mock<SodaMessageSource>(),
langContext = LangContext(),
repository = repository,
rouletteRepository = rouletteRepository,
roomInfoRepository = roomInfoRepository,
roomCancelRepository = mock<LiveRoomCancelRepository>(),
kickOutService = mock<LiveRoomKickOutService>(),
blockMemberRepository = blockMemberRepository,
signatureCanRepository = mock<SignatureCanRepository>(),
applicationEventPublisher = mock(),
useCanCalculateRepository = mock<UseCanCalculateRepository>(),
reservationRepository = mock<LiveReservationRepository>(),
explorerQueryRepository = explorerQueryRepository,
creatorDonationRankingService = mock<CreatorDonationRankingService>(),
roomVisitService = mock<LiveRoomVisitService>(),
canPaymentService = canPaymentService,
chargeRepository = mock<ChargeRepository>(),
pushTokenRepository = pushTokenRepository,
memberRepository = memberRepository,
tagRepository = mock<LiveTagRepository>(),
memberContentPreferenceService = mock<MemberContentPreferenceService>(),
canRepository = mock<CanRepository>(),
objectMapper = ObjectMapper(),
s3Uploader = mock<S3Uploader>(),
rtcTokenBuilder = rtcTokenBuilder,
rtmTokenBuilder = rtmTokenBuilder,
agoraAppId = "test-agora-app-id",
agoraAppCertificate = "test-agora-app-certificate",
coverImageBucket = "test-cover-image-bucket",
cloudFrontHost = "https://test-cloudfront-host"
)
}
@Test
@DisplayName("다른 크리에이터는 크리에이터 입장 제한 방에 입장할 수 없다")
fun shouldRejectDifferentCreatorBeforePaymentOrRoomInfoMutation() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val enteringCreator = createMember(id = 2L, role = MemberRole.CREATOR).also { it.pgChargeCan = 10 }
val room = createRoom(owner = owner, price = 10)
prepareAllowedEntry(room)
Mockito.`when`(memberRepository.findById(enteringCreator.id!!)).thenReturn(Optional.of(enteringCreator))
val exception = assertThrows<SodaException> {
service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), enteringCreator)
}
assertEquals("live.room.not_found", exception.messageKey)
Mockito.verifyNoInteractions(roomInfoRepository, canPaymentService)
}
@Test
@DisplayName("성별 제한에 맞지 않는 일반 사용자는 입장할 수 없다")
fun shouldRejectGenderMismatchedUserBeforePaymentOrRoomInfoMutation() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val member = createMember(id = 2L, gender = Gender.FEMALE)
val room = createRoom(owner = owner, price = 10, genderRestriction = GenderRestriction.MALE_ONLY)
Mockito.`when`(repository.getLiveRoom(room.id!!)).thenReturn(room)
val exception = assertThrows<SodaException> {
service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), member)
}
assertEquals("live.room.gender_restricted", exception.messageKey)
Mockito.verifyNoInteractions(roomInfoRepository, canPaymentService)
}
@Test
@DisplayName("방 생성자는 크리에이터 및 성별 제한과 무관하게 입장할 수 있다")
fun shouldAllowRoomOwner() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR, gender = Gender.MALE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.FEMALE_ONLY)
val roomInfo = prepareAllowedEntry(room)
service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), owner)
assertEquals(1, roomInfo.speakerCount)
Mockito.verify(roomInfoRepository).save(roomInfo)
}
@Test
@DisplayName("일반 사용자는 조건에 맞으면 크리에이터 입장 제한 방에 입장할 수 있다")
fun shouldAllowEligibleUser() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val member = createMember(id = 2L, gender = Gender.MALE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY)
val roomInfo = prepareAllowedEntry(room)
service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), member)
assertEquals(1, roomInfo.listenerCount)
Mockito.verify(roomInfoRepository).save(roomInfo)
}
@Test
@DisplayName("성별 미설정 사용자는 기존 성별 제한 정책대로 입장할 수 있다")
fun shouldAllowUserWithNoGender() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val member = createMember(id = 2L, gender = Gender.NONE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY)
val roomInfo = prepareAllowedEntry(room)
service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), member)
assertEquals(1, roomInfo.listenerCount)
Mockito.verify(roomInfoRepository).save(roomInfo)
}
@Test
@DisplayName("다른 크리에이터는 제한 방 정보 조회로 토큰을 발급받을 수 없다")
fun shouldRejectDifferentCreatorFromRoomInfoBeforeTokenGeneration() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, role = MemberRole.CREATOR)
val room = createRoom(owner = owner)
prepareSuccessfulRoomInfo(room)
val exception = assertThrows<SodaException> {
service.getRoomInfo(roomId = room.id!!, member = viewer)
}
assertEquals("live.room.not_found", exception.messageKey)
Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder)
}
@Test
@DisplayName("성별 제한에 맞지 않는 사용자는 방 정보 조회로 토큰을 발급받을 수 없다")
fun shouldRejectGenderMismatchedNonOwnerFromRoomInfoBeforeTokenGeneration() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, gender = Gender.FEMALE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY)
prepareSuccessfulRoomInfo(room)
val exception = assertThrows<SodaException> {
service.getRoomInfo(roomId = room.id!!, member = viewer)
}
assertEquals("live.room.gender_restricted", exception.messageKey)
Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder)
}
@Test
@DisplayName("크리에이터와 성별 제한이 함께 적용되면 크리에이터 제한을 먼저 반환한다")
fun shouldPrioritizeCreatorRestrictionOverGenderRestrictionInRoomInfo() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, role = MemberRole.CREATOR, gender = Gender.FEMALE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY)
prepareSuccessfulRoomInfo(room)
val exception = assertThrows<SodaException> {
service.getRoomInfo(roomId = room.id!!, member = viewer)
}
assertEquals("live.room.not_found", exception.messageKey)
Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder)
}
@Test
@DisplayName("입장 거절 뒤 같은 제한 방 정보 조회도 토큰을 발급하지 않는다")
fun shouldNotGenerateTokensAfterEnterLiveRejectsDifferentCreator() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, role = MemberRole.CREATOR)
val room = createRoom(owner = owner)
prepareSuccessfulRoomInfo(room)
Mockito.`when`(repository.getLiveRoom(room.id!!)).thenReturn(room)
val enterException = assertThrows<SodaException> {
service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), viewer)
}
val roomInfoException = assertThrows<SodaException> {
service.getRoomInfo(roomId = room.id!!, member = viewer)
}
assertEquals("live.room.not_found", enterException.messageKey)
assertEquals("live.room.not_found", roomInfoException.messageKey)
Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder)
}
@Test
@DisplayName("방 생성자는 모든 크리에이터와 성별 제한이 있어도 방 정보 토큰을 발급받는다")
fun shouldAllowRoomOwnerToGetRoomInfoTokens() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR, gender = Gender.MALE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.FEMALE_ONLY)
prepareSuccessfulRoomInfo(room)
assertTokenizedRoomInfo(room, owner)
}
@Test
@DisplayName("성별이 맞는 일반 사용자는 제한 방 정보 토큰을 발급받는다")
fun shouldAllowGenderMatchedUserToGetRoomInfoTokens() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, gender = Gender.MALE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY)
prepareSuccessfulRoomInfo(room)
assertTokenizedRoomInfo(room, viewer)
}
@Test
@DisplayName("크리에이터 입장이 허용된 다른 크리에이터는 방 정보 토큰을 발급받는다")
fun shouldAllowDifferentCreatorWhenCreatorJoiningIsEnabledToGetRoomInfoTokens() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, role = MemberRole.CREATOR, gender = Gender.MALE)
val room = createRoom(
owner = owner,
isAvailableJoinCreator = true,
genderRestriction = GenderRestriction.MALE_ONLY
)
prepareSuccessfulRoomInfo(room)
assertTokenizedRoomInfo(room, viewer)
}
@Test
@DisplayName("인증 성별은 프로필 성별보다 우선하여 방 정보 제한을 판정한다")
fun shouldUseAuthGenderBeforeProfileGenderForRoomInfoTokens() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, gender = Gender.MALE)
viewer.auth = Auth(
name = "viewer",
birth = "20000101",
uniqueCi = "unique-ci",
di = "di",
gender = 2
)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.FEMALE_ONLY)
prepareSuccessfulRoomInfo(room)
assertTokenizedRoomInfo(room, viewer)
}
@Test
@DisplayName("성별 미설정 사용자는 기존 정책대로 방 정보 토큰을 발급받는다")
fun shouldAllowUserWithNoGenderToGetRoomInfoTokens() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, gender = Gender.NONE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY)
prepareSuccessfulRoomInfo(room)
assertTokenizedRoomInfo(room, viewer)
}
@Test
@DisplayName("상호 차단된 사용자는 기존 방 정보 예외로 토큰을 발급받을 수 없다")
fun shouldRejectMutuallyBlockedUserFromRoomInfoBeforeTokenGeneration() {
val owner = createMember(id = 1L, role = MemberRole.CREATOR)
val viewer = createMember(id = 2L, gender = Gender.MALE)
val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY)
prepareSuccessfulRoomInfo(room)
Mockito.`when`(blockMemberRepository.isBlocked(Mockito.anyLong(), Mockito.anyLong())).thenReturn(true)
val exception = assertThrows<SodaException> {
service.getRoomInfo(roomId = room.id!!, member = viewer)
}
assertEquals("live.room.info_not_found", exception.messageKey)
Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder)
}
private fun prepareAllowedEntry(room: LiveRoom): LiveRoomInfo {
val roomInfo = LiveRoomInfo(roomId = room.id!!)
Mockito.`when`(repository.getLiveRoom(room.id!!)).thenReturn(room)
Mockito.`when`(roomInfoRepository.findById(room.id!!)).thenReturn(Optional.of(roomInfo))
Mockito.`when`(roomInfoRepository.save(roomInfo)).thenReturn(roomInfo)
return roomInfo
}
private fun prepareSuccessfulRoomInfo(room: LiveRoom): LiveRoomInfo {
val roomInfo = LiveRoomInfo(roomId = room.id!!)
room.member!!.isVisibleDonationRank = false
room.channelName = "SODA_LIVE_CHANNEL_TEST"
Mockito.`when`(roomInfoRepository.findById(room.id!!)).thenReturn(Optional.of(roomInfo))
Mockito.`when`(repository.findById(room.id!!)).thenReturn(Optional.of(room))
Mockito.`when`(blockMemberRepository.isBlocked(Mockito.anyLong(), Mockito.anyLong())).thenReturn(false)
Mockito.`when`(
rtcTokenBuilder.buildTokenWithUid(
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyInt()
)
).thenReturn("rtc-token")
Mockito.`when`(
rtmTokenBuilder.buildToken(
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyInt()
)
).thenReturn("rtm-token")
Mockito.`when`(explorerQueryRepository.getNotificationUserIds(room.member!!.id!!)).thenReturn(emptyList())
Mockito.`when`(rouletteRepository.findByCreatorId(room.member!!.id!!)).thenReturn(emptyList())
Mockito.`when`(pushTokenRepository.findByMemberId(room.member!!.id!!)).thenReturn(emptyList())
return roomInfo
}
private fun assertTokenizedRoomInfo(room: LiveRoom, member: Member) {
val response = service.getRoomInfo(roomId = room.id!!, member = member)
assertEquals(room.channelName, response.channelName)
assertEquals("rtc-token", response.rtcToken)
assertEquals("rtm-token", response.rtmToken)
assertEquals("rtc-token", response.v2vWorkerToken)
Mockito.verify(rtcTokenBuilder, Mockito.times(2)).buildTokenWithUid(
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyInt()
)
Mockito.verify(rtmTokenBuilder).buildToken(
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyString(),
Mockito.anyInt()
)
}
private fun createRoom(
owner: Member,
price: Int = 0,
isAvailableJoinCreator: Boolean = false,
genderRestriction: GenderRestriction = GenderRestriction.ALL
): LiveRoom {
return LiveRoom(
title = "test room",
notice = "test notice",
beginDateTime = LocalDateTime.now(),
numberOfPeople = 10,
coverImage = "cover/image.png",
isAdult = false,
price = price,
isAvailableJoinCreator = isAvailableJoinCreator,
genderRestriction = genderRestriction
).also {
it.id = 100L
it.member = owner
}
}
private fun createMember(
id: Long,
role: MemberRole = MemberRole.USER,
gender: Gender = Gender.NONE
): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
gender = gender,
role = role
).also { it.id = id }
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}
@@ -29,7 +29,7 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
private val entityManager: EntityManager
) {
companion object {
private val FORCED_MEMBER_IDS = setOf(2L, 16L, 17L, 29721L, 32050L, 37543L, 40850L)
private val FORCED_MEMBER_IDS = setOf(16L, 17L, 29721L, 32050L, 37543L, 40850L)
}
private lateinit var service: MemberContentPreferenceService
@@ -184,7 +184,7 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
countryContext.setCountryCode("US")
val jpMember = Member(email = "jp@test.com", password = "password", nickname = "jp-member")
.apply { id = 2L }
.apply { id = 29721L }
val jpMemberNew = Member(email = "jp-new@test.com", password = "password", nickname = "jp-member-new")
.apply { id = 37543L }
val krMember = Member(email = "kr@test.com", password = "password", nickname = "kr-member")
@@ -1,89 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
class MemberContentPreferencePolicyTest {
@AfterEach
fun cleanup() {
RequestContextHolder.resetRequestAttributes()
}
@Test
@DisplayName("요청 국가 헤더를 기준으로 국가 코드를 계산한다")
fun shouldResolveCountryCodeByRequestHeader() {
setRequestCountry(" us ")
val member = createMember(id = 200L, countryCode = "KR")
assertEquals("US", resolveCountryCodeByPolicy(member))
}
@Test
@DisplayName("강제 매핑 대상 회원 ID는 요청 국가 헤더보다 우선한다")
fun shouldPrioritizeForcedCountryMapping() {
setRequestCountry("US")
val forcedJpMember = createMember(id = 2L, countryCode = "KR")
val forcedJpMemberNew = createMember(id = 37543L, countryCode = "KR")
val forcedKrMember = createMember(id = 16L, countryCode = "US")
assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMember))
assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMemberNew))
assertEquals("KR", resolveCountryCodeByPolicy(forcedKrMember))
}
@Test
@DisplayName("요청 국가가 KR이면 인증 미완료 사용자는 성인 노출이 false다")
fun shouldHideAdultContentForKrWithoutAuth() {
setRequestCountry("KR")
val member = createMember(id = 1L, countryCode = "US")
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 국가가 KR이 아니면 멤버 countryCode와 무관하게 전달값을 사용한다")
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
setRequestCountry("US")
val member = createMember(id = 201L, countryCode = "KR")
assertTrue(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 컨텍스트가 없으면 KR fallback 정책을 사용한다")
fun shouldFallbackToKrWhenRequestContextIsMissing() {
RequestContextHolder.resetRequestAttributes()
val member = createMember(id = 202L, countryCode = "US")
assertEquals("KR", resolveCountryCodeByPolicy(member))
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
private fun setRequestCountry(countryCode: String?) {
val request = MockHttpServletRequest()
if (countryCode != null) {
request.addHeader("CloudFront-Viewer-Country", countryCode)
}
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
}
private fun createMember(id: Long, countryCode: String?): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
).apply {
this.id = id
this.countryCode = countryCode
}
}
}
@@ -61,10 +61,10 @@ class MemberContentPreferenceServiceTest {
@Test
@DisplayName("회원 ID 강제 매핑(JP)이 헤더보다 우선 적용된다")
fun shouldResolveCountryCodeByForcedJapanMappingFirst() {
val member = createMember(id = 2L)
val member = createMember(id = 29721L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2L)).thenReturn(preference)
Mockito.`when`(repository.findByMemberId(29721L)).thenReturn(preference)
val result = service.getStoredPreference(member)
@@ -449,6 +449,58 @@ class MemberContentPreferenceServiceTest {
assertTrue(service.calculateIsAdultForQuery(noAuthMember, "US", true))
}
@Test
@DisplayName("로그인 회원의 접속 국가 코드는 공백과 소문자를 정규화하고 없으면 KR로 계산된다")
fun shouldNormalizeCountryCodeForLoggedInMember() {
val member = createMember(id = 2400L)
countryContext.setCountryCode(" us ")
assertEquals("US", service.resolveCountryCode(member))
countryContext.setCountryCode(" ")
assertEquals("KR", service.resolveCountryCode(member))
countryContext.setCountryCode(null)
assertEquals("KR", service.resolveCountryCode(member))
}
@Test
@DisplayName("조회용 성인 노출 여부는 접속 국가와 인증 여부로 계산된다")
fun shouldCalculateIsAdultVisibleForQueryByCountryAndAuth() {
val noAuthMember = createMember(id = 2300L, withAuth = false)
val authMember = createMember(id = 2301L, withAuth = true)
countryContext.setCountryCode("KR")
assertFalse(service.isAdultVisibleForQuery(noAuthMember, isAdultContentVisible = true))
assertTrue(service.isAdultVisibleForQuery(authMember, isAdultContentVisible = true))
countryContext.setCountryCode("US")
assertTrue(service.isAdultVisibleForQuery(noAuthMember, isAdultContentVisible = true))
assertFalse(service.isAdultVisibleForQuery(authMember, isAdultContentVisible = false))
}
@Test
@DisplayName("비KR 요청에서는 회원의 저장 국가와 무관하게 전달한 성인 노출값을 사용한다")
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
val member = createMember(id = 2401L, withAuth = false).apply {
countryCode = "KR"
}
countryContext.setCountryCode("US")
assertEquals("US", service.resolveCountryCode(member))
assertTrue(service.isAdultVisibleForQuery(member, isAdultContentVisible = true))
assertFalse(service.isAdultVisibleForQuery(member, isAdultContentVisible = false))
}
@Test
@DisplayName("조회용 성인 노출 여부도 회원 ID 강제 매핑이 헤더보다 우선 적용된다")
fun shouldApplyForcedCountryMappingForIsAdultVisibleForQuery() {
val forcedKrMember = createMember(id = 16L, withAuth = false)
countryContext.setCountryCode("US")
assertFalse(service.isAdultVisibleForQuery(forcedKrMember, isAdultContentVisible = true))
}
@Test
@DisplayName("성인 콘텐츠 조회 가능 여부는 저장 preference의 조회용 성인 정책 결과를 반환한다")
fun shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent() {
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.api.home
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberKind
@@ -372,7 +373,9 @@ class HomeRecommendationControllerTest @Autowired constructor(
offset = 0,
limit = 21,
memberId = member.id,
includeAdultLives = false
includeAdultLives = false,
isViewerCreator = false,
effectiveViewerGender = Gender.NONE
)
)
.thenThrow(IllegalStateException("page query failed"))
@@ -2,7 +2,10 @@ package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
@@ -86,6 +89,42 @@ class HomeRecommendationFacadeTest {
assertTrue(queryPort.bannerLang == Lang.KO)
}
@Test
@DisplayName("홈 추천은 크리에이터 여부와 인증 성별 우선 유효 성별을 라이브 조회에 전달한다")
fun shouldPassCreatorRoleAndAuthGenderToHomeLiveQuery() {
val member = createMember(id = 12L, role = MemberRole.CREATOR, gender = Gender.FEMALE, authGender = 1)
facade.getHomeRecommendations(member)
assertTrue(queryPort.liveIsViewerCreator == true)
assertTrue(queryPort.liveEffectiveViewerGender == Gender.MALE)
}
@Test
@DisplayName("홈 라이브 목록은 크리에이터 여부와 회원 성별 fallback을 라이브 조회에 전달한다")
fun shouldPassCreatorRoleAndMemberGenderToLivePageQuery() {
val member = createMember(id = 13L, role = MemberRole.CREATOR, gender = Gender.FEMALE)
facade.getLives(member, page = 0, size = 20)
assertTrue(queryPort.liveIsViewerCreator == true)
assertTrue(queryPort.liveEffectiveViewerGender == Gender.FEMALE)
}
private fun createMember(
id: Long,
role: MemberRole = MemberRole.USER,
gender: Gender = Gender.NONE,
authGender: Int? = null
): Member {
return Member(password = "password", nickname = "member$id", role = role, gender = gender).apply {
this.id = id
authGender?.let {
Auth(name = "name", birth = "19900101", uniqueCi = "ci$id", di = "di$id", gender = it).member = this
}
}
}
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1,
@@ -105,13 +144,21 @@ class HomeRecommendationFacadeTest {
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
var bannerIncludeAdultBanners: Boolean = false
var bannerLang: Lang? = null
var liveIsViewerCreator: Boolean? = null
var liveEffectiveViewerGender: Gender? = null
override fun findLiveRecommendations(
offset: Long,
limit: Int,
memberId: Long?,
includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> = emptyList()
includeAdultLives: Boolean,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): List<HomeLiveRecommendationRecord> {
liveIsViewerCreator = isViewerCreator
liveEffectiveViewerGender = effectiveViewerGender
return emptyList()
}
override fun findHomeBanners(
limit: Int,
@@ -1,7 +1,9 @@
package kr.co.vividnext.sodalive.v2.api.home.live.application
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
@@ -25,7 +27,9 @@ class HomeOnAirLiveFacadeTest {
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(true)
eqValue(true),
eqValue(false),
eqValue(Gender.NONE)
)
val response = facade.getOnAirLives(member, page = 0)
@@ -34,7 +38,14 @@ class HomeOnAirLiveFacadeTest {
assertEquals(20, response.size)
assertEquals(true, response.hasNext)
assertEquals(20, response.items.size)
Mockito.verify(queryService).findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(true))
Mockito.verify(queryService).findLiveRecommendations(
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(true),
eqValue(false),
eqValue(Gender.NONE)
)
}
@Test
@@ -46,7 +57,9 @@ class HomeOnAirLiveFacadeTest {
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(false)
eqValue(false),
eqValue(false),
eqValue(Gender.NONE)
)
val response = facade.getOnAirLives(member, page = 0)
@@ -60,13 +73,46 @@ class HomeOnAirLiveFacadeTest {
val member = createMember(100L)
Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(record(1L, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)))).`when`(queryService)
.findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(false))
.findLiveRecommendations(
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(false),
eqValue(false),
eqValue(Gender.NONE)
)
val response = facade.getOnAirLives(member, page = 0)
assertEquals("2026-06-26T12:30:00Z", response.items.single().beginDateTimeUtc)
}
@Test
@DisplayName("현재 진행 중인 라이브 facade는 크리에이터 여부와 인증 성별 우선 유효 성별을 전달한다")
fun shouldPassCreatorRoleAndAuthGenderToLiveRecommendationQuery() {
val member = createMember(id = 100L, role = MemberRole.CREATOR, gender = Gender.FEMALE, authGender = 1)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(emptyList<HomeLiveRecommendationRecord>()).`when`(queryService).findLiveRecommendations(
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(true),
eqValue(true),
eqValue(Gender.MALE)
)
facade.getOnAirLives(member, page = 0)
Mockito.verify(queryService).findLiveRecommendations(
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(true),
eqValue(true),
eqValue(Gender.MALE)
)
}
private fun record(
id: Long,
creatorProfileImage: String? = "profile.png",
@@ -80,13 +126,24 @@ class HomeOnAirLiveFacadeTest {
beginDateTime = beginDateTime
)
private fun createMember(id: Long): Member {
private fun createMember(
id: Long,
role: MemberRole = MemberRole.USER,
gender: Gender = Gender.NONE,
authGender: Int? = null
): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply { this.id = id }
role = role,
gender = gender
).apply {
this.id = id
authGender?.let {
Auth(name = "name", birth = "19900101", uniqueCi = "ci$id", di = "di$id", gender = it).member = this
}
}
}
private fun <T> eqValue(value: T): T {
@@ -239,6 +239,33 @@ class DefaultCreatorChannelLiveQueryRepositoryTest @Autowired constructor(
assertEquals(visible.id, live!!.liveId)
}
@Test
@DisplayName("채널 소유자는 크리에이터 입장 제한이 설정된 본인 현재 라이브를 조회한다")
fun shouldReturnRestrictedCurrentLiveForChannelOwner() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("restricted-live-owner", MemberRole.CREATOR)
val restrictedLive = saveLiveRoom(
creator = creator,
beginDateTime = now.minusMinutes(1),
channelName = "restricted-owner-live",
isAdult = false,
genderRestriction = GenderRestriction.MALE_ONLY,
isAvailableJoinCreator = false
)
flushAndClear()
val live = repository.findCurrentLive(
creatorId = creator.id!!,
now = now,
canViewAdultContent = false,
viewerId = creator.id!!,
isViewerCreator = true,
effectiveViewerGender = Gender.MALE
)
assertEquals(restrictedLive.id, live!!.liveId)
}
@Test
@DisplayName("크리에이터 조회와 차단 관계 조회는 live service port 계약을 만족한다")
fun shouldFindCreatorAndBlockedRelationship() {
@@ -62,6 +62,25 @@ class CreatorChannelLiveQueryServiceTest {
assertEquals("https://cdn.test/audio/1.png", tab.liveReplayContents.first().imageUrl)
}
@Test
@DisplayName("다른 크리에이터가 조회하면 현재 라이브 조회에 크리에이터 여부를 전달한다")
fun shouldPassCreatorViewerStatusToCurrentLiveQuery() {
val port = FakeCreatorChannelLiveQueryPort()
val service = createService(port)
val viewer = createMember(id = 10L, role = MemberRole.CREATOR)
service.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.LATEST,
page = 0,
size = 20,
now = LocalDateTime.of(2026, 6, 17, 10, 0)
)
assertTrue(port.currentLiveIsViewerCreator == true)
}
@Test
@DisplayName("라이브 탭 서비스는 size + 1개 조회 결과를 응답 size로 제한하고 hasNext를 true로 반환한다")
fun shouldAssembleLiveTabWithHasNextWhenFetchedMoreThanRequestedSize() {
@@ -236,7 +255,8 @@ class CreatorChannelLiveQueryServiceTest {
private fun createMember(
id: Long,
gender: Gender = Gender.NONE,
authGender: Int? = null
authGender: Int? = null,
role: MemberRole = MemberRole.USER
): Member {
val member = Member(
email = "member$id@test.com",
@@ -246,6 +266,7 @@ class CreatorChannelLiveQueryServiceTest {
gender = gender
)
member.id = id
member.role = role
authGender?.let {
Auth(
name = "name",
@@ -32,7 +32,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommuni
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
@@ -194,6 +196,194 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(visibleLive.id), lives.map { it.liveRoomId })
}
@Test
@DisplayName("라이브 추천은 다른 크리에이터에게 크리에이터 입장 제한 라이브를 제외하고 방 생성자는 유지한다")
fun shouldExcludeCreatorJoinRestrictedLiveForOtherCreatorButKeepRoomOwner() {
val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0)
val viewer = saveMember("creator-viewer", MemberRole.CREATOR)
val otherCreator = saveMember("restricted-live-creator", MemberRole.CREATOR)
val availableLive = saveLiveRoom(otherCreator, baseAt, channelName = "available-live")
saveLiveRoom(
otherCreator,
baseAt.plusMinutes(1),
channelName = "creator-restricted-live",
isAvailableJoinCreator = false
)
val ownerLive = saveLiveRoom(
viewer,
baseAt.plusMinutes(2),
channelName = "owner-restricted-live",
isAvailableJoinCreator = false
)
flushAndClear()
val lives = repository.findLiveRecommendations(
limit = 10,
memberId = viewer.id,
isViewerCreator = true,
effectiveViewerGender = Gender.NONE
)
assertEquals(listOf(ownerLive.id, availableLive.id), lives.map { it.liveRoomId })
}
@Test
@DisplayName("라이브 추천은 일반 사용자에게 크리에이터 입장 제한 라이브를 노출한다")
fun shouldKeepCreatorJoinRestrictedLiveVisibleForUserViewer() {
val viewer = saveMember("user-viewer", MemberRole.USER)
val creator = saveMember("creator-restricted-live-creator", MemberRole.CREATOR)
val restrictedLive = saveLiveRoom(
creator,
LocalDateTime.of(2026, 9, 17, 10, 0),
channelName = "creator-restricted-live",
isAvailableJoinCreator = false
)
flushAndClear()
val lives = repository.findLiveRecommendations(
limit = 10,
memberId = viewer.id,
isViewerCreator = false,
effectiveViewerGender = Gender.NONE
)
assertEquals(listOf(restrictedLive.id), lives.map { it.liveRoomId })
}
@Test
@DisplayName("라이브 추천은 남성에게 남성 전용과 전체 라이브를 노출하고 방 생성자는 성별 제한에서 예외 처리한다")
fun shouldFilterFemaleOnlyLiveForMaleViewerButKeepOwnerLive() {
val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0)
val viewer = saveMember("male-viewer", MemberRole.CREATOR)
val creator = saveMember("gender-live-creator", MemberRole.CREATOR)
val maleOnlyLive = saveLiveRoom(
creator,
baseAt,
channelName = "male-only-live",
genderRestriction = GenderRestriction.MALE_ONLY
)
val allLive = saveLiveRoom(creator, baseAt.plusMinutes(1), channelName = "all-live")
saveLiveRoom(
creator,
baseAt.plusMinutes(2),
channelName = "female-only-live",
genderRestriction = GenderRestriction.FEMALE_ONLY
)
val ownerLive = saveLiveRoom(
viewer,
baseAt.plusMinutes(3),
channelName = "owner-female-only-live",
genderRestriction = GenderRestriction.FEMALE_ONLY
)
flushAndClear()
val lives = repository.findLiveRecommendations(
limit = 10,
memberId = viewer.id,
isViewerCreator = true,
effectiveViewerGender = Gender.MALE
)
assertEquals(listOf(ownerLive.id, allLive.id, maleOnlyLive.id), lives.map { it.liveRoomId })
}
@Test
@DisplayName("라이브 추천은 여성 방 생성자에게 남성 전용인 자신의 라이브를 노출한다")
fun shouldKeepMaleOnlyLiveVisibleForFemaleRoomOwner() {
val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0)
val viewer = saveMember("female-owner", MemberRole.CREATOR)
val creator = saveMember("female-owner-filter-creator", MemberRole.CREATOR)
val femaleOnlyLive = saveLiveRoom(
creator,
baseAt.plusMinutes(1),
channelName = "female-only-live",
genderRestriction = GenderRestriction.FEMALE_ONLY
)
val ownerLive = saveLiveRoom(
viewer,
baseAt.plusMinutes(2),
channelName = "owner-male-only-live",
genderRestriction = GenderRestriction.MALE_ONLY
)
flushAndClear()
val lives = repository.findLiveRecommendations(
limit = 10,
memberId = viewer.id,
isViewerCreator = true,
effectiveViewerGender = Gender.FEMALE
)
assertEquals(listOf(ownerLive.id, femaleOnlyLive.id), lives.map { it.liveRoomId })
}
@Test
@DisplayName("라이브 추천은 여성에게 여성 전용과 전체 라이브만 노출한다")
fun shouldFilterMaleOnlyLiveForFemaleViewer() {
val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0)
val viewer = saveMember("female-viewer", MemberRole.USER)
val creator = saveMember("female-filter-creator", MemberRole.CREATOR)
val femaleOnlyLive = saveLiveRoom(
creator,
baseAt.plusMinutes(2),
channelName = "female-only-live",
genderRestriction = GenderRestriction.FEMALE_ONLY
)
val allLive = saveLiveRoom(creator, baseAt.plusMinutes(1), channelName = "all-live")
saveLiveRoom(
creator,
baseAt,
channelName = "male-only-live",
genderRestriction = GenderRestriction.MALE_ONLY
)
flushAndClear()
val lives = repository.findLiveRecommendations(
limit = 10,
memberId = viewer.id,
effectiveViewerGender = Gender.FEMALE
)
assertEquals(listOf(femaleOnlyLive.id, allLive.id), lives.map { it.liveRoomId })
}
@Test
@DisplayName("라이브 추천은 성별 미설정 또는 유효 성별이 없으면 성별 제한을 적용하지 않는다")
fun shouldNotApplyGenderRestrictionForNoneOrNullGender() {
val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0)
val viewer = saveMember("unrestricted-viewer", MemberRole.USER)
val creator = saveMember("unrestricted-creator", MemberRole.CREATOR)
val maleOnlyLive = saveLiveRoom(
creator,
baseAt,
channelName = "male-only-live",
genderRestriction = GenderRestriction.MALE_ONLY
)
val femaleOnlyLive = saveLiveRoom(
creator,
baseAt.plusMinutes(1),
channelName = "female-only-live",
genderRestriction = GenderRestriction.FEMALE_ONLY
)
val allLive = saveLiveRoom(creator, baseAt.plusMinutes(2), channelName = "all-live")
flushAndClear()
val noneGenderLives = repository.findLiveRecommendations(
limit = 10,
memberId = viewer.id,
effectiveViewerGender = Gender.NONE
)
val nullGenderLives = repository.findLiveRecommendations(
limit = 10,
memberId = viewer.id,
effectiveViewerGender = null
)
val expectedIds = listOf(allLive.id, femaleOnlyLive.id, maleOnlyLive.id)
assertEquals(expectedIds, noneGenderLives.map { it.liveRoomId })
assertEquals(expectedIds, nullGenderLives.map { it.liveRoomId })
}
@Test
@DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다")
fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() {
@@ -2856,7 +3046,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
isAdult: Boolean = false,
title: String = "live-${creator.nickname}-$beginDateTime",
price: Int = 0,
isActive: Boolean = true
isActive: Boolean = true,
isAvailableJoinCreator: Boolean = true,
genderRestriction: GenderRestriction = GenderRestriction.ALL
): LiveRoom {
val room = LiveRoom(
title = title,
@@ -2864,7 +3056,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult,
price = price
price = price,
isAvailableJoinCreator = isAvailableJoinCreator,
genderRestriction = genderRestriction
)
room.member = creator
room.channelName = channelName
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
@@ -86,19 +87,23 @@ class HomeRecommendationQueryServiceTest {
}
@Test
@DisplayName("라이브 추천 조회는 paging과 성인 노출 여부를 조회 포트에 그대로 위임한다")
fun shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag() {
@DisplayName("라이브 추천 조회는 paging, 성인 노출, 크리에이터 여부와 유효 성별을 조회 포트에 그대로 위임한다")
fun shouldDelegateLiveRecommendationQueryWithViewerContext() {
val recommendations = service.findLiveRecommendations(
offset = 40,
limit = 21,
memberId = 100L,
includeAdultLives = true
includeAdultLives = true,
isViewerCreator = true,
effectiveViewerGender = Gender.MALE
)
assertEquals(40, port.liveOffset)
assertEquals(21, port.liveLimit)
assertEquals(100L, port.liveMemberId)
assertEquals(true, port.liveIncludeAdultLives)
assertEquals(true, port.liveIsViewerCreator)
assertEquals(Gender.MALE, port.liveEffectiveViewerGender)
assertEquals(port.liveRecommendations, recommendations)
}
@@ -1092,6 +1097,8 @@ class HomeRecommendationQueryServiceTest {
var liveOffset: Long? = null
var liveMemberId: Long? = null
var liveIncludeAdultLives: Boolean? = null
var liveIsViewerCreator: Boolean? = null
var liveEffectiveViewerGender: Gender? = null
var bannerLimit: Int? = null
var bannerMemberId: Long? = null
var bannerIncludeAdultBanners: Boolean? = null
@@ -1187,12 +1194,16 @@ class HomeRecommendationQueryServiceTest {
offset: Long,
limit: Int,
memberId: Long?,
includeAdultLives: Boolean
includeAdultLives: Boolean,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): List<HomeLiveRecommendationRecord> {
liveOffset = offset
liveLimit = limit
liveMemberId = memberId
liveIncludeAdultLives = includeAdultLives
liveIsViewerCreator = isViewerCreator
liveEffectiveViewerGender = effectiveViewerGender
return liveRecommendations
}
@@ -1,7 +1,13 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.usercreatorchat.controller.UserCreatorChatController
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -23,4 +29,41 @@ class UserCreatorChatControllerMappingTest {
mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/messages/text"))
.andExpect(status().isNotFound)
}
@Test
fun shouldCreateRoomWithRecipientId() {
val service = Mockito.mock(UserCreatorChatService::class.java)
val controller = UserCreatorChatController(service)
val member = Member(email = "creator@test.com", password = "pw", nickname = "creator").apply { id = 10L }
Mockito.`when`(service.createOrGetRoom(member, 20L))
.thenReturn(CreateUserCreatorChatRoomResponse(roomId = 30L))
val response = controller.createOrGetRoom(
member,
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = null)
)
Mockito.verify(service).createOrGetRoom(member, 20L)
assertEquals(30L, response.data!!.roomId)
}
@Test
fun shouldCreateRoomWithLegacyCreatorId() {
val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L)
assertEquals(20L, request.recipientMemberId())
}
@Test
fun shouldRejectMissingOrConflictingRecipientIds() {
val missing = assertThrows(SodaException::class.java) {
CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = null).recipientMemberId()
}
assertEquals("common.error.invalid_request", missing.messageKey)
val conflict = assertThrows(SodaException::class.java) {
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = 21L).recipientMemberId()
}
assertEquals("common.error.invalid_request", conflict.messageKey)
}
}
@@ -31,6 +31,42 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor(
private val messageRepository: UserCreatorChatMessageRepository,
private val entityManager: EntityManager
) {
@Test
@DisplayName("기존 리스너 시작 DM 방 생성 흐름은 계속 동작한다")
fun shouldCreateRoomWhenListenerStartsDmToCreator() {
val user = memberRepository.save(Member(email = "listener-start@test.com", password = "pw", nickname = "listener"))
val creator = memberRepository.save(
Member(email = "creator-target@test.com", password = "pw", nickname = "creator", role = MemberRole.CREATOR)
)
entityManager.flush()
entityManager.clear()
val response = service.createOrGetRoom(user, creator.id!!)
assertEquals(1, roomRepository.findAll().size)
assertEquals(roomRepository.findAll().single().id, response.roomId)
assertEquals(2, participantRepository.findAll().size)
assertEquals(0, messageRepository.findAll().size)
}
@Test
@DisplayName("크리에이터도 일반 회원에게 DM 방을 먼저 생성할 수 있다")
fun shouldCreateRoomWhenCreatorStartsDmToListener() {
val creator = memberRepository.save(
Member(email = "creator-start@test.com", password = "pw", nickname = "creator", role = MemberRole.CREATOR)
)
val listener = memberRepository.save(Member(email = "listener-target@test.com", password = "pw", nickname = "listener"))
entityManager.flush()
entityManager.clear()
val response = service.createOrGetRoom(creator, listener.id!!)
assertEquals(1, roomRepository.findAll().size)
assertEquals(roomRepository.findAll().single().id, response.roomId)
assertEquals(2, participantRepository.findAll().size)
assertEquals(0, messageRepository.findAll().size)
}
@Test
@DisplayName("AI 캐릭터용 Member와는 유저-크리에이터 DM 방을 생성할 수 없다")
fun shouldRejectCreateRoomWhenCreatorIsAiCharacterMember() {
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
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.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
@@ -93,6 +94,43 @@ class UserCreatorChatServiceTest {
Mockito.verify(participantRepository, Mockito.times(2)).save(Mockito.any(UserCreatorChatParticipant::class.java))
}
@Test
@DisplayName("크리에이터도 일반 회원에게 DM 방을 먼저 생성할 수 있고 메시지는 생성하지 않는다")
fun shouldCreateRoomWhenCreatorStartsDmToUser() {
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
val user = member(2L, "user")
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatRoom).apply { id = 10L }
}
val response = service.createOrGetRoom(creator, 2L)
assertEquals(10L, response.roomId)
val participantCaptor = ArgumentCaptor.forClass(UserCreatorChatParticipant::class.java)
Mockito.verify(participantRepository, Mockito.times(2)).save(participantCaptor.capture())
assertEquals(listOf(creator, user), participantCaptor.allValues.map { it.member })
Mockito.verifyNoInteractions(messageRepository)
}
@Test
@DisplayName("기존 활성 방이 있으면 새 방과 메시지를 생성하지 않고 기존 roomId를 반환한다")
fun shouldReturnExistingRoomWithoutCreatingMessage() {
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
val user = member(2L, "user")
val existingRoom = room(10L)
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(existingRoom)
val response = service.createOrGetRoom(creator, 2L)
assertEquals(10L, response.roomId)
Mockito.verify(roomRepository, Mockito.never()).save(Mockito.any(UserCreatorChatRoom::class.java))
Mockito.verifyNoInteractions(participantRepository)
Mockito.verifyNoInteractions(messageRepository)
}
@Test
@DisplayName("방 입장 응답은 상대방 닉네임과 프로필 이미지 URL을 포함한다")
fun shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage() {
+17
View File
@@ -1,6 +1,23 @@
server:
env: test
management:
endpoints:
web:
exposure:
include: health,deployment
endpoint:
health:
probes:
enabled: true
add-additional-paths: true
show-details: never
show-components: never
deployment:
artifact-version: ${DEPLOY_ARTIFACT_VERSION:local}
config-commit: ${DEPLOY_CONFIG_COMMIT:0000000000000000000000000000000000000000}
logging:
level:
com: