Compare commits

..

483 Commits

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

View File

@@ -597,6 +597,29 @@
--- ---
### 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
- Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다. - Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다.
@@ -604,19 +627,20 @@
- 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, Task 8.1, Task 8.2에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기, AI 캐릭터에 대응하는 `creatorId` 노출을 검증한다. - 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 코드 리뷰 및 검증을 진행했다. 변경 범위가 `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: 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-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`로 통과했다.

View File

@@ -268,6 +268,7 @@
- 기존 엔티티 후보는 `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

@@ -511,3 +511,27 @@ data class AudioRankingSnapshotRecord(
- 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 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 문서 확인: `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 완료 상태와 검증 기록만 갱신했다. - 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

@@ -13,6 +13,7 @@
- `매출`, `판매량`, `댓글 수`, `좋아요`는 기존 랭킹과 동일한 원천 지표를 사용하되, 순위 변화와 신규 진입 여부를 안정적으로 계산하려면 v2 스냅샷 생성 시점에 완료 주차 기준으로 직접 집계해야 한다. - `매출`, `판매량`, `댓글 수`, `좋아요`는 기존 랭킹과 동일한 원천 지표를 사용하되, 순위 변화와 신규 진입 여부를 안정적으로 계산하려면 v2 스냅샷 생성 시점에 완료 주차 기준으로 직접 집계해야 한다.
- 조회 시마다 모든 랭킹 타입의 원천 데이터를 집계하면 응답 지연과 계산 중복이 커지고, 운영 서버와 테스트 환경에서 같은 기준의 결과를 재현하기 어렵다. - 조회 시마다 모든 랭킹 타입의 원천 데이터를 집계하면 응답 지연과 계산 중복이 커지고, 운영 서버와 테스트 환경에서 같은 기준의 결과를 재현하기 어렵다.
- 기존 v2 패키지에 크리에이터 랭킹 스냅샷/작업 이력/fallback 패턴과 콘텐츠 추천 탭의 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재사용해야 한다. - 기존 v2 패키지에 크리에이터 랭킹 스냅샷/작업 이력/fallback 패턴과 콘텐츠 추천 탭의 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재사용해야 한다.
- 2026-06-25 후속 확인 결과, 메인 콘텐츠 랭킹 탭 API의 `coverImageUrl` 응답이 `cloud.aws.cloud-front.host`가 포함된 완성 URL이 아니라 `cover-*.png` 같은 저장 path만 내려가는 버그가 확인되었다. 앱 클라이언트는 공개 API의 이미지 필드를 직접 렌더링 가능한 URL로 기대하므로, 다른 v2 콘텐츠/크리에이터 조회 API와 동일하게 CDN host를 포함해 반환해야 한다.
--- ---
@@ -30,6 +31,7 @@
- fallback 실행은 스케줄 실행 기록처럼 저장하며, 동일 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 시도한다. - fallback 실행은 스케줄 실행 기록처럼 저장하며, 동일 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 시도한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다. - PRD에 API endpoint와 Response data class 초안을 포함한다.
- 신규 Entity가 생성되는 경우 같은 작업 디렉터리에 대응 DB table 생성/수정 DDL을 기록한다. - 신규 Entity가 생성되는 경우 같은 작업 디렉터리에 대응 DB table 생성/수정 DDL을 기록한다.
- `coverImageUrl`은 스냅샷 또는 DB에 저장된 path를 그대로 공개하지 않고, 공개 Response를 만들기 전에 `cloud.aws.cloud-front.host`를 포함한 URL로 변환한다.
--- ---
@@ -91,6 +93,7 @@
- 후보가 20개 미만이면 가능한 개수만 내려준다. - 후보가 20개 미만이면 가능한 개수만 내려준다.
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다. - 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
- 콘텐츠 제목, 크리에이터 닉네임, 커버 이미지가 기존 정책상 마스킹되어야 하는 경우 기존 콘텐츠 랭킹/추천 조회 정책을 따른다. - 콘텐츠 제목, 크리에이터 닉네임, 커버 이미지가 기존 정책상 마스킹되어야 하는 경우 기존 콘텐츠 랭킹/추천 조회 정책을 따른다.
- `coverImageUrl`은 스냅샷 저장값이 path 형태여도 공개 응답에서는 `https://...` 또는 `http://...`로 시작하는 완성 URL이어야 한다. 이미 완성 URL인 값은 중복 prefix를 붙이지 않는다.
### Feature B. rank, rankChange, isNew 의미 ### Feature B. rank, rankChange, isNew 의미
@@ -299,6 +302,15 @@ data class AudioRankingItemResponse(
) )
``` ```
`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 ```json

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

@@ -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

@@ -106,6 +106,8 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -143,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 {
@@ -217,7 +199,7 @@ class HomeRecommendationFacade(
return memberContentPreferenceService.canViewAdultContent(member) return memberContentPreferenceService.canViewAdultContent(member)
} }
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,

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

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.content.ranking.application
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.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking 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.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
@@ -9,6 +10,7 @@ import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingBlockPor
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.ZonedDateTime import java.time.ZonedDateTime
@@ -19,6 +21,8 @@ class AudioRankingQueryService(
private val memberContentPreferenceService: MemberContentPreferenceService, private val memberContentPreferenceService: MemberContentPreferenceService,
private val blockPort: AudioRankingBlockPort, private val blockPort: AudioRankingBlockPort,
private val jobService: AudioRankingSnapshotJobService, private val jobService: AudioRankingSnapshotJobService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() } private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
@@ -105,7 +109,7 @@ class AudioRankingQueryService(
rank = rank, rank = rank,
rankChange = if (showRankChange && previousRank != null) previousRank - rank else null, rankChange = if (showRankChange && previousRank != null) previousRank - rank else null,
isNew = showRankChange && previousRank == null, isNew = showRankChange && previousRank == null,
coverImageUrl = coverImageUrl coverImageUrl = coverImageUrl.toCdnUrl(cloudFrontHost)
) )
} }

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.application
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.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
@@ -29,7 +30,7 @@ class AudioRecommendationQueryService(
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
val memberId = member?.id val memberId = member?.id
val newAndHotSectionType = newAndHotSectionType(visibility) val newAndHotSectionType = newAndHotSectionType(visibility)
val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_HOME_LIMIT)
val mostCommentedSnapshots = snapshotPort.findLatestSnapshots( val mostCommentedSnapshots = snapshotPort.findLatestSnapshots(
mostCommentedSectionType(visibility), mostCommentedSectionType(visibility),
limit = MOST_COMMENTED_AUDIO_LIMIT limit = MOST_COMMENTED_AUDIO_LIMIT
@@ -38,7 +39,12 @@ class AudioRecommendationQueryService(
recommendedAudioSectionType(visibility), recommendedAudioSectionType(visibility),
limit = RECOMMENDED_AUDIO_LIMIT limit = RECOMMENDED_AUDIO_LIMIT
) )
val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(newAndHotSectionType, newAndHotSnapshots) val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(
newAndHotSectionType,
newAndHotSnapshots,
offset = 0,
limit = NEW_AND_HOT_HOME_LIMIT
)
return AudioRecommendations( return AudioRecommendations(
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent), banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent),
@@ -66,6 +72,22 @@ class AudioRecommendationQueryService(
) )
} }
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 = snapshotPort.findLatestSnapshots(sectionType, offset, limit)
val refreshedSnapshots = refreshMissingNewAndHotSnapshots(sectionType, snapshots, offset, limit)
return queryPort.findAudioCardsByIds(
refreshedSnapshots.map { it.targetId },
member.id,
canViewAdultContent,
now
)
}
fun resolveVisibility(member: Member?): AudioRecommendationVisibility { fun resolveVisibility(member: Member?): AudioRecommendationVisibility {
return if (canViewAdultContent(member)) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE return if (canViewAdultContent(member)) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
} }
@@ -93,7 +115,9 @@ class AudioRecommendationQueryService(
private fun refreshMissingNewAndHotSnapshots( private fun refreshMissingNewAndHotSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshots: List<RecommendationSnapshotRecord> snapshots: List<RecommendationSnapshotRecord>,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
if (snapshots.isNotEmpty()) return snapshots if (snapshots.isNotEmpty()) return snapshots
val today = LocalDate.now(KST_ZONE) val today = LocalDate.now(KST_ZONE)
@@ -107,7 +131,7 @@ class AudioRecommendationQueryService(
marker.delete() marker.delete()
throw ex throw ex
} }
return snapshotPort.findLatestSnapshots(sectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) return snapshotPort.findLatestSnapshots(sectionType, offset, limit)
} }
private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String { private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String {
@@ -125,7 +149,7 @@ class AudioRecommendationQueryService(
const val LATEST_AUDIO_LIMIT = 12 const val LATEST_AUDIO_LIMIT = 12
const val FREE_AUDIO_LIMIT = 10 const val FREE_AUDIO_LIMIT = 10
const val POINT_AUDIO_LIMIT = 10 const val POINT_AUDIO_LIMIT = 10
const val NEW_AND_HOT_AUDIO_LIMIT = 12 const val NEW_AND_HOT_HOME_LIMIT = 12
const val MOST_COMMENTED_AUDIO_LIMIT = 5 const val MOST_COMMENTED_AUDIO_LIMIT = 5
const val RECOMMENDED_AUDIO_LIMIT = 10 const val RECOMMENDED_AUDIO_LIMIT = 10
private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted" private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted"

View File

@@ -68,7 +68,7 @@ class AudioRecommendationSnapshotRefreshService(
visibility: AudioRecommendationVisibility visibility: AudioRecommendationVisibility
) { ) {
val sectionType = visibility.newAndHotSectionType() val sectionType = visibility.newAndHotSectionType()
val snapshots = queryPort.findNewAndHotSnapshots(windowStart, snapshotAt, visibility, NEW_AND_HOT_LIMIT) val snapshots = queryPort.findNewAndHotSnapshots(windowStart, snapshotAt, visibility, NEW_AND_HOT_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots) snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
} }
@@ -128,7 +128,7 @@ class AudioRecommendationSnapshotRefreshService(
} }
companion object { companion object {
const val NEW_AND_HOT_LIMIT = 12 const val NEW_AND_HOT_SNAPSHOT_LIMIT = 100
const val MOST_COMMENTED_LIMIT = 5 const val MOST_COMMENTED_LIMIT = 5
const val RECOMMENDED_AUDIO_LIMIT = 10 const val RECOMMENDED_AUDIO_LIMIT = 10
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")

View File

@@ -0,0 +1,343 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Expression
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
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 org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
@Repository
class DefaultHomeFollowingQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : HomeFollowingQueryRepository {
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
val creator = QMember("followingCreator")
return queryFactory
.select(creator.id, creator.nickname, creator.profileImage)
.from(creatorFollowing)
.join(creatorFollowing.creator, creator)
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(creatorFollowing.createdAt.desc(), creatorFollowing.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingCreator(
creatorId = row.get(creator.id)!!,
creatorNickname = row.get(creator.nickname)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage))
)
}
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
val creator = QMember("onAirCreator")
return queryFactory
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingLive(
liveId = row.get(liveRoom.id)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage)),
creatorNickname = row.get(creator.nickname)!!,
title = row.get(liveRoom.title)!!,
startedAtUtc = row.get(liveRoom.beginDateTime)!!.toUtcIso()
)
}
}
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
val window = monthlyScheduleWindow(now)
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
return (liveSchedules + audioSchedules)
.sortedWith(
compareBy<HomeFollowingSchedule> { it.scheduledAtUtc }
.thenBy { it.type.sortOrder }
.thenBy { it.targetId }
)
.take(limit)
}
override fun findRecentNews(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime,
limit: Int
): List<HomeFollowingNews> {
val creator = QMember("newsCreator")
return queryFactory
.select(
homeFollowingNewsInbox.id,
homeFollowingNewsInbox.newsType,
homeFollowingNewsInbox.creatorProfileImagePath,
homeFollowingNewsInbox.creatorNickname,
homeFollowingNewsInbox.title,
homeFollowingNewsInbox.body,
homeFollowingNewsInbox.thumbnailImagePath,
homeFollowingNewsInbox.targetId,
homeFollowingNewsInbox.occurredAtUtc,
homeFollowingNewsInbox.visibleFromAtUtc,
homeFollowingNewsInbox.rank
)
.from(homeFollowingNewsInbox)
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
.where(
homeFollowingNewsInbox.memberId.eq(memberId),
homeFollowingNewsInbox.isActive.isTrue,
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultNewsCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
activeNewsTargetCondition()
)
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingNews(
newsId = row.get(homeFollowingNewsInbox.id)!!.toString(),
type = row.get(homeFollowingNewsInbox.newsType)!!,
creatorProfileImageUrl = profileImageUrl(row.get(homeFollowingNewsInbox.creatorProfileImagePath)),
creatorNickname = row.get(homeFollowingNewsInbox.creatorNickname)!!,
title = row.get(homeFollowingNewsInbox.title)!!,
body = row.get(homeFollowingNewsInbox.body)!!,
thumbnailImageUrl = row.get(homeFollowingNewsInbox.thumbnailImagePath).toCdnUrl(cloudFrontHost),
targetId = row.get(homeFollowingNewsInbox.targetId)!!,
occurredAtUtc = row.get(homeFollowingNewsInbox.occurredAtUtc)!!.toUtcIso(),
visibleFromAtUtc = row.get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso(),
rank = row.get(homeFollowingNewsInbox.rank)
)
}
}
private fun findLiveSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleLiveCreator")
return queryFactory
.select(
liveRoom.id,
creator.id,
creator.profileImage,
creator.nickname,
liveRoom.title,
liveRoom.beginDateTime,
liveRoom.channelName
)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.beginDateTime.goe(window.startUtc),
liveRoom.beginDateTime.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toLiveSchedule(creator) }
}
private fun findAudioSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleAudioCreator")
return queryFactory
.select(
audioContent.id,
creator.id,
creator.profileImage,
creator.nickname,
audioContent.title,
audioContent.releaseDate
)
.from(audioContent)
.join(audioContent.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.goe(window.startUtc),
audioContent.releaseDate.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultAudioCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toAudioSchedule(creator) }
}
private fun Tuple.toLiveSchedule(creator: QMember): HomeFollowingSchedule {
val liveId = get(liveRoom.id)!!
val channelName = get(liveRoom.channelName)
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.LIVE}:$liveId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(liveRoom.title)!!,
type = CreatorActivityType.LIVE,
targetId = liveId,
scheduledAtUtc = get(liveRoom.beginDateTime)!!.toUtcIso(),
isOnAir = !channelName.isNullOrBlank()
)
}
private fun Tuple.toAudioSchedule(creator: QMember): HomeFollowingSchedule {
val contentId = get(audioContent.id)!!
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.AUDIO}:$contentId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(audioContent.title)!!,
type = CreatorActivityType.AUDIO,
targetId = contentId,
scheduledAtUtc = get(audioContent.releaseDate)!!.toUtcIso(),
isOnAir = false
)
}
private fun monthlyScheduleWindow(now: LocalDateTime): ScheduleWindow {
val kstNow = now.atOffset(ZoneOffset.UTC).atZoneSameInstant(KST_ZONE_ID).toLocalDateTime()
val startKst = kstNow.toLocalDate().atStartOfDay()
val endKst = startKst.toLocalDate().plusMonths(1).withDayOfMonth(1).atStartOfDay()
return ScheduleWindow(startUtc = startKst.toUtcFromKst(), endUtc = endKst.toUtcFromKst())
}
private fun LocalDateTime.toUtcFromKst(): LocalDateTime {
return atZone(KST_ZONE_ID).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
}
private fun adultLiveCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun adultNewsCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse
}
private fun activeNewsTargetCondition(): BooleanExpression {
val newsAudioContent = QAudioContent("newsAudioContent")
val newsCommunity = QCreatorCommunity("newsCommunity")
val activeAudioExists = JPAExpressions
.selectOne()
.from(newsAudioContent)
.where(
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
newsAudioContent.isActive.isTrue
)
.exists()
val activeCommunityExists = JPAExpressions
.selectOne()
.from(newsCommunity)
.where(
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
newsCommunity.isActive.isTrue
)
.exists()
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
}
private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
val blockMember = QBlockMember("homeFollowingBlockMember")
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 profileImageUrl(path: String?): String {
return path.toCdnUrl(cloudFrontHost) ?: "$cloudFrontHost/profile/default-profile.png"
}
private val CreatorActivityType.sortOrder: Int
get() = when (this) {
CreatorActivityType.LIVE -> 0
else -> 1
}
private data class ScheduleWindow(
val startUtc: LocalDateTime,
val endUtc: LocalDateTime
)
companion object {
private val KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}
}

View File

@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "home_following_news_inbox",
uniqueConstraints = [
UniqueConstraint(
name = "uk_home_following_news_inbox_member_type_source",
columnNames = ["member_id", "news_type", "source_key"]
)
]
)
class HomeFollowingNewsInbox(
@Column(name = "member_id", nullable = false, updatable = false)
val memberId: Long,
@Column(name = "creator_id", nullable = false, updatable = false)
val creatorId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "news_type", nullable = false, updatable = false, length = 30)
val newsType: FollowingNewsType,
@Column(name = "source_key", nullable = false, updatable = false, length = 200)
val sourceKey: String,
@Column(name = "target_id", nullable = false, updatable = false)
val targetId: Long,
@Column(name = "occurred_at_utc", nullable = false, updatable = false)
val occurredAtUtc: LocalDateTime,
@Column(name = "visible_from_at_utc", nullable = false, updatable = false)
val visibleFromAtUtc: LocalDateTime,
@Column(name = "creator_nickname", nullable = false, updatable = false, length = 100)
val creatorNickname: String,
@Column(name = "creator_profile_image_path", updatable = false, length = 500)
val creatorProfileImagePath: String?,
@Column(name = "title", nullable = false, updatable = false, length = 255)
val title: String,
@Column(name = "body", nullable = false, updatable = false, length = 1000)
val body: String,
@Column(name = "thumbnail_image_path", updatable = false, length = 500)
val thumbnailImagePath: String?,
@Column(name = "rank_no", updatable = false)
val rank: Int?,
@Column(name = "is_adult", nullable = false, updatable = false)
val isAdult: Boolean,
@Column(name = "is_active", nullable = false)
var isActive: Boolean = true
) : BaseEntity() {
fun deactivate() {
isActive = false
}
}

