Compare commits

..

324 Commits

Author SHA1 Message Date
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
884 changed files with 2894 additions and 72265 deletions

View File

@@ -7,5 +7,5 @@ indent_size = 4
indent_style = space
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 130
max_line_length = 120
tab_width = 4

6
.gitignore vendored
View File

@@ -1,9 +1,6 @@
HELP.md
.gradle
.envrc
.omx/
.worktrees/
.omo/
build/
!**/src/main/**/build/
!**/src/test/**/build/
@@ -326,7 +323,4 @@ gradle-app.setting
### Gradle Patch ###
**/build/
.kiro/
.junie
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle

View File

@@ -1,380 +0,0 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.15.12"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.12.tgz",
"integrity": "sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.15.12",
"effect": "4.0.0-beta.66",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.12.tgz",
"integrity": "sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.66",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.66.tgz",
"integrity": "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz",
"integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "1.11.12",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

112
AGENTS.md
View File

@@ -1,112 +0,0 @@
# AGENTS.md
## 문서 목적
- 이 문서는 `/Users/klaus/Develop/sodalive/Server/sodalive` 저장소에서 작업하는 에이전트용 실행 가이드다.
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
## CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)
These principles override plugin behavior, skill behavior, workflow behavior, and default model behavior unless the user's direct instruction explicitly says otherwise.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
## 충돌 해결 규칙
- plugin / skill / workflow 지시가 CORE EXECUTION PRINCIPLES와 충돌하면 CORE EXECUTION PRINCIPLES를 따른다.
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
## 프로젝트 개요
- 빌드 도구: Gradle Wrapper (`./gradlew`)
- 언어/런타임: Kotlin + Java 17
- 프레임워크: Spring Boot 2.7.14
- 주요 플러그인: `org.jlleitschuh.gradle.ktlint`
- 단일 루트 프로젝트: `settings.gradle.kts``rootProject.name = "sodalive"`
## 프로젝트 핵심 규칙
- Kotlin/Spring 스타일, 테스트 스타일, 보안 유의사항, 작업 절차, 문서 유지보수, 실행 명령어, 커밋 메시지 상세 규칙은 아래 문서를 따른다.
- `docs/agent-guides/코드스타일.md`
- `docs/agent-guides/테스트스타일.md`
- `docs/agent-guides/설정보안.md`
- `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md`
- `docs/agent-guides/실행명령어.md`
- `docs/agent-guides/커밋메시지.md`
- 공개 API 스키마는 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 기존 코드베이스 관례를 우선하며, 불확실한 규칙은 추측하지 말고 근거 파일을 먼저 확인한다.
## PRD 및 구현 계획/TASK 문서 규칙
- 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다.
- 문서는 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 작성한다.
- 상세 작성/유지보수 규칙은 `docs/agent-guides/작업절차.md``docs/agent-guides/문서유지보수.md`를 따른다.
## 에이전트 동작 원칙
- 추측하지 말고, 근거 파일을 읽고 결정한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.

View File

@@ -18,7 +18,7 @@ version = "0.0.1-SNAPSHOT"
val querydslVersion = "5.0.0"
java {
sourceCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_11
}
repositories {
@@ -41,8 +41,6 @@ dependencies {
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
implementation("com.nimbusds:nimbus-jose-jwt:9.37.3")
// querydsl (추가 설정)
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
@@ -72,17 +70,11 @@ dependencies {
implementation("org.apache.poi:poi-ooxml:5.2.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
// file mimetype check
implementation("org.apache.tika:tika-core:3.2.0")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
testRuntimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("com.github.codemonstur:embedded-redis:1.4.3")
developmentOnly("org.springframework.boot:spring-boot-devtools")
}
allOpen {
@@ -94,7 +86,7 @@ allOpen {
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "17"
jvmTarget = "11"
}
}

View File

@@ -1,60 +0,0 @@
-- Phase 5: 추천 크리에이터 동시 팔로우 중복 방지 운영 DB 반영 SQL
-- 목적: creator_following 테이블의 동일 회원/크리에이터 중복 row를 정리하고 유니크 제약을 추가한다.
-- 주의: 운영 반영 전 아래 중복 조회 결과를 검토하고, 삭제 대상 row가 운영 정책상 보존 대상인지 확인한다.
-- 1. 중복 데이터 사전 점검
select
member_id,
creator_id,
count(*) as duplicate_count,
group_concat(id order by id asc) as duplicate_ids
from creator_following
group by member_id, creator_id
having count(*) > 1;
-- 2. 중복 row 정리
-- 동일 member_id/creator_id 조합에서 가장 작은 id 1개만 유지한다.
-- 유지 row는 중복 row 중 하나라도 활성 상태였으면 활성 상태로 보정한다.
update creator_following keep_cf
join (
select
member_id,
creator_id,
min(id) as keep_id,
max(case when is_active = true then 1 else 0 end) as any_active,
max(case when is_notify = true then 1 else 0 end) as any_notify
from creator_following
group by member_id, creator_id
having count(*) > 1
) duplicate_cf on keep_cf.id = duplicate_cf.keep_id
set
keep_cf.is_active = duplicate_cf.any_active = 1,
keep_cf.is_notify = duplicate_cf.any_notify = 1;
delete duplicate_cf
from creator_following duplicate_cf
join (
select
member_id,
creator_id,
min(id) as keep_id
from creator_following
group by member_id, creator_id
having count(*) > 1
) keep_cf on duplicate_cf.member_id = keep_cf.member_id
and duplicate_cf.creator_id = keep_cf.creator_id
and duplicate_cf.id <> keep_cf.keep_id;
-- 3. 중복 정리 결과 재확인: 결과가 없어야 한다.
select
member_id,
creator_id,
count(*) as duplicate_count,
group_concat(id order by id asc) as duplicate_ids
from creator_following
group by member_id, creator_id
having count(*) > 1;
-- 4. 유니크 제약 추가
alter table creator_following
add constraint uk_creator_following_member_creator unique (member_id, creator_id);

View File

@@ -1,27 +0,0 @@
create table recommendation_snapshot (
id bigint not null auto_increment comment 'ID',
section_type varchar(50) not null comment '섹션 타입',
target_id bigint not null comment '대상 ID',
score double not null comment '점수',
snapshot_at TIMESTAMP not null comment '스냅샷 시각',
random_tie_breaker double not null comment '랜덤 타이 브레이커',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
primary key (id),
index idx_recommendation_snapshot_latest (section_type, snapshot_at, score, random_tie_breaker),
index idx_recommendation_snapshot_target (section_type, target_id)
) comment '추천 스냅샷';
create table creator_content_view_history (
id bigint not null auto_increment comment 'ID',
member_id bigint not null comment '회원 ID',
content_id bigint not null comment '콘텐츠 ID',
genre_id bigint not null comment '장르 ID',
viewed_at TIMESTAMP not null comment '시청 시각',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
primary key (id),
index idx_creator_content_view_history_member_viewed (member_id, viewed_at),
index idx_creator_content_view_history_content (content_id),
index idx_creator_content_view_history_genre (genre_id)
) comment '크리에이터 콘텐츠 시청 이력';

View File

@@ -1,619 +0,0 @@
# 메인 홈 추천 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `/api/v2/home/recommendations` 하위에 메인 홈 추천 통합 조회, 섹션별 전체보기, 콘텐츠 조회 이력 기록, 추천 크리에이터 동시 팔로우 API를 제공한다.
**Architecture:** 공개 API 조립은 `kr.co.vividnext.sodalive.v2.api.home`에 두고, 추천 정책/점수/스냅샷/조회 이력/팔로우 기능은 `kr.co.vividnext.sodalive.v2.recommendation`에 둔다. `v2.api.home``v2.recommendation`의 application use case만 호출하며, `v2.recommendation`는 API DTO에 의존하지 않는다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL, native SQL, JUnit 5, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- 통합 조회: `GET /api/v2/home/recommendations`
- 전체보기 조회:
- `GET /api/v2/home/recommendations/lives`
- `GET /api/v2/home/recommendations/debut-creators`
- `GET /api/v2/home/recommendations/first-audio-contents`
- `GET /api/v2/home/recommendations/ai-characters`
- 추천 크리에이터 동시 팔로우: `POST /api/v2/home/recommendations/creators/follow`
- 요청에는 `creatorIds`만 포함한다.
- 장르의 크리에이터와 최근 응원이 많은 크리에이터는 동일한 id 리스트 검증/팔로우 저장 로직을 사용한다.
- 페이징 방식: 기존 Spring `Pageable`을 우선 사용하고 응답에는 `items`, `page`, `size`, `hasNext`를 포함한다.
- 시간 응답: `LocalDateTime` 저장값을 기존 관례처럼 KST 기준 저장값으로 보고 UTC ISO 문자열(`...Z`)로 변환한다.
- 스냅샷 일 배치는 KST 매일 06:00:00에 실행하고, 스냅샷 기준 시각은 전날 23:59:59 KST 의미를 코드에서 명확히 계산한다. 스케줄러는 `@Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul")`로 등록한다.
- 다중 서버 인스턴스에서 스냅샷 일 배치가 중복 실행되지 않도록 기존 Redisson 기반 분산 lock을 사용한다.
- 추천 스냅샷 lock key는 `lock:recommendation-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
- 저장소에는 DB migration 디렉터리가 없으므로 신규 스냅샷/조회 이력 엔티티 추가 시 운영 DB DDL 반영은 배포 절차에서 별도 수행한다. 코드 구현 task에는 JPA 엔티티/리포지토리와 통합 테스트를 포함하고, Phase 7 완료 후 신규 엔티티 테이블 생성 SQL을 문서 산출물로 작성한다.
- 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. 단순 조회/상세 조립/대상 활성 조건은 JPA 또는 QueryDSL로 표현하고, CTE/window function/`union all`/DB-side exact scoring처럼 SQL 고급 기능이 필요한 추천 산정에만 native SQL을 사용한다. native SQL 사용 시에는 H2 MySQL mode와 Kotlin 정책 산식 parity를 포함한 repository 통합 테스트를 반드시 둔다.
- 이번 범위에서는 기존 홈/콘텐츠 홈/라이브/AI 캐릭터 API의 공개 스키마를 변경하지 않고, 앱 다국어 문구 번역, ML 개인화, A/B 테스트 플랫폼, 관리자 화면, 추천 결과 수동 편집 기능은 구현하지 않는다. 응답 enum은 앱 다국어 처리를 위해 안정적인 영문 code로 유지한다.
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationPageResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/FollowRecommendedCreatorsRequest.kt`
### 신규 추천 기능 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/CreatorDebutPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedActivityType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/CreatorContentViewHistoryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistoryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
### 기존 코드 연결
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- 콘텐츠 상세 조회 성공 시 `CreatorContentViewHistoryService.recordView(...)`를 호출한다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt`
- 기존 공개 스키마는 유지하고 인증 회원 정보를 서비스로 전달하는 기존 흐름만 활용한다.
### 테스트
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/CreatorDebutPolicyTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
---
### Phase 1: 도메인 정책과 공통 모델
- [x] **Task 1.1: 추천 점수/신규 부스트 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: `shouldApplyCreatorNewBoostByDebutDays`, `shouldApplyAiCharacterNewBoostByCreatedDays`, `shouldCalculateDebutCreatorScore`, `shouldCalculateAiChatScore`, `shouldCalculateCheerScore`, `shouldCalculateCommunityScore`, `shouldCalculateFirstAudioRecencyScore` 테스트를 먼저 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: PRD 산식과 부스트 값을 그대로 구현한다. AI 캐릭터 신규 부스트는 캐릭터 생성일 기준 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다. 첫 오디오 최신성 점수는 `release_date` 기준 3일 이내 100, 7일 이내 80, 14일 이내 60, 21일 이내 40, 30일 이내 20을 적용한다.
- REFACTOR: 산식별 public 함수명과 파라미터가 PRD 용어를 반영하는지 정리한다.
- 기대 결과: 모든 산식/부스트/최신성 점수 테스트가 PASS이고 소수 계산 오차는 `assertEquals(expected, actual, 0.0001)` 범위 안에 들어간다.
- [x] **Task 1.2: 데뷔일/신규 크리에이터 판정 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/CreatorDebutPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/CreatorDebutPolicyTest.kt`
- RED: 첫 공개 콘텐츠 일시와 첫 라이브 일시 중 빠른 값을 데뷔일로 선택하는 테스트, 데뷔 후 30일 이내만 true인 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.CreatorDebutPolicyTest`
- GREEN: `resolveDebutAt(firstContentPublishedAt, firstLiveAt)``isNewCreator(debutAt, now)`를 구현한다.
- REFACTOR: 기존 `ExplorerService.getCreatorDetail``debutDateTime` 계산과 비교해 의미가 어긋나지 않는지 확인한다.
- 기대 결과: 콘텐츠만 있는 경우, 라이브만 있는 경우, 둘 다 있는 경우, 둘 다 없는 경우가 모두 명확히 검증된다.
- [x] **Task 1.3: 섹션/활동 enum과 내부 응답 모델 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedActivityType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: `LIVE_REPLAY` 테마 콘텐츠가 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: 내부 모델에 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` enum을 추가하고 활동 분류 함수를 구현한다.
- REFACTOR: enum 값은 앱 다국어 처리를 위해 영문 code와 동일하게 유지한다.
- 기대 결과: 활동 타입 응답 문자열이 PRD의 enum 후보와 일치한다.
### Phase 2: 스냅샷 엔티티와 일 1회 집계 작업
- [x] **Task 2.1: 추천 스냅샷 엔티티/리포지토리 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: 섹션 타입, 대상 id, 점수, 기준 시각, 랜덤 tie-breaker를 저장하고 기준 시각별 최신 스냅샷만 읽는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: `RecommendationSnapshot` JPA 엔티티와 `findTop...`, `deleteBySectionTypeAndSnapshotAt` 계열 리포지토리 메서드를 구현하고, application service가 의존할 `RecommendationSnapshotPort`를 둔다.
- REFACTOR: 스냅샷 조회가 없으면 빈 배열을 반환하도록 service 경계에서 처리한다.
- 기대 결과: 스냅샷 없음이 예외가 아니라 빈 결과로 검증된다.
- [x] **Task 2.2: 스냅샷 갱신 서비스 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Create: `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/HomeRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: AI 캐릭터, 최근 응원, 인기 커뮤니티 점수를 전날 23:59:59 기준으로 생성하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 최근 7일 집계와 신규 부스트를 적용해 `AI_CHARACTER`, `CHEER_CREATOR`, `POPULAR_COMMUNITY` 스냅샷을 저장한다. AI 캐릭터의 `followIncrease`는 팔로우 대상/관계 정의가 확정되지 않아 이번 스프린트에서 제외하고 0으로 집계한다.
- REFACTOR: 무거운 QueryDSL 집계는 repository에 두고 점수 산식은 `RecommendationScorePolicy`만 사용한다.
- 기대 결과: 동일 점수 항목은 `randomTieBreaker`가 저장되어 조회 시 랜덤 tie-breaker 정렬에 사용할 수 있다.
- [x] **Task 2.3: 매일 06:00 KST 스케줄러 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: KST 매일 06:00:00 cron과 `Asia/Seoul` zone이 선언되는지 reflection 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 스케줄러가 KST 06:00:00 cron으로 `RecommendationSnapshotRefreshService.refreshDailySnapshots()`를 호출하도록 구현한다.
- REFACTOR: 스케줄러에는 집계 로직을 두지 않고 호출만 남긴다.
- 기대 결과: KST 매일 06:00에 전날 23:59:59 KST 기준 집계가 실행되는 계약이 테스트로 고정된다.
- [x] **Task 2.3.1: 일 스냅샷 스케줄러 Redisson lock 적용**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: Redisson lock 획득 성공 시 `RecommendationSnapshotRefreshService.refreshDailySnapshots()`를 1회 호출하고, 획득 실패 시 호출하지 않는 테스트를 작성한다. lock key가 `lock:recommendation-snapshot-refresh`인지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 `RedissonClient` bean을 스케줄러에 주입하고 `tryLock`으로 lock을 획득한 인스턴스만 refresh service를 호출한다. lock 획득 실패는 정상 skip으로 처리한다.
- REFACTOR: DB 기반 scheduler lock 테이블은 추가하지 않고, 기존 `AudioContentReleaseScheduledTask`의 Redisson lock 패턴을 참고하되 스케줄러에는 lock 획득/해제와 service 호출만 둔다.
- 기대 결과: 여러 서버 인스턴스에서 같은 cron이 동시에 실행돼도 클러스터 전체에서 한 인스턴스만 추천 스냅샷을 갱신한다.
- [x] **Task 2.4: Phase 2 스냅샷 집계 통합 검증과 경계 보강**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.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/port/out/RecommendationSnapshotPort.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/RecommendationSnapshotRefreshServiceTest.kt`
- RED: QueryDSL 집계 통합 테스트를 추가해 AI 캐릭터 최근 채팅 수/활성 사용자 수, 최근 응원 `CHANNEL_DONATION` 후원 금액/후원 수와 팬 Talk 수, 인기 커뮤니티 좋아요/댓글/팔로워 수가 Phase 2 요구와 일치하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 스케줄러 cron을 KST 06:00:00 `Asia/Seoul` zone으로 수정하고, 최근 응원 후원 금액/후원 수는 `CanUsage.CHANNEL_DONATION`만 집계한다.
- REFACTOR: `RecommendationSnapshotPort`가 persistence entity를 직접 노출하지 않도록 application/domain 경계 DTO 또는 모델을 도입해 `port.out` 의존 경계를 정리한다.
- 기대 결과: Phase 2 집계 의미가 DB 기반 테스트로 고정되고, 스케줄러 timezone 계약과 `port.out` 경계 정리가 문서/테스트/구현에 함께 반영된다.
- [x] **Task 2.5: 크리에이터 신규 부스트 실제 데뷔일 적용**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.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/port/out/HomeRecommendationQueryPort.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/RecommendationSnapshotRefreshServiceTest.kt`
- RED: 최근 응원/인기 커뮤니티 신규 부스트가 단순 `Member.createdAt`이 아니라 실제 데뷔일을 사용하도록 실패 테스트를 추가한다. 실제 데뷔일은 첫 공개 콘텐츠 일시와 첫 라이브 일시 중 빠른 값이며, 둘 다 없는 경우는 스냅샷 후보에서 제외되는 실패 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 최근 응원/인기 커뮤니티 후보 DTO가 실제 데뷔일을 담도록 QueryDSL 집계를 수정하고, service는 신규 부스트 계산 시 해당 데뷔일만 사용한다.
- REFACTOR: 데뷔일 의미는 `CreatorDebutPolicy.resolveDebutAt(...)`과 일치하도록 중복 계산을 최소화한다.
- 기대 결과: 최근 응원/인기 커뮤니티 신규 부스트가 `Member.createdAt`이 아니라 실제 데뷔일 기준으로 계산된다.
- [x] **Task 2.6: AI 캐릭터 최근 채팅 수를 AI 발화 수로 고정**
- Files:
- 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: AI 캐릭터 최근 채팅 수가 최근 7일 안에 해당 AI 캐릭터가 발화한 채팅 메시지 수만 세도록 실패 테스트를 추가한다. 사용자 메시지, 다른 캐릭터 메시지, 비활성 메시지, 기간 밖 메시지는 제외되는지 fixture로 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: QueryDSL where/join 조건을 보강해 `recentChatCount`가 AI 발화 메시지 수만 반환하도록 구현한다.
- REFACTOR: 테스트 이름과 후보 DTO 필드 설명이 PRD의 "AI가 발화한 채팅 수" 의미를 드러내도록 정리한다.
- 기대 결과: AI 캐릭터 추천 점수의 `최근 발생한 AI 채팅 수` 입력값이 AI 발화 수로 고정된다.
- [x] **Task 2.7: AI 캐릭터 채팅 활성 사용자 수를 중복 없는 채팅 사용자 수로 고정**
- Files:
- 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: 최근 활성 사용자 수가 최근 7일 안에 해당 AI 캐릭터와 1회 이상 채팅한 중복 없는 사용자 수로 계산되도록 실패 테스트를 추가한다. 같은 사용자의 다중 메시지는 1명으로 세고, 다른 캐릭터와만 채팅한 사용자는 제외한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: QueryDSL 집계가 캐릭터별 distinct 사용자 수를 반환하도록 구현한다.
- REFACTOR: 활성 사용자 수 집계는 Task 2.6의 AI 발화 수 집계와 의미가 섞이지 않도록 별도 테스트 케이스로 유지한다.
- 기대 결과: AI 캐릭터 추천 점수의 `최근 활성 사용자 수` 입력값이 중복 없는 채팅 사용자 수로 고정된다.
- [x] **Task 2.8: 스냅샷 최종 저장 수를 점수순으로 제한**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.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/port/out/HomeRecommendationQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 스냅샷 저장 결과가 최종 점수 내림차순과 `randomTieBreaker` 기준으로 AI 캐릭터 최대 20개, 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개만 저장되는 실패 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: repository 조회에서 최종 점수와 `randomTieBreaker`를 계산하고, 점수 정렬 이후 동점자 랜덤 노출 여지를 위한 섹션별 최종 저장 수를 적용한다. service는 기준 시각 계산과 snapshot replace만 담당한다.
- REFACTOR: `GENRE_CREATOR`는 Phase 2 스냅샷 갱신 대상이 아니라 Task 4.2의 조회 이력 기반 추천임을 문서/테스트 경계로 유지한다.
- 기대 결과: application/service가 전체 후보를 메모리로 불러와 점수를 계산하지 않고, DB에서 정확한 최종 top 후보를 동점자 랜덤 정렬까지 반영해 반환하고 저장한다.
- [x] **Task 2.9: DB-side exact scoring으로 스냅샷 후보 산정 전환**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.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/port/out/HomeRecommendationQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.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/RecommendationSnapshotRefreshServiceTest.kt`
- RED: `RecommendationScoreSpec` 공유 산식과 DB-scored snapshot 조회 계약이 없으면 컴파일/테스트가 실패하도록 테스트를 작성한다. native SQL을 사용하는 쿼리는 Kotlin `RecommendationScorePolicy` 기대값과 DB score를 비교하고, 부스트 경계일, null aggregate, 비활성/제외 row, `score desc, randomTieBreaker asc` 정렬, 최종 점수 계산 이후 limit 적용, H2 MySQL mode parameter binding 호환성을 함께 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: DB 조회에서 모든 적격 후보의 최종 score와 `randomTieBreaker`를 계산한 뒤 `score desc, randomTieBreaker asc` 정렬과 섹션별 최종 limit을 적용한다. service는 기준 시각 계산과 snapshot replace만 담당하고 Kotlin-side score 재계산과 service-side limit을 제거한다.
- REFACTOR: DB score expression과 Kotlin `RecommendationScorePolicy``RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유하도록 정리하고, 최근 응원/인기 커뮤니티 집계는 aggregate CTE 기반으로 중복 계산을 줄인다.
- 기대 결과: candidate pre-limit 없이 DB에서 정확한 최종 top 후보를 산정하고, 20/16/20 저장 상한은 최종 점수 계산과 동점 랜덤 정렬 이후 적용되는 저장 limit으로만 유지된다.
### Phase 3: 추천 조회 repository와 application service
- [x] **Task 3.1: 라이브/배너/활동 크리에이터 조회 구현**
- 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/HomeRecommendationQueryRepository.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/application/HomeRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 라이브 최신순 20개, 활성 배너 orders 정렬 최대 20개, 동일 `orders` 배너의 랜덤 tie-breaker 정렬, 크리에이터당 최신 활동 1개만 반환하는 테스트를 작성한다. 라이브 노출 정보는 크리에이터 닉네임/프로필 이미지/라이브 번호를 포함하고, 활동 크리에이터 노출 정보는 크리에이터 프로필 이미지/닉네임/활동 타입/UTC 활동 시간/이동 대상 id를 포함하며 라이브 활동의 이동 대상 id는 nullable임을 검증한다. 배너는 비활성 이벤트 대상 `EVENT`, 비활성 크리에이터 대상 `CREATOR`, 비활성 시리즈 대상 `SERIES`, 비활성 시리즈 소유 회원 대상 `SERIES`가 제외되고, `LINK` 배너는 별도 대상 엔티티 검증 없이 배너 자체 활성 상태만으로 노출되는 repository 테스트를 함께 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `LiveRoom`, `AudioContentBanner`, `AudioContent`, `CreatorCommunity` 기반 QueryDSL 조회를 구현한다. application service는 `HomeRecommendationQueryPort`에만 의존하고 persistence 구현체가 port를 구현한다. 배너는 기존 콘텐츠 홈 배너의 앱 이동 필드를 유지하고, 동일 `orders` 값은 후보군 축소 후 랜덤화하거나 랜덤 tie-breaker를 적용한다. 배너 대상 활성 조건은 service 후처리가 아니라 repository 조회 조건으로 고정한다. 활동 타입 enum 값은 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 영문 code 그대로 유지한다. 최근 활동 `COMMUNITY`의 이동 대상 id는 커뮤니티 게시글 id가 아니라 해당 게시글 작성자 크리에이터 id를 사용한다.
- REFACTOR: 차단 관계, 비활성 회원, 비활성 콘텐츠/배너 제외 조건을 공통 private 조건 함수로 정리한다. 단순 조회와 대상 활성 조건은 QueryDSL/JPA 우선으로 표현하고, native SQL은 SQL 고급 기능이 필요한 쿼리에만 남긴다.
- 기대 결과: 특정 섹션 데이터가 부족해도 service가 가능한 개수만 반환한다.
- [x] **Task 3.2: 최근 데뷔/첫 오디오 콘텐츠 조회 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/HomeRecommendationQueryRepository.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/application/HomeRecommendationQueryServiceTest.kt`
- RED: 데뷔 후 30일 이내 추천 점수순, 최근 데뷔 크리에이터 노출 정보의 프로필 이미지/닉네임, 첫 오디오 콘텐츠 3번째 이내 활성 콘텐츠만 인정, 최신성 점수 구간별 정렬, 예약 공개 콘텐츠 제외 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다.
- REFACTOR: 데뷔일 계산은 `CreatorDebutPolicy`, 산식은 `RecommendationScorePolicy`만 호출하도록 중복 제거한다.
- 기대 결과: 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠가 제외된다.
- [x] **Task 3.3: AI 캐릭터/응원/인기 커뮤니티 스냅샷 조회 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: 스냅샷 기준 AI 캐릭터 10개, AI 캐릭터 응답의 캐릭터 이름/소개/전체 채팅 수/오리지널 작품명 조건, 최근 응원 8명과 크리에이터 프로필 이미지/닉네임, 인기 커뮤니티 10개와 크리에이터 프로필 이미지/닉네임/UTC 시간/좋아요 수/댓글 수/커뮤니티 내용, 스냅샷 없음 빈 배열 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: `RecommendationSnapshotRepository`에서 최신 스냅샷을 읽고 대상 엔티티 상세 정보를 조립한다. AI 캐릭터 작품명은 오리지널 작품 캐릭터인 경우에만 채우고, 인기 커뮤니티는 스냅샷에 저장된 점수/랜덤 tie-breaker 순서를 유지한다.
- REFACTOR: 비활성/노출 제한 캐릭터, 커뮤니티 비공개/유료/핀/성인 조건을 repository 조건으로 고정한다.
- 기대 결과: AI 캐릭터 노출 필드가 PRD와 일치하고, 인기 커뮤니티는 크리에이터당 1개만 반환하며 동일 점수는 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출된다.
### Phase 4: 콘텐츠 조회 이력 기록
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/CreatorContentViewHistoryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistoryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
- RED: 인증 회원의 콘텐츠 상세 진입 시 memberId/contentId/genreId/viewedAt이 저장되는 테스트와 비회원은 저장하지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryServiceTest`
- GREEN: 이력 저장 service와 repository를 구현한다. application service는 `CreatorContentViewHistoryPort`에만 의존하고 persistence 구현체가 port를 구현한다.
- REFACTOR: 동일 회원/콘텐츠의 연속 중복 저장 허용 여부는 추천 이력으로 보존하며, 집계 시 distinct 장르 기준으로 처리한다.
- 기대 결과: 조회 이력 저장 실패가 콘텐츠 상세 조회 자체를 실패시키지 않도록 호출부에서 예외 전파 범위를 제한한다.
- [x] **Task 4.2: 장르 기반 크리에이터 추천 조회 구현**
- 선행 조건: Task 4.1의 `CreatorContentViewHistory` 엔티티/리포지토리/저장 service가 준비되어 있어야 한다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: 조회 이력 콘텐츠의 `content_theme` 기준 랜덤 5개, 부족분 랜덤 보충, 테마별 8명, 한 응답의 5개 테마 안에서 크리에이터 중복 제거, 서로 다른 조회 시점에서는 같은 크리에이터 재노출 허용, 팔로우 크리에이터 제외, 활성 크리에이터/활성 콘텐츠가 없어 빈 그룹이 되는 테마 제외, 크리에이터 프로필 이미지/닉네임/id 노출 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: `CreatorContentViewHistory.contentId``content.theme_id` 매핑을 기반으로 후보 테마/크리에이터를 조회한다. 기존 응답 필드명은 공개 스키마 호환을 위해 `genreId`, `genreName`을 유지하되 값은 `content_theme.id`, `content_theme.theme`을 담는다.
- REFACTOR: 성인 콘텐츠 테마는 `MemberContentPreference.isAdultContentVisible == true` 회원에게만 포함되도록 조건을 공통화한다.
- 기대 결과: 비회원 또는 조회 이력 없는 회원도 조회 가능한 테마 중 랜덤 5개를 받고, 활성 크리에이터/활성 콘텐츠가 없는 빈 그룹은 제외한 뒤 다른 테마로 보충된다.
- [x] **Task 4.3: 기존 콘텐츠 상세 조회 흐름에 이력 기록 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- RED: `getDetail` 성공 시 `CreatorContentViewHistoryService.recordView(...)`가 호출되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest`
- GREEN: `AudioContentService` 생성자에 optional하지 않은 신규 service 의존성을 추가하고 상세 조회 성공 지점에서 기록한다.
- REFACTOR: 기존 `GetAudioContentDetailResponse` 스키마와 Controller URL/응답은 변경하지 않는다.
- 기대 결과: 기존 상세 조회 테스트가 모두 통과하고 응답 JSON 필드가 바뀌지 않는다.
- [x] **Task 4.4: 장르 기반 크리에이터 추천 본인 제외 보정**
- Files:
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- 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`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: 조회자가 크리에이터인 경우 본인만 있는 장르는 제외하고, 8명 중 본인이 포함된 장르는 본인을 제외한 뒤 대체 크리에이터가 있으면 8명을 채우며, 대체 크리에이터가 없거나 장르 전체가 8명 미만이면 조회 가능한 크리에이터만 응답하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeRequesterOnlyGenreFromGenreCreatorRecommendations --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldBackfillCreatorAfterExcludingRequesterFromGenreCreatorRecommendations --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldReturnAvailableCreatorsAfterExcludingRequesterFromGenreCreatorRecommendations`
- GREEN: 장르 후보 eligibility, fallback 후보 count, 실제 장르별 크리에이터 조회 SQL에서 `memberId`가 있는 경우 조회자 본인 크리에이터를 제외한다.
- REFACTOR: 공개 API 응답 스키마와 service의 장르별 중복 제거/보충 정책은 유지하고, repository 후보 산정과 응답 크리에이터 목록이 같은 eligibility 기준을 쓰는지 회귀 테스트로 확인한다.
- 기대 결과: 본인만 있는 장르는 응답하지 않고, 본인을 제외한 추천 가능 크리에이터가 있으면 최대 8명까지 응답하며, 8명 미만이면 가능한 만큼만 응답한다.
### Phase 5: 추천 크리에이터 동시 팔로우
- [x] **Task 5.1: 팔로우 use case 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
- RED: mock 없이 실제 Spring/JPA 흐름으로 신규 팔로우 id와 이미 팔로우/본인 id 등 제외 id를 구분하는 테스트, 존재하지 않는 id/크리에이터가 아닌 id 포함 시 전체 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest`
- GREEN: `MemberRepository`, `CreatorFollowingRepository`를 사용해 전체 입력을 검증하고, 이미 팔로우 중인 id와 본인 id는 서버 내부에서 제외하며 신규 팔로우만 저장한다. 과거 언팔로우로 비활성화된 팔로우 이력은 신규 row를 만들지 않고 다시 활성화한다.
- REFACTOR: 섹션별 분기 없이 팔로우 처리 로직은 `followCreators(member, creatorIds)` 하나로 유지한다.
- 기대 결과: 존재하지 않는 id 또는 크리에이터가 아닌 id가 하나라도 있으면 신규 저장이 발생하지 않고, 이미 팔로우 중인 id와 본인 id는 실패가 아니라 서버 내부 제외 대상으로 처리된다. 동일 회원과 동일 크리에이터의 팔로우 row는 중복 저장되지 않는다.
- [x] **Task 5.2: 팔로우 API DTO/Controller 연결**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/FollowRecommendedCreatorsRequest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: mock 없이 `@SpringBootTest`와 실제 repository를 사용해 비로그인 요청은 Spring Security에서 거부되고, 로그인 요청은 `creatorIds`를 service에 전달해 신규 팔로우만 저장하며 결과를 `ApiResponse.ok`로 반환하는 controller 테스트를 작성한다. `creatorIds` null/empty/50개 초과 요청은 실패하고 신규 저장하지 않는 테스트를 포함한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: `POST /api/v2/home/recommendations/creators/follow`를 구현한다.
- REFACTOR: request id 리스트가 null/empty이거나 50개를 초과하면 `SodaException`으로 거부한다.
- 기대 결과: 클라이언트 응답은 성공/실패 여부만 제공하고, 신규 팔로우 id와 제외 id 목록은 공개 응답에 포함하지 않는다.
- [x] **Task 5.3: 기존 팔로우 테이블 유니크 제약 운영 반영 문서화**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt`
- Create: `docs/20260529_메인_홈_추천_API/alter-existing-tables.sql`
- TDD 예외 사유: 운영 DB 반영 SQL 문서 산출물 작성 task라 제품 코드 테스트를 새로 작성하지 않는다.
- 대체 검증 방법:
- `rg -n "uk_creator_following_member_creator|creator_following|duplicate_count|ALTER TABLE|alter table" docs/20260529_메인_홈_추천_API/alter-existing-tables.sql src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: 동일 회원과 동일 크리에이터의 팔로우 row를 중복 저장하지 않도록 `creator_following(member_id, creator_id)` 유니크 제약을 JPA entity에 명시하고, 운영 DB 반영 전 중복 데이터 점검/정리 및 `ALTER TABLE` 절차를 문서화한다.
- 기대 결과: 테스트 H2 schema와 운영 DB 반영 절차가 같은 유니크 제약명 `uk_creator_following_member_creator`를 사용하며, 기존 중복 row가 있어도 배포 전 정리 절차를 검토할 수 있다.
### Phase 6: 홈 통합/전체보기 API
- [x] **Task 6.1: 홈 통합 응답 DTO와 facade 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: 통합 조회가 섹션별 기본 limit(20/20/10/10/10/10/5x8/8/10)을 service에 전달하고, 인증 회원의 팔로우 제외/콘텐츠 조회 이력/본인인증 여부를 service 조건으로 전달하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: facade가 `HomeRecommendationQueryService` 결과를 API DTO로 변환한다. 인증 회원이면 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부 조건을 조회 context에 포함하고 비회원이면 회원 의존 조건을 제외한다.
- REFACTOR: API DTO에는 앱 이동 대상 id가 없는 라이브 활동의 target id를 nullable로 둔다.
- 기대 결과: 특정 섹션이 빈 배열이어도 통합 조회는 성공 응답이다.
- [x] **Task 6.2: 홈 통합 Controller 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: `GET /api/v2/home/recommendations`가 인증 회원/비회원 모두 호출 가능하고 `ApiResponse.ok`를 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴으로 구현한다.
- REFACTOR: controller에는 인증 null 허용과 request parameter 전달 외 로직을 두지 않는다.
- 기대 결과: 비회원은 회원 의존 조건 없이 기본 추천을 받는다.
- [x] **Task 6.3: 커뮤니티를 제외한 섹션별 전체보기 API 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationPageResponse.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`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기 endpoint가 `page`, `size`를 전달하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: 확정 URL 4개를 controller에 추가하고 `HomeRecommendationPageResponse`로 반환한다.
- REFACTOR: size 기본값은 홈 기본 노출 수와 분리해 `20`으로 두고 최대값은 `50`으로 제한한다.
- 기대 결과: 커뮤니티를 제외한 전체보기 API가 같은 페이징 응답 형식을 사용한다.
- [x] **Task 6.4: Phase 6 리뷰 보완과 인증/성인 노출 경계 수정**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.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`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: 홈 통합 조회는 비회원 호출을 유지하지만 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기는 비회원 요청을 거부하는 테스트, 음수 `page`가 런타임 예외를 만들지 않는 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: Security `permitAll`은 통합 조회 GET만 유지하고 전체보기 GET은 인증 대상이 되도록 정리한다. controller는 전체보기 요청에서 `member == null`이면 `SodaException(common.error.bad_credentials)`로 거부하고, `page < 0`은 0으로 보정한다. 성인 노출 여부는 단순 `member.auth != null` 대신 `MemberContentPreferenceService.initializeDefaultPreference(member).isAdultContentVisible`와 기존 `isAdultVisibleByPolicy(...)`를 사용한다.
- REFACTOR: 공개 응답 스키마와 기존 follow API 동작은 변경하지 않는다.
- 기대 결과: 홈 통합 API는 비회원 조회 가능, 세부 전체보기 API는 회원만 조회 가능하며 성인 노출 정책과 page 경계가 기존 프로젝트 관례와 일치한다.
- [x] **Task 6.5: 섹션 전체보기 성인 노출 정책 전파 보완**
- Files:
- 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`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: 남은 섹션 전체보기 요청에서 controller가 인증 회원을 facade에 전달하고, 홈 통합 조회 응답도 같은 회원 성인 노출 정책을 사용하는 실패 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기 controller가 인증 회원을 facade에 전달하고, facade는 홈 통합 조회와 전체보기에서 같은 `MemberContentPreferenceService.initializeDefaultPreference(member).isAdultContentVisible``isAdultVisibleByPolicy(...)` 기준으로 회원별 성인 노출 여부를 계산한다.
- REFACTOR: 성인 노출 계산이 홈 통합 조회와 전체보기에서 서로 다른 의미로 분기되지 않도록 facade 내부 private 함수로만 정리한다.
- 기대 결과: 홈 통합 조회와 남은 섹션 전체보기 모두 동일한 회원 성인 노출 정책을 사용하며, 커뮤니티 전체보기 구현 없이 회원 설정 기반 노출 여부가 일관되게 적용된다.
- [x] **Task 6.6: 전체보기 DB 레벨 페이징과 실제 데이터 페이징 테스트 보강**
- 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/HomeRecommendationQueryRepository.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`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.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/api/home/HomeRecommendationControllerTest.kt`
- RED: facade 메모리 `drop/take` 방식으로는 실제 DB 데이터에서 `page`, `size`, `hasNext`가 정확히 보장되지 않는 실패 테스트를 추가하고, 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기의 실제 데이터 페이징 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: 전체보기 조회 port/repository/service가 Spring `Pageable`과 동일한 의미의 `page`, `size`, `offset`, `limit + 1` 조회를 DB 레벨에서 적용하도록 변경하고, facade는 repository 결과를 재페이징하지 않고 `items`, `page`, `size`, `hasNext` 응답 조립만 담당한다.
- REFACTOR: 홈 통합 조회의 고정 노출 수 조회와 전체보기 페이징 조회를 분리해, 전체보기 때문에 홈 통합 조회 쿼리 의미가 바뀌지 않도록 유지한다.
- 기대 결과: 전체보기 API는 facade 메모리 페이징이 아니라 DB 레벨 페이징을 사용하고, 실제 데이터 기반 테스트로 각 섹션의 `items`, `page`, `size`, `hasNext` 계산이 검증된다.
- [x] **Task 6.7: 커뮤니티 전체보기 endpoint와 연결 로직 제거**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.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`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: `/api/v2/home/recommendations/communities` 전체보기 endpoint와 `HomeRecommendationController.getCommunities` 연결이 더 이상 존재하지 않아야 하는 실패 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- GREEN: 커뮤니티 전체보기 controller method, facade section full-view 연결, Security matcher를 제거하고 홈 통합 조회의 인기 커뮤니티 기본 노출은 유지한다.
- REFACTOR: 커뮤니티 전체보기가 필요하다는 전제의 테스트명/fixture만 제거하고, 홈 통합 조회의 인기 커뮤니티 응답 검증은 유지한다.
- 기대 결과: 커뮤니티 전체보기 API는 Phase 6 공개 endpoint에서 제외되고, 연결 로직 제거 후에도 홈 통합 조회의 인기 커뮤니티 섹션은 기존처럼 동작한다.
- [x] **Task 6.8: Phase 6 보완 task 경계와 상태 확인**
- Files:
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- RED: Task 6.5~6.8이 Phase 6 보완 범위이고 Phase 6 보완 범위 밖 구현 항목이 섞이지 않았는지 문서 diff로 확인한다.
- 실패 확인: `rg -n "Task 6\.[5-8]" docs/20260529_메인_홈_추천_API/plan-task.md`
- GREEN: Task 6.5~6.8을 성인 노출 정책 전파, DB 레벨 페이징, 커뮤니티 전체보기 제거, Phase 6 보완 task 상태 확인 범위로만 유지한다.
- REFACTOR: Phase 6 보완 task 제목과 기대 결과가 서로 겹치거나 구현 범위를 넓히지 않도록 문구만 정리한다.
- 기대 결과: Task 6.5~6.8이 모두 완료 상태로 유지되고, Phase 6에서 처리한 후속 작업 범위와 상태가 명확하다.
### Phase 7: 통합 검증과 문서 갱신
- [x] **Task 7.1: repository 조건 회귀 테스트 보강**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 차단 관계 양방향 제외, 커뮤니티 성인 노출 조건, 본인인증 여부 조건이 필요한 추천 필터, 팔로우 크리에이터 제외, 비활성 회원 제외 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: 누락 조건을 QueryDSL where 조건 또는 service 필터에 추가한다.
- REFACTOR: 같은 차단/성인 조건이 여러 쿼리에 반복되면 repository private 함수로만 정리한다.
- 기대 결과: PRD Edge Case와 회원 조건이 테스트 이름으로 추적된다.
- [x] **Task 7.2: 운영 지표 기록 지점 확인**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: 메인 홈 API 성공/실패와 응답 시간, 섹션별 빈 응답 여부, 전체보기 조회 수, 추천 섹션별 클릭률 기록 지점, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공/실패, 콘텐츠 상세 조회 흐름에서 `CreatorContentViewHistoryService.recordView(...)` 실패가 `runCatching`으로 삼켜지더라도 구조화 로그 또는 metric으로 관측되는지, 일 배치 집계 성공/실패와 소요 시간을 관측할 수 있는 로그 또는 metric 호출 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 프로젝트에 이미 사용하는 metric 클라이언트가 있으면 해당 클라이언트를 사용하고, 없으면 구조화 로그로 PRD Metrics 항목을 관측 가능하게 남긴다. 콘텐츠 조회 이력 저장 실패는 상세 조회 응답 실패로 전파하지 않되, 실패 원인과 `memberId`, `contentId`를 추적 가능한 형태로 남긴다.
- REFACTOR: 지표 기록 때문에 공개 응답 스키마나 비즈니스 분기가 바뀌지 않도록 application 경계에서만 정리한다.
- 기대 결과: PRD Metrics 항목이 구현 코드의 로그/metric 지점으로 추적되고 테스트에서 호출 여부를 확인할 수 있다. 특히 콘텐츠 상세 조회의 이력 저장 실패가 사용자 응답을 깨지 않으면서도 운영자가 감지 가능한 신호로 남는다.
- [x] **Task 7.3: 전체 테스트/린트 검증**
- Files:
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- TDD 예외 사유: 검증 명령 실행과 문서 기록 task라 제품 코드 테스트를 새로 작성하지 않는다.
- 대체 검증 방법:
- `./gradlew test`
- `./gradlew ktlintCheck`
- `./gradlew tasks --all`
- 기대 결과: 세 명령이 모두 성공하고, 이 문서 하단 검증 기록에 실행 일시/명령/결과를 누적한다.
- [x] **Task 7.4: 신규 엔티티 테이블 생성 SQL 문서화**
- Files:
- Create: `docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql`
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- TDD 예외 사유: 운영 DB 반영용 SQL 문서 산출물 작성 task라 제품 코드 테스트를 새로 작성하지 않는다.
- 대체 검증 방법:
- `rg -n "CREATE TABLE|recommendation_snapshot|creator_content_view_history" docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql`
- `./gradlew tasks --all`
- 작성 기준: Phase 7까지 완료된 최종 JPA 엔티티 필드/인덱스/nullable 조건을 기준으로 `RecommendationSnapshot`, `CreatorContentViewHistory` 등 이번 작업에서 신규 생성된 엔티티의 운영 DB 테이블 생성 SQL을 작성한다.
- REFACTOR: SQL 문서에는 이번 작업에서 새로 추가된 테이블만 포함한다. 기존 테이블 변경이나 데이터 마이그레이션은 별도 배포 절차 항목으로 분리하며, Phase 5의 `creator_following` 유니크 제약은 `docs/20260529_메인_홈_추천_API/alter-existing-tables.sql`에 기록한다.
- 기대 결과: Phase 7 완료 시점의 최종 엔티티 구조와 일치하는 신규 테이블 생성 SQL이 문서로 남아 운영 DB 반영 범위를 검토할 수 있다.
- [x] **Task 7.5: 공통 차단 필터 전체 추천 섹션 적용 보완**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.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/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: 라이브, 최근 활동, 최근 데뷔, 첫 오디오, 최근 응원 상세, 인기 커뮤니티 상세가 회원과 크리에이터의 양방향 활성 차단 관계를 제외하는 테스트를 추가한다. 커뮤니티 성인 노출, 본인인증 기반 성인 노출 조건, 팔로우 제외, 비활성 회원 제외 회귀 테스트 이름을 명시적으로 유지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: facade/service/port/repository에 `memberId` 조회 컨텍스트를 전파하고, QueryDSL/native SQL 조회에 양방향 `block_member` 제외 조건을 적용한다.
- 기대 결과: 장르 추천뿐 아니라 요청된 모든 홈 추천 섹션에서 내가 차단했거나 나를 차단한 크리에이터의 데이터가 제외된다.
- [x] **Task 7.6: 운영 성공 로그 after-commit 기록 보완**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: 조회 이력 저장, 추천 크리에이터 동시 팔로우, 일 스냅샷 갱신 성공 로그가 트랜잭션 커밋 전에는 기록되지 않고 커밋 후 기록되는 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 성공 로그는 `TransactionSynchronizationManager``afterCommit`으로 등록하고, 트랜잭션 동기화가 없는 단위 실행에서는 기존처럼 즉시 기록한다. 실패 로그와 skip 로그는 기존 동작을 유지한다.
- 기대 결과: 트랜잭션이 커밋되기 전 성공 로그가 먼저 남아 운영 지표를 오염시키지 않는다.
- [x] **Task 7.7: 홈 배너 차단 필터 누락 보완**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.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/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: 홈 배너 `CREATOR` 대상 크리에이터와 `SERIES` 대상 시리즈 소유자가 회원과 양방향 활성 차단 관계인 경우 제외되는 테스트를 추가한다. `EVENT``LINK` 배너는 기존 활성 조건 기준으로 유지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners`
- GREEN: 홈 통합 조회에서 배너 조회에도 `memberId`를 전달하고, 배너 조회 포트/서비스/repository가 `CREATOR``bannerCreator.id`, `SERIES``seriesOwner.id` 기준으로 양방향 `block_member` 제외 조건을 적용한다.
- 기대 결과: 홈 배너 섹션에서도 차단 관계 크리에이터 또는 시리즈 소유자의 추천 데이터가 노출되지 않는다.
---
## PRD Coverage Check
- Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다.
- Feature B: Task 3.1, Task 6.3에서 라이브 최신순/전체보기/비활성 회원 제외와 크리에이터 닉네임/프로필 이미지/라이브 번호 노출 필드를 검증한다.
- 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 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 G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기를 검증한다.
- Feature 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 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을 검증한다.
- 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에서 검증한다.
---
## Verification Log
- 2026-05-30: plan-task 문서 작성 전 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, `docs/20260529_메인_홈_추천_API/prd.md`를 확인했다.
- 2026-05-30: 기존 v2 패키지 구조, 테스트 스타일, QueryDSL/스케줄러 사용 패턴, 관련 엔티티/리포지토리 후보를 `find`, `rg`, `sed`로 확인해 계획의 파일 경로와 검증 명령에 반영했다.
- 2026-05-30: `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck` 생성 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 13s`를 확인했다.
- 2026-05-30: 사용자 피드백에 따라 PRD의 Feature I가 특정 섹션 한정이 아니라 공통 "여러 크리에이터 동시 팔로우" 요구사항임을 확인했다. 장르의 크리에이터와 최근 응원이 많은 크리에이터가 동일한 팔로우 로직을 쓰도록 endpoint를 `POST /api/v2/home/recommendations/creators/follow`로 일반화했다.
- 2026-05-30: 동시 팔로우 범위 수정 후 `rg`로 장르 전용 명칭(`GenreCreator`, `genre-creators`, `FollowGenre`)과 placeholder 문구가 남지 않았음을 확인했다. `./gradlew tasks --all`은 sandbox 기본 권한에서 동일한 `.gradle` lock 파일 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 752ms`를 확인했다.
- 2026-05-30: `sourceSection`은 PRD 필수 요구사항이 아니므로 제거했다. 동시 팔로우 요청은 `creatorIds`만 받도록 단순화하고, 장르의 크리에이터/최근 응원이 많은 크리에이터 화면은 같은 API를 호출하는 것으로 정리했다.
- 2026-05-30: `sourceSection` 제거 후 `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 동일한 `.gradle` lock 파일 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 718ms`를 확인했다.
- 2026-05-30: PRD와 plan-task를 대조해 본인인증 조건, 동일 orders 배너 랜덤 정렬, AI 캐릭터 응답 필드/캐릭터 생성일 기준 부스트, 첫 오디오 최신성 점수 구간, 댓글 불가 커뮤니티 점수 계산, Metrics 관측 지점, `port.out` 의존 경계 보강이 필요함을 확인하고 관련 task와 Coverage Check에 반영했다.
- 2026-05-30: 문서 보강 후 `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 동일한 `.gradle` lock 파일 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 789ms`를 확인했다.
- 2026-05-30: Phase 1 Task 1.1 RED/GREEN을 진행했다. `RecommendationScorePolicyTest`는 구현 전 `Unresolved reference: RecommendationScorePolicy`로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-30: Phase 1 Task 1.2 RED/GREEN을 진행했다. `CreatorDebutPolicyTest`는 구현 전 `Unresolved reference: CreatorDebutPolicy`로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.CreatorDebutPolicyTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-30: Phase 1 Task 1.3 RED/GREEN을 진행했다. `HomeRecommendationQueryServiceTest`는 구현 전 `RecommendedActivityType`, `RecommendedSectionType`, `HomeRecommendationQueryService` 미구현으로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-30: Phase 1 최종 검증으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. Kotlin LSP는 이 환경에 `kotlin-lsp`가 설치되어 있지 않아 실행하지 못했고, Gradle 컴파일/테스트/ktlint로 대체 확인했다.
- 2026-05-30: Phase 2 Task 2.1~2.3 RED/GREEN을 진행했다. `RecommendationSnapshotRefreshServiceTest`는 구현 전 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort`, `RecommendationSnapshotRefreshService`, `RecommendationSnapshotScheduler` 미구현으로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-30: Phase 2 검증으로 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. 병렬 Gradle 실행 중에는 KAPT 임시 stub 파일 경합이 발생해 이후 검증은 순차 실행으로 고정했다. Kotlin LSP는 이 환경에 `kotlin-lsp`가 설치되어 있지 않아 실행하지 못했고, Gradle 컴파일/테스트/ktlint로 대체 확인했다.
- 2026-05-30: 기본 구현체 명명 규칙을 접미사 `Impl` 대신 접두사 `Default`로 변경했다. `HomeRecommendationQueryRepositoryImpl``DefaultHomeRecommendationQueryRepository`로 바꿨고, PRD와 구현 계획에 AI 캐릭터 `followIncrease`는 팔로우 대상/관계 정의 확정 전까지 이번 스프린트 산식과 집계에서 제외한다고 기록했다.
- 2026-05-30: 구현 전 문서 보강으로 기본 구현체 명명 규칙을 `docs/agent-guides/코드스타일.md`에 반영하고, 당시 스냅샷 일 배치 기준을 PRD/Task 2.3~2.4에 기록했다. 이후 Phase 2 권고 보강에서 스케줄은 KST 06:00 `Asia/Seoul` zone으로 변경했다. QueryDSL 집계 통합 테스트, `RecommendationSnapshotPort` 경계 정리, 최근 응원 `CHANNEL_DONATION` 기준 후원 금액/후원 수 검증은 Task 2.4로 추가했다.
- 2026-05-30: Phase 2 Task 2.4 RED/GREEN을 진행했다. RED에서 `RecommendationSnapshotRefreshServiceTest`는 기존 KST cron/JVM timezone 기준 계산으로 실패했고, `DefaultHomeRecommendationQueryRepositoryTest`는 최근 응원 후원 금액이 `CHANNEL_DONATION` 외 사용처까지 포함되어 `expected: <150> but was: <3150>`으로 실패했다. GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다.
- 2026-05-30: Phase 2 재점검을 진행했다. `RecommendationSnapshotRefreshServiceTest``DefaultHomeRecommendationQueryRepositoryTest`는 각각 재실행 시 `BUILD SUCCESSFUL`로 통과했지만, 최근 응원/인기 커뮤니티 신규 부스트가 실제 데뷔일이 아니라 `Member.createdAt`에 의존하는 점, AI 캐릭터 최근 채팅 수의 participant 범위가 명확히 고정되지 않은 점, 스냅샷 후보 전체 저장은 과도한 데이터 저장으로 이어질 수 있다는 점을 확인했다. 해당 보완사항은 Task 2.5~2.8과 Coverage Check에 나누어 반영했고, 실제 데뷔일이 없는 크리에이터는 Task 2.5에서 스냅샷 후보 제외로 확정하고 테스트로 검증했다.
- 2026-05-30: Phase 2 Task 2.5~2.8 보강을 진행했다. `DefaultHomeRecommendationQueryRepositoryTest``RecommendationSnapshotRefreshServiceTest`에 실제 데뷔일 기준 후보, AI 발화 수, 중복 없는 활성 사용자 수, 섹션별 스냅샷 저장 상한(20/16/20) 검증을 추가했다. 첫 실행은 `AudioContent.theme` fixture 누락과 QueryDSL alias 문제로 실패했고, 보정 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-30: Phase 2 Task 2.5~2.8 최종 검증으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. Kotlin LSP는 이 환경에 `kotlin-lsp`가 설치되어 있지 않아 실행하지 못했고, Gradle 컴파일/테스트/ktlint로 대체 확인했다.
- 2026-05-30: Phase 2 권고 보강으로 스냅샷 스케줄을 KST 06:00 `Asia/Seoul` zone으로 변경했다. 최종 점수 계산 전 후보 사전 제한은 정확한 top 후보를 누락할 수 있어 적용하지 않는다. AI 20개, 최근 응원 16개, 인기 커뮤니티 20개 저장 상한은 최종 점수와 동점 랜덤 정렬 이후 repository에서 적용하는 최종 limit으로 유지한다.
- 2026-05-30: 사용자 피드백에 따라 service가 전체 후보를 모두 불러와 점수를 계산하는 구조를 DB-side exact scoring으로 전환하기로 확정했다. PRD와 Task 2.9에 `RecommendationScoreSpec` 공유 산식, DB 최종 점수 계산 후 정렬/limit, candidate pre-limit 금지, service scoring 제거 요구사항을 반영했다. 기존 20/16/20 저장 상한은 동점자 랜덤 노출 여지를 위한 최종 저장 limit으로 유지하되, 최종 점수 계산 전 후보 제한 의미로는 사용하지 않도록 명확히 했다.
- 2026-05-31: Phase 2 Task 2.9 RED/GREEN을 진행했다. RED에서 `RecommendationScoreSpec`과 DB-scored snapshot 조회 계약 미구현으로 `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest` 컴파일이 실패했다. GREEN에서 `RecommendationScoreSpec`을 추가하고, AI/최근 응원/인기 커뮤니티 스냅샷 조회가 DB에서 최종 score와 `randomTieBreaker`를 계산한 뒤 `score desc, randomTieBreaker asc` 정렬과 최종 limit을 적용하도록 변경했다. `RecommendationSnapshotRefreshService`에서는 Kotlin-side score 재계산과 service-side limit을 제거했다.
- 2026-05-31: Phase 2 Task 2.9 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다.
- 2026-05-31: Phase 2 Task 2.9 리뷰 후속으로 `RecommendationScoreSpec`에 신규 부스트 일수 상수를 추가하고, `RecommendationScorePolicy`와 native SQL boost window가 같은 상수를 쓰도록 정리했다. 최근 응원/인기 커뮤니티 native SQL은 후보 행마다 donation/comment/follower/debut 집계를 반복하지 않도록 aggregate CTE 기반으로 변경했고, 데뷔일은 콘텐츠 공개일과 라이브 시작일을 `union all`한 이벤트 집계에서 `min(debut_at)`으로 계산해 DB-side exact scoring 의미를 유지했다. PRD/plan-task의 동일 점수 정렬 문구는 스냅샷 저장 `randomTieBreaker` 기준으로 맞췄다.
- 2026-05-31: 리뷰 후속 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. 중간에 H2 native SQL 타입 추론 문제와 ktlint line length 오류가 있었고, 데뷔일 CTE를 `union all` 이벤트 집계로 단순화하고 긴 `setParameter` 호출을 줄바꿈해 해결했다.
- 2026-05-31: Phase 3 Task 3.1 RED/GREEN을 진행했다. RED에서 `HomeRecommendationQueryServiceTest``DefaultHomeRecommendationQueryRepositoryTest`는 라이브/배너/최근 활동 크리에이터 조회 record, port 메서드, service 메서드, repository 쿼리 미구현으로 컴파일 실패했다. GREEN에서 라이브 최신순 20개, 활성 배너 orders 정렬/동일 orders 랜덤 tie-breaker/최대 20개, 크리에이터당 최신 활동 1개와 `LIVE`/`AUDIO`/`COMMUNITY`/`LIVE_REPLAY` 분류를 구현했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-31: Phase 3 Task 3.1 추가 검증으로 `./gradlew ktlintCheck``./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`를 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. 중간에 테스트 코드 line length ktlint 오류가 있었고, 긴 fixture 호출을 줄바꿈해 해결했다.
- 2026-05-31: Phase 3 Task 3.2 RED/GREEN을 진행했다. RED에서 `HomeRecommendationQueryServiceTest``DefaultHomeRecommendationQueryRepositoryTest`는 최근 데뷔 크리에이터/첫 오디오 콘텐츠 record, port 메서드, service 메서드, repository 쿼리 미구현으로 컴파일 실패했다. GREEN에서 실제 데뷔일 30일 이내 최근 데뷔 크리에이터 점수/랜덤 tie-breaker 정렬, 첫 3개 업로드 이내 활성 공개 오디오 콘텐츠 판정, 비활성 선행 콘텐츠 경계, 예약 공개 제외, `release_date` 최신성 점수 정렬을 구현했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-31: Phase 3 Task 3.3 RED/GREEN을 진행했다. RED에서 `HomeRecommendationQueryServiceTest``DefaultHomeRecommendationQueryRepositoryTest`는 AI 캐릭터/최근 응원/인기 커뮤니티 상세 record, port 메서드, service 메서드, repository 상세 조회 미구현으로 `compileTestKotlin`이 실패했다. GREEN에서 `RecommendationSnapshotPort.findLatestSnapshots(sectionType)` 기반 최신 스냅샷 조회, AI 캐릭터 10개/최근 응원 8명/인기 커뮤니티 10개 limit, 스냅샷 순서 보존, 누락 상세 필터링, 인기 커뮤니티 크리에이터 중복 제거, 커뮤니티 비노출 조건 필터링, AI 캐릭터 원작명 조건부 응답과 전체 AI 발화 수 조립을 구현했다. `./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.recommendation.application.RecommendationSnapshotRefreshServiceTest``./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했다.
- 2026-05-31: Phase 3에는 `CreatorContentViewHistory` 엔티티/리포지토리/저장 서비스가 아직 구현되어 있지 않아 장르 기반 크리에이터 추천 조회를 포함하지 않았다. 해당 산출물은 Phase 4 Task 4.1 범위이므로, 장르 기반 크리에이터 추천 조회는 조회 이력 저장 모델이 준비된 뒤 Phase 4 Task 4.2에서 별도 RED/GREEN으로 진행한다.
- 2026-05-31: Phase 3 리뷰 후속으로 인기 커뮤니티 추천이 상위 10개 스냅샷을 먼저 자른 뒤 크리에이터 중복을 제거해 10개 미만으로 내려갈 수 있는 문제를 보완했다. RED에서 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채우는 테스트가 실패했고, GREEN에서 인기 커뮤니티만 최신 스냅샷 후보 20개를 읽은 뒤 상세 조립/크리에이터 중복 제거 후 최종 10개를 반환하도록 수정했다. 후속 검증으로 `./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.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다.
- 2026-05-31: 사용자 피드백에 따라 신규 엔티티 테이블 생성 SQL은 Phase 7 완료 후 최종 엔티티 구조 기준으로 작성하도록 계획을 보강했다. `RecommendationSnapshot`, `CreatorContentViewHistory` 등 이번 작업에서 새로 생성된 엔티티의 운영 DB DDL을 `docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql` 산출물로 남기는 Task 7.4를 추가했다.
- 2026-05-31: PRD와 plan-task를 재대조해 큰 기능 흐름은 반영되어 있었으나 일부 PRD 세부 항목의 task 추적성이 약한 점을 확인했다. 라이브/활동/최근 데뷔/최근 응원/인기 커뮤니티/장르 크리에이터 노출 필드, `LINK` 배너 자체 활성 상태 기준, 활동 enum 영문 code 안정성, 한 응답 내 장르 크리에이터 중복 제거와 조회 시점별 재노출 허용, 추천 섹션별 클릭률 metric, Non-Goals 범위를 관련 task와 Coverage Check에 보강했다.
- 2026-05-31: Phase 2/3 재점검 후속으로 배너 대상 활성 조건과 스냅샷 데뷔일 계산의 빈 `channel_name` 라이브 제외 누락을 확인했다. RED에서 `DefaultHomeRecommendationQueryRepositoryTest`에 회귀 테스트를 추가했고 `shouldExcludeHomeBannersWithInactiveTargetsExceptLink`, `shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt`가 실패했다. GREEN에서 `findHomeBanners``EVENT`/`CREATOR`/`SERIES` 대상 활성 조건을 추가하고, 최근 응원/인기 커뮤니티 데뷔일 CTE에 `lr.channel_name <> ''` 조건을 추가했다. 리뷰 중 PRD의 인기 커뮤니티 성인 노출 조건은 항상 제외가 아니라 `MemberContentPreference.isAdultContentVisible == true` 회원에게 노출 허용임을 재확인해, 스냅샷 산정은 성인 게시글도 후보로 유지하고 상세 조회에서 `includeAdultCommunities`로 필터링하도록 수정했다. 추가 코드 리뷰에서 기존 홈 배너가 `tabId = 1`일 때 `tab is null`만 조회하는 계약을 확인해, `findHomeBanners``cb.tab_id is null` 조건과 탭 전용 배너 제외 회귀 테스트를 보강했다. 후속 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./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.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./gradlew ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 통과했다. 병렬 Gradle test 실행 중 XML 결과 파일 쓰기 충돌로 한 번 실패했으나, 동일 명령 단독 재실행 시 성공해 테스트 assertion 실패가 아님을 확인했다.
- 2026-05-31: 사용자 피드백에 따라 여러 크리에이터 동시 팔로우에서 본인 크리에이터 id는 전체 실패 조건에서 제외하고, 이미 팔로우 중인 id와 동일하게 처리 제외 대상으로 보도록 PRD와 plan-task를 수정했다.
- 2026-06-01: 사용자 피드백에 따라 동시 팔로우 공개 응답은 성공/실패 여부만 제공하도록 단순화했다. 이미 팔로우 중인 id와 본인 id는 실패 사유로 보지 않고 서버 내부에서 제외하며, 테스트는 mock 없이 실제 Spring/JPA 흐름으로 검증하도록 조정한다.
- 2026-05-31: Phase 4 구현 중 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-01: Phase 6 Task 6.1~6.3을 진행했다. `HomeRecommendationResponse`/`HomeRecommendationPageResponse` API DTO와 `HomeRecommendationFacade`(섹션별 기본 limit 20/20/10/10/10/10/5x8/8/10 전달, 회원의 성인 노출 여부=`member.auth != null``memberId`를 장르/커뮤니티 조회 조건으로 전달, KST→UTC ISO 변환, cloud-front host 이미지 URL 조립)를 추가했다. `HomeRecommendationController`에 통합 조회 `GET /api/v2/home/recommendations`와 전체보기 5개(`/lives`, `/debut-creators`, `/first-audio-contents`, `/ai-characters`, `/communities`)를 추가했고 size 기본값 20/최대 50으로 정규화했다. `SecurityConfig`에 해당 GET endpoint 6개 `permitAll`을 추가해 비회원 접근을 허용했다. `HomeRecommendationControllerTest`에 통합 조회(비회원/회원), 페이징 응답 형식, size 상한 테스트를 추가했고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`가 12/12 통과했다. ktlint는 이 환경 셸 PATH에 Java가 없어 직접 실행하지 못했고 IDE 인스펙션으로 신규 파일 무경고를 확인했다(컨트롤러의 `@AuthenticationPrincipal` SpEL 문자열 경고는 기존 팔로우 endpoint와 동일한 false positive).
- 2026-06-01: Phase 6 Task 6.4 리뷰 보완을 진행했다. RED에서 `HomeRecommendationControllerTest`에 세부 전체보기 비회원 거부와 음수 `page` 보정 테스트를 추가했고 기존 구현은 `shouldRejectAnonymousSectionPages`, `shouldNormalizeNegativePageToZero` 2건 실패로 확인했다. GREEN에서 `SecurityConfig`는 통합 조회 `GET /api/v2/home/recommendations``permitAll`로 유지하고 전체보기 5개는 회원 인증 대상으로 변경했다. `HomeRecommendationController`는 전체보기 요청에서 인증 회원을 요구하고 `page < 0`을 0으로 보정하며, `HomeRecommendationFacade`는 성인 노출 여부를 `MemberContentPreferenceService.initializeDefaultPreference(member).isAdultContentVisible`와 기존 `isAdultVisibleByPolicy(...)`로 계산하도록 수정했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-01: Phase 7 Task 7.1 RED/GREEN을 진행했다. RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromGenreCreatorRecommendations`가 양방향 차단 크리에이터를 제외하지 못해 실패했고, GREEN에서 장르 추천 테마 후보/크리에이터 조회 native SQL에 `block_member` 양방향 활성 차단 제외 조건을 추가했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromGenreCreatorRecommendations``BUILD SUCCESSFUL`로 통과했다.
- 2026-06-01: Phase 7 Task 7.2 RED/GREEN을 진행했다. RED에서 홈 통합/전체보기 성공, 콘텐츠 조회 이력 저장/스킵, 추천 크리에이터 동시 팔로우 성공/실패, 일 스냅샷 갱신 성공, 콘텐츠 상세 조회 이력 기록 실패 관측 로그 테스트 9건이 로그 이벤트 키 미존재로 실패했다. GREEN에서 기존 프로젝트 관례대로 신규 metric dependency 없이 `LoggerFactory` 구조화 로그를 추가했고, 콘텐츠 상세 조회의 `CreatorContentViewHistoryService.recordView(...)` 실패는 응답 실패로 전파하지 않고 `memberId`, `contentId`, 원인을 로그로 남기도록 했다. 검증으로 Phase 7 대상 테스트 묶음 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest``BUILD SUCCESSFUL`로 통과했다. 추천 섹션별 클릭률은 별도 클릭 ingress가 없어 이번 범위에서는 응답/impression 관측 로그만 추가했다.
- 2026-06-01: Phase 7 리뷰 지적에 따라 홈 통합 조회와 라이브 전체보기 조회 실패 로그 테스트를 추가하고, `HomeRecommendationFacade`에서 실패 시 `home_recommendations_query_failure`, `home_recommendations_page_query_failure` 로그를 남긴 뒤 예외를 재전파하도록 보강했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLogHomeRecommendationFailure --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLogHomeRecommendationPageFailure``BUILD SUCCESSFUL`로 통과했다.
- 2026-06-01: Phase 7 재리뷰 지적에 따라 최근 데뷔/첫 오디오/AI 캐릭터 전체보기 실패도 `home_recommendations_page_query_failure` 로그를 남긴 뒤 예외를 재전파하도록 보강했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLogOtherHomeRecommendationPageFailures``BUILD SUCCESSFUL`로 통과했고, 이후 `./gradlew ktlintCheck``BUILD SUCCESSFUL in 16s`, `./gradlew test``BUILD SUCCESSFUL in 54s`로 통과했다.
- 2026-06-01: Phase 7 Task 7.4로 신규 엔티티 테이블 생성 SQL `docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql`을 작성했다. 최종 JPA 엔티티 기준으로 `recommendation_snapshot`, `creator_content_view_history` 두 신규 테이블만 포함했고, 기존 테이블 변경은 `alter-existing-tables.sql` 범위로 유지했다. 검증으로 `rg -n "CREATE TABLE|create table|recommendation_snapshot|creator_content_view_history" docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql``./gradlew tasks --all`이 모두 성공했다.
- 2026-06-01: Phase 7 Task 7.3 전체 검증을 순차 실행했다. `./gradlew ktlintCheck`는 처음에 신규 로그 호출의 긴 라인과 리뷰 보완 후 테스트 import 순서로 실패했고 줄바꿈/import 정리 후 통과했다. 최종 재리뷰 보완 후 `./gradlew ktlintCheck``BUILD SUCCESSFUL in 16s`, `./gradlew test``BUILD SUCCESSFUL in 54s`로 통과했고, `./gradlew tasks --all`은 앞선 Task 7.4 검증에서 `BUILD SUCCESSFUL in 1s`로 통과했다.
- 2026-06-01: Phase 7 Task 7.5~7.6 보완을 진행했다. 라이브/최근 활동/최근 데뷔/첫 오디오/최근 응원/인기 커뮤니티에 회원 `memberId` 조회 컨텍스트를 전달하고 양방향 활성 차단 관계를 제외하도록 수정했다. 커뮤니티 성인 노출, 본인인증 기반 성인 노출 조건, 팔로우 제외, 비활성 회원 제외 회귀 테스트 이름을 명시적으로 유지하고, 조회 이력/동시 팔로우/스냅샷 성공 로그는 트랜잭션 커밋 후 기록되도록 보완했다. 검증으로 Phase 7 대상 테스트 묶음, `./gradlew ktlintCheck`, `./gradlew test`가 모두 `BUILD SUCCESSFUL`로 통과했다.
- 2026-06-01: 리뷰 게이트 Context Mining에서 홈 배너 `CREATOR`/`SERIES` 대상의 차단 필터 누락을 발견해 Phase 7 Task 7.7로 보완했다. 홈 통합 조회의 배너 조회에도 `memberId`를 전달하고, 배너 repository 조회에서 `CREATOR`는 대상 크리에이터, `SERIES`는 시리즈 소유자 기준 양방향 활성 `block_member` 제외 조건을 적용했다. 회귀 테스트 `DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners``HomeRecommendationQueryServiceTest`를 추가/보강했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest``BUILD SUCCESSFUL`로 통과했다.
- 2026-06-06: 사용자 피드백에 따라 장르 기반 크리에이터 추천에서 조회자가 크리에이터인 경우 본인을 제외하도록 PRD와 Task 4.4를 보강했다. RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeRequesterOnlyGenreFromGenreCreatorRecommendations`, `shouldBackfillCreatorAfterExcludingRequesterFromGenreCreatorRecommendations`, `shouldReturnAvailableCreatorsAfterExcludingRequesterFromGenreCreatorRecommendations` 3건이 실패했고, GREEN에서 장르 후보 eligibility, fallback 후보 count, 실제 크리에이터 조회 native SQL에 `(:memberId is null or m.id <> :memberId)` 조건을 추가했다. service 경계에는 `HomeRecommendationQueryServiceTest.shouldReturnAvailableCreatorsWhenGenreCreatorCountIsUnderLimit`를 추가해 8명 미만이면 가능한 만큼 응답하는 정책을 고정했다. 검증으로 신규 RED 테스트 재실행, service 단일 테스트, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `./gradlew test`가 모두 `BUILD SUCCESSFUL`로 통과했다.
- 2026-06-08: 홈 추천 API DTO 패키지 경계를 정리했다. 기존 `HomeRecommendationResponse`, `HomeRecommendationPageResponse`, `FollowRecommendedCreatorsRequest` 3개 DTO를 `kr.co.vividnext.sodalive.v2.api.home.dto.recommendation` 하위로 이동하고, Controller/Facade 및 DTO 테스트 import를 갱신했다. 기존 추천 API DTO 이동은 홈 추천 API 문서 범위에만 기록하며, 크리에이터 랭킹 문서는 변경하지 않았다. 검증으로 후속 focused test와 compile/test를 실행한다.
- 2026-06-08: 홈 추천 기능 본체 패키지를 단수 동사형 `recommend`에서 명사형 `recommendation` 기준인 `kr.co.vividnext.sodalive.v2.recommendation`으로 변경했다. `src/main`/`src/test` 디렉터리, Kotlin package/import, 문서의 파일 경로와 Gradle `--tests` 필터를 새 패키지명으로 맞췄다. `/api/v2/home/recommendations`, `v2.api.home`, `v2.api.home.dto.recommendation`, 클래스명과 API 스키마는 변경하지 않았다. 검증으로 stale reference 검색, `ktlintCheck`, 추천 패키지 테스트, 홈 API 테스트, 전체 테스트를 실행한다.

View File

@@ -1,305 +0,0 @@
# PRD: 메인 홈 추천 API
## 1. Overview
메인 홈에서 여러 추천 섹션을 한 번에 조회하고, 각 섹션의 전체보기/페이징 조회와 일부 사용자 액션을 지원하는 v2 API를 제공한다.
---
## 2. Problem
- 기존 홈/콘텐츠/라이브/AI 캐릭터/커뮤니티/후원 도메인의 데이터가 여러 화면과 API에 흩어져 있어 신규 메인 홈 구성을 한 API 계약으로 제공하기 어렵다.
- 추천 섹션별 정렬 기준, 점수 산식, 갱신 주기, 노출 필드가 서로 달라 구현 전 명확한 요구사항 문서가 필요하다.
- 일부 섹션은 일 단위 집계 스냅샷이 필요하고, 일부 섹션은 실시간성 또는 랜덤성이 필요하므로 조회 API와 집계 작업의 책임을 분리해야 한다.
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용해야 하므로 신규 API/서비스/조회 로직의 패키지 경계를 사전에 확정해야 한다.
---
## 3. Goals
- 메인 홈 추천 섹션을 v2 패키지 하위 신규 코드로 제공한다.
- 홈 첫 화면에서 필요한 섹션별 기본 개수를 조회할 수 있다.
- 전체보기 요구가 있는 섹션은 별도 리스트 API로 페이징 조회할 수 있다.
- 홈 배너는 기존 콘텐츠 홈 배너 데이터를 재활용한다.
- 점수 기반 섹션은 요구된 산식, 기간, 신규 부스트를 반영한다.
- 일 1회 갱신 섹션은 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 계산된 결과를 사용한다. 서버 스케줄러 cron은 `Asia/Seoul` zone의 KST 06:00으로 등록한다.
- 시간 응답은 UTC 기준으로 내려주고 앱에서 표시 포맷과 다국어를 처리한다.
- 장르 기반 크리에이터 추천을 위해 콘텐츠 조회 이력 기록 방식을 도입한다.
- 여러 크리에이터를 동시에 팔로우하는 API를 제공한다.
---
## 4. Non-Goals
- 기존 `kr.co.vividnext.sodalive.v2` 외부의 Controller, Service, Repository 구현 코드를 직접 재사용하지 않는다.
- 기존 홈 API, 콘텐츠 홈 API, 라이브 API, AI 캐릭터 API의 공개 스키마를 변경하지 않는다.
- 앱 다국어 문구를 서버에서 번역해 내려주지 않는다.
- 추천 산식의 머신러닝 모델화, 개인화 가중치 학습, A/B 테스트 플랫폼은 이번 범위에 포함하지 않는다.
- 관리자 화면 신규 개발은 포함하지 않는다.
- 추천 결과 수동 편집 기능은 포함하지 않는다.
---
## 5. Target Users
- 회원: 메인 홈에서 라이브, 신규 크리에이터, 콘텐츠, AI 캐릭터, 커뮤니티, 후원 기반 추천을 탐색하는 사용자
- 비회원: 인증 없이 조회 가능한 추천 섹션을 탐색하는 사용자
- 앱 클라이언트: 섹션별 노출 정보와 이동 대상 id를 받아 홈 UI와 전체보기 화면을 구성하는 클라이언트
---
## 6. User Stories
- 사용자는 메인 홈 진입 시 라이브 중인 방송 20개를 최신순으로 보고 싶다.
- 사용자는 홈 배너를 최대 20개까지 정해진 노출 순서대로 보고 싶다.
- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하고 싶다.
- 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다.
- 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다.
- 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다.
- 사용자는 내가 봤던 콘텐츠 장르 또는 랜덤 장르 기준으로 팔로우하지 않은 크리에이터를 추천받고 싶다.
- 사용자는 장르 추천에서 여러 크리에이터를 한 번에 팔로우하고 싶다.
- 사용자는 최근 응원이 많은 크리에이터를 순위로 보고 싶다.
- 사용자는 인기 커뮤니티 게시글을 크리에이터별로 중복 없이 보고 싶다.
---
## 7. Core Features
### Feature A. 메인 홈 통합 조회
#### Requirements
- 신규 홈 API는 `kr.co.vividnext.sodalive.v2` 하위 패키지에 작성한다.
- 메인 홈 통합 API URL prefix는 `/api/v2/home/recommendations`를 사용한다.
- 홈 첫 화면 응답은 섹션별 기본 limit만 포함한다.
- 섹션별 기본 노출 수는 다음과 같다.
- 라이브 중인 방송: 20개
- 홈 배너: 최대 20개
- 방금 활동한 크리에이터: 10개
- 최근 데뷔한 크리에이터: 10개
- 처음부터 함께 성장: 10개
- 크리에이터와 이야기를 나눠요: 10개
- 장르의 크리에이터: 장르 최대 5개, 장르별 크리에이터 8명
- 최근 응원이 많은 크리에이터: 8명
- 인기 커뮤니티: 10개
- 인증 회원이면 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부 등 사용자 조건을 반영한다.
- 인증 회원이 차단했거나 인증 회원을 차단한 크리에이터의 라이브, 콘텐츠, 커뮤니티, 크리에이터 추천 데이터는 노출하지 않는다.
- 비회원이면 회원 의존 조건을 제외한 기본 추천만 제공한다.
#### Edge Cases
- 섹션별 데이터가 부족하면 부족한 개수만 내려주고 전체 API는 성공 처리한다.
- 특정 섹션 집계 스냅샷이 없으면 해당 섹션은 빈 배열로 내려주고 장애가 전체 홈 조회를 막지 않도록 한다.
- 앱 이동에 필요한 id가 없는 섹션은 이동 대상 필드를 nullable로 둔다.
### Feature B. 라이브 중인 방송
#### Requirements
- 라이브 중인 방송을 최신순으로 조회한다.
- 홈 첫 화면은 20개를 내려준다.
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
- 노출 정보는 크리에이터 닉네임, 프로필 이미지, 라이브 번호를 포함한다.
- 기존 `LiveRoom`, `Member` 등 엔티티는 재활용할 수 있다.
#### Edge Cases
- 방송자가 비활성 회원이면 노출하지 않는다.
### Feature C. 홈 배너
#### Requirements
- 기존 콘텐츠 홈 배너를 재활용한다.
- `orders` 기준으로 최대 20개를 조회한다.
- 활성 배너만 노출한다.
- 동일 `orders` 값이 있으면 랜덤으로 정렬한다.
- 배너 대상 엔티티가 비활성 처리되었으면 노출하지 않는다. `EVENT`는 연결 이벤트가 활성인 경우만, `CREATOR`는 연결 크리에이터 회원이 활성인 경우만, `SERIES`는 연결 시리즈와 시리즈 소유 회원이 모두 활성인 경우만 노출한다. `LINK`는 별도 대상 엔티티가 없으므로 배너 자체 활성 상태만 적용한다.
- 기존 배너 응답에서 앱 이동에 필요한 필드는 유지한다.
### Feature D. 방금 활동한 크리에이터
#### Requirements
- 최신순 10개를 조회한다.
- 활동 타입은 enum으로 내려주며 앱에서 다국어 처리한다.
- 활동 타입 후보는 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`로 한다.
- 오디오는 콘텐츠를 업로드한 경우를 의미한다.
- 커뮤니티는 커뮤니티 게시글을 등록한 경우를 의미한다.
- 라이브는 라이브 진행 후 종료한 경우를 의미한다.
- 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다.
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다.
- 라이브 활동은 별도 이동 대상 id가 필요하지 않다.
- 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 커뮤니티 게시글 작성자 크리에이터 id를 내려준다.
- 크리에이터당 최신 활동 1개만 노출한다.
#### Edge Cases
- `다시듣기` 콘텐츠는 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류한다.
### Feature E. 최근 데뷔한 크리에이터
#### Requirements
- 홈 첫 화면은 추천순 10개를 조회한다.
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
- 데뷔일은 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
- 데뷔일 계산 로직은 기존 `ExplorerService.getCreatorDetail``debutDateTime` 계산 방식과 동일하게 맞춘다.
- 데뷔 후 30일 이내 크리에이터만 대상으로 한다.
- 추천 점수는 `((팔로우 증가량 * 0.35) + (콘텐츠 활동 점수 * 0.3) + (소통 점수 * 0.2)) * 신규 부스트`로 계산한다.
- 팔로우 증가량은 최근 7일간 신규 팔로우한 유저 수로 계산한다.
- 콘텐츠 활동 점수는 최근 30일간 업로드 콘텐츠 수와 라이브 횟수로 계산한다.
- 소통 점수는 최근 7일간 커뮤니티 게시글 수, 커뮤니티 게시글 댓글 수, 커뮤니티 게시글 좋아요 수, 콘텐츠 댓글 수, 콘텐츠 좋아요 수로 계산한다.
- 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2를 적용한다.
- 추천 점수가 동일하면 랜덤으로 정렬한다.
- 노출 정보는 크리에이터 프로필 이미지, 닉네임을 포함한다.
### Feature F. 처음부터 함께 성장
#### Requirements
- 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 조회한다.
- 신규 크리에이터는 데뷔일로부터 30일 이내인 크리에이터다.
- 홈 첫 화면은 최대 10개를 조회한다.
- 전체보기 API는 신규 크리에이터의 첫 번째 콘텐츠를 페이징 조회할 수 있어야 한다.
- 첫 번째 콘텐츠 판정은 해당 크리에이터의 오디오 콘텐츠를 `created_at`, `release_date` 기준으로 정렬해 3번째 이내에 업로드된 활성 콘텐츠인 경우로 한다.
- 앞선 비활성 콘텐츠가 2개 있고 3번째 콘텐츠가 활성이라면 첫 번째 콘텐츠로 인정한다.
- 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠는 첫 번째 콘텐츠로 인정하지 않는다.
- 최신성 점수 기준일은 `release_date`로 본다.
- 최신성 점수는 3일 이내 100, 7일 이내 80, 14일 이내 60, 21일 이내 40, 30일 이내 20으로 계산한다.
- 정렬은 최신성 점수 내림차순, 동점이면 랜덤으로 한다.
#### Edge Cases
- 예약 공개 콘텐츠는 공개 전에는 노출하지 않는다.
### Feature G. 크리에이터와 이야기를 나눠요
#### Requirements
- AI 캐릭터 리스트를 조회한다.
- 홈 첫 화면은 10개를 조회한다.
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
- 노출 정보는 캐릭터 이름, 캐릭터 소개, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
- 작품명은 오리지널 작품 캐릭터인 경우에만 내려준다.
- 1차 정렬은 AI 채팅 추천 점수 내림차순이다.
- 2차 정렬은 동일 점수인 경우 랜덤이다.
- AI 채팅 추천 점수는 이번 스프린트에서 `((0.45 * 최근 발생한 AI 채팅 수) + (0.35 * 최근 활성 사용자 수)) * 신규 부스트`로 계산한다.
- 최근 발생한 AI 채팅 수와 최근 활성 사용자 수는 최근 7일 데이터 기반으로 계산한다.
- 최근 발생한 AI 채팅 수는 AI 캐릭터가 발화한 채팅 메시지 수를 의미한다.
- 최근 활성 사용자 수는 최근 7일 안에 해당 AI 캐릭터와 1회 이상 채팅한 중복 없는 사용자 수를 의미한다.
- AI 캐릭터의 팔로우 증가량은 팔로우 대상/관계의 정확한 정의가 확정되지 않아 이번 스프린트 산식과 집계에서 제외한다. 추후 AI 캐릭터 팔로우 정의가 확정되면 별도 요구사항으로 재도입한다.
- 신규 부스트는 캐릭터 생성일 기준 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다.
- 점수는 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 1회 갱신한다. 스케줄러는 `Asia/Seoul` zone의 KST 06:00 기준으로 실행한다.
#### Edge Cases
- 비활성 또는 노출 제한 캐릭터는 제외한다.
### Feature H. 장르의 크리에이터
#### Requirements
- 사용자가 조회한 장르가 없으면 조회 가능한 장르 중 랜덤 5개를 선별한다.
- 사용자가 조회한 콘텐츠가 있으면 조회한 콘텐츠들의 장르 중 랜덤 5개를 선별한다.
- 조회 이력 기반 장르가 5개 미만이면 나머지 조회 가능한 장르 중 랜덤으로 채운다.
- 각 장르별로 해당 장르의 콘텐츠를 업로드한 크리에이터를 랜덤 8명씩 노출한다.
- 같은 크리에이터가 서로 다른 조회 시점의 여러 장르 섹션에 노출될 수는 있다.
- 한 번에 조회되는 5개 장르 안에서는 같은 크리에이터가 중복 노출되지 않아야 한다.
- 사용자가 팔로우한 크리에이터는 제외한다.
- 조회하는 사용자가 크리에이터이면 본인은 장르의 크리에이터 추천에서 제외한다.
- 성인 콘텐츠 장르는 `MemberContentPreference.isAdultContentVisible == true`인 회원에게만 노출한다.
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, id를 포함한다.
- 콘텐츠 조회 데이터는 콘텐츠 상세 진입 시점에 기록한다.
#### Edge Cases
- 조회하는 크리에이터 본인만 있는 장르는 후보에서 제외한다.
- 장르의 크리에이터 8명 중 조회자 본인이 포함되어 있으면 본인을 제외하고 다른 추천 가능한 크리에이터로 채운다.
- 본인을 제외한 뒤 대체 가능한 크리에이터가 없으면 남은 추천 가능한 크리에이터만 내려준다.
- 장르별 추천 가능한 크리에이터가 8명 미만이면 가능한 만큼만 내려준다.
### Feature I. 여러 크리에이터 동시 팔로우
#### Requirements
- 크리에이터 id 리스트를 받아 해당 id의 크리에이터 중 팔로우되어 있지 않은 크리에이터를 모두 팔로우한다.
- 요청의 `creatorIds`는 1개 이상 50개 이하만 허용한다.
- 이미 팔로우한 크리에이터와 본인 크리에이터 id는 실패 사유로 보지 않고 중복 없이 유지한다.
- 과거 언팔로우로 비활성화된 팔로우 이력이 있으면 신규 이력을 만들지 않고 기존 이력을 다시 활성화한다.
- 클라이언트 응답은 성공/실패 여부만 제공하고, 신규 팔로우/처리 제외 id 목록은 공개 응답에 포함하지 않는다.
- 요청 id 중 일부라도 존재하지 않는 id, 크리에이터가 아닌 회원 id 등 유효하지 않은 값이 포함되면 전체 실패로 처리한다.
#### Edge Cases
- 이미 팔로우 중인 크리에이터 id와 본인 크리에이터 id는 유효한 id로 보며 실패 사유로 처리하지 않고 서버 내부에서 제외한다.
- 동일 회원과 동일 크리에이터의 팔로우 이력은 중복 저장하지 않는다.
### Feature J. 최근 응원이 많은 크리에이터
#### Requirements
- 응원 점수가 높은 크리에이터 8명을 조회한다.
- 노출 정보는 크리에이터 프로필 이미지, 크리에이터 닉네임을 포함한다.
- 응원 점수는 `((0.6 * 후원 금액) + (0.3 * 팬 Talk 수) + (0.1 * 후원 수)) * 신규 부스트`로 계산한다.
- 후원 금액과 후원 수는 `CanUsage.CHANNEL_DONATION` 데이터만 대상으로 계산한다.
- 팬톡은 기존 `CreatorCheers`를 의미한다.
- 점수는 최근 7일 데이터를 기반으로 계산한다.
- 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다.
- 신규 부스트의 데뷔일은 `Member.createdAt`이 아니라 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
- 점수는 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 1회 갱신한다. 스케줄러는 `Asia/Seoul` zone의 KST 06:00 기준으로 실행한다.
### Feature K. 인기 커뮤니티
#### Requirements
- 홈 첫 화면은 10개를 조회한다.
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 커뮤니티 내용을 포함한다.
- 크리에이터당 1개의 커뮤니티 게시글만 노출한다.
- 비공개 커뮤니티 게시글은 제외한다.
- 유료 커뮤니티 게시글은 제외한다.
- 핀으로 고정한 커뮤니티 게시글은 제외한다.
- 성인 속성을 가진 커뮤니티 게시글은 기존 노출 조건과 동일하게 `MemberContentPreference.isAdultContentVisible == true`인 회원에게만 노출한다.
- 동일 점수의 경우 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출한다.
- 커뮤니티 인기 점수는 `((0.5 * 좋아요 수) + (0.5 * 댓글 수) + (0.1 * 팔로우 수)) * 신규 부스트`로 계산한다.
- 점수는 최근 7일 데이터를 기반으로 계산한다.
- 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다.
- 신규 부스트의 데뷔일은 `Member.createdAt`이 아니라 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
- 점수는 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 1회 갱신한다. 스케줄러는 `Asia/Seoul` zone의 KST 06:00 기준으로 실행한다.
#### Edge Cases
- 댓글 불가 게시글도 댓글 수 0으로 점수 계산 대상에 포함한다.
---
## 8. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 신규 구현 코드는 `kr.co.vividnext.sodalive.v2` 하위에 둔다.
- 신규 코드는 클라이언트 공개 API 조립 계층과 재사용 가능한 추천 기능 계층을 분리한다.
- 클라이언트에 공개되는 메인 홈 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home` 하위에 두고, 홈 추천 API DTO는 `kr.co.vividnext.sodalive.v2.api.home.dto.recommendation` 하위에 둔다.
- 홈 API 외부에서도 재사용 가능한 추천, 점수 계산, 노출 정책, 스냅샷, 캐시, 콘텐츠 조회 이력 기능은 `kr.co.vividnext.sodalive.v2.recommendation` 하위에 둔다.
- 의존 방향은 `v2.api.home`에서 `v2.recommendation`를 호출하는 방향으로만 둔다. `v2.recommendation``v2.api.home`의 DTO나 application service에 의존하지 않는다.
- `v2.api.home``v2.recommendation` 모두 필요한 범위에서 경량 헥사고날 아키텍처를 적용하고, 기본 하위 패키지는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다.
- Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다.
- `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다.
- 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다.
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다.
- 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다.
- 조회 구현은 복잡도에 맞춰 선택한다. 단순 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를 촘촘히 검증한다.
- 홈 추천 조회에는 공통 차단 필터를 적용해 내가 차단했거나 나를 차단한 크리에이터의 데이터를 제외한다.
- 커뮤니티 게시글 조회에는 비공개 제외, 유료 글 제외, 핀 고정 글 제외, 성인 노출 조건(`MemberContentPreference.isAdultContentVisible`)을 공통 적용한다.
- 일 1회 갱신 섹션은 조회 시점마다 무거운 집계를 하지 않도록 집계 테이블 또는 스냅샷 엔티티를 신규로 둔다.
- 일 1회 스냅샷 갱신 스케줄러는 다중 서버 인스턴스에서 동시에 실행되더라도 클러스터 전체에서 한 인스턴스만 실제 갱신을 수행해야 한다.
- 클러스터 단일 실행은 신규 DB 테이블을 추가하지 않고, 기존 프로젝트에 설정된 Redisson 기반 분산 lock을 우선 사용한다.
- 추천 스냅샷 lock key는 `lock:recommendation-snapshot-refresh`를 사용하며, lock 획득 실패 인스턴스는 스냅샷 갱신을 정상 skip한다.
- 랜덤 정렬이 필요한 섹션은 성능을 고려해 후보군 축소 후 랜덤화하거나 스냅샷 생성 시 랜덤 tie-breaker 값을 저장한다. 단, 일 1회 점수 기반 스냅샷은 아래 candidate pre-limit 금지 규칙을 따른다.
- 일 1회 갱신 스냅샷은 후보를 application/service 메모리로 모두 불러와 점수를 계산하지 않는다. DB 조회에서 모든 적격 후보의 최종 점수와 랜덤 tie-breaker를 계산한 뒤 `score desc, randomTieBreaker asc` 기준으로 정렬하고, 그 이후에만 최종 저장 개수 limit을 적용한다.
- 최종 점수 계산 전 candidate pre-limit, 랜덤 후보 컷오프, 임의 2배수 선제 제한은 정확한 top 후보를 누락할 수 있으므로 금지한다.
- DB score expression과 Kotlin `RecommendationScorePolicy`는 동일한 `RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유해 산식 drift를 방지한다.
- 스냅샷 최종 저장 개수는 동점자 랜덤 노출 여지를 확보하기 위해 홈 첫 화면 노출 수의 2배인 AI 캐릭터 최대 20개, 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개로 한다. 단, 이 숫자는 최종 점수 계산과 동점 랜덤 정렬 이후 적용하는 저장 limit이며 candidate pre-limit가 아니다.
- 공개 시간은 UTC 기준 응답을 원칙으로 한다.
- 응답 DTO의 enum 값은 앱 다국어 처리를 위해 안정적인 영문 code로 내려준다.
- 기존 API 스키마는 변경하지 않고 신규 v2 endpoint로 분리한다.
---
## 9. Metrics
- 메인 홈 API 성공률과 응답 시간
- 섹션별 빈 응답 비율
- 전체보기 API 조회 수
- 추천 섹션별 클릭률
- 장르 추천 크리에이터 동시 팔로우 요청 수와 성공 수
- 콘텐츠 조회 이력 기록 성공률
- 일 배치 집계 성공/실패 수
- 집계 스냅샷 생성 소요 시간
---
## 10. Decisions
- 실제 데뷔일을 계산할 첫 공개 콘텐츠와 첫 라이브가 모두 없는 크리에이터는 Phase 2 스냅샷 후보에서 제외한다.
- Phase 2 점수 기반 스냅샷은 DB-side exact scoring으로 계산한다. service는 기준 시각 계산과 snapshot replace만 담당하고, 최종 점수 산식/정렬/limit은 repository query에서 처리한다.
- 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. native SQL은 SQL 고급 기능이 필요한 추천/랭킹/스냅샷 산정에 한정하고, 단순 상세 조회와 대상 활성 조건은 가능하면 QueryDSL/JPA 조건으로 표현한다.
---
## 11. Related Documents
- `docs/prd/sample-prd.md`
- `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md`

View File

@@ -1,61 +0,0 @@
-- MySQL 크리에이터 랭킹 스냅샷 테이블
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
-- 같은 기간 재생성 시 삭제 기준:
-- delete from creator_ranking_snapshot
-- where aggregation_start_at_utc = :aggregationStartAtUtc
-- and aggregation_end_at_utc = :aggregationEndAtUtc;
create table creator_ranking_snapshot (
id bigint not null auto_increment comment '크리에이터 랭킹 스냅샷 ID',
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
creator_id bigint not null comment '크리에이터 회원 ID(member.id)',
nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임',
profile_image_url varchar(500) null comment '스냅샷 생성 시점 크리에이터 프로필 이미지 URL',
final_score double not null comment '최종 랭킹 점수',
content_live_score double not null comment '콘텐츠/라이브 카테고리 점수',
engagement_score double not null comment '참여 반응 카테고리 점수',
support_score double not null comment '응원 카테고리 점수',
fan_loyalty_score double not null comment '팬 충성도 카테고리 점수',
live_can_amount bigint not null comment '라이브 계열 사용 캔 합계',
content_purchase_can_amount bigint not null comment '콘텐츠 구매 사용 캔 합계',
content_like_count bigint not null comment '콘텐츠 좋아요 수',
content_comment_count bigint not null comment '콘텐츠 댓글 및 대댓글 수',
channel_donation_can_amount bigint not null comment '채널 후원 사용 캔 합계',
channel_donation_count bigint not null comment '채널 후원 건수',
fan_talk_count bigint not null comment '최상위 팬 Talk 수',
final_follower_count bigint not null comment '집계 종료 시점 활성 팔로우 수',
follow_increase bigint not null comment '집계 기간 팔로우 증가 수',
created_at timestamp not null default current_timestamp comment '생성 시각',
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
primary key (id)
) engine=InnoDB default charset=utf8mb4 comment='크리에이터 랭킹 주간 스냅샷';
create index idx_creator_ranking_snapshot_period_score
on creator_ranking_snapshot (aggregation_end_at_utc, final_score desc);
create index idx_creator_ranking_snapshot_replace_period
on creator_ranking_snapshot (aggregation_start_at_utc, aggregation_end_at_utc);
create index idx_creator_ranking_snapshot_period_creator
on creator_ranking_snapshot (aggregation_start_at_utc, aggregation_end_at_utc, creator_id);
create table creator_ranking_snapshot_job (
id bigint not null auto_increment comment '크리에이터 랭킹 스냅샷 생성 job ID',
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
trigger_type varchar(20) not null comment '실행 트리거(SCHEDULED, MANUAL)',
status varchar(20) not null comment 'job 상태(PENDING, PROCESSING, DONE, FAILED)',
last_error text null comment '마지막 실패 사유',
processing_started_at timestamp null comment '처리 시작 시각',
processed_at timestamp null comment '처리 완료 시각',
created_at timestamp not null default current_timestamp comment '생성 시각',
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
primary key (id)
) engine=InnoDB default charset=utf8mb4 comment='크리에이터 랭킹 스냅샷 생성 job 이력';
create index idx_creator_ranking_snapshot_job_period_status
on creator_ranking_snapshot_job (aggregation_start_at_utc, aggregation_end_at_utc, status);
create index idx_creator_ranking_snapshot_job_status_created_at
on creator_ranking_snapshot_job (status, created_at);

View File

@@ -1,579 +0,0 @@
# 크리에이터 랭킹 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 홈 내부 랭킹 탭에서 `GET /api/v2/home/rankings/creators`로 KST 기준 지난 주 크리에이터 랭킹 상위 20명을 조회한다.
**Architecture:** 공개 endpoint는 home 하위 URL을 사용하고, 클라이언트 API 표면(Controller, API 조합 Facade, DTO)은 기존 홈 API 관례에 맞춰 `kr.co.vividnext.sodalive.v2.api.home` 하위에 둔다. 랭킹 기능 본체(domain/application/port/persistence/scheduler)는 추천 기능과 분리된 `kr.co.vividnext.sodalive.v2.ranking` 하위에 둔다. 주간 스냅샷 생성 작업이 KST 기간을 UTC DB 조회 조건으로 변환해 원천 데이터를 집계하고, 조회 API는 최신 완료 주차 스냅샷을 우선 읽어 응답을 조립한다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL 또는 native SQL, JUnit 5, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/home/rankings/creators`
- 랭킹 기능 본체 패키지: `kr.co.vividnext.sodalive.v2.ranking`
- 홈 공개 API 조립 패키지: `kr.co.vividnext.sodalive.v2.api.home`
- 집계 기간: 조회/스냅샷 생성 시점 기준 KST 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만
- DB 조회 기간: KST 집계 기간을 UTC 기준 `LocalDateTime` 또는 프로젝트 표준 시간 타입으로 변환한 기간
- 스냅샷 생성 스케줄 후보: 매주 월요일 KST 07:30, `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")`
- 다중 서버 인스턴스에서 스냅샷 스케줄러가 중복 실행되지 않도록 기존 Redisson 기반 분산 lock을 사용한다.
- 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 스냅샷 테이블이 완전히 비어 있는 cold-start fallback 성공 시 조회 API는 fallback 응답을 반환하고, 같은 집계 기간의 스냅샷 생성은 조회 서비스가 직접 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임한다.
- cold-start fallback 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 보강책이며, 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용한다.
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다.
- 관리자는 날짜 범위를 직접 선택해 스냅샷 생성 job을 만들 수 있으며, 실패한 job은 관리자 전용 재시도 API로 대기 상태로 되돌려 재처리할 수 있어야 한다.
- 스냅샷은 현재 누적 저장하며, 보존 기간/정리 배치는 운영 데이터 규모 확인 후 별도 결정한다.
- API 응답은 `showRankChange`, `items[].rank`, `items[].rankChange`, `items[].isNew`, `items[].creatorId`, `items[].nickname`, `items[].profileImageUrl`만 포함한다.
- API 응답에는 집계 기간 날짜와 `finalScore`를 포함하지 않는다.
- raw value 방식으로 계산하며 0~100 정규화는 하지 않는다.
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
- 동점자는 조회 시 랜덤 정렬로 상위 20명을 추출하고, 별도 `randomTieBreaker`는 저장하지 않는다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange=false`, `rankChange=null`, `isNew=false`로 응답한다.
- 비활성 및 탈퇴 크리에이터는 랭킹에 노출하지 않는다.
- 차단 관계가 있으면 row는 유지하되 `creatorId=0`, `nickname=""`, `profileImageUrl=기본 이미지 URL`로 마스킹한다.
- 신규 팔로우 수는 `CreatorFollowing.createdAt` 기준, 언팔로우 수는 `CreatorFollowing.isActive == false``CreatorFollowing.updatedAt` 기준으로 계산한다.
---
## 1. 파일 구조 계획
### 신규 ranking domain/application
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingItem.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt`
### 신규 홈 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt`
### 신규 scheduler / persistence
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingBlockRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJobRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
### 신규 관리자 API
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobRequest.kt`
### 문서 산출물
- Create: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
### 테스트
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicyTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
---
### Phase 1: 기간/점수 도메인 정책
- [x] **Task 1.1: KST 주간 기간 산출과 UTC 조회 기간 변환 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicyTest.kt`
- RED: 월요일 KST 기준 지난 주 기간, 월/연도 경계, 서버 timezone UTC와 무관한 기간 산출, KST 2026-06-01 00:00:00~2026-06-08 00:00:00이 UTC 2026-05-31 15:00:00~2026-06-07 15:00:00으로 변환되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest`
- GREEN: `CreatorRankingPeriodPolicy.resolveLastCompletedWeek(now: ZonedDateTime)``toUtcRange(period)`를 구현한다.
- REFACTOR: 기간 경계는 종료 미만(`< end`) 조건으로 사용할 수 있도록 `startInclusiveUtc`, `endExclusiveUtc` 명칭을 유지한다.
- 기대 결과: KST 기준 기간 산출과 UTC 변환이 테스트로 고정된다.
- [x] **Task 1.2: raw value 기반 점수 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt`
- RED: 콘텐츠/라이브 점수, 참여 반응 점수, 응원 점수, 팬 충성도 점수, 최종 점수 산식 테스트를 작성한다. 0~100 정규화 없이 캔/건수/팔로우 원천값이 그대로 가중합되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`
- GREEN: 가중치 상수와 `calculateContentLiveScore`, `calculateEngagementScore`, `calculateSupportScore`, `calculateFanLoyaltyScore`, `calculateFinalScore`를 구현한다.
- REFACTOR: 소수 계산 비교는 `assertEquals(expected, actual, 0.0001)` 기준을 사용한다.
- 기대 결과: PRD의 raw value 정책과 음수 팔로우 증가 반영이 테스트로 고정된다.
- [x] **Task 1.3: 스냅샷 후보/응답 내부 모델 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingItem.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: `rankChange` 양수/음수/null과 `isNew`를 담을 수 있는 내부 item 모델이 없으면 컴파일 실패하는 테스트 골격을 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: 스냅샷 후보와 조회 item 내부 모델을 작성한다.
- REFACTOR: API DTO와 domain model을 분리해 Controller가 persistence entity에 의존하지 않도록 한다.
- 기대 결과: 이후 service/controller task가 같은 타입을 재사용할 수 있다.
### Phase 2: 스냅샷 저장소와 DDL
- [x] **Task 2.1: 랭킹 스냅샷 엔티티/리포지토리 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- RED: 같은 집계 기간의 스냅샷 replace, 최신 완료 주차 조회, 직전 완료 주차 조회, 20위 경계 동점 후보 저장 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`
- GREEN: 스냅샷 엔티티에 `aggregationStartAtUtc`, `aggregationEndAtUtc`, `creatorId`, `finalScore`, 카테고리별 점수, 원천 지표, `createdAt`을 저장한다. 저장 전 같은 기간 row를 삭제하고 새 후보를 저장한다.
- REFACTOR: 스냅샷 조회 port는 domain model만 반환하고 JPA entity를 application 계층으로 노출하지 않는다.
- 기대 결과: 같은 기간 재생성 시 중복 노출되지 않고 최신/직전 주차를 구분해 조회할 수 있다.
- [x] **Task 2.2: 운영 DB 반영용 스냅샷 DDL 문서 작성**
- Files:
- Create: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- RED: 테스트 작성 예외. `TDD 예외 사유`: SQL 운영 반영 문서 작성 task로, 실행 대상 DB가 현재 workspace에 없다.
- 대체 검증 방법: `rg -n "creator_ranking_snapshot|aggregation_start_at_utc|creator_id|final_score" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- GREEN: `creator_ranking_snapshot` 테이블 생성 SQL, 기간/점수 조회용 index, 같은 기간 재생성 시 삭제 기준을 문서에 작성한다.
- REFACTOR: 컬럼명은 JPA entity와 1:1로 대응하도록 정리한다.
- 기대 결과: 운영 배포 전 DB 테이블 생성 SQL을 검토할 수 있다.
### Phase 3: 원천 지표 집계 repository
- [x] **Task 3.1: 콘텐츠/라이브 캔 집계 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: `CanUsage.DONATION`, `LIVE`, `SPIN_ROULETTE`는 라이브 계열 캔으로, `ORDER_CONTENT`는 콘텐츠 구매 캔으로 집계되고 환불 row가 제외되는 repository 통합 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: KST에서 변환한 UTC 기간으로 `UseCan` 계열 데이터를 조회하고 크리에이터별 캔 합계를 반환한다.
- REFACTOR: can usage 조건은 private 함수 또는 enum set으로 분리해 산식과 조회 조건이 섞이지 않도록 한다.
- 기대 결과: 콘텐츠/라이브 카테고리의 원천 지표가 정확히 집계된다.
- [x] **Task 3.2: 콘텐츠 좋아요/댓글 반응 집계 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: 활성 콘텐츠 좋아요 수, 댓글+대댓글 수, 크리에이터 본인 댓글/대댓글 제외, 비활성/삭제 정책 제외를 검증하는 repository 통합 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: `AudioContentLike`, `AudioContentComment`, `AudioContent`를 기준으로 크리에이터별 좋아요/댓글 원천 지표를 반환한다.
- REFACTOR: 댓글 작성자가 콘텐츠 소유 크리에이터와 같은 경우 제외하는 조건을 테스트 fixture 이름에 드러나게 정리한다.
- 기대 결과: 참여 반응 점수 입력값이 PRD 조건과 일치한다.
- [x] **Task 3.3: 채널 후원/팬 Talk 응원 집계 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: `CanUsage.CHANNEL_DONATION` 캔 합계와 건수, 환불 제외, `CreatorCheers` 최상위 row만 팬 Talk로 집계하고 답글은 제외하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: 채널 후원 원천 지표와 팬 Talk 원천 지표를 크리에이터별로 반환한다.
- REFACTOR: 팬 Talk 답글 제외 조건은 `parent is null` 또는 기존 엔티티 구조에 맞는 조건으로 명확히 둔다.
- 기대 결과: 응원 점수 입력값이 캔/건수/최상위 팬 Talk 기준으로 집계된다.
- [x] **Task 3.4: 팔로우 최종 수/증가 수 집계 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: 최종 팔로우 수는 기간 종료 시점 활성 row, 신규 팔로우 수는 `createdAt` 기간 내, 언팔로우 수는 `isActive=false``updatedAt` 기간 내, 기간 내 재팔로우는 신규/언팔로우 이벤트로 별도 복원하지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: `CreatorFollowing` 기준 최종 팔로우 수와 팔로우 증가 수를 반환한다.
- REFACTOR: 현재 row만으로 계산하는 정책 한계를 테스트명과 주석 한 줄로 남긴다.
- 기대 결과: 팬 충성도 점수 입력값이 PRD의 `createdAt`/`updatedAt` 정책과 일치한다.
- [x] **Task 3.5: 랭킹 후보 통합 집계와 비활성/탈퇴 제외 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: 여러 원천 지표를 크리에이터별로 합쳐 후보를 만들고, 비활성/탈퇴 크리에이터와 최종 점수 1점 미만 후보가 제외되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: 원천 지표 aggregate를 크리에이터 id 기준으로 합쳐 `CreatorRankingSnapshotCandidate`를 반환한다.
- REFACTOR: 복잡한 집계가 QueryDSL로 과도해지면 native SQL을 사용하되, 테스트로 H2 호환성을 고정한다.
- 기대 결과: 스냅샷 생성 서비스가 별도 원천 조회를 여러 번 조합하지 않고 후보 목록을 받을 수 있다.
### Phase 4: 스냅샷 생성 서비스와 스케줄러
- [x] **Task 4.1: 주간 스냅샷 생성 서비스 구현**
- Files:
- Create: `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: KST 기간 산출, UTC 조회 기간 전달, raw value 점수 계산, 20위 점수 경계 동점 후보 전체 저장, 같은 기간 replace를 검증하는 service 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
- GREEN: aggregation port에서 후보를 조회하고 score policy로 점수를 계산한 뒤 저장 대상 후보만 snapshot port에 저장한다.
- REFACTOR: service는 계산 흐름만 담당하고 DB 조회 조건/저장 구현은 port 뒤로 숨긴다.
- 기대 결과: 스냅샷 저장 대상이 “20위 초과 점수 + 20위 동점 전체” 규칙을 만족한다.
- [x] **Task 4.2: 매주 월요일 07:30 KST 스케줄러 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- RED: scheduler method에 `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")`가 선언되어 있는지 reflection 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
- GREEN: 스케줄러가 `CreatorRankingSnapshotRefreshService.refreshLastCompletedWeek()`를 호출하도록 구현한다.
- REFACTOR: 스케줄러에는 기간/점수/DB 로직을 두지 않는다.
- 기대 결과: 주간 스냅샷 생성 트리거가 KST 기준으로 고정된다.
- [x] **Task 4.3: 주간 스냅샷 스케줄러 Redisson lock 적용**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- RED: Redisson lock 획득 성공 시 `CreatorRankingSnapshotRefreshService.refreshLastCompletedWeek()`를 1회 호출하고, 획득 실패 시 호출하지 않는 테스트를 작성한다. lock key가 `lock:creator-ranking-snapshot-refresh`인지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
- GREEN: 기존 `RedissonClient` bean을 스케줄러에 주입하고 `tryLock`으로 lock을 획득한 인스턴스만 refresh service를 호출한다. lock 획득 실패는 정상 skip으로 처리한다.
- REFACTOR: DB 기반 scheduler lock 테이블은 추가하지 않고, 기존 `AudioContentReleaseScheduledTask`의 Redisson lock 패턴을 참고하되 스케줄러에는 lock 획득/해제와 service 호출만 둔다.
- 기대 결과: 여러 서버 인스턴스에서 같은 cron이 동시에 실행돼도 클러스터 전체에서 한 인스턴스만 주간 랭킹 스냅샷을 생성한다.
### Phase 5: 조회 서비스, 순위 변화, 차단 마스킹
- [x] **Task 5.1: 최신/직전 스냅샷 기반 조회 서비스 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: 최신 완료 주차 스냅샷 없음 빈 결과, 직전 주차 없음 `showRankChange=false`, 직전 주차 있음 `rankChange` 양수/음수/null 및 `isNew` 계산 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: 최신 스냅샷 후보를 최종 점수 내림차순과 동점 랜덤 정렬로 최대 20명 선정하고, 직전 스냅샷 순위와 비교해 순위 변화를 계산한다.
- REFACTOR: 동점 랜덤으로 인해 같은 동점 구간의 순위 변화가 조회마다 달라질 수 있음을 테스트에서 허용 범위로 표현한다.
- 기대 결과: 홈 API Facade가 사용할 `showRankChange`와 item 목록이 ranking application service에서 완성된다.
- [x] **Task 5.2: 차단 관계 마스킹 port 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingBlockRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: 조회자와 랭킹 크리에이터 사이에 차단 관계가 있으면 row는 유지되고 `creatorId=0`, `nickname=""`, `profileImageUrl=기본 이미지 URL`로 마스킹되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: block port로 차단 대상 creator id를 조회하고, service에서 응답 item을 마스킹한다.
- REFACTOR: 기본 이미지 URL은 기존 프로젝트 상수/설정이 있으면 재사용하고, 없으면 ranking service 내부 상수로 분리한다.
- 기대 결과: 차단 관계가 있어도 순위 row 수는 유지되고 개인 식별 정보만 가려진다.
### Phase 6: 홈 API endpoint, Facade, DTO
- [x] **Task 6.1: 랭킹 조회 DTO, 홈 API Facade, Controller 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
- RED: `GET /api/v2/home/rankings/creators``showRankChange`, `items[].rank`, `rankChange`, `isNew`, `creatorId`, `nickname`, `profileImageUrl`만 반환하고 날짜와 `finalScore`를 반환하지 않는 controller 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
- GREEN: controller, API Facade, response DTO를 구현하고 Facade가 `CreatorRankingQueryService`를 호출해 홈 API 응답으로 변환한다.
- REFACTOR: URL과 클라이언트 API 표면은 `v2.api.home` 하위에 두고, 랭킹 DTO는 `v2.api.home.dto.ranking` 하위에 둔다. 랭킹 계산/조회 본체는 `v2.ranking`에 유지한다.
- 기대 결과: 클라이언트 홈 랭킹 탭에서 사용할 공개 API 계약이 테스트로 고정된다.
- [x] **Task 6.2: 인증/비인증 조회와 차단 마스킹 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
- RED: 비회원 조회는 기본 랭킹을 반환하고, 인증 회원 조회는 차단 관계 마스킹을 적용하는 controller 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
- GREEN: 기존 인증 주입 패턴을 확인해 member nullable 흐름을 service에 전달한다.
- REFACTOR: 기존 API 응답 wrapper 관례와 상태 코드를 맞춘다.
- 기대 결과: 인증 여부에 따라 차단 마스킹만 달라지고 endpoint 계약은 동일하다.
### Phase 7: 관측/문서/회귀 검증
- [x] **Task 7.1: 스냅샷 생성/조회 로그 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: 스냅샷 생성 성공/실패, 후보 수, 저장 수, 조회 성공/실패 로그가 남는지 output capture 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: 기존 프로젝트 관례대로 `LoggerFactory` 기반 구조화 로그를 추가한다.
- REFACTOR: 로그에 개인정보를 직접 남기지 않고 creator id/count/period만 남긴다.
- 기대 결과: PRD metrics 확인에 필요한 최소 로그가 남는다.
- [x] **Task 7.2: 전체 ranking 테스트와 포맷 검증**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/**`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/**`
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- RED: 테스트 작성 예외. `TDD 예외 사유`: 구현 완료 후 회귀 검증 task다.
- 대체 검증 방법:
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'`
- `./gradlew ktlintCheck`
- `./gradlew test`
- GREEN: 실패하는 테스트가 있으면 해당 phase task로 돌아가 수정하고, 모든 명령을 통과시킨다.
- REFACTOR: plan-task 하단 검증 기록에 실행 명령, 목적, 결과를 누적한다.
- 기대 결과: ranking 기능 본체와 홈 API 조립 계층 테스트, 포맷, 전체 회귀 테스트가 통과한다.
### Phase 8: 스냅샷 job 이력과 스케줄 기록
- [x] **Task 8.1: 스냅샷 job 이력 모델/DDL 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJobRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
- Modify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepositoryTest.kt`
- RED: 집계 시작/종료 시각, 실행 트리거, 상태(`PENDING`, `PROCESSING`, `DONE`, `FAILED`), 실패 사유, 처리 시작/완료 시각을 저장하고 조회할 수 있는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest`
- GREEN: 기존 `charge_event_job` 관례를 참고해 스냅샷 job entity/repository/port와 운영 반영용 DDL을 작성한다.
- REFACTOR: 컬럼명은 관리자 목록과 worker 처리에 필요한 최소 필드로 제한하고 공개 API DTO와 분리한다.
- 기대 결과: 스냅샷 생성 이력이 기간/상태 기준으로 추적 가능해진다.
- [x] **Task 8.2: 스케줄 실행 전 job 생성과 성공/실패 기록 연결**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: 스케줄러가 스냅샷 생성 직전 집계 기간을 포함한 `SCHEDULED` job을 만들고, refresh 성공 시 `DONE`, 예외 발생 시 `FAILED`와 실패 사유를 기록하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: 스케줄러는 lock 획득 후 job service를 통해 job 생성/실행/상태 기록을 위임하고, refresh service는 기존 스냅샷 생성 책임을 유지한다.
- REFACTOR: lock 획득 실패는 job 실패로 기록하지 않고 기존 정상 skip 정책을 유지한다.
- 기대 결과: 매주 스케줄 실행 여부와 성공/실패가 관리자에서 추적 가능한 job 이력으로 남는다.
### Phase 9: 관리자 수동 생성과 실패 job 재시도 API
- [x] **Task 9.1: 관리자 날짜 범위 수동 생성 API 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobRequest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt`
- RED: `POST /admin/rankings/creators/snapshot-jobs`가 관리자 권한에서 날짜 범위를 받아 `MANUAL` job을 생성하고, 비관리자 요청은 거부되는 controller/service 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.ranking.creator.AdminCreatorRankingSnapshotJobControllerTest`
- GREEN: 기존 관리자 API 관례대로 `@PreAuthorize("hasRole('ADMIN')")``ApiResponse.ok(...)`를 사용해 수동 생성 job id와 상태를 반환한다.
- REFACTOR: 날짜 범위 validation은 KST 주차/UTC 변환 정책과 중복되지 않도록 application service에 모은다.
- 기대 결과: 운영자가 별도 DB 확인 없이 필요한 날짜 범위의 스냅샷 생성을 요청할 수 있다.
- [x] **Task 9.2: 관리자 job 목록/실패 job 재시도 API 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt`
- RED: `GET /admin/rankings/creators/snapshot-jobs`가 날짜 범위/상태/실패 사유/재시도 가능 여부를 반환하고, `POST /admin/rankings/creators/snapshot-jobs/{jobId}/retry``FAILED` job만 `PENDING`으로 되돌리는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.ranking.creator.AdminCreatorRankingSnapshotJobControllerTest`
- GREEN: 기존 `AdminChargeEventJobController`/`AdminChargeEventJobService` 패턴을 참고해 관리자 목록과 재시도 API를 구현한다.
- REFACTOR: `PENDING`, `PROCESSING`, `DONE` 상태 job은 재시도 대상으로 변경하지 않고 명확한 실패 응답을 반환한다.
- 기대 결과: 실패한 스냅샷 job을 관리자 버튼/API로 재시도할 수 있다.
### Phase 10: 스냅샷 완전 공백 fallback
- [x] **Task 10.1: 스냅샷 테이블 완전 공백 여부 조회 port 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- RED: 스냅샷 row가 하나도 없을 때만 true를 반환하고, 과거 주차 스냅샷이 하나라도 있으면 false를 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`
- GREEN: snapshot port에 `isSnapshotTableEmpty()` 또는 동등한 메서드를 추가해 조회 서비스가 fallback 조건을 판단할 수 있게 한다.
- REFACTOR: “최신 주차 스냅샷 없음”과 “테이블 완전 공백”을 서로 다른 조건으로 유지한다.
- 기대 결과: cold-start fallback이 과거 스냅샷 존재 시 실행되지 않도록 조건이 고정된다.
- [x] **Task 10.2: 조회 API cold-start fallback 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: 최신 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있을 때만 fallback 집계를 시도하고, 과거 스냅샷이 있으면 fallback을 시도하지 않는 테스트를 작성한다. 공개 응답 스키마가 `showRankChange``items`로 유지되는지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: query service가 snapshot-first 흐름을 유지하면서 완전 공백 상태에서만 제한적 fallback 집계를 호출하고 결과를 기존 ranking result로 변환한다.
- REFACTOR: fallback은 장기 실시간 랭킹 경로가 아니라 초기 스냅샷 부재 안전장치임을 service 경계와 테스트명에 드러낸다.
- 기대 결과: 초기 운영 상태에서는 빈 화면을 줄이고, 운영 중에는 기존 스냅샷 기반 정책을 유지한다.
- [x] **Task 10.3: fallback/job 관측 로그와 회귀 검증**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: fallback 시도/성공/실패와 job 상태 변경 로그가 남는지 output capture 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: 개인정보 없이 period, jobId, trigger, status, count, elapsedMs 중심의 구조화 로그를 추가한다.
- REFACTOR: 기존 Phase 7 로그와 이벤트명 충돌이 없도록 prefix를 정리한다.
- 기대 결과: 관리자 job과 cold-start fallback 상태를 운영 로그/메트릭으로 추적할 수 있다.
### Phase 11: cold-start fallback 스냅샷 생성 트리거
- [x] **Task 11.1: cold-start fallback 전용 기간 기반 lock 실행 경계 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 같은 KST 지난 주 기간에 대해 lock을 획득한 경우에만 refresh 책임을 실행하고, lock 획득 실패 시 refresh를 호출하지 않는 테스트를 작성한다. lock key는 집계 시작/종료 UTC 시각을 포함한 `lock:creator-ranking-snapshot-refresh:{start}:{end}` 형식으로 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: `CreatorRankingSnapshotJobService``ensureLastCompletedWeekSnapshotForColdStart()` 또는 동등한 메서드를 추가한다. 이 메서드는 `CreatorRankingPeriodPolicy`로 기간을 산출하고, Redisson lock을 `tryLock(0, -1, TimeUnit.SECONDS)`로 획득한 경우에만 기존 refresh service를 호출한다.
- REFACTOR: 조회 API가 직접 `creator_ranking_snapshot`을 저장하지 않도록 하고, lock 획득/해제와 refresh 위임 책임은 job service에 둔다. 스케줄러의 고정 lock key 정책은 유지하고, cold-start 전용 메서드에서만 기간 기반 lock key를 사용한다.
- 기대 결과: 운영 배포 직후 내부 테스트 등 초기 cold-start 상황에서 같은 기간 스냅샷 생성이 중복 실행되지 않는다.
- [x] **Task 11.2: fallback 성공 후 스냅샷 생성 책임 위임 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: `getCreatorRankings()`가 최신 스냅샷 없음 + 스냅샷 테이블 완전 공백 상태에서 fallback 결과를 응답하면서 cold-start 스냅샷 생성 위임 메서드를 호출하는 테스트를 작성한다. 과거 스냅샷이 있거나 fallback 후보가 없으면 cold-start 생성 위임을 호출하지 않는 테스트도 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: query service는 fallback 응답 조립 후 job service에 스냅샷 생성 책임을 위임한다. 위임 실패는 공개 API 응답을 깨지 않도록 catch 후 구조화 로그로 남기고, fallback 응답 스키마는 `showRankChange``items` 그대로 유지한다.
- REFACTOR: fallback은 장기 실시간 랭킹 경로가 아니라 초기 상태 보강책임을 테스트명과 로그 이벤트명에 드러낸다.
- 기대 결과: 첫 내부 조회에서 fallback 응답을 내려주면서 이후 조회가 스냅샷 기반으로 전환될 수 있다.
- [x] **Task 11.3: cold-start 스냅샷 생성 트리거 회귀 검증**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- RED: 테스트 작성 예외. `TDD 예외 사유`: 구현 완료 후 회귀 검증 task다.
- 대체 검증 방법:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'`
- `./gradlew ktlintCheck`
- GREEN: cold-start fallback, 스케줄러, 관리자 job, 차단 마스킹, CDN profile image 응답 테스트가 모두 통과해야 한다.
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
- 기대 결과: cold-start 스냅샷 생성 보강이 기존 스케줄/관리자/조회 경로를 깨지 않는다.
---
## 2. PRD 요구사항 추적
- Feature A: Task 1.1, Task 4.1에서 KST 기간 산출과 UTC DB 조회 변환을 검증한다.
- Feature B: Task 1.2, Task 3.1, Task 4.1에서 콘텐츠/라이브 raw can 산식을 검증한다.
- Feature C: Task 1.2, Task 3.2, Task 4.1에서 좋아요/댓글/대댓글 및 크리에이터 본인 댓글 제외를 검증한다.
- Feature D: Task 1.2, Task 3.3, Task 4.1에서 채널 후원 캔/건수와 최상위 팬 Talk 집계를 검증한다.
- Feature E: Task 1.2, Task 3.4, Task 4.1에서 최종 팔로우 수와 `createdAt`/`updatedAt` 기반 팔로우 증가 수를 검증한다.
- Feature F: Task 1.2, Task 4.1, Task 5.1에서 raw value 최종 점수, 1점 미만 제외, 20위 동점 후보 저장, 동점 랜덤 조회를 검증한다.
- Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증하고, Task 11.2에서 fallback 성공 후 응답을 깨지 않고 스냅샷 생성 책임을 위임하는 흐름을 검증한다.
- Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다. Task 11.1, Task 11.2에서 cold-start fallback 성공 후 기간 기반 lock으로 동일 기간 스냅샷 생성 중복을 방지하는 보강책을 검증한다.
- Feature I: Phase 5의 ranking 기능 본체는 `v2.ranking` 패키지 경계를 유지하고, Phase 6의 클라이언트 API 표면은 `v2.api.home` 하위에 둔다. Phase 8~10의 관리자/job/fallback 기능도 공개 API 응답 DTO를 변경하지 않는다.
---
## 3. 검증 기록
- 2026-06-08: PRD 기준 구현 계획/TASK 문서를 작성했다. 구현 시작 전 문서 산출물이므로 코드 테스트는 실행하지 않았고, 문서 규칙에 따라 `./gradlew tasks --all`로 Gradle 명령 유효성을 확인한다.
- 2026-06-08: `rg -n "TBD|TODO|작성 예정|fill in|placeholder|similar|위와 동일|적절한|나중" docs/20260608_크리에이터_랭킹/plan-task.md`로 placeholder 문구가 없음을 확인했다.
- 2026-06-08: `./gradlew tasks --all`은 sandbox 기본 권한에서 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 778ms`를 확인했다.
- 2026-06-08: Phase 1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 시 신규 ranking domain 타입 미정의로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-08: Phase 1 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest``./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest``BUILD SUCCESSFUL`을 확인했다. 병렬 실행한 period 단일 테스트 1건은 Kotlin/kapt cache 경합으로 실패해 후속 통합 검증에서 재확인한다.
- 2026-06-08: Phase 2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`는 production persistence 추가 전 실행했으나 Kotlin daemon heap 오류로 컴파일 단계에서 중단됐다. 당시 테스트가 참조하는 `CreatorRankingSnapshotRepository` 등 production 타입은 미구현 상태였다.
- 2026-06-08: Phase 2 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest` 재실행 결과 `BUILD SUCCESSFUL in 1m 49s`를 확인했다.
- 2026-06-08: DDL 대체 검증: `rg -n "creator_ranking_snapshot|aggregation_start_at_utc|creator_id|final_score" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 테이블명, 기간 컬럼, 크리에이터 id, 최종 점수 컬럼 및 index 문구를 확인했다.
- 2026-06-08: Phase 1~2 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 재실행 결과 `BUILD SUCCESSFUL in 22s`를 확인했다.
- 2026-06-08: 포맷 검증: `./gradlew ktlintCheck`는 최초 신규 테스트 긴 줄로 실패했고, 줄바꿈 수정 후 재실행해 `BUILD SUCCESSFUL in 10s`를 확인했다.
- 2026-06-08: 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 16s`를 확인했다.
- 2026-06-08: Phase 3 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 실행 결과 `DefaultCreatorRankingAggregationRepository` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-08: Phase 3 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 재실행 결과 `BUILD SUCCESSFUL in 13s`를 확인했다.
- 2026-06-08: Phase 3 focused 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 재실행 결과 `BUILD SUCCESSFUL in 14s`를 확인했다.
- 2026-06-08: Phase 3 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
- 2026-06-08: Phase 3 포맷 검증: `./gradlew ktlintCheck`는 최초 신규 테스트 긴 줄로 실패했고, 줄바꿈 수정 후 재실행해 `BUILD SUCCESSFUL in 5s`를 확인했다.
- 2026-06-08: Phase 4 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 실행 결과 `CreatorRankingSnapshotRefreshService`, `CreatorRankingSnapshotScheduler` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-08: Phase 4 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 재실행 결과 `BUILD SUCCESSFUL in 3s`를 확인했다.
- 2026-06-08: Phase 4 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
- 2026-06-08: Phase 4 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 22s`를 확인했다.
- 2026-06-08: Phase 4 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
- 2026-06-08: Phase 4 reviewer gate: 스냅샷 생성 서비스/스케줄러/테스트/문서 변경에 대해 strict review를 수행했고 `PASS` 판정을 확인했다.
- 2026-06-08: Task 4.3 및 07:30 스케줄 변경 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 실행 결과 `BUILD SUCCESSFUL in 16s`를 확인했다.
- 2026-06-08: Task 4.3 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 18s`를 확인했다.
- 2026-06-08: Task 4.3 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 26s`를 확인했다.
- 2026-06-08: Phase 5 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 `CreatorRankingBlockPort`, `CreatorRankingQueryService` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-08: Phase 5 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 재실행 결과 `BUILD SUCCESSFUL in 29s`를 확인했다.
- 2026-06-08: Phase 5 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 18s`를 확인했다.
- 2026-06-08: Phase 5 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 25s`를 확인했다.
- 2026-06-08: Phase 5 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 1s`를 확인했다.
- 2026-06-08: Phase 5 reviewer gate: 조회 서비스/차단 마스킹/테스트/문서 변경에 대해 strict review를 수행했고 `PASS` 판정을 확인했다.
- 2026-06-08: Phase 6 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest` 실행 결과 신규 endpoint/permit rule 미구현으로 비회원 요청 401, 인증 요청 404 등 신규 controller 테스트 3건 실패를 확인했다.
- 2026-06-08: Phase 6 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest` 재실행 결과 `BUILD SUCCESSFUL in 33s`를 확인했다.
- 2026-06-08: Phase 6 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 36s`를 확인했다.
- 2026-06-08: Phase 6 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 19s`를 확인했다.
- 2026-06-08: Phase 6 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 10s`를 확인했다.
- 2026-06-08: Phase 7 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 신규 로그 assertion 4건이 이벤트 로그 부재로 실패하는 것을 확인했다.
- 2026-06-08: Phase 7 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 40s`를 확인했다.
- 2026-06-08: Phase 7 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 39s`를 확인했다.
- 2026-06-08: Phase 7 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 21s`를 확인했다.
- 2026-06-08: Phase 7 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
- 2026-06-08: Phase 7 reviewer gate 1차 검토: 스냅샷 생성 성공 로그가 transaction commit 이전에 기록되는 점과 PRD Metrics의 최종 점수 1점 미만 제외 수 관측 누락으로 `FAIL` 판정을 확인했다.
- 2026-06-08: Phase 7 reviewer 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 실행 결과 신규 `lowScoreExcludedCount` 테스트가 fake 미구현으로 `compileTestKotlin` 실패하는 것을 확인했다.
- 2026-06-08: Phase 7 reviewer 수정 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 실행 결과 `BUILD SUCCESSFUL in 50s`를 확인했다.
- 2026-06-08: Phase 7 reviewer 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 37s`를 확인했다.
- 2026-06-08: Phase 7 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck`는 최초 import 순서 위반으로 실패했고, import 정렬 후 재실행해 `BUILD SUCCESSFUL in 18s`를 확인했다.
- 2026-06-08: Phase 7 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 28s`를 확인했다.
- 후속 구현 중 각 task 완료 시 실행 명령, 목적, 결과를 이 섹션에 누적한다.
- 2026-06-09: 사용자 추가 요구에 따라 PRD와 plan-task에 스냅샷 job 이력, 스케줄 job 기록, 관리자 날짜 범위 수동 생성, 실패 job 관리자 전용 재시도 API, 스냅샷 테이블 완전 공백 시 제한적 fallback 계획을 문서화했다.
- 2026-06-09: Phase 8 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 신규 job port/entity/service 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-09: Phase 8 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
- 2026-06-09: Phase 8 스케줄러 연결 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 4s`를 확인했다.
- 2026-06-09: Phase 8 DDL 대체 검증: `rg -n "creator_ranking_snapshot_job|aggregation_start_at_utc|aggregation_end_at_utc|trigger_type|status|processing_started_at|processed_at|last_error" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 job 테이블명, 기간/트리거/상태/처리 시각/실패 사유 컬럼 및 index 문구를 확인했다.
- 2026-06-09: Phase 8 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 19s`를 확인했다.
- 2026-06-09: Phase 8 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 14s`를 확인했다.
- 2026-06-09: Phase 8 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 51s`를 확인했다.
- 2026-06-09: Phase 8 reviewer gate 1차 검토: repository 테스트가 `PENDING` 저장 상태와 `PROCESSING` 전이를 직접 검증하지 않아 `FAIL` 판정을 확인했다.
- 2026-06-09: Phase 8 reviewer 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 16s`를 확인했다.
- 2026-06-09: Phase 8 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck`는 최초 unused import로 실패했고, import 제거 후 재실행해 `BUILD SUCCESSFUL in 6s`를 확인했다.
- 2026-06-09: Phase 8 reviewer 수정 후 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
- 2026-06-09: Phase 8 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 51s`를 확인했다.
- 2026-06-09: Phase 9 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest --tests kr.co.vividnext.sodalive.v2.admin.ranking.creator.AdminCreatorRankingSnapshotJobControllerTest` 실행 결과 신규 관리자 API 클래스, `createManualJob`/`findJobs`/`retryFailedJob`, `markPending` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-09: Phase 9 focused GREEN 및 관리자 API 표면 검증: retry 전이 guard 보강 후 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 1m 21s`를 확인했다. `AdminCreatorRankingSnapshotJobControllerTest``MockMvc` 요청으로 `POST /admin/rankings/creators/snapshot-jobs`, `GET /admin/rankings/creators/snapshot-jobs`, `POST /admin/rankings/creators/snapshot-jobs/{jobId}/retry`의 성공 응답과 비관리자 403/익명 401을 검증했다.
- 2026-06-09: Phase 9 ranking/admin 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 최초 병렬 Gradle 실행 중 Kotlin/kapt cache 경합으로 실패했고, 단독 재실행해 `BUILD SUCCESSFUL in 23s`를 확인했다.
- 2026-06-09: Phase 9 포맷 검증: `./gradlew ktlintCheck`는 최초 테스트 파일 닫는 brace 앞 공백과 main import 순서 위반으로 실패했고, 정리 후 재실행해 `BUILD SUCCESSFUL in 11s`를 확인했다.
- 2026-06-09: Phase 9 전체 회귀 검증: retry 전이 guard 보강 후 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 20s`를 확인했다.
- 2026-06-09: Phase 10 Task 10.1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest` 실행 결과 `isSnapshotTableEmpty` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-09: Phase 10 Task 10.1 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 27s`를 확인했다.
- 2026-06-09: Phase 10 Task 10.2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 `aggregationPort`, `nowProvider` 생성자 파라미터 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-09: Phase 10 Task 10.2 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 42s`를 확인했다.
- 2026-06-09: Phase 10 Task 10.3 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 fallback/job 로그 이벤트 부재로 신규 로그 테스트 4건 실패를 확인했다.
- 2026-06-09: Phase 10 Task 10.3 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 10s`를 확인했다.
- 2026-06-09: Phase 10 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 40s`를 확인했다.
- 2026-06-09: Phase 10 포맷 검증: `./gradlew ktlintCheck`는 최초 테스트 import 순서 위반으로 실패했고, import 정렬 후 재실행해 `BUILD SUCCESSFUL in 6s`를 확인했다.
- 2026-06-09: Phase 10 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 59s`를 확인했다.
- 2026-06-09: Phase 10 reviewer gate 1차 검토: cold-start fallback 경로에서 인증 회원의 차단 크리에이터 마스킹이 누락되어 `FAIL` 판정을 확인했다.
- 2026-06-09: Phase 10 reviewer 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 fallback 차단 마스킹 신규 테스트 1건 실패를 확인했다.
- 2026-06-09: Phase 10 reviewer 수정 GREEN 확인: fallback 결과에도 기존 차단 마스킹을 적용한 뒤 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 6s`를 확인했다.
- 2026-06-09: Phase 10 reviewer 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 30s`를 확인했다.
- 2026-06-09: Phase 10 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
- 2026-06-09: Phase 10 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
- 2026-06-09: Phase 10 reviewer 수정 후 follow-up gate: 목표/보안 검증, 코드 품질 검토, QA focused 검증이 모두 `PASS` 판정을 반환했고 blocking issue가 없음을 확인했다.
- 2026-06-09: 사용자 후속 요청에 따라 cold-start fallback 성공 시 조회 API가 직접 스냅샷을 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임하도록 PRD와 plan-task를 갱신했다. 동일 집계 기간 중복 생성을 막기 위해 기간 기반 Redisson lock key(`lock:creator-ranking-snapshot-refresh:{start}:{end}`)와 신규 Phase 11 Task 11.1~11.3을 추가했다. 문서 변경 검증으로 `rg -n "cold-start|ensureLastCompletedWeekSnapshotForColdStart|lock:creator-ranking-snapshot-refresh|Task 11|fallback 성공" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md``git diff -- docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md`를 실행해 반영 범위를 확인했다.
- 2026-06-09: creator_ranking_snapshot 최신/직전 조회 기준 확인: `rg -n "max\(latest\.aggregation_end_at_utc\)|max\(previous\.aggregation_end_at_utc\)|order by .*id|findLatestSnapshots|findPreviousCompletedSnapshots" src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence` 및 repository 코드 확인 결과 최신/직전 조회는 `id`가 아니라 `aggregation_end_at_utc`의 max/previous max 기준이며, 기간 내 정렬은 `final_score desc`임을 확인했다.
- 2026-06-09: Phase 11 Task 11.1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `RedissonClient` 생성자 인자와 `ensureLastCompletedWeekSnapshotForColdStart` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-09: Phase 11 Task 11.1 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
- 2026-06-09: Phase 11 Task 11.2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `snapshotJobService` 생성자 파라미터 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-09: Phase 11 Task 11.2 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 16s`를 확인했다.
- 2026-06-09: Phase 11 focused 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 2s`를 확인했다.
- 2026-06-09: Phase 11 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
- 2026-06-09: Phase 11 포맷 검증: `./gradlew ktlintCheck`는 최초 main import 순서 위반으로 실패했고, import 정렬 후 재실행해 `BUILD SUCCESSFUL in 23s`를 확인했다.
- 2026-06-09: Phase 11 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 2m 52s`를 확인했다.
- 2026-06-09: Phase 11 reviewer gate 1차 Code Quality 검토: 스케줄러 고정 lock과 cold-start 기간 lock이 달라 동일 기간 refresh가 동시에 실행될 수 있어 `FAIL` 판정을 확인했다.
- 2026-06-09: Phase 11 reviewer 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 스케줄 job이 cold-start와 같은 기간 lock을 사용하지 않아 신규 테스트 2건 실패를 확인했다.
- 2026-06-09: Phase 11 reviewer 수정 GREEN 확인: 스케줄 job refresh와 cold-start refresh가 공통 기간 기반 lock 경계를 사용하도록 수정한 뒤 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 9s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 43s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 27s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 10s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 Code Quality 검토: 공통 period lock은 적용됐지만 transaction commit 전에 lock이 해제될 수 있어 `FAIL` 판정을 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `TransactionTemplate`/transaction manager 생성자 인자 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 GREEN 확인: `PlatformTransactionManager``PROPAGATION_REQUIRES_NEW` `TransactionTemplate`을 내부 생성하고, period lock 안의 transaction commit 이후 unlock되도록 수정한 뒤 job service focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 3s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 45s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 17s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다.

View File

@@ -1,293 +0,0 @@
# PRD: 크리에이터 랭킹
## 1. Overview
지난 주 월요일 00:00:00 KST부터 일요일 23:59:59.999999999 KST까지의 활동 데이터를 기준으로 크리에이터 랭킹 점수를 계산하고, 최종 점수 상위 20명을 조회할 수 있는 기능을 제공한다.
---
## 2. Problem
- 크리에이터의 매출, 콘텐츠 반응, 응원, 팬 충성도를 한 번에 비교할 수 있는 주간 랭킹 기준이 필요하다.
- 서버 시스템 timezone이 UTC로 동작하더라도 랭킹 산정 기간은 KST 기준 지난 주 월요일부터 일요일까지로 고정되어야 한다.
- DB와 서버 timezone은 UTC이므로, KST 기준으로 산출한 랭킹 기간을 UTC 조회 조건으로 변환해 원천 데이터를 조회해야 한다.
- 계산 산식이 여러 도메인 데이터에 걸쳐 있어 조회 API 내부에 직접 구현하면 테스트와 스냅샷 기반 성능 개선이 어려워진다.
- 동일한 랭킹 산식을 주간 스냅샷 생성, 운영 조회, 캐시 갱신에서 재사용할 수 있도록 계산 책임과 조회 책임을 분리해야 한다.
---
## 3. Goals
- KST 기준 지난 주 월요일부터 일요일까지의 주간 크리에이터 랭킹을 계산한다.
- 최종 점수 기준 상위 20명의 크리에이터를 조회할 수 있다.
- 랭킹 계산 산식은 독립된 application/domain 컴포넌트로 분리한다.
- 계산 기간 산출은 서버 기본 timezone에 의존하지 않고 명시적으로 `Asia/Seoul` 기준을 사용한다.
- KST 기준 집계 시작/종료 시각을 UTC 기준 조회 시작/종료 시각으로 변환한 뒤 DB 데이터를 조회한다.
- 각 점수 카테고리의 원천 지표와 가중치를 테스트 가능한 형태로 관리한다.
- 조회 시 매번 무거운 원천 집계를 수행하지 않도록 주간 랭킹 계산 결과를 스냅샷으로 저장한다.
- 추후 성능 개선을 위해 캐시 저장소를 추가할 수 있는 포트 경계를 둔다.
---
## 4. Non-Goals
- 이번 PRD에서는 별도 관리자 화면 신규 개발을 포함하지 않는다. 단, 기존 관리자 영역에서 호출할 수 있는 스냅샷 수동 생성/재시도용 관리자 전용 API는 포함한다.
- 크리에이터 랭킹 산식의 머신러닝 모델화, 개인화, A/B 테스트는 포함하지 않는다.
- 실시간 랭킹 또는 현재 주 진행 중 랭킹은 포함하지 않는다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
- 기존 공개 API 스키마를 임의 변경하지 않는다.
- 랭킹 결과 수동 보정 기능은 포함하지 않는다.
- 점수 산식의 가중치를 관리자에서 동적으로 수정하는 기능은 포함하지 않는다.
---
## 5. Target Users
- 회원: 주간 인기 크리에이터를 탐색하는 사용자
- 앱 클라이언트: 랭킹 화면에 상위 크리에이터 목록과 순위/순위 변화 정보를 노출하는 클라이언트
- 운영자: 주간 크리에이터 성과를 확인하고 랭킹 산식의 결과를 검증하는 내부 사용자
---
## 6. User Stories
- 사용자는 지난 주 기준으로 가장 높은 최종 점수를 받은 크리에이터 20명을 보고 싶다.
- 사용자는 랭킹 순위, 지난 주 대비 순위 변화, 크리에이터 프로필 이미지, 닉네임을 확인하고 싶다.
- 앱 클라이언트는 홈 내부 랭킹 탭에서 동일한 API 응답으로 랭킹 화면을 구성하고 크리에이터 상세로 이동하고 싶다.
- 운영자는 특정 크리에이터의 최종 점수가 어떤 카테고리 점수로 구성되었는지 추적할 수 있어야 한다.
- 개발자는 시스템 timezone이 UTC여도 KST 기준 집계 기간이 흔들리지 않는지 테스트로 확인하고 싶다.
---
## 7. Core Features
### Feature A. 주간 랭킹 기간 산출
#### Requirements
- 랭킹 대상 기간은 조회 시점 기준 "지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만"으로 계산한다.
- 예를 들어 2026-06-08 월요일 KST에 조회하면 대상 기간은 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만이다.
- 서버 기본 timezone이 UTC여도 기간 산출은 `Asia/Seoul` 기준으로 수행한다.
- DB와 서버 timezone은 UTC이므로, KST 기준 기간을 UTC 기준 `Instant` 또는 프로젝트 표준 시간 타입으로 변환해 DB 조회 조건에 사용한다.
- 예를 들어 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만은 2026-05-31 15:00:00 UTC 이상, 2026-06-07 15:00:00 UTC 미만으로 변환해 조회한다.
#### Edge Cases
- 월요일 00:00:00 KST 직후 조회해도 방금 시작한 이번 주 데이터가 포함되지 않아야 한다.
- 연도/월 경계를 넘어가는 주차도 동일한 규칙으로 계산한다.
- DST가 없는 KST 기준을 사용하되, 구현은 `ZoneId.of("Asia/Seoul")`처럼 명시적인 timezone을 사용한다.
### Feature B. 콘텐츠 + 라이브 점수
#### Requirements
- 콘텐츠 + 라이브 점수는 라이브 계열 매출 합산 지표 70%, 콘텐츠 구매 합산 지표 30%로 계산한다.
- 라이브 계열 매출 합산 지표는 `CanUsage.DONATION`, `CanUsage.LIVE`, `CanUsage.SPIN_ROULETTE`의 사용 캔 합계로 계산한다.
- 콘텐츠 구매 합산 지표는 `CanUsage.ORDER_CONTENT` 1종의 사용 캔 합계로 계산한다.
- 환불된 사용 내역은 점수 계산에서 제외한다.
- 크리에이터별 기간 내 합계를 원천 지표로 보관하거나 응답 내부 추적이 가능해야 한다.
#### Edge Cases
- 라이브 또는 콘텐츠 구매 데이터가 없으면 해당 지표는 0점으로 계산한다.
- 음수 캔 또는 환불 데이터가 섞여 있으면 기존 `UseCan` 환불 정책과 동일한 방식으로 제외한다.
### Feature C. 참여 반응 점수
#### Requirements
- 참여 반응 점수는 콘텐츠 좋아요 수 50%, 콘텐츠 댓글 수 50%로 계산한다.
- 콘텐츠 좋아요 수는 기간 내 활성 콘텐츠 좋아요 수를 크리에이터별로 합산한다.
- 콘텐츠 댓글 수는 기간 내 활성 콘텐츠 댓글과 대댓글 수를 크리에이터별로 합산한다.
- 해당 콘텐츠의 크리에이터가 직접 작성한 댓글과 대댓글은 콘텐츠 댓글 수에서 제외한다.
- 비활성 콘텐츠, 삭제 또는 비활성 처리된 좋아요/댓글은 기존 도메인 정책에 맞춰 제외한다.
#### Edge Cases
- 좋아요 또는 댓글이 없으면 해당 지표는 0점으로 계산한다.
- 콘텐츠 댓글 수가 없거나 크리에이터 본인 댓글/대댓글만 있으면 댓글 지표는 0점으로 계산한다.
### Feature D. 응원 점수
#### Requirements
- 응원 점수는 채널 후원 캔 합계 60%, 채널 후원 수 20%, 팬 Talk 수 20%로 계산한다.
- 채널 후원 캔 합계는 `CanUsage.CHANNEL_DONATION`의 사용 캔 합계로 계산한다.
- 채널 후원 수는 `CanUsage.CHANNEL_DONATION` 사용 건수로 계산한다.
- 팬 Talk 수는 기존 `CreatorCheers`의 최상위 등록 수로 계산하고 답글은 포함하지 않는다.
- 환불된 채널 후원 내역은 점수 계산에서 제외한다.
#### Edge Cases
- 채널 후원 또는 팬 Talk 데이터가 없으면 해당 지표는 0점으로 계산한다.
- 팬 Talk 답글이 별도 row로 저장되어 있어도 팬 Talk 수에 포함하지 않는다.
### Feature E. 팬 충성도 점수
#### Requirements
- 팬 충성도 점수는 최종 팔로우 수 70%, 팔로우 증가 수 30%로 계산한다.
- 최종 팔로우 수는 랭킹 대상 기간 종료 시점 기준 활성 팔로우 수를 의미한다.
- 팔로우 증가 수는 랭킹 대상 기간 동안 활성 팔로우 수가 몇 명 증가했는지를 의미한다.
- 기본 정의는 `기간 내 신규 활성 팔로우 수 - 기간 내 비활성화된 팔로우 수`로 한다.
- 신규 활성 팔로우 수는 `CreatorFollowing.createdAt`이 랭킹 대상 기간 안에 있는 팔로우만 집계한다.
- 비활성화된 팔로우 수는 `CreatorFollowing.isActive == false`이고 `CreatorFollowing.updatedAt`이 랭킹 대상 기간 안에 있는 팔로우만 집계한다.
- 과거 언팔로우 후 기간 내 재팔로우한 경우는 `createdAt`이 과거 시점이므로 신규 증가로 반영하지 않는다.
- 이번 산식은 현재 `creator_following` row의 `createdAt`, `updatedAt`, `isActive` 기준으로 계산하며, 한 기간 안에서 여러 번 발생한 팔로우/언팔로우 이벤트 히스토리까지 별도로 복원하지 않는다.
- 팔로우 증가 수가 음수이면 음수 원천 지표와 음수 카테고리 점수를 허용하고, 최종 점수에 그대로 반영한다.
#### Edge Cases
- 기간 내 재팔로우로 다시 활성화된 팔로우는 최종 팔로우 수에는 포함될 수 있지만 팔로우 증가 수의 신규 생성분에는 포함하지 않는다.
- 기간 내 언팔로우 후 재팔로우해 최종 상태가 활성인 row는 `isActive == false` 조건에 걸리지 않으므로 비활성화된 팔로우 수에도 포함하지 않는다.
### Feature F. 최종 점수 계산 및 정렬
#### Requirements
- 최종 점수는 `(콘텐츠/라이브 카테고리 점수 * 0.35) + (참여 반응 점수 * 0.30) + (응원 점수 * 0.25) + (팬 충성도 점수 * 0.10)`으로 계산한다.
- 최종 점수 1점 이상인 크리에이터만 랭킹에 포함한다.
- 최종 점수 내림차순으로 최대 20명을 조회한다.
- 동점자는 랜덤으로 추출한다.
- 스냅샷에는 최종 점수 1점 이상인 모든 후보를 저장하지 않고, Top 20 산정에 필요한 후보만 저장한다.
- Top 20 산정에 필요한 후보는 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체를 의미한다.
- 조회 시 스냅샷에 저장된 후보 중 최종 점수 동점자를 랜덤 정렬해 상위 20명을 추출한다.
- 동점 랜덤 추출을 위한 별도 `randomTieBreaker` 값은 스냅샷에 저장하지 않는다.
- 각 하위 지표는 0~100 정규화하지 않고 원천 값(raw value)을 그대로 사용한다.
- 캔 단위 지표는 좋아요, 댓글, 팔로우 같은 개수 지표보다 최종 점수에 더 큰 영향을 줄 수 있으며, 이는 의도된 정책이다.
#### Edge Cases
- 특정 지표 값이 없으면 해당 원천 값은 0으로 계산한다.
- 최종 점수가 1점 미만이면 20명이 되지 않아도 응답에서 제외한다.
### Feature G. 랭킹 조회 API
#### Requirements
- 홈 내부 랭킹 탭에서 주간 크리에이터 랭킹 상위 20명을 조회하는 API를 제공한다.
- API endpoint는 `GET /api/v2/home/rankings/creators`를 사용한다.
- API는 최신 완료 주차의 스냅샷을 기준으로 조회하며 별도 query parameter 없이 기본 랭킹을 반환한다.
- 응답에는 순위 변화 표시 여부, 순위, 지난 주 대비 순위 변화, 신규 진입 여부, 크리에이터 id, 닉네임, 프로필 이미지를 포함한다.
- `showRankChange``items`와 같은 레벨에 내려주며, 클라이언트가 순위 변화 UI를 표시할지 판단하는 값이다.
- 각 크리에이터의 순위 변화 값은 `items[].rankChange`에 숫자로 내려준다.
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수로 내려준다.
- 예를 들어 직전 완료 주차 10위, 최신 완료 주차 5위이면 `rankChange``5`다.
- 예를 들어 직전 완료 주차 1위, 최신 완료 주차 10위이면 `rankChange``-9`다.
- 직전 완료 주차에는 순위에 없고 최신 완료 주차에 진입한 크리에이터는 `items[].isNew == true`로 내려주며, 클라이언트는 이를 `New`로 표시한다.
- 신규 진입 크리에이터의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
- 응답의 크리에이터 id는 크리에이터 상세 이동에 사용한다.
- 응답 스키마 예시는 다음과 같다.
```json
{
"showRankChange": true,
"items": [
{
"rank": 1,
"rankChange": 5,
"isNew": false,
"creatorId": 123,
"nickname": "creator",
"profileImageUrl": "https://cdn.example.com/profile.png"
},
{
"rank": 2,
"rankChange": null,
"isNew": true,
"creatorId": 456,
"nickname": "new creator",
"profileImageUrl": "https://cdn.example.com/profile-new.png"
}
]
}
```
- 운영 검증 또는 디버깅이 필요하면 카테고리별 점수와 원천 지표를 내부용 응답 또는 로그로 확인할 수 있어야 한다.
- 비활성 및 탈퇴 크리에이터는 랭킹에 노출하지 않는다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 랭킹 row는 유지하되 응답의 크리에이터 id는 `0`, 닉네임은 빈 문자열로 내려준다.
- 차단 관계가 있는 크리에이터의 프로필 이미지는 기본 이미지 URL로 내려주고, 이동 대상 id는 `0`으로 내려준다.
- 인증 사용자 조건이 필요하지 않은 공개 조회를 기본으로 하되, 차단 마스킹 정책은 인증 사용자에게 적용한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준으로 응답한다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다.
- cold-start fallback에서 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 상황을 위한 보강책이며, 장기 실시간 집계 경로로 사용하지 않는다.
- cold-start fallback 스냅샷 생성은 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용하고, lock 획득 실패 시 다른 요청 또는 작업이 처리 중인 정상 skip으로 간주한다.
#### Edge Cases
- 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다.
- 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다.
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
- fallback 성공 뒤 스냅샷 생성 트리거가 실패하더라도 공개 API 응답 스키마는 변경하지 않고, 실패는 로그/job 이력으로 추적한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange``false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다.
### Feature H. 주간 랭킹 스냅샷
#### Requirements
- 주간 랭킹은 조회 시 매번 원천 데이터를 집계하지 않고, 계산 결과를 스냅샷으로 저장한 뒤 조회 API는 스냅샷을 읽는다.
- 스냅샷 생성 기준 기간은 KST 기준 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만이다.
- 스냅샷 생성 시 원천 데이터 조회 조건은 KST 집계 기간을 UTC로 변환한 기간을 사용한다.
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
- 최종 점수 1점 이상인 후보가 20명 미만이면 해당 후보만 저장한다.
- 스냅샷은 크리에이터 id, 최종 점수, 카테고리별 점수, 원천 지표, 집계 시작/종료 시각을 저장한다.
- 최종 순위는 스냅샷 저장 시 고정하지 않고 조회 시 최종 점수 내림차순과 동점 랜덤 정렬 결과에 따라 부여한다.
- 순위 변화는 최신 완료 주차 응답에서 부여된 순위와 직전 완료 주차 스냅샷 기준 순위를 비교해 계산한다.
- 동점 랜덤 정렬 정책 때문에 동점 구간에 포함된 크리에이터의 순위와 순위 변화는 조회 결과마다 달라질 수 있으며, 이는 허용한다.
- 스냅샷 생성은 이번 주 데이터가 포함되지 않도록 주간 집계 대상 기간이 종료된 뒤 실행한다.
- 기본 스케줄 후보는 매주 월요일 KST 07:30이며, 스케줄러는 `Asia/Seoul` zone을 명시한다.
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 클러스터 전체에서 한 인스턴스만 스냅샷 생성을 수행해야 한다.
- 클러스터 단일 실행은 신규 DB 테이블을 추가하지 않고, 기존 프로젝트에 설정된 Redisson 기반 분산 lock을 우선 사용한다.
- 주간 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`를 사용하며, lock 획득 실패 인스턴스는 스냅샷 생성을 skip한다.
- 같은 집계 기간에 대해 스냅샷을 재생성할 수 있어야 하며, 재생성 시 기존 같은 기간 스냅샷을 중복 노출하지 않는다.
- 조회 API는 최신 완료 주차의 스냅샷을 기준으로 응답한다.
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 작업 완료 후 성공/실패 상태와 처리 결과를 기록한다.
- 스케줄러로 실행되는 주간 스냅샷 생성도 job 이력으로 기록한다.
- 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다.
- 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다.
- 관리자 전용 job 목록 API는 날짜 범위, 실행 트리거, 상태, 실패 사유, 재시도 가능 여부를 확인할 수 있어야 한다.
- cold-start fallback 성공 후 스냅샷 저장은 조회 서비스가 직접 DB에 쓰지 않고, 스냅샷 refresh 책임을 가진 job/service 경계로 위임한다.
- cold-start fallback 스냅샷 저장 트리거는 집계 기간을 포함한 Redisson lock key를 사용해 동일 기간 중복 생성을 방지한다. 예: `lock:creator-ranking-snapshot-refresh:{aggregationStartAtUtc}:{aggregationEndAtUtc}`.
- lock을 획득한 요청만 refresh job/service를 실행하고, lock을 획득하지 못한 요청은 이미 다른 실행자가 처리 중인 것으로 보고 fallback 응답만 반환한다.
#### Edge Cases
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
- fallback 성공 후 스냅샷 저장 트리거는 실패하더라도 조회 응답을 실패시키지 않되, job 상태 또는 구조화 로그로 실패를 추적할 수 있어야 한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다.
- Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다.
- 실패 job 재시도 API는 실패 상태 job만 대상으로 하며, 이미 대기/처리 중/성공 상태인 job은 재시도 대상으로 변경하지 않는다.
### Feature I. 랭킹 계산 컴포넌트 분리
#### Requirements
- 랭킹 계산과 조회는 Controller나 Facade 내부에 직접 구현하지 않고 별도 application/domain 컴포넌트로 분리한다.
- 크리에이터 랭킹 기능 본체는 추천 기능과 독립된 성격이므로 `v2.recommendation`가 아니라 별도 `kr.co.vividnext.sodalive.v2.ranking` 하위 패키지에 작성한다.
- 예시 컴포넌트는 다음 책임을 갖는다.
- 기간 계산 정책: KST 기준 지난 주 기간을 산출한다.
- 점수 정책: 원천 지표의 raw value에 가중치를 적용해 카테고리/최종 점수를 계산한다.
- 집계 포트: `UseCan`, 콘텐츠 반응, `CreatorCheers`, `CreatorFollowing` 원천 데이터를 조회한다.
- 스냅샷 생성 서비스: 원천 지표를 집계하고 랭킹 스냅샷을 저장한다.
- 조회 서비스: 저장된 스냅샷을 상위 20명 ranking 조회 결과로 조립한다.
- 홈 API 조합 Facade: ranking 조회 결과를 클라이언트 공개 응답 DTO로 변환한다.
- 추후 캐싱을 추가할 수 있도록 조회 서비스는 스냅샷 조회 포트와 캐시 포트를 분리할 수 있는 경계를 둔다.
#### Edge Cases
- 캐시가 추가되더라도 산식 테스트는 캐시와 분리된 순수 정책 테스트로 유지한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태를 제외하고 원천 데이터 실시간 계산 fallback을 두지 않는다.
---
## 8. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 랭킹 계산, 스냅샷 생성, 스냅샷 조회, 차단 마스킹 등 기능 본체는 `kr.co.vividnext.sodalive.v2.ranking` 하위에 작성한다.
- 클라이언트 endpoint는 홈 내부 랭킹 탭에서 호출하므로 `/api/v2/home/rankings/creators`를 사용한다.
- 클라이언트 공개 API 표면인 Controller와 API 조합 Facade는 기존 홈 API 관례를 따라 `kr.co.vividnext.sodalive.v2.api.home` 하위에 작성하고, 크리에이터 랭킹 응답 DTO는 `kr.co.vividnext.sodalive.v2.api.home.dto.ranking` 하위에 작성한다.
- 기존 엔티티 후보는 `UseCan`, `CanUsage`, `AudioContent`, `AudioContentLike`, `AudioContentComment`, `CreatorCheers`, `CreatorFollowing`, `Member` 등이다.
- 기존 공개 API 스키마는 변경하지 않는다.
- 계산 기간은 서버 기본 timezone이 아니라 명시적인 KST 기준으로 산출하고, DB 조회 시에는 UTC 기간으로 변환한다.
- QueryDSL 또는 native SQL 중 기존 성능/패턴에 맞는 방식을 선택하되, 산식 자체는 테스트 가능한 domain/application 정책으로 분리한다.
- 주간 랭킹 조회는 스냅샷 기반으로 제공한다.
- 캐싱은 이번 PRD의 필수 구현은 아니지만, 랭킹 조회 서비스가 캐시 포트를 도입할 수 있는 구조여야 한다.
- 스냅샷 스케줄러는 기존 Redisson 설정을 재사용해 클러스터 단일 실행을 보장하고, 별도 scheduler lock용 DB 테이블은 추가하지 않는다.
---
## 9. Metrics
- 랭킹 조회 API latency
- 랭킹 계산 소요 시간
- 주간 스냅샷 생성 성공/실패 수
- 주간 스냅샷 생성 지연 시간
- 스냅샷 job 상태별 수와 실패 job 재시도 수
- 관리자 수동 생성 job 요청 수와 성공/실패 수
- 스냅샷 테이블 완전 공백 fallback 시도/성공/실패 수
- 랭킹 후보 크리에이터 수
- 최종 점수 1점 미만으로 제외된 크리에이터 수
- 랭킹 조회 성공/실패 로그 수
- 캐시 도입 후 cache hit ratio
---
## 10. Open Questions
현재 PRD 기준 미결정 항목은 없다.

View File

@@ -1,30 +0,0 @@
# 문서 유지보수
## 문서 유지보수 규칙
- PRD 문서와 구현 계획/TASK 문서는 `docs/[날짜]_구현할내용한글/` 아래에 함께 둔다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- PRD 문서 파일명은 `prd.md`, 구현 계획/TASK 문서 파일명은 `plan-task.md`를 사용한다.
- PRD 문서는 `sample-prd.md`에서 필요한 섹션만 발췌해 작성하고, 불필요한 빈 섹션을 기계적으로 복사하지 않는다.
- `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다.
- 구현 계획/TASK 문서는 의미 단위 phase로 나누고 `### Phase 1: ...`, `### Phase 2: ...` 형식의 heading을 사용한다.
- 각 phase 아래에는 단계별 task를 체크박스(`- [ ] **Task N.N: ...**`) 형태로 작성한다.
- 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다.
- 각 task에는 TDD 절차를 명시한다. 기본 형식은 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다.
- 테스트 작성이 현실적으로 불가능한 task는 `TDD 예외 사유``대체 검증 방법`을 task에 명시한다.
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
- 각 task의 검증 기준에는 단일 테스트 실행 명령과 필요한 경우 전체 회귀 명령을 포함한다.
- 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- 연속된 하나의 작업에 대해 PRD 또는 구현 계획/TASK 문서가 여러 개 생기지 않도록 기존 문서 재사용 여부를 먼저 확인한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 운영 DB 반영용 DDL 문서는 MySQL 기준으로 작성한다.
- DDL 작성 시 날짜/시간 표시 컬럼은 `TIMESTAMP` 타입을 사용하고, `created_at``TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각'`, `updated_at``TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각'` 형식을 기본으로 한다.
- DDL의 모든 컬럼에는 MySQL `COMMENT`를 추가하고, 테이블에도 가능한 경우 `COMMENT`를 남긴다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.

View File

@@ -1,6 +0,0 @@
# 설정 보안
## 설정/보안 유의사항
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.

View File

@@ -1,17 +0,0 @@
# 실행 명령어
## 실행 기준
- 아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다.
- 변경 범위에 맞는 최소 명령으로 검증하고, 결과는 계획 문서 하단 검증 기록에 남긴다.
## Build/Lint/Test
```bash
./gradlew tasks --all
./gradlew bootRun
./gradlew build
./gradlew clean build
./gradlew test
./gradlew check
./gradlew ktlintCheck
./gradlew ktlintFormat
```

View File

@@ -1,20 +0,0 @@
# 작업 절차
## 작업 절차 체크리스트
- 변경 전: 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다.
- 변경 전: 사용자 프롬프트를 받으면 먼저 PRD 문서를 작성한다.
- 변경 전: PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰하고 PRD를 보강한다.
- 변경 전: PRD는 `sample-prd.md`에서 작업에 필요한 부분만 발췌해 작성한다. `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다.
- 변경 전: 문서는 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 작성한다.
- 변경 전: 보강된 PRD를 바탕으로 구현 계획/TASK 문서를 작성한 뒤, 해당 문서를 기준으로 필요한 내용만 최소 구현한다.
- 변경 전: 구현 계획/TASK 문서의 각 task에는 TDD 기준의 실패 테스트 작성, 실패 확인, 최소 구현, 통과 확인, 리팩터링/회귀 확인 단계를 포함한다.
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 전: 신규 API나 하위 코드 작성 시 `docs/agent-guides/코드스타일.md`의 패키지/코드 배치 규칙을 확인한다.
- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 PRD 또는 구현 계획/TASK 문서를 만들지 말고 기존 문서를 갱신한다.
- 변경 중: 신규 기능, 버그 수정, 리팩터링, 동작 변경은 테스트 작성이 불가능한 작업이 아닌 한 실패하는 테스트를 먼저 작성하고 실패를 확인한 뒤 구현한다.
- 변경 중: 범위가 변경되면 구현 전에 계획 문서 체크리스트를 먼저 업데이트한다.
- 변경 중: Todo를 사용할 때는 사용자에게 보이는 Todo 내용을 한국어로 작성한다. 경로, 클래스명, 명령어, 코드 식별자는 원문을 유지한다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 변경 후: 계획 문서 하단에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.

View File

@@ -1,13 +0,0 @@
# 커밋 메시지
## 커밋 메시지 규칙 (표준 Conventional Commits)
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
## 커밋 메시지 검증 절차
- `git commit` 직전/직후 항상 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- 스크립트 결과가 `[FAIL]`이면 메시지를 수정한 뒤 다시 검증한다.
- 커밋 메시지 본문에 에이전트 홍보/서명 footer를 추가하지 않는다.
- `Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)` 또는 `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>`가 있으면 커밋 완료 전 제거한다.

View File

@@ -1,81 +0,0 @@
# 코드 스타일
## 코드 스타일 규칙
### 1) 포맷/기본 규칙
- `.editorconfig` 기준을 준수한다.
- 인덴트: 공백 4칸.
- 줄바꿈: LF.
- 최대 라인 길이: 130.
- 파일 끝 개행 유지, trailing whitespace 제거.
### 2) import 규칙
- 와일드카드 import(`*`)를 사용하지 않는다.
- 사용하지 않는 import를 남기지 않는다.
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
### 3) 네이밍 규칙
- 클래스/인터페이스/enum: PascalCase.
- 함수/변수/파라미터: camelCase.
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
- 인터페이스의 기본 구현체는 접미사 `Impl`을 사용하지 않고 접두사 `Default`를 사용한다. 예: `HomeRecommendationQueryRepository`의 기본 구현체는 `DefaultHomeRecommendationQueryRepository`로 명명한다.
### 4) 패키지/코드 배치 규칙
- 기존 로직을 수정하는 경우에는 기존 패키지 구조를 따른다.
- 기존 로직 수정이 아닌 신규 API나 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
- 신규 도메인 또는 신규 기능 패키지 생성 시 `kr.co.vividnext.sodalive.v2` 바로 아래에 도메인 패키지를 먼저 만들고, 그 아래에 경량 헥사고날 아키텍처를 적용한다.
- 클라이언트 공개 API의 화면/클라이언트 맞춤 조립 계층은 `kr.co.vividnext.sodalive.v2.api.{기능}` 하위에 둘 수 있다.
- 여러 API나 내부 기능에서 재사용될 수 있는 도메인 기능은 `kr.co.vividnext.sodalive.v2.{도메인}` 하위에 별도 패키지로 둔다.
- 공개 API 조립 패키지가 재사용 도메인 패키지를 호출하는 방향은 허용하지만, 재사용 도메인 패키지가 공개 API 조립 패키지에 의존하지 않는다.
- 신규 도메인 또는 신규 기능의 기본 패키지 구조는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다.
- `application`에는 use case, orchestration service, 트랜잭션 경계를 둔다.
- `domain`에는 순수 정책, 점수 계산, 값 객체, 도메인 모델, 스냅샷 모델처럼 인프라 의존이 없는 코드를 둔다.
- `port`에는 application이 필요로 하는 입력/출력 인터페이스를 둔다. 단순 내부 호출까지 억지로 port로 만들지 않는다.
- `adapter`에는 web controller, JPA/QueryDSL persistence, cache, scheduler 등 외부 입출력 구현을 둔다.
- `dto`에는 API 요청/응답 DTO와 adapter 경계에서 사용하는 DTO를 둔다.
- 기존 패키지를 수정하는 작업에는 헥사고날 패키지 구조를 강제로 적용하지 않는다.
- 기존 `kr.co.vividnext.sodalive.v2` 하위 코드가 이미 다른 구조로 작성되어 있으면 해당 작업의 범위 안에서만 기존 구조를 유지하고, 신규 도메인부터 헥사고날 구조를 적용한다.
### 5) 타입/널 처리
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
### 6) API/응답 규칙
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
### 7) 예외 처리 규칙
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
### 8) 트랜잭션 규칙
- 서비스 계층에서 `@Transactional`을 사용한다.
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
### 9) 비동기/동시성 규칙
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
- 다중 서버 인스턴스에서 같은 `@Scheduled` 작업이 동시에 실행될 수 있는 스케줄러는 Redisson 기반 분산 lock을 적용해 클러스터 전체에서 한 인스턴스만 작업을 실행하도록 한다.
- 스케줄러 분산 lock은 기존 `RedissonClient` bean을 재사용하고, lock key는 작업 목적이 드러나도록 `lock:{job-name}` 형식으로 고정한다.
- lock 획득 실패는 다른 인스턴스가 처리 중인 정상 skip으로 보고, 작업 본문은 lock을 획득한 경우에만 실행한다.
- lock 해제는 `finally`에서 `lock.isHeldByCurrentThread` 확인 후 `unlock()`한다.
- 스케줄러 작업 시간이 예측 가능하면 무기한 watchdog 의존보다 최악 실행 시간에 여유를 더한 명시적 `leaseTime`을 우선 검토한다.
### 10) 의존성 주입
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
- 필드 주입보다 명시적 생성자 주입을 우선한다.
### 11) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.

View File

@@ -1,20 +0,0 @@
# 테스트 스타일
## 테스트 스타일 규칙
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
- 신규 기능, 버그 수정, 리팩터링, 동작 변경은 TDD를 기본 프로세스로 따른다.
- TDD 순서는 RED(실패 테스트 작성) → 실패 확인 → GREEN(최소 구현) → 통과 확인 → REFACTOR(정리) → 회귀 확인 순서로 진행한다.
- 실패 테스트는 실제 구현 결함 또는 미구현 동작 때문에 실패해야 하며, 오타/설정 오류/테스트 데이터 오류 때문에 실패한 상태로 RED를 통과한 것으로 보지 않는다.
- 테스트 작성이 현실적으로 불가능한 작업은 계획 문서에 이유와 대체 검증 방법을 명시한다.
- 도메인 모델과 엔티티는 유닛 테스트로 작성한다.
- 서비스와 컨트롤러는 통합 테스트(`@SpringBootTest`)로 작성한다.
- 목킹은 정말 필요한 경우가 아니면 사용하지 않는다.
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
## Redis 테스트 격리 규칙
- embedded Redis는 모든 테스트에 전역 등록하지 않는다. `src/test/resources/META-INF/spring.factories``EmbeddedRedisInitializer`를 등록하면 Redis가 필요 없는 `@DataJpaTest`까지 Redis를 시작하므로 금지한다.
- Redis가 필요한 통합 테스트만 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 명시적으로 opt-in 한다.
- Redis가 필요 없는 JPA/QueryDSL 슬라이스 테스트는 기존 관례처럼 `@DataJpaTest(properties = ["spring.cache.type=none"])`로 캐시를 끈다.

View File

@@ -1,14 +0,0 @@
# 20260220 LSP 설정 추가
## 구현 계획
- [x] oh-my-opencode 설정 파일에서 현재 LSP 매핑을 확인한다.
- [x] `.md` 확장자에 `remark-language-server` 매핑을 추가하고, `.sh`는 기존 `bash` 서버 설정이 정상 동작하는지 확인한다.
- [x] 수정 후 `lsp_diagnostics`로 Bash/Markdown 파일 진단이 가능한지 검증한다.
- [x] 저장소 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 무엇을: `/Users/klaus/.config/opencode/oh-my-opencode.json``remark-language-server --stdio` 기반 `.md` 매핑을 추가했다.
- 왜: Bash는 설치 후 즉시 진단 가능했지만 Markdown은 LSP 매핑이 없어 `lsp_diagnostics`가 실패했기 때문이다.
- 어떻게 검증했는지: `work/scripts/check-commit-message-rules.sh``docs/20260220_lsp설정추가.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all`, `./gradlew build`를 실행해 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -1,4 +0,0 @@
ALTER TABLE member
ADD fancimm_url VARCHAR(255) DEFAULT NULL COMMENT '팬심M url' AFTER instagram_url,
ADD x_url VARCHAR(255) DEFAULT NULL COMMENT 'X url' AFTER fancimm_url
;

View File

@@ -1,22 +0,0 @@
# 20260220 삭제 닉네임 접두사 표시 정리
## 구현 계획
- [x] 콘텐츠 댓글, 팬톡 응원, 커뮤니티 댓글의 닉네임 표시 흐름(조회/매핑/응답 DTO)을 각각 식별한다.
- [x] 닉네임이 `deleted_`로 시작하는지 판별하고 표시 시 접두사만 제거하는 공통 처리 지점을 설계한다.
- [x] 콘텐츠 댓글 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
- [x] 팬톡 응원 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
- [x] 커뮤니티 댓글 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
- [x] `deleted_` 미포함 닉네임, `deleted_` 포함 닉네임, 접두사만 존재하는 경계 케이스를 기준으로 테스트 케이스를 추가/보강한다.
## 검증 계획
- [x] 닉네임 표시에 영향이 있는 테스트를 우선 실행하고 실패 시 원인을 보정한다.
- [x] `./gradlew test`를 실행해 회귀 여부를 확인한다.
- [x] 필요 시 `./gradlew ktlintCheck`로 스타일 규칙 위반 여부를 확인한다.
- [x] `./gradlew build`를 실행해 전체 빌드 성공을 확인한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 무엇을: `String.removeDeletedNicknamePrefix()` 공통 확장 함수를 추가하고, 콘텐츠 댓글(`AudioContentCommentRepository`), 팬톡 응원(`ExplorerQueryRepository#getCheersList`), 커뮤니티 댓글(`CreatorCommunityCommentRepository`) 응답 닉네임에 동일 규칙을 적용했다.
- 왜: 탈퇴/비활성 사용자 닉네임 저장 정책(`deleted_` 접두사 유지)과 화면 표시 정책(접두사 제거)을 분리해, 사용자에게는 일관된 표시값을 제공하기 위해서다.
- 어떻게 검증했는지: `./gradlew test --tests "kr.co.vividnext.sodalive.extensions.StringExtensionsTest"`, `./gradlew test`, `./gradlew ktlintCheck`, `./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. 또한 경계 케이스(`deleted_testUser`, `testUser`, `deleted_`) 단위 테스트를 추가해 기대 출력이 각각 `testUser`, `testUser`, `""`인지 검증했다.

View File

@@ -1,15 +0,0 @@
# 20260220 커밋 규칙 스킬 분리
## 구현 계획
- [x] 커밋 메시지 정책의 최소 필수 항목을 `AGENTS.md`에 유지한다.
- [x] 커밋 상세 절차와 실행 가이드를 `.opencode/skills/commit-policy/SKILL.md`로 분리한다.
- [x] `/commit` 커맨드가 커밋 작업 시작 시 `commit-policy` 스킬을 우선 로드하도록 갱신한다.
- [x] 커밋 검증 강제 수단(`work/scripts/check-commit-message-rules.sh`)이 유지되는지 확인한다.
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 무엇을: `AGENTS.md`의 커밋 섹션을 최소 정책(형식, 한글 description, 검증 절차, 스킬 로드 지침) 중심으로 정리하고, 상세 절차를 `.opencode/skills/commit-policy/SKILL.md`로 분리했다. `/commit` 커맨드(`.opencode/commands/commit.md`)는 실행 시 `commit-policy` 스킬을 먼저 로드하도록 변경했다.
- 왜: 커밋 상세 규칙을 상시 컨텍스트에서 분리해 토큰 사용량을 줄이면서도, 커밋 시점에는 스킬 로드로 동일한 절차를 강제하기 위해서다.
- 어떻게 검증했는지: `AGENTS.md`, `.opencode/commands/commit.md`, `.opencode/skills/commit-policy/SKILL.md`, `docs/20260220_커밋규칙스킬분리.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all``./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -1,15 +0,0 @@
# 20260220 커밋 메시지 검증 규칙 추가
## 구현 계획
- [x] AGENTS.md의 커밋 메시지 규칙 섹션에 커밋 전/후 검증 절차를 추가한다.
- [x] AGENTS.md의 작업 절차 체크리스트에 커밋 전/후 스크립트 실행 규칙을 추가한다.
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
- [x] AGENTS.md 커밋 메시지 규칙과 불일치하는 `work/scripts/check-commit-message-rules.sh` 검증 로직을 정합성 있게 수정한다.
- [x] 수정한 스크립트에 대해 문법 및 실행 검증을 수행한다.
## 검증 기록
- [x] 검증 결과를 작업 완료 후 기록한다.
- 무엇을: `AGENTS.md`에 커밋 전/후 검증 절차를 추가했고, `work/scripts/check-commit-message-rules.sh`를 AGENTS.md 기준(Conventional Commit 형식, 소문자 type, 한글 description, `Refs:` footer 형식)으로 정합성 있게 수정했다.
- 왜: 문서 규칙과 실제 검증 로직이 어긋나면 커밋 메시지 정책이 일관되게 강제되지 않기 때문이다.
- 어떻게 검증했는지: `bash -n ./work/scripts/check-commit-message-rules.sh`, 유효/무효 메시지 실행 검증(`--message`), `Refs` footer 유효/무효 케이스 검증을 수행했다. 추가로 `./gradlew tasks --all``./gradlew build`를 실행해 저장소 명령 유효성과 전체 빌드 성공(`BUILD SUCCESSFUL`)을 확인했다.

View File

@@ -1,15 +0,0 @@
# 20260220 커스텀 커맨드 /commit 추가
## 구현 계획
- [x] `.opencode/commands/` 디렉터리에 `/commit` 커맨드 파일을 추가한다.
- [x] `/commit` 커맨드가 AGENTS.md 커밋 메시지 규칙(`type(scope): description`, 소문자 type, 한글 description)을 따르도록 지시한다.
- [x] `/commit` 커맨드가 커밋 직전 `./work/scripts/check-commit-message-rules.sh --message` 검증을 수행하도록 지시한다.
- [x] `/commit` 커맨드가 커밋 직후 `./work/scripts/check-commit-message-rules.sh` 재검증을 수행하도록 지시한다.
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 무엇을: `.opencode/commands/commit.md``/commit` 커스텀 커맨드를 추가해 변경사항 분석, AGENTS.md 규칙 기반 커밋 메시지 생성, 커밋 전/후 검증 스크립트 실행 절차를 일관되게 지시하도록 구성했다.
- 왜: 저장소의 커밋 메시지 컨벤션(Conventional Commit + 한글 description + Refs footer 규칙)과 검증 절차를 반복 작업마다 동일하게 강제하기 위해서다.
- 어떻게 검증했는지: `.opencode/commands/commit.md`, `docs/20260220_커스텀커맨드커밋추가.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -1,14 +0,0 @@
# 팬심M/X URL 추가 작업 계획
- [x] `Member` 엔티티 SNS 필드에 팬심M URL, X URL 속성 추가
- [x] 인스타그램 URL 수정 흐름 분석 후 동일한 수정 요청 DTO 반영
- [x] 서비스의 프로필 수정 로직에 팬심M URL, X URL 수정 처리 추가
- [x] 관련 응답 DTO에 신규 URL 필드 반영 및 매핑 연결
- [x] 후속 요청 반영: `fansimMUrl` 필드명을 `fancimmUrl`로 일괄 변경
- [x] `ddl-auto: validate` 대응을 위한 DB 컬럼 추가 SQL 파일 생성
- [x] 진단/테스트/빌드 검증 실행 후 결과 기록
## 검증 기록
- 무엇을: 팬심M/X URL 필드 추가, 인스타그램 URL 수정 흐름과 동일한 수정/응답 매핑 반영, `fansimMUrl` -> `fancimmUrl` 명칭 변경을 검증했다.
- 왜: 프로필 수정 API에서 두 URL이 저장되고, 주요 응답 DTO에서 값이 일관되게 내려가야 하기 때문이다.
- 어떻게: `./gradlew ktlintCheck test build`를 팬심M/X URL 추가 시점과 `fancimmUrl` 명칭 변경 시점에 각각 실행해 정적 검사, 테스트, 빌드 성공(Exit code 0)을 확인했다. 또한 `docs/20260220_member_fancimm_x_url_ddl.sql`에 운영 DB 반영용 DDL을 추가했다. Kotlin LSP 미구성으로 `lsp_diagnostics`는 수행할 수 없었다.

View File

@@ -1,18 +0,0 @@
CREATE TABLE channel_donation_message
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
member_id BIGINT NOT NULL COMMENT '후원한 유저',
creator_id BIGINT NOT NULL COMMENT '후원 받은 채널 크리에이터',
can INT NOT NULL COMMENT '후원한 캔',
is_secret TINYINT(1) NOT NULL DEFAULT 0 COMMENT '비밀후원 여부(false=0, true=1)',
additional_message TEXT NULL COMMENT '추가 메시지',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
KEY idx_channel_donation_message_creator_created_at (creator_id, created_at),
KEY idx_channel_donation_message_member (member_id),
CONSTRAINT fk_channel_donation_message_member
FOREIGN KEY (member_id) REFERENCES member (id),
CONSTRAINT fk_channel_donation_message_creator
FOREIGN KEY (creator_id) REFERENCES member (id)
) COMMENT ='채널 후원 메시지';

View File

@@ -1,17 +0,0 @@
# 차단 유저 댓글 및 크리에이터 노출 차단 구현
- [x] 차단(`BlockMember`) 데이터 접근 패턴 및 기존 필터 지점 확인
- [x] 콘텐츠 댓글 목록에서 차단한 유저 댓글 비노출 적용
- [x] 채널 응원 목록에서 차단한 유저 댓글 비노출 적용
- [x] 커뮤니티 댓글 목록에서 차단한 유저 댓글 비노출 적용
- [x] 차단한 크리에이터의 콘텐츠/라이브 비노출 동작 보강
- [x] 변경 파일 진단 및 테스트/빌드 검증
## 검증 기록
- 무엇을: 리뷰에서 지적된 단방향 차단 누락을 기준으로 콘텐츠/라이브/콘텐츠 댓글/커뮤니티 댓글/채널 응원(cheers) 노출 경로를 재점검해, 한쪽이라도 차단 관계면 조회·검색·상세 접근에서 숨겨지도록 양방향 차단 로직으로 보강했다. `/explorer/profile/{id}/cheers`의 우회 접근도 양방향 차단으로 막았다.
- 왜: 사용자 차단 정책을 일관되게 적용해 차단한 유저와 차단한 크리에이터의 활동이 조회 결과에 보이지 않도록 하기 위함이다.
- 어떻게 검증했는가:
- `lsp_diagnostics`를 수정 Kotlin 파일들에 대해 실행했으나, 현재 환경에 `.kt` LSP 서버가 설정되어 있지 않아 진단 불가를 확인했다.
- `./gradlew test` 실행 성공.
- `./gradlew build -x test` 실행 성공(ktlint/check 포함).

View File

@@ -1,67 +0,0 @@
# 채널 후원 기능 추가 작업 계획
## 메시지 저장 전략 선택
- 선택: 기본 메시지는 DB에 저장하지 않고, 후원 이력에는 `can`, `isSecret`, `additionalMessage`를 저장한 뒤 리스트 조회 시 메시지를 생성한다.
- 이유: 일반/비밀 구분과 캔 수 노출 요구를 구조화 필드로 충족할 수 있고, 문구 변경/다국어 확장 시 DB 마이그레이션 없이 대응 가능하다.
- 메시지 생성 규칙:
- 일반 후원: `OO캔을 후원하셨습니다.`
- 비밀 후원: `OO캔을 비밀후원하셨습니다.`
- 추가 메시지 입력 시: 기본 메시지 + `\n` + `"사용자 추가 메시지"`
- [x] 채널 후원 도메인 모델/저장소 설계 (`ChannelDonationMessage` 성격의 별도 엔티티, creator/sponsor/can/isSecret/additionalMessage/createdAt)
- [x] `CanUsage`에 채널 후원 전용 값 1종 추가 및 영향 범위 정의 (`CanPaymentService`, 사용내역 타이틀 매핑)
- [x] 채널 후원 API 요청/응답 스펙 확정 (필드: `creatorId`, `can`, `isSecret`, `message`, `container`)
- [x] 채널 후원 API 서비스 플로우 설계 (인증/크리에이터 검증 -> 캔 차감 -> 후원 메시지 DB 저장)
- [x] 채널 후원 리스트 API 스펙 확정 (최근 1개월, `createdAt` 내림차순, 페이징)
- [x] 채널 후원 리스트 조회 권한 규칙 반영
- 크리에이터: 모든 후원 내역 조회
- 유저: 일반 후원 + 본인이 한 비밀 후원 내역 조회
- [x] 리스트 응답 메시지 조합 규칙 반영 (일반/비밀 기본 메시지 + 추가 메시지 쌍따옴표 처리)
- [x] `explorer/profile/{id}` 응답 확장 설계 (최근 1개월 채널 후원 내역 최대 5건 포함)
- [x] QueryDSL 조회 조건 확정 (`createdAt >= now().minusMonths(1)`, `orderBy(createdAt.desc(), id.desc())`, `limit 5`)
- [x] 테스트 계획 수립 (서비스 단위 테스트 + 리포지토리 날짜 필터/정렬 테스트 + 컨트롤러 통합 테스트)
- [x] 정산 로직 제외 범위 명시 (정산 비율 변경 작업은 미포함, 채널 후원 기능만 구현)
- [x] 구현 후 검증 계획 확정 (`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`)
- [x] 운영 반영용 DDL 파일 추가 (`docs/20260223_channel_donation_message_ddl.sql`)
- [x] 채널 후원 회귀 테스트 구현
- 서비스: `ChannelDonationServiceTest`
- 리포지토리: `ChannelDonationMessageRepositoryTest`
- 컨트롤러: `ChannelDonationControllerTest`
## 검증 기록
- 무엇을:
- 1차 계획 수립: 채널 후원 기능의 API/도메인/조회 범위를 정의하고, 메시지 저장 전략을 선택해 계획 문서로 고정했다.
- 2차 수정: 채널 후원 리스트 API의 조회 권한 규칙(크리에이터 전체 조회, 유저는 일반 후원+본인 비밀 후원 조회)을 계획 항목에 추가했다.
- 3차 구현: 채널 후원 API/리스트 API/Explorer 프로필 확장, `CanUsage.CHANNEL_DONATION`, 메시지 엔티티 저장, 권한별 노출 필터를 구현했다.
- 왜:
- 기존 코드 패턴(Explorer/CanUsage/후원 조회)을 따르는 구현 범위를 먼저 고정해 불필요한 확장과 API 불일치를 방지하기 위해.
- 리스트 조회 시 요청자 역할에 따라 비밀 후원 노출 범위가 달라지므로, 구현 전 권한 규칙을 계획 단계에서 명확히 고정하기 위해.
- 채널 후원은 기존 라이브/콘텐츠 후원과 정산 분리를 위해 별도 `CanUsage`와 별도 메시지 저장소가 필요하고, 프로필 화면에 최근 내역 노출 요구가 있어 Explorer 응답 확장이 필요하기 때문에.
- 어떻게:
- 내부 탐색: `ExplorerController`, `ExplorerService`, `ExplorerQueryRepository`, `CanUsage`, `CanPaymentService`, `LiveRoomService`, `LiveRoomRepository`를 확인했다.
- 병렬 조사: `explore` 2건(`bg_07537536`, `bg_5be8611b`)과 `librarian` 1건(`bg_bfe81033`) 결과를 수집해 근거를 보강했다.
- 추가 확인: `AudioContentCommentRepository`, `CreatorCommunityCommentRepository`, `LiveRoomRepository`의 비밀/본인 공개 조건 패턴(`isSecret.isFalse.or(writerId.eq(memberId))`)을 확인해 문서 규칙에 반영했다.
- 구현 파일: `explorer/profile/channelDonation/*`, `CanUsage.kt`, `CanPaymentService.kt`, `CanService.kt`, `ExplorerService.kt`, `GetCreatorProfileResponse.kt`를 수정/추가했다.
- 검증 명령:
- `./gradlew test` -> 성공
- `./gradlew build` -> 최초 1회 `GetCreatorProfileResponse.kt` import 정렬 실패(ktlint), 정렬 수정 후 재실행 성공
- `./gradlew ktlintCheck` -> 성공
### 4차 보완(리뷰 지적사항 반영)
- 무엇을:
- 누락됐던 운영 반영용 DDL 파일 `docs/20260223_channel_donation_message_ddl.sql`을 추가했다.
- 채널 후원 회귀 테스트 3종(서비스/리포지토리/컨트롤러)을 신규 추가했다.
- 왜:
- `ddl-auto: validate` 환경에서 신규 엔티티 스키마 누락 시 부팅 실패 위험이 있어 적용 스크립트를 분리 관리해야 했기 때문이다.
- 권한별 비밀후원 노출, 1개월 필터, 정렬/페이징 규칙을 자동 검증해 회귀를 방지하기 위해서다.
- 어떻게:
- 추가 파일:
- `docs/20260223_channel_donation_message_ddl.sql`
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt`
- 검증 명령:
- `lsp_diagnostics` -> Kotlin LSP 미설정으로 실행 불가(환경 제약 확인)
- `./gradlew test --tests "*ChannelDonation*"` -> 성공
- `./gradlew test` -> 성공
- `./gradlew build` -> 성공

View File

@@ -1,22 +0,0 @@
# 크리에이터 상세정보 조회 API 추가 작업 계획
- [x] `ExplorerController`에 크리에이터 상세정보 조회 엔드포인트 추가
- [x] `ExplorerService`에 상세정보 조회 비즈니스 로직 추가
- [x] `ExplorerQueryRepository`에 데뷔일/활동요약 조회 쿼리 추가
- [x] 응답 DTO 추가 및 `Member` SNS URL 매핑 연결
- [x] 3차 수정: 미래 라이브만 있는 크리에이터의 음수 `D+` 노출 방지
- [x] 정적 진단/테스트/빌드 검증 및 결과 기록
## 검증 기록
- 무엇을:
- 1차 구현: 크리에이터 상세정보 조회 API(`/explorer/profile/{id}/detail`)와 응답 DTO를 추가하고, 데뷔일(라이브 `beginDateTime`/콘텐츠 `releaseDate` 최솟값), `D+N`, 활동요약, SNS URL 반환을 구현했다.
- 2차 수정: 상세 조회에 차단 관계 검사를 추가하고, 활동요약의 `contentCount`를 오픈된 콘텐츠(`releaseDate <= now`) 기준으로 집계하도록 기존 쿼리를 보정했다.
- 3차 수정: 라이브 데뷔 후보 조회에서 미래 `beginDateTime`을 제외하고, `D+` 계산 결과가 음수인 경우 `""`을 반환하도록 상세 조회 로직을 보정했다.
- 왜:
- 1차 구현: 탐색 화면에서 크리에이터 기본 정보·활동 통계·데뷔 정보·SNS를 한 번에 조회할 수 있어야 했다.
- 2차 수정: 차단 관계에서도 상세정보가 노출되는 우회가 있었고, 예약 공개 콘텐츠가 포함되어 요구사항의 “오픈한 콘텐츠 수”와 불일치할 수 있었다.
- 3차 수정: 오픈된 콘텐츠 없이 미래 예약 라이브만 있을 때 `D+-N`이 내려가 요구사항의 “오늘 기준 데뷔일로부터 며칠째(D+N)” 표현과 불일치했다.
- 어떻게:
- 1차 구현/2차 수정 모두 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했다.
- 1차 구현 시점과 2차 수정 시점에 각각 `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다.
- 3차 수정 시점에도 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했고, `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다.

View File

@@ -1,17 +0,0 @@
# 회원 차단 동일 본인인증 확장 구현
- [x] `memberBlock` 기존 단일 유저 차단 동작 확인
- [x] 차단 대상 유저가 본인인증(`Auth`)된 유저인지 확인
- [x] 본인인증 유저일 경우 동일 `di`를 가진 유저 id 목록 조회
- [x] 요청 유저(`memberId`)가 목록에 포함된 경우 제외
- [x] 대상 유저 + 동일 본인인증 유저 전체에 대해 차단 활성화 처리
- [x] 변경 파일 LSP 진단 및 관련 테스트 실행
## 검증 기록
- 무엇을: `MemberService.memberBlock`을 확장해 차단 대상 1명 + 동일 `Auth.di`를 가진 모든 계정을 일괄 차단하도록 수정했다.
- 왜: 본인인증 기반 다중 계정 우회 차단을 방지하고, 요청된 정책(동일 본인인증 정보 보유 계정 전체 차단)을 반영하기 위함이다.
- 어떻게 검증했는가:
- `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가를 확인했다.
- `./gradlew test` 실행 성공.
- `./gradlew build -x test` 실행 성공(ktlint/check 포함).

View File

@@ -1,30 +0,0 @@
## 구현 항목
- [x] SNS 응답/요청 DTO 전수 점검 후 `blogUrl` 제거
- [x] SNS 응답/요청 DTO에 `kakaoOpenChatUrl` 추가
- [x] 기존 `websiteUrl` 입력/반환 값을 `kakaoOpenChatUrl`로 동일 매핑
- [x] 회원 정보 수정 API(`ProfileUpdateRequest`, `MemberService.profileUpdate`) 반영
- [x] SNS 정보를 반환하는 API 응답(`ProfileResponse`, `MyPageResponse`, `CreatorResponse`, `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`, `GetRoomDetailManager`) 반영
- [x] LSP 진단/테스트/빌드 검증 및 결과 기록
- [x] 2차 수정: non-null Response 호환성을 위해 `GetCreatorDetailResponse``websiteUrl`, `blogUrl` 복구
- [x] 2차 수정: non-null Response 호환성을 위해 `GetLiveRoomUserProfileResponse``websiteUrl`, `blogUrl` 복구
- [x] 2차 수정 검증: 테스트/빌드 재실행 및 결과 기록
## 검증 기록
- 1차 구현
- 무엇을: SNS 필드를 `instagramUrl`, `fancimmUrl`, `xUrl`, `youtubeUrl`, `kakaoOpenChatUrl` 구조로 통일하고 `blogUrl`을 API 요청/응답 계층에서 제거했다. `kakaoOpenChatUrl`은 기존 `member.websiteUrl` 컬럼 값을 그대로 사용하도록 매핑했다.
- 왜: DB/Entity 변경 없이 기존 `websiteUrl` 저장 데이터를 카카오 오픈채팅 링크로 재해석해 노출하고, 더 이상 사용하지 않는 `blogUrl`을 API 스펙에서 제거하기 위해서다.
- 어떻게:
- 코드 반영: `ProfileUpdateRequest`, `ProfileResponse`, `MyPageResponse`, `CreatorResponse`, `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`, `GetRoomDetailResponse`, `MemberService`, `ExplorerService`, `LiveRoomService`
- 정적 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 미구성으로 불가(환경 제약 확인)
- 동작 검증: `./gradlew test && ./gradlew build` 실행
- 결과: `BUILD SUCCESSFUL` (test 성공 후 build 성공)
- 2차 수정
- 무엇을: non-null Response에서 제거되었던 `websiteUrl`, `blogUrl` 필드를 `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`에 복구했다. 동시에 각 서비스 매핑에서 해당 필드를 다시 응답에 포함했다.
- 왜: 필수 응답 키 제거로 인한 하위 호환성 이슈를 해소하기 위해서다.
- 어떻게:
- 코드 반영: `GetCreatorDetailResponse`, `ExplorerService`, `GetLiveRoomUserProfileResponse`, `LiveRoomService`
- 동작 검증: `./gradlew test && ./gradlew build` 실행
- 결과: `BUILD SUCCESSFUL` (test 성공 후 build 성공)

View File

@@ -1,13 +0,0 @@
- [x] 홈 인기 크리에이터 조회 경로 확인 및 차단 필터 적용 지점 확정
- [x] 인기 크리에이터 목록에서 내가 차단한 크리에이터 제외 로직 적용
- [x] 변경 파일 진단 및 테스트/빌드 검증 수행
- [x] 검증 결과 기록
## 1차 구현 검증 기록
- 무엇: 홈 인기 크리에이터 조회 시 차단 관계 조건을 양방향으로 반영해 내가 차단한 크리에이터가 노출되지 않도록 수정했다.
- 왜: 일반 유저가 차단한 크리에이터가 인기 크리에이터 목록에 계속 노출되는 문제를 해결하기 위해서다.
- 어떻게:
- `lsp_diagnostics`: Kotlin LSP 서버가 환경에 구성되지 않아 해당 도구 기반 진단은 수행 불가.
- `./gradlew ktlintCheck`: 성공.
- `./gradlew test`: 성공.
- `./gradlew build -x test`: 성공.

View File

@@ -1,21 +0,0 @@
# 20260225_채널후원메시지_캔_천단위콤마추가
## 구현 항목
- [x] `ChannelDonationService.kt``buildMessage` 함수 수정 (캔 수량 천단위 콤마 추가)
- [x] 관련 테스트 코드를 통한 검증
## 검증 기록
### 1차 구현
- **무엇을**: `buildMessage` 함수 내에서 `can` 변수를 `String.format("%,d", can)`으로 포맷팅하도록 수정
- **왜**: 후원 메시지 표시 시 캔 수량에 천단위 콤마를 추가하여 가독성을 높이기 위함
- **어떻게**:
- `ChannelDonationService.kt` 수정
- `./gradlew test` 실행 후 결과 확인
### 2차 수정
- **무엇을**: `ChannelDonationServiceTest``can = 1000`일 때 메시지가 `1,000캔` 형식으로 생성되는지 검증하는 테스트(`shouldFormatCanWithCommaInDonationMessage`)를 추가하고 문서 체크박스를 완료 처리
- **왜**: 기존 테스트는 천단위 콤마 포맷을 직접 검증하지 않아 문서의 "관련 테스트 코드를 통한 검증" 항목을 충족하기 어려웠기 때문
- **어떻게**:
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt`에 메시지 포맷 검증 테스트 추가
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest"` 실행: 성공
- `./gradlew build` 실행: 성공

View File

@@ -1,15 +0,0 @@
- [x] 기존 `memberBlock` 동일인 판별 로직(`di` 단일 조건)과 연관 Repository 조회 경로 확인
- [x] `AuthRepository``name + birth + di + gender` AND 조건 조회 메서드 추가
- [x] `MemberService.memberBlock`에서 다중 조건 조회 메서드 사용으로 변경
- [x] 변경 파일 정적 진단 및 테스트 실행
- [x] 구현 결과/검증 기록 문서 반영
## 검증 기록
### 1차 구현
- 무엇을: `memberBlock`의 동일인 확장 조회를 `di` 단일 조건에서 `name + birth + di + gender` AND 조건으로 변경했다.
- 왜: 동일인 판단 정밀도를 높여, `di`만 일치하는 케이스로 과차단되는 가능성을 줄이기 위해서다.
- 어떻게:
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt``getMemberIdsByNameAndBirthAndDiAndGender(...)` QueryDSL 조회를 추가했다.
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt``memberBlock`에서 `blockedMember.auth``name/birth/di/gender`를 사용해 신규 조회 메서드를 호출하도록 바꿨다.
- 검증: `lsp_diagnostics``.kt` LSP 서버 미구성으로 실행 불가(도구 에러 확인). 대신 `./gradlew test` 성공, `./gradlew build -x test` 성공으로 테스트/빌드 및 `ktlint` 체크 통과를 확인했다.

View File

@@ -1,49 +0,0 @@
SET @schema_name := DATABASE();
SET @use_can_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'use_can'
AND index_name = 'idx_use_can_channel_donation_filter'
);
SET @use_can_index_sql := IF(
@use_can_index_exists = 0,
'ALTER TABLE use_can ADD INDEX idx_use_can_channel_donation_filter (can_usage, is_refund, created_at, id)',
'SELECT "idx_use_can_channel_donation_filter already exists"'
);
PREPARE use_can_index_stmt FROM @use_can_index_sql;
EXECUTE use_can_index_stmt;
DEALLOCATE PREPARE use_can_index_stmt;
SET @use_can_calculate_join_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'use_can_calculate'
AND index_name = 'idx_use_can_calculate_settlement_join'
);
SET @use_can_calculate_join_index_sql := IF(
@use_can_calculate_join_index_exists = 0,
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_settlement_join (use_can_id, status, recipient_creator_id)',
'SELECT "idx_use_can_calculate_settlement_join already exists"'
);
PREPARE use_can_calculate_join_index_stmt FROM @use_can_calculate_join_index_sql;
EXECUTE use_can_calculate_join_index_stmt;
DEALLOCATE PREPARE use_can_calculate_join_index_stmt;
SET @use_can_calculate_creator_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'use_can_calculate'
AND index_name = 'idx_use_can_calculate_creator_settlement'
);
SET @use_can_calculate_creator_index_sql := IF(
@use_can_calculate_creator_index_exists = 0,
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_creator_settlement (recipient_creator_id, status, use_can_id)',
'SELECT "idx_use_can_calculate_creator_settlement already exists"'
);
PREPARE use_can_calculate_creator_index_stmt FROM @use_can_calculate_creator_index_sql;
EXECUTE use_can_calculate_creator_index_stmt;
DEALLOCATE PREPARE use_can_calculate_creator_index_stmt;

View File

@@ -1,191 +0,0 @@
# 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 작업 계획
- [x] 기존 정산 API 패턴(`admin.calculate`, `creator.admin.calculate`)과 채널 후원 데이터 소스(`ChannelDonationMessage`, `CanUsage.CHANNEL_DONATION`)를 확인한다.
- [x] 기존 패키지에 직접 누적하지 않도록 신규 하위 패키지를 설계한다.
- 관리자: `kr.co.vividnext.sodalive.admin.calculate.channelDonation`
- 크리에이터 관리자: `kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation`
- [x] 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)로 전체 데이터를 조회한 뒤 응답을 크리에이터별로 그룹화해 반환하도록 설계한다.
- [x] 크리에이터 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)만 입력받아 인증 사용자 본인 데이터만 조회한다.
- [x] 서비스 계층에서 날짜 문자열을 `convertLocalDateTime()`으로 변환하고 종료일은 `23:59:59`로 보정해 조회 구간을 통일한다.
- [x] 저장소(QueryRepository) 계층에 날짜 범위 조건(`createdAt >= startDate`, `createdAt <= endDate`)과 크리에이터 기준 그룹화(`groupBy(member.id)` 등)를 반영한 집계 조회를 추가한다.
- [x] API URL을 기존 정산 URL 규칙에 맞춰 확정하고 문서화한다.
- 관리자: `GET /admin/calculate/channel-donation-by-creator`
- 크리에이터 관리자: `GET /creator-admin/calculate/channel-donation`
- [x] 정산 계산 공식을 공통 로직으로 구현하고, 사람이 이해하기 쉬운 한글 주석을 추가한다.
- 원화 = 캔 * 100
- 수수료 = 원화 * 6.6%
- 정산금액 = (원화 - 수수료) * 85%
- 원천세 = 정산금액 * 3.3%
- 입금액 = 정산금액 - 원천세
- [x] 계산 정밀도 정책을 정의한다(`BigDecimal`, `RoundingMode.HALF_UP`, 반올림 시점 고정).
- [x] 성능/효율 개선 항목을 반영한다(집계 쿼리 중심 처리, 불필요한 애플리케이션 후처리 최소화, count 조회 최적화 검토).
- [x] 응답 DTO 스펙을 아래 필드로 고정하고 권한 정책(관리자=전체, 크리에이터 관리자=본인)을 함께 검증한다.
- 날짜(`yyyy-MM-dd`)
- 크리에이터
- 건수(`count`)
- 총 받은 캔 수(`totalCan`)
- 원화
- 수수료
- 정산금액
- 원천세
- 입금액
- [x] 테스트를 추가한다(관리자 날짜 필터 + 크리에이터별 그룹화 응답, 크리에이터 관리자 날짜 필터/본인 범위, 계산식 정확성, 경계값).
- [x] 검증을 수행한다(`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`).
## API URL 선정 근거
- 기본 경로는 권한 범위별 정산 컨트롤러 관례를 따른다.
- 관리자: `@RequestMapping("/admin/calculate")`
- 크리에이터 관리자: `@RequestMapping("/creator-admin/calculate")`
- 하위 경로는 기존 정산 API와 동일하게 소문자 하이픈(`kebab-case`) 명사 조합을 사용한다.
- 예: `content-donation-list`, `cumulative-sales-by-content`, `community-by-creator`
- `channel-donation` 토큰은 기존 채널 후원 API 경로(`@RequestMapping("/explorer/profile/channel-donation")`)와 용어를 맞춰 도메인 표현을 통일한다.
- 관리자 정산은 조회 결과가 크리에이터별 그룹화 응답이므로 기존 `*-by-creator` 패턴을 적용해 `channel-donation-by-creator`로 정한다.
- 크리에이터 관리자 정산은 인증 사용자 본인 범위로 고정되므로 `-by-creator` 접미사를 제외하고 `channel-donation`으로 정한다.
## 검증 기록
### 계획 수립
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 구현을 위한 작업 계획 문서를 작성했다.
- 왜: 구현 전에 패키지 구조, 날짜 범위 조회, 정산 계산식, 성능 검증 기준을 명확히 하기 위해서다.
- 어떻게:
- `docs`의 기존 작업 계획 문서 형식(체크박스 + 검증 기록)을 기준으로 템플릿을 맞췄다.
- `admin.calculate`, `creator.admin.calculate`, `explorer.profile.channelDonation` 경로를 탐색해 반영했다.
- 사용자 요청에 따라 실제 코드 구현/테스트는 수행하지 않고 계획 문서만 작성했다.
### 2차 계획 수정
- 무엇을: 조회 조건을 `관리자=날짜+크리에이터 구분`, `크리에이터 관리자=날짜만`으로 명확히 분리했고, 응답 필드를 `날짜(yyyy-MM-dd), 크리에이터, 원화, 수수료, 정산금액, 원천세, 입금액`으로 고정했다.
- 왜: 추가 요구사항(조회 조건 분리, Response 필드 고정)을 계획 단계에서 누락 없이 반영하기 위해서다.
- 어떻게:
- `admin.calculate`/`creator.admin.calculate`의 기존 날짜 파라미터 및 인증 기반 필터링 패턴을 재탐색해 계획 항목을 수정했다.
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 문서만 작성해야 하는 요청 범위를 유지하기 위해 코드 구현/테스트 변경은 수행하지 않았다.
### 3차 계획 수정
- 무엇을: 관리자 조회 요구사항을 `크리에이터 식별값으로 필터`가 아닌 `조회 결과를 크리에이터별로 그룹화하여 반환`으로 정정했다.
- 왜: 사용자 의도가 “조회 조건 추가”가 아니라 “응답 결과 구성 방식(크리에이터별 그룹화)”이었기 때문이다.
- 어떻게:
- `AdminCalculateController``*-by-creator` 엔드포인트가 날짜/페이지 파라미터만 받고(`creatorId/memberId` 미입력), 서비스/리포지토리에서 `GetCalculateByCreatorResponse``groupBy(member.id)` 기반으로 결과를 구성하는 패턴을 확인했다.
- 위 근거를 바탕으로 체크리스트를 `관리자=날짜 필터 + 크리에이터별 그룹화 응답` 기준으로 수정했다.
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
### 4차 계획 수정
- 무엇을: 작업 계획 문서에 API URL을 어떤 기준으로 정했는지(경로 규칙, 용어 선택, 최종 URL) 근거를 추가했다.
- 왜: 구현 전에 URL 명명 기준을 명확히 남겨, 이후 개발 시 경로 해석 차이와 재작업을 방지하기 위해서다.
- 어떻게:
- `AdminCalculateController`, `CreatorAdminCalculateController`, `ChannelDonationController``@RequestMapping`/`@GetMapping` 패턴을 비교해 기준 경로와 하위 경로 규칙을 도출했다.
- 관리자 URL은 `*-by-creator` 관례를 적용해 `/admin/calculate/channel-donation-by-creator`, 크리에이터 관리자 URL은 본인 범위 고정 특성에 맞춰 `/creator-admin/calculate/channel-donation`으로 문서화했다.
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
### 5차 구현
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 API를 신규 하위 패키지로 구현하고, 날짜 범위 조회/크리에이터별 그룹화/정산 공식 공통 계산 로직을 적용했다.
- 왜: 기존 정산 코드에 얽히지 않고 유지보수 가능한 구조로 요구사항(관리자=크리에이터별 그룹 응답, 크리에이터 관리자=본인 범위 조회)을 정확히 반영하기 위해서다.
- 어떻게:
- 신규 패키지 생성: `admin.calculate.channelDonation`, `creator.admin.calculate.channelDonation`, 공통 계산기 `calculate.channelDonation`.
- API 구현: `GET /admin/calculate/channel-donation-by-creator`, `GET /creator-admin/calculate/channel-donation`.
- QueryDSL 집계: `UseCan` + `UseCanCalculate`를 사용해 `CanUsage.CHANNEL_DONATION`, 날짜 범위, 환불 제외 조건을 적용하고 관리자 응답은 날짜+크리에이터 기준 그룹화, 크리에이터 관리자 응답은 날짜 기준 그룹화로 구현.
- 정산 계산식 공통화: `ChannelDonationSettlementCalculator`에서 `BigDecimal("0.066")`, `BigDecimal("0.85")`, `BigDecimal("0.033")`, `RoundingMode.HALF_UP` 정책으로 계산하고 공식 설명 한글 주석을 추가.
- 테스트 추가: 계산식/반올림 단위 테스트 및 관리자·크리에이터 관리자 컨트롤러/서비스 경로 테스트를 추가.
- 검증 실행:
- `./gradlew test --tests "kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculatorTest"` → 성공
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
- 참고: Kotlin LSP 서버 미설정 환경이라 `.kt` 파일에 대한 `lsp_diagnostics`는 실행 시 서버 미설정 오류를 반환했다.
### 6차 수정
- 무엇을: 정산 계산식을 단계별 반올림 후 다음 단계 계산하는 방식으로 수정하고, 크리에이터 관리자 조회 쿼리/카운트에서 불필요한 `member` 조인을 제거했다.
- 왜: 정산 항목 간 관계(`입금액 = 정산금액 - 원천세`)를 정수 기준으로 일관되게 맞추고, 조회 성능 최적화를 위해 불필요 조인을 줄이기 위해서다.
- 어떻게:
- `ChannelDonationSettlementCalculator`를 단계별 반올림 파이프라인으로 변경했다.
- `수수료 = round(원화 * 6.6%)`
- `정산금액 = round((원화 - 수수료) * 85%)`
- `원천세 = round(정산금액 * 3.3%)`
- `입금액 = 정산금액 - 원천세`
- 크리에이터 관리자 경로는 인증 사용자 닉네임을 서비스 인자로 전달해 응답 `creator`를 구성하고, QueryRepository의 `member` 조인/닉네임 select를 제거했다.
- 관리자 totalCount는 `member` 조인 없이 `recipientCreatorId` 기반 distinct 키로 계산하도록 변경했다.
- 검증 실행:
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
### 7차 수정
- 무엇을: 요청한 2번/3번 최적화를 반영해 QueryDSL `@QueryProjection` 기반 매핑으로 전환하고, 날짜 그룹 조회 경로 인덱스 전략 DDL을 추가했다. 또한 테스트 가독성을 위해 `@DisplayName`을 추가했다.
- 왜: `Projections.constructor` 대비 타입 안전성과 유지보수성을 높이고, 채널 후원 정산 조회의 날짜 범위/조인 필터 성능 개선 근거를 DDL로 명확히 남기기 위해서다.
- 어떻게:
- Query DTO 전환:
- `GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData``@QueryProjection`을 적용했다.
- 각 QueryRepository의 `Projections.constructor``QGet*QueryData(...)` 호출로 교체했다.
- 인덱스 전략 반영:
- `docs/20260226_channel_donation_settlement_index_ddl.sql` 파일을 추가해 `use_can`, `use_can_calculate` 인덱스 DDL을 정의했다.
- 테스트 가독성 개선:
- 채널 후원 정산 관련 신규 테스트에 `@DisplayName`(한글)을 추가해 테스트 의도를 명확히 했다.
- 검증 실행:
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
- 참고: `./gradlew test``./gradlew build`를 병렬 실행하면 테스트 결과 XML 파일 쓰기 충돌이 재발할 수 있어, 순차 실행 기준으로 최종 검증했다.
### 8차 수정
- 무엇을: 채널 후원 정산 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `count` 필드를 추가하고, QueryData/Repository/Test를 함께 갱신했다.
- 왜: 사용자 요청에 따라 정산 응답에서 그룹별 건수를 직접 확인할 수 있도록 하기 위해서다.
- 어떻게:
- Item DTO에 `@JsonProperty("count") val count: Int`를 추가했다.
- QueryDTO(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)에 `count: Long`을 추가하고 `toResponseItem()`에서 `count.toInt()`로 매핑했다.
- Repository projection에 `useCan.id.count()`를 추가해 count 값을 조회하도록 반영했다.
- 컨트롤러/서비스 테스트 fixture 및 assertion에 `count` 검증을 추가했다.
- 검증 실행:
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
### 9차 수정
- 무엇을: 채널 후원 정산 `count`가 분할 정산 레코드 수로 과집계되던 문제를 수정하고, 동일 후원(`UseCan` 1건) + 분할 정산(`UseCanCalculate` 2건) 회귀 테스트를 관리자/크리에이터 관리자 경로에 추가했다.
- 왜: 결제 게이트웨이별 분할 정산이 발생하면 기존 `useCan.id.count()`가 실제 후원 건수보다 크게 집계되어 정산 화면 `count`가 잘못 표시되기 때문이다.
- 어떻게:
- `AdminChannelDonationCalculateQueryRepository`, `CreatorAdminChannelDonationCalculateQueryRepository`의 집계 `count``useCan.id.countDistinct()`로 변경했다.
- QueryRepository 통합 테스트(`AdminChannelDonationCalculateQueryRepositoryTest`, `CreatorAdminChannelDonationCalculateQueryRepositoryTest`)를 추가해 분할 정산 시 `count=1`, `totalCan` 합산(50) 동작을 검증했다.
- H2 환경에서 MySQL 함수(`DATE_FORMAT`, `CONVERT_TZ`)를 테스트 가능하게 하기 위해 `H2MySqlFunctionDialect`, `H2MysqlDateFunctions` 테스트 지원 코드를 추가하고 각 테스트에서 alias를 등록했다.
- 검증 실행:
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew build` → 성공
- 참고: Kotlin LSP 미설정 환경이라 `.kt` 대상 `lsp_diagnostics`는 실행 시 서버 미설정 오류가 발생했다.
### 10차 수정
- 무엇을: 관리자 채널 후원 정산의 `totalCount` 쿼리에 `member` `innerJoin`을 추가해 목록 조회와 동일한 조인 조건으로 집계하도록 정렬했다.
- 왜: 기존에는 `totalCount``member` 조인 없이 계산하고 목록은 `member` `innerJoin`을 사용해, 데이터 정합성 이슈(고아 `recipientCreatorId`)가 있을 때 `totalCount``items`가 불일치할 수 있었다.
- 어떻게:
- `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotalCount(...)``member` 조인(`member.id = useCanCalculate.recipientCreatorId`)을 추가했다.
- distinct 그룹 키를 `recipientCreatorId` 문자열 대신 `member.id` 문자열 기준으로 변경해 목록 쿼리의 그룹 축(날짜+멤버)과 맞췄다.
- 검증 실행:
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew build` → 성공
- 참고: `./gradlew test``./gradlew build`를 병렬 실행했을 때 test result XML 쓰기 충돌이 1회 발생해, 이후 순차 실행으로 재검증했다.
### 10차 수정
- 무엇을: 정산 페이지 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `totalCan` 필드를 추가했다.
- 왜: 사용자 요청대로 화면에서 건수 다음에 총 받은 캔 수를 함께 노출하기 위해서다.
- 어떻게:
- Item DTO에 `@JsonProperty("totalCan") val totalCan: Int``count` 다음 위치로 추가했다.
- QueryData(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)의 `toResponseItem()`에서 `totalCan ?: 0`을 응답 Item의 `totalCan`으로 매핑했다.
- 컨트롤러/서비스 테스트 fixture와 assertion에 `totalCan` 검증을 추가했다.
- 검증 실행:
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
### 11차 수정
- 무엇을: 채널 후원 정산 인덱스 DDL(`docs/20260226_channel_donation_settlement_index_ddl.sql`)을 재실행 가능한 멱등 스크립트로 수정했다.
- 왜: 동일 DB에 DDL을 재적용할 때 기존 `ADD INDEX``Duplicate key name`으로 실패할 수 있어, 운영 재적용/롤백 후 재적용 시 안정성을 확보해야 했기 때문이다.
- 어떻게:
- `information_schema.statistics`에서 `table_schema = DATABASE()` 기준으로 인덱스 존재 여부를 조회하도록 변경했다.
- 인덱스가 없을 때만 `ALTER TABLE ... ADD INDEX`를 실행하고, 이미 존재하면 안내 `SELECT`를 실행하는 동적 SQL(`PREPARE`/`EXECUTE`) 패턴을 적용했다.
- 대상 인덱스 3개(`idx_use_can_channel_donation_filter`, `idx_use_can_calculate_settlement_join`, `idx_use_can_calculate_creator_settlement`) 모두 동일 규칙으로 반영했다.
- 검증 실행:
- `lsp_diagnostics`(대상: `docs/20260226_channel_donation_settlement_index_ddl.sql`) → `.sql` LSP 서버 미설정으로 진단 불가(환경 제약)
- `lsp_diagnostics`(대상: 본 문서) → `No diagnostics found`
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew build` → 성공

View File

@@ -1,17 +0,0 @@
# 라이브 추천 차단 JOIN 및 캐시 무효화
- [x] `LiveRecommendService.getRecommendLive`의 차단 필터 처리 구조 점검
- [x] `LiveRecommendRepository.getRecommendLive`를 DB 조회 시 차단 관계를 JOIN/조건으로 제외하도록 변경
- [x] 차단(`memberBlock`) 및 차단 해제(`memberUnBlock`) 시 추천 라이브 캐시가 즉시 반영되도록 무효화 처리
- [x] 변경 코드 정적 진단 및 테스트/빌드 검증
- [x] 검증 기록 작성
## 검증 기록
### 1차 구현
- 무엇을: `getRecommendLive`의 차단 제외 로직을 서비스 단 필터링에서 QueryDSL `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 이동했고, 차단/차단해제 시 `CacheManager``getRecommendLive:{memberId}` 키를 직접 evict 하도록 적용했다.
- 왜: 기존 방식은 추천 결과 조회 후 creator마다 `isBlocked`를 반복 호출해 후처리하고, 캐시 만료 전까지 차단/해제 결과가 반영되지 않는 문제가 있어 DB 레벨 필터링과 이벤트성 캐시 무효화가 필요했다.
- 어떻게:
- `lsp_diagnostics` (대상: `LiveRecommendRepository.kt`, `LiveRecommendService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
- `./gradlew test` 실행 결과: **성공 (BUILD SUCCESSFUL)**
- `./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL, ktlint/check 포함)**

View File

@@ -1,21 +0,0 @@
# 라이브 추천 차단 JOIN/캐시 무효화 검증 테스트
- [x] `LiveRecommendRepository.getRecommendLive`가 차단 관계(`member -> creator`, `creator -> member`)를 DB 조회 단계에서 제외하는지 테스트 추가
- [x] `LiveRecommendService.getRecommendLive`가 서비스 단 후처리 없이 저장소 결과를 그대로 위임하는지 테스트 추가
- [x] `MemberService.memberBlock`/`memberUnBlock` 호출 시 추천 라이브 캐시 키(`getRecommendLive:{memberId}`)가 즉시 무효화되는지 테스트 추가
- [x] 테스트 및 빌드 검증 수행
- [x] 검증 기록 작성
## 검증 기록
### 1차 검증 테스트 구현
- 무엇을: 문서 요구사항(추천 라이브 차단 JOIN, 서비스 위임 구조, 차단/해제 시 캐시 무효화)을 검증하는 테스트 3종을 추가했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt`
- 왜: `docs/20260226_라이브추천차단조인및캐시무효화.md`에 기재된 구현이 실제 코드에서 회귀 없이 유지되는지 자동 검증이 필요하다.
- 어떻게:
- `lsp_diagnostics` (대상: 위 3개 Kotlin 테스트 파일) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` 실행 결과: **성공 (BUILD SUCCESSFUL)**
- `./gradlew build` 1차 실행 결과: **실패 (`MemberServiceCacheEvictionTest.kt` 라인 길이/인자 줄바꿈 ktlint 위반)**
- `MemberServiceCacheEvictionTest.kt` 포맷 수정 후 `./gradlew build` 재실행 결과: **성공 (BUILD SUCCESSFUL, test/check/ktlint 통과)**

View File

@@ -1,15 +0,0 @@
# 오리지널 시리즈 차단 필터 적용
## 구현 체크리스트
- [x] `HomeService.fetchData` 경로에서 오리지널 시리즈 조회 시 `memberId` 전달
- [x] `ContentSeriesService.getOriginalAudioDramaList` 시그니처에 `memberId` 반영
- [x] `ContentSeriesRepository.getOriginalAudioDramaList` 인터페이스/구현에 `memberId` 반영
- [x] 오리지널 시리즈 QueryDSL 조회에 양방향 차단(`내가 차단`/`나를 차단`) 서브쿼리 필터 적용
- [x] 오리지널 탭 API 경로(`AudioContentMainTabSeries*`)에도 `memberId` 전달
- [x] 빌드/테스트/진단 실행 후 결과 기록
## 검증 기록
- 1차 구현
- 무엇을: 홈/시리즈탭의 오리지널 시리즈 조회 경로에 `memberId`를 전달하고, `ContentSeriesRepository.getOriginalAudioDramaList``getOriginalAudioDramaTotalCount`에 양방향 차단 서브쿼리(`blockedSubquery.exists().not()`)를 추가해 차단된 크리에이터 시리즈가 제외되도록 반영했다.
- 왜: 기존에는 오리지널 시리즈 조회 쿼리에 차단 조건이 없어, 내가 차단했거나 나를 차단한 크리에이터의 시리즈가 노출될 수 있었다.
- 어떻게: `./gradlew test` 실행 성공, `./gradlew build` 실행 성공으로 컴파일/테스트/정적검사(ktlint 포함 check 단계) 통과를 확인했다. Kotlin LSP는 환경에 서버가 없어(`.kt` 미지원) 진단 도구로는 확인할 수 없어 Gradle 빌드 기반으로 검증했다.

View File

@@ -1,26 +0,0 @@
- [x] Admin 채널 후원 정산 조회 흐름(Controller/Service/Repository/DTO) 확인
- [x] Creator 정산 조회 흐름(Controller/Service/Repository/DTO) 확인
- [x] 날짜 기준 비페이징 합계 조회 방식 결정 및 반영
- [x] `GetAdminChannelDonationSettlementResponse`에 합계 필드 추가
- [x] `GetCreatorChannelDonationSettlementResponse`에 합계 필드 추가
- [x] 관련 테스트/빌드/진단 실행 및 결과 기록
## 검증 기록
### 1차 구현
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 응답에 날짜 범위 전체(비페이징) 합계(`total`)를 추가하고, QueryRepository에 합계 전용 집계 쿼리를 추가했다.
- 왜: 기존 응답이 페이지 내 `items``totalCount`만 제공해 날짜 범위 전체 정산 합계를 확인할 수 없었기 때문이다.
- 어떻게:
- 응답 DTO 확장
- `GetAdminChannelDonationSettlementResponse``total` 필드 추가
- `GetCreatorChannelDonationSettlementResponse``total` 필드 추가
- 합계 DTO/QueryData 추가: `GetAdminChannelDonationSettlementTotal`, `GetCreatorChannelDonationSettlementTotal`, 각 `*TotalQueryData`
- 서비스/리포지토리 반영
- 관리자: `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotal(...)` 추가 후 서비스에서 `total` 매핑
- 크리에이터 관리자: `CreatorAdminChannelDonationCalculateQueryRepository.getChannelDonationSettlementTotal(...)` 추가 후 서비스에서 `total` 매핑
- 테스트 반영
- 컨트롤러/서비스/리포지토리 테스트에서 `total` 필드와 합계 집계 검증 추가
- 검증 명령 및 결과
- `lsp_diagnostics`(Kotlin 대상): `.kt` LSP 서버 미설정으로 진단 불가(환경 제약)
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew build` → 1차 실패(ktlint max line length), 코드 포맷 수정 후 재실행 성공

View File

@@ -1,17 +0,0 @@
# 2026-02-26 콘텐츠/시리즈 상세 차단 오류메시지 수정
## 구현 체크리스트
- [x] 콘텐츠 상세(`getDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경
- [x] 시리즈 상세(`getSeriesDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경
- [x] `SodaMessageSource`에 콘텐츠/시리즈 차단 전용 메시지 키 추가
- [x] 정적 진단 및 테스트로 변경 영향 검증
## 검증 기록
### 1차 구현
- 무엇: `AudioContentService.getDetail`의 차단 예외 키를 `content.error.blocked_access`로 변경하고, `ContentSeriesService.getSeriesDetail`의 차단 예외 키를 `series.error.blocked_access`로 변경했다. `SodaMessageSource`에 두 키를 추가해 한국어 기준으로 각각 "콘텐츠 접근이 차단되었습니다.", "시리즈 접근이 차단되었습니다."를 반환하도록 반영했다.
- 왜: 기존에는 차단 상황에서도 `invalid_content_retry`/`invalid_series_retry`를 사용해 오류 의미가 모호했고, 요청 사항대로 차단 상황을 명확한 문구로 안내해야 했기 때문이다.
- 어떻게:
- `lsp_diagnostics` (`AudioContentService.kt`, `ContentSeriesService.kt`, `SodaMessageSource.kt`) 실행: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가)
- `./gradlew test` 실행: 성공
- `./gradlew ktlintCheck` 실행: 성공
- `./gradlew build` 실행: 성공

View File

@@ -1,24 +0,0 @@
- [x]`fetchData` 콘텐츠 랭킹 조회 경로 및 차단 적용 패턴 확인
- [x] `RankingRepository.getAudioContentRanking`에 양방향 차단(내가 차단/나를 차단) 조건 적용
- [x] 변경 파일 진단 및 테스트/빌드 검증 수행
- [x] 검증 결과 기록
## 1차 구현 검증 기록
- 무엇: 홈 `fetchData``contentRanking`에서 내가 차단한 크리에이터와 나를 차단한 크리에이터의 콘텐츠를
모두 제외하도록 서비스 레벨 필터를 추가했다.
- 왜: 기존 랭킹 조회 쿼리에는 한 방향 차단만 반영되어 양방향 차단 관계를 완전히 차단하지 못할 수 있기 때문이다.
- 어떻게:
- `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가.
- `./gradlew ktlintCheck`: 성공.
- `./gradlew test`: 성공.
- `./gradlew build -x test`: 성공.
## 2차 수정 검증 기록
- 무엇: 서비스(`HomeService`)에서 처리하던 `contentRanking` 차단 필터를 제거하고, `RankingRepository.getAudioContentRanking`
쿼리의 `blockMemberCondition`을 양방향 차단 조건으로 수정했다.
- 왜: 홈 서비스가 아닌 랭킹 데이터 조회 계층에서 차단 정책을 일관되게 보장하기 위해서다.
- 어떻게:
- `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가.
- `./gradlew ktlintCheck`: 성공.
- `./gradlew test`: 성공.
- `./gradlew build -x test`: 성공.

View File

@@ -1,14 +0,0 @@
- [x] Explorer 후원랭킹 집계 경로에서 후원 타입 필터 조건을 확인한다.
- [x] 크리에이터 프로필 후원랭킹 집계에 `CanUsage.CHANNEL_DONATION`을 반영하도록 쿼리를 수정한다.
- [x] 변경 범위와 연관된 테스트/검증(컴파일/테스트)을 실행한다.
- [x] 구현 완료 후 체크박스를 갱신하고 검증 기록(무엇을/왜/어떻게)을 남긴다.
## 검증 기록
- 1차 구현
- 무엇을: `CreatorDonationRankingQueryRepository`의 후원랭킹 조회/총원 집계 조건에 `CanUsage.CHANNEL_DONATION`을 추가했다.
- 왜: `ExplorerService.getCreatorProfile`의 후원랭킹이 기존 `DONATION`, `SPIN_ROULETTE`, `LIVE`만 포함해 채널 후원이 누락되고 있었기 때문이다.
- 어떻게:
- `lsp_diagnostics`로 Kotlin 파일 진단을 시도했지만, 현재 환경에 `.kt` LSP 서버가 없어 도구 기반 진단은 불가했다.
- `./gradlew test` 실행 결과: 성공
- `./gradlew build -x test` 실행 결과: 성공

View File

@@ -1,17 +0,0 @@
# 최근 종료 라이브(getLatestFinishedLive) 최적화
- [x] `getLatestFinishedLive` 조회를 DB 단계에서 차단 관계(`left join`)로 필터링하도록 변경
- [x] 조회 결과를 `GetLatestFinishedLiveResponse`로 QueryProjection 하여 서비스 단 추가 `map` 제거
- [x] 회원 차단(`memberBlock`) / 차단해제(`memberUnBlock`) 시 최근 종료 라이브 캐시 무효화 적용
- [x] 정적 진단 및 테스트/빌드 검증 수행
- [x] 검증 기록 작성
## 검증 기록
### 1차 구현
- 무엇을: `getLatestFinishedLive`를 서비스 후처리(`filter`/`map`)에서 제거하고, `LiveRoomRepository`에서 `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 차단 관계를 DB 단계에서 제외하도록 변경했다. 또한 `GetLatestFinishedLiveResponse``@QueryProjection` 생성자를 추가해 쿼리 결과를 응답 DTO로 바로 생성했다. 마지막으로 `memberBlock`/`memberUnBlock`에서 `getLatestFinishedLive:{memberId}` 캐시를 즉시 evict 하도록 반영했다.
- 왜: 기존 로직은 조회 후 애플리케이션 레벨에서 차단 여부를 반복 조회하고 별도 `map`을 수행해 비용이 컸고, 차단/차단해제 직후 최근 종료 라이브 캐시가 TTL 만료 전까지 stale 상태가 될 수 있어 DB 레벨 필터링 및 이벤트성 캐시 무효화가 필요했다.
- 어떻게:
- `lsp_diagnostics` (대상: `GetLatestFinishedLiveResponse.kt`, `LiveRoomRepository.kt`, `LiveRoomService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
- `./gradlew test && ./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL)**
- `./gradlew tasks --all` 실행 결과: **성공 (BUILD SUCCESSFUL)**

View File

@@ -1,45 +0,0 @@
- [x] `getCreatorProfile`의 채널 후원 리스트 조회 경로 식별 (`ExplorerService` -> `ChannelDonationService` -> `ChannelDonationMessageRepository`)
- [x] 프로필 채널 후원 조회 시 조회 월의 1일~말일 범위만 조회되도록 기간 조건 반영
- [x] 기존 일반 채널 후원 목록 API 동작 영향 없는지 확인
- [x] 수정 파일 기준 정적 진단/테스트/빌드 검증 수행
## 검증 기록
### 1차 구현
- 무엇을: 크리에이터 프로필의 채널 후원 리스트 조회 기간을 월 단위로 제한
- 왜: 기존 기간 계산(`now - 1 month`)은 월 경계 기준 요구사항(해당 월 1일~말일)과 다름
- 어떻게:
- `lsp_diagnostics`(수정 파일 2개) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 2차 수정
- 무엇을: 프로필 집계 응답뿐 아니라 전체 채널 후원 리스트 API도 월 단위(1일~말일) 조회로 통일
- 왜: 요구사항이 프로필 전용이 아닌 전체 채널 후원 리스트 대상까지 확장됨
- 어떻게:
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 3차 수정
- 무엇을: `endDateTime` nullable 분기와 중복 메서드를 제거하고 기존 조회 메서드 시그니처에 `endDateTime`을 포함해 단일 로직으로 정리
- 왜: `endDateTime`이 항상 존재하는 현재 요구사항에서 null 분기 로직은 불필요하며 유지보수 복잡도만 증가시킴
- 어떻게:
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 4차 수정
- 무엇을: `endDateTime` 도입 이후 테스트 의미를 월 경계 의도에 맞게 보강 (`Service`는 월 시작/종료 전달 검증, `Repository`는 월 범위 기반 필터 검증)
- 왜: 기존 테스트 일부는 단순 파라미터 통과 확인 수준이어서 월 경계 요구사항을 직접 담지 못함
- 어떻게:
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 5차 수정
- 무엇을: 채널 후원 테스트 2개 파일의 가독성 개선을 위해 `@DisplayName`(한글)과 BDD(`given/when/then`) 단락 설명을 추가
- 왜: 테스트 코드 길이가 길어지며 의도 파악이 어려워져, 시나리오/준비/실행/검증 흐름을 빠르게 읽을 수 있도록 개선 필요
- 어떻게:
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*" && ./gradlew build` 실행: 성공

View File

@@ -1,18 +0,0 @@
# 관리자 채널후원 정산 리뷰 지적사항 반영 작업 계획
- [x] 하위 호환성 유지 이슈는 요구사항 재확인 결과, 기존 이름을 신규 목적 경로로 사용하기로 확정되어 작업 범위에서 제외한다.
- [x] 엑셀 다운로드 API 테스트에서 `Content-Disposition` 헤더를 실질적으로 검증하도록 보강한다.
- [x] 관련 테스트와 빌드를 실행해 회귀 여부를 확인한다.
## 검증 기록
### 1차 반영
- 무엇을: 엑셀 다운로드 컨트롤러 테스트에서 `Content-Disposition` 헤더를 `getFirst(HttpHeaders.CONTENT_DISPOSITION)`로 조회하고, `attachment; filename*=` 포함 여부를 검증하도록 수정했다.
- 왜: 기존 `response.headers.contentDisposition` null 체크만으로는 헤더 누락/형식 회귀를 충분히 잡지 못해 테스트 신뢰도를 높이기 위해서다.
- 어떻게:
- 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt`
- 범위 조정: 하위 호환성 유지 이슈는 요구사항 재확인 결과 작업 제외로 확정
- 실행 결과:
- `lsp_diagnostics (AdminChannelDonationCalculateControllerTest.kt)` → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*AdminChannelDonationCalculateControllerTest"` → 성공
- `./gradlew build` → 성공

View File

@@ -1,34 +0,0 @@
# 관리자 채널후원 크리에이터별 정산 조회 및 엑셀 API 작업 계획
- [x] 기존 관리자 채널후원 정산 API의 날짜별 조회 경로를 식별하고 URL 변경 범위를 확정한다.
- [x] 관리자 채널후원 정산 날짜별 조회 API URL을 목적에 맞게 변경한다.
- [x] 관리자 크리에이터별 채널후원 정산 조회 API(`GET /admin/calculate/channel-donation-by-creator`)를 구현한다.
- [x] 관리자 크리에이터별 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-creator/excel`)를 구현한다.
- [x] 크리에이터별 집계/카운트/합계 Query를 추가하고, 정산 계산 비율은 기존 채널후원 정산과 동일하게 적용한다.
- [x] 관련 테스트를 수정/추가하고 `./gradlew test`, `./gradlew build`로 검증한다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 채널후원 정산 API를 날짜별/크리에이터별로 분리하고, 크리에이터별 정산 엑셀 다운로드 API를 추가했다.
- 왜: 기존 `/admin/calculate/channel-donation-by-creator`가 날짜별 조회 성격이어서 URL 의미를 분리하고, 요청한 크리에이터별 목록/엑셀 기능을 제공하기 위해서다.
- 어떻게:
- 컨트롤러에서 기존 날짜별 조회 경로를 `GET /admin/calculate/channel-donation-by-date`로 변경했다.
- 신규 크리에이터별 조회 `GET /admin/calculate/channel-donation-by-creator`와 엑셀 다운로드 `GET /admin/calculate/channel-donation-by-creator/excel`를 추가했다.
- QueryRepository에 날짜별/크리에이터별 집계 메서드를 분리하고, 크리에이터별 총건수(distinct creator) 및 엑셀용 전체 조회를 추가했다.
- 서비스에서 크리에이터별 조회 응답 DTO와 엑셀(XSSFWorkbook) 생성 로직을 구현했다.
- 정산 비율/공식은 기존 `ChannelDonationSettlementCalculator`를 그대로 사용해 동일 정책을 유지했다.
- 테스트를 수정/추가해 날짜별 라우팅, 크리에이터별 조회, 엑셀 다운로드, Query 집계를 검증했다.
- 실행 결과:
- `lsp_diagnostics` (수정된 `.kt` 파일들) → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew build` → 성공
### 2차 수정
- 무엇을: 크리에이터별 정산 엑셀 다운로드 파일의 시트명과 헤더를 한글로 변경했다.
- 왜: 관리자 화면에서 다운로드한 엑셀의 컬럼 의미를 즉시 식별할 수 있도록 가독성을 높이기 위해서다.
- 어떻게:
- 시트명 `channel-donation-by-creator``크리에이터별 채널후원 정산`으로 변경했다.
- 헤더를 `크리에이터`, `건수`, `총 받은 캔 수`, `원화`, `수수료`, `정산금액`, `원천세`, `입금액`으로 변경했다.
- 실행 결과:
- `./gradlew test --tests "*channelDonation*"` → 성공

View File

@@ -1,41 +0,0 @@
# 20260303_기부목록조회월범위한국시간수정
## 구현 항목
- [x] `ChannelDonationService.kt``getChannelDonationList` 내 조회 범위 수정
- UTC 현재 시각을 기준으로 한국 시간(KST) 월 경계를 계산
- KST 월 경계(해당월 1일 00:00:00 ~ 다음달 1일 00:00:00)를 UTC 조회 구간으로 변환
- [x] 채널 후원 조회 UTC 전달값 검증 테스트 보강
- `ChannelDonationServiceTest`에서 전달된 UTC 범위를 KST로 역변환했을 때 월 경계가 유지되는지 검증
## 검증 결과
### 1차 구현
- 무엇을: 기부 목록 조회 시 사용되는 시간 범위를 한국 시간 기준으로 변경
- 왜: 현재 UTC 기준으로 1일~말일이 설정되어 있어 한국 사용자의 기대와 다름
- 어떻게: `ZoneId.of("Asia/Seoul")`을 사용하여 현재 한국 시간을 구하고, 해당 월의 시작일 자정을 계산하도록 수정함.
```kotlin
val kstZoneId = ZoneId.of("Asia/Seoul")
val nowKst = ZonedDateTime.now(kstZoneId)
val startDateTime = nowKst
.with(TemporalAdjusters.firstDayOfMonth())
.toLocalDate()
.atStartOfDay()
val endDateTime = startDateTime.plusMonths(1)
```
- 결과: 기존 단위 테스트(`ChannelDonationServiceTest`) 4건 모두 통과 확인.
- `./gradlew test --tests kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest` 실행 결과 성공.
### 2차 수정
- 무엇을: `getChannelDonationList`에서 월 조회 시작/종료 시각을 KST 기준으로 계산한 뒤 UTC `LocalDateTime`으로 변환해 repository에 전달하도록 수정
- 왜: KST 타임존만 적용하고 조회 파라미터를 UTC로 변환하지 않으면 조회 날짜가 기존과 동일하게 남아 월 경계가 의도대로 이동하지 않음
- 어떻게:
- `ChannelDonationService.kt`
- `ZonedDateTime.now(ZoneId.of("UTC"))`로 현재 시각을 얻고 `withZoneSameInstant(ZoneId.of("Asia/Seoul"))`로 KST 변환
- KST 월 시작/종료(`startDateTimeKst`, `endDateTimeKst`)를 각각 UTC로 변환해 `startDateTime`, `endDateTime` 생성
- `ChannelDonationServiceTest.kt`
- 캡처한 UTC 조회 파라미터를 KST로 역변환해 `1일 00:00:00` 및 `+1개월` 월 경계를 검증하도록 수정
- 정적 진단: `lsp_diagnostics` 실행 시 `.kt` 확장자 LSP 미구성으로 진단 불가(환경 제약)
- 검증 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공
- `./gradlew tasks --all` 실행: 성공
- 결과: KST 월 경계가 UTC 조회 구간으로 반영되어 예시와 같은 형태(예: 2026-03-01 00:00:00 KST → 2026-02-28 15:00:00 UTC 시작)로 조회 조건이 구성됨

View File

@@ -1,34 +0,0 @@
- [x] 관리자 차단 신규 API/DTO/서비스 파일 생성
- [x] 차단 처리 시 탈퇴 이유 저장 및 회원 비활성화 처리
- [x] 차단 처리 시 Redis 로그인 토큰 전체 삭제
- [x] 본인인증 회원 BlockAuth 기록 처리
- [x] 동일 본인인증 정보 계정 일괄 탈퇴 처리
- [x] 활성 계정 조회 조건을 `name + birth + di + uniqueCi`로 강화
- [x] 관리자 차단 서비스 테스트 추가
- [x] 정적 진단 및 테스트/빌드 검증
## 검증 기록
### 1차 구현
- 무엇을: `kr.co.vividnext.sodalive.admin.member` 패키지에 신규 관리자 차단 API(`AdminMemberBlockController`), 요청 DTO(`AdminMemberBlockRequest`), 서비스(`AdminMemberBlockService`)를 추가했다. 서비스에서 탈퇴 이유 저장/회원 비활성화, Redis 로그인 토큰 전체 삭제, 본인인증 정보 `BlockAuth` 기록을 순서대로 처리하고, 서비스 단위 테스트(`AdminMemberBlockServiceTest`)를 추가했다.
- 왜: 관리자 페이지에서 사용자 차단 시 계정 비활성화 이력, 세션 무효화, 본인인증 기반 재가입 차단 정보를 한 번의 동작으로 일관되게 처리하기 위해서다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
### 2차 수정
- 무엇을: 관리자 차단 시 차단 대상 회원의 본인인증 정보(`di`)와 동일한 활성 계정을 모두 조회해 일괄 탈퇴 처리하도록 `AdminMemberBlockService`를 수정했다. 각 대상 계정마다 탈퇴 사유(`SignOut`) 저장, 회원 비활성화, Redis 로그인 토큰 전체 삭제를 수행하고, 기존 `BlockAuth` 저장 로직은 유지했다. 테스트도 동일 본인인증 다계정 탈퇴 시나리오를 포함하도록 확장했다.
- 왜: 본인인증 정보를 공유하는 다중 계정을 관리자 차단 시 함께 정리해야 우회 가입 계정이 활성 상태로 남지 않기 때문이다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
### 3차 수정
- 무엇을: 활성 계정 조회 조건을 `di` 단일 조건에서 `name + birth + di + uniqueCi` AND 조건으로 강화했다. 이를 위해 `AuthRepository`의 활성 계정 조회 메서드를 `getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(...)`로 변경하고, 호출부인 `AdminMemberBlockService`, `AuthService.authenticate`를 모두 신규 메서드로 교체했다. `AdminMemberBlockServiceTest`도 신규 시그니처 기준으로 스텁/검증을 수정했다.
- 왜: `di`만으로 동일인을 판단하면 과매칭 리스크가 있어, 본인인증 핵심 식별 속성을 함께 사용해 활성 계정 판별 정확도를 높이기 위해서다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.

View File

@@ -1,32 +0,0 @@
# 관리자 정산 엑셀 다운로드 추가 작업 계획
- [x] 기존 정산 API 구조와 엑셀 다운로드 응답 패턴(`ResponseEntity<InputStreamResource>`)을 기준으로 구현 범위를 확정한다.
- [x] 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live/excel`)를 추가한다.
- [x] 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-list/excel`)를 추가한다.
- [x] 콘텐츠 후원 정산 엑셀 다운로드 API(`GET /admin/calculate/content-donation-list/excel`)를 추가한다.
- [x] 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-post/excel`)를 추가한다.
- [x] 크리에이터별 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live-by-creator/excel`)를 추가한다.
- [x] 크리에이터별 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-by-creator/excel`)를 추가한다.
- [x] 크리에이터별 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-by-creator/excel`)를 추가한다.
- [x] 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-date/excel`)를 추가한다.
- [x] 각 엑셀 API가 시작/끝 날짜를 받아 전체 데이터를 내려주도록 서비스/리포지토리를 확장한다.
- [x] `lsp_diagnostics`, 테스트, 빌드로 변경사항을 검증하고 결과를 문서 하단에 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 정산 API 8종(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별)에 `/excel` 다운로드 엔드포인트를 추가하고, 전체 데이터 엑셀 생성 서비스를 구현했다.
- 왜: 페이지네이션 기반 조회 API와 별도로 시작일/종료일 기준의 전체 정산 데이터를 한 번에 내려받을 수 있어야 한다는 요구사항을 충족하기 위해서다.
- 어떻게:
- `AdminCalculateController`에 7개 엔드포인트(`.../excel`)를 추가하고 공통 다운로드 헤더(`Content-Disposition`, xlsx content type)를 적용했다.
- `AdminCalculateService`에 7개 엑셀 생성 메서드를 추가해 기간 변환 후 전체 데이터 조회 및 `XSSFWorkbook` 기반 시트/헤더/행 작성을 구현했다.
- 페이지네이션 대상(커뮤니티 정산, 크리에이터별 정산 3종)은 `totalCount`를 조회해 `offset=0`, `limit=totalCount`로 전체 행을 조회하도록 처리했다.
- `AdminChannelDonationCalculateController``GET /admin/calculate/channel-donation-by-date/excel`를 추가하고 기존 크리에이터별 엑셀 응답 로직과 동일한 규칙을 적용했다.
- `AdminChannelDonationCalculateService`에 날짜별 엑셀 다운로드 메서드를 추가해 전체 데이터 기준 시트를 생성했다.
- 테스트를 보강했다.
- `AdminChannelDonationCalculateControllerTest`: 날짜별 엑셀 다운로드 테스트 추가
- `AdminChannelDonationCalculateServiceTest`: 날짜별 엑셀 바이트 생성 테스트 추가
- 실행 결과:
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
- `./gradlew build` → 성공

View File

@@ -1,20 +0,0 @@
# 관리자 정산 콘텐츠 크리에이터별 조회 SQL 오류 수정 작업 계획
- [x] `/admin/calculate/content-by-creator` 호출 경로(Controller/Service/Repository)와 SQL 생성 지점을 확인한다.
- [x] `ONLY_FULL_GROUP_BY` 위반 원인(`content_settlement_ratio` 비집계 컬럼)을 제거하는 최소 수정안을 적용한다.
- [x] 수정된 쿼리가 기존 응답 스키마/정산 계산 로직과 호환되는지 코드 레벨로 검증한다.
- [x] `lsp_diagnostics`, 관련 테스트, 빌드를 실행해 정상 동작을 검증한다.
## 검증 기록
### 1차 수정
- 무엇을: `AdminCalculateQueryRepository#getCalculateContentByCreator``groupBy``member.id`에서 `member.id, creatorSettlementRatio.contentSettlementRatio`로 수정해 SELECT의 비집계 컬럼(`contentSettlementRatio`)이 GROUP BY에 포함되도록 변경했다.
- 왜: `/admin/calculate/content-by-creator` 조회 시 `creator_settlement_ratio.content_settlement_ratio`가 SELECT 절에 존재하지만 GROUP BY에 없어 MySQL `ONLY_FULL_GROUP_BY` 모드에서 SQLSyntaxErrorException이 발생했기 때문이다.
- 어떻게:
- 경로/원인 확인: `AdminCalculateController#getCalculateContentByCreator` -> `AdminCalculateService#getCalculateContentByCreator` -> `AdminCalculateQueryRepository#getCalculateContentByCreator` 호출 체인을 확인했다.
- 코드 수정: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`의 콘텐츠 크리에이터별 조회 쿼리 `groupBy`를 보완했다.
- 검증 실행 결과:
- `lsp_diagnostics` (`AdminCalculateQueryRepository.kt`) -> Kotlin LSP 미설정으로 진단 불가
- `./gradlew test` -> 성공
- `./gradlew build -x test` -> 성공
- `./gradlew tasks --all` -> 성공

View File

@@ -1,14 +0,0 @@
- [x] 페이징 미적용 관리자 정산 API 식별
- [x] Controller에 Pageable 파라미터 추가 및 Service 호출에 offset/limit 전달
- [x] Service/Repository 쿼리에 offset/limit 반영
- [x] 정적 진단 및 테스트/빌드 검증
## 검증 기록
### 1차 구현
- 무엇을: 관리자 정산 API 중 페이징이 없던 `/admin/calculate/live`, `/admin/calculate/content-list`, `/admin/calculate/content-donation-list``Pageable` 기반 페이징을 추가하고, 응답을 `totalCount + items` 구조로 변경했다. 또한 동일 쿼리를 사용하는 엑셀 다운로드 로직이 기존과 동일하게 전체 데이터를 내려주도록 totalCount 기반 전체 조회 방식으로 맞췄다.
- 왜: 조회 건수가 많아질 수 있는 정산 목록 API에서 페이지 단위 조회를 지원해 응답 크기와 조회 성능을 안정적으로 관리하기 위해서다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.

View File

@@ -1,12 +0,0 @@
# 관리자 충전 상태 상세 응답 필드 수정
- [x] `GetChargeStatusDetailResponse`에서 `memberId` 제거
- [x] `GetChargeStatusDetailResponse``chargeId` 추가
- [x] 연관 매핑 코드 반영 및 빌드 검증
## 검증 기록
### 1차 구현
- 무엇을: 관리자 충전 상세 응답 DTO의 식별자를 `memberId`에서 `chargeId`로 변경하고, Query DTO/서비스 매핑/QueryDSL select 값을 동일하게 정합성 맞춰 수정했다.
- 왜: 충전 상세 응답에서 회원 식별자 대신 충전 건 식별자를 내려주도록 요구사항이 변경되었기 때문이다.
- 어떻게: `lsp_diagnostics``.kt` 확장자 LSP 미설정으로 도구 검증이 불가해 사유를 확인했고, `./gradlew build`를 실행해 컴파일/테스트/체크를 통합 검증했으며 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -1,12 +0,0 @@
# 관리자 충전 상세 캔 개수 추가
- [x] `GetChargeStatusDetailResponse``chargeCan`, `rewardCan` 필드 추가
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` QueryProjection 인자에 캔 개수 매핑 추가
- [x] 관련 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
## 검증 기록
### 1차 구현
- 무엇을: 관리자 충전 상세 응답 DTO에 `chargeCan`, `rewardCan` 필드를 추가하고, 상세 조회 QueryProjection(`QGetChargeStatusDetailResponse`) 인자에 `charge.chargeCan`, `charge.rewardCan` 매핑을 추가했다.
- 왜: 충전 상세 응답에 유료 캔/보너스 캔 수량 정보를 함께 내려주기 위한 요구사항을 반영하기 위해서다.
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test``./gradlew build -x test`를 실행해 테스트/빌드 모두 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -1,13 +0,0 @@
# 관리자 충전 상세 QueryProjection 리팩토링
- [x] `AdminChargeStatusService.getChargeStatusDetail` 후처리 매핑 제거
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` 반환 타입을 응답 DTO QueryProjection으로 변경
- [x] 관련 DTO/QueryDSL 생성 타입 정합성 확인
- [x] 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
## 검증 기록
### 1차 구현
- 무엇을: `GetChargeStatusDetailResponse``@QueryProjection`을 적용하고, `AdminChargeStatusQueryRepository`가 해당 DTO를 직접 select 하도록 변경했으며, 서비스의 후처리 `map`을 제거했다. 또한 불필요해진 `GetChargeStatusDetailQueryDto.kt` 파일을 삭제했다.
- 왜: 상세 응답 가공을 서비스에서 한 번 더 수행하지 않고 DB 조회 시점(QueryProjection)에서 완성된 응답 형태를 가져오도록 구조를 단순화하기 위해서다.
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test``./gradlew build -x test`를 실행해 테스트/빌드 성공(`BUILD SUCCESSFUL`)을 확인했다.

View File

@@ -1,27 +0,0 @@
# 관리자 정산 엑셀 스트리밍 전환 작업 계획
- [x] 기존 정산 엑셀 다운로드 API의 요청/응답 계약(엔드포인트, 쿼리 파라미터, 헤더)을 유지한다.
- [x] `AdminCalculateController`의 엑셀 응답 타입을 `StreamingResponseBody` 기반으로 전환한다.
- [x] `AdminCalculateService`의 엑셀 생성 방식을 `XSSFWorkbook + ByteArrayOutputStream`에서 `SXSSFWorkbook + 스트리밍 write`로 전환한다.
- [x] `AdminChannelDonationCalculateController`의 날짜별/크리에이터별 엑셀 응답을 `StreamingResponseBody` 기반으로 전환한다.
- [x] `AdminChannelDonationCalculateService`의 날짜별/크리에이터별 엑셀 생성을 `SXSSFWorkbook` 스트리밍 방식으로 전환한다.
- [x] 관련 테스트를 스트리밍 응답 기준으로 수정한다.
- [x] `lsp_diagnostics`, 테스트, 빌드를 실행하고 결과를 검증 기록에 남긴다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 정산 엑셀 다운로드 API 전체(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별/채널후원 크리에이터별)의 서버 내부 생성/전송 방식을 스트리밍으로 전환했다.
- 왜: 기존 `XSSFWorkbook + ByteArrayOutputStream + InputStreamResource` 방식은 전체 워크북과 바이트 배열을 메모리에 유지해 대용량 다운로드 시 피크 메모리 사용량이 커지기 때문이다.
- 어떻게:
- 컨트롤러 응답 타입을 `ResponseEntity<StreamingResponseBody>`로 변경하고, 기존 파일명 인코딩/`Content-Disposition`/xlsx MIME 타입은 유지했다.
- 서비스 반환 타입을 `StreamingResponseBody`로 변경하고 `SXSSFWorkbook(100)`로 row window 기반 생성 후 `outputStream`에 직접 `write`하도록 변경했다.
- 스트리밍 완료 시 `workbook.dispose()``workbook.close()`를 호출해 임시 파일/리소스 해제를 보장했다.
- 채널후원 컨트롤러/서비스(날짜별, 크리에이터별)에도 동일 패턴을 적용했다.
- 테스트를 스트리밍 응답 기준으로 수정했다.
- 컨트롤러 테스트: `InputStreamResource` 검증 -> `StreamingResponseBody` 검증
- 서비스 테스트: `readAllBytes()` -> `StreamingResponseBody.writeTo(ByteArrayOutputStream)` 검증
- 실행 결과:
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
- `./gradlew build` → 성공

View File

@@ -1,38 +0,0 @@
- [x] 기존 charge/payment/member 및 admin API 패턴 확인
- [x] `kr.co.vividnext.sodalive.admin.charge` 패키지에 캔 환불 API 생성
- [x] 환불 조건 검증 구현 (미사용, 7일 이내)
- [x] ChargeEntity/PaymentEntity/MemberEntity 환불 반영 로직 구현
- [x] 캔 환불 API 테스트 코드 작성
- [x] 검증 실행 및 결과 기록
## 환불 조건 상세
- 환불 가능 충전내역 조건: `charge.status == CHARGE` 그리고 `payment.status == COMPLETE`
- 이미 사용한 캔 판정 조건: `charge.title`에서 숫자를 추출해 현재 `chargeCan/rewardCan`과 비교
- 예시1) `100 캔 + 50 캔` -> `chargeCan = 100`, `rewardCan = 50`
- 예시2) `5,000 캔 + 500 캔` -> `chargeCan = 5000`, `rewardCan = 500`
- 예시3) `500캔` -> `chargeCan = 500`
- 예시4) `4,000 캔` -> `chargeCan = 4000`
## 검증 기록
### 1차 구현
- 무엇을: 관리자 캔 환불 API(`POST /admin/charge/refund`)와 환불 서비스/요청 DTO, i18n 메시지, 단위 테스트를 추가했다.
- 왜: 사용하지 않은 캔만 7일 이내 환불 가능하도록 하고, 환불 시 Charge/Payment/Member 상태를 요구사항대로 갱신하기 위해.
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
- `./gradlew build` 실행 → 성공 (ktlint/check/test/build 포함)
- LSP 진단 시도(`lsp_diagnostics`) → Kotlin LSP 미설정으로 불가, 대신 Gradle 컴파일/ktlint/test/build로 검증
### 2차 수정
- 무엇을: `AdminChargeRefundServiceTest`에 한글 `@DisplayName`을 추가하고, 각 테스트 문단에 given/when/then 역할 주석을 보강했다.
- 왜: 테스트 의도를 한눈에 파악하고, 문단별 책임을 명확히 하기 위해.
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
- `./gradlew ktlintTestSourceSetCheck` 실행 → 성공
### 3차 수정
- 무엇을: 이미 사용한 캔 판정을 `charge.title` 숫자 파싱 비교 방식으로 변경하고, 단일 숫자/콤마 포함 제목 테스트 케이스를 추가했다.
- 왜: 환불 조건을 충전 제목 기반 비교 규칙(단일/복수 숫자, 콤마 포함)으로 명확하게 적용하기 위해.
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
- `./gradlew build` 실행 → 성공

View File

@@ -1,16 +0,0 @@
- [x] `getCalculateContentDonationList` 호출 경로(Controller → Service → QueryData) 확인
- [x] 유료/무료 콘텐츠 후원 정산 비율이 모두 70%로 적용되는지 검증
- [x] `GetCalculateContentDonationQueryData` 계산 로직의 불필요 분기/중복 제거 및 가독성 개선
- [x] 관련 테스트/빌드/정적 진단 실행 및 결과 확인
---
## 검증 기록
### 1차 구현
- 무엇을: `GetCalculateContentDonationQueryData`에서 유료/무료 공통 정산 비율 70% 적용 상태를 확인하고, 정산 계산 상수(`KRW_PER_CAN`, `PAYMENT_FEE_RATE`, `SETTLEMENT_RATE`, `TAX_RATE`)를 `companion object`로 추출해 계산 로직을 정리했다.
- 왜: 유료/무료 분기 제거 후 동일 70% 정책을 명확히 유지하고, `BigDecimal` 상수 재사용으로 계산 의도와 유지보수성을 높이기 위해서다.
- 어떻게: 호출 경로(`AdminCalculateController``AdminCalculateService``AdminCalculateQueryRepository``GetCalculateContentDonationQueryData`)를 확인했고, 정적 진단은 `.kt` LSP 미구성으로 대체 검증했다. 실행 명령과 결과는 아래와 같다.
- `lsp_diagnostics` (`GetCalculateContentDonationQueryData.kt`): Kotlin LSP 미지원으로 실행 불가(환경 제약 확인)
- `./gradlew test`: 성공 (`BUILD SUCCESSFUL`)
- `./gradlew build`: 성공 (`BUILD SUCCESSFUL`, `ktlintMainSourceSetCheck` 포함)

View File

@@ -1,16 +0,0 @@
- [x] deep_link 파라미터 추가 여부를 푸시 발송 코드 기준으로 확인한다.
- [x] deep_link 값이 `voiceon://community/345` 형태인지 생성 규칙을 확인한다.
- [x] 검증 결과를 문서 하단에 기록한다.
## 검증 기록
### 1차 확인
- 무엇을: 푸시 발송 시 FCM payload에 `deep_link` 파라미터가 실제로 추가되는지와 커뮤니티 알림 형식이 `voiceon://community/{id}`인지 확인했다.
- 왜: 서버 구현이 문서 설명과 일치하는지, 그리고 앱이 기대하는 딥링크 문자열을 실제로 내려주는지 검증하기 위해서다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: `createDeepLink(deepLinkValue, deepLinkId)` 결과가 null이 아니면 `multicastMessage.putData("deep_link", deepLink)`로 payload에 추가됨.
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: 생성 규칙은 `server.env == voiceon`일 때 `voiceon://{deepLinkValue.value}/{deepLinkId}`, 그 외 환경은 `voiceon-test://{deepLinkValue.value}/{deepLinkId}`임.
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` 확인: 커뮤니티 새 글 알림은 `deepLinkValue = FcmDeepLinkValue.COMMUNITY`, `deepLinkId = member.id!!`를 전달하므로 운영 환경 기준 최종 값은 `voiceon://community/{creatorId}` 형식임.
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt` 확인: 커뮤니티 목록 조회 API가 `creatorId`를 받으므로 커뮤니티 딥링크의 식별자도 크리에이터 ID 기준과 일치함.
- `./gradlew build` 실행(성공)
- 코드 수정은 하지 않음(확인 작업만 수행).

View File

@@ -1,29 +0,0 @@
- [x] FCM 푸시 생성 경로에서 딥링크 파라미터 추가 위치 확정
- [x] `server.env` 기반 URI scheme(`voiceon://`, `voiceon-test://`) 분기 로직 구현
- [x] `deep_link_value` 매핑 규칙(`live`, `channel`, `content`, `series`, `audition`, `community`) 반영
- [x] FCM payload에 최종 딥링크 문자열(`{URISCHEME}://{deep_link_value}/{ID}`) 주입
- [x] 관련 테스트/검증 수행 후 결과 기록
## 검증 기록
### 1차 구현
- 무엇을: FCM 이벤트에 딥링크 메타(`deepLinkValue`, `deepLinkId`)를 추가하고, `FcmService`에서 `deep_link` payload(`{URISCHEME}://{deep_link_value}/{ID}`)를 생성하도록 구현했다.
- 왜: 푸시 수신 시 앱이 직접 딥링크로 진입하도록 서버에서 일관된 규칙으로 URL을 포함하기 위해서다.
- 어떻게:
- `./gradlew test` 실행(성공)
- `./gradlew build` 실행(초기 실패: import 정렬 ktlint 위반)
- `./gradlew ktlintFormat` 실행(성공)
- `./gradlew test && ./gradlew build` 재실행(성공)
- LSP 진단은 Kotlin LSP 미구성 환경으로 실행 불가(Gradle 컴파일/테스트/ktlint로 대체 검증)
### 2차 수정
- 무엇을: 오디션 푸시의 `deepLinkId``-1` 대체값이 아닌 실제 `audition.id` nullable 값으로 조정했다.
- 왜: ID가 null일 때 비정상 딥링크(`/audition/-1`)가 생성되는 가능성을 제거하기 위해서다.
- 어떻게:
- `./gradlew test && ./gradlew build` 실행(성공)
### 3차 수정
- 무엇을: `server.env` 값 해석 기준을 `voiceon`(프로덕션), `voiceon_test` 및 그 외(개발/기타)로 조정했다.
- 왜: 실제 운영 환경 변수 규칙과 딥링크 URI scheme 선택 조건을 일치시키기 위해서다.
- 어떻게:
- `./gradlew test && ./gradlew build` 실행(성공)

View File

@@ -1,179 +0,0 @@
- [x] 요구사항 확정: 푸시 발송 내용을 알림 리스트에 적재하고, 미수신 상황에서도 조회 가능하도록 범위를 고정한다.
- [x] 도메인 모델 설계: 알림 본문/발송자 스냅샷/카테고리/딥링크/언어코드/수신자 청크(JSON 배열) 저장 구조를 JPA 엔티티로 정의한다.
- [x] 푸시 적재 로직 구현: 수신자가 없으면 저장하지 않고, 언어별 데이터로 분리 저장하며 수신자 ID를 청크 단위(JSON 배열)로 기록한다.
- [x] 조회 기간 제한 구현: 알림 조회는 최근 1개월 데이터만 조회하도록 서비스/리포지토리에 공통 조건을 적용한다.
- [x] API 구현: 인증 사용자 기준 알림 목록 조회 API(전체/카테고리별)와 알림 존재 카테고리 조회 API를 구현한다.
- [x] 카테고리 다국어 응답 구현: 카테고리 조회 API 응답을 현재 기기 언어(ko/en/ja) 라벨로 반환한다.
- [x] 페이징 구현: Pageable 파라미터를 사용해 offset/limit 기반 조회를 적용한다.
- [x] 시간 포맷 구현: 발송시간을 UTC 기반 String으로 응답 DTO에 포함한다.
- [x] TDD 구현: 스프링 컨테이너 없이 실행 가능한 단위 테스트를 먼저 작성하고, 구현 후 테스트를 통과시킨다.
- [x] SQL 문서화: 신규 테이블 생성 SQL 및 추가 인덱스 SQL(MySQL, TIMESTAMP NOT NULL)을 문서 하단에 기록한다.
## API 상세 작업 계획
### 1) GET `/push/notification/list`
- 목적: 인증 사용자의 알림 리스트를 현재 기기 언어 기준으로 조회한다.
- 요청 파라미터:
- `page`, `size`, `sort` (Pageable)
- `category` (선택, 없으면 전체 조회)
- 처리 규칙:
- 인증 사용자(`Member?`) null이면 `SodaException(messageKey = "common.error.bad_credentials")`
- 현재 요청 언어(`LangContext.lang.code`)와 일치하는 알림만 조회
- 조회 범위는 `now(UTC) - 1개월` 이후 데이터만 허용
- `category` 미지정 시 전체 카테고리 조회
- `category`는 코드(`live`) 또는 다국어 라벨(`라이브`/`Live`/`ライブ`) 입력을 허용한다
- `category``전체`/`All`/`すべて`이면 전체 카테고리 조회로 처리한다
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 알림만 조회
- 응답 항목:
- 발송자 스냅샷(닉네임, 프로필 이미지)
- 발송 메시지
- 카테고리
- 딥링크
- 발송시간(UTC String)
- 구현 작업:
- [x] Controller: 인증/파라미터/ApiResponse 처리
- [x] Service: 1개월/언어/카테고리/페이지 조건 조합
- [x] Repository: 수신자 청크 JSON membership + pageable 조회 + totalCount
- [x] DTO: `GetPushNotificationListResponse`, `PushNotificationListItem` 정의
### 2) GET `/push/notification/categories`
- 목적: 인증 사용자 기준으로 알림 데이터가 실제 존재하는 카테고리만 조회한다.
- 요청 파라미터: 없음
- 처리 규칙:
- 인증 필수
- 현재 요청 언어 기준 데이터만 대상
- 최근 1개월 데이터만 대상
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 데이터만 대상
- 응답 항목:
- 카테고리 목록(현재 기기 언어 라벨)
- 구현 작업:
- [x] Controller: 인증/ApiResponse 처리
- [x] Service: 중복 제거된 카테고리 목록 반환
- [x] Repository: 사용자/언어/기간 기반 카테고리 distinct 조회
- [x] DTO: `GetPushNotificationCategoryResponse` 정의
## 비API 작업 계획
- [x] FCM 이벤트 모델 확장: 알림 리스트 적재에 필요한 카테고리/발송자 스냅샷 정보를 이벤트에 포함한다.
- [x] FCM 전송 리스너 연동: 언어별 푸시 전송 시점에 알림 리스트 저장 서비스를 호출한다.
- [x] 발송자 스냅샷 처리: 이벤트 스냅샷 우선 사용, 없으면 발송자 ID 기반 조회로 보완한다.
- [x] 딥링크 저장 처리: 현재 푸시 딥링크 규칙과 동일한 값으로 저장한다.
- [x] 수신자 청크 저장 처리: 수신자 ID를 고정 크기 청크로 분할해 JSON 배열로 저장한다.
- [x] 수신자 미존재 처리: 최종 수신자 ID가 비어 있으면 알림 자체를 저장하지 않는다.
## 테스트(TDD) 계획
- [x] 단위 테스트: 알림 저장 서비스가 수신자 없음/언어별 분리/청크 분할/스냅샷 저장을 정확히 처리하는지 검증한다.
- [x] 단위 테스트: 조회 서비스가 1개월 제한/언어 필터/카테고리 옵션/pageable 전달을 정확히 적용하는지 검증한다.
- [x] 단위 테스트: 카테고리 조회 서비스가 사용자/언어/기간 기준 distinct 결과를 반환하는지 검증한다.
- [x] 단위 테스트: 컨트롤러가 인증 실패 시 에러 응답을 반환하고, 정상 시 서비스 호출 파라미터를 올바르게 전달하는지 검증한다.
## SQL 초안 (구현 확정)
### 1) 신규 테이블 생성 SQL (MySQL)
```sql
CREATE TABLE push_notification_list
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
sender_nickname_snapshot VARCHAR(255) NOT NULL COMMENT '발송자 닉네임 스냅샷',
sender_profile_image_snapshot VARCHAR(500) NULL COMMENT '발송자 프로필 이미지 스냅샷',
message TEXT NOT NULL COMMENT '발송 메시지',
category VARCHAR(20) NOT NULL COMMENT '발송 카테고리',
deep_link VARCHAR(500) NULL COMMENT '딥링크',
language_code VARCHAR(8) NOT NULL COMMENT '언어 코드',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)'
) COMMENT ='푸시 알림 리스트';
CREATE TABLE push_notification_recipient_chunk
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
notification_id BIGINT NOT NULL COMMENT '알림 ID',
recipient_member_ids JSON NOT NULL COMMENT '수신자 회원 ID 청크(JSON 배열)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)',
CONSTRAINT fk_push_notification_recipient_chunk_notification
FOREIGN KEY (notification_id) REFERENCES push_notification_list (id)
) COMMENT ='푸시 알림 수신자 청크';
```
### 2) 추가 인덱스 SQL (MySQL)
```sql
ALTER TABLE push_notification_list
ADD INDEX idx_push_notification_list_language_created (language_code, created_at, id),
ADD INDEX idx_push_notification_list_category_language_created (category, language_code, created_at, id);
ALTER TABLE push_notification_recipient_chunk
ADD INDEX idx_push_notification_recipient_chunk_notification (notification_id);
-- MySQL 8.0.17+ 환경에서 JSON 배열 membership 최적화가 필요할 때 사용
ALTER TABLE push_notification_recipient_chunk
ADD INDEX idx_push_notification_recipient_chunk_member_ids_mvi ((CAST(recipient_member_ids AS UNSIGNED ARRAY)));
```
#### MVI 조건부 적용 가이드 (짧게)
- MySQL 8.0.17+ 환경이면 인덱스를 먼저 추가해 둔다.
- 실제 사용 여부는 옵티마이저가 쿼리 조건과 비용을 보고 결정하므로 `EXPLAIN`으로 확인한다.
- 현재 조회 조건처럼 `JSON_CONTAINS(JSON컬럼, JSON_ARRAY(값), '$')` 형태일 때 사용 후보가 된다.
- 인덱스가 선택되지 않아도 기능 오동작은 없지만, 쓰기/저장공간 비용은 항상 발생한다.
## 검증 기록
### 1차 구현
- 무엇을: 푸시 발송 시 언어별 메시지를 알림 리스트로 적재하는 `PushNotificationService`와 관련 JPA 엔티티/리포지토리/조회 API 2종(`/push/notification/list`, `/push/notification/categories`)을 추가하고, 기존 `FcmEvent` 발행 지점에 카테고리/발송자 스냅샷 소스를 연결했다.
- 왜: 푸시를 놓친 사용자도 최근 1개월 내 알림을 현재 기기 언어 기준으로 확인하고, 카테고리별 필터/카테고리 존재 여부를 조회할 수 있어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew test` 실행(성공)
- `./gradlew build` 실행(초기 실패: ktlint import 정렬 위반)
- `./gradlew ktlintFormat` 실행(성공)
- `./gradlew test` 재실행(성공)
- `./gradlew build` 재실행(성공)
- Kotlin LSP 미구성으로 `lsp_diagnostics`는 실행 불가, Gradle test/build/ktlint로 대체 검증
### 2차 수정
- 무엇을: `PushNotificationRecipientChunk``chunkOrder` 필드를 제거하고, 저장 로직/문서 SQL(컬럼 및 인덱스)을 함께 정리했다.
- 왜: 저장 시점에만 값이 할당되고 조회/정렬/필터에서 실제 사용되지 않아 불필요한 데이터였기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 3차 수정
- 무엇을: 알림 리스트 조회를 `PushNotificationListRow -> service map` 구조에서 `PushNotificationListItem` 직접 프로젝션 구조로 변경하고, 조회/카운트 쿼리에서 `innerJoin + distinct/countDistinct`를 제거해 `EXISTS` 기반 JSON membership 필터로 최적화했다.
- 왜: 중간 변환 객체가 불필요하고, 조인 기반 중복 제거 비용(distinct/countDistinct)이 커질 수 있어 페이지 조회 성능을 개선하기 위해서다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 4차 수정
- 무엇을: `sentAt` 포맷을 DB `DATE_FORMAT` 문자열 생성 방식에서 `PushNotificationListItem` QueryProjection 생성자 기반 UTC Instant 문자열(`...Z`) 생성 방식으로 변경했다.
- 왜: `GetLatestFinishedLiveResponse.dateUtc`와 동일하게 애플리케이션 레이어에서 명시적 UTC 변환을 적용해 포맷/의미 일관성을 맞추기 위해서다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 5차 수정
- 무엇을: `getAvailableCategories`가 카테고리 코드를 그대로 반환하던 동작을, 현재 기기 언어(`ko/en/ja`)에 맞는 카테고리 라벨을 반환하도록 변경했다.
- 왜: 카테고리 조회 응답을 조회 기기 언어에 따라 한글/영어/일본어로 내려주어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 6차 수정
- 무엇을: `getAvailableCategories` 응답 리스트 맨 앞에 `전체` 항목을 고정 추가하고, `ko/en/ja` 다국어 라벨로 반환하도록 변경했다.
- 왜: 카테고리 필터 UI에서 전체 조회 옵션이 항상 첫 번째로 필요하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 7차 수정
- 무엇을: `getNotificationList``category` 입력이 한글/영어/일본어 라벨(`라이브`/`Live`/`ライブ` 등)도 파싱되도록 확장하고, `전체`/`All`/`すべて` 입력은 전체 조회로 처리하도록 수정했다.
- 왜: 카테고리 조회 API가 다국어 라벨을 반환하므로, 목록 조회 API도 동일 라벨 입력을 처리할 수 있어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotificationServiceTest"` 실행(성공)
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 8차 수정
- 무엇을: 추가 인덱스 SQL 하단에 MVI 인덱스의 조건부 사용 가이드를 짧게 추가했다.
- 왜: 인덱스는 선반영 가능하지만 실제 사용은 쿼리/옵티마이저 조건에 따라 달라진다는 점을 문서에 명시하기 위해서다.
- 어떻게:
- `./gradlew tasks --all` 실행(성공)

View File

@@ -1,17 +0,0 @@
# 푸시 알림 조회 쿼리 오류 수정
- [x] `PushNotificationController` 연계 조회 API에서 발생한 DB 조회 오류 재현 경로와 실제 실패 쿼리 식별
- [x] `QuerySyntaxException` 원인인 JPQL/HQL 함수 사용 구문을 코드베이스 패턴에 맞게 수정
- [x] 수정 코드 정적 진단 및 테스트/빌드 검증 수행
- [x] 검증 결과를 문서 하단에 기록
## 검증 기록
### 1차 수정
- 무엇을: `PushNotificationListRepository.recipientContainsMember`의 QueryDSL 템플릿을 `JSON_CONTAINS({0}, JSON_ARRAY({1}), '$')`에서 `function('JSON_CONTAINS', {0}, function('JSON_ARRAY', {1}), '$') = 1`로 수정했다.
- 왜: Hibernate JPQL/HQL 파서는 MySQL 함수명(`JSON_CONTAINS`, `JSON_ARRAY`) 직접 호출 구문을 인식하지 못해 `QuerySyntaxException`이 발생하므로, JPQL 표준 함수 호출 래퍼(`function`)로 감싸 파싱 가능하도록 변경이 필요했다.
- 어떻게:
- 검색: `grep`/AST/Explore/Librarian로 `PushNotificationController -> PushNotificationService -> PushNotificationListRepository` 호출 흐름과 문제 쿼리를 확인했다.
- 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나 현재 환경에 `.kt` LSP 서버 미설정으로 실행 불가를 확인했다.
- 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest" --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationControllerTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 빌드: `./gradlew build -x test` 실행 결과 `BUILD SUCCESSFUL`.

View File

@@ -1,14 +0,0 @@
- [x] getFollowingAllChannelList 오류 재현 경로와 원인 쿼리 위치를 확인한다.
- [x] only_full_group_by 호환 방식으로 조회 쿼리를 수정한다.
- [x] 관련 응답/페이징 동작이 유지되는지 확인한다.
- [x] 변경 파일 진단과 테스트/빌드를 수행한다.
## 검증 기록
### 1차 구현
- 무엇을: `getCreatorFollowingAllList` 쿼리의 `groupBy` 컬럼을 `member.id`, `member.nickname`, `member.profileImage`, `creatorFollowing.isNotify`로 확장하고, 회귀 방지를 위해 `LiveRecommendRepositoryTest.shouldReturnFollowingCreatorListWithNotifyFlag` 테스트를 추가했다.
- 왜: `only_full_group_by` 모드에서 SELECT에 포함된 비집계 컬럼(`creatorFollowing.isNotify`)이 GROUP BY에 없어 발생하는 SQL 오류를 제거하고, 팔로잉 목록 응답(`isNotify` 포함) 동작을 재검증하기 위해서다.
- 어떻게:
- 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest.shouldReturnFollowingCreatorListWithNotifyFlag"` / 결과: 성공
- 명령: `./gradlew build` / 결과: 성공
- 명령: `lsp_diagnostics` / 결과: `.kt` 확장 LSP 미구성으로 실행 불가(대신 Gradle 컴파일/테스트 성공으로 검증)

View File

@@ -1,36 +0,0 @@
- [x] 요구사항/기존 패턴 확정: 크리에이터 커뮤니티 댓글 등록 시점에 푸시 발송 + 알림 리스트 저장 경로를 기존 FCM 이벤트 파이프라인으로 연결한다.
- QA: `CreatorCommunityService#createCommunityPostComment`, `FcmEvent`, `FcmSendListener`, `PushNotificationService` 흐름을 코드로 확인한다.
- [x] 딥링크 규칙 확정: 댓글 알림의 딥링크를 `voiceon://community/{creatorId}?postId={postId}`(테스트 환경은 `voiceon-test://community/{creatorId}?postId={postId}`)로 생성되도록 이벤트 메타를 설정한다.
- QA: `FcmService.buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)` 규칙과 `creatorId/postId` 매핑을 확인한다.
- [x] 댓글 등록 시 알림 이벤트 구현: 댓글 작성자가 크리에이터 본인이 아닌 경우에만 크리에이터 대상 `INDIVIDUAL` 이벤트를 발행한다.
- QA: 이벤트에 `category=COMMUNITY`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `deepLinkCommentPostId=postId`, `recipients=[creatorId]`가 포함되는지 확인한다.
- [x] 알림 문구 메시지 키 추가: 크리에이터 커뮤니티 댓글 알림용 다국어 키를 `SodaMessageSource`에 추가한다.
- QA: KO/EN/JA 값이 모두 존재하고 `messageKey`로 조회 가능해야 한다.
- [x] 검증 실행: 수정 파일 LSP 진단, 관련 테스트, 전체 빌드 실행 후 결과를 기록한다.
- QA: `./gradlew test`, `./gradlew build` 성공.
## 완료 기준 (Acceptance Criteria)
- [x] 댓글 등록 API 호출 후(작성자 != 크리에이터) `FcmEvent`가 발행되어 크리에이터에게 푸시 전송 대상이 생성된다.
- [x] 동일 이벤트로 저장되는 알림 리스트의 `deepLink` 값이 푸시 payload `deep_link`와 동일 규칙으로 생성된다.
- [x] 댓글 알림 딥링크는 커뮤니티 전체보기 진입 경로(`community/{creatorId}`)를 유지하면서 대상 게시글 식별자(`postId`)를 포함한다.
- [x] 기존 커뮤니티 새 글 알림 및 다른 도메인 푸시 딥링크 동작에 회귀 영향이 없다.
## 검증 기록
### 1차 구현
- 무엇을: `CreatorCommunityService#createCommunityPostComment`에 댓글 등록 직후 크리에이터 대상 `FcmEventType.INDIVIDUAL` 이벤트 발행 로직을 추가했다. 이벤트에는 `category=COMMUNITY`, `messageKey=creator.community.fcm.new_comment`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `recipients=[creatorId]`를 설정했고, 크리에이터 본인 댓글은 알림을 발행하지 않도록 제외했다. 또한 `SodaMessageSource``creator.community.fcm.new_comment` 다국어 메시지를 추가했다.
- 왜: 댓글 알림 수신자가 푸시 터치/알림 리스트 터치 시 동일 딥링크(`community/{creatorId}`)로 이동하도록, 기존 FCM 이벤트-알림 저장 공통 경로를 그대로 재사용하기 위해서다.
- 어떻게:
- `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계)
- `./gradlew test --tests "*CreatorCommunityServiceTest"` 실행(성공)
- `./gradlew test` 실행(성공)
- `./gradlew build` 실행(성공)
### 2차 수정
- 무엇을: 커뮤니티 댓글 알림 딥링크에 `postId`를 함께 전달하도록 `FcmEvent``deepLinkCommentPostId`를 추가하고, `FcmService.buildDeepLink`에서 커뮤니티 딥링크일 때 `?postId={postId}`를 붙이도록 수정했다. 이에 맞춰 `CreatorCommunityService`에서 댓글 등록 이벤트 발행 시 `deepLinkCommentPostId = postId`를 설정했고, `PushNotificationService`도 동일 딥링크 문자열을 알림 리스트에 저장하도록 반영했다. 테스트는 `CreatorCommunityServiceTest`, `PushNotificationServiceTest`를 보강했다.
- 왜: 기존 `community/{creatorId}`만으로는 어떤 게시글의 댓글 리스트를 열어야 하는지 식별할 수 없어, 커뮤니티 전체보기 진입은 유지하면서 대상 게시글 식별자를 함께 전달하기 위해서다.
- 어떻게:
- `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계)
- `./gradlew test --tests "*CreatorCommunityServiceTest" --tests "*PushNotificationServiceTest"` 실행(성공)
- `./gradlew test` 실행(성공)
- `./gradlew build` 실행(성공)

View File

@@ -1,15 +0,0 @@
- [x] 리뷰 결과 요약 및 수정 범위 확정
- [x] FcmEvent 저장 조건 제거 및 서비스 계층으로 정책 이동
- [x] PushNotificationService에서 SYSTEM 저장 제외 보장
- [x] category null 회귀 방지 테스트 추가
- [x] 검증 실행 (LSP, 테스트, 빌드)
## 검증 기록
### 1차 구현
- 무엇을: `SYSTEM` 카테고리 저장 제외 정책을 Listener에서 Service로 이동하고, `category = null` 회귀를 막는 테스트를 추가했다.
- 왜: 현재 Listener 조건은 `category != null`을 요구해 타입 기반 카테고리 보정(`resolveCategory`)을 우회할 수 있어, 비SYSTEM 이벤트의 저장 누락 위험이 있었다.
- 어떻게:
- `lsp_diagnostics` 실행: Kotlin LSP 미설정으로 불가(환경상 `.kt` 진단 서버 없음).
- `./gradlew test --tests kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest` 실행: 성공.
- `./gradlew build` 실행: 성공.

View File

@@ -1,19 +0,0 @@
## 작업 개요
- [x] `PushNotificationService`의 1주 조회 시작 시각 계산 기준을 저장 시각(`BaseEntity.createdAt`)과 동일한 시스템 기본 타임존으로 통일한다.
- [x] `getNotificationList``getAvailableCategories`가 동일한 1주일 범위를 유지하는지 확인한다.
- [x] 관련 import/함수명을 정리해 코드 가독성과 의도를 명확히 한다.
- [x] 변경 파일 진단과 Gradle 검증(`test`, `build`)을 수행하고 결과를 기록한다.
---
## 검증 기록
### 1차 구현
- 무엇을: `PushNotificationService`의 조회 기간 계산을 UTC 기준에서 시스템 기본 타임존 기준으로 변경.
- 왜: `createdAt` 저장 시각이 시스템 기본 타임존(`LocalDateTime.now()`)이므로 조회 기준만 UTC를 사용하면 서버 타임존이 UTC가 아닐 때 실제 조회 기간이 7일과 어긋날 수 있음.
- 어떻게:
- `lsp_diagnostics` 실행: `.kt` 확장자용 LSP 서버 미설정으로 도구 진단 불가(환경 제약 확인).
- `./gradlew test` 실행: 성공(BUILD SUCCESSFUL).
- `./gradlew build` 실행: 성공(BUILD SUCCESSFUL).

View File

@@ -1,58 +0,0 @@
# 20260316_라이브환불기능추가
## 구현 항목
- [x] `GetCalculateLiveQueryData``roomId` 필드 추가 및 `toGetCalculateLiveResponse` 수정 (email 제거 예정)
- [x] `GetCalculateLiveResponse``roomId` 필드 추가 (email 제거 예정)
- [x] 환불 요청 시 `roomId`, `canUsageStr` 필수 조건 확인 로직 추가
- [x] `LiveRoomService` 내 환불 처리 로직 구현 (1차 수정: `cancelLive`와 동일하게 예약자 대상)
- [x] 환불 요청 API 엔드포인트 구현 (또는 수정)
- [x] `GetCalculateLiveQueryData``GetCalculateLiveResponse`에서 `email` 필드 제거
- [x] `AdminCalculateQueryRepository``CreatorAdminCalculateQueryRepository`에서 `email` 조회 제거
- [x] 환불 대상을 '예약자'가 아닌 '해당 라이브 및 사용 조건에 맞는 모든 미환불 UseCan'으로 변경
- [x] `LiveRoomService``refundLiveByAdmin` 로직을 `AdminCalculateService`로 이동 및 수정
- [x] 이미 환불 처리된 건은 환불하지 않도록 재검증
- [x] 사용 전/후/환불 후 캔 수 일치 여부 검증 테스트 추가
- [x] 테스트 코드에 DisplayName을 사용하여 한글 설명 추가
- [x] 환불 실패 케이스에 대한 테스트 추가
## 검증 결과
### 1차 구현
- 무엇을: 라이브 환불 기능 추가
- 왜: 관리자 정산 페이지 등에서 라이브별 환불 처리를 지원하기 위함
- 어떻게:
- [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse` 수정 확인
- [x] 환불 요청 API 호출 및 `LiveRoomService.refundLiveByAdmin` 로직 실행 여부 확인
- [x] 테스트 코드(`AdminCalculateServiceTest`) 작성 및 실행 결과 확인 (성공)
### 2차 수정 (잘못된 처리 반영)
- 무엇을: 라이브 환불 로직 수정 및 필드 정리
- 왜: 환불은 예약자 기준이 아니며, 관리자 기능이므로 관리자 서비스에서 처리해야 함. 또한 개인정보 보호 등을 위해 불필요한 `email` 필드 제거.
- 어떻게:
- [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse`에서 `email` 제거 확인
- [x] '모든 미환불 UseCan' 대상 환불 로직 검증 (테스트 코드 수정 및 실행)
- [x] `LiveRoomService`에서 해당 로직 제거 및 `AdminCalculateService`에서 직접 처리 확인
### 3차 수정 (캔 수 검증 테스트 추가)
- 무엇을: 환불 시 사용자의 캔 수 변화 검증 테스트 추가
- 왜: 환불 후 사용자의 캔 수가 사용 전과 동일한지 확인하여 정합성을 보장하기 위함
- 어떻게:
- [x] `AdminCalculateServiceTest``shouldMaintainCanBalanceAfterRefund` 테스트 추가
- [x] 사용 전, 사용 후(시뮬레이션), 환불 후 캔 수를 비교하여 사용 전과 환불 후가 동일함을 검증
- [x] `./gradlew test` 실행 결과 성공 확인
### 4차 수정 (테스트 코드 가독성 개선)
- 무엇을: 테스트 코드에 `@DisplayName`을 사용하여 한글 설명 추가
- 왜: 테스트의 의도를 보다 명확하게 전달하기 위함
- 어떻게:
- [x] `AdminCalculateServiceTest.kt`의 모든 테스트 메서드에 `@DisplayName` 적용
- [x] `./gradlew test` 실행 시 한글 설명이 정상적으로 출력됨을 확인
### 5차 수정 (환불 실패 케이스 테스트 추가)
- 무엇을: 환불이 실패하는 예외 상황에 대한 테스트 케이스 추가
- 왜: 환불 요청 중 발생 가능한 예외 상황(잘못된 방 ID, 잘못된 구분 값, 파라미터 누락 등)을 사전에 검증하기 위함
- 어떻게:
- [x] `AdminCalculateServiceTest.kt`에 3개의 실패 테스트 추가
- `shouldFailWhenRoomNotFound`: 존재하지 않는 방 ID 요청 시 `live.room.not_found` 예외 검증
- `shouldFailWhenInvalidCanUsage`: 지원하지 않는 사용 구분 문자열 요청 시 예외 검증
- `shouldFailWhenRequiredParameterMissing`: 필수 파라미터 누락 시 `common.error.invalid_request` 예외 검증
- [x] `./gradlew test` 실행 결과 5개의 테스트 모두 성공 확인

View File

@@ -1,14 +0,0 @@
# 20260316_작업문서한글명변경.md
## 구현 항목
- [x] 이번 세션에서 생성된 영문 작업 문서 이름 변경
- [x] `docs/20260316_CanServiceGetCanUseStatusRefactoring.md` -> `docs/20260316_캔사용내역조회리팩토링.md`
- [x] `docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md` -> `docs/20260316_캔사용내역타임존및널처리개선.md`
## 검증 결과
### 1차 구현
- 무엇을: 이번 세션에서 생성된 작업 문서 2개의 이름을 한글로 변경
- 왜: 작업 계획 문서의 파일명 형식([날짜]_구현할내용한글.md)을 준수하기 위해
- 어떻게: bash 명령어로 `mv` 실행
- `mv docs/20260316_CanServiceGetCanUseStatusRefactoring.md docs/20260316_캔사용내역조회리팩토링.md`
- `mv docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md docs/20260316_캔사용내역타임존및널처리개선.md`

View File

@@ -1,23 +0,0 @@
# 캐릭터 등록 JP 성별 일본어 변환
- [x] `AdminChatCharacterController.registerCharacter`의 외부 API 호출 경로 확인
- QA: `callExternalApi`에서 `region`/`gender` 바디 구성 위치 확인
- [x] `region == JP`일 때 `gender` 값을 일본어로 변환하는 로직 추가
- QA: `여성 -> 女性`, `남성 -> 男性`, `기타 -> その他` 매핑 확인
- [x] 등록 API 외부 호출 시에만 변환이 적용되도록 구현
- QA: DB 저장용 `request.gender`는 기존 값 유지 여부 확인
- [x] 정적 진단 및 테스트 수행
- QA: Kotlin LSP 미구성으로 `lsp_diagnostics` 불가 확인, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest"``./gradlew build -x test` 성공
## 검증 기록
### 1차 구현
- 무엇을: `registerCharacter` 외부 API 호출 시 `region == JP` 조건에서만 `gender`를 일본어(`女性`/`男性`/`その他`)로 변환하도록 구현하고, 매핑 단위 테스트를 추가했다.
- 왜: JP 리전 요청에서 외부 API가 일본어 성별 값을 요구하므로 등록 API 요청 바디의 `gender` 값만 조건부 변환이 필요했다.
- 어떻게:
- 코드 확인: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt`에서 `callExternalApi` 바디 구성 지점 확인 후 `mapGenderForExternalApi` 헬퍼 추가
- 매핑 검증: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt`에서 JP 매핑(여성/남성/기타) 및 KR 유지 케이스 검증
- 정적 진단: `lsp_diagnostics` 실행 시 Kotlin LSP 미구성으로 불가(환경 제약)
- 실행 검증 1: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest"` → 성공
- 수동 확인: `build/test-results/test/TEST-kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest.xml`에서 `tests="4" failures="0" errors="0"` 확인
- 실행 검증 2: `./gradlew build -x test` → 성공

View File

@@ -1,16 +0,0 @@
# 20260316_캔사용내역조회DISTINCT오류수정.md
## 구현 목표
- `CanRepository.getCanUseStatus` 호출 시 발생하는 `java.sql.SQLException` (DISTINCT와 ORDER BY 충돌)을 해결한다.
## 작업 내용
- [x] `UseCanQueryDto.kt``id: Long` 필드 추가
- [x] `CanRepository.kt``getCanUseStatus` 쿼리 `select` 절에 `useCan.id` 추가
- [x] `CanServiceTest.kt``UseCanQueryDto` 생성자 호출 로직에 `id` 추가
- [x] `./gradlew ktlintFormat` 실행 및 스타일 확인
- [x] `./gradlew test` 실행하여 검증
## 검증 결과
- 무엇을: 캔 사용 내역 조회 API
- 왜: `DISTINCT` 사용 시 `ORDER BY` 컬럼(`id`)이 `SELECT` 목록에 없어 발생하는 런타임 오류 해결
- 어떻게: `id`를 DTO에 포함시켜 `SELECT` 목록에 노출되도록 수정

View File

@@ -1,40 +0,0 @@
# 20260316_CanServiceGetCanUseStatusRefactoring.md
## 작업 목표
- `CanService.getCanUseStatus` 함수의 비효율적인 필터링 및 데이터 로딩 로직 개선.
- Kotlin 레벨에서 수행하던 필터링을 DB 레벨(Querydsl)로 이동.
- Entity 전체를 조회하는 대신 필요한 필드만 조회(Query Projection)하도록 리팩토링.
## 작업 내용
- [x] `CanService.getCanUseStatus` 현재 기능 검증용 테스트 코드 작성.
- [x] `UseCanQueryDto` 생성 (QueryProjection용 DTO).
- [x] `CanRepository`에 Querydsl 기반의 고도화된 `getCanUseStatus` 추가 (또는 기존 메서드 수정).
- [x] `member.id` 필터링 (기존 유지).
- [x] `(can + rewardCan) > 0` 필터링.
- [x] `container`(`aos`, `ios`, `else`)별 `paymentGateway` 필터링 (Join 사용).
- [x] 필요한 연관 엔티티(`Member`, `Room`, `AudioContent` 등)의 필드만 선택적으로 조회.
- [x] `CanService.getCanUseStatus` 리팩토링.
- [x] 리포지토리에서 바로 DTO 또는 필요한 데이터만 받아오도록 수정.
- [x] Kotlin `filter` 제거.
- [x] Kotlin `map` 로직 단순화 또는 QueryProjection으로 흡수 가능한지 판단하여 처리.
- [x] 작성한 테스트 코드로 기능 검증.
- [x] 테스트 코드에 `@DisplayName` 추가 및 예외/엣지 케이스 테스트 보강.
- [x] 성능 및 쿼리 최적화 확인.
## 검증 결과
- **기능 검증**:
- `CanServiceTest.kt`를 작성하여 리팩토링 전후의 필터링 및 맵핑 로직이 동일하게 유지됨을 확인.
- `@DisplayName`을 추가하여 테스트 의도를 명확히 기술.
- 유효하지 않은 타임존 입력 시 `DateTimeException`이 발생하는 예외 케이스 추가.
- 데이터가 없을 때 빈 리스트 반환 및 각 `CanUsage`별 nullable 필드(닉네임, 제목 등)가 누락되었을 때의 기본 타이틀 처리 로직 검증.
- **성능 개선**:
- Kotlin 레벨의 필터링을 DB 레벨(Querydsl)로 이동하여 불필요한 데이터 조회를 줄이고 페이지네이션 정확도 향상.
- Entity 전체 조회 대신 필요한 12개 필드만 조회하는 `UseCanQueryDto` 사용 (Projection).
- `CHANNEL_DONATION` 시 별도의 Member 조회를 위해 발생하던 N+1 또는 추가 쿼리를 Join을 통해 1번의 쿼리로 최적화.
- **코드 품질**:
- `CanService`에서 더 이상 사용하지 않는 `memberRepository` 의존성 제거.
- 복잡한 맵핑 로직을 QueryProjection DTO 기반으로 깔끔하게 정리.
### 단계별 검증 내용
1. **1차 구현 및 단위 테스트**: `CanServiceTest`를 통해 `aos`, `ios` 컨테이너별 필터링 조건이 올바르게 DB 쿼리에 반영되고 결과가 맵핑되는지 검증 (성공).
2. **쿼리 최적화 확인**: `UseCanCalculate` 및 관련 엔티티들을 `leftJoin``innerJoin`을 통해 한 번의 쿼리로 가져오도록 구현됨을 코드 레벨에서 확인.

View File

@@ -1,25 +0,0 @@
# 20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md
## 작업 개요
- `CanService.getCanUseStatus` 함수에서 유효하지 않은 타임존 입력 시 처리 방식 변경 (예외 발생 -> UTC 기본값 사용).
- 캔 사용 내역 타이틀에서 `null` 문자열이 노출되는 문제 해결 및 크리에이터 닉네임 활용 로직 강화.
## 구현 항목
- [x] `CanService.getCanUseStatus` 타임존 처리 로직 수정
- `ZoneId.of(timezone)` 호출 시 예외 발생 시 `UTC`를 기본값으로 사용하도록 변경.
- [x] `CanService.getCanUseStatus` 타이틀 생성 로직 수정
- `CanUsage.LIVE` 등에서 `roomTitle`이 null인 경우 `roomMemberNickname`을 출력하도록 변경.
- 기타 `null` 문자열이 노출될 수 있는 지점 확인 및 수정.
- [x] `CanServiceTest.kt` 수정
- 타임존 예외 테스트를 UTC 기본값 동작 검증 테스트로 변경.
- 타이틀 `null` 처리 로직 변경에 따른 검증 코드 업데이트.
## 검증 기록
### 1차 구현
- **무엇을**: 타임존 안전 처리 및 타이틀 null 방지 로직 구현
- **왜**: 사용자 경험 개선 및 데이터 무결성 표시
- **어떻게**:
- `CanService.kt`: `ZoneId.of(timezone)`에 try-catch 적용, `CanUsage.LIVE` 등에서 제목 null 시 닉네임 사용하도록 수정.
- `CanServiceTest.kt`: 타임존 UTC 폴백 테스트 및 타이틀 null 방지 테스트 케이스 업데이트.
- `./gradlew test` 실행 결과: 5개 테스트 모두 통과.
- `./gradlew ktlintCheck` 실행 결과: 성공.

View File

@@ -1,14 +0,0 @@
- [x] 크리에이터 커뮤니티 게시물 고정/고정해제 API 경로 및 요청 스펙을 정의하고 반영한다.
- [x] 게시물 엔티티에 고정 상태와 고정 시각(또는 순서) 정보를 저장할 수 있도록 반영한다.
- [x] 동일 크리에이터 기준 고정 게시물 최대 3개 제한 검증을 추가하고, 초과 시 예외를 발생시킨다.
- [x] 커뮤니티 게시물 목록 정렬을 고정 우선, 최근 고정 우선, 기존 최신순 우선순위로 반영한다.
- [x] 고정/해제 및 3개 초과 예외, 정렬 우선순위를 검증하는 테스트를 추가/수정한다.
- [x] 검증 결과(무엇/왜/어떻게)를 문서 하단에 기록한다.
---
### 1차 구현 검증 기록
- 무엇을: 크리에이터 커뮤니티 게시물 고정/해제 API, 최대 3개 제한 예외, 고정 우선 정렬 반영 여부를 검증했다.
- 왜: 요청된 기능 요구사항(고정 가능 개수 제한, 최근 고정 우선 노출, 고정 해제)을 코드/테스트 기준으로 충족하는지 확인하기 위해서다.
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`를 실행했고, 총 5개 테스트(신규 3개 포함)가 모두 성공했다.

View File

@@ -1,24 +0,0 @@
# 라이브 방 후원 랭킹 기간 반영
- [x] `LiveRoomService.getRoomInfo`의 Top3 후원 랭킹 조회 로직 현황 확인
- [x] `CreatorDonationRankingService.getMemberDonationRanking`의 기간 처리 패턴 확인 및 적용 방식 결정
- [x] 크리에이터의 `DonationRankingPeriod` 선택값(`WEEKLY`/`CUMULATIVE`)을 반영해 Top3 `List<Long>` 조회 로직 수정
- [x] 정적 진단 및 테스트/빌드 검증 수행
- [x] 검증 결과 문서화
## 검증 기록
### 1차 구현
- 무엇을: 초기 계획 수립
- 왜: 작업 전 구현 범위와 검증 기준을 명확히 하기 위해
- 어떻게: 계획 문서 생성 완료
### 2차 구현
- 무엇을: 후원 랭킹 기간 처리 패턴 전수 탐색 및 `getRoomInfo` 구현 변경
- 왜: 기존 누적 고정 조회를 크리에이터 선택 기간(`DonationRankingPeriod`) 기준 조회로 변경하기 위해
- 어떻게: `grep`/`ast-grep`/백그라운드 `explore`/`librarian` 탐색 결과를 근거로 `LiveRoomService`에서 `CreatorDonationRankingService.getMemberDonationRanking(..., period = donationRankingPeriod)` 호출 후 `.map { it.userId }``List<Long>` 유지
### 3차 검증
- 무엇을: 코드 스타일/컴파일/테스트/빌드 검증
- 왜: 변경이 기존 규칙과 빌드 체인에서 안전하게 동작하는지 확인하기 위해
- 어떻게: `lsp_diagnostics`는 Kotlin LSP 미구성으로 수행 불가 확인, `./gradlew test && ./gradlew build` 1차 실행 시 import 정렬 실패(`ktlintMainSourceSetCheck`), import 순서 수정 후 동일 명령 재실행하여 `BUILD SUCCESSFUL` 확인

View File

@@ -1,32 +0,0 @@
# 라이브 룸 채팅 얼림 상태 저장/조회 추가
## 체크리스트
- [x] 데이터 모델(LiveRoomInfo)에 `isChatFrozen` 필드(Boolean, 기본 false) 추가
- [x] 요청 DTO `SetChatFreezeRequest(roomId, isChatFrozen)` 추가
- [x] 서비스 `setChatFreeze` 구현(권한: 크리에이터만)
- [x] 컨트롤러 `PUT /live/room/info/set/chat-freeze` 엔드포인트 추가
- [x] `GetRoomInfoResponse``isChatFrozen`(Boolean, 기본 false) 추가 및 조회 응답 포함
- [x] 단위 테스트는 불필요 판단으로 제거(수동 테스트 가이드로 대체)
- [x] `./gradlew build`로 컴파일 확인
- [x] `./gradlew ktlintCheck` 실행 및 포맷 확인
## 검증 기록
### 1차 구현
- 무엇을: 채팅 얼림 상태 저장/조회 기능 구현
- 왜: 라이브 룸 채팅 제어 기능 제공을 위해
- 어떻게:
- 빌드/테스트 명령 실행: `./gradlew clean build` 성공, `./gradlew ktlintCheck` 예정
- API 수동 점검 예정: `PUT /live/room/info/set/chat-freeze` 요청 본문 `{ "roomId": 1, "isChatFrozen": true }` → 200 OK, 이후 `GET /live/room/info/{id}` 응답에 `isChatFrozen: true` 포함 확인
### 수동 테스트 방법
- 사전조건: 방 생성 및 시작되어 Redis에 `LiveRoomInfo`가 존재해야 함
- 1) 채팅 얼림 설정
- 요청: `PUT /live/room/info/set/chat-freeze`
- 헤더: `Authorization: Bearer <creator_token>`
- 바디: `{ "roomId": <roomId>, "isChatFrozen": true }`
- 기대: 200 OK, 본문은 `ApiResponse.ok` 규격
- 2) 룸 정보 조회에서 반영 확인
- 요청: `GET /live/room/info/{roomId}`
- 기대: 응답 JSON 내 `isChatFrozen: true`
- 3) 해제 시나리오 재검증
- `isChatFrozen`을 false로 요청 후 조회 시 `false` 확인

View File

@@ -1,39 +0,0 @@
# 20260324 라이브 생성 시 19금 방 전환 로직 추가
## 목적
- 라이브 생성(createLiveRoom) 시 태그 기준으로 `room.isAdult` 전환 조건을 확장한다.
- 기존 문자열 매칭("음담패설") 조건은 유지하고, `tag.isAdult = true`인 경우에도 19금 방으로 전환한다.
## 범위
- `LiveRoomService.createLiveRoom`의 태그 처리 구간.
- 테스트/빌드 회귀 확인.
## 구현 체크리스트
- [x] 기존 문자열 조건 유지: `tag.tag.contains("음담패설")``room.isAdult = true`
- [x] 추가 조건 구현: `tag.isAdult == true``room.isAdult = true`
- [x] 리팩토링: `isAdultTag(LiveTag)` 보조 함수 추출 및 태그 루프 내 부수효과 제거
- [x] 리팩토링: 태그 기반 19금 여부를 누적 계산 후 최종 한 번만 `room.isAdult` 반영
- [x] 코드 스타일/네이밍/예외 규칙 준수(AGENTS.md)
- [x] `./gradlew test` 실행으로 회귀 확인
## 변경 파일
- `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`
## 검증 계획
1차 구현
- 무엇을: 라이브 생성 시 태그에 `isAdult=true`가 포함되면 `room.isAdult`가 true로 설정되는지 확인
- 왜: 19금 태그를 구조적으로 식별해 19금 방 전환을 정확히 반영하기 위함
- 어떻게:
- 명령: `./gradlew test`
- 기대: 빌드 및 모든 테스트 통과(회귀 없음)
2차(수동) 확인
- 무엇을: 태그가 `음담패설` 또는 `isAdult=true`일 때 19금 전환되는지 로직 리뷰(보조 함수 경유)
- 왜: 런타임 리스크 없이 조건 충족 여부를 빠르게 확인
- 어떻게: 코드 라인 수동 점검
- 위치: `LiveRoomService.isAdultTag``createLiveRoom`의 태그 forEach 블록
- 기대: 두 조건 중 하나라도 만족 시 `room.isAdult = true`
## 정정/추가 메모
- 현 단계에서 공개 API 스키마 변경 없음.
- 도메인 예외/응답 포맷 변경 없음.

View File

@@ -1,40 +0,0 @@
# 20260324 차단 유저 구매 콘텐츠 상세 조회 예외 처리
## 목적
- 차단 관계가 있어도 조회자가 해당 콘텐츠를 구매한 경우에는 상세 조회를 허용한다.
- 차단 예외 경로에서는 댓글 및 시리즈 내 이전/다음 콘텐츠 정보를 노출하지 않는다.
## 구현 체크리스트
- [x] `AudioContentService.getDetail`에서 구매 여부(`isExistOrderedAndOrderType`)를 차단 판정보다 먼저 계산
- [x] 차단 + 미구매인 경우 기존 `content.error.blocked_access` 예외 유지
- [x] 차단 + 구매인 경우 상세 조회 허용
- [x] 차단 + 구매인 경우 댓글 목록/댓글 수 조회 쿼리 미실행 및 응답을 `[]`, `0`으로 반환
- [x] 차단 + 구매인 경우 `previousContent`, `nextContent` 조회 쿼리 미실행 및 응답을 `null`로 반환
- [x] 정적 진단/테스트/빌드 검증 수행
## 완료 기준 (Pass/Fail)
- [x] AC1: 차단 + 미구매 요청 시 `SodaException(messageKey = "content.error.blocked_access")`가 발생해야 한다.
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
- [x] AC2: 차단 + 구매 요청 시 상세 조회가 실패하지 않아야 한다.
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
- [x] AC3: 차단 + 구매 요청 시 댓글/이전/다음 콘텐츠 조회 로직이 실행되지 않아야 한다.
- QA: 조건문 가드로 `commentRepository.findByContentId`, `totalCountCommentByContentId`, `findPreviousContent`, `findNextContent` 호출 차단 확인
## 검증 기록
- 1차 구현: 진행 전
- 무엇을: 요구사항 분석 및 기존 패턴 탐색
- 왜: 차단/구매 예외 규칙을 기존 서비스 로직과 일관되게 반영하기 위해
- 어떻게: `grep`, `ast-grep`, explore/librarian 백그라운드 탐색 수행
- 2차 구현: 기능 반영 및 시나리오 검증
- 무엇을: `AudioContentService.getDetail`에서 차단+구매 예외를 허용하고, 해당 경로에서 댓글/이전·다음 조회를 생략하도록 분기 로직을 수정했다. 또한 `AudioContentServiceTest`를 추가해 차단+미구매/차단+구매 시나리오를 실제 메서드 호출로 검증했다.
- 왜: 요청사항(구매자 접근 허용 + 댓글/이전·다음 비조회)을 코드 레벨뿐 아니라 실행 가능한 테스트로 재현해 회귀를 방지하기 위해.
- 어떻게:
- 명령: `lsp_diagnostics` (`AudioContentService.kt`, `AudioContentServiceTest.kt`)
- 결과: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가)
- 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
- 결과: 성공 (신규 2개 시나리오 테스트 통과)
- 명령: `./gradlew test`
- 결과: 성공
- 명령: `./gradlew build`
- 결과: 성공

View File

@@ -1,781 +0,0 @@
# 20260325 콘텐츠 조회 설정 서버 저장 전환
## 목적
- 클라이언트 요청 파라미터(`isAdultContentVisible`, `contentType`) 중심 조회 방식을 서버 저장값 중심 조회 방식으로 전환한다.
- 국가별(한국/해외) 성인 노출 정책을 분리해 적용한다.
- 구버전 클라이언트 호환을 위해 **기존 `isAdultContentVisible` 파라미터를 받는 API 전체**에서 전달 파라미터를 저장한다.
- 신규 회원은 회원가입 시 기본값을 선저장하고, 기존 회원은 호환 대상 API 호출 시 저장(row 생성/갱신) 후 저장값 기반으로 조회한다.
- 설정 변경 시각을 관리해 추적 가능성을 확보한다.
## 핵심 요구사항 정리
- `isAdultContentVisible` 기본값은 `false`로 변경한다. (현재 다수 컨트롤러에서 `true` 기본)
- `contentType`은 콘텐츠 조회 성향값으로 사용한다. (`ALL`, `FEMALE`, `MALE`)
- `남성향(MALE)`**여성 크리에이터(auth.gender=0)** 콘텐츠만 조회한다.
- `여성향(FEMALE)`**남성 크리에이터(auth.gender=1)** 콘텐츠만 조회한다.
- 호환 API 저장과 별도로 **직접 설정 API**(가칭 `PATCH /member/content-preference`)를 생성한다.
- 국가 판별 우선순위:
1) 회원 ID 강제 매핑 우선 적용
- `member.id in [16, 17]``countryCode = "KR"`
- `member.id in [2, 29721, 32050, 40850]``countryCode = "JP"`
2) 그 외 회원은 `CloudFront-Viewer-Country` 기반으로 결정
3) 헤더 누락/오작동 시 `countryCode = "KR"` fallback 적용
- 한국(`countryCode == "KR"`) 정책:
- 저장 시: `member.auth != null`일 때만 전달값 반영
- 조회 시: `isAdult = isAdultContentVisible && (member.auth != null)`로 계산하고, `contentType` 필터를 함께 적용
- 해외(한국 외) 정책:
- 저장 시: 전달받은 값 그대로 저장
- 조회 시: `isAdult = isAdultContentVisible`로 계산하고, `contentType` 필터를 함께 적용
- `AuthController.authVerify` 본인인증 성공 시 `isAdultContentVisible = true`로 즉시 저장한다.
- 주의: 조회 판단은 **서버 저장값 기준**으로 수행하며, 구버전 호환 구간에서는 기존 파라미터 수신 후 저장값을 갱신해 동일 정책을 적용한다.
- 기존 회원(설정 row 미존재)은 호환 대상 API 호출 시 저장 조건에 따라 row를 생성/갱신하고, 생성 즉시 저장값 기준 조회를 적용한다.
- `/member/info` 응답에 아래 필드를 추가한다.
- `countryCode`
- `isAdultContentVisible`
- `contentType`
## 네이밍 정책 결정 (이번 작업에서 확정)
- [x] **외부 API 파라미터명은 유지**: `isAdultContentVisible`, `contentType`
- 이유: 기존 클라이언트 호환성과 현재 코드베이스 전역 사용량이 매우 큼.
- 적용: `isAdultContentVisible` 파라미터 수신 API 전체에서 기존 키 그대로 수신/저장.
- [x] **내부 도메인 캡슐화 객체를 추가**: (예시) `ViewerContentPreference`
- 필드명은 기존과 동일(`isAdultContentVisible`, `contentType`)로 유지해 해석 혼선을 최소화.
- 이유: 필드명 변경으로 발생하는 전역 대규모 리네임 리스크를 피하면서도, 도메인 객체로 의미를 명확화.
- [x] 장기적으로 파라미터명 변경이 필요하면 alias 전략으로 단계적 전환(이번 범위에서는 미적용).
- [x] 최종 결정: **이번 변경 범위에서는 리네임을 하지 않는다.**
## 생성 시점 결정 (회원가입 시 선저장 vs 필요시 생성)
- [x] **신규 회원가입 시 선저장(Eager) 채택**
- 이유:
- 서버 저장값 기반 조회로 전환 시 null/미생성 분기 제거로 일관성 향상
- 조회 경로에서 동적 생성(Lazy) 경쟁 조건/추가 트랜잭션 복잡도 감소
- `/member/info` 즉시 응답 가능
- [x] 기존 회원 데이터는 마이그레이션 또는 최초 조회 시 안전한 보정 로직(백필)으로 누락 방지
## 변경 대상 상세 맵
### 1) 저장 모델/도메인 계층
- [x] 사용자 조회설정 저장 엔티티 신설 (예: `MemberContentPreference`)
- 후보 경로: `src/main/kotlin/kr/co/vividnext/sodalive/member/...`
- 필드(안):
- `member` (1:1, unique)
- `isAdultContentVisible: Boolean = false`
- `contentType: ContentType = ContentType.ALL`
- `adultContentVisibilityChangedAt: LocalDateTime?`
- `contentTypeChangedAt: LocalDateTime?`
- `createdAt`, `updatedAt` (BaseEntity)
- [x] Repository/QueryRepository/Service 추가
- 저장/조회/업데이트 정책 캡슐화
- 국가별 저장 정책/조회 정책 계산 함수 제공
### 2) 회원가입/소셜가입 기본값 선저장
- [x] 일반 가입
- `MemberService.signUpV2` (`MemberService.kt:126`)
- `MemberService.signUp` (`MemberService.kt:175`)
- [x] 소셜 가입
- `MemberService.findOrRegister(...)` 오버로드 4개
- Google/Kakao/Apple/Line 각 신규 회원 생성 지점
- [x] 기본값 저장
- `isAdultContentVisible = false`
- `contentType = ContentType.ALL`
- `changedAt` 초기값 = 생성 시각
### 3) 기존 `isAdultContentVisible` 파라미터 수신 API 전체 호환 저장
- [x] 호환 대상 API(4-1, 4-2 목록)에서 기존 파라미터 수신 후 저장 처리
- [x] 대표 진입점 구현/검증
- `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt`
- [x] `contentType`를 받지 않는 API 처리 규칙
- 대상: `LiveRoomController.kt`, `ExplorerController.kt`
- `isAdultContentVisible`만 저장하고 `contentType`은 기존 저장값 유지(미존재 시 `ContentType.ALL`)
- [x] 기존 회원 누락 row 보정 규칙
- 호환 대상 API 호출 시 row 미존재이면 기본값 row 생성 후 저장 정책 적용
- [x] 저장 정책 구현
- 한국: `member.auth != null`일 때만 전달값 반영
- 해외: 전달값 그대로 반영
- [x] 파라미터 미전달 시 저장값을 조회해 사용
### 3-1) 직접 설정 API 신설 (호환 저장과 분리)
- [x] 현행 점검: 직접 설정 API 부재 확인
- 점검 결과: `MemberController`, `AuthController`, 조회 컨트롤러에 `isAdultContentVisible`+`contentType`를 직접 저장하는 전용 엔드포인트가 없다.
- 현재는 조회 API 파라미터 전달 방식(legacy 호환)만 존재한다.
- [x] 직접 설정 API 추가
- 가칭: `PATCH /member/content-preference`
- Request: `isAdultContentVisible`, `contentType` (둘 중 하나 이상 필수)
- Response: 저장 후 최신 `isAdultContentVisible`, `contentType`
- `countryCode`는 직접 설정 API가 아닌 `/member/info` 응답에서 제공한다.
- `changedAt`은 변경 추적용 내부 필드이며 직접 설정 API 응답에는 포함하지 않는다.
- 메서드 선택 근거(`PATCH`):
- 기존 `member` 갱신 API는 `PUT/POST` 위주이지만, 본 API는 "두 필드 중 일부만 변경" 계약을 URL/메서드 수준에서 명확히 드러내기 위해 `PATCH`를 사용한다.
- `isAdultContentVisible`/`contentType` 중 일부만 변경하는 **부분 업데이트**가 기본 시나리오다.
- 전송되지 않은 필드는 기존 저장값을 유지해야 하므로 전체 교체(`PUT`)보다 부분 갱신 의미가 명확하다.
- 요청은 "전달된 필드만 대입"으로 설계해 동일 payload 재요청 시 동일 상태를 보장한다.
- [x] 직접 설정 API 저장 규칙
- 회원 설정 row가 없으면 기본값(`false`, `ALL`)으로 생성 후 요청값 반영
- 국가 결정은 강제 매핑(KR/JP) → 접속 국가 헤더 → `KR` fallback 순서를 따른다.
- `isAdultContentVisible`/`contentType` 변경 시 `changedAt` 갱신 규칙(동일값 재저장 미갱신)을 동일 적용한다.
### 3-2) 본인인증 성공 연동 저장
- [x] `AuthController.authVerify` 성공 시 `isAdultContentVisible = true` 저장
- 대상: `src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt`
- 구현: `service.authenticate(...)` 성공 직후 선호 저장 서비스 호출
- [x] 저장 시나리오
- 설정 row 미존재 시 기본 row 생성 후 `isAdultContentVisible = true` 반영
- `contentType`은 기존 저장값 유지(미존재 시 `ALL`)
- `adultContentVisibilityChangedAt` 갱신, 동일값이면 미갱신
- [x] 실패/차단 시나리오
- `isBlockAuth(...)`로 차단되어 예외가 발생한 경우 저장하지 않는다.
- 본인인증 실패 예외 흐름에서는 저장하지 않는다.
### 4) 콘텐츠/라이브/채팅 조회 경로를 저장값 기반으로 전환
#### 4-1. 홈/라이브 진입점
- [x] `/api/home` 계열
- `HomeController.kt`, `HomeService.kt`
- [x] `/api/live`
- `LiveApiController.kt`, `LiveApiService.kt`
- 연계 추천 경로: `LiveRecommendService.kt`, `LiveRecommendRepository.kt`
- [x] `/live/room`
- `LiveRoomController.kt`, `LiveRoomService.kt`
#### 4-2. 파라미터 수신 컨트롤러 전수 목록(저장값 기반 전환 대상)
- [x] 참고: `/api/home`, `/api/live`, `/live/room`은 4-1에서 별도 관리하며, 아래는 그 외 컨트롤러 전수 목록
- [x] `isAdultContentVisible` + `contentType`**둘 다 받는 컨트롤러**
- [x] `AudioContentController.kt`
- [x] `AudioContentMainController.kt`
- [x] `AudioContentCurationController.kt`
- [x] `AudioContentThemeController.kt`
- [x] `SearchController.kt`
- [x] `ContentSeriesController.kt`
- [x] `SeriesMainController.kt`
- [x] `AudioContentMainTabHomeController.kt`
- [x] `AudioContentMainTabContentController.kt`
- [x] `AudioContentMainTabFreeController.kt`
- [x] `AudioContentMainTabAsmrController.kt`
- [x] `AudioContentMainTabAlarmController.kt`
- [x] `AudioContentMainTabLiveReplayController.kt`
- [x] `AudioContentMainTabSeriesController.kt`
- [x] `isAdultContentVisible`만 받는 컨트롤러(동일 저장값 정책 연계 필요)
- `ExplorerController.kt` (`/explorer/profile/{id}`)
- `LiveRoomController.kt` (`/live/room`)
- [x] 컨트롤러 레벨에서 `member.auth != null && (isAdultContentVisible ?: true)`를 직접 계산하는 구간도 함께 전환
- `AudioContentController.kt`, `AudioContentMainController.kt`, `AudioContentThemeController.kt`
- `SeriesMainController.kt`, `AudioContentMainTabContentController.kt`, `AudioContentMainTabFreeController.kt`
- `AudioContentMainTabHomeController.kt`, `AudioContentMainTabAsmrController.kt`, `AudioContentMainTabSeriesController.kt`, `AudioContentMainTabLiveReplayController.kt`
#### 4-3. 서비스/쿼리 계층 (실제 필터 적용)
- [x] `member.auth != null && isAdultContentVisible` 계산식을 사용하는 서비스 전수 수정
- `HomeService.kt`, `LiveApiService.kt`, `LiveRoomService.kt`, `LiveRecommendService.kt`
- `AudioContentService.kt`, `AudioContentMainService.kt`
- `AudioContentMainTabHomeService.kt`, `AudioContentMainTabContentService.kt`, `AudioContentMainTabFreeService.kt`
- `AudioContentMainTabAsmrService.kt`, `AudioContentMainTabAlarmService.kt`, `AudioContentMainTabLiveReplayService.kt`, `AudioContentMainTabSeriesService.kt`
- `AudioContentCurationService.kt`, `AudioContentThemeService.kt`
- `ContentSeriesService.kt`, `SearchService.kt`, `ExplorerService.kt`
- [x] `AudioContentRepository.kt` 및 아래 쿼리 레이어의 `contentType`/성인 필터 검증
- `RankingRepository.kt`
- `SearchRepository.kt`
- `ContentSeriesRepository.kt`
- `ContentSeriesContentRepository.kt`
- `AudioContentThemeQueryRepository.kt`
- `AudioContentCurationQueryRepository.kt`
- `AudioContentMainTabRepository.kt`
- `RecommendSeriesRepository.kt`
- `ContentMainTabTagCurationRepository.kt`
- `RecommendChannelQueryRepository.kt`
- [x] `member.auth == null` 직접 분기 기반 성인 제어 로직 점검(정책 일관화)
- `AudioContentService.kt` (`isMosaic` 계산)
- `LiveRoomService.kt` (성인 라이브 입장/조회 가드)
- `LiveRecommendRepository.kt` (추천 라이브/채널에서 성인 라이브 제외 조건)
- `ExplorerQueryRepository.kt` (인증 미완료 시 성인 라이브 제외)
- `CreatorCommunityController.kt` / `CreatorCommunityService.kt` (커뮤니티 성인글 조회에서 인증 여부 분기)
- `LiveTagRepository.kt` (성인 태그 조회 가드)
#### 4-4. 채팅 캐릭터 조회
- [x] `ChatCharacterController.kt`
- 현재 `member.auth == null` 강제 체크(`common.error.adult_verification_required`)가 있어 국가별 정책 반영 지점 설계 필요
- 저장값 + 국가 정책으로 19금 캐릭터 노출 제한 로직을 통합
- [x] `ChatCharacterService.kt` / Repository 레벨에서 19금 캐릭터 필터가 필요한지 점검 후 반영
- [x] 연관 채널(캐릭터 이미지/댓글)도 동일 정책 적용 여부 검토
- `CharacterImageController.kt`
- `CharacterCommentController.kt`
### 5) `/member/info` 응답 확장
- [x] DTO 확장
- `src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt`
- 추가: `countryCode`, `isAdultContentVisible`, `contentType`
- [x] 서비스 확장
- `MemberService.getMemberInfo(...)`에서 저장값 조회 후 응답 주입
- `countryCode``member.countryCode`가 아닌 **요청 시점 국가 결정값**으로 반환
- 국가 결정 우선순위:
1) `member.id` 강제 매핑 (`KR`: 16, 17 / `JP`: 2, 29721, 32050, 40850)
2) `CountryContext.countryCode` (`CloudFront-Viewer-Country`)
3) 헤더 누락/오작동 시 `KR`
- 인프라 전제: CloudFront에서 `CloudFront-Viewer-Country` 헤더를 오리진으로 전달하도록 설정되어 있어야 한다.
- 캐시 주의: 국가별 응답이 달라지는 구간은 캐시 키에 국가 헤더를 포함하는지 함께 점검한다.
### 6) 기본값 true → false 전환
- [x] 기존 `?: true` 기본값 사용 지점 제거 또는 서버 저장값 fallback으로 대체
- 전수 대상(18개):
- `HomeController.kt`, `LiveApiController.kt`, `LiveRoomController.kt`, `ExplorerController.kt`
- `AudioContentController.kt`, `AudioContentMainController.kt`, `AudioContentCurationController.kt`, `AudioContentThemeController.kt`
- `SearchController.kt`, `ContentSeriesController.kt`, `SeriesMainController.kt`
- `AudioContentMainTabHomeController.kt`, `AudioContentMainTabContentController.kt`, `AudioContentMainTabFreeController.kt`
- `AudioContentMainTabAsmrController.kt`, `AudioContentMainTabAlarmController.kt`, `AudioContentMainTabLiveReplayController.kt`, `AudioContentMainTabSeriesController.kt`
- [x] fallback 규칙 표준화:
1) 저장값 존재 시 저장값 사용
2) 저장값 미존재 시 신규 기본값(`false`, `ContentType.ALL`) 사용 및 보정 저장
### 7) 변경 시각 관리
- [x] `isAdultContentVisible` 변경 시 `adultContentVisibilityChangedAt` 갱신
- [x] `contentType` 변경 시 `contentTypeChangedAt` 갱신
- [x] 전체 변경 추적은 `updatedAt`으로도 확인 가능하게 유지
- [x] row 최초 생성 시 `adultContentVisibilityChangedAt`, `contentTypeChangedAt` 초기값을 생성 시각으로 기록
- [x] 동일값 재저장 요청 시 `changedAt`은 갱신하지 않도록 정책 정의(노이즈 업데이트 방지)
## 데이터 마이그레이션/릴리스 계획
- [x] DDL 문서 작성 (`docs/*_ddl.sql` 패턴 준수)
- 신규 테이블 생성 또는 기존 `member` 컬럼 추가 중 1안 확정
- DDL 생성 시 컬럼 타입 규칙
- `created_at`, `updated_at`처럼 날짜/시간 저장 필드는 `timestamp`로 생성
- boolean 저장 필드는 `tinyint(1)`로 생성
- [x] 기존 회원 백필 전략 수립
- 기본값: `false` + `ALL`
- 적용 대상: 기존에 `isAdultContentVisible`, `contentType`를 받던 API 호출 시점
- 범위: **기존 회원 누락 row 보정 전용 규칙** (정상 운영 저장 정책은 3) 전체 API 호환 저장 정책을 따름)
- 처리 순서:
1) 회원 설정 테이블에 해당 member row 존재 여부 확인
2) row가 없으면 기본값(`isAdultContentVisible=false`, `contentType=ALL`)으로 생성
3) `member.auth != null`이면 요청으로 받은 값으로 갱신
4) `member.auth == null`이면 기본값을 그대로 유지(요청값으로 갱신하지 않음)
- 필요 시 배치/스크립트 실행
- [x] 단계적 배포
1) 저장 모델 배포 + 백필
2) 직접 설정 API 배포 + `authVerify` 성공 연동 배포
3) 호환 파라미터 수신 저장 전환(기존 `isAdultContentVisible` 파라미터 수신 API 전체)
4) 조회 경로 저장값 전환 + `/member/info` 확장 배포
5) 호환 파라미터 종료 조건 문서화(구버전 비율/공지/제거 시점)
## 1차 배포 구현 우선순위 (실행 순서 재정렬)
- [x] 0단계: 정책 고정
- [x] 국가 판별 우선순위 확정: `member.id` 강제 매핑(KR: 16,17 / JP: 2,29721,32050,40850) → 접속 국가 헤더 → `KR` fallback
- [x] 기존 회원 row 미존재 보정 규칙 확정: `member.auth` 여부 기반 기본값 저장/보정
- [x] `changedAt` 갱신 규칙 확정: 최초 생성 시 초기화, 동일값 재저장 시 미갱신
- [x] 직접 설정 API 계약 확정: endpoint, request/response, validation(둘 중 하나 이상 입력)
- [x] 1단계: 저장 모델/DDL 선반영
- [x] `MemberContentPreference`(가칭) 엔티티/리포지토리/서비스 추가
- [x] DDL 작성(`timestamp`, `tinyint(1)` 규칙 준수)
- [x] 2단계: 가입 경로 선저장
- [x] `signUpV2`, `signUp`, `findOrRegister`(Google/Kakao/Apple/Line)에서 기본값(`false`, `ALL`) 저장
- [x] 3단계: 직접 설정 API 우선 구현
- [x] `PATCH /member/content-preference` 추가(호환 API 저장 로직과 분리)
- [x] 설정 row 생성/갱신 + 응답 DTO + validation/예외 처리
- [x] 4단계: 본인인증 성공 연동
- [x] `AuthController.authVerify` 성공 시 `isAdultContentVisible = true` 저장
- [x] 차단/실패 예외 흐름에서 저장되지 않음을 보장
- [x] 5단계: 호환 저장 진입점 우선 전환(트래픽 핵심)
- [x] `/api/home`, `/api/live`, `/live/room`, `/explorer/profile/{id}`에서 파라미터 수신 후 저장
- [x] row 미존재 시 생성 + 정책 반영(국가/인증 분기)
- [x] 6단계: 파라미터 수신 컨트롤러 전수 전환(4-2)
- [x] 콘텐츠/검색/시리즈/메인탭 컨트롤러 전체 저장값 연동
- [x] `contentType` 미수신 API는 `isAdultContentVisible`만 저장하고 `contentType`은 기존값 유지
- [x] 7단계: 조회 경로 저장값 기준 전환(4-3, 4-4)
- [x] 서비스/쿼리 계층 `?: true` 및 직접 계산식 제거 후 저장값 기반 계산으로 통일
- [x] 채팅 캐릭터/이미지/댓글 경로를 국가+저장값 정책으로 통합
- [x] 8단계: `/member/info` 확장
- [x] 응답 필드 `countryCode`, `isAdultContentVisible`, `contentType` 추가
- [x] `countryCode`는 회원 ID 강제 매핑 우선 적용 후 접속 국가/`KR` fallback 적용
- [x] 9단계: 기본값 true → false 전수 치환
- [x] 컨트롤러 18개 `isAdultContentVisible ?: true` 제거
- [x] 저장값 우선 + 미존재 시 `false/ALL` 보정 저장으로 표준화
- [x] 10단계: 테스트/검증
- [x] 테스트 작성 원칙: `@SpringBootTest`를 사용하지 않고 단위 테스트(JUnit5 + Mockito) 중심으로 작성
- [x] 단위: 국가 분기/강제 매핑, auth 분기, changedAt, row 보정, 가입 선저장, 직접 설정 API, authVerify 연동, `/member/info` 반환
- [x] 통합: 직접 설정 API 저장 반영, authVerify 성공 자동 true 저장, 호환 API 저장 반영, 헤더 누락(`KR`) fallback
- [x] 회귀: `./gradlew test`, `./gradlew build`, `./gradlew ktlintCheck`
## 테스트/검증 계획
- [x] 테스트 작성 원칙
- `@SpringBootTest`를 사용하지 않는다.
- 서비스/정책 로직은 JUnit5 + Mockito 기반 단위 테스트로 작성한다.
- [x] 단위 테스트
- 국가 결정 우선순위 테스트
- `member.id=16,17`은 헤더와 무관하게 `KR`
- `member.id=2,29721,32050,40850`은 헤더와 무관하게 `JP`
- 그 외 회원은 `CloudFront-Viewer-Country` 사용, 누락 시 `KR` fallback
- 한국/해외 저장 정책 분기 테스트
- 한국 + `member.auth == null`에서 호환 API 호출 시 요청값으로 갱신되지 않고 기본값 유지되는지 테스트
- 해외 + `member.auth == null`에서 호환 API 호출 시 요청값이 저장되는지 테스트
- 한국/해외 조회 정책 분기 테스트
- 직접 설정 API 테스트
- `isAdultContentVisible`/`contentType`를 각각 단독/동시 변경할 때 저장 반영 및 응답(`isAdultContentVisible`, `contentType`)이 기대값인지 테스트
- 둘 다 누락된 요청을 validation 에러로 처리하는지 테스트
- `isAdultContentVisible` 값 변경 시 `adultContentVisibilityChangedAt`만 갱신되는지 테스트
- `contentType` 값 변경 시 `contentTypeChangedAt`만 갱신되는지 테스트
- 동일값 재저장 시 `changedAt`이 갱신되지 않는지 테스트
- `contentType`(ALL/FEMALE/MALE) 성별 필터 기대값 테스트
- `AuthController.authVerify` 성공 시 `isAdultContentVisible=true`로 저장되는지 테스트
- `AuthController.authVerify` 실패/차단 시 저장이 발생하지 않는지 테스트
- `contentType` 미수신 API(`LiveRoom`, `Explorer profile`)에서 `isAdultContentVisible`만 저장되는지 테스트
- 기존 회원 row 미존재 시 API 호출로 row 생성/갱신되는지 테스트
- 신규 회원가입 직후 기본값(`false`/`ALL`) 선저장 검증 테스트
- `/member/info` 필드 노출 테스트(`countryCode`는 회원 ID 강제 매핑 우선 + 비대상 회원은 접속 국가 기준 반환 검증 포함)
- [x] 통합 테스트
- 직접 설정 API(`PATCH /member/content-preference`) 호출 시 저장 후 즉시 조회 경로에 반영되는지 확인
- `authVerify` 성공 호출 시 `isAdultContentVisible=true` 자동 저장 반영 확인
- 호환 대상 API(`/api/home`, `/api/live`, `/live/room`, `explorer/profile`, 콘텐츠/검색/시리즈 계열) 파라미터 전달 → 저장 반영 확인
- 기존 회원(설정 row 없음) 첫 호출 시 저장 생성 + 같은 요청에서 저장값 기반 조회 적용 확인
- 한국/해외 각각에서 동일 API 호출 시 저장 결과와 조회 결과가 정책대로 달라지는지 확인
- `/member/info` 호출 시 강제 매핑 회원은 헤더 변경과 무관하게 고정 국가를 반환하는지 확인
- `/member/info` 호출 시 강제 매핑 대상이 아닌 회원은 헤더 변경(`KR`/`US` 등)에 따라 국가 응답이 변경되는지 확인
- `CloudFront-Viewer-Country` 헤더 누락 시 `/member/info.countryCode`가 fallback(`KR`)으로 반환되는지 확인
- 콘텐츠/라이브/채팅 캐릭터 조회 결과 정책 반영 확인
- [x] 회귀 검증 명령
- `./gradlew test`
- `./gradlew build`
- `./gradlew ktlintCheck`
## 리스크 및 대응
- [x] 리스크: 파라미터 제거 시 구버전 앱 동작 불일치
- 대응: 초기에는 구/신 정책을 공존 운영하고, 기존 회원 중 저장값이 없으면 `member.auth` 여부에 따라 기본값을 저장/보정해 조회 기준을 단일화한다.
- 판정: 대응 가능(공존 기간의 잔여 리스크는 운영으로 관리).
- [x] 리스크: 기존 회원 저장값 미존재
- 대응: `isAdultContentVisible`를 받는 API에서 설정 row 존재 여부를 확인하고, 없으면 즉시 생성/저장한다.
- 판정: 대응 가능(런타임 백필로 해소).
- [x] 리스크: 한국 인증 전 사용자 성인값 처리 혼선
- 대응: 한국은 `member.auth == null`이면 저장값을 기본값으로 저장/유지하고, `member.auth != null && isAdultContentVisible == true`일 때만 성인 처리한다.
- 판정: 대응 가능(정책 명시로 혼선 축소).
- [x] 리스크: `CloudFront-Viewer-Country` 헤더 미전달/오작동으로 현재 접속 국가 판별 실패
- 대응: 국가 판별 실패 시 한국(`KR`)으로 판단한다.
- 판정: 대응 가능(보수적 안전 기준 적용), 단 해외 사용자의 과차단 가능성은 모니터링한다.
- [x] 리스크: 호환 파라미터(legacy fallback) 장기 존치로 정책 복잡도 증가
- 대응: 앱 배포 상태(버전 점유율) 기반으로 제거 일자를 결정하고 단계적으로 삭제한다.
- 판정: 대응 가능(종료 기준·일정 관리 필요).
- [x] 리스크: 직접 설정 API가 없으면 호환 API 호출 여부에 따라 저장 타이밍이 불안정해짐
- 대응: 1차 배포에 직접 설정 API를 포함하고, 호환 저장은 구버전 공존 목적의 보조 경로로 제한한다.
- 판정: 대응 가능(명시적 설정 진입점 도입으로 안정화).
- [x] 리스크: 회원 ID 강제 국가 매핑 하드코딩이 운영 중 누락/충돌을 유발할 수 있음
- 대응: 강제 매핑 목록을 정책 상수로 단일화하고 테스트 케이스(각 ID별 기대 국가)를 고정한다.
- 판정: 대응 가능(목록 변경 절차와 테스트 동반 시 관리 가능).
## 구현 완료 후 기록 섹션 (구현 단계에서 작성)
### 사전 점검 (2026-03-25)
- 무엇을:
- 상단 목적(서버 저장값 전환/국가별 정책 분리/호환 저장/선저장/변경시각) 기준으로 변경 대상 체크리스트의 누락 여부를 점검했다.
- 왜:
- 구현 전 문서 범위 누락을 제거해 실제 작업 시 정책 누락/회귀를 방지하기 위해서다.
- 어떻게:
- 명령:
- `grep(include=*Controller.kt, pattern=isAdultContentVisible)`
- `ast-grep(lang=kotlin, pattern=member.auth != null && $X)`
- `grep(pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)`
- `Read(ExplorerController.kt, ExplorerService.kt, MemberService.kt, GetMemberInfoResponse.kt)`
- `Explore/Librarian 병렬 점검(bg_db6e2179, bg_525f613e, bg_908b86f6, bg_7bad3593, bg_3736f748)`
- 결과:
- `ExplorerService.kt`가 서비스 전수 수정 목록(4-3)에 빠져 있어 추가했다.
- `/member/info.countryCode`에 대해 CloudFront 헤더 전달 전제, fallback(`KR`), 캐시 키 점검 항목을 추가했다.
- `changedAt` 정책(초기값/동일값 재저장)과 단위 테스트 항목을 보강했다.
- legacy fallback 장기 존치 리스크 및 종료 조건 문서화 항목을 추가했다.
### 1차 구현
- 무엇을:
- `MemberContentPreference` 저장 모델/리포지토리/정책 서비스를 추가하고, 강제 국가 매핑(KR/JP) + 헤더 + `KR` fallback 규칙을 서비스 단일 경로로 구현했다.
- 회원가입/소셜가입(`signUpV2`, `signUp`, `findOrRegister` 4종) 직후 기본값(`false`, `ALL`) 선저장을 연동했다.
- `PATCH /member/content-preference`를 추가하고, 요청값(둘 중 하나 이상) 갱신 및 최신 설정 응답을 구현했다.
- `AuthController.authVerify` 성공 직후 `isAdultContentVisible=true` 저장 연동을 추가했다.
- 핵심 트래픽 진입점(`/api/home`, `/api/live`, `/live/room`, `/explorer/profile/{id}`)을 저장값 기반으로 전환하고, `/member/info``countryCode`, `isAdultContentVisible`, `contentType`를 확장했다.
- 서비스 계층의 `member.auth != null && isAdultContentVisible` 계산식을 정책 유틸(`isAdultVisibleByPolicy`) 기반으로 전환해 한국/해외 분기를 통합했다.
- DDL 문서 `docs/20260326_member_content_preference_ddl.sql`을 추가했다.
- 왜:
- 구버전 클라이언트 호환을 유지하면서도, 조회 정책 판단의 단일 기준을 서버 저장값으로 전환해 국가/인증 분기 불일치를 줄이기 위해서다.
- 본인인증 성공 이후 성인 노출 상태를 자동 동기화하고, 사용자 설정 변경 진입점을 명시적으로 제공하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test`
- `./gradlew build`
- `./gradlew ktlintCheck`
- 결과:
- 단위 테스트 추가: `MemberContentPreferenceServiceTest`, `AuthControllerTest` 작성 및 기존 테스트(`MemberServiceCacheEvictionTest`, `LiveRecommendServiceTest`) 의존성 갱신 완료.
- 회귀 검증 결과: `test`, `build`, `ktlintCheck` 모두 성공.
- 참고: `.kt` 대상 LSP 서버가 환경에 없어 LSP 진단은 실행 불가였고, 대신 Gradle 컴파일/테스트/린트 통과로 검증했다.
- 남은 항목:
- 4-2 전수 컨트롤러(콘텐츠/검색/시리즈/메인탭)와 4-4 채팅 캐릭터 경로는 후속 단계에서 동일 정책으로 확장 적용이 필요하다.
### 2차 문서 보강 (2026-03-26)
- 무엇을:
- 회원 ID 강제 국가 매핑 정책(KR: 16,17 / JP: 2,29721,32050,40850)과 `authVerify` 성공 시 `isAdultContentVisible=true` 저장 요구사항을 문서 전반에 반영했다.
- 호환 저장과 별개의 직접 설정 API(가칭 `PATCH /member/content-preference`) 필요성을 명시하고, 1차 배포 우선순위와 테스트 계획을 재정렬했다.
- 왜:
- 현재 코드는 조회 파라미터 기반(legacy) 흐름만 존재해 사용자 설정을 명시적으로 저장/관리하는 진입점이 없고,
본인인증 성공 이후 성인 노출 상태를 자동 동기화해야 정책 일관성을 유지할 수 있기 때문이다.
- 어떻게:
- 명령:
- `grep(include=*Controller.kt, pattern=isAdultContentVisible|contentType)`
- `grep(path=src/main/kotlin, pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)`
- `ast-grep(lang=kotlin, pattern=member.auth != null && $X)`
- `Read(MemberController.kt, AuthController.kt, CountryInterceptor.kt, CountryContext.kt, MemberService.kt)`
- `Explore/Librarian 병렬 점검(bg_9725b309, bg_7d18bd4d, bg_5be1625e, bg_234021df)`
- 결과:
- 직접 설정 API 부재(`MemberController`에 전용 엔드포인트 없음) 확인 결과를 문서에 반영했다.
- 국가 결정 우선순위(회원 ID 강제 매핑 > 접속 국가 헤더 > KR fallback)를 핵심 요구사항, `/member/info`, 테스트 항목에 일관 반영했다.
- `AuthController.authVerify` 성공 시 `isAdultContentVisible=true` 저장 항목을 구현 범위/우선순위/테스트에 추가했다.
### 3차 구현 (2026-03-26)
- 무엇을:
- 4-2 전수 대상 컨트롤러(`AudioContent*`, `SearchController`, `ContentSeriesController`, `SeriesMainController`, 메인탭 7종)에서 `MemberContentPreferenceService.resolveForQuery(...)`를 사용하도록 변경했다.
- 컨트롤러 단의 `isAdultContentVisible ?: true`, `member.auth != null && (isAdultContentVisible ?: true)` 계산식을 제거하고, 저장값 기반 `preference.isAdultContentVisible / preference.contentType / preference.isAdult`를 사용하도록 통일했다.
- 4-4 범위로 `ChatCharacterController`, `CharacterImageController`, `CharacterCommentController``member.auth` 강제 분기를 `MemberContentPreferenceService.getStoredPreference(member).isAdult` 기반 정책 가드로 전환했다.
- 왜:
- legacy 파라미터 기본값(`true`) 의존을 제거해 국가/인증 정책이 컨트롤러별로 분산되는 문제를 없애고, 저장값 기준 단일 정책으로 수렴하기 위해서다.
- 채팅 캐릭터 연관 경로까지 동일 정책을 적용해 도메인별 예외 분기를 줄이고 운영 일관성을 확보하기 위해서다.
- 어떻게:
- 명령:
- `grep(pattern=isAdultContentVisible\s*\?:\s*true|member\??\.auth\s*!=\s*null\s*&&\s*\(isAdultContentVisible\s*\?:\s*true\), path=src/main/kotlin, output_mode=content)`
- `grep(pattern=isAdultContentVisible\s*\?:\s*true, path=src/main/kotlin, output_mode=count)`
- `./gradlew test`
- `./gradlew ktlintCheck`
- `./gradlew build`
- 결과:
- `src/main/kotlin` 기준 `isAdultContentVisible ?: true` 패턴 0건 확인.
- 회귀 검증(`test`, `ktlintCheck`, `build`) 모두 성공.
- 참고: Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 정정 (2026-03-26)
- 무엇을:
- `1차 구현` 섹션의 "남은 항목"에 기재된 4-2/4-4 미완 상태를 최신 구현 상태(완료)와 맞춰 정정한다.
- 왜:
- 3차 구현에서 해당 범위가 실제로 완료되어, 과거 시점의 미완 표기가 현재 상태와 달라졌기 때문이다.
- 어떻게:
- 4-2 체크리스트 전 항목, 4-4 체크리스트 전 항목, 1차 배포 우선순위 6/7/9단계를 완료 상태(`[x]`)로 동기화했다.
### 4차 구현 (2026-03-26)
- 무엇을:
- 4-3 잔여 항목 중 성인 제어의 `member.auth` 직접 분기를 정책 기반으로 재정렬했다.
- `AudioContentService` 상세 조회의 연관 콘텐츠/모자이크 판단을 저장 선호 정책(`isAdult`) 기준으로 통일했다.
- `ExplorerQueryRepository#getLiveRoomList`는 성인 라이브 필터를 호출부 정책값(`isAdult`)만 사용하도록 변경했다.
- `CreatorCommunityController/Service`, `LiveTagService/Repository`는 저장 선호 기반 성인 필터를 사용하도록 정리했다.
- 태그 큐레이션/시리즈 조회의 누락 필터를 보완했다.
- `ContentMainTabTagCurationRepository`에 비성인 조회 시 `audioContent.isAdult.isFalse`를 추가했다.
- `ContentSeriesRepository#getGenreList`에 비성인 조회 시 `audioContent.isAdult.isFalse`를 추가했다.
- 단위 테스트를 보강했다.
- `MemberContentPreferenceServiceTest`, `MemberControllerTest`, `MemberServiceContentPreferenceTest`, `CreatorCommunityServiceTest`, `LiveTagServiceTest`를 추가/확장했다.
- 사용자 요청에 따라 정책 분기 의도를 설명하는 주석을 변경 코드의 핵심 분기 지점에 보강했다.
- 왜:
- 동일 기능 내에서 `member.auth` 직접 분기와 저장 선호 분기가 혼재하면 국가/인증 정책 일관성이 깨질 수 있어, 조회/필터 기준을 저장 선호 정책으로 단일화할 필요가 있었다.
- 누락된 성인 필터는 비성인 조회에서 의도치 않은 노출을 만들 수 있어 쿼리 레이어 보완이 필요했다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest"`
- `./gradlew test`
- `./gradlew ktlintCheck`
- `./gradlew build`
- 결과:
- 초기 `test`에서 `MemberServiceContentPreferenceTest` 2건 실패를 확인했고, Mockito matcher null 이슈를 테스트 코드에서 수정했다.
- 수정 후 대상 테스트/전체 테스트/ktlint/build를 재실행해 모두 성공했다.
- Kotlin LSP 미구성으로 LSP 진단은 불가했으며, Gradle 검증으로 대체했다.
### 4차 후속 보완 (Oracle 점검 반영, 2026-03-26)
- 무엇을:
- `AudioContentService#getDetail`에 비성인 정책 사용자의 성인 콘텐츠 직접 상세 진입 차단(`common.error.adult_verification_required`)을 추가했다.
- `CreatorCommunity` 댓글/답글 경로(`createCommunityPostComment`, `getCommunityPostCommentList`, `getCommentReplyList`)에 저장 선호 기반 `isAdult` 검증을 추가해 성인 게시물 우회 접근을 차단했다.
- 관련 단위 테스트(`CreatorCommunityServiceTest`)에 비성인 정책에서의 댓글 작성/댓글 목록/답글 목록 차단 케이스를 추가했다.
- 왜:
- 목록/상세/구매 경로는 정책이 적용되어도 댓글 경로와 직접 상세 진입이 열려 있으면 정책 우회가 가능해, 성인 노출 정책 일관성이 깨질 수 있기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 커뮤니티 서비스 단위 테스트 통과.
- 전체 검증 체인(test/ktlint/build) 모두 성공.
### 5차 구현 (미체크 항목 마감, 2026-03-26)
- 무엇을:
- 계획 문서의 미체크 항목 5개를 전수 점검하고, 구현/검증/문서화를 완료했다.
- 4-3 쿼리 레이어 검증 항목은 explore 병렬 감사 결과와 직접 검색 결과를 근거로 완료 처리했다.
- 통합 테스트 항목은 `MemberContentPreferenceIntegrationTest`를 추가해 아래 시나리오를 실제 영속성 연동으로 검증했다.
- 직접 설정(updatePreference) 저장 후 즉시 조회 반영
- `authVerify` 연동 메서드(`markAdultVisibleAfterAuthVerify`) 저장 반영
- legacy 호출 경로(`resolveForQuery`)의 row 생성 + 즉시 반영
- 헤더 누락 시 `KR` fallback 및 KR+미인증 기본값 유지
- KR+인증 회원의 요청값 반영 및 `isAdult` 계산
- 강제 국가 매핑 ID(`2`, `16`) 우선 적용
- 기존 회원 백필 전략/단계적 배포 항목은 현재 구현 상태(런타임 row 보정 + 단계별 배포 절차 문서화) 기준으로 완료 처리했다.
- 왜:
- 체크리스트 미완 상태를 해소하지 않으면 정책 전환 완료 기준이 불명확해지고, 운영 시 회귀 검증 근거가 약해지기 때문이다.
- 특히 통합 시나리오 부재는 “저장 후 즉시 반영” 보장을 약화시키므로 실제 repository 연동 테스트가 필요했다.
- 어떻게:
- 명령:
- `grep(pattern="^- \[ \]", include="20260325_콘텐츠조회설정서버저장전환.md")`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 신규 통합 테스트 통과.
- 전체 검증 체인(test/ktlint/build) 모두 성공.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 5차 후속 보완 (Oracle 리뷰 반영, 2026-03-26)
- 무엇을:
- `AudioContentService#getDetail`의 성인 상세 직접 진입 차단 로직에 대한 회귀 테스트를 `AudioContentServiceTest`에 추가했다.
- 비성인 정책(`isAdultContentVisible=false`)에서 성인 콘텐츠 조회 시 `common.error.adult_verification_required` 예외를 검증했다.
- 왜:
- 최종 리뷰에서 기능은 구현되어 있었지만 전용 테스트 증빙이 부족해, 정책 우회 회귀를 방지하기 위한 테스트 고정을 추가할 필요가 있었다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 신규 회귀 테스트 포함 대상 테스트 통과.
- 전체 검증 체인(test/ktlint/build) 모두 성공.
### 6차 구현 (이슈 1/2/3 안정화, 2026-03-26)
- 무엇을:
- 이슈 1 대응: `MemberContentPreferenceService.resolveForQuery`, `getStoredPreference``REQUIRES_NEW` 트랜잭션으로 분리해 `LiveRoomService`/`ExplorerService``readOnly` 조회 흐름에서도 설정 생성·갱신이 반영되도록 수정했다.
- 이슈 2 대응: 선호 변경 경로(`updatePreference`, `markAdultVisibleAfterAuthVerify`, legacy `resolveForQuery` 변경 발생 시)에 `getRecommendLive` 캐시 무효화를 연결하고, 커밋 이후에 evict 되도록 `afterCommit` 동기화를 적용했다.
- 이슈 3 대응: `initializeDefaultPreference`에서 `member` row를 `PESSIMISTIC_WRITE`로 잠근 뒤 재조회/생성하도록 변경해 동시 최초 요청 경쟁에서도 단일 row만 생성되도록 보강했다.
- 테스트 보강: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`에 캐시 무효화/충돌 재조회/초기 생성 반영 케이스를 추가했다.
- 사용자 요청 반영: 별도 계획 문서를 만들지 않고 기존 문서(`20260325_콘텐츠조회설정서버저장전환.md`)에 구현/검증 기록을 누적했다.
- 왜:
- readOnly 트랜잭션 참여로 저장이 누락될 수 있는 경로를 제거하고,
선호 변경 이후 추천 캐시 stale을 즉시 해소하며,
최초 row 생성 경쟁 시 unique 충돌이 사용자 오류로 노출되는 문제를 방지하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew test ktlintCheck build`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest.shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall"`
- 결과:
- 타깃 테스트(서비스/통합) 통과.
- 전체 검증 체인(`test`, `ktlintCheck`, `build`) 통과.
- 수동 QA 성격의 핵심 시나리오 3건(legacy 변경 캐시 무효화, 생성 충돌 재조회, 최초 legacy 호출 즉시 반영) 재실행 통과.
### 7차 버그 수정 (요청 국가 정합화 + 강제 매핑 유지, 2026-03-26)
- 무엇을:
- 검색 경로 불일치 보정을 위해 `SearchController`/`SearchService`를 수정해, `resolveForQuery(...)`에서 계산된 `preference.isAdult`를 검색 쿼리에 그대로 전달하도록 변경했다.
- `MemberContentPreferencePolicy`의 국가 결정을 `member.countryCode` 의존에서 제거하고, **강제 매핑 회원 ID(KR/JP) 우선 + 그 외 `CloudFront-Viewer-Country` 헤더 + `KR` fallback** 순서로 통일했다.
- `MemberContentPreferenceService.resolveCountryCode`도 동일하게 **강제 매핑 우선 + 접속 국가 헤더 + KR fallback**으로 유지/정렬했다.
- 사용자 지시(2번)대로 라이브 추천 캐시 키에 접속 국가를 반영하는 변경은 적용하지 않았고, 관련 시도 변경분은 모두 원복했다.
- 회귀 고정을 위해 `MemberContentPreferencePolicyTest`, `SearchServiceTest`를 추가하고, `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`를 정책 기준에 맞게 보강했다.
- 버그 수정 문서 전략은 별도 신규 문서 분리 대신, 기존 계획 문서(본 문서)에 구현/검증 기록을 누적하는 방식으로 확정했다.
- 왜:
- 검색 정책 계산에서 요청 국가와 멤버 저장 국가가 혼재되면 국가별 성인 노출 정책이 엇갈릴 수 있어, 정책 기준을 요청 흐름으로 일관화할 필요가 있었다.
- 다만 운영 중인 강제 매핑 회원은 기존 정책 계약이므로 그대로 보존해야 했고, 캐시 키 국가 분리는 현재 우선순위에서 제외하라는 사용자 지시를 준수해야 했다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest"`
- `./gradlew test`
- `./gradlew ktlintCheck`
- `./gradlew build`
- 수동 QA 성격 검증: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest.shouldPrioritizeForcedCountryMapping" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest.shouldUseProvidedIsAdultForContentSearch"`
- 결과:
- 정책/검색/통합/캐시 관련 타깃 테스트 통과.
- 전체 `test`, `ktlintCheck`, `build` 통과.
- 수동 QA 시나리오(강제 매핑 우선, 검색 isAdult 전달 고정) 통과.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 정정 (2026-03-26, 7차 중간 수정)
- 무엇을:
- `ktlintCheck` 1회 실패(테스트 파일 들여쓰기) 후 즉시 수정하고 재검증 결과를 반영한다.
- 왜:
- 7차 구현 중 테스트 파일 패치 과정에서 들여쓰기 불일치가 발생했기 때문이다.
- 어떻게:
- 실패 명령: `./gradlew ktlintCheck` (`MemberContentPreferenceServiceTest.kt` 들여쓰기 오류)
- 조치: 해당 파일 들여쓰기 정정
- 재실행: `./gradlew ktlintCheck` 성공
### 8차 리팩터링 (강제 매핑 국가 결정 로직 단일화, 2026-03-26)
- 무엇을:
- `MemberContentPreferenceService.resolveCountryCode(...)``MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)`에 중복되어 있던 강제 매핑 국가 결정 로직을 공통 함수로 통합했다.
- 신규 파일 `MemberContentPreferenceCountryResolver.kt`를 추가하고, 두 경로가 동일한 `resolveCountryCodeWithForcedMapping(...)`를 사용하도록 변경했다.
- 왜:
- 동일 정책 로직이 두 파일에 복제되어 있으면 한쪽만 수정될 때 운영 정책 불일치가 발생할 수 있어, 단일 소스로 유지보수 리스크를 줄이기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew ktlintCheck`
- `./gradlew test`
- `./gradlew build`
- 결과:
- 정책 관련 타깃 테스트 통과.
- `ktlintCheck`, `test`, `build` 통과.
- 참고: 병렬 실행 중 1회 테스트 리포트 파일 쓰기 충돌이 있었고(`:test`), 이후 `./gradlew test` 단독 재실행으로 정상 통과를 확인했다.
### 9차 정리 (MemberService 미사용 주입 제거, 2026-03-27)
- 무엇을:
- `MemberService` 생성자에서 실제로 사용되지 않던 `authRepository: AuthRepository` 주입을 제거했다.
- 관련 import(`kr.co.vividnext.sodalive.member.auth.AuthRepository`)를 함께 제거했다.
- 생성자 시그니처 변경에 맞춰 테스트 수동 생성부(`MemberServiceContentPreferenceTest`, `MemberServiceCacheEvictionTest`)의 인자 목록을 정렬했다.
- 왜:
- 미사용 주입을 유지하면 클래스 결합도와 유지보수 비용이 불필요하게 증가하고, 생성자 계약이 실제 책임보다 과도하게 커지기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"`
- `./gradlew ktlintCheck`
- `./gradlew test`
- `./gradlew build`
- 결과:
- MemberService 관련 타깃 테스트 통과.
- `ktlintCheck`, `test`, `build` 전체 통과.
### 10차 작업 계획 (communityPostLike 호출부 정합화, 2026-03-27)
- [x] `CreatorCommunityService.communityPostLike` 호출부를 전수 탐색한다.
- [x] 누락된 호출부에 `isAdult` 인자를 전달하도록 수정한다.
- [x] 관련 테스트 및 전체 검증(`ktlintCheck`, `test`, `build`)을 수행한다.
### 10차 정합화 (communityPostLike 호출부 인자 반영, 2026-03-27)
- 무엇을:
- `CreatorCommunityService.communityPostLike(request, member, isAdult)` 호출부를 전수 확인해 누락 지점을 정리했다.
- 운영 코드(`CreatorCommunityController`)는 이미 `isAdult` 전달이 되어 있어 유지했다.
- 테스트 코드(`CreatorCommunityServiceTest`)의 구 시그니처 호출을 신 시그니처로 수정하고, 테스트 설명/목 객체를 현재 구조에 맞게 정리했다.
- 왜:
- 서비스 시그니처 변경 이후 호출부가 일부 구 버전 형태를 유지하면 컴파일 실패 또는 정책 불일치가 발생할 수 있기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityControllerTest"`
- `./gradlew ktlintCheck`
- `./gradlew test`
- `./gradlew build`
- 결과:
- CreatorCommunity 타깃 테스트 통과.
- `ktlintCheck`, `test`, `build` 전체 통과.
### 코드리뷰 결과 (문서 목적 적합성/잠재 버그/일반 리뷰, 2026-03-27)
- 무엇을:
- 문서 요구사항(서버 저장값 전환, 국가 정책, legacy 호환 저장, 가입 선저장, `/member/info` 확장, `authVerify` 연동, 직접 설정 API)의 구현 여부를 코드 기준으로 대조 점검했다.
- `git diff --cached`, `git diff` 기준 변경 파일 전체를 검토하고, 변경된 핵심 경로(`MemberContentPreferenceService`, `MemberController`, `MemberService`, `AuthController`, `Home/Live/Explorer/LiveRoom/Search`, 채팅/커뮤니티/태그 경로)를 우선 리뷰했다.
- 실제 회귀 검증(`test`, `ktlintCheck`, `build`)을 다시 실행해 문서화했다.
- 왜:
- 체크리스트의 완료 표시(`[x]`)와 실제 구현 상태의 불일치, 그리고 변경분 내 정책 회귀 가능성을 배포 전에 제거하기 위해서다.
- 어떻게:
- 명령:
- `git status --short`
- `git diff --cached --name-only`
- `git diff --name-only`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 문서 핵심 목적 항목은 코드상 대부분 구현되어 있으며, API/서비스/테스트 경로가 문서 체크리스트와 전반적으로 일치함을 확인했다.
- 회귀 검증(`test`, `ktlintCheck`, `build`)은 모두 성공했다.
- 잠재 버그 1 (중요도: 중)
- 위치:
- `src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt`
- `@Cacheable(key = "'getRecommendLive:' + (#member?.id ?: 'guest')")`
- 시나리오:
- 동일 회원이 캐시 TTL(3시간) 내에 국가(`CloudFront-Viewer-Country`)가 달라진 요청을 보낼 때,
국가별 정책으로 계산되는 `isAdult` 결과가 달라도 캐시 키가 동일해 이전 국가 결과를 재사용할 수 있다.
- 예: US 요청에서 성인 추천이 캐시된 뒤 KR 요청에서도 동일 캐시를 반환.
- 영향:
- 국가별 성인 노출 정책 정합성이 깨질 수 있음(특히 요청 국가가 자주 바뀌는 환경/네트워크).
- 제안:
- 캐시 키에 정책 결정값(예: `countryCode` 또는 최종 `isAdult`)을 포함하거나,
- 선호/국가 관련 변경 시 국가 차원을 포함한 캐시 무효화 전략을 추가.
- 잠재 버그 2 (중요도: 중)
- 위치:
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- `initializeDefaultPreference(...)`의 조회 순서(`findByMemberId``findByIdForUpdate``findByMemberId`)
- 시나리오:
- MySQL 기본 격리수준(REPEATABLE READ)에서 동일 회원에 대한 최초 동시 요청이 들어오면,
첫 비잠금 조회 스냅샷이 유지되어 잠금 이후 재조회에서도 신규 row를 보지 못하고 중복 insert를 시도할 여지가 있다.
- 영향:
- 드물지만 최초 접근 경쟁 상황에서 unique key 충돌(`member_id`)로 간헐적 실패 가능.
- 제안:
- 잠금 획득을 선행한 뒤 선호 row를 조회하도록 순서를 변경하거나,
- 선호 row 조회 자체를 `FOR UPDATE`로 수행하거나,
- unique 충돌 예외를 잡아 재조회 후 반환하는 idempotent fallback을 추가.
- 일반 코드리뷰 코멘트
- 정책/저장 로직을 `MemberContentPreferenceService`로 집중시킨 방향은 유지보수 관점에서 일관성이 좋다.
- 다만 정책 계산이 "요청 국가"에 의존하는 경로는 캐시 키·무효화 정책과 항상 같이 검토되어야 하며,
해당 항목은 운영 이슈 재발 방지를 위해 테스트(국가 전환 + 캐시 적중)까지 고정하는 것을 권장한다.
### 코드리뷰 재검증 보강 (2026-03-27)
- 무엇을:
- 앞서 기록한 잠재 버그 2건을 실제 구현 파일 기준으로 재검토하고, 재현 전제와 우선순위를 보강했다.
- 왜:
- 현재 브랜치에 추가 수정(리팩터링/테스트 보강)이 포함되어 있어, 기존 리뷰 결론의 유효성을 재확인할 필요가 있었기 때문이다.
- 어떻게:
- 확인 파일:
- `src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt`
- 결과:
- 잠재 버그 1(추천 캐시 키 국가 차원 누락)은 여전히 유효하다.
- 근거: `LiveRecommendService.getRecommendLive`의 캐시 키가 `memberId`만 사용(`'getRecommendLive:' + memberId`)하고,
조회 결과는 `getStoredPreference(member).isAdult`(요청 국가 영향)로 달라질 수 있다.
- 전제: 동일 회원의 요청 국가가 TTL(3시간) 내 변경되는 환경.
- 잠재 버그 2(초기 생성 경쟁 시 중복 insert 위험)도 여전히 유효하다.
- 근거: `initializeDefaultPreference``findByMemberId`(비잠금 조회) 이후 `findByIdForUpdate(member)`를 잡고,
다시 `findByMemberId`(비잠금 조회)를 수행한다. MySQL REPEATABLE READ에서는 최초 스냅샷 영향으로
잠금 이후 재조회가 최신 row를 못 보고 중복 insert를 시도할 수 있다.
- 전제: 동일 회원 최초 접근이 동시 다발적으로 발생하는 경쟁 구간.
- 우선순위 제안:
- P1: 잠재 버그 2 완화(간헐적 DB unique 충돌/500 위험) — 사용자 오류로 직접 노출될 수 있어 우선 대응 권장.
- P2: 잠재 버그 1 보강(국가 전환 환경에서 정책 불일치 가능) — 운영 트래픽 특성(국가 전환 빈도)에 따라 단계 적용.
### 11차 작업 계획 (코드리뷰 잠재 버그 2건 보강, 2026-03-27)
- [x] 추천 라이브 캐시 키를 `memberId + isAdult` 기준으로 분리하고 무효화 키와 테스트를 동기화한다.
- [x] 선호 초기 row 생성 경로를 잠금 재조회 + unique 충돌 재조회 방식으로 보강한다.
- [x] 관련 타깃 테스트 및 전체 검증(`ktlintCheck`, `test`, `build`)을 수행한다.
### 11차 보강 구현 (잠재 버그 1/2 대응, 2026-03-27)
- 무엇을:
- 잠재 버그 1 대응:
- `LiveRecommendService`의 추천 조회 캐싱을 별도 빈 `LiveRecommendCacheService`로 분리하고,
캐시 키를 `getRecommendLive:{memberId}:{isAdult}` 형식으로 변경했다.
- 선호/차단 기반 무효화 경로(`MemberContentPreferenceService`, `MemberService`)를 `:false`, `:true` 키 양쪽 삭제로 확장했고,
롤링 배포 중 잔존 캐시 정리를 위해 기존 `getRecommendLive:{memberId}` 키 삭제도 함께 유지했다.
- 관련 테스트(`MemberContentPreferenceServiceTest`, `MemberServiceCacheEvictionTest`)를 신규 키 형식 기준으로 갱신했다.
- 잠재 버그 2 대응:
- `MemberContentPreferenceRepository``findByMemberIdForUpdate`를 추가해 잠금 재조회 경로를 명시했다.
- `MemberContentPreferenceService.initializeDefaultPreference`
`findByMemberId -> member lock -> findByMemberIdForUpdate -> saveAndFlush`로 보강하고,
unique 충돌(`DataIntegrityViolationException`) 발생 시 재조회 후 반환하도록 fallback을 추가했다.
- 경쟁 시나리오 회귀용 테스트(`shouldReturnStoredRowWhenDuplicateInsertOccurs`)를 추가했다.
- 왜:
- 동일 회원의 요청 정책 결과(`isAdult`)가 달라질 수 있는데 캐시 키가 memberId만 사용하면 stale 응답이 재사용될 수 있고,
REPEATABLE READ 환경에서 최초 동시 생성 경쟁 시 unique 충돌이 간헐적으로 사용자 오류로 노출될 수 있기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew ktlintCheck test build`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest.shouldEvictRecommendLiveCacheForRequesterAndTargetOnBlock" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest.shouldDelegateToRepositoryWithAdultFlagWhenMemberIsAuthenticated"`
- 결과:
- 타깃 테스트 통과.
- 전체 검증(`ktlintCheck`, `test`, `build`) 통과.
- 핵심 수동 QA 성격 시나리오(중복 insert fallback, 차단 시 양쪽 캐시 무효화, 성인 플래그 전달 조회) 통과.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 정정 (2026-03-27, 11차 중간 수정)
- 무엇을:
- 11차 1차 테스트에서 `MemberContentPreferenceServiceTest` 검증문이 `save`를 확인하고 있어 실패한 항목을 `saveAndFlush` 검증으로 정정했다.
- 왜:
- 동시성 보강 과정에서 서비스 저장 호출이 `save`에서 `saveAndFlush`로 변경되었기 때문이다.
- 어떻게:
- 실패 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- 조치:
- `MemberContentPreferenceServiceTest.shouldCreateDefaultPreferenceWhenRowIsMissing` 검증 대상을 `saveAndFlush`로 교체
- 재실행:
- 동일 타깃 테스트 명령 재실행 통과
### 12차 잠재 버그 재점검 (보강 후 재검토, 2026-03-27)
- 무엇을:
- 11차 보강 코드 재검토 중 `initializeDefaultPreference`의 unique 충돌 fallback 재조회가
비잠금 조회(`findByMemberId`)로 남아 있던 지점을 추가 보강했다.
- fallback 재조회를 `findByMemberIdForUpdate`로 변경해, REPEATABLE READ 스냅샷 영향으로 row를 못 보는 가능성을 낮췄다.
- 회귀 테스트(`MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs`)의 목 시퀀스를
변경된 fallback 호출 순서에 맞게 업데이트했다.
- 왜:
- 충돌 예외 이후 같은 트랜잭션에서 비잠금 재조회를 수행하면 스냅샷 일관성 때문에 최신 row를 못 보고
예외 재전파로 끝날 수 있어, 충돌 복구 경로의 신뢰성을 높일 필요가 있었기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew ktlintCheck test build`
- 결과:
- preference 서비스/통합 타깃 테스트 통과.
- 전체 검증(`ktlintCheck`, `test`, `build`) 통과.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.

View File

@@ -1,37 +0,0 @@
# 20260325 회원 차단 요청 id만 적용
- [x] memberBlock 호출 흐름 및 동일 auth 일괄 차단 지점 확인
- [x] memberBlock 로직을 request.id 단일 차단으로 수정
- [x] 관련 테스트 보강 및 회귀 검증
- [x] LSP 진단, 테스트, 빌드 검증 수행
## 2차 수정 체크리스트
- [x] `MemberService.memberBlock` 의미 단위 주석 추가
- [x] `MemberServiceCacheEvictionTest` 신규 테스트 의미 단위 주석 추가
- [x] 테스트 및 빌드 재검증
## 검증 기록
### 1차 구현
- 무엇을: `MemberService.memberBlock`에서 동일 `auth` 기반 다중 계정 확장 차단을 제거하고, `request.blockMemberId` 1건만 차단/재활성화하도록 수정했다.
- 왜: 회원 차단 API가 요청한 대상 ID만 차단해야 하며, 동일 auth 계정 전체가 함께 차단되는 과차단 동작을 제거해야 하기 때문이다.
- 어떻게:
- 탐색: explore 2개 + librarian 1개 백그라운드 분석, `grep`/`ast-grep`/`glob`로 호출 흐름과 확장 지점 확인.
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt``memberBlock`에서 `authRepository.getMemberIdsByNameAndBirthAndDiAndGender(...)` 및 다중 루프 제거.
- 테스트 변경: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt``shouldBlockOnlyRequestedMemberEvenWhenTargetHasAuth` 추가.
- 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가 확인.
- 검증 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` → 성공
- `./gradlew build` → 성공
### 2차 수정
- 무엇을: 1차에서 작성한 `memberBlock` 변경 코드와 회귀 테스트 코드에 의미 단위 주석을 추가했다.
- 왜: 요청하신 대로 작성된 코드의 의도를 블록 단위로 바로 파악할 수 있도록 하기 위해서다.
- 어떻게:
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt``memberBlock`에 검증/단일대상차단/캐시무효화 의도 주석 추가.
- 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt``shouldBlockOnlyRequestedMemberEvenWhenTargetHasAuth`에 준비/실행/검증 주석 추가.
- 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가 확인.
- 검증 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` → 성공
- `./gradlew build` → 성공

View File

@@ -1,30 +0,0 @@
SET @schema_name := DATABASE();
SET @table_exists := (
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema = @schema_name
AND table_name = 'member_content_preference'
);
SET @create_table_sql := IF(
@table_exists = 0,
'CREATE TABLE member_content_preference (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
member_id BIGINT NOT NULL COMMENT ''회원 ID (member.id 참조)'',
is_adult_content_visible TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''성인 콘텐츠 노출 여부 (0: 비노출, 1: 노출)'',
content_type VARCHAR(20) NOT NULL DEFAULT ''ALL'' COMMENT ''콘텐츠 타입 필터 값'',
adult_content_visibility_changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''성인 콘텐츠 노출 설정 변경 시각'',
content_type_changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''콘텐츠 타입 설정 변경 시각'',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
PRIMARY KEY (id),
UNIQUE KEY uk_member_content_preference_member_id (member_id),
CONSTRAINT fk_member_content_preference_member_id FOREIGN KEY (member_id) REFERENCES member (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''회원 콘텐츠 조회 설정''',
'SELECT ''member_content_preference already exists'' AS message'
);
PREPARE create_table_stmt FROM @create_table_sql;
EXECUTE create_table_stmt;
DEALLOCATE PREPARE create_table_stmt;

View File

@@ -1,102 +0,0 @@
# 20260327 멤버 콘텐츠 선호 기본값 조정
## 목적
- `MemberContentPreference` 신규 생성 기본값을 다음 정책으로 고정한다.
- 기존 회원 + `member.auth != null` 인 경우: `isAdultContentVisible = true`, `contentType = ContentType.ALL`
- 그 외: `isAdultContentVisible = false`, `contentType = ContentType.ALL`
## 구현 체크리스트
- [x] 기본값 시드 로직을 `member.auth` 기준 정책으로 단순화한다.
- QA: row 미존재 + 인증/미인증 케이스에서 저장값이 각각 `true/ALL`, `false/ALL`인지 테스트로 확인
- [x] 레거시 조회 파라미터(`isAdultContentVisible`, `contentType`)가 신규 row 기본값에 영향을 주지 않도록 정리한다.
- QA: `resolveForQuery` 호출 시 파라미터 전달 여부와 무관하게 정책 기본값으로 생성되는지 확인
- [x] 관련 단위/통합 테스트 기대값을 정책에 맞게 수정한다.
- QA: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest` 통과
- [x] 회귀 검증을 실행한다.
- QA: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`, `./gradlew build` 성공
## 구현 완료 후 기록
### 1차 구현
- 무엇을:
- `MemberContentPreferenceService.initializeDefaultPreference`의 기본 seed를 `member.auth != null` 기준으로 변경해 인증 회원은 `true/ALL`, 그 외는 `false/ALL`로 생성되도록 수정했다.
- `resolveForQuery`의 신규 row 생성 seed 계산에서 legacy 파라미터를 제거하고 `member.auth` 기반 고정 정책(`true/ALL` 또는 `false/ALL`)만 사용하도록 정리했다.
- `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`의 관련 시나리오를 정책에 맞게 수정했다.
- 왜:
- 요청사항이 “기존 회원가입 + `member.auth != null`이면 `true/ALL`, 그 외는 `false/ALL`”로 명확하여, 신규 row 기본값이 요청 파라미터에 영향을 받지 않도록 일관된 기준으로 통일해야 했기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldSeedPreferenceToTrueAndAllWhenRowMissingAndAuthenticatedRegardlessOfLegacyParams"`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- 결과:
- 정책 핵심 시나리오 단일 테스트 통과.
- 대상 단위/통합 테스트 통과.
- 전체 build(테스트/ktlint 포함) 통과.
- `.kt` 확장자용 LSP 서버가 현재 환경에 없어 `lsp_diagnostics`는 실행 불가였고, 대신 Gradle 검증으로 정합성을 확인했다.
## 연계 작업(동일 기능)
### 2차 구현 - `resolveForQuery` 조회 파라미터 제거
- 무엇을:
- `MemberContentPreferenceService.resolveForQuery` 시그니처에서 미사용 파라미터 2개
(`isAdultContentVisible`, `contentType`)를 제거하고 `member` 단일 파라미터로 정리했다.
- 시그니처 변경에 맞춰 서비스/컨트롤러/테스트의 `resolveForQuery` 호출부 인자 전달 코드를 일괄 정리했다.
- 왜:
- 실제로 사용되지 않는 파라미터를 제거해 함수 계약을 단순화하고, 호출부 가독성과 유지보수성을 높이기 위해서다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin compileTestKotlin`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
- 결과:
- 시그니처 변경 직후 컴파일 에러로 표시된 호출부를 모두 정리한 뒤 `compileKotlin/compileTestKotlin` 성공.
- 관련 단위/통합 테스트 통과.
- 전체 build(ktlint/test 포함) 성공.
- 현재 환경에는 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
Gradle 컴파일/테스트/빌드로 정합성을 확인했다.
### 3차 구현 - 수정 파일 미사용 파라미터 정리
- 무엇을:
- `resolveForQuery(member = member)`로 단순화된 이후 미사용 상태가 된
`resolvePreference` 헬퍼 파라미터를 12개 파일에서 제거했다.
- 헬퍼 호출부를 정리했고, null 회원 분기에서 실제로 파라미터를 사용하는 서비스/컨트롤러
(`HomeService`, `LiveApiService`, `AudioContentController`, `AudioContentMainTabHomeController`)는
기존 전달 로직을 유지했다.
- 왜:
- 사용되지 않는 파라미터는 경고와 혼선을 유발해 유지보수 비용을 높이므로,
실제 사용하는 함수 계약만 남겨 코드 의도를 명확히 하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
- 결과:
- `compileKotlin` 성공.
- 관련 단위/통합 테스트 성공.
- 전체 build(ktlint/test 포함) 성공.
- 현재 환경에 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
Gradle 컴파일/테스트/빌드 결과로 정합성을 확인했다.
### 4차 수정 - 잔여 미사용 파라미터 추가 정리
- 무엇을:
- 3차 정리 이후에도 남아 있던 수정 파일 내 함수 미사용 파라미터를 추가 제거했다.
- `resolvePreference(member: Member)`만 사용하는 컨트롤러들의
`@RequestParam("isAdultContentVisible")`, `@RequestParam("contentType")`를 제거하고 import를 정리했다.
- `ExplorerService.getCreatorProfile`의 미사용 파라미터 `isAdultContentVisible`을 제거하고
`ExplorerController` 호출부를 함께 수정했다.
- 왜:
- 실제 로직에서 사용되지 않는 파라미터를 제거해 함수 계약을 단순화하고,
유지보수 시 혼선을 줄이기 위해서다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin compileTestKotlin`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
- 결과:
- `compileKotlin`, `compileTestKotlin` 성공.
- 관련 단위/통합 테스트 성공.
- 전체 build(ktlint/test 포함) 성공.
- 현재 환경에 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
Gradle 검증으로 정합성을 확인했다.

View File

@@ -1,46 +0,0 @@
# 20260327 멤버 콘텐츠 선호 신규 생성 정책 수정
## 목적
- `resolveForQuery` 레거시 파라미터를 기존 row 갱신 용도로 사용하지 않고, **row 미존재 최초 생성 시에만** 제한적으로 사용한다.
- 최종 목표인 "MemberContentPreference 저장값만 조회에 사용" 방향으로 정책을 단순화한다.
## 최종 정책
- [x] `MemberContentPreference` 없음 + `member.auth != null`
- 요청 파라미터(`isAdultContentVisible`, `contentType`)가 있으면 전달값으로 생성한다.
- 요청 파라미터가 없으면 `isAdultContentVisible = true`, `contentType = ContentType.ALL`로 생성한다.
- [x] `MemberContentPreference` 없음 + `member.auth == null`
- `isAdultContentVisible = false`, `contentType = ContentType.ALL`로 생성한다.
- [x] `MemberContentPreference` 있음
- `resolveForQuery`로 들어온 요청 파라미터는 무시하고 저장값만 사용한다.
## 구현 체크리스트
- [x] `MemberContentPreferenceService` 생성 경로(`initializeDefaultPreference`)가 초기값을 정책 기반으로 받을 수 있도록 수정
- QA: `resolveForQuery` 호출 시 row 유/무에 따른 생성값이 테스트에서 일치하는지 확인
- [x] `resolveForQuery`에서 기존 row에 대한 레거시 파라미터 반영/캐시 무효화 제거
- QA: 기존 row + 파라미터 입력 시 저장값 불변 및 캐시 미무효화 테스트 통과
- [x] 관련 단위/통합 테스트 갱신
- QA: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest` 통과
- [x] 회귀 검증 실행
- QA: `./gradlew test`, `./gradlew ktlintCheck`, `./gradlew build` 성공
## 구현 완료 후 기록
### 1차 구현
- 무엇을:
- `MemberContentPreferenceService``PreferenceSeed`를 도입해 row 미존재 시 초기 생성값을 호출 목적에 맞게 주입하도록 변경했다.
- `resolveForQuery`는 더 이상 기존 row를 요청 파라미터로 갱신하지 않고, 저장값 조회 전용으로 동작하도록 수정했다.
- row 미존재 시 seed 정책을 다음과 같이 반영했다.
- `member.auth != null` + legacy 파라미터 존재: 전달값 기반 생성
- `member.auth != null` + legacy 파라미터 미존재: `true/ALL` 생성
- `member.auth == null`: 파라미터와 무관하게 `false/ALL` 생성
- `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`를 정책에 맞게 갱신/추가했다.
- 왜:
- 기존 row를 조회 API 파라미터로 계속 갱신하면 "저장값 단일 기준" 목표와 충돌하므로, 레거시 파라미터 역할을 row 최초 생성 시점으로 한정하기 위해서다.
- 기존 회원 중 row 미존재 사용자의 초기 생성 경로를 명시적으로 제어해 운영 일관성을 확보하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 정책 관련 단위/통합 테스트 통과.
- 전체 회귀 검증(`test`, `ktlintCheck`, `build`) 통과.
- `.kt` 대상 LSP 서버가 현재 환경에 없어 Kotlin LSP 진단은 수행 불가였고, 대신 Gradle 검증으로 대체했다.

View File

@@ -1,50 +0,0 @@
# 라이브 진행중 목록 19금 노출 정책 수정
## 완료 기준 (Pass/Fail)
- [x] `LiveRoomStatus.NOW` 조회 시 사용자 성인 설정과 무관하게 19금 라이브 방이 포함된다.
- [x] 예약 조회(`getLiveRoomListReservationWithDate`, `getLiveRoomListReservationWithoutDate`)의 성인 설정 필터 동작은 기존과 동일하다.
- [x] 기존 코드 패턴을 유지하며 최소 범위로 변경된다.
- [x] 변경 파일 LSP 진단 에러가 0건이다. *(Kotlin LSP 미지원 환경으로 `lsp_diagnostics` 실행 불가, 테스트/빌드 성공으로 대체 검증)*
- [x] 관련 테스트/빌드 검증 명령이 성공한다.
## 구현 체크리스트
- [x] NOW/예약 목록 분기 및 성인 필터 전달 경로를 확인한다.
- [x] NOW 목록 조회 경로만 정책에 맞게 수정한다. *(QA: NOW 경로 호출 인자 검증)*
- [x] 예약 목록 조회 경로가 기존 로직을 유지하는지 검증한다. *(QA: 예약 경로 호출 인자/쿼리 유지 확인)*
- [x] 익명 사용자(member=null) NOW 조회에서 성인 필터 우회 범위가 과도하지 않도록 조건을 보강한다. *(2차 가정, 3차에서 정책 정정됨)*
- [x] 정책 정정 반영: NOW 목록은 익명 사용자도 노출 대상이며, 후속 상세/입장 단계에서 인증/성인 검증을 수행하도록 분기와 테스트를 재정렬한다.
- [x] `FORCED_JP_MEMBER_IDS``37543L` 강제 매핑 회귀 테스트를 추가한다. *(QA: 정책/통합 테스트에 ID 37543L 검증 추가)*
- [x] 관련 테스트와 빌드 검증을 수행하고 결과를 문서에 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult = true`를 전달하도록 수정하고, 예약 분기는 기존 `isAdult` 전달을 유지했다. 또한 NOW/예약 전달 정책을 검증하는 `LiveRoomServiceAdultVisibilityPolicyTest`를 추가했다.
- 왜: 진행 중 라이브 목록은 사용자 성인 설정과 무관하게 19금 방을 노출하고, 예약 목록은 기존 정책대로 사용자 설정을 반영해야 하기 때문이다.
- 어떻게:
- 전달값 확인: `grep`으로 NOW/예약 분기의 `isAdult` 전달값 확인 (`isAdult = true` / `isAdult = isAdult`).
- LSP 진단 시도: `lsp_diagnostics` for `LiveRoomService.kt`, `LiveRoomServiceAdultVisibilityPolicyTest.kt`**불가(환경에 Kotlin LSP 서버 미구성)**
- 정책 단위 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest"`**성공(BUILD SUCCESSFUL)**
- 관련 선호도 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드: `./gradlew build`**성공(BUILD SUCCESSFUL)**
### 2차 수정 (리뷰 피드백 반영)
- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult` 전달값을 `member != null || isAdult`로 조정해 로그인 사용자에게만 우회가 적용되도록 보강했다. 또한 `LiveRoomServiceAdultVisibilityPolicyTest`에 비로그인 NOW 조회 회귀 케이스를 추가하고, `MemberContentPreferencePolicyTest`/`MemberContentPreferenceIntegrationTest``37543L -> JP` 강제 매핑 검증을 추가했다.
- 왜: 기존 `isAdult = true` 고정은 익명 사용자까지 성인 진행중 라이브를 노출할 수 있어 정책 범위가 과도해질 수 있으며, 강제 JP ID 추가(`37543L`)는 테스트로 고정해 회귀를 방지해야 하기 때문이다.
- 어떻게:
- LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일 4개 → **불가(환경에 Kotlin LSP 서버 미구성)**
- 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드(ktlint 포함): `./gradlew build`**성공(BUILD SUCCESSFUL)**
### 정정
- 정정 대상: `2차 수정 (리뷰 피드백 반영)`의 정책 가정(익명 NOW 노출 제한)
- 사유: 요구사항 재확인 결과, NOW 목록에서 익명 사용자 노출은 의도된 기능이며 상세/입장 단계에서 인증 및 성인 검증을 수행하는 정책으로 확정되었다.
- 변경 내용: NOW 분기의 익명 제한 보강(`member != null || isAdult`)을 제거하고, 익명 포함 우회(`isAdult = true`)로 복원했다. 관련 회귀 테스트도 익명 우회 기대값으로 정렬했다.
### 3차 수정 (정책 정정 반영)
- 무엇을: `LiveRoomService.getRoomList` NOW 분기의 `isAdult` 전달값을 `isAdult = true`로 복원했다. `LiveRoomServiceAdultVisibilityPolicyTest`의 익명 NOW 케이스를 `isAdult = true` 기대로 수정하고, 테스트명/DisplayName을 정책 의미에 맞게 변경했다.
- 왜: NOW 목록은 익명 사용자에게도 노출하되, 실제 터치 후 상세/입장 단계에서 인증 및 성인 검증(`live.room.adult_verification_required`)을 수행하는 것이 의도된 정책이기 때문이다.
- 어떻게:
- 탐색 근거 수집: Explore/Librarian + `grep` + `sg`로 NOW 노출 경로, 후속 인증 가드, 테스트 기대값을 재확인했다. (`rg`는 실행 환경에 미설치로 대체 탐색 수행)
- LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일들 → **불가(환경에 Kotlin LSP 서버 미구성)**
- 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드(ktlint 포함): `./gradlew build`**성공(BUILD SUCCESSFUL)**

View File

@@ -1,24 +0,0 @@
# 채널 후원 내역 탈퇴 닉네임 접두사 제거
## 완료 기준 (Pass/Fail)
- [x] 채널 후원 내역 리스트 조회 응답에서 탈퇴 회원 닉네임의 `deleted_` 접두사가 제거된다.
- [x] 비탈퇴 회원 닉네임은 기존과 동일하게 노출된다.
- [x] 기존 코드베이스의 유사 처리 패턴과 동일한 방식으로 구현된다.
- [x] 변경 파일 LSP 진단 에러가 0건이다. *(Kotlin LSP 미지원 환경으로 `lsp_diagnostics` 실행 불가, `./gradlew build` 성공으로 대체 검증)*
- [x] 관련 테스트/빌드 검증 명령이 성공한다.
## 구현 체크리스트
- [x] `deleted_` 닉네임 처리 유사 구현 위치를 전수 탐색한다.
- [x] 채널 후원 내역 조회 응답 생성 경로를 확인한다.
- [x] 조회 시점에 닉네임 접두사 제거 로직을 반영한다.
- [x] 변경사항 검증 후 체크리스트를 완료 처리한다.
## 검증 기록
### 1차 구현
- 무엇을: 채널 후원 내역 조회 응답의 탈퇴 회원 닉네임에서 `deleted_` 접두사를 제거하고, 동일 동작을 검증하는 테스트를 추가했다.
- 왜: 탈퇴 회원 닉네임이 API 응답에 내부 저장 포맷(`deleted_`) 그대로 노출되는 문제를 해결하기 위해서다.
- 어떻게:
- `lsp_diagnostics` 실행 시도: `ChannelDonationService.kt` 대상 실행 → **불가(환경에 Kotlin LSP 서버 미구성)**
- 기능 집중 테스트 실행: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest.shouldRemoveDeletedPrefixFromNicknameInDonationList"`**성공(BUILD SUCCESSFUL)**
- 관련 테스트 실행: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationControllerTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드 실행: `./gradlew build`**성공(BUILD SUCCESSFUL)**

View File

@@ -1,44 +0,0 @@
# 20260328 콘텐츠 조회 파라미터 제거 및 비로그인 기본값 고정
## 목적
- 모든 API에서 `isAdultContentVisible`, `contentType` 요청 파라미터를 제거한다.
- 비로그인 사용자는 항상 `isAdultContentVisible = false`, `contentType = ContentType.ALL`로 처리한다.
- 로그인 사용자는 기존과 동일하게 `MemberContentPreference` 기반 로직을 유지한다.
## 구현 체크리스트
- [x] `isAdultContentVisible`, `contentType`를 받는 잔여 API 시그니처를 모두 제거한다.
- QA: `grep("@RequestParam(\"isAdultContentVisible\"|@RequestParam(\"contentType\")")` 결과가 0인지 확인
- [x] 연관 서비스 메서드 시그니처/호출부를 정리한다.
- QA: `compileKotlin` 성공으로 시그니처 불일치가 없는지 확인
- [x] 비로그인 기본값을 `false/ALL`로 고정한다.
- QA: 익명 분기 `ViewerContentPreference(false/ALL)` 코드 확인 + 관련 테스트 통과
- [x] 로그인 분기는 기존 `memberContentPreferenceService.resolveForQuery(member = member)` 흐름을 유지한다.
- QA: 관련 컨트롤러/서비스에서 로그인 분기 호출 유지 확인
- [x] 회귀 검증을 수행한다.
- QA: `./gradlew test`, `./gradlew build` 성공
## 구현 완료 후 기록
### 1차 구현
- 무엇을:
- 잔여 API 파라미터를 전부 제거했다.
- `HomeController`, `LiveApiController`, `LiveRoomController`, `AudioContentController`, `AudioContentMainTabHomeController`
- 연관 서비스 시그니처와 호출부를 정리했다.
- `HomeService`, `LiveApiService`, `LiveRoomService`
- 비로그인 분기 기본값을 `ViewerContentPreference(isAdultContentVisible = false, contentType = ContentType.ALL, isAdult = false)`로 고정했다.
- 왜:
- 요청사항이 “모든 API에서 해당 파라미터 제거 + 비로그인 기본값 고정 + 로그인 기존 동작 유지”로 명확했기 때문이다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin compileTestKotlin`
- `grep("@RequestParam(\"isAdultContentVisible\"|@RequestParam(\"contentType\")", include="*Controller.kt")`
- `ast-grep: ViewerContentPreference(isAdultContentVisible = false, contentType = ContentType.ALL)`
- `./gradlew test`
- `./gradlew build`
- `lsp_diagnostics`(수정된 `.kt` 파일 대상)
- 결과:
- 컴파일 성공(`compileKotlin`, `compileTestKotlin`).
- 컨트롤러의 `@RequestParam("isAdultContentVisible")`, `@RequestParam("contentType")` 검색 결과 0건.
- 비로그인 기본값 고정 분기 5개 위치 확인(`HomeService`, `LiveApiService`, `LiveRoomService`, `AudioContentController`, `AudioContentMainTabHomeController`).
- `./gradlew test` 성공.
- `./gradlew build` 성공.
- 현재 환경은 Kotlin LSP 서버 미구성으로 `lsp_diagnostics(.kt)` 실행 불가였고, Gradle 컴파일/테스트/빌드로 정합성 검증 완료.

View File

@@ -1,2 +0,0 @@
ALTER TABLE live_room
ADD COLUMN is_capture_recording_available TINYINT(1) NOT NULL DEFAULT 0 COMMENT '캡쳐/녹화 가능 여부';

View File

@@ -1,20 +0,0 @@
# 라이브 캡쳐/녹화 설정 추가
## 구현 항목
- [x] 라이브 생성/수정/조회 관련 기존 필드 및 흐름 분석
- [x] 라이브 정보에 캡쳐/녹화 단일 가능 여부 플래그 추가
- [x] 라이브 생성 시에만 캡쳐/녹화 가능 여부를 설정하도록 반영
- [x] DB 컬럼 추가 DDL 작성
- [x] 관련 테스트 코드 보강
- [x] 정적 진단/테스트/빌드 검증 수행
## 검증 기록
### 1차 구현
- 무엇을: 라이브 생성 요청(`CreateLiveRoomRequest`)과 라이브 엔티티(`LiveRoom`)에 `isCaptureRecordingAvailable` 단일 플래그를 추가하고, 라이브 정보 응답(`GetRoomInfoResponse`)에 동일 플래그를 노출하도록 반영했다.
- 왜: 캡쳐/녹화를 분리하지 않고 하나의 설정값으로 관리하면서, 해당 값이 생성 시점에만 결정되도록 하기 위해서다.
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest"` 실행 결과: 성공
- `./gradlew build` 실행 결과: 성공
- 수동 QA(서비스 단위): `shouldPersistCaptureAndRecordingAvailabilityOnCreate`, `shouldIncludeCaptureAndRecordingAvailabilityInRoomInfo` 테스트로 생성 저장값/정보 응답값 확인
- `lsp_diagnostics` 실행 결과: `.kt` LSP 서버 미구성으로 실행 불가(대신 Gradle 컴파일·ktlint·test·build 통과로 검증)

View File

@@ -1,50 +0,0 @@
# 애플 로그인 aud 검증 실패 원인 분석
## 구현/분석 항목
- [x] `/member/login/apple` 요청 흐름과 `AppleIdentityTokenVerifier` 검증 로직을 확인한다.
QA: 관련 코드 경로와 실제 비교값(`audience` vs 설정값)을 파일 근거로 정리한다.
- [x] Apple Identity Token의 `aud` 규칙(웹 Service ID / 네이티브 Bundle ID)을 확인해 실패 원인을 확정한다.
QA: 공식 문서/신뢰 가능한 레퍼런스 근거를 함께 기록한다.
- [x] 필요 시 서버 검증 로직을 수정해 웹/앱 로그인 환경과 일치시키고, 불필요하면 수정하지 않는다.
QA: 수정 전/후 조건을 비교해 실패 지점 해소 여부를 설명한다.
- [x] 변경 사항에 대해 정적/실행 검증을 수행한다.
QA: 실행 명령과 성공/실패 결과를 기록한다.
## 검증 기록
- 1차 분석: 진행 전
- 무엇을: 애플 로그인 aud 검증 실패 재현 경로 분석을 시작했다.
- 왜: 62번째 줄 audience 검증 실패 원인을 코드/설정/외부 규격 기준으로 확정하기 위해서다.
- 어떻게: 코드 검색, 외부 문서 조사, 필요 시 테스트/빌드 검증을 수행할 계획이다.
- 2차 분석: 실패 원인 확정
- 무엇을: `/member/login/apple` 호출 경로와 Apple 토큰 audience 비교 대상을 확인했다.
- 왜: 실제 실패 지점이 검증 로직 문제인지, 설정 누락인지를 분리하기 위해서다.
- 어떻게: `MemberController.loginApple``AppleAuthService.authenticate``AppleIdentityTokenVerifier.validateClaims` 흐름을 확인했고,
`claims.audience.contains(bundleId)`(기존 62줄) 비교가 `apple.bundle-id` 단일값에만 의존함을 확인했다.
- 3차 분석: 외부 규격 대조
- 무엇을: Apple 공식 문서 기준으로 `id_token.aud` 의미를 확인했다.
- 왜: 웹 로그인에서 `aud` 기대값이 Bundle ID인지 Service ID인지 확정해야 수정 기준이 생긴다.
- 어떻게: Apple 문서에서 `aud == client_id`, 웹 Sign in with Apple JS는 `client_id`로 Service ID를 사용함을 확인했다.
따라서 웹 토큰의 `aud`가 Service ID일 때 기존 bundleId 단일 비교는 실패가 정상임을 확정했다.
- 4차 구현: 검증 로직 보완
- 무엇을: Apple 로그인 audience 검증 대상을 `bundleId` + `serviceId`로 확장했다.
- 왜: 웹(Service ID)과 앱(Bundle ID) 토큰 모두 동일 백엔드 검증 로직에서 처리하기 위해서다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt`
- `@Value("\${apple.service-id:}")` 추가
- `resolveExpectedAudiences()`로 유효 audience 집합 생성
- `isSupportedAudience()``claims.audience` 교집합 검증
- `src/main/resources/application.yml`
- `apple.serviceId: ${APPLE_SERVICE_ID:}` 추가
- `src/test/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifierTest.kt`
- bundleId/serviceId 허용 및 미일치 거부 케이스 추가
- 5차 검증: 정적/실행 확인
- 무엇을: 변경 코드의 테스트/린트/빌드를 수행했다.
- 왜: audience 로직 변경이 실제로 컴파일/테스트/스타일 검증을 통과하는지 확인하기 위해서다.
- 어떻게:
- `lsp_diagnostics` (Kotlin 파일): 로컬 환경에 `.kt` LSP 서버 미설정으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.social.apple.AppleIdentityTokenVerifierTest"` → 성공
- `./gradlew ktlintCheck build -x test` → 성공

View File

@@ -1,22 +0,0 @@
- [x] chat 패키지의 AI 캐릭터 상세/채팅 본인인증 적용 지점을 확인한다.
- [x] 기존 캐릭터 상세의 국가별 본인인증 분기 방식을 확인한다.
- [x] chat 패키지의 AI 캐릭터 및 AI 캐릭터 채팅 로직에 동일한 국가별 인증 방식을 반영한다.
- [x] 변경 사항에 대한 진단 및 관련 검증을 수행한다.
## 검증 기록
### 1차 구현
- 무엇을: `ChatRoomController`, `ChatQuotaController`, `ChatRoomQuotaController`의 본인인증 체크를 `member.auth` 직접 검사에서 `MemberContentPreferenceService.getStoredPreference(member).isAdult` 기반 국가별 판정으로 변경했다.
- 왜: AI 캐릭터 상세와 동일하게 한국은 본인인증이 필요하고, 그 외 국가는 저장된 성인 노출 설정 기준으로 접근하도록 맞추기 위해서다.
- 어떻게:
- `./gradlew compileKotlin` → 성공
- `./gradlew test` → 성공
- 변경 컨트롤러 3개에서 `member.auth == null` 직접 검사가 제거되고 `resolveIsAdultAccessible(...)`로 치환된 것을 확인함
### 2차 수정
- 무엇을: `OriginalWorkController`의 목록/상세 본인인증 체크도 동일한 국가별 판정으로 변경했다.
- 왜: `chat/original` 하위에 `member.auth` 직접 검사 잔여 지점이 남아 있어, 최초 요청 범위인 `chat` 패키지 전체 기준으로 정책이 완전히 일치하지 않았기 때문이다.
- 어떻게:
- `./gradlew compileKotlin` → 성공
- `./gradlew test` → 성공
- `src/main/kotlin/kr/co/vividnext/sodalive/chat` 전체에서 `member.auth == null|member?.auth != null` 검색 → 결과 없음

View File

@@ -1,41 +0,0 @@
SET @schema_name := DATABASE();
SET @lang_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'content_banner'
AND column_name = 'lang'
);
SET @add_lang_column_sql := IF(
@lang_column_exists = 0,
'ALTER TABLE content_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER type',
'SELECT ''content_banner.lang already exists'' AS message'
);
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
EXECUTE add_lang_column_stmt;
DEALLOCATE PREPARE add_lang_column_stmt;
UPDATE content_banner
SET lang = 'KO'
WHERE lang IS NULL;
SET @lang_column_nullable := (
SELECT IS_NULLABLE
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'content_banner'
AND column_name = 'lang'
);
SET @alter_lang_column_sql := IF(
@lang_column_nullable = 'YES',
'ALTER TABLE content_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
'SELECT ''content_banner.lang already normalized'' AS message'
);
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
EXECUTE alter_lang_column_stmt;
DEALLOCATE PREPARE alter_lang_column_stmt;

View File

@@ -1,41 +0,0 @@
SET @schema_name := DATABASE();
SET @lang_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'chat_character_banner'
AND column_name = 'lang'
);
SET @add_lang_column_sql := IF(
@lang_column_exists = 0,
'ALTER TABLE chat_character_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER sort_order',
'SELECT ''chat_character_banner.lang already exists'' AS message'
);
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
EXECUTE add_lang_column_stmt;
DEALLOCATE PREPARE add_lang_column_stmt;
UPDATE chat_character_banner
SET lang = 'KO'
WHERE lang IS NULL;
SET @lang_column_nullable := (
SELECT IS_NULLABLE
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'chat_character_banner'
AND column_name = 'lang'
);
SET @alter_lang_column_sql := IF(
@lang_column_nullable = 'YES',
'ALTER TABLE chat_character_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
'SELECT ''chat_character_banner.lang already normalized'' AS message'
);
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
EXECUTE alter_lang_column_stmt;
DEALLOCATE PREPARE alter_lang_column_stmt;

View File

@@ -1,41 +0,0 @@
SET @schema_name := DATABASE();
SET @lang_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'recommend_live_creator_banner'
AND column_name = 'lang'
);
SET @add_lang_column_sql := IF(
@lang_column_exists = 0,
'ALTER TABLE recommend_live_creator_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER is_adult',
'SELECT ''recommend_live_creator_banner.lang already exists'' AS message'
);
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
EXECUTE add_lang_column_stmt;
DEALLOCATE PREPARE add_lang_column_stmt;
UPDATE recommend_live_creator_banner
SET lang = 'KO'
WHERE lang IS NULL;
SET @lang_column_nullable := (
SELECT IS_NULLABLE
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'recommend_live_creator_banner'
AND column_name = 'lang'
);
SET @alter_lang_column_sql := IF(
@lang_column_nullable = 'YES',
'ALTER TABLE recommend_live_creator_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
'SELECT ''recommend_live_creator_banner.lang already normalized'' AS message'
);
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
EXECUTE alter_lang_column_stmt;
DEALLOCATE PREPARE alter_lang_column_stmt;

View File

@@ -1,10 +0,0 @@
- [x] 배너 목록 조회 응답 생성 경로와 언어 정보 위치를 확인한다.
- [x] 배너 목록 응답의 연결 캐릭터 이름에 배너 등록 언어를 `(언어)` 형식으로 추가한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 배너 목록 조회 응답에서 연결 캐릭터 이름 뒤에 배너 등록 언어를 `(언어)` 형식으로 붙이도록 수정했다.
- 왜: 같은 이름과 같은 이미지의 배너라도 등록 언어가 다르면 관리자 페이지에서 즉시 구분할 수 있어야 하기 때문이다.
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest"` 실행으로 컨트롤러 테스트를 검증했고, 새 테스트에서 목록 조회 응답 이름이 `character-12 (일본어)`로 반환되는 것을 확인했다. 결과는 `BUILD SUCCESSFUL`이다.

View File

@@ -1,11 +0,0 @@
- [x] 추천 크리에이터 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
- [x] 추천 크리에이터 등록 API에 `lang` 파라미터를 추가하고 `Lang` 기준으로 저장하도록 수정한다.
- [x] 관리자 추천 크리에이터 목록은 전체 언어를 유지하고, `LiveApiService.fetchData`의 추천 크리에이터 조회는 사용자 언어에 맞는 배너만 반환하도록 수정한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 추천 크리에이터 배너 엔티티와 관리자 등록 API에 `lang`을 추가하고, 라이브 메인 `fetchData``/live/recommend` 조회가 현재 요청 언어와 일치하는 배너만 조회하도록 수정했다. 운영 반영용으로 `recommend_live_creator_banner.lang` 컬럼 DDL 문서도 추가했다.
- 왜: 관리자에서는 언어별 추천 크리에이터 배너를 등록할 수 있어야 하고, 사용자 라이브 화면에서는 자신의 언어와 맞는 추천 크리에이터만 노출되어야 하기 때문이다. 관리자 목록 API는 기존처럼 전체 언어 배너를 그대로 조회해야 한다.
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 검증으로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.live.AdminLiveServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest"`로 소문자 `lang` 저장, 서비스 언어 전달, 언어별 추천 배너 조회를 검증했다. 이어서 `./gradlew ktlintCheck``./gradlew build`를 실행했고 모두 `BUILD SUCCESSFUL`이다.

View File

@@ -1,10 +0,0 @@
- [x] 시리즈 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
- [x] 배너 등록 시 언어를 저장하고 관리자 목록에서 시리즈 제목에 `(언어)` 표기를 추가한다.
- [x] 사용자 시리즈 메인 조회에서 요청 언어와 일치하는 배너만 반환하도록 수정하고 검증 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 시리즈 배너 등록 요청에 `lang`을 추가하고, 관리자 목록에서는 `seriesTitle (언어)` 형태로 응답하며, 사용자 시리즈 메인에서는 `LangContext`와 일치하는 언어 배너만 조회하도록 수정했다.
- 왜: 관리자 화면에서는 같은 시리즈명의 다국어 배너를 구분할 수 있어야 하고, 사용자 화면에서는 요청 언어와 맞는 배너만 노출되어야 하기 때문이다.
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceTest" --tests "kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest" --tests "kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest"`를 실행해 등록 언어 저장, 관리자 목록 언어 표기, 사용자 언어별 배너 조회를 검증했다. 결과는 `BUILD SUCCESSFUL`이다.

View File

@@ -1,11 +0,0 @@
- [x] 오디오 콘텐츠 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
- [x] 배너 등록 API에 `lang` 파라미터를 추가하고 지원 언어를 `Lang` 기준으로 저장하도록 수정한다.
- [x] 관리자 배너 목록은 전체 언어 배너를 유지하고, HomeService `fetchData`는 사용자 언어와 일치하는 배너만 조회하도록 수정한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 오디오 콘텐츠 배너 엔티티와 등록 요청에 `lang`을 추가하고, 홈 `fetchData`에서 현재 사용자 언어를 넘겨 해당 언어 배너만 조회하도록 수정했다. 운영 반영용으로 `content_banner.lang` 컬럼 DDL도 추가했다.
- 왜: 관리자 등록 시 언어별 배너를 구분해 저장해야 하고, 홈에서는 사용자 언어와 맞는 배너만 노출되어야 하기 때문이다. 관리자 목록 API는 기존처럼 언어 전체 배너를 그대로 조회해야 한다.
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일/테스트로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest" --tests "kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest" --tests "kr.co.vividnext.sodalive.api.home.HomeServiceTest"`로 등록 언어 저장, 언어별 배너 조회, 홈 언어 전달을 검증했다. 이어서 `./gradlew ktlintCheck`를 실행해 스타일 검증까지 확인했고 두 명령 모두 `BUILD SUCCESSFUL`이다.

View File

@@ -1,25 +0,0 @@
- [x] ChatCharacterBanner 엔티티에 한국어 기본 배너와 일본어/영어 배너를 구분할 언어 필드를 유지한다.
- [x] 관리자 배너 등록 API가 기본언어 한국어를 기본값으로 사용하고, 일본어/영어 배너도 등록할 수 있도록 요청값과 서비스 로직을 수정한다.
- [x] 캐릭터 메인 배너 조회가 요청 언어 배너를 우선 조회하고, 없으면 한국어 배너를 fallback 하도록 수정한다.
- [x] 관련 테스트 또는 검증을 수행하고 결과를 기록한다.
- [x] 관리자 배너 등록 요청의 `lang`이 ISO 639 언어코드(`ko`, `en`, `ja`)로 들어와도 `Lang` enum으로 역직렬화되도록 수정한다.
## 검증 기록
### 1차 구현
- 무엇을: 채팅 캐릭터 배너를 기본 배너와 일본어 배너 행으로 분리해 저장하도록 `lang` 필드를 추가하고, 관리자 등록 API와 메인 배너 조회 로직을 일본어 기준으로 분기했다. 운영 반영용 MySQL DDL 문서 `docs/20260402_chat_character_banner_lang_ddl.sql`도 함께 추가했다.
- 왜: 현재 배너 구조는 이미지 1개 기준 행 모델이라 동일 목적지에 여러 언어 이미지를 한 레코드에 묶는 것보다, 언어별 행 분리가 기존 정렬/활성화/수정 흐름을 가장 적게 건드리는 방식이기 때문이다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 서비스 분기와 언어 검증을 확인했고, `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 등록 API와 메인 조회 API 흐름을 실행 검증했다. 이어서 `./gradlew ktlintCheck`, `./gradlew build`를 실행했다.
- 결과: 지정한 테스트는 모두 성공했고, `ktlintCheck``build`도 모두 성공했다. Kotlin LSP 서버가 없어 `lsp_diagnostics`는 수행할 수 없었다.
### 2차 수정
- 무엇을: 배너 기본언어를 명시적 `KO`로 변경하고, 등록 가능 언어를 `KO`, `EN`, `JA`로 확장했다. 또한 메인 배너 조회는 요청 언어 배너가 없을 때 `KO` 배너로 fallback 하도록 수정했고, MySQL DDL도 `NULL -> KO` 데이터 정규화와 `NOT NULL DEFAULT 'KO'`로 보강했다.
- 왜: 기본 배너를 `null`로 해석하는 방식보다 `KO`를 명시 저장하는 방식이 등록 규칙과 조회 fallback 규칙을 더 일관되게 표현하고, 영어 배너 추가 요구사항도 자연스럽게 수용할 수 있기 때문이다.
- 어떻게: 서비스 로직과 테스트를 `KO/EN/JA` 기준으로 재작성하고, 관리자 등록 API 기본값과 메인 조회 경로를 대상으로 단위 테스트를 추가·수정했다. 이후 `ktlintCheck`, 대상 테스트, 전체 빌드를 다시 실행했다.
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 모두 실행했고 전부 성공했다. 관리자 등록 테스트에서는 `lang`이 없을 때 `registerBanner(2L, "", null)` 호출이 발생하고 성공 응답이 반환되는 것을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 이번에도 수행하지 못했다.
### 3차 수정
- 무엇을: `Lang` enum에 Jackson `@JsonCreator` 기반 역직렬화 진입점을 추가해 관리자 배너 등록 요청의 `lang``ko`, `en`, `ja` 같은 ISO 639 코드로 들어와도 `Lang.KO`, `Lang.EN`, `Lang.JA`로 파싱되도록 수정했다. 기존 enum 이름(`KO`, `EN`, `JA`) 입력도 계속 허용했다.
- 왜: 관리자 요청에서 `Lang` enum을 직접 받고 있으므로, 외부에서 ISO 639 코드 값을 보내더라도 별도 DTO 변환 없이 안전하게 처리되게 해야 하기 때문이다.
- 어떻게: `Lang.fromCode(...)`를 Jackson 역직렬화 팩토리로 연결하고, 관리자 배너 컨트롤러 테스트 요청값을 `"ja"`로 바꿨다. 또한 `ObjectMapper().readValue(...)``"en"` 입력이 실제 `Lang.EN`으로 역직렬화되는 테스트를 추가했다.
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 실행했고 모두 성공했다. 관리자 배너 등록 테스트는 실제 요청 문자열 `{"characterId":1,"lang":"ja"}` 를 사용해 성공 응답과 `registerBanner(..., Lang.JA)` 호출을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 수행하지 못했다.

View File

@@ -1,10 +0,0 @@
- [x] `CanCouponService.useCanCoupon`의 기존 본인인증 요구 조건과 국가/성인노출 관련 패턴을 확인한다.
- [x] 한국이 아닌 국가에서 `MemberContentPreference.isAdultContentVisibl``true`이면 본인인증 없이 쿠폰 사용이 가능하도록 수정한다.
- [x] 변경 파일 진단과 관련 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: `CanCouponService.useCanCoupon``MemberContentPreferenceService.getStoredPreference(member).isAdult`를 기준으로 쿠폰 사용 가능 여부를 판단하도록 수정하고, 해당 분기 회귀 테스트를 추가했다.
- 왜: 한국 사용자는 기존처럼 본인인증이 필요하고, 한국이 아닌 사용자는 성인 노출 설정이 `true`이면 본인인증 없이 쿠폰을 사용할 수 있어야 하기 때문이다.
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.can.coupon.CanCouponServiceTest"` 실행 성공, `./gradlew ktlintCheck` 실행 성공.

View File

@@ -1,15 +0,0 @@
- [x] sendMessage의 외부 채팅 API 호출 경로와 요청 payload 구성을 확인한다.
- [x] 외부 `/api/chat` 요청 body에 `member.nickname``userName` 파라미터로 전달하도록 수정한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: `ChatRoomService.sendMessage`가 외부 `/api/chat` 호출 시 `member.nickname``username` 파라미터로 함께 전달하도록 수정했다.
- 왜: 외부 채팅 API가 사용자 닉네임을 함께 받아야 하는 요구사항을 기존 메시지 전송 흐름 안에서 최소 범위로 반영해야 했기 때문이다.
- 어떻게: 내부 탐색으로 `/api/chat` payload 생성 위치가 `ChatRoomService.callExternalApiForChatSend`임을 확인한 뒤 `./gradlew compileKotlin``./gradlew test`를 실행했고 둘 다 `BUILD SUCCESSFUL`이었다. 추가로 `./gradlew test --tests '*ChatRoom*'`를 시도했지만 해당 패턴의 테스트 클래스는 없어 필터 검증은 불가했다.
### 2차 수정
- 무엇을: 외부 `/api/chat` 요청 body의 키 이름을 `username`에서 `userName`으로 변경했다.
- 왜: 외부 API 계약에서 사용자명 필드명이 camelCase인 `userName`으로 요구되기 때문이다.
- 어떻게: `ChatRoomService.callExternalApiForChatSend`의 request body 키가 `"userName"`으로 생성되는 것을 코드에서 재확인했다. 이 환경에는 Kotlin LSP가 구성되어 있지 않아 별도 diagnostics는 수행할 수 없었고, 대신 `./gradlew compileKotlin test`를 실행해 `BUILD SUCCESSFUL`을 확인했다.

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