Compare commits

..

401 Commits

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

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

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

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

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

@@ -40,7 +40,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
@@ -528,30 +527,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 +547,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 +561,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 +592,7 @@ class AudioContentService(
}
// 댓글
val commentList = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
val commentList = if (audioContent.isCommentAvailable) {
commentRepository.findByContentId(
cloudFrontHost = coverImageHost,
contentId = audioContent.id!!,
@@ -620,7 +607,7 @@ class AudioContentService(
}
// 댓글 수
val commentCount = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
val commentCount = if (audioContent.isCommentAvailable) {
commentRepository.totalCountCommentByContentId(
contentId = audioContent.id!!,
memberId = member.id!!,
@@ -675,16 +662,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 +856,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 +896,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 +970,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

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

@@ -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
@@ -30,7 +31,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 +48,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 +257,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 +307,6 @@ class ExplorerService(
queryRepository.getLiveRoomList(
creatorId,
userMember = member,
isAdult = preference.isAdult,
timezone = timezone
)
} else {
@@ -321,8 +318,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 +348,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 +382,7 @@ class ExplorerService(
timezone = timezone,
offset = 0,
limit = 3,
isAdult = preference.isAdult
isAdult = member.auth != null
)
} else {
listOf()
@@ -419,8 +412,8 @@ class ExplorerService(
seriesService
.getSeriesList(
creatorId = creatorId,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL,
member = member
)
.items

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

@@ -33,7 +33,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
@@ -159,11 +158,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 +179,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 +352,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,11 +377,10 @@ 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!!
@@ -485,13 +452,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 +481,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

@@ -2297,11 +2297,6 @@ class SodaMessageSource {
Lang.EN to "Invalid access.\nPlease check and try again.",
Lang.JA to "不正なアクセスです。\n恐れ入りますが、確認後再度お試しください。"
),
"creator.community.max_fixed_post_count" to mapOf(
Lang.KO to "최대 3개까지 고정 가능합니다.",
Lang.EN to "You can pin up to 3 posts.",
Lang.JA to "固定できる投稿は最大3件までです。"
),
"creator.community.blocked_access" to mapOf(
Lang.KO to "%s님의 요청으로 접근이 제한됩니다.",
Lang.EN to "Access is restricted at %s's request.",

View File

@@ -1,20 +0,0 @@
package kr.co.vividnext.sodalive.live.recommend
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
@Service
class LiveRecommendCacheService(
private val repository: LiveRecommendRepository
) {
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'getRecommendLive:' + (#memberId ?: 'guest') + ':' + #isAdult"
)
fun getRecommendLive(memberId: Long?, isAdult: Boolean): List<GetRecommendLiveResponse> {
return repository.getRecommendLive(
memberId = memberId,
isAdult = isAdult
)
}
}

View File

@@ -3,37 +3,29 @@ package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class LiveRecommendService(
private val repository: LiveRecommendRepository,
private val blockMemberRepository: BlockMemberRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val liveRecommendCacheService: LiveRecommendCacheService
private val blockMemberRepository: BlockMemberRepository
) {
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'getRecommendLive:' + (#member?.id ?: 'guest')"
)
fun getRecommendLive(member: Member?): List<GetRecommendLiveResponse> {
val isAdult = if (member != null) {
memberContentPreferenceService.getStoredPreference(member).isAdult
} else {
false
}
return liveRecommendCacheService.getRecommendLive(
return repository.getRecommendLive(
memberId = member?.id,
isAdult = isAdult
isAdult = member?.auth != null
)
}
fun getRecommendChannelList(member: Member?): List<GetRecommendChannelResponse> {
val isAdult = if (member != null) {
memberContentPreferenceService.getStoredPreference(member).isAdult
} else {
false
}
val onAirChannelList = repository.getOnAirRecommendChannelList(
isBlocked = {
if (member != null) {
@@ -43,7 +35,7 @@ class LiveRecommendService(
}
},
isCreator = member?.role == MemberRole.CREATOR,
isAdult = isAdult
isAdult = member?.auth != null
)
if (onAirChannelList.size >= 20) {
@@ -68,13 +60,11 @@ class LiveRecommendService(
}
fun getFollowingChannelList(member: Member): List<GetRecommendChannelResponse> {
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
val onAirFollowingChannelList = repository.getOnAirFollowingChannelList(
memberId = member.id!!,
isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) },
isCreator = member.role == MemberRole.CREATOR,
isAdult = isAdult
isAdult = member.auth != null
)
if (onAirFollowingChannelList.size >= 20) {

View File

@@ -5,7 +5,6 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest
import kr.co.vividnext.sodalive.live.room.donation.DeleteLiveRoomDonationMessage
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationRequest
import kr.co.vividnext.sodalive.live.room.info.SetChatFreezeRequest
import kr.co.vividnext.sodalive.live.room.like.LiveRoomLikeHeartRequest
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService
import kr.co.vividnext.sodalive.member.Member
@@ -35,6 +34,7 @@ class LiveRoomController(
@RequestParam timezone: String,
@RequestParam dateString: String? = null,
@RequestParam status: LiveRoomStatus,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
@@ -42,6 +42,7 @@ class LiveRoomController(
service.getRoomList(
dateString,
status,
isAdultContentVisible ?: true,
pageable,
member,
timezone
@@ -203,16 +204,6 @@ class LiveRoomController(
ApiResponse.ok(service.setManager(request, member))
}
@PutMapping("/info/set/chat-freeze")
fun setChatFreeze(
@RequestBody request: SetChatFreezeRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.setChatFreeze(request, member))
}
@PostMapping("/donation")
fun donation(
@RequestBody request: LiveRoomDonationRequest,

View File

@@ -17,9 +17,7 @@ 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.content.ContentType
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
@@ -45,7 +43,6 @@ import kr.co.vividnext.sodalive.live.room.info.GetRoomInfoResponse
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository
import kr.co.vividnext.sodalive.live.room.info.LiveRoomMember
import kr.co.vividnext.sodalive.live.room.info.SetChatFreezeRequest
import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService
import kr.co.vividnext.sodalive.live.room.like.GetLiveRoomHeartListResponse
import kr.co.vividnext.sodalive.live.room.like.GetLiveRoomHeartTotalResponse
@@ -56,16 +53,12 @@ import kr.co.vividnext.sodalive.live.room.menu.UpdateLiveMenuRequest
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService
import kr.co.vividnext.sodalive.live.roulette.NewRouletteRepository
import kr.co.vividnext.sodalive.live.signature.SignatureCanRepository
import kr.co.vividnext.sodalive.live.tag.LiveTag
import kr.co.vividnext.sodalive.live.tag.LiveTagRepository
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
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 kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
@@ -102,14 +95,12 @@ class LiveRoomService(
private val useCanCalculateRepository: UseCanCalculateRepository,
private val reservationRepository: LiveReservationRepository,
private val explorerQueryRepository: ExplorerQueryRepository,
private val creatorDonationRankingService: CreatorDonationRankingService,
private val roomVisitService: LiveRoomVisitService,
private val canPaymentService: CanPaymentService,
private val chargeRepository: ChargeRepository,
private val pushTokenRepository: PushTokenRepository,
private val memberRepository: MemberRepository,
private val tagRepository: LiveTagRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val canRepository: CanRepository,
private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader,
@@ -130,12 +121,6 @@ class LiveRoomService(
) {
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
// 태그가 성인(19금) 판정에 해당하는지 여부를 계산한다.
private fun isAdultTag(tag: LiveTag): Boolean {
// 기존 문자열 기반 조건("음담패설")을 유지하고, 태그 속성의 isAdult도 함께 평가한다.
return tag.tag.contains("음담패설") || tag.isAdult
}
private fun formatMessage(key: String, vararg args: Any): String {
val template = messageSource.getMessage(key, langContext.lang).orEmpty()
return if (args.isNotEmpty()) {
@@ -205,12 +190,11 @@ class LiveRoomService(
fun getRoomList(
dateString: String?,
status: LiveRoomStatus,
isAdultContentVisible: Boolean,
pageable: Pageable,
member: Member?,
timezone: String
): List<GetRoomListResponse> {
val preference = resolvePreference(member)
val isAdult = preference.isAdult
val effectiveGender = member?.let {
if (it.auth != null) {
if (it.auth!!.gender == 1) Gender.MALE else Gender.FEMALE
@@ -234,7 +218,7 @@ class LiveRoomService(
timezone,
memberId = member?.id,
isCreator = member?.role == MemberRole.CREATOR,
isAdult = isAdult,
isAdult = member?.auth != null && isAdultContentVisible,
effectiveGender = effectiveGender
)
} else {
@@ -242,7 +226,7 @@ class LiveRoomService(
timezone,
isCreator = member?.role == MemberRole.CREATOR,
memberId = member?.id,
isAdult = isAdult,
isAdult = member?.auth != null && isAdultContentVisible,
effectiveGender = effectiveGender
)
}
@@ -437,22 +421,17 @@ class LiveRoomService(
"${beginDateTime.hour}_${beginDateTime.minute}"
}
var isAdultByTags = false
request.tags.forEach { tagText ->
val tag = tagRepository.findByTag(tagText)
request.tags.forEach {
val tag = tagRepository.findByTag(it)
if (tag != null) {
room.tags.add(LiveRoomTag(room, tag))
if (isAdultTag(tag)) {
isAdultByTags = true
if (tag.tag.contains("음담패설")) {
room.isAdult = true
}
}
}
// 태그 판정 결과를 한 번에 반영해 부수효과를 최소화한다.
if (isAdultByTags) {
room.isAdult = true
}
val createdRoom = repository.save(room)
// 이미지 업로드
if (coverImage != null) {
@@ -534,8 +513,7 @@ class LiveRoomService(
throw SodaException(messageKey = "live.room.already_ended")
}
val preference = memberContentPreferenceService.getStoredPreference(member)
if (room.isAdult && !preference.isAdult) {
if (room.isAdult && member.auth == null) {
throw SodaException(messageKey = "live.room.adult_verification_required")
}
@@ -776,11 +754,6 @@ class LiveRoomService(
val room = repository.getLiveRoom(id = request.roomId)
?: throw SodaException(messageKey = "live.room.not_found")
val preference = memberContentPreferenceService.getStoredPreference(member)
if (room.isAdult && !preference.isAdult) {
throw SodaException(messageKey = "live.room.adult_verification_required")
}
if (
room.member!!.id!! != member.id!! &&
room.type == LiveRoomType.PRIVATE &&
@@ -1015,13 +988,11 @@ class LiveRoomService(
}
val donationRankingTop3UserIds = if (room.member!!.isVisibleDonationRank) {
val donationRankingPeriod = room.member!!.donationRankingPeriod ?: DonationRankingPeriod.CUMULATIVE
creatorDonationRankingService
explorerQueryRepository
.getMemberDonationRanking(
creatorId = room.member!!.id!!,
limit = 3,
withDonationCan = false,
period = donationRankingPeriod
room.member!!.id!!,
3,
withDonationCan = false
)
.map { it.userId }
} else {
@@ -1076,29 +1047,10 @@ class LiveRoomService(
creatorLanguageCode = creatorLanguageCode,
isPrivateRoom = room.type == LiveRoomType.PRIVATE,
password = room.password,
isActiveRoulette = isActiveRoulette,
isChatFrozen = roomInfo.isChatFrozen
isActiveRoulette = isActiveRoulette
)
}
fun setChatFreeze(request: SetChatFreezeRequest, member: Member) {
val lock = getOrCreateLock(memberId = member.id!!)
lock.write {
val room = repository.findByIdOrNull(request.roomId)
?: throw SodaException(messageKey = "live.room.not_found")
if (room.member!!.id!! != member.id!!) {
throw SodaException(messageKey = "common.error.access_denied")
}
val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId)
?: throw SodaException(messageKey = "live.room.info_not_found")
roomInfo.isChatFrozen = request.isChatFrozen
roomInfoRepository.save(roomInfo)
}
}
fun getDonationMessageList(roomId: Long, member: Member): List<LiveRoomDonationMessage> {
val liveRoomCreatorId = repository.getLiveRoomCreatorId(roomId)
?: throw SodaException(messageKey = "live.room.info_not_found")
@@ -1469,19 +1421,6 @@ class LiveRoomService(
return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() }
}
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)
}
@Transactional
fun likeHeart(request: LiveRoomLikeHeartRequest, member: Member) {
val room = repository.findByIdOrNull(request.roomId)

View File

@@ -24,6 +24,5 @@ data class GetRoomInfoResponse(
val creatorLanguageCode: String?,
val isPrivateRoom: Boolean = false,
val password: String? = null,
val isActiveRoulette: Boolean = false,
val isChatFrozen: Boolean = false
val isActiveRoulette: Boolean = false
)

View File

@@ -83,9 +83,6 @@ data class LiveRoomInfo(
managerCount = managerList.size
}
// 채팅 얼림 상태 (기본값: 해제)
var isChatFrozen: Boolean = false
fun addDonationMessage(memberId: Long, nickname: String, isSecret: Boolean, can: Int, donationMessage: String) {
val donationMessageSet = donationMessageList.toMutableSet()
donationMessageSet.add(

View File

@@ -1,6 +0,0 @@
package kr.co.vividnext.sodalive.live.room.info
data class SetChatFreezeRequest(
val roomId: Long,
val isChatFrozen: Boolean
)

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.live.tag
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.live.tag.QLiveTag.liveTag
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@@ -11,15 +13,15 @@ interface LiveTagRepository : JpaRepository<LiveTag, Long>, LiveTagQueryReposito
}
interface LiveTagQueryRepository {
fun getTags(isAdult: Boolean, cloudFrontHost: String): List<GetLiveTagResponse>
fun getTags(member: Member, cloudFrontHost: String): List<GetLiveTagResponse>
}
@Repository
class LiveTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveTagQueryRepository {
override fun getTags(isAdult: Boolean, cloudFrontHost: String): List<GetLiveTagResponse> {
override fun getTags(member: Member, cloudFrontHost: String): List<GetLiveTagResponse> {
var where = liveTag.isActive.isTrue
if (!isAdult) {
if (member.role != MemberRole.ADMIN && member.auth == null) {
where = where.and(liveTag.isAdult.isFalse)
}

View File

@@ -5,8 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
@@ -17,7 +15,6 @@ import org.springframework.web.multipart.MultipartFile
@Service
class LiveTagService(
private val repository: LiveTagRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader,
@@ -94,15 +91,7 @@ class LiveTagService(
}
fun getTags(member: Member): List<GetLiveTagResponse> {
// 관리자 화면에서는 운영 확인 목적상 성인 태그까지 전체 조회를 허용한다.
val isAdult = if (member.role == MemberRole.ADMIN) {
true
} else {
// 일반 사용자는 저장된 선호 정책(isAdult) 기준으로만 태그 노출을 제한한다.
memberContentPreferenceService.getStoredPreference(member).isAdult
}
return repository.getTags(isAdult = isAdult, cloudFrontHost = cloudFrontHost)
return repository.getTags(member = member, cloudFrontHost = cloudFrontHost)
}
fun tagExistCheck(request: CreateLiveTagRequest) {

View File

@@ -7,9 +7,6 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.UpdateMemberContentPreferenceRequest
import kr.co.vividnext.sodalive.member.contentpreference.UpdateMemberContentPreferenceResponse
import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.member.login.LoginResponse
@@ -23,7 +20,6 @@ import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
@@ -39,7 +35,6 @@ import org.springframework.web.multipart.MultipartFile
@RequestMapping("/member")
class MemberController(
private val service: MemberService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val socialAuthServiceResolver: SocialAuthServiceResolver,
private val trackingService: AdTrackingService,
private val userActionService: UserActionService,
@@ -141,27 +136,6 @@ class MemberController(
ApiResponse.ok(service.getMemberInfo(member, container ?: "web"))
}
@PatchMapping("/content-preference")
fun updateContentPreference(
@RequestBody request: UpdateMemberContentPreferenceRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = request.isAdultContentVisible,
contentType = request.contentType
)
ApiResponse.ok(
UpdateMemberContentPreferenceResponse(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType
)
)
}
@PostMapping("/notification")
fun updateNotificationSettings(
@RequestBody request: UpdateNotificationSettingRequest,

View File

@@ -17,11 +17,7 @@ import kr.co.vividnext.sodalive.member.notification.QMemberNotification.memberNo
import kr.co.vividnext.sodalive.message.QMessage.message
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import javax.persistence.LockModeType
@Repository
interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository {
@@ -31,10 +27,6 @@ interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository
fun findByKakaoId(kakaoId: Long): Member?
fun findByAppleId(appleId: String): Member?
fun findByLineId(lineId: String): Member?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from Member m where m.id = :memberId")
fun findByIdForUpdate(@Param("memberId") memberId: Long): Member?
}
interface MemberQueryRepository {

View File

@@ -17,11 +17,11 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.block.GetBlockedMemberListResponse
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.member.info.GetMemberInfoResponse
@@ -82,6 +82,7 @@ class MemberService(
private val stipulationAgreeRepository: StipulationAgreeRepository,
private val creatorFollowingRepository: CreatorFollowingRepository,
private val blockMemberRepository: BlockMemberRepository,
private val authRepository: AuthRepository,
private val signOutRepository: SignOutRepository,
private val nicknameChangeLogRepository: NicknameChangeLogRepository,
private val memberTagRepository: MemberTagRepository,
@@ -105,7 +106,6 @@ class MemberService(
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val countryContext: CountryContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val objectMapper: ObjectMapper,
private val cacheManager: CacheManager,
@@ -120,8 +120,6 @@ class MemberService(
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
private val recommendLiveCacheKeyPrefix = "getRecommendLive:"
private val recommendLiveCacheKeySuffixFalse = ":false"
private val recommendLiveCacheKeySuffixTrue = ":true"
private val latestFinishedLiveCacheKeyPrefix = "getLatestFinishedLive:"
@Transactional
@@ -156,7 +154,6 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (request.pushToken != null) {
@@ -195,7 +192,6 @@ class MemberService(
duplicateCheckNickname(request.nickname)
val member = createMember(request)
memberContentPreferenceService.initializeDefaultPreference(member)
member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
@@ -221,8 +217,6 @@ class MemberService(
}
fun getMemberInfo(member: Member, container: String): GetMemberInfoResponse {
val preference = memberContentPreferenceService.getStoredPreference(member)
val gender = if (member.auth != null) {
if (member.auth!!.gender == 1) {
messageSource.getMessage("member.gender.male", langContext.lang)
@@ -256,10 +250,7 @@ class MemberService(
messageNotice = member.notification?.message,
followingChannelLiveNotice = member.notification?.live,
followingChannelUploadContentNotice = member.notification?.uploadContent,
auditionNotice = member.notification?.audition,
countryCode = preference.countryCode,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType
auditionNotice = member.notification?.audition
)
}
@@ -537,25 +528,35 @@ class MemberService(
@Transactional
fun memberBlock(request: MemberBlockRequest, memberId: Long) {
// 요청자와 차단 대상 회원이 실제로 존재하는지 검증한다.
val member = repository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.invalid_request")
val blockedMember = repository.findByIdOrNull(id = request.blockMemberId)
?: throw SodaException(messageKey = "common.error.invalid_request")
// 요청자 본인을 차단하려는 경우에는 차단 레코드를 생성하지 않는다.
if (memberId != request.blockMemberId) {
// 요청한 blockMemberId 한 건만 대상으로 기존 차단 여부를 조회한다.
val blockTargetMemberIds = mutableSetOf(request.blockMemberId)
blockedMember.auth?.let { auth ->
val verifiedMemberIds = authRepository.getMemberIdsByNameAndBirthAndDiAndGender(
name = auth.name,
birth = auth.birth,
di = auth.di,
gender = auth.gender
)
blockTargetMemberIds.addAll(verifiedMemberIds)
}
blockTargetMemberIds.remove(memberId)
blockTargetMemberIds.forEach { targetMemberId ->
val targetMember = repository.findByIdOrNull(id = targetMemberId) ?: return@forEach
var blockMember = blockMemberRepository.getBlockAccount(
blockedMemberId = request.blockMemberId,
blockedMemberId = targetMemberId,
memberId = memberId
)
// 기존 레코드가 없으면 생성하고, 있으면 활성 상태로 전환한다.
if (blockMember == null) {
blockMember = BlockMember()
blockMember.member = member
blockMember.blockedMember = blockedMember
blockMember.blockedMember = targetMember
blockMemberRepository.save(blockMember)
} else {
@@ -563,14 +564,11 @@ class MemberService(
}
}
// 차단 반영 후 요청자 기준 캐시를 즉시 무효화한다.
evictRecommendLiveCache(memberId)
evictLatestFinishedLiveCache(memberId)
// 본인 차단이 아닌 경우 요청한 대상 회원의 캐시도 함께 무효화한다.
if (memberId != request.blockMemberId) {
evictRecommendLiveCache(request.blockMemberId)
evictLatestFinishedLiveCache(request.blockMemberId)
blockTargetMemberIds.forEach {
evictRecommendLiveCache(it)
evictLatestFinishedLiveCache(it)
}
}
@@ -849,11 +847,7 @@ class MemberService(
}
private fun evictRecommendLiveCache(memberId: Long) {
val cache = cacheManager.getCache("cache_ttl_3_hours") ?: return
cache.evict(recommendLiveCacheKeyPrefix + memberId + recommendLiveCacheKeySuffixFalse)
cache.evict(recommendLiveCacheKeyPrefix + memberId + recommendLiveCacheKeySuffixTrue)
cache.evict(recommendLiveCacheKeyPrefix + memberId)
cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId)
}
private fun evictLatestFinishedLiveCache(memberId: Long) {
@@ -923,7 +917,6 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
@@ -981,7 +974,6 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
@@ -1039,7 +1031,6 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
@@ -1097,7 +1088,6 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.member.auth
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 kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -16,7 +15,6 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/auth")
class AuthController(
private val service: AuthService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val userActionService: UserActionService
) {
@PostMapping
@@ -34,7 +32,6 @@ class AuthController(
}
val authResponse = service.authenticate(authenticateData, member.id!!)
memberContentPreferenceService.markAdultVisibleAfterAuthVerify(member.id!!)
try {
userActionService.recordAction(

View File

@@ -1,33 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.OneToOne
@Entity
class MemberContentPreference(
@Column(nullable = false)
var isAdultContentVisible: Boolean = false,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var contentType: ContentType = ContentType.ALL,
@Column(nullable = false)
var adultContentVisibilityChangedAt: LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
var contentTypeChangedAt: LocalDateTime = LocalDateTime.now()
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false, unique = true)
var member: Member? = null
}

View File

@@ -1,23 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L)
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
fun resolveCountryCodeWithForcedMapping(member: Member, requestCountryCode: String?): String {
val memberId = member.id
if (memberId != null && FORCED_KR_MEMBER_IDS.contains(memberId)) {
return "KR"
}
if (memberId != null && FORCED_JP_MEMBER_IDS.contains(memberId)) {
return "JP"
}
return requestCountryCode
?.trim()
?.takeIf { it.isNotBlank() }
?.uppercase()
?: "KR"
}

View File

@@ -1,19 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
fun resolveCountryCodeByPolicy(member: Member): String {
val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes
val requestCountryCode = requestAttributes?.request?.getHeader("CloudFront-Viewer-Country")
return resolveCountryCodeWithForcedMapping(member, requestCountryCode)
}
fun isAdultVisibleByPolicy(member: Member, isAdultContentVisible: Boolean): Boolean {
return if (resolveCountryCodeByPolicy(member) == "KR") {
member.auth != null && isAdultContentVisible
} else {
isAdultContentVisible
}
}

View File

@@ -1,17 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import javax.persistence.LockModeType
@Repository
interface MemberContentPreferenceRepository : JpaRepository<MemberContentPreference, Long> {
fun findByMemberId(memberId: Long): MemberContentPreference?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select mcp from MemberContentPreference mcp where mcp.member.id = :memberId")
fun findByMemberIdForUpdate(@Param("memberId") memberId: Long): MemberContentPreference?
}

View File

@@ -1,255 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
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.MemberRepository
import org.springframework.cache.CacheManager
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class MemberContentPreferenceService(
private val repository: MemberContentPreferenceRepository,
private val memberRepository: MemberRepository,
private val countryContext: CountryContext,
private val cacheManager: CacheManager
) {
private data class PreferenceSeed(
val isAdultContentVisible: Boolean,
val contentType: ContentType
)
companion object {
private const val RECOMMEND_LIVE_CACHE_NAME = "cache_ttl_3_hours"
private const val RECOMMEND_LIVE_CACHE_KEY_PREFIX = "getRecommendLive:"
private const val RECOMMEND_LIVE_CACHE_KEY_SUFFIX_FALSE = ":false"
private const val RECOMMEND_LIVE_CACHE_KEY_SUFFIX_TRUE = ":true"
}
@Transactional
fun initializeDefaultPreference(member: Member): MemberContentPreference {
return initializeDefaultPreference(
member = member,
seed = PreferenceSeed(
isAdultContentVisible = member.auth != null,
contentType = ContentType.ALL
)
)
}
private fun initializeDefaultPreference(
member: Member,
seed: PreferenceSeed
): MemberContentPreference {
val memberId = requireMemberId(member)
val existingPreference = repository.findByMemberId(memberId)
if (existingPreference != null) {
return existingPreference
}
memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val lockedPreference = repository.findByMemberIdForUpdate(memberId)
if (lockedPreference != null) {
return lockedPreference
}
val now = LocalDateTime.now()
val preference = MemberContentPreference(
isAdultContentVisible = seed.isAdultContentVisible,
contentType = seed.contentType,
adultContentVisibilityChangedAt = now,
contentTypeChangedAt = now
)
preference.member = member
return try {
repository.saveAndFlush(preference)
} catch (e: DataIntegrityViolationException) {
repository.findByMemberIdForUpdate(memberId) ?: throw e
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun resolveForQuery(member: Member): ViewerContentPreference {
val preference = initializeDefaultPreference(
member = member,
seed = resolvePreferenceSeedForQuery(member)
)
val countryCode = resolveCountryCode(member)
return toViewerContentPreference(
countryCode = countryCode,
member = member,
preference = preference
)
}
@Transactional
fun updatePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
if (isAdultContentVisible == null && contentType == null) {
throw SodaException(messageKey = "common.error.invalid_request")
}
val preference = initializeDefaultPreference(member)
val countryCode = resolveCountryCode(member)
val hasChanged = applyRequestValues(
preference = preference,
member = member,
countryCode = countryCode,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
if (hasChanged) {
evictRecommendLiveCacheAfterCommit(requireMemberId(member))
}
return toViewerContentPreference(
countryCode = countryCode,
member = member,
preference = preference
)
}
@Transactional
fun markAdultVisibleAfterAuthVerify(memberId: Long) {
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val preference = initializeDefaultPreference(member)
if (!preference.isAdultContentVisible) {
preference.isAdultContentVisible = true
preference.adultContentVisibilityChangedAt = LocalDateTime.now()
evictRecommendLiveCacheAfterCommit(memberId)
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun getStoredPreference(member: Member): ViewerContentPreference {
val preference = initializeDefaultPreference(member)
val countryCode = resolveCountryCode(member)
return toViewerContentPreference(
countryCode = countryCode,
member = member,
preference = preference
)
}
fun resolveCountryCode(member: Member): String {
requireMemberId(member)
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
}
fun calculateIsAdultForQuery(
member: Member,
countryCode: String,
isAdultContentVisible: Boolean
): Boolean {
return if (countryCode == "KR") {
isAdultContentVisible && member.auth != null
} else {
isAdultContentVisible
}
}
private fun resolvePreferenceSeedForQuery(member: Member): PreferenceSeed {
return PreferenceSeed(
isAdultContentVisible = member.auth != null,
contentType = ContentType.ALL
)
}
private fun applyRequestValues(
preference: MemberContentPreference,
member: Member,
countryCode: String,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): Boolean {
val shouldApplyByCountryPolicy = countryCode != "KR" || member.auth != null
if (!shouldApplyByCountryPolicy) {
return false
}
val now = LocalDateTime.now()
var hasChanged = false
if (
isAdultContentVisible != null &&
preference.isAdultContentVisible != isAdultContentVisible
) {
preference.isAdultContentVisible = isAdultContentVisible
preference.adultContentVisibilityChangedAt = now
hasChanged = true
}
if (contentType != null && preference.contentType != contentType) {
preference.contentType = contentType
preference.contentTypeChangedAt = now
hasChanged = true
}
return hasChanged
}
private fun evictRecommendLiveCacheAfterCommit(memberId: Long) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
evictRecommendLiveCache(memberId)
}
}
)
return
}
evictRecommendLiveCache(memberId)
}
private fun evictRecommendLiveCache(memberId: Long) {
val cache = cacheManager.getCache(RECOMMEND_LIVE_CACHE_NAME) ?: return
cache.evict(RECOMMEND_LIVE_CACHE_KEY_PREFIX + memberId + RECOMMEND_LIVE_CACHE_KEY_SUFFIX_FALSE)
cache.evict(RECOMMEND_LIVE_CACHE_KEY_PREFIX + memberId + RECOMMEND_LIVE_CACHE_KEY_SUFFIX_TRUE)
cache.evict(RECOMMEND_LIVE_CACHE_KEY_PREFIX + memberId)
}
private fun toViewerContentPreference(
countryCode: String,
member: Member,
preference: MemberContentPreference
): ViewerContentPreference {
return ViewerContentPreference(
countryCode = countryCode,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdult = calculateIsAdultForQuery(
member = member,
countryCode = countryCode,
isAdultContentVisible = preference.isAdultContentVisible
)
)
}
private fun requireMemberId(member: Member): Long {
return member.id ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.content.ContentType
data class UpdateMemberContentPreferenceRequest(
val isAdultContentVisible: Boolean? = null,
val contentType: ContentType? = null
)

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.content.ContentType
data class UpdateMemberContentPreferenceResponse(
val isAdultContentVisible: Boolean,
val contentType: ContentType
)

View File

@@ -1,10 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.content.ContentType
data class ViewerContentPreference(
val countryCode: String,
val isAdultContentVisible: Boolean,
val contentType: ContentType,
val isAdult: Boolean
)

View File

@@ -1,6 +1,5 @@
package kr.co.vividnext.sodalive.member.info
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.MemberRole
data class GetMemberInfoResponse(
@@ -14,8 +13,5 @@ data class GetMemberInfoResponse(
val messageNotice: Boolean?,
val followingChannelLiveNotice: Boolean?,
val followingChannelUploadContentNotice: Boolean?,
val auditionNotice: Boolean?,
val countryCode: String,
val isAdultContentVisible: Boolean,
val contentType: ContentType
val auditionNotice: Boolean?
)

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.search
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,22 +13,20 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/search")
class SearchController(
private val service: SearchService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
class SearchController(private val service: SearchService) {
@GetMapping
fun searchUnified(
@RequestParam keyword: String,
@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.searchUnified(
keyword,
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
)
@@ -37,6 +35,8 @@ class SearchController(
@GetMapping("/creators")
fun searchCreatorList(
@RequestParam keyword: 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 {
@@ -44,6 +44,8 @@ class SearchController(
ApiResponse.ok(
service.searchCreatorList(
keyword,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -54,16 +56,17 @@ class SearchController(
@GetMapping("/contents")
fun searchContentList(
@RequestParam keyword: 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.searchContentList(
keyword,
isAdult = preference.isAdult,
contentType = preference.contentType,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -74,22 +77,21 @@ class SearchController(
@GetMapping("/series")
fun searchSeriesList(
@RequestParam keyword: 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.searchSeriesList(
keyword,
isAdult = preference.isAdult,
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

@@ -8,10 +8,12 @@ import org.springframework.stereotype.Service
class SearchService(private val repository: SearchRepository) {
fun searchUnified(
keyword: String,
isAdult: Boolean,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member
): SearchUnifiedResponse {
val isAdult = member.auth != null && isAdultContentVisible
val creatorList = repository.searchCreatorList(
keyword = keyword,
memberId = member.id!!,
@@ -58,6 +60,8 @@ class SearchService(private val repository: SearchRepository) {
fun searchCreatorList(
keyword: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member,
offset: Long,
limit: Long
@@ -79,12 +83,14 @@ class SearchService(private val repository: SearchRepository) {
fun searchContentList(
keyword: String,
isAdult: Boolean,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member,
offset: Long,
limit: Long
): SearchResponse {
val isAdult = member.auth != null && isAdultContentVisible
val totalCount = repository.searchContentTotalCount(
keyword,
memberId = member.id!!,
@@ -110,12 +116,14 @@ class SearchService(private val repository: SearchRepository) {
fun searchSeriesList(
keyword: String,
isAdult: Boolean,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member,
offset: Long,
limit: Long
): SearchResponse {
val isAdult = member.auth != null && isAdultContentVisible
val totalCount = repository.searchSeriesTotalCount(
keyword,
memberId = member.id!!,

View File

@@ -87,14 +87,6 @@ spring:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
pool-name: SodaliveHikari
maximum-pool-size: ${DB_POOL_MAX:10}
minimum-idle: ${DB_POOL_MIN:0}
idle-timeout: ${DB_POOL_IDLE_TIMEOUT_MS:120000}
max-lifetime: ${DB_POOL_MAX_LIFETIME_MS:1800000}
connection-timeout: ${DB_POOL_CONNECTION_TIMEOUT_MS:10000}
keepalive-time: ${DB_POOL_KEEPALIVE_TIME_MS:0}
jpa:
hibernate:

View File

@@ -1,272 +0,0 @@
package kr.co.vividnext.sodalive.content
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository
import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import java.util.Optional
class AudioContentServiceTest {
private lateinit var repository: AudioContentRepository
private lateinit var explorerQueryRepository: ExplorerQueryRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var hashTagRepository: HashTagRepository
private lateinit var orderRepository: OrderRepository
private lateinit var limitedEditionOrderRepository: LimitedEditionOrderRepository
private lateinit var themeQueryRepository: AudioContentThemeQueryRepository
private lateinit var playbackTrackingRepository: PlaybackTrackingRepository
private lateinit var commentRepository: AudioContentCommentRepository
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
private lateinit var pinContentRepository: PinContentRepository
private lateinit var translationService: PapagoTranslationService
private lateinit var contentTranslationRepository: ContentTranslationRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var audioContentCloudFront: AudioContentCloudFront
private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
private lateinit var service: AudioContentService
@BeforeEach
fun setUp() {
repository = Mockito.mock(AudioContentRepository::class.java)
explorerQueryRepository = Mockito.mock(ExplorerQueryRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
hashTagRepository = Mockito.mock(HashTagRepository::class.java)
orderRepository = Mockito.mock(OrderRepository::class.java)
limitedEditionOrderRepository = Mockito.mock(LimitedEditionOrderRepository::class.java)
themeQueryRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java)
playbackTrackingRepository = Mockito.mock(PlaybackTrackingRepository::class.java)
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
translationService = Mockito.mock(PapagoTranslationService::class.java)
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
service = AudioContentService(
repository = repository,
explorerQueryRepository = explorerQueryRepository,
blockMemberRepository = blockMemberRepository,
hashTagRepository = hashTagRepository,
orderRepository = orderRepository,
limitedEditionOrderRepository = limitedEditionOrderRepository,
themeQueryRepository = themeQueryRepository,
playbackTrackingRepository = playbackTrackingRepository,
commentRepository = commentRepository,
audioContentLikeRepository = audioContentLikeRepository,
pinContentRepository = pinContentRepository,
translationService = translationService,
contentTranslationRepository = contentTranslationRepository,
s3Uploader = s3Uploader,
objectMapper = ObjectMapper(),
audioContentCloudFront = audioContentCloudFront,
applicationEventPublisher = applicationEventPublisher,
messageSource = SodaMessageSource(),
langContext = LangContext(),
contentThemeTranslationRepository = contentThemeTranslationRepository,
audioContentBucket = "audio-bucket",
coverImageBucket = "cover-bucket",
coverImageHost = "https://cdn.test"
)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 콘텐츠 상세를 조회하면 인증 필요 예외를 반환한다")
fun shouldThrowAdultVerificationRequiredWhenAdultContentRequestedByNonAdultPolicy() {
val viewer = createMember(id = 1002L, nickname = "viewer")
val creator = createMember(id = 2002L, nickname = "creator")
val adultContent = createAudioContent(creator = creator, isAdult = true)
Mockito.`when`(repository.findById(adultContent.id!!)).thenReturn(Optional.of(adultContent))
val exception = assertThrows(SodaException::class.java) {
service.getDetail(
id = adultContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
}
assertEquals("common.error.adult_verification_required", exception.messageKey)
Mockito.verifyNoInteractions(explorerQueryRepository)
}
@Test
@DisplayName("차단 + 미구매 사용자 요청은 콘텐츠 상세에서 차단 예외를 반환한다")
fun shouldThrowBlockedAccessWhenBlockedAndNotPurchased() {
val viewer = createMember(id = 1000L, nickname = "viewer")
val creator = createMember(id = 2000L, nickname = "creator")
val audioContent = createAudioContent(creator)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)
Mockito.`when`(
orderRepository.isExistOrderedAndOrderType(
memberId = viewer.id!!,
contentId = audioContent.id!!
)
).thenReturn(Pair(false, null))
Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!))
.thenReturn(true)
val exception = assertThrows(SodaException::class.java) {
service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
}
assertEquals("content.error.blocked_access", exception.messageKey)
}
@Test
@DisplayName("차단 + 구매 사용자 요청은 상세 조회를 허용하고 댓글/이전다음 조회를 생략한다")
fun shouldAllowDetailWhenBlockedAndPurchasedButSkipCommentAndNavigationQueries() {
val viewer = createMember(id = 1001L, nickname = "viewer")
val creator = createMember(id = 2001L, nickname = "creator")
val audioContent = createAudioContent(creator)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)
Mockito.`when`(
orderRepository.isExistOrderedAndOrderType(
memberId = viewer.id!!,
contentId = audioContent.id!!
)
).thenReturn(Pair(true, OrderType.KEEP))
Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!))
.thenReturn(true)
Mockito.`when`(explorerQueryRepository.getCreatorFollowing(creator.id!!, viewer.id!!)).thenReturn(null)
Mockito.`when`(
limitedEditionOrderRepository.getOrderSequence(
contentId = audioContent.id!!,
memberId = viewer.id!!
)
).thenReturn(null)
Mockito.`when`(
audioContentCloudFront.generateSignedURL(
resourcePath = audioContent.content!!,
expirationTime = 7_200_000L
)
).thenReturn("https://signed.test/audio")
Mockito.`when`(
repository.getCreatorOtherContentList(
cloudfrontHost = "https://cdn.test",
contentId = audioContent.id!!,
creatorId = creator.id!!,
isAdult = false
)
).thenReturn(emptyList())
Mockito.`when`(
repository.getSameThemeOtherContentList(
cloudfrontHost = "https://cdn.test",
contentId = audioContent.id!!,
themeId = audioContent.theme!!.id!!,
isAdult = false
)
).thenReturn(emptyList())
Mockito.`when`(audioContentLikeRepository.totalCountAudioContentLike(audioContent.id!!)).thenReturn(0)
Mockito.`when`(audioContentLikeRepository.findByMemberIdAndContentId(viewer.id!!, audioContent.id!!)).thenReturn(null)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = viewer.id!!,
active = true
)
).thenReturn(null)
Mockito.`when`(pinContentRepository.getPinContentList(memberId = viewer.id!!, active = true)).thenReturn(emptyList())
Mockito.`when`(
contentThemeTranslationRepository.findByContentThemeIdAndLocale(
contentThemeId = audioContent.theme!!.id!!,
locale = "ko"
)
).thenReturn(null)
val response = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertTrue(response.existOrdered)
assertTrue(response.commentList.isEmpty())
assertEquals(0, response.commentCount)
assertNull(response.previousContent)
assertNull(response.nextContent)
Mockito.verify(repository, Mockito.never()).findSeriesIdByContentId(audioContent.id!!, false)
Mockito.verifyNoInteractions(commentRepository)
}
private fun createMember(id: Long, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname
)
member.id = id
return member
}
private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent {
val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
theme.id = 300L
val audioContent = AudioContent(
title = "테스트 제목",
detail = "테스트 상세 설명",
languageCode = null,
price = 100,
purchaseOption = PurchaseOption.BOTH,
isGeneratePreview = true,
isOnlyRental = false,
isAdult = isAdult,
isPointAvailable = true,
isCommentAvailable = true,
isFullDetailVisible = true
)
audioContent.id = 500L
audioContent.member = creator
audioContent.theme = theme
audioContent.content = "output/500/content.mp3"
audioContent.coverImage = "audio_content_cover/500/cover.jpg"
audioContent.duration = "00:10:00"
audioContent.isActive = true
return audioContent
}
}

View File

@@ -149,54 +149,6 @@ class ChannelDonationServiceTest {
assertEquals(startDateTimeKst.plusMonths(1), endDateTimeKst)
}
@Test
@DisplayName("탈퇴 회원 닉네임 접두사는 목록 응답에서 제거된다")
fun shouldRemoveDeletedPrefixFromNicknameInDonationList() {
// given: 탈퇴 접두사가 포함된 후원자 닉네임 데이터를 준비한다.
val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator")
val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer")
val withdrawnMember = createMember(id = 3L, role = MemberRole.USER, nickname = "deleted_withdrawn")
val message = ChannelDonationMessage(can = 3, isSecret = false, additionalMessage = null)
message.id = 1002L
message.member = withdrawnMember
message.creator = creator
message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
// given: 목록 조회 repository 응답을 설정한다.
Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator)
Mockito.`when`(
channelDonationMessageRepository.getChannelDonationMessageTotalCount(
Mockito.eq(creator.id!!),
Mockito.eq(viewer.id!!),
Mockito.eq(false),
anyLocalDateTime(),
anyLocalDateTime()
)
).thenReturn(1)
Mockito.`when`(
channelDonationMessageRepository.getChannelDonationMessageList(
Mockito.eq(creator.id!!),
Mockito.eq(viewer.id!!),
Mockito.eq(false),
Mockito.eq(0L),
Mockito.eq(5L),
anyLocalDateTime(),
anyLocalDateTime()
)
).thenReturn(listOf(message))
// when: 채널 후원 목록 조회를 실행한다.
val result = service.getChannelDonationList(
creatorId = creator.id!!,
member = viewer,
offset = 0,
limit = 5
)
// then: 응답 닉네임에서 deleted_ 접두사가 제거되어야 한다.
assertEquals("withdrawn", result.items[0].nickname)
}
@Test
@DisplayName("후원 캔 수는 천 단위 콤마가 포함된 메시지로 포맷된다")
fun shouldFormatCanWithCommaInDonationMessage() {

View File

@@ -5,12 +5,9 @@ import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
@@ -21,24 +18,17 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import java.time.LocalDateTime
import java.util.Optional
class CreatorCommunityServiceTest {
private lateinit var repository: CreatorCommunityRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var likeRepository: CreatorCommunityLikeRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository
private lateinit var applicationEventPublisher: ApplicationEventPublisher
@@ -48,7 +38,6 @@ class CreatorCommunityServiceTest {
fun setup() {
repository = Mockito.mock(CreatorCommunityRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java)
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
useCanRepository = Mockito.mock(UseCanRepository::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
@@ -57,7 +46,7 @@ class CreatorCommunityServiceTest {
canPaymentService = Mockito.mock(CanPaymentService::class.java),
repository = repository,
blockMemberRepository = blockMemberRepository,
likeRepository = likeRepository,
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
commentRepository = commentRepository,
useCanRepository = useCanRepository,
s3Uploader = Mockito.mock(S3Uploader::class.java),
@@ -72,29 +61,6 @@ class CreatorCommunityServiceTest {
)
}
@Test
@DisplayName("좋아요 처리 시 전달된 성인 여부를 기준으로 게시글을 조회한다")
fun shouldUseProvidedIsAdultForCommunityLikeAdultFilter() {
val member = createMember(id = 88L, role = MemberRole.USER, nickname = "viewer")
val post = CreatorCommunity(content = "adult-post", price = 0, isCommentAvailable = true, isAdult = true)
post.id = 801L
post.member = createMember(id = 99L, role = MemberRole.CREATOR, nickname = "creator")
Mockito.`when`(likeRepository.findByPostIdAndMemberId(postId = 801L, memberId = 88L)).thenReturn(null)
Mockito.`when`(repository.findByIdAndActive(801L, true)).thenReturn(post)
Mockito.`when`(likeRepository.save(Mockito.any(CreatorCommunityLike::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val response = service.communityPostLike(
request = PostCommunityPostLikeRequest(postId = 801L),
member = member,
isAdult = true
)
assertTrue(response.like)
Mockito.verify(repository).findByIdAndActive(801L, true)
}
@Test
@DisplayName("크리에이터가 아닌 사용자가 댓글 작성 시 크리에이터 대상 커뮤니티 딥링크 알림 이벤트를 발행한다")
fun shouldPublishCreatorCommunityCommentNotificationEventWhenCommenterIsNotCreator() {
@@ -104,7 +70,7 @@ class CreatorCommunityServiceTest {
post.id = 301L
post.member = creator
Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(post)
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, commenter.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
@@ -114,8 +80,7 @@ class CreatorCommunityServiceTest {
comment = "새 댓글",
postId = post.id!!,
parentId = null,
isSecret = false,
isAdult = true
isSecret = false
)
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
@@ -140,7 +105,7 @@ class CreatorCommunityServiceTest {
post.id = 401L
post.member = creator
Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(post)
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, creator.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
@@ -150,142 +115,12 @@ class CreatorCommunityServiceTest {
comment = "내가 단 댓글",
postId = post.id!!,
parentId = null,
isSecret = false,
isAdult = true
isSecret = false
)
Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(Mockito.any())
}
@Test
@DisplayName("비성인 정책 사용자가 성인 커뮤니티 게시글에 댓글 작성 시 예외가 발생한다")
fun shouldThrowExceptionWhenCommentingAdultPostWithNonAdultPolicy() {
val commenter = createMember(id = 23L, role = MemberRole.USER, nickname = "viewer")
Mockito.`when`(repository.findByIdAndActive(901L, false)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.createCommunityPostComment(
member = commenter,
comment = "접근 불가 댓글",
postId = 901L,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 게시글 댓글 목록을 조회하면 예외가 발생한다")
fun shouldThrowExceptionWhenFetchingAdultPostCommentsWithNonAdultPolicy() {
Mockito.`when`(repository.findByIdAndActive(902L, false)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityPostCommentList(
postId = 902L,
memberId = 23L,
timezone = "Asia/Seoul",
offset = 0,
limit = 10,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 게시글 댓글의 답글 목록을 조회하면 예외가 발생한다")
fun shouldThrowExceptionWhenFetchingReplyOfAdultPostWithNonAdultPolicy() {
val creator = createMember(id = 31L, role = MemberRole.CREATOR, nickname = "creator")
val adultPost = CreatorCommunity(content = "adult-post", price = 0, isCommentAvailable = true, isAdult = true)
adultPost.id = 903L
adultPost.member = creator
val parentComment = CreatorCommunityComment(comment = "parent", isSecret = false)
parentComment.id = 1001L
parentComment.creatorCommunity = adultPost
parentComment.member = creator
Mockito.`when`(commentRepository.findById(1001L)).thenReturn(Optional.of(parentComment))
val exception = assertThrows(SodaException::class.java) {
service.getCommentReplyList(
commentId = 1001L,
memberId = 32L,
timezone = "Asia/Seoul",
offset = 0,
limit = 10,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("고정 게시물이 이미 3개면 추가 고정 시 예외가 발생한다")
fun shouldThrowExceptionWhenPinCountExceedsLimit() {
val creator = createMember(id = 55L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 501L
post.member = creator
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
val exception = assertThrows(SodaException::class.java) {
service.updateCommunityPostFixed(
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = true),
member = creator
)
}
assertEquals("creator.community.max_fixed_post_count", exception.messageKey)
}
@Test
@DisplayName("고정 요청 시 3개 미만이면 게시물이 고정된다")
fun shouldPinPostWhenFixedPostCountIsUnderLimit() {
val creator = createMember(id = 66L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 601L
post.member = creator
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(2L)
service.updateCommunityPostFixed(
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = true),
member = creator
)
assertTrue(post.isFixed)
assertNotNull(post.fixedAt)
}
@Test
@DisplayName("고정 해제 요청 시 게시물이 고정 해제된다")
fun shouldUnfixPostWhenIsFixedIsFalse() {
val creator = createMember(id = 77L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 701L
post.member = creator
post.isFixed = true
post.fixedAt = LocalDateTime.now()
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
service.updateCommunityPostFixed(
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = false),
member = creator
)
assertFalse(post.isFixed)
assertNull(post.fixedAt)
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",

View File

@@ -3,8 +3,6 @@ package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.auth.Auth
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.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -13,22 +11,13 @@ import org.mockito.Mockito
class LiveRecommendServiceTest {
private lateinit var repository: LiveRecommendRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var liveRecommendCacheService: LiveRecommendCacheService
private lateinit var service: LiveRecommendService
@BeforeEach
fun setup() {
repository = Mockito.mock(LiveRecommendRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
liveRecommendCacheService = Mockito.mock(LiveRecommendCacheService::class.java)
service = LiveRecommendService(
repository = repository,
blockMemberRepository = blockMemberRepository,
memberContentPreferenceService = memberContentPreferenceService,
liveRecommendCacheService = liveRecommendCacheService
)
service = LiveRecommendService(repository, blockMemberRepository)
}
@Test
@@ -50,35 +39,24 @@ class LiveRecommendServiceTest {
auth.member = member
val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend.png", creatorId = 77L))
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = kr.co.vividnext.sodalive.content.ContentType.ALL,
isAdult = true
)
)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected)
Mockito.`when`(repository.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected)
val result = service.getRecommendLive(member)
assertEquals(expected, result)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = member.id, isAdult = true)
Mockito.verifyNoInteractions(repository)
Mockito.verify(repository).getRecommendLive(memberId = member.id, isAdult = true)
Mockito.verifyNoInteractions(blockMemberRepository)
}
@Test
fun shouldDelegateToRepositoryAsGuestWhenMemberIsNull() {
val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend-guest.png", creatorId = 88L))
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected)
Mockito.`when`(repository.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected)
val result = service.getRecommendLive(null)
assertEquals(expected, result)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = null, isAdult = false)
Mockito.verifyNoInteractions(repository)
Mockito.verify(repository).getRecommendLive(memberId = null, isAdult = false)
Mockito.verifyNoInteractions(blockMemberRepository)
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
}

View File

@@ -1,265 +0,0 @@
package kr.co.vividnext.sodalive.live.room
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.agora.RtcTokenBuilder
import kr.co.vividnext.sodalive.agora.RtmTokenBuilder
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
import kr.co.vividnext.sodalive.fcm.PushTokenRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository
import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService
import kr.co.vividnext.sodalive.live.room.menu.LiveRoomMenuService
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService
import kr.co.vividnext.sodalive.live.roulette.NewRouletteRepository
import kr.co.vividnext.sodalive.live.signature.SignatureCanRepository
import kr.co.vividnext.sodalive.live.tag.LiveTagRepository
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
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.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
class LiveRoomServiceAdultVisibilityPolicyTest {
private lateinit var menuService: LiveRoomMenuService
private lateinit var messageSource: SodaMessageSource
private lateinit var langContext: LangContext
private lateinit var repository: LiveRoomRepository
private lateinit var rouletteRepository: NewRouletteRepository
private lateinit var roomInfoRepository: LiveRoomInfoRedisRepository
private lateinit var roomCancelRepository: LiveRoomCancelRepository
private lateinit var kickOutService: LiveRoomKickOutService
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var signatureCanRepository: SignatureCanRepository
private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var useCanCalculateRepository: UseCanCalculateRepository
private lateinit var reservationRepository: LiveReservationRepository
private lateinit var explorerQueryRepository: ExplorerQueryRepository
private lateinit var creatorDonationRankingService: CreatorDonationRankingService
private lateinit var roomVisitService: LiveRoomVisitService
private lateinit var canPaymentService: CanPaymentService
private lateinit var chargeRepository: ChargeRepository
private lateinit var pushTokenRepository: PushTokenRepository
private lateinit var memberRepository: MemberRepository
private lateinit var tagRepository: LiveTagRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var canRepository: CanRepository
private lateinit var objectMapper: ObjectMapper
private lateinit var s3Uploader: S3Uploader
private lateinit var rtcTokenBuilder: RtcTokenBuilder
private lateinit var rtmTokenBuilder: RtmTokenBuilder
private lateinit var service: LiveRoomService
@BeforeEach
fun setup() {
menuService = mock()
messageSource = mock()
langContext = LangContext()
repository = mock()
rouletteRepository = mock()
roomInfoRepository = mock()
roomCancelRepository = mock()
kickOutService = mock()
blockMemberRepository = mock()
signatureCanRepository = mock()
applicationEventPublisher = mock()
useCanCalculateRepository = mock()
reservationRepository = mock()
explorerQueryRepository = mock()
creatorDonationRankingService = mock()
roomVisitService = mock()
canPaymentService = mock()
chargeRepository = mock()
pushTokenRepository = mock()
memberRepository = mock()
tagRepository = mock()
memberContentPreferenceService = mock()
canRepository = mock()
objectMapper = mock()
s3Uploader = mock()
rtcTokenBuilder = mock()
rtmTokenBuilder = mock()
service = LiveRoomService(
menuService = menuService,
messageSource = messageSource,
langContext = langContext,
repository = repository,
rouletteRepository = rouletteRepository,
roomInfoRepository = roomInfoRepository,
roomCancelRepository = roomCancelRepository,
kickOutService = kickOutService,
blockMemberRepository = blockMemberRepository,
signatureCanRepository = signatureCanRepository,
applicationEventPublisher = applicationEventPublisher,
useCanCalculateRepository = useCanCalculateRepository,
reservationRepository = reservationRepository,
explorerQueryRepository = explorerQueryRepository,
creatorDonationRankingService = creatorDonationRankingService,
roomVisitService = roomVisitService,
canPaymentService = canPaymentService,
chargeRepository = chargeRepository,
pushTokenRepository = pushTokenRepository,
memberRepository = memberRepository,
tagRepository = tagRepository,
memberContentPreferenceService = memberContentPreferenceService,
canRepository = canRepository,
objectMapper = objectMapper,
s3Uploader = s3Uploader,
rtcTokenBuilder = rtcTokenBuilder,
rtmTokenBuilder = rtmTokenBuilder,
agoraAppId = "test-agora-app-id",
agoraAppCertificate = "test-agora-app-certificate",
coverImageBucket = "test-cover-image-bucket",
cloudFrontHost = "https://test-cloudfront-host"
)
Mockito.`when`(pushTokenRepository.findByMemberIds(listOf())).thenReturn(listOf())
}
@Test
@DisplayName("NOW 목록 조회는 사용자 성인 설정이 false여도 성인 방 필터를 적용하지 않는다")
fun shouldBypassAdultPreferenceForNowRooms() {
val member = createMember(id = 100L)
Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(createPreference(isAdult = false))
Mockito.`when`(
repository.getLiveRoomListNow(
offset = 0L,
limit = 20L,
timezone = "Asia/Seoul",
memberId = 100L,
isCreator = false,
isAdult = true,
effectiveGender = Gender.NONE
)
).thenReturn(emptyList())
val response = service.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
pageable = PageRequest.of(0, 20),
member = member,
timezone = "Asia/Seoul"
)
assertEquals(0, response.size)
Mockito.verify(repository).getLiveRoomListNow(
offset = 0L,
limit = 20L,
timezone = "Asia/Seoul",
memberId = 100L,
isCreator = false,
isAdult = true,
effectiveGender = Gender.NONE
)
}
@Test
@DisplayName("NOW 목록 조회는 비로그인 사용자도 성인 방 필터를 우회한다")
fun shouldBypassAdultPreferenceForAnonymousNowRooms() {
Mockito.`when`(
repository.getLiveRoomListNow(
offset = 0L,
limit = 20L,
timezone = "Asia/Seoul",
memberId = null,
isCreator = false,
isAdult = true,
effectiveGender = null
)
).thenReturn(emptyList())
val response = service.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
pageable = PageRequest.of(0, 20),
member = null,
timezone = "Asia/Seoul"
)
assertEquals(0, response.size)
Mockito.verify(repository).getLiveRoomListNow(
offset = 0L,
limit = 20L,
timezone = "Asia/Seoul",
memberId = null,
isCreator = false,
isAdult = true,
effectiveGender = null
)
}
@Test
@DisplayName("예약 목록 조회는 기존처럼 사용자 성인 설정값을 유지한다")
fun shouldKeepAdultPreferenceForReservationRooms() {
val member = createMember(id = 200L)
Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(createPreference(isAdult = false))
Mockito.`when`(
repository.getLiveRoomListReservationWithoutDate(
timezone = "Asia/Seoul",
memberId = 200L,
isCreator = false,
isAdult = false,
effectiveGender = Gender.NONE
)
).thenReturn(emptyList())
val response = service.getRoomList(
dateString = null,
status = LiveRoomStatus.RESERVATION,
pageable = PageRequest.of(0, 20),
member = member,
timezone = "Asia/Seoul"
)
assertEquals(0, response.size)
Mockito.verify(repository).getLiveRoomListReservationWithoutDate(
timezone = "Asia/Seoul",
memberId = 200L,
isCreator = false,
isAdult = false,
effectiveGender = Gender.NONE
)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private fun createPreference(isAdult: Boolean): ViewerContentPreference {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = isAdult,
contentType = ContentType.ALL,
isAdult = isAdult
)
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -1,87 +0,0 @@
package kr.co.vividnext.sodalive.live.tag
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LiveTagServiceTest {
private lateinit var repository: LiveTagRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var service: LiveTagService
@BeforeEach
fun setup() {
repository = mock()
memberContentPreferenceService = mock()
service = LiveTagService(
repository = repository,
memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(),
s3Uploader = mock<S3Uploader>(),
coverImageBucket = "bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("일반 사용자는 저장된 성인 설정값으로 라이브 태그 필터를 적용한다")
fun shouldApplyStoredPreferenceForNonAdminMember() {
val member = createMember(id = 1L, role = MemberRole.USER)
val expected = listOf(GetLiveTagResponse(1L, "일반", "https://cdn.test/live1.png", false))
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
)
Mockito.`when`(repository.getTags(isAdult = false, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(member)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = false, cloudFrontHost = "https://cdn.test")
}
@Test
@DisplayName("관리자는 저장 설정과 무관하게 성인 태그를 포함해 조회한다")
fun shouldAllowAdultTagsForAdmin() {
val admin = createMember(id = 2L, role = MemberRole.ADMIN)
val expected = listOf(GetLiveTagResponse(2L, "성인", "https://cdn.test/live2.png", true))
Mockito.`when`(repository.getTags(isAdult = true, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(admin)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = true, cloudFrontHost = "https://cdn.test")
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
private fun createMember(id: Long, role: MemberRole): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
role = role
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -1,130 +0,0 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.UpdateMemberContentPreferenceRequest
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class MemberControllerTest {
private lateinit var memberService: MemberService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var socialAuthServiceResolver: SocialAuthServiceResolver
private lateinit var trackingService: AdTrackingService
private lateinit var userActionService: UserActionService
private lateinit var controller: MemberController
@BeforeEach
fun setup() {
memberService = mock()
memberContentPreferenceService = mock()
socialAuthServiceResolver = mock()
trackingService = mock()
userActionService = mock()
controller = MemberController(
service = memberService,
memberContentPreferenceService = memberContentPreferenceService,
socialAuthServiceResolver = socialAuthServiceResolver,
trackingService = trackingService,
userActionService = userActionService,
messageSource = SodaMessageSource(),
langContext = LangContext()
)
}
@Test
@DisplayName("PATCH /member/content-preference는 저장된 최신 설정을 응답한다")
fun shouldReturnUpdatedPreferenceWhenRequestIsValid() {
val member = createMember(1L)
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
val viewerPreference = ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.FEMALE,
isAdult = true
)
Mockito.`when`(
memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
).thenReturn(viewerPreference)
val response = controller.updateContentPreference(request, member)
assertTrue(response.success)
assertEquals(true, response.data?.isAdultContentVisible)
assertEquals(ContentType.FEMALE, response.data?.contentType)
}
@Test
@DisplayName("비로그인 사용자는 PATCH /member/content-preference 호출 시 예외가 발생한다")
fun shouldThrowWhenMemberIsNullOnUpdateContentPreference() {
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = true,
contentType = ContentType.ALL
)
val exception = assertThrows(SodaException::class.java) {
controller.updateContentPreference(request, null)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
@Test
@DisplayName("두 필드 모두 누락된 PATCH 요청은 서비스 예외를 그대로 전파한다")
fun shouldPropagateServiceExceptionWhenBothFieldsAreMissing() {
val member = createMember(2L)
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = null,
contentType = null
)
Mockito.`when`(
memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = null,
contentType = null
)
).thenThrow(SodaException(messageKey = "common.error.invalid_request"))
val exception = assertThrows(SodaException::class.java) {
controller.updateContentPreference(request, member)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -4,12 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -43,6 +41,7 @@ class MemberServiceCacheEvictionTest {
stipulationAgreeRepository = mock(),
creatorFollowingRepository = mock(),
blockMemberRepository = blockMemberRepository,
authRepository = authRepository,
signOutRepository = mock(),
nicknameChangeLogRepository = mock(),
memberTagRepository = mock(),
@@ -63,7 +62,6 @@ class MemberServiceCacheEvictionTest {
messageSource = SodaMessageSource(),
langContext = LangContext(),
countryContext = CountryContext(),
memberContentPreferenceService = mock<MemberContentPreferenceService>(),
objectMapper = ObjectMapper(),
cacheManager = cacheManager,
s3Bucket = "test-bucket",
@@ -89,61 +87,8 @@ class MemberServiceCacheEvictionTest {
service.memberBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId)
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
Mockito.verifyNoInteractions(authRepository)
}
@Test
fun shouldBlockOnlyRequestedMemberEvenWhenTargetHasAuth() {
// 차단 대상에게 본인인증 정보가 연결된 상황을 준비한다.
val memberId = 500L
val blockedMemberId = 600L
val linkedMemberId = 601L
val member = createMember(id = memberId, nickname = "requester2")
val blockedMember = createMember(id = blockedMemberId, nickname = "target2")
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-ci",
di = "di-value",
gender = 1
)
auth.member = blockedMember
// 요청자와 요청 대상만 조회 가능하도록 목 동작을 설정한다.
Mockito.`when`(memberRepository.findById(memberId)).thenReturn(Optional.of(member))
Mockito.`when`(memberRepository.findById(blockedMemberId)).thenReturn(Optional.of(blockedMember))
Mockito.`when`(
blockMemberRepository.getBlockAccount(
blockedMemberId = blockedMemberId,
memberId = memberId
)
).thenReturn(null)
Mockito.`when`(
authRepository.getMemberIdsByNameAndBirthAndDiAndGender(
name = auth.name,
birth = auth.birth,
di = auth.di,
gender = auth.gender
)
).thenReturn(listOf(blockedMemberId, linkedMemberId))
// 차단 API를 실행한다.
service.memberBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId)
// 요청한 blockMemberId 한 건만 차단 처리 및 캐시 무효화되는지 검증한다.
Mockito.verify(blockMemberRepository).getBlockAccount(
blockedMemberId = blockedMemberId,
memberId = memberId
)
Mockito.verify(blockMemberRepository, Mockito.never()).getBlockAccount(
blockedMemberId = linkedMemberId,
memberId = memberId
)
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
verifyRecommendLiveCacheNotEvicted(linkedMemberId)
Mockito.verify(cache).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
Mockito.verifyNoInteractions(authRepository)
}
@@ -163,20 +108,8 @@ class MemberServiceCacheEvictionTest {
service.memberUnBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId)
assertEquals(false, blockMember.isActive)
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
}
private fun verifyRecommendLiveCacheEvicted(memberId: Long) {
Mockito.verify(cache).evict("getRecommendLive:$memberId:false")
Mockito.verify(cache).evict("getRecommendLive:$memberId:true")
Mockito.verify(cache).evict("getRecommendLive:$memberId")
}
private fun verifyRecommendLiveCacheNotEvicted(memberId: Long) {
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId:false")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId:true")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
}
private fun createMember(id: Long, nickname: String): Member {

View File

@@ -1,165 +0,0 @@
package kr.co.vividnext.sodalive.member
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.ContentType
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.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo
import kr.co.vividnext.sodalive.member.stipulation.Stipulation
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository
import kr.co.vividnext.sodalive.member.stipulation.StipulationIds
import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.cache.CacheManager
import java.time.LocalDateTime
import java.util.Optional
class MemberServiceContentPreferenceTest {
private lateinit var repository: MemberRepository
private lateinit var stipulationRepository: StipulationRepository
private lateinit var stipulationAgreeRepository: StipulationAgreeRepository
private lateinit var nicknameGenerateService: kr.co.vividnext.sodalive.member.nickname.NicknameGenerateService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var chargeRepository: kr.co.vividnext.sodalive.can.charge.ChargeRepository
private lateinit var memberPointRepository: kr.co.vividnext.sodalive.point.MemberPointRepository
private lateinit var pushTokenService: kr.co.vividnext.sodalive.fcm.PushTokenService
private lateinit var service: MemberService
@BeforeEach
fun setup() {
repository = mock()
stipulationRepository = mock()
stipulationAgreeRepository = mock()
nicknameGenerateService = mock()
memberContentPreferenceService = mock()
chargeRepository = mock()
memberPointRepository = mock()
pushTokenService = mock()
service = MemberService(
repository = repository,
tokenRepository = mock(),
stipulationRepository = stipulationRepository,
stipulationAgreeRepository = stipulationAgreeRepository,
creatorFollowingRepository = mock(),
blockMemberRepository = mock(),
signOutRepository = mock(),
nicknameChangeLogRepository = mock(),
memberTagRepository = mock(),
liveReservationRepository = mock(),
chargeRepository = chargeRepository,
memberPointRepository = memberPointRepository,
orderService = mock(),
emailService = mock(),
pushTokenService = pushTokenService,
canPaymentService = mock(),
nicknameGenerateService = nicknameGenerateService,
memberNotificationService = mock(),
s3Uploader = mock(),
validator = mock(),
tokenProvider = mock(),
passwordEncoder = mock(),
authenticationManagerBuilder = mock(),
messageSource = SodaMessageSource(),
langContext = LangContext(),
countryContext = CountryContext(),
memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(),
cacheManager = mock<CacheManager>(),
s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("getMemberInfo는 저장된 콘텐츠 설정 필드를 그대로 반환한다")
fun shouldReturnStoredPreferenceFieldsInMemberInfo() {
val member = createMember(1L)
member.createdAt = LocalDateTime.of(2026, 1, 1, 0, 0)
val preference = ViewerContentPreference(
countryCode = "JP",
isAdultContentVisible = true,
contentType = ContentType.MALE,
isAdult = true
)
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(preference)
Mockito.`when`(chargeRepository.getChargeCount(1L)).thenReturn(3)
Mockito.`when`(
memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
memberId = Mockito.eq(1L),
expiresAt = anyLocalDateTime()
)
).thenReturn(emptyList())
val response = service.getMemberInfo(member, "web")
assertEquals("JP", response.countryCode)
assertEquals(true, response.isAdultContentVisible)
assertEquals(ContentType.MALE, response.contentType)
}
@Test
@DisplayName("Google 소셜 회원 신규 생성 시 기본 콘텐츠 설정을 선저장한다")
fun shouldInitializePreferenceWhenGoogleMemberIsRegistered() {
var savedMember: Member? = null
val terms = Stipulation(title = "terms", description = "desc")
terms.id = StipulationIds.TERMS_OF_SERVICE_ID
val privacy = Stipulation(title = "privacy", description = "desc")
privacy.id = StipulationIds.PRIVACY_POLICY_ID
Mockito.`when`(repository.findByGoogleId("sub-1")).thenReturn(null)
Mockito.`when`(repository.findByEmail("google@test.com")).thenReturn(null)
Mockito.`when`(stipulationRepository.findById(StipulationIds.TERMS_OF_SERVICE_ID)).thenReturn(Optional.of(terms))
Mockito.`when`(stipulationRepository.findById(StipulationIds.PRIVACY_POLICY_ID)).thenReturn(Optional.of(privacy))
Mockito.`when`(nicknameGenerateService.generateUniqueNickname(anyLang())).thenReturn("newbie")
Mockito.`when`(repository.save(Mockito.any(Member::class.java))).thenAnswer { invocation ->
val saved = invocation.getArgument<Member>(0)
saved.id = 10L
savedMember = saved
saved
}
Mockito.`when`(stipulationAgreeRepository.save(Mockito.any())).thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.findOrRegister(
googleUserInfo = GoogleUserInfo(sub = "sub-1", email = "google@test.com", name = "google-user"),
container = "web",
marketingPid = null,
pushToken = null
)
assertTrue(result.isNew)
Mockito.verify(memberContentPreferenceService).initializeDefaultPreference(savedMember!!)
assertEquals(10L, savedMember!!.id)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
private fun anyLocalDateTime(): LocalDateTime =
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
private fun anyLang(): Lang =
Mockito.any(Lang::class.java) ?: Lang.KO
}

View File

@@ -1,109 +0,0 @@
package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class AuthControllerTest {
private lateinit var authService: AuthService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var userActionService: UserActionService
private lateinit var controller: AuthController
@BeforeEach
fun setup() {
authService = mock()
memberContentPreferenceService = mock()
userActionService = mock()
controller = AuthController(
service = authService,
memberContentPreferenceService = memberContentPreferenceService,
userActionService = userActionService
)
}
@Test
@DisplayName("authVerify 성공 시 성인노출 true 저장을 호출한다")
fun shouldSaveAdultPreferenceWhenAuthVerifySucceeds() {
val member = createMember(id = 10L)
val request = AuthVerifyRequest(receiptId = "receipt-1", version = "v1")
val certificate = AuthVerifyCertificate(
name = "홍길동",
birth = "19900101",
unique = "unique-ci",
di = "di-1",
gender = 1
)
Mockito.`when`(authService.certificate(request, memberId = 10L)).thenReturn(certificate)
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(false)
Mockito.`when`(authService.authenticate(certificate, 10L)).thenReturn(AuthResponse(gender = 1))
controller.authVerify(request, member)
Mockito.verify(memberContentPreferenceService).markAdultVisibleAfterAuthVerify(10L)
Mockito.verify(userActionService).recordAction(
memberId = 10L,
isAuth = true,
actionType = ActionType.USER_AUTHENTICATION
)
}
@Test
@DisplayName("차단 정책으로 authVerify가 실패하면 저장을 호출하지 않는다")
fun shouldNotSaveAdultPreferenceWhenAuthIsBlocked() {
val member = createMember(id = 20L)
val request = AuthVerifyRequest(receiptId = "receipt-2", version = null)
val certificate = AuthVerifyCertificate(
name = "홍길동",
birth = "19900101",
unique = "unique-ci",
di = "di-2",
gender = 1
)
Mockito.`when`(authService.certificate(request, memberId = 20L)).thenReturn(certificate)
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(true)
assertThrows(SodaException::class.java) {
controller.authVerify(request, member)
}
Mockito.verify(authService).signOut(20L)
Mockito.verify(memberContentPreferenceService, Mockito.never()).markAdultVisibleAfterAuthVerify(Mockito.anyLong())
}
@Test
@DisplayName("비로그인 사용자는 authVerify 요청 시 예외를 반환한다")
fun shouldThrowWhenMemberIsNull() {
val request = AuthVerifyRequest(receiptId = "receipt-3", version = null)
assertThrows(SodaException::class.java) {
controller.authVerify(request, null)
}
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -1,244 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.cache.concurrent.ConcurrentMapCacheManager
import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class)
class MemberContentPreferenceIntegrationTest @Autowired constructor(
private val memberRepository: MemberRepository,
private val authRepository: AuthRepository,
private val preferenceRepository: MemberContentPreferenceRepository,
private val entityManager: EntityManager
) {
companion object {
private val FORCED_MEMBER_IDS = setOf(2L, 16L, 17L, 29721L, 32050L, 37543L, 40850L)
}
private lateinit var service: MemberContentPreferenceService
private lateinit var countryContext: CountryContext
@BeforeEach
fun setup() {
countryContext = CountryContext()
service = MemberContentPreferenceService(
repository = preferenceRepository,
memberRepository = memberRepository,
countryContext = countryContext,
cacheManager = ConcurrentMapCacheManager("cache_ttl_3_hours")
)
}
@Test
@DisplayName("미인증 사용자는 row 미존재 시 legacy 파라미터와 무관하게 false/ALL로 생성한다")
fun shouldCreateDefaultPreferenceForUnauthenticatedMemberRegardlessOfLegacyParams() {
val member = saveNonForcedMember("legacy-user")
countryContext.setCountryCode("US")
assertEquals(null, preferenceRepository.findByMemberId(member.id!!))
val resolved = service.resolveForQuery(member = member)
val stored = service.getStoredPreference(member)
assertNotNull(preferenceRepository.findByMemberId(member.id!!))
assertFalse(resolved.isAdultContentVisible)
assertEquals(ContentType.ALL, resolved.contentType)
assertEquals("US", resolved.countryCode)
assertFalse(stored.isAdultContentVisible)
assertEquals(ContentType.ALL, stored.contentType)
assertFalse(stored.isAdult)
}
@Test
@DisplayName("인증 사용자는 row 미존재 + legacy 파라미터 미전달 시 true/ALL로 생성된다")
fun shouldCreateTrueAndAllWhenAuthenticatedMemberHasNoLegacyParams() {
val member = saveNonForcedMember("auth-no-legacy")
countryContext.setCountryCode("US")
saveAuth(member)
val reloadedMember = memberRepository.findById(member.id!!).orElseThrow()
assertEquals(null, preferenceRepository.findByMemberId(member.id!!))
val resolved = service.resolveForQuery(member = reloadedMember)
val stored = service.getStoredPreference(reloadedMember)
assertNotNull(preferenceRepository.findByMemberId(member.id!!))
assertTrue(resolved.isAdultContentVisible)
assertEquals(ContentType.ALL, resolved.contentType)
assertEquals("US", resolved.countryCode)
assertTrue(stored.isAdultContentVisible)
assertEquals(ContentType.ALL, stored.contentType)
assertTrue(stored.isAdult)
}
@Test
@DisplayName("직접 설정 저장(updatePreference) 후 즉시 getStoredPreference에 반영된다")
fun shouldPersistAndReflectAfterDirectUpdate() {
val member = saveNonForcedMember("patch-user")
countryContext.setCountryCode("US")
val updated = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
val stored = service.getStoredPreference(member)
assertTrue(updated.isAdultContentVisible)
assertEquals(ContentType.FEMALE, updated.contentType)
assertTrue(stored.isAdultContentVisible)
assertEquals(ContentType.FEMALE, stored.contentType)
assertTrue(stored.isAdult)
}
@Test
@DisplayName("KR 헤더 누락 + 미인증 사용자는 요청값을 보내도 기본값을 유지한다")
fun shouldKeepDefaultValuesForKrUnauthenticatedWhenHeaderMissing() {
val member = saveNonForcedMember("kr-unauth-user")
countryContext.setCountryCode(null)
val resolved = service.resolveForQuery(member = member)
val stored = service.getStoredPreference(member)
assertEquals("KR", resolved.countryCode)
assertFalse(resolved.isAdultContentVisible)
assertEquals(ContentType.ALL, resolved.contentType)
assertFalse(resolved.isAdult)
assertFalse(stored.isAdultContentVisible)
assertEquals(ContentType.ALL, stored.contentType)
}
@Test
@DisplayName("KR + 인증 사용자는 legacy 파라미터와 무관하게 true/ALL로 생성되고 isAdult가 true로 계산된다")
fun shouldCreateTrueAndAllForKrAuthenticatedMemberRegardlessOfLegacyParams() {
val member = saveNonForcedMember("kr-auth-user")
countryContext.setCountryCode(null)
saveAuth(member)
val reloadedMember = memberRepository.findById(member.id!!).orElseThrow()
val resolved = service.resolveForQuery(member = reloadedMember)
assertEquals("KR", resolved.countryCode)
assertTrue(resolved.isAdultContentVisible)
assertEquals(ContentType.ALL, resolved.contentType)
assertTrue(resolved.isAdult)
}
@Test
@DisplayName("기존 row가 있으면 legacy 파라미터를 보내도 저장값을 그대로 사용한다")
fun shouldIgnoreLegacyParamsWhenPreferenceAlreadyExists() {
val member = saveNonForcedMember("existing-pref")
countryContext.setCountryCode("US")
saveAuth(member)
val reloadedMember = memberRepository.findById(member.id!!).orElseThrow()
service.updatePreference(
member = reloadedMember,
isAdultContentVisible = false,
contentType = ContentType.FEMALE
)
val resolved = service.resolveForQuery(member = reloadedMember)
val stored = service.getStoredPreference(reloadedMember)
assertFalse(resolved.isAdultContentVisible)
assertEquals(ContentType.FEMALE, resolved.contentType)
assertFalse(stored.isAdultContentVisible)
assertEquals(ContentType.FEMALE, stored.contentType)
}
@Test
@DisplayName("authVerify 성공 후 markAdultVisibleAfterAuthVerify를 호출하면 저장값이 true로 반영된다")
fun shouldMarkAdultVisibleAfterAuthVerify() {
val member = saveNonForcedMember("auth-verified-user")
countryContext.setCountryCode("US")
service.updatePreference(member, isAdultContentVisible = false, contentType = ContentType.ALL)
service.markAdultVisibleAfterAuthVerify(member.id!!)
val stored = service.getStoredPreference(member)
assertTrue(stored.isAdultContentVisible)
}
@Test
@DisplayName("강제 매핑 회원 ID는 접속 국가 헤더보다 우선한다")
fun shouldReturnForcedCountryCodeRegardlessOfHeader() {
countryContext.setCountryCode("US")
val jpMember = Member(email = "jp@test.com", password = "password", nickname = "jp-member")
.apply { id = 2L }
val jpMemberNew = Member(email = "jp-new@test.com", password = "password", nickname = "jp-member-new")
.apply { id = 37543L }
val krMember = Member(email = "kr@test.com", password = "password", nickname = "kr-member")
.apply { id = 16L }
assertEquals("JP", service.resolveCountryCode(jpMember))
assertEquals("JP", service.resolveCountryCode(jpMemberNew))
assertEquals("KR", service.resolveCountryCode(krMember))
}
@Test
@DisplayName("강제 매핑 대상이 아니면 국가 코드는 접속 국가 헤더를 기준으로 계산된다")
fun shouldResolveCountryCodeByConnectionCountryHeaderForNonForcedMember() {
val member = saveNonForcedMember("country-user")
countryContext.setCountryCode("US")
assertEquals("US", service.resolveCountryCode(member))
countryContext.setCountryCode(null)
assertEquals("KR", service.resolveCountryCode(member))
}
private fun saveMember(seed: String): Member {
return memberRepository.saveAndFlush(
Member(
email = "$seed@test.com",
password = "password",
nickname = seed
)
)
}
private fun saveNonForcedMember(seed: String): Member {
var index = 0
while (true) {
val candidate = saveMember("$seed-$index")
if (!FORCED_MEMBER_IDS.contains(candidate.id)) {
return candidate
}
index++
}
}
private fun saveAuth(member: Member) {
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-ci-${member.id}",
di = "di-${member.id}",
gender = 1
)
auth.member = member
authRepository.saveAndFlush(auth)
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -1,89 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
class MemberContentPreferencePolicyTest {
@AfterEach
fun cleanup() {
RequestContextHolder.resetRequestAttributes()
}
@Test
@DisplayName("요청 국가 헤더를 기준으로 국가 코드를 계산한다")
fun shouldResolveCountryCodeByRequestHeader() {
setRequestCountry(" us ")
val member = createMember(id = 200L, countryCode = "KR")
assertEquals("US", resolveCountryCodeByPolicy(member))
}
@Test
@DisplayName("강제 매핑 대상 회원 ID는 요청 국가 헤더보다 우선한다")
fun shouldPrioritizeForcedCountryMapping() {
setRequestCountry("US")
val forcedJpMember = createMember(id = 2L, countryCode = "KR")
val forcedJpMemberNew = createMember(id = 37543L, countryCode = "KR")
val forcedKrMember = createMember(id = 16L, countryCode = "US")
assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMember))
assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMemberNew))
assertEquals("KR", resolveCountryCodeByPolicy(forcedKrMember))
}
@Test
@DisplayName("요청 국가가 KR이면 인증 미완료 사용자는 성인 노출이 false다")
fun shouldHideAdultContentForKrWithoutAuth() {
setRequestCountry("KR")
val member = createMember(id = 1L, countryCode = "US")
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 국가가 KR이 아니면 멤버 countryCode와 무관하게 전달값을 사용한다")
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
setRequestCountry("US")
val member = createMember(id = 201L, countryCode = "KR")
assertTrue(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 컨텍스트가 없으면 KR fallback 정책을 사용한다")
fun shouldFallbackToKrWhenRequestContextIsMissing() {
RequestContextHolder.resetRequestAttributes()
val member = createMember(id = 202L, countryCode = "US")
assertEquals("KR", resolveCountryCodeByPolicy(member))
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
private fun setRequestCountry(countryCode: String?) {
val request = MockHttpServletRequest()
if (countryCode != null) {
request.addHeader("CloudFront-Viewer-Country", countryCode)
}
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
}
private fun createMember(id: Long, countryCode: String?): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
).apply {
this.id = id
this.countryCode = countryCode
}
}
}

View File

@@ -1,517 +0,0 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
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.MemberRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.cache.Cache
import org.springframework.cache.CacheManager
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
import java.util.Optional
class MemberContentPreferenceServiceTest {
private lateinit var repository: MemberContentPreferenceRepository
private lateinit var memberRepository: MemberRepository
private lateinit var countryContext: CountryContext
private lateinit var cacheManager: CacheManager
private lateinit var recommendLiveCache: Cache
private lateinit var service: MemberContentPreferenceService
@BeforeEach
fun setup() {
repository = mock()
memberRepository = mock()
countryContext = CountryContext()
cacheManager = mock()
recommendLiveCache = mock()
Mockito.`when`(cacheManager.getCache("cache_ttl_3_hours")).thenReturn(recommendLiveCache)
service = MemberContentPreferenceService(
repository = repository,
memberRepository = memberRepository,
countryContext = countryContext,
cacheManager = cacheManager
)
}
@Test
@DisplayName("회원 ID 강제 매핑(KR)이 헤더보다 우선 적용된다")
fun shouldResolveCountryCodeByForcedKrMappingFirst() {
val member = createMember(id = 16L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(16L)).thenReturn(preference)
val result = service.getStoredPreference(member)
assertEquals("KR", result.countryCode)
}
@Test
@DisplayName("회원 ID 강제 매핑(JP)이 헤더보다 우선 적용된다")
fun shouldResolveCountryCodeByForcedJapanMappingFirst() {
val member = createMember(id = 2L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2L)).thenReturn(preference)
val result = service.getStoredPreference(member)
assertEquals("JP", result.countryCode)
}
@Test
@DisplayName("강제 매핑 대상이 아니면 접속 국가 헤더를 사용하고 헤더가 없으면 KR로 fallback 한다")
fun shouldResolveCountryCodeWithHeaderAndFallback() {
val member = createMember(id = 100L)
val preference = createPreference(member)
Mockito.`when`(repository.findByMemberId(100L)).thenReturn(preference)
countryContext.setCountryCode("JP")
val fromHeader = service.getStoredPreference(member)
assertEquals("JP", fromHeader.countryCode)
countryContext.setCountryCode(null)
val fromFallback = service.getStoredPreference(member)
assertEquals("KR", fromFallback.countryCode)
}
@Test
@DisplayName("한국 + 본인인증 미완료는 전달값으로 저장 갱신하지 않는다")
fun shouldNotApplyRequestValuesForKoreaWithoutAuth() {
val member = createMember(id = 1700L)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(1700L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertFalse(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("해외 + 본인인증 미완료는 전달값을 그대로 저장한다")
fun shouldApplyRequestValuesForNonKoreaWithoutAuth() {
val member = createMember(id = 1000L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(1000L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("US", result.countryCode)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.FEMALE, result.contentType)
}
@Test
@DisplayName("한국 + 본인인증 완료는 전달값을 저장한다")
fun shouldApplyRequestValuesForKoreaWithAuth() {
val member = createMember(id = 1701L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(1701L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("KR", result.countryCode)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.FEMALE, result.contentType)
assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime))
assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime))
}
@Test
@DisplayName("필드별 변경 시 changedAt은 변경된 필드만 갱신된다")
fun shouldUpdateOnlyChangedFieldTimestamp() {
val member = createMember(id = 3000L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(3000L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime))
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("contentType만 변경하면 contentTypeChangedAt만 갱신된다")
fun shouldUpdateOnlyContentTypeChangedAtWhenContentTypeChanges() {
val member = createMember(id = 18L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(18L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = false,
contentType = ContentType.MALE
)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime))
}
@Test
@DisplayName("동일값 재저장 시 changedAt은 갱신되지 않는다")
fun shouldNotUpdateChangedAtWhenValuesAreSame() {
val member = createMember(id = 19L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = true,
contentType = ContentType.MALE,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(19L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("getStoredPreference 호출 시 row가 없으면 기본값 row를 생성한다")
fun shouldCreateDefaultPreferenceWhenRowIsMissing() {
val member = createMember(id = 20L)
countryContext.setCountryCode(null)
val storedPreference = createPreference(member)
Mockito.`when`(repository.findByMemberId(20L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(20L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(20L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenReturn(storedPreference)
val result = service.getStoredPreference(member)
assertEquals("KR", result.countryCode)
assertEquals(storedPreference.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
Mockito.verify(repository).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("row 미존재 + 인증 사용자는 legacy 파라미터와 무관하게 true/ALL로 초기 생성된다")
fun shouldSeedPreferenceToTrueAndAllWhenRowMissingAndAuthenticatedRegardlessOfLegacyParams() {
val member = createMember(id = 2100L, withAuth = true)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2100L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(2100L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(2100L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.resolveForQuery(member = member)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertTrue(result.isAdult)
}
@Test
@DisplayName("row 미존재 + 인증 사용자는 legacy 파라미터가 없으면 true/ALL로 초기 생성된다")
fun shouldSeedPreferenceToTrueAndAllWhenRowMissingAndAuthenticatedWithoutParams() {
val member = createMember(id = 2101L, withAuth = true)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2101L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(2101L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(2101L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.resolveForQuery(member = member)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertTrue(result.isAdult)
}
@Test
@DisplayName("row 미존재 + 미인증 사용자는 legacy 파라미터와 무관하게 false/ALL로 초기 생성된다")
fun shouldSeedPreferenceToFalseAndAllWhenRowMissingAndUnauthenticated() {
val member = createMember(id = 2102L)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2102L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(2102L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(2102L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.resolveForQuery(member = member)
assertFalse(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertFalse(result.isAdult)
}
@Test
@DisplayName("초기 row 생성 경쟁 시 잠금 이후 재조회한 row를 반환한다")
fun shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock() {
val member = createMember(id = 26L)
val existing = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(26L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(26L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(26L)).thenReturn(existing)
val result = service.getStoredPreference(member)
assertEquals(existing.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(existing.contentType, result.contentType)
Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("동시 insert 충돌 발생 시 저장된 row를 재조회해 반환한다")
fun shouldReturnStoredRowWhenDuplicateInsertOccurs() {
val member = createMember(id = 27L)
val stored = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(27L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(27L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(27L)).thenReturn(null, stored)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenThrow(DataIntegrityViolationException("duplicate"))
val result = service.getStoredPreference(member)
assertEquals(stored.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(stored.contentType, result.contentType)
Mockito.verify(repository).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("직접 설정으로 저장값이 변경되면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenPreferenceChangesByUpdatePreference() {
val member = createMember(id = 30L, withAuth = true)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(30L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
verifyRecommendLiveCacheEvicted(30L)
}
@Test
@DisplayName("직접 설정 값이 동일하면 추천 라이브 캐시를 무효화하지 않는다")
fun shouldNotEvictRecommendLiveCacheWhenPreferenceIsUnchanged() {
val member = createMember(id = 31L, withAuth = true)
val preference = MemberContentPreference(
isAdultContentVisible = true,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = LocalDateTime.now().minusDays(1),
contentTypeChangedAt = LocalDateTime.now().minusDays(1)
)
preference.member = member
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(31L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
verifyRecommendLiveCacheNotEvicted(31L)
}
@Test
@DisplayName("authVerify 연동으로 성인 노출이 true로 바뀌면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenMarkAdultVisibleAfterAuthVerifyChangesValue() {
val member = createMember(id = 32L)
val preference = createPreference(member)
Mockito.`when`(memberRepository.findById(32L)).thenReturn(Optional.of(member))
Mockito.`when`(repository.findByMemberId(32L)).thenReturn(preference)
service.markAdultVisibleAfterAuthVerify(32L)
verifyRecommendLiveCacheEvicted(32L)
}
@Test
@DisplayName("기존 row가 있으면 legacy 조회 파라미터를 무시하고 저장값을 그대로 사용한다")
fun shouldIgnoreLegacyParamsWhenPreferenceAlreadyExists() {
val member = createMember(id = 21L, withAuth = true)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.FEMALE,
adultContentVisibilityChangedAt = LocalDateTime.now().minusDays(2),
contentTypeChangedAt = LocalDateTime.now().minusDays(2)
)
preference.member = member
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(21L)).thenReturn(preference)
val result = service.resolveForQuery(member = member)
assertEquals(ContentType.FEMALE, result.contentType)
assertFalse(result.isAdultContentVisible)
}
@Test
@DisplayName("기존 row가 있으면 legacy 조회 파라미터로 캐시를 무효화하지 않는다")
fun shouldNotEvictRecommendLiveCacheWhenLegacyResolveForQueryIsIgnored() {
val member = createMember(id = 25L, withAuth = true)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(25L)).thenReturn(preference)
service.resolveForQuery(member = member)
verifyRecommendLiveCacheNotEvicted(25L)
}
@Test
@DisplayName("한국/해외 조회 정책은 인증 여부와 국가코드에 따라 다르게 계산된다")
fun shouldCalculateIsAdultByCountryPolicy() {
val noAuthMember = createMember(id = 22L, withAuth = false)
val authMember = createMember(id = 23L, withAuth = true)
assertFalse(service.calculateIsAdultForQuery(noAuthMember, "KR", true))
assertTrue(service.calculateIsAdultForQuery(authMember, "KR", true))
assertTrue(service.calculateIsAdultForQuery(noAuthMember, "US", true))
}
@Test
@DisplayName("직접 설정 API 입력이 모두 누락되면 예외를 발생시킨다")
fun shouldThrowWhenAllPreferenceFieldsAreMissing() {
val member = createMember(id = 24L, withAuth = true)
val exception = assertThrows(SodaException::class.java) {
service.updatePreference(
member = member,
isAdultContentVisible = null,
contentType = null
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createPreference(member: Member): MemberContentPreference {
val now = LocalDateTime.now().minusDays(1)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = now,
contentTypeChangedAt = now
)
preference.member = member
return preference
}
private fun createMember(id: Long, withAuth: Boolean = false): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
if (withAuth) {
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-$id",
di = "di-$id",
gender = 1
)
auth.member = member
}
return member
}
private fun verifyRecommendLiveCacheEvicted(memberId: Long) {
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId:false")
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId:true")
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId")
}
private fun verifyRecommendLiveCacheNotEvicted(memberId: Long) {
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId:false")
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId:true")
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId")
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -1,159 +0,0 @@
package kr.co.vividnext.sodalive.search
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class SearchServiceTest {
private val repository: SearchRepository = Mockito.mock(SearchRepository::class.java)
private val service = SearchService(repository)
@Test
@DisplayName("콘텐츠 검색은 전달받은 isAdult 값을 그대로 사용한다")
fun shouldUseProvidedIsAdultForContentSearch() {
val member = createMember(id = 101L, countryCode = "KR")
val contentItem = SearchResponseItem(
id = 10L,
imageUrl = "https://cdn.test/content.png",
title = "title",
nickname = "creator"
)
Mockito.`when`(
repository.searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL
)
).thenReturn(1)
Mockito.`when`(
repository.searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 10
)
).thenReturn(listOf(contentItem))
val result = service.searchContentList(
keyword = "keyword",
isAdult = true,
contentType = ContentType.ALL,
member = member,
offset = 0,
limit = 10
)
assertEquals(1, result.totalCount)
assertEquals(SearchResponseType.CONTENT, result.items.first().type)
Mockito.verify(repository).searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL
)
Mockito.verify(repository, Mockito.never()).searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = false,
contentType = ContentType.ALL
)
}
@Test
@DisplayName("통합 검색은 전달받은 isAdult 값으로 콘텐츠/시리즈 조회를 수행한다")
fun shouldUseProvidedIsAdultForUnifiedSearch() {
val member = createMember(id = 102L, countryCode = "KR")
val creatorItem = SearchResponseItem(
id = 20L,
imageUrl = "https://cdn.test/creator.png",
title = "creator",
nickname = "creator"
)
val contentItem = SearchResponseItem(
id = 21L,
imageUrl = "https://cdn.test/content.png",
title = "content",
nickname = "creator"
)
val seriesItem = SearchResponseItem(
id = 22L,
imageUrl = "https://cdn.test/series.png",
title = "series",
nickname = "creator"
)
Mockito.`when`(
repository.searchCreatorList(
keyword = "keyword",
memberId = member.id!!,
offset = 0,
limit = 3
)
).thenReturn(listOf(creatorItem))
Mockito.`when`(
repository.searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
).thenReturn(listOf(contentItem))
Mockito.`when`(
repository.searchSeriesList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
).thenReturn(listOf(seriesItem))
val result = service.searchUnified(
keyword = "keyword",
isAdult = true,
contentType = ContentType.ALL,
member = member
)
assertEquals(SearchResponseType.CREATOR, result.creatorList.first().type)
assertEquals(SearchResponseType.CONTENT, result.contentList.first().type)
assertEquals(SearchResponseType.SERIES, result.seriesList.first().type)
Mockito.verify(repository).searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
Mockito.verify(repository).searchSeriesList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
}
private fun createMember(id: Long, countryCode: String?): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
).apply {
this.id = id
this.countryCode = countryCode
}
}
}