View File

@@ -0,0 +1,59 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsInbox, Long> {
fun existsByMemberIdAndNewsTypeAndSourceKey(
memberId: Long,
newsType: kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType,
sourceKey: String
): Boolean
@Query(
value = """
select member_id
from home_following_news_inbox
where news_type = :newsType
and source_key = :sourceKey
and member_id in :memberIds
""",
nativeQuery = true
)
fun findExistingMemberIds(
@Param("newsType") newsType: String,
@Param("sourceKey") sourceKey: String,
@Param("memberIds") memberIds: Collection<Long>
): List<Long>
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value = """
update home_following_news_inbox
set is_active = false,
updated_at = current_timestamp
where member_id = :memberId
and creator_id = :creatorId
and is_active = true
""",
nativeQuery = true
)
fun deactivateByMemberIdAndCreatorId(
@Param("memberId") memberId: Long,
@Param("creatorId") creatorId: Long
): Int
@Query(
value = """
select cf.member_id
from creator_following cf
where cf.creator_id = :creatorId
and cf.is_active = true
order by cf.member_id asc
""",
nativeQuery = true
)
fun findActiveFollowerIds(@Param("creatorId") creatorId: Long): List<Long>
}

View File

@@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Repository
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@Repository
class HomeFollowingNewsInboxPersistenceAdapter(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager,
transactionManager: PlatformTransactionManager? = null
) : HomeFollowingNewsInboxPort {
private val transactionTemplate = transactionManager?.let {
TransactionTemplate(it).also { template ->
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
}
override fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int {
if (records.isEmpty()) {
return 0
}
val distinctRecords = records
.distinctBy { Triple(it.memberId, it.newsType, it.sourceKey) }
return insertWithRetry(distinctRecords)
}
@Transactional
override fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long {
return repository.deactivateByMemberIdAndCreatorId(memberId, creatorId).toLong()
}
override fun findActiveFollowerIds(creatorId: Long): List<Long> {
return repository.findActiveFollowerIds(creatorId)
}
private fun insertWithRetry(records: List<HomeFollowingNewsInboxRecord>): Int {
var lastFailure: DataIntegrityViolationException? = null
repeat(MAX_INSERT_ATTEMPTS) {
try {
return executeInsertAttempt(records)
} catch (ex: DataIntegrityViolationException) {
lastFailure = ex
entityManager.clear()
}
}
throw requireNotNull(lastFailure)
}
private fun executeInsertAttempt(records: List<HomeFollowingNewsInboxRecord>): Int {
return transactionTemplate?.execute { insertNewRows(records) } ?: insertNewRows(records)
}
private fun insertNewRows(records: List<HomeFollowingNewsInboxRecord>): Int {
val entities = records
.groupBy { SourceKey(newsType = it.newsType, sourceKey = it.sourceKey) }
.flatMap { (sourceKey, sourceRecords) ->
FollowingNewsType.valueOf(sourceKey.newsType)
val existingMemberIds = repository.findExistingMemberIds(
newsType = sourceKey.newsType,
sourceKey = sourceKey.sourceKey,
memberIds = sourceRecords.map { it.memberId }
).toSet()
sourceRecords
.filterNot { it.memberId in existingMemberIds }
.map { it.toEntity() }
}
if (entities.isEmpty()) {
return 0
}
repository.saveAll(entities)
repository.flush()
return entities.size
}
private fun HomeFollowingNewsInboxRecord.toEntity(): HomeFollowingNewsInbox {
return HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.valueOf(newsType),
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title,
body = body,
thumbnailImagePath = thumbnailImagePath,
rank = rank,
isAdult = isAdult
)
}
private data class SourceKey(
val newsType: String,
val sourceKey: String
)
companion object {
private const val MAX_INSERT_ATTEMPTS = 2
}
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
interface HomeFollowingQueryRepository : HomeFollowingQueryPort

View File

@@ -0,0 +1,146 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKey
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class HomeFollowingNewsPublishService(
private val inboxPort: HomeFollowingNewsInboxPort
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun publishCommunityPostCreated(
postId: Long,
creatorId: Long,
creatorNickname: String,
creatorProfileImagePath: String?,
title: String,
body: String,
thumbnailImagePath: String?,
occurredAtUtc: LocalDateTime,
isAdult: Boolean
): Int {
return publishToFollowers(
creatorId = creatorId,
newsType = FollowingNewsType.COMMUNITY_POST,
sourceKey = HomeFollowingNewsSourceKey.communityPost(postId),
targetId = postId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = occurredAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title,
body = body,
thumbnailImagePath = thumbnailImagePath,
rank = null,
isAdult = isAdult
)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun publishContentUploaded(
contentId: Long,
creatorId: Long,
creatorNickname: String,
creatorProfileImagePath: String?,
title: String,
body: String,
thumbnailImagePath: String?,
occurredAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
isAdult: Boolean
): Int {
return publishToFollowers(
creatorId = creatorId,
newsType = FollowingNewsType.AUDIO_CONTENT,
sourceKey = HomeFollowingNewsSourceKey.audioContent(contentId),
targetId = contentId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title,
body = body,
thumbnailImagePath = thumbnailImagePath,
rank = null,
isAdult = isAdult
)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun publishCreatorRankingVisible(
creatorId: Long,
creatorNickname: String,
creatorProfileImagePath: String?,
aggregationStartAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
rank: Int
): Int {
return publishToFollowers(
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING,
sourceKey = HomeFollowingNewsSourceKey.creatorRanking(creatorId, aggregationStartAtUtc),
targetId = creatorId,
occurredAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = creatorNickname,
body = "$rank",
thumbnailImagePath = creatorProfileImagePath,
rank = rank,
isAdult = false
)
}
private fun publishToFollowers(
creatorId: Long,
newsType: FollowingNewsType,
sourceKey: String,
targetId: Long,
occurredAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
creatorNickname: String,
creatorProfileImagePath: String?,
title: String,
body: String,
thumbnailImagePath: String?,
rank: Int?,
isAdult: Boolean
): Int {
val followerIds = inboxPort.findActiveFollowerIds(creatorId)
if (followerIds.isEmpty()) {
return 0
}
val records = followerIds.map { memberId ->
HomeFollowingNewsInboxRecord(
memberId = memberId,
creatorId = creatorId,
newsType = newsType.name,
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title.take(TITLE_MAX_LENGTH),
body = body.take(BODY_MAX_LENGTH),
thumbnailImagePath = thumbnailImagePath,
rank = rank,
isAdult = isAdult
)
}
return inboxPort.insertIgnoreAll(records)
}
companion object {
private const val TITLE_MAX_LENGTH = 255
private const val BODY_MAX_LENGTH = 1_000
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class HomeFollowingQueryService(
private val queryPort: HomeFollowingQueryPort,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) }
) {
fun findHomeFollowing(member: Member): HomeFollowing {
val memberId = requireNotNull(member.id)
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member)
val now = nowProvider()
return HomeFollowing(
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
onAirLives = queryPort.findOnAirLives(memberId, canViewAdultContent, ON_AIR_LIVES_LIMIT),
recentChats = emptyList(),
monthlySchedules = queryPort.findMonthlySchedules(memberId, canViewAdultContent, now, MONTHLY_SCHEDULES_LIMIT),
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
)
}
companion object {
private const val FOLLOWING_CREATORS_LIMIT = 20
private const val ON_AIR_LIVES_LIMIT = 10
private const val MONTHLY_SCHEDULES_LIMIT = 3
private const val RECENT_NEWS_LIMIT = 30
}
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.v2.home.following.domain
enum class FollowingNewsType {
CREATOR_RANKING,
CONTENT_RANKING,
COMMUNITY_POST,
AUDIO_CONTENT,
PHOTO_CONTENT
}

View File

@@ -0,0 +1,52 @@
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?
)

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.v2.home.following.domain
import java.time.LocalDateTime
object HomeFollowingNewsSourceKey {
fun creatorRanking(creatorId: Long, aggregationStartAtUtc: LocalDateTime): String {
return "${FollowingNewsType.CREATOR_RANKING.name}:$creatorId:$aggregationStartAtUtc"
}
fun audioContent(contentId: Long): String {
return "${FollowingNewsType.AUDIO_CONTENT.name}:$contentId"
}
fun communityPost(postId: Long): String {
return "${FollowingNewsType.COMMUNITY_POST.name}:$postId"
}
}

View File

@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.v2.home.following.port.out
import java.time.LocalDateTime
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
)

View File

@@ -0,0 +1,27 @@
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>
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
@@ -19,7 +20,8 @@ import java.time.ZonedDateTime
@Service @Service
class CreatorRankingSnapshotRefreshService( class CreatorRankingSnapshotRefreshService(
private val aggregationPort: CreatorRankingAggregationPort, private val aggregationPort: CreatorRankingAggregationPort,
private val snapshotPort: CreatorRankingSnapshotPort private val snapshotPort: CreatorRankingSnapshotPort,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val periodPolicy = CreatorRankingPeriodPolicy() private val periodPolicy = CreatorRankingPeriodPolicy()
@@ -47,6 +49,28 @@ class CreatorRankingSnapshotRefreshService(
visibleFromAtUtc = visibleFromAtUtc, visibleFromAtUtc = visibleFromAtUtc,
newSnapshots = snapshots newSnapshots = snapshots
) )
afterCommit {
snapshots.forEachIndexed { index, snapshot ->
runCatching {
homeFollowingNewsPublishService.publishCreatorRankingVisible(
creatorId = snapshot.creatorId,
creatorNickname = snapshot.nickname,
creatorProfileImagePath = snapshot.profileImageUrl,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
rank = index + 1
)
}.onFailure { ex ->
log.warn(
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
snapshot.creatorId,
index + 1,
ex.message,
ex
)
}
}
}
aggregationResult.toLogCounts(storedCount = snapshots.size) aggregationResult.toLogCounts(storedCount = snapshots.size)
}.onSuccess { counts -> }.onSuccess { counts ->
afterCommit { afterCommit {
@@ -92,12 +116,18 @@ class CreatorRankingSnapshotRefreshService(
private fun afterCommit(action: () -> Unit) { private fun afterCommit(action: () -> Unit) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action() runCatching(action).onFailure { ex ->
log.warn("event=creator_ranking_after_commit_failure error={}", ex.message, ex)
}
return return
} }
TransactionSynchronizationManager.registerSynchronization( TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization { object : TransactionSynchronization {
override fun afterCommit() = action() override fun afterCommit() {
runCatching(action).onFailure { ex ->
log.warn("event=creator_ranking_after_commit_failure error={}", ex.message, ex)
}
}
} }
) )
} }

View File

@@ -51,7 +51,7 @@ class DefaultHomeRecommendationQueryRepository(
private val entityManager: EntityManager private val entityManager: EntityManager
) : HomeRecommendationQueryRepository { ) : HomeRecommendationQueryRepository {
override fun findLiveRecommendations( override fun findLiveRecommendations(
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultLives: Boolean includeAdultLives: Boolean
@@ -62,7 +62,10 @@ class DefaultHomeRecommendationQueryRepository(
HomeLiveRecommendationRecord::class.java, HomeLiveRecommendationRecord::class.java,
liveRoom.id, liveRoom.id,
member.nickname, member.nickname,
member.profileImage member.profileImage,
liveRoom.title,
liveRoom.price,
liveRoom.beginDateTime
) )
) )
.from(liveRoom) .from(liveRoom)
@@ -76,7 +79,7 @@ class DefaultHomeRecommendationQueryRepository(
member.isActive.isTrue member.isActive.isTrue
) )
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc()) .orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.offset(offset.toLong()) .offset(offset)
.limit(limit.toLong()) .limit(limit.toLong())
.fetch() .fetch()
} }
@@ -208,7 +211,7 @@ class DefaultHomeRecommendationQueryRepository(
override fun findRecentDebutCreators( override fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -352,7 +355,7 @@ class DefaultHomeRecommendationQueryRepository(
override fun findFirstAudioContents( override fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -387,6 +390,14 @@ class DefaultHomeRecommendationQueryRepository(
ac.release_date as release_date, ac.release_date as release_date,
ac.is_active as is_active, ac.is_active as is_active,
ac.is_point_available as is_point_available, ac.is_point_available as is_point_available,
ac.is_adult as is_adult,
exists (
select 1
from series_content csc
join series cs on cs.id = csc.series_id
where csc.content_id = ac.id
and cs.is_original = true
) as is_original_series,
row_number() over ( row_number() over (
partition by ac.member_id partition by ac.member_id
order by ac.created_at asc, ac.release_date asc, ac.id asc order by ac.created_at asc, ac.release_date asc, ac.id asc
@@ -413,7 +424,9 @@ class DefaultHomeRecommendationQueryRepository(
ec.title as title, ec.title as title,
ec.price as price, ec.price as price,
ec.cover_image as cover_image, ec.cover_image as cover_image,
ec.is_point_available as is_point_available ec.is_point_available as is_point_available,
ec.is_adult as is_adult,
ec.is_original_series as is_original_series
from eligible_contents ec from eligible_contents ec
join member m on m.id = ec.creator_id join member m on m.id = ec.creator_id
join creator_debut cd on cd.creator_id = ec.creator_id join creator_debut cd on cd.creator_id = ec.creator_id
@@ -462,7 +475,9 @@ class DefaultHomeRecommendationQueryRepository(
title = row[4] as String, title = row[4] as String,
price = (row[5] as Number).toInt(), price = (row[5] as Number).toInt(),
coverImage = row[6] as String?, coverImage = row[6] as String?,
isPointAvailable = row[7] as Boolean isPointAvailable = row[7].toNativeBoolean(),
isAdult = row[8].toNativeBoolean(),
isOriginalSeries = row[9].toNativeBoolean()
) )
} }
} }
@@ -1170,6 +1185,14 @@ class DefaultHomeRecommendationQueryRepository(
return if (condition == null) this else and(condition) return if (condition == null) this else and(condition)
} }
private fun Any?.toNativeBoolean(): Boolean {
return when (this) {
is Boolean -> this
is Number -> this.toInt() != 0
else -> this as Boolean
}
}
private fun includeAdultCommunityCondition(includeAdultCommunities: Boolean): BooleanExpression? { private fun includeAdultCommunityCondition(includeAdultCommunities: Boolean): BooleanExpression? {
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse
} }

View File

@@ -12,7 +12,7 @@ class RecommendationSnapshotPersistenceAdapter(
) : RecommendationSnapshotPort { ) : RecommendationSnapshotPort {
override fun findLatestSnapshots( override fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int, offset: Long,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() } return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() }

View File

@@ -24,7 +24,7 @@ interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapsho
) )
fun findLatestSnapshots( fun findLatestSnapshots(
@Param("sectionType") sectionType: String, @Param("sectionType") sectionType: String,
@Param("offset") offset: Int, @Param("offset") offset: Long,
@Param("limit") limit: Int @Param("limit") limit: Int
): List<RecommendationSnapshot> ): List<RecommendationSnapshot>

View File

