Compare commits

..

397 Commits

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

View File

@@ -83,19 +83,11 @@
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
- 필드 주입보다 명시적 생성자 주입을 우선한다.
### 10) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
## 테스트 스타일 규칙
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
## 설정/보안 유의사항
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.

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

@@ -6,10 +6,7 @@ echo "> build 파일 복사" >> /home/ec2-user/deploy.log
DEPLOY_PATH=/home/ec2-user/
cp $BUILD_JAR $DEPLOY_PATH
JAVA_OPTS_ENV_NAME=java-opts-env
source $DEPLOY_PATH$JAVA_OPTS_ENV_NAME
DEPLOY_JAR=$DEPLOY_PATH$JAR_NAME
echo "> DEPLOY_JAR 배포" >> /home/ec2-user/deploy.log
chmod +x $DEPLOY_JAR
nohup java $JAVA_OPTS -jar $DEPLOY_JAR >> /home/ec2-user/deploy.log 2> /dev/null < /dev/null &
nohup java -jar $DEPLOY_JAR >> /home/ec2-user/deploy.log 2> /dev/null < /dev/null &

View File

@@ -5,10 +5,8 @@ import kr.co.vividnext.sodalive.admin.audition.role.AdminAuditionRoleRepository
import kr.co.vividnext.sodalive.audition.AuditionStatus
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
@@ -93,14 +91,11 @@ class AdminAuditionService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.IN_PROGRESS_AUDITION,
category = PushNotificationCategory.AUDITION,
titleKey = "admin.audition.fcm.title.new",
messageKey = "admin.audition.fcm.message.new",
args = listOf(audition.title),
isAuth = audition.isAdult,
auditionId = audition.id ?: -1,
deepLinkValue = FcmDeepLinkValue.AUDITION,
deepLinkId = audition.id
auditionId = audition.id ?: -1
)
)
}

View File

