feat(content-preference): 콘텐츠 조회 설정 서버 저장 전환을 반영한다

This commit is contained in:
2026-03-27 13:33:51 +09:00
parent 1ba3cb8a40
commit a87bd147dc
75 changed files with 3593 additions and 301 deletions

View File

@@ -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 검증으로 대체했다.

View File

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

View File

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

View File

@@ -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<AudioContentMainItem> {
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<GetSeriesListResponse.SeriesListItem> {
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<GetAudioContentRankingItem> {
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<Long> = emptyList()
): List<AudioContentMainItem> {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
return getRecommendContentListByPreference(preference, member, excludeContentIds)
}
private fun getRecommendContentListByPreference(
preference: ViewerContentPreference,
member: Member?,
excludeContentIds: List<Long>
): List<AudioContentMainItem> {
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<AudioContentMainItem>,
targetSize: Int,

View File

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

View File

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

View File

@@ -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")
}
}
}

View File

@@ -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<CharacterMainResponse> = 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
}
}

View File

@@ -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")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<GetAudioContentMainItem> {
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<GetAudioContentMainItem> {
return tagCurationService.getTagCurationContentList(memberId = memberId, tag = tag, contentType = contentType)
return tagCurationService.getTagCurationContentList(
memberId = memberId,
tag = tag,
isAdult = isAdult,
contentType = contentType
)
}
}

View File