@@ -24,7 +24,7 @@ class HomeRecommendationQueryService(
private val snapshotPort: RecommendationSnapshotPort private val snapshotPort: RecommendationSnapshotPort
) { ) {
fun findLiveRecommendations( fun findLiveRecommendations(
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_LIVE_LIMIT, limit: Int = DEFAULT_LIVE_LIMIT,
memberId: Long? = null, memberId: Long? = null,
includeAdultLives: Boolean = false includeAdultLives: Boolean = false
@@ -49,7 +49,7 @@ class HomeRecommendationQueryService(
fun findRecentDebutCreators( fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT, limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -59,7 +59,7 @@ class HomeRecommendationQueryService(
fun findFirstAudioContents( fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT, limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -68,7 +68,7 @@ class HomeRecommendationQueryService(
} }
fun findAiCharacterRecommendations( fun findAiCharacterRecommendations(
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_AI_CHARACTER_LIMIT limit: Int = DEFAULT_AI_CHARACTER_LIMIT
): List<HomeAiCharacterRecommendationRecord> { ): List<HomeAiCharacterRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit) val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)

View File

@@ -5,7 +5,7 @@ import java.time.LocalDateTime
interface HomeRecommendationQueryPort { interface HomeRecommendationQueryPort {
fun findLiveRecommendations( fun findLiveRecommendations(
offset: Int = 0, offset: Long = 0,
limit: Int, limit: Int,
memberId: Long? = null, memberId: Long? = null,
includeAdultLives: Boolean = false includeAdultLives: Boolean = false
@@ -24,7 +24,7 @@ interface HomeRecommendationQueryPort {
fun findRecentDebutCreators( fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int, limit: Int,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -32,7 +32,7 @@ interface HomeRecommendationQueryPort {
fun findFirstAudioContents( fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int, limit: Int,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -80,7 +80,10 @@ interface HomeRecommendationQueryPort {
data class HomeLiveRecommendationRecord( data class HomeLiveRecommendationRecord(
val liveRoomId: Long, val liveRoomId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String? val creatorProfileImage: String?,
val title: String,
val price: Int,
val beginDateTime: LocalDateTime
) )
data class HomeBannerRecommendationRecord( data class HomeBannerRecommendationRecord(
@@ -116,7 +119,9 @@ data class HomeFirstAudioContentRecord(
val title: String, val title: String,
val price: Int, val price: Int,
val coverImage: String?, val coverImage: String?,
val isPointAvailable: Boolean val isPointAvailable: Boolean,
val isAdult: Boolean,
val isOriginalSeries: Boolean
) )
data class HomeAiCharacterRecommendationRecord( data class HomeAiCharacterRecommendationRecord(

View File

@@ -6,7 +6,7 @@ import java.time.LocalDateTime
interface RecommendationSnapshotPort { interface RecommendationSnapshotPort {
fun findLatestSnapshots( fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int = 0, offset: Long = 0,
limit: Int = Int.MAX_VALUE limit: Int = Int.MAX_VALUE
): List<RecommendationSnapshotRecord> ): List<RecommendationSnapshotRecord>

View File

@@ -0,0 +1,81 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.SignOut
import kr.co.vividnext.sodalive.member.SignOutRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
import org.springframework.test.context.ContextConfiguration
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AdminMemberServiceTest @Autowired constructor(
private val service: AdminMemberService,
private val adminMemberRepository: AdminMemberRepository,
private val signOutRepository: SignOutRepository
) {
@AfterEach
fun tearDown() {
signOutRepository.deleteAll()
adminMemberRepository.deleteAll()
}
@Test
@DisplayName("OSIV off 환경에서 탈퇴 이력이 있는 회원 리스트를 조회해도 lazy 초기화 예외가 발생하지 않는다")
fun shouldGetMemberListWithSignOutReasonsWhenOpenInViewIsDisabled() {
val member = saveMemberWithSignOutReason(
email = "admin-member-list-user@test.com",
nickname = "회원 목록 사용자",
role = MemberRole.USER
)
val response = service.getMemberList(PageRequest.of(0, 20))
val item = response.items.single { it.id == member.id }
assertEquals(1, response.totalCount)
assertTrue(item.signOutDate.isNotBlank())
}
@Test
@DisplayName("OSIV off 환경에서 탈퇴 이력이 있는 크리에이터 리스트를 조회해도 lazy 초기화 예외가 발생하지 않는다")
fun shouldGetCreatorListWithSignOutReasonsWhenOpenInViewIsDisabled() {
val creator = saveMemberWithSignOutReason(
email = "admin-member-list-creator@test.com",
nickname = "크리에이터 목록 사용자",
role = MemberRole.CREATOR
)
val response = service.getCreatorList(PageRequest.of(0, 20))
val item = response.items.single { it.id == creator.id }
assertEquals(1, response.totalCount)
assertTrue(item.signOutDate.isNotBlank())
}
private fun saveMemberWithSignOutReason(
email: String,
nickname: String,
role: MemberRole
): Member {
val member = adminMemberRepository.save(
Member(
email = email,
password = "password",
nickname = nickname,
role = role
)
)
val signOut = SignOut(reason = "운영 정책 위반")
signOut.member = member
signOutRepository.save(signOut)
return member
}
}

View File

@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.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.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@@ -57,6 +58,7 @@ class AudioContentServiceTest {
private lateinit var audioContentCloudFront: AudioContentCloudFront private lateinit var audioContentCloudFront: AudioContentCloudFront
private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var service: AudioContentService private lateinit var service: AudioContentService
@@ -80,6 +82,7 @@ class AudioContentServiceTest {
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java) contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
service = AudioContentService( service = AudioContentService(
repository = repository, repository = repository,
@@ -103,6 +106,7 @@ class AudioContentServiceTest {
messageSource = SodaMessageSource(), messageSource = SodaMessageSource(),
langContext = LangContext(), langContext = LangContext(),
contentThemeTranslationRepository = contentThemeTranslationRepository, contentThemeTranslationRepository = contentThemeTranslationRepository,
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
audioContentBucket = "audio-bucket", audioContentBucket = "audio-bucket",
coverImageBucket = "cover-bucket", coverImageBucket = "cover-bucket",
coverImageHost = "https://cdn.test" coverImageHost = "https://cdn.test"
@@ -273,6 +277,179 @@ class AudioContentServiceTest {
assertTrue(output.out.contains("contentId=${audioContent.id}")) assertTrue(output.out.contains("contentId=${audioContent.id}"))
} }
@Test
@DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다")
fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() {
val creator = createMember(id = 2100L, nickname = "audio-creator")
creator.profileImage = "profile/audio-creator.png"
val audioContent = createAudioContent(creator = creator, isAdult = true)
audioContent.isActive = false
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = audioContent.detail,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.createdAt!!,
isAdult = true
)
}
@Test
@DisplayName("유료 오디오가 상세 비공개이면 최근 소식은 전체 상세를 노출하지 않는다")
fun shouldMaskPaidAudioDetailWhenPublishingNews() {
val creator = createMember(id = 2120L, nickname = "paid-audio-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.isFullDetailVisible = false
audioContent.detail = "유료 오디오 상세 설명 전체 본문은 최근 소식에서 모두 보이면 안 됩니다"
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = "유료 오디오 상세 설명 전체 본문은 ...",
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.createdAt!!,
isAdult = false
)
}
@Test
@DisplayName("최근 소식 발행 실패는 업로드 완료 처리를 실패시키지 않는다")
fun shouldNotFailUploadCompleteWhenNewsPublishFails() {
val creator = createMember(id = 2130L, nickname = "publish-failure-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
Mockito.doAnswer { throw IllegalStateException("publish failed") }
.`when`(homeFollowingNewsPublishService)
.publishContentUploaded(
contentId = anyLongValue(),
creatorId = anyLongValue(),
creatorNickname = anyStringValue(),
creatorProfileImagePath = Mockito.anyString(),
title = anyStringValue(),
body = anyStringValue(),
thumbnailImagePath = Mockito.anyString(),
occurredAtUtc = anyLocalDateTime(),
visibleFromAtUtc = anyLocalDateTime(),
isAdult = Mockito.anyBoolean()
)
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
assertTrue(audioContent.isActive)
}
@Test
@DisplayName("업로드 완료 시 공개 시각이 생성 이후 업로드 전이면 최근 소식을 발행한다")
fun shouldPublishNewsWhenReleaseDatePassedBeforeUploadComplete() {
val creator = createMember(id = 2150L, nickname = "audio-late-upload-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.createdAt = LocalDateTime.now().minusHours(2)
audioContent.releaseDate = LocalDateTime.now().minusHours(1)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = audioContent.detail,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.releaseDate!!,
isAdult = false
)
}
@Test
@DisplayName("업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다")
fun shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive() {
val now = LocalDateTime.now()
val creator = createMember(id = 2200L, nickname = "scheduled-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.createdAt = now
audioContent.releaseDate = now.plusYears(1)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verifyNoInteractions(homeFollowingNewsPublishService)
}
@Test
@DisplayName("예약 콘텐츠 공개 작업은 활성화 시점에 최근 소식을 발행한다")
fun shouldPublishNewsWhenReleaseContentActivatesScheduledContent() {
val creator = createMember(id = 2300L, nickname = "release-creator")
creator.profileImage = "profile/release-creator.png"
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.createdAt = LocalDateTime.of(2026, 6, 24, 9, 0)
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.getNotReleaseContent()).thenReturn(listOf(audioContent))
service.releaseContent()
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = audioContent.detail,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.releaseDate!!,
isAdult = false
)
}
private fun createMember(id: Long, nickname: String): Member { private fun createMember(id: Long, nickname: String): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",
@@ -283,6 +460,14 @@ class AudioContentServiceTest {
return member return member
} }
private fun anyLongValue(): Long {
return Mockito.anyLong()
}
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent { private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent {
val theme = AudioContentTheme(theme = "수면", image = "sleep.png") val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
theme.id = 300L theme.id = 300L

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.payment.CanPaymentService
@@ -20,6 +22,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member 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.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
@@ -32,6 +35,8 @@ import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.web.multipart.MultipartFile
import java.io.InputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
@@ -41,7 +46,9 @@ class CreatorCommunityServiceTest {
private lateinit var likeRepository: CreatorCommunityLikeRepository private lateinit var likeRepository: CreatorCommunityLikeRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository private lateinit var useCanRepository: UseCanRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var service: CreatorCommunityService private lateinit var service: CreatorCommunityService
@BeforeEach @BeforeEach
@@ -51,7 +58,9 @@ class CreatorCommunityServiceTest {
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java) likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java)
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java) commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
useCanRepository = Mockito.mock(UseCanRepository::class.java) useCanRepository = Mockito.mock(UseCanRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
service = CreatorCommunityService( service = CreatorCommunityService(
canPaymentService = Mockito.mock(CanPaymentService::class.java), canPaymentService = Mockito.mock(CanPaymentService::class.java),
@@ -60,12 +69,13 @@ class CreatorCommunityServiceTest {
likeRepository = likeRepository, likeRepository = likeRepository,
commentRepository = commentRepository, commentRepository = commentRepository,
useCanRepository = useCanRepository, useCanRepository = useCanRepository,
s3Uploader = Mockito.mock(S3Uploader::class.java), s3Uploader = s3Uploader,
objectMapper = ObjectMapper(), objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
applicationEventPublisher = applicationEventPublisher, applicationEventPublisher = applicationEventPublisher,
messageSource = SodaMessageSource(), messageSource = SodaMessageSource(),
langContext = LangContext(), langContext = LangContext(),
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
imageBucket = "image-bucket", imageBucket = "image-bucket",
contentBucket = "content-bucket", contentBucket = "content-bucket",
imageHost = "https://cdn.test" imageHost = "https://cdn.test"
@@ -286,6 +296,158 @@ class CreatorCommunityServiceTest {
assertNull(post.fixedAt) assertNull(post.fixedAt)
} }
@Test
@DisplayName("커뮤니티 게시글 생성 성공 후 최근 소식을 게시글 정보로 발행한다")
fun shouldPublishNewsAfterCommunityPostCreated() {
val creator = createMember(id = 900L, role = MemberRole.CREATOR, nickname = "community-creator")
creator.profileImage = "profile/community-creator.png"
val createdAt = LocalDateTime.of(2026, 6, 25, 10, 0)
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
val post = invocation.getArgument<CreatorCommunity>(0)
post.id = 901L
post.createdAt = createdAt
post
}
service.createCommunityPost(
audioFile = null,
postImage = null,
requestString = """{"content":"커뮤니티 새 게시글 본문입니다","price":0,"isCommentAvailable":true,"isAdult":true}""",
member = creator
)
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
postId = 901L,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = "커뮤니티 새 게시글 본문입니다",
body = "커뮤니티 새 게시글 본문입니다",
thumbnailImagePath = null,
occurredAtUtc = createdAt,
isAdult = true
)
}
@Test
@DisplayName("유료 커뮤니티 게시글 최근 소식은 전체 본문을 노출하지 않고 미리보기만 발행한다")
fun shouldPublishPaidCommunityPostNewsWithMaskedContent() {
val creator = createMember(id = 910L, role = MemberRole.CREATOR, nickname = "paid-community-creator")
val fullContent = "유료 커뮤니티 게시글 전체 본문은 최근 소식에서 노출되면 안 됩니다"
val createdAt = LocalDateTime.of(2026, 6, 25, 11, 0)
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
val post = invocation.getArgument<CreatorCommunity>(0)
post.id = 911L
post.createdAt = createdAt
post
}
Mockito.`when`(
s3Uploader.upload(
inputStream = anyInputStream(),
bucket = eqValue("image-bucket"),
filePath = anyStringValue(),
metadata = anyObjectMetadata()
)
).thenReturn("creator_community/911/911-image.png")
service.createCommunityPost(
audioFile = null,
postImage = paidPostImage(),
requestString = """{"content":"$fullContent","price":10,"isCommentAvailable":true,"isAdult":false}""",
member = creator
)
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
postId = 911L,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = "유료 커뮤니티 게시글 전체 ...",
body = "유료 커뮤니티 게시글 전체 ...",
thumbnailImagePath = "creator_community/911/911-image.png",
occurredAtUtc = createdAt,
isAdult = false
)
}
@Test
@DisplayName("최근 소식 발행 실패는 커뮤니티 게시글 생성을 실패시키지 않는다")
fun shouldNotFailCommunityPostCreationWhenNewsPublishFails() {
val creator = createMember(id = 920L, role = MemberRole.CREATOR, nickname = "publish-failure-community")
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
val post = invocation.getArgument<CreatorCommunity>(0)
post.id = 921L
post.createdAt = LocalDateTime.of(2026, 6, 25, 12, 0)
post
}
Mockito.doAnswer { throw IllegalStateException("publish failed") }
.`when`(homeFollowingNewsPublishService)
.publishCommunityPostCreated(
postId = anyLongValue(),
creatorId = anyLongValue(),
creatorNickname = anyStringValue(),
creatorProfileImagePath = Mockito.anyString(),
title = anyStringValue(),
body = anyStringValue(),
thumbnailImagePath = Mockito.anyString(),
occurredAtUtc = anyLocalDateTime(),
isAdult = Mockito.anyBoolean()
)
service.createCommunityPost(
audioFile = null,
postImage = null,
requestString = """{"content":"커뮤니티 발행 실패 격리","price":0,"isCommentAvailable":true,"isAdult":false}""",
member = creator
)
Mockito.verify(repository).save(Mockito.any(CreatorCommunity::class.java))
}
private fun paidPostImage(): MultipartFile {
val pngBytes = byteArrayOf(
0x89.toByte(),
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A
)
return Mockito.mock(MultipartFile::class.java).also { image ->
Mockito.`when`(image.bytes).thenReturn(pngBytes)
Mockito.`when`(image.size).thenReturn(pngBytes.size.toLong())
Mockito.`when`(image.contentType).thenReturn("image/png")
Mockito.`when`(image.originalFilename).thenReturn("paid.png")
Mockito.`when`(image.inputStream).thenReturn(pngBytes.inputStream())
}
}
private fun anyInputStream(): InputStream {
return Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
}
private fun anyObjectMetadata(): ObjectMetadata {
return Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata()
}
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun anyLongValue(): Long {
return Mockito.anyLong()
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member { private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",

View File

@@ -66,6 +66,7 @@ class MemberServiceCacheEvictionTest {
memberContentPreferenceService = mock<MemberContentPreferenceService>(), memberContentPreferenceService = mock<MemberContentPreferenceService>(),
objectMapper = ObjectMapper(), objectMapper = ObjectMapper(),
cacheManager = cacheManager, cacheManager = cacheManager,
homeFollowingNewsInboxPort = mock(),
s3Bucket = "test-bucket", s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test" cloudFrontHost = "https://cdn.test"
) )

View File

@@ -75,6 +75,7 @@ class MemberServiceContentPreferenceTest {
memberContentPreferenceService = memberContentPreferenceService, memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(), objectMapper = ObjectMapper(),
cacheManager = mock<CacheManager>(), cacheManager = mock<CacheManager>(),
homeFollowingNewsInboxPort = mock(),
s3Bucket = "test-bucket", s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test" cloudFrontHost = "https://cdn.test"
) )

View File

@@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.login.LoginRequest import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxJpaRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@@ -11,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
@SpringBootTest @SpringBootTest
@@ -19,6 +23,7 @@ import javax.persistence.EntityManager
class MemberServiceTest @Autowired constructor( class MemberServiceTest @Autowired constructor(
private val service: MemberService, private val service: MemberService,
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@Test @Test
@@ -42,4 +47,51 @@ class MemberServiceTest @Autowired constructor(
assertEquals("common.error.bad_credentials", exception.messageKey) assertEquals("common.error.bad_credentials", exception.messageKey)
} }
@Test
@DisplayName("언팔로우 성공 시 해당 회원과 크리에이터의 활성 최근 소식을 비활성화하고 재팔로우해도 복구하지 않는다")
fun shouldDeactivateFollowingNewsInboxOnCreatorUnFollowAndKeepInactiveAfterRefollow() {
val member = memberRepository.save(Member(email = "follower@test.com", password = "password", nickname = "follower"))
val creator = memberRepository.save(
Member(
email = "creator@test.com",
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
val inbox = homeFollowingNewsInboxJpaRepository.save(
HomeFollowingNewsInbox(
memberId = member.id!!,
creatorId = creator.id!!,
newsType = FollowingNewsType.COMMUNITY_POST,
sourceKey = "COMMUNITY_POST:1",
targetId = 1L,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 1, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 1, 0),
creatorNickname = "creator",
creatorProfileImagePath = null,
title = "title",
body = "body",
thumbnailImagePath = null,
rank = null,
isAdult = false
)
)
entityManager.flush()
entityManager.clear()
service.creatorUnFollow(creatorId = creator.id!!, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
} }

View File

@@ -0,0 +1,105 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.configs.SecurityConfig
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacade
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(ContentOverviewController::class)
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
class ContentOverviewControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: ContentOverviewFacade
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@MockBean
private lateinit var tokenProvider: TokenProvider
@Test
@DisplayName("콘텐츠 전체보기는 비회원 요청을 거부한다")
fun shouldRejectAnonymousRequest() {
mockMvc.perform(
get("/api/v2/contents")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("콘텐츠 전체보기는 인증 회원과 query parameter를 facade에 전달한다")
fun shouldPassAuthenticatedMemberAndQueryParameters() {
val member = member(id = 10L)
Mockito.doReturn(emptyResponse(ContentOverviewType.FIRST_AUDIO_CONTENT)).`when`(facade)
.getContents(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(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(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(member))
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
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 emptyResponse(type: ContentOverviewType): ContentOverviewPageResponse {
return ContentOverviewPageResponse(
type = type,
items = emptyList(),
page = 0,
size = 20,
hasNext = false
)
}
}

View File

@@ -0,0 +1,204 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:content-overview-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class ContentOverviewEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("콘텐츠 전체보기 API는 비회원 요청을 거부한다")
fun shouldRejectAnonymousContentOverviewRequest() {
mockMvc.perform(get("/api/v2/contents"))
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("콘텐츠 전체보기 API는 인증 회원에게 New & Hot 오디오 페이지를 반환한다")
fun shouldReturnNewAndHotAudioOverviewForMember() {
val fixture = createNewAndHotFixture("content-overview-new-hot")
mockMvc.perform(
get("/api/v2/contents")
.param("type", "NEW_AND_HOT_AUDIO")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("NEW_AND_HOT_AUDIO"))
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.items[0].title").value("content-overview-new-hot-audio"))
.andExpect(jsonPath("$.data.items[0].coverImage").value("https://cdn.test/content-overview-new-hot.png"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("콘텐츠 전체보기 API는 인증 회원에게 첫 번째 오디오 콘텐츠 페이지를 반환한다")
fun shouldReturnFirstAudioContentOverviewForMember() {
val fixture = createFirstAudioFixture("content-overview-first")
mockMvc.perform(
get("/api/v2/contents")
.param("type", "FIRST_AUDIO_CONTENT")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT"))
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.items[0].title").value("content-overview-first-audio"))
.andExpect(jsonPath("$.data.items[0].coverImage").value("https://cdn.test/content-overview-first.png"))
.andExpect(jsonPath("$.data.items[0].isFirstContent").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다")
fun shouldFallbackInvalidTypeToNewAndHotAudio() {
val fixture = createNewAndHotFixture("content-overview-invalid-type")
mockMvc.perform(
get("/api/v2/contents")
.param("type", "UNKNOWN")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("NEW_AND_HOT_AUDIO"))
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
private fun createNewAndHotFixture(prefix: String): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1)
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
val theme = saveTheme(prefix)
val audio = saveAudio(creator, theme, "$prefix-audio", "$prefix.png", now)
saveSnapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, audio.id!!, now)
entityManager.flush()
entityManager.clear()
Fixture(viewer = viewer, audioContentId = audio.id!!)
}!!
}
private fun createFirstAudioFixture(prefix: String): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1)
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
val theme = saveTheme(prefix)
val audio = saveAudio(creator, theme, "$prefix-audio", "$prefix.png", now)
entityManager.flush()
entityManager.clear()
Fixture(viewer = viewer, audioContentId = audio.id!!)
}!!
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
entityManager.persist(member)
return member
}
private fun saveTheme(prefix: String): AudioContentTheme {
val theme = AudioContentTheme(theme = "$prefix-theme", image = "$prefix-theme.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudio(
creator: Member,
theme: AudioContentTheme,
title: String,
coverImage: String,
releaseDate: LocalDateTime
): AudioContent {
val audio = AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = false,
price = 100,
isPointAvailable = true
)
audio.member = creator
audio.theme = theme
audio.isActive = true
audio.coverImage = coverImage
audio.duration = "00:10"
entityManager.persist(audio)
return audio
}
private fun saveSnapshot(sectionType: RecommendedSectionType, targetId: Long, snapshotAt: LocalDateTime) {
entityManager.persist(
RecommendationSnapshot(
sectionType = sectionType,
targetId = targetId,
score = 1.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.0
)
)
}
private data class Fixture(
val viewer: Member,
val audioContentId: Long
)
}

View File

@@ -0,0 +1,117 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
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
@DisplayName("New & Hot 전체보기는 size + 1 조회 결과를 공통 페이지 응답으로 변환한다")
fun shouldReturnNewAndHotPage() {
val member = member(id = 10L)
Mockito.doReturn((1L..21L).map { audioCard(it) }).`when`(audioRecommendationQueryService)
.findNewAndHotAudios(member, offset = 0L, limit = 21)
val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 20, member = member)
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type)
assertEquals((1L..20L).toList(), response.items.map { it.contentId })
assertEquals("https://cdn.test/audio1.png", response.items[0].coverImage)
assertEquals(0, response.page)
assertEquals(20, response.size)
assertEquals(true, response.hasNext)
}
@Test
@DisplayName("첫 번째 오디오 콘텐츠 전체보기는 adult visibility와 offset을 반영해 조회한다")
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(),
eqValue(20L),
eqValue(21),
eqValue(member.id),
eqValue(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)
assertEquals(false, response.hasNext)
}
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 anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.of(2026, 6, 27, 0, 0)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
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
)
}
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class ContentOverviewQueryPolicyTest {
private val policy = ContentOverviewQueryPolicy()
@Test
@DisplayName("콘텐츠 전체보기 type은 null 또는 invalid 값을 기본 타입으로 보정한다")
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
@DisplayName("콘텐츠 전체보기 page와 size를 기본값과 최대값으로 보정한다")
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 = 0, size = 20), policy.createPage(0, 19))
assertEquals(ContentOverviewPage(page = 2, size = 50), policy.createPage(2, 100))
}
@Test
@DisplayName("콘텐츠 전체보기 offset은 큰 page 입력에서도 Int overflow 없이 계산한다")
fun shouldCalculateOffsetWithoutIntOverflow() {
val page = policy.createPage(Int.MAX_VALUE, 50)
assertEquals(Int.MAX_VALUE.toLong() * 50, page.offset)
}
@Test
@DisplayName("콘텐츠 전체보기 응답 목록과 hasNext는 size + 1 조회 결과로 계산한다")
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))
}
}

View File

@@ -0,0 +1,80 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class ContentOverviewPageResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("콘텐츠 전체보기 응답은 공개 JSON 필드명만 직렬화한다")
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"))
}
@Test
@DisplayName("첫 번째 오디오 콘텐츠 변환은 성인/오리지널 플래그를 전달한다")
fun shouldMapFirstAudioContentFlags() {
val response = ContentOverviewItemResponse.fromFirstAudioContent(
audio = HomeFirstAudioContentRecord(
contentId = 1L,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = 100,
coverImage = "cover/audio.png",
isPointAvailable = true,
isAdult = true,
isOriginalSeries = true
),
coverImage = "https://cdn.test/cover/audio.png",
isAdult = true,
isOriginalSeries = true
)
assertEquals(true, response.isAdult)
assertEquals(true, response.isOriginalSeries)
}
}

View File

@@ -318,30 +318,19 @@ class HomeRecommendationControllerTest @Autowired constructor(
Mockito.`when`( Mockito.`when`(
failingQueryService.findRecentDebutCreators( failingQueryService.findRecentDebutCreators(
now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN, now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN,
offset = Mockito.eq(0), offset = Mockito.eq(0L),
limit = Mockito.eq(21), limit = Mockito.eq(21),
memberId = Mockito.eq(member.id), memberId = Mockito.eq(member.id),
includeAdultContents = Mockito.eq(false) includeAdultContents = Mockito.eq(false)
) )
).thenThrow(IllegalStateException("debut page failed")) ).thenThrow(IllegalStateException("debut page failed"))
Mockito.`when`( Mockito.`when`(failingQueryService.findAiCharacterRecommendations(offset = 0L, limit = 21))
failingQueryService.findFirstAudioContents(
now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN,
offset = Mockito.eq(0),
limit = Mockito.eq(21),
memberId = Mockito.eq(member.id),
includeAdultContents = Mockito.eq(false)
)
).thenThrow(IllegalStateException("first audio page failed"))
Mockito.`when`(failingQueryService.findAiCharacterRecommendations(offset = 0, limit = 21))
.thenThrow(IllegalStateException("ai page failed")) .thenThrow(IllegalStateException("ai page failed"))
assertThrows(IllegalStateException::class.java) { facade.getRecentDebutCreators(member, page = 0, size = 20) } assertThrows(IllegalStateException::class.java) { facade.getRecentDebutCreators(member, page = 0, size = 20) }
assertThrows(IllegalStateException::class.java) { facade.getFirstAudioContents(member, page = 0, size = 20) }
assertThrows(IllegalStateException::class.java) { facade.getAiCharacters(member, page = 0, size = 20) } assertThrows(IllegalStateException::class.java) { facade.getAiCharacters(member, page = 0, size = 20) }
assertTrue(output.out.contains("section=DEBUT_CREATOR")) assertTrue(output.out.contains("section=DEBUT_CREATOR"))
assertTrue(output.out.contains("section=FIRST_AUDIO_CONTENT"))
assertTrue(output.out.contains("section=AI_CHARACTER")) assertTrue(output.out.contains("section=AI_CHARACTER"))
} }
@@ -362,15 +351,14 @@ class HomeRecommendationControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("첫 오디오/AI 캐릭터 전체보기도 같은 페이징 응답 형식을 사용한다") @DisplayName("AI 캐릭터 전체보기도 같은 페이징 응답 형식을 사용한다")
fun shouldReturnPagedSectionsWithSameFormat() { fun shouldReturnPagedSectionsWithSameFormat() {
val member = saveMember("paged-section-viewer", MemberRole.USER) val member = saveMember("paged-section-viewer", MemberRole.USER)
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
for (path in listOf("/first-audio-contents", "/ai-characters")) {
mockMvc.perform( mockMvc.perform(
get("/api/v2/home/recommendations$path") get("/api/v2/home/recommendations/ai-characters")
.with(user(MemberAdapter(member))) .with(user(MemberAdapter(member)))
.param("page", "1") .param("page", "1")
.param("size", "10") .param("size", "10")
@@ -381,12 +369,25 @@ class HomeRecommendationControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.size").value(10)) .andExpect(jsonPath("$.data.size").value(10))
.andExpect(jsonPath("$.data.hasNext").isBoolean) .andExpect(jsonPath("$.data.hasNext").isBoolean)
} }
@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)
} }
@Test @Test
@DisplayName("세부 전체보기 API는 비회원 요청을 거부한다") @DisplayName("세부 전체보기 API는 비회원 요청을 거부한다")
fun shouldRejectAnonymousSectionPages() { fun shouldRejectAnonymousSectionPages() {
for (path in listOf("/lives", "/debut-creators", "/first-audio-contents", "/ai-characters")) { for (path in listOf("/lives", "/debut-creators", "/ai-characters")) {
mockMvc.perform(get("/api/v2/home/recommendations$path")) mockMvc.perform(get("/api/v2/home/recommendations$path"))
.andExpect(status().isUnauthorized) .andExpect(status().isUnauthorized)
} }

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class HomeRecommendationResponseTest { class HomeRecommendationResponseTest {
@@ -114,4 +115,23 @@ class HomeRecommendationResponseTest {
assertEquals(true, json["popularCommunityPosts"][1]["imageUrl"].isNull) assertEquals(true, json["popularCommunityPosts"][1]["imageUrl"].isNull)
assertEquals(true, json["popularCommunityPosts"][1]["audioUrl"].isNull) assertEquals(true, json["popularCommunityPosts"][1]["audioUrl"].isNull)
} }
@Test
@DisplayName("기존 홈 라이브 추천 item 응답 스키마에는 신규 현재 진행 중 라이브 필드를 포함하지 않는다")
fun shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc() {
val item = HomeLiveItem(
roomId = 1L,
creatorNickname = "creator",
creatorProfileImage = "https://cdn.test/profile.png"
)
val json = objectMapper.readTree(objectMapper.writeValueAsString(item))
assertEquals(1L, json["roomId"].asLong())
assertEquals("creator", json["creatorNickname"].asText())
assertEquals("https://cdn.test/profile.png", json["creatorProfileImage"].asText())
assertFalse(json.has("title"))
assertFalse(json.has("price"))
assertFalse(json.has("beginDateTimeUtc"))
}
} }

View File

@@ -0,0 +1,103 @@
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.configs.SecurityConfig
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade
import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(HomeFollowingController::class)
@Import(SecurityConfig::class)
class HomeFollowingControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: HomeFollowingFacade
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@MockBean
private lateinit var tokenProvider: TokenProvider
@MockBean
private lateinit var accessDeniedHandler: JwtAccessDeniedHandler
@MockBean
private lateinit var authenticationEntryPoint: JwtAuthenticationEntryPoint
@Test
@DisplayName("팔로잉 탭 조회는 비회원에게 200 OK와 로그인 필요 응답을 반환한다")
fun shouldReturnLoginRequiredForAnonymous() {
Mockito.doReturn(HomeFollowingTabResponse.loginRequired()).`when`(facade).getFollowingTab(null)
mockMvc.perform(get("/api/v2/home/following"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(true))
.andExpect(jsonPath("$.data.followingCreators").isArray)
.andExpect(jsonPath("$.data.onAirLives").isArray)
.andExpect(jsonPath("$.data.recentChats").isArray)
.andExpect(jsonPath("$.data.monthlySchedules").isArray)
.andExpect(jsonPath("$.data.recentNews").isArray)
}
@Test
@DisplayName("팔로잉 탭 조회는 인증 회원을 facade에 전달하고 로그인 불필요 응답을 반환한다")
fun shouldPassAuthenticatedMemberToFacade() {
val member = Member(
email = "viewer@test.com",
password = "password",
nickname = "viewer",
role = MemberRole.USER
).apply { id = 10L }
Mockito.doReturn(loggedInEmptyResponse()).`when`(facade).getFollowingTab(eqValue(member))
mockMvc.perform(get("/api/v2/home/following").with(user(MemberAdapter(member))))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(false))
Mockito.verify(facade).getFollowingTab(eqValue(member))
}
private fun loggedInEmptyResponse(): HomeFollowingTabResponse {
return HomeFollowingTabResponse(
isLoginRequired = false,
followingCreators = emptyList(),
onAirLives = emptyList(),
recentChats = emptyList(),
monthlySchedules = emptyList(),
recentNews = emptyList()
)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
}

View File

@@ -0,0 +1,237 @@
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:home-following-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class HomeFollowingEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@MockBean
private lateinit var countryContext: CountryContext
@Test
@DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다")
fun shouldReturnLoginRequiredForAnonymous() {
mockMvc.perform(get("/api/v2/home/following"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(true))
.andExpect(jsonPath("$.data.followingCreators").isEmpty)
.andExpect(jsonPath("$.data.onAirLives").isEmpty)
.andExpect(jsonPath("$.data.recentChats").isEmpty)
.andExpect(jsonPath("$.data.monthlySchedules").isEmpty)
.andExpect(jsonPath("$.data.recentNews").isEmpty)
}
@Test
@DisplayName("팔로잉 탭 API는 인증 회원의 팔로잉/On Air/최근 대화/스케줄/최근 소식을 조립해 반환한다")
fun shouldAssembleFollowingTabForMember() {
Mockito.doReturn("US").`when`(countryContext).countryCode
val fixture = createFixture()
mockMvc.perform(get("/api/v2/home/following").with(user(MemberAdapter(fixture.viewer))))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(false))
.andExpect(jsonPath("$.data.followingCreators[0].creatorId").value(fixture.creatorId))
.andExpect(jsonPath("$.data.followingCreators[0].creatorNickname").value("home-following-creator"))
.andExpect(jsonPath("$.data.onAirLives[0].liveId").value(fixture.liveId))
.andExpect(jsonPath("$.data.onAirLives[0].title").value("home-following-live"))
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.chatRoomId))
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("DM"))
.andExpect(jsonPath("$.data.recentChats[0].targetName").value("home-following-creator"))
.andExpect(jsonPath("$.data.recentChats[0].lastMessage").value("recent dm"))
.andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}"))
.andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}"))
.andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString()))
.andExpect(jsonPath("$.data.recentNews[0].creatorId").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[0].ranking").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[0].rank").value(7))
.andExpect(jsonPath("$.data.recentNews[1].rank").value(nullValue()))
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
val viewer = saveMember("home-following-viewer", MemberRole.USER)
val creator = saveMember("home-following-creator", MemberRole.CREATOR, profileImage = "creator.png")
saveFollowing(viewer, creator)
val live = saveLiveRoom(creator, now.plusHours(1), channelName = "on-air")
val theme = saveTheme()
val audio = saveAudioContent(creator, theme, now.plusDays(1))
val oldNews = saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
entityManager.flush()
entityManager.clear()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
liveId = live.id!!,
audioId = audio.id!!,
chatRoomId = chatRoom.id!!,
rankedNewsId = rankedNews.id!!,
oldNewsId = oldNews.id!!
)
}!!
}
private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
profileImage = profileImage,
role = role,
countryCode = "US"
)
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
val following = CreatorFollowing(isActive = true).apply {
this.member = member
this.creator = creator
}
entityManager.persist(following)
return following
}
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
val liveRoom = LiveRoom(
title = "home-following-live",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = false
).apply {
member = creator
this.channelName = channelName
}
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveTheme(): AudioContentTheme {
val theme = AudioContentTheme(theme = "home-following-theme", image = "theme.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudioContent(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent {
val audio = AudioContent(
title = "home-following-audio",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
).apply {
member = creator
this.theme = theme
duration = "00:10:00"
isActive = true
}
entityManager.persist(audio)
return audio
}
private fun saveNews(
memberId: Long,
creatorId: Long,
sourceKey: String,
visibleFromAtUtc: LocalDateTime,
rank: Int?
): HomeFollowingNewsInbox {
val news = HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING,
sourceKey = sourceKey,
targetId = creatorId,
occurredAtUtc = visibleFromAtUtc.minusMinutes(30),
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = "home-following-creator",
creatorProfileImagePath = "creator.png",
title = "news-$sourceKey",
body = "news body",
thumbnailImagePath = null,
rank = rank,
isAdult = false
)
entityManager.persist(news)
return news
}
private fun saveDmChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): UserCreatorChatRoom {
val room = UserCreatorChatRoom()
entityManager.persist(room)
val viewerParticipant = UserCreatorChatParticipant(room, viewer)
val creatorParticipant = UserCreatorChatParticipant(room, creator)
entityManager.persist(viewerParticipant)
entityManager.persist(creatorParticipant)
val message = UserCreatorChatMessage(
chatRoom = room,
participant = creatorParticipant,
messageType = UserCreatorChatMessageType.TEXT,
textMessage = "recent dm"
)
entityManager.persist(message)
entityManager.flush()
message.createdAt = messageCreatedAt
message.updatedAt = messageCreatedAt
return room
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val liveId: Long,
val audioId: Long,
val chatRoomId: Long,
val rankedNewsId: Long,
val oldNewsId: Long
)
}