@@ -7,8 +7,6 @@ import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@@ -20,9 +18,6 @@ import java.nio.charset.StandardCharsets
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/calculate")
class AdminCalculateController(private val service: AdminCalculateService) {
@PostMapping("/live/refund")
fun refundLive(@RequestBody request: AdminLiveRefundRequest) = ApiResponse.ok(service.refundLive(request))
@GetMapping("/live")
fun getCalculateLive(
@RequestParam startDateStr: String,

View File

@@ -50,10 +50,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory
.select(
QGetCalculateLiveQueryData(
member.email,
member.nickname,
formattedDate,
liveRoom.title,
liveRoom.id,
liveRoom.price,
useCan.canUsage,
useCan.id.count(),

View File

@@ -1,102 +1,17 @@
package kr.co.vividnext.sodalive.admin.calculate
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.time.LocalDateTime
@Service
class AdminCalculateService(
private val repository: AdminCalculateQueryRepository,
private val canRepository: CanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository,
private val chargeRepository: ChargeRepository,
private val liveRoomRepository: LiveRoomRepository,
private val messageSource: SodaMessageSource,
private val langContext: LangContext
) {
private fun formatMessage(key: String, vararg args: Any): String {
val template = messageSource.getMessage(key, langContext.lang).orEmpty()
return if (args.isNotEmpty()) {
String.format(template, *args)
} else {
template
}
}
@Transactional
fun refundLive(request: AdminLiveRefundRequest) {
if (request.roomId == null || request.canUsageStr.isNullOrBlank()) {
throw SodaException(messageKey = "common.error.invalid_request")
}
val room = liveRoomRepository.findByIdOrNull(request.roomId)
?: throw SodaException(messageKey = "live.room.not_found")
val canUsage = when (request.canUsageStr) {
"유료" -> CanUsage.LIVE
"룰렛" -> CanUsage.SPIN_ROULETTE
"하트" -> CanUsage.HEART
"후원" -> CanUsage.DONATION
else -> throw SodaException(message = "Invalid canUsageStr: ${request.canUsageStr}")
}
val useCanList = canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse(
roomId = room.id!!,
canUsage = canUsage
)
for (useCan in useCanList) {
useCan.isRefund = true
val member = useCan.member!!
val useCanCalculate = useCanCalculateRepository.findByUseCanIdAndStatus(useCanId = useCan.id!!)
useCanCalculate.forEach {
it.status = UseCanCalculateStatus.REFUND
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
charge.title = formatMessage("live.room.can_title", it.can)
charge.useCan = useCan
when (it.paymentGateway) {
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan += charge.rewardCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan += charge.rewardCan
else -> member.pgRewardCan += charge.rewardCan
}
charge.member = member
val payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = it.paymentGateway
)
payment.method = formatMessage("live.room.refund_method")
charge.payment = payment
chargeRepository.save(charge)
}
}
}
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
@Transactional(readOnly = true)
fun getCalculateLive(
startDateStr: String,
@@ -249,6 +164,7 @@ class AdminCalculateService(
return createExcelStream(
sheetName = "라이브 정산",
headers = listOf(
"이메일",
"닉네임",
"날짜",
"라이브 제목",
@@ -265,18 +181,19 @@ class AdminCalculateService(
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.nickname)
row.createCell(1).setCellValue(item.date)
row.createCell(2).setCellValue(item.title)
row.createCell(3).setCellValue(item.entranceFee.toDouble())
row.createCell(4).setCellValue(item.canUsageStr)
row.createCell(5).setCellValue(item.numberOfPeople.toDouble())
row.createCell(6).setCellValue(item.totalAmount.toDouble())
row.createCell(7).setCellValue(item.totalKrw.toDouble())
row.createCell(8).setCellValue(item.paymentFee.toDouble())
row.createCell(9).setCellValue(item.settlementAmount.toDouble())
row.createCell(10).setCellValue(item.tax.toDouble())
row.createCell(11).setCellValue(item.depositAmount.toDouble())
row.createCell(0).setCellValue(item.email)
row.createCell(1).setCellValue(item.nickname)
row.createCell(2).setCellValue(item.date)
row.createCell(3).setCellValue(item.title)
row.createCell(4).setCellValue(item.entranceFee.toDouble())
row.createCell(5).setCellValue(item.canUsageStr)
row.createCell(6).setCellValue(item.numberOfPeople.toDouble())
row.createCell(7).setCellValue(item.totalAmount.toDouble())
row.createCell(8).setCellValue(item.totalKrw.toDouble())
row.createCell(9).setCellValue(item.paymentFee.toDouble())
row.createCell(10).setCellValue(item.settlementAmount.toDouble())
row.createCell(11).setCellValue(item.tax.toDouble())
row.createCell(12).setCellValue(item.depositAmount.toDouble())
}
}
}

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.fasterxml.jackson.annotation.JsonProperty
data class AdminLiveRefundRequest(
@JsonProperty("roomId") val roomId: Long?,
@JsonProperty("canUsageStr") val canUsageStr: String?
)

View File

@@ -6,11 +6,10 @@ import java.math.BigDecimal
import java.math.RoundingMode
data class GetCalculateLiveQueryData @QueryProjection constructor(
val email: String,
val nickname: String,
val date: String,
val title: String,
// 라이브 방 id
val roomId: Long,
// 유료방 입장 금액
val entranceFee: Int,
// 코인 사용 구분
@@ -67,10 +66,10 @@ data class GetCalculateLiveQueryData @QueryProjection constructor(
val depositAmount = settlementAmount.subtract(tax)
return GetCalculateLiveResponse(
email = email,
nickname = nickname,
date = date,
title = title,
roomId = roomId,
entranceFee = entranceFee,
canUsageStr = canUsageStr,
numberOfPeople = numberOfPeople,

View File

@@ -3,10 +3,10 @@ package kr.co.vividnext.sodalive.admin.calculate
import com.fasterxml.jackson.annotation.JsonProperty
data class GetCalculateLiveResponse(
@JsonProperty("email") val email: String,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("date") val date: String,
@JsonProperty("title") val title: String,
@JsonProperty("roomId") val roomId: Long,
@JsonProperty("entranceFee") val entranceFee: Int,
@JsonProperty("canUsageStr") val canUsageStr: String,
@JsonProperty("numberOfPeople") val numberOfPeople: Int,

View File

@@ -206,7 +206,7 @@ class AdminChatCharacterController(
body["description"] = request.description
body["region"] = request.region
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = mapGenderForExternalApi(request.region, it) }
request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
@@ -273,19 +273,6 @@ class AdminChatCharacterController(
}
}
private fun mapGenderForExternalApi(region: String, gender: String): String {
if (!region.equals("JP", ignoreCase = true)) {
return gender
}
return when (gender) {
"여성" -> "女性"
"남성" -> "男性"
"기타" -> "その他"
else -> gender
}
}
/**
* 캐릭터 수정 API
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환

View File

@@ -13,10 +13,8 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
@@ -330,15 +328,10 @@ class AdminLiveService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CANCEL_LIVE,
category = PushNotificationCategory.LIVE,
title = room.member!!.nickname,
messageKey = "live.room.fcm.message.canceled",
senderMemberId = room.member!!.id,
args = listOf(room.title),
pushTokens = pushTokens,
roomId = room.id,
deepLinkValue = FcmDeepLinkValue.LIVE,
deepLinkId = room.id
pushTokens = pushTokens
)
)
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
@@ -16,11 +17,15 @@ class HomeController(private val service: HomeService) {
@GetMapping
fun fetchData(
@RequestParam timezone: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.fetchData(
timezone = timezone,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -29,11 +34,15 @@ class HomeController(private val service: HomeService) {
@GetMapping("/latest-content")
fun getLatestContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getLatestContentByTheme(
theme = theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -42,11 +51,15 @@ class HomeController(private val service: HomeService) {
@GetMapping("/day-of-week-series")
fun getDayOfWeekSeriesList(
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -55,10 +68,14 @@ class HomeController(private val service: HomeService) {
// 추천 콘텐츠만 새로고침하기 위한 엔드포인트
@GetMapping("/recommend-contents")
fun getRecommendContents(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getRecommendContentList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
)
@@ -68,6 +85,8 @@ class HomeController(private val service: HomeService) {
@GetMapping("/content-ranking")
fun getContentRanking(
@RequestParam("sort", required = false) sort: ContentRankingSortType? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam("offset", required = false) offset: Long? = null,
@RequestParam("limit", required = false) limit: Long? = null,
@RequestParam("theme", required = false) theme: String? = null,
@@ -76,6 +95,8 @@ class HomeController(private val service: HomeService) {
ApiResponse.ok(
service.getContentRankingBySort(
sort = sort ?: ContentRankingSortType.REVENUE,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
offset = offset,
limit = limit,
theme = theme,

View File

@@ -18,8 +18,6 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import kr.co.vividnext.sodalive.rank.RankingRepository
@@ -49,7 +47,6 @@ class HomeService(
private val explorerQueryRepository: ExplorerQueryRepository,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -72,16 +69,17 @@ class HomeService(
fun fetchData(
timezone: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): GetHomeResponse {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = preference.isAdult
val resolvedContentType = preference.contentType
val isAdult = member?.auth != null && isAdultContentVisible
val liveList = liveRoomService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
@@ -104,14 +102,14 @@ class HomeService(
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
contentType = resolvedContentType,
contentType = contentType,
excludeThemes = listOf("다시듣기")
)
val latestContentList = contentService.getLatestContentByTheme(
memberId = memberId,
theme = latestContentThemeList,
contentType = resolvedContentType,
contentType = contentType,
isFree = false,
isAdult = isAdult
)
@@ -130,7 +128,7 @@ class HomeService(
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult,
contentType = resolvedContentType
contentType = contentType
)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
@@ -139,7 +137,7 @@ class HomeService(
val translatedDayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = resolvedContentType,
contentType = contentType,
dayOfWeek = getDayOfWeekByTimezone(timezone)
)
@@ -159,7 +157,7 @@ class HomeService(
val contentRanking = rankingService.getContentRanking(
memberId = memberId,
isAdult = isAdult,
contentType = resolvedContentType,
contentType = contentType,
startDate = startDate.minusDays(1),
endDate = endDate,
sort = ContentRankingSortType.REVENUE
@@ -168,17 +166,17 @@ class HomeService(
val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId,
isAdult = isAdult,
contentType = resolvedContentType
contentType = contentType
)
val freeContentList = getRandomizedContentList(
memberId = memberId,
isAdult = isAdult,
contentType = resolvedContentType,
contentType = contentType,
theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = true,
contentType = resolvedContentType
contentType = contentType
),
isFree = true,
isPointAvailableOnly = false
@@ -188,7 +186,7 @@ class HomeService(
val pointAvailableContentList = getRandomizedContentList(
memberId = memberId,
isAdult = isAdult,
contentType = resolvedContentType,
contentType = contentType,
theme = emptyList(),
isFree = false,
isPointAvailableOnly = true
@@ -214,8 +212,9 @@ class HomeService(
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
pointAvailableContentList = pointAvailableContentList,
recommendContentList = getRecommendContentListByPreference(
preference = preference,
recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member = member,
excludeContentIds = excludeContentIds
)
@@ -224,18 +223,18 @@ class HomeService(
fun getLatestContentByTheme(
theme: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<AudioContentMainItem> {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = preference.isAdult
val resolvedContentType = preference.contentType
val isAdult = member?.auth != null && isAdultContentVisible
val themeList = if (theme.isBlank()) {
contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = false,
contentType = resolvedContentType,
contentType = contentType,
excludeThemes = listOf("다시듣기")
)
} else {
@@ -245,7 +244,7 @@ class HomeService(
return contentService.getLatestContentByTheme(
memberId = memberId,
theme = themeList,
contentType = resolvedContentType,
contentType = contentType,
isFree = false,
isAdult = isAdult
)
@@ -253,30 +252,32 @@ class HomeService(
fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<GetSeriesListResponse.SeriesListItem> {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = preference.isAdult
val isAdult = member?.auth != null && isAdultContentVisible
return seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = preference.contentType,
contentType = contentType,
dayOfWeek = dayOfWeek
)
}
fun getContentRankingBySort(
sort: ContentRankingSortType,
isAdultContentVisible: Boolean,
contentType: ContentType,
offset: Long?,
limit: Long?,
theme: String?,
member: Member?
): List<GetAudioContentRankingItem> {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = preference.isAdult
val isAdult = member?.auth != null && isAdultContentVisible
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
@@ -290,7 +291,7 @@ class HomeService(
return rankingService.getContentRanking(
memberId = memberId,
isAdult = isAdult,
contentType = preference.contentType,
contentType = contentType,
startDate = startDate.minusDays(1),
endDate = endDate,
offset = offset ?: 0,
@@ -319,20 +320,13 @@ class HomeService(
}
fun getRecommendContentList(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?,
excludeContentIds: List<Long> = emptyList()
): List<AudioContentMainItem> {
val preference = resolvePreference(member)
return getRecommendContentListByPreference(preference, member, excludeContentIds)
}
private fun getRecommendContentListByPreference(
preference: ViewerContentPreference,
member: Member?,
excludeContentIds: List<Long>
): List<AudioContentMainItem> {
val memberId = member?.id
val isAdult = preference.isAdult
val isAdult = member?.auth != null && isAdultContentVisible
// 3개의 버킷(최근/중간/과거)에서 후보군을 조회한 뒤, 시간감쇠 점수 기반으로 샘플링한다.
val buckets = listOf(
@@ -356,7 +350,7 @@ class HomeService(
val batch = contentService.getLatestContentByTheme(
memberId = memberId,
theme = emptyList(),
contentType = preference.contentType,
contentType = contentType,
offset = bucket.offset,
limit = bucket.limit,
sortType = SortType.NEWEST,
@@ -380,19 +374,6 @@ class HomeService(
return result.take(RECOMMEND_TARGET_SIZE).shuffled()
}
private fun resolvePreference(member: Member?): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(member = member)
}
private fun pickByTimeDecay(
batch: List<AudioContentMainItem>,
targetSize: Int,

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.api.live
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -16,10 +17,14 @@ class LiveApiController(
@GetMapping
fun fetchData(
@RequestParam timezone: String,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
timezone = timezone,
member = member
)

View File

@@ -8,8 +8,6 @@ import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -19,21 +17,22 @@ class LiveApiService(
private val contentService: AudioContentService,
private val recommendService: LiveRecommendService,
private val creatorCommunityService: CreatorCommunityService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val blockMemberRepository: BlockMemberRepository
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
timezone: String,
member: Member?
): LiveMainResponse {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = preference.isAdult
val isAdult = member?.auth != null && isAdultContentVisible
val liveOnAirRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(20),
member = member,
timezone = timezone
@@ -56,7 +55,7 @@ class LiveApiService(
val replayLive = contentService.getLatestContentByTheme(
memberId = memberId,
theme = listOf("다시듣기"),
contentType = preference.contentType,
contentType = contentType,
isFree = false,
isAdult = isAdult
)
@@ -78,6 +77,7 @@ class LiveApiService(
val liveReservationRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.RESERVATION,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
@@ -93,17 +93,4 @@ class LiveApiService(
liveReservationRoomList = liveReservationRoomList
)
}
private fun resolvePreference(member: Member?): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(member = member)
}
}

View File

@@ -1,9 +1,6 @@
package kr.co.vividnext.sodalive.can
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.audition.QAudition.audition
import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant
import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole
import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
@@ -13,12 +10,7 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.QMember
@@ -32,11 +24,10 @@ interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
interface CanQueryRepository {
fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List<CanResponse>
fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List<UseCanQueryDto>
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan?
fun findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId: Long, canUsage: CanUsage): List<UseCan>
}
@Repository
@@ -66,70 +57,13 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
.fetch()
}
override fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List<UseCanQueryDto> {
val qRoomMember = QMember("roomMember")
val qAudioContentMember = QMember("audioContentMember")
val qCommunityPostMember = QMember("communityPostMember")
val qRecipientMember = QMember("recipientMember")
val gatewayCondition = when (container) {
"aos" -> useCanCalculate.paymentGateway.`in`(
PaymentGateway.PG,
PaymentGateway.PAYVERSE,
PaymentGateway.GOOGLE_IAP
)
"ios" -> useCanCalculate.paymentGateway.`in`(
PaymentGateway.PG,
PaymentGateway.PAYVERSE,
PaymentGateway.APPLE_IAP
)
else -> useCanCalculate.paymentGateway.`in`(PaymentGateway.PG, PaymentGateway.PAYVERSE)
}
override fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> {
return queryFactory
.select(
QUseCanQueryDto(
useCan.id,
useCan.canUsage,
useCan.can,
useCan.rewardCan,
useCan.createdAt,
qRoomMember.nickname,
liveRoom.title,
qAudioContentMember.nickname,
audioContent.title,
qCommunityPostMember.nickname,
audition.title,
chatCharacter.name,
qRecipientMember.nickname
)
)
.from(useCan)
.leftJoin(useCan.room, liveRoom)
.leftJoin(liveRoom.member, qRoomMember)
.leftJoin(useCan.audioContent, audioContent)
.leftJoin(audioContent.member, qAudioContentMember)
.leftJoin(useCan.communityPost, creatorCommunity)
.leftJoin(creatorCommunity.member, qCommunityPostMember)
.leftJoin(useCan.auditionApplicant, auditionApplicant)
.leftJoin(auditionApplicant.role, auditionRole)
.leftJoin(auditionRole.audition, audition)
.leftJoin(useCan.characterImage, characterImage)
.leftJoin(characterImage.chatCharacter, chatCharacter)
.innerJoin(useCan.useCanCalculates, useCanCalculate)
.leftJoin(qRecipientMember).on(useCanCalculate.recipientCreatorId.eq(qRecipientMember.id))
.where(
useCan.member.id.eq(member.id)
.and(useCan.isRefund.isFalse)
.and(useCan.can.add(useCan.rewardCan).gt(0))
.and(gatewayCondition)
)
.selectFrom(useCan)
.where(useCan.member.id.eq(member.id))
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(useCan.id.desc())
.distinct()
.fetch()
}
@@ -205,16 +139,4 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
.orderBy(useCan.id.desc())
.fetchFirst()
}
override fun findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId: Long, canUsage: CanUsage): List<UseCan> {
return queryFactory
.selectFrom(useCan)
.innerJoin(useCan.room, liveRoom)
.where(
liveRoom.id.eq(roomId)
.and(useCan.canUsage.eq(canUsage))
.and(useCan.isRefund.isFalse)
)
.fetch()
}
}

View File

@@ -1,9 +1,11 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.time.ZoneId
@@ -12,7 +14,8 @@ import java.time.format.DateTimeFormatter
@Service
class CanService(
private val repository: CanRepository,
private val countryContext: CountryContext
private val countryContext: CountryContext,
private val memberRepository: MemberRepository
) {
fun getCans(isNotSelectedCurrency: Boolean): List<CanResponse> {
val currency = if (isNotSelectedCurrency) {
@@ -39,94 +42,88 @@ class CanService(
timezone: String,
container: String
): List<GetCanUseStatusResponseItem> {
val zoneId = try {
ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("UTC")
val useCanList = repository.getCanUseStatus(member, pageable)
.filter { (it.can + it.rewardCan) > 0 }
.filter {
when (container) {
"aos" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
}
}
"ios" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
}
}
else -> it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE
}
}
}
val channelDonationCreatorIds = useCanList
.asSequence()
.filter { it.canUsage == CanUsage.CHANNEL_DONATION }
.mapNotNull { it.useCanCalculates.firstOrNull()?.recipientCreatorId }
.distinct()
.toList()
val channelDonationCreatorNicknameMap = if (channelDonationCreatorIds.isEmpty()) {
emptyMap()
} else {
memberRepository.findAllById(channelDonationCreatorIds).associate { it.id!! to it.nickname }
}
return repository.getCanUseStatus(member, pageable, container)
return useCanList
.map {
val title: String = when (it.canUsage) {
CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> {
if (it.roomMemberNickname != null) {
"[라이브 후원] ${it.roomMemberNickname}"
} else if (it.audioContentMemberNickname != null) {
"[콘텐츠 후원] ${it.audioContentMemberNickname}"
if (it.room != null) {
"[라이브 후원] ${it.room!!.member!!.nickname}"
} else if (it.audioContent != null) {
"[콘텐츠 후원] ${it.audioContent!!.member!!.nickname}"
} else {
"[후원]"
}
}
CanUsage.CHANNEL_DONATION -> {
if (it.recipientCreatorNickname.isNullOrBlank()) {
val creatorId = it.useCanCalculates.firstOrNull()?.recipientCreatorId
val creatorNickname = creatorId?.let { id -> channelDonationCreatorNicknameMap[id] }
if (creatorNickname.isNullOrBlank()) {
"[채널 후원]"
} else {
"[채널 후원] ${it.recipientCreatorNickname}"
"[채널 후원] $creatorNickname"
}
}
CanUsage.LIVE -> {
if (it.roomTitle != null) {
"[라이브] ${it.roomTitle}"
} else if (it.roomMemberNickname != null) {
"[라이브] ${it.roomMemberNickname}"
} else {
"[라이브]"
}
"[라이브] ${it.room!!.title}"
}
CanUsage.CHANGE_NICKNAME -> "닉네임 변경"
CanUsage.ALARM_SLOT -> "알람 슬롯 구매"
CanUsage.ORDER_CONTENT -> {
if (it.audioContentTitle != null) {
"[콘텐츠 구매] ${it.audioContentTitle}"
} else if (it.audioContentMemberNickname != null) {
"[콘텐츠 구매] ${it.audioContentMemberNickname}"
} else {
"[콘텐츠 구매]"
}
}
CanUsage.PAID_COMMUNITY_POST -> {
if (it.communityPostMemberNickname != null) {
"[게시글 보기] ${it.communityPostMemberNickname}"
} else {
"[게시글 보기]"
}
}
CanUsage.AUDITION_VOTE -> {
if (it.auditionTitle != null) {
"[오디션 투표] ${it.auditionTitle}"
} else {
"[오디션 투표]"
}
}
CanUsage.CHAT_MESSAGE_PURCHASE -> {
if (it.characterName != null) {
"[메시지 구매] ${it.characterName}"
} else {
"[메시지 구매]"
}
}
CanUsage.CHARACTER_IMAGE_PURCHASE -> {
if (it.characterName != null) {
"[캐릭터 이미지 구매] ${it.characterName}"
} else {
"[캐릭터 이미지 구매]"
}
}
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
}
val createdAt = it.createdAt
val createdAt = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(zoneId)
.withZoneSameInstant(ZoneId.of(timezone))
GetCanUseStatusResponseItem(
title = title,
@@ -144,12 +141,6 @@ class CanService(
timezone: String,
container: String
): List<GetCanChargeStatusResponseItem> {
val zoneId = try {
ZoneId.of(timezone)
} catch (e: Exception) {
ZoneId.of("UTC")
}
return repository.getCanChargeStatus(member, pageable, container)
.map {
val canTitle = it.title ?: ""
@@ -179,9 +170,9 @@ class CanService(
}
}
val createdAt = (it.createdAt ?: it.updatedAt!!)
val createdAt = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(zoneId)
.withZoneSameInstant(ZoneId.of(timezone))
GetCanChargeStatusResponseItem(
canTitle = canTitle,

View File

@@ -1,21 +0,0 @@
package kr.co.vividnext.sodalive.can
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.can.use.CanUsage
import java.time.LocalDateTime
data class UseCanQueryDto @QueryProjection constructor(
val id: Long,
val canUsage: CanUsage,
val can: Int,
val rewardCan: Int,
val createdAt: LocalDateTime,
val roomMemberNickname: String?,
val roomTitle: String?,
val audioContentMemberNickname: String?,
val audioContentTitle: String?,
val communityPostMemberNickname: String?,
val auditionTitle: String?,
val characterName: String?,
val recipientCreatorNickname: String?
)

View File

@@ -9,7 +9,6 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
@@ -79,7 +78,6 @@ class ChargeEventService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.SYSTEM,
title = chargeEvent.title,
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),
@@ -103,7 +101,6 @@ class ChargeEventService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.SYSTEM,
titleKey = "can.charge.event.first_title",
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),

View File

@@ -5,7 +5,6 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
@@ -23,7 +22,6 @@ class CharacterCommentController(
private val service: CharacterCommentService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -35,7 +33,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val id = service.addComment(characterId, member, request.comment)
@@ -50,7 +48,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
@@ -65,7 +63,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val data = service.listComments(imageHost, characterId, cursor, limit)
ApiResponse.ok(data)
@@ -80,7 +78,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
// characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨
val data = service.getReplies(imageHost, commentId, cursor, limit)
@@ -94,7 +92,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
service.deleteComment(characterId, commentId, member)
val message = messageSource.getMessage("chat.character.comment.deleted", langContext.lang)
ApiResponse.ok(true, message)
@@ -108,15 +106,9 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
service.reportComment(characterId, commentId, member, request.content)
val message = messageSource.getMessage("chat.character.comment.reported", langContext.lang)
ApiResponse.ok(true, message)
}
private fun validateAdultAccess(member: Member) {
if (!memberContentPreferenceService.getStoredPreference(member).isAdult) {
throw SodaException(messageKey = "common.error.adult_verification_required")
}
}
}

View File

@@ -27,7 +27,6 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -50,7 +49,6 @@ class ChatCharacterController(
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -59,8 +57,6 @@ class ChatCharacterController(
fun getCharacterMain(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<CharacterMainResponse> = run {
val isAdultAccessible = resolveIsAdultAccessible(member)
// 배너 조회 (최대 10개)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
.content
@@ -72,7 +68,7 @@ class ChatCharacterController(
}
// 최근 대화한 캐릭터(채팅방) 조회 (회원별 최근 순으로 최대 10개)
val recentCharacters = if (member == null || !isAdultAccessible) {
val recentCharacters = if (member == null || member.auth == null) {
emptyList()
} else {
chatRoomService.listMyChatRooms(member, 0, 10)
@@ -160,7 +156,7 @@ class ChatCharacterController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
// 캐릭터 상세 정보 조회
val character = service.getCharacterDetail(characterId)
@@ -400,8 +396,7 @@ class ChatCharacterController(
fun getRecommendCharacters(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val isAdultAccessible = resolveIsAdultAccessible(member)
val recent = if (member == null || !isAdultAccessible) {
val recent = if (member == null || member.auth == null) {
emptyList()
} else {
chatRoomService
@@ -452,12 +447,4 @@ class ChatCharacterController(
aiCharacterList
}
}
private fun resolveIsAdultAccessible(member: Member?): Boolean {
if (member == null) {
return false
}
return memberContentPreferenceService.getStoredPreference(member).isAdult
}
}

View File

@@ -9,7 +9,6 @@ import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseR
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -26,7 +25,6 @@ class CharacterImageController(
private val imageService: CharacterImageService,
private val imageCloudFront: ImageContentCloudFront,
private val canPaymentService: CanPaymentService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -39,7 +37,7 @@ class CharacterImageController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val pageSize = if (size <= 0) 20 else minOf(size, 20)
@@ -127,7 +125,7 @@ class CharacterImageController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val pageSize = if (size <= 0) 20 else minOf(size, 20)
val expiration = 5L * 60L * 1000L // 5분
@@ -201,7 +199,7 @@ class CharacterImageController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
validateAdultAccess(member)
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val image = imageService.getById(req.imageId)
if (!image.isActive) throw SodaException(messageKey = "chat.character.image.inactive")
@@ -225,10 +223,4 @@ class CharacterImageController(
val signedUrl = imageCloudFront.generateSignedURL(image.imagePath, expiration)
ApiResponse.ok(CharacterImagePurchaseResponse(imageUrl = signedUrl))
}
private fun validateAdultAccess(member: Member) {
if (!memberContentPreferenceService.getStoredPreference(member).isAdult) {
throw SodaException(messageKey = "common.error.adult_verification_required")
}
}
}

View File

@@ -4,8 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.springframework.data.domain.Pageable
import org.springframework.lang.Nullable
import org.springframework.security.access.prepost.PreAuthorize
@@ -27,10 +25,7 @@ import java.time.temporal.TemporalAdjusters
@RestController
@RequestMapping("/audio-content")
class AudioContentController(
private val service: AudioContentService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentController(private val service: AudioContentService) {
@PostMapping
@PreAuthorize("hasRole('CREATOR')")
fun createAudioContent(
@@ -111,19 +106,20 @@ class AudioContentController(
@RequestParam("creator-id") creatorId: Long,
@RequestParam("sort-type", required = false) sortType: SortType? = SortType.NEWEST,
@RequestParam("category-id", required = false) categoryId: Long? = 0,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getAudioContentList(
creatorId = creatorId,
sortType = sortType ?: SortType.NEWEST,
categoryId = categoryId ?: 0,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -135,16 +131,16 @@ class AudioContentController(
fun getDetail(
@PathVariable id: Long,
@RequestParam timezone: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getDetail(
id = id,
member = member,
isAdultContentVisible = preference.isAdultContentVisible,
isAdultContentVisible = isAdultContentVisible ?: true,
timezone = timezone
)
)
@@ -191,10 +187,11 @@ class AudioContentController(
@GetMapping("/ranking")
fun getAudioContentRanking(
@RequestParam("sort-type", required = false) sortType: String? = "매출",
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val preference = resolvePreference(member)
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
@@ -207,8 +204,8 @@ class AudioContentController(
ApiResponse.ok(
service.getAudioContentRanking(
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdult = member?.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
startDate = startDate,
endDate = endDate,
offset = pageable.offset,
@@ -242,6 +239,8 @@ class AudioContentController(
@GetMapping("/all")
fun getAllContents(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam("isFree", required = false) isFree: Boolean? = null,
@RequestParam("isPointAvailableOnly", required = false) isPointAvailableOnly: Boolean? = null,
@RequestParam("sort-type", required = false) sortType: SortType? = SortType.NEWEST,
@@ -250,18 +249,17 @@ class AudioContentController(
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getLatestContentByTheme(
memberId = member.id!!,
theme = if (theme == null) listOf() else listOf(theme),
contentType = preference.contentType,
contentType = contentType ?: ContentType.ALL,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
sortType = sortType ?: SortType.NEWEST,
isFree = isFree ?: false,
isAdult = preference.isAdult,
isAdult = (isAdultContentVisible ?: true) && member.auth != null,
isPointAvailableOnly = isPointAvailableOnly ?: false
)
)
@@ -269,30 +267,22 @@ class AudioContentController(
@GetMapping("/replay-live")
fun replayLive(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member)
ApiResponse.ok(
service.getLatestContentByTheme(
memberId = member?.id,
theme = listOf("다시듣기"),
contentType = preference.contentType,
contentType = contentType ?: ContentType.ALL,
isFree = false,
isAdult = preference.isAdult
isAdult = if (member != null) {
(isAdultContentVisible ?: true) && member.auth != null
} else {
false
}
)
)
}
private fun resolvePreference(member: Member?): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(member = member)
}
}

View File

@@ -28,10 +28,8 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
@@ -40,7 +38,6 @@ import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
@@ -462,15 +459,11 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.CONTENT,
titleKey = "content.notification.upload_complete_title",
message = audioContent.title,
senderMemberId = audioContent.member!!.id,
recipients = listOf(audioContent.member!!.id!!),
isAuth = null,
contentId = contentId,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = contentId
contentId = contentId
)
)
@@ -480,16 +473,12 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
category = PushNotificationCategory.CONTENT,
title = audioContent.member!!.nickname,
messageKey = "content.notification.uploaded_message",
senderMemberId = audioContent.member!!.id,
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = contentId,
creatorId = audioContent.member!!.id,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = contentId
creatorId = audioContent.member!!.id
)
)
}
@@ -506,16 +495,12 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
category = PushNotificationCategory.CONTENT,
title = audioContent.member!!.nickname,
messageKey = "content.notification.uploaded_message",
senderMemberId = audioContent.member!!.id,
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = audioContent.id!!,
creatorId = audioContent.member!!.id,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = audioContent.id!!
creatorId = audioContent.member!!.id
)
)
}
@@ -528,30 +513,18 @@ class AudioContentService(
isAdultContentVisible: Boolean,
timezone: String
): GetAudioContentDetailResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
// 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH)
val audioContent = repository.findByIdOrNull(id)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
if (audioContent.isAdult && !isAdult) {
throw SodaException(messageKey = "common.error.adult_verification_required")
}
// 크리에이터(유저) 정보
val creatorId = audioContent.member!!.id!!
val creator = explorerQueryRepository.getMember(creatorId)
?: throw SodaException(messageKey = "content.error.user_not_found")
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
memberId = member.id!!,
contentId = audioContent.id!!
)
val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)
val isBlockedAndPurchased = isBlocked && isExistsAudioContent
if (isBlocked && !isExistsAudioContent) {
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
throw SodaException(messageKey = "content.error.blocked_access")
}
@@ -560,6 +533,11 @@ class AudioContentService(
memberId = member.id!!
)
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
memberId = member.id!!,
contentId = audioContent.id!!
)
val orderSequence = if (isExistsAudioContent) {
limitedEditionOrderRepository.getOrderSequence(
contentId = audioContent.id!!,
@@ -569,12 +547,7 @@ class AudioContentService(
null
}
val seriesId = if (isBlockedAndPurchased) {
null
} else {
repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
}
val seriesId = repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
val previousContent = if (seriesId != null) {
repository.findPreviousContent(
seriesId = seriesId,
@@ -605,7 +578,7 @@ class AudioContentService(
}
// 댓글
val commentList = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
val commentList = if (audioContent.isCommentAvailable) {
commentRepository.findByContentId(
cloudFrontHost = coverImageHost,
contentId = audioContent.id!!,
@@ -620,7 +593,7 @@ class AudioContentService(
}
// 댓글 수
val commentCount = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
val commentCount = if (audioContent.isCommentAvailable) {
commentRepository.totalCountCommentByContentId(
contentId = audioContent.id!!,
memberId = member.id!!,
@@ -675,16 +648,14 @@ class AudioContentService(
cloudfrontHost = coverImageHost,
contentId = audioContent.id!!,
creatorId = creatorId,
// 관련 콘텐츠 노출도 동일하게 저장 선호 기반 성인 정책을 따른다.
isAdult = isAdult
isAdult = member.auth != null
)
val sameThemeOtherContentList = repository.getSameThemeOtherContentList(
cloudfrontHost = coverImageHost,
contentId = audioContent.id!!,
themeId = audioContent.theme!!.id!!,
// 동일 테마 추천도 메인 상세와 동일한 성인 정책으로 정렬한다.
isAdult = isAdult
isAdult = member.auth != null
)
val likeCount = audioContentLikeRepository.totalCountAudioContentLike(contentId = id)
@@ -871,8 +842,7 @@ class AudioContentService(
orderSequence = orderSequence,
isActivePreview = audioContent.isGeneratePreview,
isAdult = audioContent.isAdult,
// 성인 콘텐츠이면서 현재 조회 정책으로 열람 불가한 경우에만 모자이크를 적용한다.
isMosaic = audioContent.isAdult && !isAdult,
isMosaic = audioContent.isAdult && member.auth == null,
isOnlyRental = isOnlyRental,
existOrdered = isExistsAudioContent,
purchaseOption = purchaseOption,
@@ -912,7 +882,7 @@ class AudioContentService(
member: Member,
isAdultContentVisible: Boolean
): GetAudioContentListItem? {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
return null
@@ -986,7 +956,7 @@ class AudioContentService(
offset: Long,
limit: Long
): GetAudioContentListResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val isCreator = member.id == creatorId
if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {

View File

@@ -5,10 +5,8 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -80,7 +78,6 @@ class AudioContentCommentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CREATE_CONTENT_COMMENT,
category = PushNotificationCategory.CONTENT,
title = if (parent != null) {
parent.member!!.nickname
} else {
@@ -92,12 +89,9 @@ class AudioContentCommentService(
"content.comment.notification.new"
},
args = listOf(audioContent.title),
senderMemberId = member.id,
contentId = audioContentId,
commentParentId = parentId,
myMemberId = member.id,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = audioContentId
myMemberId = member.id
)
)

View File

@@ -2,9 +2,9 @@ package kr.co.vividnext.sodalive.content.main
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.order.OrderService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -16,20 +16,18 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/audio-content/main")
class AudioContentMainController(
private val service: AudioContentMainService,
private val orderService: OrderService,
private val memberContentPreferenceService: MemberContentPreferenceService
private val orderService: OrderService
) {
@GetMapping("/new-content-upload-creator")
fun newContentUploadCreatorList(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getNewContentUploadCreatorList(
memberId = member.id!!,
isAdult = preference.isAdult
isAdult = member.auth != null
)
)
}
@@ -39,12 +37,11 @@ class AudioContentMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getAudioContentMainBannerList(
memberId = member.id!!,
isAdult = preference.isAdult
isAdult = member.auth != null
)
)
}
@@ -66,17 +63,18 @@ class AudioContentMainController(
@GetMapping("/new")
fun getNewContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getNewContentByTheme(
theme,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member,
pageable
)
@@ -85,15 +83,16 @@ class AudioContentMainController(
@GetMapping("/theme")
fun getThemeList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getThemeList(
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
@@ -101,17 +100,18 @@ class AudioContentMainController(
@GetMapping("/new/all")
fun getNewContentAllByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getNewContentFor2WeeksByTheme(
theme = theme,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
pageable = pageable
)
@@ -120,22 +120,21 @@ class AudioContentMainController(
@GetMapping("/curation-list")
fun getCurationList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getAudioContentCurationListWithPaging(
memberId = member.id!!,
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.domain.Pageable
@@ -69,7 +68,7 @@ class AudioContentMainService(
} else {
emptyList()
},
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = member.auth != null && isAdultContentVisible,
contentType = contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -88,7 +87,7 @@ class AudioContentMainService(
* - AS-IS theme은 한글만 처리하도록 되어 있음
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
*/
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val themeListRaw = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdult,

View File

@@ -2,9 +2,9 @@ package kr.co.vividnext.sodalive.content.main.curation
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -15,31 +15,27 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/audio-content/curation")
class AudioContentCurationController(
private val service: AudioContentCurationService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentCurationController(private val service: AudioContentCurationService) {
@GetMapping("/{id}")
fun getCurationContent(
@PathVariable id: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam("sort-type", required = false) sortType: SortType? = SortType.NEWEST,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getCurationContent(
curationId = id,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
sortType = sortType ?: SortType.NEWEST,
member = member,
pageable = pageable
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -31,19 +30,20 @@ class AudioContentCurationService(
): GetCurationContentResponse {
val totalCount = repository.findTotalCountByCurationId(
curationId = curationId,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = member.auth != null && isAdultContentVisible,
contentType = contentType
)
val audioContentList = repository.findByCurationId(
curationId = curationId,
cloudfrontHost = cloudFrontHost,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = member.auth != null && isAdultContentVisible,
contentType = contentType,
sortType = sortType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
).filter { !isBlockedBetweenMembers(memberId = member.id!!, creatorId = it.creatorId) }
)
.filter { !isBlockedBetweenMembers(memberId = member.id!!, creatorId = it.creatorId) }
return GetCurationContentResponse(
totalCount = totalCount,

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.content.main.tab.alarm
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,21 +13,19 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/alarm")
class AudioContentMainTabAlarmController(
private val service: AudioContentMainTabAlarmService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentMainTabAlarmController(private val service: AudioContentMainTabAlarmService) {
@GetMapping
fun fetchContentMainTabAlarm(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -36,23 +34,22 @@ class AudioContentMainTabAlarmController(
@GetMapping("/all")
fun fetchAlarmContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchAlarmContentByTheme(
theme,
member,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -8,7 +8,6 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationQueryR
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.DayOfWeek
@@ -28,7 +27,7 @@ class AudioContentMainTabAlarmService(
contentType: ContentType,
member: Member
): GetContentMainTabAlarmResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val memberId = member.id!!
val contentBannerList = bannerService.getBannerList(
@@ -106,7 +105,7 @@ class AudioContentMainTabAlarmService(
}
val memberId = member.id!!
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val totalCount = contentRepository.totalAlarmCountByTheme(
memberId = memberId,

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.content.main.tab.asmr
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -12,21 +12,19 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/asmr")
class AudioContentMainTabAsmrController(
private val service: AudioContentMainTabAsmrService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentMainTabAsmrController(private val service: AudioContentMainTabAsmrService) {
@GetMapping
fun fetchContentMainTabAsmr(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -35,19 +33,18 @@ class AudioContentMainTabAsmrController(
@GetMapping("/popular-content-by-creator")
fun getPopularContentByCreator(
@RequestParam creatorId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -9,7 +9,6 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -27,7 +26,7 @@ class AudioContentMainTabAsmrService(
contentType: ContentType,
member: Member
): GetContentMainTabAsmrResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val memberId = member.id!!
val theme = "ASMR"
val tabId = 5L

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.content.main.tab.content
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,21 +13,19 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/content")
class AudioContentMainTabContentController(
private val service: AudioContentMainTabContentService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentMainTabContentController(private val service: AudioContentMainTabContentService) {
@GetMapping
fun fetchContentMainTabContent(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -36,17 +34,18 @@ class AudioContentMainTabContentController(
@GetMapping("/ranking")
fun getAudioContentRanking(
@RequestParam("sort-type", required = false) sortType: String?,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getAudioContentRanking(
memberId = member.id!!,
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
sortType = sortType ?: "매출"
)
)
@@ -55,17 +54,18 @@ class AudioContentMainTabContentController(
@GetMapping("/new-content-by-theme")
fun getNewContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getNewContentByTheme(
theme,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -74,16 +74,17 @@ class AudioContentMainTabContentController(
@GetMapping("/popular-content-by-creator")
fun getPopularContentByCreator(
@RequestParam creatorId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
@@ -91,19 +92,16 @@ class AudioContentMainTabContentController(
@GetMapping("/recommend-content-by-tag")
fun getRecommendedContentByTag(
@RequestParam tag: String,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getRecommendedContentByTag(
memberId = member.id!!,
tag = tag,
isAdult = preference.isAdult,
contentType = preference.contentType
contentType = contentType ?: ContentType.ALL
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -10,7 +10,6 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@@ -31,7 +30,7 @@ class AudioContentMainTabContentService(
member: Member
): GetContentMainTabContentResponse {
val memberId = member.id!!
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val tabId = 3L
// 단편 배너
@@ -115,7 +114,6 @@ class AudioContentMainTabContentService(
tagCurationService.getTagCurationContentList(
memberId = memberId,
tag = tagList[0],
isAdult = isAdult,
contentType = contentType
)
} else {
@@ -191,7 +189,7 @@ class AudioContentMainTabContentService(
contentType: ContentType,
member: Member
): List<GetAudioContentMainItem> {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val themeList = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
@@ -234,14 +232,8 @@ class AudioContentMainTabContentService(
fun getRecommendedContentByTag(
memberId: Long,
tag: String,
isAdult: Boolean,
contentType: ContentType
): List<GetAudioContentMainItem> {
return tagCurationService.getTagCurationContentList(
memberId = memberId,
tag = tag,
isAdult = isAdult,
contentType = contentType
)
return tagCurationService.getTagCurationContentList(memberId = memberId, tag = tag, contentType = contentType)
}
}

View File

@@ -27,9 +27,7 @@ class ContentMainTabTagCurationRepository(
.and(contentHashTagCurationItem.isActive.isTrue)
if (!isAdult) {
// 큐레이션 메타와 실제 콘텐츠 양쪽에서 성인 항목을 함께 차단한다.
where = where.and(contentHashTagCuration.isAdult.isFalse)
.and(audioContent.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(
@@ -62,7 +60,6 @@ class ContentMainTabTagCurationRepository(
fun getTagCurationContentList(
memberId: Long,
tag: String,
isAdult: Boolean,
contentType: ContentType
): List<GetAudioContentMainItem> {
val blockMemberCondition = blockMember.isActive.isTrue
@@ -82,11 +79,6 @@ class ContentMainTabTagCurationRepository(
.and(contentHashTagCurationItem.isActive.isTrue)
.and(contentHashTagCuration.tag.eq(tag))
if (!isAdult) {
// 추천 태그 콘텐츠 조회에서도 실제 오디오 콘텐츠 성인 노출을 동일 정책으로 제한한다.
where = where.and(audioContent.isAdult.isFalse)
}
if (contentType != ContentType.ALL) {
where = where.and(
audioContent.member.isNull.or(

View File

@@ -13,14 +13,8 @@ class ContentMainTabTagCurationService(private val repository: ContentMainTabTag
fun getTagCurationContentList(
memberId: Long,
tag: String,
isAdult: Boolean,
contentType: ContentType
): List<GetAudioContentMainItem> {
return repository.getTagCurationContentList(
memberId = memberId,
tag = tag,
isAdult = isAdult,
contentType = contentType
)
return repository.getTagCurationContentList(memberId = memberId, tag = tag, contentType = contentType)
}
}

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.content.main.tab.free
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,21 +13,19 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/free")
class AudioContentMainTabFreeController(
private val service: AudioContentMainTabFreeService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentMainTabFreeController(private val service: AudioContentMainTabFreeService) {
@GetMapping
fun fetchContentMainFree(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -35,17 +33,18 @@ class AudioContentMainTabFreeController(
@GetMapping("/introduce-creator")
fun getIntroduceCreator(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getIntroduceCreator(
member,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
@@ -55,17 +54,18 @@ class AudioContentMainTabFreeController(
@GetMapping("/new-content-by-theme")
fun getNewContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getNewContentByTheme(
theme,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -76,19 +76,18 @@ class AudioContentMainTabFreeController(
@GetMapping("/popular-content-by-creator")
fun getPopularContentByCreator(
@RequestParam creatorId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -11,7 +11,6 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.main.tab.RecommendSeriesRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -31,7 +30,7 @@ class AudioContentMainTabFreeService(
contentType: ContentType,
member: Member
): GetContentMainTabFreeResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val memberId = member.id!!
val tabId = 7L
@@ -135,7 +134,7 @@ class AudioContentMainTabFreeService(
offset: Long,
limit: Long
): List<GetAudioContentMainItem> {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val memberId = member.id!!
val introduceCreatorCuration = curationRepository.findByContentMainTabIdAndTitle(
@@ -172,7 +171,7 @@ class AudioContentMainTabFreeService(
listOf(theme)
} else {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = member.auth != null && isAdultContentVisible,
isFree = true,
contentType = contentType
).filter {
@@ -186,7 +185,7 @@ class AudioContentMainTabFreeService(
it != "자기소개"
}
},
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = member.auth != null && isAdultContentVisible,
contentType = contentType,
offset = offset,
limit = limit,

View File

@@ -3,8 +3,6 @@ package kr.co.vividnext.sodalive.content.main.tab.home
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -13,19 +11,17 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/home")
class AudioContentMainTabHomeController(
private val service: AudioContentMainTabHomeService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentMainTabHomeController(private val service: AudioContentMainTabHomeService) {
@GetMapping
fun fetchContentMainHome(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -34,14 +30,15 @@ class AudioContentMainTabHomeController(
@GetMapping("/popular-content-by-creator")
fun getPopularContentByCreator(
@RequestParam creatorId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member?.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
@@ -49,29 +46,17 @@ class AudioContentMainTabHomeController(
@GetMapping("/content/ranking")
fun getContentRanking(
@RequestParam("sort-type", required = false) sortType: String? = "매출",
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member)
ApiResponse.ok(
service.getContentRanking(
sortType = sortType ?: "매출",
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
}
private fun resolvePreference(member: Member?): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(member = member)
}
}

View File

@@ -5,7 +5,6 @@ import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.notice.ServiceNoticeService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -43,7 +42,7 @@ class AudioContentMainTabHomeService(
val formattedLastMonday = startDate.format(startDateFormatter)
val formattedLastSunday = endDate.format(endDateFormatter)
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
val isAdult = member?.auth != null && isAdultContentVisible
// 최근 공지사항
val latestNotice = noticeService.getLatestNotice()
@@ -131,7 +130,7 @@ class AudioContentMainTabHomeService(
contentType: ContentType,
member: Member?
): List<GetAudioContentRankingItem> {
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
val isAdult = member?.auth != null && isAdultContentVisible
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.content.main.tab.replay
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -12,21 +12,19 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/replay")
class AudioContentMainTabLiveReplayController(
private val service: AudioContentMainTabLiveReplayService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentMainTabLiveReplayController(private val service: AudioContentMainTabLiveReplayService) {
@GetMapping
fun fetchContentMainTabLiveReplay(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -35,19 +33,18 @@ class AudioContentMainTabLiveReplayController(
@GetMapping("/popular-content-by-creator")
fun getPopularContentByCreator(
@RequestParam creatorId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -9,7 +9,6 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -27,7 +26,7 @@ class AudioContentMainTabLiveReplayService(
contentType: ContentType,
member: Member
): GetContentMainTabLiveReplayResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val memberId = member.id!!
val theme = "다시듣기"
val tabId = 6L

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.content.main.tab.series
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,21 +13,19 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/series")
class AudioContentMainTabSeriesController(
private val service: AudioContentMainTabSeriesService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentMainTabSeriesController(private val service: AudioContentMainTabSeriesService) {
@GetMapping
fun fetchContentMainSeries(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
@@ -35,17 +33,18 @@ class AudioContentMainTabSeriesController(
@GetMapping("/original")
fun getOriginalAudioDramaList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getOriginalAudioDramaList(
memberId = member.id!!,
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
@@ -54,17 +53,18 @@ class AudioContentMainTabSeriesController(
@GetMapping("/completed-rank")
fun getRank10DaysCompletedSeriesList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getRank10DaysCompletedSeriesList(
memberId = member.id!!,
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
@@ -74,17 +74,18 @@ class AudioContentMainTabSeriesController(
@GetMapping("/recommend-by-genre")
fun getRecommendSeriesListByGenre(
@RequestParam genreId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getRecommendSeriesListByGenre(
genreId,
memberId = member.id!!,
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
@@ -92,19 +93,18 @@ class AudioContentMainTabSeriesController(
@GetMapping("/recommend-series-by-creator")
fun getRecommendSeriesByCreator(
@RequestParam creatorId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getRecommendSeriesByCreator(
creatorId = creatorId,
isAdult = preference.isAdult,
contentType = preference.contentType
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -8,7 +8,6 @@ import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.DayOfWeek
@@ -31,7 +30,7 @@ class AudioContentMainTabSeriesService(
contentType: ContentType,
member: Member
): GetContentMainTabSeriesResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val memberId = member.id!!
// 메인 배너 (시리즈)

View File

@@ -2,9 +2,9 @@ package kr.co.vividnext.sodalive.content.series
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -15,28 +15,26 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/audio-content/series")
class ContentSeriesController(
private val service: ContentSeriesService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class ContentSeriesController(private val service: ContentSeriesService) {
@GetMapping
fun getSeriesList(
@RequestParam(required = false) creatorId: Long?,
@RequestParam(name = "isOriginal", required = false) isOriginal: Boolean? = null,
@RequestParam(name = "isCompleted", required = false) isCompleted: Boolean? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getSeriesList(
creatorId = creatorId,
isOriginal = isOriginal ?: false,
isCompleted = isCompleted ?: false,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -47,16 +45,17 @@ class ContentSeriesController(
@GetMapping("/{id}")
fun getSeriesDetail(
@PathVariable id: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getSeriesDetail(
seriesId = id,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
)
@@ -66,17 +65,18 @@ class ContentSeriesController(
fun getSeriesContentList(
@PathVariable id: Long,
@RequestParam("sortType", required = false) sortType: SeriesSortType? = SeriesSortType.NEWEST,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getSeriesContentList(
seriesId = id,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
sortType = sortType ?: SeriesSortType.NEWEST,
offset = pageable.offset,
@@ -87,19 +87,18 @@ class ContentSeriesController(
@GetMapping("/recommend")
fun getRecommendSeriesList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getRecommendSeriesList(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -918,10 +918,8 @@ class ContentSeriesQueryRepositoryImpl(
.and(blockMember.id.isNull)
if (!isAdult) {
// 비성인 조회에서는 장르/시리즈/콘텐츠 3계층 모두에서 성인 항목을 제외한다.
where = where.and(seriesGenre.isAdult.isFalse)
.and(series.isAdult.isFalse)
.and(audioContent.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(

View File

@@ -21,7 +21,6 @@ import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -169,7 +168,7 @@ class ContentSeriesService(
offset: Long = 0,
limit: Long = 20
): GetSeriesListResponse {
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAuth = member.auth != null && isAdultContentVisible
val totalCount = repository.getSeriesTotalCount(
creatorId = creatorId,
@@ -207,7 +206,7 @@ class ContentSeriesService(
offset: Long = 0,
limit: Long = 20
): GetSeriesListResponse {
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAuth = member.auth != null && isAdultContentVisible
val totalCount = repository.getSeriesByGenreTotalCount(
genreId = genreId,
@@ -241,7 +240,7 @@ class ContentSeriesService(
): GetSeriesDetailResponse {
val series = repository.getSeriesDetail(
seriesId = seriesId,
isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAuth = member.auth != null && isAdultContentVisible,
contentType = contentType
) ?: throw SodaException(messageKey = "series.error.invalid_series_retry")
@@ -429,7 +428,7 @@ class ContentSeriesService(
offset: Long,
limit: Long
): GetSeriesContentListResponse {
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAdult = member.auth != null && isAdultContentVisible
val totalCount = seriesContentRepository.getContentCount(seriesId, isAdult = isAdult, contentType = contentType)
val contentList = seriesContentRepository.getContentList(
@@ -492,7 +491,7 @@ class ContentSeriesService(
contentType: ContentType,
member: Member
): List<GetSeriesListResponse.SeriesListItem> {
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isAuth = member.auth != null && isAdultContentVisible
return repository.getRecommendSeriesListV2(
imageHost = coverImageHost,
isAuth = isAuth,

View File

@@ -3,11 +3,11 @@ package kr.co.vividnext.sodalive.content.series.main
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -21,17 +21,17 @@ import org.springframework.web.bind.annotation.RestController
class SeriesMainController(
private val contentSeriesService: ContentSeriesService,
private val bannerService: ContentSeriesBannerService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@GetMapping
fun fetchData(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
.content
@@ -43,14 +43,14 @@ class SeriesMainController(
creatorId = null,
isCompleted = true,
orderByRandom = true,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
).items
val recommendSeriesList = contentSeriesService.getRecommendSeriesList(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
@@ -66,15 +66,16 @@ class SeriesMainController(
@GetMapping("/recommend")
fun getRecommendSeriesList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
contentSeriesService.getRecommendSeriesList(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
)
@@ -83,19 +84,20 @@ class SeriesMainController(
@GetMapping("/day-of-week")
fun getDayOfWeekSeriesList(
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
val pageable = PageRequest.of(page, size)
ApiResponse.ok(
contentSeriesService.getDayOfWeekSeriesList(
memberId = member.id,
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
dayOfWeek = dayOfWeek,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -105,19 +107,20 @@ class SeriesMainController(
@GetMapping("/genre-list")
fun getGenreList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
val memberId = member.id!!
val isAdult = preference.isAdult
val isAdult = member.auth != null && (isAdultContentVisible ?: true)
ApiResponse.ok(
contentSeriesService.getGenreList(
memberId = memberId,
isAdult = isAdult,
contentType = preference.contentType
contentType = contentType ?: ContentType.ALL
)
)
}
@@ -125,25 +128,24 @@ class SeriesMainController(
@GetMapping("/list-by-genre")
fun getSeriesListByGenre(
@RequestParam("genreId") genreId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
val pageable = PageRequest.of(page, size)
ApiResponse.ok(
contentSeriesService.getSeriesListByGenre(
genreId = genreId,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -2,9 +2,9 @@ package kr.co.vividnext.sodalive.content.theme
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -16,10 +16,7 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/audio-content/theme")
class AudioContentThemeController(
private val service: AudioContentThemeService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class AudioContentThemeController(private val service: AudioContentThemeService) {
@GetMapping
@PreAuthorize("hasRole('CREATOR')")
fun getThemes(
@@ -34,17 +31,18 @@ class AudioContentThemeController(
fun getActiveThemes(
@RequestParam("isFree", required = false) isFree: Boolean? = null,
@RequestParam("isPointAvailableOnly", required = false) isPointAvailableOnly: Boolean? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getActiveThemeOfContent(
isAdult = preference.isAdult,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
isFree = isFree ?: false,
isPointAvailableOnly = isPointAvailableOnly ?: false,
contentType = preference.contentType
contentType = contentType ?: ContentType.ALL
)
)
}
@@ -53,24 +51,23 @@ class AudioContentThemeController(
fun getContentByTheme(
@PathVariable id: Long,
@RequestParam("sort-type", required = false) sortType: SortType? = SortType.NEWEST,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.getContentByTheme(
themeId = id,
sortType = sortType ?: SortType.NEWEST,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(member: Member) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

@@ -12,7 +12,6 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -130,7 +129,7 @@ class AudioContentThemeService(
val totalCount = contentRepository.totalCountByTheme(
memberId = member.id!!,
theme = listOf(theme.theme),
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = member.auth != null && isAdultContentVisible,
contentType = contentType
)
@@ -138,7 +137,7 @@ class AudioContentThemeService(
memberId = member.id!!,
theme = listOf(theme.theme),
sortType = sortType,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isAdult = member.auth != null && isAdultContentVisible,
contentType = contentType,
offset = offset,
limit = limit

View File

@@ -38,10 +38,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
return queryFactory
.select(
QGetCalculateLiveQueryData(
member.email,
member.nickname,
formattedDate,
liveRoom.title,
liveRoom.id,
liveRoom.price,
useCan.canUsage,
useCan.id.count(),

View File

@@ -59,6 +59,7 @@ class ExplorerController(
fun getCreatorProfile(
@PathVariable("id") creatorId: Long,
@RequestParam timezone: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
@@ -66,6 +67,7 @@ class ExplorerController(
service.getCreatorProfile(
creatorId = creatorId,
timezone = timezone,
isAdultContentVisible = isAdultContentVisible ?: true,
member = member
)
)

View File

@@ -23,7 +23,6 @@ import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.reservation.QLiveReservation
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomType
@@ -339,7 +338,6 @@ class ExplorerQueryRepository(
fun getLiveRoomList(
creatorId: Long,
userMember: Member,
isAdult: Boolean,
timezone: String,
offset: Long = 0
): List<LiveRoomResponse> {
@@ -362,8 +360,7 @@ class ExplorerQueryRepository(
where = where.and(genderCondition.or(liveRoom.member.id.eq(userMember.id)))
}
// 라이브 목록 노출은 호출부에서 계산한 정책 결과(isAdult)만 신뢰해 필터링한다.
if (!isAdult) {
if (userMember.auth == null) {
where = where.and(liveRoom.isAdult.isFalse)
}
@@ -377,7 +374,7 @@ class ExplorerQueryRepository(
result.addAll(
queryFactory
.selectFrom(liveRoom)
.innerJoin(liveRoom.member, member).fetchJoin()
.innerJoin(liveRoom.member, member)
.leftJoin(liveRoom.cancel, liveRoomCancel)
.where(where)
.orderBy(liveRoom.beginDateTime.asc())
@@ -391,43 +388,13 @@ class ExplorerQueryRepository(
val dateTimeFormatter = DateTimeFormatter.ofPattern(dateTimePattern)
.withLocale(langContext.lang.locale)
// N+1 방지: 한 번에 필요한 정보 일괄 조회
val roomIds = result.mapNotNull { it.id }.toSet()
if (roomIds.isEmpty()) {
return emptyList()
}
// 사용자 예약 여부를 방 ID 기준으로 일괄 조회
val reservationRoomIdSet: Set<Long> = run {
// Q 클래스는 의존 파일들에서 사용되는 패턴을 맞춰 import 없이 정규 참조
val resIds = queryFactory
.select(QLiveReservation.liveReservation.room.id)
.from(QLiveReservation.liveReservation)
.where(
QLiveReservation.liveReservation.room.id.`in`(roomIds)
.and(QLiveReservation.liveReservation.member.id.eq(userMember.id))
.and(QLiveReservation.liveReservation.isActive.isTrue)
)
.fetch()
resIds.filterNotNull().toSet()
}
// 결제 여부를 방 ID 기준으로 일괄 조회 (CanUsage.LIVE)
val paidRoomIdSet: Set<Long> = run {
val ids = queryFactory
.select(useCan.room.id)
.from(useCan)
.where(
useCan.room.id.`in`(roomIds)
.and(useCan.canUsage.eq(CanUsage.LIVE))
)
.groupBy(useCan.room.id)
.fetch()
ids.filterNotNull().toSet()
}
return result
.map {
val reservations = it.reservations
.filter { reservation ->
reservation.member!!.id!! == userMember.id!! && reservation.isActive
}
val beginDateTime = it.beginDateTime
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone))
@@ -436,7 +403,22 @@ class ExplorerQueryRepository(
val beginDateTimeUtc = it.beginDateTime
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val isPaid = it.channelName != null && paidRoomIdSet.contains(it.id!!)
val isPaid = if (it.channelName != null) {
val useCan = queryFactory
.selectFrom(useCan)
.innerJoin(useCan.member, member)
.where(
useCan.member.id.eq(member.id)
.and(useCan.room.id.eq(it.id!!))
.and(useCan.canUsage.eq(CanUsage.LIVE))
)
.orderBy(useCan.id.desc())
.fetchFirst()
useCan != null
} else {
false
}
LiveRoomResponse(
roomId = it.id!!,
@@ -449,17 +431,12 @@ class ExplorerQueryRepository(
price = it.price,
channelName = it.channelName,
managerNickname = it.member!!.nickname,
// 기존: 라이브 방 커버 이미지를 반환
// 변경: 크리에이터(방 매니저) 프로필 이미지를 반환
coverImageUrl = run {
val profileImage = it.member!!.profileImage
when {
profileImage.isNullOrBlank() -> "$cloudFrontHost/profile/default-profile.png"
profileImage.startsWith("https://") -> profileImage
else -> "$cloudFrontHost/$profileImage"
}
coverImageUrl = if (it.coverImage!!.startsWith("https://")) {
it.coverImage!!
} else {
"$cloudFrontHost/${it.coverImage!!}"
},
isReservation = reservationRoomIdSet.contains(it.id!!),
isReservation = reservations.isNotEmpty(),
isActive = it.isActive,
isPrivateRoom = it.type == LiveRoomType.PRIVATE
)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.explorer
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.SortType
@@ -18,10 +19,8 @@ import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
@@ -30,7 +29,6 @@ import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Pageable
@@ -48,7 +46,6 @@ import kotlin.random.Random
@Transactional(readOnly = true)
class ExplorerService(
private val memberService: MemberService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val audioContentService: AudioContentService,
private val donationRankingService: CreatorDonationRankingService,
@@ -258,10 +255,9 @@ class ExplorerService(
fun getCreatorProfile(
creatorId: Long,
timezone: String,
isAdultContentVisible: Boolean,
member: Member
): GetCreatorProfileResponse {
val preference = memberContentPreferenceService.resolveForQuery(member = member)
// 크리에이터(유저) 정보
val creatorAccount = queryRepository.getMember(creatorId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
@@ -309,7 +305,6 @@ class ExplorerService(
queryRepository.getLiveRoomList(
creatorId,
userMember = member,
isAdult = preference.isAdult,
timezone = timezone
)
} else {
@@ -321,8 +316,8 @@ class ExplorerService(
audioContentService.getAudioContentList(
creatorId = creatorId,
sortType = SortType.NEWEST,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL,
member = member,
offset = 0,
limit = 3
@@ -351,11 +346,7 @@ class ExplorerService(
// 크리에이터의 최신 오디오 콘텐츠 1개
val latestContent = if (isCreator && !isBlock) {
audioContentService.getLatestCreatorAudioContent(
creatorId = creatorId,
member = member,
isAdultContentVisible = preference.isAdultContentVisible
)
audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible)
} else {
null
}
@@ -389,7 +380,7 @@ class ExplorerService(
timezone = timezone,
offset = 0,
limit = 3,
isAdult = preference.isAdult
isAdult = member.auth != null
)
} else {
listOf()
@@ -419,8 +410,8 @@ class ExplorerService(
seriesService
.getSeriesList(
creatorId = creatorId,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL,
member = member
)
.items
@@ -674,13 +665,9 @@ class ExplorerService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "explorer.notice.fcm.message",
senderMemberId = member.id,
creatorId = member.id!!,
deepLinkValue = FcmDeepLinkValue.CHANNEL,
deepLinkId = member.id!!
creatorId = member.id!!
)
)
}

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.explorer.profile.channelDonation
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -103,7 +102,7 @@ class ChannelDonationService(
GetChannelDonationListItem(
id = it.id!!,
memberId = it.member!!.id!!,
nickname = it.member!!.nickname.removeDeletedNicknamePrefix(),
nickname = it.member!!.nickname,
profileUrl = if (it.member!!.profileImage != null) {
"$cloudFrontHost/${it.member!!.profileImage}"
} else {

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
@@ -11,7 +10,7 @@ import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class CreatorCommunity(
data class CreatorCommunity(
@Column(columnDefinition = "TEXT", nullable = false)
var content: String,
var price: Int,
@@ -21,10 +20,7 @@ class CreatorCommunity(
var audioPath: String? = null,
@Column(nullable = true)
var imagePath: String? = null,
var isActive: Boolean = true,
var isFixed: Boolean = false,
@Column(nullable = true)
var fixedAt: LocalDateTime? = null
var isActive: Boolean = true
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
@@ -59,7 +55,6 @@ class CreatorCommunity(
dateUtc = dateUtc,
isCommentAvailable = isCommentAvailable,
isAdult = false,
isFixed = isFixed,
isLike = isLike,
existOrdered = existOrdered,
likeCount = likeCount,

View File

@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.Create
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.ModifyCommunityPostCommentRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.lang.Nullable
import org.springframework.security.access.prepost.PreAuthorize
@@ -24,10 +23,7 @@ import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/creator-community")
class CreatorCommunityController(
private val service: CreatorCommunityService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class CreatorCommunityController(private val service: CreatorCommunityService) {
@PostMapping
@PreAuthorize("hasRole('CREATOR')")
fun createCommunityPost(
@@ -72,22 +68,6 @@ class CreatorCommunityController(
)
}
@PutMapping("/fixed")
@PreAuthorize("hasRole('CREATOR')")
fun updateCommunityPostFixed(
@RequestBody request: UpdateCommunityPostFixedRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.updateCommunityPostFixed(
request = request,
member = member
)
)
}
@GetMapping
fun getCommunityPostList(
@RequestParam creatorId: Long,
@@ -96,7 +76,6 @@ class CreatorCommunityController(
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommunityPostList(
@@ -105,7 +84,7 @@ class CreatorCommunityController(
timezone = timezone,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = isAdult
isAdult = member.auth != null
)
)
}
@@ -117,14 +96,13 @@ class CreatorCommunityController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommunityPostDetail(
postId = postId,
memberId = member.id!!,
timezone = timezone,
isAdult = isAdult
isAdult = member.auth != null
)
)
}
@@ -135,10 +113,8 @@ class CreatorCommunityController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
// 좋아요 대상 게시글 조회도 저장된 성인 노출 정책을 동일하게 적용한다.
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(service.communityPostLike(request, member, isAdult))
ApiResponse.ok(service.communityPostLike(request, member))
}
@PostMapping("/comment")
@@ -147,7 +123,6 @@ class CreatorCommunityController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.createCommunityPostComment(
@@ -155,8 +130,7 @@ class CreatorCommunityController(
postId = request.postId,
parentId = request.parentId,
isSecret = request.isSecret,
member = member,
isAdult = isAdult
member = member
)
)
}
@@ -181,7 +155,6 @@ class CreatorCommunityController(
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommunityPostCommentList(
@@ -189,8 +162,7 @@ class CreatorCommunityController(
memberId = member.id!!,
timezone = timezone,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = isAdult
limit = pageable.pageSize.toLong()
)
)
}
@@ -203,7 +175,6 @@ class CreatorCommunityController(
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommentReplyList(
@@ -211,8 +182,7 @@ class CreatorCommunityController(
memberId = member.id!!,
timezone = timezone,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = isAdult
limit = pageable.pageSize.toLong()
)
)
}
@@ -223,13 +193,12 @@ class CreatorCommunityController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getLatestPostListFromCreatorsYouFollow(
timezone = timezone,
memberId = member.id!!,
isAdult = isAdult
isAdult = member.auth != null
)
)
}
@@ -240,14 +209,13 @@ class CreatorCommunityController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.purchasePost(
postId = request.postId,
memberId = member.id!!,
timezone = request.timezone,
isAdult = isAdult,
isAdult = member.auth != null,
container = request.container
)
)

View File

@@ -11,9 +11,7 @@ import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository {
fun countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(memberId: Long): Long
}
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository
interface CreatorCommunityQueryRepository {
fun findByIdAndMemberId(id: Long, memberId: Long): CreatorCommunity?
@@ -73,8 +71,7 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
creatorCommunity.createdAt,
creatorCommunity.isCommentAvailable,
creatorCommunity.price,
creatorCommunity.isAdult,
creatorCommunity.isFixed
creatorCommunity.isAdult
)
)
.from(creatorCommunity)
@@ -92,11 +89,7 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
.where(where)
.offset(offset)
.limit(limit)
.orderBy(
creatorCommunity.isFixed.desc(),
creatorCommunity.fixedAt.desc().nullsLast(),
creatorCommunity.createdAt.desc()
)
.orderBy(creatorCommunity.createdAt.desc())
.fetch()
}
@@ -165,8 +158,7 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
creatorCommunity.createdAt,
creatorCommunity.isCommentAvailable,
creatorCommunity.price,
creatorCommunity.isAdult,
creatorCommunity.isFixed
creatorCommunity.isAdult
)
)
.from(creatorCommunity)
@@ -198,8 +190,7 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
creatorCommunity.createdAt,
creatorCommunity.isCommentAvailable,
creatorCommunity.price,
creatorCommunity.isAdult,
creatorCommunity.isFixed
creatorCommunity.isAdult
)
)
.from(creatorCommunity)

View File

@@ -17,10 +17,8 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeResponse
import kr.co.vividnext.sodalive.extensions.getTimeAgoString
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -33,7 +31,6 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime
import java.time.ZoneId
@Service
@@ -125,13 +122,9 @@ class CreatorCommunityService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "creator.community.fcm.new_post",
senderMemberId = member.id,
creatorId = member.id!!,
deepLinkValue = FcmDeepLinkValue.COMMUNITY,
deepLinkId = member.id!!
creatorId = member.id!!
)
)
}
@@ -159,11 +152,6 @@ class CreatorCommunityService(
if (request.isActive != null) {
post.isActive = request.isActive
if (!post.isActive) {
post.isFixed = false
post.fixedAt = null
}
}
if (postImage != null) {
@@ -185,28 +173,6 @@ class CreatorCommunityService(
}
}
@Transactional
fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) {
val post = repository.findByIdAndMemberId(id = request.postId, memberId = member.id!!)
?: throw SodaException(messageKey = "common.error.invalid_request")
if (request.isFixed) {
if (!post.isFixed) {
val fixedPostCount = repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(member.id!!)
if (fixedPostCount >= 3) {
throw SodaException(messageKey = "creator.community.max_fixed_post_count")
}
}
post.isFixed = true
post.fixedAt = LocalDateTime.now()
} else {
post.isFixed = false
post.fixedAt = null
}
}
fun getCommunityPostList(
creatorId: Long,
memberId: Long,
@@ -380,18 +346,14 @@ class CreatorCommunityService(
}
@Transactional
fun communityPostLike(
request: PostCommunityPostLikeRequest,
member: Member,
isAdult: Boolean
): PostCommunityPostLikeResponse {
fun communityPostLike(request: PostCommunityPostLikeRequest, member: Member): PostCommunityPostLikeResponse {
var postLike = likeRepository.findByPostIdAndMemberId(postId = request.postId, memberId = member.id!!)
if (postLike == null) {
postLike = CreatorCommunityLike()
postLike.member = member
val post = repository.findByIdAndActive(request.postId, isAdult = isAdult)
val post = repository.findByIdAndActive(request.postId, isAdult = member.auth != null)
?: throw SodaException(messageKey = "creator.community.invalid_request_retry")
postLike.creatorCommunity = post
@@ -409,15 +371,12 @@ class CreatorCommunityService(
comment: String,
postId: Long,
parentId: Long? = null,
isSecret: Boolean = false,
isAdult: Boolean
isSecret: Boolean = false
) {
val post = repository.findByIdAndActive(postId = postId, isAdult = isAdult)
?: throw SodaException(messageKey = "creator.community.invalid_request_retry")
val post = repository.findByIdOrNull(id = postId)
?: throw SodaException(messageKey = "creator.community.invalid_post_retry")
val creatorId = post.member!!.id!!
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = post.member!!.id!!)) {
throw SodaException(messageKey = "creator.community.invalid_access_retry")
}
@@ -442,22 +401,6 @@ class CreatorCommunityService(
}
commentRepository.save(postComment)
if (member.id != creatorId) {
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "creator.community.fcm.new_comment",
senderMemberId = member.id,
recipients = listOf(creatorId),
deepLinkValue = FcmDeepLinkValue.COMMUNITY,
deepLinkId = creatorId,
deepLinkCommentPostId = postId
)
)
}
}
@Transactional
@@ -485,13 +428,10 @@ class CreatorCommunityService(
memberId: Long,
timezone: String,
offset: Long,
limit: Long,
isAdult: Boolean
limit: Long
): GetCommunityPostCommentListResponse {
val post = repository.findByIdAndActive(postId = postId, isAdult = isAdult)
?: throw SodaException(messageKey = "creator.community.invalid_request_retry")
if (isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!)) {
val post = repository.findByIdOrNull(id = postId)
if (post != null && isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!)) {
return GetCommunityPostCommentListResponse(totalCount = 0, items = listOf())
}
@@ -517,14 +457,9 @@ class CreatorCommunityService(
memberId: Long,
timezone: String,
offset: Long,
limit: Long,
isAdult: Boolean
limit: Long
): GetCommunityPostCommentListResponse {
val parentComment = commentRepository.findByIdOrNull(id = commentId)
if (parentComment != null && !isAdult && parentComment.creatorCommunity!!.isAdult) {
throw SodaException(messageKey = "creator.community.invalid_request_retry")
}
if (
parentComment != null &&
isBlockedBetweenMembers(memberId = memberId, creatorId = parentComment.creatorCommunity!!.member!!.id!!)

View File

@@ -16,7 +16,6 @@ data class GetCommunityPostListResponse @QueryProjection constructor(
val dateUtc: String,
val isCommentAvailable: Boolean,
val isAdult: Boolean,
val isFixed: Boolean,
val isLike: Boolean,
val existOrdered: Boolean,
val likeCount: Int,

View File

@@ -15,8 +15,7 @@ data class SelectCommunityPostResponse @QueryProjection constructor(
val date: LocalDateTime,
val isCommentAvailable: Boolean,
val price: Int,
val isAdult: Boolean,
val isFixed: Boolean
val isAdult: Boolean
) {
fun toCommunityPostListResponse(
imageHost: String,
@@ -62,7 +61,6 @@ data class SelectCommunityPostResponse @QueryProjection constructor(
dateUtc = dateUtc,
isCommentAvailable = isCommentAvailable,
isAdult = isAdult,
isFixed = isFixed,
isLike = isLike,
existOrdered = existOrdered,
likeCount = likeCount,

View File

@@ -1,6 +0,0 @@
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
data class UpdateCommunityPostFixedRequest(
val postId: Long,
val isFixed: Boolean
)

View File

@@ -1,6 +1,5 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.transaction.annotation.Transactional
@@ -22,7 +21,6 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
isAuth = request.isAuth,
message = request.message,
@@ -33,7 +31,6 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.ALL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
message = request.message,
isAuth = request.isAuth

View File

@@ -1,8 +1,6 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.GenderRestriction
@@ -18,25 +16,12 @@ enum class FcmEventType {
CREATE_CONTENT_COMMENT, IN_PROGRESS_AUDITION
}
enum class FcmDeepLinkValue(val value: String) {
LIVE("live"),
CHANNEL("channel"),
CONTENT("content"),
SERIES("series"),
AUDITION("audition"),
COMMUNITY("community")
}
class FcmEvent(
val type: FcmEventType,
val category: PushNotificationCategory? = null,
val title: String = "",
val message: String = "",
val titleKey: String? = null,
val messageKey: String? = null,
val senderMemberId: Long? = null,
val senderNicknameSnapshot: String? = null,
val senderProfileImageSnapshot: String? = null,
val args: List<Any> = listOf(),
val container: String = "",
val recipients: List<Long> = listOf(),
@@ -47,9 +32,6 @@ class FcmEvent(
val messageId: Long? = null,
val creatorId: Long? = null,
val auditionId: Long? = null,
val deepLinkValue: FcmDeepLinkValue? = null,
val deepLinkId: Long? = null,
val deepLinkCommentPostId: Long? = null,
val commentParentId: Long? = null,
val myMemberId: Long? = null,
val isAvailableJoinCreator: Boolean? = null,
@@ -61,8 +43,7 @@ class FcmSendListener(
private val pushService: FcmService,
private val memberRepository: MemberRepository,
private val contentCommentRepository: AudioContentCommentRepository,
private val messageSource: SodaMessageSource,
private val pushNotificationService: PushNotificationService
private val messageSource: SodaMessageSource
) {
@Async
@TransactionalEventListener
@@ -173,13 +154,6 @@ class FcmSendListener(
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args)
pushNotificationService.saveNotification(
fcmEvent = fcmEvent,
languageCode = lang.code,
translatedMessage = message,
recipientPushTokens = tokens
)
val tokensByOS = tokens.groupBy { it.deviceType }
for ((os, osTokens) in tokensByOS) {
osTokens.map { it.token }.distinct().chunked(500).forEach { batch ->
@@ -192,10 +166,7 @@ class FcmSendListener(
contentId = contentId ?: fcmEvent.contentId,
messageId = messageId ?: fcmEvent.messageId,
creatorId = creatorId ?: fcmEvent.creatorId,
auditionId = auditionId ?: fcmEvent.auditionId,
deepLinkValue = fcmEvent.deepLinkValue,
deepLinkId = fcmEvent.deepLinkId,
deepLinkCommentPostId = fcmEvent.deepLinkCommentPostId
auditionId = auditionId ?: fcmEvent.auditionId
)
}
}

View File

@@ -8,16 +8,11 @@ import com.google.firebase.messaging.MessagingErrorCode
import com.google.firebase.messaging.MulticastMessage
import com.google.firebase.messaging.Notification
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
@Service
class FcmService(
private val pushTokenService: PushTokenService,
@Value("\${server.env}")
private val serverEnv: String
) {
class FcmService(private val pushTokenService: PushTokenService) {
private val logger = LoggerFactory.getLogger(this::class.java)
@Async
@@ -30,10 +25,7 @@ class FcmService(
messageId: Long? = null,
contentId: Long? = null,
creatorId: Long? = null,
auditionId: Long? = null,
deepLinkValue: FcmDeepLinkValue? = null,
deepLinkId: Long? = null,
deepLinkCommentPostId: Long? = null
auditionId: Long? = null
) {
if (tokens.isEmpty()) return
logger.info("os: $container")
@@ -90,11 +82,6 @@ class FcmService(
multicastMessage.putData("audition_id", auditionId.toString())
}
val deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId)
if (deepLink != null) {
multicastMessage.putData("deep_link", deepLink)
}
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
val failedTokens = mutableListOf<String>()
@@ -128,14 +115,6 @@ class FcmService(
}
}
private fun createDeepLink(
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Long?,
deepLinkCommentPostId: Long?
): String? {
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)
}
fun sendPointGranted(tokens: List<String>, point: Int) {
if (tokens.isEmpty()) return
val data = mapOf(
@@ -201,30 +180,4 @@ class FcmService(
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
}
}
companion object {
fun buildDeepLink(
serverEnv: String,
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Long?,
deepLinkCommentPostId: Long? = null
): String? {
if (deepLinkValue == null || deepLinkId == null) {
return null
}
val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) {
"voiceon"
} else {
"voiceon-test"
}
val baseDeepLink = "$uriScheme://${deepLinkValue.value}/$deepLinkId"
if (deepLinkValue == FcmDeepLinkValue.COMMUNITY && deepLinkCommentPostId != null) {
return "$baseDeepLink?postId=$deepLinkCommentPostId"
}
return baseDeepLink
}
}
}

View File

@@ -10,7 +10,6 @@ interface PushTokenQueryRepository {
fun findByToken(token: String): PushToken?
fun findByMemberId(memberId: Long): List<PushToken>
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
fun findMemberIdsByTokenIn(tokens: List<String>): List<Long>
}
class PushTokenQueryRepositoryImpl(
@@ -37,19 +36,4 @@ class PushTokenQueryRepositoryImpl(
.where(pushToken.member.id.`in`(memberIds))
.fetch()
}
override fun findMemberIdsByTokenIn(tokens: List<String>): List<Long> {
if (tokens.isEmpty()) return emptyList()
return queryFactory
.select(pushToken.member.id)
.from(pushToken)
.where(
pushToken.token.`in`(tokens)
.and(pushToken.member.id.isNotNull)
)
.fetch()
.filterNotNull()
.distinct()
}
}

View File

@@ -1,16 +0,0 @@
package kr.co.vividnext.sodalive.fcm.notification
enum class PushNotificationCategory(val code: String) {
LIVE("live"),
CONTENT("content"),
COMMUNITY("community"),
MESSAGE("message"),
AUDITION("audition"),
SYSTEM("system");
companion object {
fun fromCode(code: String): PushNotificationCategory? {
return values().find { it.code == code.lowercase() }
}
}
}

View File

@@ -1,43 +0,0 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/push/notification")
class PushNotificationController(
private val pushNotificationService: PushNotificationService
) {
@GetMapping("/list")
fun getNotificationList(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable,
@RequestParam("category", required = false) category: String?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
pushNotificationService.getNotificationList(
member = member,
pageable = pageable,
category = category
)
)
}
@GetMapping("/categories")
fun getAvailableCategories(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(pushNotificationService.getAvailableCategories(member))
}
}

View File

@@ -1,78 +0,0 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.Table
@Entity
@Table(name = "push_notification_list")
class PushNotificationList(
@Column(nullable = false)
var senderNicknameSnapshot: String,
@Column(nullable = true)
var senderProfileImageSnapshot: String? = null,
@Column(columnDefinition = "TEXT", nullable = false)
var message: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var category: PushNotificationCategory,
@Column(nullable = true)
var deepLink: String? = null,
@Column(nullable = false, length = 8)
var languageCode: String
) : BaseEntity() {
@OneToMany(mappedBy = "notification", cascade = [CascadeType.ALL], orphanRemoval = true)
val recipientChunks: MutableList<PushNotificationRecipientChunk> = mutableListOf()
fun addRecipientChunk(chunk: PushNotificationRecipientChunk) {
chunk.notification = this
recipientChunks.add(chunk)
}
}
@Entity
@Table(name = "push_notification_recipient_chunk")
class PushNotificationRecipientChunk(
@Column(columnDefinition = "json", nullable = false)
@Convert(converter = PushNotificationRecipientChunkConverter::class)
var recipientMemberIds: List<Long>
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
var notification: PushNotificationList? = null
}
@Converter(autoApply = false)
class PushNotificationRecipientChunkConverter : AttributeConverter<List<Long>, String> {
override fun convertToDatabaseColumn(attribute: List<Long>?): String {
if (attribute == null) return "[]"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): List<Long> {
if (dbData.isNullOrBlank()) return emptyList()
return objectMapper.readValue(dbData)
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

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