Compare commits

..

455 Commits

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

View File

@@ -36,7 +36,7 @@
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
- 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다.
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
- 무료 오디오와 포인트 오디오는 가격 조건으로 분리하며, 무료/추천 또는 포인트/추천 섹션 사이의 중복만 허용한다.
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
@@ -357,7 +357,7 @@ interface AudioRecommendationQueryPort {
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다.
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. 이 문장은 2026-06-23 당시 기준이며 2026-07-31 후속 요구사항 정정에서 두 섹션을 가격 조건으로 분리한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다.
- REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
@@ -721,3 +721,10 @@ interface AudioRecommendationQueryPort {
- 2026-07-12 문서 전용 후속 정책 기록: `mostCommentedAudios` 스냅샷 저장 시 같은 크리에이터의 오디오 후보는 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장하도록 PRD Feature H와 조회 정책, plan-task 확정 정책과 Phase 9 후속 구현 task를 갱신했다. 이번 작업은 문서만 작성했으므로 source code와 test code는 변경하지 않았고, 관련 RED/GREEN/REFACTOR 구현은 Task 9.1 미완료 상태로 남겼다.
- 2026-07-12 Phase 9 선행 구현 및 Phase 8 회귀 검증: 최근 댓글 많은 오디오 스냅샷 후보에서 같은 크리에이터의 여러 오디오 중 최고 점수 1개만 남기도록 repository dedupe를 구현했다. RED에서 같은 크리에이터 낮은 점수 후보가 함께 반환되는 실패를 확인했고, GREEN 후 Phase 9 focused test, Phase 8 관련 test/ktlint/tasks 검증이 모두 `BUILD SUCCESSFUL`로 통과했다.
## 2026-07-31 후속 요구사항 정정
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
- 포인트 오디오를 `isPointAvailable == true`만으로 조회하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md``P1-T1`, `P2-T1`에서 추적한다.

View File

@@ -72,7 +72,7 @@
- `mostCommentedAudios`: 최대 5개
- `recommendedAudios`: 최대 20개
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.
- 무료 오디오와 포인트 오디오는 가격 조건상 중복 노출하지 않는다. 무료/추천 또는 포인트/추천 섹션 사이의 중복은 허용한다.
#### Edge Cases
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
@@ -134,7 +134,7 @@
#### Requirements
- 포인트 사용 가능 오디오 중 랜덤으로 최대 15개 조회한다.
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다.
- 포인트 오디오는 `isPointAvailable = true`이면서 `price > 0`인 공개 오디오로 정의한다.
- Response는 공통 오디오 카드 응답을 사용한다.
### Feature H. 최근 댓글이 많은 오디오
@@ -285,7 +285,8 @@ data class CommentedAudioResponse(
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
- 무료 오디오와 포인트 오디오는 각각 `price == 0`, `isPointAvailable == true && price > 0` 조건으로 분리한다.
- 무료/추천 또는 포인트/추천처럼 그 밖의 추천 섹션 간 중복은 서버에서 제거하지 않는다.
- `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다.
---
@@ -302,3 +303,14 @@ data class CommentedAudioResponse(
## 12. Open Questions
- 없음
---
## 13. 후속 요구사항 정정
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|---|---|---|---|
| 2026-07-31 | 확정 | 무료 콘텐츠와 포인트 결제 가능 콘텐츠를 분리하고 `pointAudios`에서 `price == 0`을 제외한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
- 공통 `AudioCardResponse.isPointAvailable`은 저장값을 그대로 노출하지 않고 `storedIsPointAvailable && price > 0`으로 응답한다.
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.

View File

@@ -27,7 +27,7 @@
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
- `type=AUDIO``price > 0`인 유료 공개 오디오만 조회한다.
- `type=FREE``price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다.
- `type=POINT``isPointAvailable == true` 조건을 유지하고 `type=AUDIO`의 유료 조건을 상속하지 않는다.
- `type=POINT``isPointAvailable == true && price > 0` 조건을 사용하고 목록과 count에 동일하게 적용한다.
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
- `AUDIO`, `FREE`, `POINT``audios`만 채우고 `series`는 빈 배열로 내려준다.
- `SERIES`, `ORIGINAL``series`만 채우고 `audios`는 빈 배열로 내려준다.
@@ -493,7 +493,7 @@ interface MainContentAllQueryPort {
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
- RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다.
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다.
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. 이 문장은 2026-06-25 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
- GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다.
@@ -587,7 +587,7 @@ interface MainContentAllQueryPort {
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
- RED: repository 테스트에 유료 오디오와 무료 오디오 fixture를 함께 만들고, `onlyPaid=true`인 `countAudios(...)`와 `findAudios(...)`가 `price == 0` 오디오를 제외하는 실패 테스트를 작성한다.
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다.
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. 이 문장은 2026-07-10 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
- RED: E2E 테스트에서 `GET /api/v2/audio/contents?type=AUDIO`와 type 미지정 기본 조회가 무료 오디오를 반환하지 않는 실패 테스트를 작성한다.
- 실패 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
@@ -667,3 +667,11 @@ interface MainContentAllQueryPort {
- GREEN: `./gradlew test` 성공.
- GREEN: `./gradlew ktlintCheck` 성공.
- GREEN: `git diff --check` 성공.
## 2026-07-31 후속 요구사항 정정
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
- `type=POINT`가 `isPointAvailable == true`만 사용하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
- POINT 목록, count와 `hasNext` 후보는 같은 보정 조건을 사용하고 무료 오디오는 제외한다.
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다.

View File

@@ -176,9 +176,9 @@
#### Requirements
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
- 포인트 오디오는 `isPointAvailable == true`이면서 `price > 0`인 공개 오디오로 정의한다.
- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
- `type=POINT``type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다.
- `type=POINT``isPointAvailable == true && price > 0` 조건을 사용하고 목록과 전체 개수에 동일하게 적용한다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases
@@ -317,7 +317,7 @@ data class MainContentSeriesResponse(
### 구현 주의사항
- `type=AUDIO``price > 0` 조건을 적용하고, `type=FREE``price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다.
- `type=POINT``isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다.
- `type=POINT``isPointAvailable == true && price > 0` 조건을 사용하며, 목록과 count가 같은 조건 함수를 공유한다.
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
@@ -339,3 +339,14 @@ data class MainContentSeriesResponse(
## 12. Open Questions
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.
---
## 13. 후속 요구사항 정정
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|---|---|---|---|
| 2026-07-31 | 확정 | `type=POINT`에서 무료 콘텐츠를 제외하고 목록·`totalCount`·`hasNext` 후보에 같은 조건을 적용한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
- `MainContentAudioResponse.isPointAvailable``storedIsPointAvailable && price > 0`으로 응답한다.
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.

View File

@@ -4,10 +4,10 @@
| 항목 | 내용 |
|---|---|
| 상태 | 3차 Phase별 리뷰 완료 |
| 확정일 | 2026-07-31 |
| 상태 | Jenkins 회귀 수정 완료 |
| 확정일 | 2026-08-03 |
| 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 |
| 현재 Phase | Phase 1~7 3차 리뷰 완료 |
| 현재 Phase | Phase 1~7 `P5-R2` 완료 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 |
@@ -318,6 +318,20 @@
- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다.
- [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다.
#### Task R5.2 Jenkins single-flight 테스트 scheduling race 제거
**Goal 실행 `P5-R2`:** 두 동시 요청이 pending refresh future를 공유하는 계약을 worker 완료 순서에 의존하지 않고 검증한다.
- **시작 조건:** Jenkins에서 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests`의 두 번째 request `Future.get(...)``TimeoutException`으로 실패하고, `REV-P5-002`가 테스트 scheduling race로 확정됨.
- **완료 증거:** Jenkins 실패 원인 기록, 결정적 테스트 최소 수정, fallback focused test 반복 통과, 직접 영향 회귀·format·문서 명령·diff 검증 결과 누적.
- **범위 밖:** `RecommendationSnapshotFallbackService` production 동작, worker 수, 300ms lock 대기와 1,500ms 홈 대기, 다른 fallback 섹션 변경.
- [x] **RED:** Jenkins 실패의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 focused test 통과를 함께 기록해 worker scheduling에 따라 결과가 달라지는 기존 테스트를 확인한다.
- [x] **RED 확인:** `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'`의 로컬 통과와 Jenkins 실패를 대조해 비결정성을 확인한다.
- [x] **GREEN:** 수동 `CapturingExecutor.runNext()` 완료 경쟁 대신 refresh worker를 latch로 pending 상태에 유지한다. 두 요청이 홈 대기 timeout으로 반환할 때까지 refresh를 완료하지 않고 `refreshCheerCreatorSnapshots(...)` 호출이 1회인지 검증한다.
- [x] **GREEN 확인:** 같은 focused test를 반복 실행하고 `RecommendationSnapshotFallbackServiceTest` 전체를 실행해 single-flight, timeout 후 worker 지속, lock 내부 double-check가 모두 통과하는지 확인한다.
- [x] **REFACTOR/GATE:** 사용하지 않는 test helper/import만 제거하고 `ktlintCheck`, `./gradlew tasks --all`, `git diff --check` 결과를 전체 검증 기록에 누적한다.
---
### Phase 6: API 회귀와 최종 검증
@@ -440,6 +454,8 @@ git diff --check
- Non-Goals: Task 4.1, Task 6.1, Task 6.3, Task 7.1에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 16명 밖 후보 보충 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다.
## 전체 검증 기록
- 2026-08-03: `P5-R2` GREEN/GATE로 수동 `CapturingExecutor` 완료 경쟁을 제거하고, 실제 worker를 latch로 pending 상태에 유지한 채 두 요청의 worker task 제출과 `refreshCheerCreatorSnapshots(...)` 호출이 각각 1회인지 검증하도록 수정했다. focused test는 최초 1회와 연속 10회 모두 통과했고, `RecommendationSnapshotFallbackServiceTest` 전체, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `./gradlew cleanTest test`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. 전체 테스트는 7분 14초 소요됐다. Oracle 고강도 리뷰도 correctness·결정성·thread cleanup·production 무변경 판단에 blocker 없이 승인했다.
- 2026-08-03: `P5-R2` RED 확인으로 Jenkins의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'` `BUILD SUCCESSFUL`을 대조했다. 같은 코드가 worker scheduling에 따라 실패·성공하여 기존 테스트의 `taskSubmitted` latch가 두 요청의 동일 future 대기를 보장하지 않는 비결정성을 확인했다.
- 2026-07-31: Phase 1~7 3차 리뷰로 PRD·plan-task·현재 코드·테스트를 정적으로 대조했다. 이전 리뷰에서 보완한 문서 정합성, 종료 라이브 데뷔 이력, `CHEER_CREATOR` refresh 실패 로그, fallback single-flight·double-check, 공개 응답 3개 필드, 본인·활성 팔로우 제외가 현재 구현과 회귀 테스트에 유지됨을 확인했다. 각 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 3차 리뷰로 별도 누적했다. 추가 확정 발견 사항이 없어 신규 회귀 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 뒤 승인된 동일 명령에서 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-31: `P3-R2``CHEER_CREATOR` 데뷔 CTE가 채널명이 있는 종료 라이브를 데뷔 이력으로 인정하도록 복구했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut``AssertionFailedError`로 실패하는 것을 확인했다. 이후 `findCheerCreatorSnapshots(...)` 라이브 branch에서 `lr.is_active = true`만 제거했고, 같은 repository focused test는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다.
- 2026-07-31: `P1-R2`로 상단 후속 변경 상태를 현재 PRD 전체, Phase 1~7 판정, 실제 미완료 Goal `P3-R2` 기준으로 정리했다. 문서 정합성 복구만 수행해 TDD 예외로 처리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all``BUILD SUCCESSFUL`로 통과했다.

View File

@@ -32,6 +32,7 @@
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` |
| `REV-P5-002` | Medium | 보완 완료 | single-flight 테스트가 worker 완료 순서에 의존해 Jenkins에서 timeout된다 | Task R5.2 | `P5-R2` |
## 6. 발견 사항 상세
@@ -102,3 +103,16 @@ sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.
---
## 11. 4차 리뷰 기록 — 2026-08-03
- **리뷰 대상:** Phase 5 / Task R5.1의 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests` Jenkins 실패.
- **검토 근거:** Jenkins `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`, `RecommendationSnapshotFallbackService.kt:141-149`, 테스트의 `CapturingExecutor`와 latch 흐름, 로컬 `cleanTest` focused test 실행, JDK 17 `CompletableFuture`·`CountDownLatch` 계약.
- **확정 발견 사항:** `REV-P5-002`. `taskSubmitted` latch는 worker task가 executor에 전달됐다는 사실만 보장하며, refresh future가 `refreshFutures`에 게시됐거나 두 요청이 같은 future에서 대기 중임을 보장하지 않는다. Jenkins에서 첫 worker가 먼저 완료되면 reference가 제거되고 두 번째 요청은 실행되지 않는 새 captured task를 기다려 바깥 `get(1s)`에서 timeout된다.
- **판정:** production single-flight 결함 근거는 없다. timeout 확대는 경쟁을 숨기므로 제외하고, refresh를 latch로 pending 상태에 유지한 채 두 요청의 refresh 호출 횟수 1회를 검증하도록 테스트만 수정한다.
- **plan·goal 전환:** `plan-task.md` Phase 5에 Task R5.2 / `P5-R2`를 추가했다.
- **검증 결과:** focused test 최초 1회와 연속 10회, `RecommendationSnapshotFallbackServiceTest` 전체, `ktlintCheck`, `tasks --all`, 전체 `cleanTest test`, `git diff --check`가 모두 통과했다. Oracle 고강도 리뷰도 blocker 없이 승인했다.
- **최종 결론:** `P5-R2` 보완 완료.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,410 @@
# 무료 콘텐츠 포인트 결제 불가 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 리뷰 후속 작업 완료 |
| 작성일 | 2026-07-31 |
| 요구사항 기준 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
| API 기준 | PRD `8. API 계약` |
| 현재 Phase | Phase 1 회귀 수정 완료 |
| 현재 활성 Goal | 없음 |
## 목표
무료 오디오 콘텐츠가 소비자 조회에서 포인트 결제 가능으로 노출되거나 POINT 전용 목록에 포함되지 않게 한다.
## 구현 방식
- 응답 DTO의 공개 필드와 내부 조회 record는 변경하지 않는다.
- 각 소비자 응답 조립 지점에서 `isPointAvailable && price > 0`을 적용한다.
- 추천과 전체 탭 POINT repository 조건에 `price > 0`을 추가한다.
- 관리자 mapper와 저장 로직은 수정하지 않는다.
- 단일 식 적용을 위한 새 공통 abstraction이나 dependency는 만들지 않는다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1. 소비자 응답 정규화 | 완료 | `3/3` | 없음 | 없음 |
| 2. POINT 조회 조건 보정 | 완료 | `1/1` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
- goal에는 token budget을 설정하지 않는다.
## 범위
### 포함
- legacy 콘텐츠 상세 `isAvailableUsePoint` 보정
- v2 콘텐츠 overview·전체 탭·추천 탭 응답 보정
- v2 홈 추천 첫 오디오 응답 보정
- v2 크리에이터 채널 홈·오디오·라이브의 공통 오디오 응답 보정
- 추천 `pointAudios`와 전체 탭 `type=POINT`의 유료 조건 보강
- 전체 탭 POINT count·pagination 회귀 검증
- AI 캐릭터 관리자 조회 원본값 유지 회귀 검증
### 제외
- DB 저장값과 기존 데이터 변경
- 콘텐츠 생성·수정 validation 변경
- 관리자 콘텐츠 mapper·service 변경
- legacy 콘텐츠 상세 이외의 legacy 조회 API 변경
- 결제·주문·포인트 차감 로직 변경
- API schema와 dependency 변경
## 기술적 제약
- 기술 스택: Kotlin, Java 17, Spring Boot 2.7.14, JUnit 5, QueryDSL/JPA.
- 기존 package와 mapper/repository 책임을 유지한다.
- 공개 API 필드명 `isAvailableUsePoint`, `isPointAvailable`을 유지한다.
- `price == 0`은 무료, 실질 포인트 가능 여부는 `configured && price > 0`으로 고정한다.
- POINT 조회의 목록과 count는 같은 repository 조건을 사용한다.
- 모든 구현 Task는 `RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR` 순서로 실행한다.
- focused test부터 실행하고 최종 Gate에서 영향 범위 회귀와 전체 `test`를 실행한다.
## Phase 1: 소비자 응답 정규화
**Phase 결과:** 무료·저장값 true인 콘텐츠가 대상 소비자 응답에서는 false로 보이지만 관리자 조회에서는 true를 유지한다.
**선행조건:** PRD `POINT-001~004`, `POINT-007~008` 확정.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료, 검증 기록 누적.
**후속 리뷰 완료 조건:** `P1-R1` 완료, `P1-GATE` 재검증과 수정 후 검증 기록 누적.
### Task 1.1 소비자 응답의 실질 포인트 가능 여부 적용
**Goal 실행 `P1-T1`:** legacy 상세와 모든 대상 v2 응답 조립 경계에서 가격을 반영한 포인트 가능 여부를 반환한다.
- **시작 조건:** PRD `DEC-001`, `DEC-003` 확인.
- **완료 증거:** RED/GREEN 체크박스, 대상 mapper 테스트, 관리자 원본값 회귀 테스트와 Progress 기록.
- **범위 밖:** POINT 전용 repository 필터와 콘텐츠 저장값 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
**Interfaces:**
- Consumes: 각 mapper가 이미 받는 `price: Int`와 저장된 Boolean 필드.
- Produces: schema를 바꾸지 않고 `configured && price > 0`으로 보정된 소비자 응답 Boolean.
- Preserves: AI 캐릭터 관리자 목록·상세의 원본 Boolean.
- [x] **RED:** 각 응답 경계에 무료·저장값 true fixture를 추가하고 소비자 응답은 false, 유료·저장값 true 응답은 true로 기대한다. 관리자 상세에는 무료·저장값 true가 true로 유지되는 회귀 assertion을 추가한다.
- [x] **RED 확인:** 아래 focused test를 실행해 소비자 응답이 현재 true를 전달하여 발생하는 assertion 실패를 확인하고, 관리자 회귀 assertion은 기존 동작으로 통과하는지 구분해 기록한다.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'
```
- [x] **GREEN:** 소비자 응답 조립 지점의 Boolean 대입을 `storedValue && price > 0`으로 바꾼다. `AiCharacterAdminAudioContentMapper`와 관리자 service는 수정하지 않는다.
- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 무료 true → false, 유료 true → true, 관리자 무료 true → true가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 동일한 한 줄 식을 유지하고 새 helper나 구조 변경을 추가하지 않는다. 변경 파일에 `./gradlew ktlintCheck`를 실행하고 결과를 Progress에 기록한다.
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 대상 소비자 응답 계약과 관리자 제외 계약을 최종 판정한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 아래 명령 성공, 공개 필드명 유지 확인과 Progress 기록.
- **범위 밖:** POINT repository 필터 구현과 관련 없는 응답 리팩터링.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.home.*'
./gradlew ktlintCheck
```
**Expected:** 대상 소비자 응답에서 무료 콘텐츠가 포인트 가능으로 노출되지 않고 기존 JSON 필드 집합이 유지된다.
### Task 1.2 소비자·관리자 포인트 가능 계약의 회귀 증거 보강
**Goal 실행 `P1-R1`:** 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
- **시작 조건:** `REV-P1-001`, `REV-P1-002` 확정과 기존 `P1-GATE` 완료.
- **완료 증거:** 대상 소비자 경계의 `price > 0, storedIsPointAvailable == false` assertion, 관리자 목록·상세의
`price == 0, storedIsPointAvailable == true` assertion, focused test와 `P1-GATE` 재검증 기록.
- **범위 밖:** production 코드 변경, 관리자 응답 보정, 공개 API schema 변경.
- **TDD 예외 사유:** production 구현은 정적 검토상 계약을 이미 충족하며, 누락된 것은 완료 근거인 회귀 assertion이다.
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
- **대체 검증 방법:** 기존 fixture가 누락한 진리표 조건을 추가하고 focused test와 `P1-GATE`를 통과시킨 뒤 mapper 식과
관리자 원본 전달 식을 다시 대조한다.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
- [x] 대상 소비자 응답 경계마다 `price > 0, storedIsPointAvailable == false`가 false로 유지되는 assertion을 추가한다.
- [x] AI 캐릭터 관리자 목록·상세 fixture를 `price == 0, storedIsPointAvailable == true`로 구성하고 저장값 true가 그대로
반환되는지 확인한다.
- [x] Task 1.1 focused test와 `P1-GATE`를 재실행하고 결과를 Progress에 누적한다.
- [x] `REV-P1-001`, `REV-P1-002`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
### Task 1.3 legacy 상세의 유료 포인트 가능 positive 회귀 증거 보강
**Goal 실행 `P1-R2`:** legacy 상세에서 `price > 0, storedIsPointAvailable == true`가 true를 반환하는 계약을 자동 회귀 테스트로 증명한다.
- **시작 조건:** `REV-P1-003` 확정과 `P1-R1` 완료.
- **완료 증거:** legacy 상세의 무료·저장값 true → false, 유료·저장값 false → false,
유료·저장값 true → true assertion, focused test와 `P1-GATE` 재검증 기록.
- **범위 밖:** production 코드, v2 응답 test, 관리자 mapper/service 변경.
- **TDD 예외 사유:** production 구현은 정적 검토상 positive 계약을 이미 충족하며, 누락된 것은 회귀 assertion이다.
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
- **대체 검증 방법:** 기존 legacy 상세 test의 진리표를 완성하고 focused test와 `P1-GATE`를 통과시킨다.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
- [x] legacy 상세 test에 `price > 0, storedIsPointAvailable == true` 응답이 true인 assertion을 추가한다.
- [x] 하나의 test가 세 계약 조건을 드러내도록 `DisplayName`과 test 함수명을 맞춘다.
- [x] `AudioContentServiceTest`, `P1-GATE`, `ktlintCheck`를 실행하고 결과를 Progress에 누적한다.
- [x] `REV-P1-003`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
## Phase 2: POINT 조회 조건 보정
**Phase 결과:** 추천과 전체 탭 POINT 목록, count와 pagination 후보에서 무료 콘텐츠가 제외된다.
**선행조건:** `P1-GATE` 완료와 PRD `POINT-005~006` 확정.
**Phase 완료 조건:** `P2-T1``P2-GATE` 완료, 검증 기록 누적.
### Task 2.1 추천·전체 탭 POINT 조회에 유료 조건 적용
**Goal 실행 `P2-T1`:** 두 POINT 조회가 `isPointAvailable == true && price > 0` 조건을 공통으로 사용한다.
- **시작 조건:** `P1-GATE` 완료, PRD `DEC-002` 확인.
- **완료 증거:** repository RED/GREEN, endpoint E2E와 Progress 기록.
- **범위 밖:** FREE/AUDIO/추천 점수·랜덤 정렬·공개/성인/차단 조건 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
**Interfaces:**
- Consumes: 기존 `audioContent.isPointAvailable``audioContent.price` QueryDSL 필드.
- Produces: 추천 `pointAudios`, 전체 탭 POINT 목록과 count에 공통 적용되는 `isPointAvailable.isTrue.and(price.gt(0))` 조건.
- Preserves: FREE 목록은 `price.eq(0)`, AUDIO 목록은 `price.gt(0)`인 기존 조건.
- [x] **RED:** 두 repository fixture에 `price = 0, isPointAvailable = true``price > 0, isPointAvailable = true`를 함께 두고 POINT 결과에는 유료 항목만 포함되도록 기대한다. 전체 탭 E2E는 `totalCount`, `audios`, `hasNext`가 같은 후보 집합을 반영하도록 기대한다.
- [x] **RED 확인:** 아래 focused test를 실행해 무료 true 콘텐츠가 POINT 결과에 포함되어 발생하는 목록 또는 count assertion 실패를 확인한다.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' \
--tests 'kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest'
```
- [x] **GREEN:** `findPointAudios` 조건과 `optionalAudioPointCondition``price.gt(0)`을 결합한다. 전체 탭 count와 목록은 기존 `audioCondition`을 계속 공유한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 POINT 결과에서 무료가 제외되고 유료 true는 유지되며 `totalCount``hasNext`가 일치하는지 확인한다.
- [x] **REFACTOR:** FREE/AUDIO 조건, 랜덤/가격/인기 정렬과 공통 visibility 조건이 바뀌지 않았는지 직접 영향 회귀와 `ktlintCheck`로 확인한다.
### Phase 2 Gate
**Goal 실행 `P2-GATE`:** 소비자 응답과 POINT 조회의 전체 요구사항을 최종 판정한다.
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** focused·전체 회귀·lint 성공, 문서 Progress와 최종 검증 기록.
- **범위 밖:** 실패를 숨기기 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' \
--tests 'kr.co.vividnext.sodalive.v2.content.all.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.*'
./gradlew test
./gradlew ktlintCheck
```
**Expected:** 모든 테스트와 lint가 성공하고 무료 콘텐츠가 어떤 대상 소비자 응답이나 POINT 전용 결과에서도 포인트 결제 가능으로 취급되지 않는다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 없음 | 아니요 | 실패 응답 경계와 fixture를 다시 대조 |
| 2 | `P1-GATE` | `P1-T1` | 아니요 | 실패 소유 mapper의 회귀 수정 goal 추가 |
| 3 | `P2-T1` | `P1-GATE` | 아니요 | 목록·count 조건 공유 여부 확인 |
| 4 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 repository 또는 mapper로 되돌림 |
| 5 | `P1-R1` | Phase별 review 판정 완료 | 아니요 | 누락된 계약 fixture와 assertion 범위를 다시 대조 |
| 6 | `P1-R2` | `P1-R1` | 아니요 | legacy 상세의 세 계약 조건을 다시 대조 |
```text
P1-T1 → P1-GATE → P2-T1 → P2-GATE
```
리뷰 후속 실행 순서:
```text
P1-R1 → P1-R2
```
## 변경 금지 항목
- 관리자 조회와 DB 저장값을 보정하지 않는다.
- 공개 DTO 필드를 추가·삭제·이름 변경하지 않는다.
- POINT 조건 보강을 FREE/AUDIO/추천 점수·정렬 변경으로 확장하지 않는다.
- 새 dependency, schema migration과 공통 abstraction을 추가하지 않는다.
- 기존 완료 문서의 체크박스와 검증 기록을 삭제하거나 덮어쓰지 않는다.
- test를 삭제·skip·완화해 Gate를 통과시키지 않는다.
## Progress
실제 구현 시 기존 기록을 삭제하거나 덮어쓰지 않고 Goal 실행 결과를 차수별로 누적한다.
### 문서 작성 검증 — 2026-07-31
- 상태: 완료
- 무엇을: 확정 요구사항을 새 PRD와 goal 실행형 계획에 기록하고 기존 추천·전체 탭 문서의 충돌 계약을 정정했다.
- 왜: 구현 전에 단일 기준 문서, 범위, 제외 조건과 완료 증거를 확정하기 위해서다.
- 어떻게:
- `rg` placeholder·요구사항/Goal 추적 검색 — placeholder 없음, `POINT-001~008``P1-T1`·`P2-T1` 연결 확인.
- 계획에 기록된 production 파일 존재 확인 — 누락 없음.
- `git diff --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- 구현 test: 문서만 변경했으므로 실행하지 않았다.
- 남은 항목: `P1-T1`부터 구현 실행.
- 다음 행동: 사용자가 구현을 요청하면 `P1-T1`의 RED부터 시작한다.
### 구현 검증 — 2026-07-31
- 상태: 완료
- 무엇을: 무료 콘텐츠 포인트 결제 불가 정책을 대상 소비자 응답과 POINT 전용 조회 조건에 적용했다.
- 왜: `price == 0` 콘텐츠가 포인트 결제 가능 상태와 동시에 노출되거나 POINT 목록·개수·페이징 후보에 포함되지 않도록 하기 위해서다.
- 어떻게:
- P1 RED focused test — 소비자 응답 경계에서 무료·저장값 true가 기존 true로 전달되어 assertion 실패 확인.
- P1 GREEN focused test — `BUILD SUCCESSFUL`, 무료 true → false, 유료 true → true, 관리자 상세 원본 true 유지 확인.
- P1-GATE — 원 wildcard 명령은 600초 제한 초과로 분할 실행했고, content 범위 `BUILD SUCCESSFUL`(9m31s), creator/home 범위 `BUILD SUCCESSFUL`(4m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(1m02s).
- P2 RED focused test — recommendation/main-all repository와 E2E에서 무료·저장값 true가 POINT 후보에 포함되어 4개 assertion 실패 확인.
- P2 GREEN focused test — `BUILD SUCCESSFUL`, 추천 `pointAudios`와 전체 탭 POINT 목록·`totalCount`·`hasNext`가 유료 포인트 후보만 반영함을 확인.
- P2-GATE 영향 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*'` `BUILD SUCCESSFUL`(1m48s).
- 최종 Gate — `./gradlew test` `BUILD SUCCESSFUL`(13m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(40s).
- 결정: 관리자 mapper/service, DB 저장값, 공개 DTO 필드명과 구조는 변경하지 않았다.
- 남은 항목: 없음.
### Phase별 리뷰 — 2026-07-31
- 상태: Phase 1 수정 goal 필요, Phase 2 확정 발견 사항 없음.
- 무엇을: PRD, 구현 계획, staged production/test diff와 관련 호출 경계를 Phase별로 대조했다.
- 왜: 완료 체크와 실제 계약 증거가 일치하는지 판정하기 위해서다.
- 어떻게:
- `git diff --cached --check` — 출력 없음.
- `rg`로 대상 응답 mapper, POINT repository 조건과 관련 test assertion을 대조했다.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았으며 기존 구현 검증 기록을 근거로만 확인했다.
- 후속: `REV-P1-001`, `REV-P1-002``P1-R1`로 전환했다.
- 리뷰 문서:
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md`
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md`
### 리뷰 후속 검증 — 2026-07-31
- 상태: 완료
- 무엇을: `REV-P1-001`, `REV-P1-002`의 누락된 회귀 증거를 `P1-R1`로 보강했다.
- 왜: 소비자 응답의 저장값 false 조건과 AI 캐릭터 관리자 무료 원본값 유지 조건을 자동 테스트로 고정하기 위해서다.
- 어떻게:
- P1-R1 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'` `BUILD SUCCESSFUL`(3m11s).
- P1-GATE — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(12m15s).
- `./gradlew ktlintCheck``BUILD SUCCESSFUL`(47s).
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
- 남은 항목: 없음.
### 2차 Phase 1 리뷰 — 2026-07-31
- 상태: `REV-P1-001`, `REV-P1-002` 수정 확인, 추가 수정 goal 필요.
- 무엇을: `P1-R1` test diff, Phase 1 production mapper, 관리자 제외 경계와 완료 기록을 다시 대조했다.
- 왜: 기존 확정 발견 사항이 실제로 수정됐는지, 계약 진리표에 다른 누락은 없는지 판정하기 위해서다.
- 어떻게:
- staged diff와 `rg`로 6개 소비자 조립 경계의 유료·저장값 false assertion을 확인했다.
- 관리자 목록·상세의 `price == 0, stored == true` fixture와 true assertion을 확인했다.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았다.
- 후속: legacy 상세의 유료·저장값 true positive assertion 누락을 `REV-P1-003`, `P1-R2`로 전환했다.
### 리뷰 후속 검증 2차 — 2026-07-31
- 상태: 완료
- 무엇을: `REV-P1-003`의 누락된 legacy 상세 positive 회귀 증거를 `P1-R2`로 보강했다.
- 왜: legacy 상세에서 무료·저장값 true, 유료·저장값 false, 유료·저장값 true의 포인트 사용 가능 계약을 하나의 test로 고정하기 위해서다.
- 어떻게:
- P1-R2 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest'` `BUILD SUCCESSFUL`(14s).
- P1-GATE 단위 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(2m 26s).
- `./gradlew ktlintCheck``BUILD SUCCESSFUL`(14s).
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
- 전체 테스트: 사용자 지시에 따라 실행하지 않았다.
- 남은 항목: 없음.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-07-31 | `PLAN-DEC-001` | 확정 | 응답 조립 지점에서 `configured && price > 0`을 직접 적용하고 새 abstraction을 만들지 않는다 | 기존 mapper 책임과 최소 변경 원칙 | `P1-T1` |
| 2026-07-31 | `PLAN-DEC-002` | 확정 | POINT repository 조건에 `price > 0`을 결합하고 전체 탭 목록과 count의 기존 조건 공유 구조를 유지한다 | 목록·count 일관성 | `P2-T1` |
| 2026-07-31 | `PLAN-DEC-003` | 확정 | 여러 소비자 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다 | 영향 범위 회귀 증거 필요 | `P2-GATE` |
## 발견된 문제
초기 구현 시 발견된 문제 없음.
### 2026-07-31 Phase별 리뷰
- `REV-P1-001` — 소비자 응답 테스트에 `price > 0, storedIsPointAvailable == false` 계약 증거가 없다.
- `REV-P1-002` — 관리자 회귀 테스트가 무료 fixture로 저장값 유지 계약을 검증하지 않는다.
- `REV-P1-003` — legacy 상세 test가 유료·저장값 true의 positive 계약을 검증하지 않는다.
- 후속 Goal: `P1-R1`
- 처리: 2026-07-31 `P1-R1` 완료.
- 추가 후속 Goal: `P1-R2`
- 처리: 2026-07-31 `P1-R2` 완료.
- Phase 2: 확정 발견 사항 없음.
## 최종 보고 형식
```markdown
구현 결과: 무료 콘텐츠 포인트 결제 불가 정책을 소비자 응답과 POINT 조회에 적용
- 변경: 대상 응답 mapper와 추천·전체 탭 POINT 조회 조건
- 결정: 관리자·DB 원본 유지, 공개 schema 유지
- 검증: focused test, 영향 범위 회귀, 전체 test, ktlintCheck 결과
- 남은 항목: 없음 또는 실패·외부 조건
- 문서: PRD와 이 plan-task.md의 Progress·검증 기록
```

View File

@@ -0,0 +1,171 @@
# PRD: 무료 콘텐츠 포인트 결제 불가
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-07-31 |
| 최종 수정일 | 2026-07-31 |
| 대상 제품 | 소비자용 오디오 콘텐츠 조회 API |
| 작성자·결정권자 | 사용자 |
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
| 관련 구현 계획 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md` |
| 관련 review | `reviews/phase-1-review.md`, `reviews/phase-2-review.md` |
## 1. Overview
무료 오디오 콘텐츠와 포인트 결제 가능 상태가 소비자 화면에서 동시에 노출되지 않도록 조회 계약을 보정한다.
저장된 포인트 결제 가능 설정은 유지하되, 소비자용 응답과 포인트 전용 목록에서는 가격을 함께 반영한 실질 상태를 사용한다.
## 2. Problem Statement
- 현재 일부 조회 응답은 `price == 0`이면서 저장된 `isPointAvailable == true`인 콘텐츠를 그대로 포인트 결제 가능으로 노출한다.
- 포인트 추천과 전체 탭 POINT 조회는 저장된 `isPointAvailable`만 필터링해 무료 콘텐츠가 포함될 수 있다.
- 무료와 포인트 결제 가능 상태가 함께 노출되면 클라이언트의 가격 표시와 결제 진입 판단이 서로 모순될 수 있다.
문제를 해결했다는 판단은 소비자용 모든 대상 응답에서 무료 콘텐츠의 포인트 결제 가능 여부가 `false`이고,
POINT 전용 목록과 개수에서 무료 콘텐츠가 제외되는 것으로 한다.
## 3. Goals
- 무료 콘텐츠의 소비자용 포인트 결제 가능 여부를 항상 `false`로 응답한다.
- 유료이면서 저장된 포인트 결제 가능 설정이 `true`인 콘텐츠는 기존처럼 `true`로 응답한다.
- 포인트 전용 목록, 전체 개수와 페이징 판단에서 무료 콘텐츠를 제외한다.
- 기존 공개 API 필드명, 응답 구조와 DB 저장값을 변경하지 않는다.
## 4. Non-Goals
- 콘텐츠 생성·수정 시 `isPointAvailable` 저장값을 강제로 변경하지 않는다.
- 기존 데이터의 일괄 수정이나 DB migration을 수행하지 않는다.
- `/api/v2/admin/ai-characters/**/audio-contents` 관리자 목록·상세의 저장값 표현을 변경하지 않는다.
- `/audio-content/{id}` 상세를 제외한 legacy 목록·추천·랭킹 API는 변경하지 않는다.
- 콘텐츠 구매·대여·소장·포인트 차감 로직은 변경하지 않는다.
- 공개 DTO의 필드 추가·삭제·이름 변경을 수행하지 않는다.
## 5. Target Users and Permissions
| 사용자 | 목표 | 주요 작업 | 적용 범위 |
|---|---|---|---|
| 소비자 | 무료 콘텐츠를 포인트 결제 대상으로 오인하지 않는다 | 콘텐츠 상세·목록·추천 조회 | 대상 소비자용 API |
| 관리자 | 저장된 콘텐츠 설정을 그대로 확인한다 | AI 캐릭터 콘텐츠 목록·상세 조회 | 변경 제외 |
기존 endpoint별 인증·성인 노출·차단 관계·공개 상태 정책은 변경하지 않는다.
## 6. 핵심 정책
### 6.1 가격과 포인트 결제 가능 여부
- 무료 콘텐츠는 `price == 0`으로 정의한다.
- 소비자에게 노출하는 실질 포인트 결제 가능 여부는 다음 식으로 정의한다.
```text
effectivePointAvailable = storedIsPointAvailable && price > 0
```
- `price == 0`이고 저장값이 `true`이면 소비자 응답은 `false`다.
- `price > 0`이고 저장값이 `true`이면 소비자 응답은 `true`다.
- 저장값이 `false`이면 가격과 관계없이 소비자 응답은 `false`다.
- 이 정책은 응답 조립 시 적용하며 엔티티의 저장값은 변경하지 않는다.
### 6.2 포인트 전용 조회
- 포인트 전용 콘텐츠는 `isPointAvailable == true && price > 0`인 공개 오디오로 정의한다.
- `GET /api/v2/audio/recommendations``pointAudios`는 이 조건을 사용한다.
- `GET /api/v2/audio/contents?type=POINT`의 목록과 `totalCount`는 동일한 조건을 사용한다.
- `hasNext`는 보정된 목록 조건으로 조회한 `size + 1` 결과를 기준으로 기존 방식대로 계산한다.
- 무료 콘텐츠는 `isPointAvailable == true`로 저장되어 있어도 POINT 목록, 개수와 페이징 후보에서 제외한다.
## 7. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 |
|---|---|---|---|---|
| `POINT-001` | 확정 | 무료 기준은 `price == 0`이다 | 무료 fixture가 가격 0으로 판정된다 | `P1-T1`, `P2-T1` |
| `POINT-002` | 확정 | 소비자용 실질 포인트 가능 여부는 `storedIsPointAvailable && price > 0`이다 | 무료·저장값 true 응답이 false이고 유료·저장값 true 응답이 true다 | `P1-T1` |
| `POINT-003` | 확정 | legacy 콘텐츠 상세의 `isAvailableUsePoint`에 실질 상태를 적용한다 | `GET /audio-content/{id}` 응답 회귀 테스트가 통과한다 | `P1-T1` |
| `POINT-004` | 확정 | 대상 v2 소비자 응답의 `isPointAvailable`에 실질 상태를 적용한다 | 각 응답 변환 테스트가 무료 true 저장값을 false로 보정한다 | `P1-T1` |
| `POINT-005` | 확정 | 추천 `pointAudios`에서 무료 콘텐츠를 제외한다 | 추천 repository·E2E 테스트에서 가격 0 항목이 없다 | `P2-T1` |
| `POINT-006` | 확정 | 전체 탭 POINT 목록·`totalCount`·`hasNext`가 같은 유료 포인트 조건을 사용한다 | repository·E2E 테스트의 목록과 페이징 메타데이터가 일치한다 | `P2-T1` |
| `POINT-007` | 확정 | AI 캐릭터 관리자 콘텐츠 조회는 저장값을 그대로 반환한다 | 무료·저장값 true인 관리자 상세가 true를 유지한다 | `P1-T1` |
| `POINT-008` | 확정 | 공개 API 스키마와 DB 저장값을 유지한다 | DTO 필드 집합과 관리자 저장값 회귀 테스트가 통과한다 | `P1-GATE`, `P2-GATE` |
## 8. API 계약
### 8.1 응답 보정 대상
| Method | Path | 응답 경계 | 보정 필드 |
|---|---|---|---|
| GET | `/audio-content/{id}` | `GetAudioContentDetailResponse` | `isAvailableUsePoint` |
| GET | `/api/v2/contents` | `ContentOverviewItemResponse` | `isPointAvailable` |
| GET | `/api/v2/audio/contents` | `MainContentAudioResponse` | `isPointAvailable` |
| GET | `/api/v2/audio/recommendations` | `AudioCardResponse` | `isPointAvailable` |
| GET | `/api/v2/home/recommendations` | `HomeFirstAudioContentItem` | `isPointAvailable` |
| GET | `/api/v2/creator-channels/{creatorId}/home` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
| GET | `/api/v2/creator-channels/{creatorId}/audio` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
| GET | `/api/v2/creator-channels/{creatorId}/live` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
- `GET /api/v2/creator-channels/{creatorId}/series`는 콘텐츠 가격과 포인트 가능 필드를 반환하지 않아 코드 변경 대상이 아니다.
- 현재 v2 소비자용 API에 동일 필드를 반환하는 새 경로가 발견되면 같은 식을 적용하고 계획 범위를 먼저 갱신한다.
### 8.2 포인트 전용 조회 조건 보정 대상
| Method | Path/section | 변경 전 | 변경 후 |
|---|---|---|---|
| GET | `/api/v2/audio/recommendations``pointAudios` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
| GET | `/api/v2/audio/contents?type=POINT` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
### 8.3 변경 제외 관리자 계약
| Method | Path | 정책 |
|---|---|---|
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | 저장된 `isPointAvailable`을 그대로 반환 |
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 저장된 값을 `isAvailableUsePoint`에 그대로 반환 |
## 9. 기술적 제약
- Kotlin, Java 17, Spring Boot 2.7.14와 현재 QueryDSL/JPA 구조를 유지한다.
- 새 dependency, DB schema, API endpoint와 DTO를 추가하지 않는다.
- 응답 변환 경계에서는 `configured && price > 0` 식을 직접 사용해 현재 파일 책임 안에서 최소 변경한다.
- POINT 조회 조건은 기존 repository 조건 함수에 `price > 0`을 결합해 목록과 count가 같은 조건을 공유하게 한다.
- 관리자 mapper와 관리자 조회 service는 변경하지 않는다.
- 관련 없는 콘텐츠 가격·결제·추천 점수·정렬·성인·차단 정책은 변경하지 않는다.
## 10. 테스트와 품질 요구사항
- TDD 순서로 무료·저장값 true fixture의 실패 테스트를 먼저 작성하고 실패 원인이 기존 원본 전달임을 확인한다.
- 소비자 응답 경계별로 무료 true → false와 유료 true → true를 검증한다.
- 추천 POINT와 전체 탭 POINT에 무료 true fixture를 추가해 목록 제외를 검증한다.
- 전체 탭은 POINT `totalCount``hasNext`가 목록 조건과 일치하는지 검증한다.
- 관리자 상세는 무료 true 저장값을 그대로 true로 응답하는 회귀 테스트를 유지한다.
- focused test 후 직접 영향받는 v2 콘텐츠·홈·크리에이터 채널 회귀와 `ktlintCheck`를 실행한다.
- 여러 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다.
## 11. 성공 기준
- [ ] `price == 0`, 저장값 `true`인 콘텐츠가 모든 대상 소비자 응답에서 `false`다. (`POINT-002~004`)
- [ ] `price > 0`, 저장값 `true`인 콘텐츠가 대상 소비자 응답에서 `true`다. (`POINT-002`)
- [ ] 저장값 `false`인 콘텐츠는 가격과 관계없이 `false`다. (`POINT-002`)
- [ ] 무료·저장값 true 콘텐츠가 추천 `pointAudios`에서 제외된다. (`POINT-005`)
- [ ] 무료·저장값 true 콘텐츠가 전체 탭 POINT 목록·`totalCount`·`hasNext` 후보에서 제외된다. (`POINT-006`)
- [ ] 관리자 목록·상세와 DB 저장값은 변경되지 않는다. (`POINT-007~008`)
- [ ] 공개 응답 필드명과 구조가 변경되지 않는다. (`POINT-008`)
## 12. Open Questions
없음.
## 13. 요구사항 추적표
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|---|---:|---|---|
| `POINT-001~004`, `POINT-007~008` | 1 | `P1-T1`, `P1-GATE` | legacy 상세·v2 응답 mapper·관리자 회귀 테스트 |
| `POINT-005~006`, `POINT-008` | 2 | `P2-T1`, `P2-GATE` | 추천/전체 탭 repository·E2E 테스트 |
## 14. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-07-31 | `DEC-001` | 확정 | 무료 기준을 `price == 0`으로 고정하고 소비자용 포인트 가능 여부를 `storedIsPointAvailable && price > 0`으로 계산한다 | 사용자 인터뷰 | `POINT-001~004`, `P1-T1` |
| 2026-07-31 | `DEC-002` | 확정 | 추천과 전체 탭 POINT 조회에서 무료 콘텐츠를 제외한다 | 사용자 선택 A | `POINT-005~006`, `P2-T1` |
| 2026-07-31 | `DEC-003` | 확정 | AI 캐릭터 관리자 조회와 DB 저장값은 변경하지 않는다 | 사용자 선택 A | `POINT-007~008`, `P1-T1` |
| 2026-07-31 | `DEC-004` | 확정 | 새 통합 문서를 기준으로 만들고 충돌하는 기존 추천·전체 탭 문서에는 정정 기록을 누적한다 | 사용자 승인 | 관련 문서 전체 |

View File

@@ -0,0 +1,229 @@
# Phase 1 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-GATE` |
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `POINT-001~004`, `POINT-007~008` 구현과 완료 증거가 일치하는지 확인한다.
- 대상 소비자 응답과 변경 제외 관리자 응답이 각각 확정 계약을 유지하는지 확인한다.
### 포함 범위
- 코드: Phase 1에서 변경한 consumer response mapper와 `AudioContentService`
- 테스트: Phase 1의 unit/facade test와 AI 캐릭터 관리자 controller test
- 문서: PRD Phase 1 요구사항, `P1-T1`, `P1-GATE`, Progress
### 제외 범위
- Phase 2 POINT 전용 repository 조건
- legacy 상세 외 legacy 목록·추천·랭킹 API
- compile과 test 재실행
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항 또는 공개 API 계약 위반 |
| Medium | 제한된 조건의 기능 회귀 또는 핵심 계약의 자동 검증 누락 |
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `POINT-001~004`, `POINT-007~008`
- 계획: `P1-T1`, `P1-GATE`
- 코드:
- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt:970`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt:49`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt:63`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt:72`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt:35`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt:270`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt:69`
- 테스트:
- `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt:286`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt:495`
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `git diff --cached --check` | 성공 | 출력 없음 |
| staged diff와 `rg` 기반 호출·assertion 대조 | 성공 | 6개 소비자 조립 경계는 모두 `stored && price > 0` 적용 |
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P1-001` | Medium | 수정 완료 | 소비자 테스트가 저장값 false 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
| `REV-P1-002` | Low | 수정 완료 | 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
| `REV-P1-003` | Medium | 수정 완료 | legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음 | `Task 1.3` | `P1-R2` |
## 6. 발견 사항 상세
### REV-P1-001 — 소비자 테스트가 저장값 false 조건을 증명하지 않음
- **심각도:** Medium
- **상태:** 수정 완료
- **관련 요구사항:** `POINT-002`, `POINT-004`
- **소유 Task:** `Task 1.2`, `P1-R1`
**관찰 내용**
변경된 소비자 테스트는 주로 `price == 0, stored == true → false`
`price > 0, stored == true → true`만 검증한다. 따라서 구현이 실수로 `price > 0`만 반환해도 해당 두 조건은 통과한다.
PRD 성공 기준인 `stored == false → false`를 자동으로 구분할 수 없다.
**근거**
- 코드: 각 소비자 조립 경계는 현재 `stored && price > 0`으로 올바르게 구현되어 있다.
- 테스트: Phase 1 변경 test에는 각 변경 경계의 `price > 0, stored == false` assertion이 없다.
- 문서: PRD `POINT-002`와 성공 기준은 저장값 false가 가격과 무관하게 false일 것을 요구한다.
**영향**
현재 production 동작 결함은 확인되지 않았다. 다만 저장값 조건이 제거되는 회귀가 발생해도 Phase 1 test가 탐지하지 못한다.
**권장 조치**
각 변경 경계의 기존 test fixture에 `price > 0, stored == false` 사례를 최소 추가하고 `P1-GATE`를 재검증한다.
**판정 기록**
- 2026-07-31 — staged 구현식은 정상이나 확정 계약의 자동 검증 누락으로 판정해 `P1-R1`로 전환했다.
- 2026-07-31 — `P1-R1`에서 대상 소비자 경계에 `price > 0, stored == false` assertion을 추가하고 focused test, `P1-GATE`, `ktlintCheck` 성공을 확인했다.
### REV-P1-002 — 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음
- **심각도:** Low
- **상태:** 수정 완료
- **관련 요구사항:** `POINT-007`, `POINT-008`
- **소유 Task:** `Task 1.2`, `P1-R1`
**관찰 내용**
계획과 Progress는 관리자 상세의 “무료·저장값 true → true” 확인을 완료 증거로 기록했지만,
`AiCharacterAdminAudioContentControllerTest`의 공통 fixture는 `price = 100`이다. 상세 assertion은 저장값 true 전달만 검증하며
무료 조건에서 소비자 보정이 관리자 경계로 번지지 않았는지는 증명하지 않는다. 관리자 목록의 동일 조건 assertion도 없다.
**근거**
- 코드: `AiCharacterAdminAudioContentMapper`는 현재 `content.isPointAvailable`을 그대로 전달한다.
- 테스트: 관리자 helper의 `price = 100`, 상세의 `isAvailableUsePoint == true` assertion.
- 문서: `P1-T1`과 구현 Progress는 무료 관리자 원본값 유지 확인을 완료 증거로 기록한다.
**영향**
현재 관리자 mapper의 기능 결함은 확인되지 않았다. 그러나 완료 기록과 실제 test fixture가 불일치하며 관리자 제외 계약의
핵심 경계가 회귀 test로 고정되지 않았다.
**권장 조치**
관리자 목록·상세 fixture를 `price == 0, stored == true`로 구성해 목록 `isPointAvailable`과 상세
`isAvailableUsePoint`가 true를 유지하는지 검증한다.
**판정 기록**
- 2026-07-31 — 구현은 정상이나 완료 증거 불일치가 확인되어 `P1-R1`로 전환했다.
- 2026-07-31 — `P1-R1`에서 관리자 목록·상세 fixture를 `price == 0, stored == true`로 보강하고 원본 true 유지 assertion 통과를 확인했다.
### REV-P1-003 — legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음
- **심각도:** Medium
- **상태:** 수정 완료
- **관련 요구사항:** `POINT-002`, `POINT-003`
- **소유 Task:** `Task 1.3`, `P1-R2`
**관찰 내용**
`AudioContentServiceTest` 상세 회귀 test는 무료·저장값 true와 유료·저장값 false가 false인 것만 검증한다.
`price > 0, stored == true → true` assertion이 없어 legacy 상세 구현이 항상 false로 회귀해도 해당 test가 통과한다.
**근거**
- 코드: `AudioContentService` 상세 응답은 현재 `audioContent.isPointAvailable && audioContent.price > 0`으로 올바르게 구현되어 있다.
- 테스트: `AudioContentServiceTest` 상세 포인트 assertion은 false 사례 2건만 포함한다.
- 문서: `P1-T1`은 각 응답 경계의 유료·저장값 true 유지를 완료 증거로 요구한다.
**영향**
현재 production 동작 결함은 확인되지 않았다. 다만 legacy 상세의 positive 계약이 회귀 test로 고정되지 않았다.
**권장 조치**
기존 상세 test에 유료·저장값 true 응답 assertion을 추가해 세 계약 조건을 완성한다.
**판정 기록**
- 2026-07-31 — staged production 구현은 정상이나 legacy 상세 positive 회귀 증거 누락으로 판정해 `P1-R2`로 전환했다.
- 2026-07-31 — `P1-R2`에서 legacy 상세 test에 유료·저장값 true assertion을 추가해 세 계약 조건을 고정하고 focused test, P1-GATE 단위 범위, `ktlintCheck` 성공을 확인했다.
## 7. 확정 항목의 plan·goal 전환
- 신규 회귀 수정 Task: `plan-task.md``Task 1.2`
- 후속 goal: `P1-R1`
- objective: 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
- 추가 회귀 수정 Task: `plan-task.md``Task 1.3`
- 추가 후속 goal: `P1-R2`
- objective: legacy 상세의 유료·저장값 true positive 계약을 자동 회귀 테스트로 증명한다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 1 production/test/document diff와 관련 mapper 확인 |
| 후보 항목 판정 완료 | 충족 | 3건 모두 판정 완료 |
| 확정 항목 plan 반영 | 충족 | `Task 1.2`, `P1-R1`, `Task 1.3`, `P1-R2` |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
**최종 결론:** 수정 완료
**남은 항목:** 없음
## 9. 수정 후 검증 기록
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
### 1차 수정 검증 — 2026-07-31
- 무엇을: `REV-P1-001`, `REV-P1-002`의 수정 내용과 회귀 증거를 재검토했다.
- 왜: 소비자 저장값 false 계약과 관리자 무료 원본값 유지 계약의 자동 검증 누락을 해소했는지 확인하기 위해서다.
- 어떻게:
- staged diff와 `rg` — 6개 소비자 조립 경계의 유료·저장값 false assertion 확인.
- staged diff — 관리자 목록·상세의 무료·저장값 true fixture와 true assertion 확인.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- compile/test — 사용자 지시에 따라 재실행하지 않고 `plan-task.md`의 기존 성공 기록만 확인.
- 판정: `REV-P1-001`, `REV-P1-002` 수정 완료.
- 남은 항목: `REV-P1-003`, `P1-R2`.
### 2차 수정 검증 — 2026-07-31
- 무엇을: `REV-P1-003`의 수정 내용과 회귀 증거를 재검토했다.
- 왜: legacy 상세의 유료·저장값 true positive 계약이 자동 검증으로 고정됐는지 확인하기 위해서다.
- 어떻게:
- `AudioContentServiceTest` — 무료·저장값 true → false, 유료·저장값 false → false, 유료·저장값 true → true assertion 확인.
- `AudioContentService` — 응답식 `audioContent.isPointAvailable && audioContent.price > 0`과 세 assertion 대조.
- `plan-task.md`의 기존 완료 기록 — focused test `BUILD SUCCESSFUL`(14s), P1-GATE 단위 범위
`BUILD SUCCESSFUL`(2m 26s), `ktlintCheck` `BUILD SUCCESSFUL`(14s) 확인.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
- 판정: `REV-P1-003` 수정 완료.
- 남은 항목: 없음.

View File

@@ -0,0 +1,111 @@
# Phase 2 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / `P2-T1`, `P2-GATE` |
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `POINT-005~006`, `POINT-008` 구현과 완료 증거가 일치하는지 확인한다.
- 추천과 전체 탭 POINT 조회가 무료 콘텐츠를 목록·count·pagination 후보에서 제외하는지 확인한다.
### 포함 범위
- 코드: 추천·전체 탭 QueryDSL repository 변경
- 테스트: 두 repository test와 추천·전체 탭 E2E test
- 문서: 관련 PRD, 기존 추천·전체 탭 후속 정정, `P2-T1`, `P2-GATE`, Progress
### 제외 범위
- Phase 1 응답 mapper
- FREE/AUDIO/정렬 정책의 신규 변경
- compile과 test 재실행
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항 또는 공개 API 계약 위반 |
| Medium | 제한된 조건의 목록·count·pagination 불일치 |
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `POINT-005~006`, `POINT-008`
- 계획: `P2-T1`, `P2-GATE`
- 코드:
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt:135`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:35`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:409`
- 테스트:
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt:87`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt:43`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt:42`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt:97`
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `git diff --cached --check` | 성공 | 출력 없음 |
| staged diff와 `rg` 기반 조건·호출 대조 | 성공 | 추천은 유료 POINT 조건, 전체 탭 list/count는 같은 `audioCondition` 공유 |
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
## 5. 발견 사항 요약
확정 발견 사항 없음.
## 6. 검토 결과
- 추천 `findPointAudios``isPointAvailable.isTrue.and(price.gt(0))`을 조회 전에 적용해 limit 후보에서도 무료 콘텐츠를 제외한다.
- 전체 탭 `countAudios``findAudios`는 동일한 `audioCondition``optionalAudioPointCondition`을 사용한다.
- 전체 탭 E2E는 유료 1건과 무료·저장값 true 1건에서 `size=1`로 조회해 `totalCount=1`, 목록 1건,
`hasNext=false`를 함께 검증한다.
- repository test는 유료·저장값 false, 유료·저장값 true, 무료·저장값 true를 구분한다.
- 기존 추천·전체 탭 문서는 2026-07-31 후속 요구사항 정정을 누적해 현재 PRD와 일치한다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 2 production/test/document diff와 관련 호출 경계 확인 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 없음
## 9. 후속 상태 확인
### 1차 재확인 — 2026-07-31
- 무엇을: Phase 1의 `P1-R2` 수정이 Phase 2 POINT 조회 계약에 영향을 주지 않았는지 재검토했다.
- 왜: 후속 test 변경 뒤에도 POINT 목록·count·pagination 조건과 기존 Phase 2 판정이 유효한지 확인하기 위해서다.
- 어떻게:
- production diff — `P1-R2`에 따른 Phase 2 repository 변경 없음 확인.
- repository와 E2E test 정적 대조 — 추천 유료 POINT 조건과 전체 탭 list/count 공통 조건 유지 확인.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
- 판정: 기존 Phase 2 판정 유지.
- 남은 항목: 없음.

View File

@@ -0,0 +1,170 @@
# 관리자 정산 크리에이터 번호 적용 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-04 |
| 요구사항 기준 | `docs/20260804_관리자정산크리에이터번호적용/prd.md` |
| 현재 Phase | Phase 1 완료 |
| 현재 활성 Goal | 없음 |
## 목표
이메일이 없는 크리에이터도 관리자 크리에이터별 정산에서 오류 없이 조회되고 `creatorId`로 구분된다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 | 없음 |
## 범위
### 포함
- 관리자 라이브·콘텐츠·커뮤니티 크리에이터별 정산 QueryDSL projection 변경
- 정산 query/response DTO의 `email` 제거 및 `creatorId` 추가
- 크리에이터별 정산 엑셀 이메일 컬럼 제거 및 크리에이터 번호 컬럼 추가
- null 이메일 크리에이터 QueryDSL 회귀 테스트와 엑셀 출력 테스트
### 제외
- 에이전트·채널후원 정산 변경
- `Member.email` 정책 변경
- 정산 공식·집계·필터·정렬·페이지네이션 변경
- dependency 추가와 관련 없는 리팩터링
## 기술적 제약
- Kotlin, Spring Boot 2.7.14, QueryDSL 5.0.0, JUnit 5의 기존 패턴을 유지한다.
- `@QueryProjection` 생성자 변경은 Gradle KAPT가 생성 코드를 갱신하도록 하고 생성 파일은 직접 수정하지 않는다.
- 구현은 RED → GREEN → REFACTOR 순서로 진행한다.
- Gradle 명령은 QueryDSL 생성 코드 충돌을 피하기 위해 순차 실행한다.
### Phase 1: 정산 projection 및 출력 변경
**Phase 결과:** null 이메일 크리에이터 정산 조회와 creatorId 기반 API·엑셀 출력이 동작한다.
**선행조건:** PRD와 본 계획 문서 작성 완료.
#### Task 1.1 QueryDSL 정산 응답에 creatorId 적용
**Goal 실행 `P1-T1`:** null 이메일 크리에이터의 세 정산 조회가 creatorId를 반환하도록 한다.
- **시작 조건:** `CALC-001`, `CALC-002`, `CALC-004` 확정.
- **완료 증거:** RED/GREEN 결과와 focused test 기록.
- **범위 밖:** 엑셀 출력 변경은 `P1-T2`에서 처리한다.
**Files:**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`
- [x] **RED:** email이 null인 크리에이터의 라이브·콘텐츠·커뮤니티 정산 조회가 `creatorId`를 반환하는 통합 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest`를 실행해 기존 projection의 null email 생성 오류 또는 미구현 `creatorId` 계약 실패를 확인한다.
- [x] **GREEN:** 두 DTO에서 `email``creatorId: Long`으로 교체하고 세 QueryDSL projection에서 `member.id`를 선택한다.
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 정산 계산 회귀 테스트를 실행한다.
#### Task 1.2 엑셀 크리에이터 번호 적용
**Goal 실행 `P1-T2`:** 크리에이터별 정산 엑셀에서 이메일 대신 크리에이터 번호를 출력한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 엑셀 헤더·값 테스트와 focused test 기록.
- **범위 밖:** 다른 정산 엑셀 형식 변경.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt`
- [x] **RED:** 크리에이터별 정산 엑셀의 첫 헤더와 값이 크리에이터 번호인지 검증하는 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest`를 실행해 남아 있는 `item.email` 계약 때문에 컴파일이 실패하는지 확인한다.
- [x] **GREEN:** 엑셀 첫 헤더를 `크리에이터 번호`로 교체하고 `creatorId`를 기록한다.
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
- [x] **REFACTOR:** 나머지 엑셀 컬럼과 계산 결과가 유지되는지 회귀 확인한다.
### 완료 조건
- [x] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 충족됐다.
- [x] 문서와 구현의 차이가 없다.
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 변경 범위의 기능·컴파일·포맷을 최종 판정한다.
```bash
./gradlew compileKotlin
./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest
./gradlew ktlintCheck
./gradlew tasks --all
git diff --check
```
**Expected:** 모든 Gradle 명령이 `BUILD SUCCESSFUL`, 테스트가 모두 통과하고 `git diff --check` 출력이 없다.
전체 테스트는 관리자 크리에이터별 정산의 projection/DTO/엑셀 경계로 변경이 제한되고 focused test와 컴파일로 세 쿼리 호출부를 검증하므로 생략한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 문서 작성 완료 | 아니요 | RED 실패 원인 재확인 |
| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | 엑셀 계약 재확인 |
| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task에 회귀 수정 기록 |
## 변경 금지 항목
- QueryDSL 생성 파일을 직접 수정하지 않는다.
- 정산 계산식과 쿼리 집계 조건을 변경하지 않는다.
- 테스트를 삭제·skip·완화하지 않는다.
- 요청 범위 밖의 정산 API를 변경하지 않는다.
## Progress
### `P1-T1` 1차 실행 — 2026-08-04
- 상태: 진행 중
- 무엇을: PRD와 구현 계획을 작성하고 QueryDSL 통합 테스트 seam을 확정했다.
- 왜: production 변경 전에 요구사항과 TDD 완료 기준을 고정하기 위해서다.
- 어떻게:
- `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md` 확인 — 완료
- `AdminCalculateQueryRepository`와 관련 DTO 호출 경로 확인 — 완료
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest` — RED 확인, `creatorId` 미구현으로 `compileTestKotlin` 실패
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — RED 확인, 남아 있는 `item.email` 참조로 `compileKotlin` 실패
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — GREEN, `BUILD SUCCESSFUL`
- 남은 항목: Phase Gate 검증.
- 다음 행동: 컴파일·focused 회귀·ktlint·문서 검증을 순차 실행한다.
### `P1-GATE` 1차 실행 — 2026-08-04
- 상태: 완료
- 무엇을: creatorId projection·응답·엑셀 변경과 null 이메일 회귀 방지를 검증했다.
- 왜: `CALC-001`~`CALC-004`의 수용 기준과 기존 계산 불변성을 최종 판정하기 위해서다.
- 어떻게:
- `./gradlew compileKotlin``BUILD SUCCESSFUL`
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` — 11 tests, failures 0, errors 0
- `./gradlew ktlintCheck``BUILD SUCCESSFUL`
- `./gradlew build -x test``BUILD SUCCESSFUL`
- `git diff --check` — 출력 없음
- 독립 Oracle 리뷰 — `APPROVED`, 차단 이슈 없음
- 수동 검증: QueryDSL H2 통합 테스트로 null 이메일 세 정산 조회를 실행하고, 실제 XLSX workbook의 헤더와 numeric creatorId 셀을 확인했다.
- 전체 테스트 생략: 변경이 관리자 크리에이터별 정산의 공용 projection/DTO/엑셀 경계에 한정되어 focused 통합·서비스·계산 테스트와 전체 빌드로 직접 영향 범위를 검증했다.
- 남은 항목: 없음.
- 다음 행동: 없음.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-04 | `DEC-001` | 확정 | email을 제거하고 creatorId를 projection·응답에 추가한다. | PRD `DEC-001` | `P1-T1` |
| 2026-08-04 | `DEC-002` | 확정 | 엑셀 이메일 컬럼을 크리에이터 번호로 교체한다. | PRD `DEC-002` | `P1-T2` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| `ISSUE-001` | High | 확정 | nullable `Member.email`이 non-null QueryProjection 생성자로 전달되어 정산 조회가 실패한다. | `P1-T1` | creatorId로 projection 계약 교체 |

View File

@@ -0,0 +1,73 @@
# 관리자 정산 크리에이터 번호 적용 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-08-04 |
| 최종 수정일 | 2026-08-04 |
| 대상 제품 | 관리자 크리에이터별 정산 조회 및 엑셀 |
| 작성자·결정권자 | 사용자 |
| 관련 구현 계획 | `docs/20260804_관리자정산크리에이터번호적용/plan-task.md` |
## 1. Overview
관리자 크리에이터별 정산에서 nullable인 `Member.email`을 필수 QueryDSL projection 값으로 사용해 발생하는 조회 오류를 제거한다. 정산 대상 식별값은 이메일 대신 non-null PK인 `creatorId`를 사용한다.
## 2. Problem Statement
- `Member.email`은 nullable이지만 `GetCalculateByCreatorQueryData.email`은 non-null `String`이다.
- 이메일이 없는 크리에이터가 정산 결과에 포함되면 QueryDSL이 DTO 생성 중 `ExpressionException`을 발생시킨다.
- 이메일은 정산 계산식에 사용되지 않으므로 필수 projection 값으로 유지할 이유가 없다.
문제 해결 여부는 이메일이 null인 크리에이터의 정산 조회가 성공하고 `creatorId`를 반환하는지로 판단한다.
## 3. Goals
- 라이브·콘텐츠·커뮤니티 크리에이터별 정산 projection에서 `email`을 제거한다.
- 정산 응답에 `creatorId`를 추가한다.
- 크리에이터별 정산 엑셀에서 이메일을 제거하고 크리에이터 번호를 제공한다.
- 기존 정산 금액 계산, 집계, 필터, 정렬과 페이지네이션을 유지한다.
## 4. Non-Goals
- `Member.email`의 nullable 정책을 변경하지 않는다.
- 이메일이 없는 회원의 다른 기능을 수정하지 않는다.
- 에이전트 정산과 채널후원 정산 API를 변경하지 않는다.
- 정산 공식 또는 정산 비율을 변경하지 않는다.
## 5. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `CALC-001` | 확정 | 크리에이터별 정산 조회는 이메일 대신 크리에이터 번호를 반환한다. | 응답 item에 `creatorId: Long`이 있고 `email`이 없다. | `P1-T1` |
| `CALC-002` | 확정 | 이메일이 없는 크리에이터도 정산 조회 대상에 포함된다. | null 이메일 크리에이터의 라이브·콘텐츠·커뮤니티 정산 QueryDSL 조회가 예외 없이 성공한다. | `P1-T1` |
| `CALC-003` | 확정 | 크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 제공한다. | 첫 헤더가 `크리에이터 번호`이고 데이터 셀에 `creatorId`가 기록된다. | `P1-T2` |
| `CALC-004` | 확정 | 기존 정산 계산 결과를 유지한다. | `totalCan`, 원화, 결제수수료, 정산금액, 원천세와 입금액 계산 회귀 테스트가 통과한다. | `P1-T1`, `P1-GATE` |
## 6. API 계약
대상 endpoint는 다음과 같다.
- `GET /admin/calculate/live-by-creator`
- `GET /admin/calculate/content-by-creator`
- `GET /admin/calculate/community-by-creator`
- 위 세 endpoint의 `/excel` 다운로드
조회 응답 item의 `email: String`을 제거하고 `creatorId: Long`을 추가한다. 요청 파라미터와 응답의 나머지 필드는 변경하지 않는다.
## 7. 성공 기준
- [x] null 이메일 크리에이터에 대한 세 종류의 크리에이터별 정산 조회가 성공한다.
- [x] 조회 응답은 `creatorId`를 포함하고 `email`을 포함하지 않는다.
- [x] 엑셀은 `크리에이터 번호` 컬럼을 포함하고 이메일 컬럼을 포함하지 않는다.
- [x] 기존 정산 계산 및 관련 회귀 테스트가 통과한다.
## 8. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-04 | `DEC-001` | 확정 | 정산에서 email을 제거하고 creatorId를 사용한다. | email은 계산에 사용되지 않고 nullable이라 projection 오류를 발생시킨다. | `CALC-001`, `CALC-002`, `P1-T1` |
| 2026-08-04 | `DEC-002` | 확정 | 엑셀의 이메일 컬럼을 크리에이터 번호 컬럼으로 교체한다. | 사용자가 이메일 제거와 creatorId 기반 구분을 확정했다. | `CALC-003`, `P1-T2` |
| 2026-08-04 | `DEC-003` | 확정 | 이메일 제거에 따른 추가 식별력 보완은 하지 않는다. | 사용자가 식별력 저하는 문제가 되지 않는다고 확정했다. | Non-Goals |

View File

@@ -0,0 +1,90 @@
# 추천 탭 배너 조회 조건 보정 Implementation Plan
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-05 |
| 요구사항 기준 | `docs/20260805_추천탭_배너_조회조건/prd.md` |
| API 기준 | 기존 공개 API 계약 유지 |
| 현재 Phase | Phase 1: 배너 조회 조건 보정 |
| 현재 활성 Goal | 없음 |
## 목표
메인 홈 추천과 메인 콘텐츠 추천에서 화면별 탭과 회원의 성인 콘텐츠 조회 가능 여부에 맞는 배너만 조회한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 | 없음 |
## 범위
### 포함
- 홈 추천 배너의 `tab_id IS NULL` 조건 유지 및 성인 배너 필터 추가
- 콘텐츠 추천 배너의 `tab_id = 2` 조건과 성인 배너 필터 추가
- application에서 계산한 성인 콘텐츠 조회 가능 여부를 persistence port까지 전달
- 관련 service/facade/Repository focused test와 직접 영향 범위 회귀
### 제외
- 공개 API endpoint와 응답 DTO 변경
- 배너 언어/활성/차단/대상 유효성/정렬/limit 정책 변경
- DB 데이터 또는 스키마 변경
## 기술적 제약
- Kotlin, Spring Boot 2.7.14, QueryDSL 기존 패턴을 유지한다.
- 성인 콘텐츠 조회 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 재사용한다.
- 조회 가능 여부가 `false`이면 `audioContentBanner.isAdult.isFalse`, `true`이면 성인 조건을 추가하지 않는다.
- 신규 공통 추상화나 의존성을 추가하지 않는다.
### Phase 1: 배너 조회 조건 보정
- [x] **Task 1.1: 추천 API별 탭 및 성인 배너 조회 조건 구현 (`P1-T1`)**
- Objective: 두 추천 API의 배너가 확정된 탭과 성인 콘텐츠 조회 정책에 따라 반환된다.
- 시작 조건: PRD `BANNER-001~004`가 확정되어 있다.
- 완료 증거: 신규/보강 테스트가 RED 후 GREEN이고 관련 production/test 코드가 컴파일된다.
- 범위 밖: 언어 필터와 공개 응답 스키마 변경.
- Modify:
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- RED: 홈 성인 조회 플래그 전달, 홈 비성인 필터, 콘텐츠 `tab_id = 2` 및 비성인 필터를 검증하는 테스트를 먼저 작성하고 실패를 확인한다.
- GREEN: 기존 QueryDSL 조건에 필요한 탭/성인 조건만 추가하고 홈 application 경로에 플래그를 전달한다.
- REFACTOR: 중복되지 않는 기존 조건 helper 패턴을 따르고 불필요한 변경이 없는지 확인한다.
- Verify:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- 검증 기록:
- RED: 두 Repository 테스트를 실행해 `shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible`, `shouldFindBannersForContentRecommendationTabWithAdultVisibility` 두 건의 기대값 실패를 확인했다.
- RED: 홈 service 테스트는 `includeAdultBanners` 미구현 컴파일 실패, Facade 테스트는 플래그 미전달 assertion 실패를 각각 확인했다.
- GREEN: 위 4개 focused test class를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 1.2: 영향 범위 회귀 및 문서 검증 (`P1-GATE`)**
- Objective: 추천 배너 변경이 기존 API 계약과 코드 품질 규칙을 깨지 않았음을 확인한다.
- 시작 조건: `P1-T1`이 완료되어 있다.
- 완료 증거: focused test, `ktlintCheck`, `tasks --all`, `git diff --check`가 통과하고 결과가 기록되어 있다.
- 범위 밖: 전체 회귀 테스트. 변경이 배너 조회 경로 두 곳에 한정되어 targeted test로 직접 영향 범위를 판단할 수 있으므로 생략한다.
- Verify:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- `./gradlew ktlintCheck`
- `./gradlew tasks --all`
- `git diff --check`
- 검증 기록:
- `HomeRecommendationControllerTest`, `AudioRecommendationControllerTest`, `AudioRecommendationEndToEndTest`를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
- `./gradlew tasks --all`: `BUILD SUCCESSFUL`.
- `git diff --check`: 출력 없음.
## 검증 기록
- 최초 Gradle 실행은 sandbox의 `~/.gradle` wrapper lock 접근 제한으로 실패했으며, 승인된 동일 명령을 재실행해 검증을 완료했다.
- 전체 회귀 테스트는 실행하지 않았다. 변경이 두 배너 조회 조건과 홈 플래그 전달에 한정되어 focused Repository/application 테스트와 두 API의 controller/E2E 회귀로 직접 영향 범위를 검증했다.

View File

@@ -0,0 +1,71 @@
# PRD: 추천 탭 배너 조회 조건 보정
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-08-05 |
| 최종 수정일 | 2026-08-05 |
| 대상 제품 | 메인 홈 추천 탭, 메인 콘텐츠 추천 탭 |
| 작성자·결정권자 | 사용자 |
| 관련 구현 계획 | `docs/20260805_추천탭_배너_조회조건/plan-task.md` |
| 관련 기존 문서 | `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` |
## 1. Overview
메인 홈 추천 API와 메인 콘텐츠 추천 API의 배너 조회에 회원의 성인 콘텐츠 조회 가능 여부를 반영하고, 두 화면이 서로 다른 탭의 배너를 조회하도록 조건을 보정한다.
## 2. Problem Statement
- 두 추천 API가 모두 `content_banner.tab_id IS NULL`인 같은 배너를 조회한다.
- 두 추천 API의 배너 조회가 `content_banner.is_adult`를 필터링하지 않아 성인 콘텐츠 조회 불가 사용자에게 성인 배너가 노출될 수 있다.
문제를 해결했다는 판단은 두 API의 탭 조건과 성인 배너 노출 조건이 Repository 테스트로 구분되어 검증되는 것으로 한다.
## 3. Goals
- 성인 콘텐츠 조회 불가 사용자는 `is_adult = false`인 배너만 조회한다.
- 성인 콘텐츠 조회 가능 사용자는 성인·비성인 배너를 모두 조회한다.
- 메인 홈 추천 API는 기존처럼 `tab_id IS NULL`인 배너를 조회한다.
- 메인 콘텐츠 추천 API는 `tab_id = 2`인 배너를 조회한다.
## 4. Non-Goals
- 공개 API endpoint와 응답 DTO를 변경하지 않는다.
- 배너 언어 필터, 정렬, 최대 조회 개수, 대상 활성/차단 정책을 변경하지 않는다.
- 배너 또는 탭 데이터와 DB 스키마를 변경하지 않는다.
## 5. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `BANNER-001` | 확정 | `GET /api/v2/home/recommendations`의 배너는 `tab_id IS NULL` 조건을 유지한다. | 탭이 없는 활성 배너만 조회하고 `tab_id = 2` 배너는 조회하지 않는다. | `P1-T1` |
| `BANNER-002` | 확정 | `GET /api/v2/audio/recommendations`의 배너는 `tab_id = 2` 조건을 사용한다. | `tab_id = 2`인 활성 배너만 조회하고 탭이 없는 배너는 조회하지 않는다. | `P1-T1` |
| `BANNER-003` | 확정 | 두 API 모두 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 반영한다. | 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 반환한다. | `P1-T1` |
| `BANNER-004` | 확정 | 기존 배너 활성/차단/대상 유효성/정렬/limit 정책을 유지한다. | 기존 관련 Repository 테스트가 계속 통과한다. | `P1-GATE` |
## 6. API 계약
| Method | Path | 변경 내용 |
|---|---|---|
| `GET` | `/api/v2/home/recommendations` | 응답 스키마 변경 없이 배너의 성인 조회 조건만 추가한다. |
| `GET` | `/api/v2/audio/recommendations` | 응답 스키마 변경 없이 배너 탭 조건을 `tab_id = 2`로 변경하고 성인 조회 조건을 추가한다. |
## 7. 성공 기준
- [x] 홈 추천 배너가 `tab_id IS NULL` 조건을 유지한다.
- [x] 콘텐츠 추천 배너가 `tab_id = 2` 조건만 사용한다.
- [x] 성인 콘텐츠 조회 불가/가능 사용자의 배너 결과가 확정 정책과 일치한다.
- [x] 기존 배너 응답 스키마와 활성/차단/정렬/limit 정책이 유지된다.
## 8. Open Questions
- 없음.
## 9. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-05 | `DEC-001` | 확정 | 성인 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 조회한다. | 사용자 답변 A | `BANNER-003`, `P1-T1` |
| 2026-08-05 | `DEC-002` | 확정 | 홈 추천은 `tab_id IS NULL`, 콘텐츠 추천은 `tab_id = 2`를 사용한다. | 사용자 직접 요구사항 | `BANNER-001`, `BANNER-002`, `P1-T1` |

View File

@@ -0,0 +1,174 @@
# 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정 Plan/TASK
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-05 |
| 요구사항 기준 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md` |
| API 기준 | 기존 `GET /creator-admin/audio-content/series/{seriesId}` 계약 유지 |
| 현재 Phase | Phase 1 완료 |
| 현재 활성 Goal | 없음, 구현 완료 |
## 목표
OSIV off 환경에서 크리에이터 관리자가 본인 시리즈 상세를 조회할 때 `Series.keywordList` lazy 초기화 예외 없이 기존 응답을 받게 한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `1/1` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
## 범위
### 포함
- `CreatorAdminContentSeriesService`의 class-level read-only 트랜잭션 경계
- `Series.keywordList` lazy 예외를 재현하고 방지하는 서비스 통합 테스트
- 기존 상세 응답과 소유권 동작의 영향 범위 회귀 검증
### 제외
- endpoint와 `GetCreatorAdminContentSeriesDetailResponse` 변경
- OSIV, entity fetch 전략, repository query 변경
- 다른 시리즈 조회·수정 흐름 리팩터링
- 새 abstraction 또는 dependency 추가
## 기술적 제약
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5를 유지한다.
- production code는 `CreatorAdminContentSeriesService` class-level annotation 한 줄만 변경한다.
- 기존 쓰기 메서드 `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`,
`updateSeriesOrders()`의 메서드 레벨 `@Transactional`은 유지해 class-level read-only 기본값을 재정의한다.
- 테스트는 실제 Spring 서비스 프록시를 사용하고 외부 테스트 트랜잭션으로 서비스 경계를 가리지 않는다.
- focused test부터 실행하고 직접 영향받는 characterization test까지만 회귀 범위를 확장한다.
- 전체 테스트는 class-level annotation 한 줄 변경과 targeted test로 영향 범위를 판정할 수 있으므로 기본적으로 생략한다. targeted test에서
범위를 설명할 수 없는 실패가 발생하거나 공통 경계 변경으로 확대될 때만 실행한다.
## Phase 1: 상세 조회 트랜잭션 회귀 수정
**Phase 결과:** 크리에이터 관리자 시리즈 상세 조회가 OSIV off 환경에서 키워드를 포함한 기존 DTO를 정상 반환한다.
**선행조건:** `CASD-001`~`CASD-003` 요구사항과 `DEC-CASD-002`, `DEC-CASD-003` 결정 확정.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료, focused·영향 범위 회귀 결과 기록.
### 구현 항목
#### Task 1.1 상세 조회 lazy 예외 재현 및 최소 수정
**Goal 실행 `P1-T1`:** 서비스 클래스의 기본 read-only 트랜잭션 안에서 상세 조회가 키워드 lazy 컬렉션을 DTO로 변환하게 한다.
- **시작 조건:** PRD 구현 기준 확정, production code 미수정 상태.
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, focused test 실제 실행 결과, class-level annotation 이외 production diff 없음,
기존 쓰기 메서드 annotation 유지.
- **범위 밖:** OSIV·entity mapping·repository query·controller·response DTO 변경.
**Files:**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt`
- Modify: `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md`
- Verify: `src/test/resources/application.yml`
**Interfaces:**
- Consumes: `CreatorAdminContentSeriesService.getDetail(id: Long, memberId: Long)`과 기존 `Series.toDetailResponse(imageHost)`
- Produces: 기존 시그니처·DTO를 유지하면서 class-level read-only 트랜잭션 안에서 완성된
`GetCreatorAdminContentSeriesDetailResponse`
- [x] **RED:** `@SpringBootTest`, `EmbeddedRedisInitializer`, 실제 `CreatorAdminContentSeriesService` 빈을 사용하는 통합 테스트를 작성한다.
테스트 외부 트랜잭션은 사용하지 않고 `TransactionTemplate` 안에서 소유 회원, 장르, 시리즈, 해시태그와 `SeriesKeyword` fixture를
저장한 뒤 트랜잭션 밖에서 `service.getDetail()`을 호출해 `keywords`와 주요 상세 필드를 검증한다.
- [x] **RED 확인:** `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`
를 실행해 `Series.keywordList``LazyInitializationException`으로 실패하는지 확인한다. 환경·fixture·컴파일 실패는 RED 증거로
인정하지 않고 먼저 바로잡는다.
- [x] **GREEN:** `CreatorAdminContentSeriesService` 클래스 선언 바로 위에 기존 import를 사용하는
`@Transactional(readOnly = true)` 한 줄을 추가하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다.
- [x] **GREEN 확인:** RED와 같은 focused test를 다시 실행해 `BUILD SUCCESSFUL`과 fixture의 `keywords`, `seriesId`,
`publishedDaysOfWeek`, `state` 값 일치를 확인한다.
- [x] **REFACTOR:** 새 abstraction 없이 테스트 fixture의 중복만 파일 내부 private helper로 제한한다. focused test와
`./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
를 실행하고, production diff가 class-level annotation 한 줄이며 모든 기존 쓰기 메서드 annotation이 유지되는지 확인해 결과를
이 Task 아래에 기록한다.
### 완료 조건
- [x] `P1-T1`의 RED/GREEN/REFACTOR와 완료 증거가 모두 충족됐다.
- [x] `CASD-001`~`CASD-003`이 자동 검증 결과로 추적된다.
- [x] API 계약, OSIV, entity mapping, repository query에 변경이 없다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 상세 조회 수정의 기능·회귀·문서 범위를 최종 판정한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 아래 명령 통과 및 실제 결과를 검증 기록에 누적.
- **범위 밖:** Gate 통과를 위한 테스트 완화와 관련 없는 코드 수정.
```bash
./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest
./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest
./gradlew --no-daemon ktlintCheck
./gradlew --no-daemon tasks --all
git diff --check
./gradlew --no-daemon test
```
- [x] focused test가 실제 실행되고 failures/errors 0으로 통과한다.
- [x] 기존 시리즈 상세·소유권 characterization test가 통과한다.
- [x] `ktlintCheck`, `tasks --all`, `git diff --check`가 통과한다.
- [x] ultrawork verification 요구에 따라 전체 테스트를 실행하고 통과했다.
- [x] production code 변경이 `CreatorAdminContentSeriesService`의 class-level `@Transactional(readOnly = true)` 한 줄뿐이다.
- [x] 기존 쓰기 메서드 5개의 메서드 레벨 `@Transactional`이 유지된다.
## 실행 순서
| 순서 | Goal | 완료 후 다음 Goal |
|---:|---|---|
| 1 | `P1-T1` | `P1-GATE` |
| 2 | `P1-GATE` | 구현 완료 |
## Progress
- 2026-08-05: 운영 stack trace와 controller → service → repository → entity DTO 변환 흐름을 확인했다.
- 2026-08-05: `Series.keywordList` lazy 접근과 트랜잭션 없는 `getDetail()`을 원인으로 확정했다.
- 2026-08-05: PRD와 Plan/TASK를 작성했으며 production code와 테스트는 아직 변경하지 않았다.
- 2026-08-05: 메서드 분류 재검토 결과 트랜잭션 없는 public 메서드 4개는 모두 조회이고, 쓰기 메서드 5개는 모두 메서드 레벨
`@Transactional`을 보유함을 확인했다. 해결안을 `getDetail()` 메서드 단위에서 서비스 class-level read-only 기본값으로 정정했다.
- 2026-08-05: `P1-T1` RED/GREEN/REFACTOR를 완료했다. 다음 Goal은 `P1-GATE`이며 Gate 체크박스는 아직 미완료다.
- 2026-08-05: `P1-GATE`의 focused·characterization·정적 검사·Gradle task 확인·전체 테스트를 모두 통과해 Phase 1과 구현을 완료했다.
## 검증 기록
- 문서 작성 시점에는 구현용 RED/GREEN 테스트를 실행하지 않았다.
- 2026-08-05: 문서 변경 후 `./gradlew --no-daemon tasks --all`로 계획에 사용한 Gradle 명령이 유효한지 확인했다.
- sandbox 실행은 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근 제한으로 실패했다.
- 승인 실행은 `BUILD SUCCESSFUL in 8s`로 통과했다.
- 2026-08-05: `git diff --check`가 출력 없이 통과했고, 구현 파일과 테스트 파일은 변경하지 않았음을 확인했다.
- 2026-08-05: class-level read-only 트랜잭션 기준으로 문서를 보완한 뒤 `./gradlew --no-daemon tasks --all`을 재실행해
`BUILD SUCCESSFUL in 7s`를 확인했다.
- 2026-08-05: production 수정 전 focused test를 실행해 `BUILD FAILED in 6m 50s`
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`을 확인했다.
- 2026-08-05: `CreatorAdminContentSeriesService``@Transactional(readOnly = true)` 한 줄을 추가한 뒤 같은 focused test를
재실행해 `BUILD SUCCESSFUL in 4m 52s`를 확인했다. 테스트는 fixture의 `seriesId`, `title`, `introduction`, `coverImageUrl`,
`publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`, `state`, `writer`, `studio` 값을 검증한다.
- 2026-08-05: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
를 실행해 `BUILD SUCCESSFUL in 47s`를 확인했다.
- 2026-08-05: production diff는 `CreatorAdminContentSeriesService` 클래스 선언 위의 `@Transactional(readOnly = true)` 한 줄뿐이며,
`createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존 메서드 레벨
`@Transactional` 5개가 유지됨을 확인했다. API 계약, OSIV, entity mapping, repository query 변경은 없다.
- 2026-08-05: `P1-GATE`에서 명령을 순차 실행해 다음 결과를 확인했다.
- `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`: exit code 0, `BUILD SUCCESSFUL in 4m 44s`.
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`: exit code 0, `BUILD SUCCESSFUL in 41s`.
- `./gradlew --no-daemon ktlintCheck`: exit code 0, `BUILD SUCCESSFUL in 30s`.
- `./gradlew --no-daemon tasks --all`: exit code 0, `BUILD SUCCESSFUL in 7s`.
- `git diff --check`: exit code 0, 출력 없음.
- `./gradlew --no-daemon test`: exit code 0, `BUILD SUCCESSFUL in 6m 20s`. 기본 계획의 targeted 검증 범위를 넘어 전체 테스트를 실행한 이유는 ultrawork verification 요구사항 때문이다.
- 2026-08-05: Gate 검증 후 production diff가 class-level `@Transactional(readOnly = true)` 한 줄뿐이고, 새 테스트 파일은
`CreatorAdminContentSeriesServiceIntegrationTest.kt` 하나이며, 기존 쓰기 메서드의 `@Transactional` 5개가 유지됨을 재확인했다.

View File

@@ -0,0 +1,148 @@
# PRD: 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-05 |
| 최종 수정일 | 2026-08-05 |
| 대상 기능 | 크리에이터 관리자 시리즈 상세 조회 |
| 관련 구현 계획 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md` |
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 `GET /creator-admin/audio-content/series/{seriesId}` 호출 시
`Series.keywordList` 접근으로 발생하는 `LazyInitializationException`을 서비스 클래스의 기본 read-only 트랜잭션 경계로 방지한다.
## 2. Problem Statement
- `CreatorAdminContentSeriesController.getDetail()``CreatorAdminContentSeriesService.getDetail()`에 상세 조회를 위임한다.
- `CreatorAdminContentSeriesService.getDetail()`에는 트랜잭션이 없으며,
`CreatorAdminContentSeriesRepository.findByIdAndCreatorId()`가 반환한 `Series`로 상세 DTO를 생성한다.
- 같은 서비스의 트랜잭션 없는 public 메서드는 모두 조회 기능이고, 데이터를 변경하는 public 메서드에는 이미 메서드 레벨
`@Transactional`이 적용되어 있다.
- `Series.keywordList`는 별도 fetch 설정이 없는 `@OneToMany`이므로 lazy 컬렉션이다.
- `Series.toDetailResponse()``keywordList.map { it.keyword!!.tag }`를 실행한다.
- 운영·테스트 설정의 `spring.jpa.open-in-view=false` 때문에 리포지토리 호출 후 영속성 컨텍스트가 종료되고, DTO 변환 중
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`
예외가 발생한다.
- 기존 `LegacyCreatorAdminSeriesCharacterizationTest`는 클래스 레벨 `@Transactional`과 직접 생성한 서비스 객체를 사용하므로,
실제 Spring 서비스 프록시의 트랜잭션 유무에 따른 회귀를 검증하지 못한다.
문제를 해결했다는 판단은 외부 테스트 트랜잭션이 없는 OSIV off 통합 테스트에서 실제 Spring 서비스 프록시로 상세 조회 후
키워드가 포함된 기존 응답을 정상 생성하는 것으로 한다.
## 3. Goals
- OSIV off 환경에서도 소유한 시리즈 상세 조회가 `LazyInitializationException` 없이 완료된다.
- 서비스 클래스의 조회 기본값을 read-only 트랜잭션으로 두고, 시리즈 조회부터 `toDetailResponse()`의 lazy 컬렉션 접근까지
같은 영속성 컨텍스트에서 처리한다.
- 실제 Spring 서비스 프록시를 호출하는 통합 테스트로 수정 전 실패와 수정 후 성공을 검증한다.
- 기존 endpoint, 인증·소유권 검사, 성공 응답 필드와 값 형식을 유지한다.
## 4. Non-Goals
- `spring.jpa.open-in-view`를 활성화하지 않는다.
- `Series.keywordList`를 전역 eager fetch로 변경하지 않는다.
- 상세 조회 쿼리를 fetch join 또는 projection으로 재작성하지 않는다.
- `Series.toDetailResponse()` 또는 `GetCreatorAdminContentSeriesDetailResponse` 구조를 변경하지 않는다.
- 시리즈 목록·수정·콘텐츠 연결 등 다른 흐름을 함께 리팩터링하지 않는다.
## 5. Target Users and Permissions
- 대상 사용자: 본인이 소유한 시리즈 상세를 조회하는 `CREATOR` 역할의 크리에이터 관리자
- 인증·권한: 기존 `@PreAuthorize("hasRole('CREATOR')")`와 인증 회원 검사를 유지한다.
- 소유권: 기존 `findByIdAndCreatorId(id, creatorId)` 조건과 `creator.admin.series.invalid_access` 오류를 유지한다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `CASD-001` | 확정 | 서비스 클래스에 read-only 트랜잭션을 기본 적용하고 기존 쓰기 메서드의 메서드 레벨 트랜잭션을 유지한다. | 키워드가 있는 소유 시리즈 조회가 OSIV off 환경에서 예외 없이 완료되고 기존 쓰기 메서드 annotation이 보존된다. | `P1-T1` |
| `CASD-002` | 확정 | 기존 상세 조회 API 계약을 유지한다. | endpoint, 권한, 오류 key, 응답 DTO의 필드·형식이 바뀌지 않는다. | `P1-T1`, `P1-GATE` |
| `CASD-003` | 확정 | 테스트 외부 트랜잭션 없이 실제 서비스 프록시를 검증한다. | 수정 전 `Series.keywordList` 예외를 재현하고, 수정 후 같은 테스트가 통과한다. | `P1-T1` |
## 7. API 계약
| Method | Path | 변경 사항 |
|---|---|---|
| `GET` | `/creator-admin/audio-content/series/{seriesId}` | 공개 계약 변경 없음 |
- 성공 응답은 기존 `ApiResponse.ok(GetCreatorAdminContentSeriesDetailResponse)`를 유지한다.
- `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`,
`state`, `writer`, `studio` 필드와 기존 문자열 변환 규칙을 유지한다.
- 인증 실패와 타 소유자·미존재 시리즈 오류 처리를 변경하지 않는다.
## 8. 해결 방안
`CreatorAdminContentSeriesService` 클래스에 `@Transactional(readOnly = true)`를 기본 적용한다.
```kotlin
@Service
@Transactional(readOnly = true)
class CreatorAdminContentSeriesService(
```
`getDetail()``findByIdAndCreatorId()` 조회와 `series.toDetailResponse()` 변환이 이 경계 안에서 모두 끝나므로 `keywordList`
정상 초기화할 수 있다. 다른 조회 메서드도 같은 기본 경계를 사용하며, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`
class-level `readOnly = true`를 쓰기 트랜잭션으로 재정의한다. 이미 서비스 파일에서 `Transactional`을 사용하고 있어 새 의존성이나
import는 필요하지 않다.
### 현재 메서드 분류
| 기본 read-only 트랜잭션을 사용하는 조회 메서드 | 메서드 레벨 쓰기 트랜잭션을 유지하는 메서드 |
|---|---|
| `getSeriesList()` | `createSeries()` |
| `getDetail()` | `modifySeries()` |
| `getSeriesContent()` | `addingContentToTheSeries()` |
| `searchContentNotInSeries()` | `removeContentInTheSeries()` |
| | `updateSeriesOrders()` |
### 제외한 대안
- OSIV 활성화: 요청 전체로 영속성 컨텍스트를 확장해 현재 저장소 정책을 되돌리므로 제외한다.
- `keywordList` eager 변경: 모든 `Series` 조회 비용에 영향을 주는 전역 변경이므로 제외한다.
- fetch join/projection 추가: 이 endpoint만의 결함을 고치는 데 리포지토리 계약과 쿼리 변경이 불필요하므로 제외한다.
- 컨트롤러 트랜잭션: 영속성 및 DTO 변환 경계는 서비스가 소유하는 기존 구조에 맞지 않으므로 제외한다.
- `getDetail()`에만 read-only 트랜잭션 적용: 현재 트랜잭션 없는 메서드가 모두 조회 기능이므로 class-level 기본값보다 반복과
누락 가능성이 크다.
## 9. 기술적 제약
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, Gradle Wrapper를 사용한다.
- `src/main/resources/application.yml``src/test/resources/application.yml``spring.jpa.open-in-view=false`를 유지한다.
- 테스트는 클래스 외부 트랜잭션을 비활성화하고 fixture 생성만 `TransactionTemplate`로 분리한다.
- 실제 Spring `CreatorAdminContentSeriesService` 빈을 주입해 proxy annotation 동작을 검증한다.
- production code 변경은 `CreatorAdminContentSeriesService` class-level annotation 한 줄로 제한한다.
- `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존
메서드 레벨 `@Transactional`을 유지한다.
## 10. 성공 기준
- [ ] 수정 전 focused test가 `Series.keywordList``LazyInitializationException`으로 실패한다.
- [ ] 서비스 클래스에 `@Transactional(readOnly = true)` 적용 후 같은 테스트가 통과한다.
- [ ] 응답의 `keywords`와 주요 기존 상세 필드 값이 fixture와 일치한다.
- [ ] 기존 소유권·상세 동작 characterization test와 `ktlintCheck`가 통과한다.
- [ ] `tasks --all``git diff --check`가 통과한다.
- [ ] API 스키마, 엔티티 fetch 전략, repository query에는 변경이 없다.
- [ ] 모든 기존 쓰기 메서드의 메서드 레벨 `@Transactional`이 유지된다.
## 11. 요구사항 추적표
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|---|---:|---|---|
| `CASD-001`, `CASD-003` | 1 | `P1-T1` | `CreatorAdminContentSeriesServiceIntegrationTest` |
| `CASD-002` | 1 | `P1-T1`, `P1-GATE` | focused test, `LegacyCreatorAdminSeriesCharacterizationTest` |
## 12. Open Questions
없음. 운영 stack trace, entity mapping, 서비스 호출 경계와 OSIV 설정으로 원인과 최소 해결 범위가 확인됐다.
## 13. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-05 | `DEC-CASD-001` | 확정 | `CreatorAdminContentSeriesService.getDetail()`에 메서드 단위 read-only 트랜잭션을 적용한다. | DTO 변환이 이미 서비스 내부에 있어 한 줄로 lazy 접근 전체를 영속성 컨텍스트 안에 포함할 수 있다. | `CASD-001`, `CASD-002`, `P1-T1` |
| 2026-08-05 | `DEC-CASD-002` | 확정 | 실제 Spring 서비스 프록시와 외부 트랜잭션이 없는 통합 테스트로 회귀를 고정한다. | 기존 characterization test는 테스트 트랜잭션과 직접 생성한 서비스 때문에 annotation 회귀를 검증할 수 없다. | `CASD-003`, `P1-T1` |
| 2026-08-05 | `DEC-CASD-003` | 정정 | `DEC-CASD-001`의 메서드 단위 적용을 class-level `@Transactional(readOnly = true)` 적용으로 정정하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다. | 트랜잭션 없는 기존 public 메서드는 모두 조회 기능이며, 쓰기 메서드는 이미 메서드 레벨 annotation으로 read-only 기본값을 재정의한다. | `CASD-001`, `CASD-002`, `P1-T1` |

View File

@@ -0,0 +1,293 @@
# 크리에이터 채널 홈 커뮤니티 응답 확장 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 크리에이터 채널 홈의 `notices``communities` 게시글에 실제 고정 여부와 댓글 가능 여부를 non-null Boolean으로 제공한다.
**Architecture:** 기존 `CreatorChannelCommunityPost`가 보유한 두 값을 공유 `CreatorChannelCommunityPostResponse.from`에서 그대로 매핑한다. 조회 계층과 데이터 모델은 건드리지 않고 공개 응답 DTO와 직접 영향받는 직렬화·E2E 테스트만 수정한다.
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Jackson, JUnit 5, MockMvc, Gradle Wrapper
## 문서 상태
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-10 |
| 요구사항 기준 | `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/prd.md` |
| API 기준 | PRD `7. API 계약` |
| 현재 Phase | Phase 1 완료: 홈 커뮤니티 상태 응답 확장 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 |
| Gate | `P1-GATE` 완료 |
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `1/1` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
- goal 실행 시 token budget을 설정하지 않는다.
## 범위
### 포함
- `GET /api/v2/creator-channels/{creatorId}/home``notices[*]`, `communities[*]``isPinned` 추가
- 같은 게시글 응답에 `isCommentAvailable` 추가
- 도메인 값의 DTO 매핑과 정확한 `is*` JSON 필드명 검증
- 기존 홈 API E2E와 Kotlin 포맷 회귀 검증
### 제외
- 조회 조건, 정렬, 최대 노출 개수와 권한 정책 변경
- 커뮤니티 탭·상세·댓글 API 변경
- DB, entity, domain model, repository 수정
- 전용 DTO 분리, 신규 dependency와 관련 없는 리팩터링
## Global Constraints
- Kotlin + Java 17, Spring Boot 2.7.14와 기존 Jackson 직렬화 방식을 유지한다.
- 기존 공유 `CreatorChannelCommunityPostResponse``from` 변환만 확장한다.
- 공개 필드명은 `isPinned`, `isCommentAvailable`로 고정하고 두 값은 nullable로 만들지 않는다.
- `CreatorChannelCommunityPost.isPinned`, `CreatorChannelCommunityPost.isCommentAvailable` 값을 재계산하지 않는다.
- 구현은 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서로 진행한다.
- focused test에서 시작해 직접 영향받는 홈 E2E와 `ktlintCheck`까지만 확장한다.
- 전체 `./gradlew test`는 공통 경계 영향이나 targeted test로 판단할 수 없는 실패가 있을 때만 실행한다.
---
## Phase 1: 홈 커뮤니티 상태 응답 확장
**Phase 결과:** 홈 API의 공지와 일반 커뮤니티 게시글이 고정 여부와 댓글 가능 여부를 정확한 Boolean 필드로 반환한다.
**선행조건:** 승인된 PRD의 `CCHC-001~004`, `DEC-001~003` 확인.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료, 체크박스와 실제 검증 결과를 Progress에 누적.
### Task 1.1 공유 게시글 응답에 상태 필드 추가
**Goal 실행 `P1-T1`:** 공유 홈 게시글 응답이 도메인의 `isPinned`, `isCommentAvailable`을 정확한 JSON 필드로 직렬화하게 한다.
- **시작 조건:** PRD 상태가 `구현 기준 확정`이고 현재 활성 Goal이 없다.
- **완료 증거:** 아래 5단계 완료, controller focused test와 홈 E2E 통과, 변경 파일과 결과를 Progress에 기록.
- **범위 밖:** 조회 service/repository, domain model, 다른 커뮤니티 API 수정.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt:92`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt:57`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt:173`
- Modify: `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md`
**Interfaces:**
- Consumes: `CreatorChannelCommunityPost.isPinned: Boolean`, `CreatorChannelCommunityPost.isCommentAvailable: Boolean`
- Produces: `CreatorChannelCommunityPostResponse.isPinned: Boolean`, `CreatorChannelCommunityPostResponse.isCommentAvailable: Boolean`
- JSON: `data.notices[*].isPinned`, `data.notices[*].isCommentAvailable`, `data.communities[*].isPinned`, `data.communities[*].isCommentAvailable`
- [x] **RED:** `CreatorChannelHomeControllerTest.shouldReturnCreatorChannelHomeForAuthenticatedMember`의 일반 커뮤니티 fixture를 아래처럼 공지와 반대 상태로 만들고, 네 JSON 경로 assertion을 추가한다. 같은 assertion을 `CreatorChannelHomeEndToEndTest.shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest`에도 추가하되 E2E fixture의 댓글 가능 값은 기존 `true`를 사용한다.
```kotlin
communities = listOf(
post.copy(
postId = 302L,
content = "community",
isCommentAvailable = false,
isPinned = false
)
)
```
```kotlin
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(false))
```
E2E assertion은 실제 저장값과 조회 조건을 검증한다.
```kotlin
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(true))
```
- [x] **RED 확인:** 다음 focused test를 실행해 `$.data.notices[0].isPinned` 등 신규 JSON 경로에 값이 없어 실패하는지 확인한다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --no-daemon
```
**Expected:** 테스트가 신규 필드 미직렬화로 실패한다. compile 오류나 fixture 오류는 RED 증거로 인정하지 않는다.
- [x] **GREEN:** `CreatorChannelCommunityPostResponse`에 기존 커뮤니티 탭 DTO와 같은 `@JsonProperty` 관례로 필드를 추가하고 `from`에서 도메인 값을 직접 매핑한다.
```kotlin
data class CreatorChannelCommunityPostResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val imageUrl: String?,
val audioUrl: String?,
val content: String,
val price: Int,
val dateUtc: String,
val existOrdered: Boolean,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked")
val isLiked: Boolean
) {
companion object {
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
return CreatorChannelCommunityPostResponse(
postId = post.postId,
creatorId = post.creatorId,
creatorNickname = post.creatorNickname,
creatorProfileUrl = post.creatorProfileUrl,
imageUrl = post.imageUrl,
audioUrl = post.audioUrl,
content = post.content,
price = post.price,
dateUtc = post.createdAt.toUtcIso(),
existOrdered = post.existOrdered,
isCommentAvailable = post.isCommentAvailable,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned,
isLiked = post.isLiked
)
}
}
}
```
- [x] **GREEN 확인:** controller test와 홈 E2E를 함께 실행해 공유 DTO 매핑, JSON 이름, 실제 조회값이 모두 통과하는지 확인한다.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \
--no-daemon
```
**Expected:** 두 테스트 클래스가 `BUILD SUCCESSFUL`로 종료된다.
- [x] **REFACTOR:** 새 abstraction을 만들지 않고 이번 Task가 추가한 fixture·필드·매핑만 정리한다. 다음 명령을 실행하고 결과를 `P1-T1` Progress에 기록한다.
```bash
./gradlew ktlintCheck --no-daemon
git diff --check
```
**Expected:** 두 명령 모두 exit code `0`.
- [x] **Commit:** 커밋 전후 규칙 검증과 함께 구현·테스트·Progress 변경만 커밋한다.
```bash
work/scripts/check-commit-message-rules.sh --message "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"
git add \
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt \
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt \
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt \
docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md
git commit -m "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"
work/scripts/check-commit-message-rules.sh HEAD
```
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** PRD의 네 확정 요구사항과 작은 변경 범위를 최종 판정한다.
- **시작 조건:** `P1-T1`의 체크박스, focused test, E2E, 포맷과 Progress 기록 완료.
- **완료 증거:** 아래 자동 검증 통과, PRD 추적성 확인, Gate 결과를 Progress에 기록.
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \
--no-daemon
./gradlew ktlintCheck --no-daemon
./gradlew tasks --all --no-daemon
git diff --check
```
**Expected:** 모든 명령이 exit code `0`이고 다음 조건이 충족된다.
- [x] `notices[*]``communities[*]`에 두 non-null Boolean이 존재한다. (`CCHC-001~003`)
- [x] `notices[*].isPinned == true`, `communities[*].isPinned == false`가 실제 조회 조건과 일치한다.
- [x] 댓글 가능 값이 `true``false` 모두 그대로 직렬화된다.
- [x] endpoint, 인증, 기존 홈 응답 필드와 조회 결과가 유지된다. (`CCHC-004`)
- [x] 전체 회귀 테스트를 생략한 근거와 대신 실행한 focused·E2E 검증을 Progress에 기록한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 승인된 PRD | 아니요 | RED 실패 원인이 신규 필드 누락인지 다시 확인 |
| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패를 소유한 Task에 회귀 수정 goal 추가 |
```text
P1-T1 → P1-GATE
```
## 변경 금지 항목
- 공개 필드명이나 기존 홈 응답 필드를 근거 없이 변경하지 않는다.
- domain, service, repository에서 두 Boolean을 재계산하지 않는다.
- 전용 DTO, helper, 신규 dependency를 추가하지 않는다.
- 테스트를 삭제·skip·완화하거나 관련 없는 코드를 정리하지 않는다.
- 기존 완료 기록과 Decision Log를 삭제하거나 덮어쓰지 않는다.
## 의사결정 및 중단 규칙
- 구현 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
- 신규 JSON 필드 외 실패가 발생하면 원인을 분리하고 범위 밖 변경을 임의로 적용하지 않는다.
- targeted test로 영향 범위를 판단할 수 없을 때만 전체 `./gradlew test --no-daemon`을 실행한다.
- 체크박스와 완료 증거, Progress 기록이 모두 충족된 뒤에만 goal을 완료 처리한다.
## Progress
기존 기록을 삭제하지 않고 Goal별 실행 결과를 아래에 누적한다.
### P1-T1
- RED 작성: controller fixture의 일반 게시글을 `postId=302L`, `content="community"`, `isCommentAvailable=false`, `isPinned=false`로 구성하고 controller 및 E2E에 공지/일반 게시글 상태 assertion을 추가했다.
- RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --no-daemon`은 exit code `1`로 실패했다. `CreatorChannelHomeControllerTest.kt:138``$.data.notices[0].isPinned` assertion에서 `PathNotFoundException`이 발생해 신규 JSON 필드 미직렬화가 원인임을 확인했으며 compile 및 fixture 오류는 없었다.
- GREEN 구현: `CreatorChannelCommunityPostResponse`에 non-null Boolean `isCommentAvailable`, `isPinned`을 명시적 `@JsonProperty`와 함께 추가하고 `post.isCommentAvailable`, `post.isPinned`을 직접 매핑했다.
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 54s`로 종료됐다. controller focused test와 직접 영향받는 홈 E2E가 함께 통과해 공지 `true/true`, 일반 게시글 `false/false` controller fixture 및 `false/true` 실제 E2E 저장값의 매핑과 JSON 이름을 확인했다.
- REFACTOR 확인: 새 abstraction 없이 계획된 fixture, 응답 필드, 직접 매핑만 유지했다. `./gradlew ktlintCheck --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 25s`였고 `git diff --check`도 exit code `0`이었다.
- 전체 회귀 생략: 이번 변경은 공유 홈 DTO의 additive 직렬화 필드 두 개와 직접 영향받는 테스트에 한정되고 controller focused test와 실제 저장·조회 E2E가 모두 통과했으므로 계획의 조건에 따라 전체 `./gradlew test`는 실행하지 않았다.
- 변경 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`, `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md`.
- 커밋 검증: 커밋 전 `work/scripts/check-commit-message-rules.sh --message "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"`가 모든 규칙을 `[PASS]`로 확인했다. 최종 diff는 계획된 네 파일만 포함하고 비밀값, 관련 없는 변경, agent footer가 없음을 자체 검토했다. 커밋 후 `work/scripts/check-commit-message-rules.sh HEAD`를 커밋 `d99104d945743663803b506417b4d2ad3ac4b0a2`에 실행해 모든 규칙이 `[PASS]`임을 확인했다.
### P1-GATE
- 자동 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --no-daemon`은 최초 실행에서 exit code `0`, `BUILD SUCCESSFUL in 40s`였고 테스트 산출물이 `UP-TO-DATE`였다. 실제 HTTP 경계를 새로 실행하기 위해 `./gradlew cleanTest --no-daemon`을 exit code `0`, `BUILD SUCCESSFUL in 16s`로 수행한 뒤 같은 Gate 테스트 명령을 다시 실행해 `:test` 실행과 exit code `0`, `BUILD SUCCESSFUL in 48s`를 확인했다.
- 정적·빌드 검증: `./gradlew ktlintCheck --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 1m 22s`, `./gradlew tasks --all --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 22s`, `git diff --check`는 출력 없이 exit code `0`이었다.
- 요구사항 추적: `CCHC-001~003``notices[*]`, `communities[*]`의 non-null Boolean `isPinned`, `isCommentAvailable`과 도메인 값 직접 매핑으로 확인했다. 실제 저장·조회 E2E에서 공지는 `isPinned=true`, 일반 게시글은 `isPinned=false`였고 controller fixture와 E2E를 합쳐 `isCommentAvailable=true/false`가 모두 그대로 직렬화됨을 확인했다. `CCHC-004`는 기존 `GET /api/v2/creator-channels/{creatorId}/home`, 비회원 `401`, 인증 회원 `200`, 기존 홈 섹션·필드·조회 결과 assertion의 유지와 focused·E2E 통과로 확인했다.
- 실제 HTTP 표면: `CreatorChannelHomeEndToEndTest.shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest`가 인증된 MockMvc `GET`을 controller에서 service, repository, H2 database 조회까지 통과시켜 실제 API 응답의 공지와 일반 게시글 값을 검증했다.
- 전체 회귀 생략: 변경은 공유 홈 응답 DTO의 additive Boolean 두 필드와 직접 직렬화 경계에 한정되고 focused controller test와 실제 저장·조회 MockMvc E2E로 영향을 판정할 수 있었으며 모호한 실패가 없었다. 계획의 실행 조건에 따라 전체 `./gradlew test`는 실행하지 않았다.
- 변경 문서 상태: 구현·테스트는 커밋 `d99104d945743663803b506417b4d2ad3ac4b0a2`에 있고, Gate에서는 기존 `P1-T1` 기록과 이미 존재한 커밋 후 검증 문장을 보존한 채 `plan-task.md``prd.md`의 완료 상태만 변경했다. Kotlin 코드, 테스트, Decision Log는 변경하지 않았다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-10 | `PLAN-DEC-001` | 확정 | 하나의 구현 Task와 하나의 Gate로 실행한다. | 공유 DTO 한 곳의 additive 변경이며 별도 구현 경계가 없다. | `P1-T1`, `P1-GATE` |
| 2026-08-10 | `PLAN-DEC-002` | 확정 | 전체 회귀는 조건부로 두고 controller test, 홈 E2E, `ktlintCheck`를 기본 검증으로 사용한다. | 변경이 홈 게시글 응답 직렬화 경계에 한정된다. | `P1-T1`, `P1-GATE` |
## 발견된 문제
없음.

View File

@@ -0,0 +1,144 @@
# PRD: 크리에이터 채널 홈 커뮤니티 응답 확장
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-10 |
| 최종 수정일 | 2026-08-10 |
| 대상 제품 | 크리에이터 채널 홈 API |
| 작성자·결정권자 | 사용자 |
| 관련 API Contract | 별도 문서 없음, 이 문서의 `7. API 계약`을 기준으로 함 |
| 관련 구현 계획 | `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md` |
| 관련 review | 없음 |
## 1. Overview
크리에이터 채널 홈 API의 `notices``communities` 게시글 응답에 고정 여부와 댓글 가능 여부를 각각 `isPinned`, `isCommentAvailable`로 제공한다. 기존 조회 결과가 이미 보유한 값을 응답에 그대로 반영해 클라이언트가 별도 추론 없이 게시글 상태를 표시할 수 있게 한다.
## 2. Problem Statement
- 현재 `GET /api/v2/creator-channels/{creatorId}/home`의 게시글 도메인 모델에는 고정 여부와 댓글 가능 여부가 있지만 홈 응답 DTO에는 두 값이 없다.
- 클라이언트는 홈의 게시글이 고정 글인지, 댓글 작성이 가능한지 응답만으로 일관되게 판단할 수 없다.
- `notices``communities`가 같은 `CreatorChannelCommunityPostResponse`를 사용하므로 두 배열의 계약을 함께 확장해야 한다.
문제를 해결했다는 판단은 두 배열의 각 게시글에 실제 도메인 값과 일치하는 non-null Boolean 필드가 직렬화되는지로 한다.
## 3. Goals
- 홈 응답의 `notices[*]``communities[*]``isPinned`를 제공한다.
- 홈 응답의 `notices[*]``communities[*]``isCommentAvailable`을 제공한다.
- 기존 커뮤니티 조회 결과의 값을 변형하거나 재계산하지 않고 그대로 사용한다.
- 기존 홈 API의 endpoint, 인증 정책과 기존 응답 필드를 유지한다.
## 4. Non-Goals
- 커뮤니티 조회 조건, 고정 정렬, 최대 노출 개수는 변경하지 않는다.
- 댓글 작성·수정·삭제 정책과 API는 변경하지 않는다.
- 커뮤니티 탭 및 게시글 상세 API 계약은 변경하지 않는다.
- DB schema, entity, domain model과 repository query는 변경하지 않는다.
- `notices``communities` 전용 응답 DTO를 새로 분리하지 않는다.
- 관련 없는 홈 응답 필드나 패키지 구조는 리팩터링하지 않는다.
## 5. Target Users and Permissions
- 대상 사용자: 인증 후 크리에이터 채널 홈을 조회하는 앱 사용자
- 인증 및 권한: 기존 홈 API 정책을 그대로 사용한다.
- 차단, 성인 콘텐츠와 구매 여부 정책: 기존 홈 API 조회 결과를 그대로 사용한다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `CCHC-001` | 확정 | `notices[*]``communities[*]``isPinned`를 제공한다. | JSON 필드명이 정확히 `isPinned`이고 값은 `CreatorChannelCommunityPost.isPinned`와 일치한다. | `7. API 계약`, `P1-T1` |
| `CCHC-002` | 확정 | `notices[*]``communities[*]``isCommentAvailable`을 제공한다. | JSON 필드명이 정확히 `isCommentAvailable`이고 값은 `CreatorChannelCommunityPost.isCommentAvailable`과 일치한다. | `7. API 계약`, `P1-T1` |
| `CCHC-003` | 확정 | 두 필드는 non-null Boolean으로 응답한다. | 게시글이 존재하면 두 필드가 누락되거나 `null`이 되지 않는다. | `P1-T1`, `P1-GATE` |
| `CCHC-004` | 확정 | 기존 홈 API 계약과 조회 정책을 보존한다. | endpoint, 인증 정책, 기존 응답 필드와 게시글 선택·정렬 결과가 변경되지 않는다. | `P1-GATE` |
### 상태별 기대값
| 배열 | `isPinned` | `isCommentAvailable` |
|---|---:|---|
| `notices` | 해당 게시글의 실제 값. 현재 홈 조회 조건상 `true` | 해당 게시글의 댓글 허용 설정값 |
| `communities` | 해당 게시글의 실제 값. 현재 홈 조회 조건상 `false` | 해당 게시글의 댓글 허용 설정값 |
- 댓글 수가 `0`이어도 `isCommentAvailable`을 별도로 계산하지 않는다.
-`notices` 또는 `communities`는 기존처럼 빈 배열로 응답한다.
## 7. API 계약
### 7.1 Endpoint
- Method: `GET`
- Path: `/api/v2/creator-channels/{creatorId}/home`
- 요청과 인증 정책: 변경 없음
### 7.2 응답 확장
`data.notices[*]``data.communities[*]`에 다음 필드를 추가한다.
| 필드 | 타입 | nullable | 의미 |
|---|---|---:|---|
| `isPinned` | Boolean | 아니요 | 해당 커뮤니티 게시글의 고정 여부 |
| `isCommentAvailable` | Boolean | 아니요 | 해당 커뮤니티 게시글의 댓글 작성 허용 여부 |
예시:
```json
{
"data": {
"notices": [
{
"postId": 301,
"isPinned": true,
"isCommentAvailable": true
}
],
"communities": [
{
"postId": 302,
"isPinned": false,
"isCommentAvailable": false
}
]
}
}
```
예시는 추가 필드와 배열별 의미만 나타내며 기존 게시글 응답 필드는 그대로 유지한다.
## 8. 기술적 제약
- Kotlin + Java 17, Spring Boot 2.7.14와 기존 Jackson 직렬화 방식을 유지한다.
- 기존 공유 `CreatorChannelCommunityPostResponse``from` 변환을 재사용한다.
- `is*` JSON 이름이 변경되지 않도록 기존 Boolean 응답 필드의 `@JsonProperty` 관례를 따른다.
- 신규 dependency와 별도 abstraction을 추가하지 않는다.
- 구현은 응답 DTO와 직접 영향받는 테스트로 제한한다.
## 9. 성공 기준
- [x] `notices[*].isPinned``notices[*].isCommentAvailable`이 실제 값으로 응답된다. (`CCHC-001~003`)
- [x] `communities[*].isPinned``communities[*].isCommentAvailable`이 실제 값으로 응답된다. (`CCHC-001~003`)
- [x] 기존 홈 API 응답 및 조회 흐름 회귀 테스트가 통과한다. (`CCHC-004`)
- [x] `ktlintCheck`가 통과한다.
- [x] 전체 회귀 테스트를 생략하면 작은 응답 DTO 변경이라는 근거와 대신 실행한 focused·영향 범위 테스트를 검증 기록에 남긴다.
## 10. Open Questions
없음.
## 11. 요구사항 추적표
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---:|---|---|---|
| `CCHC-001~003` | 1 | `P1-T1` | `CreatorChannelHomeControllerTest` | 홈 응답 JSON 필드명과 Boolean 값 확인 |
| `CCHC-004` | 1 | `P1-GATE` | `CreatorChannelHomeEndToEndTest`, `ktlintCheck` | 기존 필드 유지 여부 확인 |
## 12. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-10 | `DEC-001` | 확정 | `isPinned`, `isCommentAvailable``notices``communities` 모두에 추가한다. | 두 배열이 같은 게시글 응답 DTO를 사용하며 사용자가 인터뷰 선택지 A를 승인했다. | `CCHC-001~004`, `P1-T1`, `P1-GATE` |
| 2026-08-10 | `DEC-002` | 확정 | 공유 응답 DTO를 확장하고 전용 DTO는 분리하지 않는다. | 기존 도메인 값과 변환 경로를 재사용하는 최소 변경이다. | `CCHC-001~004`, `P1-T1` |
| 2026-08-10 | `DEC-003` | 확정 | PRD를 구현 기준으로 승인한다. | 사용자가 작성된 PRD를 검토하고 승인했다. | 문서 전체, `plan-task.md` |

View File

@@ -391,7 +391,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory
.select(
QGetCalculateByCreatorQueryData(
member.email,
member.id,
member.nickname,
useCan.can.add(useCan.rewardCan).sum(),
creatorSettlementRatio.liveSettlementRatio
@@ -447,7 +447,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory
.select(
QGetCalculateByCreatorQueryData(
member.email,
member.id,
member.nickname,
order.can.sum(),
creatorSettlementRatio.contentSettlementRatio
@@ -504,7 +504,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory
.select(
QGetCalculateByCreatorQueryData(
member.email,
member.id,
member.nickname,
useCan.can.add(useCan.rewardCan).sum(),
creatorSettlementRatio.communitySettlementRatio

View File

@@ -474,7 +474,7 @@ class AdminCalculateService(
return createExcelStream(
sheetName = sheetName,
headers = listOf(
"이메일",
"크리에이터 번호",
"닉네임",
"총 캔",
"원화",
@@ -486,7 +486,7 @@ class AdminCalculateService(
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.email)
row.createCell(0).setCellValue(item.creatorId.toDouble())
row.createCell(1).setCellValue(item.nickname)
row.createCell(2).setCellValue(item.totalCan.toDouble())
row.createCell(3).setCellValue(item.totalKrw.toDouble())

View File

@@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.admin.calculate
import com.fasterxml.jackson.annotation.JsonProperty
data class GetCalculateByCreatorItem(
@JsonProperty("email") val email: String,
@JsonProperty("creatorId") val creatorId: Long,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("totalCan") val totalCan: Int,
@JsonProperty("totalKrw") val totalKrw: Int,

View File

@@ -5,7 +5,7 @@ import java.math.BigDecimal
import java.math.RoundingMode
data class GetCalculateByCreatorQueryData @QueryProjection constructor(
val email: String,
val creatorId: Long,
val nickname: String,
val totalCan: Int,
val settlementRatio: Int?
@@ -31,7 +31,7 @@ data class GetCalculateByCreatorQueryData @QueryProjection constructor(
val depositAmount = settlementAmount.subtract(tax)
return GetCalculateByCreatorItem(
email = email,
creatorId = creatorId,
nickname = nickname,
totalCan = totalCan,
totalKrw = totalKrw.toInt(),

View File

@@ -967,7 +967,7 @@ class AudioContentService(
previousContent = previousContent,
nextContent = nextContent,
buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable,
isAvailableUsePoint = audioContent.isPointAvailable && audioContent.price > 0,
translated = translated
)
}

View File

@@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
@Transactional(readOnly = true)
class CreatorAdminContentSeriesService(
private val repository: CreatorAdminContentSeriesRepository,
private val genreRepository: CreatorAdminContentSeriesGenreRepository,

View File

@@ -60,7 +60,7 @@ data class MainContentAudioResponse(
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname

View File

@@ -46,7 +46,7 @@ data class ContentOverviewItemResponse(
title = audio.title,
coverImage = audio.imageUrl,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = audio.isFirstContent,
@@ -65,7 +65,7 @@ data class ContentOverviewItemResponse(
title = audio.title,
coverImage = coverImage,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
creatorNickname = audio.creatorNickname,
isAdult = isAdult,
isFirstContent = true,

View File

@@ -69,7 +69,7 @@ data class AudioCardResponse(
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname

View File

@@ -32,7 +32,7 @@ data class CreatorChannelAudioContentResponse(
imageUrl = audioContent.imageUrl,
price = audioContent.price,
isAdult = audioContent.isAdult,
isPointAvailable = audioContent.isPointAvailable,
isPointAvailable = audioContent.isPointAvailable && audioContent.price > 0,
isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries,

View File

@@ -181,8 +181,12 @@ data class CreatorChannelCommunityPostResponse(
val price: Int,
val dateUtc: String,
val existOrdered: Boolean,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked")
val isLiked: Boolean
) {
@@ -199,8 +203,10 @@ data class CreatorChannelCommunityPostResponse(
price = post.price,
dateUtc = post.createdAt.toUtcIso(),
existOrdered = post.existOrdered,
isCommentAvailable = post.isCommentAvailable,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned,
isLiked = post.isLiked
)
}

View File

@@ -53,7 +53,11 @@ class HomeRecommendationFacade(
memberId = member?.id,
includeAdultLives = includeAdult
).map { it.toItem() },
banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id)
banners = queryService.findHomeBanners(
limit = HOME_BANNER_LIMIT,
memberId = member?.id,
includeAdultBanners = includeAdult
)
.map { RecommendationBannerResponse.from(it.toBanner()) },
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
HOME_ACTIVE_CREATOR_LIMIT,
@@ -267,7 +271,7 @@ class HomeRecommendationFacade(
title = title,
price = price,
coverImage = imageUrl(cloudFrontHost, coverImage),
isPointAvailable = isPointAvailable
isPointAvailable = isPointAvailable && price > 0
)
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(

View File

@@ -406,7 +406,7 @@ class DefaultMainContentAllQueryRepository(
}
private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? {
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0)) else null
}
private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? {

View File

@@ -63,7 +63,8 @@ class DefaultAudioRecommendationQueryRepository(
.leftJoin(series.member, seriesOwner)
.where(
audioContentBanner.isActive.isTrue,
audioContentBanner.tab.isNull,
audioContentBanner.tab.id.eq(2L),
adultBannerCondition(canViewAdultContent),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
)
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
@@ -135,7 +136,12 @@ class DefaultAudioRecommendationQueryRepository(
now: LocalDateTime
): List<AudioCard> {
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) {
val rows = audioRows(
memberId,
canViewAdultContent,
now,
audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0))
) {
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
}
return rows.toAudioCards(now, canViewAdultContent)
@@ -539,6 +545,10 @@ class DefaultAudioRecommendationQueryRepository(
return if (canViewAdultContent) null else series.isAdult.isFalse
}
private fun adultBannerCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContentBanner.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}

View File

@@ -87,7 +87,8 @@ class DefaultHomeRecommendationQueryRepository(
override fun findHomeBanners(
limit: Int,
memberId: Long?
memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> {
val bannerCreator = QMember("bannerCreator")
val seriesOwner = QMember("seriesOwner")
@@ -115,6 +116,7 @@ class DefaultHomeRecommendationQueryRepository(
.where(
audioContentBanner.isActive.isTrue,
audioContentBanner.tab.isNull,
includeAdultBannerCondition(includeAdultBanners),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
)
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
@@ -154,6 +156,7 @@ class DefaultHomeRecommendationQueryRepository(
and lr.channel_name <> ''
and (:includeAdultActivities = true or lr.is_adult = false)
and m.is_active = true
and m.role = 'CREATOR'
union all
select m.id as creator_id,
m.nickname as creator_nickname,
@@ -169,6 +172,7 @@ class DefaultHomeRecommendationQueryRepository(
and ac.release_date is not null
and (:includeAdultActivities = true or ac.is_adult = false)
and m.is_active = true
and m.role = 'CREATOR'
union all
select m.id as creator_id,
m.nickname as creator_nickname,
@@ -182,6 +186,7 @@ class DefaultHomeRecommendationQueryRepository(
where cc.is_active = true
and (:includeAdultActivities = true or cc.is_adult = false)
and m.is_active = true
and m.role = 'CREATOR'
) activities
) ranked
where ranked.creator_rank = 1
@@ -1262,6 +1267,10 @@ class DefaultHomeRecommendationQueryRepository(
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse
}
private fun includeAdultBannerCondition(includeAdultBanners: Boolean): BooleanExpression? {
return if (includeAdultBanners) null else audioContentBanner.isAdult.isFalse
}
private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? {
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
}

View File

@@ -43,9 +43,10 @@ class HomeRecommendationQueryService(
fun findHomeBanners(
limit: Int = DEFAULT_BANNER_LIMIT,
memberId: Long? = null
memberId: Long? = null,
includeAdultBanners: Boolean = false
): List<HomeBannerRecommendationRecord> {
return queryPort.findHomeBanners(limit, memberId)
return queryPort.findHomeBanners(limit, memberId, includeAdultBanners)
}
fun findRecentlyActiveCreators(

View File

@@ -13,7 +13,8 @@ interface HomeRecommendationQueryPort {
fun findHomeBanners(
limit: Int,
memberId: Long? = null
memberId: Long? = null,
includeAdultBanners: Boolean = false
): List<HomeBannerRecommendationRecord>
fun findRecentlyActiveCreators(

View File

@@ -0,0 +1,169 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
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.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"
]
)
@Import(QueryDslConfig::class)
class AdminCalculateQueryRepositoryTest @Autowired constructor(
private val queryFactory: JPAQueryFactory,
private val memberRepository: MemberRepository,
private val liveRoomRepository: LiveRoomRepository,
private val audioContentRepository: AudioContentRepository,
private val orderRepository: OrderRepository,
private val creatorCommunityRepository: CreatorCommunityRepository,
private val useCanRepository: UseCanRepository,
private val entityManager: EntityManager
) {
private lateinit var repository: AdminCalculateQueryRepository
@BeforeEach
fun setup() {
repository = AdminCalculateQueryRepository(queryFactory)
}
@Test
@DisplayName("이메일이 없는 크리에이터도 크리에이터별 정산에서 번호로 조회한다")
fun shouldReturnCreatorIdForByCreatorRowsWhenCreatorEmailIsNull() {
// given
val creator = saveMember(email = null, nickname = "creator", role = MemberRole.CREATOR)
val buyer = saveMember(email = "buyer@test.com", nickname = "buyer", role = MemberRole.USER)
val createdAt = LocalDateTime.of(2026, 8, 4, 12, 0, 0)
val room = saveLiveRoom(creator)
saveUseCan(buyer, CanUsage.LIVE, 10, createdAt) { this.room = room }
val content = saveAudioContent(creator)
saveOrder(buyer, creator, content, createdAt)
val post = saveCommunityPost(creator)
saveUseCan(buyer, CanUsage.PAID_COMMUNITY_POST, 30, createdAt) { communityPost = post }
entityManager.flush()
entityManager.clear()
val startDate = LocalDateTime.of(2026, 8, 4, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 8, 4, 23, 59, 59)
// when
val liveRows = repository.getCalculateLiveByCreator(startDate, endDate, offset = 0, limit = 10)
val contentRows = repository.getCalculateContentByCreator(startDate, endDate, offset = 0, limit = 10)
val communityRows = repository.getCalculateCommunityByCreator(startDate, endDate, offset = 0, limit = 10)
// then
assertEquals(listOf(creator.id), liveRows.map { it.creatorId })
assertEquals(listOf(creator.id), contentRows.map { it.creatorId })
assertEquals(listOf(creator.id), communityRows.map { it.creatorId })
}
private fun saveMember(email: String?, nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = email,
password = "password",
nickname = nickname,
role = role
)
)
}
private fun saveLiveRoom(creator: Member): LiveRoom {
val room = LiveRoom(
title = "live-room",
notice = "notice",
beginDateTime = LocalDateTime.of(2026, 8, 4, 8, 0, 0),
numberOfPeople = 1,
isAdult = false,
price = 10
)
room.member = creator
return liveRoomRepository.saveAndFlush(room)
}
private fun saveAudioContent(creator: Member): AudioContent {
val theme = AudioContentTheme(theme = "theme", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content",
detail = "detail",
languageCode = "ko",
price = 20
)
content.theme = theme
content.member = creator
content.isActive = true
return audioContentRepository.saveAndFlush(content)
}
private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime) {
val order = Order(type = OrderType.KEEP)
order.member = buyer
order.creator = creator
order.audioContent = content
val saved = orderRepository.saveAndFlush(order)
updateCreatedAt("Order", saved.id!!, createdAt)
}
private fun saveCommunityPost(creator: Member): CreatorCommunity {
val post = CreatorCommunity(
content = "community-content",
price = 30,
isCommentAvailable = true,
isAdult = false
)
post.member = creator
return creatorCommunityRepository.saveAndFlush(post)
}
private fun saveUseCan(
member: Member,
usage: CanUsage,
can: Int,
createdAt: LocalDateTime,
configure: UseCan.() -> Unit
) {
val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0)
useCan.member = member
useCan.configure()
val saved = useCanRepository.saveAndFlush(useCan)
updateCreatedAt("UseCan", saved.id!!, createdAt)
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
}

View File

@@ -9,11 +9,13 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.member.Member
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
@@ -24,6 +26,8 @@ import org.mockito.Mockito.atLeastOnce
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.Optional
class AdminCalculateServiceTest {
@@ -45,6 +49,35 @@ class AdminCalculateServiceTest {
langContext
)
@Test
@DisplayName("크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 출력한다")
fun shouldWriteCreatorIdInsteadOfEmailToCreatorSettlementExcel() {
// given
val startDate = "2026-08-04".convertLocalDateTime()
val endDate = "2026-08-04".convertLocalDateTime(hour = 23, minute = 59, second = 59)
val queryData = GetCalculateByCreatorQueryData(
creatorId = 42L,
nickname = "creator",
totalCan = 100,
settlementRatio = 70
)
`when`(repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)).thenReturn(1)
`when`(repository.getCalculateLiveByCreator(startDate, endDate, 0L, 1L)).thenReturn(listOf(queryData))
// when
val output = ByteArrayOutputStream()
adminCalculateService.downloadCalculateLiveByCreatorExcel("2026-08-04", "2026-08-04").writeTo(output)
// then
XSSFWorkbook(ByteArrayInputStream(output.toByteArray())).use { workbook ->
val sheet = workbook.getSheetAt(0)
assertEquals("크리에이터 번호", sheet.getRow(0).getCell(0).stringCellValue)
assertEquals(42.0, sheet.getRow(1).getCell(0).numericCellValue)
assertEquals("닉네임", sheet.getRow(0).getCell(1).stringCellValue)
}
}
@Test
@DisplayName("라이브 환불 성공 테스트")
fun shouldRefundLiveSuccessfully() {

View File

@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
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
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
@@ -282,6 +283,50 @@ class AudioContentServiceTest {
assertTrue(output.out.contains("contentId=${audioContent.id}"))
}
@Test
@DisplayName("콘텐츠 상세는 가격과 저장값을 함께 반영해 포인트 사용 가능 여부를 반환한다")
fun shouldReturnEffectivePointAvailabilityForAudioDetail() {
val viewer = createMember(id = 1004L, nickname = "free-detail-viewer")
val creator = createMember(id = 2004L, nickname = "free-detail-creator")
val audioContent = createAudioContent(creator)
audioContent.price = 0
audioContent.isPointAvailable = true
stubSuccessfulDetailDependencies(viewer, creator, audioContent)
val response = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertFalse(response.isAvailableUsePoint)
audioContent.price = 100
audioContent.isPointAvailable = false
val paidUnavailableResponse = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertFalse(paidUnavailableResponse.isAvailableUsePoint)
audioContent.price = 100
audioContent.isPointAvailable = true
val paidAvailableResponse = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertTrue(paidAvailableResponse.isAvailableUsePoint)
}
@Test
@DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다")
fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() {

View File

@@ -0,0 +1,96 @@
package kr.co.vividnext.sodalive.creator.admin.content.series
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.hashtag.HashTag
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
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.test.context.ContextConfiguration
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorAdminContentSeriesServiceIntegrationTest @Autowired constructor(
private val service: CreatorAdminContentSeriesService,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("OSIV off 환경에서 크리에이터 관리자 시리즈 상세는 키워드와 기존 필드를 반환한다")
fun shouldReturnOwnedSeriesDetailWithKeywordsWhenOpenInViewIsDisabled() {
val fixtureIds = createSeriesFixture()
val response = service.getDetail(id = fixtureIds.seriesId, memberId = fixtureIds.memberId)
assertEquals(fixtureIds.seriesId, response.seriesId)
assertEquals("lazy series", response.title)
assertEquals("lazy series introduction", response.introduction)
assertEquals("https://cdn.test/series/lazy-series.png", response.coverImageUrl)
assertEquals("월, 수", response.publishedDaysOfWeek)
assertEquals("lazy genre", response.genre)
assertEquals("#lazy-keyword", response.keywords)
assertEquals(true, response.isAdult)
assertEquals("완결", response.state)
assertEquals("lazy writer", response.writer)
assertEquals("lazy studio", response.studio)
}
private fun createSeriesFixture(): FixtureIds {
return transactionTemplate.execute {
val member = Member(
email = "creator-admin-series-detail@test.com",
password = "password",
nickname = "creator-admin-series-detail",
role = MemberRole.CREATOR
)
entityManager.persist(member)
val genre = SeriesGenre(genre = "lazy genre")
entityManager.persist(genre)
val hashTag = HashTag("#lazy-keyword")
entityManager.persist(hashTag)
val series = Series(
title = "lazy series",
introduction = "lazy series introduction",
state = SeriesState.COMPLETE,
writer = "lazy writer",
studio = "lazy studio",
publishedDaysOfWeek = mutableSetOf(
SeriesPublishedDaysOfWeek.MON,
SeriesPublishedDaysOfWeek.WED
),
isAdult = true
).apply {
this.member = member
this.genre = genre
coverImage = "series/lazy-series.png"
}
series.keywordList.add(
SeriesKeyword().apply {
this.series = series
keyword = hashTag
}
)
entityManager.persist(series)
entityManager.flush()
val fixtureIds = FixtureIds(memberId = member.id!!, seriesId = series.id!!)
entityManager.clear()
fixtureIds
}!!
}
private data class FixtureIds(
val memberId: Long,
val seriesId: Long
)
}

View File

@@ -339,7 +339,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "target night walk",
contentPath = "private/target-night-walk.mp3"
contentPath = "private/target-night-walk.mp3",
price = 0
)
val otherCharacter = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-other-character",
@@ -374,6 +375,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id))
.andExpect(jsonPath("$.data.items[0].title").value("target night walk"))
.andExpect(jsonPath("$.data.items[0].isPointAvailable").value(true))
.andExpect(
jsonPath("$.data.items[0].contentUrl").value(
"https://signed.example.com/private/target-night-walk.mp3?Expires=1"
@@ -495,7 +497,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "detail night walk",
contentPath = "private/detail-night-walk.mp3"
contentPath = "private/detail-night-walk.mp3",
price = 0
)
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
@@ -899,7 +902,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
owner: Member,
title: String,
contentPath: String,
releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime()
releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime(),
price: Int = 100
): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
entityManager.persist(theme)
@@ -907,7 +911,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
title = title,
detail = "detail",
languageCode = "ko",
price = 100,
price = price,
isAdult = false,
isPointAvailable = true,
isCommentAvailable = true

View File

@@ -94,6 +94,27 @@ class MainContentAllEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.audios.length()").value(1))
}
@Test
@DisplayName("전체 탭 POINT API는 무료 포인트 콘텐츠를 목록과 페이징 후보에서 제외한다")
fun shouldExcludeFreePointAudioContentsForPointType() {
val fixture = createAudioFixture("main-all-point-paid-e2e", freeControl = true)
mockMvc.perform(
get("/api/v2/audio/contents")
.param("type", "POINT")
.param("sort", "LATEST")
.param("page", "0")
.param("size", "1")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.type").value("POINT"))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.audios.length()").value(1))
.andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.audios[0].price").value(100))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다")
fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() {

View File

@@ -67,6 +67,28 @@ class MainContentAllTabResponseTest {
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator"
),
MainContentAllAudio(
audioContentId = 2L,
title = "free audio",
imageUrl = "https://cdn/free-audio.jpg",
price = 0,
isAdult = false,
isPointAvailable = true,
isFirstContent = false,
isOriginalSeries = false,
creatorNickname = "creator"
),
MainContentAllAudio(
audioContentId = 3L,
title = "point unavailable audio",
imageUrl = "https://cdn/point-unavailable-audio.jpg",
price = 100,
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
isOriginalSeries = false,
creatorNickname = "creator"
)
),
series = emptyList(),
@@ -83,6 +105,8 @@ class MainContentAllTabResponseTest {
assertEquals(true, json["audios"][0]["isPointAvailable"].asBoolean())
assertEquals(true, json["audios"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["audios"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["audios"][1]["isPointAvailable"].asBoolean())
assertEquals(false, json["audios"][2]["isPointAvailable"].asBoolean())
assertEquals(true, json["hasNext"].asBoolean())
assertFalse(json["audios"][0].has("duration"))
assertFalse(json["series"].any { it.has("publishedDaysOfWeek") })

View File

@@ -1,8 +1,11 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
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
@@ -77,4 +80,71 @@ class ContentOverviewPageResponseTest {
assertEquals(true, response.isAdult)
assertEquals(true, response.isOriginalSeries)
}
@Test
@DisplayName("NEW_AND_HOT 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeNewAndHotAudio() {
val freeAudio = audioCard(price = 0, isPointAvailable = true)
val paidAudio = audioCard(price = 100, isPointAvailable = true)
val paidUnavailableAudio = audioCard(price = 100, isPointAvailable = false)
assertFalse(ContentOverviewItemResponse.fromNewAndHot(freeAudio).isPointAvailable)
assertTrue(ContentOverviewItemResponse.fromNewAndHot(paidAudio).isPointAvailable)
assertFalse(ContentOverviewItemResponse.fromNewAndHot(paidUnavailableAudio).isPointAvailable)
}
@Test
@DisplayName("첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
val freeAudio = firstAudio(price = 0, isPointAvailable = true)
val paidAudio = firstAudio(price = 100, isPointAvailable = true)
val paidUnavailableAudio = firstAudio(price = 100, isPointAvailable = false)
assertFalse(
ContentOverviewItemResponse.fromFirstAudioContent(freeAudio, "https://cdn.test/free.png", false, false)
.isPointAvailable
)
assertTrue(
ContentOverviewItemResponse.fromFirstAudioContent(paidAudio, "https://cdn.test/paid.png", false, false)
.isPointAvailable
)
assertFalse(
ContentOverviewItemResponse.fromFirstAudioContent(
paidUnavailableAudio,
"https://cdn.test/paid-unavailable.png",
false,
false
).isPointAvailable
)
}
private fun audioCard(price: Int, isPointAvailable: Boolean): AudioCard {
return AudioCard(
audioContentId = price.toLong() + 1,
title = "audio",
duration = "00:01",
imageUrl = "https://cdn.test/audio.png",
price = price,
isAdult = false,
isPointAvailable = isPointAvailable,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator"
)
}
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = price,
coverImage = "cover/audio.png",
isPointAvailable = isPointAvailable,
isAdult = false,
isOriginalSeries = false
)
}
}

View File

@@ -54,6 +54,9 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.pointAudios.length()").value(1))
.andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId))
.andExpect(jsonPath("$.data.pointAudios[0].price").value(100))
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
.andExpect(
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
@@ -68,6 +71,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png")
val theme = saveTheme()
val audio = saveAudio(creator, theme, now)
val paidPointAudio = saveAudio(creator, theme, now.minusMinutes(10), "paid-point", 100)
val series = saveSeries(creator)
saveSeriesContent(series, audio)
saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10))
@@ -79,7 +83,8 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
Fixture(
seriesId = series.id!!,
audioContentId = audio.id!!
audioContentId = audio.id!!,
paidPointAudioContentId = paidPointAudio.id!!
)
}!!
}
@@ -102,14 +107,20 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
return theme
}
private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent {
private fun saveAudio(
creator: Member,
theme: AudioContentTheme,
releaseDate: LocalDateTime,
title: String = "audio-recommendation-e2e",
price: Int = 0
): AudioContent {
val audio = AudioContent(
title = "audio-recommendation-e2e",
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = false,
price = 0,
price = price,
isPointAvailable = true
)
audio.member = creator
@@ -174,6 +185,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
private data class Fixture(
val seriesId: Long,
val audioContentId: Long
val audioContentId: Long,
val paidPointAudioContentId: Long
)
}

View File

@@ -33,10 +33,13 @@ class AudioRecommendationFacadeTest {
assertEquals(1, response.mostCommentedAudios.size)
assertEquals(1, response.recommendedAudios.size)
assertEquals(false, response.latestAudios[0].isOriginalSeries)
assertEquals(false, response.latestAudios[0].isPointAvailable)
assertEquals(true, response.newAndHotAudios[0].isPointAvailable)
assertEquals(false, response.recommendedAudios[0].isPointAvailable)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean())
assertEquals(true, json["latestAudios"][0]["isPointAvailable"].asBoolean())
assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean())
assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["latestAudios"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["latestAudios"][0].has("adult"))
@@ -61,7 +64,7 @@ class AudioRecommendationFacadeTest {
banners = listOf(RecommendationBanner("https://cdn.test/banner.png", null, null, null, "https://link.test")),
originalSeries = listOf(OriginalSeries(2L, "https://cdn.test/series.png")),
latestAudios = listOf(card),
newAndHotAudios = listOf(card.copy(audioContentId = 3L)),
newAndHotAudios = listOf(card.copy(audioContentId = 3L, price = 100)),
freeAudios = listOf(card.copy(audioContentId = 4L)),
pointAudios = listOf(card.copy(audioContentId = 5L)),
mostCommentedAudios = listOf(
@@ -73,7 +76,7 @@ class AudioRecommendationFacadeTest {
latestCommentWriterProfileImageUrl = "https://cdn.test/profile.png"
)
),
recommendedAudios = listOf(card.copy(audioContentId = 7L))
recommendedAudios = listOf(card.copy(audioContentId = 7L, price = 100, isPointAvailable = false))
)
}
}

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
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
class CreatorChannelAudioContentResponseTest {
@Test
@DisplayName("크리에이터 채널 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeAudioContent() {
val freeAudio = audioContent(price = 0, isPointAvailable = true)
val paidAudio = audioContent(price = 100, isPointAvailable = true)
val paidUnavailableAudio = audioContent(price = 100, isPointAvailable = false)
assertFalse(CreatorChannelAudioContentResponse.from(freeAudio).isPointAvailable)
assertTrue(CreatorChannelAudioContentResponse.from(paidAudio).isPointAvailable)
assertFalse(CreatorChannelAudioContentResponse.from(paidUnavailableAudio).isPointAvailable)
}
private fun audioContent(price: Int, isPointAvailable: Boolean): CreatorChannelAudioContent {
return CreatorChannelAudioContent(
audioContentId = price.toLong() + 1,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = price,
isAdult = false,
isPointAvailable = isPointAvailable,
isFirstContent = true,
seriesName = "series",
isOriginalSeries = true,
isOwned = false,
isRented = false
)
}
}

View File

@@ -66,12 +66,16 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.schedules[0].targetId").value(fixture.liveScheduleId))
.andExpect(jsonPath("$.data.schedules[1].targetId").value(fixture.audioScheduleId))
.andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.listAudioId))
.andExpect(jsonPath("$.data.audioContents[1].audioContentId").value(fixture.firstAudioId))
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
.andExpect(jsonPath("$.data.communities[0].postId").value(fixture.communityId))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk.fanTalkId").value(fixture.fanTalkId))
.andExpect(jsonPath("$.data.introduce").value("e2e introduce"))
.andExpect(jsonPath("$.data.activity.audioContentCount").value(3))

View File

@@ -135,7 +135,11 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.notices[0].isLiked").value(true))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(false))
.andExpect(jsonPath("$.data.communities[0].isLiked").value(true))
.andExpect(jsonPath("$.data.series[0].isNew").value(true))
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
@@ -283,7 +287,14 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
isOriginal = true
)
),
communities = listOf(post.copy(postId = 302L, content = "community")),
communities = listOf(
post.copy(
postId = 302L,
content = "community",
isCommentAvailable = false,
isPinned = false
)
),
fanTalk = CreatorChannelFanTalkSummary(
totalCount = 1,
latestFanTalk = CreatorChannelFanTalk(

View File

@@ -0,0 +1,183 @@
package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeGenreCreatorRecommendationGroup
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
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.mockito.Mockito
import java.time.LocalDateTime
class HomeRecommendationFacadeTest {
private val queryPort = FakeHomeRecommendationQueryPort()
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort())
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
@Test
@DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
queryPort.firstAudioContents = listOf(
firstAudio(price = 0, isPointAvailable = true),
firstAudio(price = 100, isPointAvailable = true),
firstAudio(price = 100, isPointAvailable = false)
)
val response = facade.getHomeRecommendations(null)
assertFalse(response.firstAudioContents[0].isPointAvailable)
assertTrue(response.firstAudioContents[1].isPointAvailable)
assertFalse(response.firstAudioContents[2].isPointAvailable)
}
@Test
@DisplayName("홈 추천은 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 전달한다")
fun shouldPassAdultVisibilityToHomeBannerQuery() {
val member = Member(password = "password", nickname = "member").apply { id = 10L }
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
facade.getHomeRecommendations(member)
assertTrue(queryPort.bannerIncludeAdultBanners)
}
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = price,
coverImage = "cover/audio.png",
isPointAvailable = isPointAvailable,
isAdult = false,
isOriginalSeries = false
)
}
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
var bannerIncludeAdultBanners: Boolean = false
override fun findLiveRecommendations(
offset: Long,
limit: Int,
memberId: Long?,
includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> = emptyList()
override fun findHomeBanners(
limit: Int,
memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> {
bannerIncludeAdultBanners = includeAdultBanners
return emptyList()
}
override fun findRecentlyActiveCreators(
limit: Int,
memberId: Long?,
includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> = emptyList()
override fun findRecentDebutCreators(
now: LocalDateTime,
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<RecentDebutCreatorRecord> = emptyList()
override fun findFirstAudioContents(
now: LocalDateTime,
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<HomeFirstAudioContentRecord> = firstAudioContents
override fun findAiCharacterSnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findAiCharacterRecommendationDetails(
characterIds: List<Long>
): List<HomeAiCharacterRecommendationRecord> = emptyList()
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> = emptyList()
override fun findCheerCreatorRecommendationDetails(
creatorIds: List<Long>,
memberId: Long?
): List<HomeCheerCreatorRecommendationRecord> = emptyList()
override fun findPopularCommunityRecommendationDetails(
communityIds: List<Long>,
memberId: Long?,
includeAdultCommunities: Boolean
): List<HomePopularCommunityRecommendationRecord> = emptyList()
override fun findGenreCreatorRecommendations(
memberId: Long?,
includeAdultGenres: Boolean,
genreLimit: Int,
creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup> = emptyList()
}
private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort {
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean = true
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean = true
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) = Unit
}
}

View File

@@ -52,6 +52,7 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
val inactiveTheme = saveTheme("inactive-audio-theme", isActive = false)
val free = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 0)
val point = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100, isPointAvailable = true)
val freePoint = saveAudioContent(creator, theme, now.minusHours(12), isAdult = false, price = 0, isPointAvailable = true)
saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 200)
saveAudioContent(blockedCreator, theme, now.minusDays(1), isAdult = false, price = 100)
saveAudioContent(inactiveCreator, theme, now.minusDays(1), isAdult = false, price = 100)
@@ -66,11 +67,12 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true)
val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true)
assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now))
assertEquals(3, repository.countAudios(viewer.id, canViewAdultContent = false, now))
assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true))
assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId })
assertEquals(1, repository.countAudios(null, false, now, onlyPointAvailable = true))
assertEquals(listOf(freePoint.id, point.id, free.id), visible.map { it.audioContentId })
assertEquals(listOf(point.id), paidAudios.map { it.audioContentId })
assertEquals(listOf(free.id), freeAudios.map { it.audioContentId })
assertEquals(listOf(freePoint.id, free.id), freeAudios.map { it.audioContentId })
assertEquals(listOf(point.id), pointAudios.map { it.audioContentId })
assertEquals("https://cdn.test/audio.png", visible.first().imageUrl)
}

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
@@ -41,25 +42,58 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
@Test
@DisplayName("배너는 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
fun shouldFindBannersWithHomeBannerPolicy() {
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
fun shouldFindBannersForContentRecommendationTabWithAdultVisibility() {
val viewer = saveMember("viewer", MemberRole.USER)
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator)
val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test")
saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test")
saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator)
val recommendationTab = saveContentRecommendationTab()
val visibleBanner = saveBanner(
"visible.png",
AudioContentBannerType.CREATOR,
1,
creator = visibleCreator,
tab = recommendationTab
)
val adultBanner = saveBanner(
"adult.png",
AudioContentBannerType.LINK,
2,
isAdult = true,
link = "https://adult.test",
tab = recommendationTab
)
saveBanner(
"inactive.png",
AudioContentBannerType.LINK,
3,
isActive = false,
link = "https://inactive.test",
tab = recommendationTab
)
saveBanner(
"blocked.png",
AudioContentBannerType.CREATOR,
4,
creator = blockedCreator,
tab = recommendationTab
)
saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test")
saveBlock(viewer, blockedCreator)
flushAndClear()
val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
val allBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = true)
assertEquals(
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
banners.map { it.imageUrl }
listOf("https://cdn.test/${visibleBanner.thumbnailImage}"),
safeBanners.map { it.imageUrl }
)
assertEquals(visibleCreator.id, banners.first().creatorId)
assertEquals(
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
allBanners.map { it.imageUrl }
)
assertEquals(visibleCreator.id, safeBanners.first().creatorId)
}
@Test
@@ -90,7 +124,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
val creator = saveMember("audio-creator", MemberRole.CREATOR)
val theme = saveTheme()
val first = saveAudio(
saveAudio(
creator = creator,
theme = theme,
title = "first",
@@ -108,6 +142,15 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
isPointAvailable = false,
coverImage = "latest.png"
)
val paidPoint = saveAudio(
creator = creator,
theme = theme,
title = "paid-point",
releaseDate = now.minusDays(2),
price = 20,
isPointAvailable = true,
coverImage = "paid-point.png"
)
saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true)
saveAudio(creator, theme, "future", now.plusDays(1))
saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false
@@ -139,11 +182,11 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
assertEquals(12, latestAudios.size)
assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId })
assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId })
assertEquals(10, freeAudios.size)
assertEquals(true, freeAudios.all { it.price == 0 })
assertEquals(10, pointAudios.size)
assertEquals(true, pointAudios.all { it.isPointAvailable })
assertEquals(listOf(paidPoint.id), pointAudios.map { it.audioContentId })
assertEquals(true, pointAudios.all { it.isPointAvailable && it.price > 0 })
val latestCard = latestAudios.first()
assertEquals("latest", latestCard.title)
assertEquals("00:01", latestCard.duration)
@@ -154,8 +197,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(false, latestCard.isFirstContent)
assertEquals(true, latestCard.isOriginalSeries)
assertEquals(creator.nickname, latestCard.creatorNickname)
assertEquals(true, latestAudios[1].isFirstContent)
assertEquals(false, latestAudios[1].isOriginalSeries)
assertEquals(true, latestAudios[2].isFirstContent)
assertEquals(false, latestAudios[2].isOriginalSeries)
}
@Test
@@ -360,7 +403,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
isActive: Boolean = true,
isAdult: Boolean = false,
creator: Member? = null,
link: String? = null
link: String? = null,
tab: AudioContentMainTab? = null
): AudioContentBanner {
val banner = AudioContentBanner(
thumbnailImage = thumbnailImage,
@@ -371,10 +415,18 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
)
banner.creator = creator
banner.link = link
banner.tab = tab
entityManager.persist(banner)
return banner
}
private fun saveContentRecommendationTab(): AudioContentMainTab {
entityManager.createNativeQuery(
"insert into audio_content_main_tab (id, title, is_active) values (2, '추천', true)"
).executeUpdate()
return entityManager.getReference(AudioContentMainTab::class.java, 2L)
}
private fun saveSeries(
title: String,
creator: Member,

View File

@@ -262,6 +262,36 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage })
}
@Test
@DisplayName("홈 배너는 성인 콘텐츠 조회가 불가능하면 비성인 배너만 반환한다")
fun shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible() {
val safeBanner = saveBanner(
"safe-home-banner.png",
AudioContentBannerType.LINK,
orders = 1,
isActive = true,
link = "https://safe-home-banner.test"
)
val adultBanner = saveBanner(
"adult-home-banner.png",
AudioContentBannerType.LINK,
orders = 2,
isActive = true,
isAdult = true,
link = "https://adult-home-banner.test"
)
flushAndClear()
val safeBanners = repository.findHomeBanners(limit = 20)
val allBanners = repository.findHomeBanners(limit = 20, includeAdultBanners = true)
assertEquals(listOf(safeBanner.thumbnailImage), safeBanners.map { it.thumbnailImage })
assertEquals(
listOf(safeBanner.thumbnailImage, adultBanner.thumbnailImage),
allBanners.map { it.thumbnailImage }
)
}
@Test
@DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다")
fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() {
@@ -527,6 +557,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType)
}
@Test
@DisplayName("최근 활동 크리에이터는 현재 크리에이터 역할이 아닌 회원(전 크리에이터)의 활동을 제외한다")
fun shouldExcludeNonCreatorRoleMembersFromRecentlyActiveCreators() {
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
val formerCreator = saveMember("former-creator", MemberRole.USER)
val visibleCreator = saveMember("visible-role-creator", MemberRole.CREATOR)
saveLiveRoom(formerCreator, baseAt.plusMinutes(2), channelName = "former-creator-channel")
val community = saveCommunity(visibleCreator, isCommentAvailable = true)
updateCreatedAt("CreatorCommunity", community.id!!, baseAt.plusMinutes(1))
flushAndClear()
val creators = repository.findRecentlyActiveCreators(limit = 10)
assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname })
assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType)
}
@Test
@DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다")
fun shouldFindAiCharacterSnapshotsWithFollowIncrease() {
@@ -2641,6 +2688,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
type: AudioContentBannerType,
orders: Int,
isActive: Boolean,
isAdult: Boolean = false,
creator: Member? = null,
event: Event? = null,
series: Series? = null,
@@ -2651,7 +2699,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
thumbnailImage = thumbnailImage,
type = type,
lang = Lang.KO,
isAdult = false,
isAdult = isAdult,
isActive = isActive,
orders = orders
)