View File

@@ -0,0 +1,125 @@
package kr.co.vividnext.sodalive.v2.api.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryService
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
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class HomeFollowingFacadeTest {
private val queryService = Mockito.mock(HomeFollowingQueryService::class.java)
private val chatRoomListService = Mockito.mock(ChatRoomListService::class.java)
private val facade = HomeFollowingFacade(queryService, chatRoomListService)
@Test
@DisplayName("비로그인 회원은 로그인 필요 응답을 반환하고 조회/채팅 서비스를 호출하지 않는다")
fun shouldReturnLoginRequiredWithoutCallingServicesForAnonymous() {
val response = facade.getFollowingTab(null)
assertTrue(response.isLoginRequired)
assertTrue(response.followingCreators.isEmpty())
assertTrue(response.onAirLives.isEmpty())
assertTrue(response.recentChats.isEmpty())
assertTrue(response.monthlySchedules.isEmpty())
assertTrue(response.recentNews.isEmpty())
Mockito.verifyNoInteractions(queryService, chatRoomListService)
}
@Test
@DisplayName("로그인 회원은 팔로잉 홈 조회 결과에 최근 대화 10개를 조립해 반환한다")
fun shouldAssembleFollowingHomeWithRecentChatsForMember() {
val member = Member(
email = "viewer@test.com",
password = "password",
nickname = "viewer",
role = MemberRole.USER
).apply { id = 10L }
val home = homeFollowing()
val recentChat = ChatRoomListItemResponse(
roomId = 30L,
chatType = "DM",
targetName = "creator",
targetImageUrl = "https://cdn.test/creator.png",
lastMessage = "hello",
lastMessageAt = "2026-06-25T01:00:00Z"
)
Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member)
Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null))
.`when`(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
val response = facade.getFollowingTab(member)
assertFalse(response.isLoginRequired)
assertEquals(1L, response.followingCreators.single().creatorId)
assertEquals(2L, response.onAirLives.single().liveId)
assertEquals(listOf(recentChat), response.recentChats)
assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId)
assertEquals("news-5", response.recentNews.single().newsId)
Mockito.verify(queryService).findHomeFollowing(member)
Mockito.verify(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
}
private fun homeFollowing(): HomeFollowing {
return HomeFollowing(
followingCreators = listOf(
HomeFollowingCreator(
creatorId = 1L,
creatorNickname = "creator",
creatorProfileImageUrl = "https://cdn.test/creator.png"
)
),
onAirLives = listOf(
HomeFollowingLive(
liveId = 2L,
creatorProfileImageUrl = "https://cdn.test/live.png",
creatorNickname = "creator",
title = "live",
startedAtUtc = "2026-06-25T00:00:00Z"
)
),
recentChats = emptyList(),
monthlySchedules = listOf(
HomeFollowingSchedule(
scheduleId = "LIVE:4",
creatorId = 1L,
creatorProfileImageUrl = "https://cdn.test/creator.png",
creatorNickname = "creator",
title = "schedule",
type = CreatorActivityType.LIVE,
targetId = 4L,
scheduledAtUtc = "2026-06-25T02:00:00Z",
isOnAir = false
)
),
recentNews = listOf(
HomeFollowingNews(
newsId = "news-5",
type = FollowingNewsType.CREATOR_RANKING,
creatorProfileImageUrl = "https://cdn.test/news.png",
creatorNickname = "creator",
title = "news",
body = "body",
thumbnailImageUrl = null,
targetId = 1L,
occurredAtUtc = "2026-06-25T03:00:00Z",
visibleFromAtUtc = "2026-06-25T04:00:00Z",
rank = 7
)
)
)
}
}

