diff --git a/docs/20260325_콘텐츠조회설정서버저장전환.md b/docs/20260325_콘텐츠조회설정서버저장전환.md new file mode 100644 index 00000000..4175e62b --- /dev/null +++ b/docs/20260325_콘텐츠조회설정서버저장전환.md @@ -0,0 +1,781 @@ +# 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 검증으로 대체했다. diff --git a/docs/20260326_member_content_preference_ddl.sql b/docs/20260326_member_content_preference_ddl.sql new file mode 100644 index 00000000..caa5dde0 --- /dev/null +++ b/docs/20260326_member_content_preference_ddl.sql @@ -0,0 +1,30 @@ +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; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt index 986a35b7..662b6ffd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt @@ -24,8 +24,8 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.fetchData( timezone = timezone, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, member ) ) @@ -41,8 +41,8 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.getLatestContentByTheme( theme = theme, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, member ) ) @@ -58,8 +58,8 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.getDayOfWeekSeriesList( dayOfWeek = dayOfWeek, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, member ) ) @@ -74,8 +74,8 @@ class HomeController(private val service: HomeService) { ) = run { ApiResponse.ok( service.getRecommendContentList( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, member = member ) ) @@ -95,8 +95,8 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.getContentRankingBySort( sort = sort ?: ContentRankingSortType.REVENUE, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, offset = offset, limit = limit, theme = theme, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 7f8d624b..35192c0c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -18,6 +18,8 @@ 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 @@ -47,6 +49,7 @@ class HomeService( private val explorerQueryRepository: ExplorerQueryRepository, private val langContext: LangContext, + private val memberContentPreferenceService: MemberContentPreferenceService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -69,17 +72,19 @@ class HomeService( fun fetchData( timezone: String, - isAdultContentVisible: Boolean, - contentType: ContentType, + isAdultContentVisible: Boolean?, + contentType: ContentType?, member: Member? ): GetHomeResponse { + val preference = resolvePreference(member, isAdultContentVisible, contentType) val memberId = member?.id - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = preference.isAdult + val resolvedContentType = preference.contentType val liveList = liveRoomService.getRoomList( dateString = null, status = LiveRoomStatus.NOW, - isAdultContentVisible = isAdultContentVisible, + isAdultContentVisible = isAdult, pageable = Pageable.ofSize(10), member = member, timezone = timezone @@ -102,14 +107,14 @@ class HomeService( val latestContentThemeList = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, - contentType = contentType, + contentType = resolvedContentType, excludeThemes = listOf("다시듣기") ) val latestContentList = contentService.getLatestContentByTheme( memberId = memberId, theme = latestContentThemeList, - contentType = contentType, + contentType = resolvedContentType, isFree = false, isAdult = isAdult ) @@ -128,7 +133,7 @@ class HomeService( val originalAudioDramaList = seriesService.getOriginalAudioDramaList( memberId = memberId, isAdult = isAdult, - contentType = contentType + contentType = resolvedContentType ) val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult) @@ -137,7 +142,7 @@ class HomeService( val translatedDayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( memberId = memberId, isAdult = isAdult, - contentType = contentType, + contentType = resolvedContentType, dayOfWeek = getDayOfWeekByTimezone(timezone) ) @@ -157,7 +162,7 @@ class HomeService( val contentRanking = rankingService.getContentRanking( memberId = memberId, isAdult = isAdult, - contentType = contentType, + contentType = resolvedContentType, startDate = startDate.minusDays(1), endDate = endDate, sort = ContentRankingSortType.REVENUE @@ -166,17 +171,17 @@ class HomeService( val recommendChannelList = recommendChannelService.getRecommendChannel( memberId = memberId, isAdult = isAdult, - contentType = contentType + contentType = resolvedContentType ) val freeContentList = getRandomizedContentList( memberId = memberId, isAdult = isAdult, - contentType = contentType, + contentType = resolvedContentType, theme = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, isFree = true, - contentType = contentType + contentType = resolvedContentType ), isFree = true, isPointAvailableOnly = false @@ -186,7 +191,7 @@ class HomeService( val pointAvailableContentList = getRandomizedContentList( memberId = memberId, isAdult = isAdult, - contentType = contentType, + contentType = resolvedContentType, theme = emptyList(), isFree = false, isPointAvailableOnly = true @@ -212,9 +217,8 @@ class HomeService( recommendChannelList = recommendChannelList, freeContentList = freeContentList, pointAvailableContentList = pointAvailableContentList, - recommendContentList = getRecommendContentList( - isAdultContentVisible = isAdultContentVisible, - contentType = contentType, + recommendContentList = getRecommendContentListByPreference( + preference = preference, member = member, excludeContentIds = excludeContentIds ) @@ -223,18 +227,20 @@ class HomeService( fun getLatestContentByTheme( theme: String, - isAdultContentVisible: Boolean, - contentType: ContentType, + isAdultContentVisible: Boolean?, + contentType: ContentType?, member: Member? ): List { + val preference = resolvePreference(member, isAdultContentVisible, contentType) val memberId = member?.id - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = preference.isAdult + val resolvedContentType = preference.contentType val themeList = if (theme.isBlank()) { contentThemeService.getActiveThemeOfContent( isAdult = isAdult, isFree = false, - contentType = contentType, + contentType = resolvedContentType, excludeThemes = listOf("다시듣기") ) } else { @@ -244,7 +250,7 @@ class HomeService( return contentService.getLatestContentByTheme( memberId = memberId, theme = themeList, - contentType = contentType, + contentType = resolvedContentType, isFree = false, isAdult = isAdult ) @@ -252,32 +258,34 @@ class HomeService( fun getDayOfWeekSeriesList( dayOfWeek: SeriesPublishedDaysOfWeek, - isAdultContentVisible: Boolean, - contentType: ContentType, + isAdultContentVisible: Boolean?, + contentType: ContentType?, member: Member? ): List { + val preference = resolvePreference(member, isAdultContentVisible, contentType) val memberId = member?.id - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = preference.isAdult return seriesService.getDayOfWeekSeriesList( memberId = memberId, isAdult = isAdult, - contentType = contentType, + contentType = preference.contentType, dayOfWeek = dayOfWeek ) } fun getContentRankingBySort( sort: ContentRankingSortType, - isAdultContentVisible: Boolean, - contentType: ContentType, + isAdultContentVisible: Boolean?, + contentType: ContentType?, offset: Long?, limit: Long?, theme: String?, member: Member? ): List { + val preference = resolvePreference(member, isAdultContentVisible, contentType) val memberId = member?.id - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = preference.isAdult val currentDateTime = LocalDateTime.now() val startDate = currentDateTime @@ -291,7 +299,7 @@ class HomeService( return rankingService.getContentRanking( memberId = memberId, isAdult = isAdult, - contentType = contentType, + contentType = preference.contentType, startDate = startDate.minusDays(1), endDate = endDate, offset = offset ?: 0, @@ -320,13 +328,22 @@ class HomeService( } fun getRecommendContentList( - isAdultContentVisible: Boolean, - contentType: ContentType, + isAdultContentVisible: Boolean?, + contentType: ContentType?, member: Member?, excludeContentIds: List = emptyList() + ): List { + val preference = resolvePreference(member, isAdultContentVisible, contentType) + return getRecommendContentListByPreference(preference, member, excludeContentIds) + } + + private fun getRecommendContentListByPreference( + preference: ViewerContentPreference, + member: Member?, + excludeContentIds: List ): List { val memberId = member?.id - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = preference.isAdult // 3개의 버킷(최근/중간/과거)에서 후보군을 조회한 뒤, 시간감쇠 점수 기반으로 샘플링한다. val buckets = listOf( @@ -350,7 +367,7 @@ class HomeService( val batch = contentService.getLatestContentByTheme( memberId = memberId, theme = emptyList(), - contentType = contentType, + contentType = preference.contentType, offset = bucket.offset, limit = bucket.limit, sortType = SortType.NEWEST, @@ -374,6 +391,27 @@ class HomeService( return result.take(RECOMMEND_TARGET_SIZE).shuffled() } + private fun resolvePreference( + member: Member?, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ): ViewerContentPreference { + if (member == null) { + return ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = isAdultContentVisible ?: false, + contentType = contentType ?: ContentType.ALL, + isAdult = false + ) + } + + return memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) + } + private fun pickByTimeDecay( batch: List, targetSize: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiController.kt index 263db9f9..37fd63bc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiController.kt @@ -23,8 +23,8 @@ class LiveApiController( ) = run { ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, timezone = timezone, member = member ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt index 81a756d9..0d449b69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt @@ -8,6 +8,8 @@ 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 @@ -17,22 +19,24 @@ 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, + isAdultContentVisible: Boolean?, + contentType: ContentType?, timezone: String, member: Member? ): LiveMainResponse { + val preference = resolvePreference(member, isAdultContentVisible, contentType) val memberId = member?.id - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = preference.isAdult val liveOnAirRoomList = liveService.getRoomList( dateString = null, status = LiveRoomStatus.NOW, - isAdultContentVisible = isAdultContentVisible, + isAdultContentVisible = isAdult, pageable = Pageable.ofSize(20), member = member, timezone = timezone @@ -55,7 +59,7 @@ class LiveApiService( val replayLive = contentService.getLatestContentByTheme( memberId = memberId, theme = listOf("다시듣기"), - contentType = contentType, + contentType = preference.contentType, isFree = false, isAdult = isAdult ) @@ -77,7 +81,7 @@ class LiveApiService( val liveReservationRoomList = liveService.getRoomList( dateString = null, status = LiveRoomStatus.RESERVATION, - isAdultContentVisible = isAdultContentVisible, + isAdultContentVisible = isAdult, pageable = Pageable.ofSize(10), member = member, timezone = timezone @@ -93,4 +97,25 @@ class LiveApiService( liveReservationRoomList = liveReservationRoomList ) } + + private fun resolvePreference( + member: Member?, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ): ViewerContentPreference { + if (member == null) { + return ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = isAdultContentVisible ?: false, + contentType = contentType ?: ContentType.ALL, + isAdult = false + ) + } + + return memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt index 9a903a9f..614020fc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt @@ -5,6 +5,7 @@ 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 @@ -22,6 +23,7 @@ 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 ) { @@ -33,7 +35,7 @@ class CharacterCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required") val id = service.addComment(characterId, member, request.comment) @@ -48,7 +50,7 @@ class CharacterCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required") val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode) @@ -63,7 +65,7 @@ class CharacterCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) val data = service.listComments(imageHost, characterId, cursor, limit) ApiResponse.ok(data) @@ -78,7 +80,7 @@ class CharacterCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) // characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨 val data = service.getReplies(imageHost, commentId, cursor, limit) @@ -92,7 +94,7 @@ class CharacterCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) service.deleteComment(characterId, commentId, member) val message = messageSource.getMessage("chat.character.comment.deleted", langContext.lang) ApiResponse.ok(true, message) @@ -106,9 +108,15 @@ class CharacterCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) 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") + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 9c81a93a..f2d21ffe 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -27,6 +27,7 @@ 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 @@ -49,6 +50,7 @@ class ChatCharacterController( private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val langContext: LangContext, + private val memberContentPreferenceService: MemberContentPreferenceService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -57,6 +59,8 @@ class ChatCharacterController( fun getCharacterMain( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ): ApiResponse = run { + val isAdultAccessible = resolveIsAdultAccessible(member) + // 배너 조회 (최대 10개) val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) .content @@ -68,7 +72,7 @@ class ChatCharacterController( } // 최근 대화한 캐릭터(채팅방) 조회 (회원별 최근 순으로 최대 10개) - val recentCharacters = if (member == null || member.auth == null) { + val recentCharacters = if (member == null || !isAdultAccessible) { emptyList() } else { chatRoomService.listMyChatRooms(member, 0, 10) @@ -156,7 +160,7 @@ class ChatCharacterController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") // 캐릭터 상세 정보 조회 val character = service.getCharacterDetail(characterId) @@ -396,7 +400,8 @@ class ChatCharacterController( fun getRecommendCharacters( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - val recent = if (member == null || member.auth == null) { + val isAdultAccessible = resolveIsAdultAccessible(member) + val recent = if (member == null || !isAdultAccessible) { emptyList() } else { chatRoomService @@ -447,4 +452,12 @@ class ChatCharacterController( aiCharacterList } } + + private fun resolveIsAdultAccessible(member: Member?): Boolean { + if (member == null) { + return false + } + + return memberContentPreferenceService.getStoredPreference(member).isAdult + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index 7e9d5899..f5aed50d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -9,6 +9,7 @@ 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 @@ -25,6 +26,7 @@ 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 ) { @@ -37,7 +39,7 @@ class CharacterImageController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) val pageSize = if (size <= 0) 20 else minOf(size, 20) @@ -125,7 +127,7 @@ class CharacterImageController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) val pageSize = if (size <= 0) 20 else minOf(size, 20) val expiration = 5L * 60L * 1000L // 5분 @@ -199,7 +201,7 @@ class CharacterImageController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + validateAdultAccess(member) val image = imageService.getById(req.imageId) if (!image.isActive) throw SodaException(messageKey = "chat.character.image.inactive") @@ -223,4 +225,10 @@ 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") + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt index ab0464b5..e3da7bf3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -4,6 +4,8 @@ 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 @@ -25,7 +27,10 @@ import java.time.temporal.TemporalAdjusters @RestController @RequestMapping("/audio-content") -class AudioContentController(private val service: AudioContentService) { +class AudioContentController( + private val service: AudioContentService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @PostMapping @PreAuthorize("hasRole('CREATOR')") fun createAudioContent( @@ -112,14 +117,15 @@ class AudioContentController(private val service: AudioContentService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getAudioContentList( creatorId = creatorId, sortType = sortType ?: SortType.NEWEST, categoryId = categoryId ?: 0, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -135,12 +141,13 @@ class AudioContentController(private val service: AudioContentService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, null) ApiResponse.ok( service.getDetail( id = id, member = member, - isAdultContentVisible = isAdultContentVisible ?: true, + isAdultContentVisible = preference.isAdultContentVisible, timezone = timezone ) ) @@ -192,6 +199,7 @@ class AudioContentController(private val service: AudioContentService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { + val preference = resolvePreference(member, isAdultContentVisible, contentType) val currentDateTime = LocalDateTime.now() val startDate = currentDateTime .withHour(15) @@ -204,8 +212,8 @@ class AudioContentController(private val service: AudioContentService) { ApiResponse.ok( service.getAudioContentRanking( - isAdult = member?.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, startDate = startDate, endDate = endDate, offset = pageable.offset, @@ -249,17 +257,18 @@ class AudioContentController(private val service: AudioContentService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getLatestContentByTheme( memberId = member.id!!, theme = if (theme == null) listOf() else listOf(theme), - contentType = contentType ?: ContentType.ALL, + contentType = preference.contentType, offset = pageable.offset, limit = pageable.pageSize.toLong(), sortType = sortType ?: SortType.NEWEST, isFree = isFree ?: false, - isAdult = (isAdultContentVisible ?: true) && member.auth != null, + isAdult = preference.isAdult, isPointAvailableOnly = isPointAvailableOnly ?: false ) ) @@ -271,18 +280,36 @@ class AudioContentController(private val service: AudioContentService) { @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getLatestContentByTheme( memberId = member?.id, theme = listOf("다시듣기"), - contentType = contentType ?: ContentType.ALL, + contentType = preference.contentType, isFree = false, - isAdult = if (member != null) { - (isAdultContentVisible ?: true) && member.auth != null - } else { - false - } + isAdult = preference.isAdult ) ) } + + private fun resolvePreference( + member: Member?, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ): ViewerContentPreference { + if (member == null) { + return ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = isAdultContentVisible ?: false, + contentType = contentType ?: ContentType.ALL, + isAdult = false + ) + } + + return memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 578f2ff3..d546c9c5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -40,6 +40,7 @@ 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 @@ -527,12 +528,16 @@ class AudioContentService( isAdultContentVisible: Boolean, timezone: String ): GetAudioContentDetailResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, 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) @@ -670,14 +675,16 @@ class AudioContentService( cloudfrontHost = coverImageHost, contentId = audioContent.id!!, creatorId = creatorId, - isAdult = member.auth != null + // 관련 콘텐츠 노출도 동일하게 저장 선호 기반 성인 정책을 따른다. + isAdult = isAdult ) val sameThemeOtherContentList = repository.getSameThemeOtherContentList( cloudfrontHost = coverImageHost, contentId = audioContent.id!!, themeId = audioContent.theme!!.id!!, - isAdult = member.auth != null + // 동일 테마 추천도 메인 상세와 동일한 성인 정책으로 정렬한다. + isAdult = isAdult ) val likeCount = audioContentLikeRepository.totalCountAudioContentLike(contentId = id) @@ -864,7 +871,8 @@ class AudioContentService( orderSequence = orderSequence, isActivePreview = audioContent.isGeneratePreview, isAdult = audioContent.isAdult, - isMosaic = audioContent.isAdult && member.auth == null, + // 성인 콘텐츠이면서 현재 조회 정책으로 열람 불가한 경우에만 모자이크를 적용한다. + isMosaic = audioContent.isAdult && !isAdult, isOnlyRental = isOnlyRental, existOrdered = isExistsAudioContent, purchaseOption = purchaseOption, @@ -904,7 +912,7 @@ class AudioContentService( member: Member, isAdultContentVisible: Boolean ): GetAudioContentListItem? { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { return null @@ -978,7 +986,7 @@ class AudioContentService( offset: Long, limit: Long ): GetAudioContentListResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val isCreator = member.id == creatorId if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt index df73f542..f5338503 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt @@ -5,6 +5,7 @@ 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,18 +17,20 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/audio-content/main") class AudioContentMainController( private val service: AudioContentMainService, - private val orderService: OrderService + private val orderService: OrderService, + private val memberContentPreferenceService: MemberContentPreferenceService ) { @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, null, null) ApiResponse.ok( service.getNewContentUploadCreatorList( memberId = member.id!!, - isAdult = member.auth != null + isAdult = preference.isAdult ) ) } @@ -37,11 +40,12 @@ 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, null, null) ApiResponse.ok( service.getAudioContentMainBannerList( memberId = member.id!!, - isAdult = member.auth != null + isAdult = preference.isAdult ) ) } @@ -69,12 +73,13 @@ class AudioContentMainController( pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getNewContentByTheme( theme, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member, pageable ) @@ -88,11 +93,12 @@ 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, isAdultContentVisible, contentType) ApiResponse.ok( service.getThemeList( - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } @@ -106,12 +112,13 @@ class AudioContentMainController( pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getNewContentFor2WeeksByTheme( theme = theme, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member, pageable = pageable ) @@ -126,15 +133,26 @@ class AudioContentMainController( pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getAudioContentCurationListWithPaging( memberId = member.id!!, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, offset = pageable.offset, limit = pageable.pageSize.toLong() ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt index 6a78dfc6..4b476c7a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -13,6 +13,7 @@ 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 @@ -68,7 +69,7 @@ class AudioContentMainService( } else { emptyList() }, - isAdult = member.auth != null && isAdultContentVisible, + isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible), contentType = contentType, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -87,7 +88,7 @@ class AudioContentMainService( * - AS-IS theme은 한글만 처리하도록 되어 있음 * - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리 */ - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val themeListRaw = if (theme.isBlank()) { audioContentThemeRepository.getActiveThemeOfContent( isAdult = isAdult, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationController.kt index 02025367..dacea554 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationController.kt @@ -5,6 +5,7 @@ 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,7 +16,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/audio-content/curation") -class AudioContentCurationController(private val service: AudioContentCurationService) { +class AudioContentCurationController( + private val service: AudioContentCurationService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping("/{id}") fun getCurationContent( @PathVariable id: Long, @@ -26,16 +30,27 @@ class AudioContentCurationController(private val service: AudioContentCurationSe pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getCurationContent( curationId = id, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, sortType = sortType ?: SortType.NEWEST, member = member, pageable = pageable ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt index 661cddd2..8136f52e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt @@ -6,6 +6,7 @@ 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 @@ -30,20 +31,19 @@ class AudioContentCurationService( ): GetCurationContentResponse { val totalCount = repository.findTotalCountByCurationId( curationId = curationId, - isAdult = member.auth != null && isAdultContentVisible, + isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible), contentType = contentType ) val audioContentList = repository.findByCurationId( curationId = curationId, cloudfrontHost = cloudFrontHost, - isAdult = member.auth != null && isAdultContentVisible, + isAdult = isAdultVisibleByPolicy(member, 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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmController.kt index 395ffb94..96adf929 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmController.kt @@ -4,6 +4,7 @@ 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,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/audio-content/main/alarm") -class AudioContentMainTabAlarmController(private val service: AudioContentMainTabAlarmService) { +class AudioContentMainTabAlarmController( + private val service: AudioContentMainTabAlarmService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun fetchContentMainTabAlarm( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @@ -21,11 +25,12 @@ class AudioContentMainTabAlarmController(private val service: AudioContentMainTa @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -40,16 +45,27 @@ class AudioContentMainTabAlarmController(private val service: AudioContentMainTa pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchAlarmContentByTheme( theme, member, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, offset = pageable.offset, limit = pageable.pageSize.toLong() ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt index b0d20610..d9b2fce2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt @@ -8,6 +8,7 @@ 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 @@ -27,7 +28,7 @@ class AudioContentMainTabAlarmService( contentType: ContentType, member: Member ): GetContentMainTabAlarmResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val memberId = member.id!! val contentBannerList = bannerService.getBannerList( @@ -105,7 +106,7 @@ class AudioContentMainTabAlarmService( } val memberId = member.id!! - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val totalCount = contentRepository.totalAlarmCountByTheme( memberId = memberId, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrController.kt index 13a595f7..4bad32d9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrController.kt @@ -4,6 +4,7 @@ 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,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/audio-content/main/asmr") -class AudioContentMainTabAsmrController(private val service: AudioContentMainTabAsmrService) { +class AudioContentMainTabAsmrController( + private val service: AudioContentMainTabAsmrService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun fetchContentMainTabAsmr( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @@ -20,11 +24,12 @@ class AudioContentMainTabAsmrController(private val service: AudioContentMainTab @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -38,13 +43,24 @@ class AudioContentMainTabAsmrController(private val service: AudioContentMainTab @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getPopularContentByCreator( creatorId = creatorId, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt index 0ddefb72..dd136862 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt @@ -9,6 +9,7 @@ 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 @@ -26,7 +27,7 @@ class AudioContentMainTabAsmrService( contentType: ContentType, member: Member ): GetContentMainTabAsmrResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val memberId = member.id!! val theme = "ASMR" val tabId = 5L diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentController.kt index d8209f4b..ebbd9b28 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentController.kt @@ -4,6 +4,7 @@ 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,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/audio-content/main/content") -class AudioContentMainTabContentController(private val service: AudioContentMainTabContentService) { +class AudioContentMainTabContentController( + private val service: AudioContentMainTabContentService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun fetchContentMainTabContent( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @@ -21,11 +25,12 @@ class AudioContentMainTabContentController(private val service: AudioContentMain @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -40,12 +45,13 @@ class AudioContentMainTabContentController(private val service: AudioContentMain pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getAudioContentRanking( memberId = member.id!!, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, sortType = sortType ?: "매출" ) ) @@ -60,12 +66,13 @@ class AudioContentMainTabContentController(private val service: AudioContentMain pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getNewContentByTheme( theme, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -79,12 +86,13 @@ class AudioContentMainTabContentController(private val service: AudioContentMain @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getPopularContentByCreator( creatorId = creatorId, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } @@ -92,16 +100,29 @@ class AudioContentMainTabContentController(private val service: AudioContentMain @GetMapping("/recommend-content-by-tag") fun getRecommendedContentByTag( @RequestParam tag: 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("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getRecommendedContentByTag( memberId = member.id!!, tag = tag, - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt index bce8884a..a019fcbe 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt @@ -10,6 +10,7 @@ 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 @@ -30,7 +31,7 @@ class AudioContentMainTabContentService( member: Member ): GetContentMainTabContentResponse { val memberId = member.id!! - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val tabId = 3L // 단편 배너 @@ -114,6 +115,7 @@ class AudioContentMainTabContentService( tagCurationService.getTagCurationContentList( memberId = memberId, tag = tagList[0], + isAdult = isAdult, contentType = contentType ) } else { @@ -189,7 +191,7 @@ class AudioContentMainTabContentService( contentType: ContentType, member: Member ): List { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val themeList = if (theme.isBlank()) { audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType) @@ -232,8 +234,14 @@ class AudioContentMainTabContentService( fun getRecommendedContentByTag( memberId: Long, tag: String, + isAdult: Boolean, contentType: ContentType ): List { - return tagCurationService.getTagCurationContentList(memberId = memberId, tag = tag, contentType = contentType) + return tagCurationService.getTagCurationContentList( + memberId = memberId, + tag = tag, + isAdult = isAdult, + contentType = contentType + ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt index 271fd4b5..a049ceac 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt @@ -27,7 +27,9 @@ 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( @@ -60,6 +62,7 @@ class ContentMainTabTagCurationRepository( fun getTagCurationContentList( memberId: Long, tag: String, + isAdult: Boolean, contentType: ContentType ): List { val blockMemberCondition = blockMember.isActive.isTrue @@ -79,6 +82,11 @@ 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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationService.kt index 4a73e883..014758f2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationService.kt @@ -13,8 +13,14 @@ class ContentMainTabTagCurationService(private val repository: ContentMainTabTag fun getTagCurationContentList( memberId: Long, tag: String, + isAdult: Boolean, contentType: ContentType ): List { - return repository.getTagCurationContentList(memberId = memberId, tag = tag, contentType = contentType) + return repository.getTagCurationContentList( + memberId = memberId, + tag = tag, + isAdult = isAdult, + contentType = contentType + ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeController.kt index e25411cb..51693825 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeController.kt @@ -4,6 +4,7 @@ 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,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/audio-content/main/free") -class AudioContentMainTabFreeController(private val service: AudioContentMainTabFreeService) { +class AudioContentMainTabFreeController( + private val service: AudioContentMainTabFreeService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun fetchContentMainFree( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @@ -21,11 +25,12 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -39,12 +44,13 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getIntroduceCreator( member, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, offset = pageable.offset, limit = pageable.pageSize.toLong() ) @@ -60,12 +66,13 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getNewContentByTheme( theme, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -81,13 +88,24 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getPopularContentByCreator( creatorId = creatorId, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt index bbc86510..ede888ed 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt @@ -11,6 +11,7 @@ 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 @@ -30,7 +31,7 @@ class AudioContentMainTabFreeService( contentType: ContentType, member: Member ): GetContentMainTabFreeResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val memberId = member.id!! val tabId = 7L @@ -134,7 +135,7 @@ class AudioContentMainTabFreeService( offset: Long, limit: Long ): List { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val memberId = member.id!! val introduceCreatorCuration = curationRepository.findByContentMainTabIdAndTitle( @@ -171,7 +172,7 @@ class AudioContentMainTabFreeService( listOf(theme) } else { audioContentThemeRepository.getActiveThemeOfContent( - isAdult = member.auth != null && isAdultContentVisible, + isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible), isFree = true, contentType = contentType ).filter { @@ -185,7 +186,7 @@ class AudioContentMainTabFreeService( it != "자기소개" } }, - isAdult = member.auth != null && isAdultContentVisible, + isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible), contentType = contentType, offset = offset, limit = limit, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeController.kt index 50150350..9feae3ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeController.kt @@ -3,6 +3,8 @@ 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 @@ -11,17 +13,21 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/audio-content/main/home") -class AudioContentMainTabHomeController(private val service: AudioContentMainTabHomeService) { +class AudioContentMainTabHomeController( + private val service: AudioContentMainTabHomeService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @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, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -34,11 +40,12 @@ class AudioContentMainTabHomeController(private val service: AudioContentMainTab @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getPopularContentByCreator( creatorId = creatorId, - isAdult = member?.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } @@ -50,13 +57,35 @@ class AudioContentMainTabHomeController(private val service: AudioContentMainTab @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getContentRanking( sortType = sortType ?: "매출", - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) } + + private fun resolvePreference( + member: Member?, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ): ViewerContentPreference { + if (member == null) { + return ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = isAdultContentVisible ?: false, + contentType = contentType ?: ContentType.ALL, + isAdult = false + ) + } + + return memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt index f86f0a90..c066fab0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt @@ -5,6 +5,7 @@ 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 @@ -42,7 +43,7 @@ class AudioContentMainTabHomeService( val formattedLastMonday = startDate.format(startDateFormatter) val formattedLastSunday = endDate.format(endDateFormatter) - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false // 최근 공지사항 val latestNotice = noticeService.getLatestNotice() @@ -130,7 +131,7 @@ class AudioContentMainTabHomeService( contentType: ContentType, member: Member? ): List { - val isAdult = member?.auth != null && isAdultContentVisible + val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false val currentDateTime = LocalDateTime.now() val startDate = currentDateTime diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayController.kt index 5dbc6f9f..bacd2d4c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayController.kt @@ -4,6 +4,7 @@ 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,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/audio-content/main/replay") -class AudioContentMainTabLiveReplayController(private val service: AudioContentMainTabLiveReplayService) { +class AudioContentMainTabLiveReplayController( + private val service: AudioContentMainTabLiveReplayService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun fetchContentMainTabLiveReplay( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @@ -20,11 +24,12 @@ class AudioContentMainTabLiveReplayController(private val service: AudioContentM @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -38,13 +43,24 @@ class AudioContentMainTabLiveReplayController(private val service: AudioContentM @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getPopularContentByCreator( creatorId = creatorId, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt index 77b4493d..6a2e6a84 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt @@ -9,6 +9,7 @@ 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 @@ -26,7 +27,7 @@ class AudioContentMainTabLiveReplayService( contentType: ContentType, member: Member ): GetContentMainTabLiveReplayResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val memberId = member.id!! val theme = "다시듣기" val tabId = 6L diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt index 753e11be..bd6eb7e4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt @@ -4,6 +4,7 @@ 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,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/audio-content/main/series") -class AudioContentMainTabSeriesController(private val service: AudioContentMainTabSeriesService) { +class AudioContentMainTabSeriesController( + private val service: AudioContentMainTabSeriesService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun fetchContentMainSeries( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @@ -21,11 +25,12 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.fetchData( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member ) ) @@ -39,12 +44,13 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getOriginalAudioDramaList( memberId = member.id!!, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, offset = pageable.offset, limit = pageable.pageSize.toLong() ) @@ -59,12 +65,13 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT pageable: Pageable ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getRank10DaysCompletedSeriesList( memberId = member.id!!, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, offset = pageable.offset, limit = pageable.pageSize.toLong() ) @@ -79,13 +86,14 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getRecommendSeriesListByGenre( genreId, memberId = member.id!!, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } @@ -98,13 +106,24 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getRecommendSeriesByCreator( creatorId = creatorId, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL + isAdult = preference.isAdult, + contentType = preference.contentType ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt index 692f04de..4e6fea5b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt @@ -8,6 +8,7 @@ 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 @@ -30,7 +31,7 @@ class AudioContentMainTabSeriesService( contentType: ContentType, member: Member ): GetContentMainTabSeriesResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val memberId = member.id!! // 메인 배너 (시리즈) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt index a844e2f1..88c0184a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt @@ -5,6 +5,7 @@ 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,7 +16,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/audio-content/series") -class ContentSeriesController(private val service: ContentSeriesService) { +class ContentSeriesController( + private val service: ContentSeriesService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun getSeriesList( @RequestParam(required = false) creatorId: Long?, @@ -27,14 +31,15 @@ class ContentSeriesController(private val service: ContentSeriesService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getSeriesList( creatorId = creatorId, isOriginal = isOriginal ?: false, isCompleted = isCompleted ?: false, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -50,12 +55,13 @@ class ContentSeriesController(private val service: ContentSeriesService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getSeriesDetail( seriesId = id, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member ) ) @@ -71,12 +77,13 @@ class ContentSeriesController(private val service: ContentSeriesService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getSeriesContentList( seriesId = id, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member, sortType = sortType ?: SeriesSortType.NEWEST, offset = pageable.offset, @@ -92,13 +99,24 @@ class ContentSeriesController(private val service: ContentSeriesService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getRecommendSeriesList( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index f947580f..cb4c1624 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -918,8 +918,10 @@ 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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 6f32b812..1c60aa0c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -21,6 +21,7 @@ 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 @@ -168,7 +169,7 @@ class ContentSeriesService( offset: Long = 0, limit: Long = 20 ): GetSeriesListResponse { - val isAuth = member.auth != null && isAdultContentVisible + val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible) val totalCount = repository.getSeriesTotalCount( creatorId = creatorId, @@ -206,7 +207,7 @@ class ContentSeriesService( offset: Long = 0, limit: Long = 20 ): GetSeriesListResponse { - val isAuth = member.auth != null && isAdultContentVisible + val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible) val totalCount = repository.getSeriesByGenreTotalCount( genreId = genreId, @@ -240,7 +241,7 @@ class ContentSeriesService( ): GetSeriesDetailResponse { val series = repository.getSeriesDetail( seriesId = seriesId, - isAuth = member.auth != null && isAdultContentVisible, + isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible), contentType = contentType ) ?: throw SodaException(messageKey = "series.error.invalid_series_retry") @@ -428,7 +429,7 @@ class ContentSeriesService( offset: Long, limit: Long ): GetSeriesContentListResponse { - val isAdult = member.auth != null && isAdultContentVisible + val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible) val totalCount = seriesContentRepository.getContentCount(seriesId, isAdult = isAdult, contentType = contentType) val contentList = seriesContentRepository.getContentList( @@ -491,7 +492,7 @@ class ContentSeriesService( contentType: ContentType, member: Member ): List { - val isAuth = member.auth != null && isAdultContentVisible + val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible) return repository.getRecommendSeriesListV2( imageHost = coverImageHost, isAuth = isAuth, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt index c2476382..037e5dde 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt @@ -8,6 +8,7 @@ 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,6 +22,7 @@ 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 @@ -32,6 +34,7 @@ class SeriesMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) .content @@ -43,14 +46,14 @@ class SeriesMainController( creatorId = null, isCompleted = true, orderByRandom = true, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member ).items val recommendSeriesList = contentSeriesService.getRecommendSeriesList( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member ) @@ -71,11 +74,12 @@ class SeriesMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( contentSeriesService.getRecommendSeriesList( - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member ) ) @@ -91,13 +95,14 @@ class SeriesMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) val pageable = PageRequest.of(page, size) ApiResponse.ok( contentSeriesService.getDayOfWeekSeriesList( memberId = member.id, - isAdult = member.auth != null && (isAdultContentVisible ?: true), - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, dayOfWeek = dayOfWeek, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -112,15 +117,16 @@ class SeriesMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) val memberId = member.id!! - val isAdult = member.auth != null && (isAdultContentVisible ?: true) + val isAdult = preference.isAdult ApiResponse.ok( contentSeriesService.getGenreList( memberId = memberId, isAdult = isAdult, - contentType = contentType ?: ContentType.ALL + contentType = preference.contentType ) ) } @@ -135,17 +141,28 @@ class SeriesMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) val pageable = PageRequest.of(page, size) ApiResponse.ok( contentSeriesService.getSeriesListByGenre( genreId = genreId, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt index 8f1b662c..65acd1d0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt @@ -5,6 +5,7 @@ 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,7 +17,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/audio-content/theme") -class AudioContentThemeController(private val service: AudioContentThemeService) { +class AudioContentThemeController( + private val service: AudioContentThemeService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping @PreAuthorize("hasRole('CREATOR')") fun getThemes( @@ -36,13 +40,14 @@ class AudioContentThemeController(private val service: AudioContentThemeService) @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getActiveThemeOfContent( - isAdult = member.auth != null && (isAdultContentVisible ?: true), + isAdult = preference.isAdult, isFree = isFree ?: false, isPointAvailableOnly = isPointAvailableOnly ?: false, - contentType = contentType ?: ContentType.ALL + contentType = preference.contentType ) ) } @@ -57,17 +62,28 @@ class AudioContentThemeController(private val service: AudioContentThemeService) pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.getContentByTheme( themeId = id, sortType = sortType ?: SortType.NEWEST, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index f34e2b9c..47c060c8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -12,6 +12,7 @@ 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 @@ -129,7 +130,7 @@ class AudioContentThemeService( val totalCount = contentRepository.totalCountByTheme( memberId = member.id!!, theme = listOf(theme.theme), - isAdult = member.auth != null && isAdultContentVisible, + isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible), contentType = contentType ) @@ -137,7 +138,7 @@ class AudioContentThemeService( memberId = member.id!!, theme = listOf(theme.theme), sortType = sortType, - isAdult = member.auth != null && isAdultContentVisible, + isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible), contentType = contentType, offset = offset, limit = limit diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt index 46cd9eb8..e756b2a6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -67,7 +67,7 @@ class ExplorerController( service.getCreatorProfile( creatorId = creatorId, timezone = timezone, - isAdultContentVisible = isAdultContentVisible ?: true, + isAdultContentVisible = isAdultContentVisible, member = member ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index 4a906d7b..930e11af 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -339,6 +339,7 @@ class ExplorerQueryRepository( fun getLiveRoomList( creatorId: Long, userMember: Member, + isAdult: Boolean, timezone: String, offset: Long = 0 ): List { @@ -361,7 +362,8 @@ class ExplorerQueryRepository( where = where.and(genderCondition.or(liveRoom.member.id.eq(userMember.id))) } - if (userMember.auth == null) { + // 라이브 목록 노출은 호출부에서 계산한 정책 결과(isAdult)만 신뢰해 필터링한다. + if (!isAdult) { where = where.and(liveRoom.isAdult.isFalse) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 4e7c555e..96c1beb7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -2,7 +2,6 @@ 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 @@ -31,6 +30,7 @@ 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,6 +48,7 @@ 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, @@ -257,9 +258,15 @@ class ExplorerService( fun getCreatorProfile( creatorId: Long, timezone: String, - isAdultContentVisible: Boolean, + isAdultContentVisible: Boolean?, member: Member ): GetCreatorProfileResponse { + val preference = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = null + ) + // 크리에이터(유저) 정보 val creatorAccount = queryRepository.getMember(creatorId) ?: throw SodaException(messageKey = "member.validation.user_not_found") @@ -307,6 +314,7 @@ class ExplorerService( queryRepository.getLiveRoomList( creatorId, userMember = member, + isAdult = preference.isAdult, timezone = timezone ) } else { @@ -318,8 +326,8 @@ class ExplorerService( audioContentService.getAudioContentList( creatorId = creatorId, sortType = SortType.NEWEST, - isAdultContentVisible = isAdultContentVisible, - contentType = ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member, offset = 0, limit = 3 @@ -348,7 +356,11 @@ class ExplorerService( // 크리에이터의 최신 오디오 콘텐츠 1개 val latestContent = if (isCreator && !isBlock) { - audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible) + audioContentService.getLatestCreatorAudioContent( + creatorId = creatorId, + member = member, + isAdultContentVisible = preference.isAdultContentVisible + ) } else { null } @@ -382,7 +394,7 @@ class ExplorerService( timezone = timezone, offset = 0, limit = 3, - isAdult = member.auth != null + isAdult = preference.isAdult ) } else { listOf() @@ -412,8 +424,8 @@ class ExplorerService( seriesService .getSeriesList( creatorId = creatorId, - isAdultContentVisible = isAdultContentVisible, - contentType = ContentType.ALL, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType, member = member ) .items diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt index a8b7b28e..663a6823 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt @@ -6,6 +6,7 @@ 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 @@ -23,7 +24,10 @@ import org.springframework.web.multipart.MultipartFile @RestController @RequestMapping("/creator-community") -class CreatorCommunityController(private val service: CreatorCommunityService) { +class CreatorCommunityController( + private val service: CreatorCommunityService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @PostMapping @PreAuthorize("hasRole('CREATOR')") fun createCommunityPost( @@ -92,6 +96,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult ApiResponse.ok( service.getCommunityPostList( @@ -100,7 +105,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { timezone = timezone, offset = pageable.offset, limit = pageable.pageSize.toLong(), - isAdult = member.auth != null + isAdult = isAdult ) ) } @@ -112,13 +117,14 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @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 = member.auth != null + isAdult = isAdult ) ) } @@ -129,8 +135,10 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @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)) + ApiResponse.ok(service.communityPostLike(request, member, isAdult)) } @PostMapping("/comment") @@ -139,6 +147,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @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( @@ -146,7 +155,8 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { postId = request.postId, parentId = request.parentId, isSecret = request.isSecret, - member = member + member = member, + isAdult = isAdult ) ) } @@ -171,6 +181,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult ApiResponse.ok( service.getCommunityPostCommentList( @@ -178,7 +189,8 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { memberId = member.id!!, timezone = timezone, offset = pageable.offset, - limit = pageable.pageSize.toLong() + limit = pageable.pageSize.toLong(), + isAdult = isAdult ) ) } @@ -191,6 +203,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult ApiResponse.ok( service.getCommentReplyList( @@ -198,7 +211,8 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { memberId = member.id!!, timezone = timezone, offset = pageable.offset, - limit = pageable.pageSize.toLong() + limit = pageable.pageSize.toLong(), + isAdult = isAdult ) ) } @@ -209,12 +223,13 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @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 = member.auth != null + isAdult = isAdult ) ) } @@ -225,13 +240,14 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @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 = member.auth != null, + isAdult = isAdult, container = request.container ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index da3d4c1f..0e908f7b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -380,14 +380,18 @@ class CreatorCommunityService( } @Transactional - fun communityPostLike(request: PostCommunityPostLikeRequest, member: Member): PostCommunityPostLikeResponse { + fun communityPostLike( + request: PostCommunityPostLikeRequest, + member: Member, + isAdult: Boolean + ): 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 = member.auth != null) + val post = repository.findByIdAndActive(request.postId, isAdult = isAdult) ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") postLike.creatorCommunity = post @@ -405,10 +409,11 @@ class CreatorCommunityService( comment: String, postId: Long, parentId: Long? = null, - isSecret: Boolean = false + isSecret: Boolean = false, + isAdult: Boolean ) { - val post = repository.findByIdOrNull(id = postId) - ?: throw SodaException(messageKey = "creator.community.invalid_post_retry") + val post = repository.findByIdAndActive(postId = postId, isAdult = isAdult) + ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") val creatorId = post.member!!.id!! @@ -480,10 +485,13 @@ class CreatorCommunityService( memberId: Long, timezone: String, offset: Long, - limit: Long + limit: Long, + isAdult: Boolean ): GetCommunityPostCommentListResponse { - val post = repository.findByIdOrNull(id = postId) - if (post != null && isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!)) { + val post = repository.findByIdAndActive(postId = postId, isAdult = isAdult) + ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") + + if (isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!)) { return GetCommunityPostCommentListResponse(totalCount = 0, items = listOf()) } @@ -509,9 +517,14 @@ class CreatorCommunityService( memberId: Long, timezone: String, offset: Long, - limit: Long + limit: Long, + isAdult: Boolean ): 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!!) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt new file mode 100644 index 00000000..811302fd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt @@ -0,0 +1,20 @@ +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 { + return repository.getRecommendLive( + memberId = memberId, + isAdult = isAdult + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt index 66616436..108d1249 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt @@ -3,29 +3,37 @@ 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 org.springframework.cache.annotation.Cacheable +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService 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 blockMemberRepository: BlockMemberRepository, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val liveRecommendCacheService: LiveRecommendCacheService ) { - @Transactional(readOnly = true) - @Cacheable( - cacheNames = ["cache_ttl_3_hours"], - key = "'getRecommendLive:' + (#member?.id ?: 'guest')" - ) fun getRecommendLive(member: Member?): List { - return repository.getRecommendLive( + val isAdult = if (member != null) { + memberContentPreferenceService.getStoredPreference(member).isAdult + } else { + false + } + + return liveRecommendCacheService.getRecommendLive( memberId = member?.id, - isAdult = member?.auth != null + isAdult = isAdult ) } fun getRecommendChannelList(member: Member?): List { + val isAdult = if (member != null) { + memberContentPreferenceService.getStoredPreference(member).isAdult + } else { + false + } + val onAirChannelList = repository.getOnAirRecommendChannelList( isBlocked = { if (member != null) { @@ -35,7 +43,7 @@ class LiveRecommendService( } }, isCreator = member?.role == MemberRole.CREATOR, - isAdult = member?.auth != null + isAdult = isAdult ) if (onAirChannelList.size >= 20) { @@ -60,11 +68,13 @@ class LiveRecommendService( } fun getFollowingChannelList(member: Member): List { + 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 = member.auth != null + isAdult = isAdult ) if (onAirFollowingChannelList.size >= 20) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt index c78d0db7..d1deba6e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt @@ -43,7 +43,7 @@ class LiveRoomController( service.getRoomList( dateString, status, - isAdultContentVisible ?: true, + isAdultContentVisible, pageable, member, timezone diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 571df5e1..6567bb9b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -17,6 +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 @@ -63,6 +64,8 @@ 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 @@ -106,6 +109,7 @@ class LiveRoomService( 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, @@ -201,11 +205,13 @@ class LiveRoomService( fun getRoomList( dateString: String?, status: LiveRoomStatus, - isAdultContentVisible: Boolean, + isAdultContentVisible: Boolean?, pageable: Pageable, member: Member?, timezone: String ): List { + val preference = resolvePreference(member, isAdultContentVisible) + val isAdult = preference.isAdult val effectiveGender = member?.let { if (it.auth != null) { if (it.auth!!.gender == 1) Gender.MALE else Gender.FEMALE @@ -219,7 +225,7 @@ class LiveRoomService( timezone, memberId = member?.id, isCreator = member?.role == MemberRole.CREATOR, - isAdult = true, + isAdult = isAdult, effectiveGender = effectiveGender ) } else if (dateString != null) { @@ -229,7 +235,7 @@ class LiveRoomService( timezone, memberId = member?.id, isCreator = member?.role == MemberRole.CREATOR, - isAdult = member?.auth != null && isAdultContentVisible, + isAdult = isAdult, effectiveGender = effectiveGender ) } else { @@ -237,7 +243,7 @@ class LiveRoomService( timezone, isCreator = member?.role == MemberRole.CREATOR, memberId = member?.id, - isAdult = member?.auth != null && isAdultContentVisible, + isAdult = isAdult, effectiveGender = effectiveGender ) } @@ -529,7 +535,8 @@ class LiveRoomService( throw SodaException(messageKey = "live.room.already_ended") } - if (room.isAdult && member.auth == null) { + val preference = memberContentPreferenceService.getStoredPreference(member) + if (room.isAdult && !preference.isAdult) { throw SodaException(messageKey = "live.room.adult_verification_required") } @@ -770,6 +777,11 @@ 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 && @@ -1458,6 +1470,23 @@ class LiveRoomService( return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } } + private fun resolvePreference(member: Member?, isAdultContentVisible: Boolean?): ViewerContentPreference { + if (member == null) { + return ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = isAdultContentVisible ?: false, + contentType = ContentType.ALL, + isAdult = false + ) + } + + return memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = null + ) + } + @Transactional fun likeHeart(request: LiveRoomLikeHeartRequest, member: Member) { val room = repository.findByIdOrNull(request.roomId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagRepository.kt index 8b436f1c..3704f16d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagRepository.kt @@ -2,8 +2,6 @@ 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 @@ -13,15 +11,15 @@ interface LiveTagRepository : JpaRepository, LiveTagQueryReposito } interface LiveTagQueryRepository { - fun getTags(member: Member, cloudFrontHost: String): List + fun getTags(isAdult: Boolean, cloudFrontHost: String): List } @Repository class LiveTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveTagQueryRepository { - override fun getTags(member: Member, cloudFrontHost: String): List { + override fun getTags(isAdult: Boolean, cloudFrontHost: String): List { var where = liveTag.isActive.isTrue - if (member.role != MemberRole.ADMIN && member.auth == null) { + if (!isAdult) { where = where.and(liveTag.isAdult.isFalse) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt index f97d887f..36604dcc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt @@ -5,6 +5,8 @@ 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 @@ -15,6 +17,7 @@ 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, @@ -91,7 +94,15 @@ class LiveTagService( } fun getTags(member: Member): List { - return repository.getTags(member = member, cloudFrontHost = cloudFrontHost) + // 관리자 화면에서는 운영 확인 목적상 성인 태그까지 전체 조회를 허용한다. + val isAdult = if (member.role == MemberRole.ADMIN) { + true + } else { + // 일반 사용자는 저장된 선호 정책(isAdult) 기준으로만 태그 노출을 제한한다. + memberContentPreferenceService.getStoredPreference(member).isAdult + } + + return repository.getTags(isAdult = isAdult, cloudFrontHost = cloudFrontHost) } fun tagExistCheck(request: CreateLiveTagRequest) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index e5d9368f..74df0080 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -7,6 +7,9 @@ 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 @@ -20,6 +23,7 @@ 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 @@ -35,6 +39,7 @@ 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, @@ -136,6 +141,27 @@ 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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index a83afc6e..5ea67a34 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -17,7 +17,11 @@ 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, MemberQueryRepository { @@ -27,6 +31,10 @@ interface MemberRepository : JpaRepository, 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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 254c0e59..872cde05 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -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,7 +82,6 @@ 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, @@ -106,6 +105,7 @@ 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,6 +120,8 @@ class MemberService( private val tokenLocks: MutableMap = mutableMapOf() private val recommendLiveCacheKeyPrefix = "getRecommendLive:" + private val recommendLiveCacheKeySuffixFalse = ":false" + private val recommendLiveCacheKeySuffixTrue = ":true" private val latestFinishedLiveCacheKeyPrefix = "getLatestFinishedLive:" @Transactional @@ -154,6 +156,7 @@ class MemberService( } repository.save(member) + memberContentPreferenceService.initializeDefaultPreference(member) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) if (request.pushToken != null) { @@ -192,6 +195,7 @@ class MemberService( duplicateCheckNickname(request.nickname) val member = createMember(request) + memberContentPreferenceService.initializeDefaultPreference(member) member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) @@ -217,6 +221,8 @@ 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) @@ -250,7 +256,10 @@ class MemberService( messageNotice = member.notification?.message, followingChannelLiveNotice = member.notification?.live, followingChannelUploadContentNotice = member.notification?.uploadContent, - auditionNotice = member.notification?.audition + auditionNotice = member.notification?.audition, + countryCode = preference.countryCode, + isAdultContentVisible = preference.isAdultContentVisible, + contentType = preference.contentType ) } @@ -840,7 +849,11 @@ class MemberService( } private fun evictRecommendLiveCache(memberId: Long) { - cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId) + val cache = cacheManager.getCache("cache_ttl_3_hours") ?: return + + cache.evict(recommendLiveCacheKeyPrefix + memberId + recommendLiveCacheKeySuffixFalse) + cache.evict(recommendLiveCacheKeyPrefix + memberId + recommendLiveCacheKeySuffixTrue) + cache.evict(recommendLiveCacheKeyPrefix + memberId) } private fun evictLatestFinishedLiveCache(memberId: Long) { @@ -910,6 +923,7 @@ class MemberService( } repository.save(member) + memberContentPreferenceService.initializeDefaultPreference(member) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) if (pushToken != null) { @@ -967,6 +981,7 @@ class MemberService( } repository.save(member) + memberContentPreferenceService.initializeDefaultPreference(member) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) if (pushToken != null) { @@ -1024,6 +1039,7 @@ class MemberService( } repository.save(member) + memberContentPreferenceService.initializeDefaultPreference(member) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) if (pushToken != null) { @@ -1081,6 +1097,7 @@ class MemberService( } repository.save(member) + memberContentPreferenceService.initializeDefaultPreference(member) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) if (pushToken != null) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt index 8271e953..a3c7c44a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt @@ -3,6 +3,7 @@ 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 @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/auth") class AuthController( private val service: AuthService, + private val memberContentPreferenceService: MemberContentPreferenceService, private val userActionService: UserActionService ) { @PostMapping @@ -32,6 +34,7 @@ class AuthController( } val authResponse = service.authenticate(authenticateData, member.id!!) + memberContentPreferenceService.markAdultVisibleAfterAuthVerify(member.id!!) try { userActionService.recordAction( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreference.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreference.kt new file mode 100644 index 00000000..5919ffe3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreference.kt @@ -0,0 +1,33 @@ +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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt new file mode 100644 index 00000000..e615b725 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt @@ -0,0 +1,23 @@ +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, 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" +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt new file mode 100644 index 00000000..c848a0d6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt @@ -0,0 +1,19 @@ +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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt new file mode 100644 index 00000000..b1825da1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt @@ -0,0 +1,17 @@ +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 { + 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? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt new file mode 100644 index 00000000..857d8e81 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt @@ -0,0 +1,247 @@ +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 +) { + 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 { + 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 = false, + contentType = ContentType.ALL, + 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, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ): ViewerContentPreference { + val preference = initializeDefaultPreference(member) + val countryCode = resolveCountryCode(member) + + val hasChanged = if (isAdultContentVisible != null || contentType != null) { + applyRequestValues( + preference = preference, + member = member, + countryCode = countryCode, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) + } else { + false + } + + if (hasChanged) { + evictRecommendLiveCacheAfterCommit(requireMemberId(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 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") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/UpdateMemberContentPreferenceRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/UpdateMemberContentPreferenceRequest.kt new file mode 100644 index 00000000..e8bfd64e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/UpdateMemberContentPreferenceRequest.kt @@ -0,0 +1,8 @@ +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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/UpdateMemberContentPreferenceResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/UpdateMemberContentPreferenceResponse.kt new file mode 100644 index 00000000..66133716 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/UpdateMemberContentPreferenceResponse.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.member.contentpreference + +import kr.co.vividnext.sodalive.content.ContentType + +data class UpdateMemberContentPreferenceResponse( + val isAdultContentVisible: Boolean, + val contentType: ContentType +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/ViewerContentPreference.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/ViewerContentPreference.kt new file mode 100644 index 00000000..511ebd1b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/ViewerContentPreference.kt @@ -0,0 +1,10 @@ +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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt index 4a5554f7..82b8a93a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.member.info +import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.member.MemberRole data class GetMemberInfoResponse( @@ -13,5 +14,8 @@ data class GetMemberInfoResponse( val messageNotice: Boolean?, val followingChannelLiveNotice: Boolean?, val followingChannelUploadContentNotice: Boolean?, - val auditionNotice: Boolean? + val auditionNotice: Boolean?, + val countryCode: String, + val isAdultContentVisible: Boolean, + val contentType: ContentType ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt index 5ad5d9b9..d1bb4213 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt @@ -4,6 +4,7 @@ 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,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/search") -class SearchController(private val service: SearchService) { +class SearchController( + private val service: SearchService, + private val memberContentPreferenceService: MemberContentPreferenceService +) { @GetMapping fun searchUnified( @RequestParam keyword: String, @@ -22,11 +26,12 @@ class SearchController(private val service: SearchService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.searchUnified( keyword, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, member = member ) ) @@ -35,8 +40,6 @@ class SearchController(private val service: SearchService) { @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,8 +47,6 @@ class SearchController(private val service: SearchService) { ApiResponse.ok( service.searchCreatorList( keyword, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -62,11 +63,12 @@ class SearchController(private val service: SearchService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.searchContentList( keyword, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -83,15 +85,26 @@ class SearchController(private val service: SearchService) { pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val preference = resolvePreference(member, isAdultContentVisible, contentType) ApiResponse.ok( service.searchSeriesList( keyword, - isAdultContentVisible = isAdultContentVisible ?: true, - contentType = contentType ?: ContentType.ALL, + isAdult = preference.isAdult, + contentType = preference.contentType, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() ) ) } + + private fun resolvePreference( + member: Member, + isAdultContentVisible: Boolean?, + contentType: ContentType? + ) = memberContentPreferenceService.resolveForQuery( + member = member, + isAdultContentVisible = isAdultContentVisible, + contentType = contentType + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt index 811ea595..ed66bfe2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt @@ -8,12 +8,10 @@ import org.springframework.stereotype.Service class SearchService(private val repository: SearchRepository) { fun searchUnified( keyword: String, - isAdultContentVisible: Boolean, + isAdult: Boolean, contentType: ContentType, member: Member ): SearchUnifiedResponse { - val isAdult = member.auth != null && isAdultContentVisible - val creatorList = repository.searchCreatorList( keyword = keyword, memberId = member.id!!, @@ -60,8 +58,6 @@ class SearchService(private val repository: SearchRepository) { fun searchCreatorList( keyword: String, - isAdultContentVisible: Boolean, - contentType: ContentType, member: Member, offset: Long, limit: Long @@ -83,14 +79,12 @@ class SearchService(private val repository: SearchRepository) { fun searchContentList( keyword: String, - isAdultContentVisible: Boolean, + isAdult: Boolean, contentType: ContentType, member: Member, offset: Long, limit: Long ): SearchResponse { - val isAdult = member.auth != null && isAdultContentVisible - val totalCount = repository.searchContentTotalCount( keyword, memberId = member.id!!, @@ -116,14 +110,12 @@ class SearchService(private val repository: SearchRepository) { fun searchSeriesList( keyword: String, - isAdultContentVisible: Boolean, + isAdult: Boolean, contentType: ContentType, member: Member, offset: Long, limit: Long ): SearchResponse { - val isAdult = member.auth != null && isAdultContentVisible - val totalCount = repository.searchSeriesTotalCount( keyword, memberId = member.id!!, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt index e71efa89..192892f0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt @@ -100,6 +100,28 @@ class AudioContentServiceTest { ) } + @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() { @@ -220,7 +242,7 @@ class AudioContentServiceTest { return member } - private fun createAudioContent(creator: Member): AudioContent { + private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent { val theme = AudioContentTheme(theme = "수면", image = "sleep.png") theme.id = 300L @@ -232,7 +254,7 @@ class AudioContentServiceTest { purchaseOption = PurchaseOption.BOTH, isGeneratePreview = true, isOnlyRental = false, - isAdult = false, + isAdult = isAdult, isPointAvailable = true, isCommentAvailable = true, isFullDetailVisible = true diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index 44ba0306..9afa68df 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -8,7 +8,9 @@ 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 @@ -36,6 +38,7 @@ 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 @@ -45,6 +48,7 @@ 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) @@ -53,7 +57,7 @@ class CreatorCommunityServiceTest { canPaymentService = Mockito.mock(CanPaymentService::class.java), repository = repository, blockMemberRepository = blockMemberRepository, - likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java), + likeRepository = likeRepository, commentRepository = commentRepository, useCanRepository = useCanRepository, s3Uploader = Mockito.mock(S3Uploader::class.java), @@ -68,6 +72,29 @@ 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() { @@ -77,7 +104,7 @@ class CreatorCommunityServiceTest { post.id = 301L post.member = creator - Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post)) + Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(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) } @@ -87,7 +114,8 @@ class CreatorCommunityServiceTest { comment = "새 댓글", postId = post.id!!, parentId = null, - isSecret = false + isSecret = false, + isAdult = true ) val captor = ArgumentCaptor.forClass(FcmEvent::class.java) @@ -112,7 +140,7 @@ class CreatorCommunityServiceTest { post.id = 401L post.member = creator - Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post)) + Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(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) } @@ -122,12 +150,80 @@ class CreatorCommunityServiceTest { comment = "내가 단 댓글", postId = post.id!!, parentId = null, - isSecret = false + isSecret = false, + isAdult = true ) 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() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt index 126bd3e9..2190e34a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt @@ -3,6 +3,8 @@ 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 @@ -11,13 +13,22 @@ 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) - service = LiveRecommendService(repository, blockMemberRepository) + memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + liveRecommendCacheService = Mockito.mock(LiveRecommendCacheService::class.java) + service = LiveRecommendService( + repository = repository, + blockMemberRepository = blockMemberRepository, + memberContentPreferenceService = memberContentPreferenceService, + liveRecommendCacheService = liveRecommendCacheService + ) } @Test @@ -39,24 +50,35 @@ class LiveRecommendServiceTest { auth.member = member val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend.png", creatorId = 77L)) - Mockito.`when`(repository.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected) + 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) val result = service.getRecommendLive(member) assertEquals(expected, result) - Mockito.verify(repository).getRecommendLive(memberId = member.id, isAdult = true) + Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = member.id, isAdult = true) + Mockito.verifyNoInteractions(repository) Mockito.verifyNoInteractions(blockMemberRepository) } @Test fun shouldDelegateToRepositoryAsGuestWhenMemberIsNull() { val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend-guest.png", creatorId = 88L)) - Mockito.`when`(repository.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected) + Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected) val result = service.getRecommendLive(null) assertEquals(expected, result) - Mockito.verify(repository).getRecommendLive(memberId = null, isAdult = false) + Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = null, isAdult = false) + Mockito.verifyNoInteractions(repository) Mockito.verifyNoInteractions(blockMemberRepository) + Mockito.verifyNoInteractions(memberContentPreferenceService) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagServiceTest.kt new file mode 100644 index 00000000..5a34d5f9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagServiceTest.kt @@ -0,0 +1,87 @@ +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(), + 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 mock(): T { + return Mockito.mock(T::class.java) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberControllerTest.kt new file mode 100644 index 00000000..ae1112e5 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberControllerTest.kt @@ -0,0 +1,130 @@ +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 mock(): T { + return Mockito.mock(T::class.java) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt index 2b8ee297..ff2cb59b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt @@ -9,6 +9,7 @@ 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 @@ -42,7 +43,6 @@ class MemberServiceCacheEvictionTest { stipulationAgreeRepository = mock(), creatorFollowingRepository = mock(), blockMemberRepository = blockMemberRepository, - authRepository = authRepository, signOutRepository = mock(), nicknameChangeLogRepository = mock(), memberTagRepository = mock(), @@ -63,6 +63,7 @@ class MemberServiceCacheEvictionTest { messageSource = SodaMessageSource(), langContext = LangContext(), countryContext = CountryContext(), + memberContentPreferenceService = mock(), objectMapper = ObjectMapper(), cacheManager = cacheManager, s3Bucket = "test-bucket", @@ -88,8 +89,8 @@ class MemberServiceCacheEvictionTest { service.memberBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId) - Mockito.verify(cache).evict("getRecommendLive:$memberId") - Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId") + verifyRecommendLiveCacheEvicted(memberId) + verifyRecommendLiveCacheEvicted(blockedMemberId) Mockito.verifyNoInteractions(authRepository) } @@ -140,9 +141,9 @@ class MemberServiceCacheEvictionTest { blockedMemberId = linkedMemberId, memberId = memberId ) - Mockito.verify(cache).evict("getRecommendLive:$memberId") - Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId") - Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$linkedMemberId") + verifyRecommendLiveCacheEvicted(memberId) + verifyRecommendLiveCacheEvicted(blockedMemberId) + verifyRecommendLiveCacheNotEvicted(linkedMemberId) Mockito.verifyNoInteractions(authRepository) } @@ -162,8 +163,20 @@ 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") - Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId") + } + + 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") } private fun createMember(id: Long, nickname: String): Member { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt new file mode 100644 index 00000000..f4fb2ed5 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt @@ -0,0 +1,165 @@ +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(), + 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(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 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 +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/auth/AuthControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/auth/AuthControllerTest.kt new file mode 100644 index 00000000..bf9d79de --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/auth/AuthControllerTest.kt @@ -0,0 +1,109 @@ +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 mock(): T { + return Mockito.mock(T::class.java) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt new file mode 100644 index 00000000..319e337f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt @@ -0,0 +1,205 @@ +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, 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("legacy 파라미터 최초 호출 시 row를 생성하고 같은 흐름에서 저장값 조회에 즉시 반영한다") + fun shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall() { + val member = saveNonForcedMember("legacy-user") + countryContext.setCountryCode("US") + + assertEquals(null, preferenceRepository.findByMemberId(member.id!!)) + + val resolved = service.resolveForQuery( + member = member, + isAdultContentVisible = true, + contentType = ContentType.MALE + ) + val stored = service.getStoredPreference(member) + + assertNotNull(preferenceRepository.findByMemberId(member.id!!)) + assertTrue(resolved.isAdultContentVisible) + assertEquals(ContentType.MALE, resolved.contentType) + assertEquals("US", resolved.countryCode) + assertTrue(stored.isAdultContentVisible) + assertEquals(ContentType.MALE, 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, + isAdultContentVisible = true, + contentType = ContentType.MALE + ) + 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 + 인증 사용자는 요청값이 저장되고 성인 조회값(isAdult)이 true로 계산된다") + fun shouldApplyRequestValuesForKrAuthenticatedMember() { + val member = saveNonForcedMember("kr-auth-user") + countryContext.setCountryCode(null) + saveAuth(member) + + val reloadedMember = memberRepository.findById(member.id!!).orElseThrow() + val resolved = service.resolveForQuery( + member = reloadedMember, + isAdultContentVisible = true, + contentType = ContentType.FEMALE + ) + + assertEquals("KR", resolved.countryCode) + assertTrue(resolved.isAdultContentVisible) + assertEquals(ContentType.FEMALE, resolved.contentType) + assertTrue(resolved.isAdult) + } + + @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 krMember = Member(email = "kr@test.com", password = "password", nickname = "kr-member").apply { id = 16L } + + assertEquals("JP", service.resolveCountryCode(jpMember)) + 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() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt new file mode 100644 index 00000000..287c78b3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt @@ -0,0 +1,87 @@ +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 forcedKrMember = createMember(id = 16L, countryCode = "US") + + assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMember)) + 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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt new file mode 100644 index 00000000..b79951e0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt @@ -0,0 +1,468 @@ +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 생성 경쟁 시 잠금 이후 재조회한 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("contentType 미전달 조회는 기존 contentType을 유지한다") + fun shouldKeepStoredContentTypeWhenContentTypeIsNotProvided() { + 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, + isAdultContentVisible = true, + contentType = null + ) + + assertEquals(ContentType.FEMALE, result.contentType) + assertTrue(result.isAdultContentVisible) + } + + @Test + @DisplayName("legacy 조회 파라미터로 저장값이 바뀌면 추천 라이브 캐시를 무효화한다") + fun shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery() { + 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, + isAdultContentVisible = true, + contentType = null + ) + + verifyRecommendLiveCacheEvicted(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 mock(): T { + return Mockito.mock(T::class.java) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt new file mode 100644 index 00000000..47366f99 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt @@ -0,0 +1,159 @@ +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 + } + } +}