Compare commits

...

208 Commits

Author SHA1 Message Date
5c7e8dae0a docs(recommendation): native Boolean 매핑 검증 기록을 갱신한다 2026-06-27 10:56:27 +09:00
b3cf26119b fix(recommendation): native Boolean 매핑을 보정한다 2026-06-27 10:56:05 +09:00
9c458d0ae1 fix(admin-member): 회원 목록 lazy 초기화를 방지한다 2026-06-27 07:53:58 +09:00
342c39890e docs(admin-member): 회원 목록 lazy 초기화 수정 계획을 추가한다 2026-06-27 07:53:42 +09:00
55abbd2a6d test(content): 콘텐츠 전체보기 E2E 검증을 추가한다 2026-06-27 07:32:50 +09:00
0686dd6eb3 docs(content): 콘텐츠 전체보기 Phase 4 검증 기록을 갱신한다 2026-06-27 07:09:58 +09:00
9c7b956fdc fix(home): 미배포 first-audio 하위 endpoint를 제거한다 2026-06-27 07:09:48 +09:00
b5f0cfee4b docs(content): 콘텐츠 전체보기 Phase 3 기록을 갱신한다 2026-06-27 06:42:41 +09:00
686bd2c987 feat(content): 콘텐츠 전체보기 endpoint를 추가한다 2026-06-27 06:41:47 +09:00
4e2b63acf4 feat(content): 콘텐츠 전체보기 facade를 추가한다 2026-06-27 06:41:06 +09:00
ef9ddae94b feat(recommendation): 첫 오디오 콘텐츠 플래그를 확장한다 2026-06-27 06:40:55 +09:00
151593a524 docs(content): 콘텐츠 전체보기 Phase 2 기록을 갱신한다 2026-06-27 05:50:31 +09:00
581c5fd441 feat(recommendation): New & Hot 전체보기 조회를 추가한다 2026-06-27 05:49:27 +09:00
6ab8d65207 fix(recommendation): New & Hot 스냅샷 저장 수를 확장한다 2026-06-27 05:49:16 +09:00
f99ed002b2 fix(home): 홈 추천 offset 계산 overflow를 방지한다 2026-06-27 05:12:21 +09:00
c028aa4002 fix(recommendation): 홈 추천 query offset 범위를 확장한다 2026-06-27 05:11:51 +09:00
24e217e8ee fix(recommendation): 추천 snapshot offset 범위를 확장한다 2026-06-27 05:11:22 +09:00
63df1b5777 feat(content): 콘텐츠 전체보기 조회 정책을 추가한다 2026-06-27 05:10:49 +09:00
3c4f852ddb feat(content): 콘텐츠 전체보기 응답 모델을 추가한다 2026-06-27 05:10:37 +09:00
8b24e89465 docs(content): 콘텐츠 전체보기 Phase 1 기록을 갱신한다 2026-06-27 05:10:27 +09:00
c42230e568 docs(content): 콘텐츠 전체보기 API 계획을 추가한다 2026-06-27 03:47:22 +09:00
24a61e4d78 docs(chat): 비로그인 채팅 리스트 정책을 기록한다 2026-06-27 02:35:07 +09:00
5cb69bfa6e fix(chat): 비로그인 채팅 리스트 응답을 보정한다 2026-06-27 02:34:56 +09:00
79c51cf27b docs(live): Phase 4 검증 기록을 갱신한다 2026-06-27 01:56:30 +09:00
34230f5269 test(home-live): 현재 진행 중 라이브 테스트 설명을 보강한다 2026-06-27 01:55:50 +09:00
b6d89397db test(content): 예약 공개 업로드 최근 소식 검증을 보정한다 2026-06-27 01:55:31 +09:00
d304df7ddf docs(live): Phase 3 검증 기록을 갱신한다 2026-06-27 00:48:35 +09:00
9f6300624c test(home-live): 기존 라이브 추천 응답 스키마를 고정한다 2026-06-27 00:47:48 +09:00
107e6de3eb fix(home-live): 현재 진행 중 라이브 인증 정책을 검증한다 2026-06-27 00:47:10 +09:00
e0df436fd9 docs(live): Phase 1-2 검증 기록을 갱신한다 2026-06-27 00:08:02 +09:00
5f09f59f53 feat(home-live): 현재 진행 중 라이브 endpoint를 추가한다 2026-06-27 00:07:15 +09:00
99f61ed13e feat(home-live): 현재 진행 중 라이브 facade를 추가한다 2026-06-27 00:06:38 +09:00
df5c2c9048 feat(home-live): 현재 진행 중 라이브 응답 모델을 추가한다 2026-06-27 00:06:01 +09:00
38595ee88a feat(home-live): 라이브 추천 조회 정보를 확장한다 2026-06-27 00:05:49 +09:00
8ae48d7e67 docs(live): 현재 진행 중인 라이브 조회 API 계획을 추가한다 2026-06-26 22:48:10 +09:00
f2be184fc9 docs(home-following): Phase 6 검증 기록을 갱신한다 2026-06-26 03:02:44 +09:00
9a20c54670 docs(home-following): Phase 3-5 기록을 갱신한다 2026-06-26 02:51:57 +09:00
75bd0ced28 feat(home-following): 팔로잉 탭 facade를 통합한다 2026-06-26 02:51:19 +09:00
59439df33e feat(content-ranking): 랭킹 공개 최근 소식을 발행한다 2026-06-26 02:50:51 +09:00
e89b5e1dad feat(community): 커뮤니티 게시글 최근 소식을 발행한다 2026-06-26 02:50:24 +09:00
9fc6643c18 feat(content): 오디오 업로드 최근 소식을 발행한다 2026-06-26 02:49:57 +09:00
36a60c76eb fix(member): 언팔로우 시 최근 소식을 비활성화한다 2026-06-26 02:49:30 +09:00
670b3d9f54 fix(home-following): inbox 중복 insert 처리를 보강한다 2026-06-26 02:49:01 +09:00
e598d2058d feat(home-following): 최근 소식 발행 service를 추가한다 2026-06-26 02:48:29 +09:00
8b5c872b45 feat(home-following): 최근 소식 source key를 추가한다 2026-06-26 02:48:02 +09:00
f5d755b2a6 feat(home-following): 팔로잉 탭 조회 service를 추가한다 2026-06-26 02:47:35 +09:00
45fc8bd21f feat(home-following): 팔로잉 탭 조회 repository를 추가한다 2026-06-26 02:47:06 +09:00
91c648ca44 feat(home-following): 팔로잉 탭 조회 port를 추가한다 2026-06-26 02:46:52 +09:00
b2b4a74adc docs(home-following): 팔로잉 탭 Phase 1-2 기록을 갱신한다 2026-06-25 22:16:29 +09:00
315412fb42 feat(home-following): 팔로잉 소식 inbox 저장 adapter를 추가한다 2026-06-25 22:16:02 +09:00
a28991b585 feat(home-following): 팔로잉 소식 inbox 저장 모델을 추가한다 2026-06-25 22:15:20 +09:00
cbcd87875c feat(home-following): 팔로잉 탭 공개 endpoint를 추가한다 2026-06-25 22:14:43 +09:00
e4052d097a feat(home-following): 팔로잉 탭 응답 모델을 추가한다 2026-06-25 22:14:21 +09:00
3add66ff7a docs(home-following): 팔로잉 탭 API 계획을 추가한다 2026-06-25 17:45:49 +09:00
e411beb649 docs(content-ranking): 커버 이미지 CDN 정책을 기록한다 2026-06-25 16:03:10 +09:00
4f3f8d1fa7 fix(content-ranking): 랭킹 커버 이미지를 CDN URL로 변환한다 2026-06-25 16:02:58 +09:00
65804261f7 test(content-recommendation): 추천 저장소 테스트 시간을 고정한다 2026-06-25 15:37:06 +09:00
a8ebd41f6e fix(content-recommendation): 최신성 점수 계산 기준을 보정한다 2026-06-25 14:38:36 +09:00
cba004c35f test(redis): 내장 Redis 테스트 포트를 동적으로 설정한다 2026-06-25 13:46:59 +09:00
9f0ca9caa9 test(content-all): 전체 탭 API 통합 경로를 검증한다 2026-06-25 12:02:55 +09:00
147d770e9d feat(content-all): 전체 탭 공개 endpoint를 추가한다 2026-06-25 11:27:31 +09:00
9bd0ce712e feat(content-all): 전체 탭 API 응답 조립을 추가한다 2026-06-25 11:26:56 +09:00
24556c1987 feat(content-all): 전체 탭 QueryDSL 조회를 추가한다 2026-06-25 11:26:12 +09:00
2bced956dc feat(content-all): 전체 탭 조회 서비스를 추가한다 2026-06-25 11:25:25 +09:00
2aeb9418a9 feat(content-all): 전체 탭 요청 보정 정책을 추가한다 2026-06-25 11:24:49 +09:00
1f84f8eaf2 feat(content-all): 전체 탭 도메인 모델을 추가한다 2026-06-25 11:24:38 +09:00
74dc87db1e docs(content-all): 전체 탭 API 계획을 추가한다 2026-06-25 11:24:24 +09:00
87f6e47844 fix(content-ranking): 스냅샷 job 실패 상태를 보존한다 2026-06-24 23:47:36 +09:00
79be172b93 fix(content-ranking): 공개 전 랭킹 조회를 차단한다 2026-06-24 23:47:08 +09:00
30b687737e feat(content-ranking): 스냅샷 갱신에 공개 시각을 반영한다 2026-06-24 23:46:40 +09:00
f2ea82f4a4 feat(content-ranking): 스냅샷 생성을 01시로 변경한다 2026-06-24 23:46:11 +09:00
bfbb5e6fd7 feat(content-ranking): 스냅샷 job 공개 메타데이터를 저장한다 2026-06-24 23:45:30 +09:00
da1a63da23 feat(content-ranking): 스냅샷 공개 조회 저장소를 추가한다 2026-06-24 23:44:58 +09:00
9489458b35 feat(content-ranking): 랭킹 공개 시각 정책을 추가한다 2026-06-24 23:44:42 +09:00
6b702de932 docs(content-ranking): Phase 12 완료 기록을 갱신한다 2026-06-24 23:44:32 +09:00
cdfdf0c530 docs(content-ranking): 랭킹 시간 정책 DDL을 기록한다 2026-06-24 22:33:26 +09:00
ce2b628cc2 docs(content-ranking): 랭킹 시간 정책 문서를 갱신한다 2026-06-24 22:33:13 +09:00
d5f4dc529a docs(content-ranking): 크리에이터 랭킹 후속 범위를 기록한다 2026-06-24 20:39:15 +09:00
94cfa3ba50 docs(content-ranking): 랭킹 스냅샷 계획을 갱신한다 2026-06-24 19:04:05 +09:00
9f24851835 test(content-ranking): 랭킹 API 통합 계약을 검증한다 2026-06-24 19:03:41 +09:00
cf29600ad3 feat(content-ranking): 랭킹 조회 fallback과 차단 필터를 적용한다 2026-06-24 19:03:12 +09:00
7ec19e3c8c feat(content-ranking): 랭킹 스냅샷 스케줄러를 추가한다 2026-06-24 19:02:39 +09:00
abeffb0a4f feat(content-ranking): 랭킹 스냅샷 job 서비스를 추가한다 2026-06-24 19:02:11 +09:00
90c5149df8 feat(content-ranking): 랭킹 차단 조회 포트를 추가한다 2026-06-24 19:01:58 +09:00
6fabcca03f docs(content-ranking): 랭킹 스냅샷 DDL을 갱신한다 2026-06-24 16:31:06 +09:00
cd43b40e44 docs(content-ranking): 랭킹 스냅샷 계획을 갱신한다 2026-06-24 16:24:26 +09:00
4d76958409 docs(content-ranking): 랭킹 스냅샷 요구사항을 갱신한다 2026-06-24 16:24:00 +09:00
f34962b285 feat(content-ranking): 스냅샷 기반 랭킹 조회를 추가한다 2026-06-24 16:23:18 +09:00
4e97364a14 feat(content-ranking): 랭킹 스냅샷 갱신 서비스를 추가한다 2026-06-24 16:22:28 +09:00
ee32696c6c feat(content-ranking): 랭킹 후보 집계를 추가한다 2026-06-24 16:21:43 +09:00
453d914f44 feat(content-ranking): 랭킹 스냅샷 job 저장소를 추가한다 2026-06-24 16:21:00 +09:00
f1e03706c7 feat(content-ranking): 랭킹 스냅샷 저장소를 추가한다 2026-06-24 16:19:50 +09:00
25c48a7606 docs(content-ranking): 랭킹 API 구현 기록을 갱신한다 2026-06-24 12:38:22 +09:00
e4706d6699 feat(content-ranking): 랭킹 점수 정책을 추가한다 2026-06-24 12:37:55 +09:00
dc93f9845b feat(content-ranking): 랭킹 공개 시각 정책을 추가한다 2026-06-24 12:37:26 +09:00
d62ce35912 feat(content-ranking): 랭킹 주간 기간 정책을 추가한다 2026-06-24 12:36:34 +09:00
af5f250abe feat(content-ranking): 오디오 랭킹 조회 endpoint를 추가한다 2026-06-24 12:36:05 +09:00
2c2607b6d0 feat(content-ranking): 오디오 랭킹 facade를 추가한다 2026-06-24 12:35:26 +09:00
c9d7399f0e feat(content-ranking): 오디오 랭킹 응답 계약을 추가한다 2026-06-24 12:35:12 +09:00
87c51d6087 docs(content-ranking): 랭킹 스냅샷 DDL 초안을 기록한다 2026-06-24 00:10:39 +09:00
d44f890391 docs(content-ranking): 랭킹 탭 API 요구사항과 계획을 기록한다 2026-06-24 00:10:25 +09:00
2a7d74b018 docs(audio-recommendation): 성인 정책 통일 기록을 갱신한다 2026-06-23 22:42:33 +09:00
abecbb694b refactor(creator-channel): 시리즈 탭 성인 조회 정책 호출을 통일한다 2026-06-23 22:42:02 +09:00
b34585afd2 refactor(creator-channel): 라이브 탭 성인 조회 정책 호출을 통일한다 2026-06-23 22:41:29 +09:00
e252f5d9bb refactor(creator-channel): 홈 탭 성인 조회 정책 호출을 통일한다 2026-06-23 22:41:01 +09:00
3f3497d376 refactor(creator-channel): 커뮤니티 탭 성인 조회 정책 호출을 통일한다 2026-06-23 22:40:31 +09:00
3ac6a48f73 refactor(creator-channel): 오디오 탭 성인 조회 정책 호출을 통일한다 2026-06-23 22:40:00 +09:00
e03cd7526b refactor(audio-recommendation): 성인 조회 정책 호출을 통일한다 2026-06-23 22:39:30 +09:00
e84b60418e refactor(home-recommendation): 성인 조회 정책 호출을 통일한다 2026-06-23 22:39:00 +09:00
a0375aa29c feat(content-preference): 성인 콘텐츠 조회 메서드를 추가한다 2026-06-23 22:38:48 +09:00
9987595fe2 docs(audio-recommendation): 추천 패키지 이동 기록을 갱신한다 2026-06-23 21:51:18 +09:00
cf73263505 refactor(audio-recommendation): 추천 패키지를 content 기준으로 이동한다 2026-06-23 21:51:00 +09:00
ab67e36d96 feat(audio-recommendation): 추천 조회 snapshot fallback을 적용한다 2026-06-23 21:06:25 +09:00
6a6deb33a3 feat(audio-recommendation): 추천 snapshot 스케줄러를 추가한다 2026-06-23 21:05:56 +09:00
1c7bac3a73 feat(audio-recommendation): 추천 snapshot 갱신 서비스를 추가한다 2026-06-23 21:05:26 +09:00
70346b911f feat(audio-recommendation): 추천 snapshot 저장소를 추가한다 2026-06-23 21:05:15 +09:00
b7052f03f6 docs(audio-recommendation): 추천 탭 snapshot 계획을 갱신한다 2026-06-23 21:05:05 +09:00
7212067101 feat(audio-recommendation): 오디오 추천 조회 endpoint를 추가한다 2026-06-23 16:14:56 +09:00
33b3d3e41b feat(audio-recommendation): 오디오 추천 응답 변환을 추가한다 2026-06-23 16:13:59 +09:00
45d2d616e0 feat(audio-recommendation): 실시간 추천 조회 repository를 추가한다 2026-06-23 16:13:18 +09:00
9c4ec03624 feat(audio-recommendation): 추천 섹션 매핑 서비스를 추가한다 2026-06-23 16:12:45 +09:00
3df66d98ef feat(audio-recommendation): 오디오 추천 점수 정책을 추가한다 2026-06-23 16:12:11 +09:00
cf7fea156b feat(audio-recommendation): 오디오 추천 도메인 모델을 추가한다 2026-06-23 16:11:41 +09:00
d387030a38 refactor(home-recommendation): 추천 배너 응답을 공통화한다 2026-06-23 16:11:26 +09:00
2dbe339245 docs(audio-recommendation): 콘텐츠 추천 탭 API 계획을 기록한다 2026-06-23 16:10:44 +09:00
f27074167a feat(home-recommendation): AI 캐릭터 creatorId 응답을 추가한다 2026-06-23 11:57:30 +09:00
5d1290e114 feat(home-recommendation): AI 캐릭터 creatorId 조회를 추가한다 2026-06-23 11:57:01 +09:00
a7b2ecc983 docs(home-recommendation): AI 캐릭터 creatorId 구현 계획을 기록한다 2026-06-23 11:56:53 +09:00
074c035c34 docs(home-recommendation): AI 캐릭터 creatorId 요구사항을 기록한다 2026-06-23 11:56:42 +09:00
2c44cb90ee test(creator-channel): 후원 탭 E2E 검증을 추가한다 2026-06-22 21:12:22 +09:00
02d5446888 docs(creator-channel): 후원 탭 Phase 2 기록을 갱신한다 2026-06-22 19:19:00 +09:00
8e76c2d640 feat(creator-channel): 후원 탭 legacy 랭킹 adapter를 추가한다 2026-06-22 19:18:27 +09:00
951f6789f0 feat(creator-channel): 후원 탭 repository를 추가한다 2026-06-22 19:17:56 +09:00
046ce700c7 feat(creator-channel): 후원 탭 조회 서비스를 구현한다 2026-06-22 19:17:45 +09:00
13b679d091 docs(creator-channel): 후원 탭 Phase 1 기록을 갱신한다 2026-06-22 18:00:51 +09:00
7e9e0aa320 feat(creator-channel): 후원 탭 endpoint를 추가한다 2026-06-22 18:00:16 +09:00
14f648cd10 feat(creator-channel): 후원 탭 응답 조립을 추가한다 2026-06-22 17:59:41 +09:00
34e05a577e feat(creator-channel): 후원 탭 조회 서비스 보호 동작을 추가한다 2026-06-22 17:59:09 +09:00
e516a7406f feat(creator-channel): 후원 탭 도메인 계약을 추가한다 2026-06-22 17:59:01 +09:00
b2fae3e081 docs(creator-channel): 후원 탭 API 계획을 기록한다 2026-06-22 16:31:54 +09:00
4ffd880440 docs(creator-channel): FanTalk 탭 Phase 5 기록을 갱신한다 2026-06-22 16:12:35 +09:00
45fafa9b00 test(creator-channel): FanTalk 탭 E2E 검증을 추가한다 2026-06-22 16:12:04 +09:00
bb44eaa8dd docs(creator-channel): FanTalk 탭 Phase 3과 4 기록을 갱신한다 2026-06-22 15:52:53 +09:00
408a342f17 feat(creator-channel): FanTalk 탭 repository를 추가한다 2026-06-22 15:52:03 +09:00
2848f07573 feat(creator-channel): FanTalk 탭 조회 서비스를 구현한다 2026-06-22 15:51:47 +09:00
e2a3aeefc2 docs(creator-channel): FanTalk 탭 Phase 2 기록을 갱신한다 2026-06-22 14:52:13 +09:00
0ebb686ce6 feat(creator-channel): FanTalk 탭 endpoint를 추가한다 2026-06-22 14:51:52 +09:00
90bf4c770c feat(creator-channel): FanTalk 탭 응답 조립을 추가한다 2026-06-22 14:51:44 +09:00
831c26c155 docs(creator-channel): FanTalk 탭 Phase 1 기록을 갱신한다 2026-06-22 14:26:57 +09:00
41937c7cce feat(creator-channel): FanTalk 탭 도메인 계약을 추가한다 2026-06-22 14:26:31 +09:00
dc9ee06bb8 docs(creator-channel): FanTalk 탭 API 계획을 기록한다 2026-06-22 13:40:12 +09:00
b1b6de8c3b fix(creator-channel): FanTalk 엔티티 data class 선언을 제거한다 2026-06-22 13:39:36 +09:00
a96d9ddc76 docs(creator-channel): 커뮤니티 탭 Phase 7 기록을 갱신한다 2026-06-22 01:44:12 +09:00
ccfe3f79c7 docs(creator-channel): 커뮤니티 탭 Phase 6 기록을 갱신한다 2026-06-22 01:08:31 +09:00
c04d72b04e test(creator-channel): 커뮤니티 탭 E2E 검증을 추가한다 2026-06-22 01:08:21 +09:00
3360477f75 docs(creator-channel): 커뮤니티 탭 Phase 5 기록을 갱신한다 2026-06-22 00:03:11 +09:00
0a6a689773 feat(creator-channel): 커뮤니티 탭 endpoint를 추가한다 2026-06-22 00:02:14 +09:00
e0e6b34d21 feat(creator-channel): 커뮤니티 탭 응답 조립을 추가한다 2026-06-22 00:01:45 +09:00
bd4e865f2e docs(creator-channel): 커뮤니티 탭 Phase 4 기록을 갱신한다 2026-06-21 23:20:55 +09:00
45337663e5 test(creator-channel): 홈 커뮤니티 서비스 연결을 검증한다 2026-06-21 23:20:36 +09:00
014511668a refactor(creator-channel): 홈 repository 커뮤니티 조회 책임을 제거한다 2026-06-21 23:19:52 +09:00
6ab3c50c32 feat(creator-channel): 홈 커뮤니티 조회를 공용 서비스로 연결한다 2026-06-21 23:19:37 +09:00
06e82f1bba docs(creator-channel): 커뮤니티 탭 Phase 3 기록을 갱신한다 2026-06-21 22:15:59 +09:00
0620e54cbd feat(creator-channel): 커뮤니티 탭 조회 서비스를 추가한다 2026-06-21 22:15:37 +09:00
00695d5b33 docs(creator-channel): 커뮤니티 탭 Phase 2 기록을 갱신한다 2026-06-21 20:45:10 +09:00
078718c041 feat(creator-channel): 커뮤니티 탭 repository를 추가한다 2026-06-21 20:44:24 +09:00
2ebe7afab7 docs(creator-channel): 커뮤니티 탭 Phase 1 기록을 갱신한다 2026-06-21 19:23:58 +09:00
d249d9c257 feat(creator-channel): 커뮤니티 탭 조회 계약을 추가한다 2026-06-21 19:23:32 +09:00
94b5c70cc6 docs(creator-channel): 커뮤니티 탭 API 계획을 기록한다 2026-06-21 18:29:56 +09:00
998dd10311 docs(creator-channel): 시리즈 탭 Phase 5 기록을 갱신한다 2026-06-20 06:23:50 +09:00
652c955356 test(gradle): 테스트 워커 heap을 확장한다 2026-06-20 06:23:42 +09:00
338f5c29bc test(creator-channel): 시리즈 탭 E2E 검증을 추가한다 2026-06-20 06:23:35 +09:00
7651fd83ea docs(creator-channel): 시리즈 탭 Phase 4 기록을 갱신한다 2026-06-20 05:20:28 +09:00
67fe0ec497 feat(creator-channel): 시리즈 탭 repository를 추가한다 2026-06-20 05:20:22 +09:00
a67322b7fd docs(creator-channel): 시리즈 탭 Phase 2와 3 기록을 갱신한다 2026-06-20 04:36:44 +09:00
25330e30c0 feat(creator-channel): 시리즈 탭 controller를 추가한다 2026-06-20 04:36:19 +09:00
dd68e64628 feat(creator-channel): 시리즈 탭 응답 변환을 추가한다 2026-06-20 04:35:55 +09:00
e8b8287968 feat(creator-channel): 시리즈 탭 조회 서비스를 추가한다 2026-06-20 04:35:26 +09:00
6c4df431b9 fix(creator-channel): 빈 연재 요일 문구를 보완한다 2026-06-20 04:35:18 +09:00
c39f339a86 docs(creator-channel): 시리즈 탭 Phase 1 기록을 갱신한다 2026-06-20 03:20:28 +09:00
2ebc728656 feat(creator-channel): 시리즈 탭 조회 정책을 추가한다 2026-06-20 03:19:41 +09:00
3d88dc7b8a feat(creator-channel): 시리즈 탭 조회 계약을 추가한다 2026-06-20 03:19:27 +09:00
7183e5f0ca test(user-creator-chat): Redis 통합 테스트 컨텍스트를 축소한다
embedded Redis 포트를 테스트 설정과 공유하도록 공개한다.

Redis 통합 테스트 전용 Bean만 로드하도록 TestConfiguration을 추가한다.

UserCreatorChat Redis 통합 테스트가 필요한 클래스만 로드하게 제한한다.
2026-06-20 03:12:14 +09:00
04579ccc0c fix(redis): repository 스캔 범위를 제한한다
Redis repository 자동 스캔 대상을 실제 Redis repository 패키지로 제한한다.

불필요한 repository 후보 탐색을 줄여 테스트 컨텍스트 확장과 OOM 재발을 방지한다.
2026-06-20 03:12:03 +09:00
99ee234b46 docs(creator-channel): 시리즈 탭 API 계획을 기록한다 2026-06-20 01:57:18 +09:00
1240f00ea2 docs(osiv): lazy loading 검증 기록을 남긴다 2026-06-20 00:06:02 +09:00
2395c7c208 docs(osiv): lazy loading 요구사항을 기록한다 2026-06-20 00:05:56 +09:00
37ad325cc2 fix(osiv): lazy 관계 선로딩을 보완한다 2026-06-20 00:05:48 +09:00
92fe6caf17 docs(creator-channel): 오디오 탭 테마 필터링 기준을 기록한다 2026-06-19 21:45:22 +09:00
30508e5708 fix(creator-channel): 오디오 탭 테마 조회 컨텍스트를 전달한다 2026-06-19 21:44:53 +09:00
791ce2b8d3 fix(creator-channel): 오디오 탭 테마 조회 조건을 적용한다 2026-06-19 21:44:34 +09:00
e5006d6334 docs(creator-channel): 오디오 탭 Phase 5 기록을 갱신한다 2026-06-19 20:45:05 +09:00
ababd9a962 docs(creator-channel): 오디오 탭 Phase 4 기록을 갱신한다 2026-06-19 19:05:52 +09:00
357d207fcc feat(creator-channel): 오디오 탭 controller를 추가한다 2026-06-19 19:05:41 +09:00
405bb12713 docs(creator-channel): 오디오 탭 Phase 3 기록을 갱신한다 2026-06-19 18:07:25 +09:00
76cc6e6557 feat(creator-channel): 오디오 탭 repository를 추가한다 2026-06-19 18:07:11 +09:00
cffd50c33f refactor(ranking): CDN URL 변환 공통 함수를 사용한다 2026-06-19 16:32:48 +09:00
98241e16b0 refactor(creator-channel): CDN URL 변환 공통 함수를 사용한다 2026-06-19 16:32:24 +09:00
d1fb87556e refactor(cdn): CDN URL 변환 함수를 공통화한다 2026-06-19 16:32:16 +09:00
63c28f8504 docs(cdn): CDN URL 공통화 계획을 기록한다 2026-06-19 16:32:05 +09:00
4ba0116f55 docs(creator-channel): 오디오 탭 Phase 2 기록을 갱신한다 2026-06-19 16:07:28 +09:00
c71f1ed17c feat(creator-channel): 오디오 탭 응답 변환을 추가한다 2026-06-19 16:06:56 +09:00
4fdb9bcb26 feat(creator-channel): 오디오 탭 조회 서비스를 추가한다 2026-06-19 16:06:45 +09:00
80a06ad63d docs(creator-channel): 오디오 탭 Phase 1 기록을 갱신한다 2026-06-19 15:19:31 +09:00
f743d090c3 refactor(creator-channel): 오디오 콘텐츠 응답을 공통화한다 2026-06-19 15:18:48 +09:00
9a1bfed6a4 feat(creator-channel): 오디오 탭 조회 계약을 추가한다 2026-06-19 15:17:18 +09:00
f3a574a54a feat(creator-channel): 오디오 탭 조회 정책을 추가한다 2026-06-19 15:16:36 +09:00
c6b6c16e12 docs(creator-channel): 오디오 탭 API 계획을 기록한다 2026-06-19 14:02:42 +09:00
325 changed files with 35421 additions and 1069 deletions

View File

@@ -101,6 +101,7 @@ tasks.withType<KotlinCompile> {
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
maxHeapSize = "1536m"
} }
tasks.getByName<Jar>("jar") { tasks.getByName<Jar>("jar") {

View File

@@ -543,6 +543,81 @@
- GREEN: 홈 통합 조회에서 배너 조회에도 `memberId`를 전달하고, 배너 조회 포트/서비스/repository가 `CREATOR``bannerCreator.id`, `SERIES``seriesOwner.id` 기준으로 양방향 `block_member` 제외 조건을 적용한다. - GREEN: 홈 통합 조회에서 배너 조회에도 `memberId`를 전달하고, 배너 조회 포트/서비스/repository가 `CREATOR``bannerCreator.id`, `SERIES``seriesOwner.id` 기준으로 양방향 `block_member` 제외 조건을 적용한다.
- 기대 결과: 홈 배너 섹션에서도 차단 관계 크리에이터 또는 시리즈 소유자의 추천 데이터가 노출되지 않는다. - 기대 결과: 홈 배너 섹션에서도 차단 관계 크리에이터 또는 시리즈 소유자의 추천 데이터가 노출되지 않는다.
### Phase 8: AI 캐릭터 추천 item creator id 추가
- [x] **Task 8.1: AI 캐릭터 추천 record에 creator id 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `HomeAiCharacterRecommendationRecord``creatorId`가 없어서 컴파일이 실패하는 service 테스트를 먼저 작성한다. repository 테스트에는 활성 `ChatCharacter.creatorMember.id`가 record의 `creatorId`로 내려오는 케이스와 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 상세 응답에서 제외되는 케이스를 추가한다.
- 실패 확인:
```bash
./gradlew test \
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
```
- GREEN: `HomeAiCharacterRecommendationRecord`에 non-null `creatorId: Long`을 추가하고, `findAiCharacterRecommendationDetails(...)`에서 `chatCharacter.creatorMember`를 inner join해 `creatorMember.id`를 select한다. 상세 조회 조건은 기존 `chatCharacter.isActive = true`와 `characterIds` 조건을 유지하면서 `creatorMember.isActive = true`, `creatorMember.role = CREATOR`, `creatorMember.memberKind = AI_CHARACTER`를 함께 적용한다.
- REFACTOR: 스냅샷 target id는 계속 `characterId`로 유지하고, `creatorId`는 상세 조립 단계에서만 추가한다. AI 캐릭터 추천 점수 산식, 스냅샷 생성, 정렬 순서는 변경하지 않는다.
- 기대 결과: 내부 추천 record가 `characterId`와 `creatorId`를 모두 가지며, AI 캐릭터 전체보기와 홈 통합 조회가 같은 상세 조회 결과를 재사용할 수 있다.
- [x] **Task 8.2: 홈 추천 AI 캐릭터 API 응답에 creatorId 노출**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: `HomeAiCharacterItem` 생성자와 JSON 검증에 `creatorId`를 추가해 기존 구현이 컴파일 또는 JSON assertion에서 실패하도록 한다. 홈 통합 조회의 `$.data.aiCharacters[0].creatorId`와 AI 캐릭터 전체보기의 `$.data.items[0].creatorId`가 내려오는 controller 테스트를 추가한다.
- 실패 확인:
```bash
./gradlew test \
--tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest \
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
```
- GREEN: `HomeAiCharacterItem`에 non-null `creatorId: Long`을 추가하고, `HomeRecommendationFacade.HomeAiCharacterRecommendationRecord.toItem()` 변환에서 `creatorId = creatorId`를 매핑한다.
- REFACTOR: 기존 `characterId` 필드명과 의미는 변경하지 않는다. 신규 `creatorId`는 additive schema 변경으로만 처리하고, 다른 추천 item DTO나 endpoint URL은 변경하지 않는다.
- 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters[]`와 `GET /api/v2/home/recommendations/ai-characters`의 `items[]` 모두 `characterId`와 `creatorId`를 함께 반환한다.
- [x] **Task 8.3: Phase 8 회귀 검증과 문서 기록**
- Files:
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task라 제품 코드 테스트를 새로 작성하지 않는다.
- 대체 검증 방법:
```bash
./gradlew test \
--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.api.home.dto.recommendation.HomeRecommendationResponseTest \
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
./gradlew ktlintCheck
./gradlew tasks --all
```
- 기대 결과: Phase 8 관련 테스트, ktlint, Gradle task 목록 조회가 모두 성공하고, 이 문서 하단 Verification Log에 실행 명령/목적/결과를 누적한다.
---
### Phase 9: 운영 MySQL native query Boolean 매핑 회귀 수정
- [x] **Task 9.1: 첫 오디오 콘텐츠 native query 계산 Boolean 매핑 보정**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- RED: 운영에서 관측된 `is_original_series` native query 결과 `Integer(0/1)` row를 mock query로 재현하고 기존 `row[9] as Boolean` 캐스팅 실패를 확인한다.
- GREEN: `Boolean`과 `Number` 결과를 모두 Boolean으로 변환하는 최소 매핑을 적용한다.
- REFACTOR: 첫 오디오 콘텐츠 매핑 범위 밖 공개 API 스키마와 추천 정책은 변경하지 않는다.
- 검증 기준:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- Run: `./gradlew ktlintCheck`
- 기대 결과: 운영 MySQL/JDBC에서 `EXISTS` 계산 컬럼이 `Integer`로 반환되어도 `isOriginalSeries`가 정상 매핑된다.
- 검증 기록(2026-06-27):
- 무엇을: 운영에서 관측된 `is_original_series` native query 계산 컬럼의 `Integer(0/1)` 반환을 첫 오디오 콘텐츠 row 매핑에서 처리하는지 확인했다.
- 왜: 기존 `row[9] as Boolean` 캐스팅이 운영 MySQL/JDBC 결과에서 `ClassCastException`을 발생시켰기 때문이다.
- 어떻게: RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`를 추가하고 `row[9] = 1` mock row로 기존 구현의 `ClassCastException` 실패를 확인했다. GREEN에서 native Boolean 변환 helper를 적용한 뒤 동일 단일 테스트와 repository 테스트 클래스 전체를 재실행했다.
- 결과: 단일 테스트는 RED에서 `ClassCastException`으로 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`이 모두 `BUILD SUCCESSFUL`로 통과했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 1차 실패했고 권한 상승 재실행으로 통과했다.
--- ---
## PRD Coverage Check ## PRD Coverage Check
@@ -552,19 +627,23 @@
- Feature C: Task 3.1과 Task 7.7에서 기존 콘텐츠 홈 배너 재활용, orders 정렬, 동일 orders 랜덤 정렬, 활성 배너/콘텐츠 조건, `EVENT`/`CREATOR`/`SERIES` 대상 비활성 제외, `CREATOR`/`SERIES` 대상 양방향 차단 제외, `LINK` 배너의 자체 활성 상태 기준 노출, 앱 이동 필드 유지를 검증한다. - Feature C: Task 3.1과 Task 7.7에서 기존 콘텐츠 홈 배너 재활용, orders 정렬, 동일 orders 랜덤 정렬, 활성 배너/콘텐츠 조건, `EVENT`/`CREATOR`/`SERIES` 대상 비활성 제외, `CREATOR`/`SERIES` 대상 양방향 차단 제외, `LINK` 배너의 자체 활성 상태 기준 노출, 앱 이동 필드 유지를 검증한다.
- Feature D: Task 1.3, Task 3.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable을 검증한다. - Feature D: Task 1.3, Task 3.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable을 검증한다.
- Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다. - Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다.
- Feature F: Task 1.1, Task 3.2, Task 6.3에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외 검증한다. - Feature F: Task 1.1, Task 3.2, Task 6.3, Task 9.1에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외, native query Boolean 계산 컬럼 매핑을 검증한다.
- Feature G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기 검증한다. - Feature G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3, Task 8.1, Task 8.2에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기, AI 캐릭터에 대응하는 `creatorId` 노출을 검증한다.
- Feature H: Task 4.1, Task 4.2, Task 4.3, Task 4.4에서 장르 조회 이력, 조회 이력 없을 때 랜덤 장르, 부족분 랜덤 보충, 한 응답 내 크리에이터 중복 제거, 조회 시점별 재노출 허용, 팔로우 제외, 조회자 본인 크리에이터 제외, 성인 장르 조건, 크리에이터 프로필 이미지/닉네임/id 노출을 검증한다. - Feature H: Task 4.1, Task 4.2, Task 4.3, Task 4.4에서 장르 조회 이력, 조회 이력 없을 때 랜덤 장르, 부족분 랜덤 보충, 한 응답 내 크리에이터 중복 제거, 조회 시점별 재노출 허용, 팔로우 제외, 조회자 본인 크리에이터 제외, 성인 장르 조건, 크리에이터 프로필 이미지/닉네임/id 노출을 검증한다.
- Feature I: Task 5.1, Task 5.2에서 장르의 크리에이터와 최근 응원이 많은 크리에이터가 공통 동시 팔로우 use case를 재사용하고, 이미 팔로우 중인 id와 본인 id는 서버 내부에서 제외하며, 비활성 팔로우 이력은 재활성화하고, 존재하지 않는 id/크리에이터가 아닌 id는 전체 실패로 처리하는지 검증한다. - Feature I: Task 5.1, Task 5.2에서 장르의 크리에이터와 최근 응원이 많은 크리에이터가 공통 동시 팔로우 use case를 재사용하고, 이미 팔로우 중인 id와 본인 id는 서버 내부에서 제외하며, 비활성 팔로우 이력은 재활성화하고, 존재하지 않는 id/크리에이터가 아닌 id는 전체 실패로 처리하는지 검증한다.
- Feature J: Task 1.1, Task 2.2, Task 2.3.1, Task 2.4, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 5.1, Task 5.2에서 최근 응원 점수/스냅샷 조회, 스냅샷 일 배치 클러스터 단일 실행, 8명 limit, 크리에이터 프로필 이미지/닉네임 노출, `CHANNEL_DONATION` 기준 후원 금액/후원 수, 팬 Talk 수, 최근 7일 집계, 데뷔일 기준 신규 부스트, DB-side exact scoring, 해당 섹션의 동시 팔로우를 검증한다. - Feature J: Task 1.1, Task 2.2, Task 2.3.1, Task 2.4, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 5.1, Task 5.2에서 최근 응원 점수/스냅샷 조회, 스냅샷 일 배치 클러스터 단일 실행, 8명 limit, 크리에이터 프로필 이미지/닉네임 노출, `CHANNEL_DONATION` 기준 후원 금액/후원 수, 팬 Talk 수, 최근 7일 집계, 데뷔일 기준 신규 부스트, DB-side exact scoring, 해당 섹션의 동시 팔로우를 검증한다.
- Feature K: Task 1.1, Task 2.2, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 7.1에서 인기 커뮤니티 점수/조건/홈 통합 응답 노출 필드(크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 내용)/댓글 불가 게시글 댓글 수 0점 계산, 데뷔일 기준 신규 부스트, 최근 7일 집계, DB-side exact scoring을 검증한다. - Feature K: Task 1.1, Task 2.2, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 7.1에서 인기 커뮤니티 점수/조건/홈 통합 응답 노출 필드(크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 내용)/댓글 불가 게시글 댓글 수 0점 계산, 데뷔일 기준 신규 부스트, 최근 7일 집계, DB-side exact scoring을 검증한다.
- Metrics: Task 7.2에서 메인 홈 API 성공률/응답 시간, 섹션별 빈 응답 비율, 전체보기 API 조회 수, 추천 섹션별 클릭률, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공률, 일 배치 집계 성공/실패 수와 스냅샷 생성 소요 시간의 로그 또는 metric 기록 지점을 검증한다. - Metrics: Task 7.2에서 메인 홈 API 성공률/응답 시간, 섹션별 빈 응답 비율, 전체보기 API 조회 수, 추천 섹션별 클릭률, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공률, 일 배치 집계 성공/실패 수와 스냅샷 생성 소요 시간의 로그 또는 metric 기록 지점을 검증한다.
- Technical Constraints/Non-Goals: Phase 1~7에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, 기존 공개 스키마 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서 검증한다. - Technical Constraints/Non-Goals: Phase 1~7과 Phase 9에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, 기존 공개 스키마 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, native query Boolean 계산 컬럼 매핑은 Task 9.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서 검증한다.
--- ---
## Verification Log ## Verification Log
- 2026-06-27: Phase 9 코드 리뷰 및 검증을 진행했다. 변경 범위가 첫 오디오 콘텐츠 native query row 매핑의 Boolean 변환 보정과 운영 회귀 테스트/문서 보강에 한정되어 있는지 확인했고, `isPointAvailable`, `isAdult`, `isOriginalSeries`가 `Boolean` 또는 `Number(0/1)` 모두에서 명시적으로 Boolean으로 변환되는지 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `git diff --check --cached`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
- 2026-06-23: Phase 8 코드 리뷰 및 검증을 진행했다. 변경 범위가 `creatorId` additive schema 추가에 한정되어 있는지 확인했고, `HomeAiCharacterRecommendationRecord.creatorId` → `HomeAiCharacterItem.creatorId` 매핑, `ChatCharacter.creatorMember` inner join과 활성/CREATOR/AI_CHARACTER 필터, 홈 통합/AI 캐릭터 전체보기 JSON 응답 검증 테스트를 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --rerun-tasks --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.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
- 2026-06-23: Phase 8 구현을 진행했다. RED에서 `HomeAiCharacterRecommendationRecord`와 `HomeAiCharacterItem`의 `creatorId` 미구현으로 `compileTestKotlin`이 실패하는 것을 확인했고, GREEN에서 `HomeAiCharacterRecommendationRecord.creatorId`, AI 캐릭터 상세 조회의 `ChatCharacter.creatorMember` inner join 및 활성/CREATOR/AI_CHARACTER 조건, `HomeAiCharacterItem.creatorId`, facade 매핑을 추가했다. 회귀 검증으로 `./gradlew test --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.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행해 모두 성공을 확인했다. `ktlintCheck`는 최초 실행에서 import 정렬 오류로 실패했고 import 순서 보정 후 `BUILD SUCCESSFUL`로 통과했다. 리뷰어 지적에 따라 `creatorMember` 누락 row 제외 테스트를 추가했고, 해당 단일 테스트와 Phase 8 대상 테스트 묶음, `ktlintCheck`를 재실행해 모두 성공한 뒤 리뷰어 재검토에서 승인받았다.
- 2026-06-23: 사용자 피드백에 따라 AI 캐릭터 추천 item에 `creatorId`를 추가하는 요구사항을 기존 홈 추천 API PRD와 plan-task에 후속 Phase 8로 보강했다. `creatorId`는 `ChatCharacter.creatorMember.id`로 확정하고, 기존 `characterId`는 AI 채팅 이동 대상 id로 유지하는 additive schema 변경으로 문서화했다. 검증으로 `rg -n "creatorId|Phase 8|Task 8\\.|ChatCharacter.creatorMember|Feature G" docs/20260529_메인_홈_추천_API/prd.md docs/20260529_메인_홈_추천_API/plan-task.md`, `git diff --check`, `./gradlew tasks --all`을 실행했다. `./gradlew tasks --all`은 최초 샌드박스 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했으나, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-05-30: plan-task 문서 작성 전 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, `docs/20260529_메인_홈_추천_API/prd.md`를 확인했다. - 2026-05-30: plan-task 문서 작성 전 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, `docs/20260529_메인_홈_추천_API/prd.md`를 확인했다.
- 2026-05-30: 기존 v2 패키지 구조, 테스트 스타일, QueryDSL/스케줄러 사용 패턴, 관련 엔티티/리포지토리 후보를 `find`, `rg`, `sed`로 확인해 계획의 파일 경로와 검증 명령에 반영했다. - 2026-05-30: 기존 v2 패키지 구조, 테스트 스타일, QueryDSL/스케줄러 사용 패턴, 관련 엔티티/리포지토리 후보를 `find`, `rg`, `sed`로 확인해 계획의 파일 경로와 검증 명령에 반영했다.
- 2026-05-30: `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck` 생성 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 13s`를 확인했다. - 2026-05-30: `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck` 생성 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 13s`를 확인했다.

View File

@@ -50,6 +50,7 @@
- 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다. - 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다.
- 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다. - 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다.
- 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다. - 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다.
- 사용자는 추천 AI 캐릭터의 채팅 화면뿐 아니라 크리에이터 채널로도 이동할 수 있도록 AI 캐릭터에 대응하는 creator id를 받고 싶다.
- 사용자는 내가 봤던 콘텐츠 장르 또는 랜덤 장르 기준으로 팔로우하지 않은 크리에이터를 추천받고 싶다. - 사용자는 내가 봤던 콘텐츠 장르 또는 랜덤 장르 기준으로 팔로우하지 않은 크리에이터를 추천받고 싶다.
- 사용자는 장르 추천에서 여러 크리에이터를 한 번에 팔로우하고 싶다. - 사용자는 장르 추천에서 여러 크리에이터를 한 번에 팔로우하고 싶다.
- 사용자는 최근 응원이 많은 크리에이터를 순위로 보고 싶다. - 사용자는 최근 응원이 많은 크리에이터를 순위로 보고 싶다.
@@ -163,7 +164,9 @@
- AI 캐릭터 리스트를 조회한다. - AI 캐릭터 리스트를 조회한다.
- 홈 첫 화면은 10개를 조회한다. - 홈 첫 화면은 10개를 조회한다.
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다. - 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
- 노출 정보는 캐릭터 이름, 캐릭터 소개, 작품명, 사용자들이 친 전체 채팅 수를 포함한다. - 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
- 기존 `characterId`는 AI 채팅 이동 대상 id로 유지하고, 신규 `creatorId`는 크리에이터 채널/Member 기반 기능 이동 대상 id로 별도 제공한다.
- 작품명은 오리지널 작품 캐릭터인 경우에만 내려준다. - 작품명은 오리지널 작품 캐릭터인 경우에만 내려준다.
- 1차 정렬은 AI 채팅 추천 점수 내림차순이다. - 1차 정렬은 AI 채팅 추천 점수 내림차순이다.
- 2차 정렬은 동일 점수인 경우 랜덤이다. - 2차 정렬은 동일 점수인 경우 랜덤이다.
@@ -177,6 +180,7 @@
#### Edge Cases #### Edge Cases
- 비활성 또는 노출 제한 캐릭터는 제외한다. - 비활성 또는 노출 제한 캐릭터는 제외한다.
- 활성 `ChatCharacter``creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다.
### Feature H. 장르의 크리에이터 ### Feature H. 장르의 크리에이터
@@ -258,11 +262,13 @@
- `v2.api.home``v2.recommendation` 모두 필요한 범위에서 경량 헥사고날 아키텍처를 적용하고, 기본 하위 패키지는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다. - `v2.api.home``v2.recommendation` 모두 필요한 범위에서 경량 헥사고날 아키텍처를 적용하고, 기본 하위 패키지는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다.
- Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다. - Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다.
- `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다. - `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다.
- 홈 추천 AI 캐릭터 응답의 `creatorId` 추가는 기존 `characterId` 의미를 변경하지 않는 additive schema 변경으로만 처리한다.
- 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다. - 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다.
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다. - `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다.
- 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다. - 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다.
- 조회 구현은 복잡도에 맞춰 선택한다. 단순 id 조회, 단건 조회, 명확한 조건 조회는 Spring Data JPA 기본 메서드 또는 `@Query`를 사용할 수 있고, 동적 조건/집계/서브쿼리/복합 정렬이 필요한 경우 QueryDSL을 우선 사용한다. - 조회 구현은 복잡도에 맞춰 선택한다. 단순 id 조회, 단건 조회, 명확한 조건 조회는 Spring Data JPA 기본 메서드 또는 `@Query`를 사용할 수 있고, 동적 조건/집계/서브쿼리/복합 정렬이 필요한 경우 QueryDSL을 우선 사용한다.
- native SQL은 CTE, window function, `union all`, DB-side exact scoring, DB별 랜덤 tie-breaker처럼 QueryDSL/JPA 표현이 부자연스럽거나 정확도/성능을 해칠 수 있는 경우에만 사용한다. native SQL을 사용할 때는 RED 단계에서 제외 조건, null aggregate, boundary window, 정렬/limit 순서, Kotlin 정책 산식과의 parity를 촘촘히 검증한다. - native SQL은 CTE, window function, `union all`, DB-side exact scoring, DB별 랜덤 tie-breaker처럼 QueryDSL/JPA 표현이 부자연스럽거나 정확도/성능을 해칠 수 있는 경우에만 사용한다. native SQL을 사용할 때는 RED 단계에서 제외 조건, null aggregate, boundary window, 정렬/limit 순서, Kotlin 정책 산식과의 parity를 촘촘히 검증한다.
- native SQL 결과 매핑에서 `exists`, `case`, 집계식처럼 DB/JDBC 드라이버가 `0/1` 숫자로 반환할 수 있는 Boolean 계산 컬럼은 `Boolean` 직접 캐스팅에 의존하지 않고 명시적으로 Boolean 값으로 변환한다.
- 홈 추천 조회에는 공통 차단 필터를 적용해 내가 차단했거나 나를 차단한 크리에이터의 데이터를 제외한다. - 홈 추천 조회에는 공통 차단 필터를 적용해 내가 차단했거나 나를 차단한 크리에이터의 데이터를 제외한다.
- 커뮤니티 게시글 조회에는 비공개 제외, 유료 글 제외, 핀 고정 글 제외, 성인 노출 조건(`MemberContentPreference.isAdultContentVisible`)을 공통 적용한다. - 커뮤니티 게시글 조회에는 비공개 제외, 유료 글 제외, 핀 고정 글 제외, 성인 노출 조건(`MemberContentPreference.isAdultContentVisible`)을 공통 적용한다.
- 일 1회 갱신 섹션은 조회 시점마다 무거운 집계를 하지 않도록 집계 테이블 또는 스냅샷 엔티티를 신규로 둔다. - 일 1회 갱신 섹션은 조회 시점마다 무거운 집계를 하지 않도록 집계 테이블 또는 스냅샷 엔티티를 신규로 둔다.

View File

@@ -59,3 +59,72 @@ create index idx_creator_ranking_snapshot_job_period_status
create index idx_creator_ranking_snapshot_job_status_created_at create index idx_creator_ranking_snapshot_job_status_created_at
on creator_ranking_snapshot_job (status, created_at); on creator_ranking_snapshot_job (status, created_at);
-- 이미 위 CREATE DDL이 적용된 DB의 시간 정책 변경용 DDL
-- 목적:
-- 1. 현재 기본 랭킹 타입(WEEKLY)을 명시한다.
-- 2. 공개 조회 노출 시작 시각(visible_from_at)을 저장한다.
-- 3. 최신 생성 스냅샷이 아니라 visible_from_at <= now 조건의 최신 공개 스냅샷을 조회할 수 있게 인덱스를 보강한다.
-- 주의:
-- 운영 DB 반영 시 중복 스냅샷이 있으면 uk_creator_ranking_snapshot_period_creator 생성 전 정리한다.
-- visible_from_at backfill 기준은 aggregation_end_at_utc + 9시간이다.
-- 예: 2026-06-07 15:00:00 UTC 집계 종료는 2026-06-08 00:00:00 KST이고,
-- 노출 전환 2026-06-08 09:00:00 KST는 2026-06-08 00:00:00 UTC다.
alter table creator_ranking_snapshot
add column ranking_type varchar(30) null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)' after id,
add column visible_from_at timestamp null comment '공개 조회 노출 시작 시각(UTC)' after aggregation_end_at_utc;
update creator_ranking_snapshot
set ranking_type = 'WEEKLY'
where ranking_type is null;
update creator_ranking_snapshot
set visible_from_at = timestampadd(hour, 9, aggregation_end_at_utc)
where visible_from_at is null;
alter table creator_ranking_snapshot
modify column ranking_type varchar(30) not null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)',
modify column visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)';
create unique index uk_creator_ranking_snapshot_period_creator
on creator_ranking_snapshot (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, creator_id);
drop index idx_creator_ranking_snapshot_period_score on creator_ranking_snapshot;
create index idx_creator_ranking_snapshot_period_score
on creator_ranking_snapshot (ranking_type, aggregation_end_at_utc, final_score desc);
create index idx_creator_ranking_snapshot_visible_score
on creator_ranking_snapshot (ranking_type, visible_from_at desc, final_score desc);
drop index idx_creator_ranking_snapshot_replace_period on creator_ranking_snapshot;
drop index idx_creator_ranking_snapshot_period_creator on creator_ranking_snapshot;
alter table creator_ranking_snapshot_job
add column ranking_type varchar(30) null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)' after id,
add column visible_from_at timestamp null comment '공개 조회 노출 시작 시각(UTC)' after aggregation_end_at_utc;
update creator_ranking_snapshot_job
set ranking_type = 'WEEKLY'
where ranking_type is null;
update creator_ranking_snapshot_job
set visible_from_at = timestampadd(hour, 9, aggregation_end_at_utc)
where visible_from_at is null;
alter table creator_ranking_snapshot_job
modify column ranking_type varchar(30) not null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)',
modify column visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)';
drop index idx_creator_ranking_snapshot_job_period_status on creator_ranking_snapshot_job;
create index idx_creator_ranking_snapshot_job_period_status
on creator_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, status);
create index idx_creator_ranking_snapshot_job_visible_status
on creator_ranking_snapshot_job (ranking_type, visible_from_at, status);
create index idx_creator_ranking_snapshot_job_trigger_period
on creator_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, trigger_type, created_at);

View File

@@ -2,9 +2,9 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 홈 내부 랭킹 탭에서 `GET /api/v2/home/rankings/creators`로 KST 기준 지난 주 크리에이터 랭킹 상위 20명을 조회한다. **Goal:** 홈 내부 랭킹 탭에서 `GET /api/v2/home/rankings/creators`로 KST 기준 지난 주 크리에이터 랭킹`visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷의 상위 20명을 조회한다.
**Architecture:** 공개 endpoint는 home 하위 URL을 사용하고, 클라이언트 API 표면(Controller, API 조합 Facade, DTO)은 기존 홈 API 관례에 맞춰 `kr.co.vividnext.sodalive.v2.api.home` 하위에 둔다. 랭킹 기능 본체(domain/application/port/persistence/scheduler)는 추천 기능과 분리된 `kr.co.vividnext.sodalive.v2.ranking` 하위에 둔다. 주간 스냅샷 생성 작업이 KST 기간을 UTC DB 조회 조건으로 변환해 원천 데이터를 집계하고, 조회 API는 최신 완료 주차 스냅샷을 우선 읽어 응답을 조립한다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다. **Architecture:** 공개 endpoint는 home 하위 URL을 사용하고, 클라이언트 API 표면(Controller, API 조합 Facade, DTO)은 기존 홈 API 관례에 맞춰 `kr.co.vividnext.sodalive.v2.api.home` 하위에 둔다. 랭킹 기능 본체(domain/application/port/persistence/scheduler)는 추천 기능과 분리된 `kr.co.vividnext.sodalive.v2.ranking` 하위에 둔다. 주간 스냅샷 생성 작업이 KST 기간을 UTC DB 조회 조건으로 변환해 원천 데이터를 집계하고, 조회 API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 읽어 응답을 조립한다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있고, fallback 응답도 공개 노출 전환 시각을 넘긴 기간에만 허용한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL 또는 native SQL, JUnit 5, Gradle Wrapper **Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL 또는 native SQL, JUnit 5, Gradle Wrapper
@@ -15,13 +15,19 @@
- API endpoint: `GET /api/v2/home/rankings/creators` - API endpoint: `GET /api/v2/home/rankings/creators`
- 랭킹 기능 본체 패키지: `kr.co.vividnext.sodalive.v2.ranking` - 랭킹 기능 본체 패키지: `kr.co.vividnext.sodalive.v2.ranking`
- 홈 공개 API 조립 패키지: `kr.co.vividnext.sodalive.v2.api.home` - 홈 공개 API 조립 패키지: `kr.co.vividnext.sodalive.v2.api.home`
- 집계 기간: 조회/스냅샷 생성 시점 기준 KST 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만 - 집계 기준 시각: 매주 월요일 00:00:00 KST. 이 시각을 주간 집계 종료 경계로 사용한다.
- 집계 기간: 생성 시점 기준 KST 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만
- DB 조회 기간: KST 집계 기간을 UTC 기준 `LocalDateTime` 또는 프로젝트 표준 시간 타입으로 변환한 기간 - DB 조회 기간: KST 집계 기간을 UTC 기준 `LocalDateTime` 또는 프로젝트 표준 시간 타입으로 변환한 기간
- 스냅샷 생성 스케줄 후보: 매주 월요일 KST 07:30, `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")` - 스냅샷 생성 스케줄 후보: 매주 월요일 KST 01:00, `@Scheduled(cron = "0 0 1 * * MON", zone = "Asia/Seoul")`
- 스냅샷 노출 전환 시각: 매주 월요일 KST 09:00. 스냅샷과 job 이력에 `visibleFromAt`으로 저장한다.
- 현재 기본 크리에이터 랭킹 타입: `WEEKLY`. 스냅샷과 job 이력에 `rankingType`으로 저장한다.
- 다중 서버 인스턴스에서 스냅샷 스케줄러가 중복 실행되지 않도록 기존 Redisson 기반 분산 lock을 사용한다. - 다중 서버 인스턴스에서 스냅샷 스케줄러가 중복 실행되지 않도록 기존 Redisson 기반 분산 lock을 사용한다.
- 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다. - 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다. - 조회 API는 스냅샷 기반 응답을 기본으로 하며, 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 응답한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다. - 조회 시 09:00 KST 전에는 01:00 KST에 생성된 새 주차 스냅샷이 있어도 직전 공개 스냅샷을 유지한다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
- fallback 응답도 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족할 때만 공개한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 공개 스냅샷 기준 응답을 유지한다.
- 스냅샷 테이블이 완전히 비어 있는 cold-start fallback 성공 시 조회 API는 fallback 응답을 반환하고, 같은 집계 기간의 스냅샷 생성은 조회 서비스가 직접 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임한다. - 스냅샷 테이블이 완전히 비어 있는 cold-start fallback 성공 시 조회 API는 fallback 응답을 반환하고, 같은 집계 기간의 스냅샷 생성은 조회 서비스가 직접 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임한다.
- cold-start fallback 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 보강책이며, 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용한다. - cold-start fallback 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 보강책이며, 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용한다.
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다. - 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다.
@@ -32,7 +38,7 @@
- raw value 방식으로 계산하며 0~100 정규화는 하지 않는다. - raw value 방식으로 계산하며 0~100 정규화는 하지 않는다.
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다. - 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
- 동점자는 조회 시 랜덤 정렬로 상위 20명을 추출하고, 별도 `randomTieBreaker`는 저장하지 않는다. - 동점자는 조회 시 랜덤 정렬로 상위 20명을 추출하고, 별도 `randomTieBreaker`는 저장하지 않는다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange=false`, `rankChange=null`, `isNew=false`로 응답한다. - 직전 공개 스냅샷이 없으면 `showRankChange=false`, `rankChange=null`, `isNew=false`로 응답한다.
- 비활성 및 탈퇴 크리에이터는 랭킹에 노출하지 않는다. - 비활성 및 탈퇴 크리에이터는 랭킹에 노출하지 않는다.
- 차단 관계가 있으면 row는 유지하되 `creatorId=0`, `nickname=""`, `profileImageUrl=기본 이미지 URL`로 마스킹한다. - 차단 관계가 있으면 row는 유지하되 `creatorId=0`, `nickname=""`, `profileImageUrl=기본 이미지 URL`로 마스킹한다.
- 신규 팔로우 수는 `CreatorFollowing.createdAt` 기준, 언팔로우 수는 `CreatorFollowing.isActive == false``CreatorFollowing.updatedAt` 기준으로 계산한다. - 신규 팔로우 수는 `CreatorFollowing.createdAt` 기준, 언팔로우 수는 `CreatorFollowing.isActive == false``CreatorFollowing.updatedAt` 기준으로 계산한다.
@@ -92,6 +98,22 @@
--- ---
## 1.1 DDL 영향도: `visible_from_at`, `ranking_type`
- `creator_ranking_snapshot`에는 `ranking_type varchar(30) not null`, `visible_from_at timestamp not null`을 추가한다.
- `creator_ranking_snapshot_job`에는 `ranking_type varchar(30) not null`, `visible_from_at timestamp not null`을 추가한다.
- 현재 기본 타입 값은 `WEEKLY`로 문서화하고, 코드 구현 시 `CreatorRankingType` 또는 동등한 enum/상수로 고정한다.
- `visible_from_at`은 집계 종료일 월요일 09:00:00 KST를 UTC로 변환한 값이다. 예: 2026-06-08 09:00:00 KST는 2026-06-08 00:00:00 UTC다.
- `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`의 기존 CREATE DDL은 이미 적용된 기준으로 유지하고, 하단에 운영 반영용 ALTER DDL을 추가한다.
- 운영 DB 변경은 `ADD nullable column -> backfill -> MODIFY NOT NULL -> index 보강/교체` 순서로 적용한다.
- backfill은 `ranking_type='WEEKLY'`, `visible_from_at=aggregation_end_at_utc + interval 9 hour` 기준으로 수행한다.
- 같은 타입/기간 재생성 삭제 기준은 `ranking_type + aggregation_start_at_utc + aggregation_end_at_utc`다.
- 중복 방지 기준은 `ranking_type + aggregation_start_at_utc + aggregation_end_at_utc + creator_id` unique index다.
- 최신 공개 스냅샷 조회는 `ranking_type = WEEKLY and visible_from_at <= nowUtc` 조건에서 가장 큰 `visible_from_at`을 찾은 뒤 해당 스냅샷 row를 `final_score desc` 기준으로 읽는다.
- 직전 공개 스냅샷 조회는 최신 공개 스냅샷보다 작은 `visible_from_at` 중 가장 큰 값을 기준으로 읽는다.
- job 목록/재시도 조회는 `ranking_type + aggregation period + status`, `ranking_type + visible_from_at + status`, `ranking_type + aggregation period + trigger_type + created_at` 인덱스를 사용한다.
- 공개 API 응답 DTO에는 `rankingType`, `visibleFromAt`, 집계 기간, fallback 여부를 노출하지 않는다.
### Phase 1: 기간/점수 도메인 정책 ### Phase 1: 기간/점수 도메인 정책
- [x] **Task 1.1: KST 주간 기간 산출과 UTC 조회 기간 변환 정책 작성** - [x] **Task 1.1: KST 주간 기간 산출과 UTC 조회 기간 변환 정책 작성**
@@ -445,6 +467,90 @@
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다. - REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
- 기대 결과: cold-start 스냅샷 생성 보강이 기존 스케줄/관리자/조회 경로를 깨지 않는다. - 기대 결과: cold-start 스냅샷 생성 보강이 기존 스케줄/관리자/조회 경로를 깨지 않는다.
### Phase 12: 크리에이터 랭킹 시간 정책 변경
> Phase 1~11은 완료 당시의 구현 이력이다. 시간 정책 변경은 완료된 task를 다시 수행하는 방식이 아니라, Phase 12에서 기존 07:30 생성 스케줄, 최신 완료 주차 조회, 기존 DDL/엔티티/port 구조를 `01:00 생성 후보`, `09:00 노출 전환`, `visibleFromAt <= now` 최신 공개 스냅샷 조회 기준으로 변경한다.
- [x] **Task 12.1: 집계/생성/노출 시각 분리 정책 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicyTest.kt`
- RED: 월요일 00:00:00 KST를 집계 종료 경계로 유지하고, 집계 종료일 월요일 09:00:00 KST가 `visibleFromAtUtc`로 변환되는 테스트를 작성한다. 2026-06-08 09:00:00 KST가 2026-06-08 00:00:00 UTC로 변환되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest`
- GREEN: `resolveVisibleFromAtUtc(aggregationEndAtKst)` 또는 동등한 메서드를 추가하고, 기존 집계 기간 산출은 변경하지 않는다.
- REFACTOR: 생성 후보 시각(01:00 KST)은 scheduler 책임으로 두고, period policy는 집계 기간과 공개 노출 시각 계산에 집중한다.
- 기대 결과: 집계 기준 시각과 공개 노출 전환 시각이 코드와 테스트에서 분리된다.
- [x] **Task 12.2: `rankingType`, `visibleFromAt` 스냅샷/job 저장 구조 반영**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
- Modify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepositoryTest.kt`
- RED: 스냅샷과 job record가 `rankingType=WEEKLY`, `visibleFromAtUtc`를 저장하고, 같은 타입/기간/크리에이터 중복 저장이 불가능하며, 같은 타입/기간 replace가 기존 row를 제거하는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest`
- GREEN: entity/record/port에 `rankingType`, `visibleFromAtUtc`를 추가하고, 운영 DB 변경용 ALTER DDL을 문서화한다. 기본 타입 `WEEKLY`를 생성/조회 경로에 전달한다.
- REFACTOR: DDL 컬럼명은 `ranking_type`, `visible_from_at`으로 유지하고, Kotlin 필드명은 기존 시간 필드 관례에 맞춰 `visibleFromAtUtc`로 둔다.
- 기대 결과: 스냅샷과 job 이력이 공개 노출 기준으로 조회될 수 있는 데이터를 가진다.
- [x] **Task 12.3: 스냅샷 생성 스케줄을 월요일 01:00 KST로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: scheduler method에 `@Scheduled(cron = "0 0 1 * * MON", zone = "Asia/Seoul")`가 선언되어 있는지 검증하고, 스케줄 job이 `visibleFromAtUtc`를 월요일 09:00 KST 기준으로 저장하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: 기존 07:30 cron을 01:00 cron으로 변경하고, refresh/job 생성 경로에 `visibleFromAtUtc`를 전달한다.
- REFACTOR: lock key는 기존 중복 실행 방지 정책을 유지하되, 기간 기반 lock 내부에서 `rankingType`이 필요한 경우 lock key에 포함할지 테스트로 고정한다.
- 기대 결과: 생성 후보 시각이 집계 종료 1시간 뒤로 당겨져도 공개 노출은 09:00까지 지연된다.
- [x] **Task 12.4: 조회 API를 최신 생성 스냅샷이 아닌 최신 공개 스냅샷 기준으로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: 01:00 KST에 새 스냅샷이 생성되어도 08:59:59 KST 조회는 직전 공개 스냅샷을 반환하고, 09:00:00 KST 조회는 새 스냅샷을 반환하는 테스트를 작성한다. 직전 공개 스냅샷 기준 `rankChange`, `isNew`, `showRankChange` 계산도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: snapshot port에 `findLatestVisibleSnapshots(rankingType, nowUtc)``findPreviousVisibleSnapshots(rankingType, nowUtc)` 또는 동등한 메서드를 추가하고, query service가 이 메서드만 사용하도록 변경한다.
- REFACTOR: 기존 `findLatestSnapshots()`/`findPreviousCompletedSnapshots()`가 더 이상 공개 조회에 쓰이지 않으면 제거하거나 관리자/테스트 전용으로 명확히 제한한다.
- 기대 결과: 공개 API가 latest generated가 아니라 latest visible 스냅샷만 응답한다.
- [x] **Task 12.5: cold-start fallback 공개 노출 조건 보강과 회귀 검증**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: 스냅샷 테이블이 완전히 비어 있어도 fallback 대상 기간의 `visibleFromAtUtc > nowUtc`이면 새 주차 결과를 응답하지 않는 테스트를 작성한다. `visibleFromAtUtc <= nowUtc`이면 기존 fallback 응답과 스냅샷 생성 위임이 유지되는지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: fallback 집계 전에 공개 가능 여부를 검사하고, 공개 불가 시 빈 응답 또는 직전 공개 스냅샷 응답을 유지한다.
- REFACTOR: 공개 API 응답 DTO에는 `visibleFromAtUtc`, `rankingType`, fallback 여부를 추가하지 않는다.
- 기대 결과: 초기 상태 보강책도 09:00 공개 전환 정책을 우회하지 않는다.
- [x] **Task 12.6: 시간 정책 변경 문서/DDL 정합성 검증**
- Files:
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- RED: 테스트 작성 예외. `TDD 예외 사유`: 문서와 DDL 변경 범위 검증 task다.
- 대체 검증 방법:
- `rg -n "07:30|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|latest visible|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹`
- `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- `./gradlew tasks --all`
- GREEN: 문서에 남은 07:30 표현은 과거 검증 기록 또는 기존 정책 언급인지 확인하고, 현재 목표/신규 task에는 01:00 생성과 09:00 노출 전환만 남긴다.
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
- 기대 결과: PRD, 구현 계획, DDL이 같은 시간 정책과 공개 조회 기준을 설명한다.
--- ---
## 2. PRD 요구사항 추적 ## 2. PRD 요구사항 추적
@@ -455,8 +561,8 @@
- Feature D: Task 1.2, Task 3.3, Task 4.1에서 채널 후원 캔/건수와 최상위 팬 Talk 집계를 검증한다. - Feature D: Task 1.2, Task 3.3, Task 4.1에서 채널 후원 캔/건수와 최상위 팬 Talk 집계를 검증한다.
- Feature E: Task 1.2, Task 3.4, Task 4.1에서 최종 팔로우 수와 `createdAt`/`updatedAt` 기반 팔로우 증가 수를 검증한다. - Feature E: Task 1.2, Task 3.4, Task 4.1에서 최종 팔로우 수와 `createdAt`/`updatedAt` 기반 팔로우 증가 수를 검증한다.
- Feature F: Task 1.2, Task 4.1, Task 5.1에서 raw value 최종 점수, 1점 미만 제외, 20위 동점 후보 저장, 동점 랜덤 조회를 검증한다. - Feature F: Task 1.2, Task 4.1, Task 5.1에서 raw value 최종 점수, 1점 미만 제외, 20위 동점 후보 저장, 동점 랜덤 조회를 검증한다.
- Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증하고, Task 11.2에서 fallback 성공 후 응답을 깨지 않고 스냅샷 생성 책임을 위임하는 흐름을 검증한다. - Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증하고, Task 11.2에서 fallback 성공 후 응답을 깨지 않고 스냅샷 생성 책임을 위임하는 흐름을 검증한다. Task 12.4, Task 12.5에서 조회 API가 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷만 응답하도록 검증한다.
- Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다. Task 11.1, Task 11.2에서 cold-start fallback 성공 후 기간 기반 lock으로 동일 기간 스냅샷 생성 중복을 방지하는 보강책을 검증한다. - Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다. Task 11.1, Task 11.2에서 cold-start fallback 성공 후 기간 기반 lock으로 동일 기간 스냅샷 생성 중복을 방지하는 보강책을 검증한다. Task 12.1~12.6에서 집계 기준 00:00 KST, 생성 후보 01:00 KST, 노출 전환 09:00 KST, `rankingType`/`visibleFromAt` DDL 영향과 최신 공개 스냅샷 조회 정책을 검증한다.
- Feature I: Phase 5의 ranking 기능 본체는 `v2.ranking` 패키지 경계를 유지하고, Phase 6의 클라이언트 API 표면은 `v2.api.home` 하위에 둔다. Phase 8~10의 관리자/job/fallback 기능도 공개 API 응답 DTO를 변경하지 않는다. - Feature I: Phase 5의 ranking 기능 본체는 `v2.ranking` 패키지 경계를 유지하고, Phase 6의 클라이언트 API 표면은 `v2.api.home` 하위에 둔다. Phase 8~10의 관리자/job/fallback 기능도 공개 API 응답 DTO를 변경하지 않는다.
--- ---
@@ -577,3 +683,30 @@
- 2026-06-09: Phase 11 reviewer 2차 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다. - 2026-06-09: Phase 11 reviewer 2차 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다. - 2026-06-09: Phase 11 reviewer 2차 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다.
- 2026-06-24: 크리에이터 랭킹 시간 정책 변경 문서 작업을 시작해 PRD에 집계 기준 00:00:00 KST, 생성 후보 01:00:00 KST, 노출 전환 09:00:00 KST, 최신 공개 스냅샷(`visibleFromAt <= now`) 조회 정책을 반영했다. `plan-task.md`에는 `visible_from_at`/`ranking_type` DDL 영향도와 신규 Phase 12 Task 12.1~12.6을 추가했다.
- 2026-06-24: 문서 정합성 확인: `rg -n "07:30|0 30 7|최신 완료|완료 주차" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md` 실행 결과 현재 PRD에는 변경 전 정책 표현이 남아 있지 않고, `plan-task.md`의 남은 07:30/최신 완료 표현은 Phase 1~11 완료 당시 이력 또는 과거 검증 기록이며 Phase 12 note에서 신규 변경 범위를 구분했음을 확인했다.
- 2026-06-24: 시간 정책/DDL 키워드 확인: `rg -n "00:00:00 KST|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 PRD, plan-task, DDL에 신규 시간 정책과 컬럼명이 반영됐음을 확인했다.
- 2026-06-24: DDL 핵심 컬럼 확인: `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 두 테이블의 `ranking_type`, `visible_from_at` 컬럼과 조회/관리 인덱스를 확인했다.
- 2026-06-24: 문서 변경 후 Gradle 명령 유효성 확인: `./gradlew tasks --all`은 sandbox 기본 권한에서 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 2s`를 확인했다.
- 2026-06-24: 사용자 피드백에 따라 이미 적용된 `create-ranking-tables.sql`의 CREATE DDL 변경을 되돌리고, 파일 하단에 기존 적용 DB 변경용 ALTER DDL을 추가했다. 컬럼 추가는 기존 row를 고려해 nullable로 추가한 뒤 `WEEKLY``aggregation_end_at_utc + 9시간` 기준으로 backfill하고, 이후 `MODIFY NOT NULL` 및 인덱스 보강/교체를 수행하는 순서로 정리했다.
- 2026-06-24: 피드백 반영 후 문서/DDL 재검증: `git diff -- docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 CREATE DDL 본문은 변경하지 않고 하단 ALTER 섹션만 추가됐음을 확인했다. `rg -n "이미 위 CREATE DDL|alter table creator_ranking_snapshot|add column ranking_type|update creator_ranking_snapshot|modify column ranking_type|drop index|create index idx_creator_ranking_snapshot_visible_score|alter table creator_ranking_snapshot_job|idx_creator_ranking_snapshot_job_visible_status" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 ALTER/backfill/modify/index 변경 순서를 확인했다.
- 2026-06-24: 피드백 반영 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 808ms`를 확인했다.
- 2026-06-24: 완료된 Phase 본문 수정에 대한 혼동을 줄이기 위해 Phase 2.1, Phase 4.2, Phase 5.1의 완료 task 문구는 기존 이력대로 되돌리고, Phase 12 시작부에 “Phase 1~11은 완료 당시 구현 이력이며 시간 정책 변경은 Phase 12에서 수행한다”는 note를 추가했다.
- 2026-06-24: 완료 Phase 문구 원복 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 823ms`를 확인했다.
- 2026-06-24: PRD/plan-task 크로스 체크 결과, PRD Feature A의 변경 전 기간 기준 표현이 09:00 공개 노출 전환 전 응답 정책과 충돌할 수 있어 “스냅샷 생성 또는 fallback 집계 기준 시점” 기준으로 수정했다. `rg -n "조회 시점 기준|2026-06-08 월요일 KST에 조회하면" docs/20260608_크리에이터_랭킹/prd.md`로 PRD 본문에 변경 전 표현이 남지 않았고, `rg -n "집계 기준 시각|생성 후보 시각|노출 전환 시각|visibleFromAt <= now|rankingType|visible_from_at|ranking_type|운영 반영용 ALTER" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 PRD, plan-task, DDL의 요구사항 반영 지점을 확인했다.
- 2026-06-24: PRD/plan-task 크로스 체크 수정 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 762ms`를 확인했다.
- 2026-06-24: Phase 12 Task 12.1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest` 실행 결과 `resolveVisibleFromAtUtc` 미구현으로 `compileTestKotlin` 실패를 확인했다. GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 17s`를 확인했다.
- 2026-06-24: Phase 12 Task 12.2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest` 실행 결과 `CreatorRankingType`, `rankingType`, `visibleFromAtUtc` 미구현으로 `compileTestKotlin` 실패를 확인했다. GREEN 확인: 스냅샷/job 저장 구조 반영 후 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 51s`를 확인했다.
- 2026-06-24: Phase 12 Task 12.3~12.5 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.scheduler.CreatorRankingSnapshotSchedulerTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 `findLatestVisibleSnapshots`/`findPreviousVisibleSnapshots` 미구현으로 `compileTestKotlin` 실패를 확인했다. GREEN 확인: 01:00 KST cron, 최신 공개 스냅샷 조회, cold-start fallback 공개 전 차단 반영 후 Phase 12 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 18s`를 확인했다.
- 2026-06-24: Phase 12 문서/DDL 정합성 검증: `rg -n "07:30|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|latest visible|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹` 실행 결과 남은 07:30 표현은 완료된 Phase 4/과거 검증 기록과 Phase 12 변경 note임을 확인했고, 현재 목표/신규 task에는 01:00 생성과 09:00 노출 전환이 반영됐음을 확인했다. `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 ALTER/backfill/not-null/index 변경 순서를 확인했다.
- 2026-06-24: Phase 12 ranking/API/admin 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 `BUILD SUCCESSFUL in 42s`를 확인했다.
- 2026-06-24: Phase 12 포맷 검증: `./gradlew ktlintCheck`는 최초 신규 테스트 긴 줄과 import 순서 위반으로 실패했고, 포맷 정리 후 재실행해 `BUILD SUCCESSFUL in 10s`를 확인했다.
- 2026-06-24: Phase 12 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 2m 18s`를 확인했다.
- 2026-06-24: Phase 12 reviewer gate 1차 Code Quality 검토: 스케줄 job 생성/PROCESSING/refresh/DONE/FAILED 기록이 하나의 `REQUIRES_NEW` 트랜잭션에 묶여 refresh 실패 시 `FAILED` 기록도 롤백될 수 있어 `FAIL` 판정을 확인했다.
- 2026-06-24: Phase 12 reviewer 수정 focused 검증: `CreatorRankingSnapshotJobService`의 scheduled job 상태 전이를 content ranking 패턴처럼 `savePendingJob`, `markProcessing`, `refresh`, `markDone`, `markFailed` 별도 transaction으로 분리하고, refresh rollback 이후 FAILED 상태 commit 순서 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 29s`를 확인했다.
- 2026-06-24: Phase 12 reviewer 수정 후 ranking/API/admin 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 `BUILD SUCCESSFUL in 49s`를 확인했다.
- 2026-06-24: Phase 12 reviewer 수정 후 포맷/전체 회귀 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 32s`, `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 43s`를 확인했다.
- 2026-06-24: Phase 12 reviewer 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다.
- 2026-06-24: Phase 12 코드 리뷰 및 재검증: 공개 조회 경로가 `findLatestVisibleSnapshots(WEEKLY, nowUtc)`/`findPreviousVisibleSnapshots(WEEKLY, currentAggregationStartAtUtc, nowUtc)`를 사용하고, 01:00 KST scheduler, 09:00 KST `visibleFromAtUtc`, cold-start fallback 공개 전 차단, scheduled job 실패 시 `FAILED` 상태 별도 transaction commit 흐름을 재확인했다. blocking issue는 발견하지 않았다.
- 2026-06-24: Phase 12 코드 리뷰 후 fresh 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 `BUILD SUCCESSFUL in 38s`를 확인했다. `./gradlew ktlintCheck`는 sandbox 기본 권한에서 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 855ms`를 확인했다. `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 41s`를 확인했다.

View File

@@ -1,7 +1,7 @@
# PRD: 크리에이터 랭킹 # PRD: 크리에이터 랭킹
## 1. Overview ## 1. Overview
지난 주 월요일 00:00:00 KST부터 일요일 23:59:59.999999999 KST까지의 활동 데이터를 기준으로 크리에이터 랭킹 점수를 계산하고, 최종 점수 상위 20명을 조회할 수 있는 기능을 제공한다. 지난 주 월요일 00:00:00 KST부터 이번 주 월요일 00:00:00 KST 미만까지의 활동 데이터를 기준으로 크리에이터 랭킹 점수를 계산하고, 공개 노출 전환 시각이 지난 스냅샷의 최종 점수 상위 20명을 조회할 수 있는 기능을 제공한다.
--- ---
@@ -22,6 +22,8 @@
- KST 기준 집계 시작/종료 시각을 UTC 기준 조회 시작/종료 시각으로 변환한 뒤 DB 데이터를 조회한다. - KST 기준 집계 시작/종료 시각을 UTC 기준 조회 시작/종료 시각으로 변환한 뒤 DB 데이터를 조회한다.
- 각 점수 카테고리의 원천 지표와 가중치를 테스트 가능한 형태로 관리한다. - 각 점수 카테고리의 원천 지표와 가중치를 테스트 가능한 형태로 관리한다.
- 조회 시 매번 무거운 원천 집계를 수행하지 않도록 주간 랭킹 계산 결과를 스냅샷으로 저장한다. - 조회 시 매번 무거운 원천 집계를 수행하지 않도록 주간 랭킹 계산 결과를 스냅샷으로 저장한다.
- 주간 랭킹 시간 정책을 집계 기준 시각, 스냅샷 생성 후보 시각, 공개 노출 전환 시각으로 분리한다.
- 조회 API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 응답한다.
- 추후 성능 개선을 위해 캐시 저장소를 추가할 수 있는 포트 경계를 둔다. - 추후 성능 개선을 위해 캐시 저장소를 추가할 수 있는 포트 경계를 둔다.
--- ---
@@ -57,14 +59,16 @@
### Feature A. 주간 랭킹 기간 산출 ### Feature A. 주간 랭킹 기간 산출
#### Requirements #### Requirements
- 랭킹 대상 기간은 조회 시점 기준 "지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만"으로 계산한다. - 랭킹 대상 기간은 스냅샷 생성 또는 fallback 집계 기준 시점의 "지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만"으로 계산한다.
- 예를 들어 2026-06-08 월요일 KST에 조회하면 대상 기간은 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만이다. - 예를 들어 2026-06-08 월요일 KST에 스냅샷을 생성하거나 fallback 집계를 수행하면 대상 기간은 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만이다.
- 집계 기준 시각은 매주 월요일 00:00:00 KST이며, 이 시각을 집계 종료 경계로 사용한다.
- 서버 기본 timezone이 UTC여도 기간 산출은 `Asia/Seoul` 기준으로 수행한다. - 서버 기본 timezone이 UTC여도 기간 산출은 `Asia/Seoul` 기준으로 수행한다.
- DB와 서버 timezone은 UTC이므로, KST 기준 기간을 UTC 기준 `Instant` 또는 프로젝트 표준 시간 타입으로 변환해 DB 조회 조건에 사용한다. - DB와 서버 timezone은 UTC이므로, KST 기준 기간을 UTC 기준 `Instant` 또는 프로젝트 표준 시간 타입으로 변환해 DB 조회 조건에 사용한다.
- 예를 들어 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만은 2026-05-31 15:00:00 UTC 이상, 2026-06-07 15:00:00 UTC 미만으로 변환해 조회한다. - 예를 들어 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만은 2026-05-31 15:00:00 UTC 이상, 2026-06-07 15:00:00 UTC 미만으로 변환해 조회한다.
#### Edge Cases #### Edge Cases
- 월요일 00:00:00 KST 직후 조회해도 방금 시작한 이번 주 데이터가 포함되지 않아야 한다. - 월요일 00:00:00 KST 직후 조회해도 방금 시작한 이번 주 데이터가 포함되지 않아야 한다.
- 월요일 00:00:00 KST 이후 09:00:00 KST 전까지 조회해도 새로 종료된 주차가 공개 노출 전환 전이면 이전 공개 스냅샷을 응답해야 한다.
- 연도/월 경계를 넘어가는 주차도 동일한 규칙으로 계산한다. - 연도/월 경계를 넘어가는 주차도 동일한 규칙으로 계산한다.
- DST가 없는 KST 기준을 사용하되, 구현은 `ZoneId.of("Asia/Seoul")`처럼 명시적인 timezone을 사용한다. - DST가 없는 KST 기준을 사용하되, 구현은 `ZoneId.of("Asia/Seoul")`처럼 명시적인 timezone을 사용한다.
@@ -147,14 +151,18 @@
#### Requirements #### Requirements
- 홈 내부 랭킹 탭에서 주간 크리에이터 랭킹 상위 20명을 조회하는 API를 제공한다. - 홈 내부 랭킹 탭에서 주간 크리에이터 랭킹 상위 20명을 조회하는 API를 제공한다.
- API endpoint는 `GET /api/v2/home/rankings/creators`를 사용한다. - API endpoint는 `GET /api/v2/home/rankings/creators`를 사용한다.
- API는 최신 완료 주차의 스냅샷을 기준으로 조회하며 별도 query parameter 없이 기본 랭킹을 반환한다. - API는 별도 query parameter 없이 기본 랭킹을 반환한다.
- API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 조회한다.
- 새 주차 스냅샷이 월요일 01:00:00 KST에 생성되었더라도 `visibleFromAt`인 월요일 09:00:00 KST 전에는 공개 조회에 사용하지 않는다.
- 예를 들어 2026-06-08 08:59:59 KST 조회는 2026-06-08 09:00:00 KST 공개 예정 스냅샷이 생성되어 있어도 직전 공개 스냅샷을 응답한다.
- 예를 들어 2026-06-08 09:00:00 KST 이후 조회는 해당 시각까지 공개된 최신 스냅샷을 응답한다.
- 응답에는 순위 변화 표시 여부, 순위, 지난 주 대비 순위 변화, 신규 진입 여부, 크리에이터 id, 닉네임, 프로필 이미지를 포함한다. - 응답에는 순위 변화 표시 여부, 순위, 지난 주 대비 순위 변화, 신규 진입 여부, 크리에이터 id, 닉네임, 프로필 이미지를 포함한다.
- `showRankChange``items`와 같은 레벨에 내려주며, 클라이언트가 순위 변화 UI를 표시할지 판단하는 값이다. - `showRankChange``items`와 같은 레벨에 내려주며, 클라이언트가 순위 변화 UI를 표시할지 판단하는 값이다.
- 각 크리에이터의 순위 변화 값은 `items[].rankChange`에 숫자로 내려준다. - 각 크리에이터의 순위 변화 값은 `items[].rankChange`에 숫자로 내려준다.
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수로 내려준다. - 순위가 올라갔으면 양수, 순위가 내려갔으면 음수로 내려준다.
- 예를 들어 직전 완료 주차 10위, 최신 완료 주차 5위이면 `rankChange``5`다. - 예를 들어 직전 공개 스냅샷 10위, 최신 공개 스냅샷 5위이면 `rankChange``5`다.
- 예를 들어 직전 완료 주차 1위, 최신 완료 주차 10위이면 `rankChange``-9`다. - 예를 들어 직전 공개 스냅샷 1위, 최신 공개 스냅샷 10위이면 `rankChange``-9`다.
- 직전 완료 주차에는 순위에 없고 최신 완료 주차에 진입한 크리에이터는 `items[].isNew == true`로 내려주며, 클라이언트는 이를 `New`로 표시한다. - 직전 공개 스냅샷에는 순위에 없고 최신 공개 스냅샷에 진입한 크리에이터는 `items[].isNew == true`로 내려주며, 클라이언트는 이를 `New`로 표시한다.
- 신규 진입 크리에이터의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다. - 신규 진입 크리에이터의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
- 응답의 크리에이터 id는 크리에이터 상세 이동에 사용한다. - 응답의 크리에이터 id는 크리에이터 상세 이동에 사용한다.
- 응답 스키마 예시는 다음과 같다. - 응답 스키마 예시는 다음과 같다.
@@ -190,40 +198,50 @@
- 인증 사용자 조건이 필요하지 않은 공개 조회를 기본으로 하되, 차단 마스킹 정책은 인증 사용자에게 적용한다. - 인증 사용자 조건이 필요하지 않은 공개 조회를 기본으로 하되, 차단 마스킹 정책은 인증 사용자에게 적용한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다. - 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다. - 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준으로 응답한다. - fallback 응답도 공개 노출 전환 정책을 따라야 하며, fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하지 않으면 새 주차 결과를 응답하지 않는다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다. - 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷 기준으로 응답한다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 공개 가능한 기간의 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다.
- cold-start fallback에서 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 상황을 위한 보강책이며, 장기 실시간 집계 경로로 사용하지 않는다. - cold-start fallback에서 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 상황을 위한 보강책이며, 장기 실시간 집계 경로로 사용하지 않는다.
- cold-start fallback 스냅샷 생성은 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용하고, lock 획득 실패 시 다른 요청 또는 작업이 처리 중인 정상 skip으로 간주한다. - cold-start fallback 스냅샷 생성은 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용하고, lock 획득 실패 시 다른 요청 또는 작업이 처리 중인 정상 skip으로 간주한다.
#### Edge Cases #### Edge Cases
- 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다. - 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다.
- 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다. - 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다.
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다. - 공개 가능한 최신 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으며 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
- 공개 가능한 최신 스냅샷이 없고 fallback 대상 기간의 `visibleFromAt > now`이면 새 주차 결과를 조기 노출하지 않고 빈 배열로 성공 응답한다.
- fallback 성공 뒤 스냅샷 생성 트리거가 실패하더라도 공개 API 응답 스키마는 변경하지 않고, 실패는 로그/job 이력으로 추적한다. - fallback 성공 뒤 스냅샷 생성 트리거가 실패하더라도 공개 API 응답 스키마는 변경하지 않고, 실패는 로그/job 이력으로 추적한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다. - 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 공개 스냅샷 기준 응답을 유지한다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange``false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다. - 직전 공개 스냅샷이 없으면 `showRankChange``false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다.
### Feature H. 주간 랭킹 스냅샷 ### Feature H. 주간 랭킹 스냅샷
#### Requirements #### Requirements
- 주간 랭킹은 조회 시 매번 원천 데이터를 집계하지 않고, 계산 결과를 스냅샷으로 저장한 뒤 조회 API는 스냅샷을 읽는다. - 주간 랭킹은 조회 시 매번 원천 데이터를 집계하지 않고, 계산 결과를 스냅샷으로 저장한 뒤 조회 API는 스냅샷을 읽는다.
- 스냅샷 생성 기준 기간은 KST 기준 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만이다. - 스냅샷 생성 기준 기간은 KST 기준 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만이다.
- 주간 랭킹 시간 정책은 다음 세 시각을 분리한다.
- 집계 기준 시각: 매주 월요일 00:00:00 KST. 이 시각을 집계 종료 경계로 사용한다.
- 생성 후보 시각: 매주 월요일 01:00:00 KST. 스케줄러가 새 주차 스냅샷 생성을 시도하는 후보 시각이다.
- 노출 전환 시각: 매주 월요일 09:00:00 KST. 생성된 새 주차 스냅샷의 `visibleFromAt`으로 저장하고, 이 시각 이후 공개 조회에 사용한다.
- 스냅샷 생성 시 원천 데이터 조회 조건은 KST 집계 기간을 UTC로 변환한 기간을 사용한다. - 스냅샷 생성 시 원천 데이터 조회 조건은 KST 집계 기간을 UTC로 변환한 기간을 사용한다.
- 스냅샷에는 `rankingType``visibleFromAt`을 저장한다.
- 현재 기본 크리에이터 랭킹의 `rankingType` 값은 `WEEKLY`로 시작하고, 향후 다중 크리에이터 랭킹 타입 확장 시 같은 스냅샷/job 구조를 재사용한다.
- 같은 랭킹 타입과 같은 집계 기간의 스냅샷을 재생성할 때는 기존 같은 `rankingType + aggregationStartAt + aggregationEndAt` row를 중복 노출하지 않는다.
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다. - 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
- 최종 점수 1점 이상인 후보가 20명 미만이면 해당 후보만 저장한다. - 최종 점수 1점 이상인 후보가 20명 미만이면 해당 후보만 저장한다.
- 스냅샷은 크리에이터 id, 최종 점수, 카테고리별 점수, 원천 지표, 집계 시작/종료 시각을 저장한다. - 스냅샷은 크리에이터 id, 최종 점수, 카테고리별 점수, 원천 지표, 집계 시작/종료 시각을 저장한다.
- 최종 순위는 스냅샷 저장 시 고정하지 않고 조회 시 최종 점수 내림차순과 동점 랜덤 정렬 결과에 따라 부여한다. - 최종 순위는 스냅샷 저장 시 고정하지 않고 조회 시 최종 점수 내림차순과 동점 랜덤 정렬 결과에 따라 부여한다.
- 순위 변화는 최신 완료 주차 응답에서 부여된 순위와 직전 완료 주차 스냅샷 기준 순위를 비교해 계산한다. - 순위 변화는 최신 공개 스냅샷 응답에서 부여된 순위와 직전 공개 스냅샷 기준 순위를 비교해 계산한다.
- 동점 랜덤 정렬 정책 때문에 동점 구간에 포함된 크리에이터의 순위와 순위 변화는 조회 결과마다 달라질 수 있으며, 이는 허용한다. - 동점 랜덤 정렬 정책 때문에 동점 구간에 포함된 크리에이터의 순위와 순위 변화는 조회 결과마다 달라질 수 있으며, 이는 허용한다.
- 스냅샷 생성은 이번 주 데이터가 포함되지 않도록 주간 집계 대상 기간이 종료된 뒤 실행한다. - 스냅샷 생성은 이번 주 데이터가 포함되지 않도록 주간 집계 대상 기간이 종료된 뒤 실행한다.
- 기본 스케줄 후보는 매주 월요일 KST 07:30이며, 스케줄러는 `Asia/Seoul` zone을 명시한다. - 기본 생성 스케줄 후보는 매주 월요일 KST 01:00이며, 스케줄러는 `Asia/Seoul` zone을 명시한다.
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 클러스터 전체에서 한 인스턴스만 스냅샷 생성을 수행해야 한다. - 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 클러스터 전체에서 한 인스턴스만 스냅샷 생성을 수행해야 한다.
- 클러스터 단일 실행은 신규 DB 테이블을 추가하지 않고, 기존 프로젝트에 설정된 Redisson 기반 분산 lock을 우선 사용한다. - 클러스터 단일 실행은 신규 DB 테이블을 추가하지 않고, 기존 프로젝트에 설정된 Redisson 기반 분산 lock을 우선 사용한다.
- 주간 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`를 사용하며, lock 획득 실패 인스턴스는 스냅샷 생성을 skip한다. - 주간 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`를 사용하며, lock 획득 실패 인스턴스는 스냅샷 생성을 skip한다.
- 같은 집계 기간에 대해 스냅샷을 재생성할 수 있어야 하며, 재생성 시 기존 같은 기간 스냅샷을 중복 노출하지 않는다. - 같은 랭킹 타입과 같은 집계 기간에 대해 스냅샷을 재생성할 수 있어야 하며, 재생성 시 기존 같은 기간 스냅샷을 중복 노출하지 않는다.
- 조회 API는 최신 완료 주차의 스냅샷을 기준으로 응답한다. - 조회 API는 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 응답한다.
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 작업 완료 후 성공/실패 상태와 처리 결과를 기록한다. - 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 작업 완료 후 성공/실패 상태와 처리 결과를 기록한다.
- 스케줄러로 실행되는 주간 스냅샷 생성도 job 이력으로 기록한다. - 스케줄러로 실행되는 주간 스냅샷 생성도 job 이력으로 기록한다.
- 스냅샷 job 이력에도 `rankingType``visibleFromAt`을 저장해 관리자 목록, 재시도, DDL 인덱스 기준이 스냅샷 테이블과 일치해야 한다.
- 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다. - 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다.
- 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다. - 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다.
- 관리자 전용 job 목록 API는 날짜 범위, 실행 트리거, 상태, 실패 사유, 재시도 가능 여부를 확인할 수 있어야 한다. - 관리자 전용 job 목록 API는 날짜 범위, 실행 트리거, 상태, 실패 사유, 재시도 가능 여부를 확인할 수 있어야 한다.
@@ -232,10 +250,11 @@
- lock을 획득한 요청만 refresh job/service를 실행하고, lock을 획득하지 못한 요청은 이미 다른 실행자가 처리 중인 것으로 보고 fallback 응답만 반환한다. - lock을 획득한 요청만 refresh job/service를 실행하고, lock을 획득하지 못한 요청은 이미 다른 실행자가 처리 중인 것으로 보고 fallback 응답만 반환한다.
#### Edge Cases #### Edge Cases
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다. - 최신 공개 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으며 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
- fallback 성공 후 스냅샷 저장 트리거는 실패하더라도 조회 응답을 실패시키지 않되, job 상태 또는 구조화 로그로 실패를 추적할 수 있어야 한다. - fallback 성공 후 스냅샷 저장 트리거는 실패하더라도 조회 응답을 실패시키지 않되, job 상태 또는 구조화 로그로 실패를 추적할 수 있어야 한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준 응답을 유지한다. - 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 공개 스냅샷 기준 응답을 유지한다.
- 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다. - 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다.
- 스냅샷 생성은 성공했지만 `visibleFromAt > now`이면 해당 스냅샷은 공개 조회 대상에서 제외한다.
- Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다. - Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다.
- 실패 job 재시도 API는 실패 상태 job만 대상으로 하며, 이미 대기/처리 중/성공 상태인 job은 재시도 대상으로 변경하지 않는다. - 실패 job 재시도 API는 실패 상태 job만 대상으로 하며, 이미 대기/처리 중/성공 상태인 job은 재시도 대상으로 변경하지 않는다.

View File

@@ -339,6 +339,33 @@ spring:
- 어떻게: `src/main/resources/application.yml`, `src/test/resources/application.yml``spring.jpa` 아래에 `open-in-view: false`를 추가했다. - 어떻게: `src/main/resources/application.yml`, `src/test/resources/application.yml``spring.jpa` 아래에 `open-in-view: false`를 추가했다.
- 결과: 확인된 lazy loading 재현 실패가 없어 production code의 service/repository/controller 수정은 하지 않았다. 공개 API 스키마 변경도 없다. - 결과: 확인된 lazy loading 재현 실패가 없어 production code의 service/repository/controller 수정은 하지 않았다. 공개 API 스키마 변경도 없다.
- [x] **Task 0.5: 운영 LazyInitializationException 회귀 보완**
- Files:
- Add: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt`
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
- RED: `ChatCharacterService.getCharacterDetail` 반환 후 `tagMappings.tag.tag`, `getOtherCharactersBySharedTags` 반환 후 `tagMappings.tag.tag`, `RankingRepository.getCreatorRankings` 반환 후 `Member.toExplorerSectionCreator`를 트랜잭션 밖에서 접근하는 테스트를 추가한다.
- 실패 확인:
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest`
- Expected: 기존 코드에서는 `LazyInitializationException` 또는 동등한 실패가 발생한다.
- GREEN: 응답 조립에 필요한 `ChatCharacter.tagMappings.tag`, `Member.tags.tag`를 조회 쿼리에서 fetch join으로 선로딩한다.
- 통과 확인:
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 공개 API 응답 스키마와 WebSocket 관련 구현은 변경하지 않는다.
- 검증 기록:
- 무엇: OSIV off 상태에서 운영 오류와 같은 lazy loading 경계를 재현하는 회귀 테스트를 추가하고, 필요한 연관을 fetch join으로 선로딩했다.
- 왜: `ChatCharacterController.getCharacterDetail`에서 `ChatCharacterTagMapping.tag`, `HomeService.fetchData`에서 `Member.tags`가 트랜잭션 밖에서 열려 `LazyInitializationException`이 발생했기 때문이다.
- 어떻게: `OsivLazyLoadingRegressionTest`를 추가해 `ChatCharacterService.getCharacterDetail`, `ChatCharacterService.getOtherCharactersBySharedTags`, `RankingRepository.getCreatorRankings` 반환 후 트랜잭션 밖 DTO 변환을 검증했다.
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` 실행 결과 3개 테스트 모두 `LazyInitializationException`으로 실패했다.
- GREEN: 같은 명령을 재실행해 `BUILD SUCCESSFUL in 1m 6s`로 통과했다.
- 인접 회귀: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest --tests kr.co.vividnext.sodalive.api.home.HomeServiceTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest``BUILD SUCCESSFUL in 24s`로 통과했다.
- 전체 테스트 중단: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false``UserCreatorChatRedisIntegrationTest` 실행 중 `OutOfMemoryError`가 발생해 즉시 중단했다. 이후 검증 범위는 OSIV 회귀와 인접 테스트로 간결화했다.
- lint: `./gradlew --no-daemon ktlintCheck``BUILD SUCCESSFUL in 14s`로 통과했다.
- 정적 점검: `rg -n "toExplorerSectionCreator\\(|tagMappings\\.map|tagMappings\\.joinToString|\\.tagMappings" src/main/kotlin/kr/co/vividnext/sodalive -S`로 동일 패턴 후보를 확인했다. `ExplorerService`는 클래스 단위 `@Transactional(readOnly = true)` 안에서 변환하고, `HomeService`/`RankingService`는 공통 `RankingRepository.getCreatorRankings` 선로딩으로 보완했다. `TranslationSourceExtractor`와 관리자/원작 DTO 변환의 `tagMappings` 접근은 운영 stacktrace 표면이 아니므로 별도 회귀 후보로 남겼다.
--- ---
### Phase 1: WebSocket 의존성과 인증 handshake 기반 추가 ### Phase 1: WebSocket 의존성과 인증 handshake 기반 추가
@@ -916,3 +943,10 @@ spring:
- OSIV off 테스트: Phase 0 묶음 검증 명령에 포함 - OSIV off 테스트: Phase 0 묶음 검증 명령에 포함
- 수정 방향: JPA lazy loading 직접 검증은 아니며, WebMvc 표면 회귀만 확인 - 수정 방향: JPA lazy loading 직접 검증은 아니며, WebMvc 표면 회귀만 확인
- 처리 상태: XML 기준 `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개 모두 `failures=0`, `errors=0` - 처리 상태: XML 기준 `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개 모두 `failures=0`, `errors=0`
- API/기능: 캐릭터 상세/홈 크리에이터 랭킹 운영 회귀
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt`
- 위험 유형: service/repository 반환 후 controller/service DTO 변환 중 nested lazy proxy 접근
- lazy 접근 대상: `ChatCharacter.tagMappings.tag`, `Member.tags.tag`
- OSIV off 테스트: `OsivLazyLoadingRegressionTest`
- 수정 방향: 상세/공유 태그 캐릭터 조회와 크리에이터 랭킹 조회에서 필요한 연관을 fetch join으로 선로딩
- 처리 상태: `OsivLazyLoadingRegressionTest` 3개 모두 통과

View File

@@ -235,6 +235,20 @@
- 관리자/크리에이터/사용자 API가 서로 다른 controller 패키지에 흩어져 있으므로 특정 패키지 검색만으로 점검을 끝내지 않는다. - 관리자/크리에이터/사용자 API가 서로 다른 controller 패키지에 흩어져 있으므로 특정 패키지 검색만으로 점검을 끝내지 않는다.
- OSIV off 적용 후 일부 API가 실패하면 WebSocket 전환과 섞어 수정하지 않고, lazy loading 제거 task로 분리해 먼저 처리한다. - OSIV off 적용 후 일부 API가 실패하면 WebSocket 전환과 섞어 수정하지 않고, lazy loading 제거 task로 분리해 먼저 처리한다.
### Feature H. OSIV off lazy loading 회귀 보완
#### Requirements
- 운영에서 확인된 `LazyInitializationException` 발생 지점을 우선 수정한다.
- `ChatCharacterController.getCharacterDetail` 응답 조립에 필요한 `ChatCharacter.tagMappings.tag`는 OSIV off 상태에서도 접근 가능해야 한다.
- `HomeService.fetchData`의 크리에이터 랭킹 응답 조립에 필요한 `Member.tags.tag`는 OSIV off 상태에서도 접근 가능해야 한다.
- 동일 변환 메서드(`toExplorerSectionCreator`)를 쓰는 기존 랭킹 조회도 같은 쿼리 선로딩 정책을 공유해야 한다.
- 공개 API 응답 스키마는 변경하지 않는다.
#### Edge Cases
- 컬렉션 크기만 접근하면 nested LAZY proxy(`mapping.tag`)는 초기화되지 않을 수 있다.
- 조회 테스트에 `@Transactional`이 붙어 있으면 서비스 반환 후 lazy 접근 실패를 가릴 수 있다.
- fetch join으로 one-to-many를 가져오면 중복 row가 생길 수 있으므로 결과 중복 여부를 검증한다.
--- ---
## 8. UX / UI Expectations ## 8. UX / UI Expectations

View File

@@ -0,0 +1,41 @@
# CDN URL 변환 공통화 구현 계획
### Phase 1: 공통 함수 동작 고정
- [x] **Task 1.1: 공통 CDN URL 변환 테스트 작성**
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensionsTest.kt`
- RED: `null`, blank, 절대 URL, 상대 path 입력의 기대 동작을 검증하는 실패 테스트를 작성하고 실패를 확인한다.
- GREEN: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`에 최소 구현을 추가하고 통과를 확인한다.
- REFACTOR: 함수명/패키지/import를 정리하고 단일 테스트를 다시 실행한다.
- 검증 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest`
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest` 실행 결과,
`Unresolved reference: toCdnUrl`로 실패해 공통 함수 미구현 상태를 확인했다.
- GREEN: 같은 명령 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
### Phase 2: 서비스 중복 함수 제거
- [x] **Task 2.1: 4개 서비스가 공통 함수를 사용하도록 변경**
- 파일:
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- RED: Task 1.1 테스트로 절대 URL 유지 동작을 먼저 고정한다.
- GREEN: private `toCdnUrl` 중복 선언을 제거하고 공통 함수를 import해 사용한다.
- REFACTOR: 변경 파일의 불필요한 import/중복 코드를 제거하고 회귀 테스트를 실행한다.
- 검증 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- 검증 기록:
- `rg "fun String\\?\\.toCdnUrl|toCdnUrl\\(\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 -n`
실행 결과, 공통 함수 선언 1곳만 남은 것을 확인했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과
`BUILD SUCCESSFUL`로 ranking 서비스 회귀 테스트가 통과했다.
## 검증 기록
- `./gradlew ktlintCheck` 첫 실행은 private 함수 제거 후 남은 클래스 종료 전 빈 줄로 실패했다.
- 지적된 `CreatorChannelAudioQueryService.kt`, `CreatorChannelLiveQueryService.kt`의 빈 줄만 제거한 뒤
`./gradlew ktlintCheck`를 재실행했고 `BUILD SUCCESSFUL`로 통과했다.
- 문서 변경 규칙 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`로 통과했다.
- 최종 관련 테스트로
`./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
를 실행했고 `BUILD SUCCESSFUL`로 통과했다.

View File

@@ -0,0 +1,36 @@
# PRD: CDN URL 변환 공통화
## 1. Overview
v2 서비스에서 중복 선언된 `String?.toCdnUrl()` 확장 함수를 공통 유틸로 분리한다.
## 2. Problem
- `CreatorChannelHomeQueryService`, `CreatorChannelLiveQueryService`, `CreatorChannelAudioQueryService`,
`CreatorRankingQueryService`에 유사한 CDN URL 변환 로직이 private 함수로 중복되어 있다.
- ranking 구현은 절대 URL을 그대로 유지하지 않아 다른 3곳과 동작이 다르다.
## 3. Goals
- 4개 서비스가 하나의 공통 `toCdnUrl` 함수를 사용한다.
- `null` 또는 blank 입력은 `null`을 반환한다.
- `http://`, `https://` 절대 URL은 그대로 반환한다.
- 상대 path는 `cloudFrontHost/path` 형식으로 반환한다.
## 4. Non-Goals
- QueryDSL 조회 로직이나 공개 API 스키마는 변경하지 않는다.
- 기존 CDN host 설정 방식은 변경하지 않는다.
- 다른 레거시 CDN URL 조합 코드는 이번 범위에서 정리하지 않는다.
## 5. Core Features
### Feature A: 공통 CDN URL 변환
#### Requirements
- `kr.co.vividnext.sodalive.v2` 하위 공통 패키지에 재사용 가능한 함수를 둔다.
- 기존 서비스 매핑 흐름은 유지하고 private 중복 함수만 제거한다.
#### Edge Cases
- `null`, `""`, `" "` 입력은 `null`이어야 한다.
- `https://...`, `http://...` 입력은 host를 덧붙이지 않아야 한다.
- `"profile/a.png"` 입력은 `"https://cdn.test/profile/a.png"`가 되어야 한다.
## 6. Technical Constraints
- Kotlin 확장 함수로 구현한다.
- 테스트는 JUnit 5로 작성한다.

View File

@@ -0,0 +1,628 @@
# 크리에이터 채널 오디오 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/audio`로 크리에이터 채널 오디오 탭의 테마 목록, 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 조립 계층에 둔다. 오디오 탭 조회 service, 순수 fallback/page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 하위에 두고 `v2.api.*`에 의존하지 않는다. 크리에이터 채널 오디오 콘텐츠 item domain/response는 홈/라이브/오디오 탭에서 동일하게 쓰도록 채널 공통 패키지에 둔다. 라이브 탭에서 만든 `ContentSort`와 오디오 콘텐츠 응답 의미는 재사용하되, `sort/page/size/themeId` fallback 정책은 오디오 탭 전용 정책으로 명시한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/audio`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `sort`, `required = false`, 기본값/fallback `LATEST`
- query parameter: `themeId`, `required = false`, 없거나 비활성/미존재이면 전체 활성 테마 조회
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
- controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다.
- response:
- `audioContentCount`: 적용된 필터 기준 오디오 콘텐츠 전체 개수
- `paidAudioContentCount`: 적용된 필터 기준 `price > 0` 콘텐츠 개수
- `purchasedAudioContentCount`: 적용된 필터 기준 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수
- `purchasedAudioContentRate`: `paidAudioContentCount == 0`이면 `0.0`, 아니면 `(purchasedAudioContentCount / paidAudioContentCount) * 100`
- `themes`: 활성 테마 중 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 1개 이상 있는 테마 목록. 선택한 `themeId`와 무관하게 내려준다.
- `audioContents`: 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 가진 item 목록
- `sort`: 실제 적용한 `ContentSort`
- `themeId`: 실제 적용한 활성 테마 id, 전체 조회 fallback이면 `null`
- `page`: fallback 보정 후 실제 적용된 page index
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
- 테마명은 `LangContext.lang.code` 기준으로 `ContentThemeTranslation`을 우선하고, 없거나 빈 문자열이면 `AudioContentTheme.theme` 원문으로 fallback한다.
- 테마 목록 필터링은 콘텐츠 목록/count와 같은 공개 조건, 예약 공개 제외, 성인 콘텐츠 노출 정책을 적용한다.
- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다.
- `isFirstContent`는 선택 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
- 정렬:
- `LATEST`: `releaseDate desc`, `price desc`, `audioContent.id desc`
- `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, `audioContent.id desc`
- `OWNED`: 조회자 소장 또는 유효 대여 여부 desc, `releaseDate desc`, `audioContent.id desc`
- `PRICE_HIGH`: `price desc`, `releaseDate desc`, `audioContent.id desc`
- `PRICE_LOW`: `price asc`, `releaseDate desc`, `audioContent.id desc`
- 인기순 매출은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출인 `orders.can` 합계를 사용한다. `orders.point`는 포함하지 않고, `orders.is_active = true`인 주문만 포함한다.
---
## 1. 파일 구조 계획
### 오디오 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`
### 오디오 탭 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
### 크리에이터 채널 공통 오디오 콘텐츠 item
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/common/domain/CreatorChannelAudioContent.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt`
### 문서 산출물
- Create: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
data class CreatorChannelAudioTabResponse(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioThemeResponse>,
val audioContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val themeId: Long?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelAudioTab): CreatorChannelAudioTabResponse {
return CreatorChannelAudioTabResponse(
audioContentCount = tab.audioContentCount,
paidAudioContentCount = tab.paidAudioContentCount,
purchasedAudioContentCount = tab.purchasedAudioContentCount,
purchasedAudioContentRate = tab.purchasedAudioContentRate,
themes = tab.themes.map(CreatorChannelAudioThemeResponse::from),
audioContents = tab.audioContents.map(CreatorChannelAudioContentResponse::from),
sort = tab.sort,
themeId = tab.themeId,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelAudioThemeResponse(
val themeId: Long,
val themeName: String
) {
companion object {
fun from(theme: CreatorChannelAudioTheme): CreatorChannelAudioThemeResponse {
return CreatorChannelAudioThemeResponse(
themeId = theme.themeId,
themeName = theme.themeName
)
}
}
}
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = content.audioContentId,
title = content.title,
duration = content.duration,
imageUrl = content.imageUrl,
price = content.price,
isAdult = content.isAdult,
isPointAvailable = content.isPointAvailable,
isFirstContent = content.isFirstContent,
seriesName = content.seriesName,
isOriginalSeries = content.isOriginalSeries,
isOwned = content.isOwned,
isRented = content.isRented
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.audio.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
data class CreatorChannelAudioTab(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioTheme>,
val audioContents: List<CreatorChannelAudioContent>,
val sort: ContentSort,
val themeId: Long?,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelAudioTheme(
val themeId: Long,
val themeName: String
)
data class CreatorChannelAudioContent(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import java.time.LocalDateTime
interface CreatorChannelAudioQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun findActiveThemeId(themeId: Long): Long?
fun findAudioThemes(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
locale: String
): List<CreatorChannelAudioThemeRecord>
fun countAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
fun countPaidAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int
fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord>
}
data class CreatorChannelAudioCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelAudioThemeRecord(
val themeId: Long,
val themeName: String
)
data class CreatorChannelAudioContentRecord(
val audioContentId: Long,
val title: String,
val duration: String?,
val imagePath: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
```
---
### Phase 1: 오디오 탭 정책과 domain 계약
- [x] **Task 1.1: `CreatorChannelAudioQueryPolicy` fallback 정책 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicyTest.kt`
- RED: `createPage(-1, 10)``page=0`, `size=20`을 반환하고, `createPage(2, 100)``page=2`, `size=50`을 반환하며, `resolveSort(null)``resolveSort("UNKNOWN")``ContentSort.LATEST`를 반환하는 테스트를 작성한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Expected: `CreatorChannelAudioQueryPolicy` 미존재 컴파일 실패
- GREEN: `resolveSort(sort: String?): ContentSort`, `createPage(page: Int?, size: Int?): CreatorChannelPage`, `limitItems`, `hasNext`, `purchaseRate`를 구현한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 라이브 탭의 `CreatorChannelLiveReplayQueryPolicy`는 변경하지 않는다. 오디오 탭만 fallback 정책을 가진다.
- 회귀 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
- Expected: 기존 라이브 탭 정책 테스트가 있으면 통과한다. 테스트가 없으면 `No tests found`가 아닌 컴파일 실패가 없는지 확인한다.
- [x] **Task 1.2: 오디오 탭 domain model과 port 계약 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
- RED: service 테스트 파일에 `CreatorChannelAudioTab`, `CreatorChannelAudioTheme`, `CreatorChannelAudioContent`, `CreatorChannelAudioQueryPort` import를 추가하고 아직 service가 없어서 컴파일 실패하는 상태를 만든다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Expected: `CreatorChannelAudioQueryService` 또는 domain/port 미존재 컴파일 실패
- GREEN: 위 "Domain / Port 초안"의 타입을 추가한다. `CreatorChannelPage`는 기존 `kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage`를 재사용한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: `rg -n "v2\\.api\\.creator\\.channel\\.audio" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio`로 domain/port가 API 조립 계층에 의존하지 않는지 확인한다.
- [x] **Task 1.3: 크리에이터 채널 오디오 콘텐츠 item 공통화**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/common/domain/CreatorChannelAudioContent.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
- Modify: live/home/audio domain과 DTO import
- RED: live/home/audio 테스트 import를 공통 `CreatorChannelAudioContent``CreatorChannelAudioContentResponse` 기준으로 변경하고 기존 타입 미존재/필드 불일치 컴파일 실패를 확인한다.
- GREEN: 기존 live/home/audio의 중복 `CreatorChannelAudioContent`와 중복 Response를 제거하고 공통 타입을 사용한다. 실질 사용처가 없는 `publishedAt`은 공통 domain과 live/home mapping에서 제거한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- REFACTOR: `rg -n "data class CreatorChannelAudioContent|data class CreatorChannelAudioContentResponse|publishedAt =|v2\\.creator\\.channel\\.(live|home|audio)\\.domain\\.CreatorChannelAudioContent|v2\\.api\\.creator\\.channel\\.(live|home)\\.dto\\.CreatorChannelAudioContentResponse" src/main/kotlin src/test/kotlin`로 중복 타입, 기존 패키지 import, 불필요한 domain field mapping이 남지 않았는지 확인한다.
### Phase 2: 오디오 탭 service와 API DTO 변환
- [x] **Task 2.1: `CreatorChannelAudioQueryService` orchestration 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
- RED: fake port 기반 service 테스트를 작성한다.
- `getAudioTab(creatorId=1, viewer, sort="UNKNOWN", themeId=999, page=-1, size=100)` 호출 시 실제 `sort=LATEST`, `themeId=null`, `page=0`, `size=50`, `offset=0`, `limit=51`이 port에 전달되어야 한다.
- `paidAudioContentCount=4`, `purchasedAudioContentCount=3`이면 `purchasedAudioContentRate=75.0`이어야 한다.
- `paidAudioContentCount=0`이면 `purchasedAudioContentRate=0.0`이어야 한다.
- `creator`가 없으면 `member.validation.user_not_found`, role이 `CREATOR`가 아니면 `member.validation.creator_not_found`, 차단 관계면 기존 차단 메시지 예외를 던져야 한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Expected: `CreatorChannelAudioQueryService` 미존재 컴파일 실패
- GREEN: 라이브 탭 service의 인증/차단/성인 콘텐츠 정책을 참고해 최소 구현한다. `LangContext.lang.code`를 theme/series 번역 조회 locale로 전달하고, `String?.toCdnUrl()`은 라이브 탭 service와 같은 규칙으로 구현한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: service가 QueryDSL/Q타입을 직접 import하지 않는지 확인한다.
- Run: `rg -n "Q[A-Z]|queryFactory|javax\\.persistence" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application`
- Expected: 검색 결과 없음
- [x] **Task 2.2: 오디오 탭 API response DTO와 facade 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`
- RED: facade가 service 결과를 `CreatorChannelAudioTabResponse`로 변환하고 `isOwned`, `isRented`, `hasNext`의 JSON property 의미를 보존하는 테스트를 작성한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
- Expected: facade/DTO 미존재 컴파일 실패
- GREEN: 위 "Response data class 초안"에 맞춰 DTO를 추가하고 facade에서 `CreatorChannelAudioQueryService.getAudioTab(creatorId, viewer, sort, themeId, page, size, now)` 결과를 `CreatorChannelAudioTabResponse.from(tab)`으로 변환한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 기존 라이브 탭 DTO를 이동하거나 수정하지 않는다.
- Run: `rg -n "package kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\.creator\\.channel\\.live\\.dto" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Expected: 기존 라이브 탭 DTO package 유지
### Phase 3: QueryDSL repository 구현
- [x] **Task 3.1: repository skeleton과 creator/block/theme 조회 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
- RED: `@DataJpaTest(properties = ["spring.cache.type=none"])` 기반으로 `findCreator`, `existsBlockedBetween`, `findActiveThemeId`, `findAudioThemes(creatorId, now, canViewAdultContent, locale="en")` 테스트를 작성한다. `ContentThemeTranslation`이 있으면 번역명, 없으면 원문명을 반환해야 하며, 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마는 제외해야 한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: repository 미존재 컴파일 실패
- GREEN: 라이브 탭 repository의 `findCreator`, `existsBlockedBetween`을 오디오 패키지로 필요한 만큼 복사하고, `findActiveThemeId`, `findAudioThemes`를 QueryDSL로 구현한다. `findAudioThemes``audioContentCondition(creatorId, themeId = null, now, canViewAdultContent)`를 공유해 콘텐츠 목록/count와 같은 공개 조건을 적용한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: `ContentThemeTranslation.theme`이 blank인 경우 원문 fallback을 repository 또는 domain mapping 중 한 곳에서만 처리한다.
- 후속 수정 검증 기록:
- 무엇: 테마 목록에서 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마를 제외하는 RED 테스트를 추가했다.
- 왜: 오디오 탭에서 선택 가능한 테마가 실제 콘텐츠가 없는 빈 필터로 노출되지 않아야 한다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`를 실행했다.
- 결과: 현재 구현은 활성 테마 전체를 반환해 `DefaultCreatorChannelAudioQueryRepositoryTest.kt:71`, `DefaultCreatorChannelAudioQueryRepositoryTest.kt:96`에서 실패함을 확인했다.
- 무엇: `findAudioThemes``creatorId`, `now`, `canViewAdultContent`, `locale`를 받아 콘텐츠 목록/count와 같은 공개 조건으로 테마를 조회하도록 수정했다.
- 왜: 조회 가능한 아이템이 없는 테마와 성인 노출 정책상 볼 수 없는 테마를 응답에서 제외해야 한다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`를 실행했다.
- 결과: `BUILD SUCCESSFUL`로 repository 필터링과 service 컨텍스트 전달을 확인했다.
- [x] **Task 3.2: 오디오 콘텐츠 count와 소장률 count 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
- RED: 아래 조건을 검증하는 repository 테스트를 추가한다.
- `countAudioContents`는 공개/활성/예약 공개/성인 콘텐츠 정책과 활성 `themeId` 필터를 적용한다.
- `countPaidAudioContents`는 같은 필터에서 `price > 0`만 계산한다.
- `countPurchasedAudioContents`는 유료 콘텐츠 중 `OrderType.KEEP` 또는 유효한 `OrderType.RENTAL` 주문을 가진 콘텐츠만 계산한다.
- 무료 콘텐츠는 구매 count와 소장률 count에서 제외한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: 신규 count method 미구현 실패
- GREEN: 공통 `audioContentCondition(creatorId, themeId, now, canViewAdultContent)` private helper를 만들고 count query들이 같은 조건을 공유하게 구현한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 목록 query와 count query의 조건이 어긋나지 않도록 helper 사용 여부를 확인한다.
- Run: `rg -n "audioContentCondition|countAudioContents|countPaidAudioContents|countPurchasedAudioContents" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Expected: 세 count method가 공통 조건 helper를 사용한다.
- [x] **Task 3.3: 오디오 콘텐츠 목록, 정렬, 시리즈 번역, 소장/대여 상태 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
- RED: 아래 조건을 검증하는 repository 테스트를 추가한다.
- `findAudioContents``size + 1`개 조회가 가능하도록 전달받은 `limit`을 그대로 사용한다.
- `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬이 PRD 기준으로 동작한다.
- `POPULAR``orders.can` 합계 기준으로 정렬하고 비활성 주문은 제외한다.
- `OWNED`는 소장 또는 유효 대여 중인 콘텐츠를 먼저 노출한다.
- 시리즈에 속한 콘텐츠는 `SeriesTranslation(locale)`이 있으면 번역명을, 없으면 원문명을 `seriesName`으로 반환한다.
- `isFirstContent`는 테마 필터와 무관하게 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠 기준이다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: 목록/정렬 method 미구현 실패
- GREEN: 라이브 탭 repository의 `findLiveReplayAudioRows`, `audioSeriesByContentIds`, `orderStatesByContentIds`, `firstAudioContentId` 구조를 오디오 탭 범위에 맞춰 구현한다. `themeId == null`이면 전체 활성 테마를 대상으로 한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: QueryDSL 중복이 커지면 오디오 탭 repository 내부 private helper로만 정리하고, 라이브 탭 repository까지 건드리는 공용화는 이번 범위에서 하지 않는다.
### Phase 4: Controller와 공개 API 계약
- [x] **Task 4.1: `CreatorChannelAudioController` 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt`
- RED: MockMvc 테스트를 작성한다.
- 비회원 `GET /api/v2/creator-channels/1/audio`는 401을 반환한다.
- 인증 회원 기본 요청은 facade에 `sort=null`, `themeId=null`, `page=null`, `size=null`을 전달하고 성공 응답을 반환한다.
- `sort=INVALID&page=-1&size=100&themeId=999` 요청은 controller에서 400을 내지 않고 facade까지 원 요청값을 전달한다.
- 응답 JSON에는 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount`, `purchasedAudioContentRate`, `themes`, `audioContents`, `sort`, `themeId`, `page`, `size`, `hasNext`, `audioContents[0].isOwned`, `audioContents[0].isRented`가 있어야 한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
- Expected: controller 미존재 컴파일 실패
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/audio")` controller를 추가한다. query parameter는 `@RequestParam(required = false) sort: String?`, `themeId: Long?`, `page: Int?`, `size: Int?`로 받는다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 기존 `/live`, `/home` mapping과 충돌하지 않는지 확인한다.
- Run: `rg -n "@GetMapping\\(\"/\\{creatorId\\}/(home|live|audio)\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel`
- Expected: home/live/audio 각각 1건
- [x] **Task 4.2: 오디오 탭 통합 흐름 테스트 추가**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt`
- RED: `@SpringBootTest + MockMvc` 기반으로 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/audio?sort=INVALID&page=-1&size=100&themeId=999`를 호출했을 때 200 성공과 fallback 적용 응답(`sort=LATEST`, `themeId=null`, `page=0`, `size=50`)을 받는 테스트를 작성한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest`
- Expected: endpoint 또는 fixture 미구현으로 실패
- GREEN: 필요한 최소 fixture만 추가하고 controller, facade, service, repository wiring이 동작하도록 구현을 보완한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: E2E fixture가 기존 테스트 데이터를 과도하게 공유하지 않는지 확인하고, 불필요한 데이터 생성 helper는 추가하지 않는다.
### Phase 5: 회귀 검증과 문서 기록
- [x] **Task 5.1: 관련 단위/슬라이스 테스트 회귀 실행**
- Files:
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
- TDD 예외 사유: 코드 구현이 아니라 구현 완료 후 검증 기록을 누적하는 문서 작업이다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: 모두 `BUILD SUCCESSFUL`
- 검증 기록: 구현 완료 후 실행 명령, 결과, 실패 시 원인과 수정 내역을 이 task 아래에 누적한다.
- 2026-06-19 실행: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- [x] **Task 5.2: 전체 회귀와 포맷 검증**
- Files:
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
- TDD 예외 사유: 전체 회귀/포맷 검증 기록 task다.
- 대체 검증 방법:
- Run: `./gradlew test`
- Run: `./gradlew ktlintCheck`
- Run: `git diff --check`
- Run: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio`
- Expected: Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없음, placeholder 검색은 의도하지 않은 결과 없음
- 검증 기록: 구현 완료 후 전체 검증 결과를 이 task 아래와 문서 하단의 검증 기록에 누적한다.
- 2026-06-19 실행: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test``BUILD SUCCESSFUL`.
- 2026-06-19 실행: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 2026-06-19 실행: `git diff --check` → 출력 없음.
- 2026-06-19 실행: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 계획 문서의 검증 명령/기록 자체만 매칭되어 의도하지 않은 placeholder 없음.
- 2026-06-19 코드 리뷰: controller/facade/service/repository/test 경로를 PRD 요구사항 기준으로 검토했고, 공개 조건, fallback, count, 정렬, 시리즈/테마 번역 fallback, 소장/대여 상태 관련 차단 이슈 없음.
---
## 4. 구현 순서 요약
1. 오디오 탭 fallback/page/sort/rate 정책을 테스트로 고정한다.
2. domain model과 port 계약을 추가한다.
3. service orchestration을 fake port 테스트로 고정한다.
4. API DTO와 facade 변환을 고정한다.
5. QueryDSL repository를 creator/block/theme/count/list 순서로 구현한다.
6. controller 공개 계약을 MockMvc로 고정한다.
7. E2E 테스트와 전체 회귀 검증을 실행하고 결과를 이 문서에 누적한다.
---
## 5. PRD 요구사항 추적
- API endpoint와 공개 API 패키지: Phase 4 Task 4.1
- 재사용 가능한 조회 책임을 API 밖 도메인 패키지에 배치: Phase 1, Phase 2, Phase 3
- `creatorId`, `sort`, `themeId`, `page`, `size` 요청 처리: Phase 1 Task 1.1, Phase 4 Task 4.1
- invalid `sort` -> `LATEST` fallback: Phase 1 Task 1.1, Phase 4 Task 4.1, Phase 4 Task 4.2
- page/size fallback: Phase 1 Task 1.1, Phase 2 Task 2.1, Phase 4 Task 4.2
- 비활성/미존재 `themeId` 전체 조회 fallback: Phase 2 Task 2.1, Phase 3 Task 3.1, Phase 4 Task 4.2
- 테마 다국어 목록: Phase 3 Task 3.1
- 오디오/유료/구매 count와 퍼센트 소장률: Phase 2 Task 2.1, Phase 3 Task 3.2
- 오디오 콘텐츠 목록과 `CreatorChannelAudioContentResponse` 의미 보존: Phase 2 Task 2.2, Phase 3 Task 3.3
- 시리즈 이름 다국어 표시: Phase 3 Task 3.3
- 정렬 정책: Phase 3 Task 3.3
- 기존 API endpoint/응답 의미 보존: Phase 4 Task 4.1, Phase 5 Task 5.2
---
## 6. 검증 기록
- 2026-06-19: plan-task 문서 작성 단계. 구현 코드는 아직 변경하지 않았다.
- 2026-06-19: Phase 1 완료.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` 실행 시 `CreatorChannelAudioQueryPolicy`, `CreatorChannelAudioTab`, `CreatorChannelAudioQueryPort` 미존재 컴파일 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest``BUILD SUCCESSFUL`.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest``BUILD SUCCESSFUL`.
- 의존성 확인: `rg -n "v2\\.api\\.creator\\.channel\\.audio" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 출력 없음.
- 2026-06-19: Phase 1 보강 범위 추가.
- 크리에이터 채널 오디오 콘텐츠 item은 홈/라이브/오디오 탭에서 공통 domain/response를 사용한다.
- live/home domain model의 `publishedAt`은 공개 응답에 사용하지 않고 오디오 item 공통 계약에도 필요하지 않아 제거 대상으로 확정했다.
- 2026-06-19: Task 1.3 완료.
- RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` → 공통 `CreatorChannelAudioContent`, `CreatorChannelAudioContentResponse` 미존재와 `publishedAt` 필드 불일치 컴파일 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest` → 단독 재실행 시 `BUILD SUCCESSFUL`.
- 참고: live/home 회귀 테스트를 동시에 실행했을 때 home 테스트 결과 XML 파일 쓰기 실패가 1회 발생했다. 단독 재실행에서 통과해 Gradle 병렬 실행 중 `build/test-results/test` 쓰기 충돌로 판단했다.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 공백: `git diff --check` → 출력 없음.
- 중복 확인: `rg -n "data class CreatorChannelAudioContent|data class CreatorChannelAudioContentResponse|publishedAt =|v2\\.creator\\.channel\\.(live|home|audio)\\.domain\\.CreatorChannelAudioContent|v2\\.api\\.creator\\.channel\\.(live|home)\\.dto\\.CreatorChannelAudioContentResponse" src/main/kotlin src/test/kotlin` → 공통 domain/response 1건씩, 각 탭 port record, 홈 시리즈 집계 local 변수만 확인.
- 2026-06-19: Phase 2 완료.
- Task 2.1 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``CreatorChannelAudioQueryService` 미존재 컴파일 실패 확인.
- Task 2.2 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``CreatorChannelAudioFacade` 미존재 컴파일 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 리뷰 보강: Phase 3 port 구현 전 Spring bean 생성 실패를 피하기 위해 live 탭과 동일하게 `ObjectProvider<CreatorChannelAudioQueryPort>` 주입으로 조정했다.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 의존성 확인: `rg -n "Q[A-Z]|queryFactory|javax\\.persistence" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application` → 출력 없음.
- 공백: `git diff --check` → 출력 없음.
- 2026-06-19: Phase 3 완료.
- Task 3.1~3.3 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → 테스트 미존재/구현 전 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 공백: `git diff --check` → 출력 없음.
- 참고: 검증 중 Gradle 명령을 병렬 실행했을 때 QueryDSL generated source 참조 오류가 1회 발생했다. 단독 순차 재실행에서 컴파일과 테스트가 통과해 병렬 Gradle 실행 중 generated source 작업 충돌로 판단했다.
- 리뷰 보강: `OWNED` 정렬이 주문 수가 아니라 소장 또는 유효 대여 여부 boolean 기준이 되도록 `CaseBuilder` 정렬로 수정했다.
- 보강 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest.shouldSortAudioContentsByOwnedAndReturnOrderStatesWithSeriesFallback` → 중복 주문 콘텐츠가 더 최신 소장 콘텐츠보다 앞서는 assertion 실패 확인.
- 보강 GREEN: 같은 테스트 재실행 → `BUILD SUCCESSFUL`.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 보강 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 보강 공백: `git diff --check` → 출력 없음.
- 2026-06-19: Phase 4 완료.
- Task 4.1 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest``CreatorChannelAudioController` 미존재 컴파일 실패 확인.
- Task 4.1 GREEN: 같은 테스트 재실행 → `BUILD SUCCESSFUL`.
- Task 4.2: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest``BUILD SUCCESSFUL`.
- 보강: 전체 suite 실행 중 SpringBootTest context 추가로 heap 사용량이 증가해 `OutOfMemoryError`가 발생했다. 오디오 E2E가 기존 라이브 E2E와 Spring TestContext cache를 재사용하도록 datasource property를 동일하게 맞추고, 공유 DB에서 theme 정렬에 의존하지 않도록 assertion을 조정했다.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest``BUILD SUCCESSFUL`.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest``BUILD SUCCESSFUL`.
- 보강 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 보강 전체 회귀: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test``BUILD SUCCESSFUL`.
- 보강 매핑 확인: `rg -n "@GetMapping\\(\\\"/\\{creatorId\\}/(home|live|audio)\\\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel` → home/live/audio 각각 1건.
- 보강 공백: `git diff --check` → 출력 없음.
- 2026-06-19: Phase 5 완료.
- 대상 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- 전체 회귀: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test``BUILD SUCCESSFUL`.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 공백: `git diff --check` → 출력 없음.
- placeholder 확인: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 계획 문서의 검증 명령/기록 자체만 매칭되어 의도하지 않은 placeholder 없음.
- 코드 리뷰: controller/facade/service/repository/test 경로를 PRD 요구사항 기준으로 검토했고, 공개 조건, fallback, count, 정렬, 시리즈/테마 번역 fallback, 소장/대여 상태 관련 차단 이슈 없음.
- 2026-06-19: 후속 수정 완료.
- 요구사항: 오디오 탭 `themes` 응답에서 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마를 제외한다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → 활성 테마 전체를 반환해 신규 assertion 실패 확인.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``BUILD SUCCESSFUL`.
- 문서 명령 확인: `./gradlew tasks --all``BUILD SUCCESSFUL`.
- 포맷: `./gradlew ktlintCheck``BUILD SUCCESSFUL`.

View File

@@ -0,0 +1,267 @@
# PRD: 크리에이터 채널 오디오 탭 API
## 1. Overview
크리에이터 채널의 오디오 탭에서 테마 목록, 정렬별 오디오 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 한 번에 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 오디오 탭은 테마 필터, 정렬 상태, 콘텐츠 개수, 소장률, 콘텐츠 목록을 함께 표시해야 한다.
- 기존 라이브 탭 API는 `다시듣기` 콘텐츠에 한정되어 있고, 오디오 탭은 전체 오디오 콘텐츠와 선택한 테마별 콘텐츠를 조회해야 한다.
- 클라이언트는 오디오 탭 진입 시 테마 리스트와 콘텐츠 목록을 별도 API 조합 없이 일관된 계약으로 받아야 한다.
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 표시되어야 한다.
- 기존 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다.
---
## 3. Goals
- 크리에이터 채널 오디오 탭 조회 API를 제공한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 하위 조립 계층에 둔다.
- 오디오 리스트, 오디오 개수, 소장률 계산, 테마 조회처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
- 요청은 `creatorId`, 정렬 순서, 테마를 받는다.
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
- 테마를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
- 응답에는 오디오 콘텐츠 개수, 유료 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수의 비율, 크리에이터의 콘텐츠 목록, 실제 적용된 정렬 순서, 테마 목록을 포함한다.
- 콘텐츠 목록 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
- 오디오 콘텐츠 목록은 라이브 탭의 `다시듣기` 목록과 같은 조회/정렬/소장 상태 의미를 따르되, 시리즈 이름이 표시되어야 한다.
- 테마 목록은 테마 id와 호출 유저 언어코드에 맞는 테마명을 내려준다.
---
## 4. Non-Goals
- 이번 범위는 크리에이터 채널 `오디오` 탭 조회 API만 포함한다.
- 기존 크리에이터 채널 홈 API, 라이브 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다.
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
- 오디오 콘텐츠 생성/수정/삭제 API는 포함하지 않는다.
- 테마 관리 화면, 테마 생성/수정/삭제 API, 테마 번역 관리 API는 포함하지 않는다.
- 테마 번역 데이터가 없는 항목을 새로 번역하거나 생성하는 배치 작업은 포함하지 않는다.
- 앱 표시용 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 오디오 탭에서 크리에이터의 오디오 콘텐츠를 테마별로 탐색하는 사용자
- 앱 클라이언트: 오디오 탭 구성에 필요한 테마/개수/소장률/목록 데이터를 단일 API 응답으로 표시하려는 클라이언트
- 크리에이터: 자신의 오디오 콘텐츠가 테마와 정렬 기준에 따라 적절히 노출되기를 원하는 사용자
---
## 6. User Stories
- 사용자는 크리에이터 채널 오디오 탭에 들어가면 전체 오디오 콘텐츠 개수를 확인하고 싶다.
- 사용자는 테마를 선택해 특정 테마의 오디오 콘텐츠만 보고 싶다.
- 사용자는 최신순, 인기순, 소장순, 높은 가격순, 낮은 가격순으로 오디오 콘텐츠를 바꿔 보고 싶다.
- 사용자는 유료 콘텐츠 중 자신이 구매한 콘텐츠 비율을 확인하고 싶다.
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
- 앱 클라이언트는 호출 유저 언어코드에 맞는 테마명을 받아 화면에 표시하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 오디오 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/audio`를 기본안으로 한다.
- `creatorId`는 path variable로 받는다.
- 정렬 순서는 query parameter로 받는다.
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
- 테마는 query parameter로 받는다.
- 테마 query parameter 이름은 `themeId`를 기본안으로 한다.
- `themeId`를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
- 오디오 콘텐츠 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 fallback한다.
- `size`가 20보다 작으면 `20`으로 fallback한다.
- `size`가 50보다 크면 `50`으로 fallback한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 공개된 오디오 콘텐츠가 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
- 알 수 없는 `sort` 값은 400 오류를 반환하지 않고 `LATEST`로 fallback한다.
- `themeId`가 존재하지 않거나 비활성 테마이면 오류를 반환하지 않고 전체 테마 조회로 fallback한다.
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelAudioTabResponse`를 기본안으로 한다.
- 응답에는 다음 값을 포함한다.
- `audioContentCount`: 선택한 테마 필터를 적용한 오디오 콘텐츠 전체 개수
- `paidAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 전체 개수
- `purchasedAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 중 호출자가 구매한 콘텐츠 개수
- `purchasedAudioContentRate`: `paidAudioContentCount` 대비 `purchasedAudioContentCount`의 퍼센트 값
- `themes`: 활성 테마 목록
- `audioContents`: 오디오 콘텐츠 목록
- `sort`: 콘텐츠 조회에 실제 적용한 정렬 순서
- `themeId`: 콘텐츠 조회에 실제 적용한 테마 id, 전체 조회이면 `null`
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `audioContents`의 각 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다.
- `themeId`는 요청값이 없거나 비활성/미존재 테마라 전체 조회로 fallback하면 `null`을 내려준다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 필터/정렬 조건에서 다음 page에 노출할 오디오 콘텐츠가 있으면 `true`로 내려준다.
- `purchasedAudioContentRate``paidAudioContentCount == 0`이면 `0.0`으로 내려준다.
- `purchasedAudioContentRate``(purchasedAudioContentCount / paidAudioContentCount) * 100`을 기준으로 계산한 퍼센트 값으로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelAudioTabResponse(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioThemeResponse>,
val audioContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val themeId: Long?,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class CreatorChannelAudioThemeResponse(
val themeId: Long,
val themeName: String
)
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
}
```
#### Edge Cases
- 공개된 오디오 콘텐츠가 없으면 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount``0`, `purchasedAudioContentRate``0.0`, `audioContents`는 빈 배열, `hasNext``false`로 내려준다.
- 요청한 page 범위에 콘텐츠가 없으면 `audioContents`는 빈 배열, `hasNext``false`로 내려주되 개수 필드는 전체 개수를 유지한다.
### Feature C. 테마 목록
#### Requirements
- 테마 목록은 `AudioContentTheme.isActive == true`인 테마만 내려준다.
- 테마 목록은 기존 테마 정렬 정책인 `AudioContentTheme.orders`를 따른다.
- 테마 응답은 테마 id와 테마명을 포함한다.
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 반환한다.
- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다.
- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다.
- `ko``AudioContentTheme.theme` 원문을 기본으로 사용한다.
- `en`, `ja``ContentThemeTranslation.locale`에 해당하는 번역값이 있으면 해당 `theme`을 사용한다.
- 요청 언어의 번역값이 없으면 `AudioContentTheme.theme` 원문을 fallback으로 사용한다.
- 테마 목록은 선택한 `themeId`와 무관하게 활성 테마 중 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 1개 이상 있는 테마만 내려준다.
#### Edge Cases
- 활성 테마가 없으면 `themes`는 빈 배열로 내려준다.
- 활성 테마가 있어도 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마는 `themes`에서 제외한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이고 특정 테마의 조회 가능한 콘텐츠가 성인 콘텐츠뿐이면 해당 테마는 `themes`에서 제외한다.
- 번역 데이터는 있지만 빈 문자열이면 원문 테마명을 fallback으로 사용한다.
### Feature D. 오디오 콘텐츠 목록과 개수
#### Requirements
- 조회 대상은 지정한 `creatorId`의 오디오 콘텐츠로 제한한다.
- 공개된 콘텐츠만 조회한다.
- 예약 공개 전 콘텐츠는 포함하지 않는다.
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 조회에서 제외한다.
- `themeId`가 있고 활성 테마이면 해당 테마의 오디오 콘텐츠만 조회한다.
- `themeId`가 없거나 비활성/미존재 테마이면 전체 활성 테마의 오디오 콘텐츠를 조회한다.
- 콘텐츠 목록은 `page`, `size` 기준으로 페이징 조회한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- 오디오 콘텐츠 개수는 목록 조회와 같은 공개 여부, 예약 공개, 성인 콘텐츠, 차단 정책, 테마 필터를 적용해 계산한다.
- 유료 콘텐츠 개수는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수로 계산한다.
- 호출자가 구매한 콘텐츠 개수는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수로 계산한다.
- 대여 중인 콘텐츠는 호출자가 구매한 콘텐츠 개수와 `purchasedAudioContentRate` 계산에 포함한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다.
- 응답 item 필드는 기존 `CreatorChannelAudioContentResponse`와 같은 의미를 유지한다.
- `seriesName`은 콘텐츠가 속한 시리즈 이름을 내려준다.
- 시리즈 이름은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
- `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다.
- `isFirstContent`는 선택한 테마 안에서 첫 콘텐츠인지가 아니라, 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
#### Edge Cases
- 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries``null`로 내려준다.
- 무료 콘텐츠는 구매한 콘텐츠 개수와 구매 비율 계산에서 제외한다.
- 일반적으로 `isOwned == true``isRented == true`가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
### Feature E. 콘텐츠 정렬
#### Requirements
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
- 공개 요청/응답 값은 다음을 사용한다.
- `LATEST`: 최신순, 기본값
- `POPULAR`: 인기순
- `OWNED`: 소장순
- `PRICE_HIGH`: 높은 가격순
- `PRICE_LOW`: 낮은 가격순
- `LATEST`는 공개일 최신순을 1차 정렬로 사용한다.
- `LATEST`의 2차 정렬은 높은 가격순이다.
- `LATEST`의 3차 정렬은 `audioContent.id desc`다.
- `POPULAR`은 매출이 많은 콘텐츠를 먼저 노출한다.
- `OWNED`는 조회자가 소장 또는 대여 중인 콘텐츠를 먼저 노출한다.
- `PRICE_HIGH`는 가격이 높은 콘텐츠를 먼저 노출한다.
- `PRICE_LOW`는 가격이 낮은 콘텐츠를 먼저 노출한다.
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 2차 정렬은 최신순이다.
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 `audioContent.id desc`다.
- 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다.
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다.
- 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다.
#### Edge Cases
- 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다.
- 조회자가 소장 또는 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `audioContent.id desc` 보조 정렬과 같은 결과가 될 수 있다.
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 하위에 둔다.
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
- 기존 라이브 탭의 `CreatorChannelAudioContentResponse`와 필드/의미가 어긋나지 않도록 오디오 탭 응답 DTO를 작성한다.
- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다.
- 기존 크리에이터 채널 홈/라이브 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다.
- 페이징 응답은 기존 라이브 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
- 테마명 다국어 처리는 기존 `LangContext`, `ContentThemeTranslation` 구조를 따른다.
- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다.
---
## 9. Metrics
- 오디오 탭 API 성공/실패 건수
- 오디오 탭 API 응답 시간
- 테마별 조회 건수
- 정렬 기준별 조회 건수
- 오디오 탭에서 콘텐츠 추가 로딩 요청 건수

View File

@@ -0,0 +1,500 @@
# 크리에이터 채널 시리즈 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/series`로 크리에이터 채널 시리즈 탭의 전체 시리즈 개수와 정렬/페이징된 시리즈 목록을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 조립 계층에 둔다. 시리즈 탭 조회 service, 순수 fallback/page/rate/day-of-week 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 오디오 탭의 `ContentSort`, `CreatorChannelPage`, 인증/차단/성인 콘텐츠 노출 정책 흐름을 재사용하되, 홈 API의 `CreatorChannelSeries`는 확장하지 않는다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/series`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `sort`, `required = false`, 기본값/fallback `LATEST`
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
- controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다.
- response:
- `seriesCount`: sort-bar에 표시할 조회 가능한 전체 시리즈 개수
- `series`: 시리즈 목록
- `sort`: 실제 적용한 `ContentSort`
- `page`: fallback 보정 후 실제 적용된 page index
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- series item:
- `seriesId`, `title`, `coverImageUrl`, `publishedDaysOfWeek`, `isOriginal`, `isAdult`, `isProceeding`, `contentCount`
- 조회자가 해당 시리즈의 크리에이터가 아니면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산한다.
- 조회자가 해당 시리즈의 크리에이터이면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate``null`이다.
- `purchasedPaidContentRate`: `Int?`, 비크리에이터 조회 시 `paidContentCount == 0`이면 `0`, 그 외 `(purchasedContentCount * 100) / paidContentCount`로 계산하고 소수점 이하는 버린다.
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
- 공개 시리즈 기준: `Series.isActive == true`, `Series.member.id == creatorId`.
- `coverImageUrl``Series.coverImage``String?.toCdnUrl(cloudFrontHost)`로 변환한 값이다. 커버 이미지 경로가 없거나 blank이면 `null`로 내려준다.
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다.
- 연재 요일:
- `RANDOM`이 포함되면 다른 요일을 무시하고 랜덤 문구만 반환한다.
- 랜덤 문구: `ko=랜덤`, `en=Random`, `ja=ランダム`
- 7개 요일이 모두 있으면 `ko=매일`, `en=Every day`, `ja=毎日`
- 그 외 `ko=매주 월, 목, 토`, `en=Every Mon, Thu, Sat`, `ja=毎週 月, 木, 土` 형식
- 정렬:
- `LATEST`: 대표 `releaseDate desc`, 대표 `price desc`, `series.id desc`
- `POPULAR`: 시리즈 콘텐츠의 `orders.can` 합계 desc, 대표 `releaseDate desc`, `series.id desc`; `orders.is_active = true`만 포함
- `OWNED`: 조회자가 유효하게 소장/대여 중인 시리즈 콘텐츠 개수 desc, 대표 `releaseDate desc`, `series.id desc`
- `PRICE_HIGH`: 대표 `price desc`, 대표 `releaseDate desc`, `series.id desc`
- `PRICE_LOW`: 대표 `price asc`, 대표 `releaseDate desc`, `series.id desc`
- 대표값:
- 대표 `releaseDate`: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 최근 `releaseDate`
- `price desc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 높은 가격
- `price asc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 낮은 가격
---
## 1. 파일 구조 계획
### 시리즈 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
### 시리즈 탭 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/SeriesContent.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt`
### 문서 산출물
- Create: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md`
- Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
data class CreatorChannelSeriesTabResponse(
val seriesCount: Int,
val series: List<CreatorChannelSeriesResponse>,
val sort: ContentSort,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse {
return CreatorChannelSeriesTabResponse(
seriesCount = tab.seriesCount,
series = tab.series.map(CreatorChannelSeriesResponse::from),
sort = tab.sort,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val publishedDaysOfWeek: String,
@JsonProperty("isOriginal")
val isOriginal: Boolean,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isProceeding")
val isProceeding: Boolean,
val contentCount: Int,
val purchasedContentCount: Int?,
val paidContentCount: Int?,
val purchasedPaidContentRate: Int?
) {
companion object {
fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse {
return CreatorChannelSeriesResponse(
seriesId = series.seriesId,
title = series.title,
coverImageUrl = series.coverImageUrl,
publishedDaysOfWeek = series.publishedDaysOfWeek,
isOriginal = series.isOriginal,
isAdult = series.isAdult,
isProceeding = series.isProceeding,
contentCount = series.contentCount,
purchasedContentCount = series.purchasedContentCount,
paidContentCount = series.paidContentCount,
purchasedPaidContentRate = series.purchasedPaidContentRate
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.series.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
data class CreatorChannelSeriesTab(
val seriesCount: Int,
val series: List<CreatorChannelSeries>,
val sort: ContentSort,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelSeries(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val publishedDaysOfWeek: String,
val isOriginal: Boolean,
val isAdult: Boolean,
val isProceeding: Boolean,
val contentCount: Int,
val purchasedContentCount: Int?,
val paidContentCount: Int?,
val purchasedPaidContentRate: Int?
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.series.port.out
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import java.time.LocalDateTime
interface CreatorChannelSeriesQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int
fun findSeries(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelSeriesRecord>
}
data class CreatorChannelSeriesCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelSeriesRecord(
val seriesId: Long,
val title: String,
val coverImagePath: String?,
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>,
val isOriginal: Boolean,
val isAdult: Boolean,
val state: SeriesState,
val contentCount: Int,
val purchasedContentCount: Int?,
val paidContentCount: Int?
)
```
---
## 4. 작업 계획
### Phase 1: 순수 정책과 도메인 모델 추가
- [x] **Task 1.1: `CreatorChannelSeriesQueryPolicy` 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt`
- RED: 아래 케이스를 테스트로 먼저 작성한다.
- `sort == null`, `UNKNOWN``ContentSort.LATEST`로 fallback한다.
- `page = -1`, `size = 10``page=0`, `size=20`, `fetchLimit=21`이 된다.
- `page = 2`, `size = 100``page=2`, `size=50`, `offset=100`, `fetchLimit=51`이 된다.
- `limitItems``size`만큼만 남기고 `hasNext``fetched.size > size`로 계산한다.
- 구매율은 `paidContentCount == 0`이면 `0`, `paid=4`, `purchased=3`이면 `75`, `paid=3`, `purchased=2`이면 `66`이다.
- `publishedDaysOfWeek``RANDOM` 포함 시 다른 요일을 무시하고 locale별 랜덤 문구를 반환한다.
- 7개 요일은 locale별 매일 문구를 반환한다.
- 일부 요일은 `SUN`부터 `SAT` 순서로 locale별 `매주/Every/毎週` 문구를 반환한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
- GREEN: `CreatorChannelAudioQueryPolicy`와 같은 page/sort/list 정책을 구현하되 `purchaseRate``Int`를 반환한다. `publishedDaysOfWeekText(days, locale)``ko`, `en`, `ja` 명시 매핑으로 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
- REFACTOR: `CreatorChannelAudioQueryPolicy`를 수정하지 않는다. 중복 제거는 이번 범위에서 하지 않는다.
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryPolicyTest`를 추가해 sort/page/list/구매율/연재 요일 정책을 문서 명세대로 고정했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 시 신규 `CreatorChannelSeriesQueryPolicy`, domain, port 타입 부재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: `CreatorChannelSeriesQueryPolicy`를 추가하고 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 1.2: 시리즈 탭 domain model과 port record 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
- RED: Task 1.1 테스트 컴파일이 새 domain/port 타입 부재로 실패하는 상태를 확인한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
- GREEN: 문서의 Domain / Port 초안 그대로 타입을 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
- REFACTOR: domain/port가 `kr.co.vividnext.sodalive.v2.api.*` 패키지를 import하지 않는지 확인한다.
- 구현 기록(2026-06-20): 문서의 Domain / Port 초안 기준으로 `CreatorChannelSeriesTab`, `CreatorChannelSeriesQueryPort`와 관련 record를 추가했다.
- 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존성 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series` 실행 결과 출력이 없어 domain/port의 API 패키지 의존이 없음을 확인했다.
### Phase 2: API 조립 계층 추가
- [x] **Task 2.1: 응답 DTO mapper 테스트와 DTO 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
- RED: facade 테스트 또는 DTO mapper 테스트에서 `CreatorChannelSeriesTabResponse.from` 결과가 `seriesCount`, `series`, `sort`, `page`, `size`, `hasNext`, `coverImageUrl`, `purchasedPaidContentRate: Int?`를 그대로 매핑하는지 기대한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
- GREEN: Response data class 초안대로 DTO와 mapper를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
- REFACTOR: Jackson boolean property는 `@JsonProperty("isOriginal")`, `@JsonProperty("isAdult")`, `@JsonProperty("isProceeding")`, `@JsonProperty("hasNext")`로 명시한다.
- 구현 기록(2026-06-20): `CreatorChannelSeriesFacadeTest`에 DTO mapper 검증을 추가하고 `CreatorChannelSeriesTabResponse`, `CreatorChannelSeriesResponse`를 초안대로 추가했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 DTO/facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 2.2: Facade 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
- RED: `CreatorChannelSeriesFacade.getSeriesTab(creatorId, viewer, sort, page, size, now)`가 query service 호출 결과를 `CreatorChannelSeriesTabResponse`로 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
- GREEN: `CreatorChannelAudioFacade`와 같은 형태로 read-only service를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
- REFACTOR: facade는 HTTP 예외 처리와 repository 세부 사항을 알지 않도록 query service에 위임한다.
- 구현 기록(2026-06-20): `CreatorChannelSeriesFacade.getSeriesTab`을 추가해 query service 결과를 공개 DTO로 변환하도록 했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 2.3: Controller 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
- RED: MockMvc 테스트를 작성한다.
- `GET /api/v2/creator-channels/{creatorId}/series?sort=POPULAR&page=1&size=20` 요청이 facade에 `sort="POPULAR"`, `page=1`, `size=20`을 전달한다.
- 응답 JSON에 `seriesCount`, `series[0].seriesId`, `series[0].coverImageUrl`, `series[0].publishedDaysOfWeek`, `series[0].purchasedPaidContentRate`, `sort`, `page`, `size`, `hasNext`가 있다.
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
- GREEN: `CreatorChannelAudioController`와 같은 `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/series")`, `requireMember` 구조로 controller를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
- REFACTOR: `sort``String?`으로 받고 `ContentSort` enum binding 오류가 발생하지 않게 한다.
- 구현 기록(2026-06-20): `CreatorChannelSeriesController`와 MockMvc 테스트를 추가해 인증 회원 요청, query parameter 전달, invalid sort 전달, 비회원 거부를 검증했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 시 Kotlin incremental cache 손상(`Malformed input`)으로 중단되어 controller 부재 메시지까지 도달하지 못했다.
- GREEN: `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
### Phase 3: 도메인 조회 서비스 추가
- [x] **Task 3.1: QueryService 인증/차단/creator 검증 테스트 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
- RED: 아래 서비스 테스트를 작성한다.
- `findCreator``null`이면 `member.validation.user_not_found` 예외를 던진다.
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다.
- 차단 관계가 있으면 기존 크리에이터 채널과 같은 blocked access 예외를 던진다.
- 정상 조회 시 policy가 보정한 sort/page를 사용해 port를 호출한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
- GREEN: `CreatorChannelAudioQueryService` 흐름을 기준으로 `ObjectProvider<CreatorChannelSeriesQueryPort>`, `MemberContentPreferenceService`, `SodaMessageSource`, `LangContext`, `cloud.aws.cloud-front.host`를 주입받는 service를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
- REFACTOR: 서비스는 repository record의 `coverImagePath``String?.toCdnUrl(cloudFrontHost)`로 변환해 domain의 `coverImageUrl`에 채운다.
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryServiceTest`에 creator 조회 실패, creator role 검증, 차단 예외, sort/page fallback과 port 호출 검증을 추가하고 service를 구현했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 시 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 3.2: QueryService 응답 조립 테스트 작성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
- RED: 아래 조립 테스트를 추가한다.
- 조회자가 creator 본인이면 각 series item의 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate``null`이다.
- 조회자가 creator가 아니면 `paidContentCount`, `purchasedContentCount``purchasedPaidContentRate` 정수값을 계산한다.
- `coverImagePath`가 상대 경로이면 `cloudFrontHost`가 붙은 `coverImageUrl`로 변환되고, blank이면 `coverImageUrl == null`이다.
- `fetched.size == size + 1`이면 `hasNext == true`이고 응답 목록은 `size`개만 남는다.
- `publishedDaysOfWeek`는 policy의 locale별 문자열로 변환된다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
- GREEN: service에서 `countSeries`, `findSeries` 결과를 조립하고 creator 본인 여부에 따라 구매 통계 필드를 null 처리한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
- REFACTOR: 구매율 계산은 service에 직접 두지 않고 `CreatorChannelSeriesQueryPolicy.purchaseRate`를 사용한다.
- 구현 기록(2026-06-20): service에서 `countSeries`, `findSeries`, CDN URL, 연재 요일 문자열, hasNext/list limit, creator 본인 구매 통계 null 처리, 비크리에이터 구매율 계산을 조립하도록 했다.
- RED: 신규 조립 테스트 작성 후 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
### Phase 4: QueryDSL repository 추가
- [x] **Task 4.1: Repository creator/차단/count 테스트 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
- RED: repository 테스트를 작성한다.
- active creator를 `CreatorChannelSeriesCreatorRecord`로 조회한다.
- viewer와 creator 사이 차단 관계가 있으면 `existsBlockedBetween == true`다.
- `countSeries``series.isActive == true`, `series.member.id == creatorId`, 성인 콘텐츠 노출 정책을 반영한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
- GREEN: 오디오 탭 repository의 creator/차단 조회 패턴을 복사해 series 패키지용 repository를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
- REFACTOR: count 쿼리는 목록 쿼리와 같은 공개 시리즈/성인 정책을 공유하는 private condition을 사용한다.
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryRepository`, `DefaultCreatorChannelSeriesQueryRepository`, repository 테스트를 추가해 creator 조회, 양방향 차단, series count의 활성/creator/성인 정책을 검증했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `DefaultCreatorChannelSeriesQueryRepository` 타입 부재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 4.2: Repository 목록 필드/번역/통계 테스트 작성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
- RED: `findSeries` 테스트를 작성한다.
- locale에 맞는 `SeriesTranslation` title이 있으면 번역명을 반환하고, 없거나 빈 문자열이면 원문 title을 반환한다.
- `coverImagePath``Series.coverImage` 값을 반환한다.
- `contentCount`는 공개 콘텐츠 기준으로 계산한다.
- `paidContentCount`는 공개 콘텐츠 중 `price > 0`만 계산한다.
- `purchasedContentCount`는 viewer의 active 소장 주문과 만료되지 않은 active 대여 주문을 중복 없이 계산한다.
- 예약 공개 전 콘텐츠와 `releaseDate == null` 콘텐츠는 통계에서 제외한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
- GREEN: `seriesContent``audioContent`를 기준으로 시리즈 목록을 조회하고, 목록의 series id 묶음에 대해 콘텐츠 통계를 bulk 조회해 record에 채운다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
- REFACTOR: N+1 조회가 생기지 않도록 `seriesIds` 기반 bulk map을 사용한다.
- 구현 기록(2026-06-20): `findSeries`가 시리즈 필드, `SeriesTranslation` title fallback, 공개 콘텐츠 기준 `contentCount`/`paidContentCount`, 유효 KEEP/RENTAL 기반 distinct `purchasedContentCount`를 반환하도록 구현했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `findSeries` 빈 목록으로 `NoSuchElementException` 실패를 확인했다.
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 4.3: Repository 정렬 테스트 작성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
- RED: 각 정렬별 순서 테스트를 작성한다.
- `LATEST`: 시리즈별 `max(audioContent.releaseDate) desc`, `max(audioContent.price) desc`, `series.id desc`
- `POPULAR`: `sum(orders.can) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`; inactive order 제외
- `OWNED`: viewer의 유효 소장/대여 콘텐츠 개수 desc, `max(audioContent.releaseDate) desc`, `series.id desc`
- `PRICE_HIGH`: `max(audioContent.price) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`
- `PRICE_LOW`: `min(audioContent.price) asc`, `max(audioContent.releaseDate) desc`, `series.id desc`
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
- GREEN: `groupBy(series.id)` 기반 QueryDSL 정렬을 구현한다. 정렬 대표값은 공개 콘텐츠 조건을 적용한 조인 결과에서 계산한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
- REFACTOR: 콘텐츠가 없는 시리즈는 대표값이 없는 항목으로 같은 정렬 내 마지막에 오도록 null 정렬 처리를 테스트와 구현에 고정한다.
- 구현 기록(2026-06-20): `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 추가하고 공개 콘텐츠 대표값 및 주문 조건 기반 QueryDSL group 정렬을 구현했다.
- RED/GREEN: 정렬 테스트 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 결과 기존 구현이 정렬 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다.
- 리뷰 보완: `OWNED` 정렬이 구매 개수가 아닌 공개 콘텐츠 개수로 정렬될 수 있는 문제를 발견해, 미구매 공개 콘텐츠가 더 많은 시리즈 fixture를 추가했다. RED로 `AssertionFailedError`를 확인한 뒤 `ownedOrder.audioContent.id.countDistinct()` 기준으로 수정하고 동일 명령 `BUILD SUCCESSFUL`을 확인했다.
### Phase 5: API 통합 검증
- [x] **Task 5.1: End-to-End 테스트 추가**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
- RED: 실제 Spring context 기반 테스트를 작성한다.
- `GET /api/v2/creator-channels/{creatorId}/series`가 성공하고 PRD의 전체 응답 필드를 반환한다.
- invalid `sort`, 음수 `page`, 작은 `size`가 fallback되어 응답의 `sort/page/size`에 반영된다.
- 비크리에이터 viewer는 구매 통계 정수 비율을 받는다.
- creator 본인은 구매 통계 필드가 `null`이다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
- GREEN: controller, facade, service, repository wiring 누락을 보완한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
- REFACTOR: API 응답 필드명이 PRD와 다르면 PRD 또는 코드를 먼저 맞춘 뒤 테스트를 갱신한다.
- 구현 기록(2026-06-20): `CreatorChannelSeriesEndToEndTest`를 추가해 실제 Spring context에서 controller-service-repository 경로를 검증했다.
- 검증 시나리오: 인증 회원의 전체 응답 필드, invalid `sort`/음수 `page`/작은 `size` fallback, 비크리에이터 구매 통계 정수 비율, creator 본인 구매 통계 `null` 응답을 확인했다.
- RED/GREEN: 신규 E2E 테스트 파일 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 기존 wiring이 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다.
- 보완: controller/facade/service/repository production code 수정은 필요하지 않았다.
- [x] **Task 5.2: 회귀 검증과 문서 검증 기록**
- Files:
- Modify: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md`
- Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md`
- RED: 문서와 코드 계약 차이를 확인한다.
- `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API`
- 실패 확인: 문서와 구현 계약이 불일치하면 해당 task를 완료하지 않는다.
- GREEN: 단일 테스트와 관련 회귀 테스트를 실행한다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
- 통과 확인: `./gradlew test`
- REFACTOR: Kotlin 포맷 검증은 `./gradlew ktlintCheck`로 확인한다.
- 문서 기록: 구현 완료 시 각 task 아래에 실행 명령, 성공/실패 결과, 수정 내용을 한국어로 누적 기록한다.
- 검증 기록(2026-06-20): 문서 계약 검색과 Phase 5 focused 회귀를 실행했다.
- 문서 계약 검색: `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API` 실행으로 PRD/plan의 endpoint, 구매 통계, `PRICE_LOW`, `RANDOM` 계약 기재를 확인했다.
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`는 병렬 실행 중 XML 결과 파일 동시 쓰기 실패가 발생했으나, 동일 명령 순차 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- OOM 원인 보완: 기본 `./gradlew test`에서 test worker가 `-Xmx512m`로 실행되어 full Spring context 누적 시 `Gradle Test Executor``Java heap space` 실패가 발생했다. `build.gradle.kts``tasks.withType<Test>``maxHeapSize = "1536m"`를 명시해 test worker heap을 1.5g로 고정했다.
- context 재사용 보완: `CreatorChannelSeriesEndToEndTest`의 H2 datasource URL을 기존 creator-channel E2E와 같은 `creator-channel-live-e2e`로 맞춰 `audio/live/series` E2E가 Spring context를 공유하도록 했다.
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest --info` 실행 결과 test worker가 `-Xmx1536m`로 실행되고 `HikariPool-1`만 생성되는 것을 확인했으며 `BUILD SUCCESSFUL`을 확인했다.
- 통과: 기본 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 통과: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
---
## 5. 전체 검증 명령
구현 완료 후 아래 순서로 실행한다.
```bash
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest
./gradlew test
./gradlew ktlintCheck
```
---
## 6. 계획 자체 검토
- PRD의 endpoint, request, response data class, 커버 이미지 URL, 정렬, 페이징, 구매 통계, 연재 요일 다국어, creator 본인/비본인 분기 요구사항을 task에 반영했다.
- 공개 API 조립 계층과 도메인 조회 계층을 분리했다.
- 기존 홈 API의 `CreatorChannelSeries` 확장은 계획에 포함하지 않았다.
- `purchasedPaidContentRate``Int?`로 고정했다.
- `RANDOM` 포함 시 다른 요일을 무시하는 정책을 테스트 task에 포함했다.
- 시리즈별 정렬 대표값은 `max(releaseDate)`, `max(price)`, `min(price)`로 명시했다.
- Open Questions는 PRD 기준 없음.

View File

@@ -0,0 +1,262 @@
# PRD: 크리에이터 채널 시리즈 탭 API
## 1. Overview
크리에이터 채널의 시리즈 탭에서 정렬별 시리즈 개수와 시리즈 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 시리즈 탭은 전체 시리즈 개수, 정렬 상태, 시리즈 목록을 함께 표시해야 한다.
- 기존 홈 API의 `CreatorChannelSeries`는 홈 화면용 요약 모델이라 시리즈 탭에서 필요한 연재 요일, 연재 상태, 콘텐츠 개수, 구매/유료 콘텐츠 통계를 모두 표현하지 못한다.
- 클라이언트는 시리즈 탭 진입과 추가 로딩 시 별도 API 조합 없이 일관된 계약으로 시리즈 목록을 받아야 한다.
- 연재 요일 문구는 서버에서 조합하고, 호출 유저의 언어에 맞게 반환해야 한다.
- 기존 크리에이터 채널 홈/라이브/오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다.
---
## 3. Goals
- 크리에이터 채널 시리즈 탭 조회 API를 제공한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 하위 조립 계층에 둔다.
- 시리즈 목록, 시리즈 개수, 구매/유료 콘텐츠 통계, 연재 요일 조합처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
- 기존 홈 API의 `CreatorChannelSeries`는 확장하지 않고, 시리즈 탭 전용 도메인 모델과 응답 DTO를 새로 둔다.
- 요청은 `creatorId`, 정렬 순서, 페이징 값을 받는다.
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
- 페이징 동작은 크리에이터 채널 오디오 탭 API와 같은 방식으로 처리한다.
- 응답에는 전체 시리즈 개수, 시리즈 목록, 실제 적용된 정렬 순서, page, size, hasNext를 포함한다.
- 시리즈 목록 item에는 시리즈 id, 제목, 커버 이미지 URL, 연재 요일 문구, 오리지널 여부, 19금 여부, 연재 중 여부, 전체 콘텐츠 개수를 포함한다.
- 조회자가 해당 시리즈의 크리에이터가 아닌 경우에는 구매한 콘텐츠 개수, 유료 콘텐츠 개수, 유료 콘텐츠 중 구매한 콘텐츠 비율도 포함한다.
- 시리즈 제목과 연재 요일 문구는 호출 유저 언어코드에 맞게 반환한다.
---
## 4. Non-Goals
- 이번 범위는 크리에이터 채널 `시리즈` 탭 조회 API만 포함한다.
- 기존 크리에이터 채널 홈 API, 라이브 탭 API, 오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다.
- 시리즈 상세 조회 API는 포함하지 않는다.
- 시리즈 생성/수정/삭제 API는 포함하지 않는다.
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
- 시리즈 번역 데이터가 없는 항목을 새로 번역하거나 생성하는 배치 작업은 포함하지 않는다.
- 앱 표시용 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 시리즈 탭에서 크리에이터의 시리즈를 탐색하는 사용자
- 앱 클라이언트: 시리즈 탭 구성에 필요한 개수/목록/구매 통계를 단일 API 응답으로 표시하려는 클라이언트
- 크리에이터: 자신의 시리즈가 정렬 기준에 따라 적절히 노출되기를 원하는 사용자
---
## 6. User Stories
- 사용자는 크리에이터 채널 시리즈 탭에 들어가면 전체 시리즈 개수를 확인하고 싶다.
- 사용자는 최신순, 인기순, 소장순, 높은 가격순, 낮은 가격순으로 시리즈 목록을 바꿔 보고 싶다.
- 사용자는 시리즈의 연재 요일과 연재 중 여부를 확인하고 싶다.
- 사용자는 유료 콘텐츠 중 자신이 구매한 콘텐츠 비율을 확인하고 싶다.
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
- 앱 클라이언트는 호출 유저 언어코드에 맞는 시리즈 제목과 연재 요일 문구를 받아 화면에 표시하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 시리즈 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/series`를 기본안으로 한다.
- `creatorId`는 path variable로 받는다.
- 정렬 순서는 query parameter로 받는다.
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
- 시리즈 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 fallback한다.
- `size`가 20보다 작으면 `20`으로 fallback한다.
- `size`가 50보다 크면 `50`으로 fallback한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 공개된 시리즈가 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
- 알 수 없는 `sort` 값은 400 오류를 반환하지 않고 `LATEST`로 fallback한다.
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelSeriesTabResponse`를 기본안으로 한다.
- 응답에는 다음 값을 포함한다.
- `seriesCount`: 조회 가능한 전체 시리즈 개수
- `series`: 시리즈 목록
- `sort`: 시리즈 조회에 실제 적용한 정렬 순서
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `seriesCount`는 sort-bar에 표시할 전체 개수이며, 콘텐츠 개수가 아니라 시리즈 개수다.
- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 정렬 조건에서 다음 page에 노출할 시리즈가 있으면 `true`로 내려준다.
- 조회자가 해당 시리즈의 크리에이터인 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate``null`로 내려준다.
- 조회자가 해당 시리즈의 크리에이터가 아닌 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산해 내려준다.
- `purchasedPaidContentRate`는 정수 퍼센트 값으로 내려준다.
- `purchasedPaidContentRate``paidContentCount == 0`이면 `0`으로 내려준다.
- `purchasedPaidContentRate``(purchasedContentCount * 100) / paidContentCount`를 기준으로 계산하고 소수점 이하는 버린다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelSeriesTabResponse(
val seriesCount: Int,
val series: List<CreatorChannelSeriesResponse>,
val sort: ContentSort,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class CreatorChannelSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val publishedDaysOfWeek: String,
val isOriginal: Boolean,
val isAdult: Boolean,
val isProceeding: Boolean,
val contentCount: Int,
val purchasedContentCount: Int?,
val paidContentCount: Int?,
val purchasedPaidContentRate: Int?
)
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
}
```
#### Edge Cases
- 공개된 시리즈가 없으면 `seriesCount``0`, `series`는 빈 배열, `hasNext``false`로 내려준다.
- 요청한 page 범위에 시리즈가 없으면 `series`는 빈 배열, `hasNext``false`로 내려주되 `seriesCount`는 전체 개수를 유지한다.
- 무료 콘텐츠만 포함한 시리즈는 비크리에이터 조회 시 `paidContentCount``0`, `purchasedContentCount``0`, `purchasedPaidContentRate``0`으로 내려준다.
### Feature C. 시리즈 목록과 필드
#### Requirements
- 조회 대상은 지정한 `creatorId`의 시리즈로 제한한다.
- 공개 가능한 활성 시리즈만 조회한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 시리즈는 목록과 개수에서 제외한다.
- 시리즈에 속한 콘텐츠 통계는 공개된 오디오 콘텐츠만 기준으로 계산한다.
- 예약 공개 전 콘텐츠는 콘텐츠 통계에서 제외한다.
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 콘텐츠 통계에서 제외한다.
- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
- `coverImageUrl`은 시리즈의 커버 이미지 경로를 CDN URL로 변환해 내려준다.
- 시리즈 커버 이미지 경로가 없거나 빈 문자열이면 `coverImageUrl``null`로 내려준다.
- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다.
- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다.
- `isProceeding``SeriesState.PROCEEDING`이면 `true`, 그 외 상태이면 `false`로 내려준다.
- `contentCount`는 조회 가능한 공개 콘텐츠 개수다.
- `paidContentCount`는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수다.
- `purchasedContentCount`는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수다.
- 대여 중인 콘텐츠는 구매한 콘텐츠 개수와 `purchasedPaidContentRate` 계산에 포함한다.
- 유효 구매/대여 조건은 기존 오디오 탭과 동일하게 `orders.is_active = true`이며, 대여는 만료되지 않은 주문만 포함한다.
#### Edge Cases
- 제목 번역 데이터는 있지만 빈 문자열이면 원문 시리즈명을 fallback으로 사용한다.
- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함하되 `contentCount`, `paidContentCount`, `purchasedContentCount``0`으로 계산한다.
- 일반적으로 동일 콘텐츠의 소장과 대여가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 콘텐츠 1개로 중복 없이 계산한다.
### Feature D. 연재 요일 문구
#### Requirements
- `publishedDaysOfWeek`는 서버에서 조합한 문자열로 내려준다.
- 일요일부터 토요일까지 7개 요일이 모두 있으면 호출 유저 언어에 맞는 `매일` 문구를 내려준다.
- 7개 요일이 모두 있지 않으면 호출 유저 언어에 맞는 `매주 {요일 목록}` 문구를 내려준다.
- 요일 목록은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT` 순서로 정렬한다.
- 한국어 예시는 `매일`, `매주 월, 목, 토`다.
- 영어 예시는 `Every day`, `Every Mon, Thu, Sat`다.
- 일본어 예시는 `毎日`, `毎週 月, 木, 土`다.
- `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 경우에는 다른 요일 값을 모두 무시하고 호출 유저 언어에 맞는 랜덤 문구만 내려준다.
- 랜덤 문구도 다국어 처리한다.
- 랜덤 문구는 한국어 `랜덤`, 영어 `Random`, 일본어 `ランダム`을 기본안으로 한다.
#### Edge Cases
- 연재 요일이 비어 있으면 빈 문자열 대신 호출 유저 언어에 맞는 랜덤 문구를 fallback으로 내려준다.
- `RANDOM`과 다른 요일이 동시에 저장된 데이터는 `RANDOM`을 우선해 다른 요일을 제거한 것과 같은 결과로 랜덤 문구만 내려준다.
### Feature E. 시리즈 정렬
#### Requirements
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
- 공개 요청/응답 값은 다음을 사용한다.
- `LATEST`: 최신순, 기본값
- `POPULAR`: 인기순
- `OWNED`: 소장순
- `PRICE_HIGH`: 높은 가격순
- `PRICE_LOW`: 낮은 가격순
- `LATEST`는 시리즈에 속한 콘텐츠의 `releaseDate desc`를 1차 정렬로 사용한다.
- `LATEST`의 2차 정렬은 시리즈에 속한 콘텐츠의 `price desc`다.
- `LATEST`의 3차 정렬은 `series.id desc`다.
- `POPULAR`은 시리즈에 속한 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)가 높은 시리즈를 먼저 노출한다.
- `POPULAR`의 매출 합계에는 `orders.is_active = true`인 주문만 포함한다.
- `POPULAR`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
- `POPULAR`의 3차 정렬은 `series.id desc`다.
- `OWNED`는 조회자가 시리즈에 속한 콘텐츠 중 유효하게 소장하거나 대여 중인 콘텐츠 개수가 많은 시리즈를 먼저 노출한다.
- `OWNED`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
- `OWNED`의 3차 정렬은 `series.id desc`다.
- `PRICE_HIGH`는 시리즈에 속한 콘텐츠의 `price desc`를 1차 정렬로 사용한다.
- `PRICE_HIGH`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
- `PRICE_HIGH`의 3차 정렬은 `series.id desc`다.
- `PRICE_LOW`는 시리즈에 속한 콘텐츠의 `price asc`를 1차 정렬로 사용한다.
- `PRICE_LOW`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
- `PRICE_LOW`의 3차 정렬은 `series.id desc`다.
- 시리즈에 여러 콘텐츠가 속한 경우 정렬은 시리즈 단위 집계 대표값을 사용한다.
- 정렬용 `releaseDate`는 항상 내림차순 정렬에만 사용하므로 각 시리즈에 속한 공개 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다.
- `price desc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 높은 가격이다.
- `price asc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 낮은 가격이다.
- 따라서 `LATEST`의 2차 정렬과 `PRICE_HIGH`의 1차 정렬은 시리즈별 최고 가격을 사용하고, `PRICE_LOW`의 1차 정렬은 시리즈별 최저 가격을 사용한다.
#### Edge Cases
- 매출이 없는 시리즈의 인기순 매출값은 0으로 처리한다.
- 조회자가 유효하게 소장하거나 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `series.id desc` 보조 정렬과 같은 결과가 될 수 있다.
- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다.
- 가격이 같은 시리즈는 각 정렬의 2차/3차 기준을 따른다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 하위에 둔다.
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
- 기존 홈 API의 `CreatorChannelSeries`는 홈 응답 전용 요약 모델로 유지하고, 시리즈 탭 API에서는 별도 `CreatorChannelSeriesTab`, `CreatorChannelSeries` 계열 모델을 둔다.
- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다.
- 기존 크리에이터 채널 홈/라이브/오디오 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다.
- 페이징 응답은 기존 오디오 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다.
- 연재 요일 문구 다국어 처리는 서버 코드의 명시적 매핑으로 처리한다.
---
## 9. Metrics
- 시리즈 탭 API 성공/실패 건수
- 시리즈 탭 API 응답 시간
- 정렬 기준별 조회 건수
- 시리즈 탭에서 추가 로딩 요청 건수
---
## 10. Open Questions
- 없음.

View File

@@ -0,0 +1,580 @@
# 크리에이터 채널 커뮤니티 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/community`로 크리에이터 채널 커뮤니티 탭의 조회 가능한 전체 게시글 개수와 페이징된 게시글 목록을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 커뮤니티 게시글 조회 service, page/content masking 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 홈 API는 홈 repository에 커뮤니티 조회 쿼리를 직접 두지 않고, 분리된 커뮤니티 조회 도메인의 홈 요약 조회 메서드를 호출해 기존 `notices`, `communities` 응답 계약을 유지한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/community`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
- response:
- `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수
- `communityPosts`: 커뮤니티 게시글 목록
- `page`: fallback 보정 후 실제 적용된 page index
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- community post item:
- `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned`
- 공개 게시글 기준: `CreatorCommunity.isActive == true`, `CreatorCommunity.member.id == creatorId`, `CreatorCommunity.member.isActive == true`.
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
- 성인 콘텐츠 필터는 구매 여부보다 우선한다. 조회자가 19금 게시글을 구매했거나 작성자여도 성인 콘텐츠 노출 정책이 false이면 제외한다.
- 목록 정렬:
- 고정 게시글을 먼저 노출한다.
- 고정 게시글 사이의 정렬은 `fixedAt desc`, `id desc`다.
- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`다.
- 고정 게시글과 일반 게시글은 하나의 목록으로 페이징한다.
- `communityPostCount`는 고정 게시글과 일반 게시글을 모두 포함한 전체 개수다.
- `createdAtUtc`는 게시글 생성 시각을 UTC 기준 ISO-8601 문자열로 내려준다. 구현 전 재사용 가능한 `toUtcIso` 확장함수를 검색하고, public 확장함수가 있으면 신규 생성 없이 import해서 사용한다.
- 문서 작성 시점 확인 결과 `toUtcIso`는 일부 DTO의 private/internal 확장함수로만 존재하고, 공용 확장 파일인 `kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensions.kt`에는 없다. 구현 시점에도 public 확장함수가 없으면 이 공용 확장 파일에 `fun LocalDateTime.toUtcIso(): String`을 추가하고 커뮤니티 DTO에서 import한다.
- `creatorProfileUrl``CreatorCommunity.member.profileImage``String?.toCdnUrl(cloudFrontHost)`로 변환하고, 없으면 기존 홈 API의 기본 프로필 이미지 URL을 내려준다.
- `existOrdered`는 조회자가 게시글 작성자이면 `true`, 조회자가 유효 구매 내역을 가지고 있으면 `true`, 그 외에는 `false`로 내려준다.
- `imageUrl``CreatorCommunity.imagePath`가 있고 이미지 접근 권한이 있을 때만 `String?.toCdnUrl(cloudFrontHost)`로 변환한다. 경로가 없거나 blank이면 `null`이다.
- legacy 커뮤니티 목록은 유료 미구매 게시글의 이미지를 노출했지만, 커뮤니티 탭 API는 유료 미구매 게시글의 `imageUrl``audioUrl`과 동일하게 `null`로 내려준다.
- 이미지는 signed URL을 생성하지 않고 기존 CDN URL 조합 정책만 사용한다.
- `audioUrl``CreatorCommunity.audioPath`가 있고 접근 권한이 있을 때만 `AudioContentCloudFront.generateSignedURL(resourcePath, 1000 * 60 * 30)` 결과를 내려준다.
- 이미지/오디오 접근 권한:
- 무료 게시글이면 접근 가능
- 유료 게시글이고 조회자가 게시글 작성자이면 접근 가능
- 유료 게시글이고 조회자가 `CanUsage.PAID_COMMUNITY_POST`, `isRefund == false` 구매 내역을 가지면 접근 가능
- 그 외에는 `imageUrl == null`, `audioUrl == null`
- 유료 게시글 본문은 기존 홈 API/legacy 커뮤니티 정책과 같은 마스킹을 적용한다.
- 접근 가능하면 원문
- 접근 불가이고 길이가 15 code point 초과이면 앞 15 code point + `...`
- 접근 불가이고 길이가 15 code point 이하이면 앞 절반 code point + `...`
- `commentCount``isCommentAvailable == false`이면 `0`이다.
- `commentCount`는 활성 최상위 댓글만 세고, 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다.
- `likeCount`는 활성 좋아요 수만 센다.
- legacy `/creator-community` 공개 endpoint는 변경하지 않는다.
- 홈 API 공개 응답 스키마는 변경하지 않는다.
---
## 1. 파일 구조 계획
### 커뮤니티 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
### 커뮤니티 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
### 홈 API 커뮤니티 조회 분리 대상
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/like/CreatorCommunityLikeRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt`
### 문서 산출물
- Create: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`
- Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse {
return CreatorChannelCommunityTabResponse(
communityPostCount = tab.communityPostCount,
communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelCommunityPostResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean
) {
companion object {
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
return CreatorChannelCommunityPostResponse(
postId = post.postId,
creatorId = post.creatorId,
creatorNickname = post.creatorNickname,
creatorProfileUrl = post.creatorProfileUrl,
createdAtUtc = post.createdAt.toUtcIso(),
content = post.content,
imageUrl = post.imageUrl,
audioUrl = post.audioUrl,
price = post.price,
isCommentAvailable = post.isCommentAvailable,
existOrdered = post.existOrdered,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelCommunityTab(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPost>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityPost(
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 createdAt: LocalDateTime,
val existOrdered: Boolean,
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
val isPinned: Boolean
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelCommunityQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): Int
fun findCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean,
offset: Long,
limit: Int
): List<CreatorChannelCommunityPostRecord>
fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord>
}
data class CreatorChannelCommunityCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelCommunityPostRecord(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfilePath: String?,
val imagePath: String?,
val audioPath: String?,
val content: String,
val price: Int,
val createdAt: LocalDateTime,
val existOrdered: Boolean,
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
val isPinned: Boolean
)
```
---
## 4. 작업 계획
### Phase 1: 커뮤니티 도메인 계약과 순수 정책 추가
- [x] **Task 1.1: `CreatorChannelCommunityQueryPolicy`와 domain/port 계약 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
- RED: 아래 케이스를 테스트로 먼저 작성한다.
- `page = null`, `size = null`이면 `page=0`, `size=20`, `offset=0`, `fetchLimit=21`이다.
- `page = -1`, `size = 10`이면 `page=0`, `size=20`, `fetchLimit=21`이다.
- `page = 2`, `size = 100`이면 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이다.
- `limitItems``size`만큼만 남기고 `hasNext``fetched.size > size`로 계산한다.
- 유료 본문 마스킹은 15 code point 초과면 앞 15자 + `...`, 15자 이하면 앞 절반 + `...`로 계산한다.
- 무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다.
- domain model과 port record가 Phase 1 계약 필드를 유지한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"`
- 기대 결과: `CreatorChannelCommunityQueryPolicy`, domain, port 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `CreatorChannelPage`를 재사용해 page 정책을 만들고, `maskPaidContent(content, price, isCreatorSelf, existOrdered)` 순수 함수를 추가한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 정책 클래스에는 DB, Spring MVC, API DTO 의존성을 넣지 않는다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` 실행 결과 `CreatorChannelCommunityQueryPolicy`, domain, port 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 같은 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 1의 순수 정책, domain model, port 계약, 계약 테스트만 추가했고 DB/Spring MVC/API DTO 의존성은 넣지 않았다.
### Phase 2: QueryDSL repository 분리와 조회 정책 구현
- [x] **Task 2.1: 커뮤니티 repository의 creator/차단/count/list 조회 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- RED: `@DataJpaTest(properties = ["spring.cache.type=none", "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"])`, `@Import(QueryDslConfig::class)` 패턴으로 아래 케이스를 작성한다.
- 활성 creator는 `findCreator`로 조회되고 비활성 creator는 `null`이다.
- viewer와 creator 사이 양방향 활성 차단 관계는 `existsBlockedBetween`에서 `true`다.
- `countCommunityPosts`는 creator의 활성 게시글만 세고 다른 creator, 비활성 게시글은 제외한다.
- `canViewAdultContent=false`이면 19금 게시글은 count와 list에서 제외된다.
- `canViewAdultContent=false`이고 viewer가 19금 게시글을 구매했어도 count와 list에서 제외된다.
- list는 고정 게시글을 먼저 반환하고, 고정 게시글은 `fixedAt desc`, 일반 게시글은 `createdAt desc` 순서를 따른다.
- `offset`, `limit`으로 하나의 통합 목록을 페이징한다.
- `likeCount`는 활성 좋아요만 센다.
- `isCommentAvailable=false`인 게시글의 `commentCount``0`이다.
- `commentCount`는 활성 최상위 댓글만 세고, 비밀 댓글은 작성자 본인 또는 콘텐츠 creator에게만 포함한다.
- 차단 관계에 걸린 댓글 작성자의 댓글은 `commentCount`에서 제외된다.
- 유효 구매 내역은 `CanUsage.PAID_COMMUNITY_POST`, `UseCan.member.id == viewerId`, `UseCan.communityPost.id == postId`, `UseCan.isRefund == false`다.
- 같은 게시글에 구매 내역이 중복되어도 list item은 중복되지 않는다.
- 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"`
- 기대 결과: repository 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: 기존 `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 차단 sub query, adult condition을 커뮤니티 repository로 옮기되 탭용 통합 정렬과 count를 추가한다.
- GREEN 구현 기준:
- tab list where는 `isActive == true`, `member.id == creatorId`, `member.isActive == true`, adult condition을 먼저 적용한다.
- 구매 내역 exists/join은 접근 권한 계산에만 사용하고 adult condition을 우회하지 않는다.
- 정렬은 `isFixed desc`, `fixedAt desc nullsLast`, `createdAt desc`, `id desc`를 사용한다.
- home summary 조회는 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`로 제공하고, 기존 홈과 동일하게 고정글/일반글을 분리 조회한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: `v2.creator.channel.community.adapter.out.persistence``v2.api.*`를 import하지 않는다.
- 검증 기록:
- RED: focused test 실행 결과 `DefaultCreatorChannelCommunityQueryRepository` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- GREEN: repository 구현 추가 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 계약 보정: block fixture와 구현을 양방향 활성 차단 정책에 맞춘 뒤 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Review follow-up RED: raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 focused test 2건 실패를 확인했다.
- Review follow-up GREEN: repository 보정 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint: `./gradlew --no-daemon ktlintCheck``DefaultCreatorChannelCommunityQueryRepository.kt` 1개 줄에서 처음 실패했고, formatting 후 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 2 repository/test 파일만 추가했고 service/API/home refactor는 건드리지 않았다.
### Phase 3: 커뮤니티 조회 service 구현
- [x] **Task 3.1: `CreatorChannelCommunityQueryService` 단위 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- RED: fake `CreatorChannelCommunityQueryPort`, mock `MemberContentPreferenceService`, mock `AudioContentCloudFront`, `LangContext`, `SodaMessageSource`를 사용해 아래 케이스를 작성한다.
- 요청 page/size fallback 결과를 port의 `offset`, `limit`에 전달하고 `hasNext`와 응답 목록 size를 조립한다.
- creator가 없으면 `member.validation.user_not_found` 예외를 던진다.
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다.
- 차단 관계가 있으면 기존 `explorer.creator.blocked_access` 메시지 예외를 던진다.
- `MemberContentPreferenceService``isAdultVisibleByPolicy` 결과를 port의 `canViewAdultContent`로 전달한다.
- 이미지 path는 `toCdnUrl(cloudFrontHost)`로 변환하고 blank path는 `null`이다.
- 작성자 프로필 path가 없으면 기존 홈 API의 기본 프로필 URL 정책을 적용한다.
- 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`로 조립한다.
- 무료 이미지, 구매한 유료 이미지, 작성자 본인 유료 이미지는 CDN URL을 사용하고 signed URL을 생성하지 않는다.
- 미구매 유료 이미지는 `imageUrl == null`이다.
- 무료 오디오, 구매한 유료 오디오, 작성자 본인 유료 오디오는 `AudioContentCloudFront.generateSignedURL(audioPath, 1000 * 60 * 30)` 결과를 사용한다.
- 미구매 유료 오디오는 signed URL을 생성하지 않고 `audioUrl == null`이다.
- 유료 미구매 본문은 policy의 마스킹 결과를 사용한다.
- `findHomeCommunityPosts`는 탭 전체 검증 없이 받은 `viewerId`, `canViewAdultContent`, `isPinned`, `limit`로 홈 요약 목록을 조립한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"`
- 기대 결과: service 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `getCommunityTab(creatorId, viewer, page, size, now)``findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`를 구현한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: signed URL 생성은 접근 가능한 오디오 path가 있을 때만 호출하고, 이미지는 signed URL 생성 대상에서 제외한다. service는 API DTO를 반환하지 않는다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` 실행 결과 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: service 구현 추가 후 같은 focused test 실행 중 Phase 1 마스킹 정책 기대값(`15 code point 이하이면 앞 절반 + ...`)과 테스트 기대값 불일치 1건을 확인했고, 테스트 기대값을 정책에 맞춘 뒤 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 3 service/test 파일만 추가했고 API DTO/controller/facade와 홈 API 연결은 건드리지 않았다.
### Phase 4: 홈 API 커뮤니티 조회 로직을 새 도메인으로 연결
- [x] **Task 4.1: 홈 service 회귀 테스트를 새 커뮤니티 service 의존으로 갱신**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- RED: 기존 홈 service 테스트에서 home query port의 `findCommunityPosts` stub 대신 `CreatorChannelCommunityQueryService.findHomeCommunityPosts` 결과를 사용하도록 테스트를 먼저 바꾼다.
- `notices``isPinned=true`, `limit=3`으로 조회한다.
- `communities``isPinned=false`, `limit=3`으로 조회한다.
- 홈 응답의 커뮤니티 필드명은 유지하되, 커뮤니티 도메인 정책에 맞춰 유료 미구매 게시글의 `imageUrl`/`audioUrl``null`이고 `dateUtc`는 게시글 작성 시각(`createdAt`) 기준이다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"`
- 기대 결과: service 생성자/호출부 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `CreatorChannelHomeQueryService``CreatorChannelCommunityQueryService`를 주입하고, 기존 `queryPort.findCommunityPosts` 호출 2곳을 새 community service 호출로 교체한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 홈 domain의 기존 `CreatorChannelCommunityPost` data class를 제거하고, 홈의 `notices`, `communities` 타입은 `kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost`를 사용한다. 홈 API response DTO 변환 결과가 바뀌지 않는지 테스트로 확인한다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` 실행 결과 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 홈 service가 `CreatorChannelCommunityQueryService.findHomeCommunityPosts``isPinned=true/false`, `limit=3`으로 호출하도록 교체하고, 홈 domain/DTO/test fixture를 커뮤니티 domain post 기준으로 보정한 뒤 같은 focused test `BUILD SUCCESSFUL`을 확인했다.
- 홈 API 회귀: 유료 미구매 홈 커뮤니티 게시글의 `imageUrl`/`audioUrl == null`, 고정글 `dateUtc == createdAt` 응답을 `CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`에 고정했고, 포함 회귀 focused test 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 4.2: 홈 port/repository에서 커뮤니티 조회 책임 제거**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 홈 repository 테스트에서 커뮤니티 게시글 조회 전용 테스트가 있으면 동일한 케이스가 `DefaultCreatorChannelCommunityQueryRepositoryTest`로 이동되어야 함을 먼저 확인한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"`
- 기대 결과: 기존 home port method 제거 전에는 테스트/컴파일이 아직 기존 구조를 기대해 실패할 수 있다.
- GREEN:
- `CreatorChannelHomeQueryPort.findCommunityPosts``CreatorChannelCommunityPostRecord`를 제거한다.
- `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 커뮤니티 전용 차단 sub query, `canAccessPaidCommunityContent`, `maskPaidCommunityContent`, `adultCommunityCondition`, `fixedNoticeCondition`, `visibleCommunityPostCondition` 중 홈 repository에서 더 이상 쓰지 않는 커뮤니티 전용 helper를 제거한다.
- 같은 로직은 `DefaultCreatorChannelCommunityQueryRepository`에만 남긴다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 홈 repository에서 `creatorCommunity`, `creatorCommunityLike`, `creatorCommunityComment`, `useCan` import가 더 이상 필요 없으면 제거한다. 다른 홈 조회에서 쓰는 import는 유지한다.
- 검증 기록:
- GREEN: `CreatorChannelHomeQueryPort.findCommunityPosts`, home 전용 `CreatorChannelCommunityPostRecord`, `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`와 커뮤니티 전용 helper/import 및 home repository의 직접 커뮤니티 조회 테스트를 제거했다.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- `rg -n "CreatorChannelHomeQueryPort\.findCommunityPosts|CreatorChannelCommunityPostRecord|findCommunityPosts\(" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence` 결과 home port/repository의 커뮤니티 조회 책임 잔존 0건을 확인했다.
### Phase 5: 커뮤니티 탭 API 조립 계층 추가
- [x] **Task 5.1: response DTO와 facade 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
- RED: 아래 케이스를 테스트로 먼저 작성한다.
- facade는 `CreatorChannelCommunityQueryService.getCommunityTab` 결과를 `CreatorChannelCommunityTabResponse`로 변환한다.
- `createdAtUtc`는 UTC ISO-8601 문자열이다.
- `createdAtUtc` 변환은 재사용 가능한 `toUtcIso` 확장함수가 있으면 기존 확장함수를 import해서 사용하고, 없으면 `LocalDateTimeExtensions.kt`에 공용 확장함수를 추가해 사용한다.
- `creatorProfileUrl`, `existOrdered`가 응답에 포함된다.
- `imageUrl == null`, `audioUrl == null`이 그대로 응답된다.
- `@JsonProperty``isCommentAvailable`, `isPinned`, `hasNext` 필드명이 유지된다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"`
- 기대 결과: DTO/facade 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: PRD와 이 문서의 response data class 초안을 기준으로 DTO와 facade를 구현한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: DTO는 공개 API 필드 변환만 담당하고, 구매/성인/정렬 정책을 포함하지 않는다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` 실행 결과 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: DTO/facade와 공용 `LocalDateTime.toUtcIso()` 확장함수를 추가한 뒤 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: 공개 API 응답 DTO 변환과 facade 위임만 추가했고 구매/성인/정렬 정책은 DTO에 넣지 않았다.
- [x] **Task 5.2: controller 테스트와 endpoint 구현**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- RED: `@WebMvcTest(CreatorChannelCommunityController::class)`와 기존 시리즈/오디오 controller test의 `TestSecurityConfig` 패턴으로 아래 케이스를 작성한다.
- 비회원 요청은 `401 Unauthorized`다.
- 인증 회원 요청은 `GET /api/v2/creator-channels/{creatorId}/community`를 호출하고 `creatorId`, `page`, `size`, `viewer`를 facade에 전달한다.
- `page=-1`, `size=100` 같은 값은 controller에서 거부하지 않고 facade로 전달한다.
- 성공 응답은 `success=true`, `data.communityPostCount`, `data.communityPosts[0].postId`, `data.communityPosts[0].creatorProfileUrl`, `data.communityPosts[0].existOrdered`, `data.communityPosts[0].isCommentAvailable`, `data.communityPosts[0].isPinned`, `data.page`, `data.size`, `data.hasNext`를 포함한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"`
- 기대 결과: controller 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/community")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 패턴으로 구현한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 인증 null guard는 기존 탭 controller와 같은 `requireMember` private 함수로 둔다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` 실행 결과 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: `GET /api/v2/creator-channels/{creatorId}/community` controller 구현 후 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Phase 5 focused 회귀: facade/controller focused tests 동시 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 domain/query 계층의 API 의존 0건을 확인했다.
- 코드 리뷰 및 fresh 검증: controller는 기존 v2 탭 API와 같은 인증/`requireMember` 패턴으로 facade에 `creatorId`, `viewer`, raw `page`, raw `size`만 전달하고, facade/DTO는 query service 결과를 공개 응답 DTO로 변환만 하는 것을 확인했다. `LocalDateTime.toUtcIso()` 공용 확장함수는 기존 v2 DTO private 확장함수와 동일한 UTC offset 직렬화 방식임을 확인했다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`, `git diff --check` 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했고, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다.
### Phase 6: E2E와 회귀 검증
- [x] **Task 6.1: 커뮤니티 탭 end-to-end 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
- RED: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`, `TransactionTemplate` 패턴으로 아래 케이스를 작성한다.
- controller-service-repository를 거쳐 전체 응답 필드를 반환한다.
- 고정 게시글이 일반 게시글보다 먼저 반환된다.
- `page=-1`, `size=10` 요청은 `page=0`, `size=20`으로 fallback된다.
- 성인 콘텐츠 노출 정책이 false인 조회자는 19금 게시글을 count와 list에서 받지 않는다.
- 성인 콘텐츠 노출 정책이 false인 조회자가 19금 게시글을 구매했더라도 count와 list에서 받지 않는다.
- 구매한 유료 게시글의 `imageUrl`은 CDN URL이고 signed URL이 아니며, 미구매 유료 게시글의 `imageUrl``null`이다.
- 구매한 유료 게시글의 `audioUrl`은 signed URL 형태이고, 미구매 유료 게시글의 `audioUrl``null`이다.
- 이미지가 없는 게시글의 `imageUrl``null`이다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`
- 기대 결과: API 미구현 또는 fixture 미연결로 실패
- GREEN: 필요한 fixture helper를 테스트 내부에 추가하고, `@MockBean AudioContentCloudFront`로 signed URL 결과를 `https://signed.test/community-audio`처럼 고정한다. E2E 테스트에서 실제 CloudFront private key 파일을 요구하지 않게 한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: E2E fixture는 테스트 내부 helper로 유지하고 운영 코드에 테스트 전용 분기를 넣지 않는다.
- 검증 기록:
- RED/GREEN: `CreatorChannelCommunityEndToEndTest`를 추가한 뒤 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. 별도 production 수정 없이 즉시 GREEN이었으며, Phase 1-5 구현이 이미 endpoint 동작을 충족했기 때문으로 확인했다.
- 범위: `@SpringBootTest`, `@AutoConfigureMockMvc`, `EmbeddedRedisInitializer`, `TransactionTemplate`, `@MockBean AudioContentCloudFront` 패턴으로 controller-service-repository 실제 경로를 검증했다. 고정글 우선 정렬, `page=-1`/`size=10` fallback, 성인 콘텐츠 비노출, 구매/미구매 유료 이미지·오디오 접근, 이미지 없는 게시글 `imageUrl == null`을 E2E 응답으로 고정했고 운영 코드는 변경하지 않았다. 리뷰 후 기존 v2 E2E와 같은 shared H2 datasource를 사용하도록 보정하고, 미구매 오디오 signed URL 생성이 발생하면 실패하도록 `AudioContentCloudFront` interaction 검증을 추가한 뒤 focused E2E와 ktlint를 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 6.2: 홈 API 회귀와 의존 방향 검증**
- Files:
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 홈 API 응답 스키마가 변경되지 않아야 하므로 기존 테스트가 실패하면 변경 원인을 확인한다.
- 검증 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"`
- 기대 결과: `BUILD SUCCESSFUL`
- 의존 방향 검색:
- `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`
- 기대 결과: 검색 결과 0건
- REFACTOR: 홈 API response DTO의 필드명, `dateUtc`, `existOrdered`, `likeCount`, `commentCount` 의미가 바뀌지 않도록 API DTO 변경을 피한다.
- 검증 기록:
- 홈 회귀: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 community domain/query 계층의 API 의존 0건을 확인했다.
- 코드 리뷰 및 fresh 검증: 신규 E2E가 Phase 6 범위인 controller-service-repository 실제 경로, page/size fallback, 고정글 우선 정렬, 성인 콘텐츠 비노출, 구매/미구매 유료 미디어 접근, 홈 API 회귀, 의존 방향을 검증하는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`, `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check`는 출력 없음, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다.
### Phase 7: 전체 검증과 문서 갱신
- [x] **Task 7.1: 전체 테스트와 ktlint 검증**
- Files:
- Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`
- 검증 실행:
- `./gradlew --no-daemon test`
- 기대 결과: `BUILD SUCCESSFUL`
- `./gradlew --no-daemon ktlintCheck`
- 기대 결과: `BUILD SUCCESSFUL`
- 문서 검증:
- 각 완료 task의 체크박스를 `- [x]`로 갱신한다.
- 각 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
- 전체 검증 결과는 아래 `전체 검증 기록` 섹션에 누적한다.
- REFACTOR: 검증 실패가 구현 범위 변경을 요구하면 먼저 이 문서의 task를 갱신한 뒤 코드를 수정한다.
- 검증 기록:
- 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 7은 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다.
---
## 5. 구현 순서 요약
1. Phase 1에서 순수 정책과 domain/port 계약을 먼저 고정한다.
2. Phase 2에서 QueryDSL repository를 새 커뮤니티 도메인으로 분리한다.
3. Phase 3에서 service가 인증/성인/차단/CDN URL/오디오 signed URL/마스킹 정책을 조립하게 한다.
4. Phase 4에서 홈 API의 커뮤니티 조회를 새 도메인으로 연결하고 홈 repository의 커뮤니티 쿼리를 제거한다.
5. Phase 5에서 공개 API DTO/facade/controller를 추가한다.
6. Phase 6에서 커뮤니티 탭 E2E와 홈 API 회귀를 확인한다.
7. Phase 7에서 전체 테스트, ktlint, 의존 방향 검색 결과를 누적 기록한다.
---
## 6. 전체 검증 기록
- 구현 전 문서 작성 단계에서는 코드 검증을 수행하지 않는다. 구현 단계에서 각 task 완료 즉시 실행 명령과 결과를 이 섹션에 누적한다.
- 2026-06-21: 문서 작성 검증 - placeholder와 모호한 문구 검색 결과 0건.
- 2026-06-21: 문서 변경 whitespace 검증 - `git diff --check` 실행 결과 출력 없음, exit code 0.
- 2026-06-21: 문서 유지보수 규칙 검증 - sandbox 내부 `./gradlew tasks --all``~/.gradle` lock 파일 접근 제한으로 실패했고, 승인 실행으로 재시도한 `./gradlew tasks --all``BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 1 Task 1.1 검증 - RED focused test는 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused test는 `BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 2 Task 2.1 검증 - focused repository test는 RED에서 미구현 repository로 `compileTestKotlin` 실패, GREEN과 양방향 활성 차단 계약 보정 후 `BUILD SUCCESSFUL` 확인. Review follow-up에서 raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 2건 실패를 확인했고 repository 보정 후 focused test `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test``BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 formatting 후 `BUILD SUCCESSFUL`, `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인.
- 2026-06-21: Phase 3 Task 3.1 검증 - RED focused service test는 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused service test는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew --no-daemon test`, `./gradlew --no-daemon ktlintCheck` 모두 `BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 4 Task 4.1 검증 - RED focused home service test는 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패 확인. GREEN focused home service test는 `BUILD SUCCESSFUL` 확인. 리뷰 후 홈 커뮤니티 정책을 유료 미구매 `imageUrl`/`audioUrl == null`, `dateUtc == createdAt`으로 명시하고 테스트에 고정했다.
- 2026-06-21: Phase 4 Task 4.2 검증 - focused home repository test는 `BUILD SUCCESSFUL` 확인. 홈/커뮤니티 회귀 focused test(`CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`, `CreatorChannelCommunityQueryServiceTest`, `DefaultCreatorChannelCommunityQueryRepositoryTest`)는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 import 정렬로 1회 실패 후 `./gradlew --no-daemon ktlintFormat` 적용 및 재실행 결과 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test` 전체 테스트 `BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 5 Task 5.1 검증 - RED focused facade test는 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused facade test는 `BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 5 Task 5.2 검증 - RED focused controller test는 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused controller test는 `BUILD SUCCESSFUL` 확인. Phase 5 facade/controller focused tests 동시 실행, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인.
- 2026-06-21: Phase 5 코드 리뷰 및 fresh 검증 - controller/facade/DTO 구현이 Phase 5 범위인 인증 사용자 전달, raw page/size 전달, query service 위임, 공개 응답 변환에 머무는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check``rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인.
- 2026-06-22: Phase 6 Task 6.1/6.2 검증 - 커뮤니티 탭 E2E focused test, 홈 API 회귀 focused test, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. `git diff --check`와 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인. 리뷰 후 shared H2 datasource와 오디오 signed URL interaction 검증을 보정했고, focused E2E와 ktlint 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-22: Phase 6 코드 리뷰 및 fresh 검증 - 신규 E2E와 Phase 6 문서 기록을 재검토했고 추가 코드 이슈는 발견하지 않았다. focused 커뮤니티 E2E, 홈 API 회귀, `ktlintCheck`, 전체 테스트는 모두 `BUILD SUCCESSFUL`; `git diff --check`와 community domain/query 계층의 API 의존 검색은 출력 없음으로 확인했다.
- 2026-06-22: Phase 7 Task 7.1 검증 - `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`, `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. Phase 7은 전체 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다.

View File

@@ -0,0 +1,254 @@
# PRD: 크리에이터 채널 커뮤니티 탭 API
## 1. Overview
크리에이터 채널의 커뮤니티 탭에서 조회자가 볼 수 있는 커뮤니티 게시글 전체 개수와 게시글 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 커뮤니티 게시글 일부를 홈 화면 요약용으로 조회하지만, 커뮤니티 탭은 전체 개수와 페이징 목록이 필요하다.
- 기존 홈 API의 커뮤니티 조회 로직이 `home` 도메인 repository 안에 포함되어 있어, 커뮤니티 탭 API에서 그대로 재사용하려면 홈 도메인에 의존하게 된다.
- 커뮤니티 게시글 조회 로직은 홈 화면과 커뮤니티 탭에서 모두 쓰일 수 있으므로, 하나의 커뮤니티 조회 도메인으로 분리되어야 한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 커뮤니티 게시글은 전체 개수와 목록에서 모두 제외되어야 한다.
- legacy `/creator-community` 목록 조회는 구매 내역 조건과 성인 필터 조건이 섞일 수 있으므로, `isAdult=false` 조회에서 구매한 19금 게시글이 개수나 목록에 포함되지 않도록 새 v2 조회 정책에서 명확히 보장해야 한다.
- legacy 커뮤니티 목록은 유료 게시글을 구매하지 않은 조회자에게도 게시글 이미지를 노출했지만, 커뮤니티 탭 API는 유료 미구매 게시글의 이미지도 오디오와 동일하게 `null`로 내려줘야 한다.
---
## 3. Goals
- 크리에이터 채널 커뮤니티 탭 조회 API를 제공한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위 조립 계층에 둔다.
- 커뮤니티 게시글 목록, 전체 개수, 구매 여부, 좋아요 수, 댓글 수, 성인 콘텐츠 노출, 유료 이미지/오디오 접근 정책은 API 패키지 밖 커뮤니티 도메인 패키지에 둔다.
- 크리에이터 채널 홈 API와 커뮤니티 탭 API는 동일한 커뮤니티 조회 도메인을 사용한다.
- 응답에는 조회 가능한 커뮤니티 게시글 전체 개수, 게시글 목록, page, size, hasNext를 포함한다.
- 게시글 목록 item에는 게시글 id, 크리에이터 id, 크리에이터 닉네임, 크리에이터 프로필 이미지 URL, 작성 시간 UTC, 게시글 본문, 이미지 URL, 오디오 URL, 가격, 댓글 쓰기 가능 여부, 구매 여부, 좋아요 개수, 댓글 개수, pin 여부를 포함한다.
- 유료 게시글의 이미지와 오디오 콘텐츠는 조회자가 구매했거나 게시글 작성자인 경우에만 내려준다.
- 유료 게시글을 구매하지 않은 조회자에게는 이미지 URL과 오디오 콘텐츠 URL을 `null`로 내려준다.
- 이미지가 없는 게시글은 `imageUrl``null`로 내려준다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 전체 개수와 목록에서 제외한다.
- 페이징 요청값은 기존 오디오/시리즈 탭 API와 같은 보정 규칙을 따른다.
---
## 4. Non-Goals
- 커뮤니티 게시글 작성, 수정, 삭제 API는 포함하지 않는다.
- 커뮤니티 게시글 구매 API는 포함하지 않는다.
- 커뮤니티 댓글 작성, 수정, 삭제, 목록 조회 API는 포함하지 않는다.
- 커뮤니티 좋아요 생성/취소 API는 포함하지 않는다.
- legacy `/creator-community` API의 공개 endpoint 변경은 포함하지 않는다.
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
- 홈 API의 커뮤니티 노출 개수나 홈 화면 구성 정책 변경은 포함하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 앱 표시용 상대 시간 문구는 서버에서 새로 조합하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 커뮤니티 탭에서 크리에이터의 커뮤니티 게시글을 탐색하는 사용자
- 앱 클라이언트: 커뮤니티 탭 구성에 필요한 전체 개수와 게시글 목록을 단일 API 응답으로 표시하려는 클라이언트
- 서버 개발자: 홈 API와 커뮤니티 탭 API에서 커뮤니티 조회 정책을 중복 없이 재사용하려는 개발자
---
## 6. User Stories
- 사용자는 크리에이터 채널 커뮤니티 탭에 들어가면 자신이 조회 가능한 게시글 전체 개수를 확인하고 싶다.
- 사용자는 커뮤니티 게시글을 최신순으로 추가 로딩하고 싶다.
- 성인 콘텐츠 노출이 꺼진 사용자는 19금 게시글이 개수와 목록에 포함되지 않기를 원한다.
- 사용자는 이미지가 없는 게시글도 정상적으로 목록에서 확인하고 싶다.
- 사용자는 구매한 유료 게시글의 오디오 콘텐츠를 재생할 수 있어야 한다.
- 구매하지 않은 사용자는 유료 게시글의 이미지 URL과 오디오 콘텐츠 URL을 받지 않아야 한다.
- 앱 클라이언트는 크리에이터 프로필 이미지, 댓글 작성 가능 여부, 구매 여부, 좋아요 개수, 댓글 개수, pin 여부를 게시글 item에서 바로 확인하고 싶다.
- 서버 개발자는 홈 API와 커뮤니티 탭 API가 동일한 커뮤니티 조회 도메인을 사용한다는 것을 패키지 의존 방향으로 확인하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 커뮤니티 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다.
- `creatorId`는 path variable로 받는다.
- 커뮤니티 게시글 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 보정한다.
- `size`가 20보다 작으면 `20`으로 보정한다.
- `size`가 50보다 크면 `50`으로 보정한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 공개된 커뮤니티 게시글이 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
- 요청한 page 범위에 게시글이 없으면 `communityPosts`는 빈 배열, `hasNext``false`로 내려주되 `communityPostCount`는 전체 개수를 유지한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelCommunityTabResponse`로 한다.
- 응답에는 다음 값을 포함한다.
- `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수
- `communityPosts`: 커뮤니티 게시글 목록
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `communityPostCount`는 목록 조회와 같은 공개 여부, 작성자, 성인 콘텐츠 노출, 차단 정책을 적용해 계산한다.
- `communityPostCount`에는 현재 page에 포함되지 않은 게시글도 포함한다.
- `communityPostCount`는 pinned 게시글과 일반 게시글을 모두 포함한 전체 개수다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 조건에서 다음 page에 노출할 게시글이 있으면 `true`로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class CreatorChannelCommunityPostResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean
)
```
#### Edge Cases
- 조회 가능한 커뮤니티 게시글이 없으면 `communityPostCount``0`, `communityPosts`는 빈 배열, `hasNext``false`로 내려준다.
- 이미지가 없는 게시글은 `imageUrl``null`로 내려준다.
- 유료 게시글을 구매하지 않았고 게시글 작성자도 아닌 조회자에게는 이미지가 있는 게시글이어도 `imageUrl``null`로 내려준다.
- 오디오가 없는 게시글은 `audioUrl``null`로 내려준다.
- `isCommentAvailable == false`인 게시글의 `commentCount`는 기존 커뮤니티 목록 정책과 동일하게 `0`으로 내려준다.
- Boolean 응답 필드는 Jackson 직렬화 시 `commentAvailable`, `pinned`로 바뀌지 않고 `isCommentAvailable`, `isPinned`로 내려가야 한다.
### Feature C. 커뮤니티 게시글 목록과 개수
#### Requirements
- 조회 대상은 지정한 `creatorId`가 작성한 커뮤니티 게시글로 제한한다.
- 활성 게시글만 조회한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 목록에서 제외한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 `communityPostCount`에서도 제외한다.
- 성인 콘텐츠 필터는 구매 여부보다 우선 적용한다.
- 조회자가 19금 게시글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 해당 게시글은 목록과 전체 개수에 포함하지 않는다.
- 목록은 pinned 게시글을 먼저 노출하고, 그 다음 일반 게시글을 노출한다.
- pinned 게시글 사이의 정렬은 `fixedAt desc`, `id desc`를 따른다.
- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`를 따른다.
- 목록은 `page`, `size` 기준으로 페이징 조회한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- `createdAtUtc`는 게시글 생성 시간을 UTC 기준 ISO-8601 문자열로 내려준다.
- `creatorProfileUrl`은 크리에이터 프로필 이미지 path가 있으면 기존 CDN URL 조합 정책으로 내려주고, 없으면 기본 프로필 이미지 URL을 내려준다.
- `existOrdered`는 조회자가 게시글 작성자이면 `true`, 조회자가 유효 구매 내역을 가지고 있으면 `true`, 그 외에는 `false`로 내려준다.
- `imageUrl`은 커뮤니티 게시글 이미지 path가 있고 조회자가 해당 게시글의 유료 미디어에 접근할 수 있을 때만 기존 CDN URL 조합 정책으로 내려준다.
- `likeCount`는 활성 좋아요 수를 기준으로 계산한다.
- `commentCount`는 조회자가 볼 수 있는 활성 최상위 댓글 수를 기준으로 계산한다.
- 댓글 수 계산에는 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다.
#### Edge Cases
- pinned 게시글과 일반 게시글이 섞여 있어도 전체 목록은 하나의 페이징 결과로 내려준다.
- pinned 게시글 개수가 page size를 초과하면 첫 page는 pinned 게시글만 포함될 수 있다.
- 게시글 작성자가 조회자인 경우에도 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 제외한다.
- 좋아요나 댓글이 없는 게시글은 `likeCount`, `commentCount``0`으로 내려준다.
### Feature D. 유료 이미지와 오디오 콘텐츠 접근 정책
#### Requirements
- 커뮤니티 게시글에 이미지 path가 없으면 `imageUrl``null`이다.
- 커뮤니티 게시글에 오디오 path가 없으면 `audioUrl``null`이다.
- 무료 게시글에 이미지 path가 있으면 CDN URL을 내려준다.
- 무료 게시글에 오디오 path가 있으면 signed URL을 내려준다.
- 유료 게시글에 이미지 path가 있고 조회자가 해당 게시글을 구매했으면 CDN URL을 내려준다.
- 유료 게시글에 오디오 path가 있고 조회자가 해당 게시글을 구매했으면 signed URL을 내려준다.
- 유료 게시글에 이미지 path가 있고 조회자가 게시글 작성자이면 CDN URL을 내려준다.
- 유료 게시글에 오디오 path가 있고 조회자가 게시글 작성자이면 signed URL을 내려준다.
- 유료 게시글에 이미지 path가 있지만 조회자가 구매하지 않았고 게시글 작성자도 아니면 `imageUrl``null`이다.
- 유료 게시글에 오디오 path가 있지만 조회자가 구매하지 않았고 게시글 작성자도 아니면 `audioUrl``null`이다.
- 이 이미지 제한 정책은 legacy `/creator-community` 목록의 기존 이미지 노출 동작과 다르며, 커뮤니티 탭 API에서는 오디오 접근 정책과 동일하게 적용한다.
- 이미지 URL은 signed URL로 만들지 않고 기존 CDN URL 조합 정책만 사용한다.
- 오디오 signed URL 생성은 기존 `AudioContentCloudFront.generateSignedURL` 방식을 재사용한다.
- 오디오 signed URL 만료 시간은 legacy 커뮤니티 목록 정책과 동일하게 30분을 기본으로 한다.
- 유료 게시글 본문은 기존 크리에이터 채널 홈 API의 유료 커뮤니티 본문 마스킹 정책을 따른다.
- 유료 게시글 이미지/오디오 접근 여부는 `CanUsage.PAID_COMMUNITY_POST`의 유효 구매 내역을 기준으로 판단한다.
- 환불된 구매 내역은 접근 가능 구매로 보지 않는다.
#### Edge Cases
- 조회자가 구매했더라도 성인 콘텐츠 노출 정책이 false인 19금 게시글은 목록에 포함되지 않으므로 이미지 URL과 오디오 signed URL도 내려주지 않는다.
- 구매 내역이 중복으로 있어도 응답 item은 게시글 1개로 중복 없이 내려준다.
- 이미지 path가 blank이면 `imageUrl``null`로 내려준다.
- 오디오 signed URL 생성 대상 path가 blank이면 `audioUrl``null`로 내려준다.
### Feature E. 커뮤니티 조회 도메인 분리
#### Requirements
- 커뮤니티 탭 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
- 커뮤니티 게시글 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.community -> v2.creator.channel.community`이다.
- 크리에이터 채널 홈 API는 홈 도메인 내부에 커뮤니티 조회 쿼리를 직접 보유하지 않고, 분리된 커뮤니티 조회 도메인을 사용한다.
- 홈 API의 공개 응답 필드명과 필드 의미는 변경하지 않는다.
- 홈 API의 커뮤니티 요약 조회 limit와 notice 조회 정책은 기존 동작을 유지한다.
- legacy `kr.co.vividnext.sodalive.explorer.profile.creatorCommunity` 쓰기/상세/댓글/좋아요/구매 기능은 이번 분리 대상에 포함하지 않는다.
#### Edge Cases
- 홈 API와 커뮤니티 탭 API가 같은 domain model을 사용하더라도 각 API response DTO는 각 API 패키지에서 따로 소유한다.
- 커뮤니티 도메인 분리 과정에서 기존 홈 API controller mapping과 신규 커뮤니티 탭 controller mapping이 충돌하면 안 된다.
- 도메인 분리 후 `v2.creator.channel.community` 하위에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
- 기존 크리에이터 채널 홈/라이브/오디오/시리즈 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책을 재사용한다.
- 성인 콘텐츠 노출 여부는 기존 v2 탭 API와 동일하게 `MemberContentPreferenceService``isAdultVisibleByPolicy`를 기준으로 계산한다.
- 페이징 응답은 기존 오디오/시리즈 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
- 이미지 URL은 기존 `String?.toCdnUrl(cloudFrontHost)` 방식과 같은 CDN URL 조합 정책을 따른다.
- 오디오 URL은 콘텐츠 CloudFront signed URL 생성 정책을 따른다.
- `createdAtUtc` 변환은 기존에 재사용 가능한 `toUtcIso` 확장함수가 있으면 신규 private 확장함수를 만들지 않고 기존 확장함수를 사용한다.
- 날짜 응답은 UTC 기준 ISO-8601 문자열로 내려준다.
---
## 9. Metrics
- 커뮤니티 탭 API 성공/실패 건수
- 커뮤니티 탭 API 응답 시간
- 커뮤니티 탭 추가 로딩 요청 건수
- 성인 콘텐츠 노출 정책이 false인 조회에서 19금 게시글이 개수와 목록에 포함되지 않는 테스트 통과 여부
- 유료 게시글 이미지 CDN URL/null 처리와 오디오 signed URL/null 처리 테스트 통과 여부
- 홈 API 커뮤니티 요약 조회 회귀 테스트 통과 여부
- `v2.creator.channel.community` 도메인 패키지의 `v2.api.*` import 검색 결과 0건 여부
---
## 10. Open Questions
- 없음. 구현 중 새 정책 결정이 필요하면 구현 전에 이 PRD와 `plan-task.md`를 먼저 갱신한다.

View File

@@ -0,0 +1,594 @@
# 크리에이터 채널 FanTalk 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 크리에이터 채널 FanTalk 탭의 전체 FanTalk 개수와 페이징된 FanTalk 글 목록, 크리에이터 답글을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 조립 계층에 둔다. FanTalk 조회 service, page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 두고 `v2.api.*`에 의존하지 않는다. 저장 엔티티는 legacy `CreatorCheers`를 그대로 사용하되, legacy timezone 기반 cheers 응답은 재사용하지 않고 V2 탭 전용 UTC 응답을 만든다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/fan-talks`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
- page 기준: 기존 크리에이터 채널 V2 탭 API와 동일한 0 기반 page index
- response:
- `fanTalkCount`: 조회자가 조회 가능한 최상위 FanTalk 전체 개수
- `fanTalks`: FanTalk 글 목록
- `page`: fallback 보정 후 실제 적용된 page index
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- FanTalk item:
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`, `creatorReplies`
- creator reply item:
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`
- 저장 엔티티: `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`
- 최상위 FanTalk 기준: `creatorCheers.creator.id == creatorId`, `creatorCheers.isActive == true`, `creatorCheers.parent is null`
- 크리에이터 답글 기준: `creatorCheers.parent.id in parentFanTalkIds`, `creatorCheers.creator.id == creatorId`, `creatorCheers.member.id == creatorId`, `creatorCheers.isActive == true`
- 팬끼리 답글 작성은 현재 불가능하므로 응답 대상에 포함하지 않는다. 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 있어도 제외한다.
- 목록 정렬:
- 최상위 FanTalk: `createdAt desc`, `id desc`
- 크리에이터 답글: `createdAt asc`, `id asc`
- `fanTalkCount`는 최상위 FanTalk만 계산한다. 답글은 count에 포함하지 않는다.
- `hasNext``size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
- 차단 필터:
- 조회자와 FanTalk 작성자가 서로 차단 관계이면 해당 최상위 FanTalk는 목록과 count에서 제외한다.
- 차단으로 제외된 최상위 FanTalk의 답글도 응답에 포함하지 않는다.
- 조회자와 조회 대상 크리에이터 사이 차단 관계는 기존 크리에이터 채널 접근 정책과 동일하게 API 접근 자체를 거부한다.
- creator 검증:
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
- `createdAtUtc``CreatorCheers.createdAt``kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답처럼 `removeDeletedNicknamePrefix()`를 적용한다.
- `languageCode`는 FanTalk 탭 응답에 포함하지 않는다.
- legacy `/profile/{id}/cheers` 공개 endpoint와 응답 스키마는 변경하지 않는다.
- 크리에이터 채널 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
---
## 1. 파일 구조 계획
### FanTalk 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
### FanTalk 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRole.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
### 문서 산출물
- Create: `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`
- Verify: `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
data class CreatorChannelFanTalkTabResponse(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalkResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
return CreatorChannelFanTalkTabResponse(
fanTalkCount = tab.fanTalkCount,
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelFanTalkResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String,
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
) {
companion object {
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
return CreatorChannelFanTalkResponse(
fanTalkId = fanTalk.fanTalkId,
writerId = fanTalk.writerId,
writerNickname = fanTalk.writerNickname,
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
content = fanTalk.content,
createdAtUtc = fanTalk.createdAt.toUtcIso(),
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
)
}
}
}
data class CreatorChannelFanTalkReplyResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String
) {
companion object {
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
return CreatorChannelFanTalkReplyResponse(
fanTalkId = reply.fanTalkId,
writerId = reply.writerId,
writerNickname = reply.writerNickname,
writerProfileImageUrl = reply.writerProfileImageUrl,
content = reply.content,
createdAtUtc = reply.createdAt.toUtcIso()
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelFanTalkTab(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalk>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelFanTalk(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAt: LocalDateTime,
val creatorReplies: List<CreatorChannelFanTalkReply>
)
data class CreatorChannelFanTalkReply(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAt: LocalDateTime
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelFanTalkQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countFanTalks(creatorId: Long, viewerId: Long): Int
fun findFanTalks(
creatorId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelFanTalkRecord>
fun findCreatorReplies(
creatorId: Long,
parentFanTalkIds: List<Long>
): List<CreatorChannelFanTalkReplyRecord>
}
data class CreatorChannelFanTalkCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelFanTalkRecord(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)
data class CreatorChannelFanTalkReplyRecord(
val fanTalkId: Long,
val parentFanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)
```
---
## 4. Query policy 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`에 아래 정책을 둔다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.springframework.stereotype.Component
@Component
class CreatorChannelFanTalkQueryPolicy {
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
return CreatorChannelPage(
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
)
}
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
return fetched.take(page.size)
}
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
return fetched.size > page.size
}
companion object {
private const val DEFAULT_PAGE = 0
private const val DEFAULT_PAGE_SIZE = 20
private const val MIN_PAGE = 0
private const val MIN_PAGE_SIZE = 20
private const val MAX_PAGE_SIZE = 50
}
}
```
---
## 5. 구현 TASK
### Phase 1: FanTalk 도메인 모델과 페이징 정책
- [x] **Task 1.1: FanTalk 페이징 정책 테스트와 구현**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- RED: `page`, `size` 보정과 `hasNext`, `limitItems` 동작 테스트를 먼저 작성한다.
- 테스트 케이스:
- `page == null`, `size == null`이면 `page=0`, `size=20`
- `page < 0`이면 `0`
- `size < 20`이면 `20`
- `size > 50`이면 `50`
- fetched size가 `size + 1`이면 `hasNext == true`
- fetched size가 `size` 이하이면 `hasNext == false`
- `limitItems`는 최대 `size`개만 반환
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- GREEN: `CreatorChannelFanTalkQueryPolicy``CreatorChannelCommunityQueryPolicy`와 같은 보정 규칙으로 최소 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- REFACTOR: 상수와 메서드명이 커뮤니티/시리즈 탭 정책과 일관되는지 확인한다.
- [x] **Task 1.2: FanTalk domain model과 port 계약 추가**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
- RED: Task 1.1 테스트에 domain/port 타입 import를 추가해 타입 부재 컴파일 실패를 확인한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- GREEN: `CreatorChannelFanTalkTab`, `CreatorChannelFanTalk`, `CreatorChannelFanTalkReply`, `CreatorChannelFanTalkQueryPort`, record data class를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- REFACTOR: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain``port/out`에서 `v2.api` import가 없는지 확인한다.
- 확인 명령: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
### Phase 2: API 응답 DTO와 조립 계층
- [x] **Task 2.1: FanTalk 응답 DTO와 UTC 변환 테스트**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- RED: facade 테스트에서 domain tab을 response로 변환했을 때 필드명과 UTC 문자열이 PRD와 일치하는지 검증한다.
- 검증 값:
- `fanTalkCount`
- `fanTalks[0].writerId`
- `fanTalks[0].writerNickname`
- `fanTalks[0].writerProfileImageUrl`
- `fanTalks[0].content`
- `fanTalks[0].createdAtUtc`
- `fanTalks[0].creatorReplies[0].writerId`
- `page`
- `size`
- JSON 직렬화 필드명 `hasNext`
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- GREEN: DTO를 추가하고 `createdAt.toUtcIso()`를 사용해 UTC ISO 문자열을 내려준다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- REFACTOR: `languageCode`가 응답 DTO에 포함되지 않았는지 확인한다.
- 확인 명령: `rg -n "languageCode" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
- [x] **Task 2.2: FanTalk facade 추가**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
- RED: facade가 query service의 `getFanTalkTab(creatorId, viewer, page, size, now)` 결과를 `CreatorChannelFanTalkTabResponse`로 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- GREEN: `CreatorChannelFanTalkFacade``@Service`, `@Transactional(readOnly = true)`로 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- REFACTOR: facade가 API DTO와 domain query service 조립 외 책임을 갖지 않는지 확인한다.
- [x] **Task 2.3: FanTalk controller 추가**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- RED: MockMvc 테스트를 작성한다.
- `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=1&size=20` 요청이 facade에 `creatorId`, `page=1`, `size=20`을 전달한다.
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/fan-talks")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 구조로 controller를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
- REFACTOR: controller가 `ApiResponse.ok(...)``requireMember` 외 응답 가공 책임을 갖지 않는지 확인한다.
### Phase 3: FanTalk 조회 서비스
- [x] **Task 3.1: query service의 creator 검증과 접근 차단 처리**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- RED: query service 테스트를 작성한다.
- creator가 없으면 `SodaException(messageKey = "member.validation.user_not_found")`
- creator role이 `MemberRole.CREATOR`가 아니면 `SodaException(messageKey = "member.validation.creator_not_found")`
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 채널 접근 차단 오류
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- GREEN: `CreatorChannelFanTalkQueryService`를 추가하고 `findCreator`, `existsBlockedBetween`, role 검증을 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- REFACTOR: 에러 키와 차단 메시지 흐름이 커뮤니티/홈 query service와 같은지 확인한다.
- [x] **Task 3.2: query service의 page/count/list/reply 조립**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- RED: query service 테스트를 추가한다.
- `page=-1`, `size=10` 요청 시 port에는 `offset=0`, `limit=21`이 전달되고 응답 `page=0`, `size=20`
- fetched FanTalk가 `size + 1`개이면 응답 목록은 `size`개이고 `hasNext=true`
- fetched FanTalk가 비어 있으면 `fanTalks=[]`, `hasNext=false`
- `countFanTalks` 결과가 `fanTalkCount`로 내려간다.
- `findCreatorReplies` 결과는 parent id 기준으로 각 FanTalk의 `creatorReplies`에 묶인다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- GREEN: `CreatorChannelFanTalkQueryPolicy`로 page를 만들고, `countFanTalks`, `findFanTalks`, `findCreatorReplies`를 호출해 `CreatorChannelFanTalkTab`을 조립한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- REFACTOR: reply 조회는 page에 포함된 parent FanTalk id만 대상으로 호출하는지 확인한다.
- [x] **Task 3.3: query service의 URL/닉네임 변환**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- RED: query service 테스트를 추가한다.
- writer profile path가 있으면 CDN URL로 변환한다.
- writer profile path가 없으면 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
- writer nickname은 `removeDeletedNicknamePrefix()` 결과를 내려준다.
- reply writer도 같은 URL/닉네임 변환을 적용한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- GREEN: `String?.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()``removeDeletedNicknamePrefix()`를 적용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- REFACTOR: default profile URL 생성 방식이 홈/커뮤니티 query service와 일관되는지 확인한다.
### Phase 4: QueryDSL repository
- [x] **Task 4.1: FanTalk repository 기본 creator/차단 조회**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- RED: repository 테스트를 작성한다.
- `findCreator`가 creator id, role, nickname을 조회한다.
- `existsBlockedBetween`가 양방향 활성 차단 관계를 감지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- GREEN: `JPAQueryFactory` 기반 repository를 추가하고 홈/커뮤니티 repository와 같은 creator/차단 조건을 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- REFACTOR: repository class 이름은 `Default...Repository` 접두사 규칙을 따른다.
- [x] **Task 4.2: 최상위 FanTalk count/list 조회**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
- RED: repository 테스트를 추가한다.
- `countFanTalks``creator.id`, `isActive=true`, `parent is null` 조건만 count한다.
- 비활성 FanTalk는 count/list에서 제외한다.
- 답글 FanTalk는 count/list에서 제외한다.
- 조회자와 작성자 사이 차단 관계가 있으면 count/list에서 제외한다.
- 목록 정렬은 `createdAt desc`, `id desc`다.
- `offset`, `limit`이 적용된다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- GREEN: `countFanTalks`, `findFanTalks`를 구현한다. projection은 `CreatorChannelFanTalkRecord`를 사용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- REFACTOR: 홈 API의 `fanTalkSummaryCondition`과 조건 의미가 일치하는지 확인한다.
- [x] **Task 4.3: 크리에이터 답글 조회**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
- RED: repository 테스트를 추가한다.
- `findCreatorReplies`는 parent id 목록에 속한 활성 답글만 조회한다.
- 답글 작성자가 조회 대상 크리에이터인 데이터만 조회한다.
- 크리에이터가 아닌 회원의 답글은 제외한다.
- 비활성 답글은 제외한다.
- 답글 정렬은 `createdAt asc`, `id asc`다.
- `parentFanTalkIds`가 빈 목록이면 빈 목록을 반환하고 DB 조회 결과가 없어야 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- GREEN: `findCreatorReplies`를 구현한다. projection은 `CreatorChannelFanTalkReplyRecord`를 사용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- REFACTOR: reply 조회가 최상위 FanTalk page 결과 외 parent를 가져오지 않는지 확인한다.
### Phase 5: API 통합과 회귀 검증
- [x] **Task 5.1: FanTalk End-to-End 테스트**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
- RED: E2E 테스트를 작성한다.
- 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=0&size=20` 호출 시 200 OK
- 응답 JSON에 `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext`가 포함된다.
- 최상위 FanTalk의 `createdAtUtc`는 UTC ISO 문자열이다.
- 크리에이터 답글은 `creatorReplies`에 포함된다.
- 팬이 작성한 비정상 답글 데이터는 응답에 포함되지 않는다.
- page 범위를 벗어나면 빈 목록과 `hasNext=false`를 반환하되 count는 유지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
- GREEN: Phase 1~4 구현을 연결해 E2E 테스트를 통과시킨다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
- REFACTOR: 테스트 데이터가 다른 크리에이터 채널 탭 테스트와 충돌하지 않도록 독립 fixture를 사용한다.
- [x] **Task 5.2: 패키지 의존 방향과 기존 API 회귀 확인**
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
- RED: 신규 테스트 추가는 없다. 이 task는 문서화된 구조 검증 task다.
- TDD 예외 사유: 패키지 의존 방향과 기존 endpoint 비변경 여부는 정적 검색과 기존 회귀 테스트가 더 직접적인 검증이다.
- 대체 검증 방법:
- `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- `rg -n "fan-talks|/profile/\\{id\\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer`
- GREEN: `v2.creator.channel.fantalk` 하위에서 `v2.api.*` import 검색 결과가 0건인지 확인한다.
- 통과 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
- REFACTOR: 홈 API와 legacy cheers endpoint의 공개 응답 스키마를 변경한 파일 diff가 없는지 확인한다.
- [x] **Task 5.3: 전체 FanTalk 관련 테스트와 ktlint 검증**
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- RED: 신규 테스트 추가는 없다. 이 task는 구현 완료 후 회귀 검증 task다.
- TDD 예외 사유: 전체 회귀와 ktlint는 구현 완료 상태를 검증하는 명령 실행 task다.
- 대체 검증 방법:
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
- `./gradlew ktlintCheck`
- GREEN: 실패하는 FanTalk 관련 테스트나 ktlint 오류가 있으면 해당 task의 구현 단계로 돌아가 수정한다.
- 통과 확인:
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
- `./gradlew ktlintCheck`
- REFACTOR: 필요한 경우 `./gradlew test`를 추가 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
---
## 6. 구현 시 주의사항
- 구현 전에 이 문서와 `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`가 같은 endpoint, page 기준, response field를 말하는지 다시 확인한다.
- 신규 공개 API 스키마 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 수정한다.
- `CreatorCheers` 엔티티 자체 구조는 변경하지 않는다.
- legacy `ExplorerQueryRepository.getCheersList`는 timezone 표시 문자열을 만들기 때문에 신규 V2 응답 DTO에 재사용하지 않는다.
- FanTalk 탭 query service는 홈 API query service에 의존하지 않는다.
- 홈 API의 `findFanTalkSummary`는 이번 작업에서 수정하지 않는 것을 기본으로 한다. 수정이 필요해지면 PRD와 이 문서를 먼저 갱신한다.
- controller/facade/DTO 조립 계층은 `v2.api.creator.channel.fantalk`에만 둔다.
- domain/application/port/repository 조회 계층은 `v2.creator.channel.fantalk`에만 둔다.
- 테스트 작성 시 Redis가 필요 없는 JPA/QueryDSL slice 테스트는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 관례를 따른다.
- 테스트 완료 후 각 task 아래에 실행 명령과 결과를 한국어로 누적 기록한다.
---
## 7. 검증 기록
- 문서 생성 시점에는 구현 코드를 작성하지 않았으므로 신규 테스트는 실행하지 않았다.
- 문서 변경 검증으로 `./gradlew tasks --all`을 실행했다.
- sandbox 일반 실행은 Gradle wrapper가 `/Users/klaus/.gradle/wrapper/dists/gradle-8.1.1-bin/9wiye5v2saajue4irfo8ybqfp/gradle-8.1.1-bin.zip.lck`에 접근하지 못해 `Operation not permitted`로 실패했다.
- 권한 승인 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- Phase 1 Task 1.1/1.2 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` 실행 시 `CreatorChannelFanTalkQueryPolicy`, FanTalk domain model, FanTalk port record 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk 페이징 정책, domain model, port 계약 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 확인: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 2 Task 2.1/2.2 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` 실행 시 FanTalk 응답 DTO, FanTalk facade, FanTalk query service 타입 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk 응답 DTO, FanTalk facade, Phase 3 구현 전 facade 컴파일을 위한 `CreatorChannelFanTalkQueryService` 최소 shell 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- Phase 2 범위 준수: `CreatorChannelFanTalkQueryService`는 최종 public method signature만 두고 조회/검증/DB/port 구현은 추가하지 않았다.
- Phase 2 Task 2.3 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 시 `CreatorChannelFanTalkController` 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk controller 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 3 Task 3.1/3.2/3.3 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` 실행 시 `CreatorChannelFanTalkQueryService` 생성자 의존성 미구현으로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk query service의 creator 검증, 접근 차단, page/count/list/reply 조립, CDN URL/default profile URL, 탈퇴 닉네임 prefix 제거 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest``./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 assertion 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 4 Task 4.1/4.2/4.3 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 시 `DefaultCreatorChannelFanTalkQueryRepository` 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk QueryDSL repository의 creator 조회, 양방향 차단 조회, 최상위 FanTalk count/list, 크리에이터 답글 조회 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 fixture 호출 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- Phase 4 코드 리뷰 및 재검증을 진행했다.
- 리뷰 범위: `DefaultCreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryPort`, `DefaultCreatorChannelFanTalkQueryRepositoryTest`, `CreatorChannelFanTalkQueryService` 연동부를 PRD/plan의 Phase 4 요구사항과 대조했다.
- 리뷰 결과: creator/차단 조회, 최상위 FanTalk count/list 조건, 정렬, offset/limit, 크리에이터 답글 필터와 빈 parent 목록 처리에서 수정이 필요한 결함을 발견하지 않았다.
- 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 관련 회귀 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- FanTalk 전체 재검증: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 재확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- ktlint 재확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Phase 5 Task 5.1 구현 검증을 진행했다.
- GREEN: `CreatorChannelFanTalkEndToEndTest`를 추가해 인증 회원의 FanTalk 탭 200 OK, 응답 필드, UTC ISO 문자열, 크리에이터 답글 포함, 팬 작성 답글 제외, 범위 밖 page의 빈 목록/count 유지 동작을 검증했다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 5 Task 5.2 회귀 검증을 진행했다.
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- API 참조 확인: `rg -n "fan-talks|/profile/\{id\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer` 실행 결과 신규 `fan-talks` controller 매핑과 기존 legacy cheers/home latestFanTalk 참조만 확인했다.
- 통과 확인: `./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.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Phase 5 Task 5.3 전체 FanTalk 관련 테스트와 ktlint 검증을 진행했다.
- FanTalk 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,216 @@
# PRD: 크리에이터 채널 FanTalk 탭 API
## 1. Overview
크리에이터 채널의 FanTalk 탭에서 전체 FanTalk 개수와 FanTalk 글 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 FanTalk 전체 개수와 최신 FanTalk 1건만 요약으로 제공한다.
- FanTalk 탭은 전체 개수, 페이징된 글 목록, 각 글에 달린 크리에이터 답글을 함께 표시해야 한다.
- legacy `/profile/{id}/cheers` API는 FanTalk를 조회하지만 날짜를 timezone 기반 표시 문자열로 내려주므로, V2 크리에이터 채널 탭 API에서 요구하는 UTC 기반 응답 계약과 맞지 않는다.
- FanTalk 엔티티는 legacy `CreatorCheers`를 사용하되, 신규 API 조립 계층과 도메인 조회 계층은 기존 V2 크리에이터 채널 탭 패턴처럼 분리해야 한다.
---
## 3. Goals
- 크리에이터 채널 FanTalk 탭 조회 API를 제공한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위 조립 계층에 둔다.
- FanTalk 목록, 전체 개수, 답글 조회, 페이징 보정, 차단 필터링 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 도메인 조회 계층에 둔다.
- FanTalk 저장 엔티티는 기존 `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`를 사용한다.
- 응답에는 조회 가능한 전체 FanTalk 개수, FanTalk 글 목록, page, size, hasNext를 포함한다.
- FanTalk 글 item에는 글쓴이 닉네임, 글쓴이 ID, 글쓴이 프로필 이미지, 글쓴이가 쓴 글, 글 쓴 시간 UTC, 크리에이터가 쓴 답글 목록을 포함한다.
- 크리에이터 답글 item도 FanTalk 글과 동일한 작성자/본문/시간 필드 구조를 사용한다.
- 페이징 요청값은 기존 V2 크리에이터 채널 커뮤니티/시리즈 탭 API와 같은 보정 규칙을 따른다.
---
## 4. Non-Goals
- FanTalk 작성, 수정, 삭제 API는 포함하지 않는다.
- FanTalk 답글 작성, 수정, 삭제 API는 포함하지 않는다.
- 팬 회원 간 답글 작성/조회 기능은 포함하지 않는다. 현재 팬끼리 답글을 작성할 수 없으므로 FanTalk 탭 응답에서도 팬 간 답글을 고려하지 않는다.
- legacy `/profile/{id}/cheers` API의 공개 endpoint나 응답 스키마 변경은 포함하지 않는다.
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 앱 표시용 상대 시간 문구나 timezone 변환 문자열은 서버에서 새로 조합하지 않는다.
- 신고, 언어 감지, 푸시 알림 정책 변경은 포함하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 FanTalk 탭에서 다른 팬들의 FanTalk 글과 크리에이터 답글을 탐색하는 사용자
- 앱 클라이언트: FanTalk 탭 구성에 필요한 전체 개수와 페이징 목록을 단일 API 응답으로 표시하려는 클라이언트
- 서버 개발자: 기존 `CreatorCheers` 저장 구조를 유지하면서 V2 조회 계층을 분리하려는 개발자
---
## 6. User Stories
- 사용자는 크리에이터 채널 FanTalk 탭에 들어가면 전체 FanTalk 개수를 확인하고 싶다.
- 사용자는 FanTalk 글을 최신순으로 추가 로딩하고 싶다.
- 사용자는 각 FanTalk 글에 크리에이터가 남긴 답글을 같은 화면에서 확인하고 싶다.
- 사용자는 글쓴이 닉네임, ID, 프로필 이미지, 본문, 작성 시간을 목록 item에서 바로 확인하고 싶다.
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 FanTalk 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
- `creatorId`는 path variable로 받는다.
- FanTalk 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 기존 V2 탭 API와 동일하게 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 보정한다.
- `size`가 20보다 작으면 `20`으로 보정한다.
- `size`가 50보다 크면 `50`으로 보정한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 조회 가능한 FanTalk가 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
- 요청한 page 범위에 FanTalk가 없으면 `fanTalks`는 빈 배열, `hasNext``false`로 내려주되 `fanTalkCount`는 전체 개수를 유지한다.
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelFanTalkTabResponse`로 한다.
- 응답에는 다음 값을 포함한다.
- `fanTalkCount`: 조회자가 조회 가능한 전체 FanTalk 개수
- `fanTalks`: FanTalk 글 목록
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `fanTalkCount`는 최상위 FanTalk 글만 계산한다.
- `fanTalkCount`에는 현재 page에 포함되지 않은 FanTalk 글도 포함한다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 조건에서 다음 page에 노출할 FanTalk 글이 있으면 `true`로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelFanTalkTabResponse(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalkResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class CreatorChannelFanTalkResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String,
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
)
data class CreatorChannelFanTalkReplyResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String
)
```
#### Edge Cases
- 조회 가능한 FanTalk가 없으면 `fanTalkCount``0`, `fanTalks`는 빈 배열, `hasNext``false`로 내려준다.
- FanTalk 글에 크리에이터 답글이 없으면 `creatorReplies`는 빈 배열로 내려준다.
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답 정책을 따른다.
- `createdAtUtc``CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
- Boolean 응답 필드는 현재 스키마에 없지만, 추후 추가 시 Jackson 직렬화 필드명을 명시해야 한다.
### Feature C. FanTalk 목록과 개수
#### Requirements
- 조회 대상은 지정한 `creatorId`의 FanTalk로 제한한다.
- 저장 엔티티는 `CreatorCheers`를 사용한다.
- 최상위 FanTalk 글은 `CreatorCheers.parent is null`인 활성 데이터로 정의한다.
- 활성 데이터는 `CreatorCheers.isActive == true`인 데이터로 정의한다.
- 목록은 최상위 FanTalk 글만 페이징한다.
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
- 전체 개수는 목록과 같은 creator, active, parent, 차단 필터 조건을 적용해 계산한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- 글쓴이 ID는 `CreatorCheers.member.id`를 사용한다.
- 글쓴이 닉네임은 `CreatorCheers.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
- 글쓴이 프로필 이미지는 `CreatorCheers.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
- 글쓴이가 쓴 글은 `CreatorCheers.cheers`를 사용한다.
- 글 쓴 시간은 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
- `languageCode`는 이번 FanTalk 탭 응답에 포함하지 않는다.
#### Edge Cases
- `CreatorCheers.createdAt`이 nullable 기반 엔티티 필드에서 온 경우에도 조회 결과 응답에는 null이 나오지 않아야 한다.
- FanTalk 작성자가 조회자와 차단 관계이면 해당 최상위 글은 목록과 개수에서 제외한다.
- 차단으로 제외된 최상위 글의 답글도 응답에 포함하지 않는다.
- 같은 작성자의 FanTalk가 여러 건 있어도 각각 별도 item으로 내려준다.
### Feature D. 크리에이터 답글 포함
#### Requirements
- 각 FanTalk 글에는 크리에이터가 쓴 활성 답글 목록을 `creatorReplies`로 포함한다.
- 답글은 `CreatorCheers.parent`가 해당 최상위 FanTalk 글인 데이터로 조회한다.
- 답글 작성자가 조회 대상 크리에이터인 데이터만 포함한다.
- 답글도 `CreatorCheers.isActive == true`인 데이터만 포함한다.
- 답글 item의 필드 구조는 최상위 FanTalk 글과 동일한 작성자 ID, 닉네임, 프로필 이미지, 본문, UTC 작성 시간을 사용한다.
- 답글 정렬은 오래된 답글부터 확인할 수 있도록 `createdAt asc`, `id asc`를 따른다.
- 현재 팬끼리 답글을 작성할 수 없으므로 크리에이터가 아닌 회원의 답글은 정상 응답 대상이 아니다.
- 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 존재하더라도 응답에 포함하지 않는다.
#### Edge Cases
- 크리에이터 답글이 여러 개면 모두 `creatorReplies`에 포함한다.
- 크리에이터가 작성했지만 비활성 처리된 답글은 포함하지 않는다.
- 답글 작성자인 크리에이터 프로필 이미지가 없으면 기본 프로필 이미지 URL을 내려준다.
- 답글 작성자인 크리에이터가 조회자와 차단 관계인 경우는 이미 채널 접근 차단 조건에서 처리된다.
### Feature E. V2 재사용 범위와 계층 분리
#### Requirements
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위에 둔다.
- FanTalk 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 둔다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.fantalk -> v2.creator.channel.fantalk`이다.
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
- 기존 홈 API의 FanTalk 요약 조회 로직은 참고하되, 홈 도메인 repository에 신규 탭 페이징 책임을 추가하지 않는다.
- legacy `ExplorerQueryRepository.getCheersList`의 timezone 기반 날짜 포맷 응답은 신규 V2 API에서 재사용하지 않는다.
#### Edge Cases
- 신규 `fantalk` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
- 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
- legacy FanTalk 작성/수정/삭제 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- 언어/런타임은 Kotlin + Java 17을 따른다.
- 프레임워크는 Spring Boot 2.7.14를 따른다.
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
---
## 9. Decisions
- endpoint 이름은 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 확정한다.
- `page`는 기존 크리에이터 채널 V2 탭 API와 동일하게 0 기반 page index로 처리한다.

View File

@@ -0,0 +1,544 @@
# 크리에이터 채널 후원 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/donations`로 크리에이터 채널 후원 탭의 전체 채널 후원 개수, 후원 순위 Top 8, 페이징된 채널 후원 목록을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 조립 계층에 둔다. 후원 탭 조회 service, page/month 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 두고 `v2.api.*`에 의존하지 않는다. 채널 후원 목록은 기존 `ChannelDonationMessage`와 홈 API 후원 섹션 조건을 따르고, 후원 순위는 legacy `CreatorDonationRankingService`를 통해 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일하게 재사용한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/donations`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 보정
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 보정
- response:
- `donationCount`: 조회자가 조회 가능한 현재 KST 월 범위의 전체 채널 후원 개수
- `rankings`: 후원 순위 Top 8 목록
- `donations`: 채널 후원 목록
- `page`: 보정 후 실제 적용된 page index
- `size`: 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- channel donation item:
- `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`
- ranking item:
- `userId`, `nickname`, `profileImage`, `donationCan`
- 채널 후원 목록 기준:
- 저장 엔티티는 `kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage`를 사용한다.
- 기간은 홈 후원 섹션과 동일하게 현재 KST 월 시작 이상, 다음 달 KST 월 시작 미만을 UTC `LocalDateTime`으로 변환해 사용한다.
- 정렬은 `createdAt desc`, `id desc`를 따른다.
- `hasNext``size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
- 비공개 후원 노출:
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
- 후원 순위 기준:
- `CreatorDonationRankingService.getMemberDonationRanking(...)`를 통해 legacy `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과를 재사용한다.
- Top 8 조회는 `offset = 0`, `limit = 8`을 사용한다.
- 기간은 크리에이터의 `donationRankingPeriod`를 따르고, 값이 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
- `DonationRankingPeriod.WEEKLY`이면 legacy service의 주간 범위를 그대로 사용한다.
- `DonationRankingPeriod.CUMULATIVE`이면 legacy service의 전체 누적 범위를 그대로 사용한다.
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank``true`이면 `rankings`를 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`이면 `rankings`는 빈 배열이다.
- `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 조회한다.
- `donationCan`은 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
- creator 검증:
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
- `createdAtUtc``ChannelDonationMessage.createdAt``kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
- 후원자 닉네임은 `removeDeletedNicknamePrefix()`를 적용한다.
- 후원 메시지는 홈 API와 동일하게 `additionalMessage`가 없으면 빈 문자열로 내려준다. 레거시 `ChannelDonationService.buildMessage` 기본 문구 조합은 사용하지 않는다.
- legacy `/explorer/profile/channel-donation` 공개 endpoint와 응답 스키마는 변경하지 않는다.
- 크리에이터 채널 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
---
## 1. 파일 구조 계획
### 후원 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
### 후원 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
### 문서 산출물
- Create: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
data class CreatorChannelDonationTabResponse(
val donationCount: Int,
val rankings: List<MemberDonationRankingResponse>,
val donations: List<CreatorChannelDonationResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelDonationTab): CreatorChannelDonationTabResponse {
return CreatorChannelDonationTabResponse(
donationCount = tab.donationCount,
rankings = tab.rankings.map(MemberDonationRankingResponse::from),
donations = tab.donations.map(CreatorChannelDonationResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class MemberDonationRankingResponse(
@JsonProperty("userId") val userId: Long,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("profileImage") val profileImage: String,
@JsonProperty("donationCan") val donationCan: Int
) {
companion object {
fun from(ranking: CreatorChannelDonationRanking): MemberDonationRankingResponse {
return MemberDonationRankingResponse(
userId = ranking.userId,
nickname = ranking.nickname,
profileImage = ranking.profileImage,
donationCan = ranking.donationCan
)
}
}
}
data class CreatorChannelDonationResponse(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelDonationTab(
val donationCount: Int,
val rankings: List<CreatorChannelDonationRanking>,
val donations: List<CreatorChannelDonation>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelDonationRanking(
val userId: Long,
val nickname: String,
val profileImage: String,
val donationCan: Int
)
data class CreatorChannelDonation(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAt: LocalDateTime
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelDonationQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime
): Int
fun findChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
offset: Long,
limit: Int
): List<CreatorChannelDonationRecord>
}
interface CreatorChannelDonationRankingPort {
fun findTopRankings(
creatorId: Long,
period: DonationRankingPeriod,
withDonationCan: Boolean
): List<CreatorChannelDonationRankingRecord>
}
data class CreatorChannelDonationCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String,
val isVisibleDonationRank: Boolean,
val donationRankingPeriod: DonationRankingPeriod?
)
data class CreatorChannelDonationRecord(
val nickname: String,
val profileImagePath: String?,
val can: Int,
val message: String,
val createdAt: LocalDateTime
)
data class CreatorChannelDonationRankingRecord(
val userId: Long,
val nickname: String,
val profileImage: String,
val donationCan: Int
)
```
---
## 4. 구현 Tasks
### Phase 1: 공개 계약과 순수 정책 추가
- [x] **Task 1.1: 후원 탭 domain model, port, page/month 정책 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
- RED: `CreatorChannelDonationQueryPolicyTest`를 먼저 작성한다.
- null page/size가 `0/20`, fetchLimit `21`로 보정되는지 검증한다.
- `page = -1`, `size = 10``0/20`으로 보정되는지 검증한다.
- `page = 2`, `size = 100``2/50`, offset `100`, fetchLimit `51`로 보정되는지 검증한다.
- fetched 21개에서 응답 item 20개와 `hasNext = true`가 계산되는지 검증한다.
- `now = 2026-06-22T03:00:00` 기준 KST 월 범위가 `2026-05-31T15:00:00` 이상, `2026-06-30T15:00:00` 미만 UTC로 계산되는지 검증한다.
- domain/port record가 PRD 필드를 보존하는지 생성 테스트로 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
- Expected: 신규 클래스가 없어 컴파일 실패한다.
- GREEN: domain model, port, `CreatorChannelDonationQueryPolicy`를 최소 구현한다.
- `createPage(page, size)`는 기존 FanTalk 정책과 같은 보정값을 사용한다.
- `limitItems(fetched, page)``fetched.take(page.size)`를 반환한다.
- `hasNext(fetched, page)``fetched.size > page.size`를 반환한다.
- `currentKstMonthRange(now)`는 홈 후원 섹션과 동일한 KST 월 범위 UTC 변환을 반환한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 중복 상수와 월 범위 계산을 읽기 쉽게 정리하되 기존 `CreatorChannelPage`를 재사용한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest` 실행, 신규 domain/port/policy 타입 부재로 `compileTestKotlin` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- [x] **Task 1.2: response DTO와 facade 매핑 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- RED: `CreatorChannelDonationFacadeTest`를 먼저 작성한다.
- `CreatorChannelDonationTabResponse.from(...)``donationCount`, `rankings`, `donations`, `page`, `size`, `hasNext`를 공개 필드로 매핑하는지 검증한다.
- `rankings[0]`의 JSON 필드가 `userId`, `nickname`, `profileImage`, `donationCan`인지 검증한다.
- `donations[0]`의 JSON 필드가 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`인지 검증한다.
- `hasNext`가 JSON에서 `hasNext`로 직렬화되는지 검증한다.
- facade가 query service 결과를 `CreatorChannelDonationTabResponse`로 변환하는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
- Expected: DTO/facade가 없어 컴파일 실패한다.
- GREEN: DTO와 facade를 최소 구현한다.
- `CreatorChannelDonationFacade.getDonationTab(creatorId, viewer, page, size, now)`는 query service를 호출하고 `CreatorChannelDonationTabResponse.from(...)`을 반환한다.
- DTO의 `createdAtUtc` 변환은 기존 `toUtcIso`를 사용한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: DTO가 도메인 model만 import하고 persistence/legacy 타입을 import하지 않는지 확인한다.
- Run: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Expected: 검색 결과 0건
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest` 실행, DTO/facade/query service 경계 부재로 `compileTestKotlin` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- 보완: Phase 2 전 공개 endpoint가 내부 `UnsupportedOperationException`으로 실패하지 않도록 query service placeholder를 `SodaException(messageKey = "common.error.invalid_request")`로 고정하고 `CreatorChannelDonationQueryServiceTest`를 추가했다.
- 보완 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, RED에서 `UnsupportedOperationException` 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
- [x] **Task 1.3: controller와 인증/API 계약 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
- RED: `CreatorChannelDonationControllerTest`를 먼저 작성한다.
- 비회원 요청 `GET /api/v2/creator-channels/1/donations`는 401 또는 기존 테스트 보안 설정 기준 인증 실패로 거부되는지 검증한다.
- 인증 회원 요청은 `page`, `size`, `creatorId`, `viewer`를 facade에 전달하는지 검증한다.
- 성공 응답 JSON에 `data.donationCount`, `data.rankings[0].userId`, `data.donations[0].createdAtUtc`, `data.page`, `data.size`, `data.hasNext`가 포함되는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
- Expected: controller가 없어 컴파일 실패한다.
- GREEN: controller를 최소 구현한다.
- `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/donations")`를 사용한다.
- `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")`로 회원을 받고, null이면 `SodaException(messageKey = "common.error.bad_credentials")`를 던진다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 기존 FanTalk/커뮤니티 controller와 request mapping 스타일이 같은지 확인한다.
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Expected: controller class와 endpoint mapping 각 1건 확인
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, controller 부재로 `compileTestKotlin` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- 보완: Phase 2 전 미완성 endpoint가 기본 운영 컨텍스트에 노출되지 않도록 `@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`를 추가했다.
- 보완 검증: controller annotation 계약 테스트를 추가하고 RED에서 조건부 등록 annotation 부재 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, controller class와 endpoint mapping 각 1건 확인.
### Phase 2: 도메인 조회 서비스와 legacy ranking 재사용 추가
- [x] **Task 2.1: 후원 탭 query service 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- RED: fake `CreatorChannelDonationQueryPort`, fake `CreatorChannelDonationRankingPort`를 사용해 query service 테스트를 먼저 작성한다.
- creator가 없으면 `member.validation.user_not_found` 예외를 던지는지 검증한다.
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던지는지 검증한다.
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 차단 메시지 예외를 던지는지 검증한다.
- `page = -1`, `size = 10` 요청이 `offset = 0`, `limit = 21`로 port에 전달되고 응답은 size 20으로 잘리는지 검증한다.
- 조회자 본인이 크리에이터이면 `isVisibleDonationRank = false`여도 ranking port를 호출하고 `withDonationCan = true`가 전달되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = WEEKLY`이면 ranking port에 `period = WEEKLY`, `withDonationCan = false`가 전달되고 `rankings`가 반환되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = CUMULATIVE`이면 ranking port에 `period = CUMULATIVE`, `withDonationCan = false`가 전달되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = null`이면 ranking port에 `period = CUMULATIVE`가 전달되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = false`이면 ranking port를 호출하지 않고 `rankings`가 빈 배열인지 검증한다.
- 후원 순위가 비공개라 `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`가 정상 조립되는지 검증한다.
- donation 작성자 닉네임의 삭제 prefix 제거, profileImagePath CDN 변환, 기본 프로필 이미지 fallback, null message의 빈 문자열 변환을 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
- Expected: query service가 없어 컴파일 실패한다.
- GREEN: query service를 최소 구현한다.
- `ObjectProvider<CreatorChannelDonationQueryPort>` 패턴을 사용해 기존 FanTalk query service와 같은 순환 의존 회피 스타일을 따른다.
- `CreatorChannelDonationRankingPort`는 생성자 주입한다.
- `DonationRankingPeriod`는 creator record 값이 null이면 `DonationRankingPeriod.CUMULATIVE`로 보정한다.
- `isVisibleDonationRank`가 false이고 조회자가 크리에이터 본인이 아니면 ranking port를 호출하지 않는다.
- `isVisibleDonationRank`가 true이거나 조회자가 크리에이터 본인이면 ranking port를 호출하고 creator의 ranking period를 그대로 전달한다.
- `findChannelDonations(...)` 결과는 `limitItems` 적용 후 domain으로 변환한다.
- `hasNext`는 fetch 결과 크기로 계산한다.
- Phase 1 임시 보호장치를 함께 정리한다.
- `CreatorChannelDonationQueryService.getDonationTab(...)`의 placeholder `SodaException(messageKey = "common.error.invalid_request")`를 실제 구현으로 대체한다.
- placeholder 전용 `CreatorChannelDonationQueryServiceTest`는 실제 query service 동작 테스트로 교체하고, placeholder 오류 검증은 제거한다.
- `CreatorChannelDonationController``@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`와 관련 import를 제거해 endpoint가 기본 Spring context에 등록되도록 한다.
- `CreatorChannelDonationControllerTest``@TestPropertySource(properties = ["creator-channel.donation-tab.enabled=true"])`와 conditional annotation 검증 테스트를 제거한다.
- 별도 feature flag rollout 정책을 유지하기로 결정한 경우에만 위 controller 조건부 등록을 남기고, 그 결정 사유와 활성화 설정 위치를 이 문서에 추가한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: query service가 API DTO를 import하지 않는지 확인한다.
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Expected: 검색 결과 0건
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, 새 fake port 기반 테스트가 기존 placeholder service 생성자/동작과 맞지 않아 `compileTestKotlin` 실패 확인. 같은 실행에서 당시 존재하던 Phase 2.2 repository 테스트의 미구현 repository 참조도 함께 컴파일 실패로 노출됨.
- GREEN 보정 전: 동일 명령 실행, service 구현 후 테스트 실행까지 진행됐고 차단 메시지 기대값이 실제 `explorer.creator.blocked_access` 한국어 템플릿과 달라 1건 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- Controller regression: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
- REFACTOR: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
- [x] **Task 2.2: 채널 후원 QueryDSL repository 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- RED: `@DataJpaTest`로 repository 테스트를 먼저 작성한다.
- 활성 creator는 role, nickname, `isVisibleDonationRank`, `donationRankingPeriod`를 조회하고 비활성 회원은 조회하지 않는지 검증한다.
- 조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회하는지 검증한다.
- 현재 KST 월 범위의 채널 후원만 count/list에 포함되는지 검증한다.
- 크리에이터 본인은 비공개 후원까지 count/list에 포함되는지 검증한다.
- 일반 조회자는 공개 후원과 본인의 비공개 후원만 count/list에 포함되는지 검증한다.
- 목록 정렬이 `createdAt desc`, `id desc`인지 검증한다.
- `offset`, `limit`이 적용되는지 검증한다.
- projection이 `selectFrom(channelDonationMessage)`가 아니라 필요한 컬럼 projection을 사용하는지 소스 문자열 또는 동작 테스트로 확인한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
- Expected: repository가 없어 컴파일 실패한다.
- GREEN: QueryDSL repository를 최소 구현한다.
- `findCreator(...)`는 활성 회원만 조회하고 role이 USER인 회원도 record로 반환해 service에서 `creator_not_found`를 판단하게 한다.
- `existsBlockedBetween(...)`은 기존 홈/FanTalk repository의 차단 조건과 동일하게 구현한다.
- `countChannelDonations(...)``findChannelDonations(...)``CreatorChannelDonationQueryPolicy.currentKstMonthRange(now)` 결과와 같은 월 범위 조건을 적용한다.
- `donationVisibilityCondition(creatorId, viewerId)`는 홈 API의 조건과 동일하게 구현한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 홈 repository의 기존 `findChannelDonations` 공개 동작이 변경되지 않았는지 관련 테스트를 실행한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest` 실행, 신규 repository 부재로 `Unresolved reference: DefaultCreatorChannelDonationQueryRepository` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행, `BUILD SUCCESSFUL` 확인.
- 보완: `ktlintCheck`에서 repository 테스트의 긴 `saveDonation(...)` 호출 1곳이 실패해 줄바꿈만 수정했다.
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
- [x] **Task 2.3: legacy 후원 랭킹 adapter 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
- RED: mock `CreatorDonationRankingService`를 사용해 adapter 테스트를 먼저 작성한다.
- `findTopRankings(creatorId = 1, period = CUMULATIVE, withDonationCan = false)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = false`, 같은 period가 전달되는지 검증한다.
- `findTopRankings(creatorId = 1, period = WEEKLY, withDonationCan = true)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = true`, `period = WEEKLY`가 전달되는지 검증한다.
- legacy `MemberDonationRankingResponse` 결과가 `CreatorChannelDonationRankingRecord`로 필드 손실 없이 변환되는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
- Expected: adapter가 없어 컴파일 실패한다.
- GREEN: `CreatorChannelDonationRankingPort` 구현체를 최소 구현한다.
- `CreatorDonationRankingService.getMemberDonationRanking(creatorId, offset = 0, limit = 8, withDonationCan, period)`를 호출한다.
- 반환값의 `userId`, `nickname`, `profileImage`, `donationCan`을 record로 복사한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 랭킹 산식이나 기간 계산을 V2 코드에 중복 구현하지 않았는지 확인한다.
- Run: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Expected: 검색 결과 0건
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest` 실행, 신규 adapter 부재로 `Unresolved reference: LegacyCreatorChannelDonationRankingAdapter` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
### Phase 3: 통합 검증과 회귀 확인
- [x] **Task 3.1: 후원 탭 End-to-End 테스트 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
- RED: `@SpringBootTest` + `MockMvc` 통합 테스트를 먼저 작성한다.
- 별도 `creator-channel.donation-tab.enabled` 테스트 property 없이 기본 Spring context에서 후원 탭 endpoint가 등록되는지 검증한다.
- controller-service-repository를 거쳐 후원 탭 API가 `donationCount`, `donations`, `page`, `size`, `hasNext`를 반환하는지 검증한다.
- `page` 범위 밖 요청은 빈 `donations`, 유지된 `donationCount`, `hasNext = false`를 반환하는지 검증한다.
- `page = -1`, `size = 100` 요청은 응답의 `page = 0`, `size = 50`으로 보정되는지 검증한다.
- 일반 조회자에게 크리에이터의 비공개 후원은 숨기고 조회자 본인의 비공개 후원은 노출하는지 검증한다.
- 일반 조회자가 `isVisibleDonationRank = false`인 크리에이터 채널을 조회하면 `rankings`는 빈 배열이고 `donationCount`, `donations`, `page`, `size`, `hasNext`는 정상 반환되는지 검증한다.
- 크리에이터 본인 조회 시 비공개 후원과 `donationCan` 값이 포함된 ranking이 내려오는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: 통합 wiring 또는 신규 API가 없어 실패한다.
- GREEN: 누락된 Spring bean wiring, package scan, constructor 주입 문제를 최소 수정한다.
- 신규 repository/adapter/service/controller가 component scan 대상 package에 들어가야 한다.
- 테스트 데이터는 `ChannelDonationMessage`, `UseCan`, `UseCanCalculate` 등 기존 엔티티 저장 방식에 맞춰 생성한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: End-to-End 테스트 fixture helper 중복을 줄이되 테스트 의도를 흐리지 않는 범위에서만 정리한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- E2E: `CreatorChannelDonationEndToEndTest`를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, 기존 Phase 2 wiring으로 `BUILD SUCCESSFUL` 확인.
- 검증 범위: 기본 Spring context endpoint 등록, controller-service-repository-legacy ranking 통합, page 범위 밖 응답, page/size 보정, 일반 조회자 비공개 후원/랭킹 숨김, 크리에이터 본인 비공개 후원 및 `donationCan` 노출을 확인.
- [x] **Task 3.2: 관련 테스트와 아키텍처 의존 방향 검증**
- 파일:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
- RED: 이 task는 신규 실패 테스트 작성 대상이 아니라 구현 완료 후 회귀/아키텍처 검증 task다.
- TDD 예외 사유: 개별 동작 실패 테스트는 Task 1.1부터 Task 3.1까지 작성한다. 이 task는 전체 검증과 문서 상태 확인만 담당한다.
- 대체 검증 방법: 관련 단일 테스트 묶음, import 검색, ktlint를 실행한다.
- GREEN: 관련 테스트를 묶어서 실행한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 의존 방향과 포맷을 검증한다.
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Expected: 검색 결과 0건
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2`
- Expected: 후원 탭 controller와 endpoint mapping 각 1건 확인
- Run: `rg -n "ConditionalOnProperty|creator-channel\\.donation-tab\\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Expected: 별도 feature flag rollout 정책을 유지하기로 문서화한 경우가 아니라면 검색 결과 0건
- Run: `./gradlew ktlintCheck`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- 관련 테스트 묶음: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, `BUILD SUCCESSFUL` 확인.
- 의존 방향: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
- endpoint mapping: `rg -n "class CreatorChannelDonationController|/\{creatorId\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2` 실행, controller class와 endpoint mapping 각 1건 확인.
- feature flag: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
- format: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL` 확인.
---
## 5. 구현 순서
1. Phase 1에서 공개 계약, domain/port, page/month 정책, facade/controller를 먼저 고정한다.
2. Phase 2에서 query service, QueryDSL repository, legacy ranking adapter를 TDD로 추가한다.
3. Phase 3에서 End-to-End 테스트와 아키텍처/포맷 검증을 수행한다.
4. 각 task 완료 즉시 해당 체크박스를 `- [x]`로 변경하고, 실행한 명령과 결과를 task 아래에 한국어로 누적 기록한다.
---
## 6. 전체 검증 기록
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
- Phase 2 검증은 각 Task 실행 기록에 누적했다.
- Phase 3 검증은 Task 3.1, Task 3.2 실행 기록에 누적했다. 단일 E2E, 관련 테스트 묶음, 의존 방향 검색, endpoint mapping 검색, feature flag 검색, `ktlintCheck` 모두 성공했다.

View File

@@ -0,0 +1,246 @@
# PRD: 크리에이터 채널 후원 탭 API
## 1. Overview
크리에이터 채널의 후원 탭에서 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 후원 섹션에 최신 채널 후원 일부만 제공한다.
- 후원 탭은 홈 요약보다 더 많은 채널 후원 목록을 추가 로딩해야 하고, 전체 채널 후원 개수와 후원 순위 Top 8을 함께 표시해야 한다.
- 레거시 채널 후원 목록 API는 `/explorer/profile/channel-donation`에 있고, V2 크리에이터 채널 탭 API의 패키지 분리 구조와 맞지 않는다.
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 하므로 새 집계 기준을 임의로 만들면 안 된다.
- 레거시 프로필의 후원 순위는 크리에이터 설정에 따라 비공개, 주간 공개, 전체 공개가 가능하므로 후원 탭 API도 같은 공개 범위와 기간 정책을 따라야 한다.
- 신규 API는 기존 V2 크리에이터 채널 탭과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리해야 한다.
---
## 3. Goals
- 크리에이터 채널 후원 탭 조회 API를 제공한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위 조립 계층에 둔다.
- 후원 개수, 후원 순위, 후원 목록, 페이징 보정, 비공개 후원 노출 조건 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 도메인 조회 계층에 둔다.
- 응답에는 조회 가능한 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록, page, size, hasNext를 포함한다.
- 채널 후원 목록 item의 내용은 크리에이터 채널 홈 API의 `channelDonations` 섹션과 동일한 필드 의미를 사용한다.
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 동일한 결과 리스트 구조를 사용한다.
- 페이징 요청값은 page 기본값 `0`, size 기본값 `20`, size 허용 범위 `20..50`으로 보정한다.
- V2 패키지에 있는 기존 크리에이터 채널 탭 패턴과 홈 후원 섹션 조회 로직 중 재사용 가능한 것을 확인하고 재사용한다.
---
## 4. Non-Goals
- 채널 후원 생성 API는 포함하지 않는다.
- 채널 후원 수정, 삭제, 환불 API는 포함하지 않는다.
- 후원 순위 산식, 포함 `CanUsage`, 정렬 기준 변경은 포함하지 않는다.
- 크리에이터의 후원 순위 노출 설정 변경 API는 포함하지 않는다.
- 레거시 `/explorer/profile/channel-donation` endpoint나 응답 스키마 변경은 포함하지 않는다.
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 후원 메시지 기본 문구 조합 정책을 새로 만들지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 후원 탭에서 다른 팬들의 채널 후원 내역과 후원 순위를 확인하는 사용자
- 크리에이터: 자신의 채널 후원 내역과 후원 순위를 확인하는 사용자
- 앱 클라이언트: 후원 탭 구성에 필요한 개수, 랭킹, 목록, 추가 로딩 상태를 단일 API 응답으로 표시하려는 클라이언트
- 서버 개발자: 레거시 후원 저장 구조와 랭킹 쿼리를 보존하면서 V2 조회 계층을 분리하려는 개발자
---
## 6. User Stories
- 사용자는 크리에이터 채널 후원 탭에 들어가면 전체 채널 후원 개수를 확인하고 싶다.
- 사용자는 해당 크리에이터의 후원 순위 Top 8을 확인하고 싶다.
- 사용자는 크리에이터가 후원 순위를 공개하지 않은 채널에서는 후원 순위 없이 채널 후원 목록만 확인한다.
- 크리에이터는 후원 순위를 공개하지 않은 경우에도 본인 채널에서 자신의 후원 순위를 확인하고 싶다.
- 사용자는 채널 후원 목록을 최신순으로 추가 로딩하고 싶다.
- 사용자는 후원자 닉네임, 프로필 이미지, 후원 캔 수, 메시지, 후원 시간을 목록 item에서 바로 확인하고 싶다.
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 후원 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
- `creatorId`는 path variable로 받는다.
- 채널 후원 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 보정한다.
- `size`가 20보다 작으면 `20`으로 보정한다.
- `size`가 50보다 크면 `50`으로 보정한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 조회 가능한 채널 후원이 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
- 요청한 page 범위에 채널 후원이 없으면 `donations`는 빈 배열, `hasNext``false`로 내려주되 `donationCount`는 전체 개수를 유지한다.
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelDonationTabResponse`로 한다.
- 응답에는 다음 값을 포함한다.
- `donationCount`: 조회자가 조회 가능한 전체 채널 후원 개수
- `rankings`: 후원 순위 Top 8 목록
- `donations`: 채널 후원 목록
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `donationCount`는 현재 page에 포함되지 않은 채널 후원도 포함한다.
- `rankings`는 최대 8개만 내려준다.
- `rankings` item은 기존 `MemberDonationRankingResponse`와 동일하게 `userId`, `nickname`, `profileImage`, `donationCan`을 포함한다.
- `donations` item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 동일하게 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`를 포함한다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 조건에서 다음 page에 노출할 채널 후원이 있으면 `true`로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelDonationTabResponse(
val donationCount: Int,
val rankings: List<MemberDonationRankingResponse>,
val donations: List<CreatorChannelDonationResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class MemberDonationRankingResponse(
@JsonProperty("userId") val userId: Long,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("profileImage") val profileImage: String,
@JsonProperty("donationCan") val donationCan: Int
)
data class CreatorChannelDonationResponse(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
)
```
#### Edge Cases
- 조회 가능한 채널 후원이 없으면 `donationCount``0`, `donations`는 빈 배열, `hasNext``false`로 내려준다.
- 노출 가능한 후원 순위가 없으면 `rankings`는 빈 배열로 내려준다.
- 크리에이터가 후원 순위를 공개하지 않았고 조회자가 크리에이터 본인이 아니면 채널 후원 목록은 정상 조회하되 `rankings`만 빈 배열로 내려준다.
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
- `createdAtUtc``ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
- Boolean 응답 필드는 Jackson 직렬화 필드명을 명시한다.
### Feature C. 전체 채널 후원 개수와 목록
#### Requirements
- 조회 대상은 지정한 `creatorId`의 채널 후원 메시지로 제한한다.
- 저장 엔티티는 기존 `ChannelDonationMessage`를 사용한다.
- 채널 후원 목록은 크리에이터 채널 홈 API의 후원 섹션과 동일하게 현재 KST 월 범위의 후원 메시지를 대상으로 한다.
- 현재 KST 월 범위는 `now`를 UTC로 받은 뒤 Asia/Seoul 기준 월 시작 이상, 다음 달 월 시작 미만으로 변환해 계산한다.
- 전체 채널 후원 개수는 목록과 같은 creator, 월 범위, 비공개 후원 노출 조건을 적용해 계산한다.
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- 후원자 닉네임은 `ChannelDonationMessage.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
- 후원자 프로필 이미지는 `ChannelDonationMessage.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
- 후원 캔 수는 `ChannelDonationMessage.can`을 사용한다.
- 후원 메시지는 크리에이터 채널 홈 API와 동일하게 `ChannelDonationMessage.additionalMessage`가 없으면 빈 문자열로 내려준다.
- 후원 시간은 `ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
#### Edge Cases
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
- 비회원 조회는 허용하지 않으므로 비회원 기준 비공개 후원 필터는 별도로 만들지 않는다.
- 같은 회원이 여러 번 후원한 경우 목록에서는 각각 별도 item으로 내려준다.
### Feature D. 후원 순위 Top 8
#### Requirements
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 한다.
- API 응답에는 Top 8만 내려준다.
- 호출 offset은 `0`, limit은 `8`을 사용한다.
- 순위 산식과 포함 후원 유형은 레거시 쿼리 기준을 따른다.
- `CanUsage.DONATION`
- `CanUsage.SPIN_ROULETTE`
- `CanUsage.LIVE`
- `CanUsage.CHANNEL_DONATION`
- 환불된 사용 내역은 제외한다.
- 비활성 회원은 제외한다.
- 정렬은 레거시 쿼리와 동일하게 `donationCan desc`, `member.id desc`를 따른다.
- 기간은 크리에이터의 `donationRankingPeriod` 설정을 따른다.
- `donationRankingPeriod`가 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
- `DonationRankingPeriod.WEEKLY`는 기존 레거시 서비스의 주간 범위 계산을 따른다.
- `DonationRankingPeriod.CUMULATIVE`는 기존 레거시 서비스의 전체 누적 범위 계산을 따른다.
- 후원 순위 노출 정책은 기존 프로필 정책과 동일하게 유지한다.
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank``true`이면 `rankings`를 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`이면 `rankings`는 빈 배열로 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`인 경우에도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 정상 조회한다.
- `donationCan` 노출 여부는 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
#### Edge Cases
- 순위 대상 회원이 8명보다 적으면 있는 만큼만 내려준다.
- 같은 후원 캔 금액이면 레거시 쿼리와 동일하게 회원 ID 내림차순으로 정렬한다.
- 순위 조회 결과가 없어도 후원 탭 API는 성공 처리한다.
- 후원 순위 비공개로 `rankings`가 빈 배열인 경우와 실제 순위 결과가 없어 `rankings`가 빈 배열인 경우 모두 같은 응답 스키마를 사용한다.
### Feature E. V2 재사용 범위와 계층 분리
#### Requirements
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위에 둔다.
- 후원 탭 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 둔다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.donation -> v2.creator.channel.donation`이다.
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
- `page`, `size`, `hasNext`, `limitItems` 정책은 기존 FanTalk/커뮤니티/시리즈 탭의 query policy 패턴을 재사용한다.
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
- 홈 API의 `findChannelDonations` 조회 조건과 응답 필드는 참고하되, 홈 도메인 repository에 후원 탭 페이징 책임을 추가하지 않는다.
- 후원 순위는 레거시 repository 또는 같은 쿼리 기준을 감싼 V2 port를 통해 재사용한다.
- 레거시 채널 후원 목록 API의 기본 메시지 조합(`buildMessage`)은 이번 V2 후원 탭 목록 응답에 재사용하지 않는다.
#### Edge Cases
- 신규 `donation` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
- 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
- legacy 후원 생성/목록 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- 언어/런타임은 Kotlin + Java 17을 따른다.
- 프레임워크는 Spring Boot 2.7.14를 따른다.
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
---
## 9. Decisions
- endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 확정한다.
- page는 0 기반 page index로 처리한다.
- page 기본값은 `0`, size 기본값은 `20`으로 한다.
- page가 0 미만이면 `0`으로 보정한다.
- size가 20 미만이면 `20`, 50 초과이면 `50`으로 보정한다.
- 채널 후원 목록 item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 같은 필드 의미를 사용한다.
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 같은 필드 의미를 사용한다.
- 후원 순위 산식은 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 기준을 변경하지 않는다.
- 후원 순위 공개 여부는 `isVisibleDonationRank`, 기간은 `donationRankingPeriod` 기준으로 판단한다.
- 채널 후원 목록과 개수의 기간은 홈 후원 섹션과 동일하게 현재 KST 월 범위로 한다.
---
## 10. Open Questions
- 없음. 구현 중 공개 응답 필드 추가나 기간 정책 변경이 필요하면 이 PRD를 먼저 갱신한다.

View File

@@ -0,0 +1,90 @@
-- MySQL 메인 콘텐츠 랭킹 탭 스냅샷 테이블
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
-- 같은 랭킹 타입/기간 재생성 시 삭제 기준:
-- delete from content_ranking_snapshot
-- where ranking_type = :rankingType
-- and aggregation_start_at_utc = :aggregationStartAtUtc
-- and aggregation_end_at_utc = :aggregationEndAtUtc;
create table content_ranking_snapshot (
id bigint not null auto_increment comment '콘텐츠 랭킹 스냅샷 ID',
ranking_type varchar(30) not null comment '랭킹 타입(WEEKLY_POPULAR, RISING, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT)',
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)',
content_id bigint not null comment '오디오 콘텐츠 ID',
title varchar(255) not null comment '스냅샷 생성 시점 콘텐츠 제목',
creator_member_id bigint not null comment '크리에이터 회원 ID(member.id)',
creator_nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임',
cover_image_url varchar(500) null comment '스냅샷 생성 시점 콘텐츠 커버 이미지 URL',
release_date timestamp not null comment '콘텐츠 공개 시각',
is_adult tinyint(1) not null default 0 comment '스냅샷 생성 시점 성인 콘텐츠 여부',
rank_no int not null comment '스냅샷 생성 시점 순위',
final_score double not null comment '최종 랭킹 점수 또는 정렬 지표',
normalized_score double null comment '유료/무료 그룹 정규화 점수',
raw_score double null comment '정규화 전 원점수',
revenue_can_amount bigint null comment '집계 기간 매출 캔 합계',
sales_count bigint null comment '집계 기간 판매량',
view_count bigint null comment '집계 기간 상세 페이지 조회수',
like_count bigint null comment '집계 기간 좋아요 수',
comment_count bigint null comment '집계 기간 댓글 수',
previous_sales_count bigint null comment '직전 비교 기간 판매량',
previous_view_count bigint null comment '직전 비교 기간 상세 페이지 조회수',
previous_like_count bigint null comment '직전 비교 기간 좋아요 수',
previous_comment_count bigint null comment '직전 비교 기간 댓글 수',
sales_growth_rate double null comment '판매 증가율',
view_growth_rate double null comment '조회수 증가율',
like_growth_rate double null comment '좋아요 증가율',
comment_growth_rate double null comment '댓글 증가율',
content_growth_score double null comment '지금 뜨는 중 콘텐츠 성장 점수',
boost_multiplier double null comment '신규 콘텐츠 부스트 배수',
created_at timestamp not null default current_timestamp comment '생성 시각',
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
primary key (id)
) engine=InnoDB default charset=utf8mb4 comment='메인 콘텐츠 랭킹 탭 주간 스냅샷';
create unique index uk_content_ranking_snapshot_period_content
on content_ranking_snapshot (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, content_id);
create index idx_content_ranking_snapshot_period_rank
on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, rank_no);
create index idx_content_ranking_snapshot_visible_rank
on content_ranking_snapshot (ranking_type, visible_from_at desc, rank_no);
create index idx_content_ranking_snapshot_visible_adult_rank
on content_ranking_snapshot (ranking_type, visible_from_at desc, is_adult, rank_no);
create index idx_content_ranking_snapshot_period_score
on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, final_score desc, release_date desc, content_id desc);
create index idx_content_ranking_snapshot_content
on content_ranking_snapshot (content_id);
create table content_ranking_snapshot_job (
id bigint not null auto_increment comment '콘텐츠 랭킹 스냅샷 생성 job ID',
ranking_type varchar(30) not null comment '랭킹 타입(WEEKLY_POPULAR, RISING, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT)',
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)',
trigger_type varchar(20) not null comment '실행 트리거(SCHEDULED, MANUAL, FALLBACK)',
status varchar(20) not null comment 'job 상태(PENDING, PROCESSING, DONE, FAILED)',
last_error text null comment '마지막 실패 사유',
processing_started_at timestamp null comment '처리 시작 시각',
processed_at timestamp null comment '처리 완료 시각',
created_at timestamp not null default current_timestamp comment '생성 시각',
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
primary key (id)
) engine=InnoDB default charset=utf8mb4 comment='메인 콘텐츠 랭킹 탭 스냅샷 생성 job 이력';
create index idx_content_ranking_snapshot_job_period_status
on content_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, status);
create index idx_content_ranking_snapshot_job_visible_status
on content_ranking_snapshot_job (ranking_type, visible_from_at, status);
create index idx_content_ranking_snapshot_job_trigger_period
on content_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, trigger_type, created_at);
create index idx_content_ranking_snapshot_job_status_created_at
on content_ranking_snapshot_job (status, created_at);

View File

@@ -0,0 +1,537 @@
# 메인 콘텐츠 랭킹 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/audio/rankings`로 메인 콘텐츠 랭킹 탭의 6개 랭킹 타입을 스냅샷 기반으로 조회하고, 순위/순위 변화/신규 진입 여부를 안정적으로 제공한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.ranking` 조립 계층에 둔다. 콘텐츠 랭킹 계산, 스냅샷 조회/생성, fallback, scheduler는 `kr.co.vividnext.sodalive.v2.content.ranking` 하위에 두고 `v2.api.*`에 의존하지 않는다. 스냅샷은 `rankingType + aggregation period + visibleFromAt`을 기준으로 저장하고, 조회 API는 `visibleFromAt <= now`인 생성 완료 스냅샷만 공개 응답에 사용한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL/native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/audio/rankings`
- 요청 query parameter: `type`, 기본값 `WEEKLY_POPULAR`
- 랭킹 타입:
- `WEEKLY_POPULAR`: 주간 인기
- `RISING`: 지금 뜨는 중
- `REVENUE`: 매출
- `SALES_COUNT`: 판매량
- `COMMENT_COUNT`: 댓글 수
- `LIKE_COUNT`: 좋아요
- 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
- 집계 기준 시각: 매주 월요일 `00:00:00 KST`
- 스냅샷 생성 시간대: 매주 월요일 `01:00:00 ~ 07:30:00 KST` 사이 랭킹 타입별 분산 실행
- 새 스냅샷 노출 전환 시각: 매주 월요일 `09:00:00 KST`
- 조회 API는 `visibleFromAt <= now`인 최신 완료 스냅샷만 응답한다.
- 09:00 전에는 새 스냅샷이 생성되어도 직전 공개 스냅샷을 응답한다.
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
- fallback은 요청한 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 실행한다.
- 이번 범위는 콘텐츠 랭킹만 수정한다.
- 크리에이터 랭킹의 생성 시간/표시 시간 분리와 다중 랭킹 타입 대응은 다음 범위에서 별도 PRD 문서 수정부터 시작한다.
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
### 신규 콘텐츠 랭킹 도메인 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
### 신규 콘텐츠 랭킹 application/port
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
### 신규 persistence/scheduler
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJobRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
### 문서/DDL
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.ranking.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
data class AudioRankingResponse(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItemResponse>
) {
companion object {
fun from(ranking: AudioRanking): AudioRankingResponse {
return AudioRankingResponse(
showRankChange = ranking.showRankChange,
type = ranking.type,
items = ranking.items.map(AudioRankingItemResponse::from)
)
}
}
}
data class AudioRankingItemResponse(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
@JsonProperty("isNew")
val isNew: Boolean,
val coverImageUrl: String?
) {
companion object {
fun from(item: AudioRankingItem): AudioRankingItemResponse {
return AudioRankingItemResponse(
contentId = item.contentId,
title = item.title,
creatorNickname = item.creatorNickname,
rank = item.rank,
rankChange = item.rankChange,
isNew = item.isNew,
coverImageUrl = item.coverImageUrl
)
}
}
}
```
---
## 3. Domain / Port 초안
```kotlin
package kr.co.vividnext.sodalive.v2.content.ranking.domain
enum class AudioRankingType {
WEEKLY_POPULAR,
RISING,
REVENUE,
SALES_COUNT,
COMMENT_COUNT,
LIKE_COUNT
}
data class AudioRanking(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItem>
)
data class AudioRankingItem(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
val isNew: Boolean,
val coverImageUrl: String?
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.content.ranking.port.out
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import java.time.LocalDateTime
interface AudioRankingSnapshotPort {
fun findLatestVisibleSnapshots(
rankingType: AudioRankingType,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord>
fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord>
fun replaceSnapshots(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<AudioRankingSnapshotRecord>
)
}
data class AudioRankingSnapshotRecord(
val rankingType: AudioRankingType,
val aggregationStartAtUtc: LocalDateTime,
val aggregationEndAtUtc: LocalDateTime,
val visibleFromAtUtc: LocalDateTime,
val contentId: Long,
val title: String,
val creatorMemberId: Long,
val creatorNickname: String,
val coverImageUrl: String?,
val releaseDate: LocalDateTime,
val rank: Int,
val finalScore: Double
)
```
---
### Phase 1: API 계약과 DTO
- [x] **Task 1.1: `AudioRankingType`과 응답 DTO 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt`
- RED: `AudioRankingResponse.from(...)``showRankChange`, `type`, `contentId`, `title`, `creatorNickname`, `rank`, `rankChange`, `isNew`, `coverImageUrl`을 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest`
- GREEN: DTO와 domain model을 최소 구현한다.
- REFACTOR: 공개 DTO가 persistence/entity를 import하지 않도록 확인한다.
- 기대 결과: PRD의 Response data class 계약이 테스트로 고정된다.
- [x] **Task 1.2: facade 변환 계층 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
- RED: facade가 `AudioRankingQueryService.getRankings(type, member)` 결과를 `AudioRankingResponse`로 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest`
- GREEN: facade는 query service 호출과 DTO 변환만 담당한다.
- REFACTOR: facade에 점수 계산, 스냅샷 조회, fallback 로직을 두지 않는다.
- 기대 결과: API 조립 계층과 도메인 조회 계층 의존 방향이 고정된다.
- [x] **Task 1.3: 비회원 허용 controller 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
- RED: `GET /api/v2/audio/rankings`가 비회원과 인증 회원 모두 `200 OK`를 반환하고, `type` 미지정 시 `WEEKLY_POPULAR`로 facade를 호출하는 MockMvc 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴과 `@RequestParam` 기본값을 적용한다.
- REFACTOR: controller에는 인증/요청/응답 경계만 남긴다.
- 기대 결과: endpoint 경로, 기본 type, wrapper 응답 계약이 controller 테스트로 고정된다.
### Phase 2: 기간/노출/점수 정책
- [x] **Task 2.1: KST 주간 집계 기간과 UTC 변환 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
- RED: 임의의 KST 수요일 기준으로 지난 주 월요일 00:00 KST 이상, 이번 주 월요일 00:00 KST 미만 기간을 산출하고 UTC `LocalDateTime`으로 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest`
- GREEN: `resolveLastCompletedWeek(now)``toUtcRange(period)`를 구현한다.
- REFACTOR: 서버 기본 timezone에 의존하지 않고 `ZoneId.of("Asia/Seoul")`을 명시한다.
- 기대 결과: 모든 랭킹 타입의 집계 기준 기간이 동일하게 계산된다.
- [x] **Task 2.2: 09:00 노출 전환 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
- RED: 집계 종료일 월요일 기준 `visibleFromAt`이 같은 날 09:00 KST의 UTC 시각으로 계산되고, 09:00 전에는 새 스냅샷이 공개되지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest`
- GREEN: `resolveVisibleFromAt(aggregationEndAtKst)``isVisible(visibleFromAtUtc, nowUtc)`를 구현한다.
- REFACTOR: scheduler 실행 시각과 공개 노출 시각을 별도 함수로 분리한다.
- 기대 결과: 계산 완료와 공개 노출 전환이 분리된다.
- [x] **Task 2.3: 주간 인기/지금 뜨는 중 점수 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
- RED: 유료/무료 주간 인기 원점수, 0~100 정규화, 지금 뜨는 중 증가율, 최소 반영 기준, 신규 콘텐츠 부스트를 검증하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest`
- GREEN: `calculateWeeklyPopularScore`, `normalizeScore`, `calculateRisingScore`, `applyMinimumThreshold`, `releaseBoost`를 구현한다.
- REFACTOR: 가중치와 최소 기준은 `companion object` 상수로 모은다.
- 기대 결과: PRD 산식과 “기준 미달 지표만 0점 처리” 정책이 순수 단위 테스트로 고정된다.
### Phase 3: 스냅샷 Entity/Port/DDL
- [x] **Task 3.1: 스냅샷 Entity와 port 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
- RED: `visibleFromAtUtc <= nowUtc`인 최신 스냅샷만 조회하고, 09:00 전에는 이전 visible 스냅샷을 반환하는 persistence adapter 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest`
- GREEN: `AudioRankingSnapshot`, `AudioRankingSnapshotRepository`, `DefaultAudioRankingSnapshotPersistenceAdapter`를 구현한다.
- REFACTOR: `rankingType`, `aggregationStartAtUtc`, `aggregationEndAtUtc`, `visibleFromAtUtc` 필드명을 DDL과 맞춘다.
- 기대 결과: 공개 조회 기준이 `latest generated`가 아니라 `latest visible`로 고정된다.
- [x] **Task 3.2: 스냅샷 job Entity와 port 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
- RED: `SCHEDULED`, `MANUAL`, `FALLBACK` trigger와 `PENDING`, `PROCESSING`, `DONE`, `FAILED` 상태를 저장/변경할 수 있는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
- GREEN: job entity, repository, port adapter를 구현한다.
- REFACTOR: fallback 3회 제한 조회에 필요한 `rankingType + aggregation period + triggerType` 조건을 port에 둔다.
- 기대 결과: 스케줄 실행과 fallback 실행이 모두 job 이력으로 추적된다.
- [x] **Task 3.3: DDL 문서와 Entity 필드 정합성 확인**
- Files:
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
- TDD 예외 사유: DDL 문서와 JPA Entity 필드 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "visible_from_at|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking`
- Run: `./gradlew tasks --all`
- 기대 결과: 신규 Entity에 대응하는 운영 DB DDL이 같은 작업 디렉터리에 기록되어 있다.
### Phase 4: 랭킹 후보 집계와 스냅샷 후보 생성
- [x] **Task 4.1: 기존 4종 지표의 v2 전용 집계 작성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
- RED: `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`가 v2 집계 지표를 그대로 `finalScore`로 전달하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest`
- GREEN: legacy `RankingService` 호출 없이 v2 집계 repository에서 매출, 판매량, 댓글 수, 좋아요 후보를 만든다.
- REFACTOR: 기존 랭킹 조회 조건과 v2 스냅샷 공개/제외 조건이 섞이지 않도록 snapshot 생성 경로에서 legacy 의존성을 제거한다.
- 기대 결과: 6개 랭킹 타입 모두 v2 집계/스냅샷 경로로 생성된다.
- [x] **Task 4.2: 주간 인기/지금 뜨는 중 후보 집계 repository 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
- RED: 상세 조회수, 매출, 판매량, 좋아요, 댓글 수를 집계하고 비활성/공개 전/비활성 크리에이터 콘텐츠를 제외하는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest`
- GREEN: QueryDSL 또는 native SQL로 주간 인기와 지금 뜨는 중 후보 원천 지표를 조회한다.
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
- 기대 결과: 신규 산식 2종의 원천 지표가 application service에 전달된다.
- [x] **Task 4.3: 동점 정렬과 Top 20 스냅샷 후보 생성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
- RED: 최종 점수 동점이면 `releaseDate desc`, `contentId desc` 순으로 최대 20개를 저장하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
- GREEN: 후보별 점수 계산, 정규화, 정렬, rank 부여, snapshot record 변환을 구현한다.
- REFACTOR: 점수 계산은 `AudioRankingScorePolicy`, 기간/노출 시각 계산은 policy에 위임한다.
- 기대 결과: 스냅샷 생성 결과가 조회 시 재정렬되지 않아도 안정적인 순위를 가진다.
### Phase 5: 스냅샷 생성 job과 분산 scheduler
- [x] **Task 5.1: 랭킹 타입별 refresh service 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
- RED: 각 `AudioRankingType`에 대해 집계 기간, `visibleFromAt`, 후보 목록을 계산해 기존 스냅샷을 replace하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
- GREEN: `refreshLastCompletedWeek(type, now)`를 구현하고 `AudioRankingSnapshotPort.replaceSnapshots(...)`를 호출한다.
- REFACTOR: 특정 타입 실패가 다른 타입 refresh를 막지 않도록 job service에서 타입 단위 실행을 분리한다.
- 기대 결과: 랭킹 타입별 독립 스냅샷 생성이 가능하다.
- [x] **Task 5.2: job service와 fallback 3회 제한 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
- RED: scheduled job이 `PENDING -> PROCESSING -> DONE/FAILED`로 상태 변경되고, 같은 타입/기간 fallback이 3회 이상이면 refresh를 호출하지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
- GREEN: job 생성, 상태 변경, fallback 제한, 기간 기반 Redisson lock 경계를 구현한다.
- REFACTOR: job 이력 저장은 `REQUIRES_NEW` 트랜잭션 패턴을 검토해 크리에이터 랭킹 job service와 맞춘다.
- 기대 결과: fallback과 scheduled refresh가 같은 job 이력 구조를 사용한다.
- [x] **Task 5.3: 01:00~07:30 분산 scheduler 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
- RED: 랭킹 타입별 scheduler method가 `Asia/Seoul` zone과 서로 다른 cron을 가지고, lock 획득 성공 시에만 job service를 호출하는 reflection/Mockito 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest`
- GREEN: 예시 배치로 `WEEKLY_POPULAR 02:00`, `RISING 03:00`, `REVENUE 04:00`, `SALES_COUNT 05:00`, `COMMENT_COUNT 06:00`, `LIKE_COUNT 07:00` KST scheduler를 구현한다.
- REFACTOR: lock key는 `lock:content-ranking-snapshot-refresh:{rankingType}` 형태로 목적과 타입이 드러나게 한다.
- 기대 결과: 콘텐츠 랭킹 스냅샷 생성이 01:00~07:30 범위 안에서 타입별로 분산된다.
### Phase 6: 조회 서비스와 순위 변화 계산
- [x] **Task 6.1: 최신/직전 visible 스냅샷 조회와 rankChange 계산**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- RED: 직전 공개 스냅샷이 있으면 `rankChange = previousRank - currentRank`, 신규 진입은 `isNew=true`, 직전 스냅샷이 없으면 `showRankChange=false`가 되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
- GREEN: `getRankings(type, member)`에서 최신 visible 스냅샷과 직전 visible 스냅샷을 조회해 `AudioRanking`을 조립한다.
- REFACTOR: 순위 변화 계산은 별도 private 함수로 분리한다.
- 기대 결과: 크리에이터 랭킹과 같은 의미의 `rank`, `rankChange`, `isNew`가 콘텐츠 랭킹에도 적용된다.
- [x] **Task 6.2: 차단/성인 콘텐츠 정책 반영**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- RED: 비회원은 성인 콘텐츠를 제외하고, 회원 차단 관계가 있는 콘텐츠는 기존 콘텐츠 랭킹/추천 정책에 맞게 제외 또는 마스킹되는 테스트를 작성한다. 성인 콘텐츠 제외는 스냅샷의 `isAdult``global top 20 non-adult top 20` 후보 보존으로 보충 가능해야 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
- GREEN: 조회 조건 또는 응답 조립 단계에서 성인 콘텐츠/차단 관계 정책을 적용한다.
- REFACTOR: 기존 콘텐츠 추천 탭의 성인 콘텐츠 조회 가능 여부 계산 경로를 재사용한다.
- 기대 결과: 공개 조회 정책이 기존 v2 콘텐츠 추천/랭킹 정책과 어긋나지 않는다.
- [x] **Task 6.3: 스냅샷 없음 fallback 조회 보강**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- RED: 요청 타입의 최신 visible 스냅샷이 없으면 fallback job을 최대 3회까지 실행하고, 생성 후에도 `visibleFromAt > now`이면 직전 공개 스냅샷 또는 빈 배열을 응답하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
- GREEN: query service가 snapshot job service에 fallback을 위임하고 공개 응답 스키마를 유지한다.
- REFACTOR: fallback 실패는 구조화 로그/job 이력으로 추적하고 공개 응답에 fallback 여부를 추가하지 않는다.
- 기대 결과: 테스트 환경 초기 스냅샷 공백을 보강하되, 09:00 노출 정책은 깨지 않는다.
### Phase 7: 통합 검증과 문서 정리
- [x] **Task 7.1: controller/facade/query 통합 테스트**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
- RED: `GET /api/v2/audio/rankings?type=RISING``showRankChange`, `type`, `items[].contentId`, `rank`, `rankChange`, `isNew`를 반환하는 MockMvc 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
- GREEN: controller, facade, query service wiring을 완성한다.
- REFACTOR: 공개 response에 점수, 집계 기간, fallback 여부가 노출되지 않는지 확인한다.
- 기대 결과: 공개 API 계약이 end-to-end로 검증된다.
- [x] **Task 7.2: 문서와 DDL 최종 정합성 확인**
- Files:
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
- TDD 예외 사유: 구현 완료 후 문서/DDL 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "visibleFromAt|visible_from_at|09:00:00|01:00:00|07:30:00|AudioRankingType" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin src/test/kotlin`
- Run: `./gradlew tasks --all`
- 기대 결과: PRD, 계획 문서, DDL, 코드가 같은 정책을 설명한다.
- [x] **Task 7.3: 전체 회귀 검증**
- Files:
- Verify: `build.gradle.kts`
- Verify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- TDD 예외 사유: 전체 회귀 검증과 검증 기록 누적 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.*`
- Run: `./gradlew ktlintCheck`
- 기대 결과: 콘텐츠 랭킹 신규 테스트와 ktlint가 통과하고, 검증 결과가 이 문서 하단에 누적된다.
### Phase 8: 다음 범위 크리에이터 랭킹 시간 정책 문서 시작점
- [x] **Task 8.1: 크리에이터 랭킹 시간 정책 변경을 별도 PRD 문서 수정으로 시작하도록 기록**
- Files:
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- TDD 예외 사유: 이번 구현 범위 밖의 후속 작업 진입점을 문서화하는 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- Run: `./gradlew tasks --all`
- 후속 작업 시작 지침:
- 다음 범위는 크리에이터 랭킹 PRD 문서 수정부터 시작한다.
- 현재 크리에이터 랭킹 스냅샷 생성 시간은 `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")` 기준 매주 월요일 KST 07:30이다.
- 다음 범위에서는 크리에이터 랭킹도 집계 기준 시각 `월요일 00:00:00 KST`, 생성 시간 `월요일 01:00:00 KST` 후보, 노출 전환 시각 `월요일 09:00:00 KST`로 분리하는 정책을 PRD에 먼저 반영한다.
- 크리에이터 랭킹도 향후 다중 랭킹 타입 3개가 추가될 예정이므로 `creator_ranking_snapshot``creator_ranking_snapshot_job``ranking_type`, `visible_from_at` 추가가 필요한지 DDL 영향부터 검토한다.
- 크리에이터 랭킹 코드 변경은 별도 PRD와 별도 plan-task 문서가 준비된 뒤 진행한다.
- 기대 결과: 이번 콘텐츠 랭킹 구현 범위를 넘지 않으면서, 다음 범위의 첫 작업이 문서 수정부터 시작되도록 명확한 기록이 남는다.
---
## 검증 기록
- 작성 시점: PRD 기반 구현 계획 문서를 신규 생성했다. 아직 구현 전이므로 task별 검증 기록은 없다.
- 2026-06-24 Phase 1, 2 구현: `AudioRankingType`, 응답 DTO, facade, 비회원 허용 controller, KST 주간 기간 정책, 09:00 KST 노출 전환 정책, 주간 인기/지금 뜨는 중 점수 정책을 추가했다.
- 2026-06-24 RED/GREEN: 각 task는 대상 테스트를 먼저 추가한 뒤 미구현 참조 또는 컨트롤러 미존재 실패를 확인하고 최소 구현으로 GREEN 전환했다.
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest` 통과.
- 2026-06-24 검증: `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 3, 4 구현: `content_ranking_snapshot`, `content_ranking_snapshot_job` Entity/Repository/Port/Adapter, 6개 타입 v2 집계 repository, 스냅샷 refresh service를 추가했다.
- 2026-06-24 RED/GREEN: `DefaultAudioRankingAggregationRepositoryTest`에서 H2 native query의 `release_date``Timestamp`로 반환되어 `LocalDateTime` cast 실패를 확인했고, `Timestamp.toLocalDateTime()` 변환을 추가해 GREEN 전환했다.
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotJobRepositoryTest` 통과.
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest` 통과.
- 2026-06-24 검증: `rg -n "visible_from_at|ranking_type|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking`으로 DDL/Entity 핵심 컬럼 정합성을 확인했다.
- 2026-06-24 최종 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck`, `./gradlew tasks --all` 통과.
- 2026-06-24 리뷰 반영: `RISING` 점수도 유료/무료 그룹별 0~100 정규화를 거치도록 보강했고, `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT` 스냅샷 후보 산정은 기존 랭킹 조회 재사용이 아닌 v2 전용 집계 repository 책임으로 변경했다.
- 2026-06-24 리뷰 반영 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 리뷰 재반영: `AudioRankingQueryService`가 최신/직전 visible snapshot을 조회해 `rankChange`, `isNew`, `showRankChange`를 계산하도록 구현했다.
- 2026-06-24 리뷰 재반영: 비회원/비성인 조회자를 위해 스냅샷에 `isAdult`를 저장하고, snapshot refresh는 전체 상위 20개와 비성인 상위 20개 후보의 합집합을 보존하도록 변경했다.
- 2026-06-24 Phase 7 구현: `AudioRankingControllerTest`를 Spring context 기반 MockMvc 통합 테스트로 전환해 `Controller -> Facade -> QueryService -> SnapshotRepository` 경로로 `GET /api/v2/audio/rankings?type=RISING` 응답의 `showRankChange`, `type`, `contentId`, `rank`, `rankChange`, `isNew`를 검증했다.
- 2026-06-24 Phase 7 검증: 공개 response에 `finalScore`, `aggregationStartAtUtc`, `aggregationEndAtUtc`, `visibleFromAtUtc`, `fallback`이 노출되지 않음을 통합 테스트로 확인했다.
- 2026-06-24 Phase 7 문서/DDL 정합성 검증: `rg -n "visibleFromAt|visible_from_at|09:00:00|01:00:00|07:30:00|AudioRankingType" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin src/test/kotlin`, `./gradlew tasks --all` 통과.
- 2026-06-24 Phase 7 회귀 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 5 구현: `AudioRankingSnapshotJobService``AudioRankingSnapshotScheduler`를 추가해 타입별 scheduled/fallback job 이력, fallback 3회 제한, 타입/기간 기반 Redisson lock, 02:00~07:00 KST 분산 스케줄을 구현했다.
- 2026-06-24 Phase 6 구현: `AudioRankingBlockPort`, `DefaultAudioRankingBlockRepository`를 추가하고 `AudioRankingQueryService`가 회원 차단 관계 콘텐츠를 제외하며 최신 visible snapshot 공백 시 fallback job 실행 후 재조회하도록 보강했다.
- 2026-06-24 RED/GREEN: `AudioRankingSnapshotJobServiceTest`는 service 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했고, `AudioRankingSnapshotSchedulerTest`는 scheduler 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했다. `AudioRankingQueryServiceTest``AudioRankingBlockPort`와 query service 의존성 미구현 컴파일 실패를 확인한 뒤 차단/fallback 구현으로 GREEN 전환했다.
- 2026-06-24 Phase 5/6 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest` 통과.
- 2026-06-24 Phase 5/6 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과. 병렬 실행 중 `kaptTestKotlin`에서 `StreamCorruptedException: unexpected EOF in middle of data block`이 1회 발생했으나, 동일 content ranking 테스트 단독 재실행은 통과했다.
- 2026-06-24 Phase 5/6 리뷰 반영: snapshot refresh 실패 시 `FAILED` job 이력이 rollback되지 않도록 job 생성/상태 변경을 각각 `REQUIRES_NEW` 트랜잭션으로 분리했다. 공개 조회 fallback 실행 중 예외가 발생해도 응답 스키마를 유지하도록 보강했고, 차단 creator가 직전 스냅샷에만 있는 경우도 `rankChange` 계산에서 제외되도록 latest/previous creator 합집합 기준으로 차단 관계를 조회한다.
- 2026-06-24 Phase 5/6 리뷰 반영 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 5/6 코드 리뷰 추가 반영: class-level `@Transactional(readOnly = true)` 경계에서 snapshot replace write가 실행되지 않도록 `refreshService.refreshLastCompletedWeek(...)` 호출 자체를 `REQUIRES_NEW` 트랜잭션으로 감쌌다. fallback job 생성 이전 또는 lock/transaction 단계 예외도 추적 가능하도록 `AudioRankingQueryService``event=audio_ranking_query_fallback_failure` warn 로그를 추가했다.
- 2026-06-24 Phase 5/6 코드 리뷰 추가 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 6 잔여 리스크 반영: `DefaultAudioRankingBlockRepositoryTest` DB slice 테스트를 추가해 실제 QueryDSL 양방향 활성 차단 조회, 비활성 차단 제외, 입력 목록 외 차단 제외, 빈 입력 반환을 검증하도록 보강했다.
- 2026-06-24 Phase 6 잔여 리스크 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingBlockRepositoryTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'` 통과.
- 2026-06-24 Phase 6 트랜잭션 가시성 리뷰 반영: MySQL `REPEATABLE READ`에서 fallback `REQUIRES_NEW` 커밋 후 같은 read-only 트랜잭션 재조회가 새 스냅샷을 보지 못할 수 있어, `AudioRankingQueryService.getRankings()`의 외부 `@Transactional(readOnly = true)` 경계를 제거했다.
- 2026-06-24 Phase 6 트랜잭션 가시성 검증: `getRankings()``@Transactional`이 다시 붙지 않도록 회귀 테스트를 추가했다. RED 확인 후 수정했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 8 문서 확인: `rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`로 크리에이터 랭킹 현재 07:30 스케줄과 다음 범위의 `visible_from_at`, `ranking_type` DDL 검토 시작점을 확인했다.
- 2026-06-24 Phase 8 범위 확정: 크리에이터 랭킹 코드/DDL은 수정하지 않고, 다음 범위가 별도 PRD 문서 수정부터 시작되도록 Task 8.1 완료 상태와 검증 기록만 갱신했다.
### Phase 9: `coverImageUrl` CDN host 누락 버그 수정
- [x] **Task 9.1: `AudioRankingQueryService` 응답 변환 지점에서 CDN URL 정책 고정**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- 버그 내용: 메인 콘텐츠 랭킹 탭 API는 스냅샷의 `coverImageUrl` 값을 `AudioRankingQueryService.toItem(...)`에서 그대로 `AudioRankingItem.coverImageUrl`로 옮기고 있었다. 스냅샷 생성 과정의 원천 값은 `audio_content.cover_image` 계열의 저장 path이므로, 공개 API 응답도 `cover-1.png`처럼 host 없는 path만 내려갔다.
- 영향 범위: `GET /api/v2/audio/rankings`의 item `coverImageUrl`만 대상이다. 순위 계산, 최신/직전 visible snapshot 조회, 19금 필터, 차단 필터, fallback job 실행, 스냅샷 저장 구조와 DDL은 변경하지 않는다.
- 원인: 콘텐츠 랭킹 조회 서비스가 크리에이터 랭킹의 `profileImageUrl.toCdnUrl(cloudFrontHost)` 패턴이나 v2 콘텐츠/크리에이터 조회 계층의 `toCdnUrl` 패턴을 적용하지 않았다. DTO 변환 계층은 domain item 값을 그대로 응답으로 내보내므로, domain item 조립 시점에 URL 변환이 누락되면 Response에서도 그대로 노출된다.
- RED: `AudioRankingQueryServiceTest.shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags`에 스냅샷 fixture의 `coverImageUrl = "cover-N.png"`가 응답 item에서는 `https://cdn.test/cover-N.png`로 변환되어야 한다는 assertion을 추가한다. 기존 구현에서는 path만 반환하므로 이 assertion이 실패해야 한다.
- GREEN: `AudioRankingQueryService` 생성자에 `@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String`을 주입하고, `AudioRankingSnapshotRecord.toItem(...)`에서 `coverImageUrl.toCdnUrl(cloudFrontHost)`를 사용한다. 이 방식은 `null`/blank는 `null`, 이미 `http://` 또는 `https://`로 시작하는 값은 그대로 유지하는 기존 공통 확장 함수를 재사용한다.
- REFACTOR: 별도 URL helper를 새로 만들지 않는다. 스냅샷 저장 데이터를 full URL로 마이그레이션하지 않고, 공개 응답 조립 지점에서만 변환해 기존 데이터와 신규 데이터 모두 동일하게 처리한다.
- 기대 결과: `GET /api/v2/audio/rankings` 응답의 `items[*].coverImageUrl`은 path가 아니라 `cloud.aws.cloud-front.host`가 포함된 이미지 URL로 내려간다.
## Phase 9 검증 기록
- 2026-06-25 문서 갱신: 사용자 후속 요청에 따라 `prd.md``coverImageUrl` host 누락 버그, 공개 응답 URL 정책, `toCdnUrl` 기반 변환 규칙을 추가했다. `plan-task.md`에는 버그 내용, 영향 범위, 원인, RED/GREEN/REFACTOR 기준을 Phase 9로 누적 기록했다.
- 2026-06-25 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL in 30s`를 확인했다. 이 테스트는 스냅샷 fixture의 path 값(`cover-N.png`)이 응답 item에서는 `https://cdn.test/cover-N.png`로 변환되는지 검증한다.
- 2026-06-25 문서 명령 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 8s`를 확인했고, `rg -n "coverImageUrl|Phase 9|cdn|cloud-front|toCdnUrl|host 없는 path|CDN host" docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`로 PRD와 plan-task에 버그 내용 및 수정 정책이 반영된 위치를 확인했다.
- 2026-06-25 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 32s`를 확인했다.
- 2026-06-25 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 1m 4s`를 확인했다. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`를 API 테스트와 병렬 실행했을 때는 `build/test-results/test/TEST-*.xml` 파일 쓰기 충돌로 실패했으나, 동일 명령을 단독 재실행해 `BUILD SUCCESSFUL in 19s`를 확인했다.

View File

@@ -0,0 +1,368 @@
# PRD: 메인 콘텐츠 랭킹 탭 API
## 1. Overview
메인 콘텐츠 탭의 내부 랭킹 탭에서 사용할 콘텐츠 랭킹을 조회하는 v2 API를 제공한다.
랭킹 구분은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글 수`, `좋아요`이며, 각 랭킹은 최대 20위까지 표시한다.
---
## 2. Problem
- 기존 콘텐츠 랭킹 조회는 `RankingService.getContentRanking` 기반의 정렬 조회를 제공하지만, 신규 랭킹 탭은 v2 스냅샷 기준으로 `rank`, `rankChange`, `isNew`를 크리에이터 랭킹과 같은 의미로 내려줘야 한다.
- `주간 인기``지금 뜨는 중`은 신규 점수 산식, 유료/무료 콘텐츠별 정규화, 주간 스냅샷 갱신, fallback 실행 기록이 필요하다.
- `매출`, `판매량`, `댓글 수`, `좋아요`는 기존 랭킹과 동일한 원천 지표를 사용하되, 순위 변화와 신규 진입 여부를 안정적으로 계산하려면 v2 스냅샷 생성 시점에 완료 주차 기준으로 직접 집계해야 한다.
- 조회 시마다 모든 랭킹 타입의 원천 데이터를 집계하면 응답 지연과 계산 중복이 커지고, 운영 서버와 테스트 환경에서 같은 기준의 결과를 재현하기 어렵다.
- 기존 v2 패키지에 크리에이터 랭킹 스냅샷/작업 이력/fallback 패턴과 콘텐츠 추천 탭의 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재사용해야 한다.
- 2026-06-25 후속 확인 결과, 메인 콘텐츠 랭킹 탭 API의 `coverImageUrl` 응답이 `cloud.aws.cloud-front.host`가 포함된 완성 URL이 아니라 `cover-*.png` 같은 저장 path만 내려가는 버그가 확인되었다. 앱 클라이언트는 공개 API의 이미지 필드를 직접 렌더링 가능한 URL로 기대하므로, 다른 v2 콘텐츠/크리에이터 조회 API와 동일하게 CDN host를 포함해 반환해야 한다.
---
## 3. Goals
- 메인 콘텐츠 랭킹 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 모든 랭킹 타입은 최대 20개 콘텐츠를 응답한다.
- 모든 랭킹 타입의 동점자는 `releaseDate desc`, `contentId desc` 순으로 2차, 3차 정렬한다.
- `rank`, `rankChange`, `isNew`의 의미는 크리에이터 랭킹과 동일하게 정의한다.
- `주간 인기``지금 뜨는 중`은 매주 월요일 00:00 KST 기준으로 지난 주 데이터를 계산한다.
- `매출`, `판매량`, `댓글 수`, `좋아요`도 완료된 지난 주 데이터를 기준으로 계산한다.
- 스냅샷 생성은 부하 분산을 위해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
- 새로 생성된 스냅샷은 매주 월요일 09:00:00 KST부터 조회 API에 노출한다.
- 스냅샷이 없어 조회할 수 없는 경우 스케줄러로 예약된 랭킹 계산 로직을 fallback으로 직접 실행한다.
- fallback 실행은 스케줄 실행 기록처럼 저장하며, 동일 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 시도한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
- 신규 Entity가 생성되는 경우 같은 작업 디렉터리에 대응 DB table 생성/수정 DDL을 기록한다.
- `coverImageUrl`은 스냅샷 또는 DB에 저장된 path를 그대로 공개하지 않고, 공개 Response를 만들기 전에 `cloud.aws.cloud-front.host`를 포함한 URL로 변환한다.
---
## 4. Non-Goals
- 기존 공개 API 스키마를 임의 변경하지 않는다.
- 기존 공개 API의 `RankingService.getContentRanking` 동작과 정렬 산식은 이번 작업에서 변경하지 않는다.
- 관리자 화면, 수동 보정 기능, 랭킹 결과 고정/제외 기능은 포함하지 않는다.
- 개인화 랭킹, A/B 테스트, 머신러닝 기반 점수 산정은 포함하지 않는다.
- 20위 이후 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
- 실시간 랭킹은 포함하지 않는다. 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
- 기존 크리에이터 랭킹의 다중 랭킹 타입 전환, 스냅샷 테이블 구조 변경, 계산 스케줄 분산 처리는 이번 PRD에 포함하지 않고 별도 PRD에서 다룬다.
---
## 5. Target Users
- 회원: 콘텐츠 메인 탭에서 인기 콘텐츠와 상승 중인 콘텐츠를 탐색하는 사용자
- 비회원: 인증 없이 조회 가능한 랭킹 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 내부 랭킹 탭의 랭킹 타입별 목록과 순위 변화 UI를 구성하는 클라이언트
- 운영자: 주간 콘텐츠 랭킹 계산 결과와 fallback 실행 이력을 확인하는 내부 사용자
---
## 6. User Stories
- 사용자는 주간 인기 콘텐츠 상위 20개를 보고 싶다.
- 사용자는 지난 주 대비 지금 뜨는 중인 콘텐츠를 보고 싶다.
- 사용자는 매출, 판매량, 댓글 수, 좋아요 기준의 콘텐츠 랭킹을 보고 싶다.
- 사용자는 각 콘텐츠의 현재 순위, 순위 변화, 신규 진입 여부를 보고 싶다.
- 앱 클라이언트는 하나의 API endpoint에서 랭킹 타입만 바꿔 동일한 응답 구조로 화면을 구성하고 싶다.
- 테스트 환경에서는 스냅샷이 비어 있어도 조회 API 호출만으로 fallback 랭킹 계산이 실행되기를 원한다.
---
## 7. Core Features
### Feature A. 메인 콘텐츠 랭킹 탭 조회 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/audio/rankings`로 정의한다.
- 요청 query parameter는 `type`을 사용한다.
- `type` 값은 아래 enum으로 정의한다.
- `WEEKLY_POPULAR`: 주간 인기
- `RISING`: 지금 뜨는 중
- `REVENUE`: 매출
- `SALES_COUNT`: 판매량
- `COMMENT_COUNT`: 댓글 수
- `LIKE_COUNT`: 좋아요
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 조회 API는 `visibleFromAt <= now`이고 생성이 완료된 최신 스냅샷만 응답한다.
- 월요일 09:00:00 KST 전에는 새 주차 스냅샷이 이미 생성되어 있어도 직전 공개 스냅샷을 응답한다.
- 인증 회원이면 기존 콘텐츠 랭킹/추천 조회와 같은 방식으로 회원의 19금 노출 가능 여부와 차단 관계를 반영한다.
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
- 비활성 콘텐츠, 공개 전 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
- 각 랭킹 타입은 최대 20개를 응답한다.
- 정렬은 랭킹 점수 또는 정렬 지표 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
#### Edge Cases
- 랭킹 결과가 없으면 빈 배열로 성공 응답한다.
- 후보가 20개 미만이면 가능한 개수만 내려준다.
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
- 콘텐츠 제목, 크리에이터 닉네임, 커버 이미지가 기존 정책상 마스킹되어야 하는 경우 기존 콘텐츠 랭킹/추천 조회 정책을 따른다.
- `coverImageUrl`은 스냅샷 저장값이 path 형태여도 공개 응답에서는 `https://...` 또는 `http://...`로 시작하는 완성 URL이어야 한다. 이미 완성 URL인 값은 중복 prefix를 붙이지 않는다.
### Feature B. rank, rankChange, isNew 의미
#### Requirements
- `rank`는 최신 완료 주차 스냅샷에서 해당 랭킹 타입의 정렬 결과 순위다.
- `rank`는 1부터 시작한다.
- `rankChange``직전 완료 주차 rank - 최신 완료 주차 rank`로 계산한다.
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수, 동일하면 `0`을 내려준다.
- 예를 들어 직전 완료 주차 10위, 최신 완료 주차 5위이면 `rankChange``5`다.
- 예를 들어 직전 완료 주차 1위, 최신 완료 주차 10위이면 `rankChange``-9`다.
- 직전 완료 주차에는 없고 최신 완료 주차에 진입한 콘텐츠는 `isNew == true`로 내려준다.
- 신규 진입 콘텐츠의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다.
- 직전 완료 주차 스냅샷이 있으면 `showRankChange == true`로 내려준다.
#### Edge Cases
- fallback으로 최신 주차 스냅샷을 생성했지만 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`를 유지한다.
- 동점자는 `releaseDate desc`, `contentId desc`로 결정되므로 같은 스냅샷을 조회할 때 순위가 랜덤하게 바뀌지 않는다.
### Feature C. 주간 인기 랭킹
#### Requirements
- 갱신 기준은 매주 월요일 00:00 KST다.
- 집계 대상 기간은 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만이다.
- DB 조회 조건은 KST 집계 기간을 UTC로 변환해 사용한다.
- 유료 콘텐츠와 무료 콘텐츠는 서로 다른 원천 지표와 가중치로 1차 점수를 산출한다.
- 유료 콘텐츠 점수는 `매출 45% + 판매량 35% + 좋아요 수 10% + 댓글 수 10%`로 계산한다.
- 무료 콘텐츠 점수는 `조회수 50% + 좋아요 수 25% + 댓글 수 25%`로 계산한다.
- 조회수는 상세 페이지 조회 이력인 `creator_content_view_history` 기준으로 집계한다.
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
#### 정규화 판단
- `(최고 점수 + 현재 콘텐츠 점수) * 100`은 최고점 대비 상대 위치를 0~100 범위로 맞추지 못하므로 정규화 산식으로 부적절하다.
- 유료/무료 콘텐츠를 별도 산식으로 계산한 뒤 한 목록에서 비교하려면 `(현재 점수 / 그룹 최고 점수) * 100` 방식이 더 적절하다.
- 이 방식은 각 그룹의 1위 콘텐츠를 100점으로 맞추고 나머지 콘텐츠를 상대 점수로 비교한다.
#### Edge Cases
- 유료 콘텐츠 후보가 없으면 유료 정규화는 수행하지 않고 무료 콘텐츠만 비교한다.
- 무료 콘텐츠 후보가 없으면 무료 정규화는 수행하지 않고 유료 콘텐츠만 비교한다.
- 원천 지표가 없으면 0으로 계산한다.
### Feature D. 지금 뜨는 중 랭킹
#### Requirements
- 갱신 기준은 매주 월요일 00:00 KST다.
- 집계 대상 기간은 최근 7일과 직전 7일을 비교한다.
- 기준 시점은 완료된 지난 주의 종료 시점으로 한다.
- 최근 7일: 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만
- 직전 7일: 2주 전 월요일 00:00:00 KST 이상, 지난 주 월요일 00:00:00 KST 미만
- 콘텐츠 지금 뜨는 중 점수는 `((0.5 * 콘텐츠 성장 점수) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)) * 신규 콘텐츠 부스트`로 계산한다.
- 유료 콘텐츠 성장 점수는 `(0.6 * 판매 증가율) + (0.4 * 조회수 증가율)`로 계산한다.
- 무료 콘텐츠 성장 점수는 `(0.5 * 조회수 증가율) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)`로 계산한다.
- 판매 증가율은 `(최근 7일 판매량 - 직전 7일 판매량) / max(직전 7일 판매량, 1)`로 계산한다.
- 조회수 증가율은 `(최근 7일 조회수 - 직전 7일 조회수) / max(직전 7일 조회수, 1)`로 계산한다.
- 좋아요 증가율은 `(최근 7일 좋아요 수 - 직전 7일 좋아요 수) / max(직전 7일 좋아요 수, 1)`로 계산한다.
- 댓글 증가율은 `(최근 7일 댓글 수 - 직전 7일 댓글 수) / max(직전 7일 댓글 수, 1)`로 계산한다.
- 최근 7일 조회수 10회 미만이면 조회수 증가율 반영값은 0으로 처리한다.
- 최근 7일 좋아요 수 3개 미만이면 좋아요 증가율 반영값은 0으로 처리한다.
- 최근 7일 댓글 수 3개 미만이면 댓글 증가율 반영값은 0으로 처리한다.
- 최근 7일 판매량 3건 미만이면 판매 증가율 반영값은 0으로 처리한다.
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
- 신규 콘텐츠 부스트는 집계 종료일 기준 `releaseDate` 경과 일수로 적용한다.
- Release 3일 이내: `1.5`
- Release 7일 이내: `1.3`
- Release 14일 이내: `1.15`
- Release 14일 초과: `1.0`
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
#### Edge Cases
- 모든 증가율 반영값이 0이면 지금 뜨는 중 원점수는 0으로 계산한다.
- 증가율은 음수가 될 수 있으며, 음수 원점수는 정규화 전 후보 점수에 그대로 반영한다.
- 그룹 최고 점수가 0 이하인 경우 해당 그룹 콘텐츠의 정규화 점수는 0으로 처리해 음수 최고점으로 인한 역전 현상을 피한다.
### Feature E. 매출, 판매량, 댓글 수, 좋아요 랭킹
#### Requirements
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 v2 스냅샷 생성 계층에서 완료 주차 기준으로 직접 집계한다.
- 각 타입의 최종 점수는 원천 지표를 그대로 사용한다. `REVENUE`는 매출 can 합계, `SALES_COUNT`는 판매 건수, `COMMENT_COUNT`는 활성 댓글 수, `LIKE_COUNT`는 활성 좋아요 수다.
- 각 랭킹 타입도 스냅샷으로 저장한다.
- 스냅샷 생성 시 v2 집계 결과를 기반으로 전체 기준 상위 20개와 비성인 콘텐츠 기준 상위 20개의 합집합 후보를 저장한다.
- 공개 응답은 조회자의 성인 콘텐츠 열람 가능 여부를 적용한 뒤 최대 20개 콘텐츠를 반환한다.
- 동점자는 `releaseDate desc`, `contentId desc` 순으로 정렬한다.
- 최종 응답의 `rank`, `rankChange`, `isNew` 계산은 `주간 인기`, `지금 뜨는 중`과 동일한 공통 로직을 사용한다.
#### 스냅샷 저장 판단
- 이번 PRD의 기준안은 모든 랭킹 타입을 스냅샷으로 저장하는 것이다.
- 이유는 `rankChange``isNew`가 모든 응답 item에 필요하고, 이 값은 최신 완료 주차와 직전 완료 주차의 같은 랭킹 타입 결과를 비교해야 안정적으로 계산할 수 있기 때문이다.
- `주간 인기``지금 뜨는 중`만 스냅샷으로 저장하면 `매출`, `판매량`, `댓글 수`, `좋아요`는 조회 때마다 최신/직전 주차를 동적으로 재계산해야 하며, 계산 비용과 late update에 따른 순위 흔들림이 생긴다.
- 기존 랭킹과 같은 원천 지표를 사용하는 4개 랭킹도 v2 스냅샷 생성 시점에 직접 집계해, legacy 조회 조건과 v2 공개/제외 조건이 섞이지 않도록 한다.
#### Edge Cases
- v2 집계 결과 또는 조회자에게 노출 가능한 후보가 20개 미만이면 해당 개수만 저장/응답한다.
- legacy 랭킹 조회의 최소 개수 확보용 기간 확장 로직은 v2 스냅샷 생성에 적용하지 않는다.
### Feature F. 랭킹 스냅샷 및 작업 이력
#### Requirements
- 콘텐츠 랭킹 스냅샷은 랭킹 타입, 집계 시작/종료 시각, 콘텐츠 id, 순위, 점수 또는 정렬 지표, 표시용 콘텐츠 정보를 저장한다.
- 신규 스냅샷 Entity와 작업 이력 Entity의 DB table DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 기록한다.
- 스냅샷에는 `visibleFromAt`을 저장하며, 공개 조회는 이 시각이 지난 스냅샷만 대상으로 한다.
- 스냅샷은 최신 완료 주차와 직전 완료 주차를 조회할 수 있어야 한다.
- 같은 랭킹 타입과 같은 집계 기간의 스냅샷은 중복 저장하지 않는다.
- 스냅샷 생성은 기존 크리에이터 랭킹과 동일하게 job service, refresh service, scheduler 책임으로 분리한다.
- 집계 기준 시각은 매주 월요일 00:00:00 KST다.
- 스냅샷 생성은 원천 데이터 적재 지연과 운영 부하를 고려해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
- 새 스냅샷의 기본 노출 전환 시각은 매주 월요일 09:00:00 KST다.
- 스케줄러는 `Asia/Seoul` zone을 명시한다.
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 Redisson lock으로 동일 기간/랭킹 타입은 한 번만 계산한다.
- 작업 이력에는 trigger, status, 집계 시작/종료 시각, 랭킹 타입, 오류 메시지, 처리 시작/종료 시각을 저장한다.
- trigger 값은 최소 `SCHEDULED`, `MANUAL`, `FALLBACK`을 지원한다.
#### Edge Cases
- 특정 랭킹 타입 스냅샷 생성이 실패해도 다른 랭킹 타입 생성이 가능한 구조로 분리한다.
- 일부 랭킹 타입만 스냅샷이 있으면 요청한 `type` 기준으로만 fallback 여부를 판단한다.
- 월요일 09:00:00 KST 전에 새 스냅샷이 일부만 생성되어도 공개 조회에는 반영하지 않는다.
- 월요일 09:00:00 KST 이후 특정 랭킹 타입의 새 스냅샷이 없거나 생성 실패 상태이면 해당 타입은 직전 공개 스냅샷을 응답한다.
### Feature G. 크리에이터 랭킹과의 범위 경계
#### Requirements
- 현재 크리에이터 랭킹 스냅샷 생성 스케줄은 매주 월요일 KST 07:30이다.
- 크리에이터 랭킹도 향후 다중 랭킹 타입이 추가될 예정이므로, 콘텐츠 랭킹의 스냅샷/작업 이력 구조는 향후 크리에이터 랭킹에도 같은 운영 모델을 적용할 수 있도록 `rankingType`, 집계 기간, `visibleFromAt`, job trigger/status 축을 기준으로 설계한다.
- 이번 PRD는 콘텐츠 랭킹 API와 콘텐츠 랭킹 스냅샷 생성만 구현한다.
- 기존 크리에이터 랭킹의 스냅샷 테이블에 `rankingType`을 추가하거나, 크리에이터 랭킹 계산 스케줄을 01:00~07:30 분산 방식으로 변경하는 작업은 이번 PRD에 포함하지 않는다.
- 크리에이터 랭킹 다중 타입 전환과 스케줄 분산 처리는 별도 PRD에서 기존 크리에이터 랭킹 PRD/DDL/구현 계획을 갱신해 다룬다.
### Feature H. fallback 랭킹 계산
#### Requirements
- 조회 시 요청한 랭킹 타입의 최신 완료 주차 스냅샷이 없으면 fallback 실행 가능 여부를 확인한다.
- fallback은 스케줄러가 호출하는 랭킹 계산 로직과 동일한 refresh service를 직접 실행한다.
- fallback 실행 전 `FALLBACK` trigger의 작업 이력을 `PENDING` 또는 `PROCESSING` 상태로 기록한다.
- fallback 성공 시 `DONE`, 실패 시 `FAILED`로 작업 이력을 기록한다.
- 동일 랭킹 타입과 동일 집계 기간의 fallback 실행 이력이 3회 이상이면 추가 fallback을 실행하지 않는다.
- fallback으로 스냅샷이 생성되면 해당 스냅샷을 다시 조회해 응답한다.
- fallback으로 생성된 스냅샷도 `visibleFromAt <= now` 조건을 만족해야 공개 조회에 노출한다.
- fallback 실행 후에도 스냅샷이 없으면 빈 배열로 성공 응답한다.
- fallback 여부는 공개 API response schema에 포함하지 않는다.
#### Edge Cases
- 다른 요청이 같은 랭킹 타입/기간 fallback을 처리 중이면 lock 획득 실패를 정상 skip으로 간주하고, 현재 요청은 재조회 후 없으면 빈 배열로 응답한다.
- fallback 계산 중 예외가 발생해도 공개 API는 내부 오류를 그대로 노출하지 않고 기존 예외/응답 정책을 따른다.
- fallback 작업 이력 저장 실패와 랭킹 계산 실패의 트랜잭션 경계는 구현 계획 단계에서 크리에이터 랭킹 작업 이력 패턴을 따른다.
### Feature I. v2 재사용 후보
#### Requirements
- API 조립 계층은 `v2/api/content/recommendation``AudioRecommendationController`, `AudioRecommendationFacade`, DTO 변환 패턴을 참고한다.
- 도메인 조회 계층은 `v2/content/recommendation/application/AudioRecommendationQueryService`처럼 응답 조립에 필요한 도메인 모델을 반환한다.
- `rankChange`, `isNew`, `showRankChange`, fallback 로그/작업 이력 패턴은 `v2/ranking/application/CreatorRankingQueryService``CreatorRankingSnapshotJobService`를 참고한다.
- 주간 기간 계산, UTC 변환, Redisson lock은 `v2/ranking/domain/CreatorRankingPeriodPolicy`와 크리에이터 랭킹 스냅샷 job 구조를 재사용하거나 콘텐츠 랭킹용으로 동일 패턴을 만든다.
- 상세 페이지 조회수는 `v2/recommendation/adapter/out/persistence/CreatorContentViewHistory`와 관련 port/repository를 재사용 후보로 검토한다.
- CDN URL 조립은 `v2/common/domain/CdnUrlExtensions.kt``toCdnUrl` 패턴을 우선 사용한다.
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 legacy adapter를 사용하지 않고 v2 집계 repository에서 직접 후보를 만든다.
---
## 8. API Endpoint
```http
GET /api/v2/audio/rankings?type=WEEKLY_POPULAR
Authorization: Bearer {accessToken} (optional)
```
- 비회원 조회를 허용한다.
- 회원 조회 시 기존 v2 controller 패턴과 동일하게 anonymous user를 `null` member로 처리한다.
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
- 잘못된 `type` 값에 대한 오류 응답은 기존 enum request parameter 오류 처리 정책을 따른다.
---
## 9. Response Data Class
```kotlin
data class AudioRankingResponse(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItemResponse>
)
enum class AudioRankingType {
WEEKLY_POPULAR,
RISING,
REVENUE,
SALES_COUNT,
COMMENT_COUNT,
LIKE_COUNT
}
data class AudioRankingItemResponse(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
@JsonProperty("isNew")
val isNew: Boolean,
val coverImageUrl: String?
)
```
`coverImageUrl` 응답 정책은 다음과 같다.
- 스냅샷 테이블의 표시용 커버 이미지 값은 원천 `audio_content.cover_image`와 같은 path 형태로 저장될 수 있다.
- 공개 API 응답의 `coverImageUrl`은 클라이언트가 바로 이미지 로딩에 사용할 수 있도록 `cloud.aws.cloud-front.host`를 prefix로 포함한다.
- 변환은 `v2/common/domain/CdnUrlExtensions.kt``toCdnUrl` 정책을 따른다.
- `null`, 빈 문자열, blank 값은 `null`로 유지한다.
- 이미 `https://` 또는 `http://`로 시작하는 값은 외부/완성 URL로 보고 그대로 유지한다.
- 이 정책은 스냅샷 생성, 정렬, `rankChange`, `isNew`, fallback 여부와 무관한 Response 조립 정책이며, 기존 스냅샷 데이터의 재생성이나 DDL 변경을 요구하지 않는다.
응답 예시는 다음과 같다.
```json
{
"showRankChange": true,
"type": "WEEKLY_POPULAR",
"items": [
{
"contentId": 123,
"title": "Audio title",
"creatorNickname": "creator",
"rank": 1,
"rankChange": 5,
"isNew": false,
"coverImageUrl": "https://cdn.example.com/audio-cover.png"
},
{
"contentId": 456,
"title": "New audio",
"creatorNickname": "new creator",
"rank": 2,
"rankChange": null,
"isNew": true,
"coverImageUrl": "https://cdn.example.com/audio-cover-new.png"
}
]
}
```
---
## 10. Technical Constraints
- Kotlin + Spring Boot 2.7.14 기준으로 작성한다.
- Java 17 런타임을 기준으로 한다.
- 신규 코드는 `kr.co.vividnext.sodalive.v2` 하위에 배치한다.
- 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
- 스냅샷과 작업 이력 저장은 MySQL 기준 DDL을 별도 문서 또는 구현 계획에서 작성한다.
- 이번 PRD에서 예상하는 신규 Entity는 `content_ranking_snapshot`, `content_ranking_snapshot_job` 테이블에 대응하며, 초안 DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 둔다.
- 시간 기준은 `Asia/Seoul`을 명시하고 DB 조회는 UTC 변환 범위를 사용한다.
- 테스트는 순위 변화 계산, 정규화 산식, 동점 정렬, fallback 최대 3회 제한, 작업 이력 기록을 포함해야 한다.
---
## 11. Metrics
- 랭킹 조회 API 응답 시간
- 랭킹 타입별 스냅샷 생성 성공/실패 횟수
- fallback 실행 횟수와 성공/실패 횟수
- fallback 최대 3회 초과로 빈 응답한 횟수
- 랭킹 타입별 응답 item 수
---
## 12. Open Questions
- fallback 최대 3회 제한은 동일 랭킹 타입과 동일 집계 기간 기준으로 가정한다.
- 지금 뜨는 중의 최소 반영 기준은 콘텐츠 전체 후보 제외가 아니라 지표별 점수 반영 제외로 해석한다. 예를 들어 최근 7일 조회수는 10회 미만이지만 좋아요 3개 이상, 댓글 3개 이상이면 조회수 증가율만 0으로 계산하고 좋아요/댓글 증가율은 점수에 반영한다. 콘텐츠 전체 후보 제외가 의도라면 구현 전에 수정해야 한다.

View File

@@ -0,0 +1,682 @@
# 메인 콘텐츠 추천 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/audio/recommendations`로 메인 콘텐츠 추천 탭의 배너, 오리지널 시리즈, 최신/무료/포인트/추천 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 조립 계층에 둔다. 추천 조회 service, 점수 정책, 조회 domain model, port, QueryDSL/native SQL repository, scheduler는 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 두고 `v2.api.*`에 의존하지 않는다. `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다. 배너 값 모델은 `v2.common.domain`, 배너 응답 DTO는 `v2.api.common.dto`로 분리하고, 기존 `recommendation_snapshot`은 section enum 확장 방식으로 재사용한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/audio/recommendations`
- 최종 패키지 구조: 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation`, 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation`을 사용한다.
- 기존 Phase 1-5 구현 산출물이 `audio.recommendation` 패키지에 있으면 Phase 6에서 `content.recommendation` 패키지로 이동한다.
- 인증 정책: 비회원 조회 가능. 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
- 응답 wrapper: `ApiResponse.ok(...)`
- 기본 노출 수:
- `banners`: 메인 홈 추천 배너와 동일
- `originalSeries`: 최신순 12개
- `latestAudios`: 최신순 12개
- `newAndHotAudios`: 최대 12개
- `freeAudios`: 최대 10개 랜덤
- `pointAudios`: 최대 10개 랜덤
- `mostCommentedAudios`: 최대 5개
- `recommendedAudios`: 최대 10개
- 공개 오디오 공통 조건: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`, 크리에이터 회원 활성.
- 비회원과 19금 노출 불가 회원은 성인 콘텐츠를 제외하고 `SAFE` 스냅샷을 조회한다.
- 19금 노출 가능 회원은 성인/비성인 콘텐츠를 모두 포함하는 `ALL` 스냅샷을 조회한다.
- 스냅샷 기준: KST 매일 00:00 실행, 전날 23:59:59 KST까지의 데이터를 UTC 변환 없이 KST-local `LocalDateTime`으로 반영.
- 스냅샷 저장 방식: 기존 `recommendation_snapshot` 테이블을 재사용하고 `RecommendedSectionType` enum에 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`을 추가한다. 신규 테이블 DDL은 작성하지 않는다.
- New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history``content_id`별 count를 사용한다.
- 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
---
## 1. 파일 구조 계획
### API 공통 DTO
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
### 신규 API 조립 계층
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
### 신규 도메인 조회 계층
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
### 기존 재사용 파일 확인
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.recommendation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
data class AudioRecommendationsResponse(
val banners: List<RecommendationBannerResponse>,
val originalSeries: List<OriginalSeriesResponse>,
val latestAudios: List<AudioCardResponse>,
val newAndHotAudios: List<AudioCardResponse>,
val freeAudios: List<AudioCardResponse>,
val pointAudios: List<AudioCardResponse>,
val mostCommentedAudios: List<CommentedAudioResponse>,
val recommendedAudios: List<AudioCardResponse>
) {
companion object {
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
return AudioRecommendationsResponse(
banners = recommendations.banners.map(RecommendationBannerResponse::from),
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
freeAudios = recommendations.freeAudios.map(AudioCardResponse::from),
pointAudios = recommendations.pointAudios.map(AudioCardResponse::from),
mostCommentedAudios = recommendations.mostCommentedAudios.map(CommentedAudioResponse::from),
recommendedAudios = recommendations.recommendedAudios.map(AudioCardResponse::from)
)
}
}
}
data class OriginalSeriesResponse(
val seriesId: Long,
val coverImageUrl: String?
) {
companion object {
fun from(series: OriginalSeries): OriginalSeriesResponse {
return OriginalSeriesResponse(series.seriesId, series.coverImageUrl)
}
}
}
data class AudioCardResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
) {
companion object {
fun from(audio: AudioCard): AudioCardResponse {
return AudioCardResponse(
audioContentId = audio.audioContentId,
title = audio.title,
duration = audio.duration,
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname
)
}
}
}
data class CommentedAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val latestComment: String,
val latestCommentWriterProfileImageUrl: String
) {
companion object {
fun from(audio: CommentedAudio): CommentedAudioResponse {
return CommentedAudioResponse(
audioContentId = audio.audioContentId,
title = audio.title,
imageUrl = audio.imageUrl,
latestComment = audio.latestComment,
latestCommentWriterProfileImageUrl = audio.latestCommentWriterProfileImageUrl
)
}
}
}
```
---
## 3. Domain / Port 초안
```kotlin
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
data class AudioRecommendations(
val banners: List<RecommendationBanner>,
val originalSeries: List<OriginalSeries>,
val latestAudios: List<AudioCard>,
val newAndHotAudios: List<AudioCard>,
val freeAudios: List<AudioCard>,
val pointAudios: List<AudioCard>,
val mostCommentedAudios: List<CommentedAudio>,
val recommendedAudios: List<AudioCard>
)
data class OriginalSeries(
val seriesId: Long,
val coverImageUrl: String?
)
data class AudioCard(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val isOriginalSeries: Boolean,
val creatorNickname: String
)
data class CommentedAudio(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val latestComment: String,
val latestCommentWriterProfileImageUrl: String
)
enum class AudioRecommendationVisibility {
SAFE,
ALL
}
```
```kotlin
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import java.time.LocalDateTime
interface AudioRecommendationQueryPort {
fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner>
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
fun findAudioCardsByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<AudioCard>
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
fun findNewAndHotSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
fun findMostCommentedSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
fun findRecommendedAudioSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
}
```
---
### Phase 1: 공통 DTO와 API 계약
- [x] **Task 1.1: 배너 응답 DTO를 공통 패키지로 분리**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
- RED: `HomeRecommendationResponse``banners`가 공통 `RecommendationBannerResponse` 타입을 사용하고 기존 JSON 필드 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- GREEN: `HomeBannerItem` 필드 구조를 `RecommendationBanner` domain model과 `RecommendationBannerResponse` DTO로 분리하고 홈 추천 DTO/facade import를 갱신한다.
- REFACTOR: 홈 탭 전용 controller/facade 로직은 이동하지 않고 DTO 타입만 공통화한다.
- 기대 결과: 기존 홈 추천 배너 JSON 계약은 유지되고 신규 오디오 추천 API가 같은 DTO를 재사용할 수 있다.
- [x] **Task 1.2: 오디오 추천 응답 DTO와 facade 변환 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
- RED: facade가 도메인 `AudioRecommendations``AudioRecommendationsResponse`로 변환하고 `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios` 필드를 모두 채우는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
- GREEN: facade는 `AudioRecommendationQueryService.getRecommendations(member)`만 호출하고 공개 DTO 변환만 담당한다.
- REFACTOR: `isOriginalSeries``Boolean`으로 유지하고 nullable 변환을 만들지 않는다.
- 기대 결과: API 조립 계층은 도메인 조회 계층에만 의존하고, 도메인 조회 계층은 API DTO에 의존하지 않는다.
- [x] **Task 1.3: 비회원 허용 controller 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
- RED: `GET /api/v2/audio/recommendations`가 비회원과 인증 회원 모두 `200 OK`를 반환하고 `ApiResponse.ok` wrapper를 사용하는 MockMvc 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest`
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴으로 member nullable을 facade에 전달한다.
- REFACTOR: request parameter는 추가하지 않고 controller에는 인증/응답 경계만 남긴다.
- 기대 결과: 비회원 조회 가능 계약과 endpoint 경로가 controller 테스트로 고정된다.
### Phase 2: 도메인 모델과 점수 정책
- [x] **Task 2.1: 도메인 모델과 visibility enum 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- RED: `AudioRecommendationVisibility.SAFE``NEW_AND_HOT_AUDIO_SAFE`, `ALL``NEW_AND_HOT_AUDIO_ALL`처럼 section type을 선택해야 한다는 service 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
- GREEN: `AudioRecommendations`, `OriginalSeries`, `AudioCard`, `CommentedAudio`, `AudioRecommendationVisibility`를 추가한다.
- REFACTOR: domain model에는 API DTO import를 두지 않는다. `AudioRecommendations.banners``v2.common.domain.RecommendationBanner`만 사용한다.
- 기대 결과: SAFE/ALL 선택이 문자열이 아니라 enum으로 고정된다.
- [x] **Task 2.2: 오디오 추천 점수 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
- RED: New & Hot 최신성 배수 3/7/14일/그 외, 추천 오디오 최신성 배수 3/7/30일/그 외, 최근 댓글 최신성 배수 3/7/14일/그 이상을 검증하는 테스트를 작성한다. 원본 count 가중합이 정규화 없이 계산되는 테스트도 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`
- GREEN: `calculateNewAndHotScore`, `calculateRecommendedAudioScore`, `calculateCommentScore`와 각 recency multiplier 함수를 구현한다.
- REFACTOR: 가중치와 일수 경계는 `companion object` 상수로 모아 테스트 기대값과 용어를 맞춘다.
- 기대 결과: PRD 산식과 최신성 경계가 순수 단위 테스트로 고정된다.
- [x] **Task 2.3: 스냅샷 section enum 확장**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- RED: visibility와 섹션 조합이 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`로 매핑되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
- GREEN: 기존 `RecommendedSectionType`에 오디오 추천 섹션 enum 값을 추가하고 service 내부 매핑 함수를 구현한다.
- REFACTOR: `recommendation_snapshot.section_type` 길이 50 안에 모든 enum 이름이 들어가는지 확인한다.
- 기대 결과: 신규 테이블 없이 기존 스냅샷 저장 구조를 재사용한다.
### Phase 3: 실시간 조회 섹션 repository
- [x] **Task 3.1: 배너/오리지널 시리즈/최신 오디오 조회 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
- Create: `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: 배너는 기존 홈 추천 배너와 동일 필드/활성/차단 정책을 적용하고, 오리지널 시리즈는 `isOriginal = true` 최신순 12개, 최신 오디오는 `releaseDate desc`, `audioContentId desc` 12개를 반환하는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- GREEN: QueryDSL로 `findBanners`, `findOriginalSeries`, `findLatestAudios`를 구현한다. 이미지 경로는 `toCdnUrl(cloudFrontHost)`를 사용한다.
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
- 기대 결과: 비회원은 성인 콘텐츠를 제외하고, 인증 회원은 성인 노출 가능 여부에 따라 결과가 달라진다.
- [x] **Task 3.2: 무료/포인트 랜덤 오디오 조회 구현**
- 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` 공개 오디오 중 최대 10개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 10개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다.
- 실패 확인: `./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 함수를 사용하게 정리한다.
- 기대 결과: 랜덤 섹션도 공개/성인/차단 조건을 동일하게 적용한다.
- [x] **Task 3.3: 공통 오디오 카드 enrichment 구현**
- 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: `AudioCard``audioContentId`, `title`, `duration`, `imageUrl`, `price`, `isAdult`, `isPointAvailable`, `isFirstContent`, `isOriginalSeries`, `creatorNickname`을 채우고, 시리즈 미소속이면 `isOriginalSeries = false`인 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- GREEN: first content 판정은 기존 크리에이터 채널 오디오 조회 repository의 첫 콘텐츠 계산 패턴을 참고해 구현한다. 원본 시리즈 연결이 없으면 `false`, 연결 시리즈가 있으면 `series.isOriginal`을 사용한다.
- REFACTOR: latest/free/point/snapshot 상세 조회 모두 같은 `toAudioCard` 변환을 사용한다.
- 기대 결과: 섹션별 오디오 카드 필드 의미가 동일하게 유지된다.
### Phase 4: 스냅샷 산정과 일 배치
- [x] **Task 4.1: New & Hot 스냅샷 후보 산정 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
- 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: 최근 3일 `creator_content_view_history` count, `content_like` active count, `audio_content_comment` active count, 최신성 배수를 원본 count 가중합으로 계산하고 `SAFE`는 비성인만, `ALL`은 성인/비성인을 모두 포함하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- GREEN: native SQL CTE 또는 QueryDSL aggregate로 `findNewAndHotSnapshots(windowStart, snapshotAt, visibility, limit)`를 구현한다. 정렬은 `score desc`, `randomTieBreaker asc`로 한다.
- REFACTOR: Kotlin `AudioRecommendationScorePolicy` 기대값과 DB score가 일치하는 parity 테스트 데이터를 유지한다.
- 기대 결과: `NEW_AND_HOT_AUDIO_SAFE/ALL`에 저장할 top 12 후보가 정확한 점수순으로 산출된다.
- [x] **Task 4.2: 최근 댓글 많은 오디오 스냅샷 후보 산정 구현**
- 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: 최근 7일 댓글 데이터 기반으로 댓글 수 80%, 댓글 최신성 20% 점수를 계산하고 데이터가 없으면 빈 후보를 반환하는 테스트를 작성한다. 가장 최신 댓글 1개의 본문과 작성자 프로필 이미지가 상세 조회에서 내려가는 테스트도 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- GREEN: `findMostCommentedSnapshots(...)``findCommentedAudiosByIds(...)`를 구현한다. 상세 조회 결과에는 가장 최신 댓글 본문과 작성자 프로필 이미지를 포함한다. 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자는 제외한다.
- REFACTOR: 댓글 최신성 배수 계산은 repository SQL과 `AudioRecommendationScorePolicy`가 같은 경계값을 사용하도록 테스트로 고정한다.
- 기대 결과: 스냅샷이 없거나 후보가 없으면 `mostCommentedAudios`는 빈 배열이다.
- [x] **Task 4.3: 추천 오디오 스냅샷 후보 산정 구현**
- 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: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10% 점수를 계산하고 `SAFE/ALL` visibility별 최대 10개 후보를 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- GREEN: `findRecommendedAudioSnapshots(...)`를 구현한다. 상세 조회수는 `creator_content_view_history` count를 사용하고 `AudioContent.playCount`를 사용하지 않는다.
- REFACTOR: New & Hot과 공유 가능한 조회수/좋아요/댓글 aggregate CTE를 private SQL fragment 또는 QueryDSL helper로 정리한다.
- 기대 결과: `RECOMMENDED_AUDIO_SAFE/ALL`에 저장할 top 10 후보가 정확한 점수순으로 산출된다.
- [x] **Task 4.4: 스냅샷 refresh service와 lazy 보강 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshDailySnapshots(now)`가 KST 전날 23:59:59 기준으로 여섯 section type(`NEW_AND_HOT_AUDIO_SAFE/ALL`, `MOST_COMMENTED_AUDIO_SAFE/ALL`, `RECOMMENDED_AUDIO_SAFE/ALL`)을 replace하고, New & Hot 조회 시 최신 스냅샷이 없으면 lazy refresh를 1회 호출하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 `RecommendationSnapshotPort.replaceSnapshots(...)``findLatestSnapshots(...)`를 재사용한다. lazy 보강은 New & Hot에만 적용하고, 최근 댓글 많은 오디오는 스냅샷이 없으면 빈 배열로 유지한다.
- REFACTOR: 기준 시각 계산은 private 함수로 분리하고 KST-local `LocalDateTime` 경계 테스트를 유지한다. 보강 후에도 New & Hot 후보가 0개이면 Redis marker 기준 같은 KST 날짜에는 lazy refresh를 반복하지 않는다.
- 기대 결과: 일 배치와 lazy 보강 모두 같은 산정 함수를 사용한다.
- [x] **Task 4.5: 00:00 KST 스케줄러와 Redisson lock 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
- RED: cron이 `0 0 0 * * *`, zone이 `Asia/Seoul`, lock key가 `lock:audio-recommendation-snapshot-refresh`이고 lock 획득 성공 시에만 refresh service를 호출하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`
- GREEN: `RedissonClient`를 주입하고 기존 추천 스냅샷 scheduler 패턴처럼 `tryLock` 성공 시 `refreshDailySnapshots()`를 호출한다.
- REFACTOR: 스케줄러에는 lock과 service 호출만 남기고 집계 로직을 두지 않는다.
- 기대 결과: 다중 서버에서 하루 한 번만 오디오 추천 스냅샷을 갱신한다.
### Phase 5: 통합 조회 service와 API 연결
- [x] **Task 5.1: AudioRecommendationQueryService 통합 조립**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- RED: 비회원은 `SAFE` visibility와 19금 제외 조건을 사용하고, 19금 노출 가능 회원은 `ALL` visibility를 사용하며, 각 섹션 limit이 PRD와 일치하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
- GREEN: query service가 real-time 섹션과 snapshot 섹션을 조립해 `AudioRecommendations`를 반환한다. `MemberContentPreferenceService`는 facade가 아니라 query service 또는 별도 resolver에서 사용해 도메인 조회 조건을 만든다.
- REFACTOR: 섹션 limit은 companion object 상수로 고정하고 테스트에서 같은 값을 검증한다.
- 기대 결과: 특정 섹션이 빈 배열이어도 전체 응답은 성공 가능한 domain model로 조립된다.
- [x] **Task 5.2: Facade 성인 정책/이미지 URL 변환 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
- RED: 회원/비회원별 성인 노출 정책이 query service에 전달되고, CDN URL이 포함된 domain 응답이 공개 DTO로 변환되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
- GREEN: facade는 member를 그대로 query service에 전달하고 `AudioRecommendationsResponse.from(...)`만 수행한다.
- REFACTOR: Home 탭 전용 `HomeRecommendationFacade`를 주입하거나 호출하지 않는지 import를 확인한다.
- 기대 결과: API 조립 계층은 신규 audio recommendation use case만 호출한다.
- [x] **Task 5.3: Controller/E2E 통합 검증**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
- RED: MockMvc controller 테스트와 최소 E2E 테스트를 작성해 JSON path `$.data.originalSeries`, `$.data.latestAudios`, `$.data.recommendedAudios`, `$.data.latestAudios[0].isOriginalSeries`, `$.data.mostCommentedAudios[0].latestComment`, `$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl`가 존재하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`
- GREEN: controller와 Spring bean wiring을 완성한다.
- REFACTOR: 응답 필드명이 PRD와 plan-task의 DTO 초안과 같은지 검색으로 확인한다.
- 기대 결과: 비회원과 인증 회원 모두 endpoint 호출이 성공한다.
### Phase 6: 패키지 구조 content.recommendation 이동
- [x] **Task 6.1: 공개 API 조립 계층 패키지 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
- Run: `rg -n "v2\\.api\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
- GREEN: `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 기준으로 갱신한다.
- REFACTOR: endpoint `GET /api/v2/audio/recommendations`, response DTO class/field 이름은 공개 API 계약이므로 변경하지 않는다.
- 기대 결과: 공개 API 조립 계층은 `v2.api.content.recommendation` 아래에만 존재한다.
- [x] **Task 6.2: 도메인 조회 계층 패키지 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/**` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/**`
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
- Run: `rg -n "v2\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
- GREEN: 도메인 조회 계층의 `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.content.recommendation` 기준으로 갱신한다.
- REFACTOR: class 이름(`AudioRecommendation*`)은 현재 API/섹션 의미가 오디오 중심이므로 유지하고, 패키지명만 콘텐츠 범주로 확장한다.
- 기대 결과: 도메인 조회 계층은 `v2.content.recommendation` 아래에만 존재하고 `v2.api.*`에 의존하지 않는다.
- [x] **Task 6.3: 패키지 잔여 참조와 문서 동기화 확인**
- Files:
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
- Verify: `src/main/kotlin`
- Verify: `src/test/kotlin`
- TDD 예외 사유: 문서와 package/import 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
- Run: `rg -n "api\\.content\\.recommendation|v2\\.content\\.recommendation|api/content/recommendation|v2/content/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
- 기대 결과: 잔여 `audio.recommendation` 패키지 참조는 과거 검증 기록을 제외하고 남지 않고, PRD/plan-task의 최종 구조가 `content.recommendation` 기준으로 일치한다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 6 구현 기록:
- 공개 API 조립 계층을 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 패키지로 이동했다. endpoint `GET /api/v2/audio/recommendations`, class 이름, response DTO field 이름은 변경하지 않았다.
- 도메인 조회 계층을 `kr.co.vividnext.sodalive.v2.content.recommendation` 패키지로 이동했다. `AudioRecommendation*` class 이름과 repository/query/scheduler 동작은 변경하지 않았다.
- `rg -n "kr\.co\.vividnext\.sodalive\.v2\.(api\.)?audio\.recommendation|v2\.api\.audio\.recommendation|v2\.audio\.recommendation" src/main/kotlin src/test/kotlin`: 결과 없음.
- `rg --files src/main/kotlin src/test/kotlin | rg "/v2/(api/)?audio/recommendation/"`: 결과 없음.
- `rg -n "/api/v2/audio/recommendations" src/main/kotlin src/test/kotlin`: controller, controller test, E2E test, `SecurityConfig`에서 기존 endpoint 유지 확인.
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`.
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: 병렬 Gradle 실행 중 XML test result 파일 쓰기 충돌로 1회 실패 후 단독 재실행해 `BUILD SUCCESSFUL`.
- 2026-06-23 Phase 6 코드 리뷰 및 검증 기록:
- `rg -n "v2\\.api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|/v2/audio/recommendation" src/main/kotlin src/test/kotlin`: endpoint 문자열을 제외하고 이전 패키지/경로 참조 없음.
- `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`: 문서의 Phase 1-6 과거 작업 경로/검증 기록과 endpoint 문자열만 확인됨.
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`.
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: `BUILD SUCCESSFUL`.
- `./gradlew ktlintCheck`: 최초 sandbox 실행은 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한으로 실패했고, 승인 후 재실행해 `BUILD SUCCESSFUL`.
### Phase 7: 성인 콘텐츠 조회 정책 계산 경로 통일
- [x] **Task 7.1: MemberContentPreferenceService에 성인 콘텐츠 조회 가능 여부 메서드 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
- RED: `canViewAdultContent(member)`가 저장된 `isAdultContentVisible` 설정, 국가 정책, 성인 인증 여부를 반영해 `ViewerContentPreference.isAdult`와 같은 값을 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
- GREEN: `MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean`을 추가하고 내부 구현은 `getStoredPreference(member).isAdult`를 반환한다.
- REFACTOR: 성인 콘텐츠 조회 가능 여부를 계산하는 신규 호출부는 `isAdultVisibleByPolicy(...)`를 직접 호출하지 않고 service 메서드를 사용한다.
- 기대 결과: 사용자 설정(`isAdultContentVisible`), 국가 정책, 성인 인증 여부가 하나의 공개 service 메서드로 일관되게 계산된다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
- RED: `MemberContentPreferenceServiceTest.shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent`를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests '*shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent'`를 실행해 `Unresolved reference: canViewAdultContent` 실패를 확인했다.
- GREEN: `MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean`을 추가해 `getStoredPreference(member).isAdult`를 반환하도록 했고, 동일 테스트 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- `./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.*'`: 따옴표 없이 실행한 첫 명령은 zsh glob 해석으로 실행 전 실패했고, 따옴표로 감싸 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 7.2: 추천 탭과 v2 조회 계층의 성인 정책 호출부를 service 메서드로 교체**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Test: 기존 v2 조회 service 테스트 중 성인 콘텐츠 노출 정책을 검증하는 테스트 파일
- RED: `AudioRecommendationQueryServiceTest`에서 `memberContentPreferenceService.canViewAdultContent(member)`가 호출되고 `getStoredPreference(...)` 또는 `isAdultVisibleByPolicy(...)` 직접 조합을 사용하지 않는 테스트를 작성한다. 기존 v2 조회 service 테스트에는 성인 콘텐츠 노출 가능/불가 회원별 조회 조건이 유지되는 회귀 테스트를 추가한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- Run: 변경한 기존 v2 조회 service 테스트
- GREEN: 각 호출부의 `getStoredPreference(...)` + `isAdultVisibleByPolicy(...)` 조합 또는 `getStoredPreference(...).isAdult` 직접 사용을 `memberContentPreferenceService.canViewAdultContent(member)`로 교체한다.
- REFACTOR: 더 이상 필요 없는 `isAdultVisibleByPolicy` import와 중간 `preference` 지역 변수를 제거한다.
- 기대 결과: v2 조회 계층과 추천 탭 API의 성인 콘텐츠 조회 정책 계산 경로가 `MemberContentPreferenceService.canViewAdultContent(...)`로 통일된다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
- `AudioRecommendationQueryService`, `HomeRecommendationFacade`, v2 creator channel audio/community/home/live/series 조회 service의 성인 콘텐츠 조회 가능 여부 계산을 `memberContentPreferenceService.canViewAdultContent(...)` 호출로 통일했다.
- `CreatorChannelHomeQueryService`는 기존 `preference.contentType` 전달이 필요하므로 `getStoredPreference(viewer)`는 유지하고, 성인 콘텐츠 조회 가능 여부 계산만 service 메서드로 교체했다.
- 변경한 v2 service/controller 테스트 묶음 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
- [x] **Task 7.3: 성인 정책 직접 호출 잔여 참조 확인**
- Files:
- Verify: `src/main/kotlin`
- Verify: `src/test/kotlin`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
- TDD 예외 사유: 검색 기반 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
- Run: `rg -n "canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive`
- 기대 결과: v2 조회 계층에는 성인 콘텐츠 조회 가능 여부 계산을 위한 `isAdultVisibleByPolicy(...)` 직접 호출이나 `getStoredPreference(...).isAdult` 직접 사용이 남지 않고, `canViewAdultContent(...)` 호출로 통일된다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
- `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`: 결과 없음.
- `rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive`: `MemberContentPreferenceService`와 Phase 7 변경 호출부에서 canonical 메서드 사용 확인.
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
- `git diff --check`: 출력 없음.
- Phase 7 리뷰어 검토 결과: `PASS` (차단 이슈 없음).
- [x] **Task 7.4: 중복 성인 정책 함수 정리**
- Files:
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- Verify: `src/main/kotlin`
- Verify: `src/test/kotlin`
- TDD 예외 사유: 정책 계산 로직의 공개 진입점 정리와 잔여 사용처 확인 task이며, Task 7.1/7.2의 회귀 테스트가 동작 동일성을 검증한다.
- 대체 검증 방법:
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
- Run: `rg -n "calculateIsAdultForQuery|canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference`
- GREEN: `isAdultVisibleByPolicy(...)``resolveCountryCodeByPolicy(...)`의 production 사용처가 모두 없어졌으면 제거한다. 아직 v2 외부 사용처가 남아 있으면 즉시 제거하지 않고 `@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")`로 표시한 뒤 별도 후속 task를 남긴다.
- REFACTOR: 성인 콘텐츠 조회 가능 여부 정책의 canonical 진입점은 `MemberContentPreferenceService.canViewAdultContent(member)`로 문서화하고, 내부 계산은 기존 `calculateIsAdultForQuery(...)`를 재사용한다.
- 기대 결과: 동일한 정책을 중복 구현한 `isAdultVisibleByPolicy(...)` 경로가 제거되거나 명확히 deprecated 처리되어, 신규 호출부가 다시 분산되지 않는다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 제거하지 못한 사용처가 있으면 사유와 후속 task를 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
- `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin` 실행 결과 v2 외부 기존 production 사용처(`content/main`, `content/series`, `content/theme`, `content/AudioContentService` 등)가 남아 있어 즉시 제거하지 않았다.
- `MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)``isAdultVisibleByPolicy(...)``@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")`를 추가했다.
- 성인 콘텐츠 조회 가능 여부 정책의 신규 canonical 진입점은 `MemberContentPreferenceService.canViewAdultContent(member)`로 정리했다.
- 2026-06-23 Phase 7 코드 리뷰 및 추가 검증 기록:
- 코드 리뷰: `canViewAdultContent(member)``getStoredPreference(member).isAdult`를 반환해 기본 preference 초기화, 국가 정책, 성인 인증 여부 계산 경로를 그대로 재사용함을 확인했다. v2 추천 탭/홈/creator channel 호출부도 해당 service 메서드로 통일되어 차단 이슈 없음.
- `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
- `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`: 결과 없음.
- `rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference`: `MemberContentPreferenceService`와 Phase 7 v2 변경 호출부에서 canonical 메서드 사용 확인.
- `git diff --check`: 출력 없음.
- `./gradlew ktlintCheck`: sandbox 환경에서는 Gradle wrapper lock 파일 접근 제한으로 실패했으나, 승인 후 sandbox 밖에서 재실행해 `BUILD SUCCESSFUL` 확인.
### Phase 8: 회귀 검증과 문서 기록
- [ ] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
- Files:
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
- Run: `./gradlew ktlintCheck`
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- [ ] **Task 8.2: 문서/스키마 영향 최종 확인**
- Files:
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
- TDD 예외 사유: 문서와 enum 확장 범위 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "GET /api/v2/audio/recommendations|AudioRecommendationsResponse|NEW_AND_HOT_AUDIO_SAFE|RECOMMENDED_AUDIO_ALL" docs src/main/kotlin src/test/kotlin`
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" src/main/kotlin src/test/kotlin`
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
- Run: `./gradlew tasks --all`
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다.
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
---
## 전체 검증 기록
- 계획 문서 생성 시점에는 구현 코드를 변경하지 않았으므로 테스트 실행 대상은 없다.
- 문서 변경 후 명령 유효성 확인은 `./gradlew tasks --all`로 수행한다.
- 패키지 구조 변경 계획 문서 수정 후 `./gradlew tasks --all`을 실행했다. 최초 sandbox 실행은 Gradle wrapper lock 파일의 `~/.gradle` 접근 권한 문제로 실패했고, 승인 후 재실행해 `BUILD SUCCESSFUL`을 확인했다.
## Phase 1-3 검증 기록
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`: `BUILD SUCCESSFUL` (홈 배너 공통 DTO 직렬화 필드 포함 확인).
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`: 최초 실행 시 점수 정책 테스트 기대값 산식 오산으로 실패 후 기대값 수정.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
- Phase 4 범위로 보일 수 있는 snapshot 후보 조회 stub 제거 후 동일한 6개 타깃 테스트 명령을 재실행했고 `BUILD SUCCESSFUL`.
- reviewer 지적 사항 반영: `latestComment` 응답 필드 추가, PRD 기준 최신성 배수 수정, JSON boolean 필드명과 공개 오디오 필터 테스트 보강.
- `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
- 추가 code review 지적 사항 반영: production `SecurityConfig``GET /api/v2/audio/recommendations` 비회원 허용 추가, controller 테스트의 테스트 전용 permitAll 보안 체인 제거, 오디오 추천 배너의 성인 배너 필터 제거로 홈 추천 배너와 동일 정책 유지, 오리지널 시리즈 최신순/12개 limit 및 무료/포인트 10개 limit 테스트 보강.
- 동일 targeted test 명령과 `./gradlew ktlintCheck`를 재실행했고 모두 `BUILD SUCCESSFUL`.
## Phase 4-5 검증 기록
- RED: `DefaultAudioRecommendationQueryRepositoryTest`, `AudioRecommendationSnapshotRefreshServiceTest`, `AudioRecommendationSnapshotSchedulerTest`, `AudioRecommendationQueryServiceTest`에 Phase 4/5 실패 테스트를 먼저 추가했다. 초기 실행에서 query service Mockito matcher 오류와 동시 Gradle 실행으로 인한 XML 결과 파일 쓰기 충돌, ktlint formatting 실패를 확인했다.
- GREEN: snapshot 후보 native SQL, 최신 댓글 상세 조회, KST 기준 refresh service, 00:00 KST Redisson lock scheduler, query service snapshot 조립과 New & Hot lazy refresh를 구현하고 실패 원인을 수정했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL` (New & Hot/최근 댓글/추천 후보 산정, 댓글 상세, 기존 실시간 섹션 회귀 포함).
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`: `BUILD SUCCESSFUL` (KST 전날 23:59:59 기준과 여섯 section replace 확인).
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`: `BUILD SUCCESSFUL` (cron/zone, lock 획득/skip/unlock 확인).
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`: `BUILD SUCCESSFUL` (SAFE snapshot 조회, New & Hot lazy refresh, 빈 mostCommented/recommended 허용 확인).
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.audio.recommendation.*'`: `BUILD SUCCESSFUL` (facade DTO 변환과 controller permitAll 응답 계약 확인).
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
- 추가 점검: 댓글 상세 조회에서 차단 작성자 제외 후 최신 active 댓글을 선택하도록 보강하고 `DefaultAudioRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`를 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- 추가 code review 지적 사항 반영: `findCommentedAudiosByIds`의 최신 댓글 상세 조회가 스냅샷 산정 SQL과 동일하게 크리에이터-댓글 작성자 간 차단 댓글을 제외하도록 보강하고, 해당 작성자의 더 최신 댓글이 이전 정상 댓글 선택을 막지 않도록 `newer` 후보에도 같은 차단 조건을 적용했다.
- `DefaultAudioRecommendationQueryRepositoryTest`에 viewer와 무관한 크리에이터-댓글 작성자 차단 회귀 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
- Task 5.3 보강: `AudioRecommendationEndToEndTest`를 추가해 `@SpringBootTest` + `@AutoConfigureMockMvc`로 production SecurityConfig, controller, facade, query service, repository, snapshot 조회 조합을 통과하는 최소 E2E를 검증했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
- 2026-06-23 리뷰 보정: 스냅샷 기준/윈도우를 UTC 변환 `LocalDateTime`이 아니라 KST-local `LocalDateTime`으로 저장/조회하도록 보정하고, 최신성 일수는 24시간 경과 기준으로 Kotlin 정책을 맞췄다. New & Hot lazy refresh는 보강 후에도 row가 없으면 Redis marker 기준 같은 KST 날짜에 반복 실행하지 않도록 보강했다.
- 2026-06-23 리뷰 보정 후 추가 보정: post-implementation review에서 `getRecommendations()`의 read-only transaction 안에서 lazy refresh 후 재조회하면 MySQL `REPEATABLE_READ` read view 때문에 새 스냅샷이 같은 요청에서 보이지 않을 수 있다고 지적해, query service의 외부 read-only transaction을 제거했다. 또한 인메모리 guard는 프로세스 재시작/다중 서버에서 KST 날짜별 1회를 보장하지 못하므로 `RedissonClient` Redis marker(`audio-recommendation:new-and-hot:lazy-refresh-attempted:{yyyy-MM-dd}`, TTL 2일)로 변경했다.
- 2026-06-23 리뷰 보정 검증: `./gradlew --stop && ./gradlew clean test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`: `BUILD SUCCESSFUL`.
- 2026-06-23 리뷰 보정 검증: repository focused test는 병렬 Gradle 실행 중 `kaptGenerateStubsTestKotlin` 출력 디렉터리 충돌로 1회 실패해 단독 재실행했다. H2 `MODE=MySQL``TIMESTAMPDIFF` 경계 동작이 운영 MySQL 공식 기준과 달라 신규 repository 경계 테스트는 제거하고 Kotlin 정책 테스트로 24시간 경계를 고정했다. 최종 `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
- 2026-06-23 리뷰 보정 검증: `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`, `git diff --check`: 출력 없음.
- 2026-06-25 후속 보정: `DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility`의 score 비교 실패 원인은 repository native SQL의 `timestampdiff(day, c.release_date, :snapshotAt)` 최신성 계산이 DB 날짜 경계 기준에 의존해 `AudioRecommendationScorePolicy`의 24시간 경과 기준 `ChronoUnit.DAYS` 계산과 어긋날 수 있는 점으로 확인했다. `DefaultAudioRecommendationQueryRepository`의 New & Hot/추천 오디오 공개일 최신성 계산을 `floor(timestampdiff(hour, c.release_date, :snapshotAt) / 24)`로 변경해 Kotlin 정책과 일치시켰고, `SAFE` 성인 콘텐츠 제외 조건은 기존 `(:includeAdult = true or c.is_adult = false)` 구현이 올바른 것으로 확인했다. 검증은 `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility'`, `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicyTest'`, `./gradlew ktlintCheck` 모두 `BUILD SUCCESSFUL`로 완료했다.

View File

@@ -0,0 +1,302 @@
# PRD: 메인 콘텐츠 추천 탭 API
## 1. Overview
메인 콘텐츠 탭의 내부 추천 탭에서 사용할 배너, 오리지널 시리즈, 신규/추천/무료/포인트 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 기존 `content.main.tab.home` API는 콘텐츠 홈 화면 전체 구성을 조립하지만, 신규 내부 추천 탭의 섹션 구성과 응답 필드가 다르다.
- 신규 추천 탭은 실시간 최신순/랜덤 조회와 일 단위 스냅샷 기반 점수 섹션이 섞여 있어, API 조립 계층과 도메인 조회 계층의 책임을 분리해야 한다.
- 기존 v2 패키지에 홈 추천 API, 스냅샷, 배너 조회, 오디오 응답 DTO와 유사한 코드가 있으므로 구현 전 재사용 범위를 명확히 해야 한다.
- New & Hot, 최근 댓글 많은 오디오처럼 매일 갱신되는 섹션은 데이터가 없을 때 표시/스케줄 보강 정책이 필요하다.
---
## 3. Goals
- 메인 콘텐츠 추천 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
- 메인 배너는 메인 홈 추천 배너와 동일한 데이터를 응답한다.
- 오리지널 시리즈, 최신 오디오, 무료 오디오, 포인트 오디오는 요청 시점 기준으로 조회한다.
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영한 스냅샷을 사용한다.
- New & Hot 스냅샷 데이터가 없으면 조회 시점에 lazy로 스케줄/집계 보강을 요청할 수 있어야 한다.
- 최근 댓글 많은 오디오는 스냅샷 데이터가 없으면 섹션을 빈 배열로 내려주어 앱에서 표시하지 않게 한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
---
## 4. Non-Goals
- 기존 `content.main.tab.home` 공개 API 스키마를 변경하지 않는다.
- 기존 메인 홈 추천 API의 공개 스키마를 변경하지 않는다.
- 관리자 화면, 수동 편집 기능, 추천 결과 강제 고정 기능은 포함하지 않는다.
- 개인화 추천 모델, A/B 테스트, 머신러닝 기반 추천은 포함하지 않는다.
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
---
## 5. Target Users
- 회원: 콘텐츠 메인 탭에서 추천 오디오와 오리지널 시리즈를 탐색하는 사용자
- 비회원: 인증 없이 조회 가능한 추천 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 추천 탭 첫 화면 섹션을 한 API 응답으로 구성하는 클라이언트
---
## 6. User Stories
- 사용자는 추천 탭 진입 시 메인 홈 추천 배너와 동일한 배너를 보고 싶다.
- 사용자는 오직 보이스 온에서만 볼 수 있는 오리지널 시리즈를 최신순으로 보고 싶다.
- 사용자는 새로 올라온 오디오를 최신순으로 확인하고 싶다.
- 사용자는 최근 반응이 좋은 New & Hot 오디오를 보고 싶다.
- 사용자는 무료 오디오와 포인트 사용 가능 오디오를 빠르게 탐색하고 싶다.
- 사용자는 최근 댓글이 많은 오디오와 해당 오디오의 최신 댓글, 최신 댓글 작성자 프로필 이미지를 보고 싶다.
- 사용자는 서버 추천 점수 기반의 추천 오디오를 보고 싶다.
---
## 7. Core Features
### Feature A. 메인 콘텐츠 추천 탭 통합 조회
#### Requirements
- 신규 API endpoint는 `GET /api/v2/audio/recommendations`로 정의한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 시리즈/오디오는 노출하지 않는다.
- 섹션별 기본 노출 수는 아래와 같다.
- `banners`: 메인 홈 추천 배너와 동일
- `originalSeries`: 최신순 12개
- `latestAudios`: 최신순 12개
- `newAndHotAudios`: 최대 12개
- `freeAudios`: 최대 10개 랜덤
- `pointAudios`: 최대 10개 랜덤
- `mostCommentedAudios`: 최대 5개
- `recommendedAudios`: 최대 10개
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.
#### Edge Cases
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
- 예약 공개 콘텐츠는 공개 전에는 노출하지 않는다.
- 비활성 콘텐츠, duration이 없는 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
### Feature B. 메인 배너
#### Requirements
- 메인 홈 추천 배너와 동일한 데이터를 사용한다.
- 기존 v2 홈 추천 API의 배너 응답 구조를 공통 DTO로 분리해 재사용한다.
- 배너 응답 필드는 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지한다.
- 배너 대상 엔티티가 비활성 처리되었거나 차단 관계에 있으면 기존 홈 추천 배너 정책과 동일하게 제외한다.
### Feature C. 오직 보이스 온에서만
#### Requirements
- 오리지널 시리즈를 최신순으로 12개 조회한다.
- `series.isOriginal = true`인 시리즈만 대상으로 한다.
- 활성 시리즈와 활성 크리에이터만 노출한다.
- Response 필드는 `seriesId`, `coverImageUrl`만 포함하고, 최상위 응답 필드명은 `originalSeries`로 한다.
### Feature D. 새로 올라온 오디오
#### Requirements
- 공개된 오디오 콘텐츠를 최신순으로 12개 조회한다.
- 최신순 기준은 `releaseDate desc`, 동률이면 `audioContentId desc`로 한다.
- Response는 공통 오디오 카드 응답을 사용한다.
### Feature E. New & Hot
#### Requirements
- 최대 12개를 표시한다.
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
- 최근 3일 데이터를 기반으로 최종 점수를 산출한다.
- 최종 점수는 `최신성 35% + 조회수 35% + 좋아요 15% + 댓글 수 15%`로 계산한다.
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 최근 3일 `content_id`별 count를 사용한다.
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 외 0.8을 적용한다.
- 19금 노출 정책은 스냅샷 variant로 분리한다.
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
- 19금 노출이 불가능한 사용자와 비회원은 `SAFE` 스냅샷을 조회한다.
- 19금 노출이 가능한 회원은 `ALL` 스냅샷을 조회한다.
- 산출된 스냅샷 데이터가 없으면 lazy로 스케줄/집계 보강을 추가한다.
- Response는 공통 오디오 카드 응답을 사용한다.
#### Edge Cases
- lazy 보강 중에도 즉시 산출 가능한 결과가 없으면 빈 배열로 내려준다.
### Feature F. 무료 오디오
#### Requirements
- 무료 오디오 중 랜덤으로 최대 10개 조회한다.
- 무료 오디오는 `price = 0`인 공개 오디오로 정의한다.
- Response는 공통 오디오 카드 응답을 사용한다.
### Feature G. 포인트 오디오
#### Requirements
- 포인트 사용 가능 오디오 중 랜덤으로 최대 10개 조회한다.
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다.
- Response는 공통 오디오 카드 응답을 사용한다.
### Feature H. 최근 댓글이 많은 오디오
#### Requirements
- 댓글 점수는 `댓글 수 80% + 댓글 최신성 20%`로 계산한다.
- 댓글 최신성 점수는 댓글 작성 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 이상 0을 적용한다.
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
- 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
- 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
- 최대 5개를 표시한다.
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
#### Edge Cases
- 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자 프로필은 노출하지 않는다.
- 최신 댓글 작성자 프로필 이미지가 없으면 기본 프로필 이미지 URL 정책을 적용한다.
### Feature I. 추천 오디오
#### Requirements
- 최대 10개를 표시한다.
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
- 추천 점수는 `조회수 45% + 좋아요 25% + 댓글 수 20% + 최신성 10%`로 계산한다.
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 스냅샷 집계 기간 내 `content_id`별 count를 사용한다.
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 30일 이내 1.1, 그 외 1.0을 적용한다.
- 19금 노출 정책은 New & Hot과 동일하게 `SAFE`, `ALL` 스냅샷 variant로 분리한다.
- Response는 공통 오디오 카드 응답을 사용한다.
---
## 8. API Endpoint
```http
GET /api/v2/audio/recommendations
Authorization: Bearer {accessToken} (optional)
```
- 비회원 조회를 허용한다.
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
- 별도 request query parameter는 정의하지 않는다.
---
## 9. Response Data Class
```kotlin
data class AudioRecommendationsResponse(
val banners: List<AudioBannerResponse>,
val originalSeries: List<OriginalSeriesResponse>,
val latestAudios: List<AudioCardResponse>,
val newAndHotAudios: List<AudioCardResponse>,
val freeAudios: List<AudioCardResponse>,
val pointAudios: List<AudioCardResponse>,
val mostCommentedAudios: List<CommentedAudioResponse>,
val recommendedAudios: List<AudioCardResponse>
)
data class AudioBannerResponse(
val imageUrl: String,
val eventItem: EventItem?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?
)
data class OriginalSeriesResponse(
val seriesId: Long,
val coverImageUrl: String?
)
data class AudioCardResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
)
data class CommentedAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val latestComment: String,
val latestCommentWriterProfileImageUrl: String
)
```
---
## 10. Technical Constraints
### 패키지 구조
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 하위에 둔다.
- Controller: `...adapter.in.web`
- Facade: `...application`
- Response DTO: `...dto`
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 둔다.
- Query service: `...application`
- 점수 정책/domain model: `...domain`
- 조회 port: `...port.out`
- QueryDSL/JPA 구현: `...adapter.out.persistence`
- scheduler: `...adapter.out.scheduler`
- `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다.
- 의존 방향은 `v2.api.content.recommendation -> v2.content.recommendation`만 허용한다.
### V2 공통화/재사용 대상
- `HomeBannerItem`은 메인 홈 전용 DTO가 아니라 여러 추천 화면에서 사용할 배너 응답 구조이므로 `v2.api.common.dto` 계열 공통 DTO로 분리한다.
- `v2.recommendation.adapter.out.persistence.RecommendationSnapshot`: 일 단위 추천 스냅샷 저장 구조
- `v2.recommendation.adapter.out.scheduler.RecommendationSnapshotScheduler`: Redisson 분산 lock이 적용된 스케줄러 패턴
- `v2.recommendation.adapter.out.persistence.CreatorContentViewHistory`: 오디오 상세 페이지 조회 이력 저장 구조
- `v2.recommendation.application.CreatorContentViewHistoryService`: `AudioContentService.getDetail(...)`에서 상세 조회 이력을 기록하는 서비스
- `v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
### 참고할 기존 패턴
- `v2.api.home.adapter.in.web.HomeRecommendationController``v2.api.home.application.HomeRecommendationFacade`는 메인 페이지 Home 탭 전용이므로 직접 재사용하지 않는다.
- 신규 API도 controller가 인증/요청 경계를 담당하고 facade가 도메인 조회 결과를 공개 응답 DTO로 변환하는 계층 분리 방식만 참고한다.
### 스냅샷/스케줄
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 스냅샷 기반 조회를 우선한다.
- 스케줄러는 `@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")` 기준으로 설계한다.
- 다중 서버 환경에서 중복 실행을 막기 위해 기존 Redisson lock 패턴을 따른다.
- 스냅샷 기준 시각은 KST 전날 `23:59:59`를 UTC 변환 없이 KST-local `LocalDateTime`으로 저장한다.
- 스냅샷 집계 window도 KST-local `00:00:00`부터 KST-local `23:59:59`까지를 기준으로 계산한다.
- 19금 노출 영향을 받는 스냅샷 섹션은 visibility variant를 저장한다.
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
- `SAFE``ALL`을 분리하는 이유는 스냅샷 조회 후 19금 콘텐츠를 필터링할 경우 비회원/19금 노출 불가 회원에게 최대 노출 개수를 안정적으로 채우기 어렵기 때문이다.
- 기존 `recommendation_snapshot`을 확장 재사용할지, 콘텐츠 추천 전용 스냅샷 테이블을 만들지는 구현 계획에서 DDL 영향과 enum 확장 범위를 비교해 결정한다.
### 조회 정책
- 모든 오디오 섹션은 활성 콘텐츠, 활성 크리에이터, `duration is not null`, `releaseDate <= now` 조건을 기본으로 한다.
- 성인 콘텐츠는 회원의 `MemberContentPreference`와 본인인증 정책을 반영한다.
- 비회원은 성인 콘텐츠를 제외한다.
- 차단 관계 필터는 기존 v2 홈/크리에이터 채널 조회 패턴을 따른다.
- 조회수 점수의 조회수는 `AudioContent.playCount`가 아니라 `creator_content_view_history`의 상세 페이지 조회 이력 count를 사용한다.
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
---
## 11. Metrics
- API 성공/실패 로그
- 섹션별 응답 개수
- 스냅샷 갱신 성공/실패 로그
- 스냅샷 갱신 대상 개수
- lazy 보강 발생 횟수
- 빈 섹션 목록
---
## 12. Open Questions
- 없음

View File

@@ -0,0 +1,614 @@
# 메인 콘텐츠 전체 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/audio/contents`로 메인 콘텐츠 전체 탭의 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.all` 조립 계층에 둔다. 전체 탭 조회 service, 요청 보정 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.content.all` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 `ContentSort`, `SeriesPublishedDaysOfWeek`, 콘텐츠 추천/채널 오디오/채널 시리즈 조회 패턴을 재사용하되 공개 응답 DTO는 전체 탭 전용으로 최소 필드만 둔다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/audio/contents`
- 인증 정책: 비회원 조회 가능. 인증 회원이면 `MemberContentPreferenceService`의 성인 콘텐츠 노출 가능 여부를 반영한다.
- 응답 wrapper: `ApiResponse.ok(...)`
- 요청 query parameter:
- `type`: `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`; 기본값 `AUDIO`
- `sort`: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`; 기본값 `LATEST`
- `dayOfWeek`: `type=SERIES`에서만 적용. `SeriesPublishedDaysOfWeek``SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`
- `page`: 0부터 시작. 기본값 `0`
- `size`: 기본값 `20`, 최소 `20`, 최대 `50`
- `sort`가 invalid이거나 `OWNED`이면 `LATEST`로 fallback한다.
- `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다.
- `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다.
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
- `AUDIO`, `FREE`, `POINT``audios`만 채우고 `series`는 빈 배열로 내려준다.
- `SERIES`, `ORIGINAL``series`만 채우고 `audios`는 빈 배열로 내려준다.
- 공개 오디오 조건: `audioContent.isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터.
- 공개 시리즈 조건: `series.isActive == true`, 활성 크리에이터. 성인 콘텐츠 노출 불가이면 `series.isAdult == false`.
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠/시리즈는 제외한다.
- 신규 Entity와 DDL은 작성하지 않는다.
- `SecurityConfig`에는 `GET /api/v2/audio/contents` permitAll 설정을 추가한다.
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
### 신규 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt`
- 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/domain/MainContentAllQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
### 기존 설정/회귀
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.all.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
data class MainContentAllTabResponse(
val type: MainContentAllType,
val totalCount: Int,
val audios: List<MainContentAudioResponse>,
val series: List<MainContentSeriesResponse>,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: MainContentAll): MainContentAllTabResponse {
return MainContentAllTabResponse(
type = tab.type,
totalCount = tab.totalCount,
audios = tab.audios.map(MainContentAudioResponse::from),
series = tab.series.map(MainContentSeriesResponse::from),
sort = tab.sort,
dayOfWeek = tab.dayOfWeek,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class MainContentAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
) {
companion object {
fun from(audio: MainContentAllAudio): MainContentAudioResponse {
return MainContentAudioResponse(
audioContentId = audio.audioContentId,
title = audio.title,
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname
)
}
}
}
data class MainContentSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val creatorNickname: String,
@JsonProperty("isOriginal")
val isOriginal: Boolean,
@JsonProperty("isAdult")
val isAdult: Boolean
) {
companion object {
fun from(series: MainContentAllSeries): MainContentSeriesResponse {
return MainContentSeriesResponse(
seriesId = series.seriesId,
title = series.title,
coverImageUrl = series.coverImageUrl,
creatorNickname = series.creatorNickname,
isOriginal = series.isOriginal,
isAdult = series.isAdult
)
}
}
}
```
---
## 3. Domain / Port 초안
```kotlin
package kr.co.vividnext.sodalive.v2.content.all.domain
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
enum class MainContentAllType {
AUDIO,
SERIES,
ORIGINAL,
FREE,
POINT
}
data class MainContentAll(
val type: MainContentAllType,
val totalCount: Int,
val audios: List<MainContentAllAudio>,
val series: List<MainContentAllSeries>,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?,
val page: MainContentPage,
val hasNext: Boolean
)
data class MainContentAllAudio(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val isOriginalSeries: Boolean,
val creatorNickname: String
)
data class MainContentAllSeries(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val creatorNickname: String,
val isOriginal: Boolean,
val isAdult: Boolean
)
data class MainContentPage(
val page: Int,
val size: Int
) {
val offset: Long = page.toLong() * size
}
```
```kotlin
package kr.co.vividnext.sodalive.v2.content.all.port.out
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
import java.time.LocalDateTime
interface MainContentAllQueryPort {
fun countAudios(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
): Int
fun findAudios(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
): List<MainContentAllAudio>
fun countSeries(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyOriginal: Boolean = false,
dayOfWeek: SeriesPublishedDaysOfWeek? = null
): Int
fun findSeries(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyOriginal: Boolean = false,
dayOfWeek: SeriesPublishedDaysOfWeek? = null,
locale: String
): List<MainContentAllSeries>
}
```
---
### Phase 1: 요청 보정 정책과 도메인 모델
- [x] **Task 1.1: 전체 탭 타입, page, 요청 보정 policy 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt`
- RED: 다음 테스트를 먼저 작성한다.
```kotlin
@Test
fun shouldResolveDefaultsAndFallbacks() {
val policy = MainContentAllQueryPolicy()
assertEquals(MainContentAllType.AUDIO, policy.resolveType(null))
assertEquals(MainContentAllType.AUDIO, policy.resolveType("UNKNOWN"))
assertEquals(ContentSort.LATEST, policy.resolveSort(null))
assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN"))
assertEquals(ContentSort.LATEST, policy.resolveSort("OWNED"))
assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR"))
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = null, size = null))
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = -1, size = 1))
assertEquals(MainContentPage(page = 2, size = 50), policy.createPage(page = 2, size = 100))
}
```
- RED: `type=SERIES`일 때만 요일이 적용되는 테스트를 작성한다.
```kotlin
@Test
fun shouldResolveDayOfWeekOnlyForSeriesType() {
val policy = MainContentAllQueryPolicy()
assertEquals(SeriesPublishedDaysOfWeek.MON, policy.resolveDayOfWeek(MainContentAllType.SERIES, "MON"))
assertEquals(SeriesPublishedDaysOfWeek.RANDOM, policy.resolveDayOfWeek(MainContentAllType.SERIES, "RANDOM"))
assertNull(policy.resolveDayOfWeek(MainContentAllType.SERIES, "INVALID"))
assertNull(policy.resolveDayOfWeek(MainContentAllType.ORIGINAL, "MON"))
assertNull(policy.resolveDayOfWeek(MainContentAllType.AUDIO, "MON"))
}
```
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest`
- GREEN: `resolveType(sort: String?)`, `resolveSort(sort: String?)`, `resolveDayOfWeek(type, dayOfWeek)`, `createPage(page, size)`, `limitItems`, `hasNext`를 최소 구현한다.
- REFACTOR: `OWNED` fallback과 invalid `dayOfWeek` fallback이 400으로 흐르지 않도록 controller에서 enum 직접 binding을 사용하지 않는 설계를 확인한다.
- 기대 결과: 요청 보정 정책이 순수 단위 테스트로 고정된다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 실행 시 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage` 미구현 컴파일 실패를 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공으로 기본값/fallback/page/hasNext 정책을 확인했다.
- [x] **Task 1.2: 전체 탭 domain model 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
- RED: `MainContentAllTabResponse.from(...)`이 최소 필드만 변환하는 테스트를 작성한다.
```kotlin
@Test
fun shouldMapDomainToResponseWithMinimalFields() {
val response = MainContentAllTabResponse.from(
MainContentAll(
type = MainContentAllType.SERIES,
totalCount = 1,
audios = emptyList(),
series = listOf(
MainContentAllSeries(
seriesId = 10L,
title = "시리즈",
coverImageUrl = "https://cdn/series.jpg",
creatorNickname = "creator",
isOriginal = true,
isAdult = false
)
),
sort = ContentSort.LATEST,
dayOfWeek = SeriesPublishedDaysOfWeek.MON,
page = MainContentPage(0, 20),
hasNext = false
)
)
assertEquals(MainContentAllType.SERIES, response.type)
assertEquals(1, response.totalCount)
assertTrue(response.audios.isEmpty())
assertEquals("creator", response.series.first().creatorNickname)
assertEquals(SeriesPublishedDaysOfWeek.MON, response.dayOfWeek)
}
```
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest`
- GREEN: `MainContentAll`, `MainContentAllAudio`, `MainContentAllSeries`, response DTO를 최소 구현한다.
- REFACTOR: `MainContentAudioResponse`에 `duration`, `MainContentSeriesResponse`에 `publishedDaysOfWeek`, `isProceeding`, `contentCount`, `paidContentCount`가 없는지 소스와 테스트에서 확인한다.
- 기대 결과: 공개 응답 계약이 PRD와 일치한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 실행 시 `MainContentAllTabResponse`, `MainContentAll` 계열 도메인 모델 미구현 컴파일 실패를 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공으로 도메인→응답 DTO 변환과 boolean `is*` JSON 필드명을 확인했다.
### Phase 2: API 조립 계층
- [x] **Task 2.1: facade 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt`
- RED: facade가 문자열 query parameter를 그대로 query service에 넘기고 응답 DTO로 변환하는 테스트를 작성한다.
```kotlin
@Test
fun shouldDelegateToQueryServiceAndMapResponse() {
val service = FakeMainContentAllQueryService()
val facade = MainContentAllFacade(service)
val response = facade.getContents(
type = "FREE",
sort = "PRICE_LOW",
dayOfWeek = "MON",
page = 1,
size = 30,
member = null
)
assertEquals("FREE", service.requestedType)
assertEquals("PRICE_LOW", service.requestedSort)
assertEquals("MON", service.requestedDayOfWeek)
assertEquals(MainContentAllType.FREE, response.type)
}
```
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest`
- GREEN: facade는 query service 호출과 `MainContentAllTabResponse.from(...)` 변환만 담당한다.
- REFACTOR: facade에 정렬, 요일, DB 조회 정책이 들어가지 않도록 확인한다.
- 기대 결과: API 조립 계층과 도메인 조회 계층의 책임이 분리된다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllFacade`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
- GREEN: 동일 명령 성공으로 facade가 문자열 query parameter와 `Member?`를 query service에 그대로 전달하고 응답 DTO로 변환함을 확인했다.
- [x] **Task 2.2: controller와 보안 설정 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt`
- RED: `GET /api/v2/audio/contents`가 비회원에게 `200 OK`를 반환하고 `type` 기본값을 service까지 전달하는 MockMvc 테스트를 작성한다.
```kotlin
@Test
fun shouldAllowAnonymousAndUseDefaultType() {
mockMvc.get("/api/v2/audio/contents")
.andExpect {
status { isOk() }
jsonPath("$.data.type") { value("AUDIO") }
jsonPath("$.data.sort") { value("LATEST") }
}
}
```
- RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=1&size=30`이 query parameter를 facade로 전달하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest`
- GREEN: `@RequestMapping("/api/v2/audio/contents")`, `@RequestParam type: String?`, `sort: String?`, `dayOfWeek: String?`, `page: Int?`, `size: Int?`, optional `member: Member?`로 controller를 구현한다.
- GREEN: `SecurityConfig`에 `antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()`을 추가한다.
- REFACTOR: `ContentSort`와 `SeriesPublishedDaysOfWeek`를 controller parameter에 직접 binding하지 않는지 확인한다.
- 기대 결과: 공개 endpoint, 비회원 허용, invalid parameter fallback을 위한 controller 계약이 고정된다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllController` 미구현 컴파일 실패를 확인했다.
- GREEN: 동일 명령 성공으로 비회원 `GET /api/v2/audio/contents` 200 OK, query parameter/member 전달, `SecurityConfig` permitAll 설정을 확인했다.
### Phase 3: 조회 service와 port
- [x] **Task 3.1: query port와 service 분기 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
- RED: `AUDIO`, `FREE`, `POINT` type이 audio count/list port를 올바른 필터로 호출하는 fake port 테스트를 작성한다.
```kotlin
@Test
fun shouldQueryAudiosByType() {
val port = FakeMainContentAllQueryPort()
val service = createService(port)
service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null)
assertEquals("audio", port.lastListKind)
assertTrue(port.lastOnlyFree)
assertFalse(port.lastOnlyPointAvailable)
}
```
- RED: `SERIES` type이 `dayOfWeek=MON`을 series count/list port에 전달하고 `ORIGINAL` type은 `onlyOriginal=true`, `dayOfWeek=null`로 호출하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
- GREEN: service는 policy로 type/sort/day/page를 보정하고, `type`에 따라 port 메서드를 호출한다.
- GREEN: `limit = page.size + 1`로 조회한 뒤 `policy.limitItems(...)`와 `policy.hasNext(...)`를 적용한다.
- REFACTOR: service에는 QueryDSL 조건식이나 response DTO 변환을 두지 않는다.
- 기대 결과: type별 조회 분기, 전체 개수, `hasNext`, fallback 정책이 service 단위 테스트로 고정된다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
- GREEN: 동일 명령 성공으로 `AUDIO/FREE/POINT` audio 분기, `SERIES/ORIGINAL` series 분기, `limit = size + 1`, `hasNext` 처리를 확인했다.
- [x] **Task 3.2: 성인 콘텐츠 노출 정책 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
- RED: 비회원이면 `canViewAdultContent=false`, 회원이면 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 port에 전달하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
- GREEN: 기존 `AudioRecommendationQueryService`와 같은 방식으로 성인 콘텐츠 노출 가능 여부를 계산한다.
- REFACTOR: 회원 id는 `member?.id`만 port에 전달하고, port/repository에서 차단 관계 제외 조건을 처리하게 둔다.
- 기대 결과: 비회원/회원 성인 콘텐츠 정책이 기존 v2 추천 탭과 일치한다.
- 검증 기록:
- RED: service 테스트 추가 후 `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 포함 Phase 2-3 테스트 명령 성공으로 비회원 `canViewAdultContent=false`, 회원 `MemberContentPreferenceService.canViewAdultContent(member)` 결과 전달을 확인했다.
### Phase 4: QueryDSL repository
- [x] **Task 4.1: audio count/list repository 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt`
- 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: `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를 구현한다.
- GREEN: 인기순은 `orders.isActive == true`인 주문의 `orders.can.sum().coalesce(0)`만 사용하고 `orders.point`는 더하지 않는다.
- GREEN: `isFirstContent`는 크리에이터별 전체 공개 오디오 중 가장 먼저 공개된 콘텐츠인지로 계산한다.
- GREEN: `isOriginalSeries`는 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 계산하고 시리즈 미소속이면 `false`로 내려준다.
- REFACTOR: CDN URL 변환은 `toCdnUrl(cloudFrontHost)` 패턴을 사용한다.
- 기대 결과: 오디오/무료/포인트 조회의 필터, count, 정렬, 카드 필드가 repository 테스트로 고정된다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다.
- GREEN: 동일 명령 성공으로 공개 오디오 조건, 성인/차단 제외, 무료/포인트 필터, 가격/인기 정렬, CDN URL, 첫 콘텐츠, 오리지널 시리즈 여부를 확인했다.
- [x] **Task 4.2: series count/list repository 구현**
- Files:
- Modify: `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: `SERIES` 조회가 활성 시리즈와 활성 크리에이터만 반환하고, 비회원은 성인 시리즈를 제외하며, 차단 관계 크리에이터의 시리즈를 제외하는 테스트를 작성한다.
- RED: `dayOfWeek=MON`이면 `series.publishedDaysOfWeek`에 `MON`이 포함된 시리즈만 반환하고 `dayOfWeek=RANDOM`이면 `RANDOM` 포함 시리즈만 반환하는 테스트를 작성한다.
- RED: `ORIGINAL` 조회가 `series.isOriginal == true`만 반환하고 `dayOfWeek`는 적용하지 않는 테스트를 작성한다.
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 시리즈 정렬 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
- GREEN: `DefaultCreatorChannelSeriesQueryRepository.findSeriesIds(...)` 패턴을 참고해 시리즈 id 선조회 후 row를 원래 정렬 순서대로 조립한다.
- GREEN: 시리즈 정렬 대표값은 공개 오디오 기준 `max(releaseDate)`, `max(price)`, `min(price)`, `orders.can.sum()`을 사용한다.
- GREEN: 시리즈 응답 필드는 `seriesId`, `title`, `coverImageUrl`, `creatorNickname`, `isOriginal`, `isAdult`만 조립한다.
- REFACTOR: `MainContentSeriesResponse`에서 제외된 연재 요일/연재 상태/콘텐츠 통계 필드를 조회 응답 조립용으로 불필요하게 projection하지 않는다.
- 기대 결과: 시리즈/오리지널 조회의 요일 필터, count, 정렬, 최소 응답 필드가 repository 테스트로 고정된다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 repository 미구현 컴파일 실패를 확인했다.
- GREEN: 동일 명령 성공으로 활성 시리즈/크리에이터 조건, 성인/차단 제외, 요일 필터, ORIGINAL 필터, 대표 공개 오디오 기준 정렬, 최소 시리즈 응답 필드를 확인했다.
### Phase 5: 공개 API 통합 검증
- [x] **Task 5.1: controller-to-repository 통합 테스트 작성**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
- RED: Spring context 기반으로 `GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20`가 `audios`, `totalCount`, `sort`, `page`, `size`, `hasNext`를 반환하고 `series`는 빈 배열인 테스트를 작성한다.
- RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=0&size=20`가 `series`, `dayOfWeek=MON`, `audios=[]`를 반환하는 테스트를 작성한다.
- RED: `GET /api/v2/audio/contents?type=ORIGINAL&dayOfWeek=MON`이 `dayOfWeek=null`로 응답하고 오리지널 시리즈만 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
- GREEN: 테스트 fixture에 공개/비공개/성인/무료/포인트/요일별 시리즈/오리지널 시리즈/차단 관계 데이터를 구성하고 end-to-end 응답을 통과시킨다.
- REFACTOR: controller, facade, service, repository 경계가 단방향 의존을 유지하는지 import를 확인한다.
- 기대 결과: 실제 HTTP 경로에서 PRD의 주요 응답 계약이 검증된다.
- 검증 기록:
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 `AUDIO`, `SERIES dayOfWeek=MON`, `ORIGINAL dayOfWeek 무시` HTTP 통합 경로를 확인했다.
- 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으므로, 별도 production 수정은 없었다.
- [x] **Task 5.2: 회귀 테스트와 포맷 검증**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/**`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/**`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Modify: `docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md`
- RED: 이 task는 신규 동작 추가가 아니라 전체 회귀 검증 task이므로 별도 실패 테스트를 만들지 않는다.
- TDD 예외 사유: 앞선 task에서 기능별 실패 테스트를 작성했고, 이 task는 전체 suite와 문서 검증 기록 누적이 목적이다.
- 대체 검증 방법:
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'`
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`
- `./gradlew ktlintCheck`
- `git diff --check`
- `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all`
- GREEN: 위 명령이 모두 성공하고, 응답 DTO에 제거 대상 필드가 남아 있지 않음을 확인한다.
- REFACTOR: 검증 결과를 이 문서 하단 `검증 기록`에 누적한다.
- 기대 결과: 신규 API 패키지 테스트와 포맷 검증이 완료된다.
- 검증 기록:
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공.
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
- GREEN: `./gradlew ktlintCheck` 성공.
- GREEN: `git diff --check` 성공.
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
---
## 4. 실행 명령
- 정책 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest`
- DTO 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest`
- Facade 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest`
- Controller 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest`
- Service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
- Repository 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
- End-to-end 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
- 전체 신규 패키지 테스트: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`
- 포맷 검증: `./gradlew ktlintCheck`
- 문서 변경 후 명령 유효성 확인: `./gradlew tasks --all`
---
## 5. 검증 기록
- 2026-06-25 Phase 1-3 RED/GREEN 검증
- RED: Phase 1 정책/DTO 테스트 추가 후 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage`, `MainContentAllTabResponse`, `MainContentAll` 계열 모델 미구현 컴파일 실패를 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공.
- RED: Phase 2-3 facade/controller/service 테스트 추가 후 `MainContentAllFacade`, `MainContentAllController`, `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 성공.
- 보강: `MainContentAllQueryServiceTest`에서 `AUDIO`, `FREE`, `POINT` audio 분기를 각각 독립 테스트로 검증하도록 분리했다.
- 참고: Phase 4 repository 구현 전이므로 Spring 전체 context에서 `MainContentAllQueryPort` 실제 bean 연결은 아직 범위 밖이다.
- 참고: 실제 머지/배포 전에는 Phase 4 repository adapter bean과 Phase 5 end-to-end 테스트를 구현한 뒤 Spring 전체 context 검증을 다시 수행해야 한다.
- 2026-06-25 Phase 4 RED/GREEN 검증
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다.
- GREEN: 동일 명령 성공으로 audio/series count/list repository의 공개 조건, 성인/차단 제외, FREE/POINT/ORIGINAL/dayOfWeek 필터, 정렬, CDN URL, 최소 응답 필드를 확인했다.
- 2026-06-25 Phase 4 코드 리뷰 및 검증
- 리뷰: `DefaultMainContentAllQueryRepository.findSeries(...)`가 `locale` 파라미터를 받지만 `SeriesTranslation`을 조회하지 않아, PRD의 언어코드 기반 시리즈 제목 fallback 요구사항을 충족하지 못하는 것을 확인했다.
- 리뷰: `ContentSort.LATEST`의 오디오/시리즈 정렬에 `price` 대표값이 보조 정렬로 포함되어 있어, PRD의 `releaseDate desc, id desc` 기준과 다른 순서가 나올 수 있음을 확인했다.
- RED: `shouldSortAudiosByLatestReleaseDateAndIdOnly` 추가 후 `expected: <[2, 1]> but was: <[1, 2]>` 실패로 audio `LATEST`가 같은 공개일에서 price desc를 우선하는 문제를 재현했다.
- RED: `shouldFindSeriesWithTranslatedTitleFallback` 추가 후 `expected: <Translated Series> but was: <origin-translated-series>` 실패로 series locale 번역 미적용 문제를 재현했다.
- RED: `shouldSortSeriesByPublicAudioRepresentatives` 보강 후 `expected: <[6, 5, 4]> but was: <[5, 4, 6]>` 실패로 series `LATEST`가 같은 대표 공개일에서 highestPrice desc를 우선하는 문제를 재현했다.
- GREEN: `findSeries(...)`에 `SeriesTranslation` left join과 blank fallback을 추가하고, audio/series `LATEST` 보조 정렬에서 price 대표값을 제거했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 성공.
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
- GREEN: `./gradlew ktlintCheck` 성공.
- GREEN: `git diff --check` 성공.
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, DTO 테스트의 부재 검증만 검색되었다.
- 확인: 위 리뷰 항목 2건은 보강 테스트와 구현 수정으로 해결했다.
- 2026-06-25 Phase 5 공개 API 통합 검증
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 실제 HTTP 경로에서 `AUDIO`는 `audios`와 빈 `series`, `SERIES dayOfWeek=MON`은 `series`와 빈 `audios`, `ORIGINAL dayOfWeek=MON`은 `dayOfWeek=null`과 오리지널 시리즈만 반환함을 확인했다.
- 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으며, Phase 5에서 production 코드는 변경하지 않았다.
- 참고: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'`와 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`를 동시에 실행했을 때 test result XML 파일 쓰기 충돌이 한 번 발생했다. 동일 명령을 순차 재실행해 두 테스트 모두 성공함을 확인했다.
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공.
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
- GREEN: `./gradlew ktlintCheck` 성공.
- GREEN: `git diff --check` 성공.
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.

View File

@@ -0,0 +1,333 @@
# PRD: 메인 콘텐츠 전체 탭 API
## 1. Overview
메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 기존 메인 콘텐츠 추천 탭 API는 여러 추천 섹션을 한 번에 조립하지만, 전체 탭은 사용자가 선택한 구분별 전체 콘텐츠 목록과 전체 개수, 정렬 상태, 페이징 상태를 제공해야 한다.
- 기존 크리에이터 채널 오디오/시리즈 탭 API는 특정 크리에이터 기준 조회라서, 전체 탭처럼 차단 관계가 아닌 모든 크리에이터의 공개 콘텐츠를 대상으로 하기 어렵다.
- 정렬 기준은 기존 공용 `ContentSort` enum과 의미를 공유해야 하며, 인기순 매출 산식은 포인트 사용액을 제외한 `orders.can` 합계로 명확히 고정해야 한다.
- V2 패키지에는 API 조립 계층과 도메인 조회 계층 분리, 공통 오디오 카드 DTO, 차단/성인 콘텐츠/공개 콘텐츠 필터, 시리즈 정렬 패턴이 이미 있으므로 재사용 범위를 먼저 명시해야 한다.
---
## 3. Goals
- 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다.
- 공개된 콘텐츠만 조회한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
- 비회원은 19금 콘텐츠를 노출하지 않는다.
- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다.
- 전체 콘텐츠 개수와 페이징 목록을 함께 응답한다.
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
- `SERIES` 구분은 legacy 시리즈 메인 요일별 조회와 동일하게 요일 선택을 지원한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
---
## 4. Non-Goals
- 기존 `content.main.tab.*` legacy API 스키마를 변경하지 않는다.
- 기존 메인 콘텐츠 추천 탭 API와 랭킹 탭 API의 공개 스키마를 변경하지 않는다.
- 기존 크리에이터 채널 오디오/시리즈 탭 API의 endpoint, 응답 필드, 인증 정책을 변경하지 않는다.
- 신규 스냅샷 테이블이나 배치 집계는 이번 범위에 포함하지 않는다.
- 개인화 추천, 랜덤 노출, 운영자 고정/제외 기능은 포함하지 않는다.
- 구매, 대여, 소장, 포인트 결제 API는 포함하지 않는다.
- `ContentSort` enum에 신규 값을 추가하지 않는다.
- `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않는다.
---
## 5. Target Users
- 회원: 메인 콘텐츠 전체 탭에서 원하는 구분의 공개 콘텐츠를 정렬해 탐색하는 사용자
- 비회원: 인증 없이 조회 가능한 공개 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 전체 탭의 구분, 전체 개수, 정렬 상태, 페이징 목록을 단일 계약으로 구성하려는 클라이언트
---
## 6. User Stories
- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다.
- 사용자는 선택한 요일의 시리즈 목록을 보고 싶다.
- 사용자는 오리지널 시리즈만 따로 보고 싶다.
- 사용자는 무료 오디오만 따로 보고 싶다.
- 사용자는 포인트를 사용할 수 있는 오디오만 따로 보고 싶다.
- 앱 클라이언트는 현재 적용된 구분, 정렬, page, size, hasNext를 응답에서 확인해 화면 상태와 서버 결과를 맞추고 싶다.
---
## 7. Core Features
### Feature A. 메인 콘텐츠 전체 탭 조회 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/audio/contents`를 기본안으로 한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 비회원 조회를 허용한다.
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
- 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다.
- `type` 값은 아래 enum으로 정의한다.
- `AUDIO`: 오디오
- `SERIES`: 시리즈
- `ORIGINAL`: 오리지널
- `FREE`: 무료
- `POINT`: 포인트
- `type`을 보내지 않으면 `AUDIO`를 기본값으로 사용한다.
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
- 전체 탭에서 지원하는 정렬 값은 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`다.
- `OWNED`가 들어오면 전체 탭 요구사항에 없는 정렬이므로 `LATEST`로 fallback한다.
- `dayOfWeek``type=SERIES`일 때만 적용한다.
- `dayOfWeek` 값은 legacy `SeriesMainController.getDayOfWeekSeriesList(...)`와 동일하게 `SeriesPublishedDaysOfWeek` enum 값을 사용한다.
- `dayOfWeek` 지원 값은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`이다.
- `dayOfWeek`를 보내지 않으면 전체 요일의 시리즈를 조회한다.
- `type``SERIES`가 아니면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서는 `null`로 내려준다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 fallback한다.
- `size`가 20보다 작으면 `20`으로 fallback한다.
- `size`가 50보다 크면 `50`으로 fallback한다.
- 응답에는 같은 필터 조건의 전체 콘텐츠 개수와 현재 page 목록을 포함한다.
- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
#### Edge Cases
- 공개된 콘텐츠가 없으면 `totalCount``0`, 목록은 빈 배열, `hasNext``false`로 내려준다.
- 요청한 page 범위에 콘텐츠가 없으면 목록은 빈 배열, `hasNext``false`로 내려주되 `totalCount`는 전체 개수를 유지한다.
- 특정 구분에서 지원하지 않는 응답 목록 필드는 빈 배열로 내려준다.
### Feature B. 공통 공개/차단/성인 콘텐츠 정책
#### Requirements
- 모든 구분은 공개 가능한 콘텐츠만 조회한다.
- 오디오 콘텐츠는 `isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터 조건을 만족해야 한다.
- 시리즈는 `isActive == true`, 활성 크리에이터 조건을 만족해야 한다.
- 시리즈의 콘텐츠 통계와 정렬 대표값은 공개 가능한 오디오 콘텐츠만 기준으로 계산한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 오디오/시리즈는 제외한다.
- 비회원은 19금 오디오/시리즈를 제외한다.
- 인증 회원은 `MemberContentPreferenceService`의 기존 성인 콘텐츠 노출 가능 여부를 반영한다.
- 이미지 경로는 기존 `v2.common.domain.CdnUrlExtensions`의 CDN URL 변환 패턴을 따른다.
#### Edge Cases
- 차단 관계가 있는 크리에이터의 시리즈에 속한 오디오도 조회 대상에서 제외한다.
- 예약 공개 전 오디오는 모든 구분의 목록, 개수, 정렬 대표값, 매출 집계에서 제외한다.
- 비활성 크리에이터의 콘텐츠는 모든 구분에서 제외한다.
### Feature C. 오디오 구분
#### Requirements
- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
- 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다.
#### Edge Cases
- 시리즈에 속하지 않은 오디오도 목록에 포함한다.
- 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다.
### Feature D. 시리즈 구분
#### Requirements
- `type=SERIES`는 차단 관계가 아닌 모든 크리에이터의 요일별 시리즈 콘텐츠를 조회한다.
- 활성 시리즈를 조회 대상으로 한다.
- `dayOfWeek`가 있으면 `series.publishedDaysOfWeek`에 해당 값이 포함된 시리즈만 조회한다.
- 요일 필터는 legacy `GET /audio-content/series/main/day-of-week`와 동일하게 query parameter 이름 `dayOfWeek``SeriesPublishedDaysOfWeek` enum 값을 사용한다.
- `dayOfWeek`가 없으면 요일 조건 없이 전체 시리즈를 조회한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 시리즈 개수다.
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
- 응답 최상위 `dayOfWeek`에는 실제 적용된 요일 값을 내려준다.
#### Edge Cases
- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함할 수 있다.
- `dayOfWeek=RANDOM` 요청은 legacy와 동일하게 `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 시리즈만 조회한다.
- `dayOfWeek`가 지원 enum 값이 아니면 400 오류 대신 요일 조건을 적용하지 않는 fallback을 기본안으로 한다.
### Feature E. 오리지널 구분
#### Requirements
- `type=ORIGINAL`은 차단 관계가 아닌 모든 크리에이터의 `isOriginal == true`인 시리즈를 조회한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `SERIES`와 동일하다.
- 단, `dayOfWeek` 요일 필터는 `type=ORIGINAL`에 적용하지 않는다.
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
#### Edge Cases
- 오리지널 시리즈에 공개 가능한 오디오 콘텐츠가 없어도 활성 시리즈이면 목록에 포함한다.
- 19금 오리지널 시리즈는 조회자의 성인 콘텐츠 노출 가능 여부를 따른다.
### Feature F. 무료 구분
#### Requirements
- `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다.
- 무료 오디오는 `price == 0`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases
- 무료 콘텐츠의 `PRICE_HIGH``PRICE_LOW`는 가격이 모두 0일 수 있으므로 2차/3차 정렬인 `releaseDate desc`, `id desc`가 실제 순서를 결정할 수 있다.
### Feature G. 포인트 구분
#### Requirements
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases
- 포인트 사용 가능 여부는 결제 가능 여부 필터일 뿐이며, 인기순 매출 산식에는 포인트 사용액을 포함하지 않는다.
### Feature H. 콘텐츠 정렬
#### Requirements
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
- 공개 요청/응답 값은 다음을 사용한다.
- `LATEST`: 최신순, 기본값
- `POPULAR`: 인기순
- `PRICE_HIGH`: 높은 가격순
- `PRICE_LOW`: 낮은 가격순
- `LATEST``releaseDate desc`, `id desc` 순으로 정렬한다.
- `POPULAR`은 인기순 매출 내림차순, `releaseDate desc`, `id desc` 순으로 정렬한다.
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 한다.
- 인기순 매출에는 포인트 사용액(`orders.point`)을 포함하지 않는다.
- 인기순 매출에는 `orders.isActive == true`인 주문만 포함한다.
- `PRICE_HIGH``price desc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
- `PRICE_LOW``price asc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
- 시리즈 정렬에서 `releaseDate`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다.
- 시리즈 정렬에서 `price desc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 높은 가격을 대표값으로 사용한다.
- 시리즈 정렬에서 `price asc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 낮은 가격을 대표값으로 사용한다.
- 시리즈 인기순 매출은 시리즈에 속한 공개 오디오 콘텐츠의 `orders.can` 합계를 사용한다.
#### Edge Cases
- 매출이 없는 오디오 또는 시리즈의 인기순 매출값은 0으로 처리한다.
- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다.
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
---
## 8. API Endpoint
```http
GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20
Authorization: Bearer {accessToken} (optional)
```
- 비회원 조회를 허용한다.
- `SecurityConfig``GET /api/v2/audio/contents` permitAll 설정을 추가한다.
- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다.
- `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다.
- `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다.
- 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20`
- `page`, `size`는 기존 크리에이터 채널 오디오/시리즈 탭과 같은 보정 정책을 따른다.
---
## 9. Response Data Class
```kotlin
data class MainContentAllTabResponse(
val type: MainContentAllType,
val totalCount: Int,
val audios: List<MainContentAudioResponse>,
val series: List<MainContentSeriesResponse>,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class MainContentAllType {
AUDIO,
SERIES,
ORIGINAL,
FREE,
POINT
}
data class MainContentAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
)
data class MainContentSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val creatorNickname: String,
@JsonProperty("isOriginal")
val isOriginal: Boolean,
@JsonProperty("isAdult")
val isAdult: Boolean
)
```
---
## 10. Technical Constraints
### 패키지 구조
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.all` 하위에 둔다.
- Controller: `...adapter.in.web`
- Facade: `...application`
- Response DTO: `...dto`
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.all` 하위에 둔다.
- Query service: `...application`
- 조회 정책/domain model: `...domain`
- 조회 port: `...port.out`
- QueryDSL/JPA 구현: `...adapter.out.persistence`
- 의존 방향은 `v2.api.content.all -> v2.content.all`만 허용한다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
### V2 공통화/재사용 대상
- `v2.common.domain.ContentSort`: 정렬 enum 재사용
- `creator.admin.content.series.SeriesPublishedDaysOfWeek`: legacy와 같은 요일 query parameter enum 재사용
- `content.series.main.SeriesMainController.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 API 계약 참고
- `content.series.ContentSeriesService.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 service 흐름 참고
- `v2.api.content.recommendation.adapter.in.web.AudioRecommendationController`: 비회원 허용 controller와 `ApiResponse.ok(...)` 패턴
- `v2.api.content.recommendation.application.AudioRecommendationFacade`: API 조립 계층에서 domain 결과를 response DTO로 변환하는 패턴
- `v2.content.recommendation.application.AudioRecommendationQueryService`: 회원 성인 콘텐츠 노출 가능 여부 계산과 전체 추천 조회 service 흐름
- `v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepository`: 전체 공개 오디오 조회, 차단 크리에이터 제외, CDN URL 변환, `AudioCard` 조립 패턴
- `v2.api.content.recommendation.dto.AudioCardResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
- `v2.api.creator.channel.series.dto.CreatorChannelSeriesResponse`: 시리즈 응답 필드와 `JsonProperty` 네이밍 패턴 참고
- `v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy`: `sort`, `page`, `size` fallback 정책 참고
- `v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepository`: 시리즈 정렬 대표값, 시리즈 콘텐츠 통계, `orders.can` 매출 합산 패턴 참고
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
- `MemberContentPreferenceService`: 성인 콘텐츠 노출 가능 여부 판단
- `LangContext`: 시리즈 제목 다국어 처리
### 구현 주의사항
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
- 기존 채널 오디오/시리즈 탭의 `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않으므로 전체 탭 policy에서 제외하거나 `LATEST`로 fallback한다.
- `POPULAR` 정렬은 기존 채널 탭 코드와 유사하되, 명시적으로 `orders.point`를 더하지 않고 `orders.can`만 집계한다.
- 오디오와 시리즈가 다른 응답 item 구조를 가지므로 최상위 응답은 `audios``series`를 분리한다.
- 신규 Entity나 DDL은 필요하지 않다.
---
## 11. Metrics
- 전체 탭 API 성공/실패 건수
- 전체 탭 API 응답 시간
- `type`별 조회 건수
- `sort`별 조회 건수
- 추가 로딩 요청 건수
---
## 12. Open Questions
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.

View File

@@ -0,0 +1,36 @@
-- MySQL 메인 홈 팔로잉 탭 최근 소식 inbox 테이블
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
create table home_following_news_inbox (
id bigint not null auto_increment comment '팔로잉 최근 소식 inbox ID',
member_id bigint not null comment '수신 회원 ID(member.id)',
creator_id bigint not null comment '소식 발신 크리에이터 회원 ID(member.id)',
news_type varchar(30) not null comment '소식 타입(CREATOR_RANKING, CONTENT_RANKING, COMMUNITY_POST, AUDIO_CONTENT, PHOTO_CONTENT)',
source_key varchar(200) not null comment '중복 방지용 원천 소식 식별자',
target_id bigint not null comment '터치 액션 대상 ID',
occurred_at_utc timestamp not null comment '소식 발생 시각(UTC)',
visible_from_at_utc timestamp not null comment '소식 노출 시작 시각(UTC)',
creator_nickname varchar(100) not null comment '소식 생성 시점 크리에이터 닉네임',
creator_profile_image_path varchar(500) null comment '소식 생성 시점 크리에이터 프로필 이미지 path',
title varchar(255) not null comment '소식 제목',
body varchar(1000) not null comment '소식 본문',
thumbnail_image_path varchar(500) null comment '소식 썸네일 이미지 path',
rank_no int null comment '랭킹 소식 순위',
is_adult tinyint(1) not null default 0 comment '성인 콘텐츠 또는 성인 소식 여부',
is_active tinyint(1) not null default 1 comment '활성 여부',
created_at timestamp not null default current_timestamp comment '생성 시각',
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
primary key (id)
) engine=InnoDB default charset=utf8mb4 comment='메인 홈 팔로잉 탭 사용자별 최근 소식 inbox';
create unique index uk_home_following_news_inbox_member_type_source
on home_following_news_inbox (member_id, news_type, source_key);
create index idx_home_following_news_inbox_member_visible
on home_following_news_inbox (member_id, is_active, visible_from_at_utc desc, id desc);
create index idx_home_following_news_inbox_member_creator_active
on home_following_news_inbox (member_id, creator_id, is_active);
create index idx_home_following_news_inbox_creator_type_source
on home_following_news_inbox (creator_id, news_type, source_key);

View File

@@ -0,0 +1,652 @@
# 메인 홈 팔로잉 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/home/following`으로 메인 홈 팔로잉 탭의 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식을 조회한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.following` 조립 계층에 둔다. 팔로잉 탭 조회 service, 최근 소식 publish service, domain model, port, QueryDSL/JPA repository는 `kr.co.vividnext.sodalive.v2.home.following` 하위에 두고 `v2.api.*`에 의존하지 않는다. 최근 소식은 별도 inbox table에 사용자별 row를 저장하고, 이번 범위에서는 외부 MQ/outbox/worker 없이 내부 publish service에서 follower 조회와 bulk insert를 수행한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, MySQL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 확정 사항
- API endpoint: `GET /api/v2/home/following`
- 인증 정책: 비로그인 조회 허용. 비로그인 응답은 `isLoginRequired = true`와 빈 섹션 배열을 내려준다.
- 로그인 회원 응답은 `isLoginRequired = false`와 팔로잉 탭 데이터를 내려준다.
- 응답 wrapper: `ApiResponse.ok(...)`
- `SecurityConfig``GET /api/v2/home/following` permitAll 설정을 추가한다.
- 섹션별 기본 노출 수:
- `followingCreators`: 최신 팔로우순 20개
- `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개
- `recentChats`: DM/AI 채팅 최신순 10개
- `monthlySchedules`: 이번 달 오늘 이후 일정 중 오늘과 가까운 순 3개
- `recentNews`: `visibleFromAtUtc desc`, `newsId desc` 기준 30개
- 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)``ChatRoomListItemResponse`를 재사용한다.
- 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다.
- 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다.
- 최근 소식 응답에는 별도 `creatorId`를 내려주지 않는다. 크리에이터 채널 이동이 필요한 `CREATOR_RANKING``targetId`가 크리에이터 회원 id다.
- 최근 소식 랭킹 값은 `rank: Int?`만 사용한다. `rankChange`, `isNew`, nested `ranking` object는 사용하지 않는다.
- 최근 소식 inbox table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다.
- inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다.
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다.
- 이미지 URL은 기존 `v2.common.domain.CdnUrlExtensions.toCdnUrl` 패턴을 따른다.
- UTC 문자열 변환은 기존 `toUtcIso` 패턴을 따른다.
- 성인 콘텐츠 노출 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)`를 사용한다.
- 차단 관계가 있는 크리에이터의 팔로잉 크리에이터, On Air, 스케줄, 최근 소식은 노출하지 않는다.
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
### 신규 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKey.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
### 기존 파일 수정
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTaskTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
### 문서/DDL
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
---
## 2. Response data class 초안
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`에는 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 필드 계약을 바꾸는 작업은 먼저 PRD와 이 문서를 갱신한 뒤 별도 변경으로 처리한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.home.following.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
data class HomeFollowingTabResponse(
@JsonProperty("isLoginRequired")
val isLoginRequired: Boolean,
val followingCreators: List<FollowingCreatorResponse>,
val onAirLives: List<FollowingLiveResponse>,
val recentChats: List<ChatRoomListItemResponse>,
val monthlySchedules: List<FollowingScheduleResponse>,
val recentNews: List<FollowingNewsResponse>
) {
companion object {
fun loginRequired(): HomeFollowingTabResponse {
return HomeFollowingTabResponse(
isLoginRequired = true,
followingCreators = emptyList(),
onAirLives = emptyList(),
recentChats = emptyList(),
monthlySchedules = emptyList(),
recentNews = emptyList()
)
}
fun from(home: HomeFollowing): HomeFollowingTabResponse {
return HomeFollowingTabResponse(
isLoginRequired = false,
followingCreators = home.followingCreators.map(FollowingCreatorResponse::from),
onAirLives = home.onAirLives.map(FollowingLiveResponse::from),
recentChats = home.recentChats,
monthlySchedules = home.monthlySchedules.map(FollowingScheduleResponse::from),
recentNews = home.recentNews.map(FollowingNewsResponse::from)
)
}
}
}
data class FollowingCreatorResponse(
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImageUrl: String
) {
companion object {
fun from(creator: HomeFollowingCreator): FollowingCreatorResponse {
return FollowingCreatorResponse(
creatorId = creator.creatorId,
creatorNickname = creator.creatorNickname,
creatorProfileImageUrl = creator.creatorProfileImageUrl
)
}
}
}
data class FollowingLiveResponse(
val liveId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val startedAtUtc: String
) {
companion object {
fun from(live: HomeFollowingLive): FollowingLiveResponse {
return FollowingLiveResponse(
liveId = live.liveId,
creatorProfileImageUrl = live.creatorProfileImageUrl,
creatorNickname = live.creatorNickname,
title = live.title,
startedAtUtc = live.startedAtUtc
)
}
}
}
data class FollowingScheduleResponse(
val scheduleId: String,
val creatorId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val type: CreatorActivityType,
val targetId: Long,
val scheduledAtUtc: String,
@JsonProperty("isOnAir")
val isOnAir: Boolean
) {
companion object {
fun from(schedule: HomeFollowingSchedule): FollowingScheduleResponse {
return FollowingScheduleResponse(
scheduleId = schedule.scheduleId,
creatorId = schedule.creatorId,
creatorProfileImageUrl = schedule.creatorProfileImageUrl,
creatorNickname = schedule.creatorNickname,
title = schedule.title,
type = schedule.type,
targetId = schedule.targetId,
scheduledAtUtc = schedule.scheduledAtUtc,
isOnAir = schedule.isOnAir
)
}
}
}
data class FollowingNewsResponse(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
) {
companion object {
fun from(news: HomeFollowingNews): FollowingNewsResponse {
return FollowingNewsResponse(
newsId = news.newsId,
type = news.type,
creatorProfileImageUrl = news.creatorProfileImageUrl,
creatorNickname = news.creatorNickname,
title = news.title,
body = news.body,
thumbnailImageUrl = news.thumbnailImageUrl,
targetId = news.targetId,
occurredAtUtc = news.occurredAtUtc,
visibleFromAtUtc = news.visibleFromAtUtc,
rank = news.rank
)
}
}
}
```
---
## 3. Domain / Port 초안
```kotlin
package kr.co.vividnext.sodalive.v2.home.following.domain
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
data class HomeFollowing(
val followingCreators: List<HomeFollowingCreator>,
val onAirLives: List<HomeFollowingLive>,
val recentChats: List<ChatRoomListItemResponse>,
val monthlySchedules: List<HomeFollowingSchedule>,
val recentNews: List<HomeFollowingNews>
)
data class HomeFollowingCreator(
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImageUrl: String
)
data class HomeFollowingLive(
val liveId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val startedAtUtc: String
)
data class HomeFollowingSchedule(
val scheduleId: String,
val creatorId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val type: CreatorActivityType,
val targetId: Long,
val scheduledAtUtc: String,
val isOnAir: Boolean
)
data class HomeFollowingNews(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
)
enum class FollowingNewsType {
CREATOR_RANKING,
CONTENT_RANKING,
COMMUNITY_POST,
AUDIO_CONTENT,
PHOTO_CONTENT
}
```
```kotlin
package kr.co.vividnext.sodalive.v2.home.following.port.out
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
import java.time.LocalDateTime
interface HomeFollowingQueryPort {
fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator>
fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive>
fun findMonthlySchedules(memberId: Long, canViewAdultContent: Boolean, now: LocalDateTime, limit: Int): List<HomeFollowingSchedule>
fun findRecentNews(memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, limit: Int): List<HomeFollowingNews>
}
interface HomeFollowingNewsInboxPort {
fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int
fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long
fun findActiveFollowerIds(creatorId: Long): List<Long>
}
data class HomeFollowingNewsInboxRecord(
val memberId: Long,
val creatorId: Long,
val newsType: String,
val sourceKey: String,
val targetId: Long,
val occurredAtUtc: LocalDateTime,
val visibleFromAtUtc: LocalDateTime,
val creatorNickname: String,
val creatorProfileImagePath: String?,
val title: String,
val body: String,
val thumbnailImagePath: String?,
val rank: Int?,
val isAdult: Boolean
)
```
---
## 4. Phase / Task 계획
### Phase 1: 응답 DTO, 도메인 모델, Security 기본 골격
- [x] **Task 1.1: 팔로잉 탭 응답 DTO와 domain model 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
- RED: `HomeFollowingTabResponse.loginRequired()``isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다.
- RED: `FollowingNewsResponse` 변환 결과가 `creatorId` 없이 `rank: Int?`만 포함하는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인.
- GREEN: DTO/domain enum/model을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: import, `JsonProperty`, nullable 필드 정리 후 `./gradlew --no-daemon ktlintCheck` 실행.
- [x] **Task 1.2: Controller와 Security permitAll 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt`
- RED: 비로그인 `GET /api/v2/home/following`이 200과 `isLoginRequired=true`를 반환하는 MockMvc 테스트를 작성한다.
- RED: 로그인 회원 요청이 facade를 호출하고 `isLoginRequired=false` 응답을 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행, endpoint 미구현 또는 security 미설정 실패 확인.
- GREEN: controller, facade 빈 골격, `SecurityConfig` permitAll을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 기존 `HomeRecommendationController``@AuthenticationPrincipal` 패턴과 응답 wrapper 스타일에 맞춘다.
### Phase 2: 최근 소식 Inbox 저장소
- [x] **Task 2.1: Inbox Entity/JPA repository/DDL 정합성 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
- RED: 같은 `memberId/newsType/sourceKey` 중복 저장이 1건만 유지되어야 하는 테스트를 작성한다.
- RED: `memberId/creatorId` 기준 활성 row 비활성화가 동작하는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, entity/repository 미구현 실패 확인.
- GREEN: Entity와 JPA repository를 DDL 컬럼명에 맞춰 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 컬럼명, enum 저장 방식, timestamp nullable 정책이 DDL과 맞는지 비교한다.
- [x] **Task 2.2: Inbox persistence adapter 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
- RED: `insertIgnoreAll(records)`가 중복 source key를 예외 없이 무시하고 신규 row만 저장하는 테스트를 작성한다.
- RED: `findActiveFollowerIds(creatorId)`가 활성 팔로워만 반환하는 테스트를 작성한다.
- 실패 확인: Task 2.1과 같은 단일 테스트 명령 실행, port/adapter 미구현 실패 확인.
- GREEN: JPA `saveAndFlush`와 unique 제약 기반 `DataIntegrityViolationException` 처리로 중복 source key를 예외 없이 무시하는 idempotent 저장을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: H2/MySQL dialect 분기 없이 단일 JPA 경로를 유지하고, 동시 적재 시 inserted count는 best-effort임을 검증 기록에 남긴다.
### Phase 3: 팔로잉 탭 조회 Repository/Service
- [x] **Task 3.1: 팔로잉 크리에이터 조회**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- RED: 활성 팔로우/활성 크리에이터만 최신 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다.
- RED: 차단 관계 크리에이터가 제외되는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인.
- GREEN: `creator_following`, `member`, `block_member` 조건을 QueryDSL로 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 기본 프로필 이미지와 CDN 변환 책임은 service/facade 중 기존 패턴과 맞는 위치로 정리한다.
- [x] **Task 3.2: On Air 조회**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- RED: 팔로우한 크리에이터의 `live_room.is_active=true`, `channel_name` 존재 라이브만 `beginDateTime desc, id desc`로 10개 조회하는 테스트를 작성한다.
- RED: 성인 콘텐츠 노출 불가이면 19금 라이브가 제외되는 테스트를 작성한다.
- 실패 확인: Task 3.1의 repository 단일 테스트 명령 실행, On Air 미구현 실패 확인.
- GREEN: 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)` 조건을 팔로잉 필터로 확장해 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 라이브 진행 중 판단 조건이 스케줄 `isOnAir`와 중복되면 private helper로 추출한다.
- [x] **Task 3.3: 이달의 스케줄 조회**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- RED: KST 오늘 00:00 이상 다음 달 00:00 미만의 라이브/오디오 일정을 `scheduledAt asc`로 3개 조회하는 테스트를 작성한다.
- RED: 오늘 이전 일정과 차단 크리에이터 일정이 제외되는 테스트를 작성한다.
- 실패 확인: repository 단일 테스트 명령 실행, schedule 미구현 실패 확인.
- GREEN: 기존 `CreatorChannelHomeQueryRepository.findSchedules(...)`의 live/audio 조건을 팔로잉 전체 조회로 확장한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: `scheduleId``{TYPE}:{targetId}` 형식으로 안정적으로 생성한다.
- [x] **Task 3.4: 최근 소식 조회**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- RED: `memberId`, `isActive=true`, `visibleFromAtUtc <= nowUtc` 조건으로 `visibleFromAtUtc desc, id desc` 30개를 조회하는 테스트를 작성한다.
- RED: `creatorId`가 응답 domain에 노출되지 않고 `rank`만 nullable로 내려가는 테스트를 작성한다.
- 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인.
- GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 조회 시 차단/성인/target 활성 조건을 과도하게 조인하지 않도록 필요한 조건만 유지한다.
- [x] **Task 3.5: HomeFollowingQueryService 조립**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt`
- RED: query service가 팔로잉 크리에이터 20, On Air 10, 스케줄 3, 최근 소식 30 limit로 port를 호출하는 테스트를 작성한다.
- RED: `MemberContentPreferenceService.canViewAdultContent(member)` 결과가 조회 port에 전달되는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"` 실행, service 미구현 실패 확인.
- GREEN: service에서 now/limit/성인 노출 정책을 조립한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: `nowProvider: () -> LocalDateTime`을 주입해 테스트 시간을 고정한다.
- [x] **Task 3.6: Inbox 중복 insert 충돌 통합 테스트 보강**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
- RED: 실제 `HomeFollowingNewsInboxJpaRepository`로 동일 `memberId/newsType/sourceKey` unique 충돌을 발생시킨 뒤, 같은 테스트 흐름에서 `insertIgnoreAll(records)` 또는 repository 조회가 예외 없이 동작하는 통합 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, 실제 DB 충돌 후 persistence context/transaction 상태 검증 실패를 확인한다.
- GREEN: 필요 시 adapter의 중복 충돌 처리에서 persistence context 정리 또는 트랜잭션 경계를 최소 보강한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다.
### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결
- [x] **Task 4.1: sourceKey 생성 정책 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKey.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt`
- RED: `CREATOR_RANKING:{creatorId}:{aggregationStartAtUtc}` 형식 source key 생성 테스트를 작성한다.
- RED: `AUDIO_CONTENT:{contentId}``COMMUNITY_POST:{postId}` source key 생성 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행, source key 미구현 실패 확인.
- GREEN: source key 생성 object를 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 문자열 상수는 `FollowingNewsType` enum 이름과 불일치하지 않게 정리한다.
- [x] **Task 4.2: HomeFollowingNewsPublishService 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
- RED: `publishCommunityPostCreated(...)`가 현재 active follower에게만 inbox record를 생성하는 테스트를 작성한다.
- RED: `publishContentUploaded(...)``visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다.
- RED: `publishCreatorRankingVisible(...)``rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인.
- GREEN: publish service에서 follower 조회, record 변환, `insertIgnoreAll` 호출을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 외부 MQ/outbox 없이 동작하되 호출부가 service 메서드에만 의존하도록 public API를 작게 유지한다.
- [x] **Task 4.3: 언팔로우 시 inbox 비활성화 연동**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
- RED: 언팔로우 시 해당 `memberId/creatorId`의 active inbox row가 `isActive=false`가 되는 테스트를 작성한다.
- RED: 재팔로우 시 기존 비활성 row가 복구되지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행, inbox 비활성화 미연동 실패 확인.
- GREEN: 기존 언팔로우 처리 성공 후 `HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 팔로잉 공개 API 스키마는 변경하지 않는다.
- [x] **Task 4.4: 크리에이터 랭킹 소식 발행 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- RED: `refreshLastCompletedWeek(...)`가 스냅샷 저장 성공 후 `publishCreatorRankingVisible(...)``visibleFromAtUtc`, `rank`, `creatorId`로 호출하는 테스트를 작성한다.
- RED: `snapshotPort.replaceSnapshots(...)` 실패 시 `publishCreatorRankingVisible(...)`이 호출되지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행, publish 미연동 실패 확인.
- GREEN: `snapshotPort.replaceSnapshots(...)` 성공 직후 `snapshots.mapIndexed { index, snapshot -> rank = index + 1 }`로 publish service를 호출한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 월요일 01:00 생성, 09:00 노출 정책은 inbox `visibleFromAtUtc`로만 처리한다.
- [x] **Task 4.5: 콘텐츠/커뮤니티 업로드 소식 발행 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
- RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 `publishCommunityPostCreated(...)`가 post id, creator id, 본문 요약, 생성 시각으로 호출되는 테스트를 작성한다.
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate <= now`인 즉시 공개 콘텐츠 저장 성공 후 `publishContentUploaded(...)`가 호출되는 테스트를 작성한다.
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다.
- RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `publishContentUploaded(...)`를 호출하는 테스트를 작성한다.
- 실패 확인:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`
- 기대 결과: publish 미연동으로 FAIL
- GREEN: `AudioContentService.createAudioContent(...)`, `AudioContentService.releaseContent()`, `CreatorCommunityService.createCommunityPost(...)`의 트랜잭션 성공 경로에서 publish service를 호출한다.
- 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인.
- REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다.
### Phase 5: Facade 통합, 최근 대화 재사용, API End-to-End
- [x] **Task 5.1: HomeFollowingFacade 통합**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
- RED: `member == null`이면 query/chat 서비스를 호출하지 않고 `HomeFollowingTabResponse.loginRequired()`를 반환하는 테스트를 작성한다.
- RED: 로그인 회원이면 query service와 `ChatRoomListService.getRooms(member, "ALL", null, 10)`를 호출해 응답을 조립하는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행, facade 미구현 실패 확인.
- GREEN: facade 조립 로직을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 한 섹션 데이터 부족은 빈 배열/가능한 개수로 성공 처리한다.
- [x] **Task 5.2: End-to-End API 통합 테스트**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
- RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다.
- RED: 로그인 회원 호출이 팔로잉 크리에이터/On Air/최근 대화/스케줄/최근 소식을 모두 조립하는 통합 테스트를 작성한다.
- RED: `FollowingNewsResponse``creatorId`와 nested `ranking`이 없고 `rank`만 있는지 JSON path 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 통합 미구현 실패 확인.
- GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 테스트 데이터 builder가 과하게 커지면 테스트 내부 private helper로만 분리한다.
### Phase 6: 문서/회귀 검증
- [x] **Task 6.1: 문서 동기화 확인**
- Files:
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
- TDD 예외 사유: 문서 검증 작업이며 실행 코드가 없다.
- 대체 검증 방법: `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`로 삭제된 공개 응답 필드가 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄의 `creatorId`와 DDL 내부 컬럼 `creator_id`는 허용한다.
- 실행 명령: `./gradlew tasks --all`
- 기대 결과: `BUILD SUCCESSFUL`
- [x] **Task 6.2: 전체 회귀 검증**
- Files:
- Verify: 전체 Kotlin source/test
- TDD 예외 사유: 전체 회귀 검증 task이며 신규 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- `./gradlew --no-daemon test`
- `./gradlew --no-daemon ktlintCheck`
- 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL`
- 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다.
---
## 5. 구현 순서 요약
1. DTO/domain/controller/security 기본 응답을 먼저 만든다.
2. inbox entity/repository/adapter와 unique 정책을 만든다.
3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다.
4. query service와 facade에서 섹션을 조립한다.
5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다.
6. End-to-End 테스트와 전체 회귀 검증을 수행한다.
---
## 6. 검증 기록
- 2026-06-25 Phase 1-2 구현 검증:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsKotlin` 삭제 충돌이 1회 발생해 동일 명령을 순차 재실행했다.
- 2026-06-25 Phase 3 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 repository/service 미구현 컴파일 오류로 `BUILD FAILED`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-25 Phase 4 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `HomeFollowingNewsSourceKey`, `HomeFollowingNewsPublishService` 미구현 및 생성자 의존성 미연동 컴파일 오류로 `BUILD FAILED`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `insertIgnoreAll`은 H2/MySQL dialect 분기 없이 JPA `saveAndFlush`와 unique 제약 기반 중복 예외 재확인 단일 경로로 검증했다.
- 2026-06-25 Phase 5 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`.
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 3-5 리뷰 보완 검증:
- 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 유료 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다.
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 reviewer 보완 전 7개 regression 테스트 실패를 확인했다.
- 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 3-5 2차 리뷰 보완 검증:
- 2차 리뷰 지적 사항에 따라 inbox insert 정상 경로를 row별 `saveAndFlush`에서 기존 memberId 일괄 조회 + `saveAll` + 단일 `flush`로 완화하고, 중복 충돌 fallback은 유지했다.
- 유료 오디오 콘텐츠의 `isFullDetailVisible=false` 상세 설명은 기존 상세 API 정책과 동일하게 미리보기만 최근 소식에 저장하도록 보강했다.
- 오디오/커뮤니티/랭킹 최근 소식 발행 실패가 원 업로드/게시글 생성/랭킹 스냅샷 갱신 성공을 실패로 전파하지 않도록 after-commit 발행 예외를 로그로 격리했다.
- 보완 직후 regression 테스트에서 adapter race 테스트와 Mockito matcher stubbing 불일치 실패를 확인한 뒤 테스트를 새 구현 경로에 맞게 정리했다.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 3-5 3차 리뷰 보완 검증:
- 최근 소식 조회가 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target의 `isActive=false` 상태를 최종 제외하도록 보강했다. `CREATOR_RANKING`은 creator 활성/role 필터를 유지하고, 아직 원천 테이블이 없는 예약 타입은 조회에서 노출하지 않는다.
- 이달의 스케줄 정렬을 `scheduledAtUtc asc`, `type.sortOrder asc`, `targetId asc`로 안정화했다.
- inbox insert를 H2/MySQL 공통 JPA portable path로 변경했다. 구현은 `newsType/sourceKey`별 기존 수신 member id를 일괄 조회한 뒤 신규 row만 `saveAll` + `flush`하고, unique 충돌 시 persistence context를 정리한 뒤 한 번 재조회/재시도한다.
- 추후 운영에서 follower 수가 큰 크리에이터 이벤트로 `member_id in (...)` 또는 `saveAll` 배치 크기가 병목이 되면, follower id chunking, outbox table, 비동기 worker, 재시도/모니터링 대시보드 도입을 별도 후속 작업으로 진행한다.
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterRetryTest"` 실행 결과 target 비활성 필터와 insert retry 미구현으로 `BUILD FAILED`.
- 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test` 전체 테스트 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 6 문서/회귀 검증:
- 문서 동기화 확인을 위해 `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`를 실행했다. 검색 결과의 `creatorId`는 팔로잉 크리에이터/스케줄 공개 필드, 최근 소식의 `creatorId` 부재 검증 설명, 내부 `creator_id`/port 인자/테스트 설명 맥락으로 확인했으며 삭제된 공개 응답 필드 잔존은 확인되지 않았다.
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.

View File

@@ -0,0 +1,366 @@
# PRD: 메인 홈 팔로잉 탭 API
## 1. Overview
메인 홈의 내부 팔로잉 탭에서 사용할 팔로잉 크리에이터, 진행 중인 라이브, 최근 대화, 이달의 스케줄, 최근 소식을 한 번에 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 팔로잉 탭 화면은 로그인 사용자가 팔로우한 크리에이터 기준으로 여러 섹션을 조립해야 한다.
- 기존 v2 홈 추천 API는 추천/랭킹 중심이며, 팔로잉 관계를 기준으로 섹션 전체를 구성하지 않는다.
- 기존 채팅 목록 API, 크리에이터 채널 홈 API, 크리에이터 랭킹 스냅샷 패턴에는 재사용 가능한 코드가 있지만, 팔로잉 탭의 공개 응답 필드는 화면 요구사항과 다르다.
- 최근 소식은 랭킹, 커뮤니티 게시글 업로드, 콘텐츠 업로드가 섞인 피드라 매 요청마다 팔로잉한 모든 크리에이터의 모든 원천 데이터를 크게 조인하면 응답 지연과 DB 부하가 커질 수 있다.
- 최근 소식은 전체 후보를 매번 조회하는 모델보다, 팔로우 중인 크리에이터의 이벤트가 발생할 때 각 follower의 우체통에 소식 row를 넣는 사용자별 Inbox Feed 모델이 요구사항에 더 맞다.
- 따라서 공개 API 조립 계층과 도메인 조회 계층을 분리하고, 최근 소식은 사용자별 inbox row를 최신순으로 읽는 구조가 필요하다.
---
## 3. Goals
- 메인 홈 팔로잉 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 비로그인 사용자도 API 호출은 허용하되, 로그인 유도 화면을 그릴 수 있는 응답을 제공한다.
- 사용자가 팔로우한 크리에이터 목록을 최신 팔로우순 20개 응답한다.
- 사용자가 팔로우한 크리에이터의 현재 진행 중인 라이브를 최신순 10개 응답한다.
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
- 사용자가 팔로우한 크리에이터들의 최근 소식을 최신 노출 가능 시각순 최대 30개 응답한다.
- 최근 소식은 팔로우 중인 크리에이터의 이벤트 발생 시점에 사용자별 inbox row를 생성하고, 조회 시 열람 가능 시각/활성 여부/차단/성인 노출 조건을 적용한다.
- 새로 팔로우한 사용자는 과거 소식을 받지 않는다.
- 언팔로우하면 해당 크리에이터가 보낸 기존 inbox row를 비활성화한다.
- 재팔로우해도 기존에 비활성화된 inbox row는 복구하지 않고, 재팔로우 이후 새 이벤트부터 새 inbox row를 생성한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
---
## 4. Non-Goals
- 기존 `GET /api/v2/home/recommendations` 공개 API 스키마를 변경하지 않는다.
- 기존 `GET /api/v2/chat/rooms` 공개 API 스키마를 변경하지 않는다.
- 기존 크리에이터 채널 홈/라이브/커뮤니티/콘텐츠 API 공개 스키마를 변경하지 않는다.
- 팔로잉 추가/해제 공개 API 스키마 변경은 이번 범위에 포함하지 않는다.
- 단, 최근 소식 정책을 위해 기존 팔로잉/언팔로잉 처리에 inbox 적재/비활성화 연동이 필요하면 내부 동작 보강 범위에 포함한다.
- 채팅방 생성, 메시지 전송, 읽음 처리 정책 변경은 포함하지 않는다.
- 최근 소식의 운영자 수동 고정/숨김 기능은 포함하지 않는다.
- 최근 소식 발송용 외부 MQ, outbox table, 별도 worker, cursor/retry dashboard는 이번 범위에 포함하지 않는다.
- 화보 업로드 기능 자체 구현은 포함하지 않는다. 단, 향후 콘텐츠 타입 확장을 고려한 응답 타입은 정의한다.
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
---
## 5. Target Users
- 회원: 홈 팔로잉 탭에서 자신이 팔로우한 크리에이터의 활동을 빠르게 확인하는 사용자
- 비회원: 홈 팔로잉 탭에 진입했을 때 로그인 필요 상태를 확인하고 로그인 화면으로 이동하는 사용자
- 앱 클라이언트: 팔로잉 탭 첫 화면의 여러 섹션을 하나의 API 응답으로 구성하려는 클라이언트
- 운영자: 최근 소식 inbox 적재와 노출 정책이 안정적으로 동작하기를 기대하는 내부 사용자
---
## 6. User Stories
- 사용자는 내가 팔로우한 크리에이터 목록을 최근 팔로우한 순서로 보고 싶다.
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
- 사용자는 팔로우한 크리에이터의 이번 주 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
- 앱 클라이언트는 소식 item의 타입별 터치 액션을 명확한 target id로 처리하고 싶다.
---
## 7. Core Features
### Feature A. 메인 홈 팔로잉 탭 통합 조회 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/home/following`으로 정의한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 비로그인 요청도 성공 응답으로 처리한다.
- 비로그인 요청은 `isLoginRequired = true`와 빈 섹션 배열을 내려주고, 앱 클라이언트가 로그인 유도 화면을 표시한다.
- 로그인 회원 요청은 `isLoginRequired = false`와 팔로잉 탭 데이터를 내려준다.
- 인증 회원 조회는 기존 v2 컨트롤러 패턴과 동일하게 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 사용한다.
- 별도 query parameter는 정의하지 않는다.
- API 조립 계층은 섹션별 도메인 조회 결과를 받아 공개 응답 DTO로 변환한다.
- 한 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
- 섹션별 데이터가 없으면 빈 배열을 내려준다.
#### Edge Cases
- 비로그인 요청에서는 팔로잉 크리에이터, On Air, 최근 대화, 스케줄, 최근 소식을 모두 빈 배열로 내려준다.
- 비로그인 요청에서는 팔로잉/채팅/스케줄/최근 소식 도메인 조회를 수행하지 않는다.
- 사용자가 팔로우한 크리에이터가 없으면 팔로잉 크리에이터, On Air, 스케줄, 최근 소식은 빈 배열로 내려준다.
- 최근 대화는 팔로잉 여부와 무관하게 해당 회원의 DM/AI 채팅 최신순 10개를 내려준다.
- 조회 중 차단 관계가 있는 크리에이터의 라이브, 스케줄, 최근 소식은 노출하지 않는다.
### Feature B. 팔로잉 크리에이터
#### Requirements
- 사용자가 팔로우한 활성 크리에이터를 최신 팔로우순으로 최대 20개 조회한다.
- 팔로잉 기준은 `creator_following.member_id = 요청 회원 id`, `creator_following.is_active = true`다.
- 크리에이터는 `member.role = CREATOR`, `member.is_active = true`인 대상만 노출한다.
- 응답 필드는 `creatorId`, `creatorNickname`, `creatorProfileImageUrl`을 포함한다.
- 프로필 이미지는 `v2.common.domain.CdnUrlExtensions.toCdnUrl(...)` 패턴으로 CDN URL 변환한다.
- 프로필 이미지가 없으면 기존 채팅/홈 추천과 동일한 기본 프로필 이미지 정책을 따른다.
#### Edge Cases
- 팔로잉 row는 활성 상태지만 크리에이터가 비활성 상태이면 제외한다.
- 차단 관계가 있는 크리에이터는 제외한다.
### Feature C. On Air
#### Requirements
- 사용자가 팔로우한 활성 크리에이터의 현재 진행 중인 라이브를 최신순으로 최대 10개 조회한다.
- 현재 진행 중인 라이브는 기존 홈 추천 라이브와 동일하게 `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''` 조건을 기본으로 한다.
- 정렬은 `live_room.begin_date_time desc`, `live_room.id desc`로 한다.
- 응답 필드는 `liveId`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `startedAtUtc`를 포함한다.
- 19금 라이브 노출 여부는 기존 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 반영한다.
- 성별 제한, 크리에이터 입장 제한처럼 기존 라이브 조회에서 필요한 접근 조건이 있으면 구현 계획 단계에서 기존 라이브/크리에이터 채널 라이브 조회 정책과 맞춘다.
#### Edge Cases
- 라이브 제목이 비어 있으면 기존 라이브 조회 API의 제목 fallback 정책을 확인해 따른다.
- 차단 관계가 있는 크리에이터의 라이브는 제외한다.
### Feature D. 최근 대화
#### Requirements
- DM/AI 채팅방 중 최신 대화순으로 최대 10개 조회한다.
- 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)` 재사용을 우선한다.
- 터치 시 해당 채팅방으로 이동할 수 있도록 `roomId``chatType`을 응답에 포함한다.
- 기존 채팅 목록 응답 `ChatRoomListItemResponse`는 필드가 팔로잉 탭 요구와 맞으므로 직접 재사용한다.
#### Edge Cases
- 채팅방이 없으면 빈 배열을 내려준다.
- AI/DM 메시지 preview 규칙은 기존 `ChatRoomListService``previewMessage()` 정책을 그대로 따른다.
### Feature E. 이달의 스케줄
#### Requirements
- 사용자가 팔로우한 크리에이터들의 이번 달 스케줄을 최대 3개 조회한다.
- 조회 범위는 KST 기준 오늘 00:00:00 이상, 다음 달 00:00:00 미만으로 한다.
- 오늘 이전의 데이터는 노출하지 않는다.
- 정렬은 `scheduledAt asc`, 같은 시각이면 기존 `CreatorActivityType` 정렬 정책과 target id 순으로 안정화한다.
- 스케줄 원천은 기존 크리에이터 채널 홈 스케줄 정책을 팔로잉 전체로 확장한다.
- 라이브 예약: `live_room.begin_date_time`
- 오디오 콘텐츠 예약: `content.release_date`
- 응답 필드는 `scheduleId`, `creatorId`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `type`, `targetId`, `scheduledAtUtc`, `isOnAir`를 포함한다.
- `type`은 기존 `CreatorActivityType`을 우선 재사용한다.
- 화면의 `On Air` 표시를 위해 예약 라이브가 이미 진행 중이면 `isOnAir = true`로 내려준다.
#### Edge Cases
- 오늘 이전 일정은 제외하되, 오늘 시작해서 현재 진행 중인 라이브는 스케줄에 포함할 수 있다.
- 이번 달 남은 일정이 3개 미만이면 가능한 개수만 내려준다.
- 19금 스케줄은 회원의 성인 콘텐츠 노출 가능 여부를 따른다.
- 차단 관계가 있는 크리에이터의 스케줄은 제외한다.
### Feature F. 최근 소식
#### Requirements
- 사용자가 팔로우한 크리에이터들의 소식을 최신 노출 가능 시각순으로 최대 30개 조회한다.
- 최근 소식은 사용자별 Inbox Feed로 저장한다.
- 크리에이터 이벤트 발생 시점에 해당 크리에이터를 현재 팔로우 중인 회원별 inbox row를 생성한다.
- 이번 범위에서는 별도 비동기 이벤트 발송 시스템을 도입하지 않는다.
- 이벤트 발생 처리 흐름에서 내부 publish service를 호출해 follower 조회와 inbox bulk insert를 수행한다.
- publish service는 콘텐츠/커뮤니티/랭킹 도메인 코드에 직접 흩어지지 않고, 향후 outbox/worker로 전환할 수 있는 단일 경계로 둔다.
- follower가 많아져 동기 bulk insert가 운영 부하를 만들면 publish service 내부 구현을 outbox/worker 방식으로 교체할 수 있어야 한다.
- 현재 구현은 H2/MySQL 공통 검증이 가능한 JPA portable path를 우선 사용한다. follower 수가 큰 크리에이터 이벤트에서 `member_id in (...)` 또는 `saveAll` 배치 크기가 운영 부하를 만들면, 후속 작업에서 follower id chunking, outbox table, 비동기 worker, 재시도/모니터링 대시보드로 전환한다.
- 새로 팔로우한 사용자는 팔로우 이전에 발생한 과거 소식을 받지 않는다.
- 언팔로우 시 해당 크리에이터가 보낸 기존 inbox row를 `isActive = false`로 비활성화한다.
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
- 재팔로우 이후 새로 발생한 이벤트부터 새 inbox row를 생성한다.
- 최근 소식 item 타입은 최소 아래를 지원한다.
- `CREATOR_RANKING`: 크리에이터 순위 소식
- `CONTENT_RANKING`: 향후 콘텐츠 순위 소식
- `COMMUNITY_POST`: 커뮤니티 게시글 업로드
- `AUDIO_CONTENT`: 오디오 콘텐츠 업로드
- `PHOTO_CONTENT`: 향후 화보 콘텐츠 업로드
- 이번 범위에서 `CONTENT_RANKING`은 생성하지 않는다.
- `PHOTO_CONTENT`는 화보 기능 구현 전에는 생성되지 않지만, 클라이언트 계약 확장을 위해 enum에 포함한다.
- 최근 소식은 매 요청마다 모든 팔로잉 크리에이터 원천 데이터를 직접 집계하지 않는다.
- inbox row에는 소식 타입, 발생 시각, 열람 가능 시각, 수신 회원 id, 크리에이터 id, target id, 표시용 제목/본문/이미지 path, 랭킹 순위 값 등 응답 생성에 필요한 최소 정보를 저장한다.
- API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다.
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
- 응답 필드는 `newsId`, `type`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `visibleFromAtUtc`, `rank`를 포함한다.
- 응답에는 `creatorId`를 별도 필드로 내려주지 않는다.
- `CREATOR_RANKING` 터치 액션은 해당 크리에이터 채널 이동이므로 `targetId`는 크리에이터 회원 id다.
- `CONTENT_RANKING` 터치 액션은 향후 콘텐츠 상세 이동이므로 `targetId`는 콘텐츠 id로 정의한다.
- `COMMUNITY_POST` 터치 액션은 게시글 상세 이동이므로 `targetId`는 커뮤니티 게시글 id다.
- `AUDIO_CONTENT` 터치 액션은 오디오 상세 이동이므로 `targetId`는 오디오 콘텐츠 id다.
- `PHOTO_CONTENT` 터치 액션은 향후 화보 상세 이동이므로 `targetId`는 화보 콘텐츠 id로 정의한다.
- 화면의 상대 시간 표시는 `visibleFromAtUtc` 기준을 기본으로 한다.
- 커뮤니티 게시글 업로드 소식의 `occurredAtUtc``visibleFromAtUtc`는 게시글 생성 시각을 기본값으로 한다.
- 오디오 콘텐츠 업로드 소식의 `occurredAtUtc`는 콘텐츠 업로드 또는 공개 예약 생성 시각, `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다.
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = occurredAtUtc`로 저장할 수 있다.
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다.
- 랭킹 소식은 이번에 몇 위에 올랐는지를 나타내는 `rank`를 내려준다.
- `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT``rank``null`로 내려준다.
#### Edge Cases
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
- inbox 적재 실패 시 API 조회에서 실시간 fallback 집계를 무조건 수행하지 않는다.
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
- 콘텐츠 썸네일이 없으면 `thumbnailImageUrl``null`로 내려준다.
### Feature G. Response 재사용 정책
#### Requirements
- 공개 응답 DTO는 화면 계약이 명확해야 하므로 팔로잉 탭 전용 최상위 응답 `HomeFollowingTabResponse`를 신규로 만든다.
- 기존 응답 DTO를 무조건 새로 만들지는 않는다.
- `recentChats`는 기존 `ChatRoomListItemResponse`를 직접 재사용한다.
- `followingCreators`는 기존 `HomeCreatorItem`과 필드 의미가 유사하지만 `v2.api.home.dto.recommendation` 패키지의 추천 탭 전용 DTO이므로, API 결합을 줄이기 위해 팔로잉 탭 전용 `FollowingCreatorResponse`를 만든다.
- `onAirLives`는 기존 `HomeLiveItem`에 title/start time이 없고, `CreatorChannelLiveResponse`에는 creator profile/nickname이 없어 그대로 재사용하지 않는다.
- `monthlySchedules`는 기존 `CreatorChannelScheduleResponse`에 creator 정보와 `isOnAir`가 없어 그대로 재사용하지 않는다.
- `recentNews`는 타입별 target/action이 필요한 신규 피드이므로 전용 DTO를 만든다.
- DTO를 새로 만들더라도 CDN URL 변환, UTC ISO 변환, 채팅 목록 조회, 성인 콘텐츠 노출 판단, 차단 관계 필터, 크리에이터 랭킹 스냅샷 visible 시각 정책은 기존 코드를 재사용한다.
#### Edge Cases
- 기존 `ChatRoomListItemResponse` 변경이 팔로잉 탭 공개 스키마에도 영향을 줄 수 있으므로, 채팅 목록 API 변경 시 팔로잉 탭 회귀 테스트를 함께 수행한다.
---
## 8. API Endpoint
```http
GET /api/v2/home/following
Authorization: Bearer {accessToken} (optional)
```
- 비로그인 조회를 허용한다.
- 별도 query parameter는 정의하지 않는다.
- `SecurityConfig``GET /api/v2/home/following` permitAll 설정을 추가한다.
- 컨트롤러에서 `member == null`이면 `isLoginRequired = true`와 빈 섹션 배열을 담은 응답을 반환한다.
- 앱 클라이언트는 `isLoginRequired = true`일 때 팔로잉 탭 본문 대신 로그인 유도 화면을 표시한다.
---
## 9. Response Data Class
```kotlin
data class HomeFollowingTabResponse(
@JsonProperty("isLoginRequired")
val isLoginRequired: Boolean,
val followingCreators: List<FollowingCreatorResponse>,
val onAirLives: List<FollowingLiveResponse>,
val recentChats: List<ChatRoomListItemResponse>,
val monthlySchedules: List<FollowingScheduleResponse>,
val recentNews: List<FollowingNewsResponse>
)
data class FollowingCreatorResponse(
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImageUrl: String
)
data class FollowingLiveResponse(
val liveId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val startedAtUtc: String
)
data class FollowingScheduleResponse(
val scheduleId: String,
val creatorId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val type: CreatorActivityType,
val targetId: Long,
val scheduledAtUtc: String,
@JsonProperty("isOnAir")
val isOnAir: Boolean
)
data class FollowingNewsResponse(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
)
enum class FollowingNewsType {
CREATOR_RANKING,
CONTENT_RANKING,
COMMUNITY_POST,
AUDIO_CONTENT,
PHOTO_CONTENT
}
```
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
- `scheduleId``newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다.
---
## 10. Technical Constraints
### 패키지 구조
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home.following` 하위에 둔다.
- Controller: `...adapter.in.web`
- Facade: `...application`
- Response DTO: `...dto`
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.home.following` 하위에 둔다.
- Query service: `...application`
- 최근 소식 publish service: `...application`
- 도메인 모델/정책: `...domain`
- 조회 port: `...port.out`
- QueryDSL/JPA 구현: `...adapter.out.persistence`
- 의존 방향은 `v2.api.home.following -> v2.home.following`만 허용한다.
### V2 공통화/재사용 대상
- `v2.chat.service.ChatRoomListService`: 최근 대화 조회
- `v2.chat.dto.ChatRoomListItemResponse`: 최근 대화 공개 응답 직접 재사용
- `v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepository.findSchedules(...)`: 스케줄 조회 조건 참고
- `v2.creator.channel.home.domain.CreatorChannelSchedule`: 스케줄 도메인 의미 참고
- `v2.common.domain.CreatorActivityType`: 스케줄/소식 타입 중 활동 타입 재사용
- `v2.common.domain.CdnUrlExtensions.toCdnUrl`: 이미지 URL 변환
- `v2.api.home.dto.recommendation.toUtcIso`: UTC ISO 문자열 변환 패턴
- `MemberContentPreferenceService.canViewAdultContent(...)`: 성인 콘텐츠 노출 가능 여부 판단
- `v2.ranking`: 크리에이터 랭킹 스냅샷, `visibleFromAtUtc`, `rank` 의미 참고
### 최근 소식 Inbox
- 신규 Entity와 DB table을 생성한다.
- MySQL DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`에 기록한다.
- inbox는 사용자별 소식 저장소다.
- inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다.
- 커뮤니티/콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다.
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다.
- 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다.
- 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다.
- publish service는 `publishContentUploaded(...)`, `publishCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다.
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다.
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
- 현재 `creator_following`에는 재팔로우 시점이 명확히 남지 않으므로, 조회 조건으로 재팔로우 시점을 추론하지 않는다.
- 조회 시 차단 관계, 성인 노출 여부, 원천 target 활성 여부는 최종 확인한다.
- 중복 방지를 위해 `memberId`, `newsType`, `sourceKey` 기준의 유니크 정책을 필수로 둔다.
- `sourceKey``{TYPE}:{targetId}:{periodKey}`처럼 같은 소식을 안정적으로 식별할 수 있는 값으로 정의한다.
- 언팔로우 비활성화와 사용자별 조회 성능을 위해 `memberId`, `creatorId`, `isActive` 축의 인덱스를 고려한다.
- 최신 30개 조회 성능을 위해 `memberId`, `isActive`, `visibleFromAtUtc` 축의 인덱스를 고려한다.
---
## 11. Metrics
- `GET /api/v2/home/following` 응답 시간
- 섹션별 item count
- 최근 소식 inbox 적재 성공/실패 횟수
- 최근 소식 inbox 적재 지연 시간
- 최근 소식 조회 시 필터링 후 노출 수
- 빈 섹션 비율
---
## 12. Open Questions
- 현재 PRD 기준의 미결정 요구사항은 없다.
- 구현 계획 단계에서는 기존 라이브 조회 코드의 진행 중 판단 조건과 스케줄 `isOnAir` 판단 조건을 같은 조건으로 추출할지 검토한다.

View File

@@ -0,0 +1,285 @@
# 현재 진행 중인 라이브 조회 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/home/on-air-lives`로 현재 진행 중인 라이브를 20개씩 페이징 조회한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.live` 조립 계층에 둔다. 도메인 조회는 기존 `kr.co.vividnext.sodalive.v2.recommendation``HomeRecommendationQueryService``HomeRecommendationQueryPort.findLiveRecommendations(...)`를 확장 재사용한다. 기존 추천 탭 공개 응답 DTO는 변경하지 않고, 신규 endpoint에서만 `title`, `price`, `beginDateTimeUtc`를 포함한 응답 DTO로 조립한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 확정 사항
- API endpoint: `GET /api/v2/home/on-air-lives`
- 인증 정책: 인증 회원만 조회 가능
- 비회원/anonymous 요청: 기존 인증 필요 API와 동일하게 인증 오류 반환
- 응답 wrapper: `ApiResponse.ok(...)`
- query parameter: `page`만 받음, 기본값 `0`
- page size: 항상 20개 고정, 클라이언트가 `size`를 지정하지 않음
- page 응답: `items`, `page`, `size`, `hasNext`
- `hasNext` 판정: 내부에서 `PAGE_SIZE + 1`개 조회 후 응답에는 최대 20개만 노출
- 현재 진행 중인 라이브 조건: `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''`
- 정렬: `live_room.begin_date_time desc`, `live_room.id desc`
- 방송자 조건: `member.is_active = true`
- 차단 정책: 요청 회원과 크리에이터의 양방향 활성 차단 관계 제외
- 성인 라이브 정책: `MemberContentPreferenceService.canViewAdultContent(member)` 결과 반영
- 시작 시간 응답: `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 응답
- 프로필 이미지: 기존 홈 추천 패턴과 동일하게 CDN URL 변환, 없으면 기본 프로필 이미지 URL
- 기존 공개 API 스키마 유지:
- `GET /api/v2/home/recommendations`
- `GET /api/v2/home/recommendations/lives`
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
### 기존 도메인 조회 계층 확장
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
### 기존 설정 수정
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
### 문서
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
---
## 2. Response data class 초안
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`에 아래 DTO를 추가한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.home.live.dto
data class HomeOnAirLivePageResponse(
val items: List<HomeOnAirLiveResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class HomeOnAirLiveResponse(
val roomId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val title: String,
val price: Int,
val beginDateTimeUtc: String
)
```
`src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`의 기존 record는 아래처럼 확장한다.
```kotlin
package kr.co.vividnext.sodalive.v2.recommendation.port.out
import java.time.LocalDateTime
data class HomeLiveRecommendationRecord(
val liveRoomId: Long,
val creatorNickname: String,
val creatorProfileImage: String?,
val title: String,
val price: Int,
val beginDateTime: LocalDateTime
)
```
기존 `HomeRecommendationFacade.toItem()``title`, `price`를 무시하고 기존 `HomeLiveItem` 필드만 매핑해 기존 API 응답 스키마를 유지한다.
---
### Phase 1: 도메인 조회 record 확장
- [x] **Task 1.1: 라이브 추천 record에 title/price/beginDateTime 포함**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `DefaultHomeRecommendationQueryRepositoryTest``shouldReturnLiveTitlePriceAndBeginDateTimeForOnAirLiveQuery` 테스트를 추가한다. fixture는 `LiveRoom(title = "paid live", price = 30, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30), channelName = "channel")`를 저장하고, `findLiveRecommendations(offset = 0, limit = 1, memberId = viewer.id, includeAdultLives = true)` 결과의 `title == "paid live"`, `price == 30`, `beginDateTime == LocalDateTime.of(2026, 6, 26, 12, 30)`을 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `HomeLiveRecommendationRecord``title`, `price`, `beginDateTime`을 추가하고, QueryDSL projection에 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime`을 추가한다.
- REFACTOR: 기존 `HomeRecommendationFacade.toItem()`과 기존 테스트 컴파일 오류를 수정하되 `HomeLiveItem` 공개 필드는 추가하지 않는다.
- 기대 결과: repository 테스트가 PASS이고 기존 추천 탭 응답 DTO에는 `title`, `price`, `beginDateTimeUtc`가 추가되지 않는다.
- [x] **Task 1.2: 기존 라이브 조회 조건 회귀 테스트 보강**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- RED: 기존 `shouldFindPagedLiveRecommendationsWithAdultFilter` 테스트를 확장하거나 별도 `shouldApplyOnAirLiveVisibilityPolicy` 테스트를 추가한다. 활성 방송자/비활성 방송자, `channelName = null`, 빈 `channelName`, `isActive = false`, 성인 라이브, 양방향 차단 라이브를 fixture로 만들고 조건에 맞는 라이브만 최신순으로 반환되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 기존 조회 조건이 부족하면 `member.isActive.isTrue`, `liveRoom.channelName.isNotNull`, `liveRoom.channelName.isNotEmpty`, `includeAdultLiveCondition(...)`, `notBlockedCreatorCondition(...)`을 보강한다.
- REFACTOR: 중복 조건은 기존 private condition 함수로 유지하고 신규 abstraction은 추가하지 않는다.
- 기대 결과: 진행 중 라이브 조회 정책이 PRD의 노출 조건과 일치한다.
- [x] **Task 1.3: HomeRecommendationQueryService 위임 계약 유지**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- RED: `shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag` 테스트를 추가한다. mock `HomeRecommendationQueryPort``HomeLiveRecommendationRecord(liveRoomId = 1L, creatorNickname = "creator", creatorProfileImage = "profile.png", title = "live", price = 10, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30))`을 반환하도록 하고, service가 `offset`, `limit`, `memberId`, `includeAdultLives`를 그대로 port에 전달하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: 컴파일 오류가 있으면 record 생성부와 import를 갱신한다. service 메서드 시그니처는 기존 `findLiveRecommendations(offset, limit, memberId, includeAdultLives)`를 유지한다.
- REFACTOR: service에는 신규 API 전용 page 조립 로직을 넣지 않는다.
- 기대 결과: 도메인 조회 계층은 API DTO에 의존하지 않고 기존 port record만 반환한다.
### Phase 2: 신규 API 조립 계층
- [x] **Task 2.1: 신규 응답 DTO와 직렬화 테스트 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
- RED: `shouldSerializeOnAirLivePageResponse` 테스트를 작성한다. `HomeOnAirLivePageResponse(items = listOf(HomeOnAirLiveResponse(...)), page = 0, size = 20, hasNext = true)`를 Jackson으로 직렬화하고 `items[0].roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page`, `size`, `hasNext` 필드가 존재하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
- GREEN: PRD의 Response data class와 동일한 DTO를 추가한다.
- REFACTOR: DTO에는 도메인 조회나 CDN 변환 로직을 넣지 않는다.
- 기대 결과: 공개 응답 필드명이 PRD와 일치한다.
- [x] **Task 2.2: HomeOnAirLiveFacade 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
- RED: `shouldReturnFixedSizePageAndHasNext` 테스트를 작성한다. mock `HomeRecommendationQueryService`가 21개 record를 반환하게 하고, facade가 `page = 0`, `size = 20`, `hasNext = true`, `items.size = 20`을 반환하는지 검증한다. `offset = 0`, `limit = 21`, `memberId = member.id`, `includeAdultLives = true` 호출도 검증한다.
- RED: `shouldUseDefaultProfileImageWhenCreatorProfileImageIsBlank` 테스트를 작성한다. `creatorProfileImage = null`인 record가 `https://cdn.test/profile/default-profile.png`로 매핑되는지 검증한다.
- RED: `shouldMapBeginDateTimeToUtcIsoString` 테스트를 작성한다. record의 `beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)`가 응답 `beginDateTimeUtc = "2026-06-26T12:30:00Z"`로 변환되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
- GREEN: `HomeOnAirLiveFacade``@Component`로 추가한다. 생성자에는 `HomeRecommendationQueryService`, `MemberContentPreferenceService`, `@Value("\${cloud.aws.cloud-front.host}") cloudFrontHost`를 주입한다.
- GREEN: `getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse`를 구현하고, 내부 상수는 `PAGE_SIZE = 20`, `MAX_PAGE = 10_000`으로 둔다.
- GREEN: `page.coerceIn(0, MAX_PAGE)`로 page를 보정하고, `offset = normalizedPage * PAGE_SIZE`, `limit = PAGE_SIZE + 1`로 조회한다.
- REFACTOR: CDN URL 변환은 기존 홈 추천의 `profileImageUrl(cloudFrontHost, path)` 의미와 동일하게 유지한다. 시작 시간 UTC 문자열 변환은 기존 `toUtcIso` 의미와 동일하게 유지한다. 해당 helper들이 package-private이라 재사용이 어렵다면 facade 내부 private 함수로 최소 복제한다.
- 기대 결과: facade가 page 조립, 성인 노출 플래그 계산, DTO 매핑만 담당한다.
- [x] **Task 2.3: HomeOnAirLiveController 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
- RED: `shouldRejectAnonymousRequest` 테스트를 작성한다. `GET /api/v2/home/on-air-lives`를 인증 없이 호출하면 401 Unauthorized가 반환되는지 검증한다.
- RED: `shouldPassAuthenticatedMemberAndPageToFacade` 테스트를 작성한다. `with(user(MemberAdapter(member)))``GET /api/v2/home/on-air-lives?page=2`를 호출하고 facade가 member와 page 2를 받으며 `$.data.size == 20` 응답을 반환하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- GREEN: `@RestController`, `@RequestMapping("/api/v2/home/on-air-lives")` controller를 추가한다. `@GetMapping` 메서드는 `@RequestParam(defaultValue = "0") page: Int``@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 받는다.
- GREEN: `member ?: throw SodaException(messageKey = "common.error.bad_credentials")`로 인증 회원을 요구하고, `ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(member, page))`를 반환한다.
- REFACTOR: controller에는 조회 조건/응답 매핑 로직을 넣지 않는다.
- 기대 결과: 신규 endpoint는 인증 회원만 접근 가능하고 기존 `ApiResponse.ok(...)` wrapper를 따른다.
### Phase 3: 보안 설정과 회귀 검증
- [x] **Task 3.1: SecurityConfig에 인증 필요 endpoint 등록**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
- RED: `HomeOnAirLiveControllerTest.shouldRejectAnonymousRequest``SecurityConfig` 적용 상태에서 401을 기대하도록 유지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- GREEN: `SecurityConfig``GET /api/v2/home/on-air-lives``authenticated()` 경로로 추가한다. `permitAll`에는 추가하지 않는다.
- REFACTOR: 기존 `/api/v2/home/recommendations` permitAll과 `/api/v2/home/recommendations/**` authenticated 정책을 변경하지 않는다.
- 기대 결과: 현재 진행 중인 라이브 신규 API는 인증 필수이고, 기존 추천 탭 통합 조회와 전체보기 API의 기존 보안 정책은 변경되지 않는다.
- [x] **Task 3.2: 기존 추천 탭 응답 스키마 회귀 테스트**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- RED: `shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc` 테스트를 추가한다. `HomeLiveItem(roomId = 1L, creatorNickname = "creator", creatorProfileImage = "https://cdn.test/profile.png")`를 직렬화하고 `title`, `price`, `beginDateTimeUtc` 필드가 없음을 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- GREEN: `HomeRecommendationFacade`의 기존 `HomeLiveRecommendationRecord.toItem()` 매핑은 `roomId`, `creatorNickname`, `creatorProfileImage`만 사용하도록 유지한다.
- REFACTOR: 신규 API DTO와 기존 추천 탭 DTO import가 섞이지 않도록 패키지를 명확히 유지한다.
- 기대 결과: 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마는 변경되지 않는다.
- [x] **Task 3.3: End-to-end 조회 검증**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- RED: `shouldReturnAuthenticatedOnAirLivesWithTitlePriceAndBeginDateTimeUtc` 통합 테스트를 작성한다. 인증 회원, 활성 크리에이터, 진행 중 라이브 2개를 저장하고 `GET /api/v2/home/on-air-lives?page=0` 호출 결과에서 최신순, `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page = 0`, `size = 20`, `hasNext = false`를 검증한다.
- RED: `shouldExcludeAdultLiveWhenViewerCannotViewAdultContent` 통합 테스트를 작성한다. 성인 콘텐츠 노출 불가 회원 기준으로 성인 라이브가 제외되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
- GREEN: controller, facade, query repository 연결을 보강해 통합 테스트를 통과시킨다.
- REFACTOR: 테스트 fixture helper는 해당 테스트 클래스 내부 private 함수로 두고, 공용 테스트 유틸은 만들지 않는다.
- 기대 결과: 실제 Spring MVC, Security, JPA/QueryDSL 경로로 신규 API 요구사항이 검증된다.
### Phase 4: 최종 검증과 문서 기록
- [x] **Task 4.1: 단일/회귀 테스트 실행 및 기록**
- Files:
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
- RED: 신규/수정 테스트가 모두 구현된 상태에서 아래 명령을 실행한다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- 실패 확인: 실패가 있으면 해당 task로 돌아가 원인을 수정한다.
- GREEN: 신규 API 관련 단일 테스트가 모두 PASS인지 확인한다.
- REFACTOR: `./gradlew ktlintCheck`를 실행해 포맷 위반을 확인한다.
- 회귀 확인: `./gradlew test`를 실행해 전체 테스트 회귀를 확인한다.
- 기대 결과: 단일 테스트, ktlint, 전체 테스트 결과를 이 task 아래에 한국어로 누적 기록한다.
- 검증 기록:
- 무엇을: 신규 API 관련 controller/facade/DTO/repository/query service 단일 테스트, 신규 API E2E 테스트, ktlint, 전체 회귀 테스트를 실행했다.
- 왜: Phase 1~3 구현 결과가 신규 endpoint 계약과 기존 추천 도메인 회귀 범위를 유지하는지 최종 확인하기 위해서다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했다.
- 결과: 단일 테스트 6개 명령과 `ktlintCheck`는 모두 `BUILD SUCCESSFUL`로 통과했다. `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했고, 실패 테스트는 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 검증은 모두 통과했으므로 전체 회귀 실패는 기존 하단 검증 기록과 같은 범위 외 잔여 실패로 기록한다.
- [x] **Task 4.2: 문서 동기화 확인**
- Files:
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
- RED: 구현 중 endpoint, response field, 인증 정책, page size가 바뀌었는지 확인한다.
- 실패 확인: PRD와 구현이 다르면 구현 전에 PRD와 plan-task를 먼저 갱신한다.
- GREEN: 변경 사항이 없으면 문서 경로와 검증 결과만 유지한다.
- REFACTOR: `./gradlew tasks --all`을 실행해 문서 유지보수 규칙의 명령 유효성을 확인한다.
- 기대 결과: PRD와 plan-task가 같은 endpoint, response data class, 인증 정책, 페이징 정책을 설명한다.
- 검증 기록:
- 무엇을: PRD와 plan-task의 endpoint, response field, 인증 정책, page size 설명이 구현/테스트 대상과 같은지 확인했다.
- 왜: Phase 4에서 최종 문서 계약이 실제 신규 API 구현과 어긋나지 않도록 하기 위해서다.
- 어떻게: `docs/20260626_현재진행중인라이브조회_API/prd.md`, 이 문서의 확정 사항/실행 명령, `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `HomeOnAirLiveEndToEndTest`의 검증 범위를 대조하고 `./gradlew tasks --all`을 실행했다.
- 결과: PRD와 plan-task 모두 `GET /api/v2/home/on-air-lives`, 인증 회원 전용, page size 20, `items/page/size/hasNext`, `roomId/creatorNickname/creatorProfileImage/title/price/beginDateTimeUtc` 응답 필드를 동일하게 설명한다. `./gradlew tasks --all``BUILD SUCCESSFUL`로 통과했다.
---
## 4. 실행 명령
- 컨트롤러 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- facade 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
- DTO 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
- repository 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- query service 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- 신규 API E2E 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
- 포맷 검증: `./gradlew ktlintCheck`
- 전체 회귀 테스트: `./gradlew test`
- Gradle 명령 유효성 확인: `./gradlew tasks --all`
---
## 5. 검증 기록
- 문서 작성 시점에는 구현을 진행하지 않았으므로 테스트 실행 기록은 없다.
- 2026-06-26 문서 작성 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-26 `beginDateTimeUtc` 응답 필드 문서 보강 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-26 Phase 1/2 RED 확인: 신규 테스트 추가 후 `HomeLiveRecommendationRecord.title/price/beginDateTime`, `HomeOnAirLiveResponse`, `HomeOnAirLiveFacade`, `HomeOnAirLiveController` 미구현으로 `:compileTestKotlin FAILED`를 확인했다.
- 2026-06-26 Phase 1/2 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-26 Phase 1/2 포맷 검증: `./gradlew ktlintCheck`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-26 전체 회귀 확인: `./gradlew test`는 1026개 테스트 중 1개 실패로 종료했다. 실패 테스트는 `kr.co.vividnext.sodalive.content.AudioContentServiceTest.shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive`이며, 동일 테스트 단독 재실행도 같은 `HomeFollowingNewsPublishService` mock interaction 검증 실패를 재현했다. 이번 Phase 1/2 변경 파일은 `v2/recommendation`, `v2/api/home/live`, 문서에 한정되어 해당 실패는 범위 외 잔여 실패로 기록한다.
- 2026-06-27 Phase 3 RED 확인: `HomeOnAirLiveEndToEndTest` 신규 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`를 실행했고, `$.data.items.length()`가 기대값 2가 아닌 3으로 실패하는 것을 확인했다. 실패 원인은 신규 E2E 테스트 메서드 간 H2 fixture 공유로 확인했다.
- 2026-06-27 Phase 3 GREEN 확인: `SecurityConfig``GET /api/v2/home/on-air-lives` 인증 matcher를 명시하고 E2E 테스트 격리를 보강한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 각각 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27 Phase 3 포맷 검증: `./gradlew ktlintCheck`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27 Phase 3 회귀 묶음 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27 Phase 3 코드 리뷰 보강: `HomeRecommendationResponseTest.shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc`에 테스트 스타일 규칙에 맞는 `@DisplayName`을 추가했다. 이후 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest``./gradlew --no-daemon ktlintCheck`를 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27 Phase 4 단일/E2E/포맷 검증: `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`, `HomeOnAirLiveEndToEndTest`를 각각 `./gradlew test --tests ...`로 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다. 이어서 `./gradlew ktlintCheck``BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27 Phase 4 전체 회귀 확인: `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했다. 실패 테스트는 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 테스트는 모두 통과했고, 실패 위치가 `content.AudioContentServiceTest`로 이번 Phase 4 문서 기록 범위 및 신규 `v2/api/home/live`, `v2/recommendation` 변경 범위 밖이므로 잔여 실패로 기록한다.
- 2026-06-27 Phase 4 문서 동기화 확인: PRD와 plan-task가 `GET /api/v2/home/on-air-lives`, 인증 회원 전용, page size 20, `items/page/size/hasNext`, `roomId/creatorNickname/creatorProfileImage/title/price/beginDateTimeUtc` 응답 필드를 동일하게 설명하는지 확인했다. 문서 유지보수 규칙 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27 Phase 4 코드 리뷰 보강: `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`에 테스트 스타일 규칙에 맞는 `@DisplayName`을 추가했다. 이후 `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`, `HomeOnAirLiveEndToEndTest`를 각각 `./gradlew test --tests ...`로 재실행했고 모두 `BUILD SUCCESSFUL`을 확인했다. `./gradlew ktlintCheck``./gradlew tasks --all``BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27 Phase 4 전체 회귀 재확인: `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했다. 실패 테스트는 기존 기록과 동일하게 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 테스트는 모두 통과했으므로 범위 외 잔여 실패로 유지한다.

View File

@@ -0,0 +1,186 @@
# PRD: 현재 진행 중인 라이브 조회 API
## 1. Overview
메인 홈에서 현재 진행 중인 라이브 목록을 20개씩 페이징 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 메인 홈 추천 탭 통합 API는 상단에 현재 진행 중인 라이브를 일부 내려주지만, 별도 목록 조회에 필요한 응답 필드가 부족하다.
- 기존 `GET /api/v2/home/recommendations/lives``roomId`, `creatorNickname`, `creatorProfileImage`만 내려주며 이번 요구사항의 `title`, `price`, `beginDateTimeUtc`를 포함하지 않는다.
- 기존 공개 API 스키마를 변경하면 클라이언트 회귀 영향이 생길 수 있으므로, 신규 API 계약을 별도로 명시해야 한다.
- 기존 v2 홈 추천/팔로잉 탭에는 현재 진행 중인 라이브 조회 조건과 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재활용해야 한다.
---
## 3. Goals
- 현재 진행 중인 라이브 목록 조회 API를 `kr.co.vividnext.sodalive.v2` 하위에 제공한다.
- 한 page당 20개씩 조회한다.
- 응답 item에는 `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`를 포함한다.
- 기존 패턴과 동일하게 클라이언트 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
- 기존 메인 홈 추천 탭의 라이브 조회 조건을 최대한 재사용한다.
- 인증 회원만 조회할 수 있게 하고, 회원별 차단/성인 콘텐츠 노출 조건을 반영한다.
- 기존 공개 API 응답 스키마는 변경하지 않는다.
---
## 4. Non-Goals
- 기존 `GET /api/v2/home/recommendations` 응답 스키마를 변경하지 않는다.
- 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마를 변경하지 않는다.
- 라이브 생성, 예약, 입장, 종료 API는 포함하지 않는다.
- 라이브 추천 산식, 스냅샷, 랭킹, 배너 정책은 변경하지 않는다.
- 앱 표시용 가격 단위, 다국어 문구, 날짜 포맷은 서버에서 처리하지 않는다.
- 20개 외 page size를 클라이언트가 지정하는 기능은 이번 범위에 포함하지 않는다.
---
## 5. Target Users
- 회원: 메인 홈에서 현재 진행 중인 라이브 목록을 더 탐색하는 사용자
- 앱 클라이언트: 현재 라이브 목록 화면 또는 추천 탭의 추가 로딩 화면을 구성하는 클라이언트
---
## 6. User Stories
- 사용자는 메인 홈에서 현재 진행 중인 라이브를 20개씩 추가로 보고 싶다.
- 사용자는 라이브 제목과 가격을 목록에서 바로 확인하고 싶다.
- 앱 클라이언트는 다음 page 존재 여부를 응답에서 확인해 무한 스크롤 또는 더보기 UI를 구성하고 싶다.
- 앱 클라이언트는 기존 추천 탭 상단 라이브와 동일한 노출 정책으로 별도 목록을 조회하고 싶다.
---
## 7. Core Features
### Feature A. 현재 진행 중인 라이브 목록 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/home/on-air-lives`로 정의한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- `page` query parameter를 받는다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `size` query parameter는 받지 않고, page size는 항상 20으로 고정한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단한다.
- 응답 목록에는 최대 20개만 내려준다.
- 인증 회원만 조회할 수 있다.
- 인증 회원 조회는 기존 v2 컨트롤러 패턴과 동일하게 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 사용한다.
- `member == null`이면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 현재 진행 중인 라이브는 기존 홈 추천 라이브와 동일하게 `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''` 조건을 기본으로 한다.
- 방송자는 `member.is_active = true`인 대상만 노출한다.
- 정렬은 기존 홈 추천 라이브와 동일하게 `live_room.begin_date_time desc`, `live_room.id desc`로 한다.
- 양방향 차단 관계가 있는 크리에이터의 라이브는 제외한다.
- 성인 라이브 노출 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 따른다.
- 프로필 이미지는 기존 홈 추천/팔로잉 탭과 동일하게 CDN URL로 변환하고, 값이 없으면 기본 프로필 이미지 URL을 내려준다.
#### Edge Cases
- 조회 결과가 없으면 `items = emptyList()`, `hasNext = false`를 내려준다.
- 비회원이 조회하면 목록을 내려주지 않고 인증 오류를 반환한다.
- `page`가 0보다 작으면 기존 홈 추천 컨트롤러의 `normalizePage` 패턴과 동일하게 0으로 보정한다.
- 매우 큰 `page` 값은 기존 홈 추천 컨트롤러의 `MAX_PAGE = 10_000` 패턴과 동일하게 상한 보정한다.
- 20개보다 적게 조회되면 가능한 개수만 내려주고 성공 처리한다.
- 라이브 제목이 빈 문자열이면 별도 fallback을 만들지 않고 저장된 `LiveRoom.title` 값을 그대로 내려준다.
- 라이브 가격은 `LiveRoom.price` 값을 그대로 내려준다.
- 라이브 시작 시간은 `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 내려준다.
### Feature B. 계층 분리와 재사용 정책
#### Requirements
- 클라이언트 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home.live` 하위에 둔다.
- API 조립 계층 후보 파일은 다음과 같다.
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
- 도메인 조회 계층은 기존 `kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService``HomeRecommendationQueryPort.findLiveRecommendations(...)` 확장 재사용을 기본안으로 한다.
- 기존 `HomeLiveRecommendationRecord``title`, `price`, `beginDateTime`을 추가해 신규 API DTO로 조립할 수 있게 한다.
- 기존 `HomeLiveItem`은 기존 필드만 매핑해 기존 추천 탭 공개 응답 스키마를 유지한다.
- 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`의 조회 조건과 정렬을 유지하되 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime` select를 추가한다.
- API 조립 계층은 도메인 조회 결과를 공개 응답 DTO로 변환하고, CDN URL 변환/기본 프로필 이미지 정책은 기존 홈 추천 패턴을 따른다.
#### Edge Cases
- `HomeRecommendationQueryService` 확장으로 추천 도메인 결합이 과도하다고 판단되면 구현 계획 단계에서 `kr.co.vividnext.sodalive.v2.home.live` 하위 전용 query service/port/repository를 만들 수 있다. 이 경우에도 기존 조회 조건, 정렬, 테스트 케이스는 동일하게 유지한다.
- 기존 record 확장 시 생성자 projection 순서와 모든 매핑 호출부를 함께 수정해야 한다.
### Feature C. Response 스키마
#### Requirements
- 응답 최상위 DTO 이름은 `HomeOnAirLivePageResponse`를 기본안으로 한다.
- 응답 item DTO 이름은 `HomeOnAirLiveResponse`를 기본안으로 한다.
- 응답 item은 다음 값을 포함한다.
- `roomId`: 라이브 방 id
- `creatorNickname`: 방송자 닉네임
- `creatorProfileImage`: 방송자 프로필 이미지 CDN URL
- `title`: 라이브 제목
- `price`: 라이브 입장 가격
- `beginDateTimeUtc`: 라이브 시작 시간 UTC ISO 문자열
- page metadata는 기존 `HomeRecommendationPageResponse`와 동일한 의미로 `page`, `size`, `hasNext`를 포함한다.
- `size`는 항상 `20`으로 내려준다.
#### Edge Cases
- `creatorProfileImage` 원본 값이 없으면 기본 프로필 이미지 CDN URL을 내려준다.
- `price`가 무료이면 `0`을 내려준다.
- `beginDateTimeUtc``LiveRoom.beginDateTime`을 UTC ISO 문자열로 변환한 값으로 내려준다.
---
## 8. API Endpoint
```http
GET /api/v2/home/on-air-lives?page=0
Authorization: Bearer {accessToken}
```
- `page`: 선택값, 기본값 `0`, 0부터 시작하는 page index
- `size`: 받지 않음, 서버에서 20으로 고정
- `SecurityConfig``GET /api/v2/home/on-air-lives` authenticated 설정을 추가한다.
- 회원 token이 없거나 anonymous이면 기존 인증 필요 API와 동일하게 인증 오류를 반환한다.
- 인증 회원 기준으로 차단/성인 콘텐츠 노출 조건을 반영한다.
---
## 9. Response Data Class
```kotlin
data class HomeOnAirLivePageResponse(
val items: List<HomeOnAirLiveResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class HomeOnAirLiveResponse(
val roomId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val title: String,
val price: Int,
val beginDateTimeUtc: String
)
```
---
## 10. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 신규 코드는 기존 v2 패키지 구조와 네이밍을 따른다.
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.*` 하위에 두고, 재사용 가능한 조회 책임은 API 패키지 밖 도메인 조회 계층에 둔다.
- 기존 `ApiResponse.ok(...)` 응답 wrapper를 사용한다.
- QueryDSL 기반 조회 패턴을 유지한다.
- 공개 API 스키마 변경은 신규 endpoint에만 한정한다.
- 구현 계획 단계에서는 TDD 기준으로 controller/facade/query repository 테스트를 작성한 뒤 최소 구현한다.
---
## 11. Reuse Candidates
- `HomeRecommendationController`: page 정규화, `ApiResponse.ok(...)`, `requireMember(...)` 인증 필수 패턴 참고
- `HomeRecommendationFacade`: `size + 1` 조회 후 `hasNext`를 판단하는 page 응답 조립 패턴 참고
- `HomeRecommendationPageResponse`: page metadata 의미 참고
- `HomeRecommendationQueryService.findLiveRecommendations(...)`: 현재 진행 중인 라이브 도메인 조회 진입점으로 확장 재사용
- `HomeRecommendationQueryPort.HomeLiveRecommendationRecord`: `title`, `price`, `beginDateTime`을 추가해 신규 API 응답 조립에 재사용
- `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`: 진행 중 라이브 조건, 정렬, 차단 필터, 성인 라이브 필터 재사용
- `HomeFollowingLive`/`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`: `title` 포함 라이브 응답 모델링과 CDN URL 변환 패턴 참고
- `LiveRoom`: `title`, `price`, `beginDateTime`, `channelName`, `isAdult`, `isActive` 필드 사용
- `MemberContentPreferenceService.canViewAdultContent(member)`: 성인 라이브 노출 가능 여부 판단
---
## 12. Open Questions
- 없음. 현재 PRD는 인증 회원만 조회 가능, page size 20 고정, 기존 추천 라이브 조건 재사용을 기본 가정으로 작성한다.

View File

@@ -0,0 +1,94 @@
# 관리자 회원 목록 LazyInitializationException 수정 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` 또는 동등한 TDD 절차로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `spring.jpa.open-in-view=false` 환경에서 관리자 회원 리스트와 크리에이터 리스트 조회가 `Member.signOutReasons` lazy collection 접근 때문에 실패하지 않게 한다.
**Architecture:** 기존 `AdminMemberService`의 응답 매핑 구조는 유지한다. 서비스 클래스에 read-only 트랜잭션을 기본 적용해 목록 조회와 응답 매핑 전체를 열린 영속성 컨텍스트 안에서 처리한다. 쓰기 메서드는 기존 메서드 레벨 `@Transactional`로 read-only 기본값을 override한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL, JUnit 5, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API 응답 스키마는 변경하지 않는다.
- `Member.signOutReasons`를 eager로 바꾸지 않는다.
- OSIV 설정을 켜지 않는다.
- 리포지토리 fetch join이나 projection 전면 개편은 이번 범위에서 제외한다.
- lazy 접근 문제가 확인된 대상 메서드:
- `AdminMemberService.getMemberList(...)`
- `AdminMemberService.searchMember(...)`
- `AdminMemberService.getCreatorList(...)`
- `AdminMemberService.searchCreator(...)`
---
## 1. 파일 구조 계획
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt`
- 클래스 레벨에 `@Transactional(readOnly = true)`를 추가하고, 기존 쓰기 메서드의 `@Transactional`은 유지한다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt`
- OSIV off 환경에서 탈퇴 이력이 있는 회원/크리에이터 목록 조회가 예외 없이 응답되는지 검증한다.
- Verify: `src/test/resources/application.yml`
- `spring.jpa.open-in-view: false` 테스트 설정을 그대로 사용한다.
---
### Phase 1: LazyInitializationException 재현 테스트
- [x] **Task 1.1: 관리자 회원/크리에이터 목록 실패 테스트 작성**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt`
- RED: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])` 통합 테스트를 추가한다.
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않아 서비스 호출이 테스트 트랜잭션에 의해 가려지지 않게 한다.
- RED: `MemberRole.USER` 회원과 `MemberRole.CREATOR` 회원을 저장하고, 각각 `SignOut`을 저장한다.
- RED: `service.getMemberList(PageRequest.of(0, 20))`, `service.getCreatorList(PageRequest.of(0, 20))`를 호출해 `signOutDate`가 비어 있지 않고 예외가 발생하지 않기를 기대한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`
- 기대 결과: production code 수정 전에는 `LazyInitializationException`으로 테스트가 실패한다.
- 구현 기록(2026-06-27): `AdminMemberServiceTest`를 추가해 `@Transactional` 없는 테스트 클래스에서 서비스 목록 조회를 호출하도록 했다.
- 1차 RED: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest` 실행 시 Redis 연결 실패로 Spring context 생성이 실패해 의도한 실패가 아니었다.
- 보정: 기존 통합 테스트 패턴에 맞춰 `EmbeddedRedisInitializer`를 추가했다.
- 2차 RED: 같은 명령 재실행 결과 `getMemberList`, `getCreatorList` 모두 `LazyInitializationException`으로 실패해 OSIV off lazy collection 접근 문제를 재현했다.
---
### Phase 2: 서비스 read-only 트랜잭션 보강
- [x] **Task 2.1: 서비스 클래스에 read-only 트랜잭션 기본값 추가**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt`
- GREEN: `AdminMemberService` 클래스에 `@Transactional(readOnly = true)`를 추가한다.
- GREEN: `updateMember`, `resetPassword`의 기존 메서드 레벨 `@Transactional`은 유지해 쓰기 트랜잭션으로 동작하게 한다.
- GREEN: 응답 매핑 로직과 리포지토리 쿼리는 변경하지 않는다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 불필요한 import/format 변경이 생기지 않았는지 확인한다.
- 구현 기록(2026-06-27): 최초 구현에서는 `getMemberList`, `searchMember`, `getCreatorList`, `searchCreator``@Transactional(readOnly = true)`를 추가했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 검증 이유: OSIV off 환경에서 서비스 메서드의 read-only 트랜잭션 안에서 `signOutReasons``auth` lazy 접근이 완료되는지 확인했다.
- 후속 수정(2026-06-27): 리뷰 피드백에 따라 개별 조회 메서드 annotation을 제거하고 `AdminMemberService` 클래스 레벨 `@Transactional(readOnly = true)`로 정리했다. 쓰기 메서드 `updateMember`, `resetPassword`는 기존 메서드 레벨 `@Transactional`을 유지했다.
---
### Phase 3: 회귀 검증과 문서 기록
- [x] **Task 3.1: 관련 검증 실행 및 문서 기록**
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`
- Verify: `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `:app` 모듈이 없으면 실행하지 않고 `./gradlew ktlintCheck`로 대체한다.
- Verify: `./gradlew ktlintCheck`
- Verify: `./gradlew tasks --all`
- 문서 기록: 각 task 아래에 실행 명령, 결과, 검증 이유를 한국어로 누적한다.
- 구현 기록(2026-06-27): 관련 단일 테스트, ktlint, Gradle task 목록 검증을 실행했다.
- 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint 1차: `./gradlew ktlintCheck``./gradlew tasks --all`과 동시에 실행했을 때 `~/.gradle` wrapper lock 파일 접근 sandbox 오류로 실패했다.
- ktlint 재실행: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 명령 유효성: `./gradlew --no-daemon tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
---
## 검증 기록
- 2026-06-27: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`로 OSIV off lazy collection 재현 테스트가 수정 후 통과함을 확인했다.
- 2026-06-27: `./gradlew --no-daemon ktlintCheck`로 Kotlin formatting 검증이 통과함을 확인했다.
- 2026-06-27: `./gradlew --no-daemon tasks --all`로 문서에 안내된 Gradle 명령 목록이 유효함을 확인했다.
- 2026-06-27: 최종 확인으로 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest``./gradlew --no-daemon ktlintCheck`를 재실행했고 둘 다 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27: 클래스 레벨 `@Transactional(readOnly = true)` 후속 변경 후 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest``./gradlew --no-daemon ktlintCheck`를 재실행했고 둘 다 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,77 @@
# PRD: 관리자 회원 목록 LazyInitializationException 수정
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 관리자 회원 리스트와 크리에이터 리스트 조회 시 `Member.signOutReasons` lazy collection 접근으로 발생하는 `LazyInitializationException`을 방지한다.
---
## 2. Problem
- 관리자 회원 목록 응답 생성 중 `Member.signOutReasons`를 읽어 탈퇴일을 계산한다.
- 현재 `AdminMemberService`의 목록 조회 메서드는 트랜잭션 경계가 없어 QueryDSL 조회 후 영속성 컨텍스트가 닫힌 상태에서 lazy collection을 접근할 수 있다.
- `spring.jpa.open-in-view=false` 환경에서는 이 접근이 `org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.member.Member.signOutReasons`로 이어진다.
- 같은 응답 매핑 흐름을 사용하는 관리자 회원 리스트, 회원 검색, 크리에이터 리스트, 크리에이터 검색 모두 같은 위험이 있다.
---
## 3. Goals
- `osiv=false` 환경에서도 관리자 회원 리스트와 크리에이터 리스트가 예외 없이 응답된다.
- 기존 API 응답 스키마와 정렬/필터 조건을 변경하지 않는다.
- 탈퇴 이력이 있는 회원의 `signOutDate` 계산 동작을 유지한다.
- 서비스 계층에 명확한 read-only 트랜잭션 기본 경계를 둔다.
- 실패 재현 테스트를 먼저 작성하고, 최소 수정으로 통과시킨다.
---
## 4. Non-Goals
- 관리자 회원 목록 API의 응답 필드를 변경하지 않는다.
- `Member.signOutReasons` fetch 전략을 전역 eager로 바꾸지 않는다.
- 목록 조회를 projection 전용 쿼리로 전면 개편하지 않는다.
- pagination/count 쿼리 구조를 리팩터링하지 않는다.
- OSIV 설정을 다시 켜지 않는다.
---
## 5. Target Users
- 관리자: 관리자 화면에서 회원 목록과 크리에이터 목록을 조회하는 사용자
- 운영자: 탈퇴 또는 차단 이력이 있는 회원을 포함한 목록을 안정적으로 확인해야 하는 사용자
---
## 6. User Stories
- 관리자는 탈퇴 이력이 있는 일반 회원이 포함된 회원 리스트를 조회해도 서버 오류를 만나지 않아야 한다.
- 관리자는 탈퇴 이력이 있는 크리에이터가 포함된 크리에이터 리스트를 조회해도 서버 오류를 만나지 않아야 한다.
- 관리자는 검색 결과에서도 동일하게 탈퇴일과 활성 상태를 확인할 수 있어야 한다.
---
## 7. Core Features
### Feature A. 관리자 회원 목록 조회 트랜잭션 보강
#### Requirements
- `AdminMemberService`는 클래스 레벨 `@Transactional(readOnly = true)`로 조회 기본 트랜잭션을 제공한다.
- `AdminMemberService.getMemberList(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.searchMember(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.getCreatorList(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.searchCreator(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.updateMember(...)`, `AdminMemberService.resetPassword(...)`는 메서드 레벨 `@Transactional`로 쓰기 트랜잭션을 유지한다.
- 기존 `processMemberListToGetAdminMemberListResponseItemList(...)`의 응답 필드 계산 방식은 유지한다.
#### Edge Cases
- `signOutReasons`가 비어 있으면 기존처럼 `signOutDate`는 빈 문자열이다.
- `signOutReasons`가 있으면 기존처럼 마지막 탈퇴 이력의 `createdAt`을 KST `yyyy-MM-dd HH:mm` 형식으로 내려준다.
- `auth` lazy one-to-one 접근도 같은 read-only 트랜잭션 안에서 처리되어야 한다.
---
## 8. Technical Constraints
- Kotlin + Spring Boot 2.7.14 + Spring Data JPA 기준으로 구현한다.
- 테스트 환경의 `spring.jpa.open-in-view=false` 설정을 유지한다.
- 서비스 클래스에는 `@Transactional(readOnly = true)`를 사용하고, 쓰기 메서드는 기존 메서드 레벨 `@Transactional`을 유지한다.
- 변경 범위는 `AdminMemberService`와 해당 테스트로 제한한다.
---
## 9. Metrics
- `AdminMemberServiceTest`에서 OSIV off 조건의 회원/크리에이터 목록 조회 테스트가 통과한다.
- 관련 단일 테스트와 `ktlintCheck`가 통과한다.

View File

@@ -0,0 +1,804 @@
# 콘텐츠 전체보기 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/contents`로 인증 회원이 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 콘텐츠 전체보기 목록을 동일한 페이징 계약으로 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.overview` 조립 계층에 둔다. New & Hot 조회는 기존 `v2.content.recommendation` 도메인 조회 계층을 확장해 재사용하고, 첫 번째 오디오 콘텐츠 조회는 기존 `v2.recommendation.application.HomeRecommendationQueryService.findFirstAudioContents(...)`를 재사용한다. 기존 홈 하위 전체보기 endpoint는 배포 전 기능이므로 제거하고, 새 콘텐츠 전체보기 API로 책임을 이동한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL/native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/contents`
- 인증 정책: 비회원 조회 불가. 인증 회원만 호출할 수 있다.
- 응답 wrapper: `ApiResponse.ok(...)`
- 요청 query parameter:
- `type`: `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`; 기본값 `NEW_AND_HOT_AUDIO`
- `page`: 0부터 시작. 기본값 `0`
- `size`: 기본값 `20`, 최소값보다 작으면 `20`, 최대 `50`
- invalid `type`은 400 오류 대신 `NEW_AND_HOT_AUDIO`로 fallback한다.
- `hasNext``size + 1`개 조회 후 응답 item은 최대 `size`개만 내려주는 방식으로 계산한다.
- `NEW_AND_HOT_AUDIO``AudioRecommendationQueryService`에 페이징 조회 메서드를 추가해 조회한다.
- New & Hot 첫 화면 노출 수는 `12`로 유지한다.
- New & Hot 스냅샷 저장 수는 `SAFE`, `ALL` 각각 `100`으로 확장한다.
- `FIRST_AUDIO_CONTENT``HomeRecommendationQueryService.findFirstAudioContents(...)`를 새 콘텐츠 전체보기 Facade에서 직접 호출한다.
- `GET /api/v2/home/recommendations/first-audio-contents`는 제거한다.
- 신규 DB 테이블과 DDL은 작성하지 않는다. New & Hot 전체보기용 스냅샷은 기존 `recommendation_snapshot` 테이블을 재사용하고, 저장 개수만 visibility별 100개로 확장한다.
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
### 기존 도메인 조회 계층 확장
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
### 미배포 홈 하위 endpoint 제거
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
### 통합 검증
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
---
## 2. 공개 응답 및 정책 초안
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
data class ContentOverviewPageResponse(
val type: ContentOverviewType,
val items: List<ContentOverviewItemResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO,
FIRST_AUDIO_CONTENT;
companion object {
fun from(value: String?): ContentOverviewType {
return values().firstOrNull { it.name == value } ?: NEW_AND_HOT_AUDIO
}
}
}
data class ContentOverviewItemResponse(
val contentId: Long,
val title: String,
val coverImage: String?,
val price: Int,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
val creatorNickname: String,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean
) {
companion object {
fun fromNewAndHot(audio: AudioCard): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.audioContentId,
title = audio.title,
coverImage = audio.imageUrl,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries
)
}
fun fromFirstAudioContent(
audio: HomeFirstAudioContentRecord,
coverImage: String?
): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.contentId,
title = audio.title,
coverImage = coverImage,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = true,
isOriginalSeries = audio.isOriginalSeries
)
}
}
}
```
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt`
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
data class ContentOverviewPage(
val page: Int,
val size: Int
) {
val offset: Long = page.toLong() * size
}
class ContentOverviewQueryPolicy {
fun resolveType(type: String?): ContentOverviewType {
return ContentOverviewType.from(type)
}
fun createPage(page: Int?, size: Int?): ContentOverviewPage {
val resolvedPage = (page ?: DEFAULT_PAGE).coerceAtLeast(DEFAULT_PAGE)
val requestedSize = size ?: DEFAULT_SIZE
val resolvedSize = if (requestedSize < 1) DEFAULT_SIZE else minOf(requestedSize, MAX_SIZE)
return ContentOverviewPage(page = resolvedPage, size = resolvedSize)
}
fun <T> pageItems(items: List<T>, page: ContentOverviewPage): List<T> {
return items.take(page.size)
}
fun <T> hasNext(items: List<T>, page: ContentOverviewPage): Boolean {
return items.size > page.size
}
companion object {
const val DEFAULT_PAGE = 0
const val DEFAULT_SIZE = 20
const val MAX_SIZE = 50
}
}
```
---
## 3. 테스트 helper 기준
아래 helper는 각 테스트 파일에서 필요한 범위만 복사해 사용한다. Kotlin 1.6.21을 사용하므로 enum 변환 구현에는 `entries`가 아니라 `values()`를 사용한다.
```kotlin
private fun member(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun audioCard(id: Long): AudioCard {
return AudioCard(
audioContentId = id,
title = "audio$id",
duration = "00:01",
imageUrl = "https://cdn.test/audio$id.png",
price = id.toInt(),
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator$id"
)
}
private fun firstAudio(id: Long): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = id,
creatorId = id + 100,
creatorNickname = "creator$id",
creatorProfileImage = null,
title = "first audio$id",
price = id.toInt(),
coverImage = "cover/audio$id.png",
isPointAvailable = true,
isAdult = false,
isOriginalSeries = false
)
}
private fun snapshot(
sectionType: RecommendedSectionType,
targetId: Long,
score: Double = 100.0 - targetId,
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 6, 26, 23, 59, 59)
): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = targetId,
score = score,
snapshotAt = snapshotAt,
randomTieBreaker = targetId.toDouble() / 1000
)
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.of(2026, 6, 27, 0, 0)
}
private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageResponse {
return ContentOverviewPageResponse(
type = type,
items = emptyList(),
page = 0,
size = 20,
hasNext = false
)
}
```
---
### Phase 1: 콘텐츠 전체보기 응답/요청 정책 작성
- [x] **Task 1.1: ContentOverview DTO 직렬화 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
- RED: `ContentOverviewPageResponse``ContentOverviewItemResponse``JsonProperty` 필드명을 검증하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
class ContentOverviewPageResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
fun shouldSerializeContentOverviewPageResponse() {
val response = ContentOverviewPageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
items = listOf(
ContentOverviewItemResponse(
contentId = 1L,
title = "audio",
coverImage = "https://cdn.test/audio.png",
price = 10,
isPointAvailable = true,
creatorNickname = "creator",
isAdult = false,
isFirstContent = true,
isOriginalSeries = false
)
),
page = 0,
size = 20,
hasNext = true
)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals("NEW_AND_HOT_AUDIO", json["type"].asText())
assertEquals(true, json["hasNext"].asBoolean())
assertEquals(1L, json["items"][0]["contentId"].asLong())
assertEquals("https://cdn.test/audio.png", json["items"][0]["coverImage"].asText())
assertEquals(true, json["items"][0]["isPointAvailable"].asBoolean())
assertEquals(false, json["items"][0]["isAdult"].asBoolean())
assertEquals(true, json["items"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["items"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["items"][0].has("audioContentId"))
assertEquals(false, json["items"][0].has("imageUrl"))
assertEquals(false, json["items"][0].has("duration"))
assertEquals(false, json["items"][0].has("creatorId"))
assertEquals(false, json["items"][0].has("creatorProfileImage"))
assertEquals(false, json["items"][0].has("pointAvailable"))
assertEquals(false, json["items"][0].has("adult"))
assertEquals(false, json["items"][0].has("firstContent"))
assertEquals(false, json["items"][0].has("originalSeries"))
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest`
- 기대 결과: DTO 파일이 없어서 `compileTestKotlin` 실패.
- GREEN: 위 DTO 초안을 추가하고 테스트를 통과시킨다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest`
- REFACTOR: import 정리 후 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest` 실행 시 `ContentOverviewPageResponse`, `ContentOverviewType`, `ContentOverviewItemResponse` 미구현으로 `compileTestKotlin` 실패.
- GREEN: DTO 구현 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REVIEW 보완: `fromFirstAudioContent(...)`가 성인/오리지널 플래그를 전달하는 테스트를 추가했다. 보완 RED는 `isAdult`, `isOriginalSeries` 파라미터 미존재로 `compileTestKotlin` 실패했고, 시그니처 보강 후 같은 DTO 테스트가 `BUILD SUCCESSFUL`.
- [x] **Task 1.2: ContentOverviewQueryPolicy 테스트와 구현 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt`
- RED: type/page/size 보정 정책 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
class ContentOverviewQueryPolicyTest {
private val policy = ContentOverviewQueryPolicy()
@Test
fun shouldResolveTypeWithDefaultFallback() {
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType(null))
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType("UNKNOWN"))
assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, policy.resolveType("FIRST_AUDIO_CONTENT"))
}
@Test
fun shouldNormalizePageAndSize() {
assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(null, null))
assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(-1, 0))
assertEquals(ContentOverviewPage(page = 2, size = 50), policy.createPage(2, 100))
}
@Test
fun shouldCalculatePageItemsAndHasNext() {
val page = ContentOverviewPage(page = 0, size = 2)
val items = listOf(1, 2, 3)
assertEquals(listOf(1, 2), policy.pageItems(items, page))
assertEquals(true, policy.hasNext(items, page))
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest`
- 기대 결과: policy 파일이 없어서 `compileTestKotlin` 실패.
- GREEN: 위 policy 초안을 추가하고 테스트를 통과시킨다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest`
- REFACTOR: `ContentOverviewType.from(...)`와 page 보정 로직이 DTO/Facade에 중복되지 않게 유지하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest` 실행 시 `ContentOverviewQueryPolicy`, `ContentOverviewPage` 미구현으로 `compileTestKotlin` 실패.
- GREEN: policy 구현 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REVIEW 보완: `size = 19`가 기본 size `20`으로 보정되는 테스트를 추가하고, `MIN_SIZE = 20` 정책을 반영했다. 보완 후 같은 policy 테스트가 `BUILD SUCCESSFUL`.
- REVIEW 보완: 큰 `page` 입력에서 `offset`이 Int overflow 되지 않도록 `offset: Long = page.toLong() * size`로 변경했다. 보완 RED는 `Int.MAX_VALUE, size = 50` offset assertion 실패였고, 수정 후 같은 policy 테스트가 `BUILD SUCCESSFUL`.
- REVIEW 보완: 후속 Phase에서 `ContentOverviewPage.offset`을 그대로 넘길 수 있도록 `RecommendationSnapshotPort`, `HomeRecommendationQueryPort`, 관련 service/adapter/repository offset 계약과 문서 예시를 `Long`으로 정렬했다.
- Phase 1 묶음: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
- 참고: `./gradlew test` 전체 실행은 다수 테스트의 XML 결과 파일 write 실패로 중단되어 Phase 1 로직 실패로 보지 않는다.
---
### Phase 2: New & Hot 스냅샷 저장 수와 페이징 조회 분리
- [x] **Task 2.1: New & Hot 스냅샷 저장 limit 100 테스트 작성**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- RED: `refreshDailySnapshots(now)`가 New & Hot 후보 조회 시 `limit = 100`을 전달하는 실패 테스트를 추가한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("New & Hot 스냅샷은 visibility별 100개 후보를 저장한다")
fun shouldRequestOneHundredNewAndHotSnapshotsPerVisibility() {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort)
val now = LocalDateTime.of(2026, 6, 27, 0, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 6, 26, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 6, 24, 0, 0, 0)
service.refreshDailySnapshots(now)
Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 100)
Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 100)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- 기대 결과: 현재 구현이 `NEW_AND_HOT_LIMIT = 12`를 사용하므로 verify가 실패.
- GREEN: `AudioRecommendationSnapshotRefreshService`에서 `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`을 추가하고 New & Hot 저장 조회에 사용한다.
- 구현 기준:
```kotlin
companion object {
const val NEW_AND_HOT_SNAPSHOT_LIMIT = 100
const val MOST_COMMENTED_LIMIT = 5
const val RECOMMENDED_AUDIO_LIMIT = 10
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- REFACTOR: 기존 `NEW_AND_HOT_LIMIT` 이름이 남아 있으면 저장 limit 의미가 드러나는 `NEW_AND_HOT_SNAPSHOT_LIMIT`으로 정리하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 실행 시 `shouldRequestOneHundredNewAndHotSnapshotsPerVisibility`가 기존 `limit = 12` 호출과 기대 `100` 차이로 `ArgumentsAreDifferent` 실패.
- GREEN: `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`으로 저장 조회 limit을 분리한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
- [x] **Task 2.2: AudioRecommendationQueryService의 첫 화면 12개 조회 회귀 테스트 작성**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- RED: `getRecommendations(member)`는 New & Hot 첫 화면 조회 시 여전히 12개만 요청하는 회귀 테스트를 추가한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다")
fun shouldKeepNewAndHotHomeLimitAtTwelve() {
val member = member(id = 10L)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()).`when`(snapshotPort)
.findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12)
queryService.getRecommendations(member)
Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- 기대 결과: 상수명을 아직 분리하지 않았거나 test helper가 없으면 컴파일 또는 verify 실패.
- GREEN: `AudioRecommendationQueryService`에 `NEW_AND_HOT_HOME_LIMIT = 12`를 추가하고 첫 화면 조회와 lazy refresh 재조회에 사용한다.
- 구현 기준:
```kotlin
companion object {
const val NEW_AND_HOT_HOME_LIMIT = 12
// 기존 NEW_AND_HOT_AUDIO_LIMIT 사용처는 첫 화면 의미이면 NEW_AND_HOT_HOME_LIMIT로 교체한다.
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- REFACTOR: 첫 화면 limit과 스냅샷 저장 limit 이름이 섞이지 않게 import/상수명을 정리하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 실행 시 `NEW_AND_HOT_HOME_LIMIT` 미구현으로 `compileTestKotlin` 실패.
- GREEN: `NEW_AND_HOT_HOME_LIMIT = 12`를 추가하고 홈 첫 화면 조회와 lazy refresh 재조회에서 사용하도록 정리한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
- [x] **Task 2.3: New & Hot 전체보기 페이징 조회 테스트 작성**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- RED: `findNewAndHotAudios(member, offset, limit)`가 visibility, offset, limit을 반영하고 상세 조회 순서를 유지하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("New & Hot 전체보기는 스냅샷 offset과 limit으로 오디오 카드를 조회한다")
fun shouldFindNewAndHotAudiosWithOffsetAndLimit() {
val member = member(id = 10L)
val nowSnapshots = listOf(
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 3L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 4L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 5L)
)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(nowSnapshots).`when`(snapshotPort)
.findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20L, limit = 21)
Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort)
.findAudioCardsByIds(listOf(3L, 4L, 5L), member.id, true, anyLocalDateTime())
val result = queryService.findNewAndHotAudios(member, offset = 20L, limit = 21)
assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId })
Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20L, limit = 21)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- 기대 결과: `findNewAndHotAudios` 메서드가 없어 `compileTestKotlin` 실패.
- GREEN: `AudioRecommendationQueryService.findNewAndHotAudios(member, offset, limit)`를 추가한다.
- 구현 기준:
```kotlin
fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List<AudioCard> {
val now = LocalDateTime.now()
val canViewAdultContent = canViewAdultContent(member)
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
val sectionType = newAndHotSectionType(visibility)
val snapshots = findNewAndHotSnapshotsWithLazyRefresh(sectionType, offset, limit)
return queryPort.findAudioCardsByIds(
snapshots.map { it.targetId },
member.id,
canViewAdultContent,
now
)
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- REFACTOR: 기존 `refreshMissingNewAndHotSnapshots(...)`는 첫 화면과 전체보기에서 공통 사용 가능한 private 함수로 정리하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 실행 시 `findNewAndHotAudios` 미구현으로 `compileTestKotlin` 실패.
- GREEN: `findNewAndHotAudios(member, offset, limit)`를 추가하고 기존 lazy refresh 재조회가 동일 `offset`, `limit`을 사용하도록 보강한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
---
### Phase 3: 콘텐츠 전체보기 API 조립 계층 작성
- [x] **Task 3.1: ContentOverviewFacade 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- RED: `NEW_AND_HOT_AUDIO`와 `FIRST_AUDIO_CONTENT`를 각각 조회해 `ContentOverviewPageResponse`로 변환하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
class ContentOverviewFacadeTest {
private val audioRecommendationQueryService = Mockito.mock(AudioRecommendationQueryService::class.java)
private val homeRecommendationQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = ContentOverviewFacade(
audioRecommendationQueryService = audioRecommendationQueryService,
homeRecommendationQueryService = homeRecommendationQueryService,
memberContentPreferenceService = memberContentPreferenceService,
cloudFrontHost = "https://cdn.test",
queryPolicy = ContentOverviewQueryPolicy()
)
@Test
fun shouldReturnNewAndHotPage() {
val member = member(id = 10L)
Mockito.doReturn(listOf(audioCard(1L), audioCard(2L), audioCard(3L))).`when`(audioRecommendationQueryService)
.findNewAndHotAudios(member, offset = 0L, limit = 3)
val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 2, member = member)
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type)
assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
assertEquals(listOf("https://cdn.test/audio1.png", "https://cdn.test/audio2.png"), response.items.map { it.coverImage })
assertEquals(true, response.hasNext)
}
@Test
fun shouldReturnFirstAudioContentPage() {
val member = member(id = 10L)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(firstAudio(1L), firstAudio(2L))).`when`(homeRecommendationQueryService)
.findFirstAudioContents(anyLocalDateTime(), offset = 20L, limit = 21, memberId = member.id, includeAdultContents = true)
val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member)
assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, response.type)
assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
assertEquals("https://cdn.test/cover/audio1.png", response.items[0].coverImage)
assertEquals(true, response.items[0].isFirstContent)
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest`
- 기대 결과: Facade 파일이 없어 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewFacade`를 추가하고 `size + 1` 조회, item `take(size)`, `hasNext` 계산을 구현한다.
- GREEN: `HomeFirstAudioContentRecord`에 `isAdult: Boolean`, `isOriginalSeries: Boolean` 필드를 추가하고, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)`가 해당 값을 조회해 채우도록 보강한다.
- 구현 기준:
```kotlin
fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse {
val resolvedType = queryPolicy.resolveType(type)
val resolvedPage = queryPolicy.createPage(page, size)
return when (resolvedType) {
ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage)
ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage)
}
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest`
- REFACTOR: `coverImage` CDN URL 변환은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 타입별 전용 필드 없이 `ContentOverviewItemResponse`의 동일 필드만 채우는지 확인한 뒤 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest` 실행 시 `ContentOverviewFacade` 미구현 및 `HomeFirstAudioContentRecord`의 `isAdult`, `isOriginalSeries` 필드 미구현으로 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewFacade` 추가, `HomeFirstAudioContentRecord` 플래그 확장, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)` 플래그 조회 보강 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REFACTOR: Kotlin Mockito matcher 보정과 Phase 1의 `MIN_SIZE = 20` 정책에 맞춰 테스트 기대값을 정렬했고, `String?.toCdnUrl(cloudFrontHost)`로 coverImage CDN 변환을 유지했다.
- REVIEW 보완: `findFirstAudioContents(...)` native SQL의 오리지널 시리즈 subquery가 실제 `SeriesContent`/`Series` 테이블(`series_content`, `series`)과 FK(`series_id`)를 참조하는지 검증하는 repository 테스트를 추가했다. 보완 RED는 존재하지 않는 `content_series_content` 테이블 참조로 `SQLGrammarException` 실패였고, 테이블/FK명을 실제 스키마에 맞춘 뒤 `DefaultHomeRecommendationQueryRepositoryTest`가 `BUILD SUCCESSFUL`.
- [x] **Task 3.2: ContentOverviewController 인증/파라미터 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt`
- RED: 비회원 요청은 401, 인증 회원 요청은 facade에 type/page/size/member를 전달하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
@WebMvcTest(ContentOverviewController::class)
@Import(SecurityConfig::class)
class ContentOverviewControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: ContentOverviewFacade
@Test
fun shouldRejectAnonymousRequest() {
mockMvc.perform(get("/api/v2/contents"))
.andExpect(status().isUnauthorized)
}
@Test
fun shouldPassAuthenticatedMemberAndQueryParameters() {
val member = member(id = 10L)
Mockito.doReturn(emptyResponse(ContentOverviewType.FIRST_AUDIO_CONTENT)).`when`(facade)
.getContents("FIRST_AUDIO_CONTENT", 1, 30, member)
mockMvc.perform(
get("/api/v2/contents")
.param("type", "FIRST_AUDIO_CONTENT")
.param("page", "1")
.param("size", "30")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT"))
Mockito.verify(facade).getContents("FIRST_AUDIO_CONTENT", 1, 30, member)
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest`
- 기대 결과: Controller 파일이 없어 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewController`를 추가한다.
- 구현 기준:
```kotlin
@RestController
@RequestMapping("/api/v2/contents")
class ContentOverviewController(
private val facade: ContentOverviewFacade
) {
@GetMapping
fun getContents(
@RequestParam(required = false) type: String?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getContents(type, page, size, requireMember(member)))
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest`
- REFACTOR: `SecurityConfig`에 `/api/v2/contents` permitAll을 추가하지 않았는지 확인하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest` 실행 시 `ContentOverviewController` 미구현으로 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewController` 추가 후 인증 회원 query parameter 전달 테스트 통과. 비회원 401 검증은 slice test에서 실제 `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`를 import하고 `.with(anonymous())`를 명시하도록 보정한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REFACTOR: `SecurityConfig`에 `/api/v2/contents` `permitAll`을 추가하지 않았음을 확인했다. `/api/v2/contents`는 기존 `anyRequest().authenticated()` 정책으로 인증 필수다.
- Phase 3 묶음: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
- 코드 리뷰: `ContentOverviewFacade`, `ContentOverviewController`, `HomeFirstAudioContentRecord` 확장, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)` 변경을 Phase 3 요구사항과 대조했고 차단 이슈는 발견하지 않았다.
- 리뷰 검증: `git diff --check` 실행, 공백 오류 없음.
- 리뷰 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 wiring: `./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest` 실행, `BUILD SUCCESSFUL`.
- 리뷰 Lint: `./gradlew ktlintCheck` 재실행, `BUILD SUCCESSFUL`. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
---
### Phase 4: 미배포 홈 하위 전체보기 endpoint 제거
- [x] **Task 4.1: 홈 하위 first-audio-contents 제거 테스트 갱신**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- RED: `/api/v2/home/recommendations/first-audio-contents`가 더 이상 성공 endpoint가 아님을 확인하는 테스트로 갱신한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("미배포 first-audio-contents 홈 하위 endpoint는 제거된다")
fun shouldNotExposeDeprecatedFirstAudioContentsEndpoint() {
val member = saveMember("home-viewer", MemberRole.USER)
entityManager.flush()
entityManager.clear()
mockMvc.perform(
get("/api/v2/home/recommendations/first-audio-contents")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isNotFound)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- 기대 결과: 기존 endpoint가 남아 있으면 200 OK로 응답해 테스트 실패.
- GREEN: `HomeRecommendationController.getFirstAudioContents(...)`를 제거하고, `HomeRecommendationFacade.getFirstAudioContents(...)`와 관련 로그 section 처리만 제거한다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- REFACTOR: `HomeRecommendationQueryService.findFirstAudioContents(...)`는 새 API에서 재사용하므로 제거하지 않았는지 확인하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 시 `shouldNotExposeDeprecatedFirstAudioContentsEndpoint`가 기존 endpoint 200 응답으로 실패.
- GREEN: `HomeRecommendationController.getFirstAudioContents(...)`와 `HomeRecommendationFacade.getFirstAudioContents(...)` 제거 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REFACTOR: `rg -n "findFirstAudioContents|firstAudioContents|HOME_FIRST_AUDIO_CONTENT_LIMIT" ...`로 홈 메인 `firstAudioContents`, `HOME_FIRST_AUDIO_CONTENT_LIMIT`, `HomeRecommendationQueryService.findFirstAudioContents(...)`, 새 `ContentOverviewFacade` 재사용 경로가 유지됨을 확인했다.
- [x] **Task 4.2: 홈 전체보기 인증 경로 목록 회귀 테스트 정리**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: 기존 테스트의 경로 목록에서 `/first-audio-contents`를 제거하고 `/lives`, `/debut-creators`, `/ai-characters`만 홈 하위 전체보기 endpoint로 검증하도록 갱신한다.
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- 기대 결과: controller 제거와 테스트 기대값이 어긋나면 실패.
- GREEN: 홈 추천 controller 테스트에서 first-audio-contents 성공 응답, facade 실패 로그 검증, 경로 반복 목록을 모두 새 정책에 맞춰 정리한다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- REFACTOR: 홈 추천 첫 화면의 `firstAudioContents` 필드와 `HOME_FIRST_AUDIO_CONTENT_LIMIT`는 유지되어야 하므로 삭제하지 않았는지 확인한다.
- 검증 기록:
- 테스트 정리: `HomeRecommendationControllerTest`의 성공 응답 반복 경로와 비회원 거부 반복 경로에서 `/first-audio-contents`를 제거하고, facade page failure 로그 검증에서 `FIRST_AUDIO_CONTENT` section 검증을 제거했다.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
- 코드 리뷰: `HomeRecommendationController`, `HomeRecommendationFacade`, `HomeRecommendationControllerTest` 변경을 Phase 4 요구사항과 대조했고 차단 이슈는 발견하지 않았다.
- 리뷰 확인: `rg -n "first-audio-contents|getFirstAudioContents|FIRST_AUDIO_CONTENT|findFirstAudioContents|HOME_FIRST_AUDIO_CONTENT_LIMIT|firstAudioContents" ...` 실행으로 제거 endpoint는 문서와 404 테스트에만 남고, 홈 메인 `firstAudioContents`와 새 콘텐츠 전체보기의 `findFirstAudioContents(...)` 재사용 경로가 유지됨을 확인했다.
- 리뷰 검증: `git diff --check` 실행, 공백 오류 없음.
- 리뷰 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 실행, `BUILD SUCCESSFUL`.
- 리뷰 Lint: `./gradlew ktlintCheck` 재실행, `BUILD SUCCESSFUL`. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
---
### Phase 5: End-to-End 검증
- [x] **Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt`
- RED: 인증 회원 기준 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`가 `ApiResponse.ok`와 `items/page/size/hasNext`를 반환하는 E2E 실패 테스트를 작성한다.
- 테스트 범위:
- 비회원 `GET /api/v2/contents`는 401
- 인증 회원 `GET /api/v2/contents?type=NEW_AND_HOT_AUDIO`는 200, `data.type = NEW_AND_HOT_AUDIO`
- 인증 회원 `GET /api/v2/contents?type=FIRST_AUDIO_CONTENT`는 200, `data.type = FIRST_AUDIO_CONTENT`
- invalid type은 `NEW_AND_HOT_AUDIO`로 fallback
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest`
- 기대 결과: API 구현 전에는 endpoint 미존재 또는 bean 미구성으로 실패.
- GREEN: Phase 1~4 구현을 통합해 E2E 테스트를 통과시킨다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest`
- REFACTOR: 테스트 데이터가 다른 추천 테스트와 충돌하지 않도록 각 테스트에서 저장한 데이터만 사용하고, 같은 테스트를 재실행한다.
- 검증 기록:
- E2E 테스트 작성: `ContentOverviewEndToEndTest`를 추가해 비회원 401, 인증 회원 `NEW_AND_HOT_AUDIO` 200, 인증 회원 `FIRST_AUDIO_CONTENT` 200, invalid type의 `NEW_AND_HOT_AUDIO` fallback을 검증했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest` 실행, `BUILD SUCCESSFUL`.
- [x] **Task 5.2: 전체 관련 테스트와 ktlint 검증**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/**`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/**`
- RED: 신규/수정 테스트를 한 번에 실행해 남은 컴파일 오류나 회귀를 확인한다.
- 실행 명령:
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- 기대 결과: 모든 명령 `BUILD SUCCESSFUL`.
- GREEN: 실패가 있으면 해당 task 문서 체크박스를 되돌리고 RED/GREEN 단계로 돌아가 수정한다.
- REFACTOR: `./gradlew ktlintCheck`를 실행하고 `BUILD SUCCESSFUL`을 확인한다.
- 검증 기록:
- 관련 테스트 묶음: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
---
## 검증 기록
- 구현 전 문서 생성 단계에서는 코드 변경이 없으므로 단위 테스트를 실행하지 않는다.
- 문서 변경 후 명령 유효성은 `./gradlew tasks --all`로 확인한다.
- 구현 중 각 task 완료 즉시 해당 task 아래에 실행 명령, 결과, 실패 원인과 수정 내용을 한국어로 누적 기록한다.
- Phase 3 코드 리뷰 및 검증 기록 추가 후 `git diff --check` 실행, 공백 오류 없음.
- Phase 3 코드 리뷰 및 검증 기록 추가 후 `./gradlew tasks --all` 실행, `BUILD SUCCESSFUL`. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
---
## Self-Review Checklist
- PRD의 endpoint `GET /api/v2/contents`는 Phase 3과 Phase 5에서 구현/검증한다.
- 비회원 조회 불가는 Phase 3 controller 테스트와 Phase 5 E2E 테스트에서 검증한다.
- `NEW_AND_HOT_AUDIO` 스냅샷 저장 수 100개는 Phase 2에서 검증한다.
- New & Hot 첫 화면 12개 유지 회귀는 Phase 2에서 검증한다.
- `FIRST_AUDIO_CONTENT` 조회 재사용은 Phase 3 Facade 테스트와 Phase 5 E2E 테스트에서 검증한다.
- 미배포 홈 하위 endpoint 제거는 Phase 4에서 검증한다.
- 신규 DB 테이블이 없다는 제약은 파일 구조 계획과 Phase 5 검증 범위에 반영했다.

View File

@@ -0,0 +1,242 @@
# PRD: 콘텐츠 전체보기 API
## 1. Overview
콘텐츠 섹션에서 노출되는 오디오 목록의 전체보기 화면을 위해 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 두 타입을 페이징으로 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 기존 `GET /api/v2/audio/recommendations`는 추천 탭 첫 화면의 섹션별 기본 개수만 내려주며, New & Hot 섹션 전체보기/페이징 API가 없다.
- 기존 `GET /api/v2/home/recommendations/first-audio-contents`는 아직 배포되지 않은 홈 추천 하위 개별 endpoint이며, 콘텐츠 전체보기 API가 추가되면 별도 유지할 이유가 없다.
- `GET /api/v2/audio/recommendations/contents`는 추천 API 하위 리소스처럼 보이므로, 콘텐츠 전체보기 API라는 의미와 맞지 않는다.
- `GET /api/v2/audio/contents`는 이미 메인 콘텐츠 전체 탭 API가 사용 중이므로, 새 섹션 전체보기 API 경로로 재사용하지 않는다.
- 클라이언트는 전체보기 화면에서 동일한 페이징 응답 형태로 섹션 타입만 바꿔 조회할 수 있어야 한다.
- V2 패키지에는 `AudioRecommendationQueryService`, `HomeRecommendationQueryService`, `AudioCardResponse`, `HomeFirstAudioContentItem`, `HomeRecommendationPageResponse` 등 재사용 가능한 조회/응답 패턴이 있으므로, 새 API는 기존 패턴을 우선 재사용해야 한다.
---
## 3. Goals
- 콘텐츠 전체보기 API를 `kr.co.vividnext.sodalive.v2` 하위 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 조회 타입은 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`를 지원한다.
- `NEW_AND_HOT_AUDIO``AudioRecommendationQueryService`의 New & Hot 스냅샷 조회 흐름을 재사용한다.
- `FIRST_AUDIO_CONTENT``HomeRecommendationQueryService.findFirstAudioContents` 조회 흐름을 재사용한다.
- 하나의 endpoint에서 `type` query parameter로 두 타입을 분리한다.
- 비회원 조회를 허용하지 않는다.
- 인증 회원의 차단/성인 콘텐츠 노출 가능 여부 등 기존 사용자 조건을 반영한다.
- 아직 배포되지 않은 `GET /api/v2/home/recommendations/first-audio-contents`는 제거한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
---
## 4. Non-Goals
- 기존 `GET /api/v2/audio/recommendations` 공개 응답 스키마를 변경하지 않는다.
- 기존 `GET /api/v2/home/recommendations` 공개 응답 스키마를 변경하지 않는다.
- 기존 `GET /api/v2/home/recommendations/first-audio-contents` endpoint는 배포 전 기능이므로 하위 호환 대상으로 보지 않는다.
- New & Hot 점수 산식, 스냅샷 생성 주기, lazy refresh 정책을 변경하지 않는다.
- 첫 번째 오디오 콘텐츠 판정 기준과 정렬 정책을 변경하지 않는다.
- `RECENT_DEBUT_CREATOR`, `AI_CHARACTER` 등 다른 홈 추천 전체보기 타입은 이번 범위에 포함하지 않는다.
- 새로운 DB 테이블, 배치 작업, 관리자 기능은 이번 범위에 포함하지 않는다.
---
## 5. Target Users
- 회원: 콘텐츠 섹션의 전체보기에서 더 많은 오디오 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 동일한 전체보기 화면에서 타입, page, size, hasNext를 기반으로 목록을 구성하려는 클라이언트
---
## 6. User Stories
- 사용자는 New & Hot 섹션에서 첫 화면에 보이는 개수보다 더 많은 오디오를 보고 싶다.
- 사용자는 처음부터 함께 성장 섹션의 첫 번째 오디오 콘텐츠를 전체보기로 더 탐색하고 싶다.
- 앱 클라이언트는 전체보기 화면에서 `type`만 바꿔 동일한 페이징 응답을 처리하고 싶다.
- 앱 클라이언트는 인증 회원 기준으로 서버가 기존 성인 콘텐츠/차단 정책을 반영한 결과를 받길 원한다.
---
## 7. Core Features
### Feature A. 콘텐츠 전체보기 통합 조회 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/contents`로 정의한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 비회원 조회를 허용하지 않는다.
- Security 설정은 `GET /api/v2/contents`를 인증 필요 endpoint로 둔다.
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴과 `requireMember(...)` 가드절을 사용한다.
- 요청 query parameter는 `type`, `page`, `size`를 사용한다.
- `type` 값은 아래 enum으로 정의한다.
- `NEW_AND_HOT_AUDIO`: 콘텐츠 추천 탭 New & Hot 오디오 전체보기
- `FIRST_AUDIO_CONTENT`: 메인 홈 처음부터 함께 성장 오디오 전체보기
- `type`을 보내지 않으면 `NEW_AND_HOT_AUDIO`를 기본값으로 사용한다.
- 지원하지 않는 `type` 값이 들어오면 400 오류 대신 `NEW_AND_HOT_AUDIO`로 fallback한다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 fallback한다.
- `size`가 1보다 작으면 기본값 `20`으로 fallback한다.
- `size`가 50보다 크면 `50`으로 fallback한다.
- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
#### Edge Cases
- 조회 결과가 없으면 `items`는 빈 배열, `hasNext``false`로 내려준다.
- 요청한 page 범위에 콘텐츠가 없으면 `items`는 빈 배열, `hasNext``false`로 내려준다.
- 특정 타입 조회 중 필터링으로 스냅샷 대상 상세 데이터가 제거될 수 있으며, 이 경우 가능한 항목만 내려준다.
### Feature B. NEW_AND_HOT_AUDIO 전체보기
#### Requirements
- `type=NEW_AND_HOT_AUDIO``AudioRecommendationQueryService`의 New & Hot 조회 정책을 재사용한다.
- 인증 회원의 성인 콘텐츠 노출 가능 여부에 따라 `AudioRecommendationVisibility.SAFE` 또는 `AudioRecommendationVisibility.ALL`을 결정한다.
- `SAFE``RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE`, `ALL``RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL` 스냅샷을 조회한다.
- New & Hot 첫 화면 노출 수는 기존과 동일하게 12개로 유지한다.
- New & Hot 스냅샷 저장 수는 전체보기 페이징을 위해 visibility별 100개로 확장한다.
- 스냅샷 저장 수 100개는 `SAFE``ALL` 각각에 적용한다.
- `RecommendationSnapshotPort.findLatestSnapshots(sectionType, offset, limit)`로 page offset과 `size + 1` limit을 적용한다.
- 스냅샷이 없으면 기존 `AudioRecommendationQueryService`의 New & Hot lazy refresh 정책을 재사용한다.
- 스냅샷 target id 목록을 `AudioRecommendationQueryPort.findAudioCardsByIds(...)`로 상세 조회한다.
- 응답 item은 기존 `AudioCardResponse` 필드 의미를 유지한다.
#### Edge Cases
- lazy refresh 후에도 스냅샷이 없으면 빈 배열로 내려준다.
- 스냅샷에는 있지만 비활성/예약 공개/차단/성인 콘텐츠 정책으로 상세 조회에서 제외된 항목은 응답하지 않는다.
### Feature C. FIRST_AUDIO_CONTENT 전체보기
#### Requirements
- `type=FIRST_AUDIO_CONTENT``HomeRecommendationQueryService.findFirstAudioContents(...)`를 재사용한다.
- `offset = page * size`, `limit = size + 1`로 조회한다.
- `member.id``MemberContentPreferenceService.canViewAdultContent(member)` 결과를 전달한다.
- 응답 item은 `NEW_AND_HOT_AUDIO`와 동일한 `ContentOverviewItemResponse` 필드를 모두 채운다.
- 기존 `HomeFirstAudioContentRecord`에 공통 응답 구성을 위해 필요한 `isAdult`, `isOriginalSeries` 값을 보강한다.
- `FIRST_AUDIO_CONTENT` 응답의 `isFirstContent`는 첫 번째 콘텐츠 섹션 특성상 `true`로 내려준다.
#### Edge Cases
- 첫 번째 오디오 콘텐츠 판정은 기존 홈 추천 PRD와 현재 `HomeRecommendationQueryService.findFirstAudioContents` 구현을 따른다.
- 예약 공개 콘텐츠는 기존 조회 서비스 정책에 따라 공개 전에는 노출하지 않는다.
### Feature D. 공통 콘텐츠 정책
#### Requirements
- 모든 타입은 공개 가능한 콘텐츠만 조회한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다.
- 이미지 경로와 기본 프로필 이미지는 기존 각 조회 서비스/Facade 변환 정책을 따른다.
### Feature E. 미배포 홈 하위 전체보기 API 제거
#### Requirements
- `HomeRecommendationController``GET /api/v2/home/recommendations/first-audio-contents` endpoint를 제거한다.
- 해당 endpoint만을 위한 `HomeRecommendationFacade.getFirstAudioContents(...)` 조립 메서드는 새 콘텐츠 전체보기 Facade로 책임을 옮긴 뒤 제거한다.
- 관련 Controller/Facade 테스트는 새 `GET /api/v2/contents?type=FIRST_AUDIO_CONTENT` 테스트로 대체한다.
- `SecurityConfig`에 홈 하위 전체보기 endpoint를 위한 별도 설정이 있다면 제거하거나 더 이상 영향이 없게 정리한다.
#### Edge Cases
- `HomeRecommendationQueryService.findFirstAudioContents(...)`는 새 API에서 재사용하므로 제거하지 않는다.
---
## 8. API Endpoint
```http
GET /api/v2/contents?type=NEW_AND_HOT_AUDIO&page=0&size=20
Authorization: Bearer {accessToken}
```
- 비회원 조회를 허용하지 않는다.
- `SecurityConfig`에서 `GET /api/v2/contents`는 인증 필요 endpoint로 둔다.
- `type` 미지정 또는 invalid 값은 `NEW_AND_HOT_AUDIO`로 fallback한다.
- `FIRST_AUDIO_CONTENT` 조회 예시는 아래와 같다.
```http
GET /api/v2/contents?type=FIRST_AUDIO_CONTENT&page=0&size=20
Authorization: Bearer {accessToken}
```
---
## 9. Response Data Class
```kotlin
data class ContentOverviewPageResponse(
val type: ContentOverviewType,
val items: List<ContentOverviewItemResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO,
FIRST_AUDIO_CONTENT
}
data class ContentOverviewItemResponse(
val contentId: Long,
val title: String,
val coverImage: String?,
val price: Int,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
val creatorNickname: String,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean
)
```
- `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 모두 동일한 item 필드를 채우며 타입별 nullable 전용 필드를 두지 않는다.
- 기존 `audioContentId`, `imageUrl` 공개 필드명은 각각 `contentId`, `coverImage`로 사용한다.
- `duration`, `creatorId`, `creatorProfileImage`는 콘텐츠 전체보기 응답에 포함하지 않는다.
---
## 10. Technical Constraints
### 패키지 구조
- 공개 API 조립 계층은 콘텐츠 전체보기 API 의미가 드러나도록 `kr.co.vividnext.sodalive.v2.api.content.overview` 하위에 둔다.
- Controller: `...adapter.in.web.ContentOverviewController`
- Facade: `...application.ContentOverviewFacade`
- Response DTO: `...dto.ContentOverviewPageResponse`
- 도메인 조회 계층은 기존 서비스 재사용을 우선한다.
- New & Hot: `kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService`
- 첫 번째 오디오 콘텐츠: `kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService`
- 신규 도메인 모델/정책이 필요하면 `kr.co.vividnext.sodalive.v2.content.recommendation.domain`에 최소 범위로 추가한다.
- 의존 방향은 `v2.api.content.overview -> v2.content.recommendation`, `v2.api.content.overview -> v2.recommendation`만 허용한다.
### V2 공통화/재사용 대상
- `AudioRecommendationQueryService.resolveVisibility(...)`
- `AudioRecommendationQueryService.newAndHotSectionType(...)`
- `RecommendationSnapshotPort.findLatestSnapshots(...)`
- `AudioRecommendationQueryPort.findAudioCardsByIds(...)`
- `HomeRecommendationQueryService.findFirstAudioContents(...)`
- `AudioCardResponse`의 응답 필드 의미와 `JsonProperty` 네이밍 패턴
- `HomeFirstAudioContentItem`의 응답 필드 의미와 이미지 URL 변환 패턴
- `HomeRecommendationFacade`의 page/size 보정, `size + 1` 기반 `hasNext` 계산 패턴
### 스냅샷 저장 정책
- New & Hot은 첫 화면 조회 limit과 스냅샷 저장 limit을 분리한다.
- 첫 화면 조회 limit은 `NEW_AND_HOT_HOME_LIMIT = 12`로 유지한다.
- 스냅샷 저장 limit은 `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`으로 정의한다.
- `AudioRecommendationSnapshotRefreshService``findNewAndHotSnapshots(..., limit = NEW_AND_HOT_SNAPSHOT_LIMIT)``SAFE`, `ALL` 각각 최대 100개를 저장한다.
- `AudioRecommendationQueryService.getRecommendations(...)`는 첫 화면 응답 조립 시 최신 스냅샷에서 12개만 조회한다.
- 콘텐츠 전체보기 API는 저장된 100개 스냅샷 범위 안에서 `offset`, `size + 1`로 페이징한다.
### 구현 판단
- 별도 endpoint 2개보다 typed endpoint 1개를 기본안으로 한다.
- 이유는 두 요구가 모두 “오디오 콘텐츠 목록 전체보기”이고, page/size/hasNext 응답 계약이 동일하며, `MainContentAllController``type` 기반 단일 endpoint 패턴을 이미 사용하기 때문이다.
- endpoint는 `GET /api/v2/contents`를 사용한다.
- 이유는 `GET /api/v2/audio/recommendations/contents`가 추천 하위 리소스처럼 읽혀 콘텐츠 전체보기 API 의미와 맞지 않고, `GET /api/v2/audio/contents`는 이미 메인 콘텐츠 전체 탭 API가 사용 중이기 때문이다.
- 기존 `GET /api/v2/home/recommendations/first-audio-contents`는 배포 전 endpoint이므로 제거하고, 새 API의 `type=FIRST_AUDIO_CONTENT`로 대체한다.
---
## 11. Decisions
- `GET /api/v2/contents`는 인증 회원만 호출할 수 있다.
- 기존 홈 하위 전체보기 endpoint는 배포 전 기능이므로 제거한다.
- New & Hot 스냅샷은 전체보기 지원을 위해 visibility별 100개 저장한다.

View File

@@ -213,6 +213,7 @@ CREATE TABLE user_creator_chat_message (
- `GET /api/v2/chat/rooms?filter=ALL&limit=30` - `GET /api/v2/chat/rooms?filter=ALL&limit=30`
- `filter`: `ALL`, `AI`, `DM` - `filter`: `ALL`, `AI`, `DM`
- 최신순 30개씩 cursor 기반으로 조회한다. - 최신순 30개씩 cursor 기반으로 조회한다.
- 비로그인 요청은 200 OK와 빈 목록 페이지를 반환한다.
- response data: `{ "rooms", "hasMore", "nextCursor" }` - response data: `{ "rooms", "hasMore", "nextCursor" }`
- room item: `{ "roomId", "chatType", "targetName", "targetImageUrl", "lastMessage", "lastMessageAt" }` - room item: `{ "roomId", "chatType", "targetName", "targetImageUrl", "lastMessage", "lastMessageAt" }`
@@ -271,6 +272,18 @@ CREATE TABLE user_creator_chat_message (
## 채팅 리스트 API 응답 예시 ## 채팅 리스트 API 응답 예시
비로그인 요청:
```json
{
"rooms": [],
"hasMore": false,
"nextCursor": null
}
```
로그인 요청:
```json ```json
{ {
"rooms": [ "rooms": [
@@ -362,3 +375,21 @@ CREATE TABLE user_creator_chat_message (
- 무엇을: 채팅 리스트 cursor 조건, UTC ISO 시간 변환, 빈 이미지 경로 기본 이미지 처리를 보완했다. - 무엇을: 채팅 리스트 cursor 조건, UTC ISO 시간 변환, 빈 이미지 경로 기본 이미지 처리를 보완했다.
- 왜: 같은 `lastMessageAt`을 가진 방이 여러 개일 때 cursor 이후 항목이 누락되지 않아야 하고, 문서 기준 UTC 시간과 기본 이미지 정책을 정확히 지켜야 하기 때문이다. - 왜: 같은 `lastMessageAt`을 가진 방이 여러 개일 때 cursor 이후 항목이 누락되지 않아야 하고, 문서 기준 UTC 시간과 기본 이미지 정책을 정확히 지켜야 하기 때문이다.
- 어떻게: cursor를 `lastMessageAt`, `chatType`, `roomId` tuple 기준으로 적용하고 repository seek 조건도 같은 정렬 기준에 맞췄다. `lastMessageAt``ZoneOffset.UTC` 기준 ISO 문자열로 변환하고, 빈 이미지 경로도 기본 이미지로 대체했다. 동일 timestamp cursor 테스트를 추가한 뒤 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 재리뷰에서 남은 Critical/Important 결함 없음 승인을 받았다. - 어떻게: cursor를 `lastMessageAt`, `chatType`, `roomId` tuple 기준으로 적용하고 repository seek 조건도 같은 정렬 기준에 맞췄다. `lastMessageAt``ZoneOffset.UTC` 기준 ISO 문자열로 변환하고, 빈 이미지 경로도 기본 이미지로 대체했다. 동일 timestamp cursor 테스트를 추가한 뒤 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 재리뷰에서 남은 Critical/Important 결함 없음 승인을 받았다.
### 14차 채팅 리스트 비로그인 응답 정책 반영
- [x] **Task 14.1: 비로그인 채팅 리스트 빈 목록 반환**
- 수정 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt`
- 테스트 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt`
- 문서 파일: `docs/prd/20260513_유저크리에이터채팅방개편_prd.md`, `docs/plan-task/20260513_유저크리에이터채팅방개편.md`
- RED: `ChatRoomListController.getRooms(member = null, ...)`가 예외 없이 `rooms = []`, `hasMore = false`, `nextCursor = null`을 반환하고 `ChatRoomListService`를 호출하지 않는 테스트를 먼저 작성한다.
- RED 확인 명령: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest`
- GREEN: `member == null`이면 `ChatRoomListPageResponse(emptyList(), false, null)``ApiResponse.ok`로 감싸 반환하고, 로그인 사용자는 기존처럼 service에 위임한다.
- GREEN 확인 명령: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest`
- REFACTOR/회귀 확인 명령: `./gradlew --no-daemon ktlintCheck`
- 검증 기록:
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest` 실행 결과 비로그인 테스트가 `SodaException`으로 실패해 기존 예외 동작을 재현했다.
- GREEN: `ChatRoomListController.getRooms``member == null` 분기에서 빈 `ChatRoomListPageResponse`를 반환하도록 최소 수정했다.
- GREEN 확인: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest` 실행 결과 `BUILD SUCCESSFUL in 5m`을 확인했다.
- 회귀 확인: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 33s`를 확인했다.
- 문서 확인: PRD와 plan-task 문서에서 미완성 표식을 검색한 결과 매칭이 없었다.
- 문서 명령 확인: 최초 `./gradlew --no-daemon tasks --all`은 Gradle wrapper lock 파일 샌드박스 접근 오류로 실패했고, 승인 실행한 동일 명령은 `BUILD SUCCESSFUL in 6s`로 통과했다.

View File

@@ -124,6 +124,7 @@
#### Requirements #### Requirements
- 인증된 회원이 참여 중인 채팅방만 조회한다. - 인증된 회원이 참여 중인 채팅방만 조회한다.
- 비로그인 사용자가 호출하면 예외를 발생시키지 않고 빈 목록을 내려준다.
- 필터는 `ALL`, `AI`, `DM` 3가지를 지원한다. - 필터는 `ALL`, `AI`, `DM` 3가지를 지원한다.
- `AI`는 기존 AI 캐릭터 채팅방을 의미한다. - `AI`는 기존 AI 캐릭터 채팅방을 의미한다.
- `DM`은 유저-크리에이터 채팅방을 의미하며, API 문서와 클라이언트 표시 용어에서 User-Creator 채팅 대신 DM으로 명명한다. - `DM`은 유저-크리에이터 채팅방을 의미하며, API 문서와 클라이언트 표시 용어에서 User-Creator 채팅 대신 DM으로 명명한다.
@@ -135,6 +136,7 @@
- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않는다. - 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않는다.
#### Edge Cases #### Edge Cases
- 비로그인 요청은 `rooms = []`, `hasMore = false`, `nextCursor = null`로 응답한다.
- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 사용한다. - 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 사용한다.
- 마지막 메시지가 음성 메시지이면 본문 요약 대신 `[음성 메시지]`를 내려준다. - 마지막 메시지가 음성 메시지이면 본문 요약 대신 `[음성 메시지]`를 내려준다.
- 마지막 메시지가 비활성화되었거나 표시할 수 없는 상태라면 해당 메시지를 제외하고 다음 최신 표시 가능 메시지를 기준으로 요약한다. - 마지막 메시지가 비활성화되었거나 표시할 수 없는 상태라면 해당 메시지를 제외하고 다음 최신 표시 가능 메시지를 기준으로 요약한다.
@@ -209,6 +211,7 @@
- 채팅 리스트에서 마지막 메시지가 없는 방은 노출하지 않는다. - 채팅 리스트에서 마지막 메시지가 없는 방은 노출하지 않는다.
- 음성 메시지의 마지막 대화 요약 문구는 `[음성 메시지]`를 사용한다. - 음성 메시지의 마지막 대화 요약 문구는 `[음성 메시지]`를 사용한다.
- 채팅 리스트 API URL prefix는 `/api/v2/chat/rooms`를 사용한다. - 채팅 리스트 API URL prefix는 `/api/v2/chat/rooms`를 사용한다.
- 채팅 리스트 API는 비로그인 요청에도 200 OK를 반환하며, 빈 목록 페이지를 내려준다.
- 채팅 리스트 DTO 필드명은 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다. - 채팅 리스트 DTO 필드명은 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다.
- `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다. - `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다.
- `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이며, 이미지가 없으면 기본 이미지 URL을 내려준다. - `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이며, 이미지가 없으면 기본 이미지 URL을 내려준다.

View File

@@ -196,8 +196,12 @@ class AdminOriginalWorkService(
/** 원작 상세 조회 (소프트 삭제 제외) */ /** 원작 상세 조회 (소프트 삭제 제외) */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork { fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id) val originalWork = originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
initializeResponseRelations(originalWork)
return originalWork
} }
/** 원작 페이징 조회 */ /** 원작 페이징 조회 */
@@ -210,7 +214,9 @@ class AdminOriginalWorkService(
else -> size else -> size
} }
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return originalWorkRepository.findByIsDeletedFalse(pageable) val originalWorks = originalWorkRepository.findByIsDeletedFalse(pageable)
originalWorks.content.forEach { initializeResponseRelations(it) }
return originalWorks
} }
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */ /** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
@@ -233,7 +239,14 @@ class AdminOriginalWorkService(
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */ /** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> { fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
return originalWorkRepository.searchNoPaging(searchTerm) val originalWorks = originalWorkRepository.searchNoPaging(searchTerm)
originalWorks.forEach { initializeResponseRelations(it) }
return originalWorks
}
private fun initializeResponseRelations(originalWork: OriginalWork) {
originalWork.originalLinks.forEach { it.url }
originalWork.tagMappings.forEach { it.tag.tag }
} }
/** 원작에 기존 캐릭터들을 배정 */ /** 원작에 기존 캐릭터들을 배정 */

View File

@@ -16,6 +16,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Service @Service
@Transactional(readOnly = true)
class AdminMemberService( class AdminMemberService(
private val repository: AdminMemberRepository, private val repository: AdminMemberRepository,
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,

View File

@@ -74,6 +74,28 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
pageable: Pageable pageable: Pageable
): List<ChatCharacter> ): List<ChatCharacter>
/**
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터 ID를 무작위로 조회 (현재 캐릭터 제외)
*/
@Query(
"""
SELECT c.id FROM ChatCharacter c
JOIN c.tagMappings tm
JOIN tm.tag t
WHERE c.isActive = true
AND c.id <> :characterId
AND t.id IN (
SELECT t2.id FROM ChatCharacterTagMapping tm2 JOIN tm2.tag t2 WHERE tm2.chatCharacter.id = :characterId
)
GROUP BY c.id
ORDER BY function('RAND')
"""
)
fun findRandomIdsBySharedTags(
@Param("characterId") characterId: Long,
pageable: Pageable
): List<Long>
/** /**
* 활성 캐릭터 무작위 조회 * 활성 캐릭터 무작위 조회
*/ */
@@ -99,6 +121,27 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter> fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter> fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
@Query(
"""
SELECT DISTINCT c FROM ChatCharacter c
LEFT JOIN FETCH c.tagMappings tm
LEFT JOIN FETCH tm.tag
WHERE c.id = :id
"""
)
fun findByIdWithTagMappings(@Param("id") id: Long): ChatCharacter?
@Query(
"""
SELECT DISTINCT c FROM ChatCharacter c
LEFT JOIN FETCH c.tagMappings tm
LEFT JOIN FETCH tm.tag
WHERE c.id IN :ids
"""
)
fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter>
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter? fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
} }

View File

@@ -210,13 +210,15 @@ class ChatCharacterService(
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getOtherCharactersBySharedTags(characterId: Long, limit: Int = 10): List<ChatCharacter> { fun getOtherCharactersBySharedTags(characterId: Long, limit: Int = 10): List<ChatCharacter> {
val others = chatCharacterRepository.findRandomBySharedTags( val ids = chatCharacterRepository.findRandomIdsBySharedTags(
characterId, characterId,
PageRequest.of(0, limit) PageRequest.of(0, limit)
) ).distinct()
// 태그 초기화 (지연 로딩 문제 방지) if (ids.isEmpty()) return emptyList()
others.forEach { it.tagMappings.size }
return others val charactersById = chatCharacterRepository.findByIdInWithTagMappings(ids)
.associateBy { it.id }
return ids.mapNotNull { charactersById[it] }
} }
/** /**
@@ -555,13 +557,12 @@ class ChatCharacterService(
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getCharacterDetail(id: Long): ChatCharacter? { fun getCharacterDetail(id: Long): ChatCharacter? {
val character = findById(id) ?: return null val character = chatCharacterRepository.findByIdWithTagMappings(id) ?: return null
// 지연 로딩된 관계 데이터 초기화 // 지연 로딩된 관계 데이터 초기화
character.tagMappings.size character.valueMappings.forEach { it.value.value }
character.valueMappings.size character.hobbyMappings.forEach { it.hobby.hobby }
character.hobbyMappings.size character.goalMappings.forEach { it.goal.goal }
character.goalMappings.size
character.memories.size character.memories.size
character.personalities.size character.personalities.size
character.backgrounds.size character.backgrounds.size

View File

@@ -43,8 +43,13 @@ class OriginalWorkQueryService(
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork { fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id) val originalWork = originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException(messageKey = "chat.original.not_found") } .orElseThrow { SodaException(messageKey = "chat.original.not_found") }
originalWork.originalLinks.forEach { it.url }
originalWork.tagMappings.forEach { it.tag.tag }
return originalWork
} }
/** /**

View File

@@ -23,7 +23,16 @@ import java.time.Duration
@Configuration @Configuration
@EnableCaching @EnableCaching
@EnableRedisRepositories @EnableRedisRepositories(
basePackages = [
"kr.co.vividnext.sodalive.content.playlist",
"kr.co.vividnext.sodalive.live.room.info",
"kr.co.vividnext.sodalive.live.room.kickout",
"kr.co.vividnext.sodalive.live.room.menu",
"kr.co.vividnext.sodalive.live.roulette",
"kr.co.vividnext.sodalive.member.token"
]
)
class RedisConfig( class RedisConfig(
@Value("\${spring.redis.host}") @Value("\${spring.redis.host}")
private val host: String, private val host: String,

View File

@@ -102,7 +102,12 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
.anyRequest().authenticated() .anyRequest().authenticated()

View File

@@ -39,6 +39,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -47,6 +48,8 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -82,6 +85,7 @@ class AudioContentService(
private val langContext: LangContext, private val langContext: LangContext,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
@Value("\${cloud.aws.s3.content-bucket}") @Value("\${cloud.aws.s3.content-bucket}")
private val audioContentBucket: String, private val audioContentBucket: String,
@@ -476,7 +480,8 @@ class AudioContentService(
) )
) )
if (audioContent.releaseDate == null || audioContent.releaseDate!! <= audioContent.createdAt) { val now = LocalDateTime.now()
if (audioContent.releaseDate == null || audioContent.releaseDate!! <= now) {
audioContent.isActive = true audioContent.isActive = true
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
@@ -494,6 +499,10 @@ class AudioContentService(
deepLinkId = contentId deepLinkId = contentId
) )
) )
publishContentUploadedAfterCommit(
audioContent = audioContent,
visibleFromAtUtc = audioContent.releaseDate ?: audioContent.createdAt ?: now
)
} }
} }
@@ -520,9 +529,64 @@ class AudioContentService(
deepLinkId = audioContent.id!! deepLinkId = audioContent.id!!
) )
) )
publishContentUploadedAfterCommit(
audioContent = audioContent,
visibleFromAtUtc = audioContent.releaseDate ?: LocalDateTime.now()
)
} }
} }
private fun publishContentUploadedAfterCommit(audioContent: AudioContent, visibleFromAtUtc: LocalDateTime) {
val creator = audioContent.member!!
val occurredAtUtc = audioContent.createdAt ?: visibleFromAtUtc
val newsBody = audioContent.newsDetailPreview()
afterCommit {
homeFollowingNewsPublishService.publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = newsBody,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
isAdult = audioContent.isAdult
)
}
}
private fun AudioContent.newsDetailPreview(): String {
if (price < 50 || isFullDetailVisible) {
return detail
}
val length = detail.length
return if (length < 60) {
"${detail.take(length / 2)}..."
} else {
"${detail.take(30)}..."
}
}
private fun afterCommit(action: () -> Unit) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
return
}
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
}
}
)
}
@Transactional @Transactional
fun getDetail( fun getDetail(
id: Long, id: Long,

View File

@@ -10,7 +10,7 @@ import javax.persistence.ManyToOne
import javax.persistence.OneToMany import javax.persistence.OneToMany
@Entity @Entity
data class CreatorCheers( class CreatorCheers(
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
var cheers: String, var cheers: String,
var languageCode: String?, var languageCode: String?,

View File

@@ -27,11 +27,15 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.validateImage import kr.co.vividnext.sodalive.utils.validateImage
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -52,6 +56,7 @@ class CreatorCommunityService(
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
private val messageSource: SodaMessageSource, private val messageSource: SodaMessageSource,
private val langContext: LangContext, private val langContext: LangContext,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val imageBucket: String, private val imageBucket: String,
@@ -62,6 +67,8 @@ class CreatorCommunityService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
private val log = LoggerFactory.getLogger(javaClass)
@Transactional @Transactional
fun createCommunityPost( fun createCommunityPost(
audioFile: MultipartFile?, audioFile: MultipartFile?,
@@ -134,6 +141,54 @@ class CreatorCommunityService(
deepLinkId = member.id!! deepLinkId = member.id!!
) )
) )
publishCommunityPostCreatedAfterCommit(post, member)
}
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
val occurredAtUtc = post.createdAt ?: LocalDateTime.now()
val newsContent = post.newsContentPreview()
afterCommit {
homeFollowingNewsPublishService.publishCommunityPostCreated(
postId = post.id!!,
creatorId = member.id!!,
creatorNickname = member.nickname,
creatorProfileImagePath = member.profileImage,
title = newsContent.take(80),
body = newsContent,
thumbnailImagePath = post.imagePath,
occurredAtUtc = occurredAtUtc,
isAdult = post.isAdult
)
}
}
private fun CreatorCommunity.newsContentPreview(): String {
if (price <= 0) {
return content
}
val length = content.codePointCount(0, content.length)
val previewLength = if (length > 15) 15 else length / 2
val endIndex = content.offsetByCodePoints(0, previewLength)
return content.substring(0, endIndex).plus("...")
}
private fun afterCommit(action: () -> Unit) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
return
}
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
}
}
)
} }
@Transactional @Transactional

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.extensions
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul") private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC") private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC")
@@ -26,3 +27,7 @@ fun LocalDateTime.convertToUtc(timeZone: ZoneId = DEFAULT_KST_ZONE_ID): LocalDat
.withZoneSameInstant(UTC_ZONE_ID) .withZoneSameInstant(UTC_ZONE_ID)
.toLocalDateTime() .toLocalDateTime()
} }
fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}

View File

@@ -2,12 +2,14 @@ package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service @Service
class ResourceTranslationJobScheduler( class ResourceTranslationJobScheduler(
private val sourceExtractor: TranslationSourceExtractor, private val sourceExtractor: TranslationSourceExtractor,
private val translationJobScheduler: TranslationJobScheduler private val translationJobScheduler: TranslationJobScheduler
) { ) {
@Transactional
fun scheduleResourceTranslations(resourceType: LanguageTranslationTargetType, resourceId: Long) { fun scheduleResourceTranslations(resourceType: LanguageTranslationTargetType, resourceId: Long) {
val source = sourceExtractor.extract(resourceType, resourceId) ?: return val source = sourceExtractor.extract(resourceType, resourceId) ?: return
getTranslatableLanguageCodes(source.sourceLanguage).forEach { targetLanguage -> getTranslatableLanguageCodes(source.sourceLanguage).forEach { targetLanguage ->
@@ -15,6 +17,7 @@ class ResourceTranslationJobScheduler(
} }
} }
@Transactional
fun scheduleResourceTranslation( fun scheduleResourceTranslation(
resourceType: LanguageTranslationTargetType, resourceType: LanguageTranslationTargetType,
resourceId: Long, resourceId: Long,

View File

@@ -53,6 +53,7 @@ import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.generatePassword import kr.co.vividnext.sodalive.utils.generatePassword
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.CacheManager import org.springframework.cache.CacheManager
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -109,6 +110,7 @@ class MemberService(
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
private val homeFollowingNewsInboxPort: HomeFollowingNewsInboxPort,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String, private val s3Bucket: String,
@@ -525,6 +527,7 @@ class MemberService(
if (creatorFollowing != null) { if (creatorFollowing != null) {
creatorFollowing.isActive = false creatorFollowing.isActive = false
homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
} }
} }

View File

@@ -4,12 +4,14 @@ import kr.co.vividnext.sodalive.member.Member
import org.springframework.web.context.request.RequestContextHolder import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes import org.springframework.web.context.request.ServletRequestAttributes
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
fun resolveCountryCodeByPolicy(member: Member): String { fun resolveCountryCodeByPolicy(member: Member): String {
val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes
val requestCountryCode = requestAttributes?.request?.getHeader("CloudFront-Viewer-Country") val requestCountryCode = requestAttributes?.request?.getHeader("CloudFront-Viewer-Country")
return resolveCountryCodeWithForcedMapping(member, requestCountryCode) return resolveCountryCodeWithForcedMapping(member, requestCountryCode)
} }
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
fun isAdultVisibleByPolicy(member: Member, isAdultContentVisible: Boolean): Boolean { fun isAdultVisibleByPolicy(member: Member, isAdultContentVisible: Boolean): Boolean {
return if (resolveCountryCodeByPolicy(member) == "KR") { return if (resolveCountryCodeByPolicy(member) == "KR") {
member.auth != null && isAdultContentVisible member.auth != null && isAdultContentVisible

View File

@@ -153,6 +153,10 @@ class MemberContentPreferenceService(
) )
} }
fun canViewAdultContent(member: Member): Boolean {
return getStoredPreference(member).isAdult
}
fun resolveCountryCode(member: Member): String { fun resolveCountryCode(member: Member): String {
requireMemberId(member) requireMemberId(member)
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode) return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)

View File

@@ -23,6 +23,8 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember
import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag
import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -53,6 +55,8 @@ class RankingRepository(
.select(member) .select(member)
.from(creatorRanking) .from(creatorRanking)
.innerJoin(creatorRanking.member, member) .innerJoin(creatorRanking.member, member)
.leftJoin(member.tags, memberCreatorTag).fetchJoin()
.leftJoin(memberCreatorTag.tag, creatorTag).fetchJoin()
if (memberId != null) { if (memberId != null) {
select = select.leftJoin(blockMember).on(blockMemberCondition) select = select.leftJoin(blockMember).on(blockMemberCondition)
@@ -65,6 +69,7 @@ class RankingRepository(
return select return select
.orderBy(creatorRanking.ranking.asc()) .orderBy(creatorRanking.ranking.asc())
.fetch() .fetch()
.distinctBy { it.id }
} }
fun getAudioContentRanking( fun getAudioContentRanking(

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.api.common.dto
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
data class RecommendationBannerResponse(
val imageUrl: String,
val eventItem: EventItem?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?
) {
companion object {
fun from(banner: RecommendationBanner): RecommendationBannerResponse {
return RecommendationBannerResponse(
imageUrl = banner.imageUrl,
eventItem = banner.eventItem,
creatorId = banner.creatorId,
seriesId = banner.seriesId,
link = banner.link
)
}
}
}

View File

@@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.v2.api.content.all.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/audio/contents")
class MainContentAllController(
private val facade: MainContentAllFacade
) {
@GetMapping
fun getContents(
@RequestParam(required = false) type: String?,
@RequestParam(required = false) sort: String?,
@RequestParam(required = false) dayOfWeek: String?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
facade.getContents(
type = type,
sort = sort,
dayOfWeek = dayOfWeek,
page = page,
size = size,
member = member
)
)
}
}

View File

@@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.v2.api.content.all.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponse
import kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryService
import org.springframework.stereotype.Component
@Component
class MainContentAllFacade(
private val queryService: MainContentAllQueryService
) {
fun getContents(
type: String?,
sort: String?,
dayOfWeek: String?,
page: Int?,
size: Int?,
member: Member?
): MainContentAllTabResponse {
return MainContentAllTabResponse.from(
queryService.getContents(
type = type,
sort = sort,
dayOfWeek = dayOfWeek,
page = page,
size = size,
member = member
)
)
}
}

View File

@@ -0,0 +1,94 @@
package kr.co.vividnext.sodalive.v2.api.content.all.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
data class MainContentAllTabResponse(
val type: MainContentAllType,
val totalCount: Int,
val audios: List<MainContentAudioResponse>,
val series: List<MainContentSeriesResponse>,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: MainContentAll): MainContentAllTabResponse {
return MainContentAllTabResponse(
type = tab.type,
totalCount = tab.totalCount,
audios = tab.audios.map(MainContentAudioResponse::from),
series = tab.series.map(MainContentSeriesResponse::from),
sort = tab.sort,
dayOfWeek = tab.dayOfWeek,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class MainContentAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
) {
companion object {
fun from(audio: MainContentAllAudio): MainContentAudioResponse {
return MainContentAudioResponse(
audioContentId = audio.audioContentId,
title = audio.title,
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname
)
}
}
}
data class MainContentSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val creatorNickname: String,
@JsonProperty("isOriginal")
val isOriginal: Boolean,
@JsonProperty("isAdult")
val isAdult: Boolean
) {
companion object {
fun from(series: MainContentAllSeries): MainContentSeriesResponse {
return MainContentSeriesResponse(
seriesId = series.seriesId,
title = series.title,
coverImageUrl = series.coverImageUrl,
creatorNickname = series.creatorNickname,
isOriginal = series.isOriginal,
isAdult = series.isAdult
)
}
}
}

View File

@@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/contents")
class ContentOverviewController(
private val facade: ContentOverviewFacade
) {
@GetMapping
fun getContents(
@RequestParam(required = false) type: String?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getContents(type, page, size, requireMember(member)))
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewItemResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class ContentOverviewFacade(
private val audioRecommendationQueryService: AudioRecommendationQueryService,
private val homeRecommendationQueryService: HomeRecommendationQueryService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
private val queryPolicy: ContentOverviewQueryPolicy = ContentOverviewQueryPolicy()
) {
fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse {
val resolvedType = queryPolicy.resolveType(type)
val resolvedPage = queryPolicy.createPage(page, size)
return when (resolvedType) {
ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage)
ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage)
}
}
private fun getNewAndHotContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse {
val fetched = audioRecommendationQueryService.findNewAndHotAudios(
member = member,
offset = page.offset,
limit = page.size + 1
)
return ContentOverviewPageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
items = queryPolicy.pageItems(fetched, page).map { ContentOverviewItemResponse.fromNewAndHot(it) },
page = page.page,
size = page.size,
hasNext = queryPolicy.hasNext(fetched, page)
)
}
private fun getFirstAudioContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse {
val fetched = homeRecommendationQueryService.findFirstAudioContents(
now = LocalDateTime.now(),
offset = page.offset,
limit = page.size + 1,
memberId = member.id,
includeAdultContents = memberContentPreferenceService.canViewAdultContent(member)
)
return ContentOverviewPageResponse(
type = ContentOverviewType.FIRST_AUDIO_CONTENT,
items = queryPolicy.pageItems(fetched, page).map {
ContentOverviewItemResponse.fromFirstAudioContent(
audio = it,
coverImage = it.coverImage.toCdnUrl(cloudFrontHost),
isAdult = it.isAdult,
isOriginalSeries = it.isOriginalSeries
)
},
page = page.page,
size = page.size,
hasNext = queryPolicy.hasNext(fetched, page)
)
}
}

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
data class ContentOverviewPage(
val page: Int,
val size: Int
) {
val offset: Long = page.toLong() * size
}
class ContentOverviewQueryPolicy {
fun resolveType(type: String?): ContentOverviewType {
return ContentOverviewType.from(type)
}
fun createPage(page: Int?, size: Int?): ContentOverviewPage {
val resolvedPage = (page ?: DEFAULT_PAGE).coerceAtLeast(DEFAULT_PAGE)
val requestedSize = size ?: DEFAULT_SIZE
val resolvedSize = if (requestedSize < MIN_SIZE) DEFAULT_SIZE else minOf(requestedSize, MAX_SIZE)
return ContentOverviewPage(page = resolvedPage, size = resolvedSize)
}
fun <T> pageItems(items: List<T>, page: ContentOverviewPage): List<T> {
return items.take(page.size)
}
fun <T> hasNext(items: List<T>, page: ContentOverviewPage): Boolean {
return items.size > page.size
}
companion object {
const val DEFAULT_PAGE = 0
const val DEFAULT_SIZE = 20
const val MIN_SIZE = 20
const val MAX_SIZE = 50
}
}

View File

@@ -0,0 +1,76 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
data class ContentOverviewPageResponse(
val type: ContentOverviewType,
val items: List<ContentOverviewItemResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO,
FIRST_AUDIO_CONTENT;
companion object {
fun from(value: String?): ContentOverviewType {
return values().firstOrNull { it.name == value } ?: NEW_AND_HOT_AUDIO
}
}
}
data class ContentOverviewItemResponse(
val contentId: Long,
val title: String,
val coverImage: String?,
val price: Int,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
val creatorNickname: String,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean
) {
companion object {
fun fromNewAndHot(audio: AudioCard): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.audioContentId,
title = audio.title,
coverImage = audio.imageUrl,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries
)
}
fun fromFirstAudioContent(
audio: HomeFirstAudioContentRecord,
coverImage: String?,
isAdult: Boolean,
isOriginalSeries: Boolean
): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.contentId,
title = audio.title,
coverImage = coverImage,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = isAdult,
isFirstContent = true,
isOriginalSeries = isOriginalSeries
)
}
}
}

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacade
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/audio/rankings")
class AudioRankingController(
private val facade: AudioRankingFacade
) {
@GetMapping
fun getRankings(
@RequestParam(defaultValue = "WEEKLY_POPULAR") type: AudioRankingType,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getRankings(type, member))
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponse
import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.springframework.stereotype.Component
@Component
class AudioRankingFacade(
private val queryService: AudioRankingQueryService
) {
fun getRankings(type: AudioRankingType, member: Member?): AudioRankingResponse {
return AudioRankingResponse.from(queryService.getRankings(type, member))
}
}

View File

@@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
data class AudioRankingResponse(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItemResponse>
) {
companion object {
fun from(ranking: AudioRanking): AudioRankingResponse {
return AudioRankingResponse(
showRankChange = ranking.showRankChange,
type = ranking.type,
items = ranking.items.map(AudioRankingItemResponse::from)
)
}
}
}
data class AudioRankingItemResponse(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
@JsonProperty("isNew")
val isNew: Boolean,
val coverImageUrl: String?
) {
companion object {
fun from(item: AudioRankingItem): AudioRankingItemResponse {
return AudioRankingItemResponse(
contentId = item.contentId,
title = item.title,
creatorNickname = item.creatorNickname,
rank = item.rank,
rankChange = item.rankChange,
isNew = item.isNew,
coverImageUrl = item.coverImageUrl
)
}
}
}

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/audio/recommendations")
class AudioRecommendationController(
private val audioRecommendationFacade: AudioRecommendationFacade
) {
@GetMapping
fun getRecommendations(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(audioRecommendationFacade.getRecommendations(member))
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.v2.api.content.recommendation.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
import org.springframework.stereotype.Component
@Component
class AudioRecommendationFacade(
private val queryService: AudioRecommendationQueryService
) {
fun getRecommendations(member: Member?): AudioRecommendationsResponse {
return AudioRecommendationsResponse.from(queryService.getRecommendations(member))
}
}

View File

@@ -0,0 +1,99 @@
package kr.co.vividnext.sodalive.v2.api.content.recommendation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
data class AudioRecommendationsResponse(
val banners: List<RecommendationBannerResponse>,
val originalSeries: List<OriginalSeriesResponse>,
val latestAudios: List<AudioCardResponse>,
val newAndHotAudios: List<AudioCardResponse>,
val freeAudios: List<AudioCardResponse>,
val pointAudios: List<AudioCardResponse>,
val mostCommentedAudios: List<CommentedAudioResponse>,
val recommendedAudios: List<AudioCardResponse>
) {
companion object {
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
return AudioRecommendationsResponse(
banners = recommendations.banners.map(RecommendationBannerResponse::from),
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
freeAudios = recommendations.freeAudios.map(AudioCardResponse::from),
pointAudios = recommendations.pointAudios.map(AudioCardResponse::from),
mostCommentedAudios = recommendations.mostCommentedAudios.map(CommentedAudioResponse::from),
recommendedAudios = recommendations.recommendedAudios.map(AudioCardResponse::from)
)
}
}
}
data class OriginalSeriesResponse(
val seriesId: Long,
val coverImageUrl: String?
) {
companion object {
fun from(series: OriginalSeries): OriginalSeriesResponse {
return OriginalSeriesResponse(series.seriesId, series.coverImageUrl)
}
}
}
data class AudioCardResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
) {
companion object {
fun from(audio: AudioCard): AudioCardResponse {
return AudioCardResponse(
audioContentId = audio.audioContentId,
title = audio.title,
duration = audio.duration,
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname
)
}
}
}
data class CommentedAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val latestComment: String,
val latestCommentWriterProfileImageUrl: String
) {
companion object {
fun from(audio: CommentedAudio): CommentedAudioResponse {
return CommentedAudioResponse(
audioContentId = audio.audioContentId,
title = audio.title,
imageUrl = audio.imageUrl,
latestComment = audio.latestComment,
latestCommentWriterProfileImageUrl = audio.latestCommentWriterProfileImageUrl
)
}
}
}

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelAudioController(
private val creatorChannelAudioFacade: CreatorChannelAudioFacade
) {
@GetMapping("/{creatorId}/audio")
fun getAudioTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) sort: String?,
@RequestParam(required = false) themeId: Long?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelAudioFacade.getAudioTab(
creatorId = creatorId,
viewer = requireMember(member),
sort = sort,
themeId = themeId,
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelAudioFacade(
private val creatorChannelAudioQueryService: CreatorChannelAudioQueryService
) {
fun getAudioTab(
creatorId: Long,
viewer: Member,
sort: String?,
themeId: Long?,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelAudioTabResponse {
return CreatorChannelAudioTabResponse.from(
creatorChannelAudioQueryService.getAudioTab(
creatorId = creatorId,
viewer = viewer,
sort = sort,
themeId = themeId,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
data class CreatorChannelAudioTabResponse(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioThemeResponse>,
val audioContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val themeId: Long?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelAudioTab): CreatorChannelAudioTabResponse {
return CreatorChannelAudioTabResponse(
audioContentCount = tab.audioContentCount,
paidAudioContentCount = tab.paidAudioContentCount,
purchasedAudioContentCount = tab.purchasedAudioContentCount,
purchasedAudioContentRate = tab.purchasedAudioContentRate,
themes = tab.themes.map(CreatorChannelAudioThemeResponse::from),
audioContents = tab.audioContents.map(CreatorChannelAudioContentResponse::from),
sort = tab.sort,
themeId = tab.themeId,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelAudioThemeResponse(
val themeId: Long,
val themeName: String
) {
companion object {
fun from(theme: CreatorChannelAudioTheme): CreatorChannelAudioThemeResponse {
return CreatorChannelAudioThemeResponse(
themeId = theme.themeId,
themeName = theme.themeName
)
}
}
}

View File

@@ -0,0 +1,44 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = audioContent.audioContentId,
title = audioContent.title,
duration = audioContent.duration,
imageUrl = audioContent.imageUrl,
price = audioContent.price,
isAdult = audioContent.isAdult,
isPointAvailable = audioContent.isPointAvailable,
isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries,
isOwned = audioContent.isOwned,
isRented = audioContent.isRented
)
}
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelCommunityController(
private val creatorChannelCommunityFacade: CreatorChannelCommunityFacade
) {
@GetMapping("/{creatorId}/community")
fun getCommunityTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelCommunityFacade.getCommunityTab(
creatorId = creatorId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelCommunityFacade(
private val creatorChannelCommunityQueryService: CreatorChannelCommunityQueryService
) {
fun getCommunityTab(
creatorId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityTabResponse {
return CreatorChannelCommunityTabResponse.from(
creatorChannelCommunityQueryService.getCommunityTab(
creatorId = creatorId,
viewer = viewer,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse {
return CreatorChannelCommunityTabResponse(
communityPostCount = tab.communityPostCount,
communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelCommunityPostResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean
) {
companion object {
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
return CreatorChannelCommunityPostResponse(
postId = post.postId,
creatorId = post.creatorId,
creatorNickname = post.creatorNickname,
creatorProfileUrl = post.creatorProfileUrl,
createdAtUtc = post.createdAt.toUtcIso(),
content = post.content,
imageUrl = post.imageUrl,
audioUrl = post.audioUrl,
price = post.price,
isCommentAvailable = post.isCommentAvailable,
existOrdered = post.existOrdered,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned
)
}
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelDonationController(
private val creatorChannelDonationFacade: CreatorChannelDonationFacade
) {
@GetMapping("/{creatorId}/donations")
fun getDonationTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelDonationFacade.getDonationTab(
creatorId = creatorId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelDonationFacade(
private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService
) {
fun getDonationTab(
creatorId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelDonationTabResponse {
return CreatorChannelDonationTabResponse.from(
creatorChannelDonationQueryService.getDonationTab(
creatorId = creatorId,
viewer = viewer,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
data class CreatorChannelDonationTabResponse(
val donationCount: Int,
val rankings: List<MemberDonationRankingResponse>,
val donations: List<CreatorChannelDonationResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelDonationTab): CreatorChannelDonationTabResponse {
return CreatorChannelDonationTabResponse(
donationCount = tab.donationCount,
rankings = tab.rankings.map(MemberDonationRankingResponse::from),
donations = tab.donations.map(CreatorChannelDonationResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class MemberDonationRankingResponse(
@JsonProperty("userId") val userId: Long,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("profileImage") val profileImage: String,
@JsonProperty("donationCan") val donationCan: Int
) {
companion object {
fun from(ranking: CreatorChannelDonationRanking): MemberDonationRankingResponse {
return MemberDonationRankingResponse(
userId = ranking.userId,
nickname = ranking.nickname,
profileImage = ranking.profileImage,
donationCan = ranking.donationCan
)
}
}
}
data class CreatorChannelDonationResponse(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
)
}
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelFanTalkController(
private val creatorChannelFanTalkFacade: CreatorChannelFanTalkFacade
) {
@GetMapping("/{creatorId}/fan-talks")
fun getFanTalkTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelFanTalkFacade.getFanTalkTab(
creatorId = creatorId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelFanTalkFacade(
private val creatorChannelFanTalkQueryService: CreatorChannelFanTalkQueryService
) {
fun getFanTalkTab(
creatorId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelFanTalkTabResponse {
return CreatorChannelFanTalkTabResponse.from(
creatorChannelFanTalkQueryService.getFanTalkTab(
creatorId = creatorId,
viewer = viewer,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,74 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
data class CreatorChannelFanTalkTabResponse(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalkResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
return CreatorChannelFanTalkTabResponse(
fanTalkCount = tab.fanTalkCount,
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelFanTalkResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String,
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
) {
companion object {
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
return CreatorChannelFanTalkResponse(
fanTalkId = fanTalk.fanTalkId,
writerId = fanTalk.writerId,
writerNickname = fanTalk.writerNickname,
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
content = fanTalk.content,
createdAtUtc = fanTalk.createdAt.toUtcIso(),
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
)
}
}
}
data class CreatorChannelFanTalkReplyResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String
) {
companion object {
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
return CreatorChannelFanTalkReplyResponse(
fanTalkId = reply.fanTalkId,
writerId = reply.writerId,
writerNickname = reply.writerNickname,
writerProfileImageUrl = reply.writerProfileImageUrl,
content = reply.content,
createdAtUtc = reply.createdAt.toUtcIso()
)
}
}
}

View File

@@ -1,10 +1,10 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
@@ -108,46 +108,6 @@ data class CreatorChannelLiveResponse(
} }
} }
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = audioContent.audioContentId,
title = audioContent.title,
duration = audioContent.duration,
imageUrl = audioContent.imageUrl,
price = audioContent.price,
isAdult = audioContent.isAdult,
isPointAvailable = audioContent.isPointAvailable,
isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries,
isOwned = audioContent.isOwned,
isRented = audioContent.isRented
)
}
}
}
data class CreatorChannelDonationResponse( data class CreatorChannelDonationResponse(
val nickname: String, val nickname: String,
val profileImageUrl: String, val profileImageUrl: String,
@@ -235,7 +195,7 @@ data class CreatorChannelCommunityPostResponse(
audioUrl = post.audioUrl, audioUrl = post.audioUrl,
content = post.content, content = post.content,
price = post.price, price = post.price,
dateUtc = post.date.toUtcIso(), dateUtc = post.createdAt.toUtcIso(),
existOrdered = post.existOrdered, existOrdered = post.existOrdered,
likeCount = post.likeCount, likeCount = post.likeCount,
commentCount = post.commentCount commentCount = post.commentCount

View File

@@ -1,8 +1,8 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto package kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -33,46 +33,6 @@ data class CreatorChannelLiveTabResponse(
} }
} }
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = content.audioContentId,
title = content.title,
duration = content.duration,
imageUrl = content.imageUrl,
price = content.price,
isAdult = content.isAdult,
isPointAvailable = content.isPointAvailable,
isFirstContent = content.isFirstContent,
seriesName = content.seriesName,
isOriginalSeries = content.isOriginalSeries,
isOwned = content.isOwned,
isRented = content.isRented
)
}
}
}
data class CreatorChannelLiveResponse( data class CreatorChannelLiveResponse(
val liveId: Long, val liveId: Long,
val title: String, val title: String,

View File

@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelSeriesController(
private val creatorChannelSeriesFacade: CreatorChannelSeriesFacade
) {
@GetMapping("/{creatorId}/series")
fun getSeriesTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) sort: String?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelSeriesFacade.getSeriesTab(
creatorId = creatorId,
viewer = requireMember(member),
sort = sort,
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelSeriesFacade(
private val creatorChannelSeriesQueryService: CreatorChannelSeriesQueryService
) {
fun getSeriesTab(
creatorId: Long,
viewer: Member,
sort: String?,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelSeriesTabResponse {
return CreatorChannelSeriesTabResponse.from(
creatorChannelSeriesQueryService.getSeriesTab(
creatorId = creatorId,
viewer = viewer,
sort = sort,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
data class CreatorChannelSeriesTabResponse(
val seriesCount: Int,
val series: List<CreatorChannelSeriesResponse>,
val sort: ContentSort,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse {
return CreatorChannelSeriesTabResponse(
seriesCount = tab.seriesCount,
series = tab.series.map(CreatorChannelSeriesResponse::from),
sort = tab.sort,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val publishedDaysOfWeek: String,
@JsonProperty("isOriginal")
val isOriginal: Boolean,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isProceeding")
val isProceeding: Boolean,
val contentCount: Int,
val purchasedContentCount: Int?,
val paidContentCount: Int?,
val purchasedPaidContentRate: Int?
) {
companion object {
fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse {
return CreatorChannelSeriesResponse(
seriesId = series.seriesId,
title = series.title,
coverImageUrl = series.coverImageUrl,
publishedDaysOfWeek = series.publishedDaysOfWeek,
isOriginal = series.isOriginal,
isAdult = series.isAdult,
isProceeding = series.isProceeding,
contentCount = series.contentCount,
purchasedContentCount = series.purchasedContentCount,
paidContentCount = series.paidContentCount,
purchasedPaidContentRate = series.purchasedPaidContentRate
)
}
}
}

View File

@@ -57,21 +57,6 @@ class HomeRecommendationController(
) )
} }
@GetMapping("/first-audio-contents")
fun getFirstAudioContents(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
homeRecommendationFacade.getFirstAudioContents(
requireMember(member),
normalizePage(page),
normalizeSize(size)
)
)
}
@GetMapping("/ai-characters") @GetMapping("/ai-characters")
fun getAiCharacters( fun getAiCharacters(
@RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "0") page: Int,

View File

@@ -3,10 +3,9 @@ package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeActiveCreatorItem import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeActiveCreatorItem
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeAiCharacterItem import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeAiCharacterItem
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeBannerItem
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeCreatorItem import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeCreatorItem
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeFirstAudioContentItem import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeFirstAudioContentItem
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeGenreCreatorGroupItem import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeGenreCreatorGroupItem
@@ -17,6 +16,7 @@ import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendatio
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.imageUrl import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.imageUrl
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.profileImageUrl import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.profileImageUrl
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.toUtcIso import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.toUtcIso
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord 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.HomeBannerRecommendationRecord
@@ -53,7 +53,8 @@ class HomeRecommendationFacade(
memberId = member?.id, memberId = member?.id,
includeAdultLives = includeAdult includeAdultLives = includeAdult
).map { it.toItem() }, ).map { it.toItem() },
banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id).map { it.toItem() }, banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id)
.map { RecommendationBannerResponse.from(it.toBanner()) },
recentlyActiveCreators = queryService.findRecentlyActiveCreators( recentlyActiveCreators = queryService.findRecentlyActiveCreators(
HOME_ACTIVE_CREATOR_LIMIT, HOME_ACTIVE_CREATOR_LIMIT,
member?.id, member?.id,
@@ -142,24 +143,6 @@ class HomeRecommendationFacade(
}.getOrThrow() }.getOrThrow()
} }
fun getFirstAudioContents(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeFirstAudioContentItem> {
val startedAt = System.currentTimeMillis()
return runCatching {
val fetched = queryService.findFirstAudioContents(
now = LocalDateTime.now(),
offset = page.toOffset(size),
limit = size + 1,
memberId = member.id,
includeAdultContents = resolveAdultVisibility(member)
)
fetched.toPage(page, size) { it.toItem() }
}.onSuccess {
logPageSuccess("FIRST_AUDIO_CONTENT", member, page, size, it.items.size, System.currentTimeMillis() - startedAt)
}.onFailure { ex ->
logPageFailure("FIRST_AUDIO_CONTENT", member, page, size, startedAt, ex)
}.getOrThrow()
}
fun getAiCharacters(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeAiCharacterItem> { fun getAiCharacters(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeAiCharacterItem> {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
return runCatching { return runCatching {
@@ -213,11 +196,10 @@ class HomeRecommendationFacade(
private fun resolveAdultVisibility(member: Member?): Boolean { private fun resolveAdultVisibility(member: Member?): Boolean {
if (member == null) return false if (member == null) return false
val preference = memberContentPreferenceService.initializeDefaultPreference(member) return memberContentPreferenceService.canViewAdultContent(member)
return isAdultVisibleByPolicy(member, preference.isAdultContentVisible)
} }
private fun Int.toOffset(size: Int): Int = this * size private fun Int.toOffset(size: Int): Long = this.toLong() * size
private fun <S, T> List<S>.toPage( private fun <S, T> List<S>.toPage(
page: Int, page: Int,
@@ -235,7 +217,7 @@ class HomeRecommendationFacade(
creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage) creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage)
) )
private fun HomeBannerRecommendationRecord.toItem() = HomeBannerItem( private fun HomeBannerRecommendationRecord.toBanner() = RecommendationBanner(
imageUrl = imageUrl(cloudFrontHost, thumbnailImage) ?: "", imageUrl = imageUrl(cloudFrontHost, thumbnailImage) ?: "",
eventItem = eventItem(), eventItem = eventItem(),
creatorId = creatorId, creatorId = creatorId,
@@ -286,6 +268,7 @@ class HomeRecommendationFacade(
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem( private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(
characterId = characterId, characterId = characterId,
creatorId = creatorId,
name = name, name = name,
description = description, description = description,
profileImage = imageUrl(cloudFrontHost, profileImage), profileImage = imageUrl(cloudFrontHost, profileImage),

View File

@@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.v2.api.home.dto.recommendation package kr.co.vividnext.sodalive.v2.api.home.dto.recommendation
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -19,7 +19,7 @@ internal fun profileImageUrl(cloudFrontHost: String, path: String?): String {
data class HomeRecommendationResponse( data class HomeRecommendationResponse(
val lives: List<HomeLiveItem>, val lives: List<HomeLiveItem>,
val banners: List<HomeBannerItem>, val banners: List<RecommendationBannerResponse>,
val recentlyActiveCreators: List<HomeActiveCreatorItem>, val recentlyActiveCreators: List<HomeActiveCreatorItem>,
val recentDebutCreators: List<HomeCreatorItem>, val recentDebutCreators: List<HomeCreatorItem>,
val firstAudioContents: List<HomeFirstAudioContentItem>, val firstAudioContents: List<HomeFirstAudioContentItem>,
@@ -35,14 +35,6 @@ data class HomeLiveItem(
val creatorProfileImage: String val creatorProfileImage: String
) )
data class HomeBannerItem(
val imageUrl: String,
val eventItem: EventItem?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?
)
data class HomeActiveCreatorItem( data class HomeActiveCreatorItem(
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String, val creatorProfileImage: String,
@@ -71,6 +63,7 @@ data class HomeFirstAudioContentItem(
data class HomeAiCharacterItem( data class HomeAiCharacterItem(
val characterId: Long, val characterId: Long,
val creatorId: Long,
val name: String, val name: String,
val description: String, val description: String,
val profileImage: String?, val profileImage: String?,

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/home/following")
class HomeFollowingController(
private val facade: HomeFollowingFacade
) {
@GetMapping
fun getFollowingTab(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getFollowingTab(member))
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.api.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryService
import org.springframework.stereotype.Component
@Component
class HomeFollowingFacade(
private val homeFollowingQueryService: HomeFollowingQueryService,
private val chatRoomListService: ChatRoomListService
) {
fun getFollowingTab(member: Member?): HomeFollowingTabResponse {
if (member == null) {
return HomeFollowingTabResponse.loginRequired()
}
val home = homeFollowingQueryService.findHomeFollowing(member)
val recentChats = chatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10).rooms
return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats))
}
}

View File

@@ -0,0 +1,142 @@
package kr.co.vividnext.sodalive.v2.api.home.following.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
data class HomeFollowingTabResponse(
@JsonProperty("isLoginRequired")
val isLoginRequired: Boolean,
val followingCreators: List<FollowingCreatorResponse>,
val onAirLives: List<FollowingLiveResponse>,
val recentChats: List<ChatRoomListItemResponse>,
val monthlySchedules: List<FollowingScheduleResponse>,
val recentNews: List<FollowingNewsResponse>
) {
companion object {
fun loginRequired(): HomeFollowingTabResponse {
return HomeFollowingTabResponse(
isLoginRequired = true,
followingCreators = emptyList(),
onAirLives = emptyList(),
recentChats = emptyList(),
monthlySchedules = emptyList(),
recentNews = emptyList()
)
}
fun from(home: HomeFollowing): HomeFollowingTabResponse {
return HomeFollowingTabResponse(
isLoginRequired = false,
followingCreators = home.followingCreators.map(FollowingCreatorResponse::from),
onAirLives = home.onAirLives.map(FollowingLiveResponse::from),
recentChats = home.recentChats,
monthlySchedules = home.monthlySchedules.map(FollowingScheduleResponse::from),
recentNews = home.recentNews.map(FollowingNewsResponse::from)
)
}
}
}
data class FollowingCreatorResponse(
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImageUrl: String
) {
companion object {
fun from(creator: HomeFollowingCreator): FollowingCreatorResponse {
return FollowingCreatorResponse(
creatorId = creator.creatorId,
creatorNickname = creator.creatorNickname,
creatorProfileImageUrl = creator.creatorProfileImageUrl
)
}
}
}
data class FollowingLiveResponse(
val liveId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val startedAtUtc: String
) {
companion object {
fun from(live: HomeFollowingLive): FollowingLiveResponse {
return FollowingLiveResponse(
liveId = live.liveId,
creatorProfileImageUrl = live.creatorProfileImageUrl,
creatorNickname = live.creatorNickname,
title = live.title,
startedAtUtc = live.startedAtUtc
)
}
}
}
data class FollowingScheduleResponse(
val scheduleId: String,
val creatorId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val type: CreatorActivityType,
val targetId: Long,
val scheduledAtUtc: String,
@JsonProperty("isOnAir")
val isOnAir: Boolean
) {
companion object {
fun from(schedule: HomeFollowingSchedule): FollowingScheduleResponse {
return FollowingScheduleResponse(
scheduleId = schedule.scheduleId,
creatorId = schedule.creatorId,
creatorProfileImageUrl = schedule.creatorProfileImageUrl,
creatorNickname = schedule.creatorNickname,
title = schedule.title,
type = schedule.type,
targetId = schedule.targetId,
scheduledAtUtc = schedule.scheduledAtUtc,
isOnAir = schedule.isOnAir
)
}
}
}
data class FollowingNewsResponse(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
) {
companion object {
fun from(news: HomeFollowingNews): FollowingNewsResponse {
return FollowingNewsResponse(
newsId = news.newsId,
type = news.type,
creatorProfileImageUrl = news.creatorProfileImageUrl,
creatorNickname = news.creatorNickname,
title = news.title,
body = news.body,
thumbnailImageUrl = news.thumbnailImageUrl,
targetId = news.targetId,
occurredAtUtc = news.occurredAtUtc,
visibleFromAtUtc = news.visibleFromAtUtc,
rank = news.rank
)
}
}
}

View File

@@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/home/on-air-lives")
class HomeOnAirLiveController(
private val homeOnAirLiveFacade: HomeOnAirLiveFacade
) {
@GetMapping
fun getOnAirLives(
@RequestParam(defaultValue = "0") page: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(requireMember(member), page))
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.v2.api.home.live.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.ZoneOffset
@Component
class HomeOnAirLiveFacade(
private val queryService: HomeRecommendationQueryService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse {
val normalizedPage = page.coerceIn(0, MAX_PAGE)
val fetched = queryService.findLiveRecommendations(
offset = normalizedPage.toLong() * PAGE_SIZE,
limit = PAGE_SIZE + 1,
memberId = member.id,
includeAdultLives = memberContentPreferenceService.canViewAdultContent(member)
)
val items = fetched.take(PAGE_SIZE).map { it.toResponse() }
return HomeOnAirLivePageResponse(
items = items,
page = normalizedPage,
size = PAGE_SIZE,
hasNext = fetched.size > PAGE_SIZE
)
}
private fun HomeLiveRecommendationRecord.toResponse() = HomeOnAirLiveResponse(
roomId = liveRoomId,
creatorNickname = creatorNickname,
creatorProfileImage = profileImageUrl(creatorProfileImage),
title = title,
price = price,
beginDateTimeUtc = beginDateTime.toUtcIso()
)
private fun profileImageUrl(path: String?): String {
return imageUrl(path) ?: "$cloudFrontHost/profile/default-profile.png"
}
private fun imageUrl(path: String?): String? {
return if (path.isNullOrBlank()) null else "$cloudFrontHost/$path"
}
private fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}
companion object {
private const val PAGE_SIZE = 20
private const val MAX_PAGE = 10_000
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.v2.api.home.live.dto
data class HomeOnAirLivePageResponse(
val items: List<HomeOnAirLiveResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class HomeOnAirLiveResponse(
val roomId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val title: String,
val price: Int,
val beginDateTimeUtc: String
)

View File

@@ -1,8 +1,8 @@
package kr.co.vividnext.sodalive.v2.chat.controller package kr.co.vividnext.sodalive.v2.chat.controller
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
@@ -22,7 +22,7 @@ class ChatRoomListController(
@RequestParam(required = false) cursor: String?, @RequestParam(required = false) cursor: String?,
@RequestParam(defaultValue = "30") limit: Int @RequestParam(defaultValue = "30") limit: Int
) = run { ) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") if (member == null) return@run ApiResponse.ok(ChatRoomListPageResponse(emptyList(), false, null))
ApiResponse.ok(service.getRooms(member, filter, cursor, limit)) ApiResponse.ok(service.getRooms(member, filter, cursor, limit))
} }
} }

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.v2.common.domain
fun String?.toCdnUrl(cloudFrontHost: String): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.v2.common.domain
import kr.co.vividnext.sodalive.event.EventItem
data class RecommendationBanner(
val imageUrl: String,
val eventItem: EventItem?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?
)

View File

@@ -0,0 +1,436 @@
package kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Expression
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQuery
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.QOrder
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultMainContentAllQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : MainContentAllQueryRepository {
override fun countAudios(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyFree: Boolean,
onlyPointAvailable: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.join(audioContent.member, member)
.join(audioContent.theme, audioContentTheme)
.where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable))
.fetchOne()
?.toInt()
?: 0
}
override fun findAudios(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyFree: Boolean,
onlyPointAvailable: Boolean
): List<MainContentAllAudio> {
val rows = findAudioRows(memberId, canViewAdultContent, now, sort, offset, limit, onlyFree, onlyPointAvailable)
if (rows.isEmpty()) return emptyList()
val contentIds = rows.map { it.get(audioContent.id)!! }
val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct()
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
val originalSeriesByContentId = originalSeriesFlags(contentIds)
return rows.map { row ->
val contentId = row.get(audioContent.id)!!
val creatorId = row.get(audioContent.member.id)!!
MainContentAllAudio(
audioContentId = contentId,
title = row.get(audioContent.title)!!,
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
price = row.get(audioContent.price)!!,
isAdult = row.get(audioContent.isAdult)!!,
isPointAvailable = row.get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentIdByCreatorId[creatorId] == contentId,
isOriginalSeries = originalSeriesByContentId[contentId] ?: false,
creatorNickname = row.get(member.nickname)!!
)
}
}
override fun countSeries(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyOriginal: Boolean,
dayOfWeek: SeriesPublishedDaysOfWeek?
): Int {
return queryFactory
.select(series.id.count())
.from(series)
.join(series.member, member)
.where(seriesCondition(memberId, canViewAdultContent, onlyOriginal, dayOfWeek))
.fetchOne()
?.toInt()
?: 0
}
override fun findSeries(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyOriginal: Boolean,
dayOfWeek: SeriesPublishedDaysOfWeek?,
locale: String
): List<MainContentAllSeries> {
val seriesIds = findSeriesIds(memberId, canViewAdultContent, now, sort, offset, limit, onlyOriginal, dayOfWeek)
if (seriesIds.isEmpty()) return emptyList()
val seriesTranslation = QSeriesTranslation("mainContentAllSeriesTranslation")
return queryFactory
.select(
series.id,
series.title,
seriesTranslation.renderedPayload,
series.coverImage,
member.nickname,
series.isOriginal,
series.isAdult
)
.from(series)
.join(series.member, member)
.leftJoin(seriesTranslation)
.on(
seriesTranslation.seriesId.eq(series.id),
seriesTranslation.locale.eq(locale)
)
.where(series.id.`in`(seriesIds))
.fetch()
.sortedBy { seriesIds.indexOf(it.get(series.id)!!) }
.map { row ->
val translatedTitle = row.get(seriesTranslation.renderedPayload)?.title
MainContentAllSeries(
seriesId = row.get(series.id)!!,
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: row.get(series.title)!!,
coverImageUrl = row.get(series.coverImage).toCdnUrl(cloudFrontHost),
creatorNickname = row.get(member.nickname)!!,
isOriginal = row.get(series.isOriginal)!!,
isAdult = row.get(series.isAdult)!!
)
}
}
private fun findAudioRows(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyFree: Boolean,
onlyPointAvailable: Boolean
): List<Tuple> {
val query = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.member.id,
member.nickname,
audioContent.releaseDate
)
.from(audioContent)
.join(audioContent.member, member)
.join(audioContent.theme, audioContentTheme)
.where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable))
when (sort) {
ContentSort.POPULAR -> {
val revenueOrder = QOrder("mainContentAllAudioRevenueOrder")
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.groupByAudioRow()
.orderBy(
revenueOrder.can.sum().coalesce(0).desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.PRICE_HIGH -> query.orderBy(
audioContent.price.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_LOW -> query.orderBy(
audioContent.price.asc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
ContentSort.LATEST,
ContentSort.OWNED -> query.orderBy(
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
return query.offset(offset).limit(limit.toLong()).fetch()
}
private fun findSeriesIds(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyOriginal: Boolean,
dayOfWeek: SeriesPublishedDaysOfWeek?
): List<Long> {
val audioCreator = QMember("mainContentAllSeriesAudioCreator")
val audioTheme = QAudioContentTheme("mainContentAllSeriesAudioTheme")
val revenueOrder = QOrder("mainContentAllSeriesRevenueOrder")
val publicSeriesAudioCondition = publicSeriesAudioCondition(canViewAdultContent, now, audioCreator, audioTheme)
val latestReleaseDate = CaseBuilder()
.`when`(publicSeriesAudioCondition)
.then(audioContent.releaseDate)
.otherwise(null as LocalDateTime?)
.max()
val highestPrice = CaseBuilder()
.`when`(publicSeriesAudioCondition)
.then(audioContent.price)
.otherwise(null as Int?)
.max()
val lowestPrice = CaseBuilder()
.`when`(publicSeriesAudioCondition)
.then(audioContent.price)
.otherwise(null as Int?)
.min()
val revenue = CaseBuilder()
.`when`(publicSeriesAudioCondition)
.then(revenueOrder.can)
.otherwise(0)
.sum()
.coalesce(0)
val latestReleaseDateNullLast = CaseBuilder().`when`(latestReleaseDate.isNull).then(1).otherwise(0)
val highestPriceNullLast = CaseBuilder().`when`(highestPrice.isNull).then(1).otherwise(0)
val lowestPriceNullLast = CaseBuilder().`when`(lowestPrice.isNull).then(1).otherwise(0)
val query = queryFactory
.select(series.id)
.from(series)
.join(series.member, member)
.leftJoin(seriesContent).on(seriesContent.series.id.eq(series.id))
.leftJoin(audioContent).on(seriesContent.content.id.eq(audioContent.id))
.leftJoin(audioContent.member, audioCreator)
.leftJoin(audioContent.theme, audioTheme)
.where(seriesCondition(memberId, canViewAdultContent, onlyOriginal, dayOfWeek))
.groupBy(series.id)
when (sort) {
ContentSort.POPULAR ->
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.orderBy(revenue.desc(), latestReleaseDate.desc(), series.id.desc())
ContentSort.PRICE_HIGH -> query.orderBy(
highestPriceNullLast.asc(),
highestPrice.desc(),
latestReleaseDate.desc(),
series.id.desc()
)
ContentSort.PRICE_LOW -> query.orderBy(
lowestPriceNullLast.asc(),
lowestPrice.asc(),
latestReleaseDate.desc(),
series.id.desc()
)
ContentSort.LATEST,
ContentSort.OWNED -> query.orderBy(
latestReleaseDateNullLast.asc(),
latestReleaseDate.desc(),
series.id.desc()
)
}
return query.offset(offset).limit(limit.toLong()).fetch()
}
private fun JPAQuery<Tuple>.groupByAudioRow(): JPAQuery<Tuple> {
return groupBy(
audioContent.id,
audioContent.title,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.member.id,
member.nickname,
audioContent.releaseDate
)
}
private fun firstAudioContentIds(
creatorIds: List<Long>,
now: LocalDateTime,
canViewAdultContent: Boolean
): Map<Long, Long> {
return creatorIds.associateWith { creatorId ->
queryFactory
.select(audioContent.id)
.from(audioContent)
.join(audioContent.member, member)
.join(audioContent.theme, audioContentTheme)
.where(
audioContent.member.id.eq(creatorId),
publicAudioCondition(canViewAdultContent, now)
)
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
.fetchFirst()
}.filterValues { it != null }.mapValues { it.value!! }
}
private fun originalSeriesFlags(contentIds: List<Long>): Map<Long, Boolean> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(seriesContent.content.id, series.isOriginal)
.from(seriesContent)
.join(seriesContent.series, series)
.where(seriesContent.content.id.`in`(contentIds))
.fetch()
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
}
private fun audioCondition(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyFree: Boolean,
onlyPointAvailable: Boolean
): BooleanExpression {
return publicAudioCondition(canViewAdultContent, now)
.and(optionalAudioFreeCondition(onlyFree))
.and(optionalAudioPointCondition(onlyPointAvailable))
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
}
private fun publicAudioCondition(canViewAdultContent: Boolean, now: LocalDateTime): BooleanExpression {
return audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(audioContent.member.isActive.isTrue)
.and(audioContentTheme.isActive.isTrue)
.withOptionalAnd(adultAudioCondition(canViewAdultContent))
}
private fun publicSeriesAudioCondition(
canViewAdultContent: Boolean,
now: LocalDateTime,
audioCreator: QMember,
audioTheme: QAudioContentTheme
): BooleanExpression {
return audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(audioCreator.isActive.isTrue)
.and(audioTheme.isActive.isTrue)
.withOptionalAnd(adultAudioCondition(canViewAdultContent))
}
private fun seriesCondition(
memberId: Long?,
canViewAdultContent: Boolean,
onlyOriginal: Boolean,
dayOfWeek: SeriesPublishedDaysOfWeek?
): BooleanExpression {
return series.isActive.isTrue
.and(member.isActive.isTrue)
.and(optionalOriginalCondition(onlyOriginal))
.withOptionalAnd(dayOfWeekCondition(dayOfWeek))
.withOptionalAnd(adultSeriesCondition(canViewAdultContent))
.withOptionalAnd(notBlockedCreatorCondition(memberId, series.member.id))
}
private fun optionalAudioFreeCondition(onlyFree: Boolean): BooleanExpression? {
return if (onlyFree) audioContent.price.eq(0) else null
}
private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? {
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null
}
private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? {
return if (onlyOriginal) series.isOriginal.isTrue else null
}
private fun dayOfWeekCondition(dayOfWeek: SeriesPublishedDaysOfWeek?): BooleanExpression? {
return dayOfWeek?.let { series.publishedDaysOfWeek.contains(it) }
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else series.isAdult.isFalse
}
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
if (memberId == null) return null
val blockMember = QBlockMember("mainContentAllBlockMember")
return JPAExpressions
.selectOne()
.from(blockMember)
.where(
blockMember.isActive.isTrue,
blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath))
.or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId)))
)
.notExists()
}
private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
return if (condition == null) this else and(condition)
}
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.content.all.port.out.MainContentAllQueryPort
interface MainContentAllQueryRepository : MainContentAllQueryPort

Some files were not shown because too many files have changed in this diff Show More