@@ -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<GetAudioContentMainItem> {
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(

View File

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

View File

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

View File

@@ -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<GetAudioContentMainItem> {
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,

View File

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

View File

@@ -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<GetAudioContentRankingItem> {
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime

View File

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

View File

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

View File

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

View File

@@ -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!!
// 메인 배너 (시리즈)

View File

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

View File

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

View File

@@ -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<GetSeriesListResponse.SeriesListItem> {
val isAuth = member.auth != null && isAdultContentVisible
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
return repository.getRecommendSeriesListV2(
imageHost = coverImageHost,
isAuth = isAuth,

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ class ExplorerController(
service.getCreatorProfile(
creatorId = creatorId,
timezone = timezone,
isAdultContentVisible = isAdultContentVisible ?: true,
isAdultContentVisible = isAdultContentVisible,
member = member
)
)

View File

@@ -339,6 +339,7 @@ class ExplorerQueryRepository(
fun getLiveRoomList(
creatorId: Long,
userMember: Member,
isAdult: Boolean,
timezone: String,
offset: Long = 0
): List<LiveRoomResponse> {
@@ -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)
}

View File

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

View File

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

View File

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

View File

@@ -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<GetRecommendLiveResponse> {
return repository.getRecommendLive(
memberId = memberId,
isAdult = isAdult
)
}
}

View File

@@ -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<GetRecommendLiveResponse> {
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<GetRecommendChannelResponse> {
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<GetRecommendChannelResponse> {
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
val onAirFollowingChannelList = repository.getOnAirFollowingChannelList(
memberId = member.id!!,
isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) },
isCreator = member.role == MemberRole.CREATOR,
isAdult = member.auth != null
isAdult = isAdult
)
if (onAirFollowingChannelList.size >= 20) {

View File

@@ -43,7 +43,7 @@ class LiveRoomController(
service.getRoomList(
dateString,
status,
isAdultContentVisible ?: true,
isAdultContentVisible,
pageable,
member,
timezone

View File

@@ -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<GetRoomListResponse> {
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)

View File

@@ -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<LiveTag, Long>, LiveTagQueryReposito
}
interface LiveTagQueryRepository {
fun getTags(member: Member, cloudFrontHost: String): List<GetLiveTagResponse>
fun getTags(isAdult: Boolean, cloudFrontHost: String): List<GetLiveTagResponse>
}
@Repository
class LiveTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveTagQueryRepository {
override fun getTags(member: Member, cloudFrontHost: String): List<GetLiveTagResponse> {
override fun getTags(isAdult: Boolean, cloudFrontHost: String): List<GetLiveTagResponse> {
var where = liveTag.isActive.isTrue
if (member.role != MemberRole.ADMIN && member.auth == null) {
if (!isAdult) {
where = where.and(liveTag.isAdult.isFalse)
}

View File

@@ -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<GetLiveTagResponse> {
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) {

View File

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

View File

@@ -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<Member, Long>, MemberQueryRepository {
@@ -27,6 +31,10 @@ interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository
fun findByKakaoId(kakaoId: Long): Member?
fun findByAppleId(appleId: String): Member?
fun findByLineId(lineId: String): Member?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from Member m where m.id = :memberId")
fun findByIdForUpdate(@Param("memberId") memberId: Long): Member?
}
interface MemberQueryRepository {

View File

@@ -17,11 +17,11 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.block.GetBlockedMemberListResponse
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.member.info.GetMemberInfoResponse
@@ -82,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<Long, ReentrantReadWriteLock> = 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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MemberContentPreference, Long> {
fun findByMemberId(memberId: Long): MemberContentPreference?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select mcp from MemberContentPreference mcp where mcp.member.id = :memberId")
fun findByMemberIdForUpdate(@Param("memberId") memberId: Long): MemberContentPreference?
}

View File

@@ -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")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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<S3Uploader>(),
coverImageBucket = "bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("일반 사용자는 저장된 성인 설정값으로 라이브 태그 필터를 적용한다")
fun shouldApplyStoredPreferenceForNonAdminMember() {
val member = createMember(id = 1L, role = MemberRole.USER)
val expected = listOf(GetLiveTagResponse(1L, "일반", "https://cdn.test/live1.png", false))
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
)
Mockito.`when`(repository.getTags(isAdult = false, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(member)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = false, cloudFrontHost = "https://cdn.test")
}
@Test
@DisplayName("관리자는 저장 설정과 무관하게 성인 태그를 포함해 조회한다")
fun shouldAllowAdultTagsForAdmin() {
val admin = createMember(id = 2L, role = MemberRole.ADMIN)
val expected = listOf(GetLiveTagResponse(2L, "성인", "https://cdn.test/live2.png", true))
Mockito.`when`(repository.getTags(isAdult = true, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(admin)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = true, cloudFrontHost = "https://cdn.test")
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
private fun createMember(id: Long, role: MemberRole): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
role = role
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -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 <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -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<MemberContentPreferenceService>(),
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 {

View File

@@ -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<CacheManager>(),
s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("getMemberInfo는 저장된 콘텐츠 설정 필드를 그대로 반환한다")
fun shouldReturnStoredPreferenceFieldsInMemberInfo() {
val member = createMember(1L)
member.createdAt = LocalDateTime.of(2026, 1, 1, 0, 0)
val preference = ViewerContentPreference(
countryCode = "JP",
isAdultContentVisible = true,
contentType = ContentType.MALE,
isAdult = true
)
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(preference)
Mockito.`when`(chargeRepository.getChargeCount(1L)).thenReturn(3)
Mockito.`when`(
memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
memberId = Mockito.eq(1L),
expiresAt = anyLocalDateTime()
)
).thenReturn(emptyList())
val response = service.getMemberInfo(member, "web")
assertEquals("JP", response.countryCode)
assertEquals(true, response.isAdultContentVisible)
assertEquals(ContentType.MALE, response.contentType)
}
@Test
@DisplayName("Google 소셜 회원 신규 생성 시 기본 콘텐츠 설정을 선저장한다")
fun shouldInitializePreferenceWhenGoogleMemberIsRegistered() {
var savedMember: Member? = null
val terms = Stipulation(title = "terms", description = "desc")
terms.id = StipulationIds.TERMS_OF_SERVICE_ID
val privacy = Stipulation(title = "privacy", description = "desc")
privacy.id = StipulationIds.PRIVACY_POLICY_ID
Mockito.`when`(repository.findByGoogleId("sub-1")).thenReturn(null)
Mockito.`when`(repository.findByEmail("google@test.com")).thenReturn(null)
Mockito.`when`(stipulationRepository.findById(StipulationIds.TERMS_OF_SERVICE_ID)).thenReturn(Optional.of(terms))
Mockito.`when`(stipulationRepository.findById(StipulationIds.PRIVACY_POLICY_ID)).thenReturn(Optional.of(privacy))
Mockito.`when`(nicknameGenerateService.generateUniqueNickname(anyLang())).thenReturn("newbie")
Mockito.`when`(repository.save(Mockito.any(Member::class.java))).thenAnswer { invocation ->
val saved = invocation.getArgument<Member>(0)
saved.id = 10L
savedMember = saved
saved
}
Mockito.`when`(stipulationAgreeRepository.save(Mockito.any())).thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.findOrRegister(
googleUserInfo = GoogleUserInfo(sub = "sub-1", email = "google@test.com", name = "google-user"),
container = "web",
marketingPid = null,
pushToken = null
)
assertTrue(result.isNew)
Mockito.verify(memberContentPreferenceService).initializeDefaultPreference(savedMember!!)
assertEquals(10L, savedMember!!.id)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
private fun anyLocalDateTime(): LocalDateTime =
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
private fun anyLang(): Lang =
Mockito.any(Lang::class.java) ?: Lang.KO
}

View File

@@ -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 <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -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()
}
}

View File

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

View File

@@ -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 <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

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