View File

@@ -86,12 +86,13 @@ class HomeRecommendationQueryServiceTest {
}
@Test
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
@DisplayName("홈 배너 추천은 기본 20개와 성인 노출 여부를 조회 포트에 위임한다")
fun shouldFindHomeBannersWithDefaultLimit() {
val banners = service.findHomeBanners(memberId = 100L)
val banners = service.findHomeBanners(memberId = 100L, includeAdultBanners = true)
assertEquals(20, port.bannerLimit)
assertEquals(100L, port.bannerMemberId)
assertEquals(true, port.bannerIncludeAdultBanners)
assertEquals(port.banners, banners)
}
@@ -964,6 +965,7 @@ class HomeRecommendationQueryServiceTest {
var liveIncludeAdultLives: Boolean? = null
var bannerLimit: Int? = null
var bannerMemberId: Long? = null
var bannerIncludeAdultBanners: Boolean? = null
var activeCreatorLimit: Int? = null
var activeCreatorMemberId: Long? = null
var activeCreatorIncludeAdultActivities: Boolean? = null
@@ -1063,9 +1065,14 @@ class HomeRecommendationQueryServiceTest {
return liveRecommendations
}
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> {
override fun findHomeBanners(
limit: Int,
memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> {
bannerLimit = limit
bannerMemberId = memberId
bannerIncludeAdultBanners = includeAdultBanners
return banners
}

View File

@@ -14,8 +14,8 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class RecommendationSnapshotFallbackServiceTest {
@Test
@@ -265,10 +265,18 @@ class RecommendationSnapshotFallbackServiceTest {
homeWaitMillis = 50
)
val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
allowRefreshComplete.countDown()
executor.shutdown()
val first = try {
val result = service.refreshCheerCreatorIfMissing(
offset = 0,
limit = 16,
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
)
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
result
} finally {
allowRefreshComplete.countDown()
executor.shutdown()
}
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
val second = service.refreshCheerCreatorIfMissing(
offset = 0,
@@ -283,46 +291,43 @@ class RecommendationSnapshotFallbackServiceTest {
@Test
@DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다")
fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() {
val existsSnapshotEntered = CountDownLatch(2)
val existsSnapshotReturned = CountDownLatch(2)
val snapshotPort = FakeRecommendationFallbackSnapshotPort(
existsSnapshotEntered = existsSnapshotEntered,
existsSnapshotReturned = existsSnapshotReturned
)
val refreshService = FastCheerRefreshService(snapshotPort)
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshStarted = CountDownLatch(1)
val allowRefreshComplete = CountDownLatch(1)
val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val workerExecutor = CapturingExecutor()
val workerTaskCount = AtomicInteger()
val workerPool = Executors.newFixedThreadPool(2)
val workerExecutor = Executor { command ->
workerTaskCount.incrementAndGet()
workerPool.execute(command)
}
val requestExecutor = Executors.newFixedThreadPool(2)
val service = RecommendationSnapshotFallbackService(
snapshotPort,
refreshService,
redissonClient,
workerExecutor,
homeWaitMillis = 1_000
homeWaitMillis = 50
)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
try {
val first = requestExecutor.submitCheerRefresh(service, nowUtc)
val second = requestExecutor.submitCheerRefresh(service, nowUtc)
assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS))
assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS))
assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS))
assertEquals(false, first.isDone)
assertEquals(false, second.isDone)
assertEquals(1, workerExecutor.taskCount)
workerExecutor.runNext()
assertEquals(listOf(99L), first.get(1, TimeUnit.SECONDS).map { it.targetId })
assertEquals(listOf(99L), second.get(1, TimeUnit.SECONDS).map { it.targetId })
assertEquals(true, refreshStarted.await(5, TimeUnit.SECONDS))
assertEquals(emptyList<RecommendationSnapshotRecord>(), first.get(5, TimeUnit.SECONDS))
assertEquals(emptyList<RecommendationSnapshotRecord>(), second.get(5, TimeUnit.SECONDS))
assertEquals(1, workerTaskCount.get())
assertEquals(1, refreshService.cheerRefreshCount)
} finally {
allowRefreshComplete.countDown()
requestExecutor.shutdownNow()
workerPool.shutdownNow()
}
}
@@ -408,23 +413,6 @@ class RecommendationSnapshotFallbackServiceTest {
}
}
private class CapturingExecutor : Executor {
val taskSubmitted = CountDownLatch(1)
private val tasks = LinkedBlockingQueue<Runnable>()
val taskCount: Int
get() = tasks.size
override fun execute(command: Runnable) {
tasks.add(command)
taskSubmitted.countDown()
}
fun runNext() {
tasks.poll(1, TimeUnit.SECONDS)!!.run()
}
}
private class BlockingCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort,
private val refreshStarted: CountDownLatch,
@@ -438,26 +426,7 @@ private class BlockingCheerRefreshService(
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
cheerRefreshCount += 1
refreshStarted.countDown()
allowRefreshComplete.await(1, TimeUnit.SECONDS)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L))
)
return 1
}
}
private class FastCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort
) : RecommendationSnapshotRefreshService(
snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) {
var cheerRefreshCount: Int = 0
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
cheerRefreshCount += 1
allowRefreshComplete.await()
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
@@ -496,10 +465,7 @@ private class BlockingAiAndFastCheerRefreshService(
}
}
private class FakeRecommendationFallbackSnapshotPort(
private val existsSnapshotEntered: CountDownLatch? = null,
private val existsSnapshotReturned: CountDownLatch? = null
) : RecommendationSnapshotPort {
private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots(
@@ -531,12 +497,7 @@ private class FakeRecommendationFallbackSnapshotPort(
}
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
existsSnapshotEntered?.countDown()
existsSnapshotEntered?.await(1, TimeUnit.SECONDS)
val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
existsSnapshotReturned?.countDown()
existsSnapshotReturned?.await(1, TimeUnit.SECONDS)
return exists
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
}
override fun replaceSnapshots(