feat(content-preference): 콘텐츠 조회 설정 서버 저장 전환을 반영한다
This commit is contained in:
781
docs/20260325_콘텐츠조회설정서버저장전환.md
Normal file
781
docs/20260325_콘텐츠조회설정서버저장전환.md
Normal 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 검증으로 대체했다.
|
||||
30
docs/20260326_member_content_preference_ddl.sql
Normal file
30
docs/20260326_member_content_preference_ddl.sql
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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!!
|
||||
|
||||
// 메인 배너 (시리즈)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@ class ExplorerController(
|
||||
service.getCreatorProfile(
|
||||
creatorId = creatorId,
|
||||
timezone = timezone,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
member = member
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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!!)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -43,7 +43,7 @@ class LiveRoomController(
|
||||
service.getRoomList(
|
||||
dateString,
|
||||
status,
|
||||
isAdultContentVisible ?: true,
|
||||
isAdultContentVisible,
|
||||
pageable,
|
||||
member,
|
||||
timezone
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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!!,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user