Compare commits

...

26 Commits

Author SHA1 Message Date
6a10eff15f Merge pull request 'fix(live-room): 진행중 목록 성인 노출 정책과 JP 강제 매핑 검증을 정리한다' (#412) from test into main
Reviewed-on: #412
2026-03-28 14:11:08 +00:00
2160e7b9dd fix(live-room): 진행중 목록 성인 노출 정책과 JP 강제 매핑 검증을 정리한다 2026-03-28 22:53:44 +09:00
fea329e637 Merge pull request 'fix(channel-donation): 후원 목록 탈퇴 닉네임 접두사를 제거한다' (#411) from test into main
Reviewed-on: #411
2026-03-28 10:14:16 +00:00
0efdfbeed8 fix(channel-donation): 후원 목록 탈퇴 닉네임 접두사를 제거한다 2026-03-28 19:06:04 +09:00
681e4a4036 Merge pull request 'test' (#410) from test into main
Reviewed-on: #410
2026-03-28 09:27:28 +00:00
feb1ab9f13 fix(content-preference): 조회 API 선호도 쿼리 파라미터를 제거한다 2026-03-28 18:09:39 +09:00
ff47a7686a fix(content-preference): 조회 선호도 오버라이드 파라미터를 제거해 저장값만 사용한다 2026-03-28 00:51:19 +09:00
ae68886bdb fix(content-preference): 멤버 콘텐츠 선호 신규 생성 정책을 저장값 기준으로 정리한다 2026-03-27 21:37:59 +09:00
a87bd147dc feat(content-preference): 콘텐츠 조회 설정 서버 저장 전환을 반영한다 2026-03-27 13:33:51 +09:00
c23f574162 Merge pull request 'fix(member): 회원 차단을 요청 ID 단건만 적용한다' (#409) from test into main
Reviewed-on: #409
2026-03-26 02:01:43 +00:00
1ba3cb8a40 fix(member): 회원 차단을 요청 ID 단건만 적용한다 2026-03-25 20:42:24 +09:00
c884d7d6c9 Merge pull request 'test' (#408) from test into main
Reviewed-on: #408
2026-03-24 10:41:41 +00:00
447735cad5 fix(content): 차단된 구매자의 오디오 상세 조회를 허용한다 2026-03-24 19:21:58 +09:00
681ee11784 feat(live-room): 라이브 생성 태그 기반 19금 전환 조건 확장 2026-03-24 11:42:29 +09:00
116e8cbca3 Merge pull request 'feat(deploy): EC2 배포 스크립트에 JVM 옵션 로드 기능 추가' (#407) from test into main
Reviewed-on: #407
2026-03-23 09:45:25 +00:00
bbb82a27c7 feat(deploy): EC2 배포 스크립트에 JVM 옵션 로드 기능 추가 2026-03-23 18:29:10 +09:00
c8187ba147 Merge pull request 'feat(db): Aurora Serverless v2(0.5~2 ACU) 최적화용 Hikari 풀 설정 추가' (#406) from test into main
Reviewed-on: #406
2026-03-23 05:13:58 +00:00
cfc679611c feat(db): Aurora Serverless v2(0.5~2 ACU) 최적화용 Hikari 풀 설정 추가
- maximumPoolSize=10, minimumIdle=0, idleTimeout=2m, maxLifetime=30m, connectionTimeout=10s, keepalive=0 적용
- 환경변수 미설정 시 안전한 기본값으로 동작하도록 `${DB_POOL_*}` 기본값 제공
- 유휴 시 커넥션 상주 최소화로 다운스케일 유도 및 비용/성능 균형 개선
2026-03-23 13:55:51 +09:00
676bd0b79e Merge pull request 'test' (#405) from test into main
Reviewed-on: #405
2026-03-19 09:33:40 +00:00
fe093a942c perf(explorer:creator-profile): 라이브방 목록 N+1 제거 및 예약/결제 여부 일괄 조회
- member 연관 로딩에 fetch join 적용으로 N+1 제거
- reservations 컬렉션 접근 제거 → QLiveReservation 기반 방 ID 일괄 조회로 isReservation 계산
- useCan per-room 조회 제거 → 방 ID 집합 일괄 조회(Set)로 isPaid 계산
- 기존 비즈니스 로직(날짜 포맷, 성인/성별 필터, PRIVATE 플래그 등) 유지
2026-03-19 16:45:36 +09:00
2e0f0c5044 fix(explorer): getCreatorProfile 라이브 응답의 coverImageUrl을 크리에이터 프로필 이미지로 교체
- ExplorerQueryRepository의 LiveRoomResponse 매핑에서 커버 이미지 → 프로필 이미지로 변경
- 프로필 이미지 URL 규칙 적용: null/빈→기본 이미지, https로 시작 시 원본 유지, 상대 경로는 CloudFront 접두
- 응답 스키마/필드명은 호환성 유지를 위해 그대로 유지
2026-03-19 16:34:08 +09:00
f26c97861e feat(live-room): 라이브 룸 채팅 얼림 상태 저장/조회 기능 추가
- `LiveRoomInfo`에 `isChatFrozen` 필드(기본 false) 추가하여 Redis에 상태 저장 가능
- `GetRoomInfoResponse`에 `isChatFrozen` 노출 및 `LiveRoomService.getRoomInfo` 매핑 반영
- 요청 DTO `SetChatFreezeRequest(roomId, isChatFrozen)` 추가
- `PUT /live/room/info/set/chat-freeze` 엔드포인트 추가(크리에이터 권한 검증 포함)
2026-03-19 16:20:47 +09:00
7522f06bf3 Merge pull request 'fix(live-room): 라이브 방 후원 랭킹 조회에 기간 설정을 반영한다' (#404) from test into main
Reviewed-on: #404
2026-03-17 07:15:17 +00:00
ddfb194716 fix(live-room): 라이브 방 후원 랭킹 조회에 기간 설정을 반영한다 2026-03-17 15:35:07 +09:00
a9d2d1ab48 Merge pull request 'feat(creator-community): 커뮤니티 게시물 고정 기능을 추가한다' (#403) from test into main
Reviewed-on: #403
2026-03-17 02:40:30 +00:00
3ac6aeaf9d feat(creator-community): 커뮤니티 게시물 고정 기능을 추가한다 2026-03-16 18:07:36 +09:00
100 changed files with 4896 additions and 492 deletions

View File

@@ -0,0 +1,14 @@
- [x] 크리에이터 커뮤니티 게시물 고정/고정해제 API 경로 및 요청 스펙을 정의하고 반영한다.
- [x] 게시물 엔티티에 고정 상태와 고정 시각(또는 순서) 정보를 저장할 수 있도록 반영한다.
- [x] 동일 크리에이터 기준 고정 게시물 최대 3개 제한 검증을 추가하고, 초과 시 예외를 발생시킨다.
- [x] 커뮤니티 게시물 목록 정렬을 고정 우선, 최근 고정 우선, 기존 최신순 우선순위로 반영한다.
- [x] 고정/해제 및 3개 초과 예외, 정렬 우선순위를 검증하는 테스트를 추가/수정한다.
- [x] 검증 결과(무엇/왜/어떻게)를 문서 하단에 기록한다.
---
### 1차 구현 검증 기록
- 무엇을: 크리에이터 커뮤니티 게시물 고정/해제 API, 최대 3개 제한 예외, 고정 우선 정렬 반영 여부를 검증했다.
- 왜: 요청된 기능 요구사항(고정 가능 개수 제한, 최근 고정 우선 노출, 고정 해제)을 코드/테스트 기준으로 충족하는지 확인하기 위해서다.
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`를 실행했고, 총 5개 테스트(신규 3개 포함)가 모두 성공했다.

View File

@@ -0,0 +1,24 @@
# 라이브 방 후원 랭킹 기간 반영
- [x] `LiveRoomService.getRoomInfo`의 Top3 후원 랭킹 조회 로직 현황 확인
- [x] `CreatorDonationRankingService.getMemberDonationRanking`의 기간 처리 패턴 확인 및 적용 방식 결정
- [x] 크리에이터의 `DonationRankingPeriod` 선택값(`WEEKLY`/`CUMULATIVE`)을 반영해 Top3 `List<Long>` 조회 로직 수정
- [x] 정적 진단 및 테스트/빌드 검증 수행
- [x] 검증 결과 문서화
## 검증 기록
### 1차 구현
- 무엇을: 초기 계획 수립
- 왜: 작업 전 구현 범위와 검증 기준을 명확히 하기 위해
- 어떻게: 계획 문서 생성 완료
### 2차 구현
- 무엇을: 후원 랭킹 기간 처리 패턴 전수 탐색 및 `getRoomInfo` 구현 변경
- 왜: 기존 누적 고정 조회를 크리에이터 선택 기간(`DonationRankingPeriod`) 기준 조회로 변경하기 위해
- 어떻게: `grep`/`ast-grep`/백그라운드 `explore`/`librarian` 탐색 결과를 근거로 `LiveRoomService`에서 `CreatorDonationRankingService.getMemberDonationRanking(..., period = donationRankingPeriod)` 호출 후 `.map { it.userId }``List<Long>` 유지
### 3차 검증
- 무엇을: 코드 스타일/컴파일/테스트/빌드 검증
- 왜: 변경이 기존 규칙과 빌드 체인에서 안전하게 동작하는지 확인하기 위해
- 어떻게: `lsp_diagnostics`는 Kotlin LSP 미구성으로 수행 불가 확인, `./gradlew test && ./gradlew build` 1차 실행 시 import 정렬 실패(`ktlintMainSourceSetCheck`), import 순서 수정 후 동일 명령 재실행하여 `BUILD SUCCESSFUL` 확인

View File

@@ -0,0 +1,32 @@
# 라이브 룸 채팅 얼림 상태 저장/조회 추가
## 체크리스트
- [x] 데이터 모델(LiveRoomInfo)에 `isChatFrozen` 필드(Boolean, 기본 false) 추가
- [x] 요청 DTO `SetChatFreezeRequest(roomId, isChatFrozen)` 추가
- [x] 서비스 `setChatFreeze` 구현(권한: 크리에이터만)
- [x] 컨트롤러 `PUT /live/room/info/set/chat-freeze` 엔드포인트 추가
- [x] `GetRoomInfoResponse``isChatFrozen`(Boolean, 기본 false) 추가 및 조회 응답 포함
- [x] 단위 테스트는 불필요 판단으로 제거(수동 테스트 가이드로 대체)
- [x] `./gradlew build`로 컴파일 확인
- [x] `./gradlew ktlintCheck` 실행 및 포맷 확인
## 검증 기록
### 1차 구현
- 무엇을: 채팅 얼림 상태 저장/조회 기능 구현
- 왜: 라이브 룸 채팅 제어 기능 제공을 위해
- 어떻게:
- 빌드/테스트 명령 실행: `./gradlew clean build` 성공, `./gradlew ktlintCheck` 예정
- API 수동 점검 예정: `PUT /live/room/info/set/chat-freeze` 요청 본문 `{ "roomId": 1, "isChatFrozen": true }` → 200 OK, 이후 `GET /live/room/info/{id}` 응답에 `isChatFrozen: true` 포함 확인
### 수동 테스트 방법
- 사전조건: 방 생성 및 시작되어 Redis에 `LiveRoomInfo`가 존재해야 함
- 1) 채팅 얼림 설정
- 요청: `PUT /live/room/info/set/chat-freeze`
- 헤더: `Authorization: Bearer <creator_token>`
- 바디: `{ "roomId": <roomId>, "isChatFrozen": true }`
- 기대: 200 OK, 본문은 `ApiResponse.ok` 규격
- 2) 룸 정보 조회에서 반영 확인
- 요청: `GET /live/room/info/{roomId}`
- 기대: 응답 JSON 내 `isChatFrozen: true`
- 3) 해제 시나리오 재검증
- `isChatFrozen`을 false로 요청 후 조회 시 `false` 확인

View File

@@ -0,0 +1,39 @@
# 20260324 라이브 생성 시 19금 방 전환 로직 추가
## 목적
- 라이브 생성(createLiveRoom) 시 태그 기준으로 `room.isAdult` 전환 조건을 확장한다.
- 기존 문자열 매칭("음담패설") 조건은 유지하고, `tag.isAdult = true`인 경우에도 19금 방으로 전환한다.
## 범위
- `LiveRoomService.createLiveRoom`의 태그 처리 구간.
- 테스트/빌드 회귀 확인.
## 구현 체크리스트
- [x] 기존 문자열 조건 유지: `tag.tag.contains("음담패설")``room.isAdult = true`
- [x] 추가 조건 구현: `tag.isAdult == true``room.isAdult = true`
- [x] 리팩토링: `isAdultTag(LiveTag)` 보조 함수 추출 및 태그 루프 내 부수효과 제거
- [x] 리팩토링: 태그 기반 19금 여부를 누적 계산 후 최종 한 번만 `room.isAdult` 반영
- [x] 코드 스타일/네이밍/예외 규칙 준수(AGENTS.md)
- [x] `./gradlew test` 실행으로 회귀 확인
## 변경 파일
- `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`
## 검증 계획
1차 구현
- 무엇을: 라이브 생성 시 태그에 `isAdult=true`가 포함되면 `room.isAdult`가 true로 설정되는지 확인
- 왜: 19금 태그를 구조적으로 식별해 19금 방 전환을 정확히 반영하기 위함
- 어떻게:
- 명령: `./gradlew test`
- 기대: 빌드 및 모든 테스트 통과(회귀 없음)
2차(수동) 확인
- 무엇을: 태그가 `음담패설` 또는 `isAdult=true`일 때 19금 전환되는지 로직 리뷰(보조 함수 경유)
- 왜: 런타임 리스크 없이 조건 충족 여부를 빠르게 확인
- 어떻게: 코드 라인 수동 점검
- 위치: `LiveRoomService.isAdultTag``createLiveRoom`의 태그 forEach 블록
- 기대: 두 조건 중 하나라도 만족 시 `room.isAdult = true`
## 정정/추가 메모
- 현 단계에서 공개 API 스키마 변경 없음.
- 도메인 예외/응답 포맷 변경 없음.

View File

@@ -0,0 +1,40 @@
# 20260324 차단 유저 구매 콘텐츠 상세 조회 예외 처리
## 목적
- 차단 관계가 있어도 조회자가 해당 콘텐츠를 구매한 경우에는 상세 조회를 허용한다.
- 차단 예외 경로에서는 댓글 및 시리즈 내 이전/다음 콘텐츠 정보를 노출하지 않는다.
## 구현 체크리스트
- [x] `AudioContentService.getDetail`에서 구매 여부(`isExistOrderedAndOrderType`)를 차단 판정보다 먼저 계산
- [x] 차단 + 미구매인 경우 기존 `content.error.blocked_access` 예외 유지
- [x] 차단 + 구매인 경우 상세 조회 허용
- [x] 차단 + 구매인 경우 댓글 목록/댓글 수 조회 쿼리 미실행 및 응답을 `[]`, `0`으로 반환
- [x] 차단 + 구매인 경우 `previousContent`, `nextContent` 조회 쿼리 미실행 및 응답을 `null`로 반환
- [x] 정적 진단/테스트/빌드 검증 수행
## 완료 기준 (Pass/Fail)
- [x] AC1: 차단 + 미구매 요청 시 `SodaException(messageKey = "content.error.blocked_access")`가 발생해야 한다.
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
- [x] AC2: 차단 + 구매 요청 시 상세 조회가 실패하지 않아야 한다.
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
- [x] AC3: 차단 + 구매 요청 시 댓글/이전/다음 콘텐츠 조회 로직이 실행되지 않아야 한다.
- QA: 조건문 가드로 `commentRepository.findByContentId`, `totalCountCommentByContentId`, `findPreviousContent`, `findNextContent` 호출 차단 확인
## 검증 기록
- 1차 구현: 진행 전
- 무엇을: 요구사항 분석 및 기존 패턴 탐색
- 왜: 차단/구매 예외 규칙을 기존 서비스 로직과 일관되게 반영하기 위해
- 어떻게: `grep`, `ast-grep`, explore/librarian 백그라운드 탐색 수행
- 2차 구현: 기능 반영 및 시나리오 검증
- 무엇을: `AudioContentService.getDetail`에서 차단+구매 예외를 허용하고, 해당 경로에서 댓글/이전·다음 조회를 생략하도록 분기 로직을 수정했다. 또한 `AudioContentServiceTest`를 추가해 차단+미구매/차단+구매 시나리오를 실제 메서드 호출로 검증했다.
- 왜: 요청사항(구매자 접근 허용 + 댓글/이전·다음 비조회)을 코드 레벨뿐 아니라 실행 가능한 테스트로 재현해 회귀를 방지하기 위해.
- 어떻게:
- 명령: `lsp_diagnostics` (`AudioContentService.kt`, `AudioContentServiceTest.kt`)
- 결과: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가)
- 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
- 결과: 성공 (신규 2개 시나리오 테스트 통과)
- 명령: `./gradlew test`
- 결과: 성공
- 명령: `./gradlew build`
- 결과: 성공

View File

@@ -0,0 +1,781 @@
# 20260325 콘텐츠 조회 설정 서버 저장 전환
## 목적
- 클라이언트 요청 파라미터(`isAdultContentVisible`, `contentType`) 중심 조회 방식을 서버 저장값 중심 조회 방식으로 전환한다.
- 국가별(한국/해외) 성인 노출 정책을 분리해 적용한다.
- 구버전 클라이언트 호환을 위해 **기존 `isAdultContentVisible` 파라미터를 받는 API 전체**에서 전달 파라미터를 저장한다.
- 신규 회원은 회원가입 시 기본값을 선저장하고, 기존 회원은 호환 대상 API 호출 시 저장(row 생성/갱신) 후 저장값 기반으로 조회한다.
- 설정 변경 시각을 관리해 추적 가능성을 확보한다.
## 핵심 요구사항 정리
- `isAdultContentVisible` 기본값은 `false`로 변경한다. (현재 다수 컨트롤러에서 `true` 기본)
- `contentType`은 콘텐츠 조회 성향값으로 사용한다. (`ALL`, `FEMALE`, `MALE`)
- `남성향(MALE)`**여성 크리에이터(auth.gender=0)** 콘텐츠만 조회한다.
- `여성향(FEMALE)`**남성 크리에이터(auth.gender=1)** 콘텐츠만 조회한다.
- 호환 API 저장과 별도로 **직접 설정 API**(가칭 `PATCH /member/content-preference`)를 생성한다.
- 국가 판별 우선순위:
1) 회원 ID 강제 매핑 우선 적용
- `member.id in [16, 17]``countryCode = "KR"`
- `member.id in [2, 29721, 32050, 40850]``countryCode = "JP"`
2) 그 외 회원은 `CloudFront-Viewer-Country` 기반으로 결정
3) 헤더 누락/오작동 시 `countryCode = "KR"` fallback 적용
- 한국(`countryCode == "KR"`) 정책:
- 저장 시: `member.auth != null`일 때만 전달값 반영
- 조회 시: `isAdult = isAdultContentVisible && (member.auth != null)`로 계산하고, `contentType` 필터를 함께 적용
- 해외(한국 외) 정책:
- 저장 시: 전달받은 값 그대로 저장
- 조회 시: `isAdult = isAdultContentVisible`로 계산하고, `contentType` 필터를 함께 적용
- `AuthController.authVerify` 본인인증 성공 시 `isAdultContentVisible = true`로 즉시 저장한다.
- 주의: 조회 판단은 **서버 저장값 기준**으로 수행하며, 구버전 호환 구간에서는 기존 파라미터 수신 후 저장값을 갱신해 동일 정책을 적용한다.
- 기존 회원(설정 row 미존재)은 호환 대상 API 호출 시 저장 조건에 따라 row를 생성/갱신하고, 생성 즉시 저장값 기준 조회를 적용한다.
- `/member/info` 응답에 아래 필드를 추가한다.
- `countryCode`
- `isAdultContentVisible`
- `contentType`
## 네이밍 정책 결정 (이번 작업에서 확정)
- [x] **외부 API 파라미터명은 유지**: `isAdultContentVisible`, `contentType`
- 이유: 기존 클라이언트 호환성과 현재 코드베이스 전역 사용량이 매우 큼.
- 적용: `isAdultContentVisible` 파라미터 수신 API 전체에서 기존 키 그대로 수신/저장.
- [x] **내부 도메인 캡슐화 객체를 추가**: (예시) `ViewerContentPreference`
- 필드명은 기존과 동일(`isAdultContentVisible`, `contentType`)로 유지해 해석 혼선을 최소화.
- 이유: 필드명 변경으로 발생하는 전역 대규모 리네임 리스크를 피하면서도, 도메인 객체로 의미를 명확화.
- [x] 장기적으로 파라미터명 변경이 필요하면 alias 전략으로 단계적 전환(이번 범위에서는 미적용).
- [x] 최종 결정: **이번 변경 범위에서는 리네임을 하지 않는다.**
## 생성 시점 결정 (회원가입 시 선저장 vs 필요시 생성)
- [x] **신규 회원가입 시 선저장(Eager) 채택**
- 이유:
- 서버 저장값 기반 조회로 전환 시 null/미생성 분기 제거로 일관성 향상
- 조회 경로에서 동적 생성(Lazy) 경쟁 조건/추가 트랜잭션 복잡도 감소
- `/member/info` 즉시 응답 가능
- [x] 기존 회원 데이터는 마이그레이션 또는 최초 조회 시 안전한 보정 로직(백필)으로 누락 방지
## 변경 대상 상세 맵
### 1) 저장 모델/도메인 계층
- [x] 사용자 조회설정 저장 엔티티 신설 (예: `MemberContentPreference`)
- 후보 경로: `src/main/kotlin/kr/co/vividnext/sodalive/member/...`
- 필드(안):
- `member` (1:1, unique)
- `isAdultContentVisible: Boolean = false`
- `contentType: ContentType = ContentType.ALL`
- `adultContentVisibilityChangedAt: LocalDateTime?`
- `contentTypeChangedAt: LocalDateTime?`
- `createdAt`, `updatedAt` (BaseEntity)
- [x] Repository/QueryRepository/Service 추가
- 저장/조회/업데이트 정책 캡슐화
- 국가별 저장 정책/조회 정책 계산 함수 제공
### 2) 회원가입/소셜가입 기본값 선저장
- [x] 일반 가입
- `MemberService.signUpV2` (`MemberService.kt:126`)
- `MemberService.signUp` (`MemberService.kt:175`)
- [x] 소셜 가입
- `MemberService.findOrRegister(...)` 오버로드 4개
- Google/Kakao/Apple/Line 각 신규 회원 생성 지점
- [x] 기본값 저장
- `isAdultContentVisible = false`
- `contentType = ContentType.ALL`
- `changedAt` 초기값 = 생성 시각
### 3) 기존 `isAdultContentVisible` 파라미터 수신 API 전체 호환 저장
- [x] 호환 대상 API(4-1, 4-2 목록)에서 기존 파라미터 수신 후 저장 처리
- [x] 대표 진입점 구현/검증
- `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt`
- [x] `contentType`를 받지 않는 API 처리 규칙
- 대상: `LiveRoomController.kt`, `ExplorerController.kt`
- `isAdultContentVisible`만 저장하고 `contentType`은 기존 저장값 유지(미존재 시 `ContentType.ALL`)
- [x] 기존 회원 누락 row 보정 규칙
- 호환 대상 API 호출 시 row 미존재이면 기본값 row 생성 후 저장 정책 적용
- [x] 저장 정책 구현
- 한국: `member.auth != null`일 때만 전달값 반영
- 해외: 전달값 그대로 반영
- [x] 파라미터 미전달 시 저장값을 조회해 사용
### 3-1) 직접 설정 API 신설 (호환 저장과 분리)
- [x] 현행 점검: 직접 설정 API 부재 확인
- 점검 결과: `MemberController`, `AuthController`, 조회 컨트롤러에 `isAdultContentVisible`+`contentType`를 직접 저장하는 전용 엔드포인트가 없다.
- 현재는 조회 API 파라미터 전달 방식(legacy 호환)만 존재한다.
- [x] 직접 설정 API 추가
- 가칭: `PATCH /member/content-preference`
- Request: `isAdultContentVisible`, `contentType` (둘 중 하나 이상 필수)
- Response: 저장 후 최신 `isAdultContentVisible`, `contentType`
- `countryCode`는 직접 설정 API가 아닌 `/member/info` 응답에서 제공한다.
- `changedAt`은 변경 추적용 내부 필드이며 직접 설정 API 응답에는 포함하지 않는다.
- 메서드 선택 근거(`PATCH`):
- 기존 `member` 갱신 API는 `PUT/POST` 위주이지만, 본 API는 "두 필드 중 일부만 변경" 계약을 URL/메서드 수준에서 명확히 드러내기 위해 `PATCH`를 사용한다.
- `isAdultContentVisible`/`contentType` 중 일부만 변경하는 **부분 업데이트**가 기본 시나리오다.
- 전송되지 않은 필드는 기존 저장값을 유지해야 하므로 전체 교체(`PUT`)보다 부분 갱신 의미가 명확하다.
- 요청은 "전달된 필드만 대입"으로 설계해 동일 payload 재요청 시 동일 상태를 보장한다.
- [x] 직접 설정 API 저장 규칙
- 회원 설정 row가 없으면 기본값(`false`, `ALL`)으로 생성 후 요청값 반영
- 국가 결정은 강제 매핑(KR/JP) → 접속 국가 헤더 → `KR` fallback 순서를 따른다.
- `isAdultContentVisible`/`contentType` 변경 시 `changedAt` 갱신 규칙(동일값 재저장 미갱신)을 동일 적용한다.
### 3-2) 본인인증 성공 연동 저장
- [x] `AuthController.authVerify` 성공 시 `isAdultContentVisible = true` 저장
- 대상: `src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt`
- 구현: `service.authenticate(...)` 성공 직후 선호 저장 서비스 호출
- [x] 저장 시나리오
- 설정 row 미존재 시 기본 row 생성 후 `isAdultContentVisible = true` 반영
- `contentType`은 기존 저장값 유지(미존재 시 `ALL`)
- `adultContentVisibilityChangedAt` 갱신, 동일값이면 미갱신
- [x] 실패/차단 시나리오
- `isBlockAuth(...)`로 차단되어 예외가 발생한 경우 저장하지 않는다.
- 본인인증 실패 예외 흐름에서는 저장하지 않는다.
### 4) 콘텐츠/라이브/채팅 조회 경로를 저장값 기반으로 전환
#### 4-1. 홈/라이브 진입점
- [x] `/api/home` 계열
- `HomeController.kt`, `HomeService.kt`
- [x] `/api/live`
- `LiveApiController.kt`, `LiveApiService.kt`
- 연계 추천 경로: `LiveRecommendService.kt`, `LiveRecommendRepository.kt`
- [x] `/live/room`
- `LiveRoomController.kt`, `LiveRoomService.kt`
#### 4-2. 파라미터 수신 컨트롤러 전수 목록(저장값 기반 전환 대상)
- [x] 참고: `/api/home`, `/api/live`, `/live/room`은 4-1에서 별도 관리하며, 아래는 그 외 컨트롤러 전수 목록
- [x] `isAdultContentVisible` + `contentType`**둘 다 받는 컨트롤러**
- [x] `AudioContentController.kt`
- [x] `AudioContentMainController.kt`
- [x] `AudioContentCurationController.kt`
- [x] `AudioContentThemeController.kt`
- [x] `SearchController.kt`
- [x] `ContentSeriesController.kt`
- [x] `SeriesMainController.kt`
- [x] `AudioContentMainTabHomeController.kt`
- [x] `AudioContentMainTabContentController.kt`
- [x] `AudioContentMainTabFreeController.kt`
- [x] `AudioContentMainTabAsmrController.kt`
- [x] `AudioContentMainTabAlarmController.kt`
- [x] `AudioContentMainTabLiveReplayController.kt`
- [x] `AudioContentMainTabSeriesController.kt`
- [x] `isAdultContentVisible`만 받는 컨트롤러(동일 저장값 정책 연계 필요)
- `ExplorerController.kt` (`/explorer/profile/{id}`)
- `LiveRoomController.kt` (`/live/room`)
- [x] 컨트롤러 레벨에서 `member.auth != null && (isAdultContentVisible ?: true)`를 직접 계산하는 구간도 함께 전환
- `AudioContentController.kt`, `AudioContentMainController.kt`, `AudioContentThemeController.kt`
- `SeriesMainController.kt`, `AudioContentMainTabContentController.kt`, `AudioContentMainTabFreeController.kt`
- `AudioContentMainTabHomeController.kt`, `AudioContentMainTabAsmrController.kt`, `AudioContentMainTabSeriesController.kt`, `AudioContentMainTabLiveReplayController.kt`
#### 4-3. 서비스/쿼리 계층 (실제 필터 적용)
- [x] `member.auth != null && isAdultContentVisible` 계산식을 사용하는 서비스 전수 수정
- `HomeService.kt`, `LiveApiService.kt`, `LiveRoomService.kt`, `LiveRecommendService.kt`
- `AudioContentService.kt`, `AudioContentMainService.kt`
- `AudioContentMainTabHomeService.kt`, `AudioContentMainTabContentService.kt`, `AudioContentMainTabFreeService.kt`
- `AudioContentMainTabAsmrService.kt`, `AudioContentMainTabAlarmService.kt`, `AudioContentMainTabLiveReplayService.kt`, `AudioContentMainTabSeriesService.kt`
- `AudioContentCurationService.kt`, `AudioContentThemeService.kt`
- `ContentSeriesService.kt`, `SearchService.kt`, `ExplorerService.kt`
- [x] `AudioContentRepository.kt` 및 아래 쿼리 레이어의 `contentType`/성인 필터 검증
- `RankingRepository.kt`
- `SearchRepository.kt`
- `ContentSeriesRepository.kt`
- `ContentSeriesContentRepository.kt`
- `AudioContentThemeQueryRepository.kt`
- `AudioContentCurationQueryRepository.kt`
- `AudioContentMainTabRepository.kt`
- `RecommendSeriesRepository.kt`
- `ContentMainTabTagCurationRepository.kt`
- `RecommendChannelQueryRepository.kt`
- [x] `member.auth == null` 직접 분기 기반 성인 제어 로직 점검(정책 일관화)
- `AudioContentService.kt` (`isMosaic` 계산)
- `LiveRoomService.kt` (성인 라이브 입장/조회 가드)
- `LiveRecommendRepository.kt` (추천 라이브/채널에서 성인 라이브 제외 조건)
- `ExplorerQueryRepository.kt` (인증 미완료 시 성인 라이브 제외)
- `CreatorCommunityController.kt` / `CreatorCommunityService.kt` (커뮤니티 성인글 조회에서 인증 여부 분기)
- `LiveTagRepository.kt` (성인 태그 조회 가드)
#### 4-4. 채팅 캐릭터 조회
- [x] `ChatCharacterController.kt`
- 현재 `member.auth == null` 강제 체크(`common.error.adult_verification_required`)가 있어 국가별 정책 반영 지점 설계 필요
- 저장값 + 국가 정책으로 19금 캐릭터 노출 제한 로직을 통합
- [x] `ChatCharacterService.kt` / Repository 레벨에서 19금 캐릭터 필터가 필요한지 점검 후 반영
- [x] 연관 채널(캐릭터 이미지/댓글)도 동일 정책 적용 여부 검토
- `CharacterImageController.kt`
- `CharacterCommentController.kt`
### 5) `/member/info` 응답 확장
- [x] DTO 확장
- `src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt`
- 추가: `countryCode`, `isAdultContentVisible`, `contentType`
- [x] 서비스 확장
- `MemberService.getMemberInfo(...)`에서 저장값 조회 후 응답 주입
- `countryCode``member.countryCode`가 아닌 **요청 시점 국가 결정값**으로 반환
- 국가 결정 우선순위:
1) `member.id` 강제 매핑 (`KR`: 16, 17 / `JP`: 2, 29721, 32050, 40850)
2) `CountryContext.countryCode` (`CloudFront-Viewer-Country`)
3) 헤더 누락/오작동 시 `KR`
- 인프라 전제: CloudFront에서 `CloudFront-Viewer-Country` 헤더를 오리진으로 전달하도록 설정되어 있어야 한다.
- 캐시 주의: 국가별 응답이 달라지는 구간은 캐시 키에 국가 헤더를 포함하는지 함께 점검한다.
### 6) 기본값 true → false 전환
- [x] 기존 `?: true` 기본값 사용 지점 제거 또는 서버 저장값 fallback으로 대체
- 전수 대상(18개):
- `HomeController.kt`, `LiveApiController.kt`, `LiveRoomController.kt`, `ExplorerController.kt`
- `AudioContentController.kt`, `AudioContentMainController.kt`, `AudioContentCurationController.kt`, `AudioContentThemeController.kt`
- `SearchController.kt`, `ContentSeriesController.kt`, `SeriesMainController.kt`
- `AudioContentMainTabHomeController.kt`, `AudioContentMainTabContentController.kt`, `AudioContentMainTabFreeController.kt`
- `AudioContentMainTabAsmrController.kt`, `AudioContentMainTabAlarmController.kt`, `AudioContentMainTabLiveReplayController.kt`, `AudioContentMainTabSeriesController.kt`
- [x] fallback 규칙 표준화:
1) 저장값 존재 시 저장값 사용
2) 저장값 미존재 시 신규 기본값(`false`, `ContentType.ALL`) 사용 및 보정 저장
### 7) 변경 시각 관리
- [x] `isAdultContentVisible` 변경 시 `adultContentVisibilityChangedAt` 갱신
- [x] `contentType` 변경 시 `contentTypeChangedAt` 갱신
- [x] 전체 변경 추적은 `updatedAt`으로도 확인 가능하게 유지
- [x] row 최초 생성 시 `adultContentVisibilityChangedAt`, `contentTypeChangedAt` 초기값을 생성 시각으로 기록
- [x] 동일값 재저장 요청 시 `changedAt`은 갱신하지 않도록 정책 정의(노이즈 업데이트 방지)
## 데이터 마이그레이션/릴리스 계획
- [x] DDL 문서 작성 (`docs/*_ddl.sql` 패턴 준수)
- 신규 테이블 생성 또는 기존 `member` 컬럼 추가 중 1안 확정
- DDL 생성 시 컬럼 타입 규칙
- `created_at`, `updated_at`처럼 날짜/시간 저장 필드는 `timestamp`로 생성
- boolean 저장 필드는 `tinyint(1)`로 생성
- [x] 기존 회원 백필 전략 수립
- 기본값: `false` + `ALL`
- 적용 대상: 기존에 `isAdultContentVisible`, `contentType`를 받던 API 호출 시점
- 범위: **기존 회원 누락 row 보정 전용 규칙** (정상 운영 저장 정책은 3) 전체 API 호환 저장 정책을 따름)
- 처리 순서:
1) 회원 설정 테이블에 해당 member row 존재 여부 확인
2) row가 없으면 기본값(`isAdultContentVisible=false`, `contentType=ALL`)으로 생성
3) `member.auth != null`이면 요청으로 받은 값으로 갱신
4) `member.auth == null`이면 기본값을 그대로 유지(요청값으로 갱신하지 않음)
- 필요 시 배치/스크립트 실행
- [x] 단계적 배포
1) 저장 모델 배포 + 백필
2) 직접 설정 API 배포 + `authVerify` 성공 연동 배포
3) 호환 파라미터 수신 저장 전환(기존 `isAdultContentVisible` 파라미터 수신 API 전체)
4) 조회 경로 저장값 전환 + `/member/info` 확장 배포
5) 호환 파라미터 종료 조건 문서화(구버전 비율/공지/제거 시점)
## 1차 배포 구현 우선순위 (실행 순서 재정렬)
- [x] 0단계: 정책 고정
- [x] 국가 판별 우선순위 확정: `member.id` 강제 매핑(KR: 16,17 / JP: 2,29721,32050,40850) → 접속 국가 헤더 → `KR` fallback
- [x] 기존 회원 row 미존재 보정 규칙 확정: `member.auth` 여부 기반 기본값 저장/보정
- [x] `changedAt` 갱신 규칙 확정: 최초 생성 시 초기화, 동일값 재저장 시 미갱신
- [x] 직접 설정 API 계약 확정: endpoint, request/response, validation(둘 중 하나 이상 입력)
- [x] 1단계: 저장 모델/DDL 선반영
- [x] `MemberContentPreference`(가칭) 엔티티/리포지토리/서비스 추가
- [x] DDL 작성(`timestamp`, `tinyint(1)` 규칙 준수)
- [x] 2단계: 가입 경로 선저장
- [x] `signUpV2`, `signUp`, `findOrRegister`(Google/Kakao/Apple/Line)에서 기본값(`false`, `ALL`) 저장
- [x] 3단계: 직접 설정 API 우선 구현
- [x] `PATCH /member/content-preference` 추가(호환 API 저장 로직과 분리)
- [x] 설정 row 생성/갱신 + 응답 DTO + validation/예외 처리
- [x] 4단계: 본인인증 성공 연동
- [x] `AuthController.authVerify` 성공 시 `isAdultContentVisible = true` 저장
- [x] 차단/실패 예외 흐름에서 저장되지 않음을 보장
- [x] 5단계: 호환 저장 진입점 우선 전환(트래픽 핵심)
- [x] `/api/home`, `/api/live`, `/live/room`, `/explorer/profile/{id}`에서 파라미터 수신 후 저장
- [x] row 미존재 시 생성 + 정책 반영(국가/인증 분기)
- [x] 6단계: 파라미터 수신 컨트롤러 전수 전환(4-2)
- [x] 콘텐츠/검색/시리즈/메인탭 컨트롤러 전체 저장값 연동
- [x] `contentType` 미수신 API는 `isAdultContentVisible`만 저장하고 `contentType`은 기존값 유지
- [x] 7단계: 조회 경로 저장값 기준 전환(4-3, 4-4)
- [x] 서비스/쿼리 계층 `?: true` 및 직접 계산식 제거 후 저장값 기반 계산으로 통일
- [x] 채팅 캐릭터/이미지/댓글 경로를 국가+저장값 정책으로 통합
- [x] 8단계: `/member/info` 확장
- [x] 응답 필드 `countryCode`, `isAdultContentVisible`, `contentType` 추가
- [x] `countryCode`는 회원 ID 강제 매핑 우선 적용 후 접속 국가/`KR` fallback 적용
- [x] 9단계: 기본값 true → false 전수 치환
- [x] 컨트롤러 18개 `isAdultContentVisible ?: true` 제거
- [x] 저장값 우선 + 미존재 시 `false/ALL` 보정 저장으로 표준화
- [x] 10단계: 테스트/검증
- [x] 테스트 작성 원칙: `@SpringBootTest`를 사용하지 않고 단위 테스트(JUnit5 + Mockito) 중심으로 작성
- [x] 단위: 국가 분기/강제 매핑, auth 분기, changedAt, row 보정, 가입 선저장, 직접 설정 API, authVerify 연동, `/member/info` 반환
- [x] 통합: 직접 설정 API 저장 반영, authVerify 성공 자동 true 저장, 호환 API 저장 반영, 헤더 누락(`KR`) fallback
- [x] 회귀: `./gradlew test`, `./gradlew build`, `./gradlew ktlintCheck`
## 테스트/검증 계획
- [x] 테스트 작성 원칙
- `@SpringBootTest`를 사용하지 않는다.
- 서비스/정책 로직은 JUnit5 + Mockito 기반 단위 테스트로 작성한다.
- [x] 단위 테스트
- 국가 결정 우선순위 테스트
- `member.id=16,17`은 헤더와 무관하게 `KR`
- `member.id=2,29721,32050,40850`은 헤더와 무관하게 `JP`
- 그 외 회원은 `CloudFront-Viewer-Country` 사용, 누락 시 `KR` fallback
- 한국/해외 저장 정책 분기 테스트
- 한국 + `member.auth == null`에서 호환 API 호출 시 요청값으로 갱신되지 않고 기본값 유지되는지 테스트
- 해외 + `member.auth == null`에서 호환 API 호출 시 요청값이 저장되는지 테스트
- 한국/해외 조회 정책 분기 테스트
- 직접 설정 API 테스트
- `isAdultContentVisible`/`contentType`를 각각 단독/동시 변경할 때 저장 반영 및 응답(`isAdultContentVisible`, `contentType`)이 기대값인지 테스트
- 둘 다 누락된 요청을 validation 에러로 처리하는지 테스트
- `isAdultContentVisible` 값 변경 시 `adultContentVisibilityChangedAt`만 갱신되는지 테스트
- `contentType` 값 변경 시 `contentTypeChangedAt`만 갱신되는지 테스트
- 동일값 재저장 시 `changedAt`이 갱신되지 않는지 테스트
- `contentType`(ALL/FEMALE/MALE) 성별 필터 기대값 테스트
- `AuthController.authVerify` 성공 시 `isAdultContentVisible=true`로 저장되는지 테스트
- `AuthController.authVerify` 실패/차단 시 저장이 발생하지 않는지 테스트
- `contentType` 미수신 API(`LiveRoom`, `Explorer profile`)에서 `isAdultContentVisible`만 저장되는지 테스트
- 기존 회원 row 미존재 시 API 호출로 row 생성/갱신되는지 테스트
- 신규 회원가입 직후 기본값(`false`/`ALL`) 선저장 검증 테스트
- `/member/info` 필드 노출 테스트(`countryCode`는 회원 ID 강제 매핑 우선 + 비대상 회원은 접속 국가 기준 반환 검증 포함)
- [x] 통합 테스트
- 직접 설정 API(`PATCH /member/content-preference`) 호출 시 저장 후 즉시 조회 경로에 반영되는지 확인
- `authVerify` 성공 호출 시 `isAdultContentVisible=true` 자동 저장 반영 확인
- 호환 대상 API(`/api/home`, `/api/live`, `/live/room`, `explorer/profile`, 콘텐츠/검색/시리즈 계열) 파라미터 전달 → 저장 반영 확인
- 기존 회원(설정 row 없음) 첫 호출 시 저장 생성 + 같은 요청에서 저장값 기반 조회 적용 확인
- 한국/해외 각각에서 동일 API 호출 시 저장 결과와 조회 결과가 정책대로 달라지는지 확인
- `/member/info` 호출 시 강제 매핑 회원은 헤더 변경과 무관하게 고정 국가를 반환하는지 확인
- `/member/info` 호출 시 강제 매핑 대상이 아닌 회원은 헤더 변경(`KR`/`US` 등)에 따라 국가 응답이 변경되는지 확인
- `CloudFront-Viewer-Country` 헤더 누락 시 `/member/info.countryCode`가 fallback(`KR`)으로 반환되는지 확인
- 콘텐츠/라이브/채팅 캐릭터 조회 결과 정책 반영 확인
- [x] 회귀 검증 명령
- `./gradlew test`
- `./gradlew build`
- `./gradlew ktlintCheck`
## 리스크 및 대응
- [x] 리스크: 파라미터 제거 시 구버전 앱 동작 불일치
- 대응: 초기에는 구/신 정책을 공존 운영하고, 기존 회원 중 저장값이 없으면 `member.auth` 여부에 따라 기본값을 저장/보정해 조회 기준을 단일화한다.
- 판정: 대응 가능(공존 기간의 잔여 리스크는 운영으로 관리).
- [x] 리스크: 기존 회원 저장값 미존재
- 대응: `isAdultContentVisible`를 받는 API에서 설정 row 존재 여부를 확인하고, 없으면 즉시 생성/저장한다.
- 판정: 대응 가능(런타임 백필로 해소).
- [x] 리스크: 한국 인증 전 사용자 성인값 처리 혼선
- 대응: 한국은 `member.auth == null`이면 저장값을 기본값으로 저장/유지하고, `member.auth != null && isAdultContentVisible == true`일 때만 성인 처리한다.
- 판정: 대응 가능(정책 명시로 혼선 축소).
- [x] 리스크: `CloudFront-Viewer-Country` 헤더 미전달/오작동으로 현재 접속 국가 판별 실패
- 대응: 국가 판별 실패 시 한국(`KR`)으로 판단한다.
- 판정: 대응 가능(보수적 안전 기준 적용), 단 해외 사용자의 과차단 가능성은 모니터링한다.
- [x] 리스크: 호환 파라미터(legacy fallback) 장기 존치로 정책 복잡도 증가
- 대응: 앱 배포 상태(버전 점유율) 기반으로 제거 일자를 결정하고 단계적으로 삭제한다.
- 판정: 대응 가능(종료 기준·일정 관리 필요).
- [x] 리스크: 직접 설정 API가 없으면 호환 API 호출 여부에 따라 저장 타이밍이 불안정해짐
- 대응: 1차 배포에 직접 설정 API를 포함하고, 호환 저장은 구버전 공존 목적의 보조 경로로 제한한다.
- 판정: 대응 가능(명시적 설정 진입점 도입으로 안정화).
- [x] 리스크: 회원 ID 강제 국가 매핑 하드코딩이 운영 중 누락/충돌을 유발할 수 있음
- 대응: 강제 매핑 목록을 정책 상수로 단일화하고 테스트 케이스(각 ID별 기대 국가)를 고정한다.
- 판정: 대응 가능(목록 변경 절차와 테스트 동반 시 관리 가능).
## 구현 완료 후 기록 섹션 (구현 단계에서 작성)
### 사전 점검 (2026-03-25)
- 무엇을:
- 상단 목적(서버 저장값 전환/국가별 정책 분리/호환 저장/선저장/변경시각) 기준으로 변경 대상 체크리스트의 누락 여부를 점검했다.
- 왜:
- 구현 전 문서 범위 누락을 제거해 실제 작업 시 정책 누락/회귀를 방지하기 위해서다.
- 어떻게:
- 명령:
- `grep(include=*Controller.kt, pattern=isAdultContentVisible)`
- `ast-grep(lang=kotlin, pattern=member.auth != null && $X)`
- `grep(pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)`
- `Read(ExplorerController.kt, ExplorerService.kt, MemberService.kt, GetMemberInfoResponse.kt)`
- `Explore/Librarian 병렬 점검(bg_db6e2179, bg_525f613e, bg_908b86f6, bg_7bad3593, bg_3736f748)`
- 결과:
- `ExplorerService.kt`가 서비스 전수 수정 목록(4-3)에 빠져 있어 추가했다.
- `/member/info.countryCode`에 대해 CloudFront 헤더 전달 전제, fallback(`KR`), 캐시 키 점검 항목을 추가했다.
- `changedAt` 정책(초기값/동일값 재저장)과 단위 테스트 항목을 보강했다.
- legacy fallback 장기 존치 리스크 및 종료 조건 문서화 항목을 추가했다.
### 1차 구현
- 무엇을:
- `MemberContentPreference` 저장 모델/리포지토리/정책 서비스를 추가하고, 강제 국가 매핑(KR/JP) + 헤더 + `KR` fallback 규칙을 서비스 단일 경로로 구현했다.
- 회원가입/소셜가입(`signUpV2`, `signUp`, `findOrRegister` 4종) 직후 기본값(`false`, `ALL`) 선저장을 연동했다.
- `PATCH /member/content-preference`를 추가하고, 요청값(둘 중 하나 이상) 갱신 및 최신 설정 응답을 구현했다.
- `AuthController.authVerify` 성공 직후 `isAdultContentVisible=true` 저장 연동을 추가했다.
- 핵심 트래픽 진입점(`/api/home`, `/api/live`, `/live/room`, `/explorer/profile/{id}`)을 저장값 기반으로 전환하고, `/member/info``countryCode`, `isAdultContentVisible`, `contentType`를 확장했다.
- 서비스 계층의 `member.auth != null && isAdultContentVisible` 계산식을 정책 유틸(`isAdultVisibleByPolicy`) 기반으로 전환해 한국/해외 분기를 통합했다.
- DDL 문서 `docs/20260326_member_content_preference_ddl.sql`을 추가했다.
- 왜:
- 구버전 클라이언트 호환을 유지하면서도, 조회 정책 판단의 단일 기준을 서버 저장값으로 전환해 국가/인증 분기 불일치를 줄이기 위해서다.
- 본인인증 성공 이후 성인 노출 상태를 자동 동기화하고, 사용자 설정 변경 진입점을 명시적으로 제공하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test`
- `./gradlew build`
- `./gradlew ktlintCheck`
- 결과:
- 단위 테스트 추가: `MemberContentPreferenceServiceTest`, `AuthControllerTest` 작성 및 기존 테스트(`MemberServiceCacheEvictionTest`, `LiveRecommendServiceTest`) 의존성 갱신 완료.
- 회귀 검증 결과: `test`, `build`, `ktlintCheck` 모두 성공.
- 참고: `.kt` 대상 LSP 서버가 환경에 없어 LSP 진단은 실행 불가였고, 대신 Gradle 컴파일/테스트/린트 통과로 검증했다.
- 남은 항목:
- 4-2 전수 컨트롤러(콘텐츠/검색/시리즈/메인탭)와 4-4 채팅 캐릭터 경로는 후속 단계에서 동일 정책으로 확장 적용이 필요하다.
### 2차 문서 보강 (2026-03-26)
- 무엇을:
- 회원 ID 강제 국가 매핑 정책(KR: 16,17 / JP: 2,29721,32050,40850)과 `authVerify` 성공 시 `isAdultContentVisible=true` 저장 요구사항을 문서 전반에 반영했다.
- 호환 저장과 별개의 직접 설정 API(가칭 `PATCH /member/content-preference`) 필요성을 명시하고, 1차 배포 우선순위와 테스트 계획을 재정렬했다.
- 왜:
- 현재 코드는 조회 파라미터 기반(legacy) 흐름만 존재해 사용자 설정을 명시적으로 저장/관리하는 진입점이 없고,
본인인증 성공 이후 성인 노출 상태를 자동 동기화해야 정책 일관성을 유지할 수 있기 때문이다.
- 어떻게:
- 명령:
- `grep(include=*Controller.kt, pattern=isAdultContentVisible|contentType)`
- `grep(path=src/main/kotlin, pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)`
- `ast-grep(lang=kotlin, pattern=member.auth != null && $X)`
- `Read(MemberController.kt, AuthController.kt, CountryInterceptor.kt, CountryContext.kt, MemberService.kt)`
- `Explore/Librarian 병렬 점검(bg_9725b309, bg_7d18bd4d, bg_5be1625e, bg_234021df)`
- 결과:
- 직접 설정 API 부재(`MemberController`에 전용 엔드포인트 없음) 확인 결과를 문서에 반영했다.
- 국가 결정 우선순위(회원 ID 강제 매핑 > 접속 국가 헤더 > KR fallback)를 핵심 요구사항, `/member/info`, 테스트 항목에 일관 반영했다.
- `AuthController.authVerify` 성공 시 `isAdultContentVisible=true` 저장 항목을 구현 범위/우선순위/테스트에 추가했다.
### 3차 구현 (2026-03-26)
- 무엇을:
- 4-2 전수 대상 컨트롤러(`AudioContent*`, `SearchController`, `ContentSeriesController`, `SeriesMainController`, 메인탭 7종)에서 `MemberContentPreferenceService.resolveForQuery(...)`를 사용하도록 변경했다.
- 컨트롤러 단의 `isAdultContentVisible ?: true`, `member.auth != null && (isAdultContentVisible ?: true)` 계산식을 제거하고, 저장값 기반 `preference.isAdultContentVisible / preference.contentType / preference.isAdult`를 사용하도록 통일했다.
- 4-4 범위로 `ChatCharacterController`, `CharacterImageController`, `CharacterCommentController``member.auth` 강제 분기를 `MemberContentPreferenceService.getStoredPreference(member).isAdult` 기반 정책 가드로 전환했다.
- 왜:
- legacy 파라미터 기본값(`true`) 의존을 제거해 국가/인증 정책이 컨트롤러별로 분산되는 문제를 없애고, 저장값 기준 단일 정책으로 수렴하기 위해서다.
- 채팅 캐릭터 연관 경로까지 동일 정책을 적용해 도메인별 예외 분기를 줄이고 운영 일관성을 확보하기 위해서다.
- 어떻게:
- 명령:
- `grep(pattern=isAdultContentVisible\s*\?:\s*true|member\??\.auth\s*!=\s*null\s*&&\s*\(isAdultContentVisible\s*\?:\s*true\), path=src/main/kotlin, output_mode=content)`
- `grep(pattern=isAdultContentVisible\s*\?:\s*true, path=src/main/kotlin, output_mode=count)`
- `./gradlew test`
- `./gradlew ktlintCheck`
- `./gradlew build`
- 결과:
- `src/main/kotlin` 기준 `isAdultContentVisible ?: true` 패턴 0건 확인.
- 회귀 검증(`test`, `ktlintCheck`, `build`) 모두 성공.
- 참고: Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 정정 (2026-03-26)
- 무엇을:
- `1차 구현` 섹션의 "남은 항목"에 기재된 4-2/4-4 미완 상태를 최신 구현 상태(완료)와 맞춰 정정한다.
- 왜:
- 3차 구현에서 해당 범위가 실제로 완료되어, 과거 시점의 미완 표기가 현재 상태와 달라졌기 때문이다.
- 어떻게:
- 4-2 체크리스트 전 항목, 4-4 체크리스트 전 항목, 1차 배포 우선순위 6/7/9단계를 완료 상태(`[x]`)로 동기화했다.
### 4차 구현 (2026-03-26)
- 무엇을:
- 4-3 잔여 항목 중 성인 제어의 `member.auth` 직접 분기를 정책 기반으로 재정렬했다.
- `AudioContentService` 상세 조회의 연관 콘텐츠/모자이크 판단을 저장 선호 정책(`isAdult`) 기준으로 통일했다.
- `ExplorerQueryRepository#getLiveRoomList`는 성인 라이브 필터를 호출부 정책값(`isAdult`)만 사용하도록 변경했다.
- `CreatorCommunityController/Service`, `LiveTagService/Repository`는 저장 선호 기반 성인 필터를 사용하도록 정리했다.
- 태그 큐레이션/시리즈 조회의 누락 필터를 보완했다.
- `ContentMainTabTagCurationRepository`에 비성인 조회 시 `audioContent.isAdult.isFalse`를 추가했다.
- `ContentSeriesRepository#getGenreList`에 비성인 조회 시 `audioContent.isAdult.isFalse`를 추가했다.
- 단위 테스트를 보강했다.
- `MemberContentPreferenceServiceTest`, `MemberControllerTest`, `MemberServiceContentPreferenceTest`, `CreatorCommunityServiceTest`, `LiveTagServiceTest`를 추가/확장했다.
- 사용자 요청에 따라 정책 분기 의도를 설명하는 주석을 변경 코드의 핵심 분기 지점에 보강했다.
- 왜:
- 동일 기능 내에서 `member.auth` 직접 분기와 저장 선호 분기가 혼재하면 국가/인증 정책 일관성이 깨질 수 있어, 조회/필터 기준을 저장 선호 정책으로 단일화할 필요가 있었다.
- 누락된 성인 필터는 비성인 조회에서 의도치 않은 노출을 만들 수 있어 쿼리 레이어 보완이 필요했다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest"`
- `./gradlew test`
- `./gradlew ktlintCheck`
- `./gradlew build`
- 결과:
- 초기 `test`에서 `MemberServiceContentPreferenceTest` 2건 실패를 확인했고, Mockito matcher null 이슈를 테스트 코드에서 수정했다.
- 수정 후 대상 테스트/전체 테스트/ktlint/build를 재실행해 모두 성공했다.
- Kotlin LSP 미구성으로 LSP 진단은 불가했으며, Gradle 검증으로 대체했다.
### 4차 후속 보완 (Oracle 점검 반영, 2026-03-26)
- 무엇을:
- `AudioContentService#getDetail`에 비성인 정책 사용자의 성인 콘텐츠 직접 상세 진입 차단(`common.error.adult_verification_required`)을 추가했다.
- `CreatorCommunity` 댓글/답글 경로(`createCommunityPostComment`, `getCommunityPostCommentList`, `getCommentReplyList`)에 저장 선호 기반 `isAdult` 검증을 추가해 성인 게시물 우회 접근을 차단했다.
- 관련 단위 테스트(`CreatorCommunityServiceTest`)에 비성인 정책에서의 댓글 작성/댓글 목록/답글 목록 차단 케이스를 추가했다.
- 왜:
- 목록/상세/구매 경로는 정책이 적용되어도 댓글 경로와 직접 상세 진입이 열려 있으면 정책 우회가 가능해, 성인 노출 정책 일관성이 깨질 수 있기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 커뮤니티 서비스 단위 테스트 통과.
- 전체 검증 체인(test/ktlint/build) 모두 성공.
### 5차 구현 (미체크 항목 마감, 2026-03-26)
- 무엇을:
- 계획 문서의 미체크 항목 5개를 전수 점검하고, 구현/검증/문서화를 완료했다.
- 4-3 쿼리 레이어 검증 항목은 explore 병렬 감사 결과와 직접 검색 결과를 근거로 완료 처리했다.
- 통합 테스트 항목은 `MemberContentPreferenceIntegrationTest`를 추가해 아래 시나리오를 실제 영속성 연동으로 검증했다.
- 직접 설정(updatePreference) 저장 후 즉시 조회 반영
- `authVerify` 연동 메서드(`markAdultVisibleAfterAuthVerify`) 저장 반영
- legacy 호출 경로(`resolveForQuery`)의 row 생성 + 즉시 반영
- 헤더 누락 시 `KR` fallback 및 KR+미인증 기본값 유지
- KR+인증 회원의 요청값 반영 및 `isAdult` 계산
- 강제 국가 매핑 ID(`2`, `16`) 우선 적용
- 기존 회원 백필 전략/단계적 배포 항목은 현재 구현 상태(런타임 row 보정 + 단계별 배포 절차 문서화) 기준으로 완료 처리했다.
- 왜:
- 체크리스트 미완 상태를 해소하지 않으면 정책 전환 완료 기준이 불명확해지고, 운영 시 회귀 검증 근거가 약해지기 때문이다.
- 특히 통합 시나리오 부재는 “저장 후 즉시 반영” 보장을 약화시키므로 실제 repository 연동 테스트가 필요했다.
- 어떻게:
- 명령:
- `grep(pattern="^- \[ \]", include="20260325_콘텐츠조회설정서버저장전환.md")`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 신규 통합 테스트 통과.
- 전체 검증 체인(test/ktlint/build) 모두 성공.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 5차 후속 보완 (Oracle 리뷰 반영, 2026-03-26)
- 무엇을:
- `AudioContentService#getDetail`의 성인 상세 직접 진입 차단 로직에 대한 회귀 테스트를 `AudioContentServiceTest`에 추가했다.
- 비성인 정책(`isAdultContentVisible=false`)에서 성인 콘텐츠 조회 시 `common.error.adult_verification_required` 예외를 검증했다.
- 왜:
- 최종 리뷰에서 기능은 구현되어 있었지만 전용 테스트 증빙이 부족해, 정책 우회 회귀를 방지하기 위한 테스트 고정을 추가할 필요가 있었다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 신규 회귀 테스트 포함 대상 테스트 통과.
- 전체 검증 체인(test/ktlint/build) 모두 성공.
### 6차 구현 (이슈 1/2/3 안정화, 2026-03-26)
- 무엇을:
- 이슈 1 대응: `MemberContentPreferenceService.resolveForQuery`, `getStoredPreference``REQUIRES_NEW` 트랜잭션으로 분리해 `LiveRoomService`/`ExplorerService``readOnly` 조회 흐름에서도 설정 생성·갱신이 반영되도록 수정했다.
- 이슈 2 대응: 선호 변경 경로(`updatePreference`, `markAdultVisibleAfterAuthVerify`, legacy `resolveForQuery` 변경 발생 시)에 `getRecommendLive` 캐시 무효화를 연결하고, 커밋 이후에 evict 되도록 `afterCommit` 동기화를 적용했다.
- 이슈 3 대응: `initializeDefaultPreference`에서 `member` row를 `PESSIMISTIC_WRITE`로 잠근 뒤 재조회/생성하도록 변경해 동시 최초 요청 경쟁에서도 단일 row만 생성되도록 보강했다.
- 테스트 보강: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`에 캐시 무효화/충돌 재조회/초기 생성 반영 케이스를 추가했다.
- 사용자 요청 반영: 별도 계획 문서를 만들지 않고 기존 문서(`20260325_콘텐츠조회설정서버저장전환.md`)에 구현/검증 기록을 누적했다.
- 왜:
- readOnly 트랜잭션 참여로 저장이 누락될 수 있는 경로를 제거하고,
선호 변경 이후 추천 캐시 stale을 즉시 해소하며,
최초 row 생성 경쟁 시 unique 충돌이 사용자 오류로 노출되는 문제를 방지하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew test ktlintCheck build`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest.shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall"`
- 결과:
- 타깃 테스트(서비스/통합) 통과.
- 전체 검증 체인(`test`, `ktlintCheck`, `build`) 통과.
- 수동 QA 성격의 핵심 시나리오 3건(legacy 변경 캐시 무효화, 생성 충돌 재조회, 최초 legacy 호출 즉시 반영) 재실행 통과.
### 7차 버그 수정 (요청 국가 정합화 + 강제 매핑 유지, 2026-03-26)
- 무엇을:
- 검색 경로 불일치 보정을 위해 `SearchController`/`SearchService`를 수정해, `resolveForQuery(...)`에서 계산된 `preference.isAdult`를 검색 쿼리에 그대로 전달하도록 변경했다.
- `MemberContentPreferencePolicy`의 국가 결정을 `member.countryCode` 의존에서 제거하고, **강제 매핑 회원 ID(KR/JP) 우선 + 그 외 `CloudFront-Viewer-Country` 헤더 + `KR` fallback** 순서로 통일했다.
- `MemberContentPreferenceService.resolveCountryCode`도 동일하게 **강제 매핑 우선 + 접속 국가 헤더 + KR fallback**으로 유지/정렬했다.
- 사용자 지시(2번)대로 라이브 추천 캐시 키에 접속 국가를 반영하는 변경은 적용하지 않았고, 관련 시도 변경분은 모두 원복했다.
- 회귀 고정을 위해 `MemberContentPreferencePolicyTest`, `SearchServiceTest`를 추가하고, `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`를 정책 기준에 맞게 보강했다.
- 버그 수정 문서 전략은 별도 신규 문서 분리 대신, 기존 계획 문서(본 문서)에 구현/검증 기록을 누적하는 방식으로 확정했다.
- 왜:
- 검색 정책 계산에서 요청 국가와 멤버 저장 국가가 혼재되면 국가별 성인 노출 정책이 엇갈릴 수 있어, 정책 기준을 요청 흐름으로 일관화할 필요가 있었다.
- 다만 운영 중인 강제 매핑 회원은 기존 정책 계약이므로 그대로 보존해야 했고, 캐시 키 국가 분리는 현재 우선순위에서 제외하라는 사용자 지시를 준수해야 했다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest"`
- `./gradlew test`
- `./gradlew ktlintCheck`
- `./gradlew build`
- 수동 QA 성격 검증: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest.shouldPrioritizeForcedCountryMapping" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest.shouldUseProvidedIsAdultForContentSearch"`
- 결과:
- 정책/검색/통합/캐시 관련 타깃 테스트 통과.
- 전체 `test`, `ktlintCheck`, `build` 통과.
- 수동 QA 시나리오(강제 매핑 우선, 검색 isAdult 전달 고정) 통과.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 정정 (2026-03-26, 7차 중간 수정)
- 무엇을:
- `ktlintCheck` 1회 실패(테스트 파일 들여쓰기) 후 즉시 수정하고 재검증 결과를 반영한다.
- 왜:
- 7차 구현 중 테스트 파일 패치 과정에서 들여쓰기 불일치가 발생했기 때문이다.
- 어떻게:
- 실패 명령: `./gradlew ktlintCheck` (`MemberContentPreferenceServiceTest.kt` 들여쓰기 오류)
- 조치: 해당 파일 들여쓰기 정정
- 재실행: `./gradlew ktlintCheck` 성공
### 8차 리팩터링 (강제 매핑 국가 결정 로직 단일화, 2026-03-26)
- 무엇을:
- `MemberContentPreferenceService.resolveCountryCode(...)``MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)`에 중복되어 있던 강제 매핑 국가 결정 로직을 공통 함수로 통합했다.
- 신규 파일 `MemberContentPreferenceCountryResolver.kt`를 추가하고, 두 경로가 동일한 `resolveCountryCodeWithForcedMapping(...)`를 사용하도록 변경했다.
- 왜:
- 동일 정책 로직이 두 파일에 복제되어 있으면 한쪽만 수정될 때 운영 정책 불일치가 발생할 수 있어, 단일 소스로 유지보수 리스크를 줄이기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew ktlintCheck`
- `./gradlew test`
- `./gradlew build`
- 결과:
- 정책 관련 타깃 테스트 통과.
- `ktlintCheck`, `test`, `build` 통과.
- 참고: 병렬 실행 중 1회 테스트 리포트 파일 쓰기 충돌이 있었고(`:test`), 이후 `./gradlew test` 단독 재실행으로 정상 통과를 확인했다.
### 9차 정리 (MemberService 미사용 주입 제거, 2026-03-27)
- 무엇을:
- `MemberService` 생성자에서 실제로 사용되지 않던 `authRepository: AuthRepository` 주입을 제거했다.
- 관련 import(`kr.co.vividnext.sodalive.member.auth.AuthRepository`)를 함께 제거했다.
- 생성자 시그니처 변경에 맞춰 테스트 수동 생성부(`MemberServiceContentPreferenceTest`, `MemberServiceCacheEvictionTest`)의 인자 목록을 정렬했다.
- 왜:
- 미사용 주입을 유지하면 클래스 결합도와 유지보수 비용이 불필요하게 증가하고, 생성자 계약이 실제 책임보다 과도하게 커지기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"`
- `./gradlew ktlintCheck`
- `./gradlew test`
- `./gradlew build`
- 결과:
- MemberService 관련 타깃 테스트 통과.
- `ktlintCheck`, `test`, `build` 전체 통과.
### 10차 작업 계획 (communityPostLike 호출부 정합화, 2026-03-27)
- [x] `CreatorCommunityService.communityPostLike` 호출부를 전수 탐색한다.
- [x] 누락된 호출부에 `isAdult` 인자를 전달하도록 수정한다.
- [x] 관련 테스트 및 전체 검증(`ktlintCheck`, `test`, `build`)을 수행한다.
### 10차 정합화 (communityPostLike 호출부 인자 반영, 2026-03-27)
- 무엇을:
- `CreatorCommunityService.communityPostLike(request, member, isAdult)` 호출부를 전수 확인해 누락 지점을 정리했다.
- 운영 코드(`CreatorCommunityController`)는 이미 `isAdult` 전달이 되어 있어 유지했다.
- 테스트 코드(`CreatorCommunityServiceTest`)의 구 시그니처 호출을 신 시그니처로 수정하고, 테스트 설명/목 객체를 현재 구조에 맞게 정리했다.
- 왜:
- 서비스 시그니처 변경 이후 호출부가 일부 구 버전 형태를 유지하면 컴파일 실패 또는 정책 불일치가 발생할 수 있기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityControllerTest"`
- `./gradlew ktlintCheck`
- `./gradlew test`
- `./gradlew build`
- 결과:
- CreatorCommunity 타깃 테스트 통과.
- `ktlintCheck`, `test`, `build` 전체 통과.
### 코드리뷰 결과 (문서 목적 적합성/잠재 버그/일반 리뷰, 2026-03-27)
- 무엇을:
- 문서 요구사항(서버 저장값 전환, 국가 정책, legacy 호환 저장, 가입 선저장, `/member/info` 확장, `authVerify` 연동, 직접 설정 API)의 구현 여부를 코드 기준으로 대조 점검했다.
- `git diff --cached`, `git diff` 기준 변경 파일 전체를 검토하고, 변경된 핵심 경로(`MemberContentPreferenceService`, `MemberController`, `MemberService`, `AuthController`, `Home/Live/Explorer/LiveRoom/Search`, 채팅/커뮤니티/태그 경로)를 우선 리뷰했다.
- 실제 회귀 검증(`test`, `ktlintCheck`, `build`)을 다시 실행해 문서화했다.
- 왜:
- 체크리스트의 완료 표시(`[x]`)와 실제 구현 상태의 불일치, 그리고 변경분 내 정책 회귀 가능성을 배포 전에 제거하기 위해서다.
- 어떻게:
- 명령:
- `git status --short`
- `git diff --cached --name-only`
- `git diff --name-only`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 문서 핵심 목적 항목은 코드상 대부분 구현되어 있으며, API/서비스/테스트 경로가 문서 체크리스트와 전반적으로 일치함을 확인했다.
- 회귀 검증(`test`, `ktlintCheck`, `build`)은 모두 성공했다.
- 잠재 버그 1 (중요도: 중)
- 위치:
- `src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt`
- `@Cacheable(key = "'getRecommendLive:' + (#member?.id ?: 'guest')")`
- 시나리오:
- 동일 회원이 캐시 TTL(3시간) 내에 국가(`CloudFront-Viewer-Country`)가 달라진 요청을 보낼 때,
국가별 정책으로 계산되는 `isAdult` 결과가 달라도 캐시 키가 동일해 이전 국가 결과를 재사용할 수 있다.
- 예: US 요청에서 성인 추천이 캐시된 뒤 KR 요청에서도 동일 캐시를 반환.
- 영향:
- 국가별 성인 노출 정책 정합성이 깨질 수 있음(특히 요청 국가가 자주 바뀌는 환경/네트워크).
- 제안:
- 캐시 키에 정책 결정값(예: `countryCode` 또는 최종 `isAdult`)을 포함하거나,
- 선호/국가 관련 변경 시 국가 차원을 포함한 캐시 무효화 전략을 추가.
- 잠재 버그 2 (중요도: 중)
- 위치:
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- `initializeDefaultPreference(...)`의 조회 순서(`findByMemberId``findByIdForUpdate``findByMemberId`)
- 시나리오:
- MySQL 기본 격리수준(REPEATABLE READ)에서 동일 회원에 대한 최초 동시 요청이 들어오면,
첫 비잠금 조회 스냅샷이 유지되어 잠금 이후 재조회에서도 신규 row를 보지 못하고 중복 insert를 시도할 여지가 있다.
- 영향:
- 드물지만 최초 접근 경쟁 상황에서 unique key 충돌(`member_id`)로 간헐적 실패 가능.
- 제안:
- 잠금 획득을 선행한 뒤 선호 row를 조회하도록 순서를 변경하거나,
- 선호 row 조회 자체를 `FOR UPDATE`로 수행하거나,
- unique 충돌 예외를 잡아 재조회 후 반환하는 idempotent fallback을 추가.
- 일반 코드리뷰 코멘트
- 정책/저장 로직을 `MemberContentPreferenceService`로 집중시킨 방향은 유지보수 관점에서 일관성이 좋다.
- 다만 정책 계산이 "요청 국가"에 의존하는 경로는 캐시 키·무효화 정책과 항상 같이 검토되어야 하며,
해당 항목은 운영 이슈 재발 방지를 위해 테스트(국가 전환 + 캐시 적중)까지 고정하는 것을 권장한다.
### 코드리뷰 재검증 보강 (2026-03-27)
- 무엇을:
- 앞서 기록한 잠재 버그 2건을 실제 구현 파일 기준으로 재검토하고, 재현 전제와 우선순위를 보강했다.
- 왜:
- 현재 브랜치에 추가 수정(리팩터링/테스트 보강)이 포함되어 있어, 기존 리뷰 결론의 유효성을 재확인할 필요가 있었기 때문이다.
- 어떻게:
- 확인 파일:
- `src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt`
- 결과:
- 잠재 버그 1(추천 캐시 키 국가 차원 누락)은 여전히 유효하다.
- 근거: `LiveRecommendService.getRecommendLive`의 캐시 키가 `memberId`만 사용(`'getRecommendLive:' + memberId`)하고,
조회 결과는 `getStoredPreference(member).isAdult`(요청 국가 영향)로 달라질 수 있다.
- 전제: 동일 회원의 요청 국가가 TTL(3시간) 내 변경되는 환경.
- 잠재 버그 2(초기 생성 경쟁 시 중복 insert 위험)도 여전히 유효하다.
- 근거: `initializeDefaultPreference``findByMemberId`(비잠금 조회) 이후 `findByIdForUpdate(member)`를 잡고,
다시 `findByMemberId`(비잠금 조회)를 수행한다. MySQL REPEATABLE READ에서는 최초 스냅샷 영향으로
잠금 이후 재조회가 최신 row를 못 보고 중복 insert를 시도할 수 있다.
- 전제: 동일 회원 최초 접근이 동시 다발적으로 발생하는 경쟁 구간.
- 우선순위 제안:
- P1: 잠재 버그 2 완화(간헐적 DB unique 충돌/500 위험) — 사용자 오류로 직접 노출될 수 있어 우선 대응 권장.
- P2: 잠재 버그 1 보강(국가 전환 환경에서 정책 불일치 가능) — 운영 트래픽 특성(국가 전환 빈도)에 따라 단계 적용.
### 11차 작업 계획 (코드리뷰 잠재 버그 2건 보강, 2026-03-27)
- [x] 추천 라이브 캐시 키를 `memberId + isAdult` 기준으로 분리하고 무효화 키와 테스트를 동기화한다.
- [x] 선호 초기 row 생성 경로를 잠금 재조회 + unique 충돌 재조회 방식으로 보강한다.
- [x] 관련 타깃 테스트 및 전체 검증(`ktlintCheck`, `test`, `build`)을 수행한다.
### 11차 보강 구현 (잠재 버그 1/2 대응, 2026-03-27)
- 무엇을:
- 잠재 버그 1 대응:
- `LiveRecommendService`의 추천 조회 캐싱을 별도 빈 `LiveRecommendCacheService`로 분리하고,
캐시 키를 `getRecommendLive:{memberId}:{isAdult}` 형식으로 변경했다.
- 선호/차단 기반 무효화 경로(`MemberContentPreferenceService`, `MemberService`)를 `:false`, `:true` 키 양쪽 삭제로 확장했고,
롤링 배포 중 잔존 캐시 정리를 위해 기존 `getRecommendLive:{memberId}` 키 삭제도 함께 유지했다.
- 관련 테스트(`MemberContentPreferenceServiceTest`, `MemberServiceCacheEvictionTest`)를 신규 키 형식 기준으로 갱신했다.
- 잠재 버그 2 대응:
- `MemberContentPreferenceRepository``findByMemberIdForUpdate`를 추가해 잠금 재조회 경로를 명시했다.
- `MemberContentPreferenceService.initializeDefaultPreference`
`findByMemberId -> member lock -> findByMemberIdForUpdate -> saveAndFlush`로 보강하고,
unique 충돌(`DataIntegrityViolationException`) 발생 시 재조회 후 반환하도록 fallback을 추가했다.
- 경쟁 시나리오 회귀용 테스트(`shouldReturnStoredRowWhenDuplicateInsertOccurs`)를 추가했다.
- 왜:
- 동일 회원의 요청 정책 결과(`isAdult`)가 달라질 수 있는데 캐시 키가 memberId만 사용하면 stale 응답이 재사용될 수 있고,
REPEATABLE READ 환경에서 최초 동시 생성 경쟁 시 unique 충돌이 간헐적으로 사용자 오류로 노출될 수 있기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew ktlintCheck test build`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest.shouldEvictRecommendLiveCacheForRequesterAndTargetOnBlock" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest.shouldDelegateToRepositoryWithAdultFlagWhenMemberIsAuthenticated"`
- 결과:
- 타깃 테스트 통과.
- 전체 검증(`ktlintCheck`, `test`, `build`) 통과.
- 핵심 수동 QA 성격 시나리오(중복 insert fallback, 차단 시 양쪽 캐시 무효화, 성인 플래그 전달 조회) 통과.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
### 정정 (2026-03-27, 11차 중간 수정)
- 무엇을:
- 11차 1차 테스트에서 `MemberContentPreferenceServiceTest` 검증문이 `save`를 확인하고 있어 실패한 항목을 `saveAndFlush` 검증으로 정정했다.
- 왜:
- 동시성 보강 과정에서 서비스 저장 호출이 `save`에서 `saveAndFlush`로 변경되었기 때문이다.
- 어떻게:
- 실패 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- 조치:
- `MemberContentPreferenceServiceTest.shouldCreateDefaultPreferenceWhenRowIsMissing` 검증 대상을 `saveAndFlush`로 교체
- 재실행:
- 동일 타깃 테스트 명령 재실행 통과
### 12차 잠재 버그 재점검 (보강 후 재검토, 2026-03-27)
- 무엇을:
- 11차 보강 코드 재검토 중 `initializeDefaultPreference`의 unique 충돌 fallback 재조회가
비잠금 조회(`findByMemberId`)로 남아 있던 지점을 추가 보강했다.
- fallback 재조회를 `findByMemberIdForUpdate`로 변경해, REPEATABLE READ 스냅샷 영향으로 row를 못 보는 가능성을 낮췄다.
- 회귀 테스트(`MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs`)의 목 시퀀스를
변경된 fallback 호출 순서에 맞게 업데이트했다.
- 왜:
- 충돌 예외 이후 같은 트랜잭션에서 비잠금 재조회를 수행하면 스냅샷 일관성 때문에 최신 row를 못 보고
예외 재전파로 끝날 수 있어, 충돌 복구 경로의 신뢰성을 높일 필요가 있었기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew ktlintCheck test build`
- 결과:
- preference 서비스/통합 타깃 테스트 통과.
- 전체 검증(`ktlintCheck`, `test`, `build`) 통과.
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.

View File

@@ -0,0 +1,37 @@
# 20260325 회원 차단 요청 id만 적용
- [x] memberBlock 호출 흐름 및 동일 auth 일괄 차단 지점 확인
- [x] memberBlock 로직을 request.id 단일 차단으로 수정
- [x] 관련 테스트 보강 및 회귀 검증
- [x] LSP 진단, 테스트, 빌드 검증 수행
## 2차 수정 체크리스트
- [x] `MemberService.memberBlock` 의미 단위 주석 추가
- [x] `MemberServiceCacheEvictionTest` 신규 테스트 의미 단위 주석 추가
- [x] 테스트 및 빌드 재검증
## 검증 기록
### 1차 구현
- 무엇을: `MemberService.memberBlock`에서 동일 `auth` 기반 다중 계정 확장 차단을 제거하고, `request.blockMemberId` 1건만 차단/재활성화하도록 수정했다.
- 왜: 회원 차단 API가 요청한 대상 ID만 차단해야 하며, 동일 auth 계정 전체가 함께 차단되는 과차단 동작을 제거해야 하기 때문이다.
- 어떻게:
- 탐색: explore 2개 + librarian 1개 백그라운드 분석, `grep`/`ast-grep`/`glob`로 호출 흐름과 확장 지점 확인.
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt``memberBlock`에서 `authRepository.getMemberIdsByNameAndBirthAndDiAndGender(...)` 및 다중 루프 제거.
- 테스트 변경: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt``shouldBlockOnlyRequestedMemberEvenWhenTargetHasAuth` 추가.
- 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가 확인.
- 검증 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` → 성공
- `./gradlew build` → 성공
### 2차 수정
- 무엇을: 1차에서 작성한 `memberBlock` 변경 코드와 회귀 테스트 코드에 의미 단위 주석을 추가했다.
- 왜: 요청하신 대로 작성된 코드의 의도를 블록 단위로 바로 파악할 수 있도록 하기 위해서다.
- 어떻게:
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt``memberBlock`에 검증/단일대상차단/캐시무효화 의도 주석 추가.
- 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt``shouldBlockOnlyRequestedMemberEvenWhenTargetHasAuth`에 준비/실행/검증 주석 추가.
- 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가 확인.
- 검증 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` → 성공
- `./gradlew build` → 성공

View File

@@ -0,0 +1,30 @@
SET @schema_name := DATABASE();
SET @table_exists := (
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema = @schema_name
AND table_name = 'member_content_preference'
);
SET @create_table_sql := IF(
@table_exists = 0,
'CREATE TABLE member_content_preference (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
member_id BIGINT NOT NULL COMMENT ''회원 ID (member.id 참조)'',
is_adult_content_visible TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''성인 콘텐츠 노출 여부 (0: 비노출, 1: 노출)'',
content_type VARCHAR(20) NOT NULL DEFAULT ''ALL'' COMMENT ''콘텐츠 타입 필터 값'',
adult_content_visibility_changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''성인 콘텐츠 노출 설정 변경 시각'',
content_type_changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''콘텐츠 타입 설정 변경 시각'',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
PRIMARY KEY (id),
UNIQUE KEY uk_member_content_preference_member_id (member_id),
CONSTRAINT fk_member_content_preference_member_id FOREIGN KEY (member_id) REFERENCES member (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''회원 콘텐츠 조회 설정''',
'SELECT ''member_content_preference already exists'' AS message'
);
PREPARE create_table_stmt FROM @create_table_sql;
EXECUTE create_table_stmt;
DEALLOCATE PREPARE create_table_stmt;

View File

@@ -0,0 +1,102 @@
# 20260327 멤버 콘텐츠 선호 기본값 조정
## 목적
- `MemberContentPreference` 신규 생성 기본값을 다음 정책으로 고정한다.
- 기존 회원 + `member.auth != null` 인 경우: `isAdultContentVisible = true`, `contentType = ContentType.ALL`
- 그 외: `isAdultContentVisible = false`, `contentType = ContentType.ALL`
## 구현 체크리스트
- [x] 기본값 시드 로직을 `member.auth` 기준 정책으로 단순화한다.
- QA: row 미존재 + 인증/미인증 케이스에서 저장값이 각각 `true/ALL`, `false/ALL`인지 테스트로 확인
- [x] 레거시 조회 파라미터(`isAdultContentVisible`, `contentType`)가 신규 row 기본값에 영향을 주지 않도록 정리한다.
- QA: `resolveForQuery` 호출 시 파라미터 전달 여부와 무관하게 정책 기본값으로 생성되는지 확인
- [x] 관련 단위/통합 테스트 기대값을 정책에 맞게 수정한다.
- QA: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest` 통과
- [x] 회귀 검증을 실행한다.
- QA: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`, `./gradlew build` 성공
## 구현 완료 후 기록
### 1차 구현
- 무엇을:
- `MemberContentPreferenceService.initializeDefaultPreference`의 기본 seed를 `member.auth != null` 기준으로 변경해 인증 회원은 `true/ALL`, 그 외는 `false/ALL`로 생성되도록 수정했다.
- `resolveForQuery`의 신규 row 생성 seed 계산에서 legacy 파라미터를 제거하고 `member.auth` 기반 고정 정책(`true/ALL` 또는 `false/ALL`)만 사용하도록 정리했다.
- `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`의 관련 시나리오를 정책에 맞게 수정했다.
- 왜:
- 요청사항이 “기존 회원가입 + `member.auth != null`이면 `true/ALL`, 그 외는 `false/ALL`”로 명확하여, 신규 row 기본값이 요청 파라미터에 영향을 받지 않도록 일관된 기준으로 통일해야 했기 때문이다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldSeedPreferenceToTrueAndAllWhenRowMissingAndAuthenticatedRegardlessOfLegacyParams"`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- 결과:
- 정책 핵심 시나리오 단일 테스트 통과.
- 대상 단위/통합 테스트 통과.
- 전체 build(테스트/ktlint 포함) 통과.
- `.kt` 확장자용 LSP 서버가 현재 환경에 없어 `lsp_diagnostics`는 실행 불가였고, 대신 Gradle 검증으로 정합성을 확인했다.
## 연계 작업(동일 기능)
### 2차 구현 - `resolveForQuery` 조회 파라미터 제거
- 무엇을:
- `MemberContentPreferenceService.resolveForQuery` 시그니처에서 미사용 파라미터 2개
(`isAdultContentVisible`, `contentType`)를 제거하고 `member` 단일 파라미터로 정리했다.
- 시그니처 변경에 맞춰 서비스/컨트롤러/테스트의 `resolveForQuery` 호출부 인자 전달 코드를 일괄 정리했다.
- 왜:
- 실제로 사용되지 않는 파라미터를 제거해 함수 계약을 단순화하고, 호출부 가독성과 유지보수성을 높이기 위해서다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin compileTestKotlin`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
- 결과:
- 시그니처 변경 직후 컴파일 에러로 표시된 호출부를 모두 정리한 뒤 `compileKotlin/compileTestKotlin` 성공.
- 관련 단위/통합 테스트 통과.
- 전체 build(ktlint/test 포함) 성공.
- 현재 환경에는 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
Gradle 컴파일/테스트/빌드로 정합성을 확인했다.
### 3차 구현 - 수정 파일 미사용 파라미터 정리
- 무엇을:
- `resolveForQuery(member = member)`로 단순화된 이후 미사용 상태가 된
`resolvePreference` 헬퍼 파라미터를 12개 파일에서 제거했다.
- 헬퍼 호출부를 정리했고, null 회원 분기에서 실제로 파라미터를 사용하는 서비스/컨트롤러
(`HomeService`, `LiveApiService`, `AudioContentController`, `AudioContentMainTabHomeController`)는
기존 전달 로직을 유지했다.
- 왜:
- 사용되지 않는 파라미터는 경고와 혼선을 유발해 유지보수 비용을 높이므로,
실제 사용하는 함수 계약만 남겨 코드 의도를 명확히 하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
- 결과:
- `compileKotlin` 성공.
- 관련 단위/통합 테스트 성공.
- 전체 build(ktlint/test 포함) 성공.
- 현재 환경에 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
Gradle 컴파일/테스트/빌드 결과로 정합성을 확인했다.
### 4차 수정 - 잔여 미사용 파라미터 추가 정리
- 무엇을:
- 3차 정리 이후에도 남아 있던 수정 파일 내 함수 미사용 파라미터를 추가 제거했다.
- `resolvePreference(member: Member)`만 사용하는 컨트롤러들의
`@RequestParam("isAdultContentVisible")`, `@RequestParam("contentType")`를 제거하고 import를 정리했다.
- `ExplorerService.getCreatorProfile`의 미사용 파라미터 `isAdultContentVisible`을 제거하고
`ExplorerController` 호출부를 함께 수정했다.
- 왜:
- 실제 로직에서 사용되지 않는 파라미터를 제거해 함수 계약을 단순화하고,
유지보수 시 혼선을 줄이기 위해서다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin compileTestKotlin`
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew build`
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
- 결과:
- `compileKotlin`, `compileTestKotlin` 성공.
- 관련 단위/통합 테스트 성공.
- 전체 build(ktlint/test 포함) 성공.
- 현재 환경에 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
Gradle 검증으로 정합성을 확인했다.

View File

@@ -0,0 +1,46 @@
# 20260327 멤버 콘텐츠 선호 신규 생성 정책 수정
## 목적
- `resolveForQuery` 레거시 파라미터를 기존 row 갱신 용도로 사용하지 않고, **row 미존재 최초 생성 시에만** 제한적으로 사용한다.
- 최종 목표인 "MemberContentPreference 저장값만 조회에 사용" 방향으로 정책을 단순화한다.
## 최종 정책
- [x] `MemberContentPreference` 없음 + `member.auth != null`
- 요청 파라미터(`isAdultContentVisible`, `contentType`)가 있으면 전달값으로 생성한다.
- 요청 파라미터가 없으면 `isAdultContentVisible = true`, `contentType = ContentType.ALL`로 생성한다.
- [x] `MemberContentPreference` 없음 + `member.auth == null`
- `isAdultContentVisible = false`, `contentType = ContentType.ALL`로 생성한다.
- [x] `MemberContentPreference` 있음
- `resolveForQuery`로 들어온 요청 파라미터는 무시하고 저장값만 사용한다.
## 구현 체크리스트
- [x] `MemberContentPreferenceService` 생성 경로(`initializeDefaultPreference`)가 초기값을 정책 기반으로 받을 수 있도록 수정
- QA: `resolveForQuery` 호출 시 row 유/무에 따른 생성값이 테스트에서 일치하는지 확인
- [x] `resolveForQuery`에서 기존 row에 대한 레거시 파라미터 반영/캐시 무효화 제거
- QA: 기존 row + 파라미터 입력 시 저장값 불변 및 캐시 미무효화 테스트 통과
- [x] 관련 단위/통합 테스트 갱신
- QA: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest` 통과
- [x] 회귀 검증 실행
- QA: `./gradlew test`, `./gradlew ktlintCheck`, `./gradlew build` 성공
## 구현 완료 후 기록
### 1차 구현
- 무엇을:
- `MemberContentPreferenceService``PreferenceSeed`를 도입해 row 미존재 시 초기 생성값을 호출 목적에 맞게 주입하도록 변경했다.
- `resolveForQuery`는 더 이상 기존 row를 요청 파라미터로 갱신하지 않고, 저장값 조회 전용으로 동작하도록 수정했다.
- row 미존재 시 seed 정책을 다음과 같이 반영했다.
- `member.auth != null` + legacy 파라미터 존재: 전달값 기반 생성
- `member.auth != null` + legacy 파라미터 미존재: `true/ALL` 생성
- `member.auth == null`: 파라미터와 무관하게 `false/ALL` 생성
- `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`를 정책에 맞게 갱신/추가했다.
- 왜:
- 기존 row를 조회 API 파라미터로 계속 갱신하면 "저장값 단일 기준" 목표와 충돌하므로, 레거시 파라미터 역할을 row 최초 생성 시점으로 한정하기 위해서다.
- 기존 회원 중 row 미존재 사용자의 초기 생성 경로를 명시적으로 제어해 운영 일관성을 확보하기 위해서다.
- 어떻게:
- 명령:
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
- 결과:
- 정책 관련 단위/통합 테스트 통과.
- 전체 회귀 검증(`test`, `ktlintCheck`, `build`) 통과.
- `.kt` 대상 LSP 서버가 현재 환경에 없어 Kotlin LSP 진단은 수행 불가였고, 대신 Gradle 검증으로 대체했다.

View File

@@ -0,0 +1,50 @@
# 라이브 진행중 목록 19금 노출 정책 수정
## 완료 기준 (Pass/Fail)
- [x] `LiveRoomStatus.NOW` 조회 시 사용자 성인 설정과 무관하게 19금 라이브 방이 포함된다.
- [x] 예약 조회(`getLiveRoomListReservationWithDate`, `getLiveRoomListReservationWithoutDate`)의 성인 설정 필터 동작은 기존과 동일하다.
- [x] 기존 코드 패턴을 유지하며 최소 범위로 변경된다.
- [x] 변경 파일 LSP 진단 에러가 0건이다. *(Kotlin LSP 미지원 환경으로 `lsp_diagnostics` 실행 불가, 테스트/빌드 성공으로 대체 검증)*
- [x] 관련 테스트/빌드 검증 명령이 성공한다.
## 구현 체크리스트
- [x] NOW/예약 목록 분기 및 성인 필터 전달 경로를 확인한다.
- [x] NOW 목록 조회 경로만 정책에 맞게 수정한다. *(QA: NOW 경로 호출 인자 검증)*
- [x] 예약 목록 조회 경로가 기존 로직을 유지하는지 검증한다. *(QA: 예약 경로 호출 인자/쿼리 유지 확인)*
- [x] 익명 사용자(member=null) NOW 조회에서 성인 필터 우회 범위가 과도하지 않도록 조건을 보강한다. *(2차 가정, 3차에서 정책 정정됨)*
- [x] 정책 정정 반영: NOW 목록은 익명 사용자도 노출 대상이며, 후속 상세/입장 단계에서 인증/성인 검증을 수행하도록 분기와 테스트를 재정렬한다.
- [x] `FORCED_JP_MEMBER_IDS``37543L` 강제 매핑 회귀 테스트를 추가한다. *(QA: 정책/통합 테스트에 ID 37543L 검증 추가)*
- [x] 관련 테스트와 빌드 검증을 수행하고 결과를 문서에 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult = true`를 전달하도록 수정하고, 예약 분기는 기존 `isAdult` 전달을 유지했다. 또한 NOW/예약 전달 정책을 검증하는 `LiveRoomServiceAdultVisibilityPolicyTest`를 추가했다.
- 왜: 진행 중 라이브 목록은 사용자 성인 설정과 무관하게 19금 방을 노출하고, 예약 목록은 기존 정책대로 사용자 설정을 반영해야 하기 때문이다.
- 어떻게:
- 전달값 확인: `grep`으로 NOW/예약 분기의 `isAdult` 전달값 확인 (`isAdult = true` / `isAdult = isAdult`).
- LSP 진단 시도: `lsp_diagnostics` for `LiveRoomService.kt`, `LiveRoomServiceAdultVisibilityPolicyTest.kt`**불가(환경에 Kotlin LSP 서버 미구성)**
- 정책 단위 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest"`**성공(BUILD SUCCESSFUL)**
- 관련 선호도 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드: `./gradlew build`**성공(BUILD SUCCESSFUL)**
### 2차 수정 (리뷰 피드백 반영)
- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult` 전달값을 `member != null || isAdult`로 조정해 로그인 사용자에게만 우회가 적용되도록 보강했다. 또한 `LiveRoomServiceAdultVisibilityPolicyTest`에 비로그인 NOW 조회 회귀 케이스를 추가하고, `MemberContentPreferencePolicyTest`/`MemberContentPreferenceIntegrationTest``37543L -> JP` 강제 매핑 검증을 추가했다.
- 왜: 기존 `isAdult = true` 고정은 익명 사용자까지 성인 진행중 라이브를 노출할 수 있어 정책 범위가 과도해질 수 있으며, 강제 JP ID 추가(`37543L`)는 테스트로 고정해 회귀를 방지해야 하기 때문이다.
- 어떻게:
- LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일 4개 → **불가(환경에 Kotlin LSP 서버 미구성)**
- 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드(ktlint 포함): `./gradlew build`**성공(BUILD SUCCESSFUL)**
### 정정
- 정정 대상: `2차 수정 (리뷰 피드백 반영)`의 정책 가정(익명 NOW 노출 제한)
- 사유: 요구사항 재확인 결과, NOW 목록에서 익명 사용자 노출은 의도된 기능이며 상세/입장 단계에서 인증 및 성인 검증을 수행하는 정책으로 확정되었다.
- 변경 내용: NOW 분기의 익명 제한 보강(`member != null || isAdult`)을 제거하고, 익명 포함 우회(`isAdult = true`)로 복원했다. 관련 회귀 테스트도 익명 우회 기대값으로 정렬했다.
### 3차 수정 (정책 정정 반영)
- 무엇을: `LiveRoomService.getRoomList` NOW 분기의 `isAdult` 전달값을 `isAdult = true`로 복원했다. `LiveRoomServiceAdultVisibilityPolicyTest`의 익명 NOW 케이스를 `isAdult = true` 기대로 수정하고, 테스트명/DisplayName을 정책 의미에 맞게 변경했다.
- 왜: NOW 목록은 익명 사용자에게도 노출하되, 실제 터치 후 상세/입장 단계에서 인증 및 성인 검증(`live.room.adult_verification_required`)을 수행하는 것이 의도된 정책이기 때문이다.
- 어떻게:
- 탐색 근거 수집: Explore/Librarian + `grep` + `sg`로 NOW 노출 경로, 후속 인증 가드, 테스트 기대값을 재확인했다. (`rg`는 실행 환경에 미설치로 대체 탐색 수행)
- LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일들 → **불가(환경에 Kotlin LSP 서버 미구성)**
- 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드(ktlint 포함): `./gradlew build`**성공(BUILD SUCCESSFUL)**

View File

@@ -0,0 +1,24 @@
# 채널 후원 내역 탈퇴 닉네임 접두사 제거
## 완료 기준 (Pass/Fail)
- [x] 채널 후원 내역 리스트 조회 응답에서 탈퇴 회원 닉네임의 `deleted_` 접두사가 제거된다.
- [x] 비탈퇴 회원 닉네임은 기존과 동일하게 노출된다.
- [x] 기존 코드베이스의 유사 처리 패턴과 동일한 방식으로 구현된다.
- [x] 변경 파일 LSP 진단 에러가 0건이다. *(Kotlin LSP 미지원 환경으로 `lsp_diagnostics` 실행 불가, `./gradlew build` 성공으로 대체 검증)*
- [x] 관련 테스트/빌드 검증 명령이 성공한다.
## 구현 체크리스트
- [x] `deleted_` 닉네임 처리 유사 구현 위치를 전수 탐색한다.
- [x] 채널 후원 내역 조회 응답 생성 경로를 확인한다.
- [x] 조회 시점에 닉네임 접두사 제거 로직을 반영한다.
- [x] 변경사항 검증 후 체크리스트를 완료 처리한다.
## 검증 기록
### 1차 구현
- 무엇을: 채널 후원 내역 조회 응답의 탈퇴 회원 닉네임에서 `deleted_` 접두사를 제거하고, 동일 동작을 검증하는 테스트를 추가했다.
- 왜: 탈퇴 회원 닉네임이 API 응답에 내부 저장 포맷(`deleted_`) 그대로 노출되는 문제를 해결하기 위해서다.
- 어떻게:
- `lsp_diagnostics` 실행 시도: `ChannelDonationService.kt` 대상 실행 → **불가(환경에 Kotlin LSP 서버 미구성)**
- 기능 집중 테스트 실행: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest.shouldRemoveDeletedPrefixFromNicknameInDonationList"`**성공(BUILD SUCCESSFUL)**
- 관련 테스트 실행: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationControllerTest"`**성공(BUILD SUCCESSFUL)**
- 전체 빌드 실행: `./gradlew build`**성공(BUILD SUCCESSFUL)**

View File

@@ -0,0 +1,44 @@
# 20260328 콘텐츠 조회 파라미터 제거 및 비로그인 기본값 고정
## 목적
- 모든 API에서 `isAdultContentVisible`, `contentType` 요청 파라미터를 제거한다.
- 비로그인 사용자는 항상 `isAdultContentVisible = false`, `contentType = ContentType.ALL`로 처리한다.
- 로그인 사용자는 기존과 동일하게 `MemberContentPreference` 기반 로직을 유지한다.
## 구현 체크리스트
- [x] `isAdultContentVisible`, `contentType`를 받는 잔여 API 시그니처를 모두 제거한다.
- QA: `grep("@RequestParam(\"isAdultContentVisible\"|@RequestParam(\"contentType\")")` 결과가 0인지 확인
- [x] 연관 서비스 메서드 시그니처/호출부를 정리한다.
- QA: `compileKotlin` 성공으로 시그니처 불일치가 없는지 확인
- [x] 비로그인 기본값을 `false/ALL`로 고정한다.
- QA: 익명 분기 `ViewerContentPreference(false/ALL)` 코드 확인 + 관련 테스트 통과
- [x] 로그인 분기는 기존 `memberContentPreferenceService.resolveForQuery(member = member)` 흐름을 유지한다.
- QA: 관련 컨트롤러/서비스에서 로그인 분기 호출 유지 확인
- [x] 회귀 검증을 수행한다.
- QA: `./gradlew test`, `./gradlew build` 성공
## 구현 완료 후 기록
### 1차 구현
- 무엇을:
- 잔여 API 파라미터를 전부 제거했다.
- `HomeController`, `LiveApiController`, `LiveRoomController`, `AudioContentController`, `AudioContentMainTabHomeController`
- 연관 서비스 시그니처와 호출부를 정리했다.
- `HomeService`, `LiveApiService`, `LiveRoomService`
- 비로그인 분기 기본값을 `ViewerContentPreference(isAdultContentVisible = false, contentType = ContentType.ALL, isAdult = false)`로 고정했다.
- 왜:
- 요청사항이 “모든 API에서 해당 파라미터 제거 + 비로그인 기본값 고정 + 로그인 기존 동작 유지”로 명확했기 때문이다.
- 어떻게:
- 명령:
- `./gradlew compileKotlin compileTestKotlin`
- `grep("@RequestParam(\"isAdultContentVisible\"|@RequestParam(\"contentType\")", include="*Controller.kt")`
- `ast-grep: ViewerContentPreference(isAdultContentVisible = false, contentType = ContentType.ALL)`
- `./gradlew test`
- `./gradlew build`
- `lsp_diagnostics`(수정된 `.kt` 파일 대상)
- 결과:
- 컴파일 성공(`compileKotlin`, `compileTestKotlin`).
- 컨트롤러의 `@RequestParam("isAdultContentVisible")`, `@RequestParam("contentType")` 검색 결과 0건.
- 비로그인 기본값 고정 분기 5개 위치 확인(`HomeService`, `LiveApiService`, `LiveRoomService`, `AudioContentController`, `AudioContentMainTabHomeController`).
- `./gradlew test` 성공.
- `./gradlew build` 성공.
- 현재 환경은 Kotlin LSP 서버 미구성으로 `lsp_diagnostics(.kt)` 실행 불가였고, Gradle 컴파일/테스트/빌드로 정합성 검증 완료.

View File

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

View File

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

View File

@@ -18,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,16 @@ class HomeService(
fun fetchData(
timezone: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): GetHomeResponse {
val preference = resolvePreference(member)
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,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
@@ -102,14 +104,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 +130,7 @@ class HomeService(
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType
contentType = resolvedContentType
)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
@@ -137,7 +139,7 @@ class HomeService(
val translatedDayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = resolvedContentType,
dayOfWeek = getDayOfWeekByTimezone(timezone)
)
@@ -157,7 +159,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 +168,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 +188,7 @@ class HomeService(
val pointAvailableContentList = getRandomizedContentList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = resolvedContentType,
theme = emptyList(),
isFree = false,
isPointAvailableOnly = true
@@ -212,9 +214,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 +224,18 @@ class HomeService(
fun getLatestContentByTheme(
theme: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<AudioContentMainItem> {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = 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 +245,7 @@ class HomeService(
return contentService.getLatestContentByTheme(
memberId = memberId,
theme = themeList,
contentType = contentType,
contentType = resolvedContentType,
isFree = false,
isAdult = isAdult
)
@@ -252,32 +253,30 @@ class HomeService(
fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<GetSeriesListResponse.SeriesListItem> {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = 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,
offset: Long?,
limit: Long?,
theme: String?,
member: Member?
): List<GetAudioContentRankingItem> {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
@@ -291,7 +290,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 +319,20 @@ class HomeService(
}
fun getRecommendContentList(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?,
excludeContentIds: List<Long> = emptyList()
): List<AudioContentMainItem> {
val preference = resolvePreference(member)
return getRecommendContentListByPreference(preference, member, excludeContentIds)
}
private fun getRecommendContentListByPreference(
preference: ViewerContentPreference,
member: Member?,
excludeContentIds: List<Long>
): List<AudioContentMainItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
// 3개의 버킷(최근/중간/과거)에서 후보군을 조회한 뒤, 시간감쇠 점수 기반으로 샘플링한다.
val buckets = listOf(
@@ -350,7 +356,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 +380,19 @@ class HomeService(
return result.take(RECOMMEND_TARGET_SIZE).shuffled()
}
private fun resolvePreference(member: Member?): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(member = member)
}
private fun pickByTimeDecay(
batch: List<AudioContentMainItem>,
targetSize: Int,

View File

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

View File

@@ -8,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,21 @@ class LiveApiService(
private val contentService: AudioContentService,
private val recommendService: LiveRecommendService,
private val creatorCommunityService: CreatorCommunityService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val blockMemberRepository: BlockMemberRepository
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
timezone: String,
member: Member?
): LiveMainResponse {
val preference = resolvePreference(member)
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
val liveOnAirRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(20),
member = member,
timezone = timezone
@@ -55,7 +56,7 @@ class LiveApiService(
val replayLive = contentService.getLatestContentByTheme(
memberId = memberId,
theme = listOf("다시듣기"),
contentType = contentType,
contentType = preference.contentType,
isFree = false,
isAdult = isAdult
)
@@ -77,7 +78,6 @@ class LiveApiService(
val liveReservationRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.RESERVATION,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
@@ -93,4 +93,17 @@ class LiveApiService(
liveReservationRoomList = liveReservationRoomList
)
}
private fun resolvePreference(member: Member?): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(member = member)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
@@ -527,18 +528,30 @@ 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)
?: throw SodaException(messageKey = "content.error.user_not_found")
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
memberId = member.id!!,
contentId = audioContent.id!!
)
val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)
val isBlockedAndPurchased = isBlocked && isExistsAudioContent
if (isBlocked && !isExistsAudioContent) {
throw SodaException(messageKey = "content.error.blocked_access")
}
@@ -547,11 +560,6 @@ class AudioContentService(
memberId = member.id!!
)
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
memberId = member.id!!,
contentId = audioContent.id!!
)
val orderSequence = if (isExistsAudioContent) {
limitedEditionOrderRepository.getOrderSequence(
contentId = audioContent.id!!,
@@ -561,7 +569,12 @@ class AudioContentService(
null
}
val seriesId = repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
val seriesId = if (isBlockedAndPurchased) {
null
} else {
repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
}
val previousContent = if (seriesId != null) {
repository.findPreviousContent(
seriesId = seriesId,
@@ -592,7 +605,7 @@ class AudioContentService(
}
// 댓글
val commentList = if (audioContent.isCommentAvailable) {
val commentList = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
commentRepository.findByContentId(
cloudFrontHost = coverImageHost,
contentId = audioContent.id!!,
@@ -607,7 +620,7 @@ class AudioContentService(
}
// 댓글 수
val commentCount = if (audioContent.isCommentAvailable) {
val commentCount = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
commentRepository.totalCountCommentByContentId(
contentId = audioContent.id!!,
memberId = member.id!!,
@@ -662,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)
@@ -856,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,
@@ -896,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
@@ -970,7 +986,7 @@ class AudioContentService(
offset: Long,
limit: Long
): GetAudioContentListResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isCreator = member.id == creatorId
if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.explorer
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.SortType
@@ -31,6 +30,7 @@ import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Pageable
@@ -48,6 +48,7 @@ import kotlin.random.Random
@Transactional(readOnly = true)
class ExplorerService(
private val memberService: MemberService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val audioContentService: AudioContentService,
private val donationRankingService: CreatorDonationRankingService,
@@ -257,9 +258,10 @@ class ExplorerService(
fun getCreatorProfile(
creatorId: Long,
timezone: String,
isAdultContentVisible: Boolean,
member: Member
): GetCreatorProfileResponse {
val preference = memberContentPreferenceService.resolveForQuery(member = member)
// 크리에이터(유저) 정보
val creatorAccount = queryRepository.getMember(creatorId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
@@ -307,6 +309,7 @@ class ExplorerService(
queryRepository.getLiveRoomList(
creatorId,
userMember = member,
isAdult = preference.isAdult,
timezone = timezone
)
} else {
@@ -318,8 +321,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 +351,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 +389,7 @@ class ExplorerService(
timezone = timezone,
offset = 0,
limit = 3,
isAdult = member.auth != null
isAdult = preference.isAdult
)
} else {
listOf()
@@ -412,8 +419,8 @@ class ExplorerService(
seriesService
.getSeriesList(
creatorId = creatorId,
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member
)
.items

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.Create
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.ModifyCommunityPostCommentRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.lang.Nullable
import org.springframework.security.access.prepost.PreAuthorize
@@ -23,7 +24,10 @@ import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/creator-community")
class CreatorCommunityController(private val service: CreatorCommunityService) {
class CreatorCommunityController(
private val service: CreatorCommunityService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@PostMapping
@PreAuthorize("hasRole('CREATOR')")
fun createCommunityPost(
@@ -68,6 +72,22 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
)
}
@PutMapping("/fixed")
@PreAuthorize("hasRole('CREATOR')")
fun updateCommunityPostFixed(
@RequestBody request: UpdateCommunityPostFixedRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.updateCommunityPostFixed(
request = request,
member = member
)
)
}
@GetMapping
fun getCommunityPostList(
@RequestParam creatorId: Long,
@@ -76,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(
@@ -84,7 +105,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
timezone = timezone,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = member.auth != null
isAdult = isAdult
)
)
}
@@ -96,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
)
)
}
@@ -113,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")
@@ -123,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(
@@ -130,7 +155,8 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
postId = request.postId,
parentId = request.parentId,
isSecret = request.isSecret,
member = member
member = member,
isAdult = isAdult
)
)
}
@@ -155,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(
@@ -162,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
)
)
}
@@ -175,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(
@@ -182,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
)
)
}
@@ -193,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
)
)
}
@@ -209,13 +240,14 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.purchasePost(
postId = request.postId,
memberId = member.id!!,
timezone = request.timezone,
isAdult = member.auth != null,
isAdult = isAdult,
container = request.container
)
)

View File

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

View File

@@ -33,6 +33,7 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime
import java.time.ZoneId
@Service
@@ -158,6 +159,11 @@ class CreatorCommunityService(
if (request.isActive != null) {
post.isActive = request.isActive
if (!post.isActive) {
post.isFixed = false
post.fixedAt = null
}
}
if (postImage != null) {
@@ -179,6 +185,28 @@ class CreatorCommunityService(
}
}
@Transactional
fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) {
val post = repository.findByIdAndMemberId(id = request.postId, memberId = member.id!!)
?: throw SodaException(messageKey = "common.error.invalid_request")
if (request.isFixed) {
if (!post.isFixed) {
val fixedPostCount = repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(member.id!!)
if (fixedPostCount >= 3) {
throw SodaException(messageKey = "creator.community.max_fixed_post_count")
}
}
post.isFixed = true
post.fixedAt = LocalDateTime.now()
} else {
post.isFixed = false
post.fixedAt = null
}
}
fun getCommunityPostList(
creatorId: Long,
memberId: Long,
@@ -352,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
@@ -377,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!!
@@ -452,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())
}
@@ -481,9 +517,14 @@ class CreatorCommunityService(
memberId: Long,
timezone: String,
offset: Long,
limit: Long
limit: Long,
isAdult: Boolean
): GetCommunityPostCommentListResponse {
val parentComment = commentRepository.findByIdOrNull(id = commentId)
if (parentComment != null && !isAdult && parentComment.creatorCommunity!!.isAdult) {
throw SodaException(messageKey = "creator.community.invalid_request_retry")
}
if (
parentComment != null &&
isBlockedBetweenMembers(memberId = memberId, creatorId = parentComment.creatorCommunity!!.member!!.id!!)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.search
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,20 +13,22 @@ 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,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.searchUnified(
keyword,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
member = member
)
)
@@ -35,8 +37,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 +44,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()
@@ -56,17 +54,16 @@ class SearchController(private val service: SearchService) {
@GetMapping("/contents")
fun searchContentList(
@RequestParam keyword: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.searchContentList(
keyword,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -77,21 +74,22 @@ class SearchController(private val service: SearchService) {
@GetMapping("/series")
fun searchSeriesList(
@RequestParam keyword: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
ApiResponse.ok(
service.searchSeriesList(
keyword,
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) = memberContentPreferenceService.resolveForQuery(member = member)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,12 @@ import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
@@ -18,17 +21,24 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import java.time.LocalDateTime
import java.util.Optional
class CreatorCommunityServiceTest {
private lateinit var repository: CreatorCommunityRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var likeRepository: CreatorCommunityLikeRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository
private lateinit var applicationEventPublisher: ApplicationEventPublisher
@@ -38,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)
@@ -46,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),
@@ -61,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() {
@@ -70,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) }
@@ -80,7 +114,8 @@ class CreatorCommunityServiceTest {
comment = "새 댓글",
postId = post.id!!,
parentId = null,
isSecret = false
isSecret = false,
isAdult = true
)
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
@@ -105,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) }
@@ -115,12 +150,142 @@ 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() {
val creator = createMember(id = 55L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 501L
post.member = creator
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
val exception = assertThrows(SodaException::class.java) {
service.updateCommunityPostFixed(
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = true),
member = creator
)
}
assertEquals("creator.community.max_fixed_post_count", exception.messageKey)
}
@Test
@DisplayName("고정 요청 시 3개 미만이면 게시물이 고정된다")
fun shouldPinPostWhenFixedPostCountIsUnderLimit() {
val creator = createMember(id = 66L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 601L
post.member = creator
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(2L)
service.updateCommunityPostFixed(
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = true),
member = creator
)
assertTrue(post.isFixed)
assertNotNull(post.fixedAt)
}
@Test
@DisplayName("고정 해제 요청 시 게시물이 고정 해제된다")
fun shouldUnfixPostWhenIsFixedIsFalse() {
val creator = createMember(id = 77L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 701L
post.member = creator
post.isFixed = true
post.fixedAt = LocalDateTime.now()
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
service.updateCommunityPostFixed(
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = false),
member = creator
)
assertFalse(post.isFixed)
assertNull(post.fixedAt)
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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