View File

@@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.v2.api.home.following.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class HomeFollowingTabResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("비로그인 응답은 로그인이 필요하며 모든 섹션을 빈 배열로 반환한다")
fun shouldReturnLoginRequiredResponseWithEmptySections() {
val response = HomeFollowingTabResponse.loginRequired()
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertTrue(response.isLoginRequired)
assertTrue(response.followingCreators.isEmpty())
assertTrue(response.onAirLives.isEmpty())
assertTrue(response.recentChats.isEmpty())
assertTrue(response.monthlySchedules.isEmpty())
assertTrue(response.recentNews.isEmpty())
assertEquals(true, json["isLoginRequired"].asBoolean())
assertTrue(json["followingCreators"].isArray)
assertTrue(json["onAirLives"].isArray)
assertTrue(json["recentChats"].isArray)
assertTrue(json["monthlySchedules"].isArray)
assertTrue(json["recentNews"].isArray)
}
@Test
@DisplayName("팔로잉 탭 도메인은 creatorId 없는 최근 소식과 nullable rank 응답으로 변환한다")
fun shouldMapDomainToResponseWithoutCreatorIdInRecentNews() {
val response = HomeFollowingTabResponse.from(createHomeFollowing())
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertFalse(response.isLoginRequired)
assertEquals(1L, response.followingCreators.first().creatorId)
assertEquals(10L, response.onAirLives.first().liveId)
assertEquals(100L, response.recentChats.first().roomId)
assertEquals("LIVE:20", response.monthlySchedules.first().scheduleId)
assertEquals(3, response.recentNews.first().rank)
assertEquals(false, json["isLoginRequired"].asBoolean())
assertFalse(json["recentNews"][0].has("creatorId"))
assertFalse(json["recentNews"][0].has("ranking"))
assertFalse(json["recentNews"][0].has("rankChange"))
assertFalse(json["recentNews"][0].has("isNew"))
assertEquals(3, json["recentNews"][0]["rank"].asInt())
assertEquals(true, json["monthlySchedules"][0]["isOnAir"].asBoolean())
}
private fun createHomeFollowing(): HomeFollowing {
return HomeFollowing(
followingCreators = listOf(HomeFollowingCreator(1L, "creator", "https://cdn/profile.jpg")),
onAirLives = listOf(
HomeFollowingLive(
liveId = 10L,
creatorProfileImageUrl = "https://cdn/live-profile.jpg",
creatorNickname = "live-creator",
title = "live title",
startedAtUtc = "2026-06-25T00:00:00Z"
)
),
recentChats = listOf(
ChatRoomListItemResponse(
roomId = 100L,
chatType = "DM",
targetName = "creator",
targetImageUrl = "https://cdn/chat.jpg",
lastMessage = "hello",
lastMessageAt = "2026-06-25T00:01:00Z"
)
),
monthlySchedules = listOf(
HomeFollowingSchedule(
scheduleId = "LIVE:20",
creatorId = 1L,
creatorProfileImageUrl = "https://cdn/schedule.jpg",
creatorNickname = "schedule-creator",
title = "schedule title",
type = CreatorActivityType.LIVE,
targetId = 20L,
scheduledAtUtc = "2026-06-26T00:00:00Z",
isOnAir = true
)
),
recentNews = listOf(
HomeFollowingNews(
newsId = "30",
type = FollowingNewsType.CREATOR_RANKING,
creatorProfileImageUrl = "https://cdn/news-profile.jpg",
creatorNickname = "news-creator",
title = "ranking",
body = "ranked",
thumbnailImageUrl = null,
targetId = 1L,
occurredAtUtc = "2026-06-25T00:00:00Z",
visibleFromAtUtc = "2026-06-25T09:00:00Z",
rank = 3
)
)
)
}
}

View File

@@ -0,0 +1,107 @@
package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.configs.SecurityConfig
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacade
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(HomeOnAirLiveController::class)
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
class HomeOnAirLiveControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: HomeOnAirLiveFacade
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@MockBean
private lateinit var tokenProvider: TokenProvider
@Test
@DisplayName("현재 진행 중인 라이브 조회는 비회원 요청을 거부한다")
fun shouldRejectAnonymousRequest() {
mockMvc.perform(
get("/api/v2/home/on-air-lives")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("현재 진행 중인 라이브 조회는 인증 회원과 page를 facade에 전달하고 성공 응답을 반환한다")
fun shouldPassAuthenticatedMemberAndPageToFacade() {
val member = createMember(100L)
Mockito.doReturn(createResponse()).`when`(facade).getOnAirLives(eqValue(member), eqValue(2))
mockMvc.perform(
get("/api/v2/home/on-air-lives")
.param("page", "2")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.items[0].title").value("paid live"))
Mockito.verify(facade).getOnAirLives(eqValue(member), eqValue(2))
}
private fun createResponse() = HomeOnAirLivePageResponse(
items = listOf(
HomeOnAirLiveResponse(
roomId = 1L,
creatorNickname = "creator",
creatorProfileImage = "https://cdn.test/profile.png",
title = "paid live",
price = 30,
beginDateTimeUtc = "2026-06-26T12:30:00Z"
)
),
page = 2,
size = 20,
hasNext = false
)
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply { this.id = id }
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
}

View File

@@ -0,0 +1,202 @@
package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:home-on-air-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class HomeOnAirLiveEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("현재 진행 중인 라이브 조회 API는 인증 회원에게 최신순 라이브와 상세 필드를 반환한다")
fun shouldReturnAuthenticatedOnAirLivesWithTitlePriceAndBeginDateTimeUtc() {
val fixture = createOnAirLivesFixture()
mockMvc.perform(
get("/api/v2/home/on-air-lives")
.param("page", "0")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.items.length()").value(2))
.andExpect(jsonPath("$.data.items[0].roomId").value(fixture.newestLiveId))
.andExpect(jsonPath("$.data.items[0].creatorNickname").value("on-air-e2e-creator"))
.andExpect(jsonPath("$.data.items[0].creatorProfileImage").value("https://cdn.test/on-air-e2e-creator.png"))
.andExpect(jsonPath("$.data.items[0].title").value("newest on air live"))
.andExpect(jsonPath("$.data.items[0].price").value(30))
.andExpect(jsonPath("$.data.items[0].beginDateTimeUtc").value("2026-06-26T12:30:00Z"))
.andExpect(jsonPath("$.data.items[1].roomId").value(fixture.oldestLiveId))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("현재 진행 중인 라이브 조회 API는 성인 콘텐츠를 볼 수 없는 회원에게 성인 라이브를 제외한다")
fun shouldExcludeAdultLiveWhenViewerCannotViewAdultContent() {
val fixture = createAdultFilterFixture()
mockMvc.perform(
get("/api/v2/home/on-air-lives")
.param("page", "0")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
.andExpect(jsonPath("$.data.items[0].roomId").value(fixture.visibleLiveId))
.andExpect(jsonPath("$.data.items[?(@.roomId == ${fixture.adultLiveId})]").isEmpty)
}
private fun createOnAirLivesFixture(): OnAirLivesFixture {
return transactionTemplate.execute {
val viewer = saveMember("on-air-e2e-viewer", MemberRole.USER)
val creator = saveMember("on-air-e2e-creator", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = true)
val newest = saveLiveRoom(
creator = creator,
title = "newest on air live",
price = 30,
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30),
channelName = "newest-on-air-channel",
isAdult = false
)
val oldest = saveLiveRoom(
creator = creator,
title = "oldest on air live",
price = 10,
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 0),
channelName = "oldest-on-air-channel",
isAdult = false
)
entityManager.flush()
entityManager.clear()
OnAirLivesFixture(
viewer = viewer,
newestLiveId = newest.id!!,
oldestLiveId = oldest.id!!
)
}!!
}
private fun createAdultFilterFixture(): AdultFilterFixture {
return transactionTemplate.execute {
val viewer = saveMember("on-air-adult-filter-viewer", MemberRole.USER)
val creator = saveMember("on-air-adult-filter-creator", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = false)
val adult = saveLiveRoom(
creator = creator,
title = "adult on air live",
price = 30,
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30),
channelName = "adult-on-air-channel",
isAdult = true
)
val visible = saveLiveRoom(
creator = creator,
title = "visible on air live",
price = 10,
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 0),
channelName = "visible-on-air-channel",
isAdult = false
)
entityManager.flush()
entityManager.clear()
AdultFilterFixture(
viewer = viewer,
visibleLiveId = visible.id!!,
adultLiveId = adult.id!!
)
}!!
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role
)
entityManager.persist(member)
return member
}
private fun savePreference(member: Member, isAdultContentVisible: Boolean): MemberContentPreference {
val preference = MemberContentPreference(
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL
)
preference.member = member
entityManager.persist(preference)
return preference
}
private fun saveLiveRoom(
creator: Member,
title: String,
price: Int,
beginDateTime: LocalDateTime,
channelName: String,
isAdult: Boolean
): LiveRoom {
val liveRoom = LiveRoom(
title = title,
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult,
price = price
)
liveRoom.member = creator
liveRoom.channelName = channelName
liveRoom.isActive = true
entityManager.persist(liveRoom)
return liveRoom
}
private data class OnAirLivesFixture(
val viewer: Member,
val newestLiveId: Long,
val oldestLiveId: Long
)
private data class AdultFilterFixture(
val viewer: Member,
val visibleLiveId: Long,
val adultLiveId: Long
)
}

View File

@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.v2.api.home.live.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class HomeOnAirLiveFacadeTest {
private val queryService = Mockito.mock(HomeRecommendationQueryService::class.java)
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = HomeOnAirLiveFacade(queryService, preferenceService, "https://cdn.test")
@Test
@DisplayName("현재 진행 중인 라이브 facade는 20개 고정 page와 hasNext를 조립한다")
fun shouldReturnFixedSizePageAndHasNext() {
val member = createMember(100L)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn((1L..21L).map { record(it) }).`when`(queryService).findLiveRecommendations(
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(true)
)
val response = facade.getOnAirLives(member, page = 0)
assertEquals(0, response.page)
assertEquals(20, response.size)
assertEquals(true, response.hasNext)
assertEquals(20, response.items.size)
Mockito.verify(queryService).findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(true))
}
@Test
@DisplayName("현재 진행 중인 라이브 facade는 프로필 이미지가 없으면 기본 이미지를 사용한다")
fun shouldUseDefaultProfileImageWhenCreatorProfileImageIsBlank() {
val member = createMember(100L)
Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(record(1L, creatorProfileImage = null))).`when`(queryService).findLiveRecommendations(
eqValue(0L),
eqValue(21),
eqValue(member.id),
eqValue(false)
)
val response = facade.getOnAirLives(member, page = 0)
assertEquals("https://cdn.test/profile/default-profile.png", response.items.single().creatorProfileImage)
}
@Test
@DisplayName("현재 진행 중인 라이브 facade는 시작 시간을 UTC ISO 문자열로 변환한다")
fun shouldMapBeginDateTimeToUtcIsoString() {
val member = createMember(100L)
Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(record(1L, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)))).`when`(queryService)
.findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(false))
val response = facade.getOnAirLives(member, page = 0)
assertEquals("2026-06-26T12:30:00Z", response.items.single().beginDateTimeUtc)
}
private fun record(
id: Long,
creatorProfileImage: String? = "profile.png",
beginDateTime: LocalDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)
) = HomeLiveRecommendationRecord(
liveRoomId = id,
creatorNickname = "creator-$id",
creatorProfileImage = creatorProfileImage,
title = "live-$id",
price = id.toInt(),
beginDateTime = beginDateTime
)
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply { this.id = id }
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
}

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.api.home.live.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class HomeOnAirLiveResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("현재 진행 중인 라이브 page 응답은 공개 API 필드를 직렬화한다")
fun shouldSerializeOnAirLivePageResponse() {
val response = HomeOnAirLivePageResponse(
items = listOf(
HomeOnAirLiveResponse(
roomId = 1L,
creatorNickname = "creator",
creatorProfileImage = "https://cdn.test/profile.png",
title = "paid live",
price = 30,
beginDateTimeUtc = "2026-06-26T12:30:00Z"
)
),
page = 0,
size = 20,
hasNext = true
)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals(1L, json["items"][0]["roomId"].asLong())
assertEquals("creator", json["items"][0]["creatorNickname"].asText())
assertEquals("https://cdn.test/profile.png", json["items"][0]["creatorProfileImage"].asText())
assertEquals("paid live", json["items"][0]["title"].asText())
assertEquals(30, json["items"][0]["price"].asInt())
assertEquals("2026-06-26T12:30:00Z", json["items"][0]["beginDateTimeUtc"].asText())
assertEquals(0, json["page"].asInt())
assertEquals(20, json["size"].asInt())
assertEquals(true, json["hasNext"].asBoolean())
}
}

View File

@@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.v2.chat
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.chat.controller.ChatRoomListController
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class ChatRoomListControllerTest {
private lateinit var service: ChatRoomListService
private lateinit var controller: ChatRoomListController
@BeforeEach
fun setUp() {
service = Mockito.mock(ChatRoomListService::class.java)
controller = ChatRoomListController(service)
}
@Test
@DisplayName("채팅 리스트 조회는 비로그인 사용자에게 빈 목록을 반환한다")
fun shouldReturnEmptyRoomsForAnonymousUser() {
val response = controller.getRooms(member = null, filter = "ALL", cursor = null, limit = 30)
assertTrue(response.success)
assertEquals(emptyList<ChatRoomListItemResponse>(), response.data?.rooms)
assertFalse(response.data?.hasMore ?: true)
assertNull(response.data?.nextCursor)
Mockito.verifyNoInteractions(service)
}
@Test
@DisplayName("채팅 리스트 조회는 로그인 사용자의 요청을 서비스에 위임한다")
fun shouldDelegateAuthenticatedRequestToService() {
val member = Member(password = "pw", nickname = "user").apply { id = 1L }
val serviceResponse = ChatRoomListPageResponse(
rooms = emptyList(),
hasMore = false,
nextCursor = null
)
Mockito.`when`(service.getRooms(member, "DM", "cursor", 10)).thenReturn(serviceResponse)
val response = controller.getRooms(member = member, filter = "DM", cursor = "cursor", limit = 10)
assertTrue(response.success)
assertEquals(serviceResponse, response.data)
Mockito.verify(service).getRooms(member, "DM", "cursor", 10)
}
}

View File

@@ -54,6 +54,10 @@ class AudioRankingQueryServiceTest {
assertEquals(listOf(1, 2, 3), result.items.map { it.rank }) assertEquals(listOf(1, 2, 3), result.items.map { it.rank })
assertEquals(listOf(1, -1, null), result.items.map { it.rankChange }) assertEquals(listOf(1, -1, null), result.items.map { it.rankChange })
assertEquals(listOf(false, false, true), result.items.map { it.isNew }) assertEquals(listOf(false, false, true), result.items.map { it.isNew })
assertEquals(
listOf("https://cdn.test/cover-2.png", "https://cdn.test/cover-1.png", "https://cdn.test/cover-3.png"),
result.items.map { it.coverImageUrl }
)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.nowUtc) assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.nowUtc)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.currentAggregationStartAtUtc) assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.currentAggregationStartAtUtc)
} }
@@ -230,6 +234,7 @@ class AudioRankingQueryServiceTest {
memberContentPreferenceService = memberContentPreferenceService, memberContentPreferenceService = memberContentPreferenceService,
blockPort = blockPort, blockPort = blockPort,
jobService = jobService, jobService = jobService,
cloudFrontHost = "https://cdn.test",
nowProvider = { ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) } nowProvider = { ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) }
) )
} }

View File

@@ -161,7 +161,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
@Test @Test
@DisplayName("New & Hot 후보는 조회/좋아요/댓글/최신성 점수순으로 산정하고 SAFE는 성인을 제외한다") @DisplayName("New & Hot 후보는 조회/좋아요/댓글/최신성 점수순으로 산정하고 SAFE는 성인을 제외한다")
fun shouldFindNewAndHotSnapshotsWithVisibility() { fun shouldFindNewAndHotSnapshotsWithVisibility() {
val snapshotAt = LocalDateTime.now().plusDays(1) val snapshotAt = LocalDateTime.of(2026, 6, 23, 12, 0)
val windowStart = snapshotAt.minusDays(2).toLocalDate().atStartOfDay() val windowStart = snapshotAt.minusDays(2).toLocalDate().atStartOfDay()
val creator = saveMember("snapshot-creator", MemberRole.CREATOR) val creator = saveMember("snapshot-creator", MemberRole.CREATOR)
val theme = saveTheme() val theme = saveTheme()
@@ -186,7 +186,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
releaseDate = visible.releaseDate!!, releaseDate = visible.releaseDate!!,
now = snapshotAt now = snapshotAt
) )
assertEquals(expectedScore, safe.first().score) assertEquals(expectedScore, safe.first().score, 0.0001)
} }
@Test @Test
@@ -401,9 +401,9 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private fun saveLike(audio: AudioContent, createdAt: LocalDateTime) { private fun saveLike(audio: AudioContent, createdAt: LocalDateTime) {
val like = AudioContentLike(memberId = 1L) val like = AudioContentLike(memberId = 1L)
like.audioContent = audio like.audioContent = audio
like.createdAt = createdAt
like.updatedAt = createdAt
entityManager.persist(like) entityManager.persist(like)
entityManager.flush()
updateTimestamps("content_like", like.id!!, createdAt, createdAt)
} }
private fun saveComment( private fun saveComment(
@@ -424,15 +424,22 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
comment.audioContent = audio comment.audioContent = audio
comment.member = writer comment.member = writer
comment.parent = parent comment.parent = parent
comment.createdAt = createdAt
comment.updatedAt = createdAt
entityManager.persist(comment) entityManager.persist(comment)
entityManager.flush() entityManager.flush()
comment.createdAt = createdAt updateTimestamps("content_comment", comment.id!!, createdAt, createdAt)
comment.updatedAt = createdAt
return comment return comment
} }
private fun updateTimestamps(tableName: String, id: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime) {
entityManager.createNativeQuery(
"update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id"
)
.setParameter("createdAt", createdAt)
.setParameter("updatedAt", updatedAt)
.setParameter("id", id)
.executeUpdate()
}
private fun saveSeriesContent(series: Series, audio: AudioContent) { private fun saveSeriesContent(series: Series, audio: AudioContent) {
val seriesContent = SeriesContent() val seriesContent = SeriesContent()
seriesContent.series = series seriesContent.series = series

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.application package kr.co.vividnext.sodalive.v2.content.recommendation.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
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.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
@@ -51,7 +54,7 @@ class AudioRecommendationQueryServiceTest {
.findLatestSnapshots( .findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort) .`when`(snapshotPort)
@@ -100,7 +103,7 @@ class AudioRecommendationQueryServiceTest {
.findLatestSnapshots( .findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort) .`when`(snapshotPort)
@@ -127,19 +130,14 @@ class AudioRecommendationQueryServiceTest {
@Test @Test
@DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다") @DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다")
fun shouldUseStoredPreferenceForMemberAdultVisibility() { fun shouldUseStoredPreferenceForMemberAdultVisibility() {
val member = kr.co.vividnext.sodalive.member.Member( val member = member(id = 10L)
email = "adult@test.com",
password = "password",
nickname = "adult",
role = kr.co.vividnext.sodalive.member.MemberRole.USER
)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))) Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)))
.`when`(snapshotPort) .`when`(snapshotPort)
.findLatestSnapshots( .findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
service.getRecommendations(member) service.getRecommendations(member)
@@ -149,10 +147,52 @@ class AudioRecommendationQueryServiceTest {
Mockito.verify(snapshotPort).findLatestSnapshots( Mockito.verify(snapshotPort).findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
} }
@Test
@DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다")
fun shouldKeepNewAndHotHomeLimitAtTwelve() {
val member = member(id = 10L)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))).`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
)
service.getRecommendations(member)
Mockito.verify(snapshotPort).findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
)
}
@Test
@DisplayName("New & Hot 전체보기는 스냅샷 offset과 limit으로 오디오 카드를 조회한다")
fun shouldFindNewAndHotAudiosWithOffsetAndLimit() {
val member = member(id = 10L)
val snapshots = listOf(
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 3L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 4L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 5L)
)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(snapshots).`when`(snapshotPort)
.findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21)
Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort)
.findAudioCardsByIds(eqValue(listOf(3L, 4L, 5L)), eqValue(member.id), eqValue(true), anyLocalDateTime())
val result = service.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, 20L, 21)
}
@Test @Test
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다") @DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
fun shouldMapVisibilityToAudioSectionTypes() { fun shouldMapVisibilityToAudioSectionTypes() {
@@ -186,6 +226,32 @@ class AudioRecommendationQueryServiceTest {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
} }
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 snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord { private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord( return RecommendationSnapshotRecord(
sectionType = sectionType, sectionType = sectionType,

View File

@@ -30,7 +30,7 @@ class AudioRecommendationSnapshotRefreshServiceTest {
newAndHotWindowStart, newAndHotWindowStart,
snapshotAt, snapshotAt,
AudioRecommendationVisibility.SAFE, AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_SNAPSHOT_LIMIT
) )
Mockito.verify(queryPort).findMostCommentedSnapshots( Mockito.verify(queryPort).findMostCommentedSnapshots(
mostCommentedWindowStart, mostCommentedWindowStart,
@@ -66,7 +66,7 @@ class AudioRecommendationSnapshotRefreshServiceTest {
newAndHotWindowStart, newAndHotWindowStart,
snapshotAt, snapshotAt,
AudioRecommendationVisibility.SAFE, AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_SNAPSHOT_LIMIT
) )
Mockito.verify(queryPort).findMostCommentedSnapshots( Mockito.verify(queryPort).findMostCommentedSnapshots(
mostCommentedWindowStart, mostCommentedWindowStart,
@@ -81,4 +81,27 @@ class AudioRecommendationSnapshotRefreshServiceTest {
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
) )
} }
@Test
@DisplayName("New & Hot 스냅샷은 visibility별 100개 후보를 저장한다")
fun shouldRequestOneHundredNewAndHotSnapshotsPerVisibility() {
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
)
}
} }

View File

@@ -0,0 +1,455 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultHomeFollowingQueryRepository(queryFactory, "https://cdn.test")
@Test
@DisplayName("팔로잉 크리에이터는 활성 팔로우와 활성 크리에이터만 최신 팔로우순으로 조회한다")
fun shouldFindActiveFollowingCreatorsByLatestFollowOrder() {
val viewer = saveMember("following-viewer", MemberRole.USER)
val activeCreator = saveMember("following-active", MemberRole.CREATOR, profileImage = "active.png")
val inactiveCreator = saveMember("following-inactive", MemberRole.CREATOR, isActive = false)
val olderCreator = saveMember("following-older", MemberRole.CREATOR)
val nonCreator = saveMember("following-non-creator", MemberRole.USER)
val olderFollow = saveFollowing(viewer, olderCreator, isActive = true)
val activeFollow = saveFollowing(viewer, activeCreator, isActive = true)
saveFollowing(viewer, inactiveCreator, isActive = true)
saveFollowing(viewer, nonCreator, isActive = true)
saveFollowing(viewer, saveMember("following-disabled", MemberRole.CREATOR), isActive = false)
olderFollow.createdAt = LocalDateTime.of(2026, 6, 24, 0, 0)
activeFollow.createdAt = LocalDateTime.of(2026, 6, 25, 0, 0)
flushAndClear()
val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20)
assertEquals(listOf(activeCreator.id!!, olderCreator.id!!), creators.map { it.creatorId })
assertEquals("https://cdn.test/active.png", creators.first().creatorProfileImageUrl)
}
@Test
@DisplayName("팔로잉 크리에이터는 회원과 크리에이터의 양방향 차단 관계를 제외한다")
fun shouldExcludeBlockedFollowingCreators() {
val viewer = saveMember("blocked-viewer", MemberRole.USER)
val viewerBlockedCreator = saveMember("viewer-blocked", MemberRole.CREATOR)
val creatorBlockedViewer = saveMember("creator-blocked", MemberRole.CREATOR)
val visibleCreator = saveMember("visible", MemberRole.CREATOR)
saveFollowing(viewer, viewerBlockedCreator)
saveFollowing(viewer, creatorBlockedViewer)
saveFollowing(viewer, visibleCreator)
saveBlock(viewer, viewerBlockedCreator)
saveBlock(creatorBlockedViewer, viewer)
flushAndClear()
val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20)
assertEquals(listOf(visibleCreator.id!!), creators.map { it.creatorId })
}
@Test
@DisplayName("On Air는 팔로우한 크리에이터의 진행 중 라이브만 최신순으로 조회하고 성인 라이브를 필터링한다")
fun shouldFindFollowingOnAirLivesWithAdultFilter() {
val viewer = saveMember("live-viewer", MemberRole.USER)
val creator = saveMember("live-creator", MemberRole.CREATOR, profileImage = "live-profile.png")
val otherCreator = saveMember("live-other", MemberRole.CREATOR)
val nonCreator = saveMember("live-non-creator", MemberRole.USER)
saveFollowing(viewer, creator)
saveFollowing(viewer, otherCreator, isActive = false)
saveFollowing(viewer, nonCreator)
val older = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 10, 0), channelName = "older")
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 11, 0), channelName = "adult", isAdult = true)
val latest = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "latest")
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 13, 0), channelName = null)
saveLiveRoom(otherCreator, LocalDateTime.of(2026, 6, 25, 14, 0), channelName = "other")
saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator")
flushAndClear()
val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = false, limit = 10)
assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId })
assertEquals("https://cdn.test/live-profile.png", lives.first().creatorProfileImageUrl)
}
@Test
@DisplayName("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다")
fun shouldFindMonthlySchedulesInKstWindow() {
val viewer = saveMember("schedule-viewer", MemberRole.USER)
val creator = saveMember("schedule-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("schedule-blocked", MemberRole.CREATOR)
val nonCreator = saveMember("schedule-non-creator", MemberRole.USER)
val theme = saveTheme("schedule-theme")
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
val live = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 24, 15, 0), channelName = null)
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 1, 0), isActive = false)
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 24, 14, 59), channelName = null)
saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 30, 15, 0))
saveLiveRoom(blockedCreator, LocalDateTime.of(2026, 6, 25, 0, 0), channelName = null)
saveAudioContent(nonCreator, theme, LocalDateTime.of(2026, 6, 25, 2, 0))
saveBlock(viewer, blockedCreator)
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 12, 0),
limit = 3
)
assertEquals(listOf("LIVE:${live.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId })
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type })
assertFalse(schedules.first().isOnAir)
}
@Test
@DisplayName("예약 오디오는 공개 전 비활성 상태여도 duration과 월간 releaseDate가 있으면 스케줄에 포함한다")
fun shouldIncludeInactiveScheduledAudioInMonthlySchedules() {
val viewer = saveMember("schedule-inactive-audio-viewer", MemberRole.USER)
val creator = saveMember("schedule-inactive-audio-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-inactive-audio-theme")
saveFollowing(viewer, creator)
val scheduledAudio = saveAudioContent(
creator = creator,
theme = theme,
releaseDate = LocalDateTime.of(2026, 6, 25, 3, 0),
isActive = false
)
saveAudioContent(
creator = creator,
theme = theme,
releaseDate = LocalDateTime.of(2026, 6, 25, 4, 0),
isActive = false
).duration = null
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 3
)
assertEquals(listOf("AUDIO:${scheduledAudio.id!!}"), schedules.map { it.scheduleId })
}
@Test
@DisplayName("이달의 스케줄은 UTC now를 KST로 변환해 KST 저녁의 같은 날 일정을 포함한다")
fun shouldIncludeSameKstDayScheduleWhenUtcNowIsKstEvening() {
val viewer = saveMember("schedule-evening-viewer", MemberRole.USER)
val creator = saveMember("schedule-evening-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-evening-theme")
saveFollowing(viewer, creator)
val sameKstDayEvening = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 14, 45))
saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 14, 20))
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 3
)
assertTrue(schedules.map { it.scheduleId }.contains("AUDIO:${sameKstDayEvening.id!!}"))
}
@Test
@DisplayName("이달의 스케줄은 같은 시각이면 type과 targetId 순으로 안정 정렬한다")
fun shouldSortMonthlySchedulesByTypeAndTargetIdWhenScheduledAtIsSame() {
val viewer = saveMember("schedule-tie-viewer", MemberRole.USER)
val creator = saveMember("schedule-tie-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-tie-theme")
saveFollowing(viewer, creator)
val sameTime = LocalDateTime.of(2026, 6, 25, 1, 0)
val firstLive = saveLiveRoom(creator, sameTime, channelName = null)
val secondLive = saveLiveRoom(creator, sameTime, channelName = null)
val firstAudio = saveAudioContent(creator, theme, sameTime)
val secondAudio = saveAudioContent(creator, theme, sameTime)
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
assertEquals(
listOf(
"LIVE:${firstLive.id!!}",
"LIVE:${secondLive.id!!}",
"AUDIO:${firstAudio.id!!}",
"AUDIO:${secondAudio.id!!}"
),
schedules.map { it.scheduleId }
)
}
@Test
@DisplayName("최근 소식은 활성 노출 가능 inbox를 최신순으로 조회하고 creatorId 없이 nullable rank만 반환한다")
fun shouldFindRecentNewsWithoutCreatorIdAndWithNullableRank() {
val viewer = saveMember("news-viewer", MemberRole.USER)
val creator = saveMember("news-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR)
val nonCreator = saveMember("news-non-creator", MemberRole.USER)
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
val oldVisible = saveNews(viewer.id!!, creator.id!!, "old", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
val latestVisible = saveNews(viewer.id!!, creator.id!!, "latest", LocalDateTime.of(2026, 6, 25, 9, 0), rank = 3)
saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1)
saveNews(viewer.id!!, creator.id!!, "adult", LocalDateTime.of(2026, 6, 25, 9, 30), isAdult = true)
saveNews(viewer.id!!, blockedCreator.id!!, "blocked", LocalDateTime.of(2026, 6, 25, 9, 45))
saveNews(viewer.id!!, nonCreator.id!!, "non-creator", LocalDateTime.of(2026, 6, 25, 9, 15))
saveBlock(viewer, blockedCreator)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 9, 30),
limit = 30
)
assertEquals(listOf(latestVisible.id!!.toString(), oldVisible.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(3, null), news.map { it.rank })
}
@Test
@DisplayName("최근 소식은 UTC now 이후 visibleFromAtUtc row를 조기 노출하지 않는다")
fun shouldNotExposeNewsVisibleAfterUtcNow() {
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30))
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31))
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 30
)
assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다")
fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() {
val viewer = saveMember("news-target-viewer", MemberRole.USER)
val creator = saveMember("news-target-creator", MemberRole.CREATOR)
val theme = saveTheme("news-target-theme")
saveFollowing(viewer, creator)
val activeAudio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isActive = true)
val inactiveAudio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10), isActive = false)
val activePost = saveCommunityPost(creator, "active-post", isActive = true)
val inactivePost = saveCommunityPost(creator, "inactive-post", isActive = false)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "active-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = activeAudio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "inactive-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = inactiveAudio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "active-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 2),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = activePost.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "inactive-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 3),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = inactivePost.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
assertEquals(
listOf(activePost.id!!, activeAudio.id!!),
news.map { it.targetId }
)
}
private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null, isActive: Boolean = true): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
profileImage = profileImage,
role = role
)
member.isActive = isActive
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member, isActive: Boolean = true): CreatorFollowing {
val following = CreatorFollowing(isActive = isActive).apply {
this.member = member
this.creator = creator
}
entityManager.persist(following)
return following
}
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
val block = BlockMember(isActive = true).apply {
this.member = member
this.blockedMember = blockedMember
}
entityManager.persist(block)
return block
}
private fun saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean = false
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult
).apply {
member = creator
this.channelName = channelName
}
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveTheme(seed: String): AudioContentTheme {
val theme = AudioContentTheme(theme = seed, image = "$seed.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudioContent(
creator: Member,
theme: AudioContentTheme,
releaseDate: LocalDateTime,
isActive: Boolean = true
): AudioContent {
val content = AudioContent(
title = "audio-$releaseDate",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
).apply {
member = creator
this.theme = theme
duration = "00:10:00"
this.isActive = isActive
}
entityManager.persist(content)
return content
}
private fun saveCommunityPost(creator: Member, content: String, isActive: Boolean): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = 0,
isCommentAvailable = true,
isAdult = false,
isActive = isActive
).apply {
member = creator
}
entityManager.persist(post)
return post
}
private fun saveNews(
memberId: Long,
creatorId: Long,
sourceKey: String,
visibleFromAtUtc: LocalDateTime,
rank: Int? = null,
isAdult: Boolean = false,
newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING,
targetId: Long = creatorId
): HomeFollowingNewsInbox {
val news = HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = newsType,
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = visibleFromAtUtc.minusHours(1),
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title-$sourceKey",
body = "body",
thumbnailImagePath = null,
rank = rank,
isAdult = isAdult
)
entityManager.persist(news)
return news
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,66 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
import javax.persistence.EntityManager
class HomeFollowingNewsInboxPersistenceAdapterRetryTest {
@Test
@DisplayName("insertIgnoreAll은 JPA bulk insert unique 충돌 시 기존 row를 재조회하고 남은 row만 재시도한다")
fun shouldRetryRemainingRowsWhenBulkInsertConflictsWithExistingRow() {
val repository = Mockito.mock(HomeFollowingNewsInboxJpaRepository::class.java)
val entityManager = Mockito.mock(EntityManager::class.java)
val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
val sourceKey = "CREATOR_RANKING:1:2026-06-25"
Mockito.`when`(
repository.findExistingMemberIds(
FollowingNewsType.CREATOR_RANKING.name,
sourceKey,
listOf(10L)
)
).thenReturn(emptyList()).thenReturn(listOf(10L))
Mockito.`when`(repository.saveAll(Mockito.anyList<HomeFollowingNewsInbox>()))
.thenThrow(DataIntegrityViolationException("duplicate"))
val insertedCount = adapter.insertIgnoreAll(
listOf(record(memberId = 10L, creatorId = 1L, sourceKey = sourceKey))
)
assertEquals(0, insertedCount)
Mockito.verify(repository, Mockito.times(2)).findExistingMemberIds(
FollowingNewsType.CREATOR_RANKING.name,
sourceKey,
listOf(10L)
)
Mockito.verify(repository, Mockito.times(1)).saveAll(Mockito.anyList<HomeFollowingNewsInbox>())
}
private fun record(
memberId: Long,
creatorId: Long,
sourceKey: String
): HomeFollowingNewsInboxRecord {
return HomeFollowingNewsInboxRecord(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING.name,
sourceKey = sourceKey,
targetId = creatorId,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 0, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title",
body = "body",
thumbnailImagePath = null,
rank = 1,
isAdult = false
)
}
}

View File

@@ -0,0 +1,161 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.transaction.TestTransaction
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
) {
private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
@Test
@DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다")
fun shouldInsertOnlyNewRowsWhenUniqueSourceIsDuplicated() {
val firstInsertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = "CREATOR_RANKING:1:2026-06-25")))
val secondInsertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = "CREATOR_RANKING:1:2026-06-25")))
entityManager.flush()
entityManager.clear()
assertEquals(1, firstInsertCount)
assertEquals(0, secondInsertCount)
assertEquals(1, repository.findAll().size)
assertEquals(FollowingNewsType.CREATOR_RANKING, repository.findAll().first().newsType)
}
@Test
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
@DisplayName("실제 unique 중복 무시 이후 insertIgnoreAll을 호출한 트랜잭션은 커밋 가능하다")
fun shouldCommitTransactionAfterRealDuplicateCollisionIsIgnored() {
val sourceKey = "real-duplicate"
adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
entityManager.flush()
entityManager.clear()
val insertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
val rows = repository.findAll()
assertEquals(0, insertCount)
assertEquals(1, rows.size)
TestTransaction.flagForCommit()
assertDoesNotThrow { TestTransaction.end() }
}
@Test
@DisplayName("memberId creatorId 기준 활성 inbox row를 비활성화한다")
fun shouldDeactivateActiveRowsByMemberAndCreator() {
adapter.insertIgnoreAll(
listOf(
record(memberId = 10L, creatorId = 1L, sourceKey = "A"),
record(memberId = 10L, creatorId = 1L, sourceKey = "B"),
record(memberId = 11L, creatorId = 1L, sourceKey = "C")
)
)
entityManager.flush()
entityManager.clear()
val deactivatedCount = adapter.deactivateByMemberIdAndCreatorId(memberId = 10L, creatorId = 1L)
entityManager.flush()
entityManager.clear()
val rows = repository.findAll().sortedBy { it.sourceKey }
assertEquals(2L, deactivatedCount)
assertFalse(rows.first { it.sourceKey == "A" }.isActive)
assertFalse(rows.first { it.sourceKey == "B" }.isActive)
assertTrue(rows.first { it.sourceKey == "C" }.isActive)
}
@Test
@DisplayName("findActiveFollowerIds는 활성 팔로우 관계가 있는 회원 id만 반환한다")
fun shouldFindOnlyActiveFollowerIds() {
val creator = saveMember("creator", MemberRole.CREATOR)
val activeFollowerWithoutInbox = saveMember("active-follower", MemberRole.USER)
val inactiveFollowerWithInbox = saveMember("inactive-follower", MemberRole.USER)
val otherCreatorFollower = saveMember("other-follower", MemberRole.USER)
val otherCreator = saveMember("other-creator", MemberRole.CREATOR)
saveFollowing(activeFollowerWithoutInbox, creator, isActive = true)
saveFollowing(inactiveFollowerWithInbox, creator, isActive = false)
saveFollowing(otherCreatorFollower, otherCreator, isActive = true)
adapter.insertIgnoreAll(
listOf(
record(memberId = inactiveFollowerWithInbox.id!!, creatorId = creator.id!!, sourceKey = "inactive-inbox"),
record(memberId = otherCreatorFollower.id!!, creatorId = otherCreator.id!!, sourceKey = "other-inbox")
)
)
entityManager.flush()
entityManager.clear()
val followerIds = adapter.findActiveFollowerIds(creatorId = creator.id!!)
assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds)
}
private fun saveMember(seed: String, role: MemberRole): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
role = role
)
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member, isActive: Boolean): CreatorFollowing {
val following = CreatorFollowing(isActive = isActive).apply {
this.member = member
this.creator = creator
}
entityManager.persist(following)
return following
}
private fun record(
memberId: Long = 10L,
creatorId: Long = 1L,
sourceKey: String,
newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING
): HomeFollowingNewsInboxRecord {
return HomeFollowingNewsInboxRecord(
memberId = memberId,
creatorId = creatorId,
newsType = newsType.name,
sourceKey = sourceKey,
targetId = creatorId,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 0, 0, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0, 0),
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title",
body = "body",
thumbnailImagePath = null,
rank = 1,
isAdult = false
)
}
}

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class HomeFollowingNewsPublishServiceTest {
@Test
@DisplayName("커뮤니티 게시글 발행은 현재 활성 팔로워에게만 inbox record를 생성한다")
fun shouldPublishCommunityPostCreatedToActiveFollowers() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(1L, 2L))
val service = HomeFollowingNewsPublishService(inboxPort)
val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 1, 2, 3)
service.publishCommunityPostCreated(
postId = 100L,
creatorId = 9L,
creatorNickname = "creator",
creatorProfileImagePath = "profile.png",
title = "새 커뮤니티 글",
body = "본문",
thumbnailImagePath = "post.png",
occurredAtUtc = occurredAtUtc,
isAdult = true
)
assertEquals(9L, inboxPort.findActiveFollowerIdsCreatorId)
assertEquals(listOf(1L, 2L), inboxPort.records.map { it.memberId })
val record = inboxPort.records.first()
assertEquals(FollowingNewsType.COMMUNITY_POST.name, record.newsType)
assertEquals("COMMUNITY_POST:100", record.sourceKey)
assertEquals(100L, record.targetId)
assertEquals(occurredAtUtc, record.visibleFromAtUtc)
assertEquals("post.png", record.thumbnailImagePath)
assertEquals(true, record.isAdult)
}
@Test
@DisplayName("오디오 콘텐츠 발행은 공개 시각을 visibleFromAtUtc로 저장한다")
fun shouldPublishContentUploadedWithVisibleFromAtUtc() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(3L))
val service = HomeFollowingNewsPublishService(inboxPort)
val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0)
val visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0)
service.publishContentUploaded(
contentId = 200L,
creatorId = 8L,
creatorNickname = "audio-creator",
creatorProfileImagePath = null,
title = "오디오 제목",
body = "오디오 설명",
thumbnailImagePath = "cover.jpg",
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
isAdult = false
)
val record = inboxPort.records.single()
assertEquals(FollowingNewsType.AUDIO_CONTENT.name, record.newsType)
assertEquals("AUDIO_CONTENT:200", record.sourceKey)
assertEquals(occurredAtUtc, record.occurredAtUtc)
assertEquals(visibleFromAtUtc, record.visibleFromAtUtc)
assertEquals("cover.jpg", record.thumbnailImagePath)
}
@Test
@DisplayName("발행 record의 title과 body는 inbox 컬럼 길이에 맞게 잘린다")
fun shouldTruncateTitleAndBodyToInboxColumnLimits() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(5L))
val service = HomeFollowingNewsPublishService(inboxPort)
service.publishContentUploaded(
contentId = 201L,
creatorId = 8L,
creatorNickname = "audio-creator",
creatorProfileImagePath = null,
title = "".repeat(300),
body = "".repeat(1_200),
thumbnailImagePath = null,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
isAdult = false
)
val record = inboxPort.records.single()
assertEquals(255, record.title.length)
assertEquals(1_000, record.body.length)
}
@Test
@DisplayName("크리에이터 랭킹 발행은 rank와 스냅샷 노출 시각을 저장한다")
fun shouldPublishCreatorRankingVisibleWithRankAndVisibleFromAtUtc() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(4L))
val service = HomeFollowingNewsPublishService(inboxPort)
val aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0)
val visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
service.publishCreatorRankingVisible(
creatorId = 7L,
creatorNickname = "ranker",
creatorProfileImagePath = "ranker.png",
aggregationStartAtUtc = aggregationStartAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
rank = 2
)
val record = inboxPort.records.single()
assertEquals(FollowingNewsType.CREATOR_RANKING.name, record.newsType)
assertEquals("CREATOR_RANKING:7:2026-05-31T15:00", record.sourceKey)
assertEquals(7L, record.targetId)
assertEquals(visibleFromAtUtc, record.occurredAtUtc)
assertEquals(visibleFromAtUtc, record.visibleFromAtUtc)
assertEquals(2, record.rank)
}
}
private class FakeHomeFollowingNewsInboxPort(
private val activeFollowerIds: List<Long>
) : HomeFollowingNewsInboxPort {
val records = mutableListOf<HomeFollowingNewsInboxRecord>()
var findActiveFollowerIdsCreatorId: Long? = null
override fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int {
this.records.addAll(records)
return records.size
}
override fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long = 0
override fun findActiveFollowerIds(creatorId: Long): List<Long> {
findActiveFollowerIdsCreatorId = creatorId
return activeFollowerIds
}
}

View File

@@ -0,0 +1,137 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
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 kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.TimeZone
class HomeFollowingQueryServiceTest {
private val queryPort = RecordingHomeFollowingQueryPort()
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val now = LocalDateTime.of(2026, 6, 25, 12, 0)
private val service = HomeFollowingQueryService(
queryPort,
memberContentPreferenceService
) { now }
@Test
@DisplayName("팔로잉 탭 조회는 각 섹션의 기본 limit와 고정 now를 port에 전달한다")
fun shouldCallQueryPortWithDefaultLimitsAndNow() {
val member = member(10L)
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
val home = service.findHomeFollowing(member)
assertEquals(listOf(HomeFollowingCreator(1L, "creator", "profile")), home.followingCreators)
assertEquals(emptyList<Any>(), home.recentChats)
assertEquals(20, queryPort.followingCreatorsLimit)
assertEquals(10, queryPort.onAirLivesLimit)
assertEquals(3, queryPort.monthlySchedulesLimit)
assertEquals(30, queryPort.recentNewsLimit)
assertEquals(now, queryPort.monthlySchedulesNow)
assertEquals(now, queryPort.recentNewsNow)
}
@Test
@DisplayName("성인 콘텐츠 노출 가능 여부는 On Air, 스케줄, 최근 소식 조회에 전달된다")
fun shouldPassAdultContentVisibilityToQueryPort() {
val member = member(11L)
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(false)
service.findHomeFollowing(member)
assertEquals(false, queryPort.onAirCanViewAdultContent)
assertEquals(false, queryPort.monthlySchedulesCanViewAdultContent)
assertEquals(false, queryPort.recentNewsCanViewAdultContent)
assertEquals(11L, queryPort.memberId)
}
@Test
@DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다")
fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() {
val originalTimeZone = TimeZone.getDefault()
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
val defaultQueryPort = RecordingHomeFollowingQueryPort()
val defaultService = HomeFollowingQueryService(defaultQueryPort, memberContentPreferenceService)
val member = member(12L)
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
defaultService.findHomeFollowing(member)
val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1)
val capturedNow = defaultQueryPort.recentNewsNow!!
assertFalse(capturedNow.isBefore(beforeUtc))
assertFalse(capturedNow.isAfter(afterUtc))
assertTrue(LocalDateTime.now().isAfter(capturedNow.plusHours(8)))
} finally {
TimeZone.setDefault(originalTimeZone)
}
}
private fun member(id: Long): Member {
return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id }
}
private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort {
var memberId: Long? = null
var followingCreatorsLimit: Int? = null
var onAirLivesLimit: Int? = null
var onAirCanViewAdultContent: Boolean? = null
var monthlySchedulesLimit: Int? = null
var monthlySchedulesNow: LocalDateTime? = null
var monthlySchedulesCanViewAdultContent: Boolean? = null
var recentNewsLimit: Int? = null
var recentNewsNow: LocalDateTime? = null
var recentNewsCanViewAdultContent: Boolean? = null
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
this.memberId = memberId
followingCreatorsLimit = limit
return listOf(HomeFollowingCreator(1L, "creator", "profile"))
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
onAirCanViewAdultContent = canViewAdultContent
onAirLivesLimit = limit
return emptyList()
}
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
monthlySchedulesCanViewAdultContent = canViewAdultContent
monthlySchedulesNow = now
monthlySchedulesLimit = limit
return emptyList()
}
override fun findRecentNews(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime,
limit: Int
): List<HomeFollowingNews> {
recentNewsCanViewAdultContent = canViewAdultContent
recentNewsNow = nowUtc
recentNewsLimit = limit
return emptyList()
}
}
}

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.home.following.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class HomeFollowingNewsSourceKeyTest {
@Test
@DisplayName("크리에이터 랭킹 source key는 타입, 크리에이터, 집계 시작 시각으로 생성한다")
fun shouldCreateCreatorRankingSourceKey() {
val aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0)
val sourceKey = HomeFollowingNewsSourceKey.creatorRanking(
creatorId = 10L,
aggregationStartAtUtc = aggregationStartAtUtc
)
assertEquals("CREATOR_RANKING:10:2026-05-31T15:00", sourceKey)
}
@Test
@DisplayName("오디오 콘텐츠 source key는 타입과 콘텐츠 id로 생성한다")
fun shouldCreateAudioContentSourceKey() {
assertEquals("AUDIO_CONTENT:300", HomeFollowingNewsSourceKey.audioContent(contentId = 300L))
}
@Test
@DisplayName("커뮤니티 게시글 source key는 타입과 게시글 id로 생성한다")
fun shouldCreateCommunityPostSourceKey() {
assertEquals("COMMUNITY_POST:400", HomeFollowingNewsSourceKey.communityPost(postId = 400L))
}
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
@@ -11,6 +12,7 @@ import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionSynchronizationManager
@@ -165,16 +167,125 @@ class CreatorRankingSnapshotRefreshServiceTest {
assertEquals(true, output.out.contains("error=aggregate failed")) assertEquals(true, output.out.contains("error=aggregate failed"))
} }
@Test
@DisplayName("주간 스냅샷 저장 성공 후 크리에이터 랭킹 최근 소식을 순위와 함께 발행한다")
fun shouldPublishCreatorRankingNewsAfterSnapshotsAreReplaced() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 1L,
creatorNickname = "creator-1",
creatorProfileImagePath = "profile-1.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 2L,
creatorNickname = "creator-2",
creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
}
@Test
@DisplayName("주간 스냅샷 저장 실패 시 크리에이터 랭킹 최근 소식을 발행하지 않는다")
fun shouldNotPublishCreatorRankingNewsWhenReplaceSnapshotsFails() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
snapshotPort.failure = IllegalStateException("replace failed")
assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
}
Mockito.verifyNoInteractions(publishService)
}
@Test
@DisplayName("일부 크리에이터 랭킹 최근 소식 발행 실패는 스냅샷 갱신을 실패시키지 않는다")
fun shouldNotFailSnapshotRefreshWhenCreatorRankingNewsPublishFails() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
Mockito.doAnswer { invocation ->
if (invocation.getArgument<Long>(0) == 1L) {
throw IllegalStateException("publish failed")
}
0
}.`when`(publishService)
.publishCreatorRankingVisible(
creatorId = Mockito.anyLong(),
creatorNickname = anyStringValue(),
creatorProfileImagePath = Mockito.anyString(),
aggregationStartAtUtc = anyLocalDateTime(),
visibleFromAtUtc = anyLocalDateTime(),
rank = Mockito.anyInt()
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 2L,
creatorNickname = "creator-2",
creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
}
private fun service( private fun service(
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(), aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(),
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort() snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(),
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
): CreatorRankingSnapshotRefreshService { ): CreatorRankingSnapshotRefreshService {
return CreatorRankingSnapshotRefreshService( return CreatorRankingSnapshotRefreshService(
aggregationPort = aggregationPort, aggregationPort = aggregationPort,
snapshotPort = snapshotPort snapshotPort = snapshotPort,
homeFollowingNewsPublishService = publishService
) )
} }
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
}
private fun candidate( private fun candidate(
creatorId: Long, creatorId: Long,
finalScore: Double = 0.0, finalScore: Double = 0.0,
@@ -247,6 +358,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
var aggregationStartAtUtc: LocalDateTime? = null var aggregationStartAtUtc: LocalDateTime? = null
var aggregationEndAtUtc: LocalDateTime? = null var aggregationEndAtUtc: LocalDateTime? = null
var visibleFromAtUtc: LocalDateTime? = null var visibleFromAtUtc: LocalDateTime? = null
var failure: RuntimeException? = null
override fun findSnapshotsByAggregationPeriod( override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
@@ -281,6 +393,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
visibleFromAtUtc: LocalDateTime, visibleFromAtUtc: LocalDateTime,
newSnapshots: List<CreatorRankingSnapshotRecord> newSnapshots: List<CreatorRankingSnapshotRecord>
) { ) {
failure?.let { throw it }
this.rankingType = rankingType this.rankingType = rankingType
this.aggregationStartAtUtc = aggregationStartAtUtc this.aggregationStartAtUtc = aggregationStartAtUtc
this.aggregationEndAtUtc = aggregationEndAtUtc this.aggregationEndAtUtc = aggregationEndAtUtc

View File

@@ -44,11 +44,15 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityR
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
import javax.persistence.Query
@DataJpaTest( @DataJpaTest(
properties = [ properties = [
@@ -99,13 +103,73 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val oldest = saveLiveRoom(creator, baseAt, channelName = "paged-live-oldest", isAdult = false) val oldest = saveLiveRoom(creator, baseAt, channelName = "paged-live-oldest", isAdult = false)
flushAndClear() flushAndClear()
val page0 = repository.findLiveRecommendations(offset = 0, limit = 3, includeAdultLives = false) val page0 = repository.findLiveRecommendations(offset = 0L, limit = 3, includeAdultLives = false)
val page1 = repository.findLiveRecommendations(offset = 2, limit = 3, includeAdultLives = false) val page1 = repository.findLiveRecommendations(offset = 2L, limit = 3, includeAdultLives = false)
assertEquals(listOf(newest.id, middle.id, oldest.id), page0.map { it.liveRoomId }) assertEquals(listOf(newest.id, middle.id, oldest.id), page0.map { it.liveRoomId })
assertEquals(listOf(oldest.id), page1.map { it.liveRoomId }) assertEquals(listOf(oldest.id), page1.map { it.liveRoomId })
} }
@Test
@DisplayName("라이브 추천 조회는 진행 중인 라이브의 제목, 가격, 시작 시간을 함께 반환한다")
fun shouldReturnLiveTitlePriceAndBeginDateTimeForOnAirLiveQuery() {
val beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)
val viewer = saveMember("on-air-viewer", MemberRole.USER)
val creator = saveMember("on-air-creator", MemberRole.CREATOR)
val live = saveLiveRoom(
creator = creator,
beginDateTime = beginDateTime,
channelName = "channel",
title = "paid live",
price = 30
)
flushAndClear()
val lives = repository.findLiveRecommendations(
offset = 0,
limit = 1,
memberId = viewer.id,
includeAdultLives = true
)
assertEquals(listOf(live.id), lives.map { it.liveRoomId })
assertEquals("paid live", lives.single().title)
assertEquals(30, lives.single().price)
assertEquals(beginDateTime, lives.single().beginDateTime)
}
@Test
@DisplayName("진행 중 라이브 조회 정책은 활성 방송자, 채널명, 활성 라이브, 성인 노출, 차단 관계를 적용한다")
fun shouldApplyOnAirLiveVisibilityPolicy() {
val baseAt = LocalDateTime.of(2026, 6, 26, 12, 0)
val viewer = saveMember("policy-viewer", MemberRole.USER)
val visibleCreator = saveMember("policy-visible", MemberRole.CREATOR)
val inactiveCreator = saveMember("policy-inactive", MemberRole.CREATOR, isActive = false)
val viewerBlockedCreator = saveMember("policy-viewer-blocked", MemberRole.CREATOR)
val creatorBlockedViewer = saveMember("policy-creator-blocked", MemberRole.CREATOR)
val olderVisibleLive = saveLiveRoom(visibleCreator, baseAt, channelName = "older")
val newerVisibleLive = saveLiveRoom(visibleCreator, baseAt.plusMinutes(1), channelName = "newer")
saveLiveRoom(inactiveCreator, baseAt.plusMinutes(6), channelName = "inactive-creator")
saveLiveRoom(visibleCreator, baseAt.plusMinutes(5), channelName = "inactive-live", isActive = false)
saveLiveRoom(visibleCreator, baseAt.plusMinutes(5), channelName = null)
saveLiveRoom(visibleCreator, baseAt.plusMinutes(4), channelName = "")
saveLiveRoom(visibleCreator, baseAt.plusMinutes(3), channelName = "adult", isAdult = true)
saveLiveRoom(viewerBlockedCreator, baseAt.plusMinutes(2), channelName = "viewer-blocked")
saveLiveRoom(creatorBlockedViewer, baseAt.plusMinutes(2), channelName = "creator-blocked")
saveBlock(viewer, viewerBlockedCreator)
saveBlock(creatorBlockedViewer, viewer)
flushAndClear()
val lives = repository.findLiveRecommendations(
offset = 0,
limit = 10,
memberId = viewer.id,
includeAdultLives = false
)
assertEquals(listOf(newerVisibleLive.id, olderVisibleLive.id), lives.map { it.liveRoomId })
}
@Test @Test
@DisplayName("라이브 추천은 회원과 크리에이터의 양방향 차단 관계를 제외한다") @DisplayName("라이브 추천은 회원과 크리에이터의 양방향 차단 관계를 제외한다")
fun shouldExcludeBidirectionalBlockedCreatorsFromLiveRecommendations() { fun shouldExcludeBidirectionalBlockedCreatorsFromLiveRecommendations() {
@@ -972,8 +1036,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("AudioContentLike", like.id!!, now.minusHours(1)) updateCreatedAt("AudioContentLike", like.id!!, now.minusHours(1))
flushAndClear() flushAndClear()
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 2, includeAdultContents = false) val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 2, includeAdultContents = false)
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 2, includeAdultContents = false) val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 2, includeAdultContents = false)
assertEquals(listOf(normalNewest.id, normalOldest.id), page0.map { it.creatorId }) assertEquals(listOf(normalNewest.id, normalOldest.id), page0.map { it.creatorId })
assertEquals(listOf(normalOldest.id), page1.map { it.creatorId }) assertEquals(listOf(normalOldest.id), page1.map { it.creatorId })
@@ -1011,9 +1075,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
saveAudioContent(creator3, now.minusDays(5), isActive = true) saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear() flushAndClear()
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 1, includeAdultContents = false) val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 1, includeAdultContents = false)
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 1, includeAdultContents = false) val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 1, includeAdultContents = false)
val page2 = repository.findRecentDebutCreators(now, offset = 2, limit = 1, includeAdultContents = false) val page2 = repository.findRecentDebutCreators(now, offset = 2L, limit = 1, includeAdultContents = false)
val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId } val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId }
assertEquals(setOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds.toSet()) assertEquals(setOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds.toSet())
@@ -1097,13 +1161,71 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false) val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false)
flushAndClear() flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 2, includeAdultContents = false) val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 2, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 2, includeAdultContents = false) val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 2, includeAdultContents = false)
assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId }) assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId })
assertEquals(listOf(oldest.id), page1.map { it.contentId }) assertEquals(listOf(oldest.id), page1.map { it.contentId })
} }
@Test
@DisplayName("첫 오디오 콘텐츠는 성인 여부와 오리지널 시리즈 여부를 함께 조회한다")
fun shouldMapFirstAudioContentAdultAndOriginalSeriesFlags() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val creator = saveMember("first-audio-flags", MemberRole.CREATOR)
val content = saveAudioContent(creator, now.minusDays(1), isActive = true, isAdult = false)
val series = saveSeries("first-audio-original-series", creator, isActive = true).apply {
isOriginal = true
}
saveSeriesContent(series, content)
updateCreatedAt("AudioContent", content.id!!, now.minusDays(1))
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10)
assertEquals(false, contents.single().isAdult)
assertEquals(true, contents.single().isOriginalSeries)
}
@Test
@DisplayName("첫 오디오 콘텐츠 native query의 숫자 Boolean 값을 매핑한다")
fun shouldMapNumericNativeBooleanFromFirstAudioContentRows() {
val mockEntityManager = Mockito.mock(EntityManager::class.java)
val mockQuery = Mockito.mock(Query::class.java)
val repository = DefaultHomeRecommendationQueryRepository(
JPAQueryFactory(mockEntityManager),
mockEntityManager
)
Mockito.`when`(mockEntityManager.createNativeQuery(anyString())).thenReturn(mockQuery)
Mockito.`when`(mockQuery.setParameter(anyString(), any())).thenReturn(mockQuery)
Mockito.`when`(mockQuery.resultList).thenReturn(
listOf(
arrayOf(
1L,
2L,
"creator",
null,
"title",
0,
null,
true,
false,
1
)
)
)
val contents = repository.findFirstAudioContents(
now = LocalDateTime.of(2026, 6, 27, 10, 0),
offset = 0L,
limit = 1,
memberId = null,
includeAdultContents = false
)
assertEquals(true, contents.single().isOriginalSeries)
}
@Test @Test
@DisplayName("첫 오디오 콘텐츠는 회원과 크리에이터의 양방향 차단 관계를 제외한다") @DisplayName("첫 오디오 콘텐츠는 회원과 크리에이터의 양방향 차단 관계를 제외한다")
fun shouldExcludeBidirectionalBlockedCreatorsFromFirstAudioContents() { fun shouldExcludeBidirectionalBlockedCreatorsFromFirstAudioContents() {
@@ -1136,9 +1258,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true) val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear() flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 1, includeAdultContents = false) val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 1, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 1, includeAdultContents = false) val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 1, includeAdultContents = false)
val page2 = repository.findFirstAudioContents(now, offset = 2, limit = 1, includeAdultContents = false) val page2 = repository.findFirstAudioContents(now, offset = 2L, limit = 1, includeAdultContents = false)
val pagedContentIds = (page0 + page1 + page2).map { it.contentId } val pagedContentIds = (page0 + page1 + page2).map { it.contentId }
assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet()) assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet())
@@ -2082,17 +2204,22 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
creator: Member, creator: Member,
beginDateTime: LocalDateTime, beginDateTime: LocalDateTime,
channelName: String?, channelName: String?,
isAdult: Boolean = false isAdult: Boolean = false,
title: String = "live-${creator.nickname}-$beginDateTime",
price: Int = 0,
isActive: Boolean = true
): LiveRoom { ): LiveRoom {
val room = LiveRoom( val room = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime", title = title,
notice = "notice", notice = "notice",
beginDateTime = beginDateTime, beginDateTime = beginDateTime,
numberOfPeople = 0, numberOfPeople = 0,
isAdult = isAdult isAdult = isAdult,
price = price
) )
room.member = creator room.member = creator
room.channelName = channelName room.channelName = channelName
room.isActive = isActive
entityManager.persist(room) entityManager.persist(room)
return room return room
} }

View File

@@ -65,6 +65,23 @@ class HomeRecommendationQueryServiceTest {
assertEquals(port.liveRecommendations, recommendations) assertEquals(port.liveRecommendations, recommendations)
} }
@Test
@DisplayName("라이브 추천 조회는 paging과 성인 노출 여부를 조회 포트에 그대로 위임한다")
fun shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag() {
val recommendations = service.findLiveRecommendations(
offset = 40,
limit = 21,
memberId = 100L,
includeAdultLives = true
)
assertEquals(40, port.liveOffset)
assertEquals(21, port.liveLimit)
assertEquals(100L, port.liveMemberId)
assertEquals(true, port.liveIncludeAdultLives)
assertEquals(port.liveRecommendations, recommendations)
}
@Test @Test
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다") @DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
fun shouldFindHomeBannersWithDefaultLimit() { fun shouldFindHomeBannersWithDefaultLimit() {
@@ -598,7 +615,7 @@ class HomeRecommendationQueryServiceTest {
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var liveLimit: Int? = null var liveLimit: Int? = null
var liveOffset: Int? = null var liveOffset: Long? = null
var liveMemberId: Long? = null var liveMemberId: Long? = null
var liveIncludeAdultLives: Boolean? = null var liveIncludeAdultLives: Boolean? = null
var bannerLimit: Int? = null var bannerLimit: Int? = null
@@ -608,12 +625,12 @@ class HomeRecommendationQueryServiceTest {
var activeCreatorIncludeAdultActivities: Boolean? = null var activeCreatorIncludeAdultActivities: Boolean? = null
var recentDebutNow: LocalDateTime? = null var recentDebutNow: LocalDateTime? = null
var recentDebutLimit: Int? = null var recentDebutLimit: Int? = null
var recentDebutOffset: Int? = null var recentDebutOffset: Long? = null
var recentDebutMemberId: Long? = null var recentDebutMemberId: Long? = null
var recentDebutIncludeAdultContents: Boolean? = null var recentDebutIncludeAdultContents: Boolean? = null
var firstAudioNow: LocalDateTime? = null var firstAudioNow: LocalDateTime? = null
var firstAudioLimit: Int? = null var firstAudioLimit: Int? = null
var firstAudioOffset: Int? = null var firstAudioOffset: Long? = null
var firstAudioMemberId: Long? = null var firstAudioMemberId: Long? = null
var firstAudioIncludeAdultContents: Boolean? = null var firstAudioIncludeAdultContents: Boolean? = null
var aiCharacterDetailIds: List<Long> = emptyList() var aiCharacterDetailIds: List<Long> = emptyList()
@@ -628,7 +645,10 @@ class HomeRecommendationQueryServiceTest {
HomeLiveRecommendationRecord( HomeLiveRecommendationRecord(
liveRoomId = 1L, liveRoomId = 1L,
creatorNickname = "creator", creatorNickname = "creator",
creatorProfileImage = "profile.png" creatorProfileImage = "profile.png",
title = "live",
price = 10,
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)
) )
) )
val banners = listOf( val banners = listOf(
@@ -668,7 +688,9 @@ class HomeRecommendationQueryServiceTest {
title = "first-audio", title = "first-audio",
price = 10, price = 10,
coverImage = "first-audio.png", coverImage = "first-audio.png",
isPointAvailable = true isPointAvailable = true,
isAdult = false,
isOriginalSeries = false
) )
) )
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList() var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
@@ -679,7 +701,7 @@ class HomeRecommendationQueryServiceTest {
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList() var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
override fun findLiveRecommendations( override fun findLiveRecommendations(
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultLives: Boolean includeAdultLives: Boolean
@@ -710,7 +732,7 @@ class HomeRecommendationQueryServiceTest {
override fun findRecentDebutCreators( override fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -725,7 +747,7 @@ class HomeRecommendationQueryServiceTest {
override fun findFirstAudioContents( override fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -801,7 +823,7 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
override fun findLatestSnapshots( override fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int, offset: Long,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots val latestSnapshotAt = snapshots
@@ -812,8 +834,8 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit) return all.drop(offset.toInt()).take(limit)
} }
override fun replaceSnapshots( override fun replaceSnapshots(

View File

@@ -256,7 +256,7 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
override fun findLatestSnapshots( override fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int, offset: Long,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots val latestSnapshotAt = snapshots
@@ -267,8 +267,8 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit) return all.drop(offset.toInt()).take(limit)
} }
override fun replaceSnapshots( override fun replaceSnapshots(