diff --git a/docs/20261014_크리에이터이름언어별관리/delivery-name-inventory.md b/docs/20261014_크리에이터이름언어별관리/delivery-name-inventory.md new file mode 100644 index 00000000..f8217fce --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/delivery-name-inventory.md @@ -0,0 +1,324 @@ +# P3 이름 전달 경로 인벤토리와 GREEN 검증 인계 + +## COR022 최신 범위 + +- 레거시 MessageService/Repository는 이번이름변경을 HEAD로 되돌리고 MessageListRow 및 MessageServiceLocalizedNameTest3건을 제거한다. 목록6개와차단오류의이름치환·이벤트nameArgumentMemberIds2곳만 원래형태로돌아가며 API/발송/보관/삭제업무는유지한다. +- 공통FCM/v2 DM/WS는유지한다. 레거시발송 본문args는원본이지만 기존senderMemberId로 공통알림senderNicknameSnapshot이현지화될수있는경계를그대로둔다. 아래T-MESSAGE·JOIN목록은이전이력이며최신검증은 plan-task.md MESSAGE-NAME-TRIM-T1을 따른다. +- 적용완료: 두운영파일HEAD일치·전용2파일삭제, 실제HTTP23요청/발송이벤트2건 및전체2267건/lint통과를확인했다. 임시검증소스·클래스·서버·Redis는정리했고영구테스트3건만제거됐다. + +## 상태와 경계 + +- 기준: `prd.md` NAME002/003/005/006, COR012, `plan-task.md` P3-T1/P3-T2. 이 파일은 부모 에이전트의 실행 인계이며 원 PRD/계획은 수정하지 않았다. +- 1차 인계에서는 대표 RED 테스트 4개 파일과 이 인벤토리만 작성했다. 이어진 부모의 GREEN 위임으로 아래 소유 서버 경로를 구현하고 실제 fixture 회귀를 보강했다. core Member/Lang/creatorname 모듈, 설정 파일, DDL, 외부 저장소는 수정하지 않았다. 커밋/푸시/Gradle 실행은 하지 않았다. +- 부모의 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-consumer-red.log`는 104개 중 35개 실패를 기록한다. FCM/WS/message/live의 이름 및 handshake assertion RED를 읽어 확인했다. live 상세 1건은 아래 fixture 보정 대상이다. 이 작업의 GREEN 실행 결과는 아직 없으며 부모가 직렬 검증한다. +- 아래 1차 조사/설계 표는 경로 추적 이력이다. 최종 구현 상태와 변경 파일 및 실행 대기는 문서 끝의 **GREEN 구현 상태**가 우선한다. 테이블 부재/컴파일/컨텍스트 오류를 RED 성공으로 간주하지 않는다. +- 기반 계약: `kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService.findLocalizedNames(Collection, Lang): Map`. JA + 활성 + 실제 `Member.role=CREATOR`만 반환하고 다른 언어/미등록/비활성/USER는 원본 fallback한다. HTTP 경계 외 `LangContext` 접근, 행별 조회, 전역 JSON 변환은 금지한다. +- 테스트는 `MemberRepository.saveAndFlush` 후 승인된 `creator_localized_name` 테이블에 JDBC로 실제 JA 행을 넣는다. 이름 resolver/repository/서비스는 mock하지 않는다. USER alias 행은 read-side role 필터를 검증하기 위한 의도적인 fixture이며 관리 API 허용 사례가 아니다. +- 새 통합 테스트의 mock은 외부 전송 경계인 `FcmService`, `S3Uploader`만 쓴다. 기존 Mockito 단위 테스트 및 Redis-only 컨텍스트는 새 필수 생성자 의존성을 명시적으로 제공하도록 조정했다. Redis는 각 클래스의 `EmbeddedRedisInitializer` opt-in으로 제한한다. 테스트 JWT는 공개된 테스트용 64-byte secret을 클래스 속성으로 지정한다. 라이브 토큰은 더미 자격증명으로 로컬 서명만 수행하며 실제 RTC 연결은 하지 않는다. + +## 1차 테스트와 예상 RED 이력 + +모든 경로는 저장소 루트 기준이다. T-FCM/T-WS/T-LIVE/T-MESSAGE는 아래 **정확한 테스트 파일**을 뜻한다. + +| 식별자 | 테스트 파일 | 실제 실행 경로와 단언 | 기존 동작의 예상 RED | +|---|---|---|---| +| T-FCM | `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt` | 실제 `UserCreatorChatService.sendTextMessageByWebSocket`가 발행한 DM 이벤트, 실제 `LiveRoomService.cancelLive`가 발행한 취소 이벤트를 `@RecordApplicationEvents`로 수집. 실제 listener target을 동일 테스트 트랜잭션에서 호출하고 outbound 경계만 관찰. JA/KO 토큰 그룹별 title/body/새 notification snapshot, 255 code point 별칭 저장·재조회, 원본 이벤트 args/title 불변. 이름과 같은 `creator.*$` 방 제목을 본문 인자로 유지. 이름 변경 뒤 새 알림을 생산해도 과거 저장 행이 불변인지 재조회 | JA DM 본문과 snapshot이 `dm-creator`, JA 라이브 제목과 snapshot이 `creator.*$`로 남음. KO/문구 번역/과거 행 보존은 통과해야 함 | +| T-WS | `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatLocalizedNameIntegrationTest.kt` | 실제 JWT/Redis 인증으로 handshake JA/KO/EN/미지원/누락 헤더 속성. `RANDOM_PORT` + `StandardWebSocketClient`로 localhost 연결, JOIN 후 실제 DB 저장과 Redis pubsub를 거쳐 같은 회원의 KO/JA 두 세션 비교. 별도 테스트로 JA 발신자의 SEND_ACK와 KO 수신 MESSAGE 비교. 실제 HTTP open/messages 언어별 이름도 확인 | handshake의 `attributes["lang"]` 없음. JA MESSAGE/ACK/open/messages 이름이 `original-creator`로 남음. ID/body/mine/requestId는 보존되어야 함 | +| T-LIVE | `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt` | 실제 JPA/Redis membership을 사용한 getRoomInfo, getRoomDetail, getUserProfile. speaker/manager CREATOR 별칭, USER/비활성 fallback, membership role 보존. donation/roulette 메시지의 UUID/본문/secret 필터, JA→KO 응답 독립성과 공유 Redis 원본 불변. 최근 종료 라이브 KO→JA 캐시 읽기 | JA host/manager/현재 후원·룰렛 표시 이름 및 최근 종료 라이브가 원본으로 남음. KO/USER/비활성/secret 필터/Redis 원본 단언은 통과해야 함 | +| T-MESSAGE | `src/test/kotlin/kr/co/vividnext/sodalive/message/MessageServiceLocalizedNameTest.kt` | 실제 Message sender/recipient 관계 저장 후 문자/음성 sent/received/keep 6개 읽기. CREATOR 발신자/수신자 양쪽, USER 이름 보존, JA/KO/EN, 원본 본문/ID/voice URL 보존 | JA senderNickname/recipientNickname가 원본으로 남음 | + +1차 작성 수는 FCM 3개, WS 8개 실행 사례(언어 parameterized 4개 포함), live 4개, legacy message 2개로 총 17개였다. GREEN 위임 후 현재 수는 FCM 6개, WS 9개, live 8개, legacy message 2개로 총 25개다. 추가 사례는 실제 오디오 답글 생산자의 title owner/actor 분리(CREATOR/USER), 실제 기프트 자유 문구, 음성 REST/WS 언어 독립, 중립 feed/current fallback, 예약/방문/추천/후원/하트 현재 이름이다. 전체 P3 외부 수용 기준의 완료를 뜻하지 않는다. + +부모 전용 실행 명령(이번 작업에서 미실행): + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.fcm.FcmSendListenerLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatLocalizedNameIntegrationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.live.room.LiveRoomLocalizedNameIntegrationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.message.MessageServiceLocalizedNameTest' +``` + +테스트 listener 호출은 `@Async`/REQUIRES_NEW proxy를 의도적으로 우회한다. rollback 예정인 fixture를 다른 트랜잭션에서 읽는 오류 없이 실제 언어 그룹화/문구 렌더링/DB 저장을 검증한다. async after-commit 스케줄링 자체는 이 테스트의 검증 대상이 아니다. 반면 WS 테스트는 fixture를 commit한 뒤 실제 네트워크/Redis/handler/service/broker 전체를 사용한다. 기존 localhost `StandardWebSocketClient` 선례는 검색되지 않았으며 기존 `UserCreatorChatWebSocketHandshakeIntegrationTest`의 endpoint/인증 계약과 `UserCreatorChatRedisIntegrationTest`의 실제 pubsub 선례를 결합했다. + +## 1차 FCM 경계와 메타데이터 설계 + +기존 구현은 `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt` 한 파일의 `FcmEvent`/`FcmSendListener`다. 별도의 FcmSendEvent/FcmSendListEvent 타입은 없다. 1차 조사 시 `sendPush`는 `PushTokenInfo.languageCode`로 그룹화하고 `Lang.fromAcceptLanguage`를 적용하지만 원본 `args`와 `title`을 그대로 사용했다. titleKey와 messageKey는 같은 args 배열을 사용한다. 이제 아래 명시 metadata로 그룹별 새 렌더링을 수행한다. + +GREEN에서 부모와 호출부 소유자가 합의하여 적용한 명시 정보: + +- 기존 `senderMemberId`는 **알림 행의 발신자 snapshot 주체**로 유지한다. 이미 이름을 전달하는 모든 호출부에 있다. `creatorId`, 수신자 ID, resource ID를 sender ID로 추정하지 않는다. +- 확정 필드 `titleMemberId: Long? = null`: raw title 전체가 회원 이름인 경우 그 회원 ID. 보통 senderMemberId와 같지만 오디오 답글은 다르다. +- 확정 필드 `nameArgumentMemberIds: Map = emptyMap()`: 0-based args 위치→실제 이름 소유 회원 ID. 현재 대상은 legacy 문자/음성, v2 DM/voice의 `[0]`뿐이다. 문자열 종류/값 일치/정규식/messageKey 추론으로 이름을 찾지 않는다. +- language group마다 sender/title/arg ID의 합집합을 batch 조회하고 별도 args/title/body/senderNicknameSnapshot을 만든다. 원본 이벤트/공유 DTO/저장한 과거 알림을 mutate하지 않는다. 실제 생산자에서 발행한 이벤트를 테스트하므로 metadata가 추가되면 테스트 fixture에 가짜 필드를 붙일 필요 없이 실제 호출부 수정이 검증된다. +- `PushNotificationService.saveNotification`은 language group에서 렌더링한 sender snapshot을 저장하도록 연결한다. `resolveSenderSnapshot`의 기존 명시 snapshot/profile/fallback 의미를 보존한다. `PushNotificationList`에는 senderMemberId가 없으므로 과거 목록 조회 때 현재 회원과 다시 연결하지 않는다. + +### 전 FcmEvent 발행 지점 22곳 + +아래 경로의 공통 prefix는 `src/main/kotlin/kr/co/vividnext/sodalive/`다. 파일 경로와 메서드를 모두 명시했다. 행 번호는 1차 조사 기준이며 이후 수정으로 이동할 수 있다. `titleId`/`argIds`는 위 확정 metadata 값이다. `[외부 소유]`는 이 작업의 소유 범위 밖이지만 서버 내부 파일이라는 뜻이다. 각 행의 최종 처리 상태는 끝의 상태 표에 연결했다. + +| # | 생산 파일:행 / 메서드 | 이벤트·현재 이름 위치·이름 아닌 값 | GREEN titleId / argIds / sender snapshot 주체 | 소유/검증 | +|---|---|---|---|---| +| 1 | `fcm/FcmController.kt:23` send 개별 | INDIVIDUAL; 운영자 자유 title/message, 이름 슬롯 없음 | null / empty / 기존 없음 | P3; 임의 텍스트 불변 | +| 2 | `fcm/FcmController.kt:34` send 전체 | ALL; 운영자 자유 title/message, 이름 슬롯 없음 | null / empty / 기존 없음 | P3; 임의 텍스트 불변 | +| 3 | `message/MessageService.kt:74` sendTextMessage | SEND_MESSAGE; titleKey=message.fcm.title, args[0]=sender.nickname, messageKey=text_received | null / `{0: sender.id}` / sender.id | P3; T-MESSAGE + 기존 FCM 문구 | +| 4 | `message/MessageService.kt:149` sendVoiceMessage | SEND_MESSAGE; args[0]=sender.nickname, messageKey=voice_received | null / `{0: sender.id}` / sender.id | P3; T-MESSAGE + 기존 FCM 문구 | +| 5 | `v2/usercreatorchat/service/UserCreatorChatService.kt:235` publishMessagePush | INDIVIDUAL; text/voice 공통 발행. args[0]=sender.nickname, titleKey=dm_title 또는 title, room.id는 deepLinkId | null / `{0: sender.id}` / sender.id | P3; T-FCM 실제 DM 생산자 | +| 6 | `live/room/LiveRoomService.kt:503` createLiveRoom | CREATE_LIVE; raw title=createdRoom.member.nickname; args[0]=createdRoom.title(방 제목) | createdRoom.member.id / empty / 기존 같은 ID | P3; T-FCM 취소와 같은 title 패턴 | +| 7 | `live/room/LiveRoomService.kt:685` startLive | START_LIVE; raw title=room.member.nickname; args[0]=room.title(방 제목) | room.member.id / empty / 기존 같은 ID | P3; 기존 live regression | +| 8 | `live/room/LiveRoomService.kt:760` cancelLive | CANCEL_LIVE; raw title=room.member.nickname; args[0]=room.title, 예약 취소 전 수집한 pushTokens 사용 | room.member.id / empty / 기존 같은 ID | P3; T-FCM 실제 취소 생산자 | +| 9 | `explorer/ExplorerService.kt:683` saveNotice | CHANGE_NOTICE; title=member.nickname, 고정 notice messageKey, args 없음 | member.id / empty / 기존 member.id | [외부 소유: legacy worker] | +| 10 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:139` createCommunityPost | CHANGE_NOTICE; title=member.nickname, new_post messageKey | member.id / empty / 기존 member.id | [외부 소유: legacy worker] | +| 11 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:532` createCommunityPostComment | INDIVIDUAL; title=댓글 작성 member.nickname. 수신 creatorId와 작성자 다름 | member.id / empty / 기존 member.id | [외부 소유: legacy worker] | +| 12 | `content/comment/AudioContentCommentService.kt:81` registerComment | CREATE_CONTENT_COMMENT; title은 답글이면 parent.member.nickname, 일반 댓글이면 member.nickname. args[0]=audioContent.title | `parent?.member?.id ?: member.id` / empty / **senderMemberId=member.id 유지** | [외부 소유: legacy worker]; 부모 댓글 작성자와 행위자 각각 CREATOR/USER 조합 회귀 필요 | +| 13 | `content/AudioContentService.kt:492` uploadComplete 완료 안내 | INDIVIDUAL; titleKey=upload_complete_title, message=audioContent.title. 본문은 이름 아님 | null / empty / 기존 audioContent.member.id | [외부 소유: legacy worker]; 생산자 추가 metadata 불필요, 새 sender snapshot만 언어별 | +| 14 | `content/AudioContentService.kt:511` uploadComplete 즉시 공개 | UPLOAD_CONTENT; title=audioContent.member.nickname; args[0]=콘텐츠 제목 | audioContent.member.id / empty / 기존 같은 ID | [외부 소유: legacy worker] | +| 15 | `content/AudioContentService.kt:541` releaseContent | UPLOAD_CONTENT; title=audioContent.member.nickname; args[0]=콘텐츠 제목 | audioContent.member.id / empty / 기존 같은 ID | [외부 소유: legacy worker] | +| 16 | `admin/live/AdminLiveService.kt:341` cancelNotStartedLive4hoursPassed | CANCEL_LIVE; title=room.member.nickname; args[0]=방 제목 | room.member.id / empty / 기존 같은 ID | 부모의 후속 권한 부여로 titleMemberId 한 줄만 이 작업에서 연결 | +| 17 | `can/charge/event/ChargeEventService.kt:80` applyOtherEvent | INDIVIDUAL SYSTEM; title=chargeEvent.title(이벤트 제목), args[0]=additionalCan(숫자) | null / empty / 없음 | [외부 소유: legacy]; 수정 불필요 | +| 18 | `can/charge/event/ChargeEventService.kt:104` applyFirstChargeEvent | INDIVIDUAL SYSTEM; 고정 titleKey, args[0]=additionalCan | null / empty / 없음 | [외부 소유: legacy]; 수정 불필요 | +| 19 | `v2/can/charge/event/ChargeEventJobService.kt:187` publishPaidNotification FIRST_CHARGE | INDIVIDUAL SYSTEM; 고정 titleKey, args[0]=job.additionalCan | null / empty / 없음 | [외부 소유: v2]; 수정 불필요 | +| 20 | `v2/can/charge/event/ChargeEventJobService.kt:200` publishPaidNotification 기타 | INDIVIDUAL SYSTEM; title=job.methodSnapshot, args[0]=job.additionalCan | null / empty / 없음 | [외부 소유: v2]; 수정 불필요 | +| 21 | `admin/audition/AdminAuditionService.kt:94` updateAudition | IN_PROGRESS_AUDITION; 고정 titleKey, args[0]=audition.title(오디션 제목) | null / empty / 없음 | [외부 소유: parent/admin]; 수정 불필요 | +| 22 | `v2/gift/application/GiftPushService.kt:115` publishGiftPush | INDIVIDUAL GIFT; 11개 공개 알림 메서드가 고정 title/message로 위임. memberId는 수신자, applicationNo는 deepLinkId | null / empty / 없음 | [외부 소유: v2]; 회원 표시 이름이 없으므로 수정 불필요 | + +기프트의 11개 입구는 sendTrackingDeadlineReminder, sendRecipientAddressDeadlineReminder, sendAutoCanceledToSender, sendTrackingRegisteredToRecipient, sendArrivedAtMailboxToSender, sendArrivedAtMailboxToRecipient, sendInspectionCompleted, sendUndeliverableToSender, sendUndeliverableToRecipient, sendDeliveredToSender, sendDeliveredToRecipient다. 모두 공통 22번 발행으로 수렴하며 수신자 ID를 발신자로 복사하면 안 된다. + +### FCM 저장·외부 capacity 분류 + +| 생산/읽기 파일 | 유형 | 결정/증거 | 테스트/남은 검증 | +|---|---|---|---| +| `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt` sendPush → `fcm/notification/PushNotificationService.kt` saveNotification | 신규 recipient-language payload 및 언어별 immutable snapshot 생산 | token language를 정규화한 Lang을 명시 전달. render 후 저장해야 함 | T-FCM | +| `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationList.kt` | 기존 역사 snapshot | senderNicknameSnapshot은 `@Column(nullable=false)` 기본 길이 255, message는 TEXT, languageCode 길이 8. sender ID 필드 없음. 기존 entity/DDL 확장 금지 | T-FCM 255자 신규 저장/과거 행 보존; 실제 MySQL charset/collation/컬럼 사양은 미확인 | +| `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationListRepository.kt` getNotificationList → `PushNotificationService.getNotificationList` | 역사 snapshot 읽기 | senderNicknameSnapshot을 직접 projection, locale 필터는 LangContext, 회원 join 없음. 현재 alias로 재조회하지 않음 | 저장 행은 T-FCM historical 대조군. 목록 query는 read-only 근거와 기존 `src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt`; H2에 JSON_CONTAINS 지원 fixture가 없어 새 실제 목록 SQL 테스트는 미포함 | +| `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` send | 외부 provider 전달 | title/body를 Firebase Notification에 그대로 넣으며 truncate 없음. 이번 test는 mock 경계까지이고 provider 전송하지 않음 | [blocked] provider의 byte/payload 제한과 255 Unicode 별칭 호환성, 실제 모바일 수신 증거는 메시징 소유자 필요 | + +## WS/HTTP chat 생산·읽기 지도 + +공통 prefix는 `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/`다. 모든 신규 payload의 sender ID는 message.participant.member.id이며 roomId/opponentId로 이름 조회하지 않는다. + +| 파일/메서드 | 유형·생산 → 읽기 | GREEN 경계 | 검증 | +|---|---|---|---| +| `websocket/UserCreatorChatWebSocketAuthInterceptor.kt` beforeHandshake | 실제 JWT MemberAdapter → session.attributes | `Accept-Language`를 `Lang.fromAcceptLanguage`로 읽어 `lang: Lang` 저장. 제안 상수 `LANG_ATTRIBUTE="lang"`. 누락/미지원은 KO. authentication/memberId는 그대로 | T-WS handshake | +| `websocket/UserCreatorChatWebSocketHandler.kt` handleSendText | request text → service 저장 → SEND_ACK 직렬화 | 세션 Lang을 service에 명시 전달하거나 ACK 경계에서 batch 렌더링. request scope를 WS callback에서 조회하지 않음. ACK `mine=true`, requestId 보존 | T-WS sender ACK | +| `service/UserCreatorChatService.kt` sendTextMessageByWebSocket/deliverRealtime/websocketMessagePayload | 현재 DTO를 먼저 JSON 문자열로 만들어 broker.publish(roomId, opponentId, payload)에 전달 | 중립 senderId와 원본 DTO/본문을 보존해 publish. recipient 세션 언어를 이 단계에서 결정하지 않음. 현재 broker의 기존 4개 생성자 의존성으로 테스트 가능하며 미구현 생성자를 테스트에서 호출하지 않음 | T-WS 실제 송수신 | +| `websocket/UserCreatorChatRoomMessageBroker.kt` publish/onMessage | Redis channel `v2:user-creator-chat:ws:room` → sessionRegistry.findSessions(roomId, memberId) → 같은 pre-serialized 문자열을 모두 전송 | 최소 변경은 기존 envelope의 payload가 가진 senderId/원본 값을 neutral source로 유지하고 onMessage에서 해석한 뒤 세션별 새 DTO/envelope 직렬화. KO/JA Lang별 batch map 조회. 공유 객체 변이/본문 치환 금지. 중립 메시지에 요청 언어 이름을 미리 굳히지 않음 | T-WS same-member 2-session. 기존 `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt`, `UserCreatorChatRedisIntegrationTest.kt` 회귀도 부모에서 실행 | +| `service/UserCreatorChatService.kt` openRoom/getMessages/toMessageItemDto | DB 현재 opponent.nickname/sender.nickname → HTTP 응답 | HTTP 요청 경계 Lang으로 opponentId/조회 page senderIds batch. sendVoiceMessage/deliverRestMessage의 HTTP message도 별도 request Lang, broker neutral DTO와 독립 | T-WS HTTP open/messages; voice HTTP/S3 경계는 후속 GREEN 회귀 필요 | +| `websocket/UserCreatorChatWebSocketSessionRegistry.kt` | roomId+memberId → 복수 세션 | 이미 복수 세션 지원. 회원 하나의 locale을 전역 저장하지 않음. registry 자체 변경 불필요 | 기존 `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt` | +| `repository/UserCreatorChatRoomRepository.kt` findDmChatListRooms | opponent.member.nickname → 외부 소유 `v2/chat/dto/ChatRoomListQueryDto`의 targetName | 내부 projection에 실제 opponent memberId가 없음. roomId를 alias key로 사용 금지. 아래 cross-owner 조정 필요 | 기존 `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt`, HomeFollowing 관련 회귀 | + +WS 외부 client locale 변경은 reconnect로 새 handshake를 하도록 계약해야 한다. 기존 연결의 locale 자동 변경/메시지 타입 추가는 이번 내부 테스트만으로 승인하거나 구현하지 않는다. + +## live 생산·읽기와 캐시 분류 + +공통 prefix는 `src/main/kotlin/kr/co/vividnext/sodalive/`다. 이 표의 member ID는 표시 이름의 실제 소유자이며 resource ID가 아니다. + +| 생산 파일/메서드 → 소비 파일/메서드 | 유형·ID/Lang 출처 | 최소 GREEN 적용 또는 보존 결정 | 검증/첫 단계 미포함 | +|---|---|---|---| +| `live/room/LiveRoomService.kt` enterLive/setSpeaker/setListener/setManager → `live/room/info/LiveRoomInfo.kt` → `LiveRoomMember.kt` | Redis 언어 중립 membership. 인증 Member 또는 request.memberId로 재조회한 Member의 원본 nickname/id | writer 원본 유지. `LiveRoomInfo`/`LiveRoomMember` 저장 객체를 request locale로 덮어쓰지 않음 | T-LIVE actual Redis | +| 위 membership → `live/room/LiveRoomService.kt` getRoomInfo | current live render; host Member.id + 각 member.id, HTTP LangContext 경계 | speaker/listener/manager를 **새 복사본**으로 렌더링. `LiveRoomMember.copy`는 생성자 밖 var role을 자동 복사하지 않으므로 role을 보존해야 함. 저장 권한/equality 흐름에 localized nickname을 집어넣지 않음 | T-LIVE info, role/Redis 원본 | +| 위 membership 또는 reservation Member 관계 → `live/room/LiveRoomService.kt` getRoomDetail/getUserProfile | current-name DTO. detail manager=room.member.id, users=각 id, profile=user.id | 한 요청의 IDs로 batch 조회 후 DTO names만 대체. USER/비활성 fallback. 내부 membership 권한 검사는 원본 객체로 유지 | T-LIVE detail/profile | +| `live/room/LiveRoomService.kt` donation/donationV2, `live/roulette/v2/RouletteService.kt` spinRoulette → `LiveRoomInfo.addDonationMessage/addRouletteMessage` → `getDonationMessageList` | Redis 언어 중립 live feed. memberId/nickname을 원본으로 저장, 본문/UUID/isSecret 별도 | current live read의 복사본 nickname만 batch override, secret 필터 유지. writer의 nickname/본문/canMessage/UUID를 재작성하지 않음. 이미 client에 전달한 payload도 불변 | T-LIVE donation+roulette; 실제 결제/roulette spin 자체는 미실행 | +| `live/room/LiveRoomRepository.kt` getLiveRoomListNow/ReservationWithDate/WithoutDate → `LiveRoomService.getRoomList` | 현재 DB Member 관계; creatorId 이미 있음. HTTP LangContext | 목록 전체 creatorIds batch 후 creatorNickname만 대체 | 후속 focused `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt`; 소유 범위 밖 api/home/HomeService, api/live/LiveApiService가 호출하므로 공유 서비스에서 해결 | +| `live/room/LiveRoomRepository.kt` getDonationList → `LiveRoomService.getDonationStatus` | 금액 집계 materialization 아닌 요청 시 현재 member.nickname/ID projection | donationList memberId로 batch, 금액/secret 집계/정렬 불변. 역사 닉네임 스냅샷 아님 | T-LIVE 후속 DB 금액 fixture 필요 | +| `live/room/LiveRoomRepository.kt` getHeartList → `LiveRoomService.getHeartList` | 요청 시 현재 member.nickname, 반환 projection에 memberId 없음 | 내부 donor memberId 추가 필요. 공개 DTO field 추가 없이 내부 projection/경계 연결 | T-LIVE 후속 DB 금액 fixture 필요 | +| `live/reservation/LiveReservationService.kt` makeReservation/getReservationList/getReservation | 예약은 member 관계만 저장. nickname/masterNickname는 매번 room.member.nickname | 현재 이름 조회로 분류. room.member.id와 HTTP LangContext batch. 예약 시각/가격/취소 규칙 불변 | 기존 `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt` 후속 이름 회귀 | +| `live/room/visit/LiveRoomVisitService.kt` roomVisit → getRecentVisitRoomUsers | 방문 기록은 member 관계/시각, 이름은 현재 Member→GetRoomDetailUser | 현재 이름 조회. 반환 members IDs batch + 명시 요청 Lang. 사용자/역할 필터 불변 | 새 테스트 경로 필요 시 GREEN 전 부모 계획에 추가; 첫 단계 테스트 없음 | +| `live/recommend/LiveRecommendController.kt` → `LiveRecommendService.getRecommendLive` → `LiveRecommendCacheService.getRecommendLive` → `LiveRecommendRepository.getRecommendLive` | banner read-through cache, key=`getRecommendLive:{memberId or guest}:{isAdult}:{lang.name()}`, cache_ttl_3_hours. `configs/RedisConfig.kt` TTL 3시간 | **실제 payload는 GetRecommendLiveResponse(imageUrl, creatorId)뿐이며 nickname 없음.** 이름 캐시가 아니므로 alias 적용 불필요. 이미지 안 텍스트는 서버 alias 치환 대상 아님 | `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt`, `LiveRecommendServiceTest.kt` 기존 선례. 분류는 read-only 증거, 새 RED 없음 | +| `live/recommend/LiveRecommendRepository.kt` getOnAirRecommendChannelList/getRecommendChannelList/getOnAirFollowingChannelList/getFollowingChannelList/getCreatorFollowingAllList → `LiveRecommendService` 같은 public read 메서드들 | 캐시 밖 현재 nickname+creatorId 목록. HTTP 경계 언어를 아직 안 받는 메서드 있음 | controller에서 LangContext 선택 후 service에 명시 Lang 전달하고 반환 IDs batch. count/필터/합치기/정렬은 변경하지 않음 | 기존 위 두 recommend 테스트에 후속 이름 회귀 필요 | +| `live/room/LiveRoomRepository.kt` getLatestFinishedLive → `LiveRoomService.getLatestFinishedLive` → LiveRoomController 및 외부 소유 `api/live/LiveApiService.kt` | 현재 Member.nickname을 포함한 read-through cache, key=`getLatestFinishedLive:{memberId or guest}`, cache_ttl_10_minutes/TTL 10분. **Lang 없음** | 종료된 라이브여도 nickname 역사 snapshot이 아님. 중립 캐시 밖 request-time batch 렌더링 또는 Lang별 key 분리가 필요. 단순히 cache 내부 이름만 현지화하면 다른 요청 언어로 샘 | T-LIVE KO→JA 최근 종료 라이브. 테스트는 현행 cache key를 정리함; GREEN에서 key를 바꾸면 정리 key도 맞춰야 함 | +| `live/room/LiveRoomService.kt` enterLive의 blocked-by-host 오류 / `message/MessageService.kt` blocked-by-recipient 오류 | 현재 회원 이름이 삽입되는 사용자 메시지 | ID 기반 현재 이름을 해당 HTTP 요청 언어로 렌더링. 원본 인증 Member는 수정하지 않음 | 후속 예외 회귀 필요 | + +`GetRecentRoomInfoResponse`는 title/notice/cover/인원/genderRestriction뿐이라 회원 이름 적용 대상이 아니다. live signature/menu/roulette의 상품명·메뉴·결과 문구를 회원 별칭으로 치환하지 않는다. v2 roulette은 shared feed의 이름 writer인 것만 확인했고 원본 writer 변경을 요구하지 않는다. + +## legacy message 분류 + +| 생산 → 읽기 | 유형/필요 ID | GREEN | 검증 | +|---|---|---|---| +| `src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt` sendTextMessage/sendVoiceMessage → `message/Message.kt` | DB는 sender/recipient Member 관계와 본문/voice path만 저장, nickname snapshot 없음 | 저장/원본 Member 보존. FCM name metadata는 위 #3/#4 | T-MESSAGE 실제 관계 저장 | +| `message/MessageRepository.kt` sent/received/keep text/voice → `MessageService.getTextMessageResponse/getVoiceMessageResponse` | 현재 이름 조회. 각 sender.id와 recipient.id 모두 필요 | 한 페이지 양쪽 memberIds 합집합 batch, request Lang으로 DTO만 대체. 본문/voice URL/시간/keep/delete/count 불변 | T-MESSAGE 6개 public read 메서드 | + +## 부모와 다른 소유자에게 필요한 정확한 수정 목록 + +- **P3 후속 구현의 직접 파일:** `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`(metadata + recipient group render), `fcm/notification/PushNotificationService.kt`(이미 렌더링한 새 sender snapshot 저장), `v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt`(세션 Lang), `UserCreatorChatWebSocketHandler.kt`(ACK), `UserCreatorChatRoomMessageBroker.kt`(session별 render), `v2/usercreatorchat/service/UserCreatorChatService.kt`(중립 publish/HTTP names/FCM args metadata), `message/MessageService.kt`(현재 이름·FCM metadata), `live/room/LiveRoomService.kt`(live current reads/copy/FCM title metadata/종료 라이브 캐시 경계), `live/recommend/LiveRecommendController.kt` 및 `LiveRecommendService.kt`(명시 Lang/current names), `live/reservation/LiveReservationService.kt`, `live/room/visit/LiveRoomVisitService.kt`. full prefix는 모두 `src/main/kotlin/kr/co/vividnext/sodalive/`다. +- **필요한 내부 projection만 추가:** `live/room/LiveRoomRepository.kt` getHeartList의 실제 donor memberId. `live/room/like/GetLiveRoomHeartListResponse.kt` 공개 스키마를 늘리지 말고 내부 projection 경계를 마련한다. `GetRoomInfoResponse`, `LiveRoomDonationMessage`는 이미 ID가 있어 공개 필드 추가 불필요. `LiveRoomMember`/`LiveRoomInfo`/roulette writer 저장 locale 변경 불필요. +- **legacy worker에 넘길 FCM 변경:** `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt` saveNotice(#9), `explorer/profile/creatorCommunity/CreatorCommunityService.kt` createCommunityPost/createCommunityPostComment(#10/#11), `content/AudioContentService.kt` uploadComplete/releaseContent(#14/#15), `content/comment/AudioContentCommentService.kt` registerComment(#12)에 위 titleMemberId만 명시. 기존 args는 그대로 보존하고, 이름 인자가 아니므로 새 nameArgumentMemberIds만 empty로 둔다. 특히 #12의 senderMemberId를 parent ID로 바꾸면 sender snapshot의 행위자 의미가 깨진다. +- **부모/admin 소유자에게 넘길 FCM 변경:** `src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt` cancelNotStartedLive4hoursPassed(#16)에 room.member.id의 title metadata. admin/audition은 이름 슬롯이 없어 수정하지 않는다. +- **v2 worker와 동시에 합의해야 할 공유 projection:** `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt` findDmChatListRooms가 쓰는 외부 소유 `v2/chat/dto/ChatRoomListQueryDto`에 실제 opponentMemberId를 내부 필드로 추가해야 한다. 소비자 `v2/chat/service/ChatRoomListService.kt` getRooms/toResponse 및 `v2/api/home/following/application/HomeFollowingFacade.kt` 경유 이름을 request Lang으로 렌더링하도록 v2 worker가 조정한다. AI row producer `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt` findAiChatListRooms도 같은 생성자를 쓰므로 대응 필요. public targetName/roomId 타입은 그대로다. 이 첫 단계에서 해당 파일은 수정하지 않았다. +- **기반 담당자:** creatorname 서비스/테이블 매핑은 기반 작업자가 제공한다. 이 작업은 core Member/Lang/serializer/config/DDL을 변경하지 않는다. 기반 없이 테이블 부재로 실패한 실행은 의미 있는 RED 증거가 아니다. + +## 외부 계약 차단 + +| 의존 | 소유자/경로 | 내부 확인과 차단 해제 증거 | +|---|---|---| +| EXT001 앱 전체 표시·원본 편집·client cache | [blocked: 모바일 저장소/파일/담당자 미확인] | 서버 DTO 스키마를 유지하더라도 원본 편집기에 localized 이름이 저장되지 않는다는 앱 증거 필요. 이름 cache invalidation/수명/표면 목록 확인 필요 | +| EXT002 운영 UI | [blocked: 운영 UI 저장소/경로/인증 주체 미확인] | 이번 전달 계층 테스트로 관리자 UI 저장 흐름을 완료 처리하지 않음 | +| EXT003 WS session locale | [blocked: client/messaging 저장소·담당자 미확인] | handshake Accept-Language 지원, 언어 변경 시 reconnect, 같은 회원 KO/JA 다중 세션의 실제 앱 검증. 서버 localhost 테스트는 모바일 계약 확인을 대신하지 않음 | +| EXT003 RTC/live 표시 소비 | [blocked: RTC/client 표시 경로 미확인] | 서버 RTC UID/RTM account는 member.id 문자열 및 worker UID이고 이름을 RTC에 등록하는 코드 없음. live HTTP names와 클라이언트/RTM display cache의 매핑/갱신 시점 확인 필요 | +| push provider / 운영 DB capacity | [blocked: 메시징/DB 담당자의 실제 설정 및 증거 필요] | 기존 push snapshot mapping 255와 test H2 저장만 확인 가능. 운영 MySQL charset/column, FCM payload byte 제한, 255 code point/다중 code unit 별칭의 실제 모바일 전송 호환성은 별도 확인. truncate/과거 행 rewrite/column 확장으로 우회하지 않음 | + +## 이번 정적 점검과 인계 한계 + +- `rg -n 'FcmEvent\(' src/main/kotlin`로 선언 1개와 실제 생산 22곳을 대조하고 각 호출부의 title/args/sender ID를 열어 확인했다. 실제 FcmService send는 listener 단일 진입이며 기프트도 위 단일 이벤트 발행이다. +- 기존 생성자/DTO/서비스 메서드, JPA/Redis 저장과 조회, HTTP endpoint, JWT 생성/검증, request-scoped LangContext, 테스트 초기화 선례를 읽어 테스트를 작성했다. 테스트 안에 미구현 service constructor 호출/placeholder/Disabled/실제 Firebase·S3 전송은 없다. +- `rg`로 신규 테스트의 130자 초과 줄/후행 공백/TODO/FIXME/Disabled/mock 직접 생성 패턴이 없음을 확인했다. mock bean은 FcmService/S3Uploader뿐이다. 이 작업에 `lsp_diagnostics` 도구는 제공되지 않았다. Gradle/test/build/lint/실제 localhost QA는 사용자 지시에 따라 **미실행**이며 부모가 직렬 실행한다. +- 외부 앱/RTC 계약과 실제 MySQL/FCM capacity는 내부 구현으로 해제되지 않는다. 아래 추가 통합 사례도 부모의 직렬 GREEN 실행 전까지 통과를 주장하지 않는다. + +## GREEN 구현 상태 + +이 절은 후속 GREEN 위임의 현재 상태다. 모든 `구현/대기`는 코드 연결과 정적 대조 완료, **부모 runtime 검증 대기**를 의미한다. 원 PRD/plan이나 제품 Gate를 완료 처리하지 않았다. + +| inventory 행/표면 | 처리 상태 | 구현 및 불변 조건 | 연결 테스트 | +|---|---|---|---| +| FCM #3/#4/#5 | 구현/대기 | legacy text/voice 및 v2 DM/voice가 `nameArgumentMemberIds=mapOf(0 to sender.id)` 명시. event의 원본 args는 유지 | T-FCM, T-MESSAGE, UserCreatorChatServiceTest | +| FCM #6/#7/#8/#16 | 구현/대기 | live 생성/시작/취소 및 admin 자동 취소에 `titleMemberId` 명시. 방 제목 args는 이름 metadata 없음. admin 파일은 허용된 한 줄만 수정 | T-FCM 실제 live 취소, 기존 live 회귀 | +| FCM #9/#10/#11/#12/#14/#15 | 타 소유자 연결 정적 확인/대기 | legacy worker가 실제 파일에 `titleMemberId` 추가한 것을 확인. #12는 `parent?.member?.id ?: member.id`, senderMemberId는 행위자 그대로. 이 작업은 해당 생산 파일을 수정하지 않음 | T-FCM 실제 registerComment 생산자, CREATOR/USER actor 각 1개 | +| FCM #1/#2/#17/#18/#19/#20/#21/#22 | 의도적 보존/대기 | 자유 제목/본문, 이벤트·오디션·콘텐츠 제목, 숫자 args를 이름으로 추측하지 않음. sender ID 없는 기존 fallback 의미 유지 | T-FCM 실제 GiftPushService 생산자; 기존 FcmService/PushNotificationService 회귀 | +| FCM #13 및 새 sender snapshot | 구현/대기 | sender/title/name-args ID 합집합을 정규화한 token Lang 그룹마다 batch 조회. title/body/args는 그룹별 새 값, 새 notification nickname만 sparse alias로 override. 기존 profile/명시 snapshot/default fallback은 기존 resolver 그대로 | T-FCM 255자, 역사 행 불변, 기존 PushNotificationServiceTest | +| 역사 notification 읽기/용량 | 의도적 보존/외부 blocked | sender ID 없는 기존 rows와 목록 projection, JPA/DDL 길이를 변경하지 않음. provider/운영 DB capacity는 계속 blocked | T-FCM historical 대조군; 실제 목록 JSON_CONTAINS SQL 미검증 | +| WS handshake / ACK | 구현/대기 | `LANG_ATTRIBUTE="lang"`에 Lang 저장, 누락/미지원 KO. handler가 `sendTextMessageByWebSocket(..., lang: Lang)`에 필수 명시. WS callback에는 LangContext 접근 없음 | T-WS 실제 JWT/localhost, 기존 handler/auth tests | +| WS pubsub / 복수 세션 | 구현/대기 | 기존 `UserCreatorChatRoomPublishedMessage(roomId, memberId, payload: String)` wire 유지. 원본 senderId/name/body가 포함된 중립 payload를 publish. broker가 세션 Lang별 batch 조회 후 각 세션에 새 ObjectNode copy. MESSAGE 내 senderNickname 필드만 변경 | T-WS 같은 회원 KO/JA 동시 세션, 기존 broker/Redis integration | +| chat HTTP open/messages/voice | 구현/대기 | HTTP 요청 Lang으로 현재 opponent/page sender 이름을 batch 렌더링. voice HTTP DTO와 WS neutral DTO는 독립. body/mine/requestId/ID/voice URL 유지 | T-WS HTTP 및 새 voice REST/WS 사례 | +| chat 목록 공유 projection | 계약 연결 정적 확인/대기 | `UserCreatorChatRoomRepository.findDmChatListRooms` constructor 끝에 `opponent.member.id` 추가. v2 worker의 `ChatRoomListQueryDto` 마지막 `opponentMemberId: Long?`와 소비 batch lookup을 실제 파일에서 확인. 이 작업은 v2/chat/AI repository를 수정하지 않음 | 기존 v2 ChatRoomListService/Controller 및 HomeFollowing tests를 부모가 실행 | +| live Redis membership / donation / roulette writer | 의도적 원본 보존/대기 | `LiveRoomInfo`, `LiveRoomMember`, donation/roulette writer 저장 구조와 이름은 바꾸지 않음. 응답에서만 copy. role은 copy 후 명시 복원, secret 필터와 UUID/본문 유지 | T-LIVE role/secret/Redis 원본 | +| live info/detail/profile/list | 구현/대기 | 실제 memberIds로 current Member batch와 활성 JA alias를 결합. 목록과 shared Redis base 이름도 현재 원본 fallback. 권한 검사에 localized 객체를 넣지 않음 | T-LIVE info/detail/profile/fallback, 기존 AdultVisibilityPolicyTest | +| live donation/heart 집계 | 구현/대기 | donation `userId` 활용. heart는 내부 `LiveRoomHeartListRow(memberId, ...)` projection 추가 후 기존 공개 3-field DTO로 변환. 금액/순서/secret 합계 불변 | T-LIVE 실제 UseCan fixture | +| live 예약 / 최근 방문 | 구현/대기 | reservation owner IDs 및 visitor IDs batch. 예약 read transaction과 room/owner fetch join으로 OSIV off 및 owner N+1 방지. 일반 사용자/역할/예약 정책 필터 유지 | T-LIVE 예약 생성·목록·상세 및 방문자, 기존 LiveReservationServiceIntegrationTest | +| live 추천 / 팔로잉 | 구현/대기 | `LiveRecommendService`의 HTTP 호출 경계 LangContext를 사용해 기존 api/live caller signature를 유지. on-air early return과 off-air 결합 모두 한 batch 렌더링. 이름 없는 3시간 banner cache는 수정하지 않음 | T-LIVE 실제 추천/팔로잉, 기존 LiveRecommendService/Repository tests | +| 최근 종료 live 10분 cache | 구현/대기 | 같은 기존 key/TTL의 `@Cacheable`을 repository query 경계로 이동해 **중립 DTO만 캐시**. 서비스는 캐시 밖에서 현재 Member 이름+alias batch로 새 응답. 원본 변경/별칭 해제도 cached nickname로 fallback하지 않음 | T-LIVE KO→JA→별칭 해제/현재 원본 변경, 기존 key 정리 유지 | +| legacy message 현재 이름 / 오류 문구 | 구현/대기 | text/voice sent/received/keep의 sender+recipient 합집합 Member 및 alias batch. 기존 Message/Member 저장 불변. blocked recipient/host 오류의 표시 이름도 현재 요청 언어로 렌더링 | T-MESSAGE 및 기존 live 정책 tests | + +### fixture 및 기존 테스트 정합성 + +- 부모 RED XML의 live 상세 예외는 `MemberContentPreferenceService.initializeDefaultPreference:61` → `getStoredPreference(REQUIRES_NEW)` → `LiveRoomService.getRoomDetail:538`이다. 미커밋 test Member를 새 트랜잭션이 못 보는 fixture 문제라 해당 상세 테스트만 `Propagation.NOT_SUPPORTED`로 저장을 commit하고 method `DirtiesContext`로 DB fixture를 격리했다. 원래 policy assertion은 삭제/완화하지 않았다. 같은 실제 preference 경계를 타는 새 추천 테스트에도 동일 격리를 사용한다. +- 기존 UserCreatorChatService/LiveRoomService/LiveRecommendService Mockito constructor는 새 필수 resolver와 HTTP LangContext를 명시한다. Redis-only broker integration은 필수 resolver를 `@MockBean`으로 공급한다. 실제 새 이름 통합 테스트는 계속 실제 JPA 이름 resolver를 사용한다. +- voice 추가 사례는 실제 JPA 전송 서비스를 HTTP request context에서 직접 호출하고 실제 localhost WS 수신과 비교한다. S3만 mock한다. multipart HTTP POST 자체의 네트워크 E2E를 실행한 증거는 아니며, open/messages GET은 기존 테스트가 실제 HTTP로 호출한다. +- WebSocket send의 필수 Lang 인자를 모든 기존 test call/stub/verify에 추가했다. PushNotificationService의 필수 `localizedSenderNickname` 인자를 기존 fallback tests에는 null로 명시해 원래 동작 검증을 유지한다. nullable/optional resolver constructor로 우회하지 않았다. +- 제공된 `creatorname-foundation-final-lint.log`의 11개 진단은 content/explorer/member/search 테스트에만 있으며 이 작업 소유 파일은 없었다. 그 파일은 병렬 legacy 소유자에게 남기고, 이 작업 변경 파일의 import/인자 줄바꿈/130자/후행 공백은 정적으로 점검했다. ktlint를 실행했다고 주장하지 않는다. + +### 이 GREEN 위임에서 변경한 정확한 파일 + +공통 main prefix: `src/main/kotlin/kr/co/vividnext/sodalive/`. + +- `fcm/FcmEvent.kt` +- `fcm/notification/PushNotificationService.kt` +- `v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt` +- `v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt` +- `v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt` +- `v2/usercreatorchat/service/UserCreatorChatService.kt` +- `v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt` +- `message/MessageService.kt` +- `live/room/LiveRoomService.kt` +- `live/room/LiveRoomRepository.kt` +- `live/room/like/GetLiveRoomHeartListResponse.kt` +- `live/recommend/LiveRecommendService.kt` +- `live/reservation/LiveReservationService.kt` +- `live/reservation/LiveReservationRepository.kt` +- `live/room/visit/LiveRoomVisitService.kt` +- `admin/live/AdminLiveService.kt` (titleMemberId metadata 한 줄만) + +공통 test prefix: `src/test/kotlin/kr/co/vividnext/sodalive/`. + +- `fcm/FcmSendListenerLocalizedNameTest.kt` +- `fcm/notification/PushNotificationServiceTest.kt` +- `v2/usercreatorchat/UserCreatorChatServiceTest.kt` +- `v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt` +- `v2/usercreatorchat/websocket/UserCreatorChatLocalizedNameIntegrationTest.kt` +- `v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt` +- `v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt` +- `v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt` +- `live/room/LiveRoomLocalizedNameIntegrationTest.kt` +- `live/room/LiveRoomServiceTest.kt` +- `live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt` +- `live/recommend/LiveRecommendServiceTest.kt` + +문서는 이 `delivery-name-inventory.md`만 갱신했다. T-MESSAGE 신규 통합 테스트는 1차 작성본을 그대로 사용한다. main PRD/plan-task, foundation/Member/Lang, legacy worker 소유 생산 파일, v2/chat 및 AI repository는 이 작업에서 수정하지 않았다. + +### 부모의 직렬 GREEN 검증 명령 + +아래는 **미실행** 명령이다. 병렬 쓰기가 끝난 뒤 부모가 실행하고, assertion/compile/lint 결과를 별도 기록한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.fcm.FcmSendListenerLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatLocalizedNameIntegrationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.live.room.LiveRoomLocalizedNameIntegrationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.message.MessageServiceLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest' --tests 'kr.co.vividnext.sodalive.fcm.FcmServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest' --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.*' +./gradlew test --tests 'kr.co.vividnext.sodalive.live.room.LiveRoomServiceTest' --tests 'kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest' --tests 'kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest' --tests 'kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest' +./gradlew ktlintCheck +./gradlew assemble +``` + +동일 localhost/Redis surface 테스트가 부모의 GREEN 및 수동 사용 근거를 제공할 때까지 서버 runtime 완료를 주장하지 않는다. 외부 앱·RTC reconnect·provider/운영 DB capacity blocked 항목은 그대로 유지한다. 새 SDK 기능, DB 적용, historical row rewrite, truncate, 커밋/푸시는 하지 않았다. + +## COR013 Delivery JOIN 전환 인계, 2026-10-15 + +이 절은 앞의 batch 기반 구현 이력보다 우선하는 현재 전환 manifest다. PRD 10절과 계획의 JOIN-T1~T3를 읽고 delivery 소유 코드만 수정했다. main PRD/plan-task, core creatorname/Member/Lang, 다른 legacy/v2 도메인, 검색 JA 및 관리자 /ja 쓰기 정책은 수정하지 않았다. 부모가 제공한 core 43개 통과와 이전 전체 2249개 통과는 **이번 변경 후 실행 결과가 아니다**. + +부모 RED 로그 `creatorname-join-consumer-red.log`에서 `LiveRoomLocalizedNameIntegrationTest`의 추천/팔로잉 테스트가 이름 출력이 아닌 별칭 후속 SELECT 단언으로 실패한 것을 확인했다. 이번 작업은 `read`/`rg`/`apply_patch`만 사용했다. Gradle, 테스트, 빌드, lint, HTTP/WS 실행, 에이전트, git/커밋/푸시, 운영 DB 접근은 하지 않았다. 아래 상태는 **부모 직렬 GREEN 실행 준비**, PASS 판정이 아니다. + +### 전환 Manifest + +아래 main 경로의 공통 prefix는 `src/main/kotlin/kr/co/vividnext/sodalive/`다. 모든 직접 DB 표시 query는 요청 `Lang`의 명시 LEFT JOIN과 `COALESCE`를 사용한다. member ID·Lang·active·CREATOR는 ON 조건이며 기존 업무 WHERE/count/페이지/금액 조건과 공개 JSON 필드는 유지한다. + +| 정확한 파일 | 전환 내용 | +|---|---| +| `live/recommend/LiveRecommendRepository.kt`; `live/recommend/LiveRecommendService.kt` | on-air/off-air 추천·팔로잉 및 전체 팔로잉 5개 projection에서 `CreatorNameJoin` 사용. 서비스 이름 map/copy와 resolver 주입 제거. 20개 early return도 이미 조인된 결과를 반환. count에는 이름 조인을 추가하지 않음 | +| `live/room/LiveRoomRepository.kt`; `live/room/LiveRoomListRow.kt`; `live/room/LiveRoomService.kt` | NOW/날짜별 예약/예정 목록은 원본 room과 조인된 이름을 함께 반환. 태그·예약·권한에 필요한 원본 room/member를 변이하지 않으며 이름 후속 조회는 제거. 상세 host도 별도 read query에서 이름을 함께 선택. 금융/쓰기에서 사용하는 기존 `getLiveRoom`/`getLiveRoomAndAccountId`는 원본 entity 그대로 | +| `live/room/GetLiveRoomUserProfileResponse.kt`; `live/room/detail/GetRoomDetailResponse.kt` | 프로필은 원본 Member와 조인된 이름을 반환하여 Redis membership/권한 비교는 원본으로 하고 공개 nickname만 resolved 값 사용. 상세 manager 생성자도 query의 resolved 이름을 받음. 공개 응답 field/type은 불변 | +| `live/room/like/GetLiveRoomHeartListResponse.kt` | 내부 donor-ID batch 전용 `LiveRoomHeartListRow` 제거. 하트 query가 기존 공개 3-field item을 직접 반환. 후원/하트 합계·secret 노출·정렬은 원래 수식 유지 | +| `live/reservation/LiveReservationRepository.kt`; `live/reservation/LiveReservationListRow.kt`; `live/reservation/LiveReservationService.kt` | 예약 목록/단건을 scalar projection으로 전환. 예약 전 live 상세의 booker 목록도 이름 조인된 `GetRoomDetailUser` 반환. 생성·취소·가격·환불 원본 객체와 시간/cover fallback은 유지. 생성 응답만 기존 쓰기 경계 sparse batch 유지 | +| `live/room/visit/LiveRoomVisitRepository.kt`; `live/room/visit/LiveRoomVisitService.kt` | 최근 방문자 query가 resolved 이름과 profile URL을 직접 projection. 기존 사용자/역할 필터 보존, 이름 map/copy와 서비스 resolver/host 주입 제거 | +| `message/MessageRepository.kt`; `message/MessageListRow.kt`; `message/MessageService.kt` | text/voice sent/received/keep 6개 목록의 sender/recipient를 서로 다른 QMember/이름 alias로 조인한 scalar row로 반환. `findMessageNames` 및 원본+별칭 2-query 결합 제거. write/keep/delete/FCM 원본 metadata와 기존 text `isKept=false` 동작 보존 | +| `v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt`; `v2/usercreatorchat/dto/UserCreatorChatMessageRow.kt`; `v2/usercreatorchat/service/UserCreatorChatService.kt` | HTTP history는 sender 이름을 조인한 scalar row 반환. cursor/active/id-desc query와 createdAt 응답 정렬·mine·본문·URL·시간 의미 보존. 신규 전송의 neutral publisher/ACK/voice 응답 경계는 별도로 유지 | +| `v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt` | HTTP open 상대 프로필만 `findActiveOpponentProfile(roomId, memberId, lang)` JOIN projection 사용. 송신 검증에서 필요한 원본 `findActiveOpponent`는 유지 | +| `v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt`; `v2/chat/service/ChatRoomListService.kt` | DM opponent 목록이 요청 Lang 이름을 직접 조인. 통합 목록 서비스의 이름-only batch/copy와 resolver 주입 제거. 활성 참여자·최신 메시지·팔로우·cursor·AI/DM 병합 순서 유지 | +| `v2/chat/dto/ChatRoomListDtos.kt`; `chat/room/repository/ChatRoomRepository.kt` | batch 전용 내부 `opponentMemberId` 제거, 공유 query DTO는 7필드. AI select-new도 동일 생성자로 맞추고 `pc.character.name` 그대로 선택. AI creator alias는 캐릭터 이름으로 사용하지 않음 | + +### Cross-Owner 및 내부 계약 + +- 기반은 제공된 `CreatorNameJoin(owner: QMember, lang: Lang, alias: String)`의 `name/condition/displayName`과 `CreatorLocalizedNameService.findCurrentNames(ids, lang)`를 사용한다. 기반 파일은 수정하지 않았다. `LiveRoomService.findCurrentNames`는 이제 core의 단일 JOIN SELECT에 위임한다. +- Live recommendation/list/donation/heart/reservation/visitor repository의 표시 query에는 필수 `Lang`을 전달한다. 기존 HTTP 서비스 경계의 `LangContext`가 선택하며 외부 `api/live`, `api/home` 호출 signature는 유지한다. +- `ChatRoomListService.getRooms(member, lang, filter, cursor, limit, followedCreatorsOnly)`는 그대로다. v2 writer 소유 `HomeFollowingFacade`의 기존 명시 Lang 호출은 수정할 필요가 없다. 공유 AI repository에서는 위 7-field query DTO 생성자만 조정했다. +- DM repository의 `findDmChatListRooms`는 `followedCreatorsOnly` 다음, `pageable` 앞에 필수 `lang: Lang`을 받는다. history는 `findMessageHistory(chatRoom, cursor: Long?, lang: Lang, pageable)`로 통합했다. JPQL `:lang`에는 enum 객체를 전달하고 CREATOR role은 실제 `MemberRole.CREATOR` enum literal로 ON에 둔다. 문자열 `"EN"`/`"CREATOR"`로 Hibernate enum 인자를 우회하지 않는다. +- 중립 종료-live repository cache에는 Lang 인자를 추가하지 않았다. 기존 `getLatestFinishedLive:{memberId or guest}` key/10분 TTL과 raw DTO entry는 유지하며, miss/hit 모두 서비스에서 현재 이름을 렌더링한다. + +### 잔여 Batch 전수 목록 + +현재 소유 소스의 `findLocalizedNames` 직접 호출은 아래 **9곳**, 현재 원본까지 필요한 호출은 `LiveRoomService.findCurrentNames`의 core 위임 **1곳**으로 수렴한다. 단순 DB 목록을 이미 materialized됐다는 이유로 남긴 예외는 없다. + +| 경계 | 정확한 호출 위치 | 남기는 이유/보존 의미 | +|---|---|---| +| 중립 live membership | `live/room/LiveRoomService.kt#getRoomInfo` → `findCurrentNames` | Redis speaker/listener/manager와 host ID를 한 번에 현재 이름으로 렌더링. 복사본의 role, 원본 membership 및 금융/권한 객체 보존 | +| 중립 live 상세 참가자 | 같은 파일 `getRoomDetail`의 on-air Redis branch → `findCurrentNames` | Redis 참가자만 한 번 렌더링. host와 예약 전 DB booker branch는 JOIN으로 전환되어 이 batch에 포함되지 않음 | +| 중립 live feed | 같은 파일 `getDonationMessageList` → `findCurrentNames` | donation/roulette Redis feed를 기존 secret 필터 후 복사. UUID/본문/공유 원본 보존 | +| 중립 read-through cache | 같은 파일 `getLatestFinishedLive` → `findCurrentNames` | raw 캐시 재사용 후 한 JOIN SELECT로 현재 원본+해당 언어 이름 조회. 원본 변경/별칭 해제도 캐시의 옛 이름으로 fallback하지 않음 | +| 이미 검증에 필요한 원본 entity | 같은 파일 `enterLive`의 차단/참여제한 오류 2곳 → `findLocalizedNames` | 원본 room/host로 입장 검증 후 오류 문구만 sparse override. principal/host 원본은 불변 | +| 쓰기 반환 | `live/reservation/LiveReservationService.kt#makeReservation` → `findLocalizedNames` | 가격/결제/예약에 이미 필요한 room/owner 원본을 보존하고 생성 응답 이름만 렌더링 | +| 쓰기 검증 오류 | `message/MessageService.kt#sendTextMessage`, `sendVoiceMessage` → `findLocalizedNames` | 이미 검증한 recipient를 변경하지 않고 blocked 오류 문구만 렌더링. DB history는 batch 없음 | +| ACK/쓰기 반환 | `v2/usercreatorchat/service/UserCreatorChatService.kt#sendTextMessageByWebSocket`, `deliverRestMessage` → `findLocalizedNames` | 명시 sender-session Lang/HTTP Lang별 응답만 sparse 렌더링. 원본 신규 메시지와 neutral pubsub/FCM snapshot은 별도 유지 | +| 수신자별 전달 | `fcm/FcmEvent.kt#FcmSendListener.sendPush` → `findLocalizedNames` | recipient Lang 그룹마다 sender/title/name-args ID 합집합의 sparse map 사용. 명시 snapshot/profile/raw/default fallback을 유지하며 current-name 조회로 바꾸지 않음 | +| 세션별 전달 | `v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt#onMessage` → `findLocalizedNames` | 같은 수신자의 복수 세션 Lang별 독립 payload. publisher payload가 이미 있으므로 DB JOIN 강제 없음 | + +`PushNotificationListRepository`/`PushNotificationService`의 이미 렌더된 과거 `senderNicknameSnapshot` 목록은 변경하지 않았다. FCM title owner/name-argument metadata도 그대로다. `LiveRecommendCacheService`의 3시간 banner payload에는 이름이 없으며 변경하지 않았다. 예약 booker의 raw `Member` 조회는 취소/환불 등 기존 업무용이고 표시 이름 batch 호출이 아니다. + +### 테스트 변경과 직렬 실행 인계 + +아래 test 경로의 공통 prefix는 `src/test/kotlin/kr/co/vividnext/sodalive/`다. 새 테스트 및 단언은 아직 실행하지 않았다. + +- `live/room/LiveRoomLocalizedNameIntegrationTest.kt`: 기존 추천 RED 단언 유지. 추천·팔로잉 전체에서 standalone name SELECT 금지, 활성 EN 읽기, NOW/날짜 예약/예정 목록 및 예약 booker/list/detail의 USER/미등록/비활성 행 보존과 query count 보강. 예약 read 2회/방문 read 2회/후원+하트 3회 단언. 실제 종료 cache의 raw entry와 room query 1회 재사용을 유지하고 KO/JA/EN current-name 조회는 매 호출 1회 단언. +- `message/MessageServiceLocalizedNameTest.kt`: 기존 JA/KO/EN·본문·USER·voice 검증 보존. 6개 목록의 활성 EN 및 USER alias 무시, 비활성 fallback, count+list 합계 12회와 entity load 0 단언 추가. +- 신규 `v2/usercreatorchat/UserCreatorChatNameJoinRepositoryTest.kt`: 실제 JPQL/DB로 모든 Lang의 DM 목록/history/opponent-profile 이름·USER/미등록/비활성 fallback, 한 query/entity load 0, inactive 메시지 제외와 history cursor 검증. 실제 AI/DM 통합 service+repositories로 AI 이름 불변·EN DM·팔로우·페이지 cursor 및 두 페이지 총 query 4회 검증. Redis가 불필요한 `@DataJpaTest`이며 새 mock 없음. +- `live/recommend/LiveRecommendRepositoryTest.kt`; `live/recommend/LiveRecommendServiceTest.kt`; `live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt`; `v2/usercreatorchat/UserCreatorChatServiceTest.kt`; `v2/chat/ChatRoomListServiceTest.kt`: 변경된 필수 Lang/projection/생성자 계약에 적응. 제거된 이름 서비스에 빈 optional stub/호환 생성자를 추가하지 않았다. +- 기존 실제 localhost HTTP/WS/Redis 통합 테스트, FCM outbound/S3 mock 테스트 및 관리자 쓰기 정책 테스트는 삭제·완화하지 않았다. + +부모 전용 명령이며 병렬 작업이 모두 끝난 뒤 **한 Gradle runner에서 직렬 실행**한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.live.room.LiveRoomLocalizedNameIntegrationTest' --tests 'kr.co.vividnext.sodalive.message.MessageServiceLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatNameJoinRepositoryTest' --offline --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.live.*' --tests 'kr.co.vividnext.sodalive.message.*' --tests 'kr.co.vividnext.sodalive.fcm.*' --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.*' --tests 'kr.co.vividnext.sodalive.v2.chat.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.following.*' --offline --console=plain +./gradlew test build ktlintCheck --continue --offline --console=plain +``` + +정적 확인: 변경 메서드의 전체 호출부와 direct mock 생성자를 `rg`로 대조했다. 소유 코드에서 `findNicknamesByIds`, `findMessageNames`, `localizeChannels`, `opponentMemberId`, `LiveRoomHeartListRow` 참조는 제거됐다. 변경 파일의 130자 초과/후행 공백을 점검했다. LSP 도구는 제공되지 않았으며 compile/JPQL 실행/테스트 통과 및 실제 HTTP/WS 동작은 부모 직렬 검증 전까지 주장하지 않는다. 외부 앱/RTC/provider/운영 DB blocker는 그대로다. diff --git a/docs/20261014_크리에이터이름언어별관리/legacy-name-inventory.md b/docs/20261014_크리에이터이름언어별관리/legacy-name-inventory.md new file mode 100644 index 00000000..45a6be9b --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/legacy-name-inventory.md @@ -0,0 +1,480 @@ +# Legacy HTTP·검색 크리에이터 이름 적용 목록 + +## COR021 최신 범위 + +- ContentSeriesRepository의 목록 이름JOIN5곳과 ContentSeriesService 상세이름batch1곳을 HEAD로 되돌린다. 이 레거시 서비스 재사용자인 Home/Explorer의 시리즈 작성자 이름도 원본으로 돌아가며 기존 제목/장르/요일 번역은 유지한다. +- 공통 이름 기반과 별도v2·관리자 조회는 유지한다. 직접 관련된 AudioContentRepositoryLocalizedNameTest의 시리즈case는 이름단언만 정리하고 제목 번역/fallback을 보존한다. 최신 검증은 plan-task.md SERIES-NAME-TRIM-T1을 따른다. +- 적용 완료: 운영2파일HEAD일치, 제목검증case유지, 실제 시리즈·Home HTTP 및 전체2270건/lint통과를 확인했다. 영구테스트수증감은 없고 임시검증코드는 제거했다. + +## COR020 최신 범위 + +- L42 CharacterCommentService/Repository 이름 변경을 HEAD로 되돌리고 해당 이름 대체 통합테스트1개만 제거한다. 댓글 작성·삭제·신고·권한·커서·건수·언어 감지와 다른 테스트는 유지한다. +- 실제 제거 대상은 LegacyCurrentNameReadIntegrationTest의 shouldLocalizeCharacterCommentWritersWithoutChangingCharacterNameOrUserReplies이며 T20 신규 예정 별도 파일은 생성되지 않았다. 아래 이력과 구분하고 최신 검증은 plan-task.md CHARACTER-COMMENT-TRIM-T1을 따른다. +- 적용 완료: 운영2파일HEAD일치, 전용test1건 제거 후 전체2270건/lint통과. 실제 댓글·답글·상세HTTP10요청도 확인했고 임시 검증 코드는 제거했다. + +## COR019 최신 범위 + +- AudioContentMainController/Service의 이름 변경을 롤백하고 두 파일의 HEAD diff를 제거한다. L24의 전용 findByThemeFor2Weeks/findAudioContentByCurationId 이름 추가분과 L25·메인 큐레이션의 controller cache-hit 후 이름 batch를 제거한다. +- shared findByTheme는 AudioContentThemeService가 계속 사용하므로 유지한다. 전용 Main 테스트는 현재 없으며 T09는 계획 이력이다. 다른 실제 조회의 테스트는 삭제하지 않는다. 자세한 검증은 plan-task.md LEGACY-MAIN-T1을 따른다. +- 적용 완료: 두 메인 파일 HEAD와 동일, repository 전용2메서드 롤백. 임시 실제 HTTP5요청 통과 후 검증 코드는 제거했고 기존 전체2271건 및 lint 통과를 확인했다. 영구 테스트 증감은 없다. + +## COR017 최신 범위 + +- AudioContentMainTab*은 사용자 확인상 미사용이다. 해당 7개 서비스 파일은 HEAD와 같은 상태로 되돌리고 이름 원본 보장을 위한 null 인자·공유 raw 모드·전용 검증을 제거한다. 공유 조회 변경의 미사용 탭 간접 영향은 허용한다. +- 실제 사용 중인 Main/Theme/Home 조회의 언어별 이름과 제목 번역은 유지한다. AudioContentCuration 및 레거시 커뮤니티·팬톡 등 나머지 COR015 범위는 바꾸지 않는다. +- 아래 COR015의 탭 raw 보장 규칙은 당시 이력이며 이 절로 대체한다. 구체 파일·검증은 plan-task.md UNUSED-TAB-T1을 따른다. + +## COR015 현재 범위 보정 + +- 아래 COR012/COR013 표와 실행 기록은 과거 이력이다. 현재 범위는 PRD COR015가 우선한다. 이번 후속 변경은 코드·테스트 정리 및 정적 검토만 하며 실행 검증은 금지되어 있다. +- L13/L14 레거시 팬톡, L26~L30 큐레이션·탭, L37~L39 레거시 커뮤니티의 이름 표시 적용을 제거한다. API·기존 번역·권한·저장·알림 업무는 유지한다. +- L17/L18/L24/L31의 공유 조회는 호출자별로 구분한다. 제외된 탭/큐레이션은 원본 이름을 받고, 다른 API의 언어별 이름과 제목·본문 번역은 유지한다. 원본 경계는 KO 언어를 의미하지 않는다. +- `LegacyCreatorNameRenderer`는 제거하고 필요한 소비자는 `CreatorLocalizedNameService`를 직접 주입한다. T22 래퍼 전용 테스트는 제거하되 실제 core batch의 SQL 수·빈 입력·역할·현재 원본 fallback 회귀는 core 테스트에 보존한다. +- T04/T17의 레거시 별칭 기대값은 원본 경계에 맞춰 최소 보정한다. 검색·프로필·후원·기타 유지 대상의 별칭 테스트, characterization·번역·권한 테스트, v2 테스트는 삭제하지 않는다. 불필요한 테스트 설정 import와 제거된 생성자 인자만 정리한다. + +## 단계와 계약 + +- 기준: 같은 디렉터리 `prd.md`의 NAME002~006 및 COR012, `plan-task.md`의 P2-T1/P2-T2. +- 현재 단계는 **legacy GREEN 소스 연결 및 정적 계약 감사, 부모 직렬 검증 대기**다. 아래 첫 테스트 단계 기록은 보존하며 구현 연결 결과는 마지막 절에 누적한다. runtime PASS나 제품 완료 기록이 아니다. +- 첫 단계에서는 `src/main`을 수정하지 않았다. 후속 부모 지시로 이번에는 legacy 소스·constructor fixture·추가 회귀를 작성했다. main PRD/plan, foundation/v2/async 소스, 운영 DDL은 수정하지 않았고 Gradle·커밋·푸시도 수행하지 않았다. +- 범위: non-v2 현재 앱 HTTP 표시·검색. `content/main/tab/replay`는 라이브 오디오 콘텐츠 탭이므로 포함한다. +- 별도 slice: `fcm/*`, `live/*`, `message/*`와 비동기 발송. `admin/*`, `creator/admin/*`, `partner/*`, auth/login 원본 이름은 수정 제외한다. 공유 `Series` 엔티티를 읽는 앱 서비스는 포함하되 creator-admin 응답은 바꾸지 않는다. +- 공통 구현은 `kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService.findLocalizedNames(Collection, Lang): Map`이다. JA 활성 CREATOR만 이름 map에 포함하고 나머지는 기존 원본을 사용한다. +- 응답 조립 직전 실제 owner member IDs를 모아 batch 조회한다. `LangContext`는 HTTP 경계에서만 읽고 내부 호출에는 명시 `Lang`을 전달한다. 행별 이름 조회, `Member.nickname` 변이, principal 변이, 공통 entity getter, 전역 Jackson 변환은 금지한다. +- 공개 DTO field 이름·타입은 보존한다. content/series/cheers/post/order/receipt/character ID를 이름 map key로 쓰지 않는다. owner ID가 없는 projection은 내부 record로 보충하고 HTTP에는 새 필드를 노출하지 않는다. +- 검색은 요청 언어와 무관하게 기존 nickname 조건 **OR 활성 JA alias EXISTS**다. alias는 owner `member_id`, `lang=JA`, `is_active=true`, owner `role=CREATOR`로 상관시킨다. 제목·소개·본문·태그·장르 조건과 기존 visibility/block/requester/count/page 조건은 바꾸지 않는다. + +## 이번에 작성한 테스트와 RED 인계 + +아래 경로는 저장소 루트 기준이다. 모두 기존 실제 API/repository를 호출하며 미래 production helper나 reflection 없이 동작한다. 별칭 fixture는 회원을 flush한 뒤 `JdbcTemplate`으로 `creator_localized_name(member_id, lang, display_name, is_active, created_at, updated_at)`에 넣는다. foundation의 entity 매핑으로 생성되는 테스트 테이블을 사용하며 수동 CREATE TABLE을 하지 않는다. USER/EN 행은 관리 API로 생성할 수 없는 오염 데이터까지 읽기 경계가 방어하는지 검사하는 fixture다. + +| Owner | 실제 테스트 파일 | 사례 수 | 보호하는 동작 / 의도된 RED | +|---|---|---:|---| +| T01 | `src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt` | 5 | original OR JA alias, containsIgnoreCase, 동일 회원 양쪽 매칭 중복 없음, 중복 별칭 owner 분리, list/count/page 일치, soft clear 및 동일 행 재활성화. creator 활성/role/태그 활성/양방향 차단, content 성인/활성/duration/limited/차단, series 성인/episode 성인/활성/duration/차단/조인 중복 유지. content/series title 불변 및 series owner와 episode owner 구분. 미구현 alias list/count assertion에서 실패해야 한다. | +| T02 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt` | 2 | KO 채널 검색에서도 JA alias, 원본 유지, CREATOR/self/isActive 필터 및 중복 없음, soft clear/EN 제외와 재활성화. 미구현 alias-only hit에서 실패해야 한다. | +| T03 | `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt` | 3 | CREATOR requester의 USER 원본 매칭 유지, USER requester의 USER 제외, ADMIN/AGENT/BOT/self 제외, USER+AI_CHARACTER 별칭 제외, soft clear/EN 제외 및 원본 변경 후 재활성화. CREATOR alias hit 누락에서 실패해야 한다. 해당 repository에는 현재 count API가 없으므로 가상의 count API를 만들지 않는다. | +| T04 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt` | 3 | 실제 MockMvc HTTP: JA profile/detail 이름, CREATOR 팬톡 부모/답글과 USER 작성자 혼합, KO/EN 원본, 원본 변경 뒤 별칭 유지 및 clear/missing fallback. `/search` creator `title`+`nickname` JA, content/series 제목 불변·실제 owner 이름 JA, KO `/search/creators` alias hit. 기존 원본 vs 별칭 응답 assertion에서 실패해야 한다. | +| T05 | `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt` | 2 | 실제 `getLatestContentByTheme(locale=ja)`가 content ID와 다른 owner ID로 JA creatorNickname을 반환, USER 유지, KO/EN 원본, 제목 불변. 공개/미래/성인/활성/duration/차단 필터 유지와 원본 변경 후 soft clear fallback. 현재 원본 vs 별칭 ownername assertion에서 실패해야 한다. | + +### 부모 실행 명령 + +아래 명령은 **아직 실행하지 않았다**. foundation 파일 저장 및 foundation 검증이 끝난 뒤 부모가 순서대로 실행한다. RED는 compile/context/fixture 오류가 아니라 위 요구 동작 assertion 실패여야 한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.search.SearchRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerQueryRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.member.MemberRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerLocalizedNameEndToEndTest' +``` + +T01/T02는 기존 constructor로 QueryDSL repository를 직접 만든다. GREEN에서 dependency가 추가되면 테스트 constructor도 명시적으로 맞춘다. T03/T05는 JPA slice다. GREEN에서 공통 service/adapter dependency를 주입할 경우 해당 slice의 `@Import`를 명시적으로 보완한다. T04는 `@SpringBootTest`+MockMvc이며 기존 `EmbeddedRedisInitializer` opt-in, rollback 트랜잭션과 독립 H2 DB를 사용한다. T05도 독립 H2 DB로 owner/resource ID fixture 충돌을 피한다. + +## 전체 source edit map + +아래 모든 소스 경로의 공통 prefix는 `src/main/kotlin/kr/co/vividnext/sodalive/`다. 각 셀은 그 prefix 아래 **정확한 경로**이며 wildcard 변경 지시가 아니다. `CURRENT`는 요청 시 현재 이름 조회, `NEUTRAL-CACHE`는 언어 중립 응답 캐시, `RAW`는 원본 편집·인증 경계, `IMMUTABLE`은 기존 역사 값/발송물을 뜻한다. Test owner T01~T05는 이번에 작성했고 T06 이후는 후속 GREEN 전에 추가할 명시적 테스트 소유자다. + +### 검색·회원·탐색 + +| ID | 실제 경로와 심볼 / caller | 응답 타입·이름 및 실제 owner | 유형·언어 / 최소 수정 경계 | Test owner | +|---|---|---|---|---| +| L01 | `search/SearchRepository.kt`: `searchCreatorList`, `searchCreatorTotalCount`; `search/SearchService.kt`: `searchUnified`, `searchCreatorList`; `search/SearchController.kt` | `SearchResponseItem.id=member.id`; **creator branch의 `title`와 `nickname` 모두 이름** | CURRENT. list/count에 같은 alias EXISTS. service 응답 조립에서 Lang별 batch map으로 두 이름 field만 대체. | T01, T04; 기존 `search/SearchServiceTest.kt` constructor 회귀 | +| L02 | 같은 `search/SearchRepository.kt`: `searchContentList`, `searchContentTotalCount`; `search/SearchService.kt`: `searchContentList`, `searchUnified` | `SearchResponseItem.id=audioContent.id`; `nickname=audioContent.member.nickname`. **owner ID projection 없음** | CURRENT. 원본 nickname branch만 OR alias로 확장. 내부 owner ID projection 추가; content title/detail/theme/hashTag predicates·adult/gender/duration/limited/block/count/group/page 유지. | T01, T04 | +| L03 | 같은 `search/SearchRepository.kt`: `searchSeriesList`, `searchSeriesTotalCount`; `search/SearchService.kt`: `searchSeriesList`, `searchUnified` | `id=series.id`; `nickname=series.member.nickname`. **owner ID projection 없음**, episode owner와 series owner는 다를 수 있음 | CURRENT. series owner 상관 alias; episode content visibility/gender 조건과 title/introduction/genre/keyword/group/count/page 유지. 내부 owner ID 필요. | T01, T04 | +| L04 | `explorer/ExplorerQueryRepository.kt`: `getSearchChannel`; `explorer/ExplorerService.kt`: `getSearchChannel`; `explorer/ExplorerController.kt` | `GetRoomDetailUser.nickname`, 반환 Member의 `id` | CURRENT. query original OR alias, 활성 CREATOR/self 제외 유지; service의 양방향 차단 후 응답 mapping에서 batch. DTO 파일이 `live/room/detail`에 있어도 전역 constructor는 변경하지 않는다. | T02, T04 후속 channel HTTP 사례 | +| L05 | `member/MemberRepository.kt`: `MemberQueryRepositoryImpl.findByNicknameAndOtherCondition`; `member/MemberService.kt`: `searchMember`; `member/MemberController.kt` | 혼합 `GetRoomDetailUser.nickname`, 결과 Member `id` | CURRENT. creator alias만 OR, ADMIN/AGENT/BOT/self 제외 및 requester USER의 USER 제외 유지. 현재 query에 isActive/count 조건은 없으므로 이번 작업에서 임의 추가하지 않음. service 양방향 block 유지·배치 표시. | T03, T06 | +| L06 | `member/MemberRepository.kt`: `getMemberProfile`; `member/MemberService.kt`: `getMemberProfile` | `GetMemberProfileResponse.nickname`; 대상 `memberId`가 owner, `myMemberId`는 차단 조건 | CURRENT. 기존 대상 ID로 batch하여 응답 대체. | T06 | +| L07 | `member/MemberService.kt`: `getMyPage`; `member/myPage/MyPageResponse.kt` | `MyPageResponse.nickname`, 인증 Member `id` | CURRENT display. principal을 바꾸지 말고 DTO에서만 alias 적용. raw editor의 `ProfileResponse`와 구분. | T06 | +| L08 | `member/block/BlockMemberRepository.kt`: `getBlockedMemberList`; `member/MemberService.kt`: `getBlockedMemberList` | `GetBlockedMemberListItem.nickname`, blockedMember `id` | CURRENT 혼합 목록. blocked member owner IDs로 batch, 기존 block 활성·member 활성/count 유지. | T06 | +| L09 | `explorer/ExplorerService.kt`: `getExplorer`, `getCreatorRank`; `explorer/ExplorerQueryRepository.kt`: `getCreatorRankings`, `getNewCreators`, `findCreatorByGender` | `GetExplorerSectionCreatorResponse.nickname`, 해당 Member `id` | CURRENT. `CreatorRanking`은 순위/member 참조만 저장하며 nickname snapshot 아님. 기존 `Member.toExplorerSectionCreator` 전역 변경 대신 service 조립 DTO에서 batch. | T04 후속 섹션 사례; 기존 `explorer/ExplorerServiceTest.kt` | +| L10 | `explorer/ExplorerService.kt`: `getCreatorProfile`, `getCreatorDetail` | `CreatorResponse.nickname`, `GetCreatorDetailResponse.nickname`, `creatorAccount.id` | CURRENT. 기존 `langContext.lang`을 명시 Lang로 공통 service에 전달. profile creator와 중첩 하위 service 결과를 각각 실제 owner로 처리. 이름을 넣는 blocked-access 메시지도 같은 creator 표시 계약 대상, auth 식별값 아님. | T04 | +| L11 | `explorer/ExplorerQueryRepository.kt`: `getSimilarCreatorList`, `getRandomCreatorList`; `explorer/ExplorerService.kt`: `getCreatorProfile` | `SimilarCreatorResponse.nickname`, `userId=후보 creator member.id` | CURRENT. 이미 owner ID 보유. profile assembly에서 batch; random/order/tag/fallback query 로직 변경 없음. | T04 후속 similar creator 사례 | +| L12 | `explorer/ExplorerQueryRepository.kt`: `getFollowerList`; `explorer/ExplorerService.kt`: `getFollowerList` | `GetFollowerListDto` / `GetFollowerListResponseItem.nickname`; `userId=팔로워 member.id`, creatorId 아님 | CURRENT 혼합 목록. role/ID 보유; follower DTO 조립에서 batch. count·block·follow/notify 의미 유지. | T04 후속 follower 사례 | +| L13 | `explorer/ExplorerQueryRepository.kt`: `getCheersList`; `explorer/ExplorerService.kt`: `getCreatorProfileCheers`, `getCreatorProfile` | `GetCheersResponseItem.nickname`, 부모·답글 각각 `memberId=writer.id`; `cheersId/creatorId` 아님 | CURRENT 혼합 작성자. 부모·답글 ID를 한 번에 모아 batch, `removeDeletedNicknamePrefix()` 원본 fallback 유지. 날짜·parent·block·count·pagination 유지. | T04 | +| L14 | `explorer/ExplorerService.kt`: `writeCheers`, `modifyCheers` | HTTP `CreatorChannelFanTalkResponse.writerNickname`, `creatorReplies[].writerNickname`; 각각 writer member ID | CURRENT HTTP 쓰기 응답. 공유 v2 `from` factory 전역 수정하지 말고 legacy caller에서 응답 대체; async event는 후속 slice. | T04 후속 쓰기 응답 사례; 기존 `explorer/ExplorerServiceTest.kt`, `explorer/ExplorerControllerTest.kt` | + +### 홈·랭킹·추천·캐시 + +| ID | 실제 경로와 심볼 / caller | 응답 타입·이름 및 실제 owner | 유형·언어 / 최소 수정 경계 | Test owner | +|---|---|---|---|---| +| L15 | `api/home/HomeService.kt`: `fetchData`; `rank/RankingRepository.kt`: `getCreatorRankings` | `GetHomeResponse.creatorRanking[].nickname`, 순위 Member `id` | CURRENT. HomeService에 이미 `LangContext` 있음. follow state 유지, home의 creator DTO만 batch. | T07 | +| L16 | `rank/RankingService.kt`: `getCreatorRanking`, `fetchCreatorBySellContentCountRankTop20`, `fetchCreatorBySeriesRevenueRankTop20`; `rank/RankingRepository.kt`: 같은 이름의 query | explorer creator DTO 또는 `ContentCreatorResponse.creatorNickname`, `creatorId=member.id` | CURRENT 순위 materialization/집계의 **현재 이름**. 순위 데이터가 과거 기간 기준이어도 query가 현재 member를 join하므로 이름은 역사 snapshot 아님. service에서 batch. | T08 | +| L17 | `rank/RankingRepository.kt`: `getAudioContentRanking`, `fetchCreatorContentBySalesCountTop4`, `fetchCreatorContentByLikeCountTop4`, `fetchFreeContentByCreatorIdTop4`; `rank/RankingService.kt`: 대응 메서드 | `GetAudioContentRankingItem.creatorNickname`, `creatorId=member.id`; `contentId`는 별도 | CURRENT. repository에서 title 번역만 기존 locale로 처리, service에서 owner 이름 batch. `api/home/HomeService.getContentRankingBySort`와 모든 content tab caller까지 도달. | T08, T07, T09 | +| L18 | `rank/RankingService.kt`: `getSeriesRanking`, `getSeriesAllRankingByGenre`, `getCompleteSeriesRanking`, `fetchCreatorSeriesBySales`, `seriesToSeriesListItem`; `rank/RankingRepository.kt`: 대응 query | `GetSeriesListResponse.SeriesListItem.creator.nickname`, `series.member.id`, `seriesId` 아님 | CURRENT. 공통 series response assembly에서 batch, creator-admin Series entity·writer/studio 변경 없음. | T08, T09 | +| L19 | `query/recommend/RecommendChannelQueryRepository.kt`: `getRecommendChannelList`; `query/recommend/RecommendChannelQueryService.kt`: `getRecommendChannel`; `api/home/HomeService.kt`: `fetchData` | `RecommendChannelResponse.creatorNickname`, `channelId=member.id`; contentList resource ID와 별도 | NEUTRAL-CACHE. `@Cacheable(default)` key는 `recommendChannel:{memberId 또는 guest}:{isAdult}:{contentType}`로 Lang 없음. cached raw response는 공유 DTO 변이 없이 **cache hit 이후 caller에서 copy+batch**하여 현재 이름 적용. 캐시 안에 JA를 저장하거나 cached DTO를 mutate하면 KO/JA 오염. 이미 존재하는 content title locale cache 문제와 이름 변경을 혼동하지 않음. | T07, T10 | +| L20 | `explorer/profile/CreatorDonationRankingQueryRepository.kt`: `getMemberDonationRanking`; `explorer/profile/CreatorDonationRankingService.kt`: `getMemberDonationRanking`; `explorer/ExplorerService.kt`: profile/donation ranking | `MemberDonationRankingResponse.nickname`, **donor 응답 `userId`**(query record는 `memberId`), 순위 대상 creatorId 아님 | NEUTRAL-CACHE. query→response→Redis list write→cache hit 반환 양쪽 확인. key `creator_donation_ranking_v2:{creatorId}:{period}:{offset}:{limit}:{withDonationCan}`, TTL 다음 월요일, Lang 없음. legacy 응답 조립에서 hit/miss 모두 donor IDs로 copy+batch. count 캐시는 숫자뿐이므로 이름 변경 없음. v2 adapter consumer는 별도 slice. | T11, T04 후속 donor 사례 | +| L21 | `explorer/ExplorerQueryRepository.kt`: `getMemberDonationRanking` | `MemberDonationRankingResponse.nickname`, donor `account.id` | CURRENT. 현재 ExplorerService는 L20의 service를 호출하므로 이 메서드는 legacy direct query 경로의 남은 raw projection이다. 실제 앱 caller가 추가되지 않으면 불필요한 독립 수정은 하지 않음. | T11, source caller 확인 | + +L19/L20의 cache hit에서는 alias map에 값이 없을 때 cached nickname을 곧바로 **현재 원본**이라고 간주하면 안 된다. 원본 변경 후 soft clear가 일어나면 cached 원본도 stale할 수 있다. 후속 구현은 cache hit 이후 owner IDs로 현재 Member 원본을 batch 재조회하는 경계(`member/MemberRepository.kt`의 scalar `findNicknamesByIds`)를 두고 현재 원본 fallback과 alias map을 함께 응답에 적용한다. 캐시 entry/과거 record를 재작성하지 않는다. T10/T11에 원본 변경→alias clear→같은 cache hit의 current fallback 사례를 포함한다. + +### 오디오·시리즈·메인 탭·주문·플레이리스트 + +| ID | 실제 경로와 심볼 / caller | 응답 타입·이름 및 실제 owner | 유형·언어 / 최소 수정 경계 | Test owner | +|---|---|---|---|---| +| L22 | `content/AudioContentService.kt`: `getDetail`; `content/AudioContentRepository.kt`: `findByIdAndActive`; `explorer/ExplorerQueryRepository.kt`: `getMember` | `GetAudioContentDetailResponse.creator.nickname` / `AudioContentCreator`, `audioContent.member.id` | CURRENT. 상세 조립에서 creator owner batch, 기존 title/detail/theme 번역과 접근/구매/visibility 유지. | T12; 기존 `content/AudioContentServiceTest.kt` | +| L23 | `content/AudioContentRepository.kt`: `getLatestContentByTheme`; `content/AudioContentService.kt`: `getLatestContentByTheme`; `api/home/HomeService.kt`: latest/free/point/recommend buckets | `AudioContentMainItem.creatorNickname`, `creatorId=member.id`, contentId와 별도 | CURRENT. query method가 `locale`를 이미 받으며 translated-title tuple와 locale-null projection 두 경로 모두 이름 대응 필요. tuple 응답 조립에 명시 Lang의 batch map 사용; service/홈 caller는 현재 locale 전달 유지. title 번역과 owner name 분리. | T05, T07 | +| L24 | `content/AudioContentRepository.kt`: `findByTheme`, `findAlarmContentByTheme`, `findByThemeFor2Weeks`, `findAudioContentByCurationId`, `findAudioContentByCurationIdV2`, `findContentByCurationId`, `getAudioContentRanking`; `content/main/AudioContentMainService.kt`: `getNewContentByTheme`, `getNewContentFor2WeeksByTheme`, `getAudioContentMainBannerList`, `getAudioContentCurationListWithPaging`; `content/AudioContentService.kt`: `getAudioContentRanking` | `GetAudioContentMainItem.creatorNickname` / `GetAudioContentRankingItem.creatorNickname`, `creatorId=member.id` | CURRENT. 각 owner ID는 기존 projection 보유. 해당 service assembly에서 batch 또는 명시 Lang를 query 응답 조립까지 전달. 필터/count/정렬 변경 금지. | T09, T12; T05는 대표 query이고 이 행 전체 검증을 대체하지 않음 | +| L25 | `content/AudioContentRepository.kt`: `getNewContentUploadCreatorList`; `content/main/AudioContentMainService.kt`: `getNewContentUploadCreatorList` | `ContentCreatorResponse.creatorNickname`, `creatorId=member.id` | CURRENT. service에서 batch, creator별 contentList 기존 로직 유지. | T09 | +| L26 | `content/main/curation/AudioContentCurationQueryRepository.kt`: `findByCurationId`; `content/main/curation/AudioContentCurationService.kt`: `getCurationContent`, `getContentCurationList` | `GetAudioContentMainItem.creatorNickname`, `creatorId=member.id` | CURRENT. curation ID를 owner로 쓰지 않음. service에서 batch; active/genre/adult/block/count 유지. | T09 | +| L27 | `content/main/tab/content/ContentMainTabTagCurationRepository.kt`: `getTagCurationContentList`; `content/main/tab/content/ContentMainTabTagCurationService.kt`: `getTagCurationContentList` | `GetAudioContentMainItem.creatorNickname`, `creatorId=member.id` | CURRENT. tag curation content query caller에서 명시 Lang+batch. | T09 | +| L28 | `content/main/tab/AudioContentMainTabRepository.kt`: `findCreatorByThemeContent`, `findCreatorWithHasFreeContent` | `ContentCreatorResponse.creatorNickname`, `creatorId=member.id` | CURRENT. caller는 `content/main/tab/asmr/AudioContentMainTabAsmrService.kt`, `content/main/tab/replay/AudioContentMainTabLiveReplayService.kt`, `content/main/tab/free/AudioContentMainTabFreeService.kt`의 `fetchData`. 배치 표시 후 creator별 인기 콘텐츠는 L17을 재사용. | T09 | +| L29 | `content/main/tab/RecommendSeriesRepository.kt`: `getNewSeriesList`, `getRecommendSeriesList`; `content/main/tab/free/AudioContentMainTabFreeService.kt`, `content/main/tab/series/AudioContentMainTabSeriesService.kt`: `fetchData` | `GetRecommendSeriesListResponse.creatorNickname`, `creatorId=series.member.id` | CURRENT. 추천 row의 ID/seriesId가 아닌 creatorId로 service batch. | T09 | +| L30 | `content/main/tab/home/AudioContentMainTabHomeService.kt`, `content/main/tab/content/AudioContentMainTabContentService.kt`, `content/main/tab/alarm/AudioContentMainTabAlarmService.kt`, `content/main/tab/series/AudioContentMainTabSeriesService.kt` 및 L28의 세 service: `fetchData`와 개별 content/rank/creator/series 조회 메서드 | 중첩 content creatorNickname / series.creator.nickname / ContentCreatorResponse.creatorNickname | CURRENT. 각 nested producer L17/L18/L24~29/L31 적용 누락 확인. 기존 반환된 공통 DTO를 재로컬라이즈하거나 parent/tab ID로 lookup하지 않음. 별도 wildcard 리팩터링 없음. | T09 | +| L31 | `content/series/ContentSeriesRepository.kt`: `getSeriesListV2`, `getSeriesByGenreListV2`, `getRecommendSeriesListV2`, `getOriginalAudioDramaList`, `findByCurationIdV2`, `getDayOfWeekSeriesListV2`; `content/series/ContentSeriesService.kt`: `getSeriesList`, `getSeriesListByGenre`, `getRecommendSeriesList`, `getOriginalAudioDramaList`, `fetchSeriesByCurationId`, `getDayOfWeekSeriesList` | `GetSeriesListResponse.SeriesListItem.creator.nickname`, `creatorId=series.member.id`; seriesId는 resource | CURRENT. **V2라는 메서드 이름이어도 non-v2 파일의 레거시 caller이므로 포함**. 기존 locale은 series title 번역용. 각 반환 목록 조립에서 owner batch; title/genre/price/adult/block/count/요일/정렬 유지. | T13, T07, T09; 기존 `content/series/main/SeriesMainControllerTest.kt` | +| L32 | `content/series/ContentSeriesService.kt`: `getSeriesDetail`; `content/series/ContentSeriesRepository.kt`: `getSeriesDetail` | `GetSeriesDetailResponse.creator.nickname`, `series.member.id` | CURRENT. 상세 DTO 조립에서 batch. `Series.writer`, `studio`, title/introduction/keyword는 creator nickname 아님. | T13 | +| L33 | `content/comment/AudioContentCommentRepository.kt`: `findByContentId`, `getAudioContentCommentReplyList`; `content/comment/AudioContentCommentService.kt`: `getCommentList`, `getCommentReplyList`; `content/AudioContentService.kt`: `getDetail` | `GetAudioContentCommentListItem.nickname`, `writerId=comment.member.id` | CURRENT 혼합 작성자. parent/child 각 writer ID batch; 상세의 commentList도 포함. deleted-prefix fallback, active/block/secret/count/page 유지. blocked-by-creator HTTP 에러의 이름도 creator owner로 표시, FCM args는 제외. | T14, T12 | +| L34 | `content/order/OrderRepository.kt`: `getAudioContentOrderList`, `getAudioContentMainOrderList`; `content/order/OrderService.kt`: 대응 메서드 | `GetAudioContentOrderListItem.creatorNickname` / `GetAudioContentMainItem.creatorNickname`, audioContent owner `creatorId` | CURRENT. 주문 날짜는 역사지만 이름은 query가 현재 Member를 join함. service 응답 batch, KEEP/RENTAL/expiry/isActive/count/page 유지. | T15 | +| L35 | `content/order/OrderRepository.kt`: `findBuyerListByContentId`; `content/AudioContentService.kt`: `getDetail`; `content/GetAudioContentDetailResponse.kt`: `ContentBuyer` | `ContentBuyer.nickname`, **order.member.id가 buyer owner**, 현재 projection은 nickname/profile만 있음 | CURRENT 혼합 구매자. content creator ID로 조회하면 오동작. 내부 buyer member ID record를 추가하고 상세 assembly batch, 공개 ContentBuyer 타입 보존. | T12, T15 | +| L36 | `content/AudioContentRepository.kt`: `fetchContentForPlaylist`; `content/playlist/AudioContentPlaylistService.kt`: `getPlaylistDetail`; `content/playlist/AudioContentPlaylist.kt`: `AudioContentPlaylistContent` | `AudioContentPlaylistContent.creatorNickname`, **projection owner ID 없음**; `id=content.id` | CURRENT. Redis playlist는 content IDs/order/endDate만 저장. getPlaylistDetail에서 SQL 현재 이름 재조회하므로 historical name snapshot 아님. 내부 memberId 추가하여 query→service batch. Redis 저장 record 이름 rewrite/새 필드 없음. | T16 | + +### 커뮤니티·후원·사용 내역·캐릭터 작성자·오디션 + +| ID | 실제 경로와 심볼 / caller | 응답 타입·이름 및 실제 owner | 유형·언어 / 최소 수정 경계 | Test owner | +|---|---|---|---|---| +| L37 | `explorer/profile/creatorCommunity/CreatorCommunityRepository.kt`: `getCommunityPostList`, `getCommunityPost`, `getLatestPostListFromCreatorsYouFollow`; `explorer/profile/creatorCommunity/CreatorCommunityService.kt`: `getCommunityPostList`, `getCommunityPostDetail`, `getLatestPostListFromCreatorsYouFollow`; `explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt`: `toCommunityPostListResponse` | `GetCommunityPostListResponse.creatorNickname` / `SelectCommunityPostResponse.creatorNickname`, post owner `creatorId` | CURRENT. 본문 번역과 별개로 이름 batch. postId/firstComment writer를 owner와 혼동하지 않음. paid mask/adult/pin/block/count 유지. 상세·구매 시 blocked message의 creator 이름도 HTTP current-display. | T17 | +| L38 | `explorer/profile/creatorCommunity/CreatorCommunity.kt`: `toCommunityPostListResponse`; `explorer/profile/creatorCommunity/CreatorCommunityService.kt`: `purchasePost` | 같은 `GetCommunityPostListResponse.creatorNickname`, entity `member.id` | CURRENT 구매 HTTP 응답. entity builder의 모든 호출을 전역 변경하지 말고 legacy service의 반환 DTO에서 override. v2/admin consumers는 별도 scope. | T17 | +| L39 | `explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt`: `findByPostId`, `getCommunityCommentReplyList`; `explorer/profile/creatorCommunity/CreatorCommunityService.kt`: `getCommunityPostCommentList`, `getCommentReplyList` 및 post list/detail의 firstComment | `GetCommunityPostCommentListItem.nickname`, `writerId=comment.member.id` | CURRENT mixed writer. 부모·reply·firstComment 각각 writer IDs batch, deleted-prefix/secret/block/count/page 유지. | T17 | +| L40 | `explorer/profile/channelDonation/ChannelDonationMessageRepository.kt`: `getChannelDonationMessageList`; `explorer/profile/channelDonation/ChannelDonationService.kt`: `getChannelDonationList`, `getChannelDonationListForProfile` | `GetChannelDonationListItem.nickname`, **donor `memberId`**, creatorId는 목록 filter | CURRENT mixed donor. 기존 secret/default message/삭제 prefix/adult/visibility/cursor 유지, donor member IDs batch. HTTP recipient render이며 FCM 전달은 제외. | T18 | +| L41 | `can/CanRepository.kt`: `CanQueryRepositoryImpl.getCanUseStatus`; `can/UseCanQueryDto.kt`; `can/CanService.kt`: `getCanUseStatus` | `GetCanUseStatusResponseItem.title` 안 room/audio/community/recipient creator name. `UseCanQueryDto.id=useCan.id`; **각 owner ID 없음** | CURRENT receipt: 현재 member를 join하며 이름 snapshot column 없음. `qRoomMember.id`, `qAudioContentMember.id`, `qCommunityPostMember.id`, `qRecipientMember.id` 내부 projection을 추가하고 service가 title를 만들기 전에 한 번 batch. payment gateway/refund/amount/distinct/page·resource title 우선 fallback 유지. live 이름도 이 HTTP receipt의 조회는 포함하되 `live/*` production 수정은 하지 않음. | T19 | +| L42 | `chat/character/comment/CharacterCommentService.kt`: `listComments`, `getReplies`, `getLatestComment`, `toCommentResponse`, `toReplyResponse`; `chat/character/comment/CharacterCommentDto.kt` | `CharacterCommentResponse.memberNickname`, `CharacterReplyResponse.memberNickname`, entity writer `member.id` | CURRENT mixed writer. characterId는 owner 아님. 부모·답글 목록+original 및 latest에서 writer IDs batch하고 Lang 명시 전달. 캐릭터 자체 `name`/본문 번역은 변경 없음. | T20 | +| L43 | `audition/applicant/AuditionApplicantRepository.kt`: `getAuditionApplicantList`; `audition/applicant/AuditionApplicantService.kt`: `getAuditionApplicantList`; `audition/applicant/AuditionApplicantController.kt` | `GetAuditionRoleApplicantItem.nickname`, **applicant memberId**, applicantId는 resource | CURRENT mixed applicant. service assembly batch, applicant/member 활성·role filter·vote count·sort/page 유지. admin audition은 제외. | T21 | + +## 원본·불변·비이름 경계 + +| 경로와 심볼 | 분류와 결정 | 보존 검증 owner | +|---|---|---| +| `member/Member.kt`: `nickname`, `toExplorerSectionCreator`; `member/MemberAdapter.kt`; `member/MemberService.kt`: `loadUserByUsername` | RAW. entity/principal 원본 불변. `toExplorerSectionCreator`는 앱/관리/기타 공용이므로 이름 getter/global mapper를 변형하지 않고 앱 caller의 DTO를 변경한다. | T04, T06; 기존 Member 서비스·보안 테스트 | +| `member/MemberController.kt`: `getMember` (`GET /member`), `changeNickname`, profile PUT; `member/MemberService.kt`: `getMember`, `updateNickname`, `profileUpdate`; `member/ProfileResponse.kt`, `member/ProfileUpdateRequest.kt` | RAW EDITOR 경계. GET·PUT가 동일 ProfileResponse를 쓰고 nickname editing input을 제공한다. 기존 raw 응답/입력을 임의 로컬라이즈하지 않는다. 외부 client가 GET을 표시에도 쓰는지는 EXT001 확인 전 blocked이며 서버 구현 완료로 raw editor 안전을 증명하지 않는다. 별도 current display는 L06/L07. | T06; 외부 EXT001 | +| `member/MemberRepository.kt`: `findByNickname`, `existsByNickname`, `findNicknamesWithPrefix`; `member/MemberService.kt`: `duplicateCheckNickname`, `validateNickname`; `member/nickname/NicknameGenerateService.kt` | RAW 식별·중복·생성. alias search 대상은 `findByNicknameAndOtherCondition`만, raw unique 검사나 자동 닉네임 생성은 변경 없음. | T06 | +| `member/nickname/NicknameChangeLog.kt`: `prevNickname`; `member/MemberService.kt`: `updateNickname` | IMMUTABLE 변경 전 이름 기록. 기존 row rewrite 없음. 별칭 해제 시 과거 이름이 아니라 현재 Member.nickname으로 fallback. | T03, T04 | +| `member/login/LoginResponse.kt`; `member/MemberService.kt`: login/signup/findOrRegister; `member/auth/*`, `member/social/*`, `admin/*`, `creator/admin/*`, `partner/*` | RAW/제외. login·인증·원본 식별·관리/파트너 계약을 바꾸지 않는다. role+AI_CHARACTER인 CREATOR 표시는 포함, AI_CHARACTER kind만인 USER는 불변. | T03; 기존 auth/admin 테스트 | +| `chat/character/service/ChatCharacterCreatorMemberService.kt`: `ensureAiCharacterCreatorMember`, `syncAiCharacterCreatorMemberDisplayFields`, `syncDisplayFields` | RAW 동기화. ChatCharacter.name에서 원본 Member.nickname을 만든다. 별칭은 별도 테이블이므로 이 producer를 로컬라이즈하거나 원본 sync를 중단하지 않음. | 기존 `chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`, `ChatCharacterCreatorMemberServiceIntegrationTest.kt` | +| `content/AudioContentService.kt`: `publishContentUploadedAfterCommit`; `explorer/profile/creatorCommunity/CreatorCommunityService.kt`: `publishCommunityPostCreatedAfterCommit` | 신규 v2 following event producer. 과거 following rows는 rewrite하지 않는다. v2 producer/consumer inventory slice의 책임이며 이 단계에서 원본 args를 변형하지 않음. | v2 following slice | +| `content/AudioContentService.kt`: uploadComplete/releaseContent; `explorer/ExplorerService.kt`: saveNotice; community/comment FcmEvent args | 새 async recipient payload는 이후 P3 slice. HTTP 이름 변경을 FCM title/args 변경으로 확대하지 않음. | async slice | +| `content/AudioContentRepository.kt`: `findByCreatorId`, other/previous/next-content; `content/series/content/ContentSeriesContentRepository.kt`; content/series banner, theme/category/tag/genre title | 비이름 field. `GetAudioContentListItem`, `OtherContentResponse`, series-content item에는 creator nickname 없음. existing content title/본문/character.name·Series.writer/studio는 creator localized-name으로 대체하지 않음. | T01, T04, T05, T12, T13 | +| L19/L20 cached nickname, Redis playlist detail | NEUTRAL-CACHE/current lookup, **과거 이름 snapshot 면제 아님**. cache hit 뒤 표시 대체하고 shared cache DTO 원본 유지. playlist 이름은 SQL current 조회. | T10, T11, T16 | + +## 후속 테스트 소유자 + +아래 경로의 공통 prefix는 `src/test/kotlin/kr/co/vividnext/sodalive/`다. `신규 예정` 파일은 **아직 생성하지 않았다**. 이번 대표 15개 사례가 inventory 전체 GREEN 검증을 대신하지 않으며 해당 표면을 구현하기 전에 이 소유자의 RED 사례를 보강한다. + +| Owner | 정확한 테스트 경로 | 상태·핵심 사례 | +|---|---|---| +| T06 | `member/MemberServiceTest.kt`, `member/MemberControllerTest.kt`; 신규 예정 `member/MemberLocalizedNameEndToEndTest.kt` | 기존 두 파일 있음. profile/myPage/search/block mixed USER, raw ProfileResponse·nickname edit·auth 불변. | +| T07 | `api/home/HomeServiceTest.kt`; 신규 예정 `api/home/HomeLocalizedNameEndToEndTest.kt` | 기존 있음. creator ranking·중첩 content/series/recommend/current names, guest KO/JA. | +| T08 | 신규 예정 `rank/RankingLocalizedNameEndToEndTest.kt` | creator/content/series/current owner 및 period 결과·title 불변. | +| T09 | 신규 예정 `content/main/LegacyContentMainLocalizedNameEndToEndTest.kt` | main/curation/tag/free/content/home/alarm/asmr/replay/series 탭의 creator와 ownernames. | +| T10 | 신규 예정 `query/recommend/RecommendChannelLocalizedNameCacheTest.kt` | 같은 member KO→JA→KO cache hit, alias update 후 current name, 원본 변경+clear 후 현재 원본 fallback, cached raw DTO 불변. | +| T11 | 신규 예정 `explorer/profile/CreatorDonationRankingLocalizedNameCacheTest.kt` | donor CREATOR/USER 혼합, cache hit/miss와 Lang 순서·current alias 갱신·원본 변경+clear fallback, count cache 불변. | +| T12 | `content/AudioContentServiceTest.kt`; 신규 예정 `content/AudioContentLocalizedNameEndToEndTest.kt` | 기존 있음. 상세 creator·mixed buyers·comments, owner ID와 contentId 구분, 원본/번역 title 유지. | +| T13 | 신규 예정 `content/series/ContentSeriesLocalizedNameEndToEndTest.kt`; 기존 `content/series/main/SeriesMainControllerTest.kt` | 목록/genre/recommend/original/curation/dayOfWeek/detail ownernames, writer/studio/title 불변. | +| T14 | 신규 예정 `content/comment/AudioContentCommentLocalizedNameEndToEndTest.kt` | mixed 부모/답글 및 상세 nested 목록, deleted original fallback/secret/block/page. | +| T15 | 신규 예정 `content/order/OrderLocalizedNameEndToEndTest.kt` | 주문 목록 현재 creator와 상세 mixed buyer, rental/expiry/count/public schema. | +| T16 | 신규 예정 `content/playlist/AudioContentPlaylistLocalizedNameEndToEndTest.kt` | Redis metadata는 그대로, detail SQL 이름만 current owner alias·원본 fallback·순서. | +| T17 | `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`, `CreatorCommunityTranslationEndToEndTest.kt`; 신규 예정 `explorer/profile/creatorCommunity/CreatorCommunityLocalizedNameEndToEndTest.kt` | 기존 두 파일 있음. post/detail/following/firstComment/comment/reply mixed owners·본문 번역 유지. | +| T18 | `explorer/profile/channelDonation/ChannelDonationServiceTest.kt`, `ChannelDonationMessageRepositoryTest.kt`; 신규 예정 `explorer/profile/channelDonation/ChannelDonationLocalizedNameEndToEndTest.kt` | 기존 두 파일 있음. donor aliases는 channel creator와 독립; USER·secret/default text 불변. | +| T19 | `can/CanServiceTest.kt`, `can/CanControllerIntegrationTest.kt`; 신규 예정 `can/CanReceiptLocalizedNameEndToEndTest.kt` | 기존 두 파일 있음. recipient/audio/community/room owner ID 구분, title 우선순위와 원본 resource title·receipt 금액 유지. | +| T20 | 신규 예정 `chat/character/comment/CharacterCommentLocalizedNameEndToEndTest.kt` | mixed comment/reply/latest/original, character name과 writer creator name 구분. | +| T21 | 신규 예정 `audition/applicant/AuditionApplicantLocalizedNameEndToEndTest.kt` | CREATOR/USER applicant owner, applicantId≠memberId, likes/count/order 유지. | + +## 정적 확인과 남은 검증 + +- `rg`로 non-v2 nickname/projection 사용을 전체 검색하고 실제 caller/service/DTO를 읽어 목록에 연결했다. 추가 발견은 blocked member, audition applicant, buyerList, recommend series, receipt의 owner ID 누락이다. +- foundation `CreatorLocalizedNameEntity.kt`의 실제 table/column names와 fixture SQL을 대조했다. `JA` enum NAME·member FK·timestamp 값을 일치시켰다. +- 신규 테스트의 130자 초과/trailing whitespace 검색 결과 0건. production helper, reflection, 강제 실패 placeholder는 추가하지 않았다. +- Gradle/test/build/lint와 실제 HTTP 실행은 의도적으로 미실행이다. 부모의 serialized RED가 다음 검증 단계이며 이 문서는 RED 성공 또는 GREEN 완료를 주장하지 않는다. +- 현재 도구 목록에 `lsp_diagnostics`가 없어 Kotlin LSP 진단을 수행하지 못했다. 컴파일/context 오류가 보이면 부모 RED에서 먼저 수정하고 요구 동작 assertion 실패를 별도로 기록해야 한다. +- 외부 EXT001 raw editor/client display/cache 계약과 운영 DDL/MySQL collation은 이 테스트 단계에서 검증하지 않았다. 기존 문서의 승인 이전 문구를 덮어쓰지 않고 COR012를 기준으로 삼았다. + +## 후속 GREEN 구현 연결 기록 + +### 실행 근거와 상태 + +- 부모 제공 RED: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-consumer-red.log`. foundation 이후 기존 consumer 테스트가 실행됐고 별칭 hit/표시 assertion 실패를 확인했다. 이 worker는 Gradle을 실행하지 않았다. +- HTTP fixture 오류도 별도로 확인했다. `build/test-results/test/TEST-kr.co.vividnext.sodalive.explorer.ExplorerLocalizedNameEndToEndTest.xml`의 profile와 unified 요청은 `success=false`, 로그인 오류와 `data=null`이다. stack은 `MemberContentPreferenceService.initializeDefaultPreference`→`resolveForQuery(REQUIRES_NEW)`다. 미커밋 viewer가 별도 트랜잭션에서 보이지 않았으며 JSON field명이 잘못된 것이 아니다. `TestTransaction`으로 fixture를 먼저 커밋하고 다음 검증 트랜잭션을 시작하도록 수정했다. 별칭/원본/title/owner ID 기대값을 완화하지 않았다. +- 부모 lint 로그 `creatorname-foundation-final-lint.log`에 나온 5개 신규 테스트의 JDBC 인자를 각각 별도 줄로 수정했다. main 새 import 순서와 130자 길이도 정적으로 대조했다. +- `v2/creatorname/application/LegacyCreatorNameRenderer.kt`는 명시 Lang의 페이지 batch/copy와 중립 캐시의 현재 원본+별칭 map을 제공한다. 내부에서 foundation의 정확한 `findLocalizedNames(Collection, Lang)`를 호출한다. 요청 컨텍스트/기본-null service/전역 serializer/Member 변이는 없다. +- `v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt`의 owner 상관 `EXISTS`를 SearchRepository 6개 list/count 조건, Explorer channel, MemberQuery 검색에 적용했다. 원본 containsIgnoreCase 및 기존 다른 검색 조건·필터·group/count/page는 유지했다. + +### L01~L43 연결 결과 + +| Inventory | 구현 경계 / 정적 판단 | runtime 상태 | +|---|---|---| +| L01~L03 | SearchService가 Lang을 명시 전달. creator title+nickname 대체. `search/SearchResponse.kt`의 `SearchNameRow`에 content/series 실제 ownerId를 내부 projection으로 추가하고 공개 SearchResponseItem으로 변환. | 부모 GREEN 대기 | +| L04~L08 | Explorer/Member 검색·타 회원 profile·myPage·blocked list의 response assembly에 batch/copy 적용. 원본 편집 GET/PUT·login·nickname 식별/변경은 기존 raw 그대로. | 부모 GREEN 대기 | +| L09~L14 | 탐색 섹션, profile/detail/blocked error, similar creator, follower, 부모/답글 cheers, fanTalk 쓰기 응답을 owner별 batch/copy로 연결. profile 내 live manager는 같은 profile creator의 이름을 복사하되 live 소스/저장 객체는 수정하지 않음. | 부모 GREEN 대기 | +| L15~L18 | Home creator ranking, RankingService의 creator/content/series 공통 assembly와 각각의 Top4/Top20 소비자를 연결. 순위/가격/수량/소유/요일/title fields는 그대로. | 부모 GREEN 대기 | +| L19~L20 | HomeService와 ExplorerService에서 neutral cache hit 뒤 현재 Member 원본을 batch 재조회하고 alias를 합쳐 새 DTO로 반환. recommend/donation cache producer·key·TTL·저장 entry는 그대로. | 부모 GREEN 대기 | +| L21 | `ExplorerQueryRepository.getMemberDonationRanking`의 실제 앱 caller가 없음을 `rg`로 다시 확인. 현재 앱은 cached CreatorDonationRankingService를 사용하므로 사용되지 않는 query를 별도로 수정하지 않음. | 연결 제외: 사용되지 않는 projection | +| L22~L23 | Audio 상세 creator/comment/buyer IDs 합집합, latest-content locale query의 owner IDs로 batch. title/detail/theme 번역은 기존 로직. `ContentBuyerRow`는 내부 projection이며 공개 ContentBuyer에는 ID를 추가하지 않음. | 부모 GREEN 대기 | +| L24~L30 | main/theme/curation/tag 및 alarm/content/free/asmr/replay/series 탭에 전체 응답 owner IDs batch 적용. Home tab은 이미 수정된 RankingService를 재사용하여 별도 중복 렌더링하지 않음. cached main 목록은 controller에서 cache hit 뒤 처리한다. | 부모 GREEN 대기 | +| L31~L32 | ContentSeriesService의 모든 목록·genre/recommend/original/curation/dayOfWeek/detail 경계를 연결. 공유 repository/Series entity/admin output을 전역 변경하지 않음. | 부모 GREEN 대기 | +| L33 | Audio 댓글/답글은 명시 Lang 인자를 추가. 앱 AudioContentCommentController만 요청 Lang을 전달하고 기존 shared ADMIN 호출은 기본 KO로 raw 이름 보존. 상세 nested comments도 포함. | 부모 GREEN 대기 | +| L34~L36 | Order response assembly, 내부 ContentBuyerRow, 내부 PlaylistContentRow로 실제 owner IDs 확보. Redis playlist metadata 및 공개 playlist/buyer DTO는 그대로. | 부모 GREEN 대기 | +| L37~L39 | Community 목록/detail/following/purchase, firstComment, 댓글/답글을 연결. 댓글 shared ADMIN consumer가 있어 기본 KO/앱 controller 명시 Lang 경계를 분리. entity/v2 DTO factory 및 번역/paid mask는 그대로. | 부모 GREEN 대기 | +| L40~L43 | donor 목록, Can internal DTO의 room/audio/community/recipient IDs, character comment/reply/latest writers, audition applicant memberIds를 batch로 연결. receipt title 우선순위·금액·resource 제목, USER 이름, count/page/filter는 그대로. | 부모 GREEN 대기 | + +### GREEN 감사에서 추가 확인한 캐시·공유 경계 + +- L24/L25의 `content/main/AudioContentMainService.kt`는 `newContentUploadCreatorList`와 `getAudioContentCurationListWithPaging`의 raw creator 이름 포함 결과를 `default` cache에 저장한다. `content/main/AudioContentMainController.kt`에서 cache hit 뒤 전체 owner IDs의 현재 이름을 조회해 복사했다. +- `content/AudioContentService.kt.getAudioContentRanking`은 Lang 없는 `contentRanking` key와 `cache_ttl_3_days`에 이름 포함 결과를 저장한다. `content/AudioContentController.kt`에서 cache hit 뒤 현재 이름을 batch/copy 처리했다. +- L24에서 사용하는 `content/theme/AudioContentThemeService.kt.getContentByTheme`에도 이름 포함 item이 있어 연결했다. theme/title 자체의 번역 계약은 변경하지 않았다. +- v2 ADMIN `AiCharacterAdminCommunityPostFacade.comments/commentReplies`, `AiCharacterAdminAudioContentFacade.comments/commentReplies`가 legacy comment 서비스를 실제 재사용한다. 이 service 읽기의 기본 Lang은 KO이고 앱 controller만 요청 Lang을 명시한다. v2 ADMIN 파일을 수정하거나 route/principal로 언어를 추론하지 않았다. +- 기존 neutral cache의 콘텐츠 제목/날짜 locale 문제나 visibility 수명은 이번 이름 변경으로 확장 수정하지 않았다. aliases와 현재 원본의 표시만 cache 밖에서 갱신한다. + +### 소유한 FCM 호출 metadata + +`delivery-name-inventory.md` #9/#10/#11/#12/#14/#15의 6곳에 `titleMemberId`를 명시했다. Explorer notice/community post/comment는 실제 작성 member ID, audio upload/release는 audio owner ID, audio reply는 `parent?.member?.id ?: member.id`다. 기존 title/args/content 제목·deepLink·senderMemberId는 그대로다. 특히 reply snapshot의 sender는 실제 commenter이며 title owner와 다를 수 있다. 중앙 FcmEvent/async/live/message/v2 소스는 수정하지 않았다. + +### 추가 회귀와 constructor fixture + +추가 테스트는 **아직 실행하지 않았다**. 이번 원래 15개와 아래 13개를 합쳐 대상 이름 회귀는 28개 실행 사례다. 새 회귀의 RED/GREEN을 관찰했다고 주장하지 않으며 부모가 별도 검증 기록을 남긴다. + +| Owner | 정확한 테스트 파일 | 사례 / 보호 경계 | +|---|---|---| +| T22 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCreatorNameRendererTest.kt` | 기존 2 + 리뷰 회귀 4: 실제 foundation/JPA batch의 1 select(작은/중복 큰 페이지), KO 0 alias select, USER/missing/원본 DTO 보존; neutral cache의 원본 변경+clear 현재 fallback. 현재 이름 경로는 서로 다른 1/4명에서 JA 2 SQL, KO/EN 1 SQL, entityLoadCount 0 및 빈 입력 0 SQL. | +| T23 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt` | 9: profile/myPage/blocked/raw editor, 주문+Redis playlist, mixed buyer 상세, community post/firstComment/reply+shared raw default, donor, receipt 실제 projection, character writers, applicant memberId, donor neutral Redis cache hit/원본 변경+clear/과거 DTO·cache entry 불변. | +| T24 | `src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt` | 2 parameterized: parent CREATOR/commenter USER와 반대 조합. 실제 JPA/댓글 service를 호출하고 outbound event publisher만 capture하여 title owner≠snapshot sender, 원본 title 및 content title args/empty name slots 유지. | + +기존 직접 constructor fixture는 새 dependency를 명시적으로 추가했다. legacy JPA slice에는 `src/test/kotlin/kr/co/vividnext/sodalive/support/LegacyCreatorNameTestConfiguration.kt`를 import했다. 이 configuration은 renderer, foundation service, adapter를 제공한다. 기존 isolated unit fixture의 `originalLegacyNameRenderer()`는 실제 renderer와 이름 없는 dependency doubles를 사용하며 production default-null 호환성 경로가 아니다. + +소유 범위 밖의 v2/foundation/admin/partner/async JPA slice도 AudioContent custom repository를 스캔한다면 위 test configuration import가 필요하다. 해당 파일은 직접 수정하지 않았다. 직접 constructor가 있는 `v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt`와 `v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt`도 소유자가 `nameRenderer` 인자를 명시해야 한다. 이 항목은 부모에 전달했으며 해당 소유자 수정 여부는 부모의 최종 compile에서 확인한다. + +### 정확한 변경 소스 manifest + +아래 공통 prefix는 `src/main/kotlin/kr/co/vividnext/sodalive/`다. 기존 row의 query producer는 current/raw 데이터를 유지하고 실제 response assembly만 수정한 경우도 포함해 연결 경계를 표시했다. + +```text +v2/creatorname/application/LegacyCreatorNameRenderer.kt +v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt +search/SearchRepository.kt +search/SearchResponse.kt +search/SearchService.kt +member/MemberRepository.kt +member/MemberService.kt +explorer/ExplorerQueryRepository.kt +explorer/ExplorerService.kt +explorer/profile/creatorCommunity/CreatorCommunityService.kt +explorer/profile/creatorCommunity/CreatorCommunityController.kt +explorer/profile/channelDonation/ChannelDonationService.kt +api/home/HomeService.kt +rank/RankingService.kt +content/AudioContentRepository.kt +content/AudioContentService.kt +content/AudioContentController.kt +content/GetAudioContentDetailResponse.kt +content/series/ContentSeriesService.kt +content/comment/AudioContentCommentService.kt +content/comment/AudioContentCommentController.kt +content/order/OrderRepository.kt +content/order/OrderService.kt +content/playlist/AudioContentPlaylist.kt +content/playlist/AudioContentPlaylistService.kt +content/main/AudioContentMainService.kt +content/main/AudioContentMainController.kt +content/main/curation/AudioContentCurationService.kt +content/main/tab/content/ContentMainTabTagCurationService.kt +content/main/tab/content/AudioContentMainTabContentService.kt +content/main/tab/free/AudioContentMainTabFreeService.kt +content/main/tab/alarm/AudioContentMainTabAlarmService.kt +content/main/tab/asmr/AudioContentMainTabAsmrService.kt +content/main/tab/replay/AudioContentMainTabLiveReplayService.kt +content/main/tab/series/AudioContentMainTabSeriesService.kt +content/theme/AudioContentThemeService.kt +can/CanRepository.kt +can/CanService.kt +can/UseCanQueryDto.kt +chat/character/comment/CharacterCommentService.kt +audition/applicant/AuditionApplicantService.kt +``` + +변경 테스트 manifest의 공통 prefix는 `src/test/kotlin/kr/co/vividnext/sodalive/`다. 기존 JPA 파일의 변경은 필요한 test configuration import만이며 본래 테스트 로직을 정리하거나 완화하지 않았다. + +```text +support/LegacyCreatorNameTestConfiguration.kt +v2/creatorname/application/LegacyCreatorNameRendererTest.kt +v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt +content/comment/AudioContentCommentNameMetadataTest.kt +search/SearchRepositoryLocalizedNameTest.kt +search/SearchServiceTest.kt +explorer/ExplorerQueryRepositoryLocalizedNameTest.kt +explorer/ExplorerLocalizedNameEndToEndTest.kt +explorer/ExplorerServiceTest.kt +member/MemberRepositoryLocalizedNameTest.kt +member/MemberServiceContentPreferenceTest.kt +member/MemberServiceCacheEvictionTest.kt +content/AudioContentRepositoryLocalizedNameTest.kt +content/AudioContentServiceTest.kt +api/home/HomeServiceTest.kt +can/CanServiceTest.kt +explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt +explorer/profile/channelDonation/ChannelDonationServiceTest.kt +explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt +content/CreatorCommunityLanguageDetectTest.kt +content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt +content/main/banner/AudioContentBannerRepositoryTest.kt +event/EventRepositoryTest.kt +member/contentpreference/MemberContentPreferenceIntegrationTest.kt +osiv/OsivLazyLoadingRegressionTest.kt +``` + +### 부모 전용 GREEN 명령과 검증 한계 + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.search.SearchRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerQueryRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.member.MemberRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerLocalizedNameEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.LegacyCreatorNameRendererTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.LegacyCurrentNameReadIntegrationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.comment.AudioContentCommentNameMetadataTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.search.SearchServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.api.home.HomeServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanServiceTest' +./gradlew ktlintCheck +./gradlew assemble +``` + +- 현재 확인은 source caller/constructor/DTO/owner ID/Lang 및 cache producer/consumer 대조다. public response field 이름·타입과 Member/principal/original editor 불변을 소스에서 점검했다. +- 변경 코드의 길이/후행 공백과 신규 테스트의 placeholder/Disabled/reflection을 `rg`로 점검했다. lint 로그의 인자 줄바꿈 수정은 실제 ktlint 재실행 PASS를 뜻하지 않는다. +- kapt가 생성할 `QSearchNameRow`, `QContentBuyerRow`, `QPlaylistContentRow` 및 변경 QUseCanQueryDto constructor는 부모 compile/test가 검증한다. LSP 도구는 제공되지 않았다. +- Gradle, build, lint, 실제 HTTP/SDK/Redis QA는 이번 GREEN worker에서 실행하지 않았다. 앱 전체/외부 cache/editor/RTC/provider 및 운영 DB는 기존 blocker를 유지한다. + +## COR013: DB 직접 이름 JOIN 전환 + +### 근거와 검증 상태 + +- 기준은 PRD 10절과 JOIN-T1~JOIN-T3다. 위의 이전 JA 전용 batch 구현·검증 기록은 당시 이력이며 아래 분류가 현재 조회 경계다. +- 부모 제공 실제 RED 로그는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-join-consumer-red.log`다. `SearchRepositoryLocalizedNameTest.shouldSearchContentOwnerAliasWithMatchingCountAndExistingContentFilters`의 표시 값은 맞았지만 standalone alias SELECT가 0이 아닌 1이었다. 해당 SQL assertion은 그대로 유지했다. +- 공통 `CreatorNameJoin(owner, lang, alias)`의 member ID·요청 Lang·활성 이름·CREATOR 조건을 ON으로 연결한다. 목록 SQL이 COALESCE 이름을 반환하며 count와 후보 ID 쿼리에는 표시 JOIN을 추가하지 않는다. JOIN별 alias는 owner별로 분리했다. +- 이 worker는 Gradle, 테스트, build, ktlint, 실제 HTTP를 실행하지 않았다. LSP 도구도 제공되지 않았다. 아래는 소스·호출자·fixture 연결 및 정적 점검 결과이며 runtime GREEN 판정은 부모의 직렬 실행에 남긴다. + +### L01~L43 현재 분류 + +| Inventory | 현재 구현 / 실제 쿼리와 경계 | +|---|---| +| L01~L03 | `SearchRepository.searchCreatorList/searchContentList/searchSeriesList`가 요청 Lang JOIN으로 공개 `SearchResponseItem`을 직접 투영한다. creator title+nickname을 함께 선택하고 content/series title은 그대로다. `SearchNameRow`와 후속 alias batch를 제거했다. JA EXISTS 검색 및 count·필터·page는 유지한다. | +| L04~L06, L08 | `ExplorerQueryRepository.getSearchChannel`, `MemberQueryRepositoryImpl.findByNicknameAndOtherCondition/getMemberProfile`, `BlockMemberQueryRepositoryImpl.getBlockedMemberList`에서 표시 이름을 함께 선택한다. 검색 결과는 Member를 변이하지 않는 기존 `GetRoomDetailUser`로 반환한다. 앱 caller는 요청 Lang을 명시한다. | +| L07 | `MemberService.getMyPage`는 인증 principal의 raw Member를 그대로 사용하므로 DTO 경계 sparse batch를 유지한다. 원본 편집·login·nickname 식별은 raw다. | +| L09, L11~L13 | `ExplorerQueryRepository.getCreatorRankings/getNewCreators/findCreatorByGender/getSimilarCreatorList/getRandomCreatorList/getFollowerList/getCheersList`에 JOIN을 적용했다. creator DTO 및 cheers 부모·답글 이름은 쿼리에서 선택한다. cheers 답글은 페이지 부모 IDs의 별도 원래 데이터 조회에 같은 JOIN을 적용하며 단독 alias 조회를 하지 않는다. count·차단·부모/답글 노출 의미는 유지한다. | +| L10, L14 | profile/detail은 role·차단·노출 및 중첩 조립에 필요한 raw Member 경계, fanTalk 작성/수정은 저장 entity 반환 경계다. 기존 sparse batch로 HTTP DTO/차단 오류에만 표시한다. FCM 원본 args/title과 principal은 바꾸지 않는다. | +| L15~L18 | `RankingRepository.getCreatorRankings/getAudioContentRanking/getSeriesRanking/getCompleteSeriesRanking/getSeriesAllRankingByGenre/fetchCreatorBySellContentCountRankTop20/fetchCreatorContentBySalesCountTop4/fetchCreatorBySeriesRevenueRankTop20/fetchCreatorSeriesBySales/fetchFreeContentByCreatorIdTop4/fetchCreatorContentByLikeCountTop4`에서 이름을 선택한다. creator는 기존 DTO, series는 원본 entity+SQL 표시 이름 tuple로 조립하고 RankingService/HomeService의 이름 전용 batch를 제거했다. 집계·요일·후속 content count/new 판정은 그대로다. | +| L19~L20 | 추천 채널·donation ranking의 cache producer, DTO, key, TTL은 raw·언어 중립으로 유지한다. `HomeService.fetchData`, `ExplorerService.renderDonationRanking`이 cache hit 뒤 공통 단일 JOIN current-name batch를 호출한다. cached 원본을 현재 fallback으로 간주하지 않는다. | +| L21 | `ExplorerQueryRepository.getMemberDonationRanking`도 명시 Lang이 있으면 donor JOIN을 선택하도록 전환했다. 실제 앱 caller는 여전히 L20 cached service다. null Lang의 기존 raw 모드를 보존하고 새 caller는 만들지 않았다. | +| L22~L23 | Audio 상세는 권한·구매·번역에 필요한 원본 audio/member 경계의 creator sparse batch만 유지한다. buyer와 nested comment는 각 SQL에서 이름을 선택한다. `AudioContentQueryRepositoryImpl.getLatestContentByTheme`의 locale 경로는 같은 title tuple SQL에 JOIN을 추가했고 repository renderer 의존성을 제거했다. locale=null은 기존 raw query 모드다. | +| L24~L27 | `findByTheme/findAlarmContentByTheme/findByThemeFor2Weeks/findAudioContentByCurationId/findAudioContentByCurationIdV2/findContentByCurationId`, `AudioContentCurationQueryRepository.findByCurationId`, `ContentMainTabTagCurationRepository.getTagCurationContentList`가 명시 Lang JOIN을 지원한다. 앱 main/theme/curation/tag caller는 Lang을 전달하고 후속 이름 copy/batch를 제거했다. 중립 cache를 만드는 `findAudioContentByCurationId`의 null Lang과 `getAudioContentRanking` raw producer는 유지한다. | +| L25 | `getNewContentUploadCreatorList`는 이름 포함 중립 cache producer이므로 raw다. `AudioContentMainController`의 cache-hit 이후 current-name batch를 유지한다. | +| L28~L30 | `AudioContentMainTabRepository.findCreatorByThemeContent/findCreatorWithHasFreeContent`, `RecommendSeriesRepository.getNewSeriesList/getRecommendSeriesList`가 JOIN으로 반환한다. alarm/content/free/asmr/replay/series의 모든 직접 content/curation/creator/series caller에 Lang을 전달했고 탭 전체 응답을 다시 복사하던 alias batch를 제거했다. home 탭은 변경된 RankingService/ContentSeriesService를 그대로 재사용한다. | +| L31~L32 | `ContentSeriesQueryRepositoryImpl.getSeriesListV2/getSeriesByGenreListV2/getRecommendSeriesListV2/getOriginalAudioDramaList/findByCurationIdV2/getDayOfWeekSeriesListV2`의 기존 locale tuple SQL에 JOIN을 추가했다. 목록 service의 renderSeries를 제거했다. 상세는 접근/번역/가격/요일에 필요한 raw Series entity 경계의 sparse batch를 유지한다. writer/studio/title과 공용 entity는 그대로다. | +| L33 | `AudioContentCommentQueryRepositoryImpl.findByContentId/getAudioContentCommentReplyList`가 writer 이름을 선택한다. 앱 controller 및 Audio 상세는 요청 Lang, 기존 shared ADMIN 호출은 `lang=null`의 명시 raw 모드다. KO를 raw의 대용으로 사용하지 않는다. secret/block/count/page와 원본 deleted-prefix fallback은 보존한다. | +| L34~L36 | `OrderQueryRepositoryImpl.getAudioContentOrderList/getAudioContentMainOrderList/findBuyerListByContentId`, `AudioContentQueryRepositoryImpl.fetchContentForPlaylist`에서 이름을 선택한다. 공개 buyer/playlist DTO로 직접 투영해 `ContentBuyerRow`/`PlaylistContentRow`와 이름 전용 owner IDs·copy/batch를 제거했다. Redis playlist metadata, 순서, KEEP/RENTAL/expiry 조건은 그대로다. | +| L37~L39 | `CreatorCommunityQueryRepositoryImpl.getCommunityPostList/getCommunityPost/getLatestPostListFromCreatorsYouFollow`, `CreatorCommunityCommentQueryRepositoryImpl.findByPostId/getCommunityCommentReplyList`가 JOIN 이름을 선택한다. firstComment에도 요청 Lang을 전달한다. 목록/detail의 renderPosts를 제거했고 구매 쓰기 반환의 creator sparse batch만 유지한다. shared ADMIN 댓글 기본값은 null raw, 앱만 요청 Lang이다. 본문 번역·유료 마스크·secret/filter는 그대로다. | +| L40 | `ChannelDonationMessageQueryRepositoryImpl.getChannelDonationMessageList`가 donor entity+SQL 표시 이름 tuple을 반환한다. service의 donor alias batch를 제거했다. 원본 이름의 deleted-prefix fallback, secret/default message·날짜·정렬·count는 보존한다. | +| L41 | `CanQueryRepositoryImpl.getCanUseStatus`의 room/audio/community/recipient owner 4개에 각각 고유 JOIN을 연결했다. CanService는 받은 이름으로 기존 title 우선순위를 적용한다. 후속 이름 map, copy와 `UseCanQueryDto`의 이름 batch 전용 owner IDs를 제거했다. 금액·결제/refund/distinct/page 및 resource 제목은 그대로다. | +| L42 | `CharacterCommentQueryRepositoryImpl.findDisplayComments/findDisplayReplies/findDisplayComment`가 writer 이름을 같은 SQL에서 선택한다. list/latest 및 replies의 original도 이 query를 사용하며 service의 alias batch를 제거했다. 원본 entity를 변경하지 않고 기존 comment/reply DTO로 조립한다. cursor·active·정렬·reply count와 캐릭터 자체 이름은 그대로다. | +| L43 | `AuditionApplicantQueryRepositoryImpl.getAuditionApplicantList`가 applicant member JOIN 이름을 투영한다. service에 Lang을 명시하고 alias batch를 제거했다. vote 집계·role·활성·page 조건은 유지한다. | + +### 남은 이름 batch 호출: 정확히 15곳 + +아래는 non-v2 담당 소스에서 `nameRenderer.findCurrentNames/findLocalizedNames/render`를 검색한 결과다. 직접 DB 목록을 이미 조회했다는 이유로 남긴 batch는 없다. + +| 분류 | 실제 호출 위치 | 이유 | +|---|---|---| +| NEUTRAL-CACHE, current 5곳 | `api/home/HomeService.fetchData`; `explorer/ExplorerService.renderDonationRanking`; `content/main/AudioContentMainController.getNewContentUploadCreatorList/getAudioContentCurationListWithPaging`; `content/AudioContentController.getAudioContentRanking` | cache hit 이후 현재 원본+요청 Lang 별칭을 공통 단일 JOIN batch로 읽고 새 응답을 만든다. 원본 cache payload는 유지한다. | +| PRINCIPAL, sparse 1곳 | `member/MemberService.getMyPage` | 인증 principal을 변이하지 않고 표시 DTO만 렌더한다. | +| RAW ENTITY DETAIL, sparse 4곳 | `explorer/ExplorerService.getCreatorDetail/getCreatorProfile`; `content/AudioContentService.getDetail`; `content/series/ContentSeriesService.getSeriesDetail` | role/접근/구매/가격/번역 등 기존 비즈니스 조립에 필요한 raw entity의 이름을 덮어쓰지 않는다. | +| WRITE / ERROR, sparse 5곳 | `explorer/ExplorerService.writeCheers`의 차단 오류, `renderFanTalk`의 작성/수정 반환; `content/comment/AudioContentCommentService.registerComment`의 차단 오류; `explorer/profile/creatorCommunity/CreatorCommunityService.purchasePost`의 차단 오류 및 구매 반환 | 저장·검증에 사용한 raw entity와 기존 event args/title을 유지하고 HTTP 반환/오류만 렌더한다. | + +별칭 표시가 없는 원본 인증/editor/admin/partner 및 역사 저장 이름은 기존 계약을 유지한다. FCM metadata 변경 이력은 위 절 그대로이며 COR013에서 해당 event title/args를 다시 수정하지 않았다. + +### 이번 source manifest + +공통 prefix는 `src/main/kotlin/kr/co/vividnext/sodalive/`다. 공유 core/helper/test support, v2, live/message/fcm, chat room repository 및 부모 소유 PRD/plan은 수정하지 않았다. + +```text +search/SearchRepository.kt +search/SearchResponse.kt +member/MemberRepository.kt +member/MemberService.kt +member/block/BlockMemberRepository.kt +explorer/ExplorerQueryRepository.kt +explorer/ExplorerService.kt +explorer/profile/creatorCommunity/CreatorCommunityRepository.kt +explorer/profile/creatorCommunity/CreatorCommunityService.kt +explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt +explorer/profile/channelDonation/ChannelDonationMessageRepository.kt +explorer/profile/channelDonation/ChannelDonationService.kt +api/home/HomeService.kt +rank/RankingRepository.kt +rank/RankingService.kt +content/AudioContentRepository.kt +content/AudioContentService.kt +content/GetAudioContentDetailResponse.kt +content/comment/AudioContentCommentRepository.kt +content/comment/AudioContentCommentService.kt +content/series/ContentSeriesRepository.kt +content/series/ContentSeriesService.kt +content/order/OrderRepository.kt +content/order/OrderService.kt +content/playlist/AudioContentPlaylist.kt +content/playlist/AudioContentPlaylistService.kt +content/main/AudioContentMainService.kt +content/main/curation/AudioContentCurationQueryRepository.kt +content/main/curation/AudioContentCurationService.kt +content/main/tab/AudioContentMainTabRepository.kt +content/main/tab/RecommendSeriesRepository.kt +content/main/tab/content/ContentMainTabTagCurationRepository.kt +content/main/tab/content/ContentMainTabTagCurationService.kt +content/main/tab/content/AudioContentMainTabContentService.kt +content/main/tab/free/AudioContentMainTabFreeService.kt +content/main/tab/alarm/AudioContentMainTabAlarmService.kt +content/main/tab/asmr/AudioContentMainTabAsmrService.kt +content/main/tab/replay/AudioContentMainTabLiveReplayService.kt +content/main/tab/series/AudioContentMainTabSeriesService.kt +content/theme/AudioContentThemeService.kt +can/CanRepository.kt +can/CanService.kt +can/UseCanQueryDto.kt +chat/character/comment/CharacterCommentRepository.kt +chat/character/comment/CharacterCommentService.kt +audition/applicant/AuditionApplicantRepository.kt +audition/applicant/AuditionApplicantService.kt +``` + +### 이번 test caller manifest와 부모 직렬 검증 + +공통 prefix는 `src/test/kotlin/kr/co/vividnext/sodalive/`다. + +```text +search/SearchRepositoryLocalizedNameTest.kt +can/CanServiceTest.kt +api/home/HomeServiceTest.kt +osiv/OsivLazyLoadingRegressionTest.kt +explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt +explorer/profile/channelDonation/ChannelDonationServiceTest.kt +explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +``` + +- Search 직접 constructor의 renderer 인자를 제거했다. 기존 standalone alias SELECT=0 assertion은 완화하지 않았고 같은 콘텐츠 사례에 EN 표시 이름 선택 및 EN 이름으로 검색하지 않는 JA 검색 계약 assertion을 추가했다. +- Can fixture의 제거된 내부 owner ID 인자와 renderer constructor 인자를 제거했다. Home/Community stub은 앱이 전달하는 실제 Lang을 명시했다. donation fixture는 entity+이름 tuple과 Lang matcher로 적응했다. 기존 name/filter/secret/USER/count/page 기대값은 유지했다. +- donation repository의 기존 fetch-member 사례는 `deleted_viewer3` 원본 entity가 그대로이고 SQL 표시 이름만 `viewer3`임을 함께 확인하도록 보강했다. OSIV 사례는 repository가 반환하는 기존 explorer DTO의 tags를 검증하며 세션 밖 사용 경계는 유지한다. +- `AudioContentQueryRepositoryImpl`은 더 이상 renderer 주입을 요구하지 않는다. 기존 JPA test configuration import는 다른 service/core fixture까지 함께 제공하는 경우가 있어 일괄 삭제하지 않았다. 다른 owner 소스·테스트를 수정해야 하는 확인된 컴파일 변경은 없다. 전체 compile과 v2 ADMIN의 null raw 호출 회귀는 부모가 확인한다. +- `rg`로 전환 query/caller, 제거된 내부 row/ID 참조, 15개 batch 경계, 추가 import 정렬·사용 및 130자/후행 공백을 대조했다. Kotlin compile/LSP 또는 runtime 검증을 대신하지 않는다. +- 추가 정적 delimiter 검사 대상 74개 repository/service 파일에서 괄호 불일치 0건, 담당 Kotlin 소스·변경 테스트의 130자 초과/후행 공백 검색 0건이었다. 삭제한 `SearchNameRow/ContentBuyerRow/PlaylistContentRow` 및 이름 batch 전용 Can owner ID의 Kotlin 참조도 0건이다. + +아래는 **이 worker에서 실행하지 않은** 부모 직렬 검증 명령이다. 모든 parallel writer가 종료된 뒤 실행한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.search.SearchRepositoryLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.explorer.ExplorerQueryRepositoryLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.member.MemberRepositoryLocalizedNameTest' --offline --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerLocalizedNameEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.LegacyCurrentNameReadIntegrationTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.*' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.can.CanServiceTest' --tests 'kr.co.vividnext.sodalive.api.home.HomeServiceTest' --tests 'kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest' --offline --console=plain +./gradlew test build ktlintCheck --continue --offline --console=plain +``` diff --git a/docs/20261014_크리에이터이름언어별관리/plan-task.md b/docs/20261014_크리에이터이름언어별관리/plan-task.md new file mode 100644 index 00000000..6d84f972 --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/plan-task.md @@ -0,0 +1,1047 @@ +# 크리에이터 이름 언어별 관리 구현 계획 + +> **실행 전제:** 2026-10-14 사용자 문서·DDL 확인 및 구현 승인(COR012). 제품 코드·테스트 구현을 진행한다. 기존 승인 대기 문구는 이 승인 이전 이력이며 운영 DB 적용·배포와 외부 클라이언트 완료는 별도다. + +| 문서 항목 | 내용 | +|---|---| +| 상태 | COR022 전체롤백·실제HTTP/이벤트·전체회귀·lint·리뷰 완료 | +| 작성일 | 2026-10-14 | +| 요구사항 기준 | `docs/20261014_크리에이터이름언어별관리/prd.md` | +| API 기준 | COR014 관리자 목록 확장·GET 제거 및 COR015 제외 경계. COR016 전체 회귀에 기존 HTTP 테스트 포함 | +| 현재 Phase | COR022 완료 | +| 현재 활성 Goal | 없음. `MESSAGE-NAME-TRIM-T1` 완료 | +| 다음 Goal | 없음. 후속 변경은 별도 승인 후 진행 | +| 구현 목표 | 승인된 서버의 저장·관리·조회·검색·수신자별 이름 처리와 검증. 외부 클라이언트 미확인은 blocker 유지 | + +## 목표 + +승인 후 운영자는 CREATOR의 일본어 이름을 수동 관리하고, 앱은 모든 크리에이터 표시 경로에서 요청 언어의 이름을 제공한다. 사용자 원본 닉네임과 과거 전달물은 유지한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1. 조사와 기반 | 내부 구현·검증 완료, Gate 통과 | 3/3 | 완료 | 운영 DB 적용/배포는 별도 승인 | +| 2. HTTP와 검색 | 내부 연결·검증 완료, Gate 통과 | 2/2 | 완료 | inventory는 내부 서버 소비자 범위이며 전체 client 소비자는 미확인 | +| 3. 전달과 외부 연동 | 내부 서버 구현·검증 완료, 외부 연동 차단 | 1/2 | `P3-T2` 미완료 | EXT001~003과 provider/운영 DB 용량 증거 없음, `P3-GATE` 미완료 | +| 4. 최종 Gate | 내부 추적·리뷰 보완 완료, 외부 Gate 차단 | 2/2 | `P4-GATE` 외부 대기 | EXT001~003 및 운영 환경 검증만 남음 | + +- 계획/DDL 파일 상태를 제품 구현 상태로 오인하지 않는다. 현재 실행 Goal은 상단 상태를 따른다. +- 별도 승인 뒤에도 미완료 Goal 하나만 활성화한다. +- 완료된 내부 단계의 checkbox는 실행 증거에 맞춰 표시한다. 외부 의존성이나 후속 reviewfix가 남은 Task/Gate는 완료 표시하지 않는다. + +## 문서·DDL 후속 Goal + +**Goal `DOCDDL`:** 기존 수동 언어관리 패턴으로 두 문서의 저장/해제 의미를 맞추고 `schema.sql` 초안을 작성한다. +- **시작 조건:** 사용자가 `creator_localized_name` 테이블명을 확정하고 문서·DDL 파일 작성만 승인했다. 사용자의 물리 DELETE 질문은 삭제 방식 지정이 아니다. +- **완료 증거:** 아래 세 파일의 정합성 및 MySQL DDL 정적 대조. 파일 작성 완료와 DB 미적용을 분리한다. +- **Files:** `docs/20261014_크리에이터이름언어별관리/prd.md`, `docs/20261014_크리에이터이름언어별관리/plan-task.md`, `docs/20261014_크리에이터이름언어별관리/schema.sql`만 변경/생성. +- **범위 밖:** DB 연결/SQL 실행, Kotlin·테스트·설정·빌드 변경, 제품 테스트/빌드, 배포, 다른 DDL, 이력 복원 UI. +- **TDD 예외 사유:** 문서와 미적용 DDL 산출물만 작성하므로 Kotlin 테스트를 만들거나 실행하지 않는다. +- **대체 검증:** 직접 소스와 문서를 읽고 `rg`로 저장 키/활성 조건/승인 경계를 대조한다. 기대 결과는 DB `JA`와 HTTP `ja` 구분, 비활성 제외, 재활성화 동일 행, 제품 checkbox 미완료다. +- [x] 두 문서의 현재 설계와 승인 경계를 보정하고 기존 DEC/COR/Progress를 보존한다. +- [x] `schema.sql` 파일을 작성한다. DB 미적용이며 `P1-T2` 완료가 아니다. + +**독립 Gate `DOCDDL-GATE`:** 산출물 작성 후 상위 검토자가 세 파일의 저장 계약과 승인 범위를 판정한다. 제품 실행과 DB 적용은 Gate 범위 밖이다. +- [x] 독립 문서·DDL 정합성 정적 검토 PASS 및 판정 증거 기록. +- [x] `./gradlew tasks --all` 명령 유효성 확인 및 결과 기록. 제품 테스트/빌드는 아님. + +## 범위 + +### 포함 + +- 수동 JA 별칭 저장/변경/해제. JA 별칭 우선 표시, 원본 fallback, 이름 변경 후 별칭 보존. +- COR015의 ContentMainTab/AudioContentCuration/레거시 커뮤니티·팬톡을 제외한 CREATOR 표시 표면, 레거시와 v2 HTTP, 혼합 작성자 목록, 검색, 푸시, live, WebSocket chat. +- 일본어 별칭과 기존 원본 닉네임을 함께 검색하며 기존 필터와 결과 개수를 보존. +- 언어별 역사 스냅샷 불변. 현재 조회는 요청 언어, 새 수신자별 payload는 수신자/세션 언어. +- 외부 운영 UI, 모바일 앱/client cache, RTC/live locale 연동은 외부 blocker 작업으로 추적하며 서버 완료로 간주하지 않음. + +### 제외 + +- KO/EN 이름 관리, 자동 번역/음역, 일반 USER 이름 변경. +- 원본 `Member.nickname`, 원본 이름 편집 흐름, 인증/식별값 변경. +- 기존 역사 스냅샷 및 발송/전달 완료 payload의 일괄 수정. +- 기존 role-edit PUT 변경, 전역 JSON serialization 변환, request `LangContext`를 비동기 작업에서 사용. +- COR012 승인 범위의 production 코드·테스트는 수행한다. 운영 DB 적용과 배포는 별도 승인 대상이다. + +## 기술적 제약과 제안 상태 + +- 확정 제품 요구는 PRD NAME001~007이다. 외부 작업은 PRD EXT001~003이다. +- 신규 재사용 도메인은 `kr.co.vividnext.sodalive.v2.creatorname` 제안. 구현 시 `application`, `domain`, `port`, `adapter`, `dto` 배치 원칙을 따른다. +- 저장 기술 결정: `(member_id, lang)` 고유(활성/비활성 전체, `is_active` 제외), displayName 중복 허용 및 `VARCHAR(255)`; entity는 `displayName @Column(nullable = false, length = 255)`. 255 Unicode code point 초과는 관리 경계에서 저장 전에 거부하고 자르지 않으며 Unicode 문자 제한은 없다. whitespace-only clear는 기존 승인 대기 제안이며, 그 외 문자는 그대로 저장. +- 테이블명 `creator_localized_name`은 사용자 확정. `lang: Lang`에 `@Enumerated(EnumType.STRING)`, `@Column(nullable = false, length = 10)`을 사용해 DB `lang VARCHAR(10)`에는 enum NAME `KO/EN/JA`를 저장한다. 이번 쓰기는 명시 `Lang.JA`만 허용하며 KO 기본값은 없다. 자유 문자열/새 enum/언어 converter는 만들지 않는다. 미래 언어는 기존 `Lang`과 요청 처리를 별도 확장하며 언어별 컬럼은 추가하지 않는다. +- clear는 JA 행의 `is_active=false`만 설정하고 `display_name`을 보존한다. 미등록 clear는 no-op. 비공백 저장은 비활성까지 member+lang으로 조회해 같은 ID의 값을 갱신하고 활성화한다. 마지막 비활성 값 하나만 유지하며 전체 개정 이력/복원 UI는 아니다. 원본 닉네임·다른 언어 행·과거 전달 snapshot은 불변이다. +- 당시 P1 계약은 JA 활성 CREATOR batch였다. 현재 조회는 모든 기존 `Lang`에서 활성 CREATOR 이름을 적용하고 비활성/미등록은 원본으로 fallback한다. 이는 COR013 JOIN-T1/T2에서 전환됐으며 관리 쓰기는 여전히 JA만이다. 기존 패턴 비교와 저장 계약 근거는 PRD 4절을 따른다. +- DDL 초안 위치는 `docs/20261014_크리에이터이름언어별관리/schema.sql`이며 파일 작성만 승인됐다. MySQL COMMENT/TIMESTAMP 규칙 적용, DB 미적용. signed BIGINT/FK는 기존 member_content_preference DDL 근거이며 실제 `member.id` 타입·engine 및 collation/contains·저장 한계는 운영 반영 전 확인하고 DB 실행은 별도 승인받는다. +- 승인 관리자 API: `GET/PUT /api/v2/admin/creators/{creatorId}/localized-names/ja`. COR012 승인 및 실제 JWT HTTP 테스트 확인. ADMIN 인증, `Member.role == CREATOR`만 대상이며 해당 role의 `Member.memberKind == AI_CHARACTER`도 포함한다. `memberKind == AI_CHARACTER`만으로 `role == USER`를 허용하지 않는다. +- 제안 GET body: `creatorId`, `locale: ja`(`Lang.code`), `originalNickname`, `displayName: nullable`. 미등록/비활성은 모두 null이며 남은 비활성 이름을 노출하지 않는다. 제안 PUT 필수 `displayName: String`, 빈 값 soft clear. HTTP locale은 기존 `Lang.fromCode`/`Lang.fromAcceptLanguage`로 경계에서 매핑하고 내부에는 `Lang`을 전달한다. 기존 `ApiResponse.ok/error`, `SodaException` 패턴 적용. +- 기존 `/admin/member` ADMIN 보호는 권한 패턴 참고일 뿐 신규 creator-admin UI/API 위치 확정이 아니다. `/creator-admin/member`의 CREATOR/AGENT 경로는 변경하지 않는다. +- 기존 batch service는 명시 `Lang`과 sparse CREATOR 별칭 map을 반환한다. COR013에서는 DB current-name 조회에 `CreatorNameJoin`과 단일 JOIN+COALESCE batch를 추가했다. 행별 쿼리, Jackson 전역 변환, principal 수정 금지. +- HTTP 경계에서만 `LangContext`를 읽는다. DTO field 이름/타입은 유지하고 필요한 projection에 내부 memberId/role을 최소 추가한다. +- 검색은 요청 locale과 무관하게 기존 nickname 조건 OR JA alias `EXISTS`다. 별칭 조건은 owner `member_id` 상관 + `lang=Lang.JA`(DB `JA`) + `is_active=true` + owner `Member.role=CREATOR`이며 list/count에 동일 적용한다. Explorer channel, SearchRepository creator branch, MemberQueryRepositoryImpl을 조사하고 기존 title/detail/tag/visibility/block/requester 및 USER 검색을 보존한다. +- 푸시 언어는 기존 `PushTokenInfo.languageCode` 그룹을 사용한다. 발신자 ID와 이름이 들어간 args 위치를 확인하고 그룹마다 args/title/body 및 `senderNicknameSnapshot`을 새로 구성한다. +- WebSocket handshake `Accept-Language`는 세션 attribute로 저장하고 기본 KO. locale 변경 때 reconnect는 클라이언트 계약 제안이다. Redis는 원본 메시지/발신 ID를 전달하고 broker가 세션별 payload를 새로 만든다. +- 같은 회원의 KO/JA 세션은 서로 다른 메시지를 받아야 한다. 공유 DTO mutation, 정규식 치환, 비동기 `LangContext` 사용은 금지한다. SEND_ACK와 HTTP chat response도 별도 locale 검증한다. +- 캐시와 materialization은 producer-write/consumer-read 근거로 분류한다. 기존 `LiveRecommendCacheService`는 3시간 캐시 키에 `Lang` 포함. 이는 다른 캐시/스냅샷의 자동 면제를 뜻하지 않는다. +- `PushNotificationList.senderNicknameSnapshot`의 기존 JPA/DB column mapping과 FCM provider limits는 read-only compatibility preflight로 확인하며, 변경은 이번 범위에 포함하지 않는다. 과거 notification row rewrite와 값 truncate는 금지. 외부 payload 한계는 기존 compatibility preflight에서 담당자와 확인한다. +- 테스트는 JUnit 5. 서비스/컨트롤러 통합 테스트는 `@SpringBootTest`, Redis 필요 시에만 `EmbeddedRedisInitializer` opt-in 한다. +- 외부 경로 미확인 상태에서 경로를 만들지 않는다. 소유자, 시스템/저장소, 계약, 완료 증거를 얻기 전 `[blocked]` 유지. +- `./gradlew tasks --all`은 계획 작성 중 실행해 성공했다. 모든 아래 product test/build/lint는 미래 명령이며 현재 실행하지 않았다. + +## 전역 검토 초점 + +1. 별칭 입력의 원문 보존, 빈/승인된 공백-only clear의 행·값 보존/비활성화, 동일 ID 재활성화 여부. P1-T2 test. +2. 중복 이름이 허용되고 별칭 검색이 count/list에서 중복 행 없이 같은 결과인지. P2-T2 test. +3. 혼합 CREATOR/USER 목록에서 USER 원본 값과 기존 DTO 계약을 보존하는지. P2-T1 test. +4. 원본 닉네임 변경 후 JA 별칭이 유지되고 과거 스냅샷은 재작성되지 않는지. P1-T2 및 P3-T1 test. +5. 한 회원의 동시 KO/JA 세션과 서로 다른 푸시 언어가 각각 독립 payload를 받는지. P3-T1 test, EXT003 연동 검증. + +## 파일 책임 지도 + +모든 경로는 저장소 루트 기준이다. 표 안에서 full path 뒤의 파일명만 적힌 경우 같은 행에서 가장 가까운 full path의 directory를 상속한다. 기존 경로는 현재 존재 여부를 검색/확인했다. `제안 생성`은 승인 후 새 경로이며 현재 존재하지 않는다. 이 seed inventory는 감사 종료 목록이 아니며 preflight에서 surface를 더 찾으면 계획을 먼저 갱신한다. + +### 기존 소스, 조사/수정 후보 + +| 책임 | 확인된 파일 | +|---|---| +| 회원 원본/검색과 프로필 응답 | `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`; `MemberController.kt`; `ProfileResponse.kt`; `GetMemberProfileResponse.kt`; `MemberRepository.kt`의 `MemberQueryRepositoryImpl.findByNicknameAndOtherCondition` | +| 기존 ADMIN 근거 | `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt`; `AdminMemberService.kt` | +| 검색 | `src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt`; `SearchController.kt`; `SearchService.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt` | +| 레거시 탐색 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt` (`getSearchChannel`); `ExplorerService.kt` | +| 레거시 홈/랭킹/추천 | `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt`; `RankingRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt`; `RecommendChannelQueryRepository.kt` | +| 콘텐츠/라이브 | `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt` | +| v2 홈/팔로잉 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`; `HomeFollowingNewsInbox.kt` | +| v2 랭킹/기프트 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt` | +| v2 content query adapters | `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt` | +| v2 creator channel adapters | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt` | +| 푸시 | `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt`; `PushNotificationList.kt` | +| v2 user-creator chat | `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt`; `UserCreatorChatWebSocketHandler.kt`; `UserCreatorChatWebSocketSessionRegistry.kt`; `UserCreatorChatRoomMessageBroker.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt` | + +### 신규 제안 소스, 승인 후 생성 가능 + +Root: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/`. + +- Create: `domain/CreatorLocalizedName.kt`, `port/out/CreatorLocalizedNameRepository.kt`, `application/CreatorLocalizedNameService.kt`. +- Create: `adapter/out/persistence/CreatorLocalizedNameEntity.kt`, `adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt`. +- Create: `adapter/in/web/AdminCreatorLocalizedNameController.kt`, `dto/CreatorLocalizedNameRequest.kt`, `dto/CreatorLocalizedNameResponse.kt`. +- DDL 초안: `docs/20261014_크리에이터이름언어별관리/schema.sql`. 자동 migration이 아니며 `src/main/resources/db/migration` 배치/실행은 승인하지 않았다. + +### 기존 테스트 근거와 future test target + +| 목적 | 기존 테스트 파일, 존재 확인 | +|---|---| +| 레거시 탐색 이름 조립 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt` | +| 검색 서비스 | `src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt` | +| 푸시 전달 | `src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt`; `FcmSendListener` 전용 test 유무는 preflight에서 재검색 | +| chat service와 websocket | `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`; 같은 `websocket/`의 `UserCreatorChatWebSocketHandlerTest.kt`, `UserCreatorChatWebSocketAuthInterceptorTest.kt`, `UserCreatorChatRoomMessageBrokerTest.kt`, `UserCreatorChatRedisIntegrationTest.kt` | +| legacy home | `src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt` | +| v2 home/creator channel/gift | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt` | +| v2 content/creator channel end-to-end | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` | +| 제안 도메인/관리 API | 제안 생성: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt`; 같은 `creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt` | +| 검색 repository alias | 기존 repository test 없음. 제안 생성 `src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt`와 `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt` | +| 푸시 event listener | 기존 FcmSendListener 전용 test 없음. 제안 생성 `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt`로 실제 language grouping/render를 검증 | +| push persistence capacity | `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationList.kt` 기존 mapping 호환성 확인. 제안 `src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationListPersistenceTest.kt`로 255 Unicode code point의 저장·조회 및 과거 행 불변 검증 | +| member repository search | 제안 생성 `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt`로 기존 requester/role filters와 USER 결과를 검증 | +| 별칭 저장 persistence | 제안 생성 `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt`로 member+lang unique, soft clear의 행/값 보존, 동일 ID 재활성화, 반복 주기, 중복 표시명/원본 불변을 검증 | + +테스트 경로 표는 저장소 루트 기준이며 동일 표 행에서만 명시한 파일명 prefix를 상속한다. 신규 test는 위 제안 경로와 같은 package/class 명을 쓴다. 모든 planned execution command에는 아래처럼 구체 FQCN을 적는다. P1 audit가 추가 surface를 찾으면 RED 시작 전에 정확한 test path와 command를 계획에 추가한다. + +### 외부 경로, 아직 특정 불가 + +| 의존 | 파일/시스템 경로 | 담당 역할 | 차단 해제 증거 | +|---|---|---|---| +| 운영자 UI | `[blocked: owner/repository/path 미확인]` | 운영 도구/관리 UI 소유자 | 저장소/경로, 인증과 배포 흐름, API 소비 test/통합 증거 | +| 모바일 표면과 raw nickname editor | `[blocked: app repository/path 미확인]` | 모바일/클라이언트 소유자 | 모든 표시 surface 및 원본 편집 흐름 목록, 편집기에 원본만 저장하는 test, locale 계약 | +| locale/cache/RTC | `[blocked: client/RTC repository/path 미확인]` | 클라이언트 및 메시징 담당자 | locale propagation, cache invalidation, reconnect 계약, KO/JA 동시 세션 end-to-end 결과 | + +## Phase 1: 사전 조사, 저장 기반과 관리 API + +**Phase 결과:** 기존 모든 내부 표시 경로·snapshot 유형의 근거표가 만들어지고 승인된 경우에만 별칭 저장/관리 API가 작동한다. + +**선행조건:** 사용자 별도 구현 승인. 외부 blocker는 담당자/경로/계약이 확인되기 전 전체 제품 Goal을 완료 처리하지 않는다. + +**Phase 완료 조건:** `P1-T1`~`P1-T3`와 `P1-GATE`의 완료 증거, 내부 스토리지/API 검증, 외부 범위 차단 현황 기록. + +### Task 1.1 표시 경로 및 데이터 유형 감사 + +**Goal 실행 `P1-T1`:** 앱 크리에이터 이름의 모든 내부 producer/read path를 조사해 수정 대상과 스냅샷 정책을 근거와 함께 고정한다. + +- **시작 조건:** 구현 승인이 기록되고 PRD NAME001~007/EXT001~003 검토 완료. +- **완료 증거:** 발견 표면과 test owner inventory, current lookup/snapshot/neutral cache/materialization 분류, 검색 누락 방지. 아래 파일 지도는 검증된 seed일 뿐 전수 완료 목록이나 감사 종료 조건이 아니다. +- **범위 밖:** 코드/테스트/설정/DDL 수정, 외부 저장소 파일 추정, 미분류 표면을 완료 처리. +- **TDD 예외 사유:** read-only 조사 task라 production 동작 test를 만들지 않는다. +- **대체 검증 방법:** `rg` 결과와 각 호출/producer/consumer 파일을 열어 evidence table 작성, 중복과 미분류를 서로 대조한다. + +**Files:** 앞의 source/test seed inventory와 전체 `src/main/kotlin/kr/co/vividnext/sodalive` 추가 검색 결과. 반드시 `api/home/HomeService`, `rank/Ranking*`, `query/recommend/RecommendChannelQuery*`, `member/MemberRepository.findByNicknameAndOtherCondition`, 모든 v2 content/channel query adapter, `PushNotificationList.senderNicknameSnapshot`의 JPA/DB·downstream payload 한계까지 producer/read/write 근거를 확인한다. 외부 경로는 `[blocked]`. + +- [x] 모든 내부 nickname/projection surface와 cache/materialization/snapshot을 조사해 세 inventory에 근거를 기록했다. 이는 저장소 내 client 전체 consumer를 확인했다는 뜻은 아니다. +- [x] `MemberQueryRepositoryImpl.findByNicknameAndOtherCondition`의 requester 조건과 USER/Admin/Agent/Bot/self 경계를 기록하고 CREATOR alias만 추가했다. +- [x] 발견한 owner memberId 누락 projection을 보완해 parent/resource ID를 alias key로 사용하지 않도록 연결했다. +- [x] inbox/ranking snapshot 및 중립 cache의 producer/read 소비를 구분하고 역사 데이터를 재작성하지 않았다. +- [x] push snapshot의 기존 mapping과 H2 저장 검증을 확인했다. 실제 MySQL/provider 용량은 별도 미검증으로 남겼다. +- [x] 모바일/운영 UI/RTC 경로는 저장소와 담당자가 미확인임을 기록하고 EXT001~003 `[blocked]`로 유지했다. +- [x] 현재 발견 범위는 관련 task와 blocker에 연결했다. 전 저장소 외부 표면 완전성은 주장하지 않는다. + +### Task 1.2 별칭 저장과 이름 해석 service + +**Goal 실행 `P1-T2`:** 승인된 저장 계약으로 Lang별 CREATOR 별칭의 소프트 lifecycle을 보존하고 요청 ID/lang에 대한 활성 별칭 map을 batch 조회한다. + +- **시작 조건:** `P1-T1` 완료, DB 컬럼/engine/collation 한계와 `schema.sql` 검토, 별도 제품 구현 승인 및 필요한 DB 실행 승인. DDL 파일 작성 승인은 DB 적용 승인이 아니다. +- **완료 증거:** test로 저장·갱신·clear·중복·원본 보존·batch/fallback, `role == CREATOR` 허용 및 그 외 역할 거부를 확인. `memberKind == AI_CHARACTER`만인 `role == USER`는 반드시 거부한다. +- **범위 밖:** 자동 번역, 별칭 고유성, 원본 `Member.nickname` 변경, 행별 조회, 과거 데이터 rewrite. + +**Files:** + +- Create: 앞의 creatorname 제안 root 하위 `domain/CreatorLocalizedName.kt`, `port/out/CreatorLocalizedNameRepository.kt`, `application/CreatorLocalizedNameService.kt`, `adapter/out/persistence/CreatorLocalizedNameEntity.kt`, `adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt`. +- Test: 제안 `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt` 및 `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt`. +- DDL: `docs/20261014_크리에이터이름언어별관리/schema.sql`을 검토용 초안으로 참조한다. 운영 적용 전 실제 `member.id` 타입/engine와 collation 확인 및 별도 DB 실행 승인 필요. Member 원본 entity는 수정하지 않는다. + +**Interfaces:** + +- Consumes: `Member.id`, `Member.role`, 명시 `Lang`; PRD NAME001/002/005/007. 관리 입력은 Unicode code point 255자로 제한하며 초과를 쓰기 전에 거부한다. +- Produces: 당시 P1 구현은 JA 전용이었으며 nonJA 빈 map을 반환했다. COR013 현재 `findLocalizedNames(memberIds: Collection, lang: Lang): Map`은 모든 Lang의 활성 CREATOR 별칭만 포함한다. `findCurrentNames(ids, lang)`은 원본/별칭 COALESCE를 한 SQL로 반환한다. 관리 GET은 JA 정책대로 비활성/미등록을 null로 취급한다. +- Repository 제안: `findByMemberIdAndLang(memberId: Long, lang: Lang): CreatorLocalizedName?`는 비활성 포함 쓰기 조회. `findActiveLocalizedNames(memberIds: Collection, lang: Lang): Map`는 JA/활성/CREATOR 한정 읽기다. Entity는 `displayName @Column(nullable = false, length = 255)`, `lang: Lang` enum-string/length 10 및 `isActive: Boolean`, `(member_id, lang)` unique를 DDL과 일치시킨다. + +- [x] **RED:** service/persistence의 저장 한도, Unicode 경계, clear/lifecycle, 중복 허용, role, batch와 불변 조건 테스트를 작성했다. +- [x] **RED 확인:** foundation RED 실행에서 의도된 HTTP 404 및 batch 미구현 assertion을 확인했다. +- [x] **GREEN:** 승인된 저장 계약과 JA 활성 CREATOR batch/fallback 구현을 완료했다. +- [x] **GREEN 확인:** foundation 18 tests 통과, 실패/skip 0. 실제 JWT HTTP GET/PUT 성공 및 미인증 401 확인. +- [x] **REFACTOR:** 최종 lint와 assemble 포함 전체 검증 성공 로그 확인. 실제 DB 적용은 미수행. + +### Task 1.3 ADMIN 관리 경계 + +**Goal 실행 `P1-T3`:** ADMIN만 CREATOR의 일본어 별칭을 조회/저장/해제하고, 원본 이름과 역할 변경은 그대로 둔다. + +- **시작 조건:** `P1-T2` 계약 확정, API 제안에 대한 문서/구현 승인, 운영 UI는 별도 blocker로 기록. +- **완료 증거:** GET/PUT 계약, clear 및 ADMIN 권한을 검증하고 `role=CREATOR`의 AI_CHARACTER kind 허용과 `role=USER, memberKind=AI_CHARACTER` 거부를 별도 단언한다. +- **범위 밖:** 관리자 UI 구축, 기존 role-edit endpoint 수정, endpoint를 승인된 공개 계약으로 간주. + +**Files:** Create `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameResponse.kt`; proposed test `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt`. Reference only: existing `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt`; do not change role-edit PUT or `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt`. + +**Interfaces:** + +- Consumes: `P1-T2` service/repository contract. Controller는 `/ja`를 기존 `Lang.fromCode`로 `Lang.JA`에 매핑해 명시 전달하며 DB 값은 `JA`다(쓰기 KO 기본값 없음). +- Produces: 제안 `GET/PUT /api/v2/admin/creators/{creatorId}/localized-names/ja`; GET `creatorId`, `locale=Lang.JA.code`(`ja`), `originalNickname`, nullable `displayName`(비활성/미등록은 null); PUT required `displayName: String`. + +- [x] **RED:** 실제 API 응답 및 역할/권한 assertion을 구성했다. +- [x] **RED 확인:** foundation RED 기록에서 실제 endpoint 미구현 404를 확인했다. +- [x] **GREEN:** COR012 승인 API 및 기존 web/error/security 패턴을 구현했다. +- [x] **GREEN 확인:** 실제 JWT HTTP GET/PUT, 성공/오류 envelope, 미인증 401을 테스트했다. +- [x] **REFACTOR:** 전체 ktlint 및 assemble 포함 회귀 성공. + +### Phase 1 완료 조건 및 Gate + +- [x] `P1-T1`~`P1-T3` 구현/테스트 증거가 기록됐다. +- [x] 발견된 내부 경로와 확인된 외부 blocker를 연결하고 역사 snapshot 무변경 회귀를 통과했다. +- [x] DDL 한계와 COR012 API 승인 및 실제 DB 미검증 상태가 기록됐다. +- [x] `P1-GATE` 내부 검증 통과. 전체 회귀/ktlint/assemble은 통합 로그에서 성공. + +**Phase 1 Gate `P1-GATE`:** 저장·관리 계약, 요구 추적, 직접 영향 회귀를 판정한다. + +- **시작 조건:** Phase 1 Task 전체 완료, 요구사항/제안 API 승인 근거 존재. +- **완료 증거:** 아래 focused tests, lint, compile/package 결과 및 snapshot/원본 불변 확인이 기록됨. +- **범위 밖:** 외부 UI blocker를 내부 서버 성공으로 표시, test 삭제/완화. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNamePersistenceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.adapter.in.web.AdminCreatorLocalizedNameControllerTest' +./gradlew ktlintCheck +./gradlew assemble +``` + +**Expected:** 명령 exit code 0, JUnit 결과 성공, API field/role/clear 및 원본 불변 확인. Phase Gate는 전체 test suite를 실행하지 않고 `assemble`로 compile/package를 확인한다. + +## Phase 2: 모든 HTTP 표시 경로와 검색 + +**Phase 결과:** 현재 이름을 조회하는 HTTP 요청은 명시 언어의 CREATOR 이름을 받고, 검색은 원본+JA 별칭을 유지하면서 중복을 만들지 않는다. + +**선행조건:** `P1-GATE` 완료 및 `P1-T1` inventory에서 빠진 경로 없음. + +**Phase 완료 조건:** `P2-T1`, `P2-T2`, `P2-GATE` 완료. 레거시/v2 응답 DTO 계약이 유지되고 locale boundary가 test됨. + +### Task 2.1 HTTP read surface와 혼합 목록 적용 + +**과거 Goal 실행 `P2-T1`:** 당시 batch 기반 구현은 inventory의 HTTP current-name 응답에 alias override를 적용했다. COR013에서 직접 DB projection 경로는 JOIN으로 전환했으며, 과거 checkbox와 Progress는 그 시점의 이력이다. + +- **시작 조건:** `P1-GATE` 완료, 각 surface의 projection과 명시 `Lang` entrypoint를 `P1-T1` evidence에서 확인. +- **당시 완료 증거:** 각 affected feature test에서 JA override, fallback, 혼합 USER 보존, field type/name 불변을 확인했다. COR013은 요청 Lang에 해당하는 활성 별칭도 조회한다. +- **범위 밖:** snapshot rewrite, 전역 serializer, path inventory 없이 wildcard 수정. + +**Files:** seed 중 Member profile, Explorer, legacy `api/home`, `rank`, `query/recommend`, content/series, live, v2 home/following, gift, ranking, content/channel adapters. P1 inventory가 추가한 모든 HTTP current-name consumer만 수정한다. + +**Tests:** 영향 surface마다 file map의 exact existing class 또는 P1 audit가 implementation 전 추가한 exact test path. Seed E2E targets: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`; community, fantalk, donation EndToEndTest paths in the test map; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt`. + +**Interfaces:** + +- 당시 P2 계약: `P1-T2` batch `Map`을 HTTP 경계에서 적용했다. COR013 이후 직접 DB projection은 요청 Lang의 JOIN+COALESCE 결과를 사용하며 기존 DTO contract를 유지한다. cache/recipient 및 그 밖의 명시된 경계만 batch를 유지한다. + +- [x] **RED:** legacy/v2 consumer 테스트에서 JA 표시, KO/EN/fallback, 혼합 USER 및 공개 응답 불변 assertion을 작성했다. +- [x] **RED 확인:** 통합 RED에서 104개 중 35개 테스트가 이름 표시/handshake 등 미구현 assertion으로 실패했다. fixture/context 실패는 이후 보정하고 기대값은 완화하지 않았다. +- [x] **GREEN:** inventory에 연결한 legacy/v2 내부 HTTP surface에 owner ID batch render를 적용하고 기존 DTO field 계약을 유지했다. +- [x] **GREEN 확인:** 최신 전체 실행에서 2245개 테스트 통과. 개별 surface 통과 여부보다 전체 회귀 실행 결과를 기준으로 기록. +- [x] **REFACTOR:** 전체 ktlint 통과. Oracle이 별도로 제기한 쿼리 비용 및 helper 위치 보완은 P4-R1로 분리했고 후속 검증·리뷰 승인까지 완료했다. + +### Task 2.2 검색 원본+JA 별칭 + +**Goal 실행 `P2-T2`:** 공개 creator/channel 검색과 기존 member nickname 검색에서 원본 및 JA 별칭을 함께 찾고 기존 결과/필터/count 의미를 보존한다. + +- **시작 조건:** `P1-GATE` 완료, alias search가 request locale과 무관하게 JA임을 확인하고 DB contains/collation 의미를 보존한다. +- **완료 증거:** request가 KO여도 JA alias hit, alias clear 이후 alias-only 검색 miss, original match 지속, 중복 방지, 기존 USER/requester filters를 test로 검증. +- **범위 밖:** 콘텐츠/시리즈 title, character title, detail/tag를 creator alias 조건으로 오염시키기. 기존 콘텐츠 검색의 제목 등 조건과 필터는 그대로 둔다. + +**Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt` `getSearchChannel`; `src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt` creator-name branches in searchCreator/searchContent/searchSeries (not title/detail/tag predicates); and `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt` `MemberQueryRepositoryImpl.findByNicknameAndOtherCondition`. Keep requester filters and existing USER matches. In searchCreator both `SearchResponseItem.title` and `.nickname` are creator-name fields and use the owner `memberId`; in content/series results title remains content/series title, while creator nickname remains separately searchable. Never key aliases by parent resource ID; add internal owner memberId projection where missing and keep public DTO unchanged. + +**Tests:** existing `src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt`; proposed `src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt`. + +**Interfaces:** + +- Consumes: owner memberId 상관 JA alias `EXISTS` predicate(`lang=Lang.JA`/DB `JA`, `is_active=true`, owner `Member.role=CREATOR`), 기존 keyword와 표시 요청 `Lang`. HTTP `locale=ja`를 DB 문자열 비교값으로 쓰지 않는다. +- Produces: 기존 `searchCreatorTotalCount`/`searchCreatorList` 반환 타입, 기존 필터와 pagination. + +- [x] **RED:** creator/channel/member 검색에서 원본 OR JA alias, 중복/clear, USER·기존 필터와 content title 불변 회귀를 작성했다. +- [x] **RED 확인:** 통합 RED run의 consumer 실패에 검색 별칭 누락 assertion이 포함됐다. +- [x] **GREEN:** Explorer, SearchRepository list/count 및 MemberRepository의 alias EXISTS를 owner/JA/active/CREATOR로 상관시켰다. +- [x] **GREEN 확인:** 최신 전체 회귀 통과. 검색 테스트는 이번 run에서 전체 suite로 검증됐다. +- [x] **REFACTOR:** lint 및 전체 회귀 통과. P4-R1에서 predicate helper 배치와 SQL 조회 비용을 보완한다. + +### Phase 2 완료 조건 및 Gate + +- [x] 저장소 inventory의 legacy/v2 서버 HTTP surface 연결 및 snapshot 불변 회귀를 검증했다. 이 확인은 외부 client 전체 surface 검증이 아니다. +- [x] 서버 공개 DTO schema와 raw nickname editor 경계를 변경하지 않았다. client가 raw endpoint를 어디서 쓰는지는 미확인이다. +- [x] 검색 list/count/page 중복 방지 assertion과 전체 회귀를 통과했다. +- [x] `P2-GATE` 내부 검증 통과. + +**Phase 2 Gate `P2-GATE`:** 검색과 HTTP 표시 회귀를 확인한다. + +- **시작 조건:** `P2-T1`, `P2-T2` 완료. +- **완료 증거:** 각 수정 surface의 test class 목록/실행 결과, API schema 비교, lint 결과. +- **범위 밖:** 범위 inventory 외 리팩터링. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.ExplorerQueryRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.api.home.HomeServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.search.SearchServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.search.SearchRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.member.MemberRepositoryLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest' +./gradlew ktlintCheck +``` + +**Expected:** 위 seed focused tests와 실제 수정 surface test가 성공한다. P1 audit가 추가 발견한 surface는 실행 전에 exact test path/FQCN을 이 Task에 추가한다. 명령은 direct HTTP/read-surface 회귀 범위다. + +## Phase 3: 수신자별 푸시, WebSocket/live와 외부 통합 + +**Phase 결과:** 신규 비동기 전달마다 각 수신자/세션 locale로 새 이름 payload가 만들어지고 외부 앱 계약까지 검증되거나 blocker로 명확히 남는다. + +**선행조건:** Phase 2 Gate 완료, EXT001~003 담당 역할과 연동 계약 확인. 외부 계약이 미확인이면 영향 경로 task는 `[blocked]`, 완료 금지. + +**Phase 완료 조건:** 내부 `P3-T1`, `P3-T2` 완료, 외부 blocker에 path/owner/재개 증거가 첨부되고 실제 통합 gate가 판정됨. + +### Task 3.1 푸시와 WebSocket per-recipient rendering + +**Goal 실행 `P3-T1`:** 푸시와 socket 채팅의 신규 payload를 수신자/세션 언어에 맞춰 독립 생성하고 기존 전달물은 보존한다. + +- **시작 조건:** EXT003 locale 계약을 확인하거나 client session propagation 부분을 blocker로 분리한 승인 결정, locale source와 sender-name interpolation 위치 확인. +- **완료 증거:** 푸시 lang group별 argument/title/body/sender snapshot, 같은 user KO/JA sessions의 서로 다른 output, SEND_ACK 및 HTTP response locale, Redis payload neutrality 확인. +- **범위 밖:** PushNotificationList 과거 행 재작성 및 entity/DDL 용량 확장, regex substitution, shared DTO 변경, async request `LangContext` 사용. + +**Files:** modify `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`; `fcm/notification/PushNotificationService.kt` if needed. Reference only: `fcm/notification/PushNotificationList.kt` mapping compatibility; no column changes or historical row rewrite. Modify existing chat paths in the source map; DTO only for proven contract need. Tests: proposed `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt` exercises actual recipient language groups and rendered notification arguments; existing PushNotificationService and chat/websocket tests. External client/RTC path `[blocked]`. + +**Interfaces:** + +- Consumes: `P1-T2` explicit Lang batch resolver. 푸시는 recipient `languageCode`; socket은 handshake에서 session attribute로 기록된 locale. +- Produces: push group마다 새 args/title/body/`senderNicknameSnapshot`; neutral Redis event(원본 message와 sender ID); broker는 각 session locale로 독립 payload 생성. + +- [x] **RED:** FCM token locale 그룹, WS handshake/session, ACK/HTTP, 원본 event/DTO 불변 회귀를 작성했다. +- [x] **RED 확인:** consumer RED에서 FCM/WS locale assertion 실패를 확인하고 fixture/context 원인을 별도로 보정했다. +- [x] **GREEN:** 서버 FCM 그룹 render와 session locale별 WS payload, HTTP/message/live current-name 경로를 구현했다. truncate 및 과거 payload 수정은 없다. +- [x] **GREEN 확인:** 최종 전체 회귀 통과, WS localhost/JWT 통합 테스트 포함. 외부 앱 reconnect/provider 수신은 증명하지 않는다. +- [x] **REFACTOR:** 전체 ktlint/assemble 및 test run 성공. + +### Task 3.2 live, cache/materialization 및 외부 소비자 통합 + +**Goal 실행 `P3-T2`:** live 표시, 비언어 캐시 및 수신자 client의 이름 수명을 검증해 current name과 불변 snapshot을 각각 올바르게 제공한다. + +- **시작 조건:** `P1-T1` source evidence와 EXT001~003 owner/repository/path/contract 확인. 미확인 external work는 실행하지 않고 blocked 상태 유지. +- **완료 증거:** live/new response와 캐시 classification test, producer/consumer별 decision table, 모바일/관리 UI/RTC 통합 test owner가 제출한 증거. +- **범위 밖:** historical snapshot 재작성/자동 삭제, 모든 cache 일괄 invalidation, 외부 경로 추측. + +**Files:** internal candidates in `src/main/kotlin/kr/co/vividnext/sodalive/`: `live/room/LiveRoomService.kt`, `live/recommend/LiveRecommendCacheService.kt` (repository/response exact path recheck), v2 ranking query/snapshot and following query/inbox paths in file map. Tests: existing `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt`; exact recommend/snapshot/following test paths from P1 inventory. EXT001 mobile/cache/raw editor, EXT002 admin UI, EXT003 RTC/client reconnect paths remain `[blocked]`. + +**Interfaces:** + +- Consumes: HTTP/service locale resolver; per-session lang contract from `P3-T1` where live/chat share session. +- Produces: current-name reads localized at request time; historical name snapshots retained; client/UI may complete only with external owner verification. + +- [x] **RED:** live/current/cache 및 historical 불변 동작 테스트를 작성하고 실제 구현 전 실패 증거를 남겼다. +- [x] **RED 확인:** 소비자 RED 및 별도 fixture 보정으로 이름 assertion과 test infrastructure 문제를 구분했다. +- [x] **GREEN:** 내부 server current-name/cache consumer를 locale batch 경로로 연결하고 historical snapshot을 유지했다. +- [x] **GREEN 확인:** 전체 회귀 통과. 내부 live/cache 구현은 검증됐으나 client cache/raw editor/RTC 외부 증거 미제공. +- [x] **REFACTOR:** 내부 변경 범위 lint/assemble/회귀 통과. 외부 의존성 때문에 Task 자체와 P3 Gate는 미완료다. + +### Phase 3 완료 조건 및 Gate + +- [x] 서버 push, websocket session, live 수신자별 이름 회귀가 통과했고 historical payload 수정은 없다. +- [x] reconnect/cache lifetime external evidence가 없어 blocker로 남기고 제품 완료를 보류했다. +- [ ] 모든 CREATOR display surfaces와 raw original editor가 내부/외부 소유자에게 연결되고 client owner evidence가 제출됐다. +- [ ] `P3-GATE` 외부 연동 증거 전까지 미완료로 유지한다. + +**Phase 3 Gate `P3-GATE`:** 다중 domain의 수신자별 전달 및 외부 연동 증거를 판정한다. + +- **시작 조건:** `P3-T1`, `P3-T2` 완료. EXT001~003의 차단 해제 조건 충족. +- **완료 증거:** KO/JA 동시 세션 결과, language grouped push 결과, HTTP/ACK 확인, 모바일 표시/cache/raw editor test, RTC reconnect test. +- **범위 밖:** 외부 증거 없이 서버 코드만으로 앱 전체 완료 판정. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.fcm.FcmSendListenerLocalizedNameTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.fcm.notification.PushNotificationListPersistenceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.live.room.LiveRoomServiceTest' +./gradlew ktlintCheck +``` + +**Expected:** 실제 변경된 test class와 Redis initializer opt-in이 확인되고 모두 성공. 외부 test는 소유자가 제공한 실제 command/result를 따로 기록하며 서버 test 성공과 합쳐 주장하지 않는다. + +## Phase 4: 최종 호환성 및 회귀 Gate + +**Phase 결과:** 요구사항 전체의 추적, 내부/외부 범위, API 호환성, 저장/이름/전달 안전성이 검증된다. + +**선행조건:** `P1-GATE`, `P2-GATE`, `P3-GATE` 완료 및 제품 변경 전체 승인 범위 기록. + +**Phase 완료 조건:** `P4-GATE`와 요구사항 추적표, 전체 회귀 결과 또는 조건부 생략 근거, blocker 0건. + +### Task 4.1 최종 요구 추적표 + +**Goal 실행 `P4-T1`:** 각 NAME/EXT 요구와 구현/검증 증거 사이에 빈 연결이 없는 최종 traceability table을 작성한다. + +**Files:** `docs/20261014_크리에이터이름언어별관리/prd.md`, `docs/20261014_크리에이터이름언어별관리/plan-task.md` 및 선행 Task에 누적한 검증 증거. + +- **시작 조건:** 선행 Phase Gate가 모두 통과했거나 사용자 승인으로 미포함 범위의 결정 문서화. +- **완료 증거:** NAME001~007/EXT001~003 각각 source owner, test/evidence, status, 미완료 blocker를 가진 traceability table. +- **범위 밖:** 제품 코드 수정, blocker를 `complete`로 대체. +- **TDD 예외 사유:** 문서만 수정하는 read-only traceability 검토 task. +- **대체 검증 방법:** 요구사항 표, 계획 Task, 실제 test output/외부 증거를 행별 대조한다. 빈 증거는 실패로 처리한다. + +- [x] NAME/EXT 요구 ID를 내부 test/evidence 또는 명시 external blocker에 연결했다. +- [x] 세 inventory의 확인 항목을 계획과 대조했다. 전역 client surface 전수 검증이라고 주장하지 않는다. +- [x] historical snapshot과 neutral cache 분류 근거 경로를 inventory에 연결했다. +- [x] 현재 API schema와 서버 raw nickname editor 경계를 대조했다. 외부 client 실제 사용은 미확인이다. + +### Task 4.2 리뷰 보완 P4-R1 + +**Goal 실행 `P4-R1`:** 독립 Oracle 리뷰에서 확인한 현재 이름 fallback 쿼리 비용과 helper 패키지 배치 문제를 보완하고 cache hit 회귀를 실제로 입증한다. + +- **상태:** 수정·대상/전체 테스트·독립 리뷰 승인 완료. 운영 DB의 실측은 수행하지 않았다. +- **선행 조건:** 최신 source와 리뷰 finding을 다시 확인한다. 제안 경로는 수정 직전 실제 파일에서 검증한다. +- **Files:** `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCreatorNameRenderer.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`와 여섯 consumer 및 관련 cache test. 기존 root creatorname helper는 v2로 이동했다. +- **범위 밖:** 외부 앱/RTC/UI blocker, 운영 DB 적용, 검색 의미/공개 DTO 변경, SQL 실측 없는 성능 수치 주장. + +- [x] **RED:** 현재 이름 조회의 SQL 수/엔티티 로드 및 실제 cache hit/raw entry 보존 단언을 추가했다. +- [x] **RED 확인:** 이름 조회 성능 단언 3건 실패 확인(JA 4/10회, KO·EN 3/9회, 엔티티 1/4). 캐시 증거 단언은 기존 구현으로 통과해 캐시 production 코드는 추가 수정하지 않았다. +- [x] **GREEN:** 여섯 이름 전용 fallback을 scalar ID/nickname 조회로 교체하고 공통 helper 두 개와 관련 테스트를 v2로 이동했다. +- [x] **GREEN 확인:** 타깃 71개 테스트 통과. JA SQL 2/2회, KO·EN 1/1회, 엔티티 로드 0/0, 빈 입력 SQL 0회. 실제 Redis raw entry 불변 및 방 목록 SQL 1회 재사용 확인. +- [x] **REFACTOR:** 전체 2249개 테스트, assemble·ktlint 성공 및 최종 Oracle APPROVE. 상위 build/실제 HTTP·WS 재검증도 통과했다. + +**완료 판정:** `P4-R1` 완료. 외부 증거가 필요한 `P4-GATE`는 별도 차단 상태로 유지한다. 운영 DB에서 SQL 성능 실측은 하지 않았다. + +### Phase 4 Gate + +**Goal 실행 `P4-GATE`:** 여러 domain에 걸친 최종 회귀와 문서/외부 증거를 하나의 승인 가능한 판정으로 남긴다. + +- **시작 조건:** `P4-T1`, 이전 Gate 전체 완료. +- **완료 증거:** 최종 회귀, lint/build, API contract 비교, external owner 증거 및 traceability 모두 통과. +- **범위 밖:** 실패 test 삭제/skip, external path의 추정, 승인되지 않은 후속 구현. + +조건부 전체 검증 명령: + +```bash +./gradlew test +./gradlew ktlintCheck +./gradlew build +``` + +`test`/`build`는 여러 domain과 공통 serialization/권한 경계를 건드린 경우 실행한다. 제품 승인이 전체 회귀를 요구하거나 targeted test만으로 영향을 판단할 수 없는 실패가 생겨도 실행한다. 해당 조건이 모두 없고 안전성 판단에 충분한 focused/feature 회귀가 있을 때만 전체 회귀를 생략하며, 그 사실·근거·대체 명령을 Progress에 남긴다. 이 계획 문서 작성 시 product test/build/lint는 실행하지 않았다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 상태/병행 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 0 | 구현 승인 | 사용자 명시 승인 | 완료: COR012 | 운영 DB 적용·배포 별도 승인 | +| 1 | `P1-T1` | 승인 | 완료 | 외부 inventory 미확인은 blocker로 유지 | +| 2 | `P1-T2` | `P1-T1` 및 storage decision | 완료 | 운영 DB는 미적용 | +| 3 | `P1-T3` | `P1-T2` 및 COR012 API 승인 | 완료 | 운영 UI는 EXT002 blocker | +| 4 | `P1-GATE` | Phase 1 Task 완료 | 완료 | 내부 검증 통과 | +| 5 | `P2-T1` / `P2-T2` | `P1-GATE` | 완료 | 전체 회귀 통과, 외부 표면 미확인 | +| 6 | `P2-GATE` | Phase 2 Task 완료 | 완료 | 내부 검증 통과 | +| 7 | `P3-T1` | `P2-GATE` 및 내부 locale 경계 | 내부 완료 | 외부 client/provider 증거는 blocker | +| 8 | `P3-T2` / `P3-GATE` | P3 내부 구현 및 external evidence | 미완료 | EXT001~003 및 provider/DB blocker | +| 9 | `P4-T1` | 확인된 내부 증거 및 blocker 목록 | 기록 완료 | 외부 전체 범위 완료 아님 | +| 10 | `P4-R1` / `P4-GATE` | 리뷰 finding 수정/재검증 및 Gate 조건 | 서버 보완 완료 / 외부 차단 | 외부 evidence 수령 후 제품 Gate 재개 | + +```text +COR012 → P1 내부 Gate → P2 내부 Gate → P3-T1 내부 검증 + → P3-T2/P3-GATE [EXT001~003 blocked] + → P4-T1 evidence → P4-R1 reviewfix → P4-GATE [미완료] +``` + +## 요구사항 추적표 + +| ID | 계획 위치 | 핵심 검증 | 현재 상태 | +|---|---|---|---| +| NAME001 | P1-T2, P1-T3 | member ID로 저장/조회/clear, ADMIN 권한, CREATOR 대상 | 서버 구현 및 테스트 통과 | +| NAME002 | P1-T2, P2-T1, P3-T2 | JA 우선, missing fallback, original rename 뒤 유지 | 내부 서버 회귀 통과, 외부 surface 미검증 | +| NAME003 | P1-T1, P2-T1, P3-T1, EXT001/003 | 내부 inventory 및 mixed USER, 외부 client 전체 surface | 내부 테스트 통과, EXT001/003 blocked | +| NAME004 | P1-T1, P2-T2 | locale-independent original OR JA alias, count/list 중복 방지 | 서버 구현/회귀/리뷰 보완 통과 | +| NAME005 | P1-T3, P2-T1, EXT001 | 원본 편집/인증/USER flow 불변 | 서버 경계 회귀 통과, 실제 client editor EXT001 blocked | +| NAME006 | P1-T1, P3-T1, P3-T2 | snapshot 보존, 내부 current lookup/new payload locale별 | 내부 회귀 통과, 외부 앱/provider 증거 blocked | +| NAME007 | P1-T2, P1-T3 | empty/whitespace clear, 그 외 원문 문자 보존 | 계획 제안 | +| EXT001 | P3-T2, P3-GATE | 앱 표면/locale/cache/raw editor 소유자 test | `[blocked]` | +| EXT002 | P1-T3, P3-GATE | 실제 운영 UI 소유/인증/deployment/API 소비 test | `[blocked]` | +| EXT003 | P3-T1, P3-T2, P3-GATE | session locale, reconnect, receiver locale, simultaneous sessions | `[blocked]` | + +## 변경 금지 및 결정 규칙 + +- COR012 승인된 서버 구현 범위는 진행한다. 운영 DB 적용과 운영 배포는 별도 승인이 필요하다. +- API route/DTO는 COR012 승인 및 실제 HTTP 테스트 근거가 있다. MySQL 물리 호환성은 실제 DB 적용 전 확인해야 한다. +- product 요구 범위를 바꾸면 PRD decision log를 먼저 갱신하고 plan/test를 재승인한다. +- 완료된 task의 checkbox/Progress/test 결과를 지우거나 덮지 않는다. 회귀 수정은 새 Task/Goal로 누적한다. +- 과거 snapshot/payload를 current-name 경로로 분류하거나 자동 변환하지 않는다. +- 외부 UI, 모바일, client cache, RTC 범위는 경로 미확인을 이유로 scope-out/completed 처리하지 않는다. +- test 삭제/skip/약화, DTO 이름·타입 변경, 글로벌 serializer trick, `LangContext` 비동기 오용을 금한다. +- MySQL DDL 초안은 모든 column/table COMMENT와 프로젝트 `TIMESTAMP` convention을 적용한다. displayName unique/인덱스나 추가 인덱스는 두지 않고 실제 DB 호환성은 운영 적용 전에 확인한다. +- 새로운 dependency는 필요성이 증명된 경우 소비 Task에서만 추가한다. + +## Progress + +아래 과거 기록은 당시 상태를 보존한다. 최신 구현/검증 상태는 마지막 누적 기록을 따른다. + +### 문서 작성 검증, 2026-10-14 + +- 상태: 문서 검토 / 구현 승인 대기 +- 무엇을: PRD decision log를 DEC001~008 개별 행으로 복원하고 DEC009를 append, 기술 제안/외부 blocker 분리, goal 계획 작성. +- 왜: 사용자 요구사항 추적과 승인 전 비활성 계획을 보장. +- 어떻게: + - `./gradlew tasks --all` — 성공, Gradle task 목록 출력. 문서 변경 유지보수 guide의 명령 유효성 확인만 수행. + - product test/build/lint — 실행하지 않음. 구현 승인 전이고 문서 전용 변경이므로 실행 불필요. + - 문서 경로 점검 — PRD, plan, sample, guide 및 계획에 적은 기존 소스/테스트 파일을 검색/대조. +- 남은 항목: 사용자 문서 검토 및 별도 구현 승인. 모바일/운영 UI/RTC 담당자와 경로/계약 미확인. +- 다음 행동: PRD와 계획을 검토하고 기술 제안/API 계약 승인 여부를 결정한다. 승인 전 구현 goal 생성하지 않음. + +### 리뷰 보정 문서 검증, 2026-10-14 + +- 상태: 문서 검토 / 구현 승인 대기; 활성 Goal 없음. +- 무엇을: legacy home/rank/recommend, member nickname search, v2 content/channel paths, role/memberKind boundary, search tests, push persistence capacity를 inventory와 task에 추가했다. +- 어떻게: 기존 경로는 `rg --files`로 확인. `./gradlew test`, `./gradlew build`, `./gradlew assemble`, `./gradlew ktlintCheck`는 실행하지 않았다. product test는 구현 승인 전이며 이 보정은 문서만 변경한다. +- 남은 항목: 모든 product tests/build는 NOT RUN. 외부 운영 UI/mobile/cache/RTC 소유자·경로와 payload capacity 계약은 미확인/blocked. +- 다음 행동: 리뷰 재확인 및 별도 구현 승인 전까지 product 변경/Goal 실행 금지. + +### 문서 최종 검토, 2026-10-14 + +- 상태: 문서 작성 및 에이전트 검토 완료 / 사용자 검토 및 구현 승인 대기. 제품 구현은 미착수다. +- 독립 검토: 레거시 홈/랭킹/추천 누락과 role/memberKind 경계 표현을 보완한 뒤 동일 검토자가 재검토하여 PASS, 중요한 잔여 발견 사항 없음으로 판정했다. +- 사용자 질문 반영: 별도 `creator_localized_name` 테이블의 목적, 원본 이름 저장 위치, 언어별 행 구성과 일본어 이름 해제 방식을 PRD에 명시했다. 실제 테이블/DDL은 생성하지 않았다. +- 문서 검증: `./gradlew tasks --all` exit 0, `BUILD SUCCESSFUL`(869ms). 두 문서 각각 `git diff --no-index --check -- /dev/null 문서경로` exit 0. `git status --short`에서 이번 문서 디렉터리만 추가 상태임을 확인했다. +- 검증 한계: Markdown LSP 진단 도구는 제공되지 않아 직접 문서를 읽고 경로·요구사항·체크박스·명령 및 공백 오류를 대조했다. 제품 테스트/빌드/lint와 외부 앱/운영 UI/RTC 실행 검증은 수행하지 않았다. +- 남은 조건: 사용자 문서 검토와 별도 구현 승인, EXT001~003의 소유 저장소/계약 확인 및 실제 DB/provider 용량 검증. 이 조건들은 문서 작성 완료와 제품 구현 완료를 구분한다. + +외부 blocker의 상세 상태와 재개 조건은 앞의 `외부 경로, 아직 특정 불가` 표를 기준으로 한다. blocker가 남으면 영향 Task와 Phase Gate는 완료가 아니며, 승인 없는 scope change로 생략하지 않는다. + +### DOCDDL 후속 작성 검증, 2026-10-14 + +- 상태: 세 산출물 작성 완료, 독립 `DOCDDL-GATE` 미완료. DB 미적용이며 P1~P4 제품 checkbox는 미완료 유지. +- 무엇을/왜: 사용자 확정 테이블명과 문서·DDL 작성 승인만 반영하고 COR008~010을 누적했다. `Lang` enum-string/soft clear는 기존 수동 관리 근거에 따른 기술 결정이지 사용자 삭제 방식 지정이 아니다. +- 어떻게: `read`로 직접 소스·기존 DDL·세 산출물을 읽고 `rg`로 member+lang/DB `JA`/HTTP `ja`, 비활성 제외·동일 ID 재활성화, COMMENT/TIMESTAMP/unique/FK 및 승인 경계를 정적으로 대조했다. 물리 DELETE 언급은 초기 선택·사용자 질문 이력 설명에만 남았다. +- 실행하지 않음: DB 연결/SQL 실행, Kotlin 테스트, lint/build/앱 QA. `./gradlew tasks --all`의 이번 변경 후 확인은 상위 검토자가 수행할 예정이며 아직 완료로 기록하지 않는다. Markdown/SQL LSP 도구도 제공되지 않았다. +- 남은 조건: 독립 Gate, 별도 제품/DB 실행 승인, 운영 `member.id` 타입·engine/collation/저장 한계 확인과 EXT001~003 계약. 과거 Progress의 DDL 미작성 기록은 당시 이력으로 보존하며 이번 파일 작성 기록이 우선한다. + +### DOCDDL VARCHAR(255) 보정, 2026-10-14 + +- 상태: 세 산출물 보정 완료, 독립 `DOCDDL-GATE` 및 제품 구현 승인 대기. 제품 P1~P4 checkbox는 미완료 유지. +- 무엇을: `display_name` 저장을 `VARCHAR(255)`로 맞추고 PRD/계획의 현재 저장 계약과 경계 테스트를 보정했다. TEXT가 불필요하다는 사용자 확인과 기본 JPA nickname 표현을 따른 기술 결정을 구분했다. +- 어떻게: 직접 읽기와 `rg`로 현행 TEXT/300자·무제한 요구 잔여가 없음을 정적으로 확인했다. SQL/테스트/빌드 실행은 하지 않았다. +- 남은 조건: 독립 검토 pending; 실제 DB 물리 호환성 미확인. + +### DOCDDL-GATE 최종 검증, 2026-10-14 + +- 상태: 문서·DDL 파일 작성 및 정적 Gate 완료. DB 미적용, P1~P4 제품 구현은 미착수다. +- 독립 검토: 새 검토자가 현재 세 산출물을 직접 대조해 PASS 판정. `Lang` enum DB `JA`/HTTP `ja`, soft clear·동일 ID 재활성화, 활성 JA/CREATOR 필터, `VARCHAR(255)`·255/256 code point 경계 계획, 승인 범위와 실제 DB 미검증 전제의 정합성을 확인했다. +- 실행 검증: `./gradlew tasks --all` exit 0, `BUILD SUCCESSFUL`(867ms). 세 파일 각각 `git diff --no-index --check -- /dev/null 파일경로` exit 0. `rg`로 DDL의 7개 컬럼·PK·member+lang unique·FK·COMMENT·TIMESTAMP·engine/charset 및 제품 checkbox 미완료를 직접 확인했다. +- 검증 범위: SQL은 MySQL 구문과 기존 DDL의 정적 대조만 수행했다. MySQL/SQL parser/SQL LSP 도구가 없어 실제 SQL 실행 검증은 하지 않았고, DB 접속·컨테이너 실행·제품 테스트/빌드/lint·외부 앱 QA도 수행하지 않았다. +- 남은 조건: 사용자 문서 검토 및 별도 제품/DB 실행 승인, 적용 환경의 `member.id` 타입·engine/collation·저장 호환성 확인, EXT001~003 계약. 파일 작성 완료는 실제 DB 테이블 생성이나 기능 완료가 아니다. + +### 구현 승인 및 P1-T1 기준선, 2026-10-14 + +- 사용자 확인: "DDL + 문서 확인 / 구현 승인". 이어서 ULW 실행 요청. 과거 제품 구현 미승인 문구는 COR012로 대체한다. 운영 DB 적용·배포는 별도 승인 대상이다. +- 사용자 추가 제약: 커밋·푸시는 승인 없이 진행하지 않는다. 이번 실행은 작업 디렉터리 변경만 유지한다. +- 환경: 현재 `test` 브랜치, 시작 시 이 작업의 문서/DDL 3개만 미추적 상태. 테스트 설정은 H2 메모리 DB와 localhost embedded Redis를 사용하고 Firebase/Android Publisher는 비활성화한다. 실제 outbound가 필요한 테스트는 외부 client를 대체한다. +- 기준선: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest' --tests 'kr.co.vividnext.sodalive.search.SearchServiceTest'` exit 0, `BUILD SUCCESSFUL`(35s). 종료 중 Redis reconnect warning은 변경 전에도 관찰된 경고이며 테스트 실패가 아니다. +- 처리 경계: 원본 `Member.nickname` 및 인증/편집 흐름은 불변. ID를 모은 batch 별칭 조회를 응답 경계에서 적용한다. 레거시 조회/검색, v2 조회, 수신자별 전달을 서로 다른 파일 소유 범위로 나눠 조사·회귀 테스트를 준비한다. Gradle 실행은 직렬화한다. +- 외부 범위: 모바일/관리 UI/RTC 저장소 및 클라이언트 동작은 여전히 확인되지 않았다. 서버 구현·테스트와 전체 제품 연동 완료를 구분한다. +- 중단 기준: 승인된 서버 시나리오의 실제 HTTP/실시간 결과, 테스트·빌드·정적 검증, 자원 정리와 독립 리뷰를 확인한 뒤 보고한다. 외부 미검증은 blocker로 명시한다. + +### P1-T1~T3 구현 및 소비자 RED, 2026-10-14 + +- P1-T1: `legacy-name-inventory.md`, `v2-name-inventory.md`, `delivery-name-inventory.md`에 호출 경로/현재 조회/중립 캐시/스냅샷/원본 편집/외부 blocker 및 정확한 테스트 소유자를 기록했다. 운영·외부 클라이언트 근거는 미확인으로 유지한다. +- P1-T2/T3: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/` 운영 8개 파일과 테스트 4개 파일 구현. 기존 회원 행 잠금·오류 규약을 재사용하고 활성 JA CREATOR 배치 조회, 소프트 해제·동일 행 재활성화, 255 code point 상한 및 ADMIN 실제 HTTP를 검증했다. +- RED 증거: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-foundation-red.log`, `creatorname-persistence-red.log`, `creatorname-batch-red.log`. 실제 HTTP 404 및 미구현 batch 결과의 assertion 실패를 확인했다. +- GREEN 증거: 같은 임시 디렉터리 `creatorname-foundation-final-green.log`. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.*' assemble --console=plain` 성공, 18개 테스트(서비스 9/persistence 5/HTTP 4), 실패·skip 0. 부모 통합 RED 실행에서도 이 18개는 모두 통과했다. +- 실제 표면: `AdminCreatorLocalizedNameControllerTest`는 random-port localhost에 실제 JWT GET/PUT을 전송한다. 성공 200/성공 envelope, 권한·업무 오류는 기존 200/오류 envelope, 미인증 401. `@DirtiesContext` 종료와 작성자 curl 연결 거부로 서버 정리를 확인했다. +- H2 차이: 테스트 DB는 UTF-16 VARCHAR 길이 차이를 피하려고 테스트 전용 VARCHAR(510)+255 code point CHECK를 사용한다. 운영 entity/DDL은 VARCHAR(255)이며 별도 persistence 매핑 테스트가 255 컬럼을 확인한다. 운영 MySQL 적용 검증을 대체하지 않는다. +- 독립 모듈 리뷰: 운영·테스트 파일 검토 결과 중요한 코드 결함 없음. `ktlintCheck`는 다른 소유자의 새 소비자 테스트 5파일 11개 서식 오류 때문에 실패하여 전체 P1 Gate는 미완료다. 해당 소유자가 수정하며 최종 통합 전에 재실행한다. +- 위 모듈 리뷰/lint 행은 이 당시 실행 상태를 기록한 이력이다. 아래 최종 전체 실행이 서식 보완 후 `ktlintCheck`를 포함해 통과했으므로 현재 P1 Gate는 통과 상태다. +- 소비자 RED: `./gradlew test --tests '*LocalizedName*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' --console=plain`에서 104개 중 35개 실패. `creatorname-consumer-red.log`에 현재 원본 이름/검색 누락/handshake 언어 부재 assertion을 기록했다. live 준비 데이터 오류 등 미구현과 무관한 실패는 소유자에게 따로 보정 요청했다. +- 실행 판단: 모듈 코드·18개 테스트 검증 후 서로 독립인 소비자 연결을 병행한다. 전체 Gate 실패를 성공 처리하지 않으며 Gradle 실행은 부모에서 직렬화한다. 커밋·푸시·운영 DB 적용은 하지 않았다. + +### P2/P3 연결 및 통합 검증 착수, 2026-10-14 + +- 상태: 레거시·v2·전달 담당자의 코드 연결 완료 보고를 받아 통합 검증 중이다. GREEN이나 전체 Gate 완료로 아직 표시하지 않는다. +- 세부 목록: `legacy-name-inventory.md`의 L01~L43, `v2-name-inventory.md`의 HTTP/중립 캐시/원본 경계, `delivery-name-inventory.md`의 FCM 생산 22곳·세션·라이브·메시지 연결 상태를 참조한다. 목록의 '구현/대기'는 실행 검증 완료가 아니다. +- 최초 컴파일: `./gradlew compileTestKotlin --console=plain` 실패. `ContentSeriesService.kt`의 `renderSeries`가 이전 메서드 내부에 배치되어 unresolved reference가 발생했다. 통합 담당자가 이 오류와 직접 생성자·JPA slice 의존성 연결을 수정한다. +- 검증 소유권: 이후 Gradle 명령은 통합 담당자 한 명만 실행한다. 병행 작업자는 소스 읽기/리뷰만 수행하며 같은 build 디렉터리에 중복 Gradle을 실행하지 않는다. +- 코드 리뷰: Oracle이 전체 현재 diff와 신규 파일·시나리오를 읽고 있다. 원본/권한/언어별 캐시/검색 조건/소유자 ID/수신자별 이름의 불변식 및 누락 경로를 판정하며 결과 전 최종 완료 선언은 하지 않는다. +- 외부 경계와 승인: 운영 DB와 외부 앱/관리 UI/RTC는 아직 검증되지 않았다. 사용자 지시에 따라 커밋·푸시를 실행하지 않는다. + +### 통합 전체 회귀 및 상태 갱신, 2026-10-14 + +- 상태: 서버 구현과 전체 회귀 통과. P4-R1 리뷰 보완 진행 중이며 외부 연동/최종 Gate는 미완료. 현재 활성 Goal은 `P4-R1`이다. +- 근거: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-integrated-full.log`. 최종 성공 실행은 322 suites, 2245 tests, failures 0, errors 0, skipped 0. `test`, `ktlintCheck`, `assemble` 포함 명령이 `BUILD SUCCESSFUL`로 종료됐다. +- 실행 이력: 이전 전체 실행은 2245 중 200 fixture/context 실패로 끝났고 로그에서 삭제하지 않았다. constructor/JPA/fixture/context 문제를 고쳤으며 테스트 기대를 약화하지 않고 마지막 전체 실행에서 통과했다. +- RED/GREEN: 기반의 genuine RED는 actual HTTP 404 및 batch 미구현; consumer RED는 104 tests 중 35개 이름/locale 실패를 기록했다. 최종 GREEN에는 기반 18 tests, consumer 및 전달/캐시/라이브 회귀가 포함된다. +- 실제 표면: 관리 API JWT GET/PUT HTTP 서버 port 53418, WebSocket JWT/Redis HTTP 서버 port 53427 통합 테스트가 실행됐다. 성공 API envelope는 200, 오류 envelope는 200, 미인증은 401. teardown curl은 두 포트 모두 연결 거부(exit 7)로 서버 종료를 확인했고 Hikari pool shutdown을 로그에서 확인했다. 종료 후 잔류 JVM/Redis 프로세스는 보고되지 않았다. +- P1/P2: 기반 저장·관리 및 P2 내부 legacy/v2 표시·검색 검증과 내부 Gate 완료. inventory가 다루는 서버 consumer는 확인했지만 외부 client 전체 display consumer의 전수 확인을 주장하지 않는다. +- P3: 내부 FCM/WS/live/message 구현과 테스트는 통과. EXT001 모바일 표면/raw editor/cache, EXT002 운영 UI, EXT003 client locale/reconnect/RTC, push provider 및 운영 DB 용량은 소유자/계약/운영 evidence 미확인으로 blocked이며 `P3-T2`, `P3-GATE` 미완료. +- P4-R1 리뷰 보완: Oracle이 현재 이름 fallback의 여섯 `findAllById` entity graph 조회를 scalar ID/nickname 조회로 교체, 두 common helper를 `v2.creatorname` 아래로 이동할 것을 지적했다. 실제 cache hit assertion도 아직 입증되지 않았다. 수정 담당자가 진행 중이며 이 문서 갱신 시점에 수정 또는 테스트/리뷰 승인 증거는 없다. 별도 P4-R1 task를 추가했으며 체크박스는 미완료다. +- 승인 경계: COR012는 문서의 DDL/API 구현 및 테스트를 승인했다. 운영 DB 적용·배포는 미승인 별도 작업이며 커밋·푸시는 사용자 승인 없이 하지 않는다. +- 검증 제한: `lsp_diagnostics` 도구가 없어 LSP 진단은 미수행. compile, 전체 test, assemble 및 ktlint는 위 성공 로그를 근거로 한다. 실제 MySQL/FCM provider/client 앱 QA는 수행하지 않았다. +- 다음 행동: P4-R1 구현 후 target 및 full regression, lint/build 재실행과 Oracle 승인 기록. 외부 blockers 해소 증거를 받기 전 제품/최종 Gate 완료 금지. + +### P4-R1 및 서버 최종 검증 완료, 2026-10-15 + +- 상태: 승인된 서버 구현과 리뷰 보완 완료. 외부 연동 P3-T2/P3-GATE 및 제품 전체 P4-GATE는 EXT001~003으로 차단 유지하며 서버 완료와 구분한다. +- 수정 증거: `creatorname-reviewfix-files.txt`, `creatorname-reviewfix-target-red.log`, `creatorname-reviewfix-target-green.log`. 로그 공통 디렉터리는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/`다. 기존 이름 전용 엔티티 조회 6곳을 scalar로 교체했고 Member 관계 매핑이나 Gift의 엔티티 소비는 변경하지 않았다. helper 2개와 관련 테스트만 v2로 이동했다. +- 성능/캐시: 영속성 컨텍스트를 비운 1명/4명에서 JA SQL 2/2회, KO·EN 1/1회, entityLoadCount 0/0. 중복·누락 ID·USER·탈퇴 원본·빈 입력도 검증했다. 실제 `cache_ttl_10_minutes::getLatestFinishedLive` raw entry는 `host`로 유지됐고 KO→JA→원본 변경/별칭 해제 중 방 목록 query 실행은 1회였다. +- 전체 검증: `./gradlew test assemble ktlintCheck --continue --offline --console=plain` exit 0, 322 suites / 2249 tests / failures 0 / errors 0 / skipped 0. `creatorname-reviewfix-full.log`에 `BUILD SUCCESSFUL in 7m 24s`; 부모가 XML 집계를 별도로 확인했다. 이후 `./gradlew build --offline --console=plain`도 exit 0(3s, 검증 작업 UP-TO-DATE), `creatorname-parent-build.log`에 기록했다. +- 실제 표면 재실행: 부모가 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.adapter.in.web.AdminCreatorLocalizedNameControllerTest' --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatLocalizedNameIntegrationTest' --offline --console=plain`을 실행했다. 13개/실패·오류·skip 0, `BUILD SUCCESSFUL in 38s`, `creatorname-parent-http-ws-qa.log`가 증거다. 이 좁은 재실행으로 현재 XML은 13개 결과이며 전체 2249개 증거는 직전 전체 실행 로그·집계를 따른다. +- 자원 정리: 실제 HTTP 54539/WS 54547의 Tomcat·Hikari 종료 XML 확인. 두 포트 후속 curl은 각각 exit 7(연결 거부), `pgrep -fl 'Gradle Test Executor|redis-server'` 결과 없음. 정상 Gradle daemon은 빌드 인프라로 유지한다. +- 독립 리뷰: Oracle이 수정분과 SQL/캐시 증거를 재검토해 APPROVE, 잔여 차단 코드 지적 없음으로 판정했다. 새 검토 세션은 이전 세션 재개 도구 오류로 교체했으며 기존 두 지적과 수정분만 재검토했다. +- 한계: LSP 진단 도구 미제공으로 Kotlin 컴파일·ktlint로 대체했다. H2 Unicode 길이 보정 테스트는 MySQL 운영 검증을 대체하지 않는다. 운영 DB 적용, 실제 모바일/관리 UI/RTC/provider, 배포는 미수행이며 커밋·푸시도 하지 않았다. + +## COR013: 조회 경로 3종 리팩터링 + +기존 완료 체크와 로그는 당시 증거로 보존한다. 아래는 별도 사용자 승인 후속 작업이며 제품 범위 추가나 운영 DB 적용이 아니다. 기존 3개 inventory의 현재 조회/캐시/역사 분류를 따른다. + +과거 P1~P4의 checkbox와 Progress는 당시 batch 기반 구현·검증 이력으로 보존한다. 현재 COR013 조회 정책은 이 절과 최신 inventory를 따른다. DB 직접 projection은 요청 Lang의 JOIN+COALESCE, 중립 cache/수신자별 전달 및 원본 entity 경계는 필요한 batch다. 관리와 검색은 JA 정책이다. + +### JOIN-T1: 공통 조회 계약 + +- Objective: DB projection의 조인과 경계 batch를 기존 Lang으로 처리한다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoin.kt`(신규), 같은 도메인의 서비스·port·repository·LegacyCreatorNameRenderer 및 해당 테스트. +- 시작: COR013 및 이번 creatorname/maincontent/search 기준선 성공 확인. 완료 증거: 요청 Lang별 JOIN fallback 및 단일 SQL batch, sparse override 의미 보존, 기존 관리 JA 테스트 통과. +- 계약: `CreatorNameJoin(owner: QMember, lang: Lang, alias: String)`는 `name`, `condition`, `displayName`을 제공한다. query owner가 명시적으로 leftJoin/on/select하며 owner별 alias는 충돌하지 않게 지정한다. `findLocalizedNames`는 모든 Lang의 sparse 활성 CREATOR map, 신규 `findCurrentNames(ids, lang)`는 존재하는 회원의 원본/별칭 COALESCE map 한 SQL이다. 빈 IDs는 0 SQL. +- [x] RED: EN 활성 별칭 읽기, JA/KO/EN current batch 단일 SQL, JOIN의 USER/미등록/비활성 보존 assertion 실패를 확인했다. +- [x] GREEN: 읽기 JA guard를 제거하고 공통 조인/batch를 구현했다. 관리자 get/save의 JA 정책과 검색 JA EXISTS는 유지했다. +- [x] REFACTOR: 최초 core 43개 통과 및 공통 쿼리 계약 확인. 이후 미사용 helper의 전용 테스트 1개 제거를 포함한 최종 전체 회귀는 JOIN-GATE에서 통과했다. + +### JOIN-T2: DB 직접 projection 전환 + +- Objective: 기존 DB 조회가 표시 이름까지 반환하고 별칭 후속 SELECT/이름 복사 처리를 하지 않는다. +- Files: legacy inventory L01~L43의 DB query owner 및 caller, v2 inventory의 content/home/channel/recommendation/native SQL query 및 facade, delivery inventory의 live/message/DM DB 목록 query. 원본 편집·entity 저장·언어별 역사 producer는 제외한다. +- 시작: JOIN-T1 계약 확정. 소유 범위는 legacy / v2(creatorname·usercreatorchat 제외) / live·message·fcm·usercreatorchat·v2chat으로 나누며 공유 파일 writer는 한 명만 둔다. +- 구현 규칙: `ON(member_id, lang, is_active, CREATOR)`와 COALESCE. count/후보 ID pagination에는 불필요한 표시 조인 금지. GROUP BY와 native SQL은 원래 집계/필터/정렬을 보존하며 native lang은 enum NAME으로 bind한다. 공개 JSON owner ID 추가·원본 Member 변이·전역 serializer·표시 subselect 우회는 금지한다. +- [x] RED: 실제 main-content/search/혼합 writer repository에서 별칭 전용 후속 SELECT가 남는 실패를 확인했고 SQL assertion은 유지했다. +- [x] GREEN: 세 inventory가 명시한 직접 DB projection에 join을 적용하고 이름 전용 batch/copy를 제거했다. owner alias는 충돌 없이 분리하고 count/group/page는 보존했다. +- [x] REFACTOR: 소유자별 테스트 및 부모의 최종 전체 build/QA를 JOIN-GATE에서 확인했다. + +### JOIN-T3 / JOIN-GATE: 경계 보존 및 최종 검증 + +- Objective: 중립 캐시·수신자별 batch와 언어별 역사 스냅샷의 기존 의미가 유지된다. +- Files: 기존 FCM/WS/cache 및 localized-name 통합 테스트, PRD/plan과 3개 inventory. +- 시작: JOIN-T2 변경 완료. 완료 증거: 아래 실제 테스트·빌드·HTTP/WS·캐시 검사와 독립 리뷰. 외부 모바일/UI/RTC 증거는 기존 blocker로 유지한다. +- [x] current-name batch 단일 JOIN, recipient sparse alias fallback, raw shared payload 및 이미 렌더된 역사 이름 보존 assertion을 확인했다. +- [x] 부모 소유 최종 `./gradlew test build ktlintCheck --continue --offline --console=plain` 성공, 2265개 테스트 및 실제 ADMIN HTTP/WS QA·자원 종료 증거를 기록했다. +- [x] 세 inventory의 잔여 batch 호출을 cache/recipient/principal/business entity/write-return/error 경계별로 대조했다. 단순 DB projection으로 남은 batch는 보고되지 않았다. +- 범위 밖: 커밋·푸시, 운영 DB 적용·배포, 관리자 다국어 쓰기/API 확대, 검색 언어 확대, 외부 클라이언트 구현. + +### COR013 기준선 + +- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' --tests 'kr.co.vividnext.sodalive.search.SearchRepositoryLocalizedNameTest' --offline --console=plain` 성공(1m53s). 로그: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-join-baseline.log`. +- 쿼리 수 개선은 H2에서 실제 statement 수로 검증하며 운영 MySQL 지연 시간 개선을 측정 없이 주장하지 않는다. 모든 Gradle 실행은 직렬화한다. + +### COR013 진행 및 최종 Gate 대기, 2026-10-15 + +- 상태: JOIN-T1 및 JOIN-T2 완료. JOIN-T3 경계 assertion 통과. `JOIN-GATE`의 최종 전체 build와 실제 HTTP/WS QA 결과는 부모 실행 중이므로 아직 미완료다. 외부 제품 Gate `P3-GATE`와 `P4-GATE`도 별도 blocker로 유지한다. +- 기준선: `creatorname-join-baseline.log` 성공(1m53s). core RED는 43개 중 13개 assertion 실패, core GREEN은 43개 통과(`creatorname-join-core-green.log`). 이는 현재 전체 회귀가 아니다. +- 소비자 RED: `creatorname-join-consumer-red.log`에서 3개 테스트 실패. 표시 결과 외에 standalone alias SELECT 기대 0/실제 1이 확인되어 assertion을 유지했다. +- 소비자 GREEN: `creatorname-join-first-green.log`에서 3개 통과(2m37s), alias 전용 SELECT 0 확인. +- 통합 시도: `creatorname-join-full.log`의 `./gradlew test build ktlintCheck --continue --offline`는 테스트/build 후 ktlint의 줄바꿈/import 정렬 오류 18파일로 실패했다. 성공으로 기록하지 않는다. +- 형식 보정: 동작 변경 없이 ktlint 형식만 수정한 뒤 `creatorname-join-lint-green.log`의 ktlintCheck 성공(23s)을 확인했다. 최종 전체 실행/HTTP·WS QA 완료를 이 결과로 대체하지 않는다. +- Oracle 3-path 검토는 material code finding 없이 APPROVE. 판정은 full runtime 결과 대기 조건을 포함한다. +- 부모 최종 실행 결과를 확인한 뒤 별도 기록한다. 전체 test 수는 집계 확인 전 기재하지 않는다. MySQL/provider/UI/RTC 실제 검증은 미수행이며 운영 DB 적용·배포·commit/push도 하지 않는다. + +### COR013 JOIN-GATE 완료, 2026-10-15 + +- 상태: JOIN-T1/T2/T3 및 서버 JOIN-GATE 완료. 기존 외부 제품 P3-GATE/P4-GATE는 EXT001~003으로 차단된 상태이며 서버 완료와 구분한다. +- 조회 결과: DB 직접 projection은 명시 LEFT JOIN의 ON에 member/lang/active/CREATOR 조건을 두고 COALESCE를 선택한다. 후속 alias-only SELECT는 대표 main-content/search/live 추천에서 1→0회다. current-name cache batch는 기존 2조회 결합 대신 단일 joined scalar SQL, FCM/WS는 sparse recipient override, 이미 언어별로 저장된 알림 이름은 저장값 반환을 유지한다. +- 정리: 이번 전환으로 production caller가 없어진 `LegacyCreatorNameRenderer.render`와 `MemberQueryRepository.findNicknamesByIds`만 제거했다. passing 상태의 해당 generic-render 전용 테스트 1개도 제거해 총 테스트 수가 2266→2265가 됐다. 실제 batch의 모든 Lang/역할/누락/중복/비활성·현재 원본/SQL 수 단언은 보존했고 `creatorname-join-orphan-check.log`의 core 및 lint가 통과했다. +- 최종 명령: `./gradlew test build ktlintCheck --continue --offline --console=plain` exit 0, **2265 tests / failures 0 / errors 0 / skipped 0**, `BUILD SUCCESSFUL in 13m 45s`. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-join-release-check.log`; 부모가 XML 수치를 별도 집계했다. 앞선 2266개 통과+서식 오류 및 서식 보정 후 성공 로그는 이력으로 유지한다. +- 실제 표면: 최종 전체 실행에 실제 JWT ADMIN HTTP 4개, localhost HTTP/WebSocket 9개가 포함되어 모두 통과했다. 포트 58173/58186의 Hikari 종료 XML 및 부모 후속 curl exit 7을 확인했다. `pgrep -fl 'Gradle Test Executor|redis-server'` 결과 없음. 정상 Gradle daemon은 유지한다. +- 리뷰: Oracle COR013 정적 검토에 material finding 없음, 전체 runtime 성공으로 승인 조건 충족. 미사용 API 제거 수정분 재검토도 APPROVE로 종료했다. 이전 검토 세션 재개가 도구 timeout으로 실패해 새 검토자가 마지막 삭제 변경만 재검토했다. +- 한계: LSP 진단 도구는 없어 Kotlin 컴파일·ktlint로 검증했다. H2/로컬 Redis 검증은 운영 MySQL 실행 계획·실측 지연 시간 및 실제 모바일/provider/RTC 검증을 대신하지 않는다. 운영 DB 적용·배포·커밋·푸시는 수행하지 않았다. + +## COR014: 관리자 회원 목록 통합 + +후속 COR015는 아래 COR014 기록을 변경하지 않고 문서 하단에 누적한다. + +### ADMIN-LIST-T1: 목록 확장 및 쓰기 API 분리 + +- Objective: 관리자는 기존 회원 페이지 목록에서 원본과 모든 활성 언어별 이름을 함께 보고, 신규 일본어 PUT으로만 등록·수정·해제한다. +- 시작 조건: PRD COR014 사용자 적용 승인. 빌드 금지 요청을 유지한다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt`, `AdminMemberService.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/`의 service/port/persistence adapter 및 `AdminCreatorLocalizedNameController.kt`. Test: 기존 `admin/member/AdminMemberServiceTest.kt`, `v2/creatorname` service/controller 테스트 및 필요 시 신규 관리자 목록 계약 테스트. +- 계약: 목록 항목에 `localizedNames: Map` 추가. 원본 nickname·기존 count/offset/limit 유지. 페이지 CREATOR IDs에 대한 활성 별칭 1회 조회, 언어 필터 없이 Lang.code 키로 조립한다. 미등록·비활성 언어 생략, 일반 회원은 빈 map. 목록·검색 4개 공통 변환 경로를 사용하며 간단 선택용 두 API는 변경하지 않는다. +- API: 전용 GET 제거, PUT과 `CreatorLocalizedNameResponse` 확인 응답 유지. 단건 조회 서비스는 운영 caller가 사라지면 제거하고 테스트는 목록/저장 결과/persistence 기준으로 같은 lifecycle 검증을 유지한다. +- 범위 밖: 앱 언어 선택/JA 검색, 관리자 콘텐츠 원본 표시, DDL/H2 보정, 신규 다국어 쓰기/POST/DELETE, 레거시 API 제거, commit/push/운영 적용. +- TDD 실행 예외: 사용자 빌드 금지에 따라 Gradle이 유발하는 컴파일·테스트도 실행하지 않는다. 테스트 코드를 먼저 작성하되 RED/GREEN 확인은 미수행으로 남긴다. 정적 점검을 실행 검증으로 표현하지 않는다. +- [x] 테스트 작성: 모든 언어 표시·헤더 독립·일반 회원/해제 제외·4개 목록/검색·페이지/count·batch 쿼리 수·GET 제거·PUT 정책의 계약 테스트를 작성/보정했다. 실행하지 않았다. +- [x] 구현: 목록 DTO/공통 조립/별칭 batch 연결 및 전용 GET/단건 서비스 제거. PUT 확인 응답과 기존 페이지 쿼리는 유지했다. +- [x] 정적 검토: caller와 타입/role/언어/페이지/GET PUT 경로 및 문서 일치를 직접 확인하고 독립 PASS(static) 판정을 받았다. +- [ ] 실행 검증: **미실행/차단**. 허용 후 focused 명령 `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.*' --tests 'kr.co.vividnext.sodalive.v2.creatorname.*'`와 HTTP/전체 영향 회귀를 실행한다. 현재는 `tasks --all`도 실행하지 않는다. +- 완료 구분: 코드 적용 및 정적 리뷰 완료와 빌드·테스트/HTTP 검증 완료는 별도 상태로 기록한다. + +### COR014 정적 적용 기록, 2026-10-15 + +- 변경: 운영 6개 파일(관리자 목록 DTO/서비스, 이름 service/port/adapter/controller), 기존 테스트 3개 파일(AdminMemberServiceTest, CreatorLocalizedNameServiceTest, AdminCreatorLocalizedNameControllerTest)을 수정했다. 별칭 조회는 `findAllLocalizedNames` → `findAllActiveLocalizedNames`로 연결되며 언어 WHERE 조건 없이 페이지의 활성 CREATOR 별칭만 반환한다. +- 보존: 회원 원본 nickname·role/loginType 문구 번역·count/offset/limit·순서, 간단 선택용 DTO, 관리자 일반 콘텐츠 조회, 앱의 COR013 JOIN·JA 검색, PUT 권한/soft clear/잠금/255 code point 계약은 변경하지 않았다. `CreatorLocalizedNameResponse`는 PUT에 계속 사용한다. +- 정적 증거: 상위 에이전트가 수정한 운영/테스트 9개 파일을 직접 읽고 `rg`로 제거된 `getLocalizedName` 참조가 없고 기존 관리자 목록 4개가 공통 변환을 사용하는 것을 확인했다. `git diff --check`와 130자/후행 공백 검색에서 지적이 없었다. 이는 컴파일/ktlint 실행 결과가 아니다. +- 독립 리뷰: 첫 리뷰 작업은 도구의 모델 지원 오류로 결과 없이 종료되어 다른 검토자로 재시도했다. 최종 결과 **PASS(static)**, 호출·nested map 타입·ADMIN 권한·페이지·언어 무필터·테스트 fixture/공통 예외 규약의 명백한 문제 없음. +- 미실행: 사용자 빌드 금지로 Gradle(`tasks --all` 포함), 컴파일, ktlint, 테스트, RED/GREEN, 실제 HTTP/앱/DB 실행을 전혀 하지 않았다. LSP 도구도 제공되지 않는다. 이전 전체 테스트 통과 기록을 COR014의 증거로 재사용하지 않는다. +- 남은 조건: 사용자 실행 허용 후 이 절의 focused 명령과 HTTP 계약을 검증해야 한다. 정적 적용 완료는 실행 검증 통과가 아니다. 커밋·푸시·운영 DB 적용·레거시 API 제거는 하지 않았다. + +## COR015: 선택적 레거시 이름 롤백 및 테스트 정리 + +### LEGACY-TRIM-T1 + +- Objective: 승인된 네 레거시 영역은 원본 이름을 반환하고 v2/공유 업무를 보존하며 불필요한 이름 래퍼·테스트 의존성을 남기지 않는다. +- 시작 조건: 사용자 문서 정리·적용 승인 및 불필요한 테스트 동시 정리 요청. 기존 미커밋 변경을 보존한다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/`, `content/main/curation/`, 공유 `content/AudioContentRepository.kt`, `content/series/ContentSeriesService.kt`, `ContentSeriesRepository.kt`, `rank/RankingService.kt`, `RankingRepository.kt`; `explorer/ExplorerService.kt`, `ExplorerQueryRepository.kt`, `explorer/profile/creatorCommunity/` 및 comment repository. 이름 래퍼 `v2/creatorname/application/LegacyCreatorNameRenderer.kt`와 남은 소비자, 대응 `src/test/kotlin/kr/co/vividnext/sodalive/` 테스트/`support/LegacyCreatorNameTestConfiguration.kt`, 이 디렉터리 PRD/계획/inventory. +- 보존: API/DTO schema, 기존 저장·권한·번역·알림, v2 커뮤니티·팬톡 JOIN, 공유 content/rank 소비자의 이름 표시, COR014 관리자 목록/PUT, 기존 미사용 업무 메서드. +- 공유 경계: KO 별칭도 유효하므로 `Lang.KO`로 원본 조회를 흉내 내지 않는다. 공유 조회가 필요하면 이름 조회에만 명시 raw/null 경계를 사용하고 제목·본문 번역 언어는 유지한다. +- 테스트 정리: 제거된 기능의 전용 단언/래퍼 테스트만 삭제한다. batch의 활성/역할/현재 원본/SQL 수 회귀는 core에 보존하고 characterization/번역/권한 테스트는 생성자·fixture만 적응한다. 불필요 `@Import`는 실제 의존성을 확인한 뒤 제거한다. +- 시나리오 S1: 네 제외 영역은 활성 JA/KO 별칭이 있어도 원본 이름을 반환한다. repository/HTTP 테스트 코드를 보정하고 production caller·projection을 정적 대조한다. +- 시나리오 S2: v2와 공유 content/rank 및 기타 유지 대상은 요청 Lang 이름/미등록 원본을 유지한다. 기존 JOIN·core·v2 테스트 단언과 caller를 대조한다. +- 시나리오 S3: 제거 wrapper 참조가 없고 불필요 테스트 설정만 정리되며 COR014·업무/번역/알림 단언이 유지된다. `rg`, 직접 diff 검토, 독립 정적 리뷰로 확인한다. +- 실행 제한: Gradle(`tasks --all` 포함), 컴파일, 테스트, lint, 앱/HTTP/DB 실행을 하지 않는다. TDD RED/GREEN과 실제 표면 검증은 차단이며 정적 검토로 통과를 주장하지 않는다. LSP 도구도 미제공이다. +- [x] RED 코드: 기존 테스트를 읽고 변경 계약에 맞는 최소 회귀 단언을 먼저 작성/보정했다. 실행 실패 확인은 금지에 따라 미수행. +- [x] GREEN 코드: 선택적 롤백, wrapper 직접 core 전환, 불필요 테스트/fixture 정리. 테스트 통과를 뜻하지 않는다. +- [x] REFACTOR 정적 검토: 잔여 참조/공유 caller/테스트 보존/문서 일치 및 `git diff --check`, 독립 APPROVE(static). +- [ ] 실행 검증 [blocked]: 허용 후 `./gradlew test --tests 'kr.co.vividnext.sodalive.content.*' --tests 'kr.co.vividnext.sodalive.explorer.*' --tests 'kr.co.vividnext.sodalive.v2.creatorname.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.admin.member.*'`와 영향 회귀·HTTP를 확인한다. 현재 실행하지 않는다. +- 종료 조건: 승인된 코드·테스트 정리가 정적 리뷰에서 blocker 없이 확인되면 즉시 종료하며 실행 검증은 미완료로 보고한다. 커밋·푸시·운영 적용은 범위 밖이다. + +### COR015 적용·정적 검토 기록, 2026-10-15 + +- 전용 조회: ContentMainTab/AudioContentCuration/레거시 커뮤니티·팬톡의 이름 JOIN·projection·이름 전용 언어 전달·응답 치환을 제거했다. 기존 업무 메서드, 삭제 회원 접두사 처리, 본문 번역, 공유 FCM `titleMemberId`는 유지했다. +- 공유 조회: `findByTheme(lang=null)`, 원본 오디오드라마 `nameLang=null`, 콘텐츠 랭킹 `nameLang=null`, 크리에이터 랭킹 `lang=null`로 제외된 탭만 원본을 읽는다. HomeService 등 유지 소비자의 명시 Lang과 제목 번역 locale은 보존한다. 시리즈 조회의 테스트 mock 보존용 위임 overload는 남기지 않고 HomeService/테스트도 명시 이름 언어를 전달한다. +- 의존성: `LegacyCreatorNameRenderer.kt`와 `LegacyCreatorNameRendererTest.kt`를 삭제했다. 남은 호출자는 `CreatorLocalizedNameService` 직접 주입이며 `originalLocalizedNameService()`는 기존 mock fixture를 직접 반환한다. 삭제된 래퍼의 단건/다건 SQL 1회·엔티티 로드 0·빈 입력·탈퇴 원본·이름 변경 후 clear fallback 단언은 기존 core 서비스 테스트로 통합했다. +- 테스트 정리: 실제 core 빈이 불필요한 JPA 테스트의 설정 import를 제거하고, GiftQueryServiceTest/AudioContentCommentNameMetadataTest/core persistence·JOIN 테스트의 필요한 설정은 유지했다. 커뮤니티 번역·언어 감지·characterization 테스트의 제거된 생성자 의존성도 정리했다. v2·검색·프로필·후원 등의 회귀 단언은 유지했다. +- 최소 회귀: 기존 `AudioContentRepositoryLocalizedNameTest`에 테마·큐레이션/알람·랭킹·오리지널 시리즈 raw/shared 회귀 4개를 추가했다. 기존 Explorer HTTP/커뮤니티 통합 사례는 활성 JA/KO 별칭에도 원본인 경계로 보정하고 팬톡 작성·수정·차단 메시지 및 FCM ID 단언을 보강했다. 모두 미실행이다. +- 부모 정적 확인: `rg`에서 삭제 wrapper/helper/필드 참조 0건, 탭·큐레이션 이름 JOIN 및 이름 전용 LangContext 0건. 커뮤니티의 번역용 LangContext는 유지한다. 전용 조회 caller를 전체 검색해 다른 live caller가 없음을 확인했고, `git diff --check`는 exit 0이다. 미추적 테스트 4개도 개별 `git diff --no-index --check /dev/null `에서 지적이 없었다. +- 실행 한계: 컴파일·LSP·Gradle·테스트·lint·HTTP·앱·DB 실행은 수행하지 않았다. 정적 일관성은 실제 SQL/컨텍스트/HTTP 성공 증거가 아니며 기존 COR013 테스트 결과를 이번 변경의 검증으로 재사용하지 않는다. 실행 자원을 만들지 않았으므로 QA 종료 대상도 없다. +- 독립 Gate: `ses_ec179d026ffeTbyj1KNLcvwUpg` 검토자가 S1~S3의 코드·테스트·caller·설정 의존성을 대조하여 **APPROVE(static)** 판정했다. 확정 blocker 및 정적으로 입증된 fixture 실패 없음. 상세 기록은 `reviews/cor015-legacy-trim-review.md`이며 실행 검증은 계속 차단 상태다. + +## COR016: H2 번역 JSON fixture 보정 + +### JSON-FIXTURE-T1 + +- Objective: 운영 코드와 기대값을 변경하지 않고 두 번역 회귀 테스트가 정상 JSON 객체를 읽어 통과한다. +- 시작 조건: 사용자 최소 수정 및 테스트 통과 요청. 이전 실행 제한은 이번 테스트 검증에 대해 해제한다. +- Files: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt`와 기존 PRD/계획/리뷰 기록. 검증 중 확인된 이전 래퍼 전환의 `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt` 인자 줄바꿈 1곳만 추가 보정하며 운영 동작 변경은 없다. +- 실패 증거: 사용자 전체 실행 XML은 2271건/실패2/오류0/skip0이다. 해당 클래스는 6건 중 랭킹 JSON 문자열 역직렬화 예외와 시리즈 번역 제목 fallback 단언 실패 2건이다. 랭킹/시리즈 SQL의 제목 번역 JOIN은 유지되어 있다. +- 원인: 두 fixture의 JPA String converter→H2 JSON 적재가 JSON 객체 대신 문자열을 만든다. 기존 DefaultMainContentAllQueryRepositoryTest는 persist/flush 후 FORMAT JSON으로 실제 객체를 적재한다. 그 방식만 재사용한다. +- S1: 랭킹 raw/KO/JA 이름 모드 모두 요청한 번역 제목과 올바른 닉네임을 반환한다. 기존 실패 테스트의 실제 H2/JPA 조회로 증명한다. +- S2: 시리즈 raw 이름 모드에서도 번역 제목을 반환하며 기본 JA/KO 이름 조회가 유지된다. 기존 실패 테스트의 실제 H2/JPA 조회로 증명한다. +- S3: 같은 클래스의 기존 4개 회귀 및 전체 테스트가 통과하고 이번 수정으로 src/main의 변경이 없다. 테스트 수는 2271을 유지한다. +- 범위 밖: 운영 converter/JSON 저장 정책/DDL 수정, 테스트 삭제·단언 완화·skip, 새 helper 계층, 운영 DB/배포/커밋/푸시. +- [x] RED 증거: 사용자 실행 XML의 예외·단언·실제 SQL과 현재 코드를 대조하여 두 실패를 확인했다. 테스트 fixture 결함이므로 제품 기능 RED와 구분한다. +- [x] GREEN: 두 fixture만 최소 보정하고 `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' --offline --console=plain` exit 0, 22초, 6건/실패0/오류0/skip0을 확인했다. 두 실패 사례의 기존 단언이 모두 통과했다. +- [x] 회귀: 전체 테스트 2271건 통과. ktlint의 줄바꿈 실패는 아래 이력대로 최소 보정 후 전체 ktlintCheck 통과를 확인했다. COR014/COR015를 포함한 실행 검증이며 전체 명령 첫 시도의 종료 코드 1은 보존한다. +- [x] 종료: 결과 XML/명령 종료 코드/운영 동작 불변/테스트 프로세스 종료를 확인했다. LSP 도구 미제공은 Kotlin 컴파일·ktlint 결과와 구분한다. + +- 구현: 테스트 파일의 두 위치에 각 6줄(설명 주석 포함)만 추가했다. 기존 JPA flush 후 `jdbcTemplate.update`의 bound `? format json`으로 해당 content/series ID 및 `ja` 행만 보정한 뒤 clear한다. 단언·테스트 수는 그대로다. +- 운영 코드 보존: 수정 전후 `git diff -- src/main | shasum -a 256` 값이 `334607606753fad8ffee5ef24936be1fcce154238730357ba9576970a5c75149`로 동일하다. 이번 수정에는 운영 코드 변경이 없다. +- focused 종료: 테스트용 HikariPool-1과 EntityManagerFactory shutdown을 확인했다. 전체 회귀·ktlint 및 독립 좁은 리뷰 결과는 대기 중이다. +- 전체 실행 1차: `./gradlew test ktlintCheck --offline --console=plain` 종료 코드 1. 부모가 XML 323 suites/2271 tests/실패0/오류0/skip0을 직접 합산했다. 테스트는 모두 통과했지만 같은 테스트 파일 276행의 argument-list-wrapping 3건으로 ktlint가 실패했다. 로그: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor016-full.log`(12분 1초). 성공으로 덮어쓰지 않는다. +- 후속 최소 보정: 위 호출의 인자 줄바꿈만 수정한다. 실행 의미가 동일하므로 전체 2271건을 다시 실행하지 않고 대상 클래스6건과 전체 ktlint를 재검증한다. 전체 회귀 통과 증거는 1차 XML 합산이며 후속 focused 결과와 구분한다. +- 독립 수정 리뷰: `ses_ec1576ac3ffesO9K10ayy0JKzr`가 scoped update/flush/clear/기대값 보존을 확인하여 APPROVE 판정했다. 기존 리뷰 세션 재개는 도구 Task not found로 실패해 새 좁은 리뷰로 대체했다. +- lint 2차: 테스트 파일 줄바꿈 보정 후 재검증에서 기존 HomeService 178행 argument-list-wrapping 1건이 드러났다. 첫 실행은 test lint 실패로 main check 결과 출력 전에 종료됐다. 이전 래퍼 전환에서 생긴 호출 형식만 줄바꿈하고 focused+전체 lint를 재확인한다. 운영 코드 불변은 의미·동작 기준이며 이 형식 수정은 예외로 명시한다. +- 최종 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.api.home.HomeServiceTest' ktlintCheck --continue --offline --console=plain` exit 0, BUILD SUCCESSFUL 40초. Kotlin 컴파일과 전체 ktlintCheck 통과, XML 2 suites/7 tests/실패0/오류0/skip0을 부모가 확인했다. 전체 2271건은 줄바꿈 보정 전 실행 결과이며 현재 XML의 focused 7건과 혼동하지 않는다. +- 운영 영향: 테스트 fixture 12줄과 테스트 호출 줄바꿈, HomeService 호출 줄바꿈 1곳만 이번에 보정했다. HomeService 파일의 공백 제거 SHA256은 전후 `cb99eb4501b1ab3e2bab002bc445d8a600dc07c71e5e61108e130777c2e46a89`로 동일하다. 운영 로직/converter/DB schema와 테스트 단언은 변경하지 않았다. +- 종료 증거: 부모 `pgrep -fl 'Gradle Test Executor|redis-server|GradleWrapperMain'` 결과 없음. focused Hikari/JPA shutdown 확인. `git diff --check` 지적 없음. 별도 운영 앱/DB 연결은 하지 않았다. +- 잔여 경고: 기존 HomeService 101행 불필요한 `!!` 컴파일 경고 및 Gradle 9 비호환 deprecation 경고는 범위 밖이라 수정하지 않았다. 실패나 테스트 skip은 아니다. 운영 MySQL/provider/실제 모바일 검증은 수행하지 않았으며 커밋·푸시도 없다. + +## COR017: 미사용 탭 완전 제외 + +### UNUSED-TAB-T1 + +- Objective: 미사용 탭 7파일이 HEAD와 같아지고 해당 탭을 위한 특별 raw 모드 없이 실사용 조회의 언어별 이름·제목 번역이 유지된다. +- 시작 조건: 사용자 범위 명확화 및 수정 승인. COR015의 탭 원본 이름 보장 해석만 대체하며 이전 증거는 보존한다. +- Files (main 공통 prefix `src/main/kotlin/kr/co/vividnext/sodalive/`): `content/main/tab/{alarm/AudioContentMainTabAlarmService,asmr/AudioContentMainTabAsmrService,content/AudioContentMainTabContentService,free/AudioContentMainTabFreeService,home/AudioContentMainTabHomeService,replay/AudioContentMainTabLiveReplayService,series/AudioContentMainTabSeriesService}.kt`; `content/AudioContentRepository.kt`, `rank/RankingRepository.kt`, `rank/RankingService.kt`, `content/series/ContentSeriesRepository.kt`, `content/series/ContentSeriesService.kt`, `api/home/HomeService.kt`. Test prefix `src/test/kotlin/kr/co/vividnext/sodalive/`: `api/home/HomeServiceTest.kt`, `content/AudioContentRepositoryLocalizedNameTest.kt`. +- 범위: 탭7파일에서 lang=null 6곳/nameLang=null 7곳을 제거한다. findByTheme의 탭 전용 nullable 분기를 제거하고 기존 기본 KO/명시 Lang 조회로 단순화한다. 랭킹·시리즈의 이름 언어를 제목 locale과 별개로 raw 지정하던 nameLang 인자/전달을 제거한다. 랭킹 creator 원본 전용 null 분기도 더 이상 필요하지 않으면 제거한다. Home caller/기존 mock을 새 단일 계약에 맞춘다. +- 보존: 공유 메서드를 쓰는 AudioContentMainService/AudioContentThemeService/HomeService의 언어 전달과 활성 이름/원본 fallback/제목 번역, 다른 실제 raw 조회·캐시·관리자 경계, COR014/커뮤니티·팬톡/AudioContentCuration, API·DDL·converter 및 H2 FORMAT JSON fixture. +- 테스트: 미사용 탭 raw 모드 단언과 전용 호출 사례만 제거한다. 실사용 메서드의 KO/JA 이름 및 제목 번역 단언은 유지/통합한다. 테스트 개수를 줄이기 위해 유효한 회귀를 삭제하지 않으며 실제 케이스 증감은 결과에 기록한다. +- S1: `git diff HEAD -- content/main/tab`가 비어 있고 null 인자가 없다. 파일 자체 삭제나 API 삭제가 아니다. +- S2: 실제 H2/JPA 조회에서 KO/JA 별칭과 미등록 원본, 각 locale의 번역 제목/미등록 번역 fallback이 유지된다. HomeService mock/호출 일치도 검증한다. +- S3: 전체 회귀 및 ktlint 통과, 실제 HTTP/WS 테스트 자원 종료, 유지 범위에 회귀 없음. 신규 의존성·추상화·호환 wrapper는 추가하지 않는다. +- [x] 기존 GREEN 기준: COR016 전체2271 및 focused7/lint 통과 기록과 현재 테스트를 읽고 축소 계약을 테스트에 먼저 반영했다. 기능 추가가 아닌 제외 범위 정리로 새 실패 기능 테스트는 만들지 않았다. +- [x] 최소 구현: 위15파일에 한정해 탭 전용 코드를 제거하고 모든 실제 caller를 적응했다. 부모가 공유 경계·테스트를 직접 읽고 탭 HEAD diff 없음과 nameLang 잔여 참조 0건을 확인했다. +- [x] focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.api.home.HomeServiceTest' --tests 'kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest' ktlintCheck --continue --offline --console=plain` 최종 exit 0, 21초, 3 suites/17 tests/실패0/오류0/skip0 및 전체 ktlintCheck 통과. +- [x] 최종: `./gradlew test ktlintCheck --continue --offline --console=plain` exit 0, 전체2271건/실패0/오류0/skip0 및 ktlintCheck 통과, 독립 APPROVE, 테스트 자원 종료와 탭7파일 HEAD diff 없음을 확인했다. +- 범위 밖: 미사용 API 자체 삭제, 다른 모듈 raw 정책 재설계, 관련 없는 정리, 운영 DB/배포/커밋/푸시. LSP 미제공은 Kotlin 컴파일/ktlint로 보완하되 동일 도구라고 표현하지 않는다. + +### COR017 구현·focused 기록 + +- 탭7파일의 13개 인자 추가를 되돌렸고 HEAD와 동일하다. 공유 운영6파일·테스트2파일의 나머지 유효한 기능 변경은 유지했다. +- 공유 조회: findByTheme/getCreatorRankings의 불필요 nullable 언어 분기를 제거했다. 랭킹·시리즈의 별도 nameLang 인자 없이 기존 locale로 이름과 제목을 조회한다. 랭킹의 이전 locale=null 경계와 큐레이션 raw 조회는 유지한다. Home의 시리즈 호출·mock도 기존 5인자 형태로 맞췄다. +- 테스트: repository6/Home1 메서드 수는 유지하면서 미사용 탭의 null 조합·알람/curationV2 호출 검증만 제거했다. 실제 사용 메서드의 KO/JA 별칭·번역 제목·원본 제목 fallback과 큐레이션2경로 검증 및 FORMAT JSON 적재는 유지했다. +- focused 첫 실행: 17건 중 소유자-ID 테스트1건 실패. 이름조회 기능 단언이 아니라 회원과 콘텐츠의 독립 자동증가 ID가 우연히 11로 같아진 fixture 가정이 원인이다. 같은 테스트 파일에서 ID가 같은 경우 다음 콘텐츠를 생성해 다른 ID를 확보했으며 assertNotEquals와 실제 결과 단언은 유지했다. 이어 최종17건 모두 통과했다. +- 로그: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor017-focused.log`에 첫 실패와 후속 성공을 누적했다. Hikari/JPA 종료와 focused 후 테스트 프로세스 잔존 없음이 확인됐다. +- 현재: 전체 회귀와 독립 리뷰 진행 중. 이전 전체2271 통과 기록을 COR017 전체 검증으로 대체하지 않는다. + +### COR017 최종 검증 완료 + +- 최종 명령: `./gradlew test ktlintCheck --continue --offline --console=plain`, exit 0, BUILD SUCCESSFUL 8분 36초. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor017-full.log`의 `COR017_FULL_EXIT_CODE=0`과 ktlintCheck 성공 상태를 확인했다. +- 부모가 최종 XML을 직접 합산하여 **323 suites/2271 tests/실패0/오류0/skip0**을 확인했다. 테스트 메서드 수는 유지되었고, 미사용 탭 전용 조합만 제거하면서 실사용 언어·제목 검증을 보존했다. 이번 결과는 COR017 최종 소스 기준이다. +- 실제 표면: 전체 suite의 기존 H2/JPA·HTTP/WS 테스트가 통과했다. 테스트용 JPA/Hikari shutdown과 `pgrep -fl 'Gradle Test Executor|redis-server|GradleWrapperMain'` 결과 없음을 확인했다. 부모의 localhost:52527 후속 curl은 exit 7로 연결되지 않아 테스트 서버가 종료됐음을 확인했다. +- 독립 리뷰: `ses_ec125e618ffej4zvXb6whEPb0z`가 **APPROVE** 판정했다. S1 탭 무변경, S2 실사용 Lang/locale 보존, S3 테스트 계약·ID fixture 보정이 단언을 약화하지 않음을 확인했다. 리뷰 시 남아 있던 전체 실행 결과 대기는 위 최종 통과로 해소됐다. +- 최종 범위: 탭7파일이 HEAD와 같아 변경 목록에서 빠졌다. 공유 운영6파일·테스트2파일은 필요한 다른 변경이 남는다. `git diff --check` 지적 없음. 운영 DB·배포·커밋·푸시는 수행하지 않았고 LSP는 미제공이며 Kotlin 컴파일·ktlint로 검증했다. 실제 운영 MySQL/provider/모바일 검증은 기존 외부 범위다. + +## COR018: 원본 이름의 언어 중립성 명확화 + +### ORIGINAL-NAME-T1 + +- Objective: 운영 코드 변경 없이 임의 언어 원본과 요청 Lang이 독립임을 기존 테스트로 검증하고 잘못 읽힐 수 있는 설명을 바로잡는다. +- 시작 조건: 사용자 테스트5파일+기존문서2파일 범위 확정·구현 승인. COR017 전체2271건 통과가 변경 전 기준이다. +- Files: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt`, `v2/creatorname/adapter/out/persistence/CreatorNameJoinTest.kt`, `v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`, `v2/api/home/HomeRecommendationControllerTest.kt`, `message/MessageServiceLocalizedNameTest.kt`(첫 파일 이후 동일 test root prefix). 문서는 이 PRD/계획2파일만 변경하며 새 리뷰 문서도 만들지 않고 이 절에 판정을 기록한다. +- S1: 원본 `하늘`/`さくら`/`Alice` 각각과 요청 KO/JA/EN을 독립적으로 검증한다. 활성 별칭이 있으면 등록값, 미등록·비활성이면 해당 원본을 그대로 반환하며 USER도 보존한다. 실제 service/JOIN H2 query와 기존 SQL 수/페이지/역할 단언으로 확인한다. +- S2: 별칭 해제 후 요청 언어와 관계없이 변경된 현재 원본으로 돌아간다. 원본과 요청 언어가 같아 보이는 경우에도 활성 수동 별칭이 우선한다. 기존 lifecycle/단건·다건 SQL/빈 입력 단언을 유지한다. +- S3: 전체 콘텐츠 HTTP 및 메시지 목록에서 일본어 원본이 KO/EN 별칭 미등록 시 그대로 나온다. 테스트 설명에 미등록 조건을 명시한다. 홈 추천은 기존 기대값을 바꾸지 않고 설명만 정정한다. +- TDD 적용: 이번에는 이미 정상인 동작의 테스트 보강이다. 운영 코드를 임시로 망가뜨리거나 실패 단언을 만들지 않는다. 기존 GREEN을 기준으로 fixture/시나리오를 먼저 보강하고 실제 DB·HTTP 경로의 통과를 확인한다. 가짜 RED 성공을 기록하지 않는다. +- 범위 밖: src/main 전체, DB schema/DDL/converter/운영 설정, 자동 언어 감지·원본 언어 metadata, 관리자 쓰기 언어 확대, 신규 테스트 파일, 다른 API별 조합 복제, 커밋/푸시/운영 DB/배포. +- [x] 기존 테스트 검토·fixture 설명 및 원본 문자열 조합 보강. 기존 매개변수3행을 원본 문자열3종으로 바꾸고 각 행에서 요청 Lang3종을 순회한다. 새 메서드·파일은 추가하지 않았다. +- [x] focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoinTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' --tests 'kr.co.vividnext.sodalive.message.MessageServiceLocalizedNameTest' ktlintCheck --continue --offline --console=plain` exit0, 1분55초, 5 suites/58 tests/실패0/오류0/skip0, 전체 ktlintCheck 통과. +- [x] 최종: `./gradlew test ktlintCheck --continue --offline --console=plain` exit0, 전체2271건/실패0/오류0/skip0, ktlintCheck 통과, 독립 리뷰 승인 및 테스트 자원 종료를 확인했다. src/main 불변과 승인7파일 범위를 유지했고 실행 수 증감은0이다. +- LSP 도구 미제공은 Kotlin 컴파일/ktlint로 보완한다. 실제 운영 MySQL/provider/모바일은 실행하지 않는다. + +### COR018 구현·focused 증거 + +- 공통 service/current-name, JOIN projection, sparse 조회의 기존3개 매개변수 테스트를 원본3종×요청Lang3종으로 재구성했다. 활성 KO/JA/EN 별칭과 미등록·비활성·USER fallback을 구분하고 SQL수/엔티티 로드/페이지/역할 단언을 유지했다. 매개변수 실행 수는 9건 그대로이고 내부 원본·언어 조합은9→27개다. +- 원본 변경·해제 테스트는 `さくら`→`Alice`로 원본 변경 후 JA 별칭 유지, 해제 후 모든 요청 Lang에서 현재 `Alice` 반환 및 동일 행 ID/이름값 보존·비활성을 확인한다. +- MainContentAll HTTP는 KO 별칭 미등록 시 `改名さくら`를 반환하며 JA 등록값을 우선한다. 메시지 voice 조회는 KO/EN 별칭 미등록 시 `さくら`, JA는 등록값을 확인한다. 홈 추천 테스트는 설명만 정정했다. +- 운영 불변: 수정 전후 `git diff HEAD -- src/main | shasum -a 256`은 `fdbd17dd0ad3a85b6c35cff6ce3f6f35add2d6afb7ea6ae3869c3638c63ba2a6`로 동일하다. 신규 production/테스트 파일이나 다른 문서 변경은 없다. +- focused 로그: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor018-focused.log`. 부모가5파일 수정과 XML58건을 직접 확인했다. 종료 과정의 기존 embedded Redis 재접속 warning 및 Gradle deprecation은 확인했지만 실패는 없다. 전체 회귀·독립 리뷰·최종 종료 검증은 진행 중이다. + +### COR018 최종 실행·리뷰 결과 + +- 최종 명령 `./gradlew test ktlintCheck --continue --offline --console=plain`: exit0, BUILD SUCCESSFUL 9분17초. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor018-full.log`의 `COR018_FULL_EXIT_CODE=0`과 ktlintCheck 성공 상태를 확인했다. +- 부모가 최종 XML을 직접 합산하여 **323 suites/2271 tests/실패0/오류0/skip0**을 확인했다. 대상5클래스는 service13/JOIN7/전체콘텐츠HTTP7/홈추천28/message3으로 합계58건이다. 이전 대비 JUnit 실행 건수는0 증감이며 기존 매개변수 행의 내부 조합만 보강했다. +- 독립 리뷰 `ses_ec10286d4ffeJKLqqL8wzJ9MAH`: **APPROVE(static)**, 구체적 차단 사항 없음. 원본3종×요청3언어, 동일 문자권 원본의 별칭 우선, 미등록/비활성/USER fallback, 이름 변경·해제 후 현재 원본 및 SQL/페이지 단언을 확인했다. 검토 시 진행 중이던 전체 실행도 위 결과로 완료됐다. 승인 범위 준수를 위해 별도 리뷰 파일은 만들지 않았다. +- 운영 코드 보존: 최종 `git diff HEAD -- src/main`의 SHA256은 시작 시 값 `fdbd17dd0ad3a85b6c35cff6ce3f6f35add2d6afb7ea6ae3869c3638c63ba2a6`과 동일하다. 신규 테스트/운영 파일·API·schema·설정 변경은 없다. 미사용 탭은 계속 HEAD와 동일하다. +- 실제 표면·종료: 전체 suite에 기존 실제 JWT ADMIN HTTP4건과 localhost HTTP/WebSocket·Redis9건이 포함되어 통과했다. JPA/Hikari shutdown을 확인했고 부모 `pgrep -fl 'Gradle Test Executor|redis-server|GradleWrapperMain'` 결과 없음, localhost:53324 및53337 후속 curl은 모두 exit7로 종료된 서버에 연결되지 않았다. `git diff --check` 지적 없음. +- 한계: Kotlin 컴파일·ktlint로 검증했으며 LSP 도구는 미제공이다. 별도 build/assemble 패키징, 운영 DB/MySQL·외부 provider·실제 모바일 검증은 하지 않았다. Redis 종료 시 재접속 warning과 Gradle9 deprecation은 기존 경고이며 실패나 skip은 없다. 커밋·푸시·배포는 수행하지 않았다. + +## COR019: 레거시 콘텐츠 메인 변경 롤백 + +### LEGACY-MAIN-T1 + +- Objective: 두 메인 파일이 HEAD와 같고 전용 조회2개의 이름 JOIN 추가분이 제거되며 공유 실사용 이름/번역과 기존 업무는 유지된다. +- 시작 조건: 사용자 조사 결과 확인 후 롤백 승인. COR018 전체2271건 통과 기준을 보존한다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt`, `AudioContentMainService.kt`(같은 directory), `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt`의 `findByThemeFor2Weeks`/`findAudioContentByCurationId` 선언·구현만. 기존 PRD/계획/inventory 상태도 갱신한다. +- 제거: controller 신규 이름서비스/LangContext 주입과 신규크리에이터·큐레이션 cache-hit 후 이름 batch/copy2곳; service 신규명시Lang전달2곳; repository 전용2메서드의 추가lang/CreatorNameJoin/projection 변경. +- 보존: service의 기존 LangContext와 제목·테마 번역, 인증/차단/성인 필터/캐시/순서/페이지, shared findByTheme·playlist·getLatestContentByTheme, core/v2 기능 및 테스트. 공유 조회의 제외API 간접 영향은 별도 raw 모드로 막지 않는다. +- 테스트 범위: 직접 전용 추가 테스트·constructor fixture는0건이다. 문서 T09 신규 예정 파일은 실제 생성되지 않았으므로 삭제할 파일이 아니다. AudioContentRepositoryLocalizedNameTest6건, MainContentAllEndToEndTest, HomeServiceTest 및 공통 batch 테스트는 유지한다. +- S1: 두 메인 파일 `git diff HEAD` 없음. 전용2repository메서드는 원래 시그니처·원본 projection이며 남은 caller는 컴파일된다. +- S2: 기존 shared 조회와 v2/홈/메시지 등의 현지화·번역 테스트가 통과한다. 기존 테스트 삭제·단언 완화·skip은 없다. +- S3: 실제 메인 HTTP 요청으로 cache 후처리 제거·전용query 원본 반환과 기존 응답 구조/인증을 확인하고, 테스트용 서버·DB·스크립트 등 임시 검증 자원을 종료한다. 직접 전용 테스트 부재를 기존 전체 테스트 통과로 대체하지 않는다. 임시 검증 코드는 작업 디렉터리 밖에 두며 영구 테스트 파일로 남기지 않는다. +- TDD 기준: 정상 기준에서 기능 추가분을 제거하는 롤백이며 새 제품 로직이나 가짜 실패 테스트는 추가하지 않는다. 기존 테스트와 실제 표면 검증으로 이전 업무 보존을 확인한다. +- [x] 지정3파일 최소 롤백·모든 caller 정적 대조 및 두파일HEAD일치 확인. repository 전용2메서드 선언·구현도 HEAD 형태로 복원했다. +- [x] focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.api.home.HomeServiceTest' --tests 'kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest' ktlintCheck --continue --offline --console=plain` exit0, 33초, 3 suites/17 tests/실패0/오류0/skip0 및 전체 ktlintCheck 통과. +- [x] 실제 메인 HTTP smoke 및 임시 검증 자원 정리. 실제 JWT localhost HTTP 인증4요청200·무인증1요청401과 이름/DTO/제목 단언이 통과했다. 임시 소스·init·빈 디렉터리를 제거했고 서버/Redis 종료를 확인했다. +- [x] 최종 `./gradlew test ktlintCheck --continue --offline --console=plain` exit0, 전체2271건/실패0/오류0/skip0 및 lint 통과, 독립 리뷰 승인과 실제 HTTP/종료·범위 확인 완료. +- 범위 밖: 다른 조회의 현지화 롤백, 원본=KO 취급, 공개 API/schema/DDL 변경, 기존 업무 메서드 삭제, 커밋/푸시/운영 DB/배포. LSP는 미제공이며 compile/ktlint 검증과 구분한다. + +### COR019 적용·focused 기록 + +- 두 메인 파일의 HEAD diff가 없음을 부모가 확인했다. service의 기존 LangContext·번역·캐시·필터는 그대로이며 repository에서는 지정2메서드만 이름 추가분을 제거했다. 테스트 파일·단언·메서드는 수정/삭제하지 않았다. +- focused 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor019-focused.log`에 `COR019_FOCUSED_EXIT_CODE=0`과 BUILD SUCCESSFUL 33초를 확인했다. 부모가 XML17건을 직접 합산했다. +- 직접 메인 경로 테스트가 없으므로 저장소 밖 `opencode/cor019-http-smoke/`에 임시 Kotlin 테스트·Gradle init만 준비했다. 실제 RANDOM_PORT HTTP/JWT/H2를 사용하고 target service/repository를 mock하지 않는다. 원본 일본어+활성KO/JA 별칭 fixture로 4개경로와 무인증을 확인한다. 영구 테스트 추가가 아니며 실행 후 제거한다. +- 현재 HTTP smoke와 독립 읽기 전용 리뷰 진행 중. 이 완료 후 임시 소스를 제거하고 기존 전체 suite를 실행한다. + +### COR019 실제 HTTP·리뷰 결과 + +- 임시 HTTP 명령: `./gradlew --no-daemon --offline --console=plain -I /var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/cor019-http-smoke/init.gradle test --tests kr.co.vividnext.sodalive.cor019smoke.Cor019RollbackHttpSmokeTest`, exit0, BUILD SUCCESSFUL39초, 임시1case/실패0/오류0/skip0. 로그 `opencode/creatorname-cor019-http-smoke.log`에 요청/응답 및 `GRADLE_EXIT_CODE=0`을 보존했다. +- 실제 관측: JA 헤더+USER JWT의 `/audio-content/main/new-content-upload-creator`, `/curation-list`, `/new/all`은 원본 `原名花子`, `/new`는 의도된 shared 기본KO 별칭 `한국어 별칭`을 반환했다. 인증 응답은 모두200/success=true, 항목수·ID·DTO·콘텐츠 제목을 확인했다. 무인증 신규크리에이터 요청은401이었다. 원본 언어를 KO로 가정하지 않았다. +- 종료: Tomcat54687/Hikari/JPA 종료, Redis54683·HTTP54687 listener 없음, 부모 pgrep 관련프로세스없음 및 HTTP54687 curl exit7을 확인했다. 임시 Kotlin/init을 apply_patch로 삭제하고 빈 src/parent 디렉터리를 rmdir했다. 영구 테스트 추가·삭제·수정은 없다. 외부 로그만 증거로 남긴다. +- 독립 리뷰 `ses_ec0bfd190ffeJDgOdPfIjH1xf2`: **APPROVE(static)**, 차단사항 없음. 두파일HEAD일치, 전용2쿼리·callers, 기존번역/업무·공유조회/테스트보존을 확인했다. 메인 `/new`의 sharedKO 간접 동작은 승인된 범위이며 새raw모드로 바꾸지 않았다. +- 현재: 임시 init 없이 정상 전체 suite 실행 중. 최종 XML에 임시 smoke가 포함되지 않고2271건을 유지하는지도 확인한다. + +### COR019 최종 완료 기록 + +- 일반 전체 명령 `./gradlew test ktlintCheck --continue --offline --console=plain`(-I 없음): exit0, BUILD SUCCESSFUL7분47초. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor019-full.log`의 `COR019_FULL_EXIT_CODE=0`과 ktlintCheck 성공을 확인했다. +- 부모가 최신 XML을 직접 합산해 **323 suites/2271 tests/실패0/오류0/skip0**을 확인했다. 기존 테스트 파일·단언·메서드를 추가/삭제/수정하지 않았다. 임시 Cor019RollbackHttpSmokeTest는 최종 XML/컴파일 클래스에 없고 외부 소스/init도 제거됐다. +- 최종 범위: 두 메인 파일 `git diff HEAD --exit-code` 출력 없음/exit0. AudioContentRepository의 전용2메서드 추가분만 롤백했고 다른 shared 조회는 유지한다. 운영 코드3파일 중2개는 변경 목록에서 빠지고1개는 다른 기능 diff가 남는다. +- 실제 HTTP5요청 결과와 independent APPROVE(static)의 실행 Gate는 모두 충족됐다. 전체 suite에 기존 ADMIN HTTP4건 및 HTTP/WS9건도 포함되어 통과했다. 서버·JPA/Hikari 종료를 확인했고, 부모 pgrep 관련프로세스없음 및 localhost:55007 curl exit7을 확인했다. 실행자도 HTTP54995·배포테스트 포트·이전 smoke 포트 종료를 확인했다. +- `git diff --check` 지적 없음. LSP 미제공은 Kotlin 컴파일/ktlint로 보완했다. 별도 패키징·운영 MySQL/외부 provider/실제 모바일 검증·운영 DB 적용·배포·커밋·푸시는 하지 않았다. 기존 Gradle9 deprecation 경고는 남아 있으나 실행 실패나 skip은 없다. + +## COR020: 캐릭터 댓글 변경 롤백 + +### CHARACTER-COMMENT-TRIM-T1 + +- Objective: 두 운영 파일은 HEAD와 같고 캐릭터 댓글 이름 대체 테스트1개만 제거되며 나머지 기능과 검증은 유지된다. +- 시작 조건: 사용자 두 운영파일 롤백 및 해당 테스트 삭제 승인. 이전 전체2271건 통과 기준이다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt`, `CharacterCommentRepository.kt`(동일 directory); `src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt`의 지정1메서드·전용import/주입. 기존 PRD/계획/inventory 상태를 함께 갱신한다. +- 제거: 서비스 신규 LangContext와 표시이름 인자·tuple 조립 및 findDisplayComments/Replies/Comment 호출; repository 신규 QueryDSL 확장/interface/impl/JOIN. 기존 derived query와 원본 nickname 조립으로 복원한다. 지정 테스트의 ChatCharacter/CharacterComment/CharacterCommentService import와 characterCommentService 주입도 사용처가 없어지면 제거한다. +- 보존: addComment/addReply/delete/report·검증·이벤트·PageRequest/커서·건수, 기존 controller와 배너테스트2개, 통합테스트의 나머지8개·공통 helper·LangContext mock, 다른 core/HTTP/전달 이름 계약. 테스트 파일 전체는 삭제하지 않는다. +- S1: 두 운영 파일 git diff HEAD 없음, 제거한 query메서드 caller 잔존 없음, 컴파일 성공. +- S2: 통합테스트9→8개 및 캐릭터 controller 배너2개·관련 회귀 통과. 전체 실행 수는 다른 변경이 없으면2271→2270이며 실제 XML로 확인한다. +- S3: 작업디렉터리 밖 임시 검증으로 실제 댓글/답글 HTTP 및 최신댓글 조회에서 활성별칭이 있어도 저장된 원본을 확인한다. 본문·ID·건수와 인증/권한은 기존 계약대로 유지한다. 검증용 파일/프로세스/포트는 제거하고 로그만 남긴다. 영구 테스트는 추가하지 않는다. +- TDD 기준: 제거되는 기능의 passing 전용테스트를 승인에 따라 삭제하는 롤백이다. 다른 테스트 단언을 완화하거나 제품 코드를 임시로 망가뜨리는 RED는 만들지 않는다. 정상 기준과 실제 표면/회귀로 보존 범위를 확인한다. +- [x] 운영2파일 및 테스트1개 최소 롤백, 전용fixture 정리·모든 참조·HEAD일치 확인. 두 운영 파일 diff는 비어 있고 지정 테스트1개·전용import3개·주입1개만 제거했다. +- [x] focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.LegacyCurrentNameReadIntegrationTest' --tests 'kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest' ktlintCheck --continue --offline --console=plain` exit0, BUILD SUCCESSFUL2분19초. 2 suites/10 tests(통합8+배너2)/실패0/오류0/skip0 및 전체 lint 통과. +- [x] 임시 댓글 HTTP/조회 smoke 및 검증 자원 정리. 실제 인증7요청200·비인증3요청401과 원본이름/본문/ID/건수/커서/최신댓글 단언 통과. 임시Kotlin/init/디렉터리 제거 및 서버/Redis 종료 확인. +- [x] 최종 `./gradlew test ktlintCheck --continue --offline --console=plain` exit0, 전체2270건/실패0/오류0/skip0 및 lint통과, 독립리뷰승인과 실제HTTP·임시case미포함·자원종료 확인. +- 범위 밖: 다른 캐릭터/이름 기능 변경, 신규 영구 테스트·설정·DDL·API, 커밋/푸시/운영 DB/배포. LSP 미제공은 Kotlin 컴파일·ktlint로 보완한다. + +### COR020 적용·focused 기록 + +- 부모가 두 운영 파일 HEAD diff 없음, 제거된 custom query 참조 없음, 통합테스트 지정1개만 제거 및 나머지8개 유지 상태를 확인했다. 기존 배너테스트2개는 수정하지 않았다. +- 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor020-focused.log`의 `GRADLE_EXIT_CODE=0`과 부모 XML10건 집계로 focused 통과를 확인했다. Hikari/JPA 종료 및 Redis56640 LISTEN 해제를 실행자가 확인했다. +- 현재: 임시 실제 댓글 HTTP 검증 준비와 독립 읽기 전용 리뷰 진행 중. 전체2270건 검증은 아직 미완료다. + +### COR020 HTTP·독립 리뷰 증거 + +- 임시 명령은 저장소 밖 cor020-comment-smoke의 Kotlin 테스트 소스를 Gradle init으로만 추가해 실제 RANDOM_PORT/JWT/H2 경로를 실행했다. 첫 실행 exit0, BUILD SUCCESSFUL59초, 임시1case/실패0/오류0/skip0. 외부 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor020-http-smoke.log`에 실제응답·cleanup·종료증거를 보존했다. +- 실제 HTTP: `/api/chat/character/1/comments`, `/comments/1/replies`의 페이지/빈끝페이지, 상세 `/api/chat/character/1`을 JA로 요청해 인증7개200/success=true와 비인증3개401을 확인했다. 활성JA/KO 별칭이 있어도 CREATOR는 `原名花子`, USER는 기존 이름이다. totalCount3/replyCount2, 답글original.replyCount0, 최신원댓글ID1과본문/커서를 확인했다. 최신댓글도 서비스 mock이 아닌 상세HTTP 응답으로 확인했다. +- 종료: fixture삭제수 답글2/원댓글1/캐릭터1/별칭2/선호1/회원2 및 token없음을 단언했다. Tomcat/JPA/Hikari종료, HTTP56749/Redis56747 listener없음, 부모 pgrep관련프로세스없음과 HTTPcurl exit7을 확인했다. 부모가 임시Kotlin/init을 apply_patch로 삭제하고 빈디렉터리를 제거했다. 정상 전체컴파일 후 임시case미포함도 확인할 예정이다. +- 독립 리뷰 `ses_ebd7fff7fffeYkwJ53C2vUyJ72`: APPROVE(static), 차단문제없음. 두운영파일HEAD일치와기존업무보존·지정테스트1개제거·다른8개및배너2개유지 확인. 전체실행Gate만대기다. +- 관측된 기존 테스트 환경 한계: 스모크 시작 시 H2 background scheduler의 SKIP LOCKED SQL 호환 오류와 종료시 scheduler/Lettuce warning이 기록됐다. 원래코드 변경 없이 실제요청/단언/자원종료는 통과했으며 운영 MySQL 동작 검증으로 간주하지 않는다. +- 현재: 임시init 없이 전체suite 실행 중이며 예상2270건 및 임시컴파일class/XML미포함을 확인한다. + +### COR020 최종 완료 기록 + +- 최종 일반 명령 `./gradlew test ktlintCheck --continue --offline --console=plain`(-I 없음): exit0, BUILD SUCCESSFUL8분49초. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor020-full.log`의 `GRADLE_EXIT_CODE=0`과 ktlintCheck 성공 상태를 확인했다. +- 부모 XML 직접 합산: **323 suites/2270 tests/실패0/오류0/skip0**. 대상 통합테스트8·배너2 유지, 제거한 전용1건에 따라 전체2271→2270이다. 임시 smoke는 최종 XML/컴파일 클래스에 없고 소스/init도 제거됐다. +- 부모가 두 운영 파일 HEAD diff 없음과 삭제된 확장의 caller 없음, 실제 댓글/답글/상세HTTP 단언과 독립 APPROVE를 확인했다. 기존 댓글 업무와 나머지8통합테스트/2배너테스트는 보존했다. +- 자원 종료: JPA/Hikari 종료, 부모 pgrep 테스트executor/Redis/wrapper 없음 및 localhost57097 curl exit7. 실행자는 HTTP57085·deployment포트·Redis56822 종료도 확인했다. 무관한 기존 daemon/JVM은 종료하지 않았다. +- 전체 git diff --check 지적 없음. 별도 패키징·운영DB/배포·커밋·푸시·실제모바일/provider검증은 하지 않았다. 기존 Redis 종료재접속/Gradle deprecation 경고는 남았지만 테스트실패나skip은 없다. +- COR020 작업은 여기서 종료한다. 후속 ContentSeriesRepository/Service는 사용자 요청 순서대로 지금부터 영향 조사만 수행하며 구현 범위를 자동 확대하지 않는다. + +## COR021: 시리즈 이름 변경 롤백 + +### SERIES-NAME-TRIM-T1 + +- Objective: 시리즈 운영2파일이 HEAD와 같고 이름 현지화 테스트 조각만 제거되며 제목 번역과 다른 기능 검증은 유지된다. +- 시작 조건: 후속 조사 후 사용자 "말한대로 진행하자" 승인. COR020 전체2270건 통과 기준이다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt`, `ContentSeriesService.kt`(동일 directory); `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt`의 시리즈1case. 기존 PRD/계획/inventory 상태 갱신. +- 제거: 저장소 getSeriesListV2/getSeriesByGenreListV2/getRecommendSeriesListV2/getOriginalAudioDramaList/getDayOfWeekSeriesListV2의 CreatorNameJoin·별칭projection/tuple추출·추가groupBy와 전용import2개. 서비스 CreatorLocalizedNameService import/주입 및 getSeriesDetail.nickname batch. 메서드/API/클래스 자체는 삭제하지 않는다. +- 보존: 기존 LangContext/Lang, seriesTranslation/genre/day표시/콘텐츠 번역, 성인/차단/권한/순서/count/page, 공통CreatorNameJoin/이름서비스 및 v2/관리자 독립조회, Home/Explorer의 service 호출·mock. 시리즈 작성자 이름이 해당 재사용 응답에서 원본으로 돌아가는 것은 승인 범위다. +- 테스트: shouldLocalizeOriginalSeriesNamesAndTitlesByRequestedLocale의 이름별칭2등록·별칭기대값·nickname단언만 제거하고 제목 검증으로 명명한다. JA 번역 제목/KO 미등록 원문제목 단언과 실제repository호출·FMT JSON fixture는 유지한다. 별칭helper의 암묵flush에 의존하지 않도록 JSON update 전 explicit flush를 보존한다. 테스트6개 및 전체2270개 수는 유지할 계획이다. +- S1: 두 운영 파일 HEAD diff 없음 및 public signature/caller 컴파일 성공. +- S2: 실제 H2 조회에서 JA 제목 번역과 KO 원문 fallback, 다른5개 repository 테스트 및 Home/SeriesMain/OSIV/독립v2 검증 통과. 이름부분만 삭제하며 제목단언을 완화하지 않는다. +- S3: 저장소 밖 임시 실제HTTP/JPA smoke로 활성별칭이 있어도 시리즈 작성자 원본을 반환하고 제목/페이지/권한을 유지함을 확인한다. 관련 서비스의 목록5경로·상세를 가능한 공통fixture로 확인하고, 별도v2는 기존테스트로 보존한다. 임시파일·서버·Redis를 정리한 뒤 일반전체suite에 임시case가 남지 않게 한다. +- [x] 지정 운영2파일 롤백 및 기존시리즈테스트 부분정리, 모든caller·HEAD일치 대조. 부모가 두파일HEADdiff없음과 제목단언/explicitflush/FMT JSON 보존을 확인했다. +- [x] focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.api.home.HomeServiceTest' --tests 'kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest' --tests 'kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest' ktlintCheck --continue --offline --console=plain` exit0, BUILD SUCCESSFUL52초, 4 suites/18 tests/실패0/오류0/skip0 및 lint통과. +- [x] 임시 시리즈 실제표면 검증 및 자원 정리. 목록5쿼리·상세·Home의 실제HTTP에서 원본작성자/번역/페이지/인증을 확인했다. 임시4파일과빈디렉터리를 제거하고 서버/Redis종료를 확인했다. +- [x] 최종 `./gradlew test ktlintCheck --continue --offline --console=plain` exit0, 전체2270건/실패0/오류0/skip0 및 lint통과, 독립리뷰승인·실제HTTP·임시case제외·자원종료 확인 완료. +- TDD 적용: 기존 GREEN에서 승인된 이름 기능만 제거하는 롤백이다. 나머지 유효한 테스트는 유지하며 새 제품로직·가짜RED·영구테스트파일은 추가하지 않는다. +- 범위 밖: 다른 서비스/별도v2의 현지화 제거, 제목번역삭제, API/DTO/schema/DDL 변경, 신규호환wrapper/raw모드, 커밋/푸시/운영DB/배포. LSP는 미제공이며 compile/ktlint 검증과 구분한다. + +### COR021 적용·focused 기록 + +- 시리즈 운영2파일을 HEAD로 복원했다. 이름 처리 목록5곳/상세1곳과 전용import/주입만 제거됐으며 기존제목/장르/요일번역과 호출시그니처는 그대로다. +- 시리즈 테스트는 shouldLocalizeOriginalSeriesTitlesByRequestedLocale로 정정하고 별칭등록2곳/이름기대값/닉네임단언만 제거했다. JA번역제목·KO번역미등록원문fallback, FMT JSON 및명시flush를 유지했다. 다른5개테스트·공통import/주입은 변하지 않았고 테스트6건수도 유지됐다. +- 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor021-focused.log`의 exit0 및 부모XML18건 집계로 검증했다. 운영2파일gitdiffHEAD없음, 범위밖변경없음, focused테스트자원종료를 확인했다. +- 현재 저장소 밖 cor021-series-smoke의 실제 HTTP/JPA 검증과 독립리뷰 진행 중. 임시하네스는 결과/응답/원본목록 검증방식을 기록하고 실행 후 삭제한다. Home비성공시 실제service/JPA 보완은 HTTP로 오인하지 않고 실패원인을 별도 명시한다. + +### COR021 실제 HTTP·리뷰 결과 + +- 외부 임시init을 사용하는 실제 RANDOM_PORT/JWT/H2 smoke는 첫 실행 exit0, BUILD SUCCESSFUL40초, 임시1case/실패0/오류0/skip0이었다. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor021-http-smoke.log`에 `COR021_SMOKE_EXIT_CODE=0` 및 응답·runtime manifest·자원종료증거를 보존했다. +- 실제 인증HTTP9요청은 모두200/success=true, 무인증1요청은401이었다. 목록/추천/장르/요일/상세·Home 오리지널 목록에서 활성KO/JA 별칭 대신 원본 `原名花子`를 반환했다. JA 제목·장르·요일 번역, 상세 raw title+translated.title, KO 제목미등록fallback, 빈페이지/count·ID/콘텐츠수를 확인했다. +- 원본목록 검증방식은 `originalCoverage=HTTP /api/home`이며 서비스/JPA 보완 분기는 사용하지 않았다. 기존 전체 테스트나 mock을 직접HTTP 증거로 대신하지 않았다. +- fixture한정 삭제건수 `[1,1,1,1,1,1,1,1,1,2,1,2]` 및 viewer token없음, Tomcat/JPA/Hikari 종료를 확인했다. HTTP57562/Redis57556 listener없음과 부모curl57562 exit7/pgrep관련프로세스없음 확인 후, 부모가 임시Kotlin/init/manifest/runtime-manifest4파일과 빈디렉터리를 제거했다. 로그만 보존한다. +- 독립 리뷰 `ses_ebd52bd19ffeXYnsWluRcQAoJm`: APPROVE(static), 차단사항없음. 운영2파일HEAD일치·제목/업무보존·이름단언만제거·명시flush/FMT JSON 보존을 확인했다. 전체실행Gate만대기다. +- 관측사항: 기존 H2 스케줄러 SKIP LOCKED 오류와 Lettuce 종료경고가 있었지만 대상HTTP와자원종료는통과했다. H2 스모크 상세 에피소드 releaseDate가 `%2026.10.15`로 표시된 것도 관측했으며 이번 이름 롤백과 무관한 표시 처리라 수정하지 않았다. 운영환경에서 같은지는 미검증이다. +- 현재: 기존 실행자 재개가 도구 Task not found로 실패해 새 검증 실행자를 지정했다. 임시init 없이 정상 전체suite2270건 및 임시case미포함을 검증 중이다. + +### COR021 최종 완료 기록 + +- 최종 일반 명령 `./gradlew test ktlintCheck --continue --offline --console=plain`(-I 없음): exit0, BUILD SUCCESSFUL11분8초. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor021-full.log`의 `COR021_GRADLE_EXIT=0`과 ktlintCheck 성공 상태를 확인했다. +- 부모가 최신 XML을 직접 합산하여 **323 suites/2270 tests/실패0/오류0/skip0**을 확인했다. repository6/Home1/SeriesMain1/OSIV10 유지, 이름단언만 정리하고 제목검증을 보존했으므로 전체 테스트 실행 수 증감은0이다. +- 부모가 두 시리즈 운영 파일 HEAD diff 없음과 임시 smoke 클래스/XML 미포함을 확인했다. 임시 소스/init/manifest/runtime 파일 및 디렉터리는 앞서 제거했다. 실제 시리즈5조회·상세·Home HTTP와 독립 APPROVE의 runtime Gate도 충족됐다. +- 최종 자원 확인: JPA/Hikari shutdown, 부모 pgrep 테스트executor/Redis/wrapper 없음 및 localhost57976 curl exit7. 실행자는 이전smoke57562/57556와 Redis57654도 종료됐음을 확인했다. 무관한 daemon/JVM은 유지했다. +- 전체 git diff --check 지적 없음. LSP 미제공은 Kotlin 컴파일/ktlint로 보완했다. 별도패키징·운영MySQL/provider/실제모바일 검증과 커밋·푸시·운영DB·배포는 하지 않았다. 기존 Redis 종료재접속/Gradle deprecation 및 앞서 기록한 H2 스모크 관측사항은 이번 이름 롤백 범위 밖으로 유지한다. + +## COR022: 레거시 메시지 이름 변경 전체 롤백 + +### MESSAGE-NAME-TRIM-T1 + +- Objective: 레거시 메시지2파일이 HEAD와 같고 전용 projection/테스트파일만 제거되며 기존 메시지업무 및 공통FCM/v2 DM은 유지된다. +- 시작 조건: 사용자 조사 결과대로 전체롤백 승인. COR021 전체2270건 통과 기준이다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt`, `MessageRepository.kt`, `MessageListRow.kt`(동일 directory, 마지막은삭제); `src/test/kotlin/kr/co/vividnext/sodalive/message/MessageServiceLocalizedNameTest.kt` 전체삭제. 기존PRD/계획/delivery inventory 갱신. +- 제거: 서비스6목록의 신규Lang전달/MessageListRow조립2곳, 차단오류별칭조회2곳, 추가이름서비스주입/import, 발송FCM nameArgumentMemberIds2곳. repository6목록의 lang/row반환 및 공통sender/recipient별칭JOIN/projection을 기존 List 조회로 복원한다. 해당2파일 외 사용처 없는 MessageListRow 전체와 전용테스트3건만 삭제한다. +- 보존: MessageController·API·DTO·Message entity·count/page/filter·발송·보관·삭제·권한·본문/URL/시간/문구번역, 기존 LangContext, FCM senderMemberId, 공통이름서비스/JOIN/FCM/알림snapshot 및 v2 UserCreatorChat/WS 코드·테스트. 쿼리scalar 최적화도 롤백되므로 삭제테스트의 SQL12회/엔티티로드0 계약은 유지 대상이 아니다. +- 푸시 경계: 전체HEAD복원은 이름인자 매핑만 제거한다. 공통 FcmEvent.sendPush는 senderMemberId로 별칭을 여전히 조회하여 localizedSenderNickname을 알림저장에 전달할 수 있다. 본문원본과 snapshot별칭의 구분을 명시하고 공통sender 처리까지 임의 제거하지 않는다. +- S1: 두운영파일 git diff HEAD 없음, MessageListRow와삭제테스트파일/잔여참조없음, 전체컴파일성공. 기존API/업무메서드는 삭제하지 않는다. +- S2: 공통FCM/알림/v2 DM/WS focused·전체회귀 통과. 정상전체2270→2267 예상이며 실제XML로확인한다. +- S3: 저장소밖 임시 실제HTTP/JPA 검증으로 문자/음성6목록의원본이름·본문/URL/건수/인증과 발송이벤트 메타데이터 복원을 확인한다. 대상service/repository를 mock하지 않으며 외부 S3/FCM 전송만 격리할 수 있다. 임시파일/서버/Redis는 제거하고 기존suite에 임시case를 남기지 않는다. +- [x] 지정4파일 최소롤백/삭제 및전체caller·잔여참조·HEAD대조. 부모가 두 운영파일HEAD일치와src의MessageListRow/삭제테스트 참조없음을 확인했다. +- [x] focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.fcm.FcmSendListenerLocalizedNameTest' --tests 'kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest' --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatNameJoinRepositoryTest' ktlintCheck --continue --offline --console=plain` exit0, BUILD SUCCESSFUL1분31초, 4 suites/33 tests/실패0/오류0/skip0 및lint통과. +- [x] 임시 실제메시지HTTP/발송계약 검증 및자원정리. 실제HTTP23요청과2발송이벤트단언 통과, fixture/JWT/서버/Redis종료와임시3파일·빈디렉터리제거 확인. +- [x] 최종 `./gradlew test ktlintCheck --continue --offline --console=plain` exit0, 전체2267건/실패0/오류0/skip0·lint통과, 독립리뷰승인과 실제HTTP/이벤트·임시case제외·자원종료 확인완료. +- TDD 적용: 승인된 현지화 기능/최적화를 제거하는 롤백이며 전용passing테스트3건도 범위와 함께 제거한다. 다른 테스트를 약화/skip하거나 신규제품로직/가짜RED를 추가하지 않는다. +- 범위 밖: 레거시메시지 API나업무자체삭제, 공통FCM/DM/WS롤백, 신규호환flags/rawmode, schema/DDL/운영설정변경, 커밋/푸시/운영DB/배포. LSP미제공은 Kotlin컴파일/ktlint와구분한다. + +### COR022 적용·focused 기록 + +- MessageService/Repository는 HEAD와동일하며 MessageListRow16줄파일과 MessageServiceLocalizedNameTest185줄/3case파일을 삭제했다. senderMemberId·기존LangContext·API/업무는유지하고 추가nameArgumentMemberIds만 제거했다. 다른코드·테스트·설정·DDL은변경하지 않았다. +- 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor022-focused.log`의 실제exit0과 부모XML33건집계로 focused통과를 확인했다. 실행자가 JPA/Hikari와 테스트executor/wrapper/Redis종료·Redis58314 listener없음을 확인했다. 기존Kotlin·Redis종료·Gradle경고는범위밖으로 유지했다. +- 현재 외부임시cor022-message-smoke의 실제JWT HTTP/JPA 검증과독립읽기전용리뷰진행중이다. 임시하네스는 S3업로드만 대체하고 실제sender/recipient목록·keep·차단오류 및 EventListener로 수집한2발송이벤트의 원본args/빈이름매핑/senderID를 확인하도록 작성했다. 공통FCM snapshot이 원본이라는 잘못된단언은 추가하지 않았다. 영구테스트파일은 추가하지 않는다. + +### COR022 실제 HTTP·리뷰 기록 + +- 임시클래스는 외부 Gradle init으로만 추가했다. compileTestKotlin exit0/12초 후 실제HTTPsmoke 첫 실행도 exit0/33초, 임시1case/실패0/오류0/skip0이었다. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor022-http-smoke.log`에 actual응답·이벤트·XML·자원종료증거를 보존했다. +- 실제HTTP23요청: 6개목록 양방향12GET, 문자/음성2전송, 정상keep4건 및발신자keep거부·중복keep거부·차단후text/voice거부, 무인증401을 확인했다. 200응답22건에는 기존업무오류의 success=false 응답도 포함된다. CREATOR원본 `星野原名`, USER이름·본문·URL·날짜·DTO·건수와기존text isKept=false 계약을 유지했다. +- EventListener로 실제발행2이벤트의 args=[星野原名], nameArgumentMemberIds={}, senderMemberId=1 및메시지ID를 검증했다. 음성업로드는S3외부경계만mock했고 대상서비스/저장소/publisher는실제코드다. push토큰을만들지않아실제외부FCM전송이나legacy알림snapshot저장을검증한것은아니다. 공통snapshot은기존회귀로유지한다. +- 종료: 소유fixture/token삭제단언, Tomcat/JPA/Hikari종료, HTTP58366·Redis58363 listener/프로세스없음, 부모curl58366 exit7과pgrep없음확인. 임시Kotlin/init/README3파일과빈디렉터리를삭제했으며로그만남겼다. 별도report경로override는실행전제거해정상build경로를사용했다. +- 독립리뷰 `ses_ebd2ae7baffetOadUlZZicZIPD`: APPROVE, 정적차단문제없음. 2파일HEAD일치·전용파일/참조삭제·기존업무/공통FCM/v2보존 및 승인된본문/snapshot구분을 확인했다. +- 기존테스트환경의 H2 SKIP LOCKED scheduler오류와종료thread경고는관측했지만메시지단언은통과하고자원종료도확인했다. 운영환경에서같은문제라는뜻은아니며이번범위에서는수정하지않았다. 현재임시init없는정상전체2267건회귀실행중이다. + +### COR022 최종 완료 기록 + +- 일반전체명령 `./gradlew test ktlintCheck --continue --offline --console=plain`(-I 없음): exit0, BUILD SUCCESSFUL7분55초. 로그 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-cor022-full.log`의 `COR022_GRADLE_EXIT=0`과lint성공상태를 확인했다. +- 부모가 최신XML을 직접 합산하여 **322 suites/2267 tests/실패0/오류0/skip0**을 확인했다. 기존2270건에서 승인된전용3건만제거됐다. 삭제테스트·임시smoke XML/HTML/컴파일클래스는없다. 컴파일출력의 빈cor022smoke디렉터리도 부모가 읽어0entries임을확인한뒤rmdir로제거했다. +- 부모가 MessageService/Repository HEAD diff없음 및src삭제심볼참조0건을 확인했다. 목록·발송·보관·삭제API와원본이름·차단문구번역·기존senderMemberId를보존한다. 공통FCM/v2DM/WS및관련회귀가통과했고이전판정의runtimeGate도충족했다. +- 종료확인: JPA/Hikari종료, 부모pgrep테스트executor/Redis/wrapper없음과localhost58709 curl exit7. 실행자는HTTP58697 및Redis58438도닫혔음을확인했다. 이전HTTPsmoke자원은별도정리기록대로종료됐다. +- 전체gitdiffcheck지적없음. 전체로그ERROR행0, 기존Redis종료재접속/Gradle9 deprecation경고는남았지만테스트실패나skip은없다. LSP미제공은Kotlin컴파일/ktlint로보완했다. +- 실제외부S3/FCM전송·운영MySQL·모바일·별도패키징·운영DB적용·배포·커밋·푸시는수행하지않았다. 푸시본문raw인자/공통senderSnapshot별칭가능경계는승인대로유지한다. 작업은여기서종료한다. + +### 커밋 전 검증, 2026-10-16 + +- 사용자 `/commit-with-validation` 요청에 따라 기존 구현 변경과 문서를 커밋 대상으로 확인했다. 이전 커밋 미수행 기록은 당시 이력으로 보존한다. +- 여러 도메인의 변경과 패키징을 확인하기 위해 `./gradlew test build ktlintCheck --offline --console=plain`을 실행했다. exit 0, `BUILD SUCCESSFUL in 1m 7s`, bootJar 및 build 성공이다. +- test와 ktlintCheck는 Gradle의 `UP-TO-DATE` 판정으로 기존 결과를 재사용했다. 테스트 본문을 새로 실행한 것은 아니며 기존 HTML 보고서의 2267 tests / failures 0 / ignored 0을 확인했다. +- `git diff --check` 통과. 추적 파일 추가분의 대표 credential 패턴 검색에서 후보가 없었고 변경 목록에 환경설정·credential 파일은 없었다. +- 운영 DB 적용·배포·외부 provider 및 모바일 검증은 이번 커밋 검증에 포함하지 않는다. diff --git a/docs/20261014_크리에이터이름언어별관리/prd.md b/docs/20261014_크리에이터이름언어별관리/prd.md new file mode 100644 index 00000000..894a7694 --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/prd.md @@ -0,0 +1,233 @@ +# 크리에이터 이름 언어별 관리 PRD + +> 문서 상태: COR022 레거시 메시지 전체롤백·검증 완료. 두운영파일HEAD일치, 전용projection/테스트3건 제거 후 전체2267건/실패0/오류0/skip0과lint통과, 실제HTTP23요청·발송이벤트2건·독립리뷰승인을 확인했다. 공통FCM/v2 DM은 유지하고 임시자원은 정리했다. 운영DB적용·배포·커밋·푸시는 하지 않았다. + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 작성일 / 최종 수정일 | 2026-10-14 / 2026-10-16 | +| 대상 제품 | 앱 내 크리에이터 표시 이름의 수동 언어별 관리 | +| 구현 계획 | `plan-task.md`, COR012 승인, 서버 구현 진행 기록 및 잔여 Gate 포함 | +| API Contract | COR012 승인, 구현 및 HTTP 테스트로 확인 | +| DDL 산출물 | `schema.sql` 초안 파일 작성 승인, DB 미적용·별도 승인 전 실행 금지 | +| 구현 승인 | COR012 승인. 운영 DB 적용·배포는 별도 승인 필요하며 외부 클라이언트 의존성은 차단 상태 | + +## 1. 목표와 범위 + +운영자가 크리에이터의 일본어 이름을 수동 관리하고, 앱의 크리에이터 표시 이름을 요청 언어에 따라 제공한다. 관리 쓰기/API 범위는 JA만이다. 조회는 기존 `Lang` 전부를 사용하며, 해당 언어에 활성 이름이 있으면 적용하고 없으면 현재 원본 이름으로 fallback한다. KO/EN 관리 기능은 포함하지 않는다. 저장 구조는 기존 `Lang`을 재사용한다. + +앱 크리에이터 표시 경로가 대상이되 COR017에서 사용자가 미사용이라고 확인한 AudioContentMainTab 계열은 이번 변경에서 완전히 제외한다. 탭 파일은 기존 HEAD와 같게 유지하며, 미사용 탭의 원본 닉네임 반환을 보장하기 위한 null 인자·별도 raw 모드·테스트는 요구하지 않는다. 공유 조회는 실제 사용 호출자의 언어별 이름과 제목 번역을 보존하며, 그 변경이 미사용 탭 결과에 간접 영향을 주는 것은 허용한다. AudioContentCuration 및 레거시 커뮤니티·팬톡은 COR015의 원본 이름 계약을 유지한다. API 자체와 기존 저장·권한·제목/본문 번역·알림 업무는 삭제하지 않는다. 그 밖의 레거시/v2 HTTP 및 v2 커뮤니티·팬톡은 유지한다. 사용자별 신규 전달은 각 수신자/세션 언어로 렌더링한다. 기존 언어별 역사 스냅샷과 이미 전달된 payload는 수정하지 않는다. + +일반 USER 이름, `Member.nickname` 원본, 원본 닉네임 편집기, 로그인·인증·원본 식별 흐름은 변경하지 않는다. 자동 번역도 하지 않는다. JA 별칭은 원본 닉네임이 나중에 변경되어도 계속 우선한다. 일본어 별칭은 원본 닉네임 검색과 함께 검색 가능해야 한다. + +원본은 언어가 아니라 현재 `Member.nickname`에 저장된 문자열이다. 한국어·일본어·영어 등 어떤 언어일 수도 있으며 `Lang.KO`와 동의어가 아니다. 요청 언어의 활성 CREATOR 별칭이 있으면 원본 문자열의 언어와 무관하게 별칭이 우선하고, 없거나 해제됐으면 현재 원본을 그대로 반환한다. 원본이 일본어여도 JA 별칭이 따로 등록되어 있으면 등록값을 우선한다. `Lang.KO` 기본값은 요청 언어의 기본값일 뿐 원본 언어 지정이 아니다. 원본 언어 필드나 자동 언어 감지는 추가하지 않는다. + +COR019에서 `AudioContentMainController`와 `AudioContentMainService`의 이번 이름 변경도 제외한다. 두 파일은 HEAD와 같게 되돌리고, 이 서비스만 사용하는 `findByThemeFor2Weeks`/`findAudioContentByCurationId`의 이름 언어 인자·JOIN만 제거한다. 공유 `findByTheme`의 현지화는 다른 실제 사용 API 때문에 유지하며, 이를 제외 API에 한해 차단하는 별도 raw 모드는 추가하지 않는다. 기존 제목·테마 번역, 캐시, 권한·차단·성인 필터는 유지한다. 전용으로 추가된 테스트는 실제 없으므로 공유 테스트를 삭제하지 않는다. + +COR020에서 `CharacterCommentService`/`CharacterCommentRepository`의 작성자 이름 현지화 변경도 제외한다. 두 파일을 HEAD 형태로 복원하고 댓글 목록·답글의 원댓글·최신 댓글은 기존 저장 nickname을 사용한다. 댓글 작성/답글/삭제/신고·권한·커서·건수·언어 감지 이벤트는 변경하지 않는다. 해당 이름 대체 기능 전용 통합테스트1개만 제거하며 배너·다른 이름 조회 테스트는 유지한다. + +COR021에서 `ContentSeriesRepository`/`ContentSeriesService`의 이름 현지화 변경도 HEAD로 되돌린다. 저장소5개 목록 조회의 이름 JOIN 및 서비스 상세 이름 batch만 제거되며 기존 제목·소개·장르·요일 번역과 권한·필터·순서·페이지·건수는 보존한다. 이 서비스를 재사용하는 HomeService의 오리지널/요일별 목록과 ExplorerService의 프로필 시리즈 작성자 이름도 원본으로 돌아가는 영향을 포함한다. 공통 CreatorNameJoin/CreatorLocalizedNameService 및 별도 v2 시리즈 조회는 삭제하지 않는다. 관련 테스트1개에서는 이름 현지화 단언만 제거하고 제목 번역·원문 fallback 검증을 유지한다. + +COR022에서 레거시 `/message`의 MessageService/MessageRepository 이름 변경 전체를 HEAD로 되돌린다. 문자·음성의 발신/수신/보관 목록은 기존 엔티티 조회와 원본 이름을 사용하고 차단 오류의 이름 치환도 제거한다. MessageListRow와 전용 현지화 테스트3건은 제거하되 API·발송·보관·삭제·권한·본문/시간/URL 처리 자체는 유지한다. 전체 롤백에는 문자·음성 FCM 이벤트의 nameArgumentMemberIds 추가분2곳 제거도 포함된다. 기존 senderMemberId와 공통 FCM 처리는 남으므로 푸시 본문은 원본 인자를 사용하되 알림 senderNicknameSnapshot은 수신 언어 별칭으로 저장될 수 있다. 이는 공통 알림 기능의 별도 계약이며 v2 DM/WS나 과거 snapshot까지 롤백하지 않는다. + +운영자는 문자 종류 제한 없이 JA 이름을 입력하며, 비공백 값은 문자 변경이나 음역 없이 보존한다. 저장 길이는 255 code point이며, UTF-8 byte나 UTF-16 code unit이 아닌 Unicode code point 기준으로 센다. 임의 Unicode를 허용하고 255 초과 입력은 쓰기 전에 관리 경계에서 거부하며 자르지 않는다. 빈 문자열 저장은 JA 이름 해제다. 공백-only 해석, 중복 별칭의 DB 제약/회원 ID 처리, ADMIN route/대상 판정은 기술 제안이다. + +인터뷰 명확도 자체 평가: 약 0.10. 차원별 점수는 Goal 1.0, Scope 0.9, Constraints 0.9, Success 0.9, Context 0.75다. 제품 질문은 정리됐으며 외부 시스템 경로·소유자 계약은 별도 blocker로 남는다. + +## 2. 제품 요구사항 + +| ID | 상태 | 요구사항 및 수용 사례 | +|---|---|---| +| NAME001 | 확정 | 관리자가 CREATOR 회원의 JA 이름을 수동 저장·갱신·해제한다. 빈 값 저장은 해제다. | +| NAME002 | 확정 | JA 요청은 JA 별칭을 원본보다 우선하고, 미등록이면 현재 `Member.nickname`으로 fallback한다. 원본 변경 후에도 별칭을 유지한다. | +| NAME003 | COR022 범위 보정 | AudioContentMainTab/MainController/Service, CharacterCommentService/Repository, ContentSeriesService/Repository 및 레거시 message/MessageService/Repository의 이번 이름 변경을 제외하며 별도 호환 raw 모드를 만들지 않는다. AudioContentCuration/레거시 커뮤니티·팬톡의 기존 제외 계약과 나머지 앱 표면의 언어별 이름·USER 원본 보존은 유지한다. 공통FCM과 별도v2 커뮤니티·팬톡·시리즈·DM/WS는 적용 대상이다. | +| NAME004 | 확정 | JA 별칭은 요청 locale과 무관하게 원본 닉네임 검색과 함께 검색되고 기존 contains/collation 의미와 필터가 유지된다. | +| NAME005 | 확정 | 일반 USER 이름, 원본 닉네임 편집, 인증/식별 목적의 원본 사용을 변경하지 않는다. | +| NAME006 | 확정 | 기존 언어별 역사 스냅샷과 이미 발송된 payload는 변경하지 않는다. 현재 이름 조회는 요청 언어, 신규 수신자별 payload는 수신자/세션 언어를 따른다. | +| NAME007 | COR012 승인·구현 | 빈 문자열 및 공백-only는 별칭 해제로 처리한다. 그 밖의 입력 문자는 그대로 보존하며 자동 번역·음역은 없다. 저장 상한은 255 code point다. | +| NAME008 | COR014 승인 | 관리자 회원 페이지 목록·검색의 기존 `nickname`은 원본을 유지하고, `localizedNames: Map`에 CREATOR의 모든 활성 언어별 등록 이름을 함께 반환한다. 키는 Lang.code, 미등록/해제 언어는 생략하며 미등록·일반 회원은 빈 map이다. 이름 필드는 Accept-Language로 하나를 선택하지 않는다. | +| NAME009 | COR014 승인 | 닉네임 전용 관리자 GET을 제거하고 기존 일본어 PUT의 등록·수정·소프트 해제를 유지한다. PUT의 저장 결과 응답은 유지하며 관리자 일반 콘텐츠 조회는 원본 닉네임을 유지한다. | +| NAME010 | COR018 명확화 | 원본 nickname은 언어 중립 값이다. 한국어·일본어·영어 원본 각각에 대해 요청 언어의 활성 별칭 우선/미등록·해제 시 현재 원본 fallback을 검증한다. 원본과 별칭 문자열이 같은 언어라는 이유로 별칭을 무시하지 않는다. | + +### 핵심 수용 사례 + +| 조건 | 결과 | +|---|---| +| JA 별칭 등록 후 원본 닉네임 변경 | JA 표시/검색은 별칭을 유지하고, KO/EN은 해당 언어의 활성 이름이 있으면 적용하고 없으면 현재 원본을 사용 | +| 같은 JA 별칭을 두 CREATOR에 저장 | 중복 표시명 허용 및 `(member_id, lang)` 고유 기술 설계가 구현 승인될 경우 모두 저장, 결과 회원은 중복 행 없이 구분 | +| JA 별칭이 빈 문자열 | 기술 계획은 행/값을 보존하고 비활성화, 원본 이름 표시 및 원본 검색 지속; 미등록이면 no-op | +| 해제 후 비공백 별칭을 재저장 | 비활성 포함 member+lang 조회로 같은 ID의 행 갱신·재활성화, 반복해도 한 행 유지 | +| JA 별칭이 공백만 | 별칭 해제로 처리하는 기술 제안 승인 시 원본 표시/검색 | +| CREATOR와 USER가 한 작성자 목록에 함께 있음 | CREATOR만 이름 대체, USER는 원래 값 유지 | +| 같은 사용자의 KO/JA 채팅 세션 동시 연결 | 각각 해당 세션 언어에 맞는 신규 payload 수신 | +| 기존 언어별 스냅샷 또는 과거 발송 payload | 과거 값 불변 | +| 언어 중립 캐시/랭킹 materialization | 생산자와 소비자를 조사한 후 언어별 표시 요구 충족 여부를 분류 | + +## 3. 외부 의존 및 차단 + +| ID | 외부 의존 | 담당 역할 | 영향 | 재개 조건 | +|---|---|---|---|---| +| EXT001 | 모바일/클라이언트 전체 표시 표면, locale 전달, client cache와 원본 이름 편집 흐름 | 모바일/클라이언트 소유자 | 서버 변경만으로 전체 앱 노출과 원본 편집 안전을 입증할 수 없음 | 표면 inventory, locale 계약, 캐시 무효화/수명, 원본 편집 경로 및 담당자 확인 | +| EXT002 | 운영자 관리 UI와 배포/인증 연결 | 운영도구/관리 UI 소유자 | 서버 관리 API만으로 운영자 사용자 흐름 완료 불가 | UI 저장소·담당자·인증 주체·배포 위치·연동 방식 확인 | +| EXT003 | RTC/live/chat 세션 locale 및 알림 수신자 언어 계약 | 클라이언트 및 메시징/알림 담당자 | 세션별 언어와 재연결 동작 확인 없이는 전달 언어를 보장할 수 없음 | locale 전달 방식, 변경 시 reconnect 계약, 수신자 언어 결정 시점과 end-to-end 검증 경로 확정 | + +외부 경로는 범위에서 제외되지 않는다. 소유 저장소와 파일 경로가 확인되지 않은 외부 작업은 `[blocked]`로 남긴다. 서버 계획의 완료로 앱 전체 요구를 완료 처리하지 않는다. + +## 4. 기술 조사와 구현 계약 + +테이블명과 문서·DDL 파일 작성 범위는 사용자 확정이다. `Lang`/소프트 lifecycle은 기존 수동 관리 패턴에 근거한 기술 결정이며 사용자가 삭제 방식을 직접 선택했다는 뜻이 아니다. COR012에서 서버 구현과 테스트가 승인됐다. API는 승인·구현됐고 운영 DB 적용은 별도 승인 대상이다. + +### 별도 이름 테이블과 수동 관리 설계 + +기존 회원 테이블에 언어별 닉네임 컬럼을 추가하는 방식이 아니라, **크리에이터의 언어별 표시 이름만 저장하는 별도 테이블**을 설계한다. 테이블명은 사용자 확인으로 `creator_localized_name` 확정이다. DDL 초안은 같은 디렉터리의 `schema.sql`에 작성하며 DB에는 적용하지 않는다. + +| 데이터 | 저장 위치와 의미 | +|---|---| +| 원본 닉네임 | 기존 `Member.nickname`, 변경하지 않음 | +| 크리에이터 회원 ID | 별도 테이블의 `member_id`, 기존 회원 식별자 참조 | +| 표시 언어 | entity `lang: Lang`, `@Enumerated(EnumType.STRING)` 및 `@Column(nullable = false, length = 10)`; DB `lang VARCHAR(10)`에 enum NAME `KO/EN/JA` 저장, 이번 쓰기는 명시 `JA`만 | +| 수동 등록 이름 | 별도 테이블의 `display_name VARCHAR(255)`, 해당 언어의 표시 이름; 255 Unicode code point 초과는 관리 경계에서 거부하고 자르지 않음 | +| 활성 상태 | `is_active`, 해제 시 false로 바꾸고 이름은 보존; 표시/별칭 검색에는 true만 사용 | + +`UNIQUE(member_id, lang)`은 활성/비활성 모두에 적용하고 `is_active`를 포함하지 않는다. clear는 기존 JA 행의 `is_active=false`만 설정해 `display_name`을 보존하며, 없는 행은 no-op이다. 비공백 저장은 member+lang으로 비활성까지 찾아 같은 ID를 갱신·활성화한다. 원본 `Member.nickname`, 다른 언어 행, 과거 언어별 snapshot/전달 payload는 유지한다. 마지막 비활성 값 하나를 남기는 설계이지 모든 개정 이력이나 복원 UI가 아니다. + +앱 표시 조회는 요청 `Lang`과 일치하는 활성 이름 및 대상 `Member.role=CREATOR`를 적용한다. 비활성/미등록은 현재 원본 이름으로 fallback한다. 읽기에 JA 전용 gate는 두지 않는다. 관리자 목록은 COR014에 따라 원본과 모든 활성 언어 이름을 분리해 제공하며 전용 관리 GET은 제거한다. 별칭 검색은 요청 언어와 무관하게 활성 JA·CREATOR만 포함한다. + +HTTP path/응답의 `locale: ja`는 `Lang.code`이며 DB에는 소문자 `ja`가 아닌 enum NAME `JA`를 저장한다. 요청 경계는 기존 `Lang.fromCode`/`Lang.fromAcceptLanguage`를 사용하고 서비스·repository에는 명시 `Lang`을 전달한다. 자유 문자열/새 enum/언어 converter를 추가하지 않는다. 배너의 KO 기본값은 복사하지 않으며 작성자가 언어를 명시한다. 미래 언어는 기존 `Lang`과 요청 처리를 별도 확장해야 하지만 언어별 컬럼 추가는 필요 없다. + +### 기존 패턴 비교와 근거 + +| 근거 경로·심볼 | 재사용 또는 차이 | +|---|---| +| `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt:31` (`lang`, `isActive`), `src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt:24` (`lang`, `isActive`) | 수동 언어별 관리의 기존 `Lang` enum-string/활성 상태 재사용. length 10은 새 DDL과 맞추는 명시 매핑이며 기존 KO 기본값은 재사용하지 않음 | +| `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt:140` (`deleteBanner`), `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt:202` (`delete`) | `isActive=false` 소프트 해제 근거. 이름의 동일 행 재활성화는 이번 별도 기술 계획이며 배너가 지원한다고 주장하지 않음 | +| `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt:16` (`findByIsActiveTrueAndLangOrderBySortOrderAsc`) | 기존 Lang 인자·활성 필터 읽기 패턴 재사용. 배너의 KO 대체 정책은 이름 조회에 복사하지 않음 | +| `src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt:15` (`UniqueConstraint`, `contentId`, `locale`) | 소유자+언어별 고유 행만 참고. 자동 번역의 `locale: String` 저장과 달리 수동 이름은 `lang: Lang`을 사용 | +| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt:6` (`code`, `fromCode`, `fromAcceptLanguage`), `src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt:18` (`preHandle`) | 기존 KO/EN/JA enum 및 HTTP 경계 매핑 재사용, 요청 언어를 저장 문자열로 새로 정의하지 않음 | +| `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:16` (`id`, `createdAt`, `updatedAt`) | 관리 ID/생성·수정 시각 구조 참고. SQL 기본 TIMESTAMP 정의는 문서 유지보수 규칙 적용 | + +초기 물리 DELETE 설명은 단순한 최소 기술 선택이었으며 프로젝트 관례의 필수 조건이 아니었다. 사용자는 그 이유와 기존 언어 저장 패턴 재사용을 질문했으며 소프트 삭제를 직접 선택한 것은 아니다. 위 수동 관리 근거로 현재 기술 설계를 보정했다. + +| 주제 | 제안 | 근거 및 제한 | +|---|---|---| +| 도메인/저장 | `kr.co.vividnext.sodalive.v2.creatorname` 신규 도메인 및 member+lang 고유, displayName non-unique, `VARCHAR(255)` 저장; 신규 entity는 `displayName @Column(nullable = false, length = 255)` | 신규 도메인은 v2 경량 헥사고날 구조. 255 code point를 관리 경계에서 검증하고 초과 입력은 거부한다. DDL 초안 작성만 승인, 실제 DB 호환성은 미검증. 중복 표시명 정책은 인터뷰 확정 선택이 아님 | +| 관리 API | COR014: `PUT /api/v2/admin/creators/{creatorId}/localized-names/ja`만 유지, ADMIN 보호 | 필수 `displayName: String`, 빈 값 soft clear. 기존 PUT 결과 `creatorId`, `locale: ja`, `originalNickname`, nullable `displayName` 및 ApiResponse 유지. 독립 GET은 제거하고 조회는 기존 관리자 회원 목록에 통합한다. 이번 변경의 HTTP 실행 검증은 미수행이다. | +| 관리 검증 | `Member.role == CREATOR`인 대상만 허용하며, 그 중 별도 `Member.memberKind == AI_CHARACTER`도 포함. `memberKind`만 AI_CHARACTER인 `role == USER`는 허용하지 않음 | ADMIN 보호와 role 판정은 근거 기반 기술 제안. 신규 message key만 제안하며 기존 role-edit PUT 불변 | +| 공통 이름 조회 | DB 직접 projection은 `CreatorNameJoin`으로 요청 Lang의 LEFT JOIN+COALESCE를 사용한다. 중립 캐시·수신자별 전달·principal·쓰기 결과 경계는 batch를 사용하며 현재 원본이 필요한 batch는 JOIN+COALESCE 단일 SQL, sparse 별칭이 필요한 곳은 `findLocalizedNames`를 쓴다. | 읽기는 모든 기존 `Lang`에서 활성/CREATOR만 적용하며 JA-only gate가 없다. 쓰기 조회는 비활성 포함 member+lang. 행별 쿼리·전역 Jackson 변환·principal 변경 금지. `LangContext`는 HTTP 경계에서만 읽음 | +| HTTP/DTO 호환 | 레거시/v2 응답 DTO의 field 이름·타입 보존. 필요한 조회 projection에만 내부 memberId/role 추가 | 원본 닉네임 편집 API는 로컬라이즈 값을 원본으로 저장하지 않도록 외부 계약 확인 필요 | +| 검색 | 기존 원본 조건 OR alias `EXISTS`(owner `member_id` 상관, `lang=Lang.JA`/DB `JA`, `is_active=true`, owner `Member.role=CREATOR`); containsIgnoreCase 의미 유지 | 요청 locale과 무관하게 활성 JA만 별칭 검색. Explorer channel, SearchRepository creator branch, MemberRepository의 list/count에 동일 조건과 기존 필터/USER 검색 보존. 콘텐츠·시리즈 제목·detail·tag 검색은 별칭 대상 아님 | +| 푸시/저장 한계 | `FcmSendListener.sendPush`에서 언어별 payload 생성. 기존 `PushNotificationList.senderNicknameSnapshot` mapping이 255자 이름을 보존하는지 확인 | 기존 mapping/컬럼 확장은 이번 계획에 포함하지 않는다. 과거 알림 재작성·이름 자르기 금지. 실제 저장 및 외부 payload 호환성 문제가 확인되면 별도 결정 전 해당 적용은 차단한다. | +| WebSocket | handshake `Accept-Language`를 session attribute로 저장, 미전달 기본 KO. locale 변경은 reconnect 계약 제안 | Redis pubsub에는 원본 메시지/발신 ID 유지, broker에서 세션별 새 payload 구성. 공유 DTO 변이 금지. ACK와 HTTP 응답도 별도 확인 | +| 캐시/스냅샷 | 각 producer/read path를 조사해 현재 이름 조회와 불변 historical snapshot을 구별 | 기존 `LiveRecommendCacheService`의 3시간 캐시는 key에 Lang을 포함. 다른 캐시/랭킹/inbox는 자동 면제하지 않음 | + +`schema.sql`은 단일 CREATE TABLE 초안이며 MySQL 컬럼/테이블 COMMENT와 기본 TIMESTAMP 정의를 따른다. signed BIGINT와 member FK/engine/charset 초안 근거는 `docs/plan-task/20260326_member_content_preference_ddl.sql:12`, COMMENT/TIMESTAMP 및 소유자+언어 unique/FK 형태 근거는 `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql:5`다. 복합 unique가 FK 지원 인덱스이며 추가 인덱스·displayName 고유성/인덱스·언어 기본값·cascade·check·trigger를 두지 않는다. displayName은 `VARCHAR(255)`이며 실제 DB 호환성은 미검증이다. **DDL 파일 작성 완료와 DB 적용은 별개다.** 운영 반영 전에 실제 `member.id` 타입(부호 포함)·engine과 collation/contains 및 저장 호환성을 확인하고 DB 실행을 별도 승인받는다. 현재는 DB 연결/실행이나 운영 호환성을 검증하지 않았다. + +## 5. 근거 경로 및 범위 추적 + +주요 근거와 seed inventory는 계획 문서에 실제 전체 경로 및 test target으로 분류했다. 목록은 감사 시작점이지 전수 완료 선언이 아니다. 기존 `MemberRepository` nickname 검색 경로와 legacy home/rank/recommend 및 v2 content/channel query adapter를 빠짐없이 조사한다. + +이 저장소에는 모바일 앱 및 운영 UI 소스가 없다. 외부 API, 캐시 및 RTC 소유 경로는 계약이 확인되기 전 구체 경로를 추정하지 않는다. + +## 6. 의사결정 기록 + +현재 후속 실행 범위는 COR022를 따른다. 이전 실행 금지 및 정적 승인 기록은 당시 이력으로 보존한다. + +기존 결정 로그의 행은 요약하지 않고 각각 보존한다. 아래 DEC 항목은 최초 결정의 주제를 명시한 기록이다. COR 행은 그 결정을 보정하고 우선 적용한다. + +| 날짜 | ID | 상태 | 결정 | +|---|---|---|---| +| 2026-10-14 | DEC001 | 확정 | 운영자가 일본어 이름을 수동 관리한다. 자동 생성/번역하지 않는다. | +| 2026-10-14 | DEC002 | 확정 | 입력 문자 종류 제한을 두지 않는다. | +| 2026-10-14 | DEC003 | 확정 | JA 이름은 일본어 표시에 우선 적용한다. 미등록 시 원본 이름을 사용한다. | +| 2026-10-14 | DEC004 | 확정 | 원본 `Member.nickname`은 보존한다. | +| 2026-10-14 | DEC005 | 확정 | 앱의 크리에이터 표시 표면을 대상으로 하며 혼합 목록에서도 CREATOR만 변경한다. | +| 2026-10-14 | DEC006 | 확정 | 미래 언어 가능성을 고려할 수 있지만 현재 KO/EN 이름 관리 기능은 만들지 않는다. | +| 2026-10-14 | DEC007 | 확정 | JA 별칭은 기존 원본 닉네임 검색과 함께 검색한다. | +| 2026-10-14 | DEC008 | 확정 | JA 이름 빈 값 저장은 별칭 제거이며 원본 fallback으로 돌아간다. | +| 2026-10-14 | COR001 | 정정 | 별칭은 원본 닉네임 변경 이후에도 우선한다. 미래 언어 준비는 기술 제안일 뿐 제품 범위가 아니다. | +| 2026-10-14 | COR002 | 정정 | 일반 USER와 원본 편집/인증/식별 흐름은 불변. 제품 업무 길이 제한을 추가하지 않는다. 중복 별칭 허용 및 회원 ID+locale 저장은 기술 제안이며 직접 확정된 인터뷰 선택은 아니다. | +| 2026-10-14 | COR003 | 정정, OQ-001 해결 | 원본 이름 검색을 유지하면서 JA 별칭 검색을 더한다. | +| 2026-10-14 | COR004 | 정정, OQ-002 해결 | 빈 문자열 저장으로 JA 이름을 해제한다. 공백-only 해제는 기술 해석 제안으로 별도 승인 대기다. | +| 2026-10-14 | COR005 | 정정, OQ-003 해결 | 과거 언어별 스냅샷/전달 payload는 불변. 현재 조회는 요청 언어, 새 수신자별 payload는 해당 수신자/세션 언어. 언어 중립 캐시를 자동 제외하지 않는다. | +| 2026-10-14 | COR006 | 정정 | `/admin/member` ADMIN 권한은 참고 근거일 뿐 신규 API 소유권 계약이 아니다. `/creator-admin/member` CREATOR/AGENT 관리 화면 대상이 아니다. | +| 2026-10-14 | COR007 | 정정 | PRD와 plan-task 문서 작성만 승인되었다. 구현/테스트/설정/DDL은 별도 승인 전 금지. | +| 2026-10-14 | DEC009 | 확정 | 기존 언어별 과거 스냅샷과 이미 전달된 payload는 변경하지 않는다. 요청 시 현재 이름을 조회하는 경로는 요청 언어, 신규 수신자별 payload는 수신자/세션 언어를 적용한다. | +| 2026-10-14 | COR008 | 후속 확정, COR007 범위 보정 | 사용자 확인으로 테이블명 `creator_localized_name` 확정, 기존 두 문서 보정과 같은 디렉터리 `schema.sql` 파일 작성만 승인. DB 적용·Kotlin·테스트·설정·운영 배포는 미승인이다. | +| 2026-10-14 | COR009 | 근거 기반 기술 결정 | 기존 수동 관리의 `Lang` enum-string과 `isActive`를 재사용한다. DB 키는 member+lang/enum NAME `JA`, HTTP locale은 `Lang.code`의 `ja`다. 초기 물리 DELETE는 단순 기술 선택이지 프로젝트 강제 관례나 사용자 요구가 아니며, 사용자가 소프트 삭제를 직접 선택한 것도 아니다. | +| 2026-10-14 | COR010 | 기술 lifecycle 보정 | clear는 행/값 보존·비활성화, absent는 no-op. 표시/검색/관리 GET은 비활성 제외, 재저장은 비활성 포함 조회로 동일 ID 재활성화. unique는 활성/비활성 전체 member+lang. 전체 이력/복원 기능은 포함하지 않는다. | +| 2026-10-14 | COR011 | 기술 저장 한계 보정 | 사용자는 TEXT가 불필요하다고 확인했다. `VARCHAR(255)`는 기본 JPA nickname 표현에 맞춘 기술 결정이며 사용자가 255를 직접 선택한 것은 아니다. 현재 저장은 255 Unicode code point로 제한하고 초과는 관리 경계에서 거부한다. COR002의 무길이 제한 표현은 현재 저장 한도에 대해 superseded이며 문자 종류 제한 없음은 유지한다. | +| 2026-10-14 | COR012 | 사용자 구현 승인 | 사용자: "DDL + 문서 확인 / 구현 승인" 및 ulw. 현재 문서의 API·저장·소프트 해제·길이·검색·전달 설계를 구현하고 테스트한다. 앞선 제품 구현 미승인 문구를 대체한다. 운영 DB 적용·배포는 승인하지 않았고 외부 저장소 계약은 확인 전 완료로 간주하지 않는다. | +| 2026-10-15 | COR013 | 사용자 조회 방식 수정 승인 | "3가지 경로에 각각 적절한 방식으로 조회하도록 수정하자". DB 직접 이름 projection은 요청 Lang의 LEFT JOIN+COALESCE, 중립 캐시·수신자별 전달 및 기존 원본 엔티티/쓰기 결과 경계는 batch, 이미 언어별로 렌더된 역사 스냅샷은 저장값을 유지한다. 읽기의 JA 전용 제한은 제거하되 검색 JA·관리 /ja 및 저장 정책은 유지한다. | +| 2026-10-15 | COR014 | 사용자 관리자 조회 통합 적용 승인 | "문서에 반영하고 적용한다". 별도 닉네임 GET 대신 기존 `/admin/member/list`와 공통 응답을 쓰는 목록·검색 4개 경로에 모든 활성 언어 이름을 추가한다. PUT의 등록·수정·해제와 결과 응답은 유지한다. 관리자 콘텐츠 원본 표시 및 레거시 관리자 회원 목록은 보존한다. 빌드·테스트 실행 없이 수정·정적 검토만 진행한다. | +| 2026-10-15 | COR015 | 사용자 선택적 롤백·테스트 정리 승인 | ContentMainTab/AudioContentCuration/레거시 커뮤니티·팬톡의 이번 이름 처리만 제거한다. v2와 공유 저장·권한·번역·알림, COR014 관리자 목록/PUT은 유지한다. LegacyCreatorNameRenderer 위임 래퍼는 제거하고 필요한 호출자는 core service를 직접 사용한다. 제거 기능만 검증하는 테스트와 불필요 fixture/import는 정리하되 기존 업무·v2·core 회귀 단언은 보존한다. 기존 미사용 메서드 자체 삭제는 제외하며 빌드·테스트 실행 및 커밋·푸시는 하지 않는다. | +| 2026-10-15 | COR016 | 사용자 최소 수정·통과 검증 승인 | 사용자가 전체 실행의 두 실패를 확인하고 최소 수정하여 통과시키도록 요청했다. AudioContentRepositoryLocalizedNameTest의 H2 번역 payload 적재만 기존 FORMAT JSON 패턴으로 보정한다. 운영 코드/converter/DDL/요구사항/테스트 기대값은 보존한다. 대상 및 회귀 테스트 실행을 재개하되 운영 DB 적용·배포·커밋·푸시는 하지 않는다. | +| 2026-10-15 | COR017 | 사용자 미사용 탭 완전 제외 승인 | AudioContentMainTab*은 사용하지 않으므로 이번 기능 변경에서 배제해도 무관함을 확인하고 수정 승인했다. 탭 7파일의 null 전달 13곳을 되돌려 HEAD diff를 없애고, 공유 운영6파일·테스트2파일에서는 미사용 탭 전용 raw 인자/분기/검증만 정리한다. 실사용 이름·제목 번역과 기존 다른 raw 경계는 유지한다. | +| 2026-10-15 | COR018 | 사용자 테스트·문서7파일 보정 승인 | 원본은 KO가 아니라 임의 언어의 저장값임을 명확히 했다. 공통 service/JOIN 테스트2파일, 대표 HTTP/메시지 테스트3파일, 기존 PRD/계획2파일만 보강한다. 운영 로직은 이미 요청 언어 별칭→원본 fallback이므로 변경하지 않는다. 조건을 생략한 KO 원본 설명을 고치고 기존 테스트에 원본 문자열3종 및 일본어 원본 HTTP/메시지 사례를 반영한다. 신규 테스트 파일·원본 언어 필드·자동 감지·쓰기 언어 확대는 제외한다. | +| 2026-10-15 | COR019 | 사용자 콘텐츠 메인 롤백 승인 | 조사 후 운영3파일(메인 controller/service 전체 변경 및 repository 전용2메서드 이름 변경)의 롤백을 승인했다. 두 메인 파일은 변경 목록에서 제외하고 실제 사용하는 공유 repository·core·v2 테스트는 보존한다. 전용 추가 테스트0건이므로 테스트 삭제는 하지 않는다. 새로운 원본 보호 모드·API 삭제·운영 적용은 포함하지 않는다. | +| 2026-10-16 | COR020 | 사용자 캐릭터 댓글 롤백·테스트 제거 승인 | CharacterCommentService/Repository 두 운영파일 변경을 HEAD로 되돌리고 LegacyCurrentNameReadIntegrationTest의 shouldLocalizeCharacterCommentWritersWithoutChangingCharacterNameOrUserReplies 1개 및 전용3import/서비스주입을 제거한다. 나머지8개 통합테스트와 캐릭터 배너 테스트는 유지한다. 신규 영구 테스트·API·원본 보호 모드 추가는 제외한다. | +| 2026-10-16 | COR021 | 사용자 시리즈 롤백·제목 검증 보존 승인 | 조사 결과 설명대로 운영2파일의 이름 처리6곳(목록JOIN5+상세batch1)을 롤백한다. 홈·프로필 재사용 경로에도 이름 표시 영향이 있음을 설명하고 진행 승인받았다. AudioContentRepositoryLocalizedNameTest의 혼합 시리즈 테스트는 이름별칭 fixture/기대값/단언만 정리하고 제목 번역·fallback 및 H2 FORMAT JSON 처리를 보존한다. 테스트 메서드/파일 전체 삭제나 새 raw 모드 추가는 하지 않는다. | +| 2026-10-16 | COR022 | 사용자 메시지 전체 롤백 승인 | 조사 결과대로 MessageService/Repository 전체HEAD복원, 전용 MessageListRow 및 MessageServiceLocalizedNameTest(3건) 파일 삭제를 승인했다. 푸시 본문 메타데이터2곳 제거·senderMemberId 및 공통 snapshot 현지화 유지라는 영향을 포함한다. 레거시 API/업무 자체와 공통 FCM·v2 DM/WS 테스트는 삭제하지 않는다. | + +DEC003/005/007/008은 각각 원래 사용자 선택을 요약한 결정 기록이다. 공백-only 해석, 중복 별칭의 구체 저장 제약, 구체 API route/ADMIN 관리 경계는 후속 기술 제안이지 별도 인터뷰에서 명시된 사용자 선택이 아니다. +COR002의 member+locale·무길이 제한 표현과 COR007의 DDL 작성 금지는 당시 기록으로 보존하며 현재 범위/저장 결정은 COR008~011이 우선한다. + +## 7. 진행 판정 + +- `plan-task.md`는 NAME001~007, EXT001~003을 구체 task와 검증에 연결한다. +- COR013의 JOIN-T1 공통 계약과 JOIN-T2 DB projection 전환은 구현 및 focused 증거 확인 완료. JOIN-T3 경계 assertion은 통과했으나 최종 전체 build/QA Gate 결과는 대기 중이다. +- 외부 모바일/운영 UI/RTC 계약은 차단 조건·담당 역할·재개 증거를 유지한다. +- COR012까지의 과거 전체 검증은 아래 이력으로 보존한다. COR013 최종 실행은 현재 미완료이며 `plan-task.md`의 COR013 진행 기록만 근거로 삼는다. 외부 앱 전체 표시, 운영 UI, RTC/client 계약 및 운영 DB/provider 한계도 미검증이며 제품 완료를 선언하지 않는다. +- 운영 DB 적용·운영 배포는 별도 승인 대상이다. 커밋·푸시는 별도 사용자 승인 없이 수행하지 않는다. + +## 8. 구현 및 검토 상태, 2026-10-14 + +- 기반 저장/API, legacy 및 v2 내부 HTTP/검색 소비자, 서버 FCM/WebSocket/live/message 경로가 구현됐다. 세 inventory의 변경 기록과 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-integrated-full.log`의 최종 성공 실행을 근거로 한다. +- 당시 최종 전체 실행: 322 suites, 2245 tests, failures 0, errors 0, skipped 0. `assemble`와 `ktlintCheck` 포함 명령이 `BUILD SUCCESSFUL`로 종료됐다. 이는 COR013 전 과거 이력이다. 이전 200 fixture/context 실패 실행과 로그도 당시 기록으로 보존한다. +- 테스트가 실제 관리 API JWT HTTP를 사용했다. 테스트 서버는 HTTP 53418, WebSocket 53427에서 기동됐고 teardown curl은 양 포트 연결 거부(exit 7)를 확인했다. Hikari pool 종료를 로그에서 확인했으며 테스트 후 잔류 JVM/Redis 프로세스는 보고되지 않았다. +- 전체 앱 클라이언트 표시/원본 편집 안전성, 운영 관리 UI, RTC reconnect/provider 및 실제 운영 DB 적용은 확인되지 않았다. EXT001~003과 운영 DB/provider 호환성은 blocker다. +- Oracle 검토에서 두 보완점이 확인됐다. 현재 이름 fallback 경로의 `findAllById` entity graph 조회를 scalar ID/nickname 조회로 바꾸고, 공통 helper 두 개를 `v2.creatorname` 하위로 이동한다. cache 실제 hit를 입증하는 테스트 assertion도 추가할 필요가 있다. 수정 담당자가 작업 중이며 아직 수정·재검증·리뷰 승인 완료로 기록하지 않는다. +- 검증 환경에 `lsp_diagnostics` 도구가 없어 LSP 진단은 수행하지 못했다. compile, 전체 테스트, assemble, ktlint 결과는 위 실행 로그가 근거다. + +## 9. 최종 서버 검증, 2026-10-15 + +- 리뷰 지적 2건 해결: 이름 전용 현재 원본 조회를 `MemberQueryRepository.findNicknamesByIds` scalar 조회로 교체했고 공통 renderer/predicate를 `v2.creatorname` 아래로 이동했다. 1명/4명 조회에서 JA SQL 2/2회, KO·EN 1/1회, 엔티티 로드 0/0을 테스트로 확인했다. +- COR013 전 당시 전체 회귀: 322 suites, **2249 tests / failures 0 / errors 0 / skipped 0**, `test assemble ktlintCheck --continue --offline --console=plain` 성공. 이후 상위 에이전트의 `./gradlew build --offline --console=plain`도 성공했다. 이 결과는 COR013 이후 최종 검증을 뜻하지 않는다. +- 실제 표면 재검증: 상위 에이전트가 ADMIN HTTP 4개와 localhost HTTP/WebSocket 9개를 별도 실행해 **13개 모두 통과**했다. 포트 54539/54547의 Tomcat·Hikari 종료와 후속 curl exit 7, 테스트 JVM/Redis 잔존 없음도 확인했다. +- 최종 독립 리뷰: Oracle의 수정분 재검토 **APPROVE**, 중요한 잔여 코드 지적 없음. 실제 캐시 entry의 원본과 방 목록 쿼리 1회 재사용까지 검증했다. +- 외부 모바일/운영 UI/RTC 및 실제 provider/운영 MySQL 호환성은 미검증으로 유지한다. 운영 DB 적용·배포·커밋·푸시는 실행하지 않았다. 상세 명령·로그는 계획 문서 최종 기록을 따른다. + +## 10. 조회 경로별 처리, COR013 + +1. DB에서 이름을 직접 조회하는 목록·projection은 `CreatorNameJoin(owner, lang, alias)`로 `creator_localized_name`을 LEFT JOIN한다. 회원 ID·요청 언어·활성 별칭·CREATOR 역할 조건은 ON에 두고 `COALESCE(display_name, nickname)`를 선택한다. 후속 별칭 SELECT와 이름 전용 copy/map을 제거한다. 검색 조건·정렬·집계·페이지·공개 DTO는 보존한다. +2. 중립 캐시와 Redis payload, 수신자/세션 언어가 늦게 정해지는 전달, 인증 principal 및 기존 원본 엔티티를 유지해야 하는 쓰기/오류 경계는 필요한 batch를 유지한다. 현재 원본까지 필요한 batch는 member+별칭 JOIN의 단일 SQL로 처리하고 sparse 별칭 map은 명시 fallback 의미가 있는 곳에만 쓴다. 세 inventory의 잔여 호출 분류를 따른다. +3. 이미 언어별로 렌더해 저장한 역사 이름은 그대로 반환한다. 중립 랭킹/inbox/cache는 historical rendered snapshot과 구별하며, cache hit에서도 현재 이름을 렌더한다. + +읽기 서비스와 저장소는 전달받은 기존 `Lang`을 사용한다. JA/KO/EN 각각 활성 이름이 있으면 해당 값을, 없거나 비활성이면 원본을 반환한다. 현재 관리자 입력 /ja 및 저장의 일본어 범위, 검색의 JA 별칭 EXISTS는 이번 조회 리팩터링에서 확장하지 않는다. `Member.nickname` 원본과 인증/편집 경계는 그대로 둔다. 승인된 세 inventory의 COR013 최신 구간이 개별 소비자와 남은 batch를 분류한다. + +### COR013 구현 및 검증 상태, 2026-10-15 + +- JOIN-T1 공통 `CreatorNameJoin` 및 모든 Lang의 sparse/current-name 조회 계약, JOIN-T2의 직접 DB projection 전환은 구현됐다. legacy/v2/live/message/DM 목록 경로는 각 inventory의 COR013 manifest를 기준으로 한다. +- JOIN별 owner alias는 고유하게 지정한다. 삭제 prefix 정리는 raw 원본 이름에만 적용하며 수동 별칭 원문은 바꾸지 않는다. 기존 count/group/page/filter와 공개 DTO는 보존한다. +- 기준선은 `creatorname-join-baseline.log` 성공(1m53s). core 43개 RED 중 13개 assertion 실패 뒤 GREEN 43개 통과(`creatorname-join-core-green.log`); consumer 3개 RED는 이름 출력이 아닌 추가 alias SELECT 기대 0/실제 1을 포함했다(`creatorname-join-consumer-red.log`). 수정 후 consumer 3개 통과, alias SELECT 0 확인(`creatorname-join-first-green.log`, 2m37s). +- 첫 전체 실행 `creatorname-join-full.log`은 테스트 2266개가 통과했으나 ktlint 서식 오류로 실패했다. 서식 보정 후 `creatorname-join-final-green.log`의 전체 test/build/ktlint가 성공했다. 이후 실사용이 없어진 보조 메서드 2개와 해당 passing helper 테스트 1개만 제거하고 전체 검증을 다시 실행했다. +- 최종 `./gradlew test build ktlintCheck --continue --offline --console=plain` 성공: **2265 tests / failures 0 / errors 0 / skipped 0**, `creatorname-join-release-check.log`의 `BUILD SUCCESSFUL in 13m 45s`. 실제 ADMIN JWT HTTP 4개와 localhost HTTP/WebSocket 9개도 포함되어 통과했다. +- 대표 콘텐츠·검색·라이브 추천의 별칭 전용 후속 SELECT는 각각 1회에서 0회로 줄었다. 캐시 current-name batch는 요청 Lang에 무관하게 1 SQL이며 sparse 수신자 override와 역사 스냅샷 보존도 검증했다. 운영 지연 시간 개선을 측정한 것은 아니다. +- Oracle 3-path 리뷰에 중대한 코드 지적이 없었고, 미사용 보조 메서드 제거 후 수정분 재검토도 APPROVE다. 최종 실행 결과로 runtime 조건을 충족했다. HTTP 58173/WS 58186 종료 및 후속 curl exit 7, 테스트 JVM/Redis 잔존 없음도 확인했다. +- 제품 전체 완료는 별개다. 모바일/client, 운영 UI, RTC/provider 및 실제 MySQL 호환성은 외부 확인이 필요하며 운영 DB 적용·배포·커밋·푸시는 수행하지 않는다. + +## 11. 관리자 조회 통합, COR014 + +- 대상: `/admin/member/list`, `/admin/member/search`, `/admin/member/creator/list`, `/admin/member/creator/search`. 공통 `GetAdminMemberListResponseItem`에 `localizedNames`만 추가하고 기존 필드·totalCount·순서·페이지 크기는 유지한다. 이는 의도적인 관리자 JSON 응답 확장이다. +- 원본 `nickname`은 그대로 두고 CREATOR의 모든 활성 등록 이름을 `{"ja":"등록 이름","en":"등록 이름"}` 형태로 제공한다. 특정 언어로 필터링하거나 원본을 미등록 언어에 채우지 않는다. soft 해제된 값은 노출하지 않으며 일반 회원과 별칭 없는 회원은 `{}`다. +- 회원 페이지를 먼저 읽고 해당 페이지의 CREATOR ID들에 대한 모든 활성 언어 이름을 한 번에 조회한다. 다언어 1:N 조인으로 회원 페이지/건수가 증가하지 않도록 기존 페이지 쿼리는 보존하고 회원별 추가 조회는 하지 않는다. 역할·로그인 방식 등의 기존 번역 문구는 유지한다. +- `/search-by-nickname`과 `/creator/all/list`의 별도 간단 DTO, 관리자 콘텐츠/시리즈/댓글의 원본 닉네임 표시, 일반 앱의 COR013 언어별 표시·JA 검색은 변경하지 않는다. +- 독립 GET과 더 이상 운영 호출이 없는 단건 조회 서비스는 제거한다. `CreatorLocalizedNameResponse`는 PUT 확인 응답에 계속 사용하므로 유지한다. 별도 POST/DELETE, 전체 언어 신규 GET, 레거시 API 제거, H2 보정/DDL 변경은 추가하지 않는다. +- 실행 제한: 테스트를 먼저 작성하되 사용자 빌드 금지에 따라 Gradle·컴파일·테스트·앱 실행을 하지 않는다. 실제 RED/GREEN·HTTP 성공은 미검증으로 기록하며 이전 통과 수치를 이번 변경의 증거로 재사용하지 않는다. + +### COR014 적용 결과 + +- 공통 관리자 목록 항목에 `localizedNames`를 추가했다. 기존 페이지 조회 후 CREATOR ID의 모든 활성 이름을 단일 scalar 조회로 묶으며, `nickname`과 기존 페이지/count 쿼리는 유지한다. 간단 선택 API와 관리자 콘텐츠 원본 이름 조회는 변경하지 않았다. +- 전용 닉네임 GET 및 미사용 `getLocalizedName` 서비스를 제거했다. PUT과 그 확인 응답 `CreatorLocalizedNameResponse`는 유지한다. 새 전체 언어 GET이나 별도 POST/DELETE는 추가하지 않았다. +- 관리자 4개 목록/검색, Accept-Language 독립, 다언어 페이지 중복 방지, USER·해제 제외, 빈 ID/일괄 쿼리 수, 제거한 GET, 기존 PUT lifecycle·권한·255/256 code point 테스트 코드를 작성·보정했다. 실행하지 않았다. +- 독립 리뷰는 **PASS(static)**이며 명백한 호출·타입·권한·페이지 계약 문제는 발견되지 않았다. 빌드/테스트/DB/HTTP 실행 성공을 의미하지 않는다. 사용하지 않는 레거시 API 제거와 H2 보정 변경은 이번 범위 밖으로 유지했다. diff --git a/docs/20261014_크리에이터이름언어별관리/reviews/cor015-legacy-trim-review.md b/docs/20261014_크리에이터이름언어별관리/reviews/cor015-legacy-trim-review.md new file mode 100644 index 00000000..af61f6da --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/reviews/cor015-legacy-trim-review.md @@ -0,0 +1,47 @@ +# COR015 레거시 이름 정리 정적 리뷰 + +## 리뷰 정보 + +- 날짜: 2026-10-15 +- 대상: 기존 미커밋 COR012~014 변경 위의 COR015 선택적 롤백·테스트 정리. 전체 HEAD diff를 이번 변경으로 간주하지 않는다. +- 기준: 같은 디렉터리 상위 `prd.md` COR015, `plan-task.md` LEGACY-TRIM-T1의 S1~S3. +- 독립 검토 세션: `ses_ec179d026ffeTbyj1KNLcvwUpg`. +- 판정: **APPROVE(static)**. 확정 발견 사항 없음. 실행 검증 승인이나 테스트 통과 판정은 아니다. + +## 확인 결과 + +| 기준 | 정적 확인 | +|---|---| +| S1 제외 영역 원본 표시 | 7개 콘텐츠 탭의 중첩 목록·랭킹·추천·태그·큐레이션, 레거시 커뮤니티 목록/상세/첫 댓글/댓글/답글/구매 및 팬톡 목록/작성/수정에서 원본을 사용한다. 기존 삭제 회원 접두사 처리를 보존한다. | +| S2 유지 대상 보존 | nullable 이름 언어는 공유 조회의 원본 모드만 제어하며 제목 번역 언어는 유지한다. Home 등 유지 호출자는 요청 Lang을 전달한다. v2 커뮤니티·팬톡 JOIN, 관리자 회원 목록의 localizedNames와 PUT을 보존한다. | +| S3 의존성·테스트 정리 | 삭제 래퍼/helper/필드의 src 참조가 없다. 테스트 생성자·조회 인자는 production과 일치하며 실제 core 빈이 필요한 4개 테스트에 설정을 유지한다. 래퍼의 SQL 수·역할·빈 입력·현재 원본 fallback 단언은 core 서비스 테스트에 통합했다. | + +검토한 주요 테스트는 `AudioContentRepositoryLocalizedNameTest`, `ExplorerLocalizedNameEndToEndTest`, +`LegacyCurrentNameReadIntegrationTest`, `ExplorerServiceTest`, `LegacyFanTalkReplyCharacterizationTest`, +`CreatorLocalizedNameServiceTest`다. 삭제 테스트는 제거된 래퍼 전용이며 기존 업무·번역·권한 및 v2 회귀는 유지한다. +새 raw/shared fixture와 단언에서 정적으로 확정 가능한 실패는 발견하지 못했다. + +## 검증과 한계 + +- 부모와 검토자의 `GIT_MASTER=1 git diff --check`: 오류 없음. +- 부모의 `rg` 잔여 참조/공유 caller 대조 및 미추적 변경 테스트 4개 `git diff --no-index --check`: 지적 없음. +- 사용자 제한으로 Gradle·빌드·컴파일·테스트·lint·앱·HTTP·DB 실행은 하지 않았다. LSP 도구도 제공되지 않는다. +- 실제 SQL 실행, Spring 컨텍스트 기동, HTTP 회귀는 미검증이다. COR013의 과거 통과 기록으로 대체하지 않는다. +- 검증용 프로세스·포트·DB 연결을 생성하지 않았다. 운영 적용·배포·커밋·푸시도 하지 않았다. +- 확정 회귀 수정 Task 전환 항목 없음. 실행 허용 후 COR014/COR015의 focused·영향 회귀 검증이 필요하다. + +## COR016 실행 결과에 따른 후속 판정 + +- 사용자 전체 실행에서 2271건 중 2건 실패가 확인됐다. 앞선 정적 리뷰는 H2 번역 JSON fixture 차이를 놓쳤으며 실행 통과 증거로 사용하지 않는다. +- 확정 항목 `REV-COR016-001`: AudioContentRepositoryLocalizedNameTest의 두 새 번역 fixture가 JSON 객체 대신 JSON 문자열로 저장된다. 랭킹은 converter 역직렬화 예외, 시리즈는 빈 번역 제목의 원문 fallback으로 실패한다. 요구사항·기대값은 유효하고 두 테스트 모두 유지 대상이다. +- 소유 Goal: `JSON-FIXTURE-T1`. 수정은 두 fixture에 기존 H2 FORMAT JSON 적재 패턴을 추가하는 것으로 제한했다. 운영 코드와 단언은 그대로다. +- focused 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryLocalizedNameTest' --offline --console=plain` exit 0, 6건 모두 통과, 22초. 실제 H2/JPA 조회에서 닉네임 raw/KO/JA와 번역 제목 단언을 확인했다. +- 전체 회귀·ktlint 및 수정분 독립 리뷰: 진행 중. 그 결과 확인 전 전체 완료로 간주하지 않는다. + +### COR016 최종 결과 + +- 독립 좁은 리뷰 `ses_ec1576ac3ffesO9K10ayy0JKzr`: **APPROVE**. H2-only scoped update와 flush/clear 순서, 단언 보존을 확인했다. +- 전체 테스트: 323 suites/2271 tests/실패0/오류0/skip0. 첫 복합 명령은 test lint 줄바꿈 오류로 종료 코드 1이므로 전체 명령 성공으로 기록하지 않는다. +- 후속 형식 보정: 테스트의 인자 줄바꿈과 이전 HomeService 래퍼 전환 호출의 줄바꿈 1곳을 정리했다. 운영 의미와 기대값 변화는 없다. +- 최종 focused+전체 ktlint 명령: 종료 코드 0, 40초, 대상 클래스6건+HomeService1건 모두 통과하고 ktlintCheck도 통과했다. 전체 회귀 재실행은 형식 변경뿐이므로 생략했다. +- `REV-COR016-001` 수정·검증 완료. 부모가 전체/후속 XML 집계와 테스트 프로세스 잔존 없음, HomeService 공백 제거 내용 동일을 확인했다. 상세 명령·로그는 plan-task.md의 COR016 기록을 따른다. diff --git a/docs/20261014_크리에이터이름언어별관리/reviews/cor017-unused-tab-review.md b/docs/20261014_크리에이터이름언어별관리/reviews/cor017-unused-tab-review.md new file mode 100644 index 00000000..39637418 --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/reviews/cor017-unused-tab-review.md @@ -0,0 +1,24 @@ +# COR017 미사용 탭 제외 리뷰 + +## 범위와 판정 + +- 날짜: 2026-10-15 +- 기준: PRD COR017, 계획 UNUSED-TAB-T1. 기존 COR012~016 전체 diff가 아니라 미사용 탭 전용 코드 제거 변경만 평가한다. +- 검토 세션: `ses_ec125e618ffej4zvXb6whEPb0z`. +- 판정: **APPROVE**, 차단 문제 없음. 미사용 탭의 원본 닉네임 보존은 요구하지 않는다. + +## 근거 + +| 기준 | 결과 | +|---|---| +| S1 탭 무변경 | content/main/tab의 git diff HEAD가 비어 있다. 7개 서비스의 명시 null 인자13곳이 제거됐으며 API/파일 자체는 유지한다. | +| S2 실사용 조회 보존 | Main/Theme/Home의 언어 전달과 랭킹·시리즈 제목 locale을 유지한다. nameLang 인자를 제거하고 랭킹의 기존 nullable locale 경계 및 큐레이션 raw 조회는 보존한다. | +| S3 테스트 유효성 | 실사용 KO/JA 이름·USER/원본 fallback·제목 번역/fallback·큐레이션2경로 검증과 FORMAT JSON 적재를 유지한다. ID 충돌 시 다음 고유 콘텐츠 ID를 사용하는 fixture 보정은 기존 assertNotEquals와 기능 단언을 약화하지 않는다. | + +## 실행 증거 + +- focused 최초17건 중 fixture ID 가정 실패1건을 확인하고 같은 지정 테스트 파일만 보정했다. 최종17건 및 ktlint 통과, 21초. 두 결과는 creatorname-cor017-focused.log에 보존했다. +- 전체: `./gradlew test ktlintCheck --continue --offline --console=plain` exit0, 8분36초. 부모가 XML을 합산해 323 suites/2271 tests/실패0/오류0/skip0을 확인했다. 로그는 임시 opencode 디렉터리의 creatorname-cor017-full.log다. +- 실제 H2/JPA·기존 HTTP/WS 회귀 포함. JPA/Hikari 종료, 관련 프로세스 잔존 없음, localhost:52527 후속 curl 연결 실패(exit7)를 확인했다. +- 최종 탭 HEAD diff 없음과 git diff --check 무지적. 검토 시 대기하던 전체 실행 Gate도 해소됐다. +- 신규 회귀 수정 Task 전환 항목 없음. 운영 DB/배포/커밋/푸시는 하지 않았으며 운영 MySQL·실제 모바일/provider 검증은 수행하지 않았다. diff --git a/docs/20261014_크리에이터이름언어별관리/schema.sql b/docs/20261014_크리에이터이름언어별관리/schema.sql new file mode 100644 index 00000000..e344f3a0 --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/schema.sql @@ -0,0 +1,16 @@ +-- DDL 파일 작성 완료, DB 미적용. 별도 승인 전 실행 금지 +-- 운영 적용 전 실제 member.id 타입(부호 포함), member 테이블 engine 및 collation을 확인한다. + +CREATE TABLE creator_localized_name ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '식별자', + member_id BIGINT NOT NULL COMMENT '크리에이터 회원 ID (member.id 참조)', + lang VARCHAR(10) NOT NULL COMMENT 'Lang enum 이름 (KO/EN/JA), 현재 관리 대상 JA', + display_name VARCHAR(255) NOT NULL COMMENT '수동 등록 표시 이름 (비활성 시 마지막 값 보존)', + is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성 여부 (0: 이름 해제, 1: 표시 및 별칭 검색 대상)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + CONSTRAINT uk_creator_localized_name_member_lang UNIQUE (member_id, lang), + CONSTRAINT fk_creator_localized_name_member + FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='크리에이터 언어별 수동 표시 이름'; diff --git a/docs/20261014_크리에이터이름언어별관리/v2-name-inventory.md b/docs/20261014_크리에이터이름언어별관리/v2-name-inventory.md new file mode 100644 index 00000000..da439b81 --- /dev/null +++ b/docs/20261014_크리에이터이름언어별관리/v2-name-inventory.md @@ -0,0 +1,370 @@ +# P2 v2 HTTP 이름 적용 지도 및 GREEN 인계 + +> 2026-10-15 COR013의 현재 구현 상태는 문서 마지막의 **COR013 JOIN 전환 manifest**를 따른다. 위아래의 COR012 batch 설계/검증 이력은 당시 기록으로 보존한다. COR013 runtime GREEN은 부모의 직렬 검증 전까지 미확인이다. + +## 상태와 경계 + +- 기준: `prd.md` COR012 및 `plan-task.md` P2-T1, NAME002/003/005/006. 첫 단계의 통합 테스트 4개에 이어 아래 앱 표면의 운영 연결과 필수 생성자/fixture 적응을 구현했다. PRD/plan, 설정, DDL, core/legacy 전달 코드는 수정하지 않았다. +- **구현됨, 부모 직렬 GREEN 검증 대기**다. 부모 RED 로그 `creatorname-consumer-red.log` 100~110행에서 네 테스트의 실제 이름 assertion 실패를 확인했다. 아래 모든 HTTP 행과 blocked-message 행은 구현했으며 테스트/빌드/린트 통과나 수동 HTTP QA 완료를 주장하지 않는다. +- 경로 약어: `M/` = `src/main/kotlin/kr/co/vividnext/sodalive/`, `T/` = `src/test/kotlin/kr/co/vividnext/sodalive/`. 아래 경로는 이 prefix를 붙인 정확한 기존 파일이다. +- `v2.usercreatorchat`, `v2.creatorname`은 다른 소유자의 수정 영역이다. 추가 승인된 `v2.chat` DTO/service/controller 및 `chat/room/repository/ChatRoomRepository.kt`의 AI projection만 이 세션에서 수정했다. 검색/legacy/비동기 전달은 부모 작업이다. + +## 공통 적용 결정 + +1. 기존 `M/i18n/LangInterceptor.kt#preHandle`이 `Accept-Language`를 해석한다. 각 HTTP controller에서 `LangContext.lang`을 읽어 facade의 명시 `lang: Lang` 인자로 전달한다. 이름 기능 때문에 내부 서비스에서 request scope를 새로 읽거나 언어를 다시 추정하지 않는다. 기존 콘텐츠 번역 로직의 언어 선택은 별도 기능으로 보존한다. +2. facade가 조회 결과의 **실제 이름 주인 member ID**를 합쳐 `CreatorLocalizedNameService.findLocalizedNames(memberIds, lang)`을 batch 호출한다. 응답 전 domain/record `copy`로 `map[id] ?: originalName`을 선택한다. 하나의 목록에 DB 행별 호출을 하지 않는다. 공유 DTO, JPA `Member.nickname`, principal, 저장 snapshot을 변이하지 않고 공개 DTO field 이름/타입도 유지한다. +3. CREATOR가 댓글 작성자/답글 작성자/후원자로 등장해도 적용 대상이다. `writerId`, donor `memberId`로 찾으며 채널 owner ID를 대신 쓰지 않는다. USER는 같은 이름 필드여도 그대로다. foundation의 활성 JA + 현재 `Member.role == CREATOR` 필터를 사용하고 `memberKind`만으로 판정하지 않는다. +4. 중립 ranking snapshot 이름은 현재 원본이 아니다. 이 두 ranking consumer는 owner IDs로 `MemberRepository.findNicknamesByIds`도 한 번 호출해 엔티티 로드 없이 현재 원본을 확보하고 JA map을 우선한다. KO/EN 또는 JA 미등록은 **현재** nickname으로 fallback한다. 삭제된 owner의 기존 표시 정책은 유지하며 저장 row를 갱신하지 않는다. 차단 마스킹된 creator ID `0`/빈 이름을 복구하지 않는다. + +## 작성한 실제 회귀 테스트 + +| 정확한 기존 파일 / 새 심볼 | 실제 경로와 기대 동작 | +|---|---| +| `T/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt#shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles` | `GET /api/v2/audio/contents?type=AUDIO`: 저장 후 원본을 변경해도 JA 별칭 우선, KO는 변경된 원본, JA 미등록 작성자는 원본, audio title 불변. 별도 USER를 먼저 저장해 content ID와 owner member ID를 분리하며 내부 owner ID는 JSON에 노출하지 않는다. | +| `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt#shouldLocalizeCreatorAuthorsAndLatestRepliesWithoutChangingUserNames` | `GET /api/v2/creator-channels/community-posts/{postId}`: post creator, CREATOR root comment, USER root comment, CREATOR latestReply를 함께 저장한다. JA는 CREATOR만 별칭이며 USER는 잔존 별칭 row가 있어도 원본이다. 본문은 이름 치환 대상이 아니다. | +| `T/v2/api/home/CreatorRankingControllerTest.kt#shouldRenderCurrentLocalizedNamesWithoutRewritingRankingSnapshots` | `GET /api/v2/home/rankings/creators`: 원본 이름으로 중립 snapshot 저장 후 회원 원본 변경. JA 별칭/JA 미등록 현재 원본/KO 현재 원본을 검증하고 DB snapshot 이름과 회원 원본 보존을 단언한다. | +| `T/v2/api/home/HomeRecommendationControllerTest.kt#shouldLocalizeNativeDebutCreatorResultsByRequestLanguage` | `GET /api/v2/home/recommendations/debut-creators`: 실제 live 활동으로 native CTE 후보를 만든다. 원본 변경 후 KO 현재 원본과 JA 별칭을 검증한다. repository/query service를 mock하지 않는다. | + +픽스처는 기존 `saveMember`, `saveAudioContent`, `saveCommunity`, `saveCommunityComment`, `saveSnapshot`, `saveLiveRoom`과 실제 JPA 저장을 재사용한다. 별칭만 허용된 H2 `JdbcTemplate` INSERT로 저장했다. `member_id`, enum NAME `'JA'`, `display_name`, `is_active`, BaseEntity 시각 컬럼을 명시하며 production DDL 실행/새 schema 생성/반사 호출은 없다. USER의 alias row는 관리 API가 USER 저장을 허용한다는 뜻이 아니라 역할 변경 후 잔존 행과 같은 읽기 필터 회귀를 재현한다. 기존 번역/외부 미디어 mock은 유지했으며 이름 서비스는 mock하지 않았다. + +### 부모 RED 명령 + +foundation 준비 후 한 번에 하나씩 실행한다. 첫 단계는 새 메서드만 좁혀서 실제 이름 assertion 실패인지 확인한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest.shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.shouldLocalizeCreatorAuthorsAndLatestRepliesWithoutChangingUserNames' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest.shouldRenderCurrentLocalizedNamesWithoutRewritingRankingSnapshots' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLocalizeNativeDebutCreatorResultsByRequestLanguage' +``` + +예상 RED 지점은 각각 `creatorNickname` JA 기대값, post/comment/reply JA 이름, ranking `nickname` JA 기대값, native debut `creatorNickname` JA 기대값이다. 테이블 미생성, 컴파일/컨텍스트 실패, SQL fixture 실패는 RED 증거로 취급하지 않는다. GREEN 뒤에는 같은 명령과 아래 HTTP owner의 전체 class 회귀를 수행한다. + +### foundation 선행 인터페이스 확인 + +- 조사 시작 때 파일이 없었지만 인계 전 실제 소스를 다시 읽었다. `M/v2/creatorname/application/CreatorLocalizedNameService.kt#findLocalizedNames(memberIds: Collection, lang: Lang): Map`와 `#saveLocalizedName(creatorId: Long, lang: Lang, displayName: String)`가 현재 존재한다. +- `M/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt`는 `creator_localized_name` 및 위 INSERT 컬럼, enum-string `Lang`, BaseEntity 상속과 일치한다. `M/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt`의 ADMIN PUT도 확인했다. 시그니처 추측이나 임시 타입은 추가하지 않았다. +- 후속 상태: 부모가 foundation 18 tests PASS 및 검토 완료를 전달했고 consumer RED도 실행했다. 초기 조사 당시의 repository `emptyMap()` 미구현 관찰은 더 이상 현재 blocker가 아니다. 이번 소비자는 실제 foundation 필수 생성자 의존성을 사용하며 nullable/optional service 우회가 없다. + +## HTTP 표면별 정확한 적용 지점 + +아래 모든 행은 **구현됨, GREEN 대기**다. 각 controller는 `LangContext.lang`을 명시 전달하고 facade는 batch와 immutable copy를 수행한다. 아래 초기 설계와 달리 following news는 각 subtype 대신 상위 `HomeFollowingNews.creatorMemberId` 하나에 joined owner를 보존했다. live는 `HomeFollowingLive.creatorMemberId`를 사용한다. 공개 DTO에는 두 필드를 추가하지 않았다. + +| 표면 / 응답 이름 | 기존 파일과 심볼 / 필요한 최소 변경 | HTTP test owner | +|---|---|---| +| 홈 추천 `lives`, `recentlyActiveCreators`, `recentDebutCreators`, `firstAudioContents`, `genreCreators[].creators`, `cheerCreators`, `popularCommunityPosts`의 `creatorNickname` | `M/v2/api/home/adapter/in/web/HomeRecommendationController.kt#getHomeRecommendations/getLives/getDebutCreators` → `M/v2/api/home/application/HomeRecommendationFacade.kt#getHomeRecommendations/getLives/getRecentDebutCreators`. 섹션 결과를 모은 후 ID 합집합 batch. banner의 국가 기반 `bannerLang`은 이름 언어로 재사용 금지. `HomeFirstAudioContentRecord.creatorId`는 이미 존재한다. | `T/v2/api/home/HomeRecommendationControllerTest.kt` | +| 홈 live `creatorNickname` | 위 `getLives` 및 `M/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt#getOnAirLives` → `M/v2/api/home/live/application/HomeOnAirLiveFacade.kt#getOnAirLives`. 공유 `M/v2/recommendation/port/out/HomeRecommendationQueryPort.kt#HomeLiveRecommendationRecord`에 내부 `creatorMemberId` 추가, `M/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt#findLiveRecommendations`의 QueryDSL constructor projection에 `member.id` 추가. `liveRoomId/roomId`로 alias 조회 금지. | `T/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt` | +| 홈 creator ranking `items[].nickname` | `M/v2/api/home/adapter/in/web/CreatorRankingController.kt#getCreatorRankings` → `M/v2/api/home/application/HomeCreatorRankingFacade.kt#getCreatorRankings`. `M/v2/ranking/application/CreatorRankingQueryService.kt#getCreatorRankings` 결과의 `items[].creatorId`를 batch key로 사용. snapshot 원본이 아닌 현재 Member fallback도 batch 조회. 기존 block mask와 rank/rankChange/isNew 유지. | `T/v2/api/home/CreatorRankingControllerTest.kt` | +| 팔로잉 creator/live/schedule/news 이름 및 recentChats | `M/v2/api/home/following/adapter/in/web/HomeFollowingController.kt#getFollowingTab` → `M/v2/api/home/following/application/HomeFollowingFacade.kt#getFollowingTab`. `M/v2/home/following/domain/HomeFollowing.kt`: `HomeFollowingCreator.creatorId`, `HomeFollowingSchedule.creatorId`, `HomeFollowingCreatorRankingNews.creatorId` 재사용. `HomeFollowingLive`, `HomeFollowingContentNews`, `HomeFollowingCommunityPostNews`에 내부 owner 추가. `M/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt#findOnAirLives/findRecentNews/toHomeFollowingNews`에 현재 join한 `creator.id` selection/전달 추가. `audioContent`, `photoContent`, `communityPost`의 이름과 creatorRanking `nickname`을 분기별 copy. recentChats는 아래 v2.chat 소유 경계 사용. | `T/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` | +| 전체 콘텐츠 audio/series `creatorNickname` | `M/v2/api/content/all/adapter/in/web/MainContentAllController.kt#getContents` → `M/v2/api/content/all/application/MainContentAllFacade.kt#getContents`. `M/v2/content/all/domain/MainContentAll.kt#MainContentAllAudio/MainContentAllSeries`에 내부 `creatorMemberId` 추가. `M/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt#findAudios/findSeries`에서 전달. audio row는 owner를 이미 select하지만 domain에서 버린다. series는 `member.id` selection도 추가. title 번역/정렬/pagination 불변. | `T/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` | +| 콘텐츠 추천 5종 audio card `creatorNickname` | `M/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt#getRecommendations` → `M/v2/api/content/recommendation/application/AudioRecommendationFacade.kt#getRecommendations`. `M/v2/content/recommendation/domain/AudioRecommendation.kt#AudioCard`에 내부 owner 추가. `M/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt#audioRows/toAudioCards`의 이미 select한 `audioContent.member.id`를 card까지 전달. `latestAudios/newAndHotAudios/freeAudios/pointAudios/recommendedAudios` 합집합 batch. `OriginalSeries`, `CommentedAudio`는 이름 필드 없음. | `T/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt` | +| 콘텐츠 overview `items[].creatorNickname` | `M/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt#getContents` → `M/v2/api/content/overview/application/ContentOverviewFacade.kt#getContents/getNewAndHotContents/getFirstAudioContents`. NEW_AND_HOT은 위 `AudioCard` owner 추가 사용. FIRST_AUDIO는 `M/v2/recommendation/port/out/HomeRecommendationQueryPort.kt#HomeFirstAudioContentRecord.creatorId`가 이미 있으므로 새 owner 필드 불필요. native first-audio CTE 결과 이름도 batch copy. | `T/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt` | +| 오디오 ranking `items[].creatorNickname` | `M/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt#getRankings` → `M/v2/api/content/ranking/application/AudioRankingFacade.kt#getRankings`. `M/v2/content/ranking/domain/AudioRanking.kt#AudioRankingItem`에 내부 `creatorMemberId` 추가. `M/v2/content/ranking/application/AudioRankingQueryService.kt#toItem`에서 기존 snapshot record owner를 버리지 않고 전달. facade가 현재 Member + alias batch로 선택. locale별 content title은 손대지 않음. | `T/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt` | +| 채널 home `creator.nickname`, `notices/communities[].creatorNickname`, `fanTalk.latestFanTalk.nickname`, `channelDonations[].nickname` | `M/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt#getHome` → `M/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt#getHome`. `M/v2/creator/channel/home/domain/CreatorChannelHome.kt`의 creator/post/member IDs 재사용. donation만 내부 donor memberId 추가: 같은 파일 `CreatorChannelDonation`, `M/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt#CreatorChannelDonationRecord`, `M/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt#findChannelDonations`, `M/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt#toDomain`. 모든 이름 주인 합집합 batch, fan/donor가 CREATOR인 경우도 적용. | `T/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` | +| 채널 community tab/detail 및 댓글/답글 `creatorNickname/writerNickname` | `M/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt#getCommunityTab/getCommunityPostDetail/getCommunityComments/getCommunityReplies` → `M/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`의 같은 네 심볼. `M/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt#CreatorChannelCommunityPost.creatorId`, `M/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt#CreatorChannelCommunityComment/CreatorChannelCommunityReply.writerId`가 존재. detail은 post/comment/latestReply ID를 합쳐 한 번 lookup. 별도 댓글/답글 endpoint도 각각 처리. translator의 postId와 이름의 memberId를 혼용하지 않는다. | `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` | +| 채널 fanTalk와 creatorReplies `writerNickname` | `M/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt#getFanTalkTab` → `M/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt#getFanTalkTab`. `M/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt#CreatorChannelFanTalk/CreatorChannelFanTalkReply.writerId` 합집합으로 batch. USER 팬 이름 보존, CREATOR 작성자/답글은 적용. | `T/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt` | +| 채널 donation `rankings[].nickname`, `donations[].nickname` | `M/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt#getDonationTab` → `M/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt#getDonationTab`. `M/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt#CreatorChannelDonationRanking.userId`는 실제 donor member ID다. donation list는 donor ID를 추가: 같은 파일 `CreatorChannelDonation`, `M/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt#CreatorChannelDonationRecord`, `M/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt#findChannelDonations`, `M/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt#toDomain`. USER 불변, CREATOR donor만 적용. | `T/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` | +| 기프트 목록/상세 `counterpartNickname`, `giftInfo.recipientCreatorNickname/senderNickname` | `M/v2/api/gift/adapter/in/web/GiftController.kt#getGifts/getGiftDetail`가 명시 Lang 전달. facade 없는 기존 구조이므로 `M/v2/gift/application/GiftQueryService.kt#getGifts/getGiftDetail/toGiftListItem`의 결과 조립에 resolver 주입. 목록은 이미 batch 조회한 `membersById`의 counterpart IDs, 상세는 이미 읽은 sender/recipient IDs를 묶어 lookup. `applicationNo/giftId`는 key가 아님. `senderInfo.name`, 수령인 실명/주소, categoryNameSnapshot, status/sizeName은 치환 금지. command 응답에는 회원 이름이 없어 수정하지 않음. | `T/v2/api/gift/adapter/in/web/GiftControllerTest.kt` | + +## 정상 응답 밖의 표시 경계 + +- 채널 audio/series/live 정상 응답에는 creator 이름이 없다. `M/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`, `M/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`, `M/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`, `M/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`의 title/seriesName을 creator 이름으로 치환하거나 필드를 신설하지 않는다. +- 단, 아래 일곱 query service는 blocked-access 메시지에 `String.format(messageTemplate, creator.nickname)`을 넣는다. 이 이름도 사용자에게 표시된다. 각각 controller → facade → 해당 함수로 `lang: Lang`을 전달하고 **오류 분기에서만** 대상 creator 한 명을 resolver batch API로 조회해 format한다. 정상 응답 facade lookup과 중복 조회하지 않는다. 기존 번역/권한/차단 정책과 message key는 유지한다. + +| 정확한 query service / 심볼 | 명시 Lang 전달 facade | 기존 HTTP test owner | +|---|---|---| +| `M/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt#getHome` | `M/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt#getHome` | `T/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` | +| `M/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt#getCommunityTab` | `M/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt#getCommunityTab` | `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` | +| `M/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt#getFanTalkTab` | `M/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt#getFanTalkTab` | `T/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt` | +| `M/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt#getDonationTab` | `M/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt#getDonationTab` | `T/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` | +| `M/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt#getAudioTab` | `M/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt#getAudioTab` | `T/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt` | +| `M/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt#getSeriesTab` | `M/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt#getSeriesTab` | `T/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt` | +| `M/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt#getLiveTab` | `M/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt#getLiveTab` | `T/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveEndToEndTest.kt` | + +audio/series/live의 controller 파일은 각각 `M/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`, `M/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`, `M/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt`다. 성공 DTO는 그대로 두고 위 오류 경계의 명시 Lang 인자만 전달한다. + +## neutral snapshot / native producer-consumer 분류 + +| 기존 파일 / 심볼 | 분류와 구현 결정 | +|---|---| +| `M/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt#aggregateAllCandidates/toCandidate` → `M/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt` → `M/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt` → `M/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt` → `CreatorRankingQueryService#getCreatorRankings` | native SQL이 당시 원본 `Member.nickname`을 저장하며 Lang 컬럼/수신자별 렌더링이 없는 **중립 materialization**이다. 역사 면제 아님. 조회 현재 이름 선택만 추가하고 저장 snapshot/rank/score는 불변. 새 ranking 회귀가 이 구분을 고정한다. | +| `M/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt#AGGREGATION_SQL` → `M/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt` → `M/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt` → `M/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt#toRecords/toRecord` → `AudioRankingQueryService#toItem` | `c.member_id`, `m.nickname`을 저장하는 중립 creator 이름. consumer가 locale별 `ContentTranslation` title을 고르지만 creator 이름이 언어별 역사 데이터가 되는 것은 아니다. owner 전달 + 현재 이름 batch 필요. title/renderedPayload/기존 snapshot row는 불변. | +| `M/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt` 및 `M/v2/recommendation/application/RecommendationSnapshotRefreshService.kt` → `M/v2/recommendation/application/HomeRecommendationQueryService.kt#findCheerCreatorRecommendations/findPopularCommunityRecommendations` | section/targetId/score 후보 snapshot이고 표시 이름의 언어 snapshot이 아니다. detail 조회(`findCheerCreatorRecommendationDetails`, `findPopularCommunityRecommendationDetails`)에서 현재 member ID와 원본을 얻어 facade에서 batch. targetId는 section에 따라 creator 또는 community resource이므로 alias key로 그대로 사용하지 않음. | +| `M/v2/home/following/application/HomeFollowingNewsPublishService.kt#publishToFollowers` → `M/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt` → `DefaultHomeFollowingQueryRepository#findRecentNews/toHomeFollowingNews` | Lang 없는 중립 inbox. producer는 raw creatorNickname/title/body를 저장하지만 **HTTP consumer는 creator를 join해 현재 nickname을 다시 읽는다**. creator-ranking title에 저장된 과거 nickname은 현재 응답의 이름 원천이 아니다. consumer의 joined owner ID를 전달해 언어 선택. inbox 저장값/producer는 수정하지 않음. | +| `DefaultHomeRecommendationQueryRepository#findRecentlyActiveCreators/findRecentDebutCreators/findFirstAudioContents/findCreatorsByGenre` | native SQL에서 현재 creator 이름을 읽는 current lookup. 결과 record의 creatorId로 batch한다. 새 native debut HTTP 회귀는 CTE/query/service를 실제 실행하도록 작성했다. | +| `M/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` | card의 이름은 현재 QueryDSL member join. native most-commented 조회에는 creator 이름 응답이 없어 comment 본문/title을 치환하지 않음. recommendation candidate snapshot도 현재 card를 다시 조회하므로 이름 저장 변경 불필요. | + +이 v2 조사에서 **이미 수신 언어로 렌더된 역사 이름 snapshot으로 면제할 HTTP 이름 필드는 확인되지 않았다**. `snapshot`이라는 클래스명, content title 번역 또는 과거 생성 시점만으로 면제하지 않는다. + +## 다른 소유자와의 연결 및 원본 유지 영역 + +- `M/v2/chat/controller/ChatRoomListController.kt#getRooms` → `M/v2/chat/service/ChatRoomListService.kt#getRooms/toResponse`: DM의 `targetName`은 creator 또는 USER 상대 회원 이름이므로 대상이다. AI `targetName`은 `M/chat/room/repository/ChatRoomRepository.kt#findAiChatListRooms`의 `pc.character.name`이며 **캐릭터 제목이므로 제외**한다. 최근 메시지 본문도 이름 문자열 치환 대상이 아니다. +- 초기 조사에서 DM owner ID는 `M/v2/chat/dto/ChatRoomListDtos.kt#ChatRoomListQueryDto`에서 누락되어 있었다. 후속 승인에 따라 DTO/AI projection은 이 세션이, `M/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt#findDmChatListRooms`의 `opponent.member.id` projection은 delivery 소유자가 각각 구현했다. 이 세션은 DM repository를 수정하지 않았다. roomId/character creatorMemberId로 이름을 치환하지 않는다. +- 후속 구현: `ChatRoomListService`가 최종 `pageRows`의 DM member IDs를 batch 처리한다. `HomeFollowingFacade`는 같은 명시 Lang으로 이미 선택된 recentChats를 받아 다시 치환하지 않는다. 실제 계약은 `ChatRoomListQueryDto(roomId, chatType, targetName, targetImagePath, lastMessage, messageType, lastMessageAt, opponentMemberId: Long?)`다. trailing 필드에 default는 없다. AI는 `cast(null as long)`, delivery 소유자가 변경한 DM JPQL은 마지막 `opponent.member.id`를 전달함을 read/rg로 확인했다. 공개 `ChatRoomListItemResponse`는 변경하지 않았다. 해당 소유자의 변경은 이 세션의 수정 파일에 포함하지 않는다. +- 앱이 아닌 운영 식별/편집 응답은 원본 유지: `M/v2/gift/application/GiftAdminQueryService.kt#getGifts/getGiftDetail` 및 `M/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt`, `M/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt#toResponse`, `M/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt#toResponse`, `M/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt#list/createReply/updateReply`. 앱 DTO를 재사용하더라도 DTO 전역 변환을 적용하지 않는다. admin nickname 검색/원본 편집/배송 실명은 별칭 표시 대상과 분리한다. +- `M/v2/api/home/application/HomeRecommendationFacade.kt#HomeAiCharacterRecommendationRecord.toItem`의 `name`, content/series/live title, event banner, `GiftStatus.displayName`, `GiftSize.displayName`은 creator 이름이 아니며 수정하지 않는다. ADMIN charge/ranking job API는 작업 상태 응답이며 creator display-name 필드가 없다. + +## GREEN 생성자·fixture 적응 지도 + +현재 테스트 생성자와 의존성을 읽고 `rg`로 직접 생성 지점을 대조해 아래 적응을 수행했다. 기존 facade 단위 테스트는 필수 resolver mock과 명시 Lang을 전달한다. controller slice의 `@MockBean LangContext` getter는 `Lang.KO`로 stub했다. 새 기능의 실제 DB/HTTP 검증은 위 통합 테스트가 담당하며 기존 assertion은 완화하지 않았다. `GiftQueryServiceTest`에는 foundation service/repository를 실제 import했다. + +| 정확한 기존 test 파일 | 최소 적응 | +|---|---| +| `T/v2/api/home/application/HomeRecommendationFacadeTest.kt`; `T/v2/api/home/HomeRecommendationControllerTest.kt` | 현재 `HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")`. 새 resolver 및 `lang` 호출 인자 추가. 후자는 장애 로그 test에서도 facade를 3곳 직접 생성하므로 Spring 주입만 보고 끝내지 않음. | +| `T/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`; `T/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` | facade resolver/lang 추가와 `HomeLiveRecommendationRecord` owner fixture 추가. QueryDSL positional constructor와 모든 fake port live record도 동기화. | +| `T/v2/api/home/following/application/HomeFollowingFacadeTest.kt`; `T/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`; `T/v2/home/following/application/HomeFollowingQueryServiceTest.kt` | facade resolver/lang 및 chat service 명시 Lang stub. `HomeFollowingLive/ContentNews/CommunityPostNews` 내부 owner fixture 추가. 공개 response DTO와 JSON 필드는 추가하지 않음. | +| `T/v2/api/content/all/application/MainContentAllFacadeTest.kt`; `T/v2/content/all/application/MainContentAllQueryServiceTest.kt`; `T/v2/api/content/all/dto/MainContentAllTabResponseTest.kt` | 현재 `MainContentAllFacade(queryService)`에 resolver 추가, facade 호출 lang 추가. `MainContentAllAudio/Series`의 필수 내부 owner fixture를 모두 채움. | +| `T/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`; `T/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`; `T/v2/api/content/overview/application/ContentOverviewFacadeTest.kt`; `T/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt` | `AudioRecommendationFacade(queryService)` 및 overview의 기존 두 query service/preference/host 의존성은 유지하고 resolver/lang 추가. 모든 `AudioCard` fixture owner 추가. FIRST_AUDIO record는 기존 creatorId 사용. | +| `T/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`; `T/v2/api/content/ranking/dto/AudioRankingResponseTest.kt` | `AudioRankingFacade(queryService)`에 resolver 및 현재 Member batch 조회 의존성/lang 추가. `AudioRankingItem` 내부 owner fixture 추가. contentId를 owner로 쓰지 않는 assertion. | +| `T/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`; `T/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`; `T/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` | home facade resolver/lang, donor record/domain owner fixture, query service의 오류 분기 resolver/lang 의존성 및 controller facade stubs 조정. `CreatorChannelHomeQueryServiceTest`의 donation copy 기대값에도 필드 전파. | +| `T/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`; `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`; `T/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` | 현재 `CreatorChannelCommunityFacade(service)`를 여러 메서드에서 직접 생성. resolver/lang 추가, detail/comment/latestReply 합집합 검증. query service 오류 분기에도 명시 Lang/resolver 전달. | +| `T/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`; `T/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`; `T/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`; `T/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`; `T/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt` | facade/query service resolver와 명시 Lang, donor record/domain owner fixture 추가. writer/donor가 USER면 원본, CREATOR면 alias. ranking은 `LegacyCreatorChannelDonationRankingAdapter`가 넘긴 donor `userId` 재사용. | +| `T/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`; `T/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`; `T/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`; `T/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`; `T/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt`; `T/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt` | 성공 title DTO는 그대로. blocked-access 이름을 위한 명시 Lang forwarding 및 query service resolver 생성자 추가. 기존 controller MockMvc forwarding stubs의 추가 Lang도 일치시킴. | +| `T/v2/gift/application/GiftQueryServiceTest.kt#queryService`; `T/v2/api/gift/adapter/in/web/GiftControllerTest.kt` | 기존 helper는 여섯 repository + host로 `GiftQueryService`를 직접 생성한다. resolver 추가, `getGifts/getGiftDetail`에 Lang 지정. `@DataJpaTest` helper에는 실제 foundation service/repository를 구성하거나 import하며 조회 서비스를 mock해 버리지 않음. HTTP는 실제 queryService를 사용하고 기존 commandService mock은 유지. | +| `T/v2/chat/ChatRoomListServiceTest.kt`; `T/v2/chat/ChatRoomListControllerTest.kt` | service/controller 직접 생성 인자와 query DTO의 DM owner/AI null fixture, Lang 호출 인자를 조정. AI character name 불변과 DM USER 보존 확인. | +| `T/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`; `T/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`; `T/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`; `T/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` | 기존 실제 persistence test에서 새 내부 owner ID가 resource ID와 다른 fixture로 올바르게 전달되는지 단언. projection constructor 순서와 current joined ID 전달 검증. | + +위 표 외에도 owner 필드를 추가하는 각 data class의 생성 위치는 GREEN 직전에 전체 `M/`, `T/`에서 다시 `rg`한다. repository의 `Projections.constructor`, JPQL `select new`, Kotlin positional fixture는 공개 JSON 변화 없이 모두 맞춰야 한다. optional owner default `0`으로 컴파일만 통과시키지 않는다. + +## GREEN 구현 범위와 검증 소유 + +- 운영 수정 후보는 위 HTTP 표의 controller/facade, owner를 잃는 domain/port/projection adapter, 두 ranking의 현재 이름 batch consumer, 기프트 query result 조립, 채널 오류 분기 query service로 제한한다. response DTO `from`은 기존 공개 필드를 그대로 복사하므로 원칙적으로 수정할 필요 없다. +- 읽기 근거로만 등장한 snapshot entity/producer/native aggregation, following inbox producer, admin 원본 편집기는 수정 후보가 아니다. 두 타 소유 영역은 조정 전 수정 금지다. `v2.chat` DM projection은 소유자 인계 항목이다. +- 첫 4개 테스트는 대표 RED이지 전체 surface 완료 증거가 아니다. GREEN에서는 HTTP 표의 각 owner class에 JA/KO/EN/미등록/비활성/혼합 USER 및 필요한 owner-ID 회귀를 추가하고 해당 exact class만 우선 실행한다. fixture/생성자 적응은 기존 단위/adapter 회귀로 확인한다. +- 검증 실행 예시는 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest'`다. 다른 행도 `T/` 아래 package와 class를 그대로 FQCN으로 사용한다. 단계 마지막에 부모가 `./gradlew ktlintCheck`, `./gradlew assemble`을 직렬 실행한다. +- 이 단계는 부모 RED 증거와 소스·fixture·필드·시그니처의 read/rg 대조만 수행했다. Gradle/서버 실행/DB 연결/commit/push는 하지 않았다. 사용 가능한 도구에 `lsp_diagnostics`가 없어 IDE 진단도 수행하지 못했다. 부모의 실제 GREEN 결과를 기다린다. + +### 추가 확인한 중립 후원 랭킹 캐시 + +`M/explorer/profile/CreatorDonationRankingService.kt#getMemberDonationRanking`의 Redis key는 Lang을 포함하지 않고 raw nickname이 담긴 `MemberDonationRankingListResponse`를 캐시한다. 이 파일과 캐시 저장은 수정하지 않았다. `CreatorChannelDonationFacade`는 legacy adapter가 넘긴 donor `userId`를 batch Member 조회해 현재 원본으로 fallback하고 같은 aggregate의 JA map을 우선한다. 목록 donor와 ranking donor의 alias lookup은 합집합 한 번이다. 따라서 중립 donor cache도 역사 면제로 남기지 않았다. + +### 정적 점검 결과와 남은 검증 + +- content/live/ranking/following/donor 내부 owner 생성 위치를 `src/main`, `src/test` 전체에서 `rg`해 모든 직접 생성 fixture를 적응했다. JPQL `select new`의 AI/DM trailing ID와 QueryDSL constructor의 donor ID 첫 인자/live owner 마지막 인자를 대조했다. +- `findLocalizedNames`는 정상 목록/aggregate별 한 번이며 blocked-access 경로는 오류 분기에서만 한 명을 batch API로 조회한다. snapshot current fallback은 batch Member 조회이고 `0L` 마스킹은 ID 집합에서 제외한다. `LangContext`는 새 이름 기능에서 controller 경계만 읽는다. +- public response DTO, snapshot entity/producer, inbox producer, core/legacy, `FcmEvent`는 수정하지 않았다. AI character name/content title/본문 및 배송 실명·주소는 치환하지 않는다. +- 부모의 `creatorname-foundation-final-lint.log`를 읽었으며 첫 단계 v2 테스트 4개에 해당하는 lint 지적은 없었다. 다른 소유자 테스트의 lint는 변경하지 않았다. 변경 파일의 길이/공백을 `rg`로 점검했으며 전체 lint 실행을 대신한 것으로 주장하지 않는다. +- 길이 검색에서 `DefaultHomeRecommendationQueryRepository.kt`의 기존 raw SQL 두 줄은 130자를 넘는다. 이번 변경은 live owner projection 한 필드이며 그 SQL 본문은 변경하지 않았다. 이 검색 결과를 새 Kotlin lint 실패로 단정하거나 무관한 SQL 포맷 변경으로 확장하지 않는다. +- 내부 연결의 알려진 누락은 없다. 남은 불확실성은 실제 compile/test/lint, H2/Hibernate의 typed-null JPQL 실행 및 전체 HTTP 응답 회귀다. 특히 `v2.chat`의 AI/DM 생성자 계약은 부모 통합 GREEN에서 검증해야 한다. + +### 부모 직렬 GREEN 명령 + +먼저 위 네 메서드의 동일 명령으로 GREEN을 확인한 뒤 다음 정확한 기존 class 회귀를 실행한다. 명령은 이 세션에서 실행하지 않았다. + +```bash +./gradlew compileTestKotlin +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest' +./gradlew ktlintCheck +./gradlew assemble +``` + +### 이번 GREEN 단계 실제 운영 수정 파일 + +아래는 `M/` 기준이다. 기존 표의 나머지 producer/DTO/admin 파일은 읽기 근거이지 수정 파일이 아니다. + +```text +v2/api/content/all/adapter/in/web/MainContentAllController.kt +v2/api/content/all/application/MainContentAllFacade.kt +v2/api/content/overview/adapter/in/web/ContentOverviewController.kt +v2/api/content/overview/application/ContentOverviewFacade.kt +v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt +v2/api/content/recommendation/application/AudioRecommendationFacade.kt +v2/api/content/ranking/adapter/in/web/AudioRankingController.kt +v2/api/content/ranking/application/AudioRankingFacade.kt +v2/content/all/domain/MainContentAll.kt +v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt +v2/content/recommendation/domain/AudioRecommendation.kt +v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +v2/content/ranking/domain/AudioRanking.kt +v2/content/ranking/application/AudioRankingQueryService.kt +v2/api/home/adapter/in/web/HomeRecommendationController.kt +v2/api/home/application/HomeRecommendationFacade.kt +v2/api/home/adapter/in/web/CreatorRankingController.kt +v2/api/home/application/HomeCreatorRankingFacade.kt +v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt +v2/api/home/live/application/HomeOnAirLiveFacade.kt +v2/api/home/following/adapter/in/web/HomeFollowingController.kt +v2/api/home/following/application/HomeFollowingFacade.kt +v2/recommendation/port/out/HomeRecommendationQueryPort.kt +v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +v2/home/following/domain/HomeFollowing.kt +v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt +v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt +v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt +v2/creator/channel/home/domain/CreatorChannelHome.kt +v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt +v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt +v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt +v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt +v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt +v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt +v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt +v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt +v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt +v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt +v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt +v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt +v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt +v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt +v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt +v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt +v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt +v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt +v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt +v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt +v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt +v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt +v2/api/gift/adapter/in/web/GiftController.kt +v2/gift/application/GiftQueryService.kt +v2/chat/dto/ChatRoomListDtos.kt +v2/chat/service/ChatRoomListService.kt +v2/chat/controller/ChatRoomListController.kt +chat/room/repository/ChatRoomRepository.kt +``` + +### 이번 GREEN 단계 실제 테스트 적응 파일 + +아래는 `T/` 기준이다. 첫 단계 네 assertion의 기대값은 유지했다. 이 단계에서는 그중 `HomeRecommendationControllerTest`의 기존 직접 facade 생성/호출도 적응했으며 다른 세 RED 파일은 변경할 필요가 없었다. + +```text +v2/api/content/all/application/MainContentAllFacadeTest.kt +v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt +v2/api/content/all/dto/MainContentAllTabResponseTest.kt +v2/content/all/application/MainContentAllQueryServiceTest.kt +v2/api/content/overview/application/ContentOverviewFacadeTest.kt +v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt +v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt +v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt +v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt +v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt +v2/api/content/ranking/application/AudioRankingFacadeTest.kt +v2/api/content/ranking/dto/AudioRankingResponseTest.kt +v2/api/home/application/HomeRecommendationFacadeTest.kt +v2/api/home/HomeRecommendationControllerTest.kt +v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt +v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt +v2/api/home/following/application/HomeFollowingFacadeTest.kt +v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt +v2/api/home/following/dto/HomeFollowingTabResponseTest.kt +v2/home/following/application/HomeFollowingQueryServiceTest.kt +v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt +v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt +v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt +v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt +v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt +v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt +v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt +v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt +v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt +v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt +v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt +v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt +v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt +v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt +v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt +v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt +v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt +v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt +v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt +v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt +v2/gift/application/GiftQueryServiceTest.kt +v2/chat/ChatRoomListServiceTest.kt +v2/chat/ChatRoomListControllerTest.kt +``` + +## COR013 JOIN 전환 manifest, 2026-10-15 + +### 범위와 인계 상태 + +- 승인 근거는 `prd.md` 10절과 `plan-task.md` JOIN-T1~T3다. 이 단계는 `v2.creatorname`, `v2.usercreatorchat`, `v2.chat` 및 core/legacy를 수정하지 않았다. `v2.chat` 전체는 delivery 소유자에게 넘어갔으며 아래 팔로잉 recentChats의 기존 명시 Lang 계약만 소비한다. +- 부모 RED 로그는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-join-consumer-red.log`다. `MainContentAllEndToEndTest.shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles`는 이름 출력 assertion 뒤 standalone alias SELECT 기대 0/실제 1에서 실패했다. 기존 2249 전체 PASS와 JOIN 기반 43 PASS는 변경 전/공통 모듈 기준선이며 이번 v2 결과로 재사용하지 않는다. +- 모든 소유 current DB 이름 projection은 기존 owner에 `CreatorNameJoin(owner, lang, uniqueAlias)`의 `name/condition/displayName`을 명시 LEFT JOIN/ON/select한다. 조건은 공통 helper의 owner ID + 요청 Lang + active + CREATOR ON이다. JA-only 읽기 guard를 만들지 않았으며 관리자 /ja 쓰기와 JA 검색은 그대로다. +- 아래 DB 읽기에서 facade 별칭 service 의존성, 후속 이름 SELECT와 이름 전용 copy/map을 제거했다. 제목 번역, 페이지 구성, CDN 변환 및 차단 마스킹 등 이름과 다른 기존 변환은 유지한다. DTO는 `val` 기반이며 회원·snapshot·inbox entity를 변이하지 않는다. + +### DB 읽기 전환 지점 + +각 행의 경로는 앞서 정의한 `M/` prefix를 적용한다. query adapter 외에 같은 행의 service/port/facade도 함께 수정했다. + +| 경로/심볼 | 현재 구현과 호출 계약 | +|---|---| +| `v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt#findAudios/findAudioRows/findSeries` | 기존 `locale`을 `Lang.fromCode(locale)`로 재사용한다. audio/series의 실제 owner 이름을 JOIN으로 읽고 인기순 audio GROUP BY에는 raw nickname과 별칭 displayName을 함께 명시했다. `countAudios/countSeries/findSeriesIds`에는 이름 조인을 넣지 않았다. `v2/content/all/application/MainContentAllQueryService.kt#getContents(..., lang: Lang)`와 `v2/api/content/all/application/MainContentAllFacade.kt`가 언어를 전달한다. `v2/content/all/port/out/MainContentAllQueryPort.kt`의 기존 locale 계약은 유지한다. | +| `v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt#audioRows/toAudioCards` | 최신/무료/포인트/ID 기반 New&Hot·추천 card 모두 같은 JOIN을 사용한다. 기존 locale을 재사용하며 most-commented native query는 이름 출력이 없어 변경하지 않았다. `v2/content/recommendation/application/AudioRecommendationQueryService.kt#getRecommendations(member, lang)/findNewAndHotAudios(member, offset, limit, lang)`와 `v2/api/content/recommendation/application/AudioRecommendationFacade.kt`, `v2/api/content/overview/application/ContentOverviewFacade.kt`에서 전달한다. | +| `v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt#findLiveRecommendations/findCheerCreatorRecommendationDetails/findPopularCommunityRecommendationDetails` | live creator, 응원 creator, community owner 각각 고유 alias의 QueryDSL JOIN이다. community 집계 GROUP BY에 별칭 displayName을 추가하고 기존 countDistinct·필터·순서를 유지한다. `v2/recommendation/port/out/HomeRecommendationQueryPort.kt`와 `v2/recommendation/application/HomeRecommendationQueryService.kt`에 필수 Lang을 전달한다. | +| `v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt#findRecentlyActiveCreators/findRecentDebutCreators/findFirstAudioContents/findCreatorsByGenre` | native 현재 SELECT 네 곳에 LEFT JOIN + COALESCE를 넣었다. 최근 활동/장르는 후보 ranking/grouping 뒤 outer display owner에 조인하고 debut/first-audio는 현재 member에 조인한다. native `:lang`은 `lang.name` 또는 `Lang.fromCode(locale).name`으로 bind한다. CTE/window/rank/order/limit, 후보 ID/count query와 snapshot 생성 SQL은 보존한다. `findGenreCreatorRecommendations`의 private helper까지 Lang을 전달하며 first-audio port는 기존 locale을 사용한다. | +| `v2/api/home/application/HomeRecommendationFacade.kt`; `v2/api/home/live/application/HomeOnAirLiveFacade.kt` | 요청 Lang을 위 query service에 명시 전달하고 JOIN된 record를 그대로 응답 조립에 사용한다. country 기반 `bannerLang`은 배너 전용이며 creator 이름 언어로 쓰지 않는다. | +| `v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt#findFollowingCreators/findOnAirLives/findMonthlySchedules/findRecentNews` | 팔로잉 creator/on-air/live schedule/audio schedule/news의 현재 owner 이름을 JOIN으로 읽는다. `v2/home/following/port/out/HomeFollowingQueryPort.kt`의 네 메서드와 `v2/home/following/application/HomeFollowingQueryService.kt#findHomeFollowing(member, lang)`에 필수 Lang을 추가했다. news는 중립 inbox의 이름이 아니라 이미 조인한 현재 creator 이름을 선택한다. | +| `v2/api/home/following/application/HomeFollowingFacade.kt#getFollowingTab` | 위 JOIN 결과를 사용하며 `ChatRoomListService.getRooms(member, lang=lang, followedCreatorsOnly=true, ...)` 계약은 그대로다. recentChats를 다시 이름 치환하지 않는다. | +| `v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt#findCreator/findChannelDonations/findFanTalkSummary` | creator/donor/latest-fanTalk writer에 각각 JOIN한다. `v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`의 세 읽기 메서드에 필수 Lang을 추가했고 `v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`에서 전달한다. notices/communities도 아래 community service에 같은 Lang을 전달한다. `v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`의 batch와 copy들을 제거했다. | +| `v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt#findCreator/findCommunityPosts/findHomeCommunityPosts/findCommunityPost/findCommunityComments/findLatestRepliesByCommentIds/findCommunityReplies` | post creator와 각 comment/reply writer를 JOIN한다. tuple alias는 `postCreatorDisplayName/commentWriterDisplayName`으로 지정하고 raw 회원 nickname field를 alias로 쓰지 않는다. `v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`의 해당 메서드와 `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt#findHomeCommunityPosts/getCommunityPostDetail/getCommunityComments/getCommunityReplies`에 필수 Lang을 추가했다. `v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`는 JOIN 결과를 바로 매핑한다. | +| `v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt#findCreator/findFanTalks/findCreatorReplies` | creator와 root/reply writer 이름을 JOIN한다. `v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt` 세 메서드에 필수 Lang, `v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`의 private reply helper까지 전달한다. `v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`의 alias dependency/copy를 제거했다. | +| `v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt#findCreator/findChannelDonations` | creator/donor의 직접 DB 읽기를 JOIN으로 바꿨다. `v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt` 두 메서드에 필수 Lang, `v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`가 전달한다. `v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`는 DB donation 목록에 후속 치환하지 않고 중립 cache ranking만 current batch한다. | +| `v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt#findCreator`; `v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt#findCreator`; `v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt#findCreator` | 각 `v2/creator/channel/{audio,series,live}/port/out/CreatorChannel{Audio,Series,Live}QueryPort.kt#findCreator`는 필수 Lang을 받는다. 각 `v2/creator/channel/{audio,series,live}/application/CreatorChannel{Audio,Series,Live}QueryService.kt`의 blocked-access 이름은 이 record.nickname을 그대로 사용한다. 후속 resolver 주입/오류 분기 alias SELECT를 제거했다. 정상 DTO의 제목/콘텐츠 필드는 그대로다. | +| `v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt#findLatestVisibleSnapshots` | DB의 중립 snapshot을 조회하면서 현재 Member/별칭을 LEFT JOIN한다. `COALESCE(alias, current nickname, snapshot nickname)`로 삭제된 owner의 기존 fallback도 유지하며 snapshot entity를 수정하지 않는다. 기존 최신 visible 조건/rankNo 순서를 QueryDSL로 동일하게 적용했다. `v2/ranking/port/out/CreatorRankingSnapshotPort.kt#findLatestVisibleSnapshots(type, nowUtc, lang)`와 `v2/ranking/application/CreatorRankingQueryService.kt#getCreatorRankings(viewerMemberId, lang)`는 필수 Lang이다. `v2/api/home/application/HomeCreatorRankingFacade.kt`의 Member scalar batch와 alias batch/copy를 모두 제거했다. | +| `v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt#findLatestVisibleSnapshots` | 위와 같이 현재 owner/name을 snapshot 읽기에서 JOIN하며 기존 locale을 재사용한다. 제목 번역은 별개로 유지한다. `v2/content/ranking/application/AudioRankingQueryService.kt#getRankings(type, member, lang)` → 기존 snapshot port locale을 전달한다. `v2/api/content/ranking/application/AudioRankingFacade.kt`의 Member scalar batch와 alias batch/copy를 제거했다. | + +`v2/creator/channel/{audio,series,live}` 행의 중괄호는 세 기존 파일의 일대일 대응이며 신규 추정 경로가 아니다. 관리 AI community/fanTalk은 별도 raw repository를 사용함을 확인했으므로 앱 query에 admin용 KO 기본값/null-language fallback을 추가하지 않았다. 원래 언어 중립 snapshot writer가 쓰는 집계기간/raw 조회 계약도 바꾸지 않았다. + +`MainContentAllQueryService`, `AudioRecommendationQueryService`, `AudioRankingQueryService`, `HomeRecommendationQueryService`, `HomeFollowingQueryService`는 요청 Lang을 명시 전달받으며 더 이상 필요 없는 `LangContext` 생성자 의존성을 제거했다. 기존 locale port를 그대로 쓰는 경우 코드 ↔ enum 매핑에는 `Lang.fromCode`만 사용한다. 나머지 채널 서비스의 기존 본문/제목 번역 context는 이번 이름 조회와 무관한 동작으로 유지한다. + +혼합 writer/donor record에는 조회 결과의 `isLocalizedName`을 보존해 원본 `deleted_` prefix 정리만 유지하고 별칭 원문은 정리하지 않는다. 이 flag는 내부 `CreatorChannelCommunityCommentRecord`, `CreatorChannelFanTalkRecord/ReplyRecord`, 두 `CreatorChannelDonationRecord`, 홈 `CreatorChannelFanTalkRecord`에만 있으며 공개 DTO에는 추가하지 않는다. raw fixture의 기본 false와 QueryDSL의 실제 alias-row 존재 projection을 구분한다. + +### 남은 batch와 미전환 읽기의 분류 + +| 정확한 경계 | 유지 이유와 현재 처리 | +|---|---| +| `M/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt#findCurrentNames` | `M/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt` → `M/explorer/profile/CreatorDonationRankingService.kt#getMemberDonationRanking`은 Lang 없는 Redis `creator_donation_ranking_v2:...`에 raw nickname을 저장한다. cache hit는 `cachedData.rankings`를 그대로 반환하므로 DB JOIN으로 치환할 수 없는 중립 재사용 경계다. rankings donor ID만 `findCurrentNames` 한 번으로 현재 별칭/원본을 함께 조회한다. 별도 `Member.findNicknamesByIds`는 제거했다. cache entry/producer는 변이하지 않는다. | +| `M/v2/gift/application/GiftQueryService.kt#getGifts/getGiftDetail` | 기프트·배송 업무 엔티티와 sender/recipient 회원을 이미 읽어 방향/상태/존재 검증, profile, 주소 공개 범위를 조립하는 기존 경계다. 이름만을 위해 기프트 query/permission DTO를 재설계하지 않았다. 각 use case에서 `findLocalizedNames` 한 번의 sparse batch와 이미 로드한 nickname fallback을 유지하며 추가 Member 이름 query는 없다. 실명/주소/category snapshot/status/size는 치환하지 않는다. | +| 두 ranking adapter의 previous/raw 조회와 snapshot refresh/aggregation | 이전 데이터는 rank 변화 계산 또는 중립 writer 입력이며 그 이름을 직접 HTTP 이름으로 노출하지 않는다. latest 표시 read만 현재 이름 JOIN으로 바꿨다. 실제 `v2/ranking`, `v2/content/ranking` 읽기에는 Redis/Cacheable 및 `.getFirst()` 기반 공유 raw ranking cache가 없고 DB snapshot을 직접 소비함을 read/rg로 확인했다. test fake의 ArrayDeque `firstOrNull`은 cache producer가 아니다. | +| `M/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`와 publish service | 언어 중립 저장값은 그대로다. 이미 현재 creator를 조인하는 HTTP consumer는 위에서 JOIN 전환했으므로 역사 snapshot 면제가 아니다. | +| admin/원본 역할 확인, AI 캐릭터 제목, content/series/live title | 운영 식별/원본 편집과 이름 아닌 필드는 기존 raw 정책을 유지한다. 언어별로 렌더되어 저장된 historical 이름으로 면제한 소유 v2 HTTP 필드는 없다. | +| `v2.creatorname`, `v2.usercreatorchat`, `v2.chat`, core/legacy/delivery | 다른 소유자 범위다. 공통 helper/batch 계약만 소비했으며 공유 파일을 수정하지 않았다. chat recentChats의 기존 Lang 호출은 보존했다. | + +소유 범위 `findLocalizedNames/findCurrentNames/findNicknamesByIds` 재검색 결과는 위 기프트 두 호출과 donation cache의 current batch 한 호출뿐이다. 단순 current DB projection으로 남긴 alias batch는 없으며 entity/snapshot/inbox 원본도 변경하지 않았다. + +### 테스트/생성자 적응과 정적 증거 + +- 기존 facade tests의 resolver mock/MemberRepository 생성자 인자를 제거했고 query service tests의 `LangContext` 인자는 필요한 곳만 제거했다. HomeRecommendation의 모든 직접 facade 생성(장애 로그 tests 포함), 채널 seven fake ports, home/following fake ports, 두 ranking fake ports 및 snapshot adapter 직접 생성자를 현재 계약으로 맞췄다. `DefaultAudioRankingSnapshotPersistenceAdapterTest`는 기존 EntityManager로 `JPAQueryFactory`를 전달한다. nullable dependency로 테스트를 우회하지 않는다. +- `T/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt#shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles`의 실제 standalone alias SQL counter 기대값 0을 그대로 유지했다. 같은 파일의 `#shouldReadActiveEnglishCreatorNameAndFallbackWhenInactive`는 활성 EN 읽기, 비활성 EN 원본 fallback, POPULAR 집계/page 개수와 제목 불변을 검증하도록 추가했다. +- `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt#shouldLocalizeCreatorAuthorsAndLatestRepliesWithoutChangingUserNames`의 JA/USER/본문 assertion을 유지하고 `deleted_`로 시작하는 별칭도 post/root comment/latest reply에서 원문 그대로인지 추가했다. 기존 삭제 원본 이름 정리 tests의 기대값은 완화하지 않았다. +- 추가로 적응한 ranking tests는 `T/v2/ranking/application/CreatorRankingQueryServiceTest.kt`, `T/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`, `T/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`, `T/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`, `T/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`다. reflection 기반 service method 검사에도 Lang type을 추가했다. 일본어/영어 번역 기대 tests는 기존 언어를 명시 인자로 옮겼다. +- `read/rg` 및 임시 정적 점검으로 34개 관련 계약의 언어/locale 참조 468곳을 대조했고 알려진 누락은 0이었다. `src/main`, `src/test`의 직접 생성자/override, v2 외부 import/caller, 기존 생성 QMember/QSnapshot field와 native `:lang` 네 곳의 enum NAME binding을 대조했다. 이는 컴파일/실제 SQL 실행을 대신하는 성공 주장이 아니다. +- 이 writer는 요청대로 Gradle/서버/DB/agent/commit/push를 실행하지 않았다. `lsp_diagnostics` 도구도 제공되지 않아 미실행이다. 남은 runtime 확인은 H2/MySQL 대응 JOIN/COALESCE와 boolean/enum binding, 전체 consumer 회귀 및 parent GREEN이다. + +### 부모 직렬 GREEN 명령, 이 단계 미실행 + +먼저 새 SQL counter RED의 같은 메서드와 EN/grouping 회귀를 실행하고, 아래 module/HTTP consumer 회귀 및 빌드/린트를 한 번에 하나씩 실행한다. + +```bash +./gradlew compileTestKotlin --offline --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' --offline --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' --offline --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --offline --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.home.following.*' --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --offline --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest' --offline --console=plain +./gradlew ktlintCheck assemble --offline --console=plain +``` + +정적 인계 상태는 **parent GREEN 준비**이며 위 명령의 PASS/실제 HTTP QA 완료를 기록하지 않는다. 공통 및 다른 writer 변경이 합쳐진 전체 회귀와 실제 자원 종료 검증은 부모 JOIN-GATE 소유다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt index bb6d442c..213d4eda 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt @@ -342,6 +342,7 @@ class AdminLiveService( type = FcmEventType.CANCEL_LIVE, category = PushNotificationCategory.LIVE, title = room.member!!.nickname, + titleMemberId = room.member!!.id, messageKey = "live.room.fcm.message.canceled", senderMemberId = room.member!!.id, args = listOf(room.title), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt index c112b9cd..d72fbe91 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberProvider import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.Pageable import org.springframework.data.repository.findByIdOrNull @@ -22,6 +23,7 @@ class AdminMemberService( private val passwordEncoder: PasswordEncoder, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val creatorLocalizedNameService: CreatorLocalizedNameService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -93,6 +95,8 @@ class AdminMemberService( private fun processMemberListToGetAdminMemberListResponseItemList( memberList: List ): List { + val creatorIds = memberList.filter { it.role == MemberRole.CREATOR }.map { requireNotNull(it.id) } + val localizedNames = creatorLocalizedNameService.findAllLocalizedNames(creatorIds) return memberList .asSequence() .map { @@ -145,7 +149,8 @@ class AdminMemberService( auth = it.auth != null, signUpDate = signUpDate, signOutDate = signOutDate, - isActive = it.isActive + isActive = it.isActive, + localizedNames = localizedNames[it.id].orEmpty() ) } .toList() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt index 5830eef6..4ad89e66 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt @@ -16,5 +16,6 @@ data class GetAdminMemberListResponseItem( val auth: Boolean, val signUpDate: String, val signOutDate: String, - val isActive: Boolean + val isActive: Boolean, + val localizedNames: Map = emptyMap() ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index e93ed984..bc444c2e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -24,6 +24,7 @@ import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService import kr.co.vividnext.sodalive.rank.ContentRankingSortType import kr.co.vividnext.sodalive.rank.RankingRepository import kr.co.vividnext.sodalive.rank.RankingService +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @@ -49,6 +50,7 @@ class HomeService( private val explorerQueryRepository: ExplorerQueryRepository, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val memberContentPreferenceService: MemberContentPreferenceService, @Value("\${cloud.aws.cloud-front.host}") @@ -87,7 +89,7 @@ class HomeService( timezone = timezone ) - val creatorRankingMembers = rankingRepository.getCreatorRankings(memberId = memberId) + val creatorRankingMembers = rankingRepository.getCreatorRankings(memberId = memberId, lang = langContext.lang) val creatorRankingIds = creatorRankingMembers.mapNotNull { it.id } val followedCreatorIds = if (memberId != null) { explorerQueryRepository.getFollowedCreatorIds(creatorRankingIds, memberId) @@ -99,7 +101,7 @@ class HomeService( val creatorId = creator.id!! val follow = memberId != null && followedCreatorIds.contains(creatorId) - creator.toExplorerSectionCreator(imageHost, follow) + creator.copy(follow = follow) } val latestContentThemeList = contentThemeService.getActiveThemeOfContent( @@ -171,6 +173,10 @@ class HomeService( isAdult = isAdult, contentType = resolvedContentType ) + val recommendedNames = localizedNameService.findCurrentNames( + recommendChannelList.map { it.channelId }, + langContext.lang + ) val freeContentList = getRandomizedContentList( memberId = memberId, @@ -212,7 +218,9 @@ class HomeService( dayOfWeekSeriesList = translatedDayOfWeekSeriesList, popularCharacters = translatedPopularCharacters, contentRanking = contentRanking, - recommendChannelList = recommendChannelList, + recommendChannelList = recommendChannelList.map { + it.copy(creatorNickname = recommendedNames[it.channelId] ?: it.creatorNickname) + }, freeContentList = freeContentList, pointAvailableContentList = pointAvailableContentList, recommendContentList = getRecommendContentListByPreference( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt index 7d56c8a3..f4fe070b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt @@ -5,7 +5,9 @@ import kr.co.vividnext.sodalive.audition.AuditionApplicant import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole import kr.co.vividnext.sodalive.audition.QAuditionVote.auditionVote +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository @@ -18,7 +20,8 @@ interface AuditionApplicantQueryRepository { auditionRoleId: Long, sortType: AuditionApplicantSortType, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List fun findActiveApplicantByMemberIdAndRoleId(memberId: Long, roleId: Long): AuditionApplicant? @@ -64,8 +67,10 @@ class AuditionApplicantQueryRepositoryImpl( auditionRoleId: Long, sortType: AuditionApplicantSortType, offset: Long, - limit: Long + limit: Long, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "applicantMemberName") val orderBy = if (sortType == AuditionApplicantSortType.LIKES) { auditionVote.id.count().desc() } else { @@ -77,7 +82,7 @@ class AuditionApplicantQueryRepositoryImpl( QGetAuditionRoleApplicantItem( auditionApplicant.id, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), auditionApplicant.voicePath.prepend("/").prepend(cloudFrontHost), auditionVote.id.count() @@ -85,6 +90,7 @@ class AuditionApplicantQueryRepositoryImpl( ) .from(auditionApplicant) .innerJoin(auditionApplicant.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(auditionApplicant.role, auditionRole) .leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id)) .where( @@ -92,7 +98,7 @@ class AuditionApplicantQueryRepositoryImpl( auditionApplicant.isActive.isTrue, member.isActive.isTrue ) - .groupBy(auditionApplicant.id) + .groupBy(auditionApplicant.id, member.id, creatorName.name.displayName) .orderBy(orderBy) .offset(offset) .limit(limit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt index d7a8e7de..c66d781c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.audition.AuditionApplicant import kr.co.vividnext.sodalive.audition.role.AuditionRoleRepository import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value @@ -21,6 +22,7 @@ class AuditionApplicantService( private val s3Uploader: S3Uploader, private val objectMapper: ObjectMapper, + private val langContext: LangContext, @Value("\${cloud.aws.s3.bucket}") private val bucket: String @@ -36,7 +38,8 @@ class AuditionApplicantService( auditionRoleId = auditionRoleId, sortType = sortType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) return GetAuditionApplicantListResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index eba01100..676b5fa2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -19,10 +19,12 @@ import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -32,7 +34,7 @@ interface CanRepository : JpaRepository, CanQueryRepository interface CanQueryRepository { fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List - fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List + fun getCanUseStatus(member: Member, pageable: Pageable, container: String, lang: Lang = Lang.KO): List fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? @@ -66,11 +68,15 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .fetch() } - override fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List { + override fun getCanUseStatus(member: Member, pageable: Pageable, container: String, lang: Lang): List { val qRoomMember = QMember("roomMember") val qAudioContentMember = QMember("audioContentMember") val qCommunityPostMember = QMember("communityPostMember") val qRecipientMember = QMember("recipientMember") + val roomName = CreatorNameJoin(qRoomMember, lang, "receiptRoomCreatorName") + val audioName = CreatorNameJoin(qAudioContentMember, lang, "receiptAudioCreatorName") + val communityName = CreatorNameJoin(qCommunityPostMember, lang, "receiptCommunityCreatorName") + val recipientName = CreatorNameJoin(qRecipientMember, lang, "receiptRecipientCreatorName") val gatewayCondition = when (container) { "aos" -> useCanCalculate.paymentGateway.`in`( @@ -96,23 +102,26 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue useCan.can, useCan.rewardCan, useCan.createdAt, - qRoomMember.nickname, + roomName.displayName, liveRoom.title, - qAudioContentMember.nickname, + audioName.displayName, audioContent.title, - qCommunityPostMember.nickname, + communityName.displayName, audition.title, chatCharacter.name, - qRecipientMember.nickname + recipientName.displayName ) ) .from(useCan) .leftJoin(useCan.room, liveRoom) .leftJoin(liveRoom.member, qRoomMember) + .leftJoin(roomName.name).on(roomName.condition) .leftJoin(useCan.audioContent, audioContent) .leftJoin(audioContent.member, qAudioContentMember) + .leftJoin(audioName.name).on(audioName.condition) .leftJoin(useCan.communityPost, creatorCommunity) .leftJoin(creatorCommunity.member, qCommunityPostMember) + .leftJoin(communityName.name).on(communityName.condition) .leftJoin(useCan.auditionApplicant, auditionApplicant) .leftJoin(auditionApplicant.role, auditionRole) .leftJoin(auditionRole.audition, audition) @@ -120,6 +129,7 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .leftJoin(characterImage.chatCharacter, chatCharacter) .innerJoin(useCan.useCanCalculates, useCanCalculate) .leftJoin(qRecipientMember).on(useCanCalculate.recipientCreatorId.eq(qRecipientMember.id)) + .leftJoin(recipientName.name).on(recipientName.condition) .where( useCan.member.id.eq(member.id) .and(useCan.isRefund.isFalse) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index d51443a3..10431274 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @@ -14,7 +15,8 @@ import java.time.format.DateTimeFormatter @Transactional(readOnly = true) class CanService( private val repository: CanRepository, - private val countryContext: CountryContext + private val countryContext: CountryContext, + private val langContext: LangContext ) { fun getCans(forcedCurrency: String? = null): List { val currency = forcedCurrency ?: when (countryContext.countryCode) { @@ -43,7 +45,7 @@ class CanService( ZoneId.of("UTC") } - return repository.getCanUseStatus(member, pageable, container) + return repository.getCanUseStatus(member, pageable, container, langContext.lang) .map { val title: String = when (it.canUsage) { CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt index fd52954e..0f86c596 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -3,9 +3,11 @@ package kr.co.vividnext.sodalive.content 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.i18n.LangContext 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.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.data.domain.Pageable import org.springframework.lang.Nullable import org.springframework.security.access.prepost.PreAuthorize @@ -29,7 +31,9 @@ import java.time.temporal.TemporalAdjusters @RequestMapping("/audio-content") class AudioContentController( private val service: AudioContentService, - private val memberContentPreferenceService: MemberContentPreferenceService + private val memberContentPreferenceService: MemberContentPreferenceService, + private val localizedNameService: CreatorLocalizedNameService, + private val langContext: LangContext ) { @PostMapping @PreAuthorize("hasRole('CREATOR')") @@ -214,7 +218,10 @@ class AudioContentController( offset = pageable.offset, limit = pageable.pageSize.toLong(), sortType = sortType ?: "매출" - ) + ).let { response -> + val names = localizedNameService.findCurrentNames(response.items.map { it.creatorId }, langContext.lang) + response.copy(items = response.items.map { it.copy(creatorNickname = names[it.creatorId] ?: it.creatorNickname) }) + } ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index cf2ea86c..26905664 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -29,9 +29,11 @@ import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentT import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.event.QEvent.event +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -85,7 +87,8 @@ interface AudioContentQueryRepository { contentType: ContentType, offset: Long = 0, limit: Long = 20, - isFree: Boolean = false + isFree: Boolean = false, + lang: Lang = Lang.KO ): List fun findAlarmContentByTheme( @@ -167,7 +170,7 @@ interface AudioContentQueryRepository { fun isContentCreator(contentId: Long, memberId: Long): Boolean - fun fetchContentForPlaylist(contentIdList: List): List + fun fetchContentForPlaylist(contentIdList: List, lang: Lang = Lang.KO): List fun getCoverImageById(id: Long): String? fun findPreviousContent(seriesId: Long, title: String, isAdult: Boolean): OtherContentResponse? fun findNextContent(seriesId: Long, title: String, isAdult: Boolean): OtherContentResponse? @@ -467,8 +470,10 @@ class AudioContentQueryRepositoryImpl( contentType: ContentType, offset: Long, limit: Long, - isFree: Boolean + isFree: Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "themeCreatorName") val blockMemberCondition = blockMember.isActive.isTrue .and( blockMember.member.id.eq(member.id) @@ -535,7 +540,7 @@ class AudioContentQueryRepositoryImpl( where = where.and(audioContent.price.loe(0)) } - return queryFactory + val query = queryFactory .select( QGetAudioContentMainItem( audioContent.id, @@ -543,7 +548,7 @@ class AudioContentQueryRepositoryImpl( audioContent.title, member.id, member.profileImage.prepend("/").prepend(imageHost), - member.nickname, + creatorName.displayName, audioContent.price, audioContent.duration, audioContent.isPointAvailable @@ -557,7 +562,8 @@ class AudioContentQueryRepositoryImpl( .offset(offset) .limit(limit) .orderBy(*orderBy.toTypedArray()) - .fetch() + query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } override fun findAlarmContentByTheme( @@ -1210,7 +1216,8 @@ class AudioContentQueryRepositoryImpl( return foundedContentId != null && foundedContentId == contentId } - override fun fetchContentForPlaylist(contentIdList: List): List { + override fun fetchContentForPlaylist(contentIdList: List, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "playlistCreatorName") return queryFactory .select( QAudioContentPlaylistContent( @@ -1219,12 +1226,13 @@ class AudioContentQueryRepositoryImpl( audioContentTheme.theme, audioContent.coverImage.prepend("/").prepend(imageHost), audioContent.duration, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(imageHost) ) ) .from(audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(audioContent.theme, audioContentTheme) .where(audioContent.id.`in`(contentIdList)) .fetch() @@ -1466,6 +1474,7 @@ class AudioContentQueryRepositoryImpl( } val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost) + val creatorName = CreatorNameJoin(member, Lang.fromCode(locale), "latestCreatorName") var select = queryFactory .select( @@ -1474,11 +1483,12 @@ class AudioContentQueryRepositoryImpl( audioContent.title, contentTranslation.renderedPayload, coverImageUrl, - member.nickname, + creatorName.displayName, audioContent.isPointAvailable ) .from(audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(audioContent.theme, audioContentTheme) .leftJoin(contentTranslation) .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) @@ -1523,7 +1533,7 @@ class AudioContentQueryRepositoryImpl( val payload = row.get(contentTranslation.renderedPayload) val translatedTitle = payload?.title val imageUrl = row.get(coverImageUrl)!! - val creatorNickname = row.get(member.nickname)!! + val creatorNickname = row.get(creatorName.displayName)!! val isPointAvailableValue = row.get(audioContent.isPointAvailable) ?: false AudioContentMainItem( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 38949296..2b18766e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -40,6 +40,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.utils.generateFileName +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.slf4j.LoggerFactory @@ -87,6 +88,7 @@ class AudioContentService( private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService, @@ -512,6 +514,7 @@ class AudioContentService( type = FcmEventType.UPLOAD_CONTENT, category = PushNotificationCategory.CONTENT, title = audioContent.member!!.nickname, + titleMemberId = audioContent.member!!.id, messageKey = "content.notification.uploaded_message", senderMemberId = audioContent.member!!.id, args = listOf(audioContent.title), @@ -542,6 +545,7 @@ class AudioContentService( type = FcmEventType.UPLOAD_CONTENT, category = PushNotificationCategory.CONTENT, title = audioContent.member!!.nickname, + titleMemberId = audioContent.member!!.id, messageKey = "content.notification.uploaded_message", senderMemberId = audioContent.member!!.id, args = listOf(audioContent.title), @@ -702,7 +706,8 @@ class AudioContentService( isContentCreator = creatorId == member.id!!, timezone = timezone, offset = 0, - limit = 1 + limit = 1, + lang = langContext.lang ) } else { listOf() @@ -844,7 +849,7 @@ class AudioContentService( audioContent.limited != null && audioContent.remaining != null ) { - orderRepository.findBuyerListByContentId(id) + orderRepository.findBuyerListByContentId(id, langContext.lang) } else { listOf() } @@ -920,6 +925,10 @@ class AudioContentService( ) } + val names = localizedNameService.findLocalizedNames( + listOf(creatorId), + langContext.lang + ) return GetAudioContentDetailResponse( contentId = audioContent.id!!, title = audioContent.title, @@ -955,7 +964,7 @@ class AudioContentService( isAvailablePin = isAvailablePin, creator = AudioContentCreator( creatorId = creatorId, - nickname = creator.nickname, + nickname = names[creatorId] ?: creator.nickname, profileImageUrl = if (creator.profileImage != null) { "$coverImageHost/${creator.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt index 7f2b8af5..3e9ef1ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content.comment import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.UserActionService @@ -18,7 +19,8 @@ import org.springframework.web.bind.annotation.RestController @RestController class AudioContentCommentController( private val service: AudioContentCommentService, - private val userActionService: UserActionService + private val userActionService: UserActionService, + private val langContext: LangContext ) { @PostMapping("/audio-content/comment") fun registerComment( @@ -79,7 +81,8 @@ class AudioContentCommentController( audioContentId = audioContentId, memberId = member.id!!, timezone = timezone, - pageable = pageable + pageable = pageable, + lang = langContext.lang ) ) } @@ -98,7 +101,8 @@ class AudioContentCommentController( commentId = commentId, memberId = member.id!!, timezone = timezone, - pageable = pageable + pageable = pageable, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt index 90cd4530..c6aaeeb2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt @@ -1,16 +1,18 @@ package kr.co.vividnext.sodalive.content.comment +import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.JPAExpressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment -import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.fcm.PushTokenInfo import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken import kr.co.vividnext.sodalive.fcm.QPushTokenInfo +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -26,7 +28,8 @@ interface AudioContentCommentQueryRepository { isContentCreator: Boolean, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? = null ): List fun totalCountCommentByContentId(contentId: Long, memberId: Long, isContentCreator: Boolean): Int @@ -37,7 +40,8 @@ interface AudioContentCommentQueryRepository { memberId: Long, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? = null ): List fun findPushTokenByContentIdAndCommentParentIdMyMemberId( @@ -58,8 +62,13 @@ class AudioContentCommentQueryRepositoryImpl( isContentCreator: Boolean, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? ): List { + val creatorName = lang?.let { CreatorNameJoin(member, it, "audioCommentWriterName") } + val originalName = CaseBuilder().`when`(member.nickname.startsWith("deleted_")) + .then(member.nickname.substring(8)).otherwise(member.nickname) + val displayName = creatorName?.name?.displayName?.coalesce(originalName) ?: originalName var where = audioContentComment.audioContent.id.eq(contentId) .and(audioContentComment.isActive.isTrue) .and(audioContentComment.parent.isNull) @@ -85,12 +94,12 @@ class AudioContentCommentQueryRepositoryImpl( "%Y.%m.%d %W %h:%i %p" ) - return queryFactory + val query = queryFactory .select( QGetAudioContentCommentListItem( audioContentComment.id, audioContentComment.member.id, - audioContentComment.member.nickname, + displayName, audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.comment, audioContentComment.languageCode, @@ -107,10 +116,10 @@ class AudioContentCommentQueryRepositoryImpl( .offset(offset) .limit(limit.toLong()) .orderBy(audioContentComment.createdAt.desc()) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() .map { it.copy( - nickname = it.nickname.removeDeletedNicknamePrefix(), replyCount = commentReplyCountByAudioContentCommentId(it.id, memberId) ) } @@ -160,8 +169,13 @@ class AudioContentCommentQueryRepositoryImpl( memberId: Long, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? ): List { + val creatorName = lang?.let { CreatorNameJoin(member, it, "audioReplyWriterName") } + val originalName = CaseBuilder().`when`(member.nickname.startsWith("deleted_")) + .then(member.nickname.substring(8)).otherwise(member.nickname) + val displayName = creatorName?.name?.displayName?.coalesce(originalName) ?: originalName val formattedDate = Expressions.stringTemplate( "DATE_FORMAT({0}, {1})", Expressions.dateTimeTemplate( @@ -174,12 +188,12 @@ class AudioContentCommentQueryRepositoryImpl( "%Y.%m.%d %W %h:%i %p" ) - return queryFactory + val query = queryFactory .select( QGetAudioContentCommentListItem( audioContentComment.id, audioContentComment.member.id, - audioContentComment.member.nickname, + displayName, audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.comment, audioContentComment.languageCode, @@ -201,10 +215,8 @@ class AudioContentCommentQueryRepositoryImpl( .offset(offset) .limit(limit.toLong()) .orderBy(audioContentComment.createdAt.desc()) - .fetch() - .map { - it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) - } + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } override fun findPushTokenByContentIdAndCommentParentIdMyMemberId( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 4d4d3743..a9f4e2ea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -9,10 +9,12 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +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.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable @@ -30,6 +32,7 @@ class AudioContentCommentService( private val orderRepository: OrderRepository, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -49,7 +52,9 @@ class AudioContentCommentService( val creator = audioContent.member!! val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, creatorId = creator.id!!) if (isBlocked) { - throw SodaException(formatMessage("content.comment.error.blocked_by_creator", creator.nickname)) + val name = localizedNameService.findLocalizedNames(listOf(creator.id!!), langContext.lang)[creator.id] + ?: creator.nickname + throw SodaException(formatMessage("content.comment.error.blocked_by_creator", name)) } val (isExistsAudioContent, _) = orderRepository.isExistOrderedAndOrderType( @@ -86,6 +91,7 @@ class AudioContentCommentService( } else { member.nickname }, + titleMemberId = parent?.member?.id ?: member.id, messageKey = if (parent != null) { "content.comment.notification.reply" } else { @@ -140,7 +146,8 @@ class AudioContentCommentService( audioContentId: Long, memberId: Long, timezone: String, - pageable: Pageable + pageable: Pageable, + lang: Lang? = null ): GetAudioContentCommentListResponse { val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) ?: return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) @@ -157,7 +164,8 @@ class AudioContentCommentService( isContentCreator = isContentCreator, timezone = timezone, offset = pageable.offset, - limit = pageable.pageSize + limit = pageable.pageSize, + lang = lang ) val totalCount = repository.totalCountCommentByContentId( contentId = audioContentId, @@ -172,7 +180,8 @@ class AudioContentCommentService( commentId: Long, memberId: Long, timezone: String, - pageable: Pageable + pageable: Pageable, + lang: Lang? = null ): GetAudioContentCommentListResponse { val parentComment = repository.findByIdOrNull(id = commentId) ?: return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) @@ -187,7 +196,8 @@ class AudioContentCommentService( memberId = memberId, timezone = timezone, offset = pageable.offset, - limit = pageable.pageSize + limit = pageable.pageSize, + lang = lang ) val totalCount = repository.commentReplyCountByAudioContentCommentId(commentId, memberId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt index faa75229..d1a3a489 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt @@ -10,7 +10,9 @@ import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.playlist.ContentIdAndEndDateData import kr.co.vividnext.sodalive.content.playlist.QContentIdAndEndDateData +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -29,7 +31,8 @@ interface OrderQueryRepository { coverImageHost: String, memberId: Long, offset: Long = 0, - limit: Long = 10 + limit: Long = 10, + lang: Lang = Lang.KO ): List fun totalAudioContentOrderListCount(memberId: Long, dateTime: LocalDateTime): Int @@ -38,12 +41,13 @@ interface OrderQueryRepository { coverImageHost: String, memberId: Long, offset: Long = 0, - limit: Long = 20 + limit: Long = 20, + lang: Lang = Lang.KO ): List fun findOrderedContent(contentIdList: List, memberId: Long): List fun findEndDateByContentId(contentIdList: List, memberId: Long): List - fun findBuyerListByContentId(contentId: Long): List + fun findBuyerListByContentId(contentId: Long, lang: Lang = Lang.KO): List fun findByMemberIdAndContentId(memberId: Long, contentId: Long, createdAt: LocalDateTime): Order? } @@ -134,8 +138,10 @@ class OrderQueryRepositoryImpl( coverImageHost: String, memberId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "orderCreatorName") val where = order.member.id.eq(memberId) .and(order.isActive.isTrue) .and( @@ -153,7 +159,7 @@ class OrderQueryRepositoryImpl( audioContent.id, audioContent.coverImage.prepend("/").prepend(coverImageHost), audioContent.member.id, - audioContent.member.nickname, + creatorName.displayName, audioContent.title, audioContent.theme.theme, audioContent.duration, @@ -165,6 +171,8 @@ class OrderQueryRepositoryImpl( ) .from(order) .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(where) .offset(offset) .limit(limit) @@ -197,8 +205,10 @@ class OrderQueryRepositoryImpl( coverImageHost: String, memberId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "mainOrderCreatorName") return queryFactory .select( QGetAudioContentMainItem( @@ -209,7 +219,7 @@ class OrderQueryRepositoryImpl( member.profileImage .prepend("/") .prepend(coverImageHost), - member.nickname, + creatorName.displayName, audioContent.price, audioContent.duration, audioContent.isPointAvailable @@ -218,6 +228,7 @@ class OrderQueryRepositoryImpl( .from(order) .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( order.member.id.eq(memberId) .and(order.isActive.isTrue) @@ -266,16 +277,18 @@ class OrderQueryRepositoryImpl( .fetch() } - override fun findBuyerListByContentId(contentId: Long): List { + override fun findBuyerListByContentId(contentId: Long, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "contentBuyerName") return queryFactory .select( QContentBuyer( - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(imageHost) ) ) .from(order) .innerJoin(order.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( order.isActive.isTrue, order.audioContent.id.eq(contentId), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt index 6db421aa..4ee63f68 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.PurchaseOption import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.point.PointUsageService import org.springframework.beans.factory.annotation.Value @@ -26,6 +27,7 @@ class OrderService( private val audioContentCommentQueryRepository: AudioContentCommentRepository, private val audioContentLikeQueryRepository: AudioContentLikeRepository, private val limitedEditionOrderRepository: LimitedEditionOrderRepository, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val audioContentCoverImageHost: String @@ -123,7 +125,8 @@ class OrderService( coverImageHost = audioContentCoverImageHost, memberId = member.id!!, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { val commentCount = audioContentCommentQueryRepository @@ -154,7 +157,8 @@ class OrderService( coverImageHost = audioContentCoverImageHost, memberId = memberId, offset = 0, - limit = 20 + limit = 20, + lang = langContext.lang ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt index 367eae3c..5c6cc5c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.content.playlist import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.live.roulette.RedisIdGenerator import kr.co.vividnext.sodalive.member.Member import org.springframework.data.repository.findByIdOrNull @@ -16,7 +17,8 @@ class AudioContentPlaylistService( private val idGenerator: RedisIdGenerator, private val orderRepository: OrderRepository, private val audioContentRepository: AudioContentRepository, - private val redisRepository: AudioContentPlaylistRedisRepository + private val redisRepository: AudioContentPlaylistRedisRepository, + private val langContext: LangContext ) { fun createPlaylist(request: CreatePlaylistRequest, member: Member) { if (request.contentIdAndOrderList.size >= 30) { @@ -173,7 +175,8 @@ class AudioContentPlaylistService( } val contentList = audioContentRepository.fetchContentForPlaylist( - contentIdList = contentIdAndOrderList.map { it.contentId } + contentIdList = contentIdAndOrderList.map { it.contentId }, + lang = langContext.lang ) val orderMap = contentIdAndOrderList.sortedBy { it.order } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index f76dbda3..ece624e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -112,7 +112,8 @@ class AudioContentThemeService( isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible), contentType = contentType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) return GetContentByThemeResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index 930e11af..a91f8c37 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -29,6 +29,7 @@ import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoomType import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.live.room.cancel.QLiveRoomCancel.liveRoomCancel +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member @@ -40,6 +41,8 @@ import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.creatorNameContains import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.math.BigDecimal @@ -122,13 +125,16 @@ class ExplorerQueryRepository( creatorId: Long, limit: Long, offset: Long = 0, - withDonationCan: Boolean + withDonationCan: Boolean, + lang: Lang? = null ): List { val member = QMember("user") + val creatorName = lang?.let { CreatorNameJoin(member, it, "legacyDonationRankName") } + val displayName = creatorName?.displayName ?: member.nickname val donation = useCan.rewardCan.add(useCan.can).sum() - return queryFactory - .select(member, donation) + val query = queryFactory + .select(member, donation, displayName) .from(useCanCalculate) .innerJoin(useCanCalculate.useCan, useCan) .innerJoin(useCan.member, member) @@ -144,16 +150,17 @@ class ExplorerQueryRepository( .or(useCan.canUsage.eq(CanUsage.LIVE)) ) ) - .groupBy(useCan.member.id) + .groupBy(useCan.member.id, member.id, displayName) .orderBy(donation.desc(), member.id.desc()) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() .asSequence() .map { val account = it.get(member)!! val donationCan = it.get(donation)!! MemberDonationRankingResponse( account.id!!, - account.nickname, + it.get(displayName)!!, if (account.profileImage != null) { "$cloudFrontHost/${account.profileImage}" } else { @@ -165,9 +172,12 @@ class ExplorerQueryRepository( .toList() } - fun getNewCreators(): List { + fun getNewCreators(lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "newExplorerCreatorName") return queryFactory - .selectFrom(member) + .select(member, creatorName.displayName) + .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.role.eq(MemberRole.CREATOR) .and(member.createdAt.goe(LocalDateTime.now().minusDays(30))) @@ -176,9 +186,13 @@ class ExplorerQueryRepository( .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(20) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(cloudFrontHost).copy(nickname = row.get(creatorName.displayName)!!) + } } - fun findCreatorByGender(gender: Int): List { + fun findCreatorByGender(gender: Int, lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "genderExplorerCreatorName") val today = LocalDate.now() val offset = (today.dayOfMonth - 1) % 26 val characters = ('A'..'Z').map { it.toString() }.plus((0..9).map { it.toString() }) @@ -192,7 +206,9 @@ class ExplorerQueryRepository( val randomExpression = Expressions.numberTemplate(Double::class.java, "function('rand')").asc() return queryFactory - .selectFrom(member) + .select(member, creatorName.displayName) + .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(member.auth, auth) .where( member.role.eq(MemberRole.CREATOR) @@ -204,17 +220,30 @@ class ExplorerQueryRepository( .offset(0) .limit(10) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(cloudFrontHost).copy(nickname = row.get(creatorName.displayName)!!) + } } - fun getSearchChannel(channel: String, memberId: Long): List { - return queryFactory.selectFrom(member) + fun getSearchChannel(channel: String, memberId: Long, lang: Lang? = null): List { + val creatorName = lang?.let { CreatorNameJoin(member, it, "channelSearchCreatorName") } + val displayName = creatorName?.displayName ?: member.nickname + val query = queryFactory.select( + Projections.constructor( + GetRoomDetailUser::class.java, + member.id, + displayName, + member.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ).from(member) .where( - member.nickname.containsIgnoreCase(channel) + creatorNameContains(member, channel) .and(member.isActive.isTrue) .and(member.id.ne(memberId)) .and(member.role.eq(MemberRole.CREATOR)) ) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } fun getMember(memberId: Long): Member? { @@ -224,7 +253,8 @@ class ExplorerQueryRepository( .fetchFirst() } - fun getSimilarCreatorList(creatorId: Long): List { + fun getSimilarCreatorList(creatorId: Long, lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "similarCreatorName") val creator = queryFactory .selectFrom(member) .where(member.id.eq(creatorId)) @@ -242,14 +272,15 @@ class ExplorerQueryRepository( .and(member.tags.size().gt(0)) .and(memberCreatorTag.tag.isActive.isTrue) - return getRandomCreatorList(where = where, limit = 3) + return getRandomCreatorList(where = where, limit = 3, lang = lang) } val cnt = memberCreatorTag.member.count() val similarCreators = queryFactory - .select(member, cnt) + .select(member, cnt, creatorName.displayName) .from(memberCreatorTag) .innerJoin(memberCreatorTag.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(memberCreatorTag.tag, creatorTag) .leftJoin(member.auth, auth) .where( @@ -265,7 +296,7 @@ class ExplorerQueryRepository( ) ) ) - .groupBy(memberCreatorTag.member.id) + .groupBy(memberCreatorTag.member.id, member.id, creatorName.name.displayName) .orderBy(cnt.desc()) .limit(3) .fetch() @@ -273,7 +304,7 @@ class ExplorerQueryRepository( val account = it.get(member)!! SimilarCreatorResponse( account.id!!, - account.nickname, + it.get(creatorName.displayName)!!, if (account.profileImage != null) { "$cloudFrontHost/${account.profileImage}" } else { @@ -298,29 +329,32 @@ class ExplorerQueryRepository( where = where.and(member.id.ne(userId)) } - val additionalCreator = getRandomCreatorList(where = where, limit = (3 - similarCreators.size).toLong()) + val additionalCreator = getRandomCreatorList(where = where, limit = (3 - similarCreators.size).toLong(), lang = lang) return similarCreators + additionalCreator } else { return similarCreators } } - private fun getRandomCreatorList(where: Predicate, limit: Long): List { + private fun getRandomCreatorList(where: Predicate, limit: Long, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "randomCreatorName") val result = queryFactory - .select(member) + .select(member, creatorName.displayName) .from(memberCreatorTag) .join(memberCreatorTag.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(memberCreatorTag.tag, creatorTag) .leftJoin(member.auth, auth) - .groupBy(memberCreatorTag.member.id) + .groupBy(memberCreatorTag.member.id, member.id, creatorName.name.displayName) .where(where) .limit(limit) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .fetch() - .map { + .map { row -> + val it = row.get(member)!! SimilarCreatorResponse( it.id!!, - it.nickname, + row.get(creatorName.displayName)!!, if (it.profileImage != null) { "$cloudFrontHost/${it.profileImage}" } else { @@ -474,7 +508,13 @@ class ExplorerQueryRepository( .fetchFirst() ?: "" } - fun getCheersList(creatorId: Long, memberId: Long, timezone: String, offset: Long, limit: Long): GetCheersResponse { + fun getCheersList( + creatorId: Long, + memberId: Long, + timezone: String, + offset: Long, + limit: Long + ): GetCheersResponse { val cheersDatePattern = messageSource .getMessage("explorer.date.cheers.format", langContext.lang) ?: messageSource.getMessage("explorer.date.cheers.format", Lang.KO).orEmpty() @@ -671,8 +711,10 @@ class ExplorerQueryRepository( fun getFollowerList( creatorId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { + val creatorName = CreatorNameJoin(member, lang, "followerMemberName") val blockMemberCondition = blockMember.member.id.eq(creatorId) .and(blockMember.isActive.isTrue) .and(blockMember.blockedMember.id.eq(member.id)) @@ -682,12 +724,13 @@ class ExplorerQueryRepository( QGetFollowerListDto( member.id, member.profileImage.prepend("/").prepend(cloudFrontHost), - member.nickname, + creatorName.displayName, member.role ) ) .from(creatorFollowing) .innerJoin(creatorFollowing.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(blockMember).on(blockMemberCondition) .where( member.isActive.isTrue @@ -805,12 +848,17 @@ class ExplorerQueryRepository( .fetchFirst() } - fun getCreatorRankings(): List { + fun getCreatorRankings(lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "explorerRankCreatorName") return queryFactory - .select(member) + .select(member, creatorName.displayName) .from(creatorRanking) .innerJoin(creatorRanking.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .orderBy(creatorRanking.ranking.asc()) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(cloudFrontHost).copy(nickname = row.get(creatorName.displayName)!!) + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index f632cfe1..63f93ec9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -32,6 +32,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable @@ -65,15 +66,15 @@ class ExplorerService( private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { fun getCreatorRank(memberId: Long): GetExplorerSectionResponse { val creatorRankings = queryRepository - .getCreatorRankings() + .getCreatorRankings(langContext.lang) .filter { !isBlockedBetweenMembers(memberId = memberId, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } val currentDateTime = LocalDateTime.now() val lastMonday = currentDateTime @@ -107,9 +108,8 @@ class ExplorerService( // 인기 크리에이터 val creatorRankings = queryRepository - .getCreatorRankings() + .getCreatorRankings(langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } val currentDateTime = LocalDateTime.now() val lastMonday = currentDateTime @@ -140,9 +140,8 @@ class ExplorerService( // 새로 시작 (newCreators) val newCreators = queryRepository - .getNewCreators() + .getNewCreators(langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } val newCreatorsSection = GetExplorerSectionResponse( title = messageSource.getMessage("explorer.section.new_creators.title", langContext.lang).orEmpty(), @@ -159,9 +158,8 @@ class ExplorerService( .orEmpty(), color = "39abde", creators = queryRepository - .findCreatorByGender(1) + .findCreatorByGender(1, langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } ) val femaleCreatorSection = GetExplorerSectionResponse( @@ -170,9 +168,8 @@ class ExplorerService( .orEmpty(), color = "ffa517", creators = queryRepository - .findCreatorByGender(0) + .findCreatorByGender(0, langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } ) val randomInt = Random.nextInt(0, 2) @@ -192,22 +189,23 @@ class ExplorerService( throw SodaException(messageKey = "explorer.search.channel.min_length") } - return queryRepository.getSearchChannel(channel, member.id!!) + return queryRepository.getSearchChannel(channel, member.id!!, langContext.lang) .asSequence() .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { GetRoomDetailUser(it, cloudFrontHost) } .toList() } fun getCreatorDetail(creatorId: Long, memberId: Long): GetCreatorDetailResponse { val creatorAccount = queryRepository.getMember(creatorId) ?: throw SodaException(messageKey = "member.validation.user_not_found") + val creatorName = localizedNameService.findLocalizedNames(listOf(creatorId), langContext.lang)[creatorId] + ?: creatorAccount.nickname if (isBlockedBetweenMembers(memberId = memberId, otherMemberId = creatorId)) { val messageTemplate = messageSource .getMessage("explorer.creator.blocked_access", langContext.lang) .orEmpty() - throw SodaException(message = String.format(messageTemplate, creatorAccount.nickname)) + throw SodaException(message = String.format(messageTemplate, creatorName)) } if (creatorAccount.role != MemberRole.CREATOR) { @@ -239,7 +237,7 @@ class ExplorerService( } return GetCreatorDetailResponse( - nickname = creatorAccount.nickname, + nickname = creatorName, profileImageUrl = if (creatorAccount.profileImage != null) { "$cloudFrontHost/${creatorAccount.profileImage}" } else { @@ -266,6 +264,8 @@ class ExplorerService( // 크리에이터(유저) 정보 val creatorAccount = queryRepository.getMember(creatorId) ?: throw SodaException(messageKey = "member.validation.user_not_found") + val creatorName = localizedNameService.findLocalizedNames(listOf(creatorId), langContext.lang)[creatorId] + ?: creatorAccount.nickname // 차단된 사용자 체크 val isBlockedByCreator = memberService.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) @@ -274,7 +274,7 @@ class ExplorerService( val messageTemplate = messageSource .getMessage("explorer.creator.blocked_access", langContext.lang) .orEmpty() - throw SodaException(message = String.format(messageTemplate, creatorAccount.nickname)) + throw SodaException(message = String.format(messageTemplate, creatorName)) } val isCreator = creatorAccount.role == MemberRole.CREATOR @@ -300,7 +300,7 @@ class ExplorerService( // 추천 크리에이터 val similarCreatorList = if (isCreator) { - queryRepository.getSimilarCreatorList(creatorId) + queryRepository.getSimilarCreatorList(creatorId, langContext.lang) } else { listOf() } @@ -437,7 +437,7 @@ class ExplorerService( } else { "$cloudFrontHost/profile/default-profile.png" }, - nickname = creatorAccount.nickname, + nickname = creatorName, tags = creatorAccount.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(), introduce = creatorAccount.introduce, instagramUrl = creatorAccount.instagramUrl, @@ -450,9 +450,9 @@ class ExplorerService( isNotification = creatorFollowing?.isFollow ?: false, notificationRecipientCount = notificationRecipientCount ), - userDonationRanking = memberDonationRanking, + userDonationRanking = renderDonationRanking(memberDonationRanking), similarCreatorList = similarCreatorList, - liveRoomList = liveRoomList, + liveRoomList = liveRoomList.map { it.copy(managerNickname = creatorName) }, contentList = translatedContentList, latestContent = latestContent, totalContentCount = totalContentCount, @@ -537,7 +537,7 @@ class ExplorerService( null }, totalCount = donationMemberTotal, - userDonationRanking = donationRanking + userDonationRanking = renderDonationRanking(donationRanking) ) } @@ -548,7 +548,12 @@ class ExplorerService( ): GetFollowerListResponse { val totalCount = queryRepository.getFollowerListTotalCount(creatorId) - val followerList = queryRepository.getFollowerList(creatorId, pageable.offset, pageable.pageSize.toLong()) + val followerList = queryRepository.getFollowerList( + creatorId, + pageable.offset, + pageable.pageSize.toLong(), + langContext.lang + ) .map { val creatorFollowing = if (it.role == MemberRole.CREATOR) { queryRepository.getCreatorFollowing( @@ -684,6 +689,7 @@ class ExplorerService( type = FcmEventType.CHANGE_NOTICE, category = PushNotificationCategory.COMMUNITY, title = member.nickname, + titleMemberId = member.id, messageKey = "explorer.notice.fcm.message", senderMemberId = member.id, creatorId = member.id!!, @@ -693,6 +699,11 @@ class ExplorerService( ) } + private fun renderDonationRanking(items: List): List { + val names = localizedNameService.findCurrentNames(items.map { it.userId }, langContext.lang) + return items.map { it.copy(nickname = names[it.userId] ?: it.nickname) } + } + private fun isBlockedBetweenMembers(memberId: Long, otherMemberId: Long): Boolean { return memberService.isBlocked(blockedMemberId = memberId, memberId = otherMemberId) || memberService.isBlocked(blockedMemberId = otherMemberId, memberId = memberId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt index b5f18b33..dc00b1a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt @@ -1,7 +1,11 @@ package kr.co.vividnext.sodalive.explorer.profile.channelDonation +import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -18,8 +22,9 @@ interface ChannelDonationMessageQueryRepository { offset: Long, limit: Long, startDateTime: LocalDateTime, - endDateTime: LocalDateTime - ): List + endDateTime: LocalDateTime, + lang: Lang = Lang.KO + ): List> fun getChannelDonationMessageTotalCount( creatorId: Long, @@ -40,8 +45,13 @@ class ChannelDonationMessageQueryRepositoryImpl( offset: Long, limit: Long, startDateTime: LocalDateTime, - endDateTime: LocalDateTime - ): List { + endDateTime: LocalDateTime, + lang: Lang + ): List> { + val creatorName = CreatorNameJoin(member, lang, "channelDonorName") + val originalName = CaseBuilder().`when`(member.nickname.startsWith("deleted_")) + .then(member.nickname.substring(8)).otherwise(member.nickname) + val displayName = creatorName.name.displayName.coalesce(originalName) val where = whereCondition( creatorId = creatorId, memberId = memberId, @@ -51,8 +61,10 @@ class ChannelDonationMessageQueryRepositoryImpl( ) return queryFactory - .selectFrom(channelDonationMessage) - .leftJoin(channelDonationMessage.member).fetchJoin() + .select(channelDonationMessage, displayName) + .from(channelDonationMessage) + .leftJoin(channelDonationMessage.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) .where(where) .offset(offset) .limit(limit) @@ -61,6 +73,7 @@ class ChannelDonationMessageQueryRepositoryImpl( channelDonationMessage.id.desc() ) .fetch() + .map { row -> row.get(channelDonationMessage)!! to row.get(displayName)!! } } override fun getChannelDonationMessageTotalCount( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt index 9cb46f3a..b4f1cd57 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt @@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.explorer.profile.channelDonation import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member @@ -99,12 +98,13 @@ class ChannelDonationService( offset = offset, limit = limit, startDateTime = startDateTime, - endDateTime = endDateTime - ).map { + endDateTime = endDateTime, + lang = langContext.lang + ).map { (it, nickname) -> GetChannelDonationListItem( id = it.id!!, memberId = it.member!!.id!!, - nickname = it.member!!.nickname.removeDeletedNicknamePrefix(), + nickname = nickname, profileUrl = if (it.member!!.profileImage != null) { "$cloudFrontHost/${it.member!!.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 9eb1805b..ada466e7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -140,6 +140,7 @@ class CreatorCommunityService( type = FcmEventType.CHANGE_NOTICE, category = PushNotificationCategory.COMMUNITY, title = member.nickname, + titleMemberId = member.id, messageKey = "creator.community.fcm.new_post", senderMemberId = member.id, creatorId = member.id!!, @@ -533,6 +534,7 @@ class CreatorCommunityService( type = FcmEventType.INDIVIDUAL, category = PushNotificationCategory.COMMUNITY, title = member.nickname, + titleMemberId = member.id, messageKey = "creator.community.fcm.new_comment", senderMemberId = member.id, recipients = listOf(creatorId), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt index 537d3227..b3d49acf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Propagation @@ -56,7 +57,9 @@ class FcmEvent( val commentParentId: Long? = null, val myMemberId: Long? = null, val isAvailableJoinCreator: Boolean? = null, - val genderRestriction: GenderRestriction? = null + val genderRestriction: GenderRestriction? = null, + val titleMemberId: Long? = null, + val nameArgumentMemberIds: Map = emptyMap() ) @Component @@ -65,7 +68,8 @@ class FcmSendListener( private val memberRepository: MemberRepository, private val contentCommentRepository: AudioContentCommentRepository, private val messageSource: SodaMessageSource, - private val pushNotificationService: PushNotificationService + private val pushNotificationService: PushNotificationService, + private val localizedNameService: CreatorLocalizedNameService ) { @Async @TransactionalEventListener @@ -169,18 +173,26 @@ class FcmSendListener( creatorId: Long? = null, auditionId: Long? = null ) { - val tokensByLang = pushTokens.groupBy { it.languageCode } + val tokensByLang = pushTokens.groupBy { Lang.fromAcceptLanguage(it.languageCode) } + val nameMemberIds = listOfNotNull(fcmEvent.senderMemberId, fcmEvent.titleMemberId) + .plus(fcmEvent.nameArgumentMemberIds.values) + .distinct() - for ((langCode, tokens) in tokensByLang) { - val lang = Lang.fromAcceptLanguage(langCode) - val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args) - val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args) + for ((lang, tokens) in tokensByLang) { + val names = localizedNameService.findLocalizedNames(nameMemberIds, lang) + val args = fcmEvent.args.mapIndexed { index, argument -> + fcmEvent.nameArgumentMemberIds[index]?.let { names[it] } ?: argument + } + val defaultTitle = fcmEvent.titleMemberId?.let { names[it] } ?: fcmEvent.title + val title = translate(fcmEvent.titleKey, defaultTitle, lang, args) + val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, args) pushNotificationService.saveNotification( fcmEvent = fcmEvent, languageCode = lang.code, translatedMessage = message, - recipientPushTokens = tokens + recipientPushTokens = tokens, + localizedSenderNickname = fcmEvent.senderMemberId?.let { names[it] } ) val tokensByOS = tokens.groupBy { it.deviceType } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt index ec34350c..0f348000 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt @@ -36,7 +36,8 @@ class PushNotificationService( fcmEvent: FcmEvent, languageCode: String, translatedMessage: String, - recipientPushTokens: List + recipientPushTokens: List, + localizedSenderNickname: String? ) { if (recipientPushTokens.isEmpty()) return @@ -58,7 +59,7 @@ class PushNotificationService( ) val notification = PushNotificationList( - senderNicknameSnapshot = senderSnapshot.nickname, + senderNicknameSnapshot = localizedSenderNickname ?: senderSnapshot.nickname, senderProfileImageSnapshot = senderSnapshot.profileImage, message = translatedMessage, category = category, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt index cece4a56..fc641568 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -70,8 +71,10 @@ class LiveRecommendRepository( fun getOnAirRecommendChannelList( isBlocked: (Long) -> Boolean, isCreator: Boolean, - isAdult: Boolean + isAdult: Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "onAirRecommendedCreatorName") var where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -88,20 +91,21 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(true) ) ) .from(liveRoom) .rightJoin(liveRoom.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( where .and(liveRoom.isActive.isTrue) .and(liveRoom.channelName.isNotNull) .and(liveRoom.channelName.isNotEmpty) ) - .groupBy(member.id) + .groupBy(member.id, member.nickname, member.profileImage, creatorName.name.displayName) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(20) .fetch() @@ -113,8 +117,10 @@ class LiveRecommendRepository( fun getRecommendChannelList( withOutCreatorList: List, limit: Long, - isBlocked: (Long) -> Boolean + isBlocked: (Long) -> Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "recommendedCreatorName") val where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -123,12 +129,13 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(false) ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(where.and(member.id.notIn(withOutCreatorList))) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(limit) @@ -140,8 +147,10 @@ class LiveRecommendRepository( memberId: Long, isBlocked: (Long) -> Boolean, isCreator: Boolean, - isAdult: Boolean + isAdult: Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "onAirFollowingCreatorName") var where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -158,7 +167,7 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(true) ) @@ -166,6 +175,7 @@ class LiveRecommendRepository( .from(liveRoom, creatorFollowing) .rightJoin(liveRoom.member, member) .innerJoin(creatorFollowing.creator, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( where .and(liveRoom.member.id.eq(creatorFollowing.creator.id)) @@ -175,7 +185,7 @@ class LiveRecommendRepository( .and(liveRoom.channelName.isNotNull) .and(liveRoom.channelName.isNotEmpty) ) - .groupBy(member.id) + .groupBy(member.id, member.nickname, member.profileImage, creatorName.name.displayName) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(20) .fetch() @@ -188,8 +198,10 @@ class LiveRecommendRepository( memberId: Long, withOutCreatorList: List, limit: Long, - isBlocked: (Long) -> Boolean + isBlocked: (Long) -> Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "followingCreatorName") val where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -198,13 +210,14 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(false) ) ) .from(creatorFollowing) .innerJoin(creatorFollowing.creator, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( where .and(member.id.notIn(withOutCreatorList)) @@ -244,8 +257,10 @@ class LiveRecommendRepository( memberId: Long, offset: Long, limit: Long, - isBlocked: (Long) -> Boolean + isBlocked: (Long) -> Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "allFollowingCreatorName") val where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) .and(creatorFollowing.isActive.isTrue) @@ -256,7 +271,7 @@ class LiveRecommendRepository( .select( QGetCreatorFollowingAllListItem( member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(true), creatorFollowing.isNotify @@ -264,10 +279,12 @@ class LiveRecommendRepository( ) .from(creatorFollowing) .innerJoin(creatorFollowing.creator, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(where) .groupBy( member.id, member.nickname, + creatorName.name.displayName, member.profileImage, creatorFollowing.isNotify ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt index 0a2a36fe..703df1c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.live.recommend import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -13,7 +14,8 @@ class LiveRecommendService( private val repository: LiveRecommendRepository, private val blockMemberRepository: BlockMemberRepository, private val memberContentPreferenceService: MemberContentPreferenceService, - private val liveRecommendCacheService: LiveRecommendCacheService + private val liveRecommendCacheService: LiveRecommendCacheService, + private val langContext: LangContext ) { fun getRecommendLive(member: Member?, lang: Lang): List { val isAdult = if (member != null) { @@ -45,7 +47,8 @@ class LiveRecommendService( } }, isCreator = member?.role == MemberRole.CREATOR, - isAdult = isAdult + isAdult = isAdult, + lang = langContext.lang ) if (onAirChannelList.size >= 20) { @@ -63,7 +66,8 @@ class LiveRecommendService( } else { false } - } + }, + lang = langContext.lang ) return onAirChannelList + notOnAirCreatorList @@ -76,7 +80,8 @@ class LiveRecommendService( memberId = member.id!!, isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) }, isCreator = member.role == MemberRole.CREATOR, - isAdult = isAdult + isAdult = isAdult, + lang = langContext.lang ) if (onAirFollowingChannelList.size >= 20) { @@ -89,7 +94,8 @@ class LiveRecommendService( memberId = member.id!!, withOutCreatorList = onAirCreatorIdList, limit = (20 - onAirCreatorIdList.size).toLong(), - isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) }, + lang = langContext.lang ) return onAirFollowingChannelList + notOnAirFollowingChannelList @@ -105,7 +111,8 @@ class LiveRecommendService( memberId = member.id!!, offset = pageable.offset, limit = pageable.pageSize.toLong(), - isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) }, + lang = langContext.lang ) return GetCreatorFollowingAllListResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationListRow.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationListRow.kt new file mode 100644 index 00000000..f2a904c0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationListRow.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.live.reservation + +import java.time.LocalDateTime + +data class LiveReservationListRow( + val id: Long, + val roomId: Long, + val title: String, + val coverImage: String?, + val price: Int, + val masterNickname: String, + val beginDateTime: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt index b161730a..7ea1cdbe 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt @@ -1,10 +1,17 @@ package kr.co.vividnext.sodalive.live.reservation +import com.querydsl.core.types.Projections +import com.querydsl.jpa.impl.JPAQuery import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -12,29 +19,45 @@ import org.springframework.stereotype.Repository interface LiveReservationRepository : JpaRepository, LiveReservationQueryRepository interface LiveReservationQueryRepository { - fun getReservationList(roomId: Long): List + fun getReservationList(roomId: Long, lang: Lang): List fun cancelReservation(roomId: Long) fun getReservationBookerList(roomId: Long): List fun isExistsReservation(roomId: Long, memberId: Long): Boolean - fun getReservationListByMemberId(memberId: Long, active: Boolean): List + fun getReservationListByMemberId(memberId: Long, active: Boolean, lang: Lang): List fun getReservationByReservationAndMemberId( reservationId: Long, - memberId: Long - ): LiveReservation? + memberId: Long, + lang: Lang + ): LiveReservationListRow? fun getReservationCount(memberId: Long): Int } @Repository -class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveReservationQueryRepository { - override fun getReservationList(roomId: Long): List { +class LiveReservationQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : LiveReservationQueryRepository { + override fun getReservationList(roomId: Long, lang: Lang): List { + val bookerName = CreatorNameJoin(member, lang, "liveReservationBookerName") return queryFactory - .selectFrom(liveReservation) + .select( + Projections.constructor( + GetRoomDetailUser::class.java, + member.id, + bookerName.displayName, + member.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ) + .from(liveReservation) .innerJoin(liveReservation.room, liveRoom) + .innerJoin(liveReservation.member, member) + .leftJoin(bookerName.name).on(bookerName.condition) .where( liveRoom.id.eq(roomId) .and(liveReservation.isActive.isTrue) @@ -76,11 +99,8 @@ class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFacto .fetchFirst() != null } - override fun getReservationListByMemberId(memberId: Long, active: Boolean): List { - return queryFactory - .selectFrom(liveReservation) - .innerJoin(liveReservation.member, member) - .innerJoin(liveReservation.room, liveRoom) + override fun getReservationListByMemberId(memberId: Long, active: Boolean, lang: Lang): List { + return reservationRows(lang) .where( liveRoom.isActive.isTrue .and(liveReservation.isActive.eq(active)) @@ -89,11 +109,12 @@ class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFacto .fetch() } - override fun getReservationByReservationAndMemberId(reservationId: Long, memberId: Long): LiveReservation? { - return queryFactory - .selectFrom(liveReservation) - .innerJoin(liveReservation.member, member) - .innerJoin(liveReservation.room, liveRoom) + override fun getReservationByReservationAndMemberId( + reservationId: Long, + memberId: Long, + lang: Lang + ): LiveReservationListRow? { + return reservationRows(lang) .where( liveReservation.id.eq(reservationId) .and(member.id.eq(memberId)) @@ -101,6 +122,29 @@ class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFacto .fetchFirst() } + private fun reservationRows(lang: Lang): JPAQuery { + val owner = QMember("reservationRoomOwner") + val ownerName = CreatorNameJoin(owner, lang, "reservationRoomOwnerName") + return queryFactory + .select( + Projections.constructor( + LiveReservationListRow::class.java, + liveReservation.id, + liveRoom.id, + liveRoom.title, + liveRoom.coverImage, + liveRoom.price, + ownerName.displayName, + liveRoom.beginDateTime + ) + ) + .from(liveReservation) + .innerJoin(liveReservation.member, member) + .innerJoin(liveReservation.room, liveRoom) + .innerJoin(liveRoom.member, owner) + .leftJoin(ownerName.name).on(ownerName.condition) + } + override fun getReservationCount(memberId: Long): Int { return queryFactory .selectFrom(liveReservation) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt index de3abde4..332e02af 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.live.room.LiveRoomType import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -25,6 +26,7 @@ class LiveReservationService( private val liveReservationCancelRepository: LiveReservationCancelRepository, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -78,10 +80,11 @@ class LiveReservationService( "live.reservation.datetime_format", langContext.lang ).orEmpty() + val names = localizedNameService.findLocalizedNames(listOf(room.member!!.id!!), langContext.lang) return MakeLiveReservationResponse( reservationId = reservation.id!!, - nickname = room.member!!.nickname, + nickname = names[room.member!!.id] ?: room.member!!.nickname, title = room.title, beginDateString = beginDateTime.format(DateTimeFormatter.ofPattern(reservationDateFormat)), beginDateTimeUtc = room.beginDateTime @@ -98,34 +101,35 @@ class LiveReservationService( ) } + @Transactional(readOnly = true) fun getReservationList(memberId: Long, active: Boolean, timezone: String): List { val detailDateFormat = messageSource.getMessage( "live.room.datetime_format_detail", langContext.lang ).orEmpty() - return repository - .getReservationListByMemberId(memberId, active) + val reservations = repository.getReservationListByMemberId(memberId, active, langContext.lang) + return reservations .asSequence() .map { - val beginDateTime = it.room!!.beginDateTime + val beginDateTime = it.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) GetLiveReservationResponse( - reservationId = it.id!!, - roomId = it.room!!.id!!, - title = it.room!!.title, - coverImageUrl = if (it.room!!.coverImage != null) { - "$cloudFrontHost/${it.room!!.coverImage}" + reservationId = it.id, + roomId = it.roomId, + title = it.title, + coverImageUrl = if (it.coverImage != null) { + "$cloudFrontHost/${it.coverImage}" } else { "$cloudFrontHost/profile/default-profile.png" }, - price = it.room!!.price, - masterNickname = it.room!!.member!!.nickname, + price = it.price, + masterNickname = it.masterNickname, beginDateTime = beginDateTime.format( DateTimeFormatter.ofPattern(detailDateFormat) ), - beginDateTimeUtc = it.room!!.beginDateTime + beginDateTimeUtc = it.beginDateTime .atZone(ZoneId.of("UTC")) .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), cancelable = beginDateTime.minusHours(4).isAfter( @@ -138,33 +142,34 @@ class LiveReservationService( .toList() } + @Transactional(readOnly = true) fun getReservation(reservationId: Long, memberId: Long, timezone: String): GetLiveReservationResponse { - val reservation = repository.getReservationByReservationAndMemberId(reservationId, memberId) + val reservation = repository.getReservationByReservationAndMemberId(reservationId, memberId, langContext.lang) ?: throw SodaException(messageKey = "live.reservation.invalid_reservation") val detailDateFormat = messageSource.getMessage( "live.room.datetime_format_detail", langContext.lang ).orEmpty() - val beginDateTime = reservation.room!!.beginDateTime + val beginDateTime = reservation.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) return GetLiveReservationResponse( - reservationId = reservation.id!!, - roomId = reservation.room!!.id!!, - title = reservation.room!!.title, - coverImageUrl = if (reservation.room!!.coverImage != null) { - "$cloudFrontHost/${reservation.room!!.coverImage}" + reservationId = reservation.id, + roomId = reservation.roomId, + title = reservation.title, + coverImageUrl = if (reservation.coverImage != null) { + "$cloudFrontHost/${reservation.coverImage}" } else { "$cloudFrontHost/profile/default-profile.png" }, - price = reservation.room!!.price, - masterNickname = reservation.room!!.member!!.nickname, + price = reservation.price, + masterNickname = reservation.masterNickname, beginDateTime = beginDateTime.format( DateTimeFormatter.ofPattern(detailDateFormat) ), - beginDateTimeUtc = reservation.room!!.beginDateTime + beginDateTimeUtc = reservation.beginDateTime .atZone(ZoneId.of("UTC")) .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), cancelable = beginDateTime.minusHours(4).isAfter( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt index b2d2cabb..7b01146c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.live.room +import kr.co.vividnext.sodalive.member.Member + data class GetLiveRoomUserProfileResponse( val userId: Long, val nickname: String, @@ -19,3 +21,5 @@ data class GetLiveRoomUserProfileResponse( val isFollowing: Boolean?, val isBlock: Boolean ) + +data class LiveRoomUserProfileRow(val member: Member, val nickname: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomListRow.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomListRow.kt new file mode 100644 index 00000000..1946d422 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomListRow.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.live.room + +data class LiveRoomListRow(val room: LiveRoom, val creatorNickname: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt index f7fa5f88..106bd3d0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt @@ -8,17 +8,19 @@ import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.live.room.QQuarterLiveRankings.quarterLiveRankings import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationItem import kr.co.vividnext.sodalive.live.room.donation.QGetLiveRoomDonationItem import kr.co.vividnext.sodalive.live.room.like.GetLiveRoomHeartListItem -import kr.co.vividnext.sodalive.live.room.like.QGetLiveRoomHeartListItem import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value +import org.springframework.cache.annotation.Cacheable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -35,8 +37,9 @@ interface LiveRoomQueryRepository { memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List + effectiveGender: Gender?, + lang: Lang + ): List fun getLiveRoomListReservationWithDate( date: LocalDateTime, @@ -45,30 +48,40 @@ interface LiveRoomQueryRepository { memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List + effectiveGender: Gender?, + lang: Lang + ): List fun getLiveRoomListReservationWithoutDate( timezone: String, memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List + effectiveGender: Gender?, + lang: Lang + ): List fun getLiveRoom(id: Long): LiveRoom? + fun getLiveRoomDetail(id: Long, lang: Lang): LiveRoomListRow? + fun getLiveRoomUserProfile(userId: Long, lang: Lang): LiveRoomUserProfileRow? fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? fun getRecentRoomInfo(memberId: Long): GetRecentRoomInfoResponse? fun getDonationTotal(roomId: Long): Int? - fun getDonationList(roomId: Long, isLiveCreator: Boolean, memberId: Long): List + fun getDonationList(roomId: Long, isLiveCreator: Boolean, memberId: Long, lang: Lang): List fun getRoomActiveAndChannelNameIsNotNull(memberId: Long): List fun getActiveRoomIdList(memberId: Long): Int fun getTotalHeartCount(roomId: Long): Int? fun getLiveRoomCreatorId(roomId: Long): Long? - fun getHeartList(roomId: Long): List + fun getHeartList(roomId: Long, lang: Lang): List + + @Cacheable( + cacheNames = ["cache_ttl_10_minutes"], + key = "'getLatestFinishedLive:' + (#memberId ?: 'guest')" + ) fun getLatestFinishedLive(memberId: Long?): List } +@Repository class LiveRoomQueryRepositoryImpl( private val queryFactory: JPAQueryFactory, @@ -82,8 +95,10 @@ class LiveRoomQueryRepositoryImpl( memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List { + effectiveGender: Gender?, + lang: Lang + ): List { + val creatorName = CreatorNameJoin(member, lang, "liveRoomNowCreatorName") var where = liveRoom.channelName.isNotNull .and(liveRoom.channelName.isNotEmpty) .and(liveRoom.isActive.isTrue) @@ -116,8 +131,10 @@ class LiveRoomQueryRepositoryImpl( } var select = queryFactory - .selectFrom(liveRoom) - .innerJoin(liveRoom.member, member) + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(quarterLiveRankings).on(liveRoom.id.eq(quarterLiveRankings.roomId)) if (hasMemberId) { @@ -146,6 +163,7 @@ class LiveRoomQueryRepositoryImpl( liveRoom.member.createdAt.desc() ) .fetch() + .map { LiveRoomListRow(it.get(liveRoom)!!, it.get(creatorName.displayName)!!) } } override fun getLiveRoomListReservationWithDate( @@ -155,8 +173,10 @@ class LiveRoomQueryRepositoryImpl( memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List { + effectiveGender: Gender?, + lang: Lang + ): List { + val creatorName = CreatorNameJoin(member, lang, "liveRoomReservationDateCreatorName") var where = liveRoom.beginDateTime.goe(date) .and(liveRoom.beginDateTime.lt(date.plusDays(1))) .and( @@ -192,8 +212,10 @@ class LiveRoomQueryRepositoryImpl( } var select = queryFactory - .selectFrom(liveRoom) - .innerJoin(liveRoom.member, member) + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) if (memberId != null) { val blockMemberCondition = blockMember.isActive.isTrue @@ -216,6 +238,7 @@ class LiveRoomQueryRepositoryImpl( .limit(limit) .orderBy(liveRoom.beginDateTime.asc()) .fetch() + .map { LiveRoomListRow(it.get(liveRoom)!!, it.get(creatorName.displayName)!!) } } override fun getLiveRoomListReservationWithoutDate( @@ -223,8 +246,10 @@ class LiveRoomQueryRepositoryImpl( memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List { + effectiveGender: Gender?, + lang: Lang + ): List { + val creatorName = CreatorNameJoin(member, lang, "liveRoomReservationCreatorName") var where = liveRoom.beginDateTime.gt( LocalDateTime.now() .atZone(ZoneId.of(timezone)) @@ -276,8 +301,10 @@ class LiveRoomQueryRepositoryImpl( } var select = queryFactory - .selectFrom(liveRoom) - .innerJoin(liveRoom.member, member) + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) .limit(10) if (memberId != null) { @@ -299,6 +326,7 @@ class LiveRoomQueryRepositoryImpl( .where(where) .orderBy(*orderBy.toTypedArray()) .fetch() + .map { LiveRoomListRow(it.get(liveRoom)!!, it.get(creatorName.displayName)!!) } } override fun getLiveRoom(id: Long): LiveRoom? { @@ -312,6 +340,29 @@ class LiveRoomQueryRepositoryImpl( .fetchFirst() } + override fun getLiveRoomDetail(id: Long, lang: Lang): LiveRoomListRow? { + val creatorName = CreatorNameJoin(member, lang, "liveRoomDetailCreatorName") + val row = queryFactory + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) + .where(liveRoom.id.eq(id).and(liveRoom.isActive.isTrue)) + .fetchFirst() ?: return null + return LiveRoomListRow(row.get(liveRoom)!!, row.get(creatorName.displayName)!!) + } + + override fun getLiveRoomUserProfile(userId: Long, lang: Lang): LiveRoomUserProfileRow? { + val profileName = CreatorNameJoin(member, lang, "liveRoomUserProfileName") + val row = queryFactory + .select(member, profileName.displayName) + .from(member) + .leftJoin(profileName.name).on(profileName.condition) + .where(member.id.eq(userId)) + .fetchOne() ?: return null + return LiveRoomUserProfileRow(row.get(member)!!, row.get(profileName.displayName)!!) + } + override fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? { return queryFactory .selectFrom(liveRoom) @@ -359,7 +410,13 @@ class LiveRoomQueryRepositoryImpl( .fetchOne() } - override fun getDonationList(roomId: Long, isLiveCreator: Boolean, memberId: Long): List { + override fun getDonationList( + roomId: Long, + isLiveCreator: Boolean, + memberId: Long, + lang: Lang + ): List { + val donorName = CreatorNameJoin(member, lang, "liveRoomDonorName") val where = liveRoom.id.eq(roomId) .and(useCan.canUsage.eq(CanUsage.DONATION).or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE))) .and(useCan.isRefund.isFalse) @@ -380,7 +437,7 @@ class LiveRoomQueryRepositoryImpl( .coalesce("profile/default-profile.png") .prepend("/") .prepend(cloudFrontHost), - member.nickname, + donorName.displayName, member.id.coalesce(0), Expressions.asNumber( CaseBuilder() @@ -406,7 +463,8 @@ class LiveRoomQueryRepositoryImpl( .from(useCan) .innerJoin(useCan.room, liveRoom) .innerJoin(useCan.member, member) - .groupBy(useCan.member.id) + .leftJoin(donorName.name).on(donorName.condition) + .groupBy(useCan.member.id, member.nickname, donorName.name.displayName) .where(where) .orderBy(sortExpression.sum().desc()) .fetch() @@ -461,26 +519,29 @@ class LiveRoomQueryRepositoryImpl( .fetchFirst() } - override fun getHeartList(roomId: Long): List { + override fun getHeartList(roomId: Long, lang: Lang): List { + val donorName = CreatorNameJoin(member, lang, "liveRoomHeartDonorName") val where = liveRoom.id.eq(roomId) .and(useCan.canUsage.eq(CanUsage.HEART)) .and(useCan.isRefund.isFalse) return queryFactory .select( - QGetLiveRoomHeartListItem( + Projections.constructor( + GetLiveRoomHeartListItem::class.java, member.profileImage .coalesce("profile/default-profile.png") .prepend("/") .prepend(cloudFrontHost), - member.nickname, + donorName.displayName, useCan.can.add(useCan.rewardCan).sum() ) ) .from(useCan) .innerJoin(useCan.room, liveRoom) .innerJoin(useCan.member, member) - .groupBy(useCan.member.id) + .leftJoin(donorName.name).on(donorName.condition) + .groupBy(useCan.member.id, member.nickname, donorName.name.displayName) .where(where) .orderBy(useCan.can.add(useCan.rewardCan).sum().desc()) .fetch() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 736cab4a..611d770d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.PushTokenRepository import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +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.LiveReservationRepository @@ -67,8 +68,8 @@ 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 kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value -import org.springframework.cache.annotation.Cacheable import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable import org.springframework.data.repository.findByIdOrNull @@ -89,6 +90,7 @@ class LiveRoomService( private val menuService: LiveRoomMenuService, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val repository: LiveRoomRepository, private val rouletteRepository: NewRouletteRepository, @@ -247,14 +249,14 @@ class LiveRoomService( ) } - val creatorIds = roomList.mapNotNull { it.member?.id }.distinct() + val creatorIds = roomList.mapNotNull { it.room.member?.id }.distinct() val languageTagByMemberId = buildLanguageTagMap(creatorIds) return roomList - .map { - val roomInfo = roomInfoRepository.findByIdOrNull(it.id!!) + .map { (room, creatorNickname) -> + val roomInfo = roomInfoRepository.findByIdOrNull(room.id!!) - val reservations = it.reservations + val reservations = room.reservations .filter { reservation -> if (member?.id != null) { reservation.member!!.id!! == member.id!! && reservation.isActive @@ -263,7 +265,7 @@ class LiveRoomService( } } - val beginDateTime = it.beginDateTime + val beginDateTime = room.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) .format( @@ -272,42 +274,42 @@ class LiveRoomService( .withLocale(langContext.lang.locale) ) - val beginDateTimeUtc = it.beginDateTime + val beginDateTimeUtc = room.beginDateTime .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) - val tags = it.tags + val tags = room.tags .filter { tag -> tag.tag.isActive } .map { tag -> tag.tag.tag } - .let { list -> applyLanguageTagToRoomTags(it.member?.id, list, languageTagByMemberId) } + .let { list -> applyLanguageTagToRoomTags(room.member?.id, list, languageTagByMemberId) } GetRoomListResponse( - roomId = it.id!!, - title = it.title, - content = it.notice, + roomId = room.id!!, + title = room.title, + content = room.notice, beginDateTime = beginDateTime, beginDateTimeUtc = beginDateTimeUtc, numberOfParticipate = (roomInfo?.listenerCount ?: 0) + (roomInfo?.speakerCount ?: 0) + (roomInfo?.managerCount ?: 0), - numberOfPeople = it.numberOfPeople, - isAdult = it.isAdult, - price = it.price, - channelName = it.channelName, - creatorProfileImage = if (it.member!!.profileImage != null) { - "$cloudFrontHost/${it.member!!.profileImage}" + numberOfPeople = room.numberOfPeople, + isAdult = room.isAdult, + price = room.price, + channelName = room.channelName, + creatorProfileImage = if (room.member!!.profileImage != null) { + "$cloudFrontHost/${room.member!!.profileImage}" } else { "$cloudFrontHost/profile/default-profile.png" }, - creatorNickname = it.member!!.nickname, - creatorId = it.member!!.id!!, + creatorNickname = creatorNickname, + creatorId = room.member!!.id!!, tags = tags, - coverImageUrl = if (it.coverImage!!.startsWith("https://")) { - it.coverImage!! + coverImageUrl = if (room.coverImage!!.startsWith("https://")) { + room.coverImage!! } else { - "$cloudFrontHost/${it.coverImage!!}" + "$cloudFrontHost/${room.coverImage!!}" }, isReservation = reservations.isNotEmpty(), - isPrivateRoom = it.type == LiveRoomType.PRIVATE + isPrivateRoom = room.type == LiveRoomType.PRIVATE ) } } @@ -319,7 +321,7 @@ class LiveRoomService( isCreator: Boolean, isAdult: Boolean, effectiveGender: Gender? - ): List { + ): List { return repository.getLiveRoomListNow( offset = pageable.offset, limit = pageable.pageSize.toLong(), @@ -327,7 +329,8 @@ class LiveRoomService( memberId = memberId, isCreator = isCreator, isAdult = isAdult, - effectiveGender = effectiveGender + effectiveGender = effectiveGender, + lang = langContext.lang ) } @@ -339,7 +342,7 @@ class LiveRoomService( isCreator: Boolean, isAdult: Boolean, effectiveGender: Gender? - ): List { + ): List { val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val date = LocalDate.parse(dateString, dateTimeFormatter).atStartOfDay() .atZone(ZoneId.of(timezone)) @@ -353,7 +356,8 @@ class LiveRoomService( memberId = memberId, isCreator = isCreator, isAdult = isAdult, - effectiveGender = effectiveGender + effectiveGender = effectiveGender, + lang = langContext.lang ) } @@ -363,8 +367,15 @@ class LiveRoomService( isCreator: Boolean, isAdult: Boolean, effectiveGender: Gender? - ): List { - return repository.getLiveRoomListReservationWithoutDate(timezone, memberId, isCreator, isAdult, effectiveGender) + ): List { + return repository.getLiveRoomListReservationWithoutDate( + timezone, + memberId, + isCreator, + isAdult, + effectiveGender, + langContext.lang + ) } @Transactional @@ -504,6 +515,7 @@ class LiveRoomService( type = FcmEventType.CREATE_LIVE, category = PushNotificationCategory.LIVE, title = createdRoom.member!!.nickname, + titleMemberId = createdRoom.member!!.id, messageKey = if (createdRoom.channelName != null) { "live.room.fcm.message.started" } else { @@ -525,7 +537,7 @@ class LiveRoomService( } fun getRoomDetail(roomId: Long, member: Member, timezone: String): GetRoomDetailResponse { - val room = repository.getLiveRoom(id = roomId) + val (room, creatorNickname) = repository.getLiveRoomDetail(id = roomId, lang = langContext.lang) ?: throw SodaException(messageKey = "live.room.already_ended") val creatorId = room.member!!.id!! @@ -573,7 +585,7 @@ class LiveRoomService( genderRestriction = room.genderRestriction, password = room.password ) - response.manager = GetRoomDetailManager(room.member!!, cloudFrontHost = cloudFrontHost) + response.manager = GetRoomDetailManager(room.member!!, cloudFrontHost, creatorNickname) if (!room.channelName.isNullOrBlank()) { val roomInfo = roomInfoRepository.findByIdOrNull(roomId) @@ -618,21 +630,14 @@ class LiveRoomService( } ) - response.participatingUsers = users + val names = findCurrentNames(users.map { it.id }, langContext.lang) + response.participatingUsers = users.map { it.copy(nickname = names[it.id] ?: it.nickname) } response.numberOfParticipants = users.size } } else { - val reservationList = reservationRepository.getReservationList(roomId) + val reservationList = reservationRepository.getReservationList(roomId, langContext.lang) + response.isPaid = reservationList.any { it.id == member.id } response.participatingUsers = reservationList - .asSequence() - .map { - if (it.member!!.id!! == member.id!!) { - response.isPaid = true - } - - GetRoomDetailUser(it.member!!, cloudFrontHost) - } - .toList() response.numberOfParticipants = reservationList.size } @@ -686,6 +691,7 @@ class LiveRoomService( type = FcmEventType.START_LIVE, category = PushNotificationCategory.LIVE, title = room.member!!.nickname, + titleMemberId = room.member!!.id, messageKey = "live.room.fcm.message.started", senderMemberId = room.member!!.id, args = listOf(room.title), @@ -761,6 +767,7 @@ class LiveRoomService( type = FcmEventType.CANCEL_LIVE, category = PushNotificationCategory.LIVE, title = room.member!!.nickname, + titleMemberId = room.member!!.id, messageKey = "live.room.fcm.message.canceled", senderMemberId = room.member!!.id, args = listOf(room.title), @@ -792,8 +799,9 @@ class LiveRoomService( val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = room.member!!.id!!) if (isBlocked) { + val names = localizedNameService.findLocalizedNames(listOf(room.member!!.id!!), langContext.lang) throw SodaException( - message = formatMessage("live.room.enter_blocked_by_host", room.member!!.nickname) + message = formatMessage("live.room.enter_blocked_by_host", names[room.member!!.id] ?: room.member!!.nickname) ) } @@ -807,8 +815,12 @@ class LiveRoomService( val kickOutCount = kickOutService.getKickOutCount(roomId = room.id!!, userId = member.id!!) if (kickOutCount >= 2) { + val names = localizedNameService.findLocalizedNames(listOf(room.member!!.id!!), langContext.lang) throw SodaException( - message = formatMessage("live.room.participation_blocked_by_host", room.member!!.nickname) + message = formatMessage( + "live.room.participation_blocked_by_host", + names[room.member!!.id] ?: room.member!!.nickname + ) ) } @@ -1057,6 +1069,10 @@ class LiveRoomService( ?.lowercase() ?.take(2) + val names = findCurrentNames( + listOf(creatorId) + (roomInfo.speakerList + roomInfo.listenerList + roomInfo.managerList).map { it.id }, + langContext.lang + ) return GetRoomInfoResponse( roomId = roomId, title = room.title, @@ -1079,7 +1095,7 @@ class LiveRoomService( rtmToken = rtmToken, v2vWorkerToken = v2vWorkerToken, creatorId = room.member!!.id!!, - creatorNickname = room.member!!.nickname, + creatorNickname = names[creatorId] ?: room.member!!.nickname, creatorProfileUrl = if (room.member!!.profileImage != null) { "$cloudFrontHost/${room.member!!.profileImage}" } else { @@ -1090,9 +1106,9 @@ class LiveRoomService( isCaptureRecordingAvailable = room.isCaptureRecordingAvailable, participantsCount = roomInfo.listenerCount + roomInfo.speakerCount + roomInfo.managerCount, totalAvailableParticipantsCount = room.numberOfPeople, - speakerList = roomInfo.speakerList, - listenerList = roomInfo.listenerList, - managerList = roomInfo.managerList, + speakerList = roomInfo.speakerList.map { it.copy(nickname = names[it.id] ?: it.nickname).apply { role = it.role } }, + listenerList = roomInfo.listenerList.map { it.copy(nickname = names[it.id] ?: it.nickname).apply { role = it.role } }, + managerList = roomInfo.managerList.map { it.copy(nickname = names[it.id] ?: it.nickname).apply { role = it.role } }, donationRankingTop3UserIds = donationRankingTop3UserIds, menuPan = menuPan?.menu ?: "", creatorLanguageCode = creatorLanguageCode, @@ -1129,12 +1145,14 @@ class LiveRoomService( val roomInfo = roomInfoRepository.findByIdOrNull(roomId) ?: throw SodaException(messageKey = "live.room.info_not_found") - return if (liveRoomCreatorId != member.id!!) { + val messages = if (liveRoomCreatorId != member.id!!) { roomInfo.donationMessageList .filter { !it.isSecret || it.memberId == member.id!! } } else { roomInfo.donationMessageList } + val names = findCurrentNames(messages.map { it.memberId }, langContext.lang) + return messages.map { it.copy(nickname = names[it.memberId] ?: it.nickname) } } fun deleteDonationMessage(request: DeleteLiveRoomDonationMessage, member: Member) { @@ -1161,7 +1179,7 @@ class LiveRoomService( val roomInfo = roomInfoRepository.findByIdOrNull(roomId) ?: throw SodaException(messageKey = "live.room.info_not_found") - val user = memberRepository.findByIdOrNull(userId) + val (user, nickname) = repository.getLiveRoomUserProfile(userId, langContext.lang) ?: throw SodaException(messageKey = "common.error.invalid_request") val isFollowing = if (user.role == MemberRole.CREATOR) { @@ -1194,7 +1212,7 @@ class LiveRoomService( return GetLiveRoomUserProfileResponse( userId = user.id!!, - nickname = user.nickname, + nickname = nickname, profileUrl = if (user.profileImage != null) { "$cloudFrontHost/${user.profileImage}" } else { @@ -1439,7 +1457,8 @@ class LiveRoomService( val donationList = repository.getDonationList( roomId = room.id!!, isLiveCreator = isLiveCreator, - memberId = memberId + memberId = memberId, + lang = langContext.lang ).filter { it.secretCan > 0 || it.can > 0 } val totalCan = donationList.sumOf { it.can } @@ -1492,6 +1511,10 @@ class LiveRoomService( return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } } + private fun findCurrentNames(memberIds: Collection, lang: Lang): Map { + return localizedNameService.findCurrentNames(memberIds.distinct(), lang) + } + private fun resolvePreference(member: Member?): ViewerContentPreference { if (member == null) { return ViewerContentPreference( @@ -1531,7 +1554,7 @@ class LiveRoomService( } fun getHeartList(roomId: Long): GetLiveRoomHeartListResponse { - val heartList = repository.getHeartList(roomId = roomId) + val heartList = repository.getHeartList(roomId = roomId, lang = langContext.lang) return GetLiveRoomHeartListResponse( heartList = heartList, @@ -1541,11 +1564,10 @@ class LiveRoomService( } @Transactional(readOnly = true) - @Cacheable( - cacheNames = ["cache_ttl_10_minutes"], - key = "'getLatestFinishedLive:' + (#member?.id ?: 'guest')" - ) fun getLatestFinishedLive(member: Member?): List { - return repository.getLatestFinishedLive(memberId = member?.id) + val items = repository.getLatestFinishedLive(memberId = member?.id) + val memberIds = items.map { it.memberId }.distinct() + val names = findCurrentNames(memberIds, langContext.lang) + return items.map { it.copy(nickname = names[it.memberId] ?: it.nickname) } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt index a0def2f7..6c932644 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt @@ -37,9 +37,9 @@ data class GetRoomDetailManager( val profileImageUrl: String, val isCreator: Boolean ) { - constructor(member: Member, cloudFrontHost: String) : this( + constructor(member: Member, cloudFrontHost: String, nickname: String) : this( id = member.id!!, - nickname = member.nickname, + nickname = nickname, introduce = member.introduce, youtubeUrl = member.youtubeUrl, instagramUrl = member.instagramUrl, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt index 882df29c..dcc0d642 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt @@ -1,14 +1,12 @@ package kr.co.vividnext.sodalive.live.room.like -import com.querydsl.core.annotations.QueryProjection - data class GetLiveRoomHeartListResponse( val heartList: List, val totalCount: Int, val totalHeart: Int ) -data class GetLiveRoomHeartListItem @QueryProjection constructor( +data class GetLiveRoomHeartListItem( val profileImage: String, val nickname: String, val heart: Int diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt index 3ab4ce04..a01fbabb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt @@ -1,12 +1,17 @@ package kr.co.vividnext.sodalive.live.room.visit +import com.querydsl.core.types.Projections import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -16,11 +21,15 @@ interface LiveRoomVisitRepository : JpaRepository, LiveRoom interface LiveRoomVisitQueryRepository { fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit? fun findFirstByMemberIdOrderByUpdatedAtDesc(memberId: Long): LiveRoomVisit? - fun getRecentVisitRoomUsers(roomId: Long, member: Member): List + fun getRecentVisitRoomUsers(roomId: Long, member: Member, lang: Lang): List } @Repository -class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomVisitQueryRepository { +class LiveRoomVisitQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : LiveRoomVisitQueryRepository { override fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit? { return queryFactory .selectFrom(liveRoomVisit) @@ -44,7 +53,9 @@ class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory .fetchFirst() } - override fun getRecentVisitRoomUsers(roomId: Long, member: Member): List { + override fun getRecentVisitRoomUsers(roomId: Long, member: Member, lang: Lang): List { + val visitor = QMember.member + val visitorName = CreatorNameJoin(visitor, lang, "recentLiveRoomVisitorName") var where = liveRoomVisit.room.id.eq(roomId) .and(liveRoomVisit.member.isActive.isTrue) .and(liveRoomVisit.member.id.ne(member.id)) @@ -58,10 +69,18 @@ class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory } return queryFactory - .select(QMember.member) + .select( + Projections.constructor( + GetRoomDetailUser::class.java, + visitor.id, + visitorName.displayName, + visitor.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ) .from(liveRoomVisit) - .innerJoin(liveRoomVisit.member, QMember.member) + .innerJoin(liveRoomVisit.member, visitor) .innerJoin(liveRoomVisit.room, liveRoom) + .leftJoin(visitorName.name).on(visitorName.condition) .where(where) .fetch() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt index 9beb448f..13aad844 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt @@ -1,9 +1,9 @@ package kr.co.vividnext.sodalive.live.room.visit +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.Member -import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @@ -12,9 +12,7 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class LiveRoomVisitService( private val repository: LiveRoomVisitRepository, - - @Value("\${cloud.aws.cloud-front.host}") - private val cloudFrontHost: String + private val langContext: LangContext ) { @Transactional fun roomVisit(room: LiveRoom, member: Member) { @@ -34,9 +32,6 @@ class LiveRoomVisitService( val roomVisit = repository.findFirstByMemberIdOrderByUpdatedAtDesc(member.id!!) ?: return emptyList() - return repository.getRecentVisitRoomUsers(roomVisit.room!!.id!!, member) - .asSequence() - .map { GetRoomDetailUser(it, cloudFrontHost) } - .toList() + return repository.getRecentVisitRoomUsers(roomVisit.room!!.id!!, member, langContext.lang) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index 5ea67a34..1d6a7f69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -1,12 +1,15 @@ package kr.co.vividnext.sodalive.member +import com.querydsl.core.types.Projections import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.fcm.PushTokenInfo import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken import kr.co.vividnext.sodalive.fcm.QPushTokenInfo +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.auth.QAuth.auth import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -15,6 +18,8 @@ import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollow import kr.co.vividnext.sodalive.member.nickname.QNicknameChangeLog.nicknameChangeLog import kr.co.vividnext.sodalive.member.notification.QMemberNotification.memberNotification import kr.co.vividnext.sodalive.message.QMessage.message +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.creatorNameContains import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock @@ -38,7 +43,7 @@ interface MemberRepository : JpaRepository, MemberQueryRepository } interface MemberQueryRepository { - fun findByNicknameAndOtherCondition(nickname: String, member: Member): List + fun findByNicknameAndOtherCondition(nickname: String, member: Member, lang: Lang? = null): List fun findCreatorByIdOrNull(memberId: Long): Member? fun getAllRecipientPushTokens(isAuth: Boolean?): List fun getCreateLiveRoomNotificationRecipientPushTokens( @@ -70,7 +75,7 @@ interface MemberQueryRepository { fun getPushTokenFromReservationList(roomId: Long): List fun getAuditionNoticeRecipientPushTokens(isAuth: Boolean): List - fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse + fun getMemberProfile(memberId: Long, myMemberId: Long, lang: Lang = Lang.KO): GetMemberProfileResponse fun existsByNickname(nickname: String): Boolean fun findNicknamesWithPrefix(prefix: String): List @@ -86,8 +91,10 @@ class MemberQueryRepositoryImpl( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) : MemberQueryRepository { - override fun findByNicknameAndOtherCondition(nickname: String, member: Member): List { - var where = QMember.member.nickname.containsIgnoreCase(nickname) + override fun findByNicknameAndOtherCondition(nickname: String, member: Member, lang: Lang?): List { + val creatorName = lang?.let { CreatorNameJoin(QMember.member, it, "memberSearchName") } + val displayName = creatorName?.displayName ?: QMember.member.nickname + var where = creatorNameContains(QMember.member, nickname) .and(QMember.member.id.ne(member.id)) .and(QMember.member.role.ne(MemberRole.ADMIN)) .and(QMember.member.role.ne(MemberRole.AGENT)) @@ -97,10 +104,19 @@ class MemberQueryRepositoryImpl( where = where.and(QMember.member.role.ne(MemberRole.USER)) } - return queryFactory - .selectFrom(QMember.member) + val query = queryFactory + .select( + Projections.constructor( + GetRoomDetailUser::class.java, + QMember.member.id, + displayName, + QMember.member.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ) + .from(QMember.member) .where(where) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } override fun findCreatorByIdOrNull(memberId: Long): Member? { @@ -499,17 +515,19 @@ class MemberQueryRepositoryImpl( .fetch() } - override fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse { + override fun getMemberProfile(memberId: Long, myMemberId: Long, lang: Lang): GetMemberProfileResponse { + val creatorName = CreatorNameJoin(member, lang, "profileMemberName") return queryFactory .select( QGetMemberProfileResponse( member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), blockMember.id.isNotNull ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(blockMember) .on( member.id.eq(blockMember.blockedMember.id) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 71eb01ca..34645bfd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -53,6 +53,7 @@ import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generatePassword +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort import org.springframework.beans.factory.annotation.Value import org.springframework.cache.CacheManager @@ -105,6 +106,7 @@ class MemberService( private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val countryContext: CountryContext, private val memberContentPreferenceService: MemberContentPreferenceService, @@ -308,7 +310,8 @@ class MemberService( ).sumOf { it.point } return MyPageResponse( - nickname = member.nickname, + nickname = localizedNameService.findLocalizedNames(listOf(member.id!!), langContext.lang)[member.id] + ?: member.nickname, profileUrl = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { @@ -542,8 +545,7 @@ class MemberService( fun getBlockedMemberList(memberId: Long, offset: Long, limit: Long): GetBlockedMemberListResponse { val totalCount = blockMemberRepository.getBlockedMemberTotalCount(memberId) - val items = blockMemberRepository.getBlockedMemberList(offset = offset, limit = limit, memberId = memberId) - + val items = blockMemberRepository.getBlockedMemberList(offset, limit, memberId, langContext.lang) return GetBlockedMemberListResponse(totalCount = totalCount, items = items) } @@ -610,14 +612,11 @@ class MemberService( throw SodaException(messageKey = "member.validation.nickname_min_length") } - return repository.findByNicknameAndOtherCondition(nickname, member) + return repository.findByNicknameAndOtherCondition(nickname, member, langContext.lang) .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) && !blockMemberRepository.isBlocked(blockedMemberId = it.id!!, memberId = member.id!!) } - .map { - GetRoomDetailUser(it, cloudFrontHost) - } } @Transactional @@ -837,7 +836,7 @@ class MemberService( } fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse { - return repository.getMemberProfile(memberId, myMemberId) + return repository.getMemberProfile(memberId, myMemberId, langContext.lang) } private fun formatMessage(key: String, vararg args: Any): String { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt index 155c454a..a3affa80 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.member.block import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -14,7 +16,7 @@ interface BlockMemberQueryRepository { fun getBlockAccount(blockedMemberId: Long, memberId: Long): BlockMember? fun isBlocked(blockedMemberId: Long, memberId: Long): Boolean fun getBlockedMemberIdList(memberId: Long): List - fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long): List + fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long, lang: Lang = Lang.KO): List fun getBlockedMemberTotalCount(memberId: Long): Int } @@ -61,18 +63,20 @@ class BlockMemberQueryRepositoryImpl( .fetch() } - override fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long): List { + override fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "blockedMemberName") return queryFactory .select( QGetBlockedMemberListItem( blockMember.blockedMember.id, - blockMember.blockedMember.nickname, + creatorName.displayName, blockMember.blockedMember.profileImage.prepend("/").prepend(cloudFrontHost), blockMember.isActive ) ) .from(blockMember) .innerJoin(blockMember.blockedMember, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( blockMember.member.id.eq(memberId) .and(blockMember.isActive.isTrue) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index 3fcb3a16..52c4c3aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -18,13 +18,15 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse import kr.co.vividnext.sodalive.explorer.QCreatorRanking.creatorRanking -import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -36,7 +38,9 @@ class RankingRepository( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { - fun getCreatorRankings(memberId: Long? = null): List { + fun getCreatorRankings(memberId: Long? = null, lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "rankingCreatorName") + val displayName = creatorName.displayName val blockMemberCondition = if (memberId != null) { blockMember.isActive.isTrue .and( @@ -52,11 +56,12 @@ class RankingRepository( } var select = queryFactory - .select(member) + .select(member, displayName) .from(creatorRanking) .innerJoin(creatorRanking.member, member) .leftJoin(member.tags, memberCreatorTag).fetchJoin() .leftJoin(memberCreatorTag.tag, creatorTag).fetchJoin() + select.leftJoin(creatorName.name).on(creatorName.condition) if (memberId != null) { select = select.leftJoin(blockMember).on(blockMemberCondition) @@ -69,6 +74,9 @@ class RankingRepository( return select .orderBy(creatorRanking.ranking.asc()) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(imageHost).copy(nickname = row.get(displayName)!!) + } .distinctBy { it.id } } @@ -84,6 +92,8 @@ class RankingRepository( theme: String = "", locale: String? = null ): List { + val creatorName = locale?.let { CreatorNameJoin(member, Lang.fromCode(it), "rankingContentCreatorName") } + val displayName = creatorName?.displayName ?: member.nickname val blockMemberCondition = if (memberId != null) { blockMember.isActive.isTrue .and( @@ -148,7 +158,7 @@ class RankingRepository( audioContent.price, audioContent.duration, member.id, - member.nickname, + displayName, audioContent.isPointAvailable, creatorProfileImageUrl ) @@ -199,6 +209,7 @@ class RankingRepository( select = select.leftJoin(contentTranslation) .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) } + if (creatorName != null) select.leftJoin(creatorName.name).on(creatorName.condition) if (memberId != null) { where = where.and(blockMember.id.isNull) @@ -269,6 +280,7 @@ class RankingRepository( } val results = select + .groupBy(member.id, displayName) .offset(offset) .limit(limit) .fetch() @@ -283,7 +295,7 @@ class RankingRepository( val price = row.get(audioContent.price) ?: 0 val duration = row.get(audioContent.duration) ?: "" val creatorId = row.get(member.id)!! - val creatorNickname = row.get(member.nickname)!! + val creatorNickname = row.get(displayName)!! val isPointAvailable = row.get(audioContent.isPointAvailable) ?: false val creatorProfileImageUrlValue = row.get(creatorProfileImageUrl)!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt index 49d59bfb..702d89ed 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt @@ -26,7 +26,6 @@ class RankingService( fun getCreatorRanking(memberId: Long?, rankingDate: String): GetExplorerSectionResponse { val creatorRankings = repository .getCreatorRankings(memberId = memberId) - .map { it.toExplorerSectionCreator(imageHost) } return GetExplorerSectionResponse( title = "인기 크리에이터", diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt index dee7a1c7..7b78cb92 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt @@ -10,11 +10,14 @@ import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentThe import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.creatorNameContains import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository @@ -50,7 +53,7 @@ class SearchRepository( .and(member.role.eq(MemberRole.CREATOR)) .and(creatorTag.isActive.isTrue.or(creatorTag.id.isNull)) .and( - member.nickname.containsIgnoreCase(keyword) + creatorNameContains(member, keyword) .or(member.introduce.containsIgnoreCase(keyword)) .or(creatorTag.tag.containsIgnoreCase(keyword)) ) @@ -65,7 +68,8 @@ class SearchRepository( keyword: String, memberId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { val blockMemberCondition = blockMember.isActive.isTrue .and( @@ -77,16 +81,18 @@ class SearchRepository( ) ) + val creatorName = CreatorNameJoin(member, lang, "searchCreatorName") return queryFactory .select( QSearchResponseItem( member.id, member.profileImage.prepend("/").prepend(imageHost), - member.nickname, - member.nickname + creatorName.displayName, + creatorName.displayName ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(memberCreatorTag).on(memberCreatorTag.member.id.eq(member.id)) .leftJoin(creatorTag).on(memberCreatorTag.tag.id.eq(creatorTag.id)) .leftJoin(blockMember).on(blockMemberCondition) @@ -95,13 +101,13 @@ class SearchRepository( .and(member.role.eq(MemberRole.CREATOR)) .and(creatorTag.isActive.isTrue.or(creatorTag.id.isNull)) .and( - member.nickname.containsIgnoreCase(keyword) + creatorNameContains(member, keyword) .or(member.introduce.containsIgnoreCase(keyword)) .or(creatorTag.tag.containsIgnoreCase(keyword)) ) .and(blockMember.id.isNull) ) - .groupBy(member.id) + .groupBy(member.id, creatorName.name.displayName) .orderBy(member.id.desc()) .offset(offset) .limit(limit) @@ -131,7 +137,7 @@ class SearchRepository( .and(audioContent.duration.isNotNull) .and( audioContent.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(audioContent.detail.containsIgnoreCase(keyword)) .or(audioContent.theme.theme.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(audioContentHashTag.isActive.isTrue)) @@ -177,7 +183,8 @@ class SearchRepository( isAdult: Boolean, contentType: ContentType, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { val blockMemberCondition = blockMember.isActive.isTrue .and( @@ -196,7 +203,7 @@ class SearchRepository( .and(audioContent.duration.isNotNull) .and( audioContent.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(audioContent.detail.containsIgnoreCase(keyword)) .or(audioContent.theme.theme.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(audioContentHashTag.isActive.isTrue)) @@ -222,23 +229,25 @@ class SearchRepository( } } + val creatorName = CreatorNameJoin(member, lang, "searchContentCreatorName") return queryFactory .select( QSearchResponseItem( audioContent.id, audioContent.coverImage.prepend("/").prepend(imageHost), audioContent.title, - audioContent.member.nickname + creatorName.displayName ) ) .from(audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(audioContentTheme).on(audioContentTheme.id.eq(audioContent.theme.id)) .leftJoin(audioContentHashTag).on(audioContentHashTag.audioContent.id.eq(audioContent.id)) .leftJoin(hashTag).on(audioContentHashTag.hashTag.id.eq(hashTag.id)) .leftJoin(blockMember).on(blockMemberCondition) .where(where) - .groupBy(audioContent.id) + .groupBy(audioContent.id, member.id, creatorName.name.displayName) .orderBy(audioContent.id.desc()) .offset(offset) .limit(limit) @@ -270,7 +279,7 @@ class SearchRepository( .and(audioContent.limited.isNull) .and( series.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(series.introduction.containsIgnoreCase(keyword)) .or(seriesGenre.genre.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(seriesKeyword.isActive.isTrue)) @@ -319,7 +328,8 @@ class SearchRepository( isAdult: Boolean, contentType: ContentType, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { val blockMemberCondition = blockMember.isActive.isTrue .and( @@ -340,7 +350,7 @@ class SearchRepository( .and(audioContent.limited.isNull) .and( series.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(series.introduction.containsIgnoreCase(keyword)) .or(seriesGenre.genre.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(seriesKeyword.isActive.isTrue)) @@ -367,25 +377,27 @@ class SearchRepository( } } + val creatorName = CreatorNameJoin(member, lang, "searchSeriesCreatorName") return queryFactory .select( QSearchResponseItem( series.id, series.coverImage.prepend("/").prepend(imageHost), series.title, - series.member.nickname + creatorName.displayName ) ) .from(seriesContent) .innerJoin(seriesContent.series, series) .innerJoin(seriesContent.content, audioContent) .innerJoin(series.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(seriesGenre).on(seriesGenre.id.eq(series.genre.id)) .leftJoin(seriesKeyword).on(seriesKeyword.series.id.eq(series.id)) .leftJoin(hashTag).on(hashTag.id.eq(seriesKeyword.keyword.id)) .leftJoin(blockMember).on(blockMemberCondition) .where(where) - .groupBy(series.id, series.orders) + .groupBy(series.id, series.orders, member.id, creatorName.name.displayName) .orderBy(series.orders.asc()) .offset(offset) .limit(limit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt index ed66bfe2..a4ab6f76 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt @@ -1,11 +1,12 @@ package kr.co.vividnext.sodalive.search import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.springframework.stereotype.Service @Service -class SearchService(private val repository: SearchRepository) { +class SearchService(private val repository: SearchRepository, private val langContext: LangContext) { fun searchUnified( keyword: String, isAdult: Boolean, @@ -16,7 +17,8 @@ class SearchService(private val repository: SearchRepository) { keyword = keyword, memberId = member.id!!, offset = 0, - limit = 3 + limit = 3, + lang = langContext.lang ) .map { it.type = SearchResponseType.CREATOR @@ -29,7 +31,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = 0, - limit = 3 + limit = 3, + lang = langContext.lang ) .map { it.type = SearchResponseType.CONTENT @@ -42,7 +45,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = 0, - limit = 3 + limit = 3, + lang = langContext.lang ) .map { it.type = SearchResponseType.SERIES @@ -67,7 +71,8 @@ class SearchService(private val repository: SearchRepository) { keyword = keyword, memberId = member.id!!, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { it.type = SearchResponseType.CREATOR @@ -98,7 +103,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { it.type = SearchResponseType.CONTENT @@ -129,7 +135,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { it.type = SearchResponseType.SERIES diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt index 736b6770..5598ce91 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.content.all.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -12,7 +13,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/audio/contents") class MainContentAllController( - private val facade: MainContentAllFacade + private val facade: MainContentAllFacade, + private val langContext: LangContext ) { @GetMapping fun getContents( @@ -30,7 +32,8 @@ class MainContentAllController( dayOfWeek = dayOfWeek, page = page, size = size, - member = member + member = member, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt index b0dea149..f092e6cb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.content.all.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponse import kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryService @@ -15,17 +16,18 @@ class MainContentAllFacade( dayOfWeek: String?, page: Int?, size: Int?, - member: Member? + member: Member?, + lang: Lang ): MainContentAllTabResponse { - return MainContentAllTabResponse.from( - queryService.getContents( - type = type, - sort = sort, - dayOfWeek = dayOfWeek, - page = page, - size = size, - member = member - ) + val contents = queryService.getContents( + type = type, + sort = sort, + dayOfWeek = dayOfWeek, + page = page, + size = size, + member = member, + lang = lang ) + return MainContentAllTabResponse.from(contents) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt index 81b45124..3e32e277 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -13,7 +14,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/contents") class ContentOverviewController( - private val facade: ContentOverviewFacade + private val facade: ContentOverviewFacade, + private val langContext: LangContext ) { @GetMapping fun getContents( @@ -22,7 +24,7 @@ class ContentOverviewController( @RequestParam(required = false) size: Int?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - ApiResponse.ok(facade.getContents(type, page, size, requireMember(member))) + ApiResponse.ok(facade.getContents(type, page, size, requireMember(member), langContext.lang)) } private fun requireMember(member: Member?): Member { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt index 18ed043b..d819cde3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.content.overview.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewItemResponse @@ -21,42 +22,46 @@ class ContentOverviewFacade( private val cloudFrontHost: String, private val queryPolicy: ContentOverviewQueryPolicy = ContentOverviewQueryPolicy() ) { - fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse { + fun getContents(type: String?, page: Int?, size: Int?, member: Member, lang: Lang): ContentOverviewPageResponse { val resolvedType = queryPolicy.resolveType(type) val resolvedPage = queryPolicy.createPage(page, size) return when (resolvedType) { - ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage) - ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage) + ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage, lang) + ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage, lang) } } - private fun getNewAndHotContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse { + private fun getNewAndHotContents(member: Member, page: ContentOverviewPage, lang: Lang): ContentOverviewPageResponse { val fetched = audioRecommendationQueryService.findNewAndHotAudios( member = member, offset = page.offset, - limit = page.size + 1 + limit = page.size + 1, + lang = lang ) + val items = queryPolicy.pageItems(fetched, page) return ContentOverviewPageResponse( type = ContentOverviewType.NEW_AND_HOT_AUDIO, - items = queryPolicy.pageItems(fetched, page).map { ContentOverviewItemResponse.fromNewAndHot(it) }, + items = items.map(ContentOverviewItemResponse::fromNewAndHot), page = page.page, size = page.size, hasNext = queryPolicy.hasNext(fetched, page) ) } - private fun getFirstAudioContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse { + private fun getFirstAudioContents(member: Member, page: ContentOverviewPage, lang: Lang): ContentOverviewPageResponse { val fetched = homeRecommendationQueryService.findFirstAudioContents( now = LocalDateTime.now(), offset = page.offset, limit = page.size + 1, memberId = member.id, - includeAdultContents = memberContentPreferenceService.canViewAdultContent(member) + includeAdultContents = memberContentPreferenceService.canViewAdultContent(member), + lang = lang ) + val items = queryPolicy.pageItems(fetched, page) return ContentOverviewPageResponse( type = ContentOverviewType.FIRST_AUDIO_CONTENT, - items = queryPolicy.pageItems(fetched, page).map { + items = items.map { ContentOverviewItemResponse.fromFirstAudioContent( audio = it, coverImage = it.coverImage.toCdnUrl(cloudFrontHost), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt index 36d781be..58ffe901 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacade import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType @@ -13,13 +14,14 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/audio/rankings") class AudioRankingController( - private val facade: AudioRankingFacade + private val facade: AudioRankingFacade, + private val langContext: LangContext ) { @GetMapping fun getRankings( @RequestParam(defaultValue = "WEEKLY_POPULAR") type: AudioRankingType, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - ApiResponse.ok(facade.getRankings(type, member)) + ApiResponse.ok(facade.getRankings(type, member, langContext.lang)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt index 40333589..ee109034 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.content.ranking.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponse import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryService @@ -10,7 +11,8 @@ import org.springframework.stereotype.Component class AudioRankingFacade( private val queryService: AudioRankingQueryService ) { - fun getRankings(type: AudioRankingType, member: Member?): AudioRankingResponse { - return AudioRankingResponse.from(queryService.getRankings(type, member)) + fun getRankings(type: AudioRankingType, member: Member?, lang: Lang): AudioRankingResponse { + val result = queryService.getRankings(type, member, lang) + return AudioRankingResponse.from(result) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt index 0b45e546..650e60d8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -11,12 +12,13 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/audio/recommendations") class AudioRecommendationController( - private val audioRecommendationFacade: AudioRecommendationFacade + private val audioRecommendationFacade: AudioRecommendationFacade, + private val langContext: LangContext ) { @GetMapping fun getRecommendations( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - ApiResponse.ok(audioRecommendationFacade.getRecommendations(member)) + ApiResponse.ok(audioRecommendationFacade.getRecommendations(member, langContext.lang)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt index 63277fe7..b4e19432 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService @@ -9,7 +10,8 @@ import org.springframework.stereotype.Component class AudioRecommendationFacade( private val queryService: AudioRecommendationQueryService ) { - fun getRecommendations(member: Member?): AudioRecommendationsResponse { - return AudioRecommendationsResponse.from(queryService.getRecommendations(member)) + fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse { + val result = queryService.getRecommendations(member, lang) + return AudioRecommendationsResponse.from(result) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt index fba0ce79..128ff280 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -14,7 +15,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/creator-channels") class CreatorChannelAudioController( - private val creatorChannelAudioFacade: CreatorChannelAudioFacade + private val creatorChannelAudioFacade: CreatorChannelAudioFacade, + private val langContext: LangContext ) { @GetMapping("/{creatorId}/audio") fun getAudioTab( @@ -32,7 +34,8 @@ class CreatorChannelAudioController( sort = sort, themeId = themeId, page = page, - size = size + size = size, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt index 177638a1..614deb7a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryService @@ -19,7 +20,8 @@ class CreatorChannelAudioFacade( themeId: Long?, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelAudioTabResponse { return CreatorChannelAudioTabResponse.from( creatorChannelAudioQueryService.getAudioTab( @@ -29,7 +31,8 @@ class CreatorChannelAudioFacade( themeId = themeId, page = page, size = size, - now = now + now = now, + lang = lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt index 3901833c..7fb9cb67 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.w import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -14,7 +15,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/creator-channels") class CreatorChannelCommunityController( - private val creatorChannelCommunityFacade: CreatorChannelCommunityFacade + private val creatorChannelCommunityFacade: CreatorChannelCommunityFacade, + private val langContext: LangContext ) { @GetMapping("/{creatorId}/community") fun getCommunityTab( @@ -28,7 +30,8 @@ class CreatorChannelCommunityController( creatorId = creatorId, viewer = requireMember(member), page = page, - size = size + size = size, + lang = langContext.lang ) ) } @@ -41,7 +44,8 @@ class CreatorChannelCommunityController( ApiResponse.ok( creatorChannelCommunityFacade.getCommunityPostDetail( postId = postId, - viewer = requireMember(member) + viewer = requireMember(member), + lang = langContext.lang ) ) } @@ -58,7 +62,8 @@ class CreatorChannelCommunityController( postId = postId, viewer = requireMember(member), page = page, - size = size + size = size, + lang = langContext.lang ) ) } @@ -75,7 +80,8 @@ class CreatorChannelCommunityController( commentId = commentId, viewer = requireMember(member), page = page, - size = size + size = size, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt index f88a3de6..9237b288 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse @@ -20,31 +21,33 @@ class CreatorChannelCommunityFacade( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityTabResponse { - return CreatorChannelCommunityTabResponse.from( - creatorChannelCommunityQueryService.getCommunityTab( - creatorId = creatorId, - viewer = viewer, - page = page, - size = size, - now = now - ) + val tab = creatorChannelCommunityQueryService.getCommunityTab( + creatorId = creatorId, + viewer = viewer, + page = page, + size = size, + now = now, + lang = lang ) + return CreatorChannelCommunityTabResponse.from(tab) } fun getCommunityPostDetail( postId: Long, viewer: Member, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityPostDetailResponse { - return CreatorChannelCommunityPostDetailResponse.from( - creatorChannelCommunityQueryService.getCommunityPostDetail( - postId = postId, - viewer = viewer, - now = now - ) + val detail = creatorChannelCommunityQueryService.getCommunityPostDetail( + postId = postId, + viewer = viewer, + now = now, + lang = lang ) + return CreatorChannelCommunityPostDetailResponse.from(detail) } fun getCommunityComments( @@ -52,17 +55,18 @@ class CreatorChannelCommunityFacade( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityCommentsResponse { - return CreatorChannelCommunityCommentsResponse.from( - creatorChannelCommunityQueryService.getCommunityComments( - postId = postId, - viewer = viewer, - page = page, - size = size, - now = now - ) + val comments = creatorChannelCommunityQueryService.getCommunityComments( + postId = postId, + viewer = viewer, + page = page, + size = size, + now = now, + lang = lang ) + return CreatorChannelCommunityCommentsResponse.from(comments) } fun getCommunityReplies( @@ -70,16 +74,17 @@ class CreatorChannelCommunityFacade( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityRepliesResponse { - return CreatorChannelCommunityRepliesResponse.from( - creatorChannelCommunityQueryService.getCommunityReplies( - commentId = commentId, - viewer = viewer, - page = page, - size = size, - now = now - ) + val replies = creatorChannelCommunityQueryService.getCommunityReplies( + commentId = commentId, + viewer = viewer, + page = page, + size = size, + now = now, + lang = lang ) + return CreatorChannelCommunityRepliesResponse.from(replies) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt index ca0b8b42..c618884f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.we import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -14,7 +15,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/creator-channels") class CreatorChannelDonationController( - private val creatorChannelDonationFacade: CreatorChannelDonationFacade + private val creatorChannelDonationFacade: CreatorChannelDonationFacade, + private val langContext: LangContext ) { @GetMapping("/{creatorId}/donations") fun getDonationTab( @@ -28,7 +30,8 @@ class CreatorChannelDonationController( creatorId = creatorId, viewer = requireMember(member), page = page, - size = size + size = size, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt index 13a96348..14d409f9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @@ -10,22 +12,29 @@ import java.time.LocalDateTime @Service @Transactional(readOnly = true) class CreatorChannelDonationFacade( - private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService + private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService, + private val creatorLocalizedNameService: CreatorLocalizedNameService ) { fun getDonationTab( creatorId: Long, viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelDonationTabResponse { + val tab = creatorChannelDonationQueryService.getDonationTab( + creatorId = creatorId, + viewer = viewer, + page = page, + size = size, + now = now, + lang = lang + ) + val names = creatorLocalizedNameService.findCurrentNames(tab.rankings.map { it.userId }.toSet(), lang) return CreatorChannelDonationTabResponse.from( - creatorChannelDonationQueryService.getDonationTab( - creatorId = creatorId, - viewer = viewer, - page = page, - size = size, - now = now + tab.copy( + rankings = tab.rankings.map { it.copy(nickname = names[it.userId] ?: it.nickname) } ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt index 09feee1b..d8e8dc00 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -14,7 +15,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/creator-channels") class CreatorChannelFanTalkController( - private val creatorChannelFanTalkFacade: CreatorChannelFanTalkFacade + private val creatorChannelFanTalkFacade: CreatorChannelFanTalkFacade, + private val langContext: LangContext ) { @GetMapping("/{creatorId}/fan-talks") fun getFanTalkTab( @@ -28,7 +30,8 @@ class CreatorChannelFanTalkController( creatorId = creatorId, viewer = requireMember(member), page = page, - size = size + size = size, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt index 079abb9d..ec34a8c5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryService @@ -17,16 +18,17 @@ class CreatorChannelFanTalkFacade( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelFanTalkTabResponse { - return CreatorChannelFanTalkTabResponse.from( - creatorChannelFanTalkQueryService.getFanTalkTab( - creatorId = creatorId, - viewer = viewer, - page = page, - size = size, - now = now - ) + val tab = creatorChannelFanTalkQueryService.getFanTalkTab( + creatorId = creatorId, + viewer = viewer, + page = page, + size = size, + now = now, + lang = lang ) + return CreatorChannelFanTalkTabResponse.from(tab) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt index 75477284..3157cc39 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -13,7 +14,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/creator-channels") class CreatorChannelHomeController( - private val creatorChannelHomeFacade: CreatorChannelHomeFacade + private val creatorChannelHomeFacade: CreatorChannelHomeFacade, + private val langContext: LangContext ) { @GetMapping("/{creatorId}/home") fun getHome( @@ -23,7 +25,8 @@ class CreatorChannelHomeController( ApiResponse.ok( creatorChannelHomeFacade.getHome( creatorId = creatorId, - viewer = requireMember(member) + viewer = requireMember(member), + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt index 8c96e13b..cecf3e9d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryService @@ -16,14 +17,10 @@ class CreatorChannelHomeFacade( fun getHome( creatorId: Long, viewer: Member, - now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + lang: Lang ): CreatorChannelHomeResponse { - return CreatorChannelHomeResponse.from( - creatorChannelHomeQueryService.getHome( - creatorId = creatorId, - viewer = viewer, - now = now - ) - ) + val home = creatorChannelHomeQueryService.getHome(creatorId = creatorId, viewer = viewer, now = now, lang = lang) + return CreatorChannelHomeResponse.from(home) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt index 66e4e8da..2cc7aa24 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacade import kr.co.vividnext.sodalive.v2.common.domain.ContentSort @@ -15,7 +16,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/creator-channels") class CreatorChannelLiveController( - private val creatorChannelLiveFacade: CreatorChannelLiveFacade + private val creatorChannelLiveFacade: CreatorChannelLiveFacade, + private val langContext: LangContext ) { @GetMapping("/{creatorId}/live") fun getLiveTab( @@ -31,7 +33,8 @@ class CreatorChannelLiveController( viewer = requireMember(member), sort = sort, page = page, - size = size + size = size, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt index 8ac95583..0efa6616 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.live.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveTabResponse import kr.co.vividnext.sodalive.v2.common.domain.ContentSort @@ -19,7 +20,8 @@ class CreatorChannelLiveFacade( sort: ContentSort, page: Int, size: Int, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelLiveTabResponse { return CreatorChannelLiveTabResponse.from( creatorChannelLiveQueryService.getLiveTab( @@ -28,7 +30,8 @@ class CreatorChannelLiveFacade( sort = sort, page = page, size = size, - now = now + now = now, + lang = lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt index b44d8280..4a5a5718 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -14,7 +15,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/creator-channels") class CreatorChannelSeriesController( - private val creatorChannelSeriesFacade: CreatorChannelSeriesFacade + private val creatorChannelSeriesFacade: CreatorChannelSeriesFacade, + private val langContext: LangContext ) { @GetMapping("/{creatorId}/series") fun getSeriesTab( @@ -30,7 +32,8 @@ class CreatorChannelSeriesController( viewer = requireMember(member), sort = sort, page = page, - size = size + size = size, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt index 5fee5eed..4f247aa8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.series.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryService @@ -18,7 +19,8 @@ class CreatorChannelSeriesFacade( sort: String?, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelSeriesTabResponse { return CreatorChannelSeriesTabResponse.from( creatorChannelSeriesQueryService.getSeriesTab( @@ -27,7 +29,8 @@ class CreatorChannelSeriesFacade( sort = sort, page = page, size = size, - now = now + now = now, + lang = lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt index 17826e91..607e9343 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationResponse @@ -32,7 +33,8 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/v2/gifts") class GiftController( private val queryService: GiftQueryService, - private val commandService: GiftCommandService + private val commandService: GiftCommandService, + private val langContext: LangContext ) { @GetMapping("/form-options") fun getFormOptions() = ApiResponse.ok(GiftFormOptionsResponse.from(queryService.getFormOptions())) @@ -49,7 +51,8 @@ class GiftController( memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"), type = type, page = page, - size = size + size = size, + lang = langContext.lang ) ) ) @@ -62,7 +65,8 @@ class GiftController( GiftDetailResponse.from( queryService.getGiftDetail( memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"), - applicationNo = applicationNo + applicationNo = applicationNo, + lang = langContext.lang ) ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt index 664c20a1..0347d270 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.home.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.application.HomeCreatorRankingFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -11,12 +12,13 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/home/rankings") class CreatorRankingController( - private val homeCreatorRankingFacade: HomeCreatorRankingFacade + private val homeCreatorRankingFacade: HomeCreatorRankingFacade, + private val langContext: LangContext ) { @GetMapping("/creators") fun getCreatorRankings( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - ApiResponse.ok(homeCreatorRankingFacade.getCreatorRankings(member)) + ApiResponse.ok(homeCreatorRankingFacade.getCreatorRankings(member, langContext.lang)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt index 9f6b2e19..35dd70a8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.FollowRecommendedCreatorsRequest @@ -18,13 +19,14 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/v2/home/recommendations") class HomeRecommendationController( private val homeRecommendationFacade: HomeRecommendationFacade, - private val recommendedCreatorFollowService: RecommendedCreatorFollowService + private val recommendedCreatorFollowService: RecommendedCreatorFollowService, + private val langContext: LangContext ) { @GetMapping fun getHomeRecommendations( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - ApiResponse.ok(homeRecommendationFacade.getHomeRecommendations(member)) + ApiResponse.ok(homeRecommendationFacade.getHomeRecommendations(member, langContext.lang)) } @GetMapping("/lives") @@ -37,7 +39,8 @@ class HomeRecommendationController( homeRecommendationFacade.getLives( requireMember(member), normalizePage(page), - normalizeSize(size) + normalizeSize(size), + langContext.lang ) ) } @@ -52,7 +55,8 @@ class HomeRecommendationController( homeRecommendationFacade.getRecentDebutCreators( requireMember(member), normalizePage(page), - normalizeSize(size) + normalizeSize(size), + langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt index c0618a44..7e78b36d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.dto.ranking.CreatorRankingResponse import kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryService @@ -9,9 +10,8 @@ import org.springframework.stereotype.Component class HomeCreatorRankingFacade( private val creatorRankingQueryService: CreatorRankingQueryService ) { - fun getCreatorRankings(member: Member?): CreatorRankingResponse { - return CreatorRankingResponse.from( - creatorRankingQueryService.getCreatorRankings(viewerMemberId = member?.id) - ) + fun getCreatorRankings(member: Member?, lang: Lang): CreatorRankingResponse { + val result = creatorRankingQueryService.getCreatorRankings(viewerMemberId = member?.id, lang = lang) + return CreatorRankingResponse.from(result) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index 1e36c20a..a1f86435 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -44,21 +44,23 @@ class HomeRecommendationFacade( ) { private val log = LoggerFactory.getLogger(javaClass) - fun getHomeRecommendations(member: Member?): HomeRecommendationResponse { + fun getHomeRecommendations(member: Member?, lang: Lang): HomeRecommendationResponse { val startedAt = System.currentTimeMillis() return runCatching { val now = LocalDateTime.now() val includeAdult = resolveAdultVisibility(member) val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO - HomeRecommendationResponse( - lives = queryService.findLiveRecommendations( - limit = HOME_LIVE_LIMIT, - memberId = member?.id, - includeAdultLives = includeAdult, - isViewerCreator = member?.role == MemberRole.CREATOR, - effectiveViewerGender = member?.effectiveGender() - ).map { it.toItem() }, + val lives = queryService.findLiveRecommendations( + limit = HOME_LIVE_LIMIT, + memberId = member?.id, + includeAdultLives = includeAdult, + isViewerCreator = member?.role == MemberRole.CREATOR, + effectiveViewerGender = member?.effectiveGender(), + lang = lang + ) + val response = HomeRecommendationResponse( + lives = lives.map { it.toItem() }, banners = queryService.findHomeBanners( limit = HOME_BANNER_LIMIT, memberId = member?.id, @@ -69,21 +71,24 @@ class HomeRecommendationFacade( recentlyActiveCreators = queryService.findRecentlyActiveCreators( HOME_ACTIVE_CREATOR_LIMIT, member?.id, - includeAdult + includeAdult, + lang ) .map { it.toItem() }, recentDebutCreators = queryService.findRecentDebutCreators( now, limit = HOME_RECENT_DEBUT_CREATOR_LIMIT, memberId = member?.id, - includeAdultContents = includeAdult + includeAdultContents = includeAdult, + lang = lang ) .map { it.toItem() }, firstAudioContents = queryService.findFirstAudioContents( now, limit = HOME_FIRST_AUDIO_CONTENT_LIMIT, memberId = member?.id, - includeAdultContents = includeAdult + includeAdultContents = includeAdult, + lang = lang ) .map { it.toItem() }, aiCharacters = queryService.findAiCharacterRecommendations( @@ -94,16 +99,19 @@ class HomeRecommendationFacade( memberId = member?.id, includeAdultGenres = includeAdult, genreLimit = HOME_GENRE_CREATOR_GENRE_LIMIT, - creatorLimit = HOME_GENRE_CREATOR_CREATOR_LIMIT + creatorLimit = HOME_GENRE_CREATOR_CREATOR_LIMIT, + lang = lang ).map { it.toItem() }, - cheerCreators = queryService.findCheerCreatorRecommendations(HOME_CHEER_CREATOR_LIMIT, member?.id) + cheerCreators = queryService.findCheerCreatorRecommendations(HOME_CHEER_CREATOR_LIMIT, member?.id, lang) .map { it.toCreatorItem() }, popularCommunityPosts = queryService.findPopularCommunityRecommendations( limit = HOME_POPULAR_COMMUNITY_LIMIT, memberId = member?.id, - includeAdultCommunities = includeAdult + includeAdultCommunities = includeAdult, + lang = lang ).map { it.toItem() } ) + response }.onSuccess { response -> log.info( "event=home_recommendations_query_success memberId={} elapsedMs={} emptySections={}", @@ -122,7 +130,7 @@ class HomeRecommendationFacade( }.getOrThrow() } - fun getLives(member: Member, page: Int, size: Int): HomeRecommendationPageResponse { + fun getLives(member: Member, page: Int, size: Int, lang: Lang): HomeRecommendationPageResponse { val startedAt = System.currentTimeMillis() return runCatching { val fetched = queryService.findLiveRecommendations( @@ -131,7 +139,8 @@ class HomeRecommendationFacade( memberId = member.id, includeAdultLives = resolveAdultVisibility(member), isViewerCreator = member.role == MemberRole.CREATOR, - effectiveViewerGender = member.effectiveGender() + effectiveViewerGender = member.effectiveGender(), + lang = lang ) fetched.toPage(page, size) { it.toItem() } }.onSuccess { @@ -141,7 +150,12 @@ class HomeRecommendationFacade( }.getOrThrow() } - fun getRecentDebutCreators(member: Member, page: Int, size: Int): HomeRecommendationPageResponse { + fun getRecentDebutCreators( + member: Member, + page: Int, + size: Int, + lang: Lang + ): HomeRecommendationPageResponse { val startedAt = System.currentTimeMillis() return runCatching { val fetched = queryService.findRecentDebutCreators( @@ -149,7 +163,8 @@ class HomeRecommendationFacade( offset = page.toOffset(size), limit = size + 1, memberId = member.id, - includeAdultContents = resolveAdultVisibility(member) + includeAdultContents = resolveAdultVisibility(member), + lang = lang ) fetched.toPage(page, size) { it.toItem() } }.onSuccess { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt index f77ad8a1..468a595a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -11,12 +12,13 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/home/following") class HomeFollowingController( - private val facade: HomeFollowingFacade + private val facade: HomeFollowingFacade, + private val langContext: LangContext ) { @GetMapping fun getFollowingTab( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - ApiResponse.ok(facade.getFollowingTab(member)) + ApiResponse.ok(facade.getFollowingTab(member, langContext.lang)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt index 49ac1735..4c633bd1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.following.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService @@ -11,14 +12,15 @@ class HomeFollowingFacade( private val homeFollowingQueryService: HomeFollowingQueryService, private val chatRoomListService: ChatRoomListService ) { - fun getFollowingTab(member: Member?): HomeFollowingTabResponse { + fun getFollowingTab(member: Member?, lang: Lang): HomeFollowingTabResponse { if (member == null) { return HomeFollowingTabResponse.loginRequired() } - val home = homeFollowingQueryService.findHomeFollowing(member) + val home = homeFollowingQueryService.findHomeFollowing(member, lang) val recentChats = chatRoomListService.getRooms( member, + lang = lang, filter = "ALL", cursor = null, limit = 10, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt index 1eb7bf57..854a9c2d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacade import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -13,14 +14,15 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/home/on-air-lives") class HomeOnAirLiveController( - private val homeOnAirLiveFacade: HomeOnAirLiveFacade + private val homeOnAirLiveFacade: HomeOnAirLiveFacade, + private val langContext: LangContext ) { @GetMapping fun getOnAirLives( @RequestParam(defaultValue = "0") page: Int, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(requireMember(member), page)) + ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(requireMember(member), page, langContext.lang)) } private fun requireMember(member: Member?): Member { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt index 4328d852..c95bc8be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.live.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -20,7 +21,7 @@ class HomeOnAirLiveFacade( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { - fun getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse { + fun getOnAirLives(member: Member, page: Int, lang: Lang): HomeOnAirLivePageResponse { val normalizedPage = page.coerceIn(0, MAX_PAGE) val fetched = queryService.findLiveRecommendations( offset = normalizedPage.toLong() * PAGE_SIZE, @@ -28,9 +29,11 @@ class HomeOnAirLiveFacade( memberId = member.id, includeAdultLives = memberContentPreferenceService.canViewAdultContent(member), isViewerCreator = member.role == MemberRole.CREATOR, - effectiveViewerGender = member.effectiveGender() + effectiveViewerGender = member.effectiveGender(), + lang = lang ) - val items = fetched.take(PAGE_SIZE).map { it.toResponse() } + val pageItems = fetched.take(PAGE_SIZE) + val items = pageItems.map { it.toResponse() } return HomeOnAirLivePageResponse( items = items, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt index 15a6ab59..2eb7d285 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.chat.controller import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService @@ -13,7 +14,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/chat/rooms") class ChatRoomListController( - private val service: ChatRoomListService + private val service: ChatRoomListService, + private val langContext: LangContext ) { @GetMapping fun getRooms( @@ -23,6 +25,6 @@ class ChatRoomListController( @RequestParam(defaultValue = "30") limit: Int ) = run { if (member == null) return@run ApiResponse.ok(ChatRoomListPageResponse(emptyList(), false, null)) - ApiResponse.ok(service.getRooms(member, filter, cursor, limit)) + ApiResponse.ok(service.getRooms(member, langContext.lang, filter, cursor, limit)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt index fd047951..f8bd48ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.chat.service import kr.co.vividnext.sodalive.chat.room.ChatMessageType import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.toUtcIso import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse @@ -26,6 +27,7 @@ class ChatRoomListService( ) { fun getRooms( member: Member, + lang: Lang, filter: String = ChatRoomListFilter.ALL.name, cursor: String? = null, limit: Int = DEFAULT_LIMIT, @@ -50,6 +52,7 @@ class ChatRoomListService( parsedCursor?.chatType, parsedCursor?.roomId, followedCreatorsOnly, + lang, pageable ) } @@ -70,6 +73,7 @@ class ChatRoomListService( parsedCursor?.chatType, parsedCursor?.roomId, followedCreatorsOnly, + lang, pageable ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt index 219d58f2..72e9c125 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt @@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.content.translation.QContentTranslation import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember @@ -23,6 +24,7 @@ import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -64,7 +66,10 @@ class DefaultMainContentAllQueryRepository( onlyFree: Boolean, onlyPointAvailable: Boolean ): List { - val rows = findAudioRows(memberId, canViewAdultContent, now, sort, offset, limit, onlyPaid, onlyFree, onlyPointAvailable) + val creatorName = CreatorNameJoin(member, Lang.fromCode(locale), "mainContentAllAudioCreatorName") + val rows = findAudioRows( + memberId, canViewAdultContent, now, sort, offset, limit, onlyPaid, onlyFree, onlyPointAvailable, creatorName + ) if (rows.isEmpty()) return emptyList() val contentIds = rows.map { it.get(audioContent.id)!! } @@ -85,7 +90,8 @@ class DefaultMainContentAllQueryRepository( isPointAvailable = row.get(audioContent.isPointAvailable)!!, isFirstContent = firstContentIdByCreatorId[creatorId] == contentId, isOriginalSeries = originalSeriesByContentId[contentId] ?: false, - creatorNickname = row.get(member.nickname)!! + creatorNickname = row.get(creatorName.displayName)!!, + creatorMemberId = creatorId ) } } @@ -122,18 +128,21 @@ class DefaultMainContentAllQueryRepository( if (seriesIds.isEmpty()) return emptyList() val seriesTranslation = QSeriesTranslation("mainContentAllSeriesTranslation") + val creatorName = CreatorNameJoin(member, Lang.fromCode(locale), "mainContentAllSeriesCreatorName") return queryFactory .select( series.id, series.title, seriesTranslation.renderedPayload, series.coverImage, - member.nickname, + member.id, + creatorName.displayName, series.isOriginal, series.isAdult ) .from(series) .join(series.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(seriesTranslation) .on( seriesTranslation.seriesId.eq(series.id), @@ -148,9 +157,10 @@ class DefaultMainContentAllQueryRepository( seriesId = row.get(series.id)!!, title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: row.get(series.title)!!, coverImageUrl = row.get(series.coverImage).toCdnUrl(cloudFrontHost), - creatorNickname = row.get(member.nickname)!!, + creatorNickname = row.get(creatorName.displayName)!!, isOriginal = row.get(series.isOriginal)!!, - isAdult = row.get(series.isAdult)!! + isAdult = row.get(series.isAdult)!!, + creatorMemberId = row.get(member.id)!! ) } } @@ -164,7 +174,8 @@ class DefaultMainContentAllQueryRepository( limit: Int, onlyPaid: Boolean, onlyFree: Boolean, - onlyPointAvailable: Boolean + onlyPointAvailable: Boolean, + creatorName: CreatorNameJoin ): List { val query = queryFactory .select( @@ -175,11 +186,12 @@ class DefaultMainContentAllQueryRepository( audioContent.isAdult, audioContent.isPointAvailable, audioContent.member.id, - member.nickname, + creatorName.displayName, audioContent.releaseDate ) .from(audioContent) .join(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .join(audioContent.theme, audioContentTheme) .where(audioCondition(memberId, canViewAdultContent, now, onlyPaid, onlyFree, onlyPointAvailable)) @@ -192,7 +204,7 @@ class DefaultMainContentAllQueryRepository( revenueOrder.audioContent.id.eq(audioContent.id), revenueOrder.isActive.isTrue ) - .groupByAudioRow() + .groupByAudioRow(creatorName) .orderBy( revenueOrder.can.sum().coalesce(0).desc(), audioContent.releaseDate.desc(), @@ -301,7 +313,7 @@ class DefaultMainContentAllQueryRepository( return query.offset(offset).limit(limit.toLong()).fetch() } - private fun JPAQuery.groupByAudioRow(): JPAQuery { + private fun JPAQuery.groupByAudioRow(creatorName: CreatorNameJoin): JPAQuery { return groupBy( audioContent.id, audioContent.title, @@ -311,6 +323,7 @@ class DefaultMainContentAllQueryRepository( audioContent.isPointAvailable, audioContent.member.id, member.nickname, + creatorName.name.displayName, audioContent.releaseDate ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt index e19a53ba..3ee94b86 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt @@ -1,7 +1,7 @@ package kr.co.vividnext.sodalive.v2.content.all.application import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek -import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.common.domain.ContentSort @@ -19,8 +19,7 @@ import java.time.LocalDateTime class MainContentAllQueryService( private val queryPort: MainContentAllQueryPort, private val memberContentPreferenceService: MemberContentPreferenceService, - private val queryPolicy: MainContentAllQueryPolicy = MainContentAllQueryPolicy(), - private val langContext: LangContext + private val queryPolicy: MainContentAllQueryPolicy = MainContentAllQueryPolicy() ) { fun getContents( type: String?, @@ -28,7 +27,8 @@ class MainContentAllQueryService( dayOfWeek: String?, page: Int?, size: Int?, - member: Member? + member: Member?, + lang: Lang ): MainContentAll { val resolvedType = queryPolicy.resolveType(type) val resolvedSort = queryPolicy.resolveSort(sort) @@ -47,6 +47,7 @@ class MainContentAllQueryService( memberId = memberId, canViewAdultContent = canViewAdultContent, now = now, + lang = lang, onlyPaid = true ) @@ -58,6 +59,7 @@ class MainContentAllQueryService( memberId = memberId, canViewAdultContent = canViewAdultContent, now = now, + lang = lang, onlyFree = true ) @@ -69,6 +71,7 @@ class MainContentAllQueryService( memberId = memberId, canViewAdultContent = canViewAdultContent, now = now, + lang = lang, onlyPointAvailable = true ) @@ -79,7 +82,8 @@ class MainContentAllQueryService( page = resolvedPage, memberId = memberId, canViewAdultContent = canViewAdultContent, - now = now + now = now, + lang = lang ) MainContentAllType.ORIGINAL -> getSeriesContents( @@ -90,6 +94,7 @@ class MainContentAllQueryService( memberId = memberId, canViewAdultContent = canViewAdultContent, now = now, + lang = lang, onlyOriginal = true ) } @@ -103,6 +108,7 @@ class MainContentAllQueryService( memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime, + lang: Lang, onlyPaid: Boolean = false, onlyFree: Boolean = false, onlyPointAvailable: Boolean = false @@ -122,7 +128,7 @@ class MainContentAllQueryService( sort = sort, offset = page.offset, limit = page.size + 1, - locale = langContext.lang.code, + locale = lang.code, onlyPaid = onlyPaid, onlyFree = onlyFree, onlyPointAvailable = onlyPointAvailable @@ -148,6 +154,7 @@ class MainContentAllQueryService( memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime, + lang: Lang, onlyOriginal: Boolean = false ): MainContentAll { val totalCount = queryPort.countSeries(memberId, canViewAdultContent, now, onlyOriginal, dayOfWeek) @@ -160,7 +167,7 @@ class MainContentAllQueryService( limit = page.size + 1, onlyOriginal = onlyOriginal, dayOfWeek = dayOfWeek, - locale = langContext.lang.code + locale = lang.code ) return MainContentAll( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt index c1d0c4e3..acf3da67 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt @@ -23,7 +23,8 @@ data class MainContentAllAudio( val isPointAvailable: Boolean, val isFirstContent: Boolean, val isOriginalSeries: Boolean, - val creatorNickname: String + val creatorNickname: String, + val creatorMemberId: Long ) data class MainContentAllSeries( @@ -32,5 +33,6 @@ data class MainContentAllSeries( val coverImageUrl: String?, val creatorNickname: String, val isOriginal: Boolean, - val isAdult: Boolean + val isAdult: Boolean, + val creatorMemberId: Long ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt index 698e5b78..496f660a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt @@ -1,9 +1,15 @@ package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence +import com.querydsl.jpa.JPAExpressions +import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.QAudioRankingSnapshot.audioRankingSnapshot import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @@ -11,14 +17,34 @@ import java.time.LocalDateTime @Repository class DefaultAudioRankingSnapshotPersistenceAdapter( private val repository: AudioRankingSnapshotRepository, - private val contentTranslationRepository: ContentTranslationRepository + private val contentTranslationRepository: ContentTranslationRepository, + private val queryFactory: JPAQueryFactory ) : AudioRankingSnapshotPort { override fun findLatestVisibleSnapshots( rankingType: AudioRankingType, nowUtc: LocalDateTime, locale: String ): List { - return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).toRecords(locale) + val snapshot = audioRankingSnapshot + val latest = QAudioRankingSnapshot("latestVisibleAudioRanking") + val creatorName = CreatorNameJoin(member, Lang.fromCode(locale), "audioRankingCreatorName") + val displayName = creatorName.displayName.coalesce(snapshot.creatorNickname) + return queryFactory + .select(snapshot, displayName) + .from(snapshot) + .leftJoin(member).on(member.id.eq(snapshot.creatorMemberId)) + .leftJoin(creatorName.name).on(creatorName.condition) + .where( + snapshot.rankingType.eq(rankingType), + snapshot.visibleFromAtUtc.eq( + JPAExpressions.select(latest.visibleFromAtUtc.max()).from(latest) + .where(latest.rankingType.eq(rankingType), latest.visibleFromAtUtc.loe(nowUtc)) + ) + ) + .orderBy(snapshot.rank.asc()) + .fetch() + .map { it.get(snapshot)!!.toRecord(displayCreatorNickname = it.get(displayName)!!) } + .translateTitles(locale) } override fun findPreviousVisibleSnapshots( @@ -31,7 +57,7 @@ class DefaultAudioRankingSnapshotPersistenceAdapter( rankingType = rankingType.name, currentAggregationStartAtUtc = currentAggregationStartAtUtc, nowUtc = nowUtc - ).toRecords(locale) + ).map { it.toRecord() }.translateTitles(locale) } @Transactional @@ -50,7 +76,7 @@ class DefaultAudioRankingSnapshotPersistenceAdapter( repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) }) } - private fun List.toRecords(locale: String): List { + private fun List.translateTitles(locale: String): List { if (isEmpty()) return emptyList() val translatedTitleByContentId = contentTranslationRepository.findByContentIdInAndLocale( map { it.contentId }, @@ -58,19 +84,19 @@ class DefaultAudioRankingSnapshotPersistenceAdapter( ).mapNotNull { translation -> translation.renderedPayload.title.takeIf { it.isNotBlank() }?.let { translation.contentId to it } }.toMap() - return map { snapshot -> snapshot.toRecord(translatedTitleByContentId[snapshot.contentId] ?: snapshot.title) } + return map { snapshot -> snapshot.copy(title = translatedTitleByContentId[snapshot.contentId] ?: snapshot.title) } } - private fun AudioRankingSnapshot.toRecord(displayTitle: String = title): AudioRankingSnapshotRecord { + private fun AudioRankingSnapshot.toRecord(displayCreatorNickname: String = creatorNickname): AudioRankingSnapshotRecord { return AudioRankingSnapshotRecord( rankingType = rankingType, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, visibleFromAtUtc = visibleFromAtUtc, contentId = contentId, - title = displayTitle, + title = title, creatorMemberId = creatorMemberId, - creatorNickname = creatorNickname, + creatorNickname = displayCreatorNickname, coverImageUrl = coverImageUrl, releaseDate = releaseDate, isAdult = isAdult, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt index e0c95d4c..396e2ddc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt @@ -1,6 +1,6 @@ package kr.co.vividnext.sodalive.v2.content.ranking.application -import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl @@ -19,7 +19,6 @@ import java.time.ZonedDateTime @Service class AudioRankingQueryService( private val snapshotPort: AudioRankingSnapshotPort, - private val langContext: LangContext, private val memberContentPreferenceService: MemberContentPreferenceService, private val blockPort: AudioRankingBlockPort, private val jobService: AudioRankingSnapshotJobService, @@ -29,9 +28,9 @@ class AudioRankingQueryService( ) { private val log = LoggerFactory.getLogger(javaClass) - fun getRankings(type: AudioRankingType, member: Member?): AudioRanking { + fun getRankings(type: AudioRankingType, member: Member?, lang: Lang): AudioRanking { val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() - val locale = langContext.lang.code + val locale = lang.code val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc, locale) if (latestSnapshots.isEmpty()) { return AudioRanking(showRankChange = false, type = type, items = emptyList()) @@ -114,7 +113,8 @@ class AudioRankingQueryService( rank = rank, rankChange = if (showRankChange && previousRank != null) previousRank - rank else null, isNew = showRankChange && previousRank == null, - coverImageUrl = coverImageUrl.toCdnUrl(cloudFrontHost) + coverImageUrl = coverImageUrl.toCdnUrl(cloudFrontHost), + creatorMemberId = creatorMemberId ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt index 0f488f97..0232723a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt @@ -13,5 +13,6 @@ data class AudioRankingItem( val rank: Int, val rankChange: Int?, val isNew: Boolean, - val coverImageUrl: String? + val coverImageUrl: String?, + val creatorMemberId: Long ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 70923408..33fb1e81 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import org.springframework.beans.factory.annotation.Value @@ -120,7 +121,7 @@ class DefaultAudioRecommendationQueryRepository( now: LocalDateTime, locale: String ): List { - val rows = audioRows(memberId, canViewAdultContent, now) { + val rows = audioRows(memberId, canViewAdultContent, now, locale) { orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong()) } return rows.toAudioCards(now, canViewAdultContent, locale) @@ -134,7 +135,7 @@ class DefaultAudioRecommendationQueryRepository( locale: String ): List { val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") - val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) { + val rows = audioRows(memberId, canViewAdultContent, now, locale, audioContent.price.eq(0)) { orderBy(randomTieBreaker.asc()).limit(limit.toLong()) } return rows.toAudioCards(now, canViewAdultContent, locale) @@ -152,6 +153,7 @@ class DefaultAudioRecommendationQueryRepository( memberId, canViewAdultContent, now, + locale, audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0)) ) { orderBy(randomTieBreaker.asc()).limit(limit.toLong()) @@ -168,7 +170,7 @@ class DefaultAudioRecommendationQueryRepository( ): List { if (contentIds.isEmpty()) return emptyList() val orderById = contentIds.withIndex().associate { it.value to it.index } - val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this } + val rows = audioRows(memberId, canViewAdultContent, now, locale, audioContent.id.`in`(contentIds)) { this } return rows.toAudioCards(now, canViewAdultContent, locale).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE } } @@ -444,9 +446,11 @@ class DefaultAudioRecommendationQueryRepository( memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime, + locale: String, extraCondition: BooleanExpression? = null, customize: com.querydsl.jpa.impl.JPAQuery.() -> com.querydsl.jpa.impl.JPAQuery ): List { + val creatorName = CreatorNameJoin(member, Lang.fromCode(locale), "audioRecommendationCreatorName") return queryFactory .select( audioContent.id, @@ -457,10 +461,11 @@ class DefaultAudioRecommendationQueryRepository( audioContent.isAdult, audioContent.isPointAvailable, audioContent.member.id, - member.nickname + creatorName.displayName ) .from(audioContent) .join(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .join(audioContent.theme, audioContentTheme) .where(publicAudioCondition(memberId, canViewAdultContent, now), extraCondition) .customize() @@ -478,6 +483,7 @@ class DefaultAudioRecommendationQueryRepository( val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent) val isOriginalSeriesByContentId = originalSeriesFlags(contentIds) val translatedTitleByContentId = translatedTitles(contentIds, locale) + val creatorName = CreatorNameJoin(member, Lang.fromCode(locale), "audioRecommendationCreatorName") return map { row -> val contentId = row.get(audioContent.id)!! val creatorId = row.get(audioContent.member.id)!! @@ -491,7 +497,8 @@ class DefaultAudioRecommendationQueryRepository( isPointAvailable = row.get(audioContent.isPointAvailable)!!, isFirstContent = firstContentIdByCreatorId[creatorId] == contentId, isOriginalSeries = isOriginalSeriesByContentId[contentId] ?: false, - creatorNickname = row.get(member.nickname)!! + creatorNickname = row.get(creatorName.displayName)!!, + creatorMemberId = creatorId ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt index 2cf8a09a..2573c9cc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard @@ -18,16 +17,15 @@ import java.time.ZoneId class AudioRecommendationQueryService( private val queryPort: AudioRecommendationQueryPort, private val memberContentPreferenceService: MemberContentPreferenceService, - private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService, - private val langContext: LangContext + private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService ) { - fun getRecommendations(member: Member?): AudioRecommendations { + fun getRecommendations(member: Member?, lang: Lang): AudioRecommendations { val now = LocalDateTime.now() val fallbackNow = LocalDateTime.now(KST_ZONE) val canViewAdultContent = canViewAdultContent(member) val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val memberId = member?.id - val locale = langContext.lang.code + val locale = lang.code val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO val newAndHotSectionType = newAndHotSectionType(visibility) val mostCommentedSectionType = mostCommentedSectionType(visibility) @@ -77,7 +75,7 @@ class AudioRecommendationQueryService( ) } - fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List { + fun findNewAndHotAudios(member: Member, offset: Long, limit: Int, lang: Lang): List { val now = LocalDateTime.now() val fallbackNow = LocalDateTime.now(KST_ZONE) val canViewAdultContent = canViewAdultContent(member) @@ -90,7 +88,7 @@ class AudioRecommendationQueryService( member.id, canViewAdultContent, now, - langContext.lang.code + lang.code ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt index 0721a131..4756f94c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt @@ -28,7 +28,8 @@ data class AudioCard( val isPointAvailable: Boolean, val isFirstContent: Boolean, val isOriginalSeries: Boolean, - val creatorNickname: String + val creatorNickname: String, + val creatorMemberId: Long ) data class CommentedAudio( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt index 19ec10f8..67f3d5ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt @@ -15,12 +15,14 @@ import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslati import kr.co.vividnext.sodalive.content.translation.QContentTranslation import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -28,17 +30,19 @@ import java.time.LocalDateTime class DefaultCreatorChannelAudioQueryRepository( private val queryFactory: JPAQueryFactory ) : CreatorChannelAudioQueryRepository { - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelAudioCreatorRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelAudioCreatorName") return queryFactory .select( Projections.constructor( CreatorChannelAudioCreatorRecord::class.java, member.id, member.role, - member.nickname + creatorName.displayName ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.id.eq(creatorId), member.isActive.isTrue diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt index f3b8e0b5..676c2e50 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.audio.application import kr.co.vividnext.sodalive.common.SodaException +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.Member @@ -39,18 +40,19 @@ class CreatorChannelAudioQueryService( themeId: Long?, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelAudioTab { val resolvedSort = queryPolicy.resolveSort(sort) val audioPage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! - val creator = queryPort.findCreator(creatorId, viewerId) + val creator = queryPort.findCreator(creatorId, viewerId, lang) ?: throw SodaException(messageKey = "member.validation.user_not_found") if (queryPort.existsBlockedBetween(viewerId, creatorId)) { val messageTemplate = messageSource - .getMessage("explorer.creator.blocked_access", langContext.lang) + .getMessage("explorer.creator.blocked_access", lang) .orEmpty() throw SodaException(message = String.format(messageTemplate, creator.nickname)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt index dfa848b1..e43460b9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt @@ -1,11 +1,12 @@ package kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import java.time.LocalDateTime interface CreatorChannelAudioQueryPort { - fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? + fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelAudioCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt index 9ee6efd3..eff7f526 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt @@ -4,28 +4,37 @@ import com.querydsl.core.Tuple import com.querydsl.core.types.OrderSpecifier import com.querydsl.core.types.dsl.BooleanExpression import com.querydsl.core.types.dsl.CaseBuilder +import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository @Repository class DefaultCreatorChannelCommunityQueryRepository( private val queryFactory: JPAQueryFactory ) : CreatorChannelCommunityQueryRepository { - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? { + private val postCreatorDisplayName = Expressions.stringPath("postCreatorDisplayName") + private val commentWriterDisplayName = Expressions.stringPath("commentWriterDisplayName") + private val localizedWriterName = Expressions.booleanPath("localizedWriterName") + + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCommunityCreatorRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelCommunityCreatorName") val creator = queryFactory - .select(member.id, member.role, member.nickname) + .select(member.id, member.role, creatorName.displayName) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.id.eq(creatorId), member.isActive.isTrue @@ -35,7 +44,7 @@ class DefaultCreatorChannelCommunityQueryRepository( return CreatorChannelCommunityCreatorRecord( creatorId = creator.get(member.id)!!, role = creator.get(member.role)!!, - nickname = creator.get(member.nickname)!! + nickname = creator.get(creatorName.displayName)!! ) } @@ -71,11 +80,15 @@ class DefaultCreatorChannelCommunityQueryRepository( viewerId: Long, canViewAdultContent: Boolean, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "channelCommunityPostCreatorName") val rows = queryFactory - .selectCommunityPostRow() + .selectCommunityPostRow(creatorName) .from(creatorCommunity) + .join(creatorCommunity.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(communityPostCondition(creatorId, canViewAdultContent)) .orderBy( CaseBuilder() @@ -104,11 +117,15 @@ class DefaultCreatorChannelCommunityQueryRepository( viewerId: Long, isPinned: Boolean, canViewAdultContent: Boolean, - limit: Int + limit: Int, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "channelHomePostCreatorName") val rows = queryFactory - .selectCommunityPostRow() + .selectCommunityPostRow(creatorName) .from(creatorCommunity) + .join(creatorCommunity.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( homeCommunityPostCondition(creatorId, viewerId, canViewAdultContent), creatorCommunity.isFixed.eq(isPinned), @@ -124,11 +141,15 @@ class DefaultCreatorChannelCommunityQueryRepository( override fun findCommunityPost( postId: Long, viewerId: Long, - canViewAdultContent: Boolean + canViewAdultContent: Boolean, + lang: Lang ): CreatorChannelCommunityPostRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelPostDetailCreatorName") val rows = queryFactory - .selectCommunityPostRow() + .selectCommunityPostRow(creatorName) .from(creatorCommunity) + .join(creatorCommunity.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(communityPostByIdCondition(postId, canViewAdultContent)) .fetch() @@ -155,11 +176,15 @@ class DefaultCreatorChannelCommunityQueryRepository( viewerId: Long, creatorId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { + val writerName = CreatorNameJoin(member, lang, "channelCommentWriterName") return queryFactory - .selectCommunityCommentRow() + .selectCommunityCommentRow(writerName) .from(creatorCommunityComment) + .join(creatorCommunityComment.member, member) + .leftJoin(writerName.name).on(writerName.condition) .where(rootCommentCondition(postId, creatorId, viewerId)) .orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc()) .offset(offset) @@ -170,13 +195,17 @@ class DefaultCreatorChannelCommunityQueryRepository( override fun findLatestRepliesByCommentIds( commentIds: List, - viewerId: Long + viewerId: Long, + lang: Lang ): List { if (commentIds.isEmpty()) return emptyList() + val writerName = CreatorNameJoin(member, lang, "channelLatestReplyWriterName") return queryFactory - .selectCommunityCommentRow() + .selectCommunityCommentRow(writerName) .from(creatorCommunityComment) + .join(creatorCommunityComment.member, member) + .leftJoin(writerName.name).on(writerName.condition) .where(replyCondition(commentIds, viewerId)) .orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc()) .fetch() @@ -224,11 +253,15 @@ class DefaultCreatorChannelCommunityQueryRepository( commentId: Long, viewerId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { + val writerName = CreatorNameJoin(member, lang, "channelReplyWriterName") return queryFactory - .selectCommunityCommentRow() + .selectCommunityCommentRow(writerName) .from(creatorCommunityComment) + .join(creatorCommunityComment.member, member) + .leftJoin(writerName.name).on(writerName.condition) .where(replyCondition(commentId, viewerId)) .orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc()) .offset(offset) @@ -237,10 +270,10 @@ class DefaultCreatorChannelCommunityQueryRepository( .map { it.toCommunityCommentRecord() } } - private fun JPAQueryFactory.selectCommunityPostRow() = select( + private fun JPAQueryFactory.selectCommunityPostRow(creatorName: CreatorNameJoin) = select( creatorCommunity.id, creatorCommunity.member.id, - creatorCommunity.member.nickname, + creatorName.displayName.`as`(postCreatorDisplayName), creatorCommunity.member.profileImage, creatorCommunity.imagePath, creatorCommunity.audioPath, @@ -252,16 +285,17 @@ class DefaultCreatorChannelCommunityQueryRepository( creatorCommunity.isCommentAvailable ) - private fun JPAQueryFactory.selectCommunityCommentRow() = select( + private fun JPAQueryFactory.selectCommunityCommentRow(writerName: CreatorNameJoin) = select( creatorCommunityComment.id, creatorCommunityComment.parent.id, creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.member.id, creatorCommunityComment.member.profileImage, - creatorCommunityComment.member.nickname, + writerName.displayName.`as`(commentWriterDisplayName), creatorCommunityComment.comment, creatorCommunityComment.isSecret, - creatorCommunityComment.createdAt + creatorCommunityComment.createdAt, + writerName.name.id.isNotNull.`as`(localizedWriterName) ) private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression { @@ -519,7 +553,7 @@ class DefaultCreatorChannelCommunityQueryRepository( get() = get(creatorCommunity.member.id)!! private val Tuple.creatorNickname: String - get() = get(creatorCommunity.member.nickname)!! + get() = get(postCreatorDisplayName)!! private val Tuple.creatorProfilePath: String? get() = get(creatorCommunity.member.profileImage) @@ -555,10 +589,11 @@ class DefaultCreatorChannelCommunityQueryRepository( postId = get(creatorCommunityComment.creatorCommunity.id)!!, writerId = get(creatorCommunityComment.member.id)!!, writerProfilePath = get(creatorCommunityComment.member.profileImage), - writerNickname = get(creatorCommunityComment.member.nickname)!!, + writerNickname = get(commentWriterDisplayName)!!, content = get(creatorCommunityComment.comment)!!, isSecret = get(creatorCommunityComment.isSecret)!!, - createdAt = get(creatorCommunityComment.createdAt)!! + createdAt = get(creatorCommunityComment.createdAt)!!, + isLocalizedName = get(localizedWriterName)!! ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt index 785d6015..2385b674 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.application import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.common.SodaException 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.member.Member @@ -46,17 +47,18 @@ class CreatorChannelCommunityQueryService( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityTab { val communityPage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! - val creator = queryPort.findCreator(creatorId, viewerId) + val creator = queryPort.findCreator(creatorId, viewerId, lang) ?: throw SodaException(messageKey = "member.validation.user_not_found") if (queryPort.existsBlockedBetween(viewerId, creatorId)) { val messageTemplate = messageSource - .getMessage("explorer.creator.blocked_access", langContext.lang) + .getMessage("explorer.creator.blocked_access", lang) .orEmpty() throw SodaException(message = String.format(messageTemplate, creator.nickname)) } @@ -69,7 +71,8 @@ class CreatorChannelCommunityQueryService( viewerId = viewerId, canViewAdultContent = canViewAdultContent, offset = communityPage.offset, - limit = communityPage.fetchLimit + limit = communityPage.fetchLimit, + lang = lang ) val communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage) val displayContents = creatorCommunityTranslationService @@ -94,7 +97,8 @@ class CreatorChannelCommunityQueryService( viewerId: Long, isPinned: Boolean, canViewAdultContent: Boolean, - limit: Int + limit: Int, + lang: Lang ): List { val posts = queryPortProvider.getObject() .findHomeCommunityPosts( @@ -102,7 +106,8 @@ class CreatorChannelCommunityQueryService( viewerId = viewerId, isPinned = isPinned, canViewAdultContent = canViewAdultContent, - limit = limit + limit = limit, + lang = lang ) val displayContents = creatorCommunityTranslationService .findDisplayContents(posts.map { it.postId }, langContext.lang.code) @@ -115,12 +120,13 @@ class CreatorChannelCommunityQueryService( fun getCommunityPostDetail( postId: Long, viewer: Member, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityPostDetail { val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer) - val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent) + val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent, lang) ?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY) validateNotBlocked(queryPort, viewerId, postRecord.creatorId) @@ -134,7 +140,7 @@ class CreatorChannelCommunityQueryService( return CreatorChannelCommunityPostDetail( post = postRecord.copy(content = displayContent?.content ?: postRecord.content).toDomain(viewerId), - comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now) + comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now, lang = lang) ) } @@ -143,13 +149,14 @@ class CreatorChannelCommunityQueryService( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityComments { val communityPage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer) - val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent) + val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent, lang) ?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY) validateNotBlocked(queryPort, viewerId, postRecord.creatorId) @@ -168,12 +175,14 @@ class CreatorChannelCommunityQueryService( viewerId = viewerId, creatorId = postRecord.creatorId, offset = communityPage.offset, - limit = communityPage.fetchLimit + limit = communityPage.fetchLimit, + lang = lang ) val limitedComments = queryPolicy.limitItems(fetchedComments, communityPage) val latestReplies = queryPort.findLatestRepliesByCommentIds( commentIds = limitedComments.map { it.commentId }, - viewerId = viewerId + viewerId = viewerId, + lang = lang ).associateBy { it.parentCommentId } return CreatorChannelCommunityComments( @@ -193,7 +202,8 @@ class CreatorChannelCommunityQueryService( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelCommunityReplies { val communityPage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() @@ -217,7 +227,8 @@ class CreatorChannelCommunityQueryService( commentId = commentId, viewerId = viewerId, offset = communityPage.offset, - limit = communityPage.fetchLimit + limit = communityPage.fetchLimit, + lang = lang ) return CreatorChannelCommunityReplies( @@ -274,7 +285,7 @@ class CreatorChannelCommunityQueryService( commentId = commentId, writerId = writerId, writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), - writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerNickname = if (isLocalizedName) writerNickname else writerNickname.removeDeletedNicknamePrefix(), content = content, isSecret = isSecret, createdAt = createdAt, @@ -287,7 +298,7 @@ class CreatorChannelCommunityQueryService( commentId = commentId, writerId = writerId, writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), - writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerNickname = if (isLocalizedName) writerNickname else writerNickname.removeDeletedNicknamePrefix(), content = content, createdAt = createdAt ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt index d5cdd98c..7ca0ff5d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt @@ -1,10 +1,11 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import java.time.LocalDateTime interface CreatorChannelCommunityQueryPort { - fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? + fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCommunityCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean @@ -19,7 +20,8 @@ interface CreatorChannelCommunityQueryPort { viewerId: Long, canViewAdultContent: Boolean, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List fun findHomeCommunityPosts( @@ -27,13 +29,15 @@ interface CreatorChannelCommunityQueryPort { viewerId: Long, isPinned: Boolean, canViewAdultContent: Boolean, - limit: Int + limit: Int, + lang: Lang ): List fun findCommunityPost( postId: Long, viewerId: Long, - canViewAdultContent: Boolean + canViewAdultContent: Boolean, + lang: Lang ): CreatorChannelCommunityPostRecord? fun countCommunityComments( @@ -47,12 +51,14 @@ interface CreatorChannelCommunityQueryPort { viewerId: Long, creatorId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List fun findLatestRepliesByCommentIds( commentIds: List, - viewerId: Long + viewerId: Long, + lang: Lang ): List fun findRootCommentContext( @@ -70,7 +76,8 @@ interface CreatorChannelCommunityQueryPort { commentId: Long, viewerId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List } @@ -107,7 +114,8 @@ data class CreatorChannelCommunityCommentRecord( val writerNickname: String, val content: String, val isSecret: Boolean, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isLocalizedName: Boolean = false ) data class CreatorChannelCommunityCommentContextRecord( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt index cc59ab1c..289f9633 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt @@ -4,11 +4,13 @@ import com.querydsl.core.types.Projections import com.querydsl.core.types.dsl.BooleanExpression import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -18,16 +20,18 @@ class DefaultCreatorChannelDonationQueryRepository( ) : CreatorChannelDonationQueryRepository { private val queryPolicy = CreatorChannelDonationQueryPolicy() - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelDonationCreatorRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelDonationCreatorName") val creator = queryFactory .select( member.id, member.role, - member.nickname, + creatorName.displayName, member.isVisibleDonationRank, member.donationRankingPeriod ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.id.eq(creatorId), member.isActive.isTrue @@ -37,7 +41,7 @@ class DefaultCreatorChannelDonationQueryRepository( return CreatorChannelDonationCreatorRecord( creatorId = creator.get(member.id)!!, role = creator.get(member.role)!!, - nickname = creator.get(member.nickname)!!, + nickname = creator.get(creatorName.displayName)!!, isVisibleDonationRank = creator.get(member.isVisibleDonationRank)!!, donationRankingPeriod = creator.get(member.donationRankingPeriod) ) @@ -75,20 +79,26 @@ class DefaultCreatorChannelDonationQueryRepository( viewerId: Long, now: LocalDateTime, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { + val donorName = CreatorNameJoin(member, lang, "channelDonationDonorName") return queryFactory .select( Projections.constructor( CreatorChannelDonationRecord::class.java, - channelDonationMessage.member.nickname, + channelDonationMessage.member.id, + donorName.displayName, channelDonationMessage.member.profileImage, channelDonationMessage.can, channelDonationMessage.additionalMessage, - channelDonationMessage.createdAt + channelDonationMessage.createdAt, + donorName.name.id.isNotNull ) ) .from(channelDonationMessage) + .join(channelDonationMessage.member, member) + .leftJoin(donorName.name).on(donorName.condition) .where(channelDonationCondition(creatorId, viewerId, now)) .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) .offset(offset) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt index 38ec62b8..0b3fbd20 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt @@ -2,7 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation.application 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.Lang import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member @@ -30,7 +30,6 @@ class CreatorChannelDonationQueryService( private val rankingPort: CreatorChannelDonationRankingPort, private val queryPolicy: CreatorChannelDonationQueryPolicy, private val messageSource: SodaMessageSource, - private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -39,17 +38,18 @@ class CreatorChannelDonationQueryService( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime + now: LocalDateTime, + lang: Lang ): CreatorChannelDonationTab { val donationPage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! - val creator = queryPort.findCreator(creatorId, viewerId) + val creator = queryPort.findCreator(creatorId, viewerId, lang) ?: throw SodaException(messageKey = "member.validation.user_not_found") if (queryPort.existsBlockedBetween(viewerId, creatorId)) { val messageTemplate = messageSource - .getMessage("explorer.creator.blocked_access", langContext.lang) + .getMessage("explorer.creator.blocked_access", lang) .orEmpty() throw SodaException(message = String.format(messageTemplate, creator.nickname)) } @@ -61,7 +61,8 @@ class CreatorChannelDonationQueryService( viewerId = viewerId, now = now, offset = donationPage.offset, - limit = donationPage.fetchLimit + limit = donationPage.fetchLimit, + lang = lang ) return CreatorChannelDonationTab( @@ -95,7 +96,8 @@ class CreatorChannelDonationQueryService( } private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( - nickname = nickname.removeDeletedNicknamePrefix(), + memberId = memberId, + nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(), profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), can = can, message = message.orEmpty(), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt index ad91d33f..3ead9d17 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt @@ -19,6 +19,7 @@ data class CreatorChannelDonationRanking( ) data class CreatorChannelDonation( + val memberId: Long, val nickname: String, val profileImageUrl: String, val can: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt index 48cba338..3f50af07 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt @@ -1,11 +1,12 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.DonationRankingPeriod import kr.co.vividnext.sodalive.member.MemberRole import java.time.LocalDateTime interface CreatorChannelDonationQueryPort { - fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? + fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelDonationCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean @@ -20,7 +21,8 @@ interface CreatorChannelDonationQueryPort { viewerId: Long, now: LocalDateTime, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List } @@ -33,9 +35,11 @@ data class CreatorChannelDonationCreatorRecord( ) data class CreatorChannelDonationRecord( + val memberId: Long, val nickname: String, val profileImagePath: String?, val can: Int, val message: String?, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isLocalizedName: Boolean = false ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt index 5e808c63..dac6b022 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt @@ -4,21 +4,25 @@ import com.querydsl.core.types.Projections import com.querydsl.core.types.dsl.BooleanExpression import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository @Repository class DefaultCreatorChannelFanTalkQueryRepository( private val queryFactory: JPAQueryFactory ) : CreatorChannelFanTalkQueryRepository { - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelFanTalkCreatorRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelFanTalkCreatorName") val creator = queryFactory - .select(member.id, member.role, member.nickname) + .select(member.id, member.role, creatorName.displayName) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.id.eq(creatorId), member.isActive.isTrue @@ -28,7 +32,7 @@ class DefaultCreatorChannelFanTalkQueryRepository( return CreatorChannelFanTalkCreatorRecord( creatorId = creator.get(member.id)!!, role = creator.get(member.role)!!, - nickname = creator.get(member.nickname)!! + nickname = creator.get(creatorName.displayName)!! ) } @@ -59,21 +63,26 @@ class DefaultCreatorChannelFanTalkQueryRepository( creatorId: Long, viewerId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { + val writerName = CreatorNameJoin(member, lang, "channelFanTalkWriterName") return queryFactory .select( Projections.constructor( CreatorChannelFanTalkRecord::class.java, creatorCheers.id, creatorCheers.member.id, - creatorCheers.member.nickname, + writerName.displayName, creatorCheers.member.profileImage, creatorCheers.cheers, - creatorCheers.createdAt + creatorCheers.createdAt, + writerName.name.id.isNotNull ) ) .from(creatorCheers) + .join(creatorCheers.member, member) + .leftJoin(writerName.name).on(writerName.condition) .where(fanTalkCondition(creatorId, viewerId)) .orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc()) .offset(offset) @@ -83,9 +92,11 @@ class DefaultCreatorChannelFanTalkQueryRepository( override fun findCreatorReplies( creatorId: Long, - parentFanTalkIds: List + parentFanTalkIds: List, + lang: Lang ): List { if (parentFanTalkIds.isEmpty()) return emptyList() + val writerName = CreatorNameJoin(member, lang, "channelFanTalkReplyWriterName") return queryFactory .select( @@ -94,13 +105,16 @@ class DefaultCreatorChannelFanTalkQueryRepository( creatorCheers.id, creatorCheers.parent.id, creatorCheers.member.id, - creatorCheers.member.nickname, + writerName.displayName, creatorCheers.member.profileImage, creatorCheers.cheers, - creatorCheers.createdAt + creatorCheers.createdAt, + writerName.name.id.isNotNull ) ) .from(creatorCheers) + .join(creatorCheers.member, member) + .leftJoin(writerName.name).on(writerName.condition) .where( creatorCheers.creator.id.eq(creatorId), creatorCheers.member.id.eq(creatorId), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt index a5b43316..ff80e832 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt @@ -2,7 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application 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.Lang import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -27,7 +27,6 @@ class CreatorChannelFanTalkQueryService( private val queryPortProvider: ObjectProvider, private val queryPolicy: CreatorChannelFanTalkQueryPolicy, private val messageSource: SodaMessageSource, - private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -36,17 +35,18 @@ class CreatorChannelFanTalkQueryService( viewer: Member, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelFanTalkTab { val fanTalkPage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! - val creator = queryPort.findCreator(creatorId, viewerId) + val creator = queryPort.findCreator(creatorId, viewerId, lang) ?: throw SodaException(messageKey = "member.validation.user_not_found") if (queryPort.existsBlockedBetween(viewerId, creatorId)) { val messageTemplate = messageSource - .getMessage("explorer.creator.blocked_access", langContext.lang) + .getMessage("explorer.creator.blocked_access", lang) .orEmpty() throw SodaException(message = String.format(messageTemplate, creator.nickname)) } @@ -57,10 +57,11 @@ class CreatorChannelFanTalkQueryService( creatorId = creatorId, viewerId = viewerId, offset = fanTalkPage.offset, - limit = fanTalkPage.fetchLimit + limit = fanTalkPage.fetchLimit, + lang = lang ) val fanTalkRecords = queryPolicy.limitItems(fetchedFanTalks, fanTalkPage) - val repliesByParentId = findRepliesByParentId(queryPort, creatorId, fanTalkRecords) + val repliesByParentId = findRepliesByParentId(queryPort, creatorId, fanTalkRecords, lang) return CreatorChannelFanTalkTab( fanTalkCount = queryPort.countFanTalks(creatorId, viewerId), @@ -80,11 +81,12 @@ class CreatorChannelFanTalkQueryService( private fun findRepliesByParentId( queryPort: CreatorChannelFanTalkQueryPort, creatorId: Long, - fanTalkRecords: List + fanTalkRecords: List, + lang: Lang ): Map> { val parentFanTalkIds = fanTalkRecords.map { it.fanTalkId } if (parentFanTalkIds.isEmpty()) return emptyMap() - return queryPort.findCreatorReplies(creatorId, parentFanTalkIds) + return queryPort.findCreatorReplies(creatorId, parentFanTalkIds, lang) .groupBy( keySelector = { it.parentFanTalkId }, valueTransform = { it.toDomain() } @@ -96,7 +98,7 @@ class CreatorChannelFanTalkQueryService( ) = CreatorChannelFanTalk( fanTalkId = fanTalkId, writerId = writerId, - writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerNickname = if (isLocalizedName) writerNickname else writerNickname.removeDeletedNicknamePrefix(), writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), content = content, createdAt = createdAt, @@ -106,7 +108,7 @@ class CreatorChannelFanTalkQueryService( private fun CreatorChannelFanTalkReplyRecord.toDomain() = CreatorChannelFanTalkReply( fanTalkId = fanTalkId, writerId = writerId, - writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerNickname = if (isLocalizedName) writerNickname else writerNickname.removeDeletedNicknamePrefix(), writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), content = content, createdAt = createdAt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt index 957408cb..3eddf5e1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt @@ -1,10 +1,11 @@ package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import java.time.LocalDateTime interface CreatorChannelFanTalkQueryPort { - fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? + fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelFanTalkCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean @@ -14,12 +15,14 @@ interface CreatorChannelFanTalkQueryPort { creatorId: Long, viewerId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List fun findCreatorReplies( creatorId: Long, - parentFanTalkIds: List + parentFanTalkIds: List, + lang: Lang ): List } @@ -35,7 +38,8 @@ data class CreatorChannelFanTalkRecord( val writerNickname: String, val writerProfileImagePath: String?, val content: String, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isLocalizedName: Boolean = false ) data class CreatorChannelFanTalkReplyRecord( @@ -45,5 +49,6 @@ data class CreatorChannelFanTalkReplyRecord( val writerNickname: String, val writerProfileImagePath: String?, val content: String, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isLocalizedName: Boolean = false ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index 6d465e6f..36ab9621 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -17,11 +17,13 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing @@ -36,6 +38,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelL import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelScheduleRecord import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSeriesRecord import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSnsRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository import java.time.Duration import java.time.LocalDateTime @@ -46,10 +49,12 @@ import java.time.temporal.ChronoUnit class DefaultCreatorChannelHomeQueryRepository( private val queryFactory: JPAQueryFactory ) : CreatorChannelHomeQueryRepository { - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelHomeCreatorName") val creator = queryFactory - .select(member.id, member.role, member.nickname, member.profileImage, member.introduce, member.memberKind) + .select(member.id, member.role, creatorName.displayName, member.profileImage, member.introduce, member.memberKind) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.id.eq(creatorId), member.isActive.isTrue @@ -81,7 +86,7 @@ class DefaultCreatorChannelHomeQueryRepository( creatorId = creator.get(member.id)!!, role = creator.get(member.role)!!, characterId = characterId, - nickname = creator.get(member.nickname)!!, + nickname = creator.get(creatorName.displayName)!!, profileImagePath = creator.get(member.profileImage), introduce = creator.get(member.introduce)!!, followerCount = queryFactory @@ -172,8 +177,11 @@ class DefaultCreatorChannelHomeQueryRepository( creatorId: Long, viewerId: Long?, now: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List { + val donor = QMember("channelHomeDonor") + val donorName = CreatorNameJoin(donor, lang, "channelHomeDonorName") val kstZoneId = ZoneId.of("Asia/Seoul") val utcZoneId = ZoneId.of("UTC") val nowKst = now.atZone(utcZoneId).withZoneSameInstant(kstZoneId) @@ -187,14 +195,18 @@ class DefaultCreatorChannelHomeQueryRepository( .select( Projections.constructor( CreatorChannelDonationRecord::class.java, - channelDonationMessage.member.nickname, + channelDonationMessage.member.id, + donorName.displayName, channelDonationMessage.member.profileImage, channelDonationMessage.can, channelDonationMessage.additionalMessage.coalesce(""), - channelDonationMessage.createdAt + channelDonationMessage.createdAt, + donorName.name.id.isNotNull ) ) .from(channelDonationMessage) + .join(channelDonationMessage.member, donor) + .leftJoin(donorName.name).on(donorName.condition) .where( channelDonationMessage.creator.id.eq(creatorId), channelDonationMessage.createdAt.goe(start), @@ -356,7 +368,9 @@ class DefaultCreatorChannelHomeQueryRepository( } } - override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord { + override fun findFanTalkSummary(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelFanTalkSummaryRecord { + val writer = QMember("channelHomeFanTalkWriter") + val writerName = CreatorNameJoin(writer, lang, "channelHomeFanTalkWriterName") val totalCount = queryFactory .select(creatorCheers.id.count()) .from(creatorCheers) @@ -371,19 +385,22 @@ class DefaultCreatorChannelHomeQueryRepository( CreatorChannelFanTalkRecord::class.java, creatorCheers.id, creatorCheers.member.id, - creatorCheers.member.nickname, + writerName.displayName, creatorCheers.member.profileImage, creatorCheers.cheers, creatorCheers.languageCode, - creatorCheers.createdAt + creatorCheers.createdAt, + writerName.name.id.isNotNull ) ) .from(creatorCheers) + .join(creatorCheers.member, writer) + .leftJoin(writerName.name).on(writerName.condition) .where(fanTalkSummaryCondition(creatorId, viewerId)) .orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc()) .limit(1) .fetchFirst() - ?.let { it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) } + ?.let { if (it.isLocalizedName) it else it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) } return CreatorChannelFanTalkSummaryRecord( totalCount = totalCount, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt index aacc99e8..1476ed04 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.home.application import kr.co.vividnext.sodalive.common.SodaException 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.member.Gender @@ -54,15 +55,16 @@ class CreatorChannelHomeQueryService( fun getHome( creatorId: Long, viewer: Member, - now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + lang: Lang ): CreatorChannelHome { val viewerId = viewer.id!! - val creator = queryPort.findCreator(creatorId, viewerId) + val creator = queryPort.findCreator(creatorId, viewerId, lang) ?: throw SodaException(messageKey = "member.validation.user_not_found") if (queryPort.existsBlockedBetween(viewerId, creatorId)) { val messageTemplate = messageSource - .getMessage("explorer.creator.blocked_access", langContext.lang) + .getMessage("explorer.creator.blocked_access", lang) .orEmpty() throw SodaException(message = String.format(messageTemplate, creator.nickname)) } @@ -100,13 +102,14 @@ class CreatorChannelHomeQueryService( effectiveViewerGender = effectiveViewerGender )?.toDomain(), latestAudioContent = latestAudioContent, - channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now).map { it.toDomain() }, + channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang).map { it.toDomain() }, notices = communityQueryService.findHomeCommunityPosts( creatorId = creatorId, viewerId = viewerId, isPinned = true, canViewAdultContent = canViewAdultContent, - limit = 3 + limit = 3, + lang = lang ), schedules = queryPolicy.limitSchedules( queryPort.findSchedules( @@ -135,9 +138,10 @@ class CreatorChannelHomeQueryService( viewerId = viewerId, isPinned = false, canViewAdultContent = canViewAdultContent, - limit = 3 + limit = 3, + lang = lang ), - fanTalk = queryPort.findFanTalkSummary(creatorId, viewerId).toDomain(), + fanTalk = queryPort.findFanTalkSummary(creatorId, viewerId, lang).toDomain(), introduce = creator.introduce, activity = queryPort.findActivity(creatorId, now).toDomain(), sns = queryPort.findSns(creatorId).toDomain() @@ -193,7 +197,8 @@ class CreatorChannelHomeQueryService( ) private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( - nickname = nickname.removeDeletedNicknamePrefix(), + memberId = memberId, + nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(), profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), can = can, message = message, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt index f307f829..e36d61da 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt @@ -43,6 +43,7 @@ data class CreatorChannelLive( ) data class CreatorChannelDonation( + val memberId: Long, val nickname: String, val profileImageUrl: String, val can: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt index 623f3a60..5325e485 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt @@ -1,13 +1,14 @@ package kr.co.vividnext.sodalive.v2.creator.channel.home.port.out import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import java.time.LocalDateTime interface CreatorChannelHomeQueryPort { - fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? + fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean @@ -32,7 +33,8 @@ interface CreatorChannelHomeQueryPort { creatorId: Long, viewerId: Long?, now: LocalDateTime, - limit: Int = 8 + limit: Int = 8, + lang: Lang ): List fun findSchedules( @@ -66,7 +68,7 @@ interface CreatorChannelHomeQueryPort { limit: Int = 8 ): List - fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord + fun findFanTalkSummary(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelFanTalkSummaryRecord fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord @@ -112,11 +114,13 @@ data class CreatorChannelAudioContentRecord( ) data class CreatorChannelDonationRecord( + val memberId: Long, val nickname: String, val profileImagePath: String?, val can: Int, val message: String, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isLocalizedName: Boolean = false ) data class CreatorChannelScheduleRecord( @@ -148,7 +152,8 @@ data class CreatorChannelFanTalkRecord( val profileImagePath: String?, val content: String, val languageCode: String?, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isLocalizedName: Boolean = false ) data class CreatorChannelActivityRecord( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt index 514b48bc..1df940e5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt @@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.Gender @@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -27,17 +29,19 @@ import java.time.LocalDateTime class DefaultCreatorChannelLiveQueryRepository( private val queryFactory: JPAQueryFactory ) : CreatorChannelLiveQueryRepository { - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelLiveCreatorName") return queryFactory .select( Projections.constructor( CreatorChannelCreatorRecord::class.java, member.id, member.role, - member.nickname + creatorName.displayName ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.id.eq(creatorId), member.isActive.isTrue diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt index 3301359a..11cc83f4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt @@ -1,7 +1,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.live.application import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member @@ -30,7 +30,6 @@ class CreatorChannelLiveQueryService( private val queryPolicy: CreatorChannelLiveReplayQueryPolicy, private val memberContentPreferenceService: MemberContentPreferenceService, private val messageSource: SodaMessageSource, - private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -40,17 +39,18 @@ class CreatorChannelLiveQueryService( sort: ContentSort, page: Int, size: Int, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelLiveTab { val livePage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! - val creator = queryPort.findCreator(creatorId, viewerId) + val creator = queryPort.findCreator(creatorId, viewerId, lang) ?: throw SodaException(messageKey = "member.validation.user_not_found") if (queryPort.existsBlockedBetween(viewerId, creatorId)) { val messageTemplate = messageSource - .getMessage("explorer.creator.blocked_access", langContext.lang) + .getMessage("explorer.creator.blocked_access", lang) .orEmpty() throw SodaException(message = String.format(messageTemplate, creator.nickname)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt index 86cafd42..615d3955 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt @@ -1,12 +1,13 @@ package kr.co.vividnext.sodalive.v2.creator.channel.live.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import java.time.LocalDateTime interface CreatorChannelLiveQueryPort { - fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? + fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt index ba396213..03e9cbfb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt @@ -11,11 +11,13 @@ import kr.co.vividnext.sodalive.content.order.QOrder import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -23,17 +25,19 @@ import java.time.LocalDateTime class DefaultCreatorChannelSeriesQueryRepository( private val queryFactory: JPAQueryFactory ) : CreatorChannelSeriesQueryRepository { - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelSeriesCreatorRecord? { + val creatorName = CreatorNameJoin(member, lang, "channelSeriesCreatorName") return queryFactory .select( Projections.constructor( CreatorChannelSeriesCreatorRecord::class.java, member.id, member.role, - member.nickname + creatorName.displayName ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.id.eq(creatorId), member.isActive.isTrue diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt index 8c61a6f8..38db825a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.series.application import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +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.Member @@ -37,18 +38,19 @@ class CreatorChannelSeriesQueryService( sort: String?, page: Int?, size: Int?, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(), + lang: Lang ): CreatorChannelSeriesTab { val resolvedSort = queryPolicy.resolveSort(sort) val seriesPage = queryPolicy.createPage(page, size) val queryPort = queryPortProvider.getObject() val viewerId = viewer.id!! - val creator = queryPort.findCreator(creatorId, viewerId) + val creator = queryPort.findCreator(creatorId, viewerId, lang) ?: throw SodaException(messageKey = "member.validation.user_not_found") if (queryPort.existsBlockedBetween(viewerId, creatorId)) { val messageTemplate = messageSource - .getMessage("explorer.creator.blocked_access", langContext.lang) + .getMessage("explorer.creator.blocked_access", lang) .orEmpty() throw SodaException(message = String.format(messageTemplate, creator.nickname)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt index 17afc1f1..b5584372 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt @@ -2,12 +2,13 @@ package kr.co.vividnext.sodalive.v2.creator.channel.series.port.out import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import java.time.LocalDateTime interface CreatorChannelSeriesQueryPort { - fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? + fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelSeriesCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt new file mode 100644 index 00000000..c0d2414b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService +import kr.co.vividnext.sodalive.v2.creatorname.dto.CreatorLocalizedNameRequest +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/admin/creators/{creatorId}/localized-names/ja") +@PreAuthorize("hasRole('ADMIN')") +class AdminCreatorLocalizedNameController(private val service: CreatorLocalizedNameService) { + @PutMapping + fun saveLocalizedName(@PathVariable creatorId: Long, @RequestBody request: CreatorLocalizedNameRequest) = ApiResponse.ok( + service.saveLocalizedName(creatorId, Lang.fromCode("ja"), request.displayName) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt new file mode 100644 index 00000000..ac5c7754 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.ForeignKey +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + name = "creator_localized_name", + uniqueConstraints = [UniqueConstraint(name = "uk_creator_localized_name_member_lang", columnNames = ["member_id", "lang"])] +) +class CreatorLocalizedNameEntity( + @Column(name = "member_id", nullable = false, updatable = false) + val memberId: Long, + + @Enumerated(EnumType.STRING) + @Column(name = "lang", nullable = false, length = 10, updatable = false) + val lang: Lang, + + @Column(name = "display_name", nullable = false, length = 255) + var displayName: String, + + @Column(name = "is_active", nullable = false) + var isActive: Boolean = true +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "member_id", + nullable = false, + insertable = false, + updatable = false, + foreignKey = ForeignKey(name = "fk_creator_localized_name_member") + ) + private var member: Member? = null + + fun toDomain(): CreatorLocalizedName = CreatorLocalizedName(memberId, lang, displayName, isActive, id) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoin.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoin.kt new file mode 100644 index 00000000..b9cb6b85 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoin.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.StringExpression +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember + +class CreatorNameJoin(owner: QMember, lang: Lang, alias: String) { + val name: QCreatorLocalizedNameEntity = QCreatorLocalizedNameEntity(alias) + val condition: BooleanExpression = name.memberId.eq(owner.id) + .and(name.lang.eq(lang)) + .and(name.isActive.isTrue) + .and(owner.role.eq(MemberRole.CREATOR)) + val displayName: StringExpression = name.displayName.coalesce(owner.nickname) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt new file mode 100644 index 00000000..3400c9de --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.JPAExpressions +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.QCreatorLocalizedNameEntity.creatorLocalizedNameEntity + +fun creatorNameContains(owner: QMember, keyword: String): BooleanExpression { + val alias = creatorLocalizedNameEntity + return owner.nickname.containsIgnoreCase(keyword).or( + owner.role.eq(MemberRole.CREATOR).and( + JPAExpressions.selectOne().from(alias).where( + alias.memberId.eq(owner.id), + alias.lang.eq(Lang.JA), + alias.isActive.isTrue, + alias.displayName.containsIgnoreCase(keyword) + ).exists() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt new file mode 100644 index 00000000..18fb4827 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt @@ -0,0 +1,88 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.QCreatorLocalizedNameEntity.creatorLocalizedNameEntity +import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName +import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository +import org.springframework.stereotype.Repository +import javax.persistence.EntityManager + +@Repository +class DefaultCreatorLocalizedNameRepository( + private val entityManager: EntityManager, + private val queryFactory: JPAQueryFactory +) : CreatorLocalizedNameRepository { + override fun findByMemberIdAndLang(memberId: Long, lang: Lang): CreatorLocalizedName? = queryFactory + .selectFrom(creatorLocalizedNameEntity) + .where(creatorLocalizedNameEntity.memberId.eq(memberId), creatorLocalizedNameEntity.lang.eq(lang)) + .fetchOne() + ?.toDomain() + + override fun findActiveLocalizedNames(memberIds: Collection, lang: Lang): Map { + if (memberIds.isEmpty()) return emptyMap() + return queryFactory + .select(creatorLocalizedNameEntity.memberId, creatorLocalizedNameEntity.displayName) + .from(creatorLocalizedNameEntity) + .innerJoin(member).on(member.id.eq(creatorLocalizedNameEntity.memberId)) + .where( + creatorLocalizedNameEntity.memberId.`in`(memberIds), + creatorLocalizedNameEntity.lang.eq(lang), + creatorLocalizedNameEntity.isActive.isTrue, + member.role.eq(MemberRole.CREATOR) + ) + .fetch() + .associate { row -> + requireNotNull(row[creatorLocalizedNameEntity.memberId]) to + requireNotNull(row[creatorLocalizedNameEntity.displayName]) + } + } + + override fun findAllActiveLocalizedNames(memberIds: Collection): Map> { + if (memberIds.isEmpty()) return emptyMap() + return queryFactory + .select(creatorLocalizedNameEntity.memberId, creatorLocalizedNameEntity.lang, creatorLocalizedNameEntity.displayName) + .from(creatorLocalizedNameEntity) + .innerJoin(member).on(member.id.eq(creatorLocalizedNameEntity.memberId)) + .where( + creatorLocalizedNameEntity.memberId.`in`(memberIds), + creatorLocalizedNameEntity.isActive.isTrue, + member.role.eq(MemberRole.CREATOR) + ) + .fetch() + .groupBy { row -> requireNotNull(row[creatorLocalizedNameEntity.memberId]) } + .mapValues { (_, rows) -> + rows.associate { row -> + requireNotNull(row[creatorLocalizedNameEntity.lang]).code to + requireNotNull(row[creatorLocalizedNameEntity.displayName]) + } + } + } + + override fun findCurrentNames(memberIds: Collection, lang: Lang): Map { + if (memberIds.isEmpty()) return emptyMap() + val join = CreatorNameJoin(member, lang, "currentCreatorName") + return queryFactory + .select(member.id, join.displayName) + .from(member) + .leftJoin(join.name).on(join.condition) + .where(member.id.`in`(memberIds.distinct())) + .fetch() + .associate { row -> requireNotNull(row[member.id]) to requireNotNull(row[join.displayName]) } + } + + override fun save(name: CreatorLocalizedName): CreatorLocalizedName { + val entity = if (name.id == null) { + CreatorLocalizedNameEntity(name.memberId, name.lang, name.displayName, name.isActive) + .also { entityManager.persist(it) } + } else { + entityManager.find(CreatorLocalizedNameEntity::class.java, name.id).apply { + displayName = name.displayName + isActive = name.isActive + } + } + return entity.toDomain() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameService.kt new file mode 100644 index 00000000..6450e51b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameService.kt @@ -0,0 +1,61 @@ +package kr.co.vividnext.sodalive.v2.creatorname.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang +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.v2.creatorname.domain.CreatorLocalizedName +import kr.co.vividnext.sodalive.v2.creatorname.dto.CreatorLocalizedNameResponse +import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class CreatorLocalizedNameService( + private val memberRepository: MemberRepository, + private val repository: CreatorLocalizedNameRepository +) { + fun findLocalizedNames(memberIds: Collection, lang: Lang): Map { + if (memberIds.isEmpty()) return emptyMap() + return repository.findActiveLocalizedNames(memberIds, lang) + } + + fun findCurrentNames(memberIds: Collection, lang: Lang): Map { + if (memberIds.isEmpty()) return emptyMap() + return repository.findCurrentNames(memberIds, lang) + } + + fun findAllLocalizedNames(memberIds: Collection): Map> { + if (memberIds.isEmpty()) return emptyMap() + return repository.findAllActiveLocalizedNames(memberIds) + } + + @Transactional + fun saveLocalizedName(creatorId: Long, lang: Lang, displayName: String): CreatorLocalizedNameResponse { + requireJapanese(lang) + if (displayName.codePointCount(0, displayName.length) > 255) { + throw SodaException(messageKey = "common.error.invalid_request", errorProperty = "displayName") + } + val creator = requireCreator(memberRepository.findByIdForUpdate(creatorId)) + val existing = repository.findByMemberIdAndLang(creatorId, lang) + val name = if (displayName.isBlank()) { + existing?.copy(isActive = false) + } else { + existing?.copy(displayName = displayName, isActive = true) ?: CreatorLocalizedName(creatorId, lang, displayName) + } + name?.let { repository.save(it) } + return CreatorLocalizedNameResponse(creatorId, lang.code, creator.nickname, name?.takeIf { it.isActive }?.displayName) + } + + private fun requireJapanese(lang: Lang) { + if (lang != Lang.JA) throw SodaException(messageKey = "common.error.invalid_request") + } + + private fun requireCreator(member: Member?): Member { + val creator = member ?: throw SodaException(messageKey = "admin.member.not_found") + if (creator.role != MemberRole.CREATOR) throw SodaException(messageKey = "admin.live.creator_required") + return creator + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/domain/CreatorLocalizedName.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/domain/CreatorLocalizedName.kt new file mode 100644 index 00000000..c8ab7ed0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/domain/CreatorLocalizedName.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.v2.creatorname.domain + +import kr.co.vividnext.sodalive.i18n.Lang + +data class CreatorLocalizedName( + val memberId: Long, + val lang: Lang, + val displayName: String, + val isActive: Boolean = true, + val id: Long? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameRequest.kt new file mode 100644 index 00000000..f341c7d7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.v2.creatorname.dto + +data class CreatorLocalizedNameRequest(val displayName: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameResponse.kt new file mode 100644 index 00000000..3230604e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameResponse.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.creatorname.dto + +data class CreatorLocalizedNameResponse( + val creatorId: Long, + val locale: String, + val originalNickname: String, + val displayName: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/port/out/CreatorLocalizedNameRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/port/out/CreatorLocalizedNameRepository.kt new file mode 100644 index 00000000..8f5273fb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/port/out/CreatorLocalizedNameRepository.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.v2.creatorname.port.out + +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName + +interface CreatorLocalizedNameRepository { + fun findByMemberIdAndLang(memberId: Long, lang: Lang): CreatorLocalizedName? + fun findActiveLocalizedNames(memberIds: Collection, lang: Lang): Map + fun findAllActiveLocalizedNames(memberIds: Collection): Map> + fun findCurrentNames(memberIds: Collection, lang: Lang): Map + fun save(name: CreatorLocalizedName): CreatorLocalizedName +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index 8d56c415..961060c5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.v2.gift.application import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery @@ -112,7 +114,8 @@ class GiftQueryService( private val memberRepository: MemberRepository, private val giftMailboxRepository: GiftMailboxRepository, @Value("\${cloud.aws.cloud-front.host}") - private val cloudFrontHost: String + private val cloudFrontHost: String, + private val creatorLocalizedNameService: CreatorLocalizedNameService ) { @Transactional(readOnly = true) fun getFormOptions() = GiftFormOptionsResult( @@ -134,7 +137,7 @@ class GiftQueryService( ) @Transactional(readOnly = true) - fun getGifts(memberId: Long, type: GiftListType, page: Int, size: Int): GiftListResult { + fun getGifts(memberId: Long, type: GiftListType, page: Int, size: Int, lang: Lang): GiftListResult { val normalizedPage = page.coerceAtLeast(0) val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size val pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id")) @@ -156,13 +159,14 @@ class GiftQueryService( val membersById = memberRepository.findAllById( gifts.content.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet() ).associateBy { it.id ?: throw invalidRequest() } + val names = creatorLocalizedNameService.findLocalizedNames(membersById.keys, lang) val deliveriesByGiftId = deliveryRepository.findByGiftIdIn( gifts.content.mapNotNull { it.id } ).associateBy { it.giftId } return GiftListResult( totalCount = gifts.totalElements, - items = gifts.content.map { it.toGiftListItem(memberId, membersById, deliveriesByGiftId) }, + items = gifts.content.map { it.toGiftListItem(memberId, membersById, deliveriesByGiftId, names) }, page = normalizedPage, size = normalizedSize, hasNext = gifts.hasNext() @@ -170,12 +174,13 @@ class GiftQueryService( } @Transactional(readOnly = true) - fun getGiftDetail(memberId: Long, applicationNo: String): GiftDetailResult { + fun getGiftDetail(memberId: Long, applicationNo: String, lang: Lang): GiftDetailResult { val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() val direction = gift.directionFor(memberId) val sender = memberRepository.findByIdOrNull(gift.senderMemberId) ?: throw invalidRequest() val recipient = memberRepository.findByIdOrNull(gift.recipientMemberId) ?: throw invalidRequest() + val names = creatorLocalizedNameService.findLocalizedNames(listOf(gift.senderMemberId, gift.recipientMemberId), lang) val recipientAddress = if (direction == GiftDirection.RECEIVED) delivery.toRecipientAddressResult() else null return GiftDetailResult( @@ -184,8 +189,10 @@ class GiftQueryService( status = gift.status, statusName = gift.status.displayName, giftInfo = GiftDetailInfoResult( - recipientCreatorNickname = recipient.nickname.takeIf { direction == GiftDirection.SENT }, - senderNickname = sender.nickname.takeIf { direction == GiftDirection.RECEIVED }, + recipientCreatorNickname = (names[gift.recipientMemberId] ?: recipient.nickname) + .takeIf { direction == GiftDirection.SENT }, + senderNickname = (names[gift.senderMemberId] ?: sender.nickname) + .takeIf { direction == GiftDirection.RECEIVED }, sizeName = gift.sizeCode.displayName, categoryName = gift.categoryNameSnapshot, applicationNo = gift.applicationNo.takeIf { direction == GiftDirection.SENT }, @@ -212,7 +219,8 @@ class GiftQueryService( private fun Gift.toGiftListItem( memberId: Long, membersById: Map, - deliveriesByGiftId: Map + deliveriesByGiftId: Map, + names: Map ): GiftListItemResult { val direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED val counterpartId = if (direction == GiftDirection.SENT) recipientMemberId else senderMemberId @@ -229,7 +237,7 @@ class GiftQueryService( categoryName = categoryNameSnapshot, sizeName = sizeCode.displayName, counterpartMemberId = counterpartId, - counterpartNickname = counterpart.nickname, + counterpartNickname = names[counterpartId] ?: counterpart.nickname, counterpartProfileImageUrl = counterpart.profileImageUrl(), recipientAddressRegistered = recipientAddressRegistered, recipientAddressDeadlineAt = delivery.recipientAddressDeadlineAt.takeUnless { recipientAddressRegistered }, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt index 9be531ca..1e453543 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt @@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.Gender @@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews @@ -46,12 +48,14 @@ class DefaultHomeFollowingQueryRepository( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) : HomeFollowingQueryRepository { - override fun findFollowingCreators(memberId: Long, limit: Int): List { + override fun findFollowingCreators(memberId: Long, limit: Int, lang: Lang): List { val creator = QMember("followingCreator") + val creatorName = CreatorNameJoin(creator, lang, "followingCreatorName") return queryFactory - .select(creator.id, creator.nickname, creator.profileImage) + .select(creator.id, creatorName.displayName, creator.profileImage) .from(creatorFollowing) .join(creatorFollowing.creator, creator) + .leftJoin(creatorName.name).on(creatorName.condition) .where( creatorFollowing.member.id.eq(memberId), creatorFollowing.isActive.isTrue, @@ -65,7 +69,7 @@ class DefaultHomeFollowingQueryRepository( .map { row -> HomeFollowingCreator( creatorId = row.get(creator.id)!!, - creatorNickname = row.get(creator.nickname)!!, + creatorNickname = row.get(creatorName.displayName)!!, creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage)) ) } @@ -76,13 +80,23 @@ class DefaultHomeFollowingQueryRepository( canViewAdultContent: Boolean, effectiveViewerGender: Gender, isViewerCreator: Boolean, - limit: Int + limit: Int, + lang: Lang ): List { val creator = QMember("onAirCreator") + val creatorName = CreatorNameJoin(creator, lang, "followingOnAirCreatorName") return queryFactory - .select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime) + .select( + liveRoom.id, + creator.profileImage, + creatorName.displayName, + liveRoom.title, + liveRoom.beginDateTime, + creator.id + ) .from(liveRoom) .join(liveRoom.member, creator) + .leftJoin(creatorName.name).on(creatorName.condition) .join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id)) .where( creatorFollowing.member.id.eq(memberId), @@ -104,9 +118,10 @@ class DefaultHomeFollowingQueryRepository( HomeFollowingLive( liveId = row.get(liveRoom.id)!!, creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage)), - creatorNickname = row.get(creator.nickname)!!, + creatorNickname = row.get(creatorName.displayName)!!, title = row.get(liveRoom.title)!!, - startedAtUtc = row.get(liveRoom.beginDateTime)!!.toUtcIso() + startedAtUtc = row.get(liveRoom.beginDateTime)!!.toUtcIso(), + creatorMemberId = row.get(creator.id)!! ) } } @@ -117,7 +132,8 @@ class DefaultHomeFollowingQueryRepository( effectiveViewerGender: Gender, isViewerCreator: Boolean, now: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List { val window = monthlyScheduleWindow(now) val liveSchedules = findLiveSchedules( @@ -125,9 +141,10 @@ class DefaultHomeFollowingQueryRepository( canViewAdultContent, effectiveViewerGender, isViewerCreator, - window + window, + lang ) - val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window) + val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window, lang) return (liveSchedules + audioSchedules) .sortedWith( compareBy { it.scheduledAtUtc } @@ -141,17 +158,20 @@ class DefaultHomeFollowingQueryRepository( memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List { val creator = QMember("newsCreator") + val creatorName = CreatorNameJoin(creator, lang, "followingNewsCreatorName") val newsAudioContent = QAudioContent("recentNewsAudioContent") val newsCommunity = QCreatorCommunity("recentNewsCommunity") val rows = queryFactory .select( homeFollowingNewsInbox.id, homeFollowingNewsInbox.newsType, + creator.id, creator.profileImage, - creator.nickname, + creatorName.displayName, homeFollowingNewsInbox.targetId, homeFollowingNewsInbox.visibleFromAtUtc, homeFollowingNewsInbox.rank, @@ -163,6 +183,7 @@ class DefaultHomeFollowingQueryRepository( ) .from(homeFollowingNewsInbox) .join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId)) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(newsAudioContent).on( homeFollowingNewsInbox.newsType.`in`(FollowingNewsType.AUDIO_CONTENT, FollowingNewsType.CONTENT_RANKING), newsAudioContent.id.eq(homeFollowingNewsInbox.targetId) @@ -190,11 +211,14 @@ class DefaultHomeFollowingQueryRepository( .map { it.get(homeFollowingNewsInbox.targetId)!! } val likeCounts = communityLikeCounts(communityPostIds) val commentCounts = communityCommentCounts(communityPostIds) - return rows.map { row -> row.toHomeFollowingNews(creator, newsAudioContent, newsCommunity, likeCounts, commentCounts) } + return rows.map { row -> + row.toHomeFollowingNews(creator, creatorName, newsAudioContent, newsCommunity, likeCounts, commentCounts) + } } private fun Tuple.toHomeFollowingNews( newsCreator: QMember, + creatorName: CreatorNameJoin, newsAudioContent: QAudioContent, newsCommunity: QCreatorCommunity, likeCounts: Map, @@ -203,12 +227,13 @@ class DefaultHomeFollowingQueryRepository( val type = get(homeFollowingNewsInbox.newsType)!! val targetId = get(homeFollowingNewsInbox.targetId)!! val creatorProfileImageUrl = profileImageUrl(get(newsCreator.profileImage)) - val creatorNickname = get(newsCreator.nickname)!! + val creatorNickname = get(creatorName.displayName)!! val visibleFromAtUtc = get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso() val rank = get(homeFollowingNewsInbox.rank) return HomeFollowingNews( newsId = get(homeFollowingNewsInbox.id)!!.toString(), + creatorMemberId = get(newsCreator.id)!!, type = type, visibleFromAtUtc = visibleFromAtUtc, creatorRanking = if (type == FollowingNewsType.CREATOR_RANKING && rank != null) { @@ -264,21 +289,24 @@ class DefaultHomeFollowingQueryRepository( canViewAdultContent: Boolean, effectiveViewerGender: Gender, isViewerCreator: Boolean, - window: ScheduleWindow + window: ScheduleWindow, + lang: Lang ): List { val creator = QMember("scheduleLiveCreator") + val creatorName = CreatorNameJoin(creator, lang, "followingLiveScheduleCreatorName") return queryFactory .select( liveRoom.id, creator.id, creator.profileImage, - creator.nickname, + creatorName.displayName, liveRoom.title, liveRoom.beginDateTime, liveRoom.channelName ) .from(liveRoom) .join(liveRoom.member, creator) + .leftJoin(creatorName.name).on(creatorName.condition) .join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id)) .where( creatorFollowing.member.id.eq(memberId), @@ -294,26 +322,29 @@ class DefaultHomeFollowingQueryRepository( notBlockedCreatorCondition(memberId, creator.id) ) .fetch() - .map { row -> row.toLiveSchedule(creator) } + .map { row -> row.toLiveSchedule(creator, creatorName) } } private fun findAudioSchedules( memberId: Long, canViewAdultContent: Boolean, - window: ScheduleWindow + window: ScheduleWindow, + lang: Lang ): List { val creator = QMember("scheduleAudioCreator") + val creatorName = CreatorNameJoin(creator, lang, "followingAudioScheduleCreatorName") return queryFactory .select( audioContent.id, creator.id, creator.profileImage, - creator.nickname, + creatorName.displayName, audioContent.title, audioContent.releaseDate ) .from(audioContent) .join(audioContent.member, creator) + .leftJoin(creatorName.name).on(creatorName.condition) .join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id)) .where( creatorFollowing.member.id.eq(memberId), @@ -328,17 +359,17 @@ class DefaultHomeFollowingQueryRepository( notBlockedCreatorCondition(memberId, creator.id) ) .fetch() - .map { row -> row.toAudioSchedule(creator) } + .map { row -> row.toAudioSchedule(creator, creatorName) } } - private fun Tuple.toLiveSchedule(creator: QMember): HomeFollowingSchedule { + private fun Tuple.toLiveSchedule(creator: QMember, creatorName: CreatorNameJoin): HomeFollowingSchedule { val liveId = get(liveRoom.id)!! val channelName = get(liveRoom.channelName) return HomeFollowingSchedule( scheduleId = "${CreatorActivityType.LIVE}:$liveId", creatorId = get(creator.id)!!, creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)), - creatorNickname = get(creator.nickname)!!, + creatorNickname = get(creatorName.displayName)!!, title = get(liveRoom.title)!!, type = CreatorActivityType.LIVE, targetId = liveId, @@ -347,13 +378,13 @@ class DefaultHomeFollowingQueryRepository( ) } - private fun Tuple.toAudioSchedule(creator: QMember): HomeFollowingSchedule { + private fun Tuple.toAudioSchedule(creator: QMember, creatorName: CreatorNameJoin): HomeFollowingSchedule { val contentId = get(audioContent.id)!! return HomeFollowingSchedule( scheduleId = "${CreatorActivityType.AUDIO}:$contentId", creatorId = get(creator.id)!!, creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)), - creatorNickname = get(creator.nickname)!!, + creatorNickname = get(creatorName.displayName)!!, title = get(audioContent.title)!!, type = CreatorActivityType.AUDIO, targetId = contentId, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt index d896247b..8bacb91c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt @@ -1,6 +1,6 @@ package kr.co.vividnext.sodalive.v2.home.following.application -import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -19,34 +19,34 @@ import java.time.LocalDateTime class HomeFollowingQueryService( private val queryPort: HomeFollowingQueryPort, private val memberContentPreferenceService: MemberContentPreferenceService, - private val langContext: LangContext, private val creatorCommunityTranslationService: CreatorCommunityTranslationService, private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) } ) { - fun findHomeFollowing(member: Member): HomeFollowing { + fun findHomeFollowing(member: Member, lang: Lang): HomeFollowing { val memberId = requireNotNull(member.id) val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member) val effectiveViewerGender = member.effectiveGender() val isViewerCreator = member.role == MemberRole.CREATOR val now = nowProvider() - val recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT) + val recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT, lang) val communityPostIds = recentNews.mapNotNull { news -> if (news.type == FollowingNewsType.COMMUNITY_POST) news.communityPost?.postId else null } val displayContents = if (communityPostIds.isEmpty()) { emptyMap() } else { - creatorCommunityTranslationService.findDisplayContents(communityPostIds, langContext.lang.code) + creatorCommunityTranslationService.findDisplayContents(communityPostIds, lang.code) } return HomeFollowing( - followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT), + followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT, lang), onAirLives = queryPort.findOnAirLives( memberId, canViewAdultContent, effectiveViewerGender, isViewerCreator, - ON_AIR_LIVES_LIMIT + ON_AIR_LIVES_LIMIT, + lang ), recentChats = emptyList(), monthlySchedules = queryPort.findMonthlySchedules( @@ -55,7 +55,8 @@ class HomeFollowingQueryService( effectiveViewerGender, isViewerCreator, now, - MONTHLY_SCHEDULES_LIMIT + MONTHLY_SCHEDULES_LIMIT, + lang ), recentNews = recentNews.map { news -> if (news.type != FollowingNewsType.COMMUNITY_POST) return@map news diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt index d9ecb27f..23a78156 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt @@ -22,7 +22,8 @@ data class HomeFollowingLive( val creatorProfileImageUrl: String, val creatorNickname: String, val title: String, - val startedAtUtc: String + val startedAtUtc: String, + val creatorMemberId: Long ) data class HomeFollowingSchedule( @@ -45,7 +46,8 @@ data class HomeFollowingNews( val audioContent: HomeFollowingContentNews? = null, val photoContent: HomeFollowingContentNews? = null, val contentRanking: HomeFollowingContentRankingNews? = null, - val communityPost: HomeFollowingCommunityPostNews? = null + val communityPost: HomeFollowingCommunityPostNews? = null, + val creatorMemberId: Long ) data class HomeFollowingCreatorRankingNews( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt index 2f866374..e091942a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.home.following.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive @@ -8,14 +9,15 @@ import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule import java.time.LocalDateTime interface HomeFollowingQueryPort { - fun findFollowingCreators(memberId: Long, limit: Int): List + fun findFollowingCreators(memberId: Long, limit: Int, lang: Lang): List fun findOnAirLives( memberId: Long, canViewAdultContent: Boolean, effectiveViewerGender: Gender, isViewerCreator: Boolean, - limit: Int + limit: Int, + lang: Lang ): List fun findMonthlySchedules( @@ -24,13 +26,15 @@ interface HomeFollowingQueryPort { effectiveViewerGender: Gender, isViewerCreator: Boolean, now: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List fun findRecentNews( memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt index 30c21fea..d81ee73c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt @@ -1,5 +1,11 @@ package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence +import com.querydsl.jpa.JPAExpressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshot.creatorRankingSnapshot import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord @@ -9,7 +15,8 @@ import java.time.LocalDateTime @Repository class DefaultCreatorRankingSnapshotRepository( - private val repository: CreatorRankingSnapshotRepository + private val repository: CreatorRankingSnapshotRepository, + private val queryFactory: JPAQueryFactory ) : CreatorRankingSnapshotPort { override fun findSnapshotsByAggregationPeriod( aggregationStartAtUtc: LocalDateTime, @@ -31,9 +38,28 @@ class DefaultCreatorRankingSnapshotRepository( override fun findLatestVisibleSnapshots( rankingType: CreatorRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + lang: Lang ): List { - return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() } + val snapshot = creatorRankingSnapshot + val latest = QCreatorRankingSnapshot("latestVisibleCreatorRanking") + val creatorName = CreatorNameJoin(member, lang, "rankingCreatorName") + val displayName = creatorName.displayName.coalesce(snapshot.nickname) + return queryFactory + .select(snapshot, displayName) + .from(snapshot) + .leftJoin(member).on(member.id.eq(snapshot.creatorId)) + .leftJoin(creatorName.name).on(creatorName.condition) + .where( + snapshot.rankingType.eq(rankingType), + snapshot.visibleFromAtUtc.eq( + JPAExpressions.select(latest.visibleFromAtUtc.max()).from(latest) + .where(latest.rankingType.eq(rankingType), latest.visibleFromAtUtc.loe(nowUtc)) + ) + ) + .orderBy(snapshot.rankNo.asc()) + .fetch() + .map { it.get(snapshot)!!.toRecord(it.get(displayName)!!) } } override fun findPreviousVisibleSnapshots( @@ -64,14 +90,14 @@ class DefaultCreatorRankingSnapshotRepository( repository.saveAll(newSnapshots.map { it.toEntity(rankingType, visibleFromAtUtc) }) } - private fun CreatorRankingSnapshot.toRecord(): CreatorRankingSnapshotRecord { + private fun CreatorRankingSnapshot.toRecord(displayNickname: String = nickname): CreatorRankingSnapshotRecord { return CreatorRankingSnapshotRecord( rankingType = rankingType, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, visibleFromAtUtc = visibleFromAtUtc, creatorId = creatorId, - nickname = nickname, + nickname = displayNickname, profileImageUrl = profileImageUrl, rankNo = rankNo, finalScore = finalScore, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt index 986df8a8..b6be469c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.ranking.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType @@ -24,11 +25,11 @@ class CreatorRankingQueryService( ) { private val log = LoggerFactory.getLogger(javaClass) - fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult { + fun getCreatorRankings(viewerMemberId: Long?, lang: Lang): CreatorRankingResult { val startedAt = System.currentTimeMillis() return runCatching { val nowUtc = nowUtc() - val latestSnapshots = findLatestVisibleSnapshots(nowUtc) + val latestSnapshots = findLatestVisibleSnapshots(nowUtc, lang) val latestItems = latestSnapshots.toRankedItems() if (latestItems.isEmpty()) { return@runCatching QueryLogResult(CreatorRankingResult(showRankChange = false, items = emptyList()), 0) @@ -69,15 +70,15 @@ class CreatorRankingQueryService( }.getOrThrow().result } - private fun findLatestVisibleSnapshots(nowUtc: LocalDateTime): List { - val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) + private fun findLatestVisibleSnapshots(nowUtc: LocalDateTime, lang: Lang): List { + val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc, lang) if (latestSnapshots.isNotEmpty()) return latestSnapshots runCatching { snapshotJobService.refreshLastCompletedWeekByFallback() } .onFailure { ex -> log.warn("event=creator_ranking_query_fallback_failure error={}", ex.message, ex) } - return snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) + return snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc, lang) } private data class QueryLogResult( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt index 918cc2a1..9d56ade1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.ranking.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import java.time.LocalDateTime @@ -15,7 +16,8 @@ interface CreatorRankingSnapshotPort { fun findLatestVisibleSnapshots( rankingType: CreatorRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + lang: Lang ): List fun findPreviousVisibleSnapshots( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 00d64eb1..0e49f283 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -32,6 +32,7 @@ import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord @@ -62,22 +63,26 @@ class DefaultHomeRecommendationQueryRepository( memberId: Long?, includeAdultLives: Boolean, isViewerCreator: Boolean, - effectiveViewerGender: Gender? + effectiveViewerGender: Gender?, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "homeLiveCreatorName") return queryFactory .select( Projections.constructor( HomeLiveRecommendationRecord::class.java, liveRoom.id, - member.nickname, + creatorName.displayName, member.profileImage, liveRoom.title, liveRoom.price, - liveRoom.beginDateTime + liveRoom.beginDateTime, + member.id ) ) .from(liveRoom) .join(liveRoom.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( liveRoom.isActive.isTrue, liveRoom.channelName.isNotNull, @@ -138,11 +143,12 @@ class DefaultHomeRecommendationQueryRepository( override fun findRecentlyActiveCreators( limit: Int, memberId: Long?, - includeAdultActivities: Boolean + includeAdultActivities: Boolean, + lang: Lang ): List { val sql = """ select ranked.creator_id, - ranked.creator_nickname, + coalesce(creator_name.display_name, ranked.creator_nickname), ranked.creator_profile_image, ranked.activity_type, ranked.activity_at, @@ -200,6 +206,9 @@ class DefaultHomeRecommendationQueryRepository( and m.role = 'CREATOR' ) activities ) ranked + join member display_owner on display_owner.id = ranked.creator_id + left join creator_localized_name creator_name on creator_name.member_id = display_owner.id + and creator_name.lang = :lang and creator_name.is_active = true and display_owner.role = 'CREATOR' where ranked.creator_rank = 1 and ${notBlockedCreatorSql("ranked.creator_id")} order by ranked.activity_at desc, ranked.target_sort_id desc @@ -210,6 +219,7 @@ class DefaultHomeRecommendationQueryRepository( .setParameter("liveReplayTheme", LIVE_REPLAY_THEME) .setParameter("memberId", memberId) .setParameter("includeAdultActivities", includeAdultActivities) + .setParameter("lang", lang.name) .setParameter("limit", limit) @Suppress("UNCHECKED_CAST") @@ -232,7 +242,8 @@ class DefaultHomeRecommendationQueryRepository( offset: Long, limit: Int, memberId: Long?, - includeAdultContents: Boolean + includeAdultContents: Boolean, + lang: Lang ): List { val sql = """ with creator_debut as ( @@ -328,9 +339,11 @@ class DefaultHomeRecommendationQueryRepository( group by communication.creator_id ) select m.id as creator_id, - m.nickname as creator_nickname, + coalesce(creator_name.display_name, m.nickname) as creator_nickname, m.profile_image as creator_profile_image from member m + left join creator_localized_name creator_name on creator_name.member_id = m.id + and creator_name.lang = :lang and creator_name.is_active = true and m.role = 'CREATOR' join creator_debut cd on cd.creator_id = m.id left join follow_stats fs on fs.creator_id = m.id left join content_stats cs on cs.creator_id = m.id @@ -354,6 +367,7 @@ class DefaultHomeRecommendationQueryRepository( val query = entityManager.createNativeQuery(sql) .setRecommendationQueryParameters(now, limit) + .setParameter("lang", lang.name) .setParameter("offset", offset) .setParameter("memberId", memberId) .setParameter("includeAdultContents", includeAdultContents) @@ -437,7 +451,7 @@ class DefaultHomeRecommendationQueryRepository( ) select ec.content_id as content_id, m.id as creator_id, - m.nickname as creator_nickname, + coalesce(creator_name.display_name, m.nickname) as creator_nickname, m.profile_image as creator_profile_image, ec.title as title, ec.price as price, @@ -447,6 +461,8 @@ class DefaultHomeRecommendationQueryRepository( ec.is_original_series as is_original_series from eligible_contents ec join member m on m.id = ec.creator_id + left join creator_localized_name creator_name on creator_name.member_id = m.id + and creator_name.lang = :lang and creator_name.is_active = true and m.role = 'CREATOR' join creator_debut cd on cd.creator_id = ec.creator_id where ec.active_rank = 1 and m.is_active = true @@ -468,6 +484,7 @@ class DefaultHomeRecommendationQueryRepository( val query = entityManager.createNativeQuery(sql) .setParameter("now", now) + .setParameter("lang", Lang.fromCode(locale).name) .setParameter("limit", limit) .setParameter("offset", offset) .setParameter("memberId", memberId) @@ -832,20 +849,23 @@ class DefaultHomeRecommendationQueryRepository( override fun findCheerCreatorRecommendationDetails( creatorIds: List, - memberId: Long? + memberId: Long?, + lang: Lang ): List { if (creatorIds.isEmpty()) return emptyList() + val creatorName = CreatorNameJoin(member, lang, "homeCheerCreatorName") return queryFactory .select( Projections.constructor( HomeCheerCreatorRecommendationRecord::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.isActive.isTrue, member.id.`in`(creatorIds), @@ -859,9 +879,11 @@ class DefaultHomeRecommendationQueryRepository( override fun findPopularCommunityRecommendationDetails( communityIds: List, memberId: Long?, - includeAdultCommunities: Boolean + includeAdultCommunities: Boolean, + lang: Lang ): List { if (communityIds.isEmpty()) return emptyList() + val creatorName = CreatorNameJoin(member, lang, "homeCommunityCreatorName") return queryFactory .select( @@ -869,7 +891,7 @@ class DefaultHomeRecommendationQueryRepository( HomePopularCommunityRecommendationRecord::class.java, creatorCommunity.id, member.id, - member.nickname, + creatorName.displayName, member.profileImage, creatorCommunity.imagePath, creatorCommunity.audioPath, @@ -884,6 +906,7 @@ class DefaultHomeRecommendationQueryRepository( ) .from(creatorCommunity) .join(creatorCommunity.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(creatorCommunityLike).on( creatorCommunityLike.creatorCommunity.id.eq(creatorCommunity.id), creatorCommunityLike.isActive.isTrue @@ -905,6 +928,7 @@ class DefaultHomeRecommendationQueryRepository( creatorCommunity.id, member.id, member.nickname, + creatorName.name.displayName, member.profileImage, creatorCommunity.imagePath, creatorCommunity.audioPath, @@ -919,7 +943,8 @@ class DefaultHomeRecommendationQueryRepository( memberId: Long?, includeAdultGenres: Boolean, genreLimit: Int, - creatorLimit: Int + creatorLimit: Int, + lang: Lang ): List { val groups = mutableListOf() val selectedGenreIds = mutableSetOf() @@ -931,7 +956,7 @@ class DefaultHomeRecommendationQueryRepository( ) viewedTargets.forEach { target -> - groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit)) + groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit, lang)) selectedGenreIds.add(target.id) } @@ -941,7 +966,8 @@ class DefaultHomeRecommendationQueryRepository( memberId = memberId, includeAdultGenres = includeAdultGenres, genreLimit = genreLimit, - creatorLimit = creatorLimit + creatorLimit = creatorLimit, + lang = lang ) return groups @@ -993,7 +1019,8 @@ class DefaultHomeRecommendationQueryRepository( memberId: Long?, includeAdultGenres: Boolean, genreLimit: Int, - creatorLimit: Int + creatorLimit: Int, + lang: Lang ) { val fullTargets = findFallbackGenreRecommendationTargets( memberId = memberId, @@ -1004,7 +1031,7 @@ class DefaultHomeRecommendationQueryRepository( ) fullTargets.forEach { target -> - groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit)) + groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit, lang)) selectedGenreIds.add(target.id) } val partialTargets = findFallbackGenreRecommendationTargets( @@ -1016,7 +1043,7 @@ class DefaultHomeRecommendationQueryRepository( ) partialTargets.forEach { target -> - groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit)) + groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit, lang)) selectedGenreIds.add(target.id) } } @@ -1099,7 +1126,8 @@ class DefaultHomeRecommendationQueryRepository( target: GenreRecommendationTarget, memberId: Long?, includeAdultGenres: Boolean, - creatorLimit: Int + creatorLimit: Int, + lang: Lang ): HomeGenreCreatorRecommendationGroup { return HomeGenreCreatorRecommendationGroup( genreId = target.id, @@ -1109,7 +1137,8 @@ class DefaultHomeRecommendationQueryRepository( genreId = target.id, memberId = memberId, includeAdultGenres = includeAdultGenres, - creatorLimit = creatorLimit + creatorLimit = creatorLimit, + lang = lang ) ) } @@ -1152,11 +1181,12 @@ class DefaultHomeRecommendationQueryRepository( genreId: Long, memberId: Long?, includeAdultGenres: Boolean, - creatorLimit: Int + creatorLimit: Int, + lang: Lang ): List { val sql = """ select candidates.creator_id, - candidates.creator_nickname, + coalesce(creator_name.display_name, candidates.creator_nickname), candidates.creator_profile_image from ( select m.id as creator_id, @@ -1190,12 +1220,16 @@ class DefaultHomeRecommendationQueryRepository( ) group by m.id, m.nickname, m.profile_image ) candidates + join member display_owner on display_owner.id = candidates.creator_id + left join creator_localized_name creator_name on creator_name.member_id = display_owner.id + and creator_name.lang = :lang and creator_name.is_active = true and display_owner.role = 'CREATOR' order by rand() asc limit :creatorLimit """.trimIndent() val query = entityManager.createNativeQuery(sql) .setParameter("genreId", genreId) + .setParameter("lang", lang.name) .setParameter("memberId", memberId) .setParameter("includeAdultGenres", includeAdultGenres) .setParameter("creatorLimit", creatorLimit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index 3e83f618..ed2837be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService @@ -33,7 +32,6 @@ class HomeRecommendationQueryService( private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null, private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null, private val clock: Clock = Clock.systemUTC(), - private val langContext: LangContext, private val creatorCommunityTranslationService: CreatorCommunityTranslationService ) { private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() @@ -44,7 +42,8 @@ class HomeRecommendationQueryService( memberId: Long? = null, includeAdultLives: Boolean = false, isViewerCreator: Boolean = false, - effectiveViewerGender: Gender? = null + effectiveViewerGender: Gender? = null, + lang: Lang ): List { return queryPort.findLiveRecommendations( offset, @@ -52,7 +51,8 @@ class HomeRecommendationQueryService( memberId, includeAdultLives, isViewerCreator, - effectiveViewerGender + effectiveViewerGender, + lang ) } @@ -68,9 +68,10 @@ class HomeRecommendationQueryService( fun findRecentlyActiveCreators( limit: Int = DEFAULT_ACTIVE_CREATOR_LIMIT, memberId: Long? = null, - includeAdultActivities: Boolean = false + includeAdultActivities: Boolean = false, + lang: Lang ): List { - return queryPort.findRecentlyActiveCreators(limit, memberId, includeAdultActivities) + return queryPort.findRecentlyActiveCreators(limit, memberId, includeAdultActivities, lang) } fun findRecentDebutCreators( @@ -78,9 +79,10 @@ class HomeRecommendationQueryService( offset: Long = 0, limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT, memberId: Long? = null, - includeAdultContents: Boolean = false + includeAdultContents: Boolean = false, + lang: Lang ): List { - return queryPort.findRecentDebutCreators(now, offset, limit, memberId, includeAdultContents) + return queryPort.findRecentDebutCreators(now, offset, limit, memberId, includeAdultContents, lang) } fun findFirstAudioContents( @@ -88,9 +90,10 @@ class HomeRecommendationQueryService( offset: Long = 0, limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT, memberId: Long? = null, - includeAdultContents: Boolean = false + includeAdultContents: Boolean = false, + lang: Lang ): List { - return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents, langContext.lang.code) + return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents, lang.code) } fun findAiCharacterRecommendations( @@ -128,10 +131,11 @@ class HomeRecommendationQueryService( fun findCheerCreatorRecommendations( limit: Int = DEFAULT_CHEER_CREATOR_LIMIT, - memberId: Long? = null + memberId: Long? = null, + lang: Lang ): List { val snapshots = findCheerCreatorSnapshotsWithFallback() - val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId) + val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId, lang) .associateBy { it.creatorId } return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit) @@ -155,13 +159,15 @@ class HomeRecommendationQueryService( fun findPopularCommunityRecommendations( limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT, memberId: Long? = null, - includeAdultCommunities: Boolean = false + includeAdultCommunities: Boolean = false, + lang: Lang ): List { val snapshots = findPopularCommunitySnapshotsWithFallback() val detailsById = queryPort.findPopularCommunityRecommendationDetails( snapshots.map { it.targetId }, memberId, - includeAdultCommunities + includeAdultCommunities, + lang ) .associateBy { it.communityId } val selectedCreatorIds = mutableSetOf() @@ -173,7 +179,7 @@ class HomeRecommendationQueryService( val displayContents = creatorCommunityTranslationService.findDisplayContents( recommendations.map { it.communityId }, - langContext.lang.code + lang.code ) return recommendations.map { recommendation -> displayContents[recommendation.communityId] @@ -201,13 +207,14 @@ class HomeRecommendationQueryService( memberId: Long?, includeAdultGenres: Boolean, genreLimit: Int = DEFAULT_GENRE_CREATOR_GENRE_LIMIT, - creatorLimit: Int = DEFAULT_GENRE_CREATOR_CREATOR_LIMIT + creatorLimit: Int = DEFAULT_GENRE_CREATOR_CREATOR_LIMIT, + lang: Lang ): List { val selectedCreatorIds = mutableSetOf() val partialFallbackGroups = mutableListOf() val selectedGroups = mutableListOf() - queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, creatorLimit) + queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, creatorLimit, lang) .forEach { group -> if (selectedGroups.size >= genreLimit) return@forEach diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index a27605bc..126a7289 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -12,7 +12,8 @@ interface HomeRecommendationQueryPort { memberId: Long? = null, includeAdultLives: Boolean = false, isViewerCreator: Boolean = false, - effectiveViewerGender: Gender? = null + effectiveViewerGender: Gender? = null, + lang: Lang ): List fun findHomeBanners( @@ -25,7 +26,8 @@ interface HomeRecommendationQueryPort { fun findRecentlyActiveCreators( limit: Int, memberId: Long? = null, - includeAdultActivities: Boolean = false + includeAdultActivities: Boolean = false, + lang: Lang ): List fun findRecentDebutCreators( @@ -33,7 +35,8 @@ interface HomeRecommendationQueryPort { offset: Long = 0, limit: Int, memberId: Long? = null, - includeAdultContents: Boolean = false + includeAdultContents: Boolean = false, + lang: Lang ): List fun findFirstAudioContents( @@ -72,20 +75,23 @@ interface HomeRecommendationQueryPort { fun findCheerCreatorRecommendationDetails( creatorIds: List, - memberId: Long? = null + memberId: Long? = null, + lang: Lang ): List fun findPopularCommunityRecommendationDetails( communityIds: List, memberId: Long? = null, - includeAdultCommunities: Boolean + includeAdultCommunities: Boolean, + lang: Lang ): List fun findGenreCreatorRecommendations( memberId: Long?, includeAdultGenres: Boolean, genreLimit: Int, - creatorLimit: Int + creatorLimit: Int, + lang: Lang ): List } @@ -95,7 +101,8 @@ data class HomeLiveRecommendationRecord( val creatorProfileImage: String?, val title: String, val price: Int, - val beginDateTime: LocalDateTime + val beginDateTime: LocalDateTime, + val creatorMemberId: Long ) data class HomeBannerRecommendationRecord( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatMessageRow.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatMessageRow.kt new file mode 100644 index 00000000..0bed6fe7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatMessageRow.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.dto + +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType +import java.time.LocalDateTime + +data class UserCreatorChatMessageRow( + val messageId: Long, + val messageType: UserCreatorChatMessageType, + val createdAt: LocalDateTime?, + val textMessage: String?, + val voiceMessage: String?, + val senderId: Long, + val senderNickname: String, + val senderProfileImage: String? +) + +data class UserCreatorChatOpponentProfile(val nickname: String, val profileImage: String?) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt index bb6ad062..b80744a8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt @@ -1,23 +1,43 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.repository +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageRow import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository interface UserCreatorChatMessageRepository : JpaRepository { - fun findByChatRoomAndIsActiveTrueOrderByIdDesc( - chatRoom: UserCreatorChatRoom, + @Query( + """ + select new kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageRow( + m.id, m.messageType, m.createdAt, m.textMessage, m.voiceMessage, + sender.id, coalesce(senderName.displayName, sender.nickname), sender.profileImage + ) + from UserCreatorChatMessage m + join m.participant participant + join participant.member sender + left join CreatorLocalizedNameEntity senderName + on senderName.memberId = sender.id + and senderName.lang = :lang + and senderName.isActive = true + and sender.role = kr.co.vividnext.sodalive.member.MemberRole.CREATOR + where m.chatRoom = :chatRoom + and m.isActive = true + and (:cursor is null or m.id < :cursor) + order by m.id desc + """ + ) + fun findMessageHistory( + @Param("chatRoom") chatRoom: UserCreatorChatRoom, + @Param("cursor") cursor: Long?, + @Param("lang") lang: Lang, pageable: Pageable - ): List - - fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( - chatRoom: UserCreatorChatRoom, - id: Long, - pageable: Pageable - ): List + ): List fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: UserCreatorChatRoom, id: Long): Boolean } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt index 329faeec..884fdd8f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.repository +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatOpponentProfile import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -33,4 +35,27 @@ interface UserCreatorChatParticipantRepository : JpaRepository :memberId + """ + ) + fun findActiveOpponentProfile( + @Param("roomId") roomId: Long, + @Param("memberId") memberId: Long, + @Param("lang") lang: Lang + ): UserCreatorChatOpponentProfile? } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt index 390db13a..3a077b70 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.repository +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom import org.springframework.data.domain.Pageable @@ -41,7 +42,7 @@ interface UserCreatorChatRoomRepository : JpaRepository } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt index 86e2be85..34962c63 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt @@ -8,11 +8,14 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.utils.generateFileName +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant @@ -21,6 +24,7 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoom import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorChatMessageResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorVoiceMessageRequest import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageRow import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository @@ -52,6 +56,8 @@ class UserCreatorChatService( private val applicationEventPublisher: ApplicationEventPublisher, private val objectMapper: ObjectMapper, private val s3Uploader: S3Uploader, + private val localizedNameService: CreatorLocalizedNameService, + private val langContext: LangContext, @Value("\${cloud.aws.s3.bucket}") private val bucket: String, @@ -80,7 +86,7 @@ class UserCreatorChatService( fun openRoom(member: Member, roomId: Long, limit: Int = 20): UserCreatorChatRoomOpenResponse { val room = findRoom(roomId) requireParticipant(roomId, member.id!!) - val opponent = participantRepository.findActiveOpponent(roomId, member.id!!)?.member + val opponent = participantRepository.findActiveOpponentProfile(roomId, member.id!!, langContext.lang) ?: throw SodaException(messageKey = "chat.room.invalid_access") val opponentProfilePath = opponent.profileImage ?: "profile/default-profile.png" val page = getMessages(member, roomId, cursor = null, limit = limit) @@ -98,12 +104,8 @@ class UserCreatorChatService( val room = findRoom(roomId) requireParticipant(roomId, member.id!!) val pageable = PageRequest.of(0, limit.coerceIn(1, 100)) - val fetched = if (cursor != null) { - messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable) - } else { - messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable) - } - val nextCursor = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id + val fetched = messageRepository.findMessageHistory(room, cursor, langContext.lang, pageable) + val nextCursor = fetched.minOfOrNull { it.messageId } val hasMore = nextCursor?.let { messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, it) } ?: false return UserCreatorChatMessagesPageResponse( messages = fetched.sortedBy { it.createdAt }.map { toMessageItemDto(it, member) }, @@ -113,19 +115,25 @@ class UserCreatorChatService( } @Transactional - fun sendTextMessageByWebSocket(memberId: Long, roomId: Long, textMessage: String): UserCreatorChatMessageItemDto { + fun sendTextMessageByWebSocket( + memberId: Long, + roomId: Long, + textMessage: String, + lang: Lang + ): UserCreatorChatMessageItemDto { if (textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request") val senderParticipant = validateParticipant(roomId, memberId) val sender = senderParticipant.member val context = resolveSendContext(sender, roomId) val message = saveTextMessage(context, textMessage) val senderMessage = toMessageItemDto(message, sender) + val names = localizedNameService.findLocalizedNames(listOf(sender.id!!), lang) val opponent = context.opponentParticipant.member val deliveredRealtime = deliverRealtime(message, opponent) if (!deliveredRealtime) { publishMessagePush(message, sender, opponent) } - return senderMessage + return senderMessage.copy(senderNickname = names[sender.id] ?: senderMessage.senderNickname) } @Transactional @@ -185,7 +193,9 @@ class UserCreatorChatService( opponentParticipant: UserCreatorChatParticipant ): SendUserCreatorChatMessageResponse { val opponent = opponentParticipant.member - val item = toMessageItemDto(message, member) + val neutralItem = toMessageItemDto(message, member) + val names = localizedNameService.findLocalizedNames(listOf(neutralItem.senderId), langContext.lang) + val item = neutralItem.copy(senderNickname = names[neutralItem.senderId] ?: neutralItem.senderNickname) val deliveredRealtime = deliverRealtime(message, opponent) if (deliveredRealtime) { return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false) @@ -239,6 +249,7 @@ class UserCreatorChatService( messageKey = messageKey, senderMemberId = sender.id, args = listOf(sender.nickname), + nameArgumentMemberIds = mapOf(0 to sender.id!!), recipients = listOf(opponent.id!!), deepLinkValue = FcmDeepLinkValue.CHAT, deepLinkId = message.chatRoom.id @@ -298,6 +309,21 @@ class UserCreatorChatService( ) } + private fun toMessageItemDto(message: UserCreatorChatMessageRow, member: Member): UserCreatorChatMessageItemDto { + val profilePath = message.senderProfileImage ?: "profile/default-profile.png" + return UserCreatorChatMessageItemDto( + messageId = message.messageId, + messageType = message.messageType.name, + mine = message.senderId == member.id, + createdAt = message.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L, + textMessage = message.textMessage, + voiceMessageUrl = message.voiceMessage?.let { "$cloudFrontHost/$it" }, + senderId = message.senderId, + senderNickname = message.senderNickname, + senderProfileImageUrl = "$cloudFrontHost/$profilePath" + ) + } + private data class SendContext( val room: UserCreatorChatRoom, val senderParticipant: UserCreatorChatParticipant, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt index a0b8a744..565b3762 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.data.redis.connection.Message import org.springframework.data.redis.connection.MessageListener import org.springframework.data.redis.core.StringRedisTemplate @@ -16,7 +19,8 @@ class UserCreatorChatRoomMessageBroker( private val stringRedisTemplate: StringRedisTemplate, private val sessionRegistry: UserCreatorChatWebSocketSessionRegistry, private val objectMapper: ObjectMapper, - listenerContainer: RedisMessageListenerContainer + listenerContainer: RedisMessageListenerContainer, + private val localizedNameService: CreatorLocalizedNameService ) : MessageListener { init { listenerContainer.addMessageListener(this, ChannelTopic(ROOM_CHANNEL)) @@ -36,9 +40,25 @@ class UserCreatorChatRoomMessageBroker( String(message.body, StandardCharsets.UTF_8), UserCreatorChatRoomPublishedMessage::class.java ) - sessionRegistry.findSessions(published.roomId, published.memberId) + val envelope = objectMapper.readTree(published.payload) + val senderId = envelope.path("payload").path("senderId").takeIf { it.isIntegralNumber }?.asLong() + val sessions = sessionRegistry.findSessions(published.roomId, published.memberId) .filter { session -> session.isOpen } - .forEach { session -> sendMessage(session, published.payload) } + sessions.groupBy { it.attributes[UserCreatorChatWebSocketAuthInterceptor.LANG_ATTRIBUTE] as? Lang ?: Lang.KO } + .forEach { (lang, languageSessions) -> + val names = localizedNameService.findLocalizedNames(listOfNotNull(senderId), lang) + val nickname = senderId?.let { names[it] } + languageSessions.forEach { session -> + val payload = if (nickname != null) { + val localized = envelope.deepCopy() + (localized["payload"] as ObjectNode).put("senderNickname", nickname) + objectMapper.writeValueAsString(localized) + } else { + published.payload + } + sendMessage(session, payload) + } + } } private fun sendMessage(session: WebSocketSession, payload: String) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt index 8a9e479c..99c02892 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.jwt.JwtFilter import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.MemberAdapter +import org.springframework.http.HttpHeaders import org.springframework.http.server.ServerHttpRequest import org.springframework.http.server.ServerHttpResponse import org.springframework.stereotype.Component @@ -35,6 +37,7 @@ class UserCreatorChatWebSocketAuthInterceptor( attributes[MEMBER_ID_ATTRIBUTE] = memberId attributes[AUTHENTICATION_ATTRIBUTE] = authentication + attributes[LANG_ATTRIBUTE] = Lang.fromAcceptLanguage(request.headers.getFirst(HttpHeaders.ACCEPT_LANGUAGE)) return true } @@ -58,6 +61,7 @@ class UserCreatorChatWebSocketAuthInterceptor( companion object { const val MEMBER_ID_ATTRIBUTE = "memberId" const val AUTHENTICATION_ATTRIBUTE = "authentication" + const val LANG_ATTRIBUTE = "lang" private const val BEARER_PREFIX = "Bearer " } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt index 4ee1edb0..2b3beec2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService import org.springframework.stereotype.Component import org.springframework.web.socket.CloseStatus @@ -60,7 +61,8 @@ class UserCreatorChatWebSocketHandler( val message = service.sendTextMessageByWebSocket( memberId = memberId(session), roomId = request.roomId, - textMessage = textMessage + textMessage = textMessage, + lang = session.attributes[UserCreatorChatWebSocketAuthInterceptor.LANG_ATTRIBUTE] as? Lang ?: Lang.KO ) sendEnvelope(session, UserCreatorChatWebSocketMessageType.SEND_ACK, request.requestId, request.roomId, message) } catch (e: SodaException) { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt index 5b5b41cc..656c7f52 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt @@ -1,6 +1,10 @@ package kr.co.vividnext.sodalive.admin.member +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.SignOut import kr.co.vividnext.sodalive.member.SignOutRepository @@ -10,24 +14,140 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpHeaders +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.HttpRequestMethodNotSupportedException @SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"]) +@AutoConfigureMockMvc @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) class AdminMemberServiceTest @Autowired constructor( private val service: AdminMemberService, private val adminMemberRepository: AdminMemberRepository, - private val signOutRepository: SignOutRepository + private val signOutRepository: SignOutRepository, + private val jdbcTemplate: JdbcTemplate, + private val mockMvc: MockMvc, + private val objectMapper: ObjectMapper ) { @AfterEach fun tearDown() { signOutRepository.deleteAll() + jdbcTemplate.update("delete from creator_localized_name") adminMemberRepository.deleteAll() } + @ParameterizedTest + @ValueSource(strings = ["/list", "/search", "/creator/list", "/creator/search"]) + @DisplayName("관리자 목록·검색 4개 API는 페이지와 원본을 유지하며 헤더와 무관하게 모든 활성 언어 이름을 제공한다") + fun shouldEmbedAllLanguagesWithoutChangingMemberPagesOrOriginalNames(path: String) { + val first = saveListMember("first") + val second = saveListMember("second", kind = MemberKind.AI_CHARACTER, isActive = false) + val missing = saveListMember("missing") + val cleared = saveListMember("cleared") + val regularUser = saveListMember("user", MemberRole.USER) + val aiUser = saveListMember("ai-user", MemberRole.USER, MemberKind.AI_CHARACTER) + saveAlias(first, Lang.JA, " 日本 Name 😀 ") + saveAlias(first, Lang.EN, "English") + saveAlias(first, Lang.KO, "해제됨", isActive = false) + saveAlias(second, Lang.JA, "AI 日本") + saveAlias(second, Lang.KO, "AI 한국어") + saveAlias(cleared, Lang.JA, "inactive", isActive = false) + saveAlias(regularUser, Lang.JA, "must not expose USER") + saveAlias(aiUser, Lang.EN, "must not expose AI USER") + val members = listOf(first, second, missing, cleared, regularUser, aiUser) + val expectedIds = if (path.startsWith("/creator/")) { + listOf(cleared.id, missing.id, second.id, first.id) + } else { + listOf(aiUser.id, regularUser.id, cleared.id, missing.id, second.id, first.id) + } + val expectedNames = mapOf( + first.id to mapOf("ja" to " 日本 Name 😀 ", "en" to "English"), + second.id to mapOf("ja" to "AI 日本", "ko" to "AI 한국어") + ) + listOf(null, "ko", "ja", "en").forEach { language -> + for (page in 0..expectedIds.size / 2) { + val request = get("/admin/member$path") + .with(user("admin").roles("ADMIN")) + .param("page", page.toString()) + .param("size", "2") + .param("search_word", "cor014-original") + language?.let { request.header(HttpHeaders.ACCEPT_LANGUAGE, it) } + val result = mockMvc.perform(request) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(expectedIds.size)) + .andReturn() + val items = objectMapper.readTree(result.response.contentAsByteArray).path("data").path("items") + assertEquals(expectedIds.drop(page * 2).take(2), items.map { it.path("id").asLong() }) + items.forEach { item -> + val member = members.single { it.id == item.path("id").asLong() } + assertEquals(member.nickname, item.path("nickname").asText()) + assertEquals(member.email, item.path("email").asText()) + assertEquals("https://cdn.test/profile/default-profile.png", item.path("profileUrl").asText()) + assertEquals(member.isActive, item.path("isActive").asBoolean()) + assertEquals( + objectMapper.valueToTree(expectedNames[member.id] ?: emptyMap()), + item.path("localizedNames") + ) + } + } + } + } + + @Test + @DisplayName("이름 전용 GET은 제거되어 기존 공통 예외 응답을 반환하고 이름 데이터를 노출하지 않는다") + fun shouldRejectDedicatedLocalizedNameGet() { + val result = mockMvc.perform( + get("/api/v2/admin/creators/1/localized-names/ja") + .with(user("admin").roles("ADMIN")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andReturn() + assertTrue(result.resolvedException is HttpRequestMethodNotSupportedException) + } + + private fun saveListMember( + suffix: String, + role: MemberRole = MemberRole.CREATOR, + kind: MemberKind = MemberKind.HUMAN, + isActive: Boolean = true + ): Member = adminMemberRepository.saveAndFlush( + Member( + email = "cor014-$suffix@test.com", + password = "password", + nickname = "cor014-original-$suffix", + role = role, + memberKind = kind, + isActive = isActive + ) + ) + + private fun saveAlias(member: Member, lang: Lang, displayName: String, isActive: Boolean = true) { + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, ?, current_timestamp, current_timestamp)", + requireNotNull(member.id), + lang.name, + displayName, + isActive + ) + } + @Test @DisplayName("OSIV off 환경에서 탈퇴 이력이 있는 회원 리스트를 조회해도 lazy 초기화 예외가 발생하지 않는다") fun shouldGetMemberListWithSignOutReasonsWhenOpenInViewIsDisabled() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt index 245e857f..8e21b222 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt @@ -18,6 +18,7 @@ import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService import kr.co.vividnext.sodalive.rank.ContentRankingSortType import kr.co.vividnext.sodalive.rank.RankingRepository import kr.co.vividnext.sodalive.rank.RankingService +import kr.co.vividnext.sodalive.support.originalLocalizedNameService import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -68,6 +69,7 @@ class HomeServiceTest { rankingRepository = rankingRepository, explorerQueryRepository = explorerQueryRepository, langContext = LangContext().apply { setLang(Lang.JA) }, + localizedNameService = originalLocalizedNameService(), memberContentPreferenceService = Mockito.mock( kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService::class.java ), @@ -99,7 +101,7 @@ class HomeServiceTest { Mockito.doReturn(emptyList()).`when`(liveRoomService) .getRoomList(null, LiveRoomStatus.NOW, Pageable.ofSize(10), null, timezone) - Mockito.`when`(rankingRepository.getCreatorRankings(null)).thenReturn(emptyList()) + Mockito.`when`(rankingRepository.getCreatorRankings(null, Lang.JA)).thenReturn(emptyList()) Mockito.`when`( contentThemeService.getActiveThemeOfContent( false, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt index 53f2b4a9..c689b2d1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -22,7 +23,7 @@ class CanServiceTest { fun setUp() { repository = mock(CanRepository::class.java) countryContext = mock(CountryContext::class.java) - service = CanService(repository, countryContext) + service = CanService(repository, countryContext, LangContext()) } @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..8160bebc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt @@ -0,0 +1,328 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationQueryRepository +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.content.translation.ContentTranslation +import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.explorer.CreatorRanking +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.rank.RankingRepository +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +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.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:legacy-content-owner-name;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig::class, RankingRepository::class, AudioContentCurationQueryRepository::class) +class AudioContentRepositoryLocalizedNameTest @Autowired constructor( + private val repository: AudioContentRepository, + private val seriesRepository: ContentSeriesRepository, + private val rankingRepository: RankingRepository, + private val curationRepository: AudioContentCurationQueryRepository, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @Test + @DisplayName("JA 메인 콘텐츠 조회는 콘텐츠 ID가 아닌 실제 소유자 ID로 크리에이터 이름만 대체한다") + fun shouldLocalizeCurrentOwnerNameByCreatorIdWithoutChangingContentTitleOrUserName() { + val viewer = saveMember("owner-query-viewer", MemberRole.USER) + val creator = saveMember("owner-original", MemberRole.CREATOR) + val user = saveMember("user-original", MemberRole.USER) + val creatorContent = saveContent(creator, "Original audio title").takeIf { it.id != creator.id } + ?: saveContent(creator, "Original audio title") + val userContent = saveContent(user, "User audio title") + saveAlias(creator, "Japanese owner") + saveAlias(user, "Must not replace USER") + entityManager.clear() + assertNotEquals(creator.id, creatorContent.id) + + val items = findLatest(viewer.id!!, "ja").associateBy { it.contentId } + + assertAll( + { assertEquals("Japanese owner", items[creatorContent.id]!!.creatorNickname) }, + { assertEquals(creator.id, items[creatorContent.id]!!.creatorId) }, + { assertEquals("Original audio title", items[creatorContent.id]!!.title) }, + { assertEquals("user-original", items[userContent.id]!!.creatorNickname) }, + { assertEquals("User audio title", items[userContent.id]!!.title) }, + { + assertEquals( + "owner-original", + findLatest(viewer.id!!, "ko").first { it.contentId == creatorContent.id }.creatorNickname + ) + }, + { + assertEquals( + "owner-original", + findLatest(viewer.id!!, "en").first { it.contentId == creatorContent.id }.creatorNickname + ) + } + ) + } + + @Test + @DisplayName("메인 콘텐츠 이름 대체는 공개 필터를 유지하고 해제 후 변경된 현재 원본으로 돌아간다") + fun shouldPreserveVisibilityFiltersAndFallbackToRenamedOriginalAfterSoftClear() { + val viewer = saveMember("visibility-query-viewer", MemberRole.USER) + val creator = saveMember("before-rename", MemberRole.CREATOR) + val visible = saveContent(creator, "visible title") + saveContent(creator, "future title").releaseDate = LocalDateTime.now().plusDays(1) + saveContent(creator, "adult title").isAdult = true + saveContent(creator, "inactive title").isActive = false + saveContent(creator, "unfinished title").duration = null + val blockedCreator = saveMember("blocked-owner", MemberRole.CREATOR) + saveContent(blockedCreator, "blocked title") + entityManager.persist( + BlockMember(isActive = true).apply { + member = blockedCreator + blockedMember = viewer + } + ) + saveAlias(creator, "Retained owner alias") + saveAlias(blockedCreator, "Blocked owner alias") + creator.nickname = "renamed-original" + entityManager.flush() + entityManager.clear() + + val activeItems = findLatest(viewer.id!!, "ja") + assertEquals(listOf(visible.id), activeItems.map { it.contentId }) + assertEquals("Retained owner alias", activeItems.single().creatorNickname) + + jdbcTemplate.update( + "update creator_localized_name set is_active = false where member_id = ? and lang = 'JA'", + creator.id!! + ) + entityManager.clear() + assertEquals("renamed-original", findLatest(viewer.id!!, "ja").single().creatorNickname) + } + + @Test + @DisplayName("테마 공유 조회는 요청한 KO·JA 별칭과 기존 제목을 반환한다") + fun shouldLocalizeThemeNamesByRequestedLanguageWithoutChangingTitle() { + val viewer = saveMember("raw-theme-viewer", MemberRole.USER) + val creator = saveMember("raw-theme-original", MemberRole.CREATOR) + val content = saveContent(creator, "Unchanged theme title") + saveAlias(creator, "Japanese theme owner") + saveAlias(creator, "Korean theme owner", Lang.KO) + entityManager.clear() + + val names = listOf( + Lang.KO to "Korean theme owner", + Lang.JA to "Japanese theme owner" + ) + for ((lang, expected) in names) { + val item = repository.findByTheme(memberId = viewer.id!!, contentType = ContentType.ALL, lang = lang).single() + assertEquals(content.id, item.contentId) + assertEquals(expected, item.creatorNickname) + assertEquals("Unchanged theme title", item.title) + } + } + + @Test + @DisplayName("큐레이션 조회는 활성 KO·JA 별칭이 있어도 원본 닉네임을 반환한다") + fun shouldKeepCurationNamesRawWithActiveAliases() { + val creator = saveMember("raw-curation-original", MemberRole.CREATOR) + val curation = AudioContentCuration(title = "curation", description = "description") + .also { entityManager.persist(it) } + val content = saveContent(creator, "Unchanged curation title").apply { this.curation = curation } + entityManager.persist( + AudioContentCurationItem().apply { + this.curation = curation + this.content = content + } + ) + saveAlias(creator, "Japanese curation owner") + saveAlias(creator, "Korean curation owner", Lang.KO) + entityManager.clear() + + val results = listOf( + repository.findContentByCurationId(curation.id!!, false, ContentType.ALL), + curationRepository.findByCurationId(curation.id!!, "https://cdn.test", contentType = ContentType.ALL) + ) + for (items in results) { + assertEquals(content.id, items.single().contentId) + assertEquals("raw-curation-original", items.single().creatorNickname) + assertEquals("Unchanged curation title", items.single().title) + } + } + + @Test + @DisplayName("랭킹 공유 조회는 요청한 KO·JA 별칭과 제목 번역·원본 fallback을 반환한다") + fun shouldLocalizeRankingNamesAndTitlesByRequestedLocale() { + val viewer = saveMember("raw-rank-viewer", MemberRole.USER) + val creator = saveMember("raw-rank-original", MemberRole.CREATOR).apply { profileImage = "profile.png" } + val content = saveContent(creator, "Original ranking title") + entityManager.persist(CreatorRanking(ranking = 1).apply { member = creator }) + entityManager.persist( + Order(type = OrderType.KEEP).apply { + member = viewer + this.creator = creator + audioContent = content + } + ) + entityManager.persist( + ContentTranslation(content.id!!, "ja", ContentTranslationPayload("Translated ranking title", "detail", "")) + ) + saveAlias(creator, "Japanese ranking owner") + saveAlias(creator, "Korean ranking owner", Lang.KO) + // H2 JSON 컬럼에 문자열이 아닌 JSON 객체를 적재한다. + jdbcTemplate.update( + "update content_translation set rendered_payload = ? format json where content_id = ? and locale = 'ja'", + """{"title":"Translated ranking title","detail":"detail","tags":""}""", + content.id!! + ) + entityManager.clear() + + val names = listOf( + Triple(Lang.KO, "Korean ranking owner", "Original ranking title"), + Triple(Lang.JA, "Japanese ranking owner", "Translated ranking title") + ) + for ((lang, expected, expectedTitle) in names) { + assertEquals(expected, rankingRepository.getCreatorRankings(viewer.id, lang).single().nickname) + val item = rankingRepository.getAudioContentRanking( + memberId = viewer.id, + isAdult = false, + contentType = ContentType.ALL, + startDate = LocalDateTime.now().minusDays(1), + endDate = LocalDateTime.now().plusDays(1), + offset = 0, + limit = 10, + sortType = "판매량", + locale = lang.code + ).single() + assertEquals(expected, item.creatorNickname) + assertEquals(expectedTitle, item.title) + } + assertEquals( + "Korean ranking owner", + rankingRepository.getCreatorRankings(viewer.id).single().nickname + ) + } + + @Test + @DisplayName("오리지널 시리즈 공유 조회는 요청한 언어의 제목 번역·원본 fallback을 반환한다") + fun shouldLocalizeOriginalSeriesTitlesByRequestedLocale() { + val creator = saveMember("raw-series-original", MemberRole.CREATOR) + val content = saveContent(creator, "Series content") + val genre = SeriesGenre(genre = "genre").also { entityManager.persist(it) } + val series = Series(title = "Original series title", introduction = "introduction", isOriginal = true).apply { + member = creator + this.genre = genre + coverImage = "series.png" + entityManager.persist(this) + } + entityManager.persist( + SeriesContent().apply { + this.series = series + this.content = content + } + ) + entityManager.persist( + SeriesTranslation(series.id!!, "ja", SeriesTranslationPayload("Translated series title", "intro", emptyList())) + ) + entityManager.flush() + // H2 JSON 컬럼에 문자열이 아닌 JSON 객체를 적재한다. + jdbcTemplate.update( + "update series_translation set rendered_payload = ? format json where series_id = ? and locale = 'ja'", + """{"title":"Translated series title","introduction":"intro","keywords":[]}""", + series.id!! + ) + entityManager.clear() + + val titles = listOf( + "ja" to "Translated series title", + "ko" to "Original series title" + ) + for ((locale, expectedTitle) in titles) { + val localized = seriesRepository.getOriginalAudioDramaList( + imageHost = "https://cdn.test", + isAdult = false, + contentType = ContentType.ALL, + locale = locale + ).single() + assertEquals(expectedTitle, localized.title) + } + } + + private fun findLatest(viewerId: Long, locale: String): List { + return repository.getLatestContentByTheme( + memberId = viewerId, + theme = emptyList(), + contentType = ContentType.ALL, + offset = 0, + limit = 20, + sortType = SortType.NEWEST, + isFree = false, + isAdult = false, + locale = locale + ) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "profile.png", + role = role + ).also { + entityManager.persist(it) + } + } + + private fun saveContent(creator: Member, title: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1) + ).apply { + member = creator + this.theme = theme + isActive = true + coverImage = "audio.png" + duration = "00:10:00" + entityManager.persist(this) + } + } + + private fun saveAlias(member: Member, displayName: String, lang: Lang = Lang.JA) { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang.name, + displayName + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt index b19c7bdd..b04bc186 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt @@ -24,6 +24,7 @@ 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.support.originalLocalizedNameService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.junit.jupiter.api.Assertions.assertEquals @@ -114,6 +115,7 @@ class AudioContentServiceTest { applicationEventPublisher = applicationEventPublisher, messageSource = SodaMessageSource(), langContext = LangContext(), + localizedNameService = originalLocalizedNameService(), contentThemeTranslationRepository = contentThemeTranslationRepository, homeFollowingNewsPublishService = homeFollowingNewsPublishService, memberContentPreferenceService = memberContentPreferenceService, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt new file mode 100644 index 00000000..000f9644 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt @@ -0,0 +1,91 @@ +package kr.co.vividnext.sodalive.content.comment + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.fcm.FcmEvent +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.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Import +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class) +class AudioContentCommentNameMetadataTest @Autowired constructor( + private val entityManager: EntityManager, + private val repository: AudioContentCommentRepository, + private val blockRepository: BlockMemberRepository, + private val contentRepository: AudioContentRepository, + private val orderRepository: OrderRepository, + private val localizedNameService: CreatorLocalizedNameService +) { + @ParameterizedTest + @CsvSource("CREATOR, USER", "USER, CREATOR") + @DisplayName("답글 알림 제목은 부모 작성자 ID이고 snapshot 발신자 ID와 콘텐츠 제목 인자는 그대로다") + fun shouldKeepParentTitleOwnerSeparateFromActualCommenterAndResourceTitle( + parentRole: MemberRole, + commenterRole: MemberRole + ) { + val parentWriter = Member(nickname = "parent-original", password = "password", role = parentRole) + val commenter = Member(nickname = "commenter-original", password = "password", role = commenterRole) + val contentOwner = Member(nickname = "content-owner-original", password = "password", role = MemberRole.CREATOR) + entityManager.persist(parentWriter) + entityManager.persist(commenter) + entityManager.persist(contentOwner) + val theme = AudioContentTheme(theme = "metadata-theme", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent(title = "Unchanged content title", detail = "detail", languageCode = "ko").apply { + member = contentOwner + this.theme = theme + isActive = true + entityManager.persist(this) + } + val parent = AudioContentComment(comment = "parent comment", languageCode = "ko").apply { + member = parentWriter + audioContent = content + entityManager.persist(this) + } + entityManager.flush() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val service = AudioContentCommentService( + repository, + blockRepository, + contentRepository, + publisher, + orderRepository, + SodaMessageSource(), + LangContext(), + localizedNameService, + "https://cdn.test" + ) + + service.registerComment(commenter, "reply", content.id!!, parent.id!!, false, "ko") + + val captor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(publisher).publishEvent(captor.capture()) + val event = captor.value + assertNotEquals(parentWriter.id, commenter.id) + assertEquals(parentWriter.id, event.titleMemberId) + assertEquals(commenter.id, event.senderMemberId) + assertEquals("parent-original", event.title) + assertEquals(listOf("Unchanged content title"), event.args) + assertEquals(emptyMap(), event.nameArgumentMemberIds) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt new file mode 100644 index 00000000..51c681cd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt @@ -0,0 +1,259 @@ +package kr.co.vividnext.sodalive.explorer + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +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.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.transaction.TestTransaction +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:legacy-creator-name-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class ExplorerLocalizedNameEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @MockBean + private lateinit var countryContext: CountryContext + + @BeforeEach + fun setUp() { + Mockito.doReturn("US").`when`(countryContext).countryCode + } + + @Test + @DisplayName("레거시 프로필 이름은 현지화하되 팬톡 작성자·답글은 활성 JA·KO 별칭이 있어도 원본을 유지한다") + fun shouldLocalizeProfileButKeepOriginalFanTalkWriterAndReplyNamesDespiteActiveAliases() { + val viewer = saveMember("profile-viewer", MemberRole.USER) + val creator = saveMember("profile-original", MemberRole.CREATOR).apply { isVisibleDonationRank = false } + val fan = saveMember("fan-original", MemberRole.USER) + val peer = saveMember("deleted_peer-original", MemberRole.CREATOR) + val fanTalk = saveCheers(fan, creator, "fan talk") + saveCheers(peer, creator, "creator fan talk") + saveCheers(creator, creator, "creator reply", parent = fanTalk) + saveAlias(creator, "Japanese profile") + saveAlias(peer, "Japanese peer") + saveAlias(fan, "Must not replace fan") + saveAlias(creator, "Korean profile", lang = "KO") + saveAlias(peer, "Korean peer", lang = "KO") + entityManager.clear() + commitFixture() + + mockMvc.perform( + get("/explorer/profile/${creator.id}") + .param("timezone", "Asia/Seoul") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.creator.creatorId").value(creator.id!!)) + .andExpect(jsonPath("$.data.creator.nickname").value("Japanese profile")) + .andExpect(jsonPath("$.data.cheers.totalCount").value(2)) + .andExpect(jsonPath("$.data.cheers.cheers[0].memberId").value(peer.id!!)) + .andExpect(jsonPath("$.data.cheers.cheers[0].nickname").value("peer-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].nickname").value("fan-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].replyList[0].memberId").value(creator.id!!)) + .andExpect(jsonPath("$.data.cheers.cheers[1].replyList[0].nickname").value("profile-original")) + + listOf("ko", "en").forEach { locale -> + mockMvc.perform( + get("/explorer/profile/${creator.id}") + .param("timezone", "Asia/Seoul") + .header("Accept-Language", locale) + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect( + jsonPath("$.data.creator.nickname").value(if (locale == "ko") "Korean profile" else "profile-original") + ) + .andExpect(jsonPath("$.data.cheers.cheers[0].nickname").value("peer-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].nickname").value("fan-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].replyList[0].nickname").value("profile-original")) + } + assertEquals("profile-original", entityManager.find(Member::class.java, creator.id).nickname) + } + + @Test + @DisplayName("JA 상세는 원본 변경 후에도 별칭을 유지하고 해제·미등록이면 현재 원본을 반환한다") + fun shouldKeepAliasAfterOriginalRenameAndFallbackAfterSoftClearOrMissingAlias() { + val viewer = saveMember("detail-viewer", MemberRole.USER) + val creator = saveMember("before-rename", MemberRole.CREATOR) + val unregistered = saveMember("unregistered-original", MemberRole.CREATOR) + saveAlias(creator, "Retained profile alias") + creator.nickname = "renamed-original" + entityManager.flush() + entityManager.clear() + commitFixture() + + mockMvc.perform( + get("/explorer/profile/${creator.id}/detail") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.nickname").value("Retained profile alias")) + + jdbcTemplate.update( + "update creator_localized_name set is_active = false where member_id = ? and lang = 'JA'", + creator.id!! + ) + entityManager.clear() + mockMvc.perform( + get("/explorer/profile/${creator.id}/detail") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.nickname").value("renamed-original")) + mockMvc.perform( + get("/explorer/profile/${unregistered.id}/detail") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.nickname").value("unregistered-original")) + } + + @Test + @DisplayName("통합 검색은 creator 제목·닉네임만 JA 이름으로 바꾸고 콘텐츠·시리즈 제목과 소유자 ID를 구분한다") + fun shouldLocalizeCreatorSearchTitleAndAllOwnerNamesWithoutReplacingContentOrSeriesTitles() { + val viewer = saveMember("unified-viewer", MemberRole.USER) + saveMember("unused-member", MemberRole.USER) + val creator = saveMember("unified-original", MemberRole.CREATOR) + val theme = AudioContentTheme(theme = "unified-theme", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent( + title = "Unchanged content title", + detail = "description", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1) + ).apply { + member = creator + this.theme = theme + isActive = true + coverImage = "audio.png" + duration = "00:10:00" + entityManager.persist(this) + } + val genre = SeriesGenre(genre = "unified-genre", isAdult = false, isActive = true) + entityManager.persist(genre) + val series = Series(title = "Unchanged series title", introduction = "introduction").apply { + member = creator + this.genre = genre + coverImage = "series.png" + entityManager.persist(this) + } + entityManager.persist( + SeriesContent().apply { + this.series = series + this.content = content + } + ) + saveAlias(creator, "Japanese unified creator") + entityManager.clear() + assertNotEquals(creator.id, content.id) + assertNotEquals(creator.id, series.id) + commitFixture() + + mockMvc.perform( + get("/search") + .param("keyword", "unified-original") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.creatorList[0].id").value(creator.id!!)) + .andExpect(jsonPath("$.data.creatorList[0].title").value("Japanese unified creator")) + .andExpect(jsonPath("$.data.creatorList[0].nickname").value("Japanese unified creator")) + .andExpect(jsonPath("$.data.contentList[0].id").value(content.id!!)) + .andExpect(jsonPath("$.data.contentList[0].title").value("Unchanged content title")) + .andExpect(jsonPath("$.data.contentList[0].nickname").value("Japanese unified creator")) + .andExpect(jsonPath("$.data.seriesList[0].id").value(series.id!!)) + .andExpect(jsonPath("$.data.seriesList[0].title").value("Unchanged series title")) + .andExpect(jsonPath("$.data.seriesList[0].nickname").value("Japanese unified creator")) + + mockMvc.perform( + get("/search/creators") + .param("keyword", "Japanese unified") + .header("Accept-Language", "ko") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].title").value("unified-original")) + .andExpect(jsonPath("$.data.items[0].nickname").value("unified-original")) + } + + private fun commitFixture() { + TestTransaction.flagForCommit() + TestTransaction.end() + TestTransaction.start() + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role + ).also { entityManager.persist(it) } + } + + private fun saveCheers(writer: Member, creator: Member, text: String, parent: CreatorCheers? = null): CreatorCheers { + return CreatorCheers(cheers = text, languageCode = "ko").apply { + member = writer + this.creator = creator + this.parent = parent + entityManager.persist(this) + } + } + + private fun saveAlias(member: Member, displayName: String, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..32332ada --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.explorer + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +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.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +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.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class ExplorerQueryRepositoryLocalizedNameTest @Autowired constructor( + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate, + queryFactory: JPAQueryFactory +) { + private val langContext = LangContext().apply { setLang(Lang.KO) } + private val repository = ExplorerQueryRepository(queryFactory, SodaMessageSource(), langContext, "https://cdn.test") + + @Test + @DisplayName("KO 채널 검색도 원본 또는 활성 JA 이름을 찾고 self·비활성·USER를 제외한다") + fun shouldSearchOriginalOrActiveJaAliasInKoWithoutBypassingChannelFilters() { + val viewer = saveMember("Comet-viewer") + val original = saveMember("Comet-original") + val aliasOnly = saveMember("another-original") + val inactive = saveMember("Comet-inactive").apply { isActive = false } + val user = saveMember("Comet-user", MemberRole.USER) + listOf(viewer, original, aliasOnly, inactive, user).forEach { saveAlias(it, "Comet alias") } + entityManager.clear() + + assertAll( + { + assertEquals( + setOf(original.id, aliasOnly.id), + repository.getSearchChannel("cOmEt", viewer.id!!).map { it.id }.toSet() + ) + }, + { assertEquals(2, repository.getSearchChannel("cOmEt", viewer.id!!).size) }, + { assertEquals(listOf(original.id), repository.getSearchChannel("Comet-original", viewer.id!!).map { it.id }) } + ) + } + + @Test + @DisplayName("채널 검색은 해제한 JA와 EN 이름을 제외하고 동일 행 재활성화 후 별칭을 다시 찾는다") + fun shouldIgnoreSoftClearedAndNonJaChannelAliasesAndSearchReactivatedAlias() { + val viewer = saveMember("channel-viewer", MemberRole.USER) + val creator = saveMember("channel-original") + saveAlias(creator, "ChannelAlias", isActive = false) + saveAlias(creator, "EnglishChannel", lang = "EN") + entityManager.clear() + + assertEquals(emptyList(), repository.getSearchChannel("ChannelAlias", viewer.id!!).map { it.id }) + assertEquals(emptyList(), repository.getSearchChannel("EnglishChannel", viewer.id!!).map { it.id }) + assertEquals(listOf(creator.id), repository.getSearchChannel("channel-original", viewer.id!!).map { it.id }) + jdbcTemplate.update( + "update creator_localized_name set is_active = true where member_id = ? and lang = 'JA'", + creator.id!! + ) + + assertEquals(listOf(creator.id), repository.getSearchChannel("ChannelAlias", viewer.id!!).map { it.id }) + } + + private fun saveMember(nickname: String, role: MemberRole = MemberRole.CREATOR): Member { + return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role).also { + entityManager.persist(it) + } + } + + private fun saveAlias(member: Member, displayName: String, isActive: Boolean = true, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName, + isActive + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt index e365ce82..b3143abb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt @@ -11,12 +11,15 @@ import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +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.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -42,7 +45,28 @@ class ExplorerServiceTest { } } - private fun createService(queryRepository: ExplorerQueryRepository): ExplorerService { + @Test + @DisplayName("팬톡 수정 응답은 별칭 대신 작성자 원본 이름을 반환하고 deleted_ 접두사만 제거한다") + fun shouldKeepOriginalWriterNameAndRemoveDeletedPrefixWhenModifyingCheers() { + val queryRepository = Mockito.mock(ExplorerQueryRepository::class.java) + val localizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java) + val service = createService(queryRepository, localizedNameService) + val writer = createMember(id = 10L, nickname = "deleted_writer", role = MemberRole.CREATOR) + val cheers = createCheers(writer, createMember(id = 20L, nickname = "creator", role = MemberRole.CREATOR)) + Mockito.doReturn(cheers).`when`(queryRepository).getCheers(101L) + Mockito.`when`(localizedNameService.findLocalizedNames(listOf(10L), Lang.JA)).thenReturn(mapOf(10L to "alias")) + + val response = service.modifyCheers(PutWriteCheersRequest(cheersId = 101L, content = "modified"), writer) + + assertEquals("writer", response.writerNickname) + assertEquals("modified", response.content) + Mockito.verifyNoInteractions(localizedNameService) + } + + private fun createService( + queryRepository: ExplorerQueryRepository, + localizedNameService: CreatorLocalizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java) + ): ExplorerService { return ExplorerService( memberService = Mockito.mock(MemberService::class.java), memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), @@ -57,7 +81,8 @@ class ExplorerServiceTest { applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java), contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), messageSource = Mockito.mock(SodaMessageSource::class.java), - langContext = Mockito.mock(LangContext::class.java), + langContext = LangContext().apply { setLang(Lang.JA) }, + localizedNameService = localizedNameService, cloudFrontHost = "https://cdn.test" ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt index 3b8f9148..d48dde39 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt @@ -72,9 +72,9 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( // then: 비공개 타인 메시지는 제외되고, createdAt desc/id desc 정렬이 적용돼야 한다. assertEquals(3, list.size) - assertEquals(secretMine.id, list[0].id) - assertEquals(publicTieSecond.id, list[1].id) - assertEquals(publicTieFirst.id, list[2].id) + assertEquals(secretMine.id, list[0].first.id) + assertEquals(publicTieSecond.id, list[1].first.id) + assertEquals(publicTieFirst.id, list[2].first.id) assertEquals(3, totalCount) } @@ -127,9 +127,9 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( // then: 본인 조회는 비공개 메시지를 포함하고 최신순으로 반환해야 한다. assertEquals(3, list.size) - assertEquals(recentSecretOther.id, list[0].id) - assertEquals(recentSecretMine.id, list[1].id) - assertEquals(recentPublic.id, list[2].id) + assertEquals(recentSecretOther.id, list[0].first.id) + assertEquals(recentSecretMine.id, list[1].first.id) + assertEquals(recentPublic.id, list[2].first.id) assertEquals(3, totalCount) } @@ -138,7 +138,7 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( fun shouldFetchMemberWithDonationMessages() { // given: 크리에이터/후원자와 조회 대상 메시지를 준비한다. val creator = saveMember(nickname = "creator3", role = MemberRole.CREATOR) - val viewer = saveMember(nickname = "viewer3", role = MemberRole.USER) + val viewer = saveMember(nickname = "deleted_viewer3", role = MemberRole.USER) val message = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) val monthStart = LocalDateTime.now().withDayOfMonth(1).toLocalDate().atStartOfDay() val nextMonthStart = monthStart.plusMonths(1) @@ -160,8 +160,9 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( // then: 조회 결과의 후원자 정보는 세션 없이도 접근 가능해야 한다. assertEquals(1, list.size) - assertEquals(viewer.id, list[0].member!!.id) - assertEquals("viewer3", list[0].member!!.nickname) + assertEquals(viewer.id, list[0].first.member!!.id) + assertEquals("deleted_viewer3", list[0].first.member!!.nickname) + assertEquals("viewer3", list[0].second) } private fun saveMember(nickname: String, role: MemberRole): Member { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt index 625ded9a..cde61cf0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.explorer.profile.channelDonation import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.common.SodaException +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.Member @@ -97,9 +98,10 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) - ).thenReturn(listOf(message)) + ).thenReturn(listOf(message to viewer.nickname)) // when: 채널 후원 목록 조회를 실행한다. val result = service.getChannelDonationList( @@ -129,7 +131,8 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) // then: 월 시작/다음 달 시작 범위를 사용해야 한다. @@ -181,9 +184,10 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) - ).thenReturn(listOf(message)) + ).thenReturn(listOf(message to "withdrawn")) // when: 채널 후원 목록 조회를 실행한다. val result = service.getChannelDonationList( @@ -228,9 +232,10 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) - ).thenReturn(listOf(message)) + ).thenReturn(listOf(message to viewer.nickname)) // when: 목록 조회를 실행한다. val result = service.getChannelDonationList( @@ -275,7 +280,8 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) ).thenReturn(emptyList()) @@ -302,7 +308,8 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) // then: 월 시작/다음 달 시작 범위를 사용해야 한다. diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index 93283d6b..0487c7cd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -150,6 +150,7 @@ class CreatorCommunityServiceTest { assertEquals(FcmEventType.INDIVIDUAL, event.type) assertEquals(PushNotificationCategory.COMMUNITY, event.category) assertEquals("creator.community.fcm.new_comment", event.messageKey) + assertEquals(commenter.id, event.titleMemberId) assertEquals(commenter.id, event.senderMemberId) assertEquals(listOf(creator.id!!), event.recipients) assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt new file mode 100644 index 00000000..26c3a603 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt @@ -0,0 +1,287 @@ +package kr.co.vividnext.sodalive.fcm + +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentService +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationList +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationListRepository +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationRecipientChunk +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.live.reservation.LiveReservation +import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.live.room.LiveRoomService +import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest +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.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertAll +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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.event.ApplicationEvents +import org.springframework.test.context.event.RecordApplicationEvents +import org.springframework.test.util.AopTestUtils +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@RecordApplicationEvents +@Transactional +class FcmSendListenerLocalizedNameTest @Autowired constructor( + private val listener: FcmSendListener, + private val chatService: UserCreatorChatService, + private val liveRoomService: LiveRoomService, + private val commentService: AudioContentCommentService, + private val giftPushService: GiftPushService, + private val memberRepository: MemberRepository, + private val pushTokenRepository: PushTokenRepository, + private val notificationRepository: PushNotificationListRepository, + private val roomRepository: LiveRoomRepository, + private val reservationRepository: LiveReservationRepository, + private val jdbcTemplate: JdbcTemplate, + private val langContext: LangContext, + private val entityManager: EntityManager +) { + @Autowired + private lateinit var applicationEvents: ApplicationEvents + + @MockBean + private lateinit var fcmService: FcmService + + @MockBean + private lateinit var s3Uploader: S3Uploader + + @BeforeEach + fun setUpRequest() { + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) + langContext.setLang(Lang.EN) + } + + @AfterEach + fun resetRequest() { + RequestContextHolder.resetRequestAttributes() + } + + @Test + @DisplayName("실제 DM 이벤트의 이름 인자와 255자 발신자 스냅샷을 토큰 언어별로 독립 렌더링한다") + fun shouldRenderDmNameArgumentAndSenderSnapshotForEachTokenLanguage() { + val creator = savedMember("dm-creator", MemberRole.CREATOR) + val recipient = savedMember("dm-recipient") + val japaneseName = "名".repeat(255) + saveAlias(creator, japaneseName) + saveTokens(recipient) + val roomId = chatService.createOrGetRoom(creator, recipient.id!!).roomId + + chatService.sendTextMessageByWebSocket(creator.id!!, roomId, "dm-creator 본문은 그대로", Lang.EN) + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + entityManager.flush() + entityManager.clear() + + val pushes = outgoingPushes() + val snapshots = notificationRepository.findAll().associateBy { it.languageCode } + assertAll( + { assertEquals(2, pushes.size) }, + { assertEquals("DM", pushes.getValue("token-ja").first) }, + { assertEquals("${japaneseName}からDMが届きました。", pushes.getValue("token-ja").second) }, + { assertEquals("DM", pushes.getValue("token-ko").first) }, + { assertEquals("dm-creator님으로 부터 DM이 도착했습니다.", pushes.getValue("token-ko").second) }, + { assertEquals(japaneseName, snapshots.getValue("ja").senderNicknameSnapshot) }, + { assertEquals("dm-creator", snapshots.getValue("ko").senderNicknameSnapshot) }, + { assertEquals(pushes.getValue("token-ja").second, snapshots.getValue("ja").message) }, + { assertEquals(pushes.getValue("token-ko").second, snapshots.getValue("ko").message) }, + { assertEquals(listOf("dm-creator"), event.args) }, + { assertEquals("dm-creator", memberRepository.findById(creator.id!!).orElseThrow().nickname) } + ) + } + + @Test + @DisplayName("실제 라이브 취소 이벤트의 제목만 현지화하고 같은 문자열인 방 제목 인자는 보존한다") + fun shouldLocalizeLiveSenderTitleWithoutReplacingRoomTitleArgument() { + val creator = savedMember("creator.*$", MemberRole.CREATOR) + val recipient = savedMember("live-recipient") + saveAlias(creator, "日本の配信者") + saveTokens(recipient) + val room = roomRepository.saveAndFlush( + LiveRoom( + title = "creator.*$", + notice = "notice", + beginDateTime = LocalDateTime.now().plusDays(1), + numberOfPeople = 10, + isAdult = false + ).apply { member = creator } + ) + reservationRepository.saveAndFlush( + LiveReservation().apply { + member = recipient + this.room = room + } + ) + + liveRoomService.cancelLive(CancelLiveRequest(room.id!!, "취소 사유"), creator) + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + + val pushes = outgoingPushes() + val snapshots = notificationRepository.findAll().associateBy { it.languageCode } + assertAll( + { assertEquals("日本の配信者", pushes.getValue("token-ja").first) }, + { assertEquals("ライブ取消: creator.*$", pushes.getValue("token-ja").second) }, + { assertEquals("creator.*$", pushes.getValue("token-ko").first) }, + { assertEquals("라이브 취소 : creator.*$", pushes.getValue("token-ko").second) }, + { assertEquals("日本の配信者", snapshots.getValue("ja").senderNicknameSnapshot) }, + { assertEquals("creator.*$", snapshots.getValue("ko").senderNicknameSnapshot) }, + { assertEquals("creator.*$", event.title) }, + { assertEquals(listOf("creator.*$"), event.args) } + ) + } + + @Test + @DisplayName("발신자 ID가 없는 과거 알림은 현재 이름 변경 뒤 신규 알림을 발송해도 이름과 본문이 불변이다") + fun shouldPreserveHistoricalNotificationWithoutSenderId() { + val creator = savedMember("historical-creator", MemberRole.CREATOR) + val recipient = savedMember("historical-recipient") + saveTokens(recipient) + val historical = notificationRepository.saveAndFlush( + PushNotificationList( + senderNicknameSnapshot = "過去の名前", + message = "過去の配信通知", + category = PushNotificationCategory.LIVE, + languageCode = "ja" + ).apply { addRecipientChunk(PushNotificationRecipientChunk(listOf(recipient.id!!))) } + ) + saveAlias(creator, "現在の名前") + creator.nickname = "changed-original" + entityManager.flush() + entityManager.clear() + + val roomId = chatService.createOrGetRoom(creator, recipient.id!!).roomId + chatService.sendTextMessageByWebSocket(creator.id!!, roomId, "새 알림 본문", Lang.EN) + AopTestUtils.getUltimateTargetObject(listener).send(publishedEvent()) + entityManager.flush() + entityManager.clear() + val stored = notificationRepository.findById(historical.id!!).orElseThrow() + + assertEquals(3L, notificationRepository.count()) + assertEquals("過去の名前", stored.senderNicknameSnapshot) + assertEquals("過去の配信通知", stored.message) + } + + @ParameterizedTest + @EnumSource(value = MemberRole::class, names = ["CREATOR", "USER"]) + @DisplayName("실제 오디오 답글은 부모 이름 제목과 행위자 snapshot을 분리하고 콘텐츠 제목 인자를 보존한다") + fun shouldKeepReplyTitleOwnerSeparateFromActualSender(senderRole: MemberRole) { + val parentAuthor = savedMember("parent-author", MemberRole.CREATOR) + val sender = savedMember("reply-sender", senderRole) + saveAlias(parentAuthor, "親コメントの作者") + saveAlias(sender, "返信した作者") + saveTokens(parentAuthor) + val theme = AudioContentTheme("theme", "theme.png") + entityManager.persist(theme) + val content = AudioContent(title = "parent-author", detail = "detail", languageCode = "en").apply { + member = parentAuthor + this.theme = theme + } + entityManager.persist(content) + val parent = AudioContentComment("parent", "en").apply { + member = parentAuthor + audioContent = content + } + entityManager.persist(parent) + entityManager.flush() + + commentService.registerComment(sender, "reply", content.id!!, parent.id, languageCode = "en") + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + val pushes = outgoingPushes() + val snapshots = notificationRepository.findAll().associateBy { it.languageCode } + + assertEquals(parentAuthor.id, event.titleMemberId) + assertEquals(sender.id, event.senderMemberId) + assertEquals("親コメントの作者", pushes.getValue("token-ja").first) + assertEquals("コメントに返信しました: parent-author", pushes.getValue("token-ja").second) + assertEquals("parent-author", pushes.getValue("token-ko").first) + assertEquals("reply-sender", snapshots.getValue("ko").senderNicknameSnapshot) + assertEquals( + if (senderRole == MemberRole.CREATOR) "返信した作者" else "reply-sender", + snapshots.getValue("ja").senderNicknameSnapshot + ) + assertEquals(listOf("parent-author"), event.args) + } + + @Test + @DisplayName("실제 기프트 알림의 자유 제목과 본문은 수신자 별칭이나 토큰 언어로 이름 치환하지 않는다") + fun shouldPreserveGiftTextAndDefaultSenderSnapshotWithoutNameMetadata() { + val recipient = savedMember("운송장 번호를 입력해 주세요", MemberRole.CREATOR) + saveAlias(recipient, "名前ではない通知") + saveTokens(recipient) + + giftPushService.sendTrackingDeadlineReminder("gift-001", recipient.id!!) + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + val pushes = outgoingPushes() + + assertEquals(null, event.senderMemberId) + assertEquals(null, event.titleMemberId) + assertEquals(emptyMap(), event.nameArgumentMemberIds) + assertEquals("운송장 번호를 입력해 주세요", pushes.getValue("token-ja").first) + assertEquals(pushes.getValue("token-ko"), pushes.getValue("token-ja")) + notificationRepository.findAll().forEach { assertEquals(event.title, it.senderNicknameSnapshot) } + } + + private fun publishedEvent(): FcmEvent = applicationEvents.stream(FcmEvent::class.java).toList().single() + + private fun savedMember(nickname: String, role: MemberRole = MemberRole.USER): Member = memberRepository.saveAndFlush( + Member(email = "$nickname@localized-fcm.test", password = "pw", nickname = nickname, role = role) + ) + + private fun saveAlias(member: Member, displayName: String) { + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + displayName + ) + } + + private fun saveTokens(member: Member) { + pushTokenRepository.saveAndFlush(PushToken("token-ja", "aos", "ja-JP").apply { this.member = member }) + pushTokenRepository.saveAndFlush(PushToken("token-ko", "ios", "ko-KR").apply { this.member = member }) + } + + private fun outgoingPushes(): Map> = Mockito.mockingDetails(fcmService).invocations + .filter { it.method.name == "send" } + .associate { invocation -> + val tokens = invocation.getArgument>(0) + tokens.single() to Pair(invocation.getArgument(1), invocation.getArgument(2)) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt index c8235db0..6736b609 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt @@ -71,7 +71,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "테스트 메시지", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: 수신자 없음 규칙에 따라 저장이 발생하지 않아야 한다. @@ -95,7 +96,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "시스템 알림", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: SYSTEM 카테고리 보정 결과에 따라 저장이 발생하지 않아야 한다. @@ -124,7 +126,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "라이브가 시작되었습니다.", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: 보정된 LIVE 카테고리로 저장되어야 한다. @@ -163,7 +166,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "라이브가 시작되었습니다.", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: 발송자 스냅샷/딥링크/카테고리/언어와 수신자 청크가 정확히 저장되어야 한다. @@ -206,7 +210,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "새 댓글이 등록되었습니다.", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) val captor = ArgumentCaptor.forClass(PushNotificationList::class.java) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt index a1de892a..f4790ce6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt @@ -94,7 +94,8 @@ class LiveRecommendRepositoryTest @Autowired constructor( memberId = viewer.id!!, offset = 0, limit = 20, - isBlocked = { false } + isBlocked = { false }, + lang = Lang.KO ) assertEquals(2, result.size) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt index db60bf77..79694871 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.live.recommend import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -28,7 +29,8 @@ class LiveRecommendServiceTest { repository = repository, blockMemberRepository = blockMemberRepository, memberContentPreferenceService = memberContentPreferenceService, - liveRecommendCacheService = liveRecommendCacheService + liveRecommendCacheService = liveRecommendCacheService, + langContext = LangContext() ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt new file mode 100644 index 00000000..830247d3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt @@ -0,0 +1,523 @@ +package kr.co.vividnext.sodalive.live.room + +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.fcm.FcmService +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.live.recommend.LiveRecommendService +import kr.co.vividnext.sodalive.live.reservation.LiveReservation +import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository +import kr.co.vividnext.sodalive.live.reservation.LiveReservationService +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest +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.LiveRoomMemberRole +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService +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.following.CreatorFollowing +import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hibernate.SessionFactory +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +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.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.cache.CacheManager +import org.springframework.data.domain.PageRequest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.time.LocalDateTime +import javax.persistence.EntityManagerFactory + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "spring.jpa.properties.hibernate.generate_statistics=true", + "jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==", + "agora.app-id=0123456789abcdef0123456789abcdef", + "agora.app-certificate=0123456789abcdef0123456789abcdef" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class LiveRoomLocalizedNameIntegrationTest @Autowired constructor( + private val service: LiveRoomService, + private val reservationService: LiveReservationService, + private val reservationRepository: LiveReservationRepository, + private val visitService: LiveRoomVisitService, + private val recommendService: LiveRecommendService, + private val followingRepository: CreatorFollowingRepository, + private val useCanRepository: UseCanRepository, + private val roomRepository: LiveRoomRepository, + private val roomInfoRepository: LiveRoomInfoRedisRepository, + private val memberRepository: MemberRepository, + private val jdbcTemplate: JdbcTemplate, + private val cacheManager: CacheManager, + private val entityManagerFactory: EntityManagerFactory, + private val langContext: LangContext +) { + @MockBean + private lateinit var fcmService: FcmService + + @MockBean + private lateinit var s3Uploader: S3Uploader + + private val roomIds = mutableListOf() + private val finishedLiveCacheKeys = mutableListOf() + + @BeforeEach + fun setUpRequest() { + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) + } + + @AfterEach + fun resetRequestAndRemoveRoomInfo() { + roomIds.forEach { roomInfoRepository.deleteById(it) } + finishedLiveCacheKeys.forEach { cacheManager.getCache("cache_ttl_10_minutes")?.evict(it) } + RequestContextHolder.resetRequestAttributes() + } + + @Test + @DisplayName("라이브 info는 모든 멤버 목록을 요청 언어로 복사하고 역할과 공유 Redis 원본을 보존한다") + fun shouldRenderRoomInfoMemberNamesWithoutMutatingSharedRedisMembership() { + val fixture = createFixture() + langContext.setLang(Lang.JA) + val japanese = service.getRoomInfo(fixture.roomId, fixture.viewer) + langContext.setLang(Lang.KO) + val korean = service.getRoomInfo(fixture.roomId, fixture.viewer) + val stored = roomInfoRepository.findById(fixture.roomId).orElseThrow() + + assertAll( + { assertEquals("日本-host", japanese.creatorNickname) }, + { assertEquals("日本-host", japanese.speakerList.single().nickname) }, + { assertEquals("日本-manager", japanese.managerList.single().nickname) }, + { assertEquals(listOf("viewer", "fallback"), japanese.listenerList.map { it.nickname }) }, + { assertEquals(LiveRoomMemberRole.SPEAKER, japanese.speakerList.single().role) }, + { assertEquals(LiveRoomMemberRole.MANAGER, japanese.managerList.single().role) }, + { assertEquals("host", korean.creatorNickname) }, + { assertEquals("host", korean.speakerList.single().nickname) }, + { assertEquals("manager", korean.managerList.single().nickname) }, + { assertEquals("host", stored.speakerList.single().nickname) }, + { assertEquals("manager", stored.managerList.single().nickname) }, + { assertEquals("host", memberRepository.findById(fixture.host.id!!).orElseThrow().nickname) } + ) + } + + @Test + @DisplayName("라이브 상세와 멤버 프로필의 현재 이름은 JA 별칭이며 USER와 비활성 별칭은 원본이다") + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + fun shouldRenderCurrentRoomDetailAndMemberProfileNames() { + val fixture = createFixture() + langContext.setLang(Lang.JA) + + val detail = service.getRoomDetail(fixture.roomId, fixture.viewer, "UTC") + val hostProfile = service.getUserProfile(fixture.roomId, fixture.host.id!!, fixture.viewer) + val viewerProfile = service.getUserProfile(fixture.roomId, fixture.viewer.id!!, fixture.viewer) + + assertAll( + { assertEquals("日本-host", detail.manager!!.nickname) }, + { assertEquals("日本-host", detail.participatingUsers.single { it.id == fixture.host.id }.nickname) }, + { assertEquals("日本-manager", detail.participatingUsers.single { it.id == fixture.manager.id }.nickname) }, + { assertEquals("viewer", detail.participatingUsers.single { it.id == fixture.viewer.id }.nickname) }, + { assertEquals("fallback", detail.participatingUsers.single { it.id == fixture.fallback.id }.nickname) }, + { assertEquals("日本-host", hostProfile.nickname) }, + { assertEquals("viewer", viewerProfile.nickname) } + ) + } + + @Test + @DisplayName("현재 후원 룰렛 메시지 이름은 요청별로 렌더링하며 비밀 필터와 본문 UUID 및 Redis 원본은 보존한다") + fun shouldRenderDonationAndRouletteNamesOnCopiesAfterSecretFiltering() { + val fixture = createFixture() + val roomInfo = roomInfoRepository.findById(fixture.roomId).orElseThrow() + roomInfo.addDonationMessage(fixture.manager.id!!, "manager", false, 10, "manager 후원 본문") + roomInfo.addRouletteMessage(fixture.manager.id!!, "manager", "manager 룰렛 결과") + roomInfo.addDonationMessage(fixture.viewer.id!!, "viewer", false, 10, "viewer 본문") + roomInfo.addDonationMessage(fixture.manager.id!!, "manager", true, 10, "비밀 본문") + roomInfoRepository.save(roomInfo) + langContext.setLang(Lang.JA) + val japanese = service.getDonationMessageList(fixture.roomId, fixture.viewer) + langContext.setLang(Lang.KO) + val korean = service.getDonationMessageList(fixture.roomId, fixture.viewer) + val stored = roomInfoRepository.findById(fixture.roomId).orElseThrow().donationMessageList + + assertAll( + { assertEquals(listOf("日本-manager", "日本-manager", "viewer"), japanese.map { it.nickname }) }, + { assertEquals(listOf("manager", "manager", "viewer"), korean.map { it.nickname }) }, + { assertEquals(listOf("manager 후원 본문", "manager 룰렛 결과", "viewer 본문"), japanese.map { it.donationMessage }) }, + { assertEquals(korean.map { it.uuid }, japanese.map { it.uuid }) }, + { assertEquals(listOf("manager", "manager", "viewer", "manager"), stored.map { it.nickname }) }, + { assertEquals(4, stored.size) } + ) + } + + @Test + @DisplayName("최근 종료 라이브의 현재 이름은 같은 회원의 KO 조회 캐시 이후에도 JA 요청을 따른다") + fun shouldRenderJapaneseFinishedLiveNameAfterKoreanCacheRead() { + val fixture = createFixture() + val room = roomRepository.findById(fixture.roomId).orElseThrow() + room.isActive = false + fixture.host.profileImage = "profile/host.png" + roomRepository.flush() + val cacheKey = "getLatestFinishedLive:${fixture.viewer.id}" + finishedLiveCacheKeys.add(cacheKey) + val cache = requireNotNull(cacheManager.getCache("cache_ttl_10_minutes")) + cache.evict(cacheKey) + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + langContext.setLang(Lang.KO) + val korean = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals(1L, currentNameQueryCount()) + val roomListQuery = statistics.queries.single { it.contains("from LiveRoom") } + val roomListExecutions = statistics.getQueryStatistics(roomListQuery).executionCount + val cached = cache.get(cacheKey)?.get() + assertNotNull(cached) + assertEquals(1L, roomListExecutions) + assertEquals("host", (cached as List<*>).filterIsInstance().single().nickname) + langContext.setLang(Lang.JA) + val nameQueryCountBeforeHit = currentNameQueryCount() + val japanese = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals(1L, currentNameQueryCount() - nameQueryCountBeforeHit) + assertEquals(roomListExecutions, statistics.getQueryStatistics(roomListQuery).executionCount) + assertEquals(cached, cache.get(cacheKey)?.get()) + fixture.host.nickname = "updated-host" + memberRepository.flush() + jdbcTemplate.update("UPDATE creator_localized_name SET is_active = false WHERE member_id = ?", fixture.host.id!!) + val nameQueryCountBeforeFallback = currentNameQueryCount() + val fallback = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals(1L, currentNameQueryCount() - nameQueryCountBeforeFallback) + + assertEquals("host", korean.nickname) + assertEquals("日本-host", japanese.nickname) + assertEquals("updated-host", fallback.nickname) + assertEquals("host", korean.nickname) + assertEquals(roomListExecutions, statistics.getQueryStatistics(roomListQuery).executionCount) + assertEquals(cached, cache.get(cacheKey)?.get()) + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'EN', 'English host', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + fixture.host.id!! + ) + langContext.setLang(Lang.EN) + val nameQueryCountBeforeEnglishHit = currentNameQueryCount() + val english = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals("English host", english.nickname) + assertEquals(1L, currentNameQueryCount() - nameQueryCountBeforeEnglishHit) + assertEquals(roomListExecutions, statistics.getQueryStatistics(roomListQuery).executionCount) + assertEquals(cached, cache.get(cacheKey)?.get()) + } + + @Test + @DisplayName("언어 중립 live feed와 종료 캐시는 별칭 해제 후 변경된 현재 원본으로 fallback한다") + fun shouldFallBackToCurrentOriginalWithoutRewritingNeutralRedisNames() { + val fixture = createFixture() + val info = roomInfoRepository.findById(fixture.roomId).orElseThrow() + info.addDonationMessage(fixture.host.id!!, "host", false, 10, "host 본문") + roomInfoRepository.save(info) + fixture.host.nickname = "updated-host" + memberRepository.flush() + jdbcTemplate.update("UPDATE creator_localized_name SET is_active = false WHERE member_id = ?", fixture.host.id!!) + langContext.setLang(Lang.JA) + + val response = service.getRoomInfo(fixture.roomId, fixture.viewer) + val messages = service.getDonationMessageList(fixture.roomId, fixture.viewer) + val stored = roomInfoRepository.findById(fixture.roomId).orElseThrow() + + assertEquals("updated-host", response.creatorNickname) + assertEquals("updated-host", response.speakerList.single().nickname) + assertEquals("updated-host", messages.single().nickname) + assertEquals("host", stored.speakerList.single().nickname) + assertEquals("host", stored.donationMessageList.single().nickname) + assertEquals("host 본문", messages.single().donationMessage) + } + + @Test + @DisplayName("예약 생성·목록·상세와 최근 방문자 조회는 실제 크리에이터 소유 ID로 JA 이름을 표시한다") + fun shouldLocalizeReservationAndRecentVisitorNames() { + val fixture = createFixture() + langContext.setLang(Lang.JA) + val reservation = reservationService.makeReservation( + MakeLiveReservationRequest(fixture.roomId, "web", "UTC"), + fixture.viewer.id!! + ) + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val listed = reservationService.getReservationList(fixture.viewer.id!!, true, "UTC").single() + val detail = reservationService.getReservation(reservation.reservationId, fixture.viewer.id!!, "UTC") + assertEquals(2L, statistics.queryExecutionCount, statistics.queries.joinToString("\n")) + val room = roomRepository.findById(fixture.roomId).orElseThrow() + visitService.roomVisit(room, fixture.viewer) + visitService.roomVisit(room, fixture.manager) + statistics.clear() + val visitor = visitService.getRecentVisitRoomUsers(fixture.viewer).single() + assertEquals(2L, statistics.queryExecutionCount, statistics.queries.joinToString("\n")) + + assertEquals("日本-host", reservation.nickname) + assertEquals("日本-host", listed.masterNickname) + assertEquals("日本-host", detail.masterNickname) + assertEquals(fixture.manager.id, visitor.id) + assertEquals("日本-manager", visitor.nickname) + langContext.setLang(Lang.KO) + assertEquals("manager", visitService.getRecentVisitRoomUsers(fixture.viewer).single().nickname) + } + + @Test + @DisplayName("추천과 팔로잉의 on-air 및 off-air 분기는 요청별 이름을 표시하고 count와 순서를 보존한다") + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + fun shouldLocalizeRecommendedAndFollowingChannels() { + val fixture = createFixture() + followingRepository.saveAndFlush( + CreatorFollowing().apply { + member = fixture.viewer + creator = fixture.host + } + ) + followingRepository.saveAndFlush( + CreatorFollowing().apply { + member = fixture.viewer + creator = fixture.manager + } + ) + langContext.setLang(Lang.JA) + + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val recommended = recommendService.getRecommendChannelList(null).associateBy { it.creatorId } + val aliasQueries = statistics.queries.filter { + Regex("""(?is)^select\s+\w+\.memberId,\s*\w+\.displayName\s+from\s+CreatorLocalizedNameEntity\b""") + .containsMatchIn(it) + } + val aliasQueryCount = aliasQueries.sumOf { statistics.getQueryStatistics(it).executionCount } + val queryCount = statistics.queryExecutionCount + val following = recommendService.getFollowingChannelList(fixture.viewer).associateBy { it.creatorId } + val all = recommendService.getFollowingAllChannelList(fixture.viewer, PageRequest.of(0, 20)) + + assertEquals("日本-host", recommended.getValue(fixture.host.id!!).nickname) + assertEquals("日本-manager", recommended.getValue(fixture.manager.id!!).nickname) + assertEquals("fallback", recommended.getValue(fixture.fallback.id!!).nickname) + assertEquals("日本-host", following.getValue(fixture.host.id!!).nickname) + assertEquals("日本-manager", following.getValue(fixture.manager.id!!).nickname) + assertEquals(2, all.totalCount) + assertEquals(setOf("日本-host", "日本-manager"), all.items.map { it.nickname }.toSet()) + langContext.setLang(Lang.KO) + assertEquals( + setOf("host", "manager"), + recommendService.getFollowingAllChannelList(fixture.viewer, PageRequest.of(0, 20)).items.map { it.nickname }.toSet() + ) + assertEquals(0L, aliasQueryCount, "Standalone alias SELECTs (total queries=$queryCount): $aliasQueries") + assertNoStandaloneNameQueries() + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'EN', 'English host', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + fixture.host.id!! + ) + langContext.setLang(Lang.EN) + assertEquals( + setOf("English host", "manager"), + recommendService.getFollowingAllChannelList(fixture.viewer, PageRequest.of(0, 20)).items.map { it.nickname }.toSet() + ) + assertNoStandaloneNameQueries() + } + + @Test + @DisplayName("후원과 하트 집계는 donor ID로 이름만 바꾸고 USER·금액·비밀 합계와 공개 DTO 필드를 보존한다") + fun shouldLocalizeDonationAndHeartDonorNamesWithoutChangingAmounts() { + val fixture = createFixture() + val room = roomRepository.findById(fixture.roomId).orElseThrow() + listOf(CanUsage.DONATION, CanUsage.HEART).forEach { usage -> + useCanRepository.saveAndFlush( + UseCan(usage, 10, 2).apply { + member = fixture.manager + this.room = room + } + ) + useCanRepository.saveAndFlush( + UseCan(usage, 5, 1).apply { + member = fixture.viewer + this.room = room + } + ) + } + langContext.setLang(Lang.JA) + + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val donations = service.getDonationStatus(fixture.roomId, fixture.host.id!!) + val hearts = service.getHeartList(fixture.roomId) + assertEquals(3L, statistics.queryExecutionCount, statistics.queries.joinToString("\n")) + + assertEquals(listOf("日本-manager", "viewer"), donations.donationList.map { it.nickname }) + assertEquals(listOf("日本-manager", "viewer"), hearts.heartList.map { it.nickname }) + assertEquals(18, donations.totalCan) + assertEquals(0, donations.totalSecretCan) + assertEquals(18, hearts.totalHeart) + assertEquals(2, hearts.totalCount) + } + + @Test + @DisplayName("진행·날짜별 예약·예정 라이브 목록은 EN 이름을 조인하고 USER·미등록·비활성 소유자를 유지한다") + fun shouldJoinEnglishNamesInEveryDatabaseRoomListWithoutMutatingOwners() { + val fixture = createFixture() + val owners = listOf(fixture.host, fixture.manager, fixture.viewer, fixture.fallback) + listOf(fixture.host, fixture.viewer, fixture.fallback).forEach { + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'EN', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + it.id!!, + "EN-${it.nickname}", + it != fixture.fallback + ) + } + val rooms = listOf(roomRepository.findById(fixture.roomId).orElseThrow()) + owners.drop(1).map { owner -> + roomRepository.saveAndFlush( + LiveRoom("${owner.nickname} room", "notice", LocalDateTime.now(), 10, "cover.png", isAdult = false).apply { + member = owner + channelName = "live_${owner.id}" + } + ) + } + val expected = mapOf( + fixture.host.id to "EN-host", + fixture.manager.id to "manager", + fixture.viewer.id to "viewer", + fixture.fallback.id to "fallback" + ) + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + val current = roomRepository.getLiveRoomListNow(0, 20, "UTC", null, false, true, null, Lang.EN) + assertEquals(expected, current.associate { it.room.member!!.id to it.creatorNickname }) + assertEquals(1L, statistics.queryExecutionCount) + + owners.forEach { owner -> + reservationRepository.saveAndFlush( + LiveReservation().apply { + member = owner + room = rooms.first() + } + ) + } + statistics.clear() + val bookers = reservationRepository.getReservationList(fixture.roomId, Lang.EN) + assertEquals(expected, bookers.associate { it.id to it.nickname }) + assertEquals(1L, statistics.queryExecutionCount) + statistics.clear() + val reservation = reservationRepository.getReservationListByMemberId(fixture.viewer.id!!, true, Lang.EN).single() + val reserved = reservationRepository.getReservationByReservationAndMemberId(reservation.id, fixture.viewer.id!!, Lang.EN) + assertEquals("EN-host", reservation.masterNickname) + assertEquals("EN-host", reserved!!.masterNickname) + assertEquals(2L, statistics.queryExecutionCount) + + val date = LocalDateTime.now().plusDays(1).withHour(12).withMinute(0) + rooms.forEach { + it.channelName = null + it.beginDateTime = date + } + roomRepository.flush() + statistics.clear() + val dated = roomRepository.getLiveRoomListReservationWithDate( + date.toLocalDate().atStartOfDay(), + 0, + 20, + null, + false, + true, + null, + Lang.EN + ) + val upcoming = roomRepository.getLiveRoomListReservationWithoutDate("UTC", null, false, true, null, Lang.EN) + + assertEquals(expected, dated.associate { it.room.member!!.id to it.creatorNickname }) + assertEquals(expected, upcoming.associate { it.room.member!!.id to it.creatorNickname }) + assertEquals(2L, statistics.queryExecutionCount) + assertEquals(listOf("host", "manager", "viewer", "fallback"), owners.map { it.nickname }) + assertNoStandaloneNameQueries() + } + + private fun assertNoStandaloneNameQueries() { + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + val nameQueries = statistics.queries.filter { + Regex("""(?is)\bfrom\s+CreatorLocalizedNameEntity\b""").containsMatchIn(it) || + Regex("""(?is)^select\s+\w+\.id,\s*coalesce\([^)]*\)\s+from\s+Member\b""").containsMatchIn(it) + } + assertEquals(0L, nameQueries.sumOf { statistics.getQueryStatistics(it).executionCount }, nameQueries.joinToString("\n")) + } + + private fun currentNameQueryCount(): Long { + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + return statistics.queries.filter { it.contains("from Member") && it.contains("CreatorLocalizedNameEntity") } + .sumOf { statistics.getQueryStatistics(it).executionCount } + } + + private fun createFixture(): Fixture { + val host = savedMember("host", MemberRole.CREATOR) + val manager = savedMember("manager", MemberRole.CREATOR) + val viewer = savedMember("viewer", MemberRole.USER) + val fallback = savedMember("fallback", MemberRole.CREATOR, activeAlias = false) + val room = roomRepository.saveAndFlush( + LiveRoom( + title = "room title", + notice = "notice", + beginDateTime = LocalDateTime.now().minusMinutes(1), + numberOfPeople = 10, + coverImage = "cover.png", + isAdult = false + ).apply { + member = host + channelName = "localized_live_${host.id}" + } + ) + roomIds.add(room.id!!) + roomInfoRepository.save( + LiveRoomInfo(room.id!!).apply { + addSpeaker(host, "https://cdn.test") + addManager(manager, "https://cdn.test") + addListener(viewer, "https://cdn.test") + addListener(fallback, "https://cdn.test") + } + ) + return Fixture(room.id!!, host, manager, viewer, fallback) + } + + private fun savedMember(nickname: String, role: MemberRole, activeAlias: Boolean = true): Member { + val member = memberRepository.saveAndFlush( + Member( + email = "$nickname@localized-live.test", + password = "pw", + nickname = nickname, + profileImage = "profile/$nickname.png", + role = role, + isVisibleDonationRank = false + ) + ) + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'JA', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + "日本-$nickname", + activeAlias + ) + return member + } + + private data class Fixture(val roomId: Long, val host: Member, val manager: Member, val viewer: Member, val fallback: Member) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt index 5abbbeb2..0d94acdd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt @@ -12,6 +12,7 @@ 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.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository @@ -30,6 +31,7 @@ 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 kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -106,6 +108,7 @@ class LiveRoomServiceAdultVisibilityPolicyTest { menuService = menuService, messageSource = messageSource, langContext = langContext, + localizedNameService = mock(), repository = repository, rouletteRepository = rouletteRepository, roomInfoRepository = roomInfoRepository, @@ -152,7 +155,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 100L, isCreator = false, isAdult = true, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) ).thenReturn(emptyList()) @@ -172,7 +176,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 100L, isCreator = false, isAdult = true, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) } @@ -187,7 +192,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = null, isCreator = false, isAdult = true, - effectiveGender = null + effectiveGender = null, + lang = Lang.KO ) ).thenReturn(emptyList()) @@ -207,7 +213,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = null, isCreator = false, isAdult = true, - effectiveGender = null + effectiveGender = null, + lang = Lang.KO ) } @@ -222,7 +229,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 200L, isCreator = false, isAdult = false, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) ).thenReturn(emptyList()) @@ -240,7 +248,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 200L, isCreator = false, isAdult = false, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt index 3ff85a8f..8e3dac67 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt @@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.member.MemberRole 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.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -70,6 +71,7 @@ class LiveRoomServiceTest { menuService = mock(), messageSource = mock(), langContext = LangContext(), + localizedNameService = mock(), repository = repository, rouletteRepository = rouletteRepository, roomInfoRepository = roomInfoRepository, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..f1078873 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt @@ -0,0 +1,108 @@ +package kr.co.vividnext.sodalive.member + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +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.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class MemberRepositoryLocalizedNameTest @Autowired constructor( + private val repository: MemberRepository, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @Test + @DisplayName("크리에이터 요청의 회원 검색은 USER 원본 매칭과 CREATOR 별칭을 함께 유지한다") + fun shouldRetainUserOriginalMatchesAndAddOnlyCreatorAliasesForCreatorRequester() { + val requester = saveMember("needle-requester", MemberRole.CREATOR) + val creator = saveMember("creator-original", MemberRole.CREATOR) + val user = saveMember("needle-user", MemberRole.USER) + val aiUser = saveMember("ai-user-original", MemberRole.USER).apply { memberKind = MemberKind.AI_CHARACTER } + val admin = saveMember("needle-admin", MemberRole.ADMIN) + val agent = saveMember("needle-agent", MemberRole.AGENT) + val bot = saveMember("needle-bot", MemberRole.BOT) + listOf(requester, creator, user, aiUser, admin, agent, bot).forEach { saveAlias(it, "needle-alias") } + entityManager.clear() + + val matches = repository.findByNicknameAndOtherCondition("NeEdLe", requester) + + assertEquals(setOf(creator.id, user.id), matches.map { it.id }.toSet()) + assertEquals(2, matches.size) + assertEquals(listOf(creator.id), repository.findByNicknameAndOtherCondition("creator-original", requester).map { it.id }) + } + + @Test + @DisplayName("USER 요청의 회원 검색은 기존 USER·self·관리 역할 제외 조건을 별칭에도 적용한다") + fun shouldPreserveUserRequesterRoleAndSelfExclusionsForAliases() { + val requester = saveMember("needle-viewer", MemberRole.USER) + val creator = saveMember("creator-original", MemberRole.CREATOR) + val user = saveMember("needle-user", MemberRole.USER) + val admin = saveMember("needle-admin", MemberRole.ADMIN) + val agent = saveMember("needle-agent", MemberRole.AGENT) + val bot = saveMember("needle-bot", MemberRole.BOT) + listOf(requester, creator, user, admin, agent, bot).forEach { saveAlias(it, "needle-alias") } + entityManager.clear() + + assertEquals(listOf(creator.id), repository.findByNicknameAndOtherCondition("needle", requester).map { it.id }) + } + + @Test + @DisplayName("비활성 JA와 다른 언어 이름은 회원 별칭 검색에서 제외하고 현재 원본은 유지한다") + fun shouldExcludeInactiveAndNonJaAliasesWhileRetainingCurrentOriginalNickname() { + val requester = saveMember("member-clear-viewer", MemberRole.CREATOR) + val creator = saveMember("before-rename", MemberRole.CREATOR) + saveAlias(creator, "RetainedAlias", isActive = false) + saveAlias(creator, "EnglishAlias", lang = "EN") + creator.nickname = "after-rename" + entityManager.flush() + entityManager.clear() + + assertEquals(emptyList(), repository.findByNicknameAndOtherCondition("RetainedAlias", requester).map { it.id }) + assertEquals(emptyList(), repository.findByNicknameAndOtherCondition("EnglishAlias", requester).map { it.id }) + assertEquals(listOf(creator.id), repository.findByNicknameAndOtherCondition("after-rename", requester).map { it.id }) + jdbcTemplate.update( + "update creator_localized_name set is_active = true where member_id = ? and lang = 'JA'", + creator.id!! + ) + + assertAll( + { + assertEquals( + listOf(creator.id), + repository.findByNicknameAndOtherCondition("RetainedAlias", requester).map { it.id } + ) + }, + { + assertEquals( + listOf(creator.id), + repository.findByNicknameAndOtherCondition("after-rename", requester).map { it.id } + ) + } + ) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role).also { + entityManager.persist(it) + } + } + + private fun saveAlias(member: Member, displayName: String, isActive: Boolean = true, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName, + isActive + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt index 556af54e..88fbc6f6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt @@ -10,6 +10,7 @@ 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 kr.co.vividnext.sodalive.support.originalLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -62,6 +63,7 @@ class MemberServiceCacheEvictionTest { authenticationManagerBuilder = mock(), messageSource = SodaMessageSource(), langContext = LangContext(), + localizedNameService = originalLocalizedNameService(), countryContext = CountryContext(), memberContentPreferenceService = mock(), objectMapper = ObjectMapper(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt index fe7d6b2d..794b6882 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt @@ -13,6 +13,7 @@ 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 kr.co.vividnext.sodalive.support.originalLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -71,6 +72,7 @@ class MemberServiceContentPreferenceTest { authenticationManagerBuilder = mock(), messageSource = SodaMessageSource(), langContext = LangContext(), + localizedNameService = originalLocalizedNameService(), countryContext = CountryContext(), memberContentPreferenceService = memberContentPreferenceService, objectMapper = ObjectMapper(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt index 76f43e5e..6d1ced60 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt @@ -250,7 +250,7 @@ class OsivLazyLoadingRegressionTest @Autowired constructor( val creators = rankingRepository.getCreatorRankings(memberId = null) - val response = creators.single().toExplorerSectionCreator("https://cdn.test") + val response = creators.single() assertEquals("#creator-tag", response.tags) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..d392b657 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt @@ -0,0 +1,297 @@ +package kr.co.vividnext.sodalive.search + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.member.tag.CreatorTag +import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag +import org.hibernate.SessionFactory +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +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.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none", "spring.jpa.properties.hibernate.generate_statistics=true"]) +@Import(QueryDslConfig::class) +class SearchRepositoryLocalizedNameTest @Autowired constructor( + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate, + queryFactory: JPAQueryFactory +) { + private val repository = SearchRepository(queryFactory, "https://cdn.test") + + @Test + @DisplayName("크리에이터 검색은 원본과 활성 JA 별칭을 함께 찾고 중복 별칭도 회원별로 한 번씩 센다") + fun shouldSearchOriginalOrActiveJaAliasWithDistinctOwnersAndMatchingCount() { + val viewer = saveMember("search-viewer", MemberRole.USER) + val original = saveMember("Orion-original") + val aliasOnly = saveMember("second-original") + saveAlias(original, "Orion Japanese") + saveAlias(aliasOnly, "Orion Japanese") + entityManager.clear() + + val items = repository.searchCreatorList("oRiOn", viewer.id!!, 0, 20) + val page = repository.searchCreatorList("oRiOn", viewer.id!!, 1, 1) + + assertAll( + { assertEquals(2, repository.searchCreatorTotalCount("oRiOn", viewer.id!!)) }, + { assertEquals(listOf(aliasOnly.id, original.id), items.map { it.id }) }, + { assertEquals(listOf(original.id), page.map { it.id }) }, + { + assertEquals( + listOf(original.id), + repository.searchCreatorList("Orion-original", viewer.id!!, 0, 20).map { it.id } + ) + } + ) + } + + @Test + @DisplayName("별칭 검색은 회원 활성·역할·태그 활성·양방향 차단 필터를 우회하지 않는다") + fun shouldPreserveCreatorVisibilityTagAndBidirectionalBlockFiltersForAliasMatches() { + val viewer = saveMember("filter-viewer", MemberRole.USER) + val visible = saveMember("visible-original") + val inactive = saveMember("inactive-original").apply { isActive = false } + val user = saveMember("user-original", MemberRole.USER) + val blockedByViewer = saveMember("blocked-original") + val blockingViewer = saveMember("blocking-original") + val inactiveTagOwner = saveMember("inactive-tag-original") + saveBlock(viewer, blockedByViewer) + saveBlock(blockingViewer, viewer) + val inactiveTag = CreatorTag(tag = "inactive-tag", isActive = false) + entityManager.persist(inactiveTag) + entityManager.persist(MemberCreatorTag(member = inactiveTagOwner, tag = inactiveTag)) + listOf(visible, inactive, user, blockedByViewer, blockingViewer, inactiveTagOwner).forEach { + saveAlias(it, "AliasVisibility") + } + entityManager.clear() + + assertAll( + { assertEquals(1, repository.searchCreatorTotalCount("AliasVisibility", viewer.id!!)) }, + { + assertEquals( + listOf(visible.id), + repository.searchCreatorList("AliasVisibility", viewer.id!!, 0, 20).map { it.id } + ) + } + ) + } + + @Test + @DisplayName("비활성 JA와 다른 언어 별칭은 제외하고 원본 검색과 같은 행 재활성화는 유지한다") + fun shouldExcludeSoftClearedAndNonJaAliasesWithoutLosingOriginalSearch() { + val viewer = saveMember("clear-viewer", MemberRole.USER) + val creator = saveMember("current-original") + val content = saveContent(creator, "clear content title") + val series = saveSeries(creator, "clear series title", content) + saveAlias(creator, "ClearedAlias", isActive = false) + saveAlias(creator, "EnglishAlias", lang = "EN") + entityManager.clear() + + assertEquals(0, repository.searchCreatorTotalCount("ClearedAlias", viewer.id!!)) + assertEquals(emptyList(), repository.searchCreatorList("ClearedAlias", viewer.id!!, 0, 20).map { it.id }) + assertEquals(0, repository.searchCreatorTotalCount("EnglishAlias", viewer.id!!)) + assertEquals(1, repository.searchCreatorTotalCount("current-original", viewer.id!!)) + assertEquals(0, repository.searchContentTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + assertEquals(0, repository.searchSeriesTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + assertEquals(1, repository.searchContentTotalCount("current-original", viewer.id!!, false, ContentType.ALL)) + assertEquals(1, repository.searchSeriesTotalCount("current-original", viewer.id!!, false, ContentType.ALL)) + + repeat(2) { + jdbcTemplate.update( + "update creator_localized_name set is_active = true where member_id = ? and lang = 'JA'", + creator.id!! + ) + assertAll( + { assertEquals(1, repository.searchCreatorTotalCount("ClearedAlias", viewer.id!!)) }, + { assertEquals(1, repository.searchContentTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchSeriesTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) }, + { + assertEquals( + listOf(content.id), + repository.searchContentList("ClearedAlias", viewer.id!!, false, ContentType.ALL, 0, 20).map { it.id } + ) + }, + { + assertEquals( + listOf(series.id), + repository.searchSeriesList("ClearedAlias", viewer.id!!, false, ContentType.ALL, 0, 20).map { it.id } + ) + }, + { + assertEquals( + listOf(creator.id), + repository.searchCreatorList("ClearedAlias", viewer.id!!, 0, 20).map { it.id } + ) + } + ) + jdbcTemplate.update( + "update creator_localized_name set is_active = false where member_id = ? and lang = 'JA'", + creator.id!! + ) + assertEquals(0, repository.searchCreatorTotalCount("ClearedAlias", viewer.id!!)) + assertEquals(0, repository.searchContentTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + assertEquals(0, repository.searchSeriesTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + } + } + + @Test + @DisplayName("콘텐츠 검색은 소유자 별칭을 찾되 성인·활성·완성·한정·차단 필터와 콘텐츠 제목을 보존한다") + fun shouldSearchContentOwnerAliasWithMatchingCountAndExistingContentFilters() { + val viewer = saveMember("content-search-viewer", MemberRole.USER) + val creator = saveMember("audio-owner-original") + val visible = saveContent(creator, "Unchanged audio title") + saveContent(creator, "adult audio").isAdult = true + saveContent(creator, "inactive audio").isActive = false + saveContent(creator, "unfinished audio").duration = null + saveContent(creator, "limited audio", limited = 5) + val blocked = saveMember("blocked-audio-owner") + saveContent(blocked, "blocked audio") + saveBlock(viewer, blocked) + saveAlias(creator, "AudioAlias") + saveAlias(creator, "English Audio Owner", lang = "EN") + saveAlias(blocked, "AudioAlias") + entityManager.clear() + + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val items = repository.searchContentList("AudioAlias", viewer.id!!, false, ContentType.ALL, 0, 20, Lang.JA) + val aliasQueries = statistics.queries.filter { + Regex("""(?is)^select\s+\w+\.memberId,\s*\w+\.displayName\s+from\s+CreatorLocalizedNameEntity\b""") + .containsMatchIn(it) + } + val aliasQueryCount = aliasQueries.sumOf { statistics.getQueryStatistics(it).executionCount } + val queryCount = statistics.queryExecutionCount + + assertAll( + { assertEquals(1, repository.searchContentTotalCount("AudioAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(listOf(visible.id), items.map { it.id }) }, + { assertEquals(listOf("Unchanged audio title"), items.map { it.title }) }, + { assertEquals(listOf("AudioAlias"), items.map { it.nickname }) }, + { assertEquals(1, repository.searchContentTotalCount("audio-owner-original", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchContentTotalCount("Unchanged audio title", viewer.id!!, false, ContentType.ALL)) } + ) + assertEquals(0L, aliasQueryCount, "Standalone alias SELECTs (total queries=$queryCount): $aliasQueries") + val englishItems = repository.searchContentList("AudioAlias", viewer.id!!, false, ContentType.ALL, 0, 20, Lang.EN) + assertEquals(listOf("English Audio Owner"), englishItems.map { it.nickname }) + assertEquals(0, repository.searchContentTotalCount("English Audio Owner", viewer.id!!, false, ContentType.ALL)) + } + + @Test + @DisplayName("시리즈 검색은 시리즈 소유자 별칭을 찾고 콘텐츠 조인 중복과 기존 공개 필터를 보존한다") + fun shouldSearchSeriesOwnerAliasWithoutDuplicatingJoinedContentsOrChangingTitles() { + val viewer = saveMember("series-search-viewer", MemberRole.USER) + val creator = saveMember("series-owner-original") + val audioOwner = saveMember("different-audio-owner") + val visible = saveSeries(creator, "Unchanged series title", saveContent(audioOwner, "first episode")) + saveSeriesContent(visible, saveContent(audioOwner, "second episode")) + saveSeries(creator, "adult series", saveContent(creator, "public episode")).isAdult = true + saveSeries(creator, "adult episode series", saveContent(creator, "adult episode").apply { isAdult = true }) + saveSeries(creator, "inactive series", saveContent(creator, "active episode")).isActive = false + saveSeries(creator, "unfinished series", saveContent(creator, "unfinished episode").apply { duration = null }) + val blocked = saveMember("blocked-series-owner") + saveSeries(blocked, "blocked series", saveContent(blocked, "blocked episode")) + saveBlock(blocked, viewer) + saveAlias(creator, "SeriesAlias") + saveAlias(audioOwner, "EpisodeOwnerAlias") + saveAlias(blocked, "SeriesAlias") + entityManager.clear() + + val items = repository.searchSeriesList("SeriesAlias", viewer.id!!, false, ContentType.ALL, 0, 20) + + assertAll( + { assertEquals(1, repository.searchSeriesTotalCount("SeriesAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(listOf(visible.id), items.map { it.id }) }, + { assertEquals(listOf("Unchanged series title"), items.map { it.title }) }, + { assertEquals(0, repository.searchSeriesTotalCount("EpisodeOwnerAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchSeriesTotalCount("series-owner-original", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchSeriesTotalCount("Unchanged series title", viewer.id!!, false, ContentType.ALL)) } + ) + } + + private fun saveMember(nickname: String, role: MemberRole = MemberRole.CREATOR): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role + ).also { entityManager.persist(it) } + } + + private fun saveAlias(member: Member, displayName: String, isActive: Boolean = true, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName, + isActive + ) + } + + private fun saveBlock(member: Member, blockedMember: Member) { + entityManager.persist( + BlockMember(isActive = true).apply { + this.member = member + this.blockedMember = blockedMember + } + ) + } + + private fun saveContent(creator: Member, title: String, limited: Int? = null): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "description", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1), + limited = limited + ).apply { + member = creator + this.theme = theme + isActive = true + coverImage = "audio.png" + duration = "00:10:00" + entityManager.persist(this) + } + } + + private fun saveSeries(creator: Member, title: String, content: AudioContent): Series { + val genre = SeriesGenre(genre = "genre-$title", isAdult = false, isActive = true) + entityManager.persist(genre) + return Series(title = title, introduction = "introduction", languageCode = "ko").apply { + member = creator + this.genre = genre + coverImage = "series.png" + entityManager.persist(this) + saveSeriesContent(this, content) + } + } + + private fun saveSeriesContent(series: Series, content: AudioContent) { + entityManager.persist( + SeriesContent().apply { + this.series = series + this.content = content + } + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt index 47366f99..e9e3ebd7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.search import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName @@ -9,7 +10,7 @@ import org.mockito.Mockito class SearchServiceTest { private val repository: SearchRepository = Mockito.mock(SearchRepository::class.java) - private val service = SearchService(repository) + private val service = SearchService(repository, LangContext()) @Test @DisplayName("콘텐츠 검색은 전달받은 isAdult 값을 그대로 사용한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/LegacyCreatorNameTestConfiguration.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/LegacyCreatorNameTestConfiguration.kt new file mode 100644 index 00000000..c50424b0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/LegacyCreatorNameTestConfiguration.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.support + +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.DefaultCreatorLocalizedNameRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService +import org.mockito.Mockito +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Import + +@TestConfiguration +@Import( + CreatorLocalizedNameService::class, + DefaultCreatorLocalizedNameRepository::class +) +class LegacyCreatorNameTestConfiguration + +fun originalLocalizedNameService(): CreatorLocalizedNameService = + Mockito.mock(CreatorLocalizedNameService::class.java) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt index e3978075..ecc42b79 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt @@ -132,6 +132,7 @@ class LegacyCommunityPostCharacterizationTest { assertEquals(FcmEventType.CHANGE_NOTICE, event.type) assertEquals(PushNotificationCategory.COMMUNITY, event.category) assertEquals("creator.community.fcm.new_post", event.messageKey) + assertEquals(creator.id, event.titleMemberId) assertEquals(creator.id, event.creatorId) assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue) assertEquals(creator.id, event.deepLinkId) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt index 8e3755f6..9f146e9a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt @@ -22,6 +22,7 @@ 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 kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull @@ -42,6 +43,7 @@ class LegacyFanTalkReplyCharacterizationTest { private lateinit var cheersRepository: CreatorCheersRepository private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var messageSource: SodaMessageSource + private lateinit var localizedNameService: CreatorLocalizedNameService private lateinit var service: ExplorerService @BeforeEach @@ -51,6 +53,7 @@ class LegacyFanTalkReplyCharacterizationTest { cheersRepository = Mockito.mock(CreatorCheersRepository::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) messageSource = Mockito.mock(SodaMessageSource::class.java) + localizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java) service = ExplorerService( memberService = memberService, memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), @@ -66,6 +69,7 @@ class LegacyFanTalkReplyCharacterizationTest { contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), messageSource = messageSource, langContext = LangContext().apply { setLang(Lang.KO) }, + localizedNameService = localizedNameService, cloudFrontHost = "https://cdn.test" ) } @@ -177,6 +181,8 @@ class LegacyFanTalkReplyCharacterizationTest { val root = createCheers(id = 300L, content = "root", member = creator, creator = creator) Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) Mockito.`when`(queryRepository.getCreatorCheers(root.id!!)).thenReturn(root) + Mockito.`when`(localizedNameService.findLocalizedNames(listOf(creator.id!!), Lang.KO)) + .thenReturn(mapOf(creator.id!! to "Korean creator alias")) var nextId = 301L Mockito.`when`(cheersRepository.save(Mockito.any(CreatorCheers::class.java))).thenAnswer { invocation -> invocation.getArgument(0).also { cheers -> @@ -185,7 +191,7 @@ class LegacyFanTalkReplyCharacterizationTest { } } - service.writeCheers( + val response = service.writeCheers( PostWriteCheersRequest(root.id, creator.id!!, "first reply", "ko"), creator ) @@ -198,6 +204,8 @@ class LegacyFanTalkReplyCharacterizationTest { Mockito.verify(cheersRepository, Mockito.times(2)).save(cheersCaptor.capture()) assertEquals(listOf("first reply", "second reply"), cheersCaptor.allValues.map { it.cheers }) assertTrue(cheersCaptor.allValues.all { it.parent === root }) + assertEquals("duplicate-creator", response.writerNickname) + Mockito.verifyNoInteractions(localizedNameService) } @Test @@ -222,6 +230,8 @@ class LegacyFanTalkReplyCharacterizationTest { Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) Mockito.`when`(memberService.isBlocked(writer.id!!, creator.id!!)).thenReturn(true) Mockito.`when`(messageSource.getMessage("explorer.creator.blocked_cheers", Lang.KO)).thenReturn("blocked %s") + Mockito.`when`(localizedNameService.findLocalizedNames(listOf(creator.id!!), Lang.KO)) + .thenReturn(mapOf(creator.id!! to "Korean blocked alias")) val exception = assertThrows(SodaException::class.java) { service.writeCheers(PostWriteCheersRequest(creatorId = creator.id!!, content = "reply"), writer) @@ -229,6 +239,7 @@ class LegacyFanTalkReplyCharacterizationTest { assertNull(exception.messageKey) assertEquals("blocked blocked-creator", exception.message) + Mockito.verifyNoInteractions(localizedNameService) Mockito.verifyNoInteractions(cheersRepository, applicationEventPublisher) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt index c65901dc..65d65777 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.content.all.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.configs.SecurityConfig +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.jwt.JwtAccessDeniedHandler @@ -14,6 +15,7 @@ import kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFac import kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponse import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -41,6 +43,11 @@ class MainContentAllControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -57,7 +64,7 @@ class MainContentAllControllerTest @Autowired constructor( @DisplayName("전체 탭 조회는 비회원에게 200 OK와 기본 응답을 반환한다") fun shouldAllowAnonymousAndUseDefaultType() { Mockito.doReturn(response(MainContentAllType.AUDIO, ContentSort.LATEST)).`when`(facade) - .getContents(null, null, null, null, null, null) + .getContents(null, null, null, null, null, null, Lang.KO) mockMvc.perform(get("/api/v2/audio/contents")) .andExpect(status().isOk) @@ -76,7 +83,7 @@ class MainContentAllControllerTest @Autowired constructor( role = MemberRole.USER ).apply { id = 10L } Mockito.doReturn(response(MainContentAllType.SERIES, ContentSort.POPULAR)).`when`(facade) - .getContents("SERIES", "POPULAR", "MON", 1, 30, member) + .getContents("SERIES", "POPULAR", "MON", 1, 30, member, Lang.KO) mockMvc.perform( get("/api/v2/audio/contents") @@ -92,7 +99,7 @@ class MainContentAllControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.type").value("SERIES")) .andExpect(jsonPath("$.data.sort").value("POPULAR")) - Mockito.verify(facade).getContents("SERIES", "POPULAR", "MON", 1, 30, member) + Mockito.verify(facade).getContents("SERIES", "POPULAR", "MON", 1, 30, member, Lang.KO) } private fun response(type: MainContentAllType, sort: ContentSort): MainContentAllTabResponse { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt index be6caf15..f7a03958 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt @@ -10,12 +10,16 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDays import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.nullValue +import org.hibernate.SessionFactory +import org.junit.jupiter.api.Assertions.assertEquals 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.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc @@ -30,6 +34,7 @@ import javax.persistence.EntityManager properties = [ "cloud.aws.cloud-front.host=https://cdn.test", "spring.cache.type=none", + "spring.jpa.properties.hibernate.generate_statistics=true", "spring.datasource.url=jdbc:h2:mem:main-content-all-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" ] ) @@ -39,8 +44,107 @@ import javax.persistence.EntityManager class MainContentAllEndToEndTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager, - private val transactionTemplate: TransactionTemplate + private val transactionTemplate: TransactionTemplate, + private val jdbcTemplate: JdbcTemplate ) { + @Test + @DisplayName("전체 콘텐츠 API는 JA 별칭을 우선하고 KO 별칭 미등록 시 일본어 원본을, 별칭 없는 작성자는 현재 원본을 반환한다") + fun shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles() { + transactionTemplate.executeWithoutResult { saveMember("main-all-resource-id-control", MemberRole.USER) } + val localized = createAudioFixture("main-all-localized") + val fallback = createAudioFixture("main-all-fallback") + transactionTemplate.executeWithoutResult { + jdbcTemplate.update( + """ + insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) + values (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.trimIndent(), + localized.creatorId, + "日本語クリエイター" + ) + entityManager.find(Member::class.java, localized.creatorId).nickname = "改名さくら" + } + + mockMvc.perform(get("/api/v2/audio/contents").param("type", "AUDIO").header("Accept-Language", "ko")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect( + jsonPath("$.data.audios[?(@.audioContentId == ${localized.audioContentId})].creatorNickname") + .value(contains("改名さくら")) + ) + + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + mockMvc.perform(get("/api/v2/audio/contents").param("type", "AUDIO").header("Accept-Language", "ja")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect( + jsonPath("$.data.audios[?(@.audioContentId == ${localized.audioContentId})].creatorNickname") + .value(contains("日本語クリエイター")) + ) + .andExpect( + jsonPath("$.data.audios[?(@.audioContentId == ${fallback.audioContentId})].creatorNickname") + .value(contains("main-all-fallback-creator")) + ) + .andExpect( + jsonPath("$.data.audios[?(@.audioContentId == ${localized.audioContentId})].title") + .value(contains("main-all-localized-audio")) + ) + .andExpect(jsonPath("$.data.audios[0].creatorId").doesNotExist()) + .andExpect(jsonPath("$.data.audios[0].creatorMemberId").doesNotExist()) + + val aliasQueries = statistics.queries.filter { + Regex("""(?is)^select\s+\w+\.memberId,\s*\w+\.displayName\s+from\s+CreatorLocalizedNameEntity\b""") + .containsMatchIn(it) + } + val aliasQueryCount = aliasQueries.sumOf { statistics.getQueryStatistics(it).executionCount } + assertEquals( + 0L, + aliasQueryCount, + "Standalone alias SELECTs (total queries=${statistics.queryExecutionCount}): $aliasQueries" + ) + } + + @Test + @DisplayName("영어 활성 이름도 JOIN으로 읽으며 비활성 이름은 원본으로 fallback하고 인기순 집계를 유지한다") + fun shouldReadActiveEnglishCreatorNameAndFallbackWhenInactive() { + val active = createAudioFixture("main-all-english-active") + val inactive = createAudioFixture("main-all-english-inactive") + transactionTemplate.executeWithoutResult { + listOf(Triple(active.creatorId, "English creator", true), Triple(inactive.creatorId, "Inactive name", false)) + .forEach { (creatorId, name, isActive) -> + jdbcTemplate.update( + """ + insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) + values (?, 'EN', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.trimIndent(), + creatorId, + name, + isActive + ) + } + } + + mockMvc.perform( + get("/api/v2/audio/contents").param("type", "AUDIO").param("sort", "POPULAR").header("Accept-Language", "en") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.audios.length()").value(2)) + .andExpect( + jsonPath("$.data.audios[?(@.audioContentId == ${active.audioContentId})].creatorNickname") + .value(contains("English creator")) + ) + .andExpect( + jsonPath("$.data.audios[?(@.audioContentId == ${inactive.audioContentId})].creatorNickname") + .value(contains("main-all-english-inactive-creator")) + ) + .andExpect( + jsonPath("$.data.audios[?(@.audioContentId == ${active.audioContentId})].title") + .value(contains("main-all-english-active-audio")) + ) + } + @Test @DisplayName("전체 탭 AUDIO API는 controller-service-repository를 거쳐 오디오 응답과 빈 series를 반환한다") fun shouldReturnAudioContentsThroughControllerServiceAndRepository() { @@ -196,7 +300,7 @@ class MainContentAllEndToEndTest @Autowired constructor( entityManager.flush() entityManager.clear() - AudioFixture(audioContentId = audio.id!!) + AudioFixture(audioContentId = audio.id!!, creatorId = creator.id!!) }!! } @@ -308,7 +412,8 @@ class MainContentAllEndToEndTest @Autowired constructor( } private data class AudioFixture( - val audioContentId: Long + val audioContentId: Long, + val creatorId: Long ) private data class SeriesFixture( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt index fa18262b..16b16725 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.content.all.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.ContentSort @@ -36,7 +37,7 @@ class MainContentAllFacadeTest { page = MainContentPage(1, 30), hasNext = false ) - ).`when`(queryService).getContents("FREE", "PRICE_LOW", "MON", 1, 30, member) + ).`when`(queryService).getContents("FREE", "PRICE_LOW", "MON", 1, 30, member, Lang.KO) val response = facade.getContents( type = "FREE", @@ -44,10 +45,11 @@ class MainContentAllFacadeTest { dayOfWeek = "MON", page = 1, size = 30, - member = member + member = member, + lang = Lang.KO ) - Mockito.verify(queryService).getContents("FREE", "PRICE_LOW", "MON", 1, 30, member) + Mockito.verify(queryService).getContents("FREE", "PRICE_LOW", "MON", 1, 30, member, Lang.KO) assertEquals(MainContentAllType.FREE, response.type) assertEquals(ContentSort.PRICE_LOW, response.sort) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt index d240725c..698e0c51 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt @@ -27,6 +27,7 @@ class MainContentAllTabResponseTest { audios = emptyList(), series = listOf( MainContentAllSeries( + creatorMemberId = 100L, seriesId = 10L, title = "시리즈", coverImageUrl = "https://cdn/series.jpg", @@ -58,6 +59,7 @@ class MainContentAllTabResponseTest { totalCount = 1, audios = listOf( MainContentAllAudio( + creatorMemberId = 100L, audioContentId = 1L, title = "audio", imageUrl = "https://cdn/audio.jpg", @@ -69,6 +71,7 @@ class MainContentAllTabResponseTest { creatorNickname = "creator" ), MainContentAllAudio( + creatorMemberId = 100L, audioContentId = 2L, title = "free audio", imageUrl = "https://cdn/free-audio.jpg", @@ -80,6 +83,7 @@ class MainContentAllTabResponseTest { creatorNickname = "creator" ), MainContentAllAudio( + creatorMemberId = 100L, audioContentId = 3L, title = "point unavailable audio", imageUrl = "https://cdn/point-unavailable-audio.jpg", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt index 8babe2a8..b84149d1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.configs.SecurityConfig +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.jwt.JwtAccessDeniedHandler @@ -13,6 +14,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacade import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponse import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -41,6 +43,11 @@ class ContentOverviewControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -62,7 +69,7 @@ class ContentOverviewControllerTest @Autowired constructor( fun shouldPassAuthenticatedMemberAndQueryParameters() { val member = member(id = 10L) Mockito.doReturn(emptyResponse(ContentOverviewType.FIRST_AUDIO_CONTENT)).`when`(facade) - .getContents(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(member)) + .getContents(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(member), eqValue(Lang.KO)) mockMvc.perform( get("/api/v2/contents") @@ -75,7 +82,8 @@ class ContentOverviewControllerTest @Autowired constructor( .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT")) - Mockito.verify(facade).getContents(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(member)) + Mockito.verify(facade) + .getContents(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(member), eqValue(Lang.KO)) } private fun eqValue(value: T): T { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt index b8fc896f..ffe45558 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.content.overview.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService @@ -31,9 +32,9 @@ class ContentOverviewFacadeTest { fun shouldReturnNewAndHotPage() { val member = member(id = 10L) Mockito.doReturn((1L..21L).map { audioCard(it) }).`when`(audioRecommendationQueryService) - .findNewAndHotAudios(member, offset = 0L, limit = 21) + .findNewAndHotAudios(member, offset = 0L, limit = 21, lang = Lang.KO) - val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 20, member = member) + val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 20, member = member, lang = Lang.KO) assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type) assertEquals((1L..20L).toList(), response.items.map { it.contentId }) @@ -54,10 +55,11 @@ class ContentOverviewFacadeTest { eqValue(20L), eqValue(21), eqValue(member.id), - eqValue(true) + eqValue(true), + eqValue(Lang.KO) ) - val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member) + val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member, lang = Lang.KO) assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, response.type) assertEquals(listOf(1L, 2L), response.items.map { it.contentId }) @@ -79,6 +81,7 @@ class ContentOverviewFacadeTest { private fun audioCard(id: Long): AudioCard { return AudioCard( + creatorMemberId = id + 100L, audioContentId = id, title = "audio$id", duration = "00:01", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt index 848ba7bd..bf8ed8bb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt @@ -120,6 +120,7 @@ class ContentOverviewPageResponseTest { private fun audioCard(price: Int, isPointAvailable: Boolean): AudioCard { return AudioCard( + creatorMemberId = 10L, audioContentId = price.toLong() + 1, title = "audio", duration = "00:01", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt index 85c5178c..1200d5ca 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.content.ranking.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryService import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking @@ -23,6 +24,7 @@ class AudioRankingFacadeTest { type = AudioRankingType.RISING, items = listOf( AudioRankingItem( + creatorMemberId = 10L, contentId = 1L, title = "audio", creatorNickname = "creator", @@ -33,11 +35,11 @@ class AudioRankingFacadeTest { ) ) ) - Mockito.doReturn(ranking).`when`(queryService).getRankings(AudioRankingType.RISING, member) + Mockito.doReturn(ranking).`when`(queryService).getRankings(AudioRankingType.RISING, member, Lang.KO) - val response = facade.getRankings(AudioRankingType.RISING, member) + val response = facade.getRankings(AudioRankingType.RISING, member, Lang.KO) - Mockito.verify(queryService).getRankings(AudioRankingType.RISING, member) + Mockito.verify(queryService).getRankings(AudioRankingType.RISING, member, Lang.KO) assertEquals(true, response.showRankChange) assertEquals(AudioRankingType.RISING, response.type) assertEquals(1, response.items.size) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt index 65528537..6d7f5a86 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt @@ -19,6 +19,7 @@ class AudioRankingResponseTest { type = AudioRankingType.RISING, items = listOf( AudioRankingItem( + creatorMemberId = 10L, contentId = 1001L, title = "rising audio", creatorNickname = "creator", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt index d2abfd98..50992a80 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.configs.SecurityConfig +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.jwt.JwtAccessDeniedHandler @@ -12,6 +13,7 @@ import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacade import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -39,6 +41,11 @@ class AudioRecommendationControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -54,7 +61,7 @@ class AudioRecommendationControllerTest @Autowired constructor( @Test @DisplayName("오디오 추천 조회는 비회원에게 200 OK와 ApiResponse.ok wrapper를 반환한다") fun shouldReturnRecommendationsForAnonymous() { - Mockito.doReturn(emptyResponse()).`when`(facade).getRecommendations(null) + Mockito.doReturn(emptyResponse()).`when`(facade).getRecommendations(null, Lang.KO) mockMvc.perform(get("/api/v2/audio/recommendations")) .andExpect(status().isOk) @@ -74,13 +81,13 @@ class AudioRecommendationControllerTest @Autowired constructor( nickname = "viewer", role = MemberRole.USER ).apply { id = 10L } - Mockito.doReturn(emptyResponse()).`when`(facade).getRecommendations(eqValue(member)) + Mockito.doReturn(emptyResponse()).`when`(facade).getRecommendations(eqValue(member), eqValue(Lang.KO)) mockMvc.perform(get("/api/v2/audio/recommendations").with(user(MemberAdapter(member)))) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - Mockito.verify(facade).getRecommendations(eqValue(member)) + Mockito.verify(facade).getRecommendations(eqValue(member), eqValue(Lang.KO)) } private fun emptyResponse(): AudioRecommendationsResponse { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt index b2d79d53..55799fd0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.application import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard @@ -20,9 +21,9 @@ class AudioRecommendationFacadeTest { @Test @DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다") fun shouldConvertDomainRecommendationsToResponse() { - Mockito.doReturn(domain()).`when`(queryService).getRecommendations(null) + Mockito.doReturn(domain()).`when`(queryService).getRecommendations(null, Lang.KO) - val response = facade.getRecommendations(null) + val response = facade.getRecommendations(null, Lang.KO) assertEquals(1, response.banners.size) assertEquals(1, response.originalSeries.size) @@ -49,6 +50,7 @@ class AudioRecommendationFacadeTest { private fun domain(): AudioRecommendations { val card = AudioCard( + creatorMemberId = 10L, audioContentId = 1L, title = "audio", duration = "00:01", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt index a817086a..de3fc657 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +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.Member @@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelA import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioThemeResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -47,6 +49,11 @@ class CreatorChannelAudioControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -88,7 +95,8 @@ class CreatorChannelAudioControllerTest @Autowired constructor( eqValue(null), eqValue(null), eqValue(null), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -118,7 +126,8 @@ class CreatorChannelAudioControllerTest @Autowired constructor( eqValue(null), eqValue(null), eqValue(null), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } @@ -133,7 +142,8 @@ class CreatorChannelAudioControllerTest @Autowired constructor( eqValue(999L), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -158,7 +168,8 @@ class CreatorChannelAudioControllerTest @Autowired constructor( eqValue(999L), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt index 42e673bb..982bd733 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.ContentSort @@ -33,7 +34,8 @@ class CreatorChannelAudioFacadeTest { themeId = 10L, page = 0, size = 20, - now = now + now = now, + lang = Lang.KO ) val response = facade.getAudioTab( @@ -43,7 +45,8 @@ class CreatorChannelAudioFacadeTest { themeId = 10L, page = 0, size = 20, - now = now + now = now, + lang = Lang.KO ) assertEquals(3, response.audioContentCount) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt index ce9c67b4..cd6b6433 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +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.Member @@ -14,6 +15,7 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChan import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityReplyResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -50,6 +52,11 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -89,7 +96,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -117,7 +125,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } @@ -130,7 +139,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -148,7 +158,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } @@ -169,7 +180,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( Mockito.doReturn(createDetailResponse()).`when`(facade).getCommunityPostDetail( eqValue(101L), eqValue(viewer), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -195,7 +207,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(0), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -222,7 +235,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -240,7 +254,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } @@ -253,7 +268,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(0), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -278,7 +294,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -296,7 +313,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( eqValue(viewer), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt index f0303992..17758620 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt @@ -22,6 +22,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc @@ -44,7 +45,8 @@ import javax.persistence.EntityManager class CreatorChannelCommunityEndToEndTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager, - private val transactionTemplate: TransactionTemplate + private val transactionTemplate: TransactionTemplate, + private val jdbcTemplate: JdbcTemplate ) { @MockBean private lateinit var audioContentCloudFront: AudioContentCloudFront @@ -52,6 +54,67 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( @MockBean private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService + @Test + @DisplayName("커뮤니티 상세의 JA 이름은 크리에이터 댓글과 최신 답글에만 적용하고 USER 이름은 유지한다") + fun shouldLocalizeCreatorAuthorsAndLatestRepliesWithoutChangingUserNames() { + val fixture = transactionTemplate.execute { + val viewer = saveMember("community-localized-viewer", MemberRole.USER) + val creator = saveMember("community-localized-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity(creator, isFixed = false, price = 0, content = "localized-name community") + val userComment = saveCommunityComment(viewer, post) + val creatorComment = saveCommunityComment(creator, post) + saveCommunityComment(creator, post, parent = userComment) + entityManager.flush() + listOf(creator.id!! to "日本語の投稿者", viewer.id!! to "USERには使わない名前").forEach { (memberId, name) -> + jdbcTemplate.update( + """ + insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) + values (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.trimIndent(), + memberId, + name + ) + } + updateCommentCreatedAt(userComment.id!!, LocalDateTime.of(2026, 10, 14, 10, 0)) + updateCommentCreatedAt(creatorComment.id!!, LocalDateTime.of(2026, 10, 14, 11, 0)) + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.creatorNickname").value("日本語の投稿者")) + .andExpect(jsonPath("$.data.content").value("localized-name community")) + .andExpect(jsonPath("$.data.comments.comments.length()").value(2)) + .andExpect(jsonPath("$.data.comments.comments[0].writerNickname").value("日本語の投稿者")) + .andExpect(jsonPath("$.data.comments.comments[1].writerId").value(fixture.viewer.id)) + .andExpect(jsonPath("$.data.comments.comments[1].writerNickname").value("community-localized-viewer")) + .andExpect(jsonPath("$.data.comments.comments[1].latestReply.writerNickname").value("日本語の投稿者")) + + transactionTemplate.executeWithoutResult { + jdbcTemplate.update( + "update creator_localized_name set display_name = ? where member_id = ? and lang = 'JA'", + "deleted_表示名そのまま", + entityManager.find(CreatorCommunity::class.java, fixture.postId).member!!.id!! + ) + } + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.creatorNickname").value("deleted_表示名そのまま")) + .andExpect(jsonPath("$.data.comments.comments[0].writerNickname").value("deleted_表示名そのまま")) + .andExpect(jsonPath("$.data.comments.comments[1].latestReply.writerNickname").value("deleted_表示名そのまま")) + } + @Test @DisplayName("커뮤니티 탭 API는 E2E로 정렬, fallback, 성인 필터, 유료 미디어 접근 정책을 반환한다") fun shouldReturnCommunityTabThroughControllerServiceAndRepository() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt index ef2dec13..db011e51 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse @@ -71,7 +72,8 @@ class CreatorChannelCommunityFacadeTest { viewer = viewer, page = -1, size = 100, - now = now + now = now, + lang = Lang.KO ) val response = facade.getCommunityTab( @@ -79,7 +81,8 @@ class CreatorChannelCommunityFacadeTest { viewer = viewer, page = -1, size = 100, - now = now + now = now, + lang = Lang.KO ) assertEquals(2, response.communityPostCount) @@ -99,9 +102,9 @@ class CreatorChannelCommunityFacadeTest { val facade = CreatorChannelCommunityFacade(service) val viewer = createMember(id = 10L) val now = LocalDateTime.of(2026, 7, 6, 12, 0) - Mockito.doReturn(createDetail()).`when`(service).getCommunityPostDetail(101L, viewer, now) + Mockito.doReturn(createDetail()).`when`(service).getCommunityPostDetail(101L, viewer, now, Lang.KO) - val response = facade.getCommunityPostDetail(101L, viewer, now) + val response = facade.getCommunityPostDetail(101L, viewer, now, Lang.KO) assertEquals(101L, response.postId) assertTrue(response.isLiked) @@ -119,9 +122,9 @@ class CreatorChannelCommunityFacadeTest { val facade = CreatorChannelCommunityFacade(service) val viewer = createMember(id = 10L) val now = LocalDateTime.of(2026, 7, 6, 12, 0) - Mockito.doReturn(createComments()).`when`(service).getCommunityComments(101L, viewer, -1, 100, now) + Mockito.doReturn(createComments()).`when`(service).getCommunityComments(101L, viewer, -1, 100, now, Lang.KO) - val response = facade.getCommunityComments(101L, viewer, -1, 100, now) + val response = facade.getCommunityComments(101L, viewer, -1, 100, now, Lang.KO) assertEquals(1, response.commentCount) assertEquals(0, response.page) @@ -139,9 +142,9 @@ class CreatorChannelCommunityFacadeTest { val facade = CreatorChannelCommunityFacade(service) val viewer = createMember(id = 10L) val now = LocalDateTime.of(2026, 7, 6, 12, 0) - Mockito.doReturn(createReplies()).`when`(service).getCommunityReplies(201L, viewer, -1, 100, now) + Mockito.doReturn(createReplies()).`when`(service).getCommunityReplies(201L, viewer, -1, 100, now, Lang.KO) - val response = facade.getCommunityReplies(201L, viewer, -1, 100, now) + val response = facade.getCommunityReplies(201L, viewer, -1, 100, now, Lang.KO) assertEquals(1, response.replyCount) assertEquals(201L, response.replies.first().commentId) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt index addcd495..540e730a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +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.Member @@ -10,6 +11,7 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.Crea import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.MemberDonationRankingResponse +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -46,6 +48,11 @@ class CreatorChannelDonationControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -85,7 +92,8 @@ class CreatorChannelDonationControllerTest @Autowired constructor( eqValue(viewer), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -117,7 +125,8 @@ class CreatorChannelDonationControllerTest @Autowired constructor( eqValue(viewer), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt index d04fe573..fd352035 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse @@ -10,6 +11,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChanne import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -57,7 +59,10 @@ class CreatorChannelDonationFacadeTest { @DisplayName("후원 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다") fun shouldMapDonationTabQueryResultToPublicResponse() { val service = Mockito.mock(CreatorChannelDonationQueryService::class.java) - val facade = CreatorChannelDonationFacade(service) + val facade = CreatorChannelDonationFacade( + service, + Mockito.mock(CreatorLocalizedNameService::class.java) + ) val viewer = createMember(id = 10L) val now = LocalDateTime.of(2026, 6, 21, 12, 0) Mockito.doReturn(createTab()).`when`(service).getDonationTab( @@ -65,7 +70,8 @@ class CreatorChannelDonationFacadeTest { viewer = viewer, page = -1, size = 100, - now = now + now = now, + lang = Lang.KO ) val response = facade.getDonationTab( @@ -73,7 +79,8 @@ class CreatorChannelDonationFacadeTest { viewer = viewer, page = -1, size = 100, - now = now + now = now, + lang = Lang.KO ) assertEquals(3, response.donationCount) @@ -106,6 +113,7 @@ class CreatorChannelDonationFacadeTest { ), donations = listOf( CreatorChannelDonation( + memberId = 10L, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", can = 50, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt index ba73eca9..21e058a8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +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.Member @@ -10,6 +11,7 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.Creat import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkReplyResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -46,6 +48,11 @@ class CreatorChannelFanTalkControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -85,7 +92,8 @@ class CreatorChannelFanTalkControllerTest @Autowired constructor( eqValue(viewer), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -111,7 +119,8 @@ class CreatorChannelFanTalkControllerTest @Autowired constructor( eqValue(viewer), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt index f4272a21..8f01caa9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse @@ -59,7 +60,8 @@ class CreatorChannelFanTalkFacadeTest { viewer = viewer, page = -1, size = 100, - now = now + now = now, + lang = Lang.KO ) val response = facade.getFanTalkTab( @@ -67,7 +69,8 @@ class CreatorChannelFanTalkFacadeTest { viewer = viewer, page = -1, size = 100, - now = now + now = now, + lang = Lang.KO ) assertEquals(2, response.fanTalkCount) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt index a95f29f3..8b305a21 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +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.Member @@ -21,6 +22,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLiv import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -57,6 +59,11 @@ class CreatorChannelHomeControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -94,7 +101,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor( Mockito.doReturn(CreatorChannelHomeResponse.from(createHome())).`when`(facade).getHome( Mockito.eq(1L), Mockito.any(Member::class.java) ?: viewer, - Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now(), + Mockito.eq(Lang.KO) ?: Lang.KO ) mockMvc.perform( @@ -147,7 +155,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor( Mockito.verify(facade).getHome( Mockito.eq(1L), Mockito.eq(viewer) ?: viewer, - Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now(), + Mockito.eq(Lang.KO) ?: Lang.KO ) } @@ -158,7 +167,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor( Mockito.doReturn(CreatorChannelHomeResponse.from(createEmptyHome())).`when`(facade).getHome( Mockito.eq(1L), Mockito.any(Member::class.java) ?: viewer, - Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now(), + Mockito.eq(Lang.KO) ?: Lang.KO ) mockMvc.perform( @@ -244,6 +254,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor( ), channelDonations = listOf( CreatorChannelDonation( + memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", can = 50, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt index be855561..9dfb070f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType @@ -39,13 +40,15 @@ class CreatorChannelHomeFacadeTest { Mockito.doReturn(createHome()).`when`(service).getHome( creatorId = 1L, viewer = viewer, - now = now + now = now, + lang = Lang.KO ) val response = facade.getHome( creatorId = 1L, viewer = viewer, - now = now + now = now, + lang = Lang.KO ) assertEquals(1L, response.creator.creatorId) @@ -90,21 +93,23 @@ class CreatorChannelHomeFacadeTest { Mockito.doReturn(createHome()).`when`(service).getHome( Mockito.eq(1L), Mockito.eq(viewer) ?: viewer, - Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now(), + Mockito.eq(Lang.KO) ?: Lang.KO ) try { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1) - facade.getHome(creatorId = 1L, viewer = viewer) + facade.getHome(creatorId = 1L, viewer = viewer, lang = Lang.KO) val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1) val nowCaptor = ArgumentCaptor.forClass(LocalDateTime::class.java) Mockito.verify(service).getHome( Mockito.eq(1L), Mockito.eq(viewer) ?: viewer, - nowCaptor.capture() ?: LocalDateTime.MIN + nowCaptor.capture() ?: LocalDateTime.MIN, + Mockito.eq(Lang.KO) ?: Lang.KO ) val capturedNow = nowCaptor.value assertTrue(!capturedNow.isBefore(beforeUtc), "captured now should be after UTC lower bound") @@ -180,6 +185,7 @@ class CreatorChannelHomeFacadeTest { ), channelDonations = listOf( CreatorChannelDonation( + memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", can = 50, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt index ff50925d..a7de8df0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +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.Member @@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorC import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveTabResponse import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -47,6 +49,11 @@ class CreatorChannelLiveControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -87,7 +94,8 @@ class CreatorChannelLiveControllerTest @Autowired constructor( eqValue(ContentSort.LATEST), eqValue(0), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -112,7 +120,8 @@ class CreatorChannelLiveControllerTest @Autowired constructor( eqValue(ContentSort.LATEST), eqValue(0), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } @@ -127,7 +136,8 @@ class CreatorChannelLiveControllerTest @Autowired constructor( eqValue(ContentSort.LATEST), eqValue(0), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -162,7 +172,8 @@ class CreatorChannelLiveControllerTest @Autowired constructor( eqValue(ContentSort.LATEST), eqValue(-1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -185,7 +196,8 @@ class CreatorChannelLiveControllerTest @Autowired constructor( eqValue(ContentSort.LATEST), eqValue(0), eqValue(0), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt index e17f02a0..d930f17f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.live.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.ContentSort @@ -30,7 +31,8 @@ class CreatorChannelLiveFacadeTest { sort = ContentSort.LATEST, page = 0, size = 20, - now = now + now = now, + lang = Lang.KO ) val response = facade.getLiveTab( @@ -39,7 +41,8 @@ class CreatorChannelLiveFacadeTest { sort = ContentSort.LATEST, page = 0, size = 20, - now = now + now = now, + lang = Lang.KO ) assertEquals(1, response.liveReplayContentCount) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt index 7ad5dc80..9d69ce3f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +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.Member @@ -10,6 +11,7 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.Creato import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -46,6 +48,11 @@ class CreatorChannelSeriesControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -86,7 +93,8 @@ class CreatorChannelSeriesControllerTest @Autowired constructor( eqValue("POPULAR"), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -118,7 +126,8 @@ class CreatorChannelSeriesControllerTest @Autowired constructor( eqValue("POPULAR"), eqValue(1), eqValue(20), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } @@ -132,7 +141,8 @@ class CreatorChannelSeriesControllerTest @Autowired constructor( eqValue("INVALID"), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) mockMvc.perform( @@ -153,7 +163,8 @@ class CreatorChannelSeriesControllerTest @Autowired constructor( eqValue("INVALID"), eqValue(-1), eqValue(100), - anyValue(LocalDateTime.now()) + anyValue(LocalDateTime.now()), + eqValue(Lang.KO) ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt index edc813f0..bc9e0ba8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.series.application import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse @@ -63,7 +64,8 @@ class CreatorChannelSeriesFacadeTest { sort = "OWNED", page = 1, size = 20, - now = now + now = now, + lang = Lang.KO ) val response = facade.getSeriesTab( @@ -72,7 +74,8 @@ class CreatorChannelSeriesFacadeTest { sort = "OWNED", page = 1, size = 20, - now = now + now = now, + lang = Lang.KO ) assertEquals(2, response.seriesCount) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt index 2496fe1f..bb8188b5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt @@ -9,11 +9,13 @@ import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType +import org.junit.jupiter.api.Assertions.assertEquals 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.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc @@ -37,8 +39,51 @@ import javax.persistence.EntityManager class CreatorRankingControllerTest @Autowired constructor( private val mockMvc: MockMvc, private val memberRepository: MemberRepository, - private val entityManager: EntityManager + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate ) { + @Test + @DisplayName("언어 중립 랭킹은 조회 언어의 현재 이름을 반환하고 저장된 스냅샷 이름은 변경하지 않는다") + fun shouldRenderCurrentLocalizedNamesWithoutRewritingRankingSnapshots() { + val localized = saveMember("ranking-localized-before", MemberRole.CREATOR) + val fallback = saveMember("ranking-fallback-before", MemberRole.CREATOR) + saveSnapshot(localized.id!!, "ranking-localized-before", null, rankNo = 1) + saveSnapshot(fallback.id!!, "ranking-fallback-before", null, rankNo = 2) + jdbcTemplate.update( + """ + insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) + values (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.trimIndent(), + localized.id, + "日本語ランキング名" + ) + localized.nickname = "ranking-localized-current" + fallback.nickname = "ranking-fallback-current" + entityManager.flush() + entityManager.clear() + + mockMvc.perform(get("/api/v2/home/rankings/creators").header("Accept-Language", "ja")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items[0].creatorId").value(localized.id)) + .andExpect(jsonPath("$.data.items[0].nickname").value("日本語ランキング名")) + .andExpect(jsonPath("$.data.items[1].creatorId").value(fallback.id)) + .andExpect(jsonPath("$.data.items[1].nickname").value("ranking-fallback-current")) + + mockMvc.perform(get("/api/v2/home/rankings/creators").header("Accept-Language", "ko")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items[0].nickname").value("ranking-localized-current")) + .andExpect(jsonPath("$.data.items[1].nickname").value("ranking-fallback-current")) + + assertEquals( + listOf("ranking-localized-before", "ranking-fallback-before"), + entityManager.createQuery( + "select s.nickname from CreatorRankingSnapshot s order by s.rankNo", + String::class.java + ).resultList + ) + assertEquals("ranking-localized-current", memberRepository.findById(localized.id!!).get().nickname) + } + @Test @DisplayName("크리에이터 랭킹 API는 공개 응답 스키마만 노출하고 내부 스냅샷 필드를 숨긴다") fun shouldReturnOnlyPublicCreatorRankingSchemaThroughEndpoint() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index e46538da..85fec011 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member @@ -35,6 +36,7 @@ import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc @@ -56,11 +58,50 @@ class HomeRecommendationControllerTest @Autowired constructor( private val mockMvc: MockMvc, private val memberRepository: MemberRepository, private val creatorFollowingRepository: CreatorFollowingRepository, - private val entityManager: EntityManager + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate ) { @MockBean private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService + @Test + @DisplayName("native 데뷔 크리에이터 조회도 JA 별칭을 우선하고 KO 별칭 미등록 시 현재 원본 이름을 반환한다") + fun shouldLocalizeNativeDebutCreatorResultsByRequestLanguage() { + val viewer = saveMember("localized-native-viewer", MemberRole.USER) + val creator = saveMember("localized-native-before", MemberRole.CREATOR) + saveLiveRoom(creator, LocalDateTime.now().minusDays(1), "localized-native-live", isAdult = false) + entityManager.flush() + jdbcTemplate.update( + """ + insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) + values (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.trimIndent(), + creator.id, + "日本語デビュー名" + ) + creator.nickname = "localized-native-current" + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/debut-creators") + .header("Accept-Language", "ko") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items[0].creatorId").value(creator.id)) + .andExpect(jsonPath("$.data.items[0].creatorNickname").value("localized-native-current")) + + mockMvc.perform( + get("/api/v2/home/recommendations/debut-creators") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items[0].creatorId").value(creator.id)) + .andExpect(jsonPath("$.data.items[0].creatorNickname").value("日本語デビュー名")) + } + @Test @DisplayName("추천 크리에이터 동시 팔로우 비로그인 요청은 Spring Security에서 거부한다") fun shouldRejectAnonymousFollowRequest() { @@ -328,12 +369,16 @@ class HomeRecommendationControllerTest @Autowired constructor( fun shouldLogHomeRecommendationFailure(output: CapturedOutput) { val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java) val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test") - Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, includeAdultLives = false)) + val facade = HomeRecommendationFacade( + failingQueryService, + preferenceService, + "https://cdn.test" + ) + Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, lang = Lang.KO)) .thenThrow(IllegalStateException("home query failed")) val exception = assertThrows(IllegalStateException::class.java) { - facade.getHomeRecommendations(member = null) + facade.getHomeRecommendations(member = null, lang = Lang.KO) } assertEquals("home query failed", exception.message) @@ -366,7 +411,11 @@ class HomeRecommendationControllerTest @Autowired constructor( val member = saveMember("page-failure-viewer", MemberRole.USER) val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java) val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test") + val facade = HomeRecommendationFacade( + failingQueryService, + preferenceService, + "https://cdn.test" + ) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) Mockito.`when`( failingQueryService.findLiveRecommendations( @@ -375,13 +424,14 @@ class HomeRecommendationControllerTest @Autowired constructor( memberId = member.id, includeAdultLives = false, isViewerCreator = false, - effectiveViewerGender = Gender.NONE + effectiveViewerGender = Gender.NONE, + lang = Lang.KO ) ) .thenThrow(IllegalStateException("page query failed")) val exception = assertThrows(IllegalStateException::class.java) { - facade.getLives(member, page = 0, size = 20) + facade.getLives(member, page = 0, size = 20, lang = Lang.KO) } assertEquals("page query failed", exception.message) @@ -395,7 +445,11 @@ class HomeRecommendationControllerTest @Autowired constructor( val member = saveMember("other-page-failure-viewer", MemberRole.USER) val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java) val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test") + val facade = HomeRecommendationFacade( + failingQueryService, + preferenceService, + "https://cdn.test" + ) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) Mockito.`when`( failingQueryService.findRecentDebutCreators( @@ -403,13 +457,16 @@ class HomeRecommendationControllerTest @Autowired constructor( offset = Mockito.eq(0L), limit = Mockito.eq(21), memberId = Mockito.eq(member.id), - includeAdultContents = Mockito.eq(false) + includeAdultContents = Mockito.eq(false), + lang = Mockito.eq(Lang.KO) ?: Lang.KO ) ).thenThrow(IllegalStateException("debut page failed")) Mockito.`when`(failingQueryService.findAiCharacterRecommendations(offset = 0L, limit = 21)) .thenThrow(IllegalStateException("ai page failed")) - assertThrows(IllegalStateException::class.java) { facade.getRecentDebutCreators(member, page = 0, size = 20) } + assertThrows(IllegalStateException::class.java) { + facade.getRecentDebutCreators(member, page = 0, size = 20, lang = Lang.KO) + } assertThrows(IllegalStateException::class.java) { facade.getAiCharacters(member, page = 0, size = 20) } assertTrue(output.out.contains("section=DEBUT_CREATOR")) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index cd286c06..f8cedadb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.application import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -35,11 +34,14 @@ class HomeRecommendationFacadeTest { private val queryService = HomeRecommendationQueryService( queryPort, EmptyRecommendationSnapshotPort(), - langContext = LangContext(), creatorCommunityTranslationService = creatorCommunityTranslationService ) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test") + private val facade = HomeRecommendationFacade( + queryService, + preferenceService, + "https://cdn.test" + ) @Test @DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다") @@ -50,7 +52,7 @@ class HomeRecommendationFacadeTest { firstAudio(price = 100, isPointAvailable = false) ) - val response = facade.getHomeRecommendations(null) + val response = facade.getHomeRecommendations(null, Lang.KO) assertFalse(response.firstAudioContents[0].isPointAvailable) assertTrue(response.firstAudioContents[1].isPointAvailable) @@ -63,7 +65,7 @@ class HomeRecommendationFacadeTest { val member = Member(password = "password", nickname = "member").apply { id = 10L } Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - facade.getHomeRecommendations(member) + facade.getHomeRecommendations(member, Lang.KO) assertTrue(queryPort.bannerIncludeAdultBanners) } @@ -74,7 +76,7 @@ class HomeRecommendationFacadeTest { val member = Member(password = "password", nickname = "jp-member").apply { id = 11L } Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member) - facade.getHomeRecommendations(member) + facade.getHomeRecommendations(member, Lang.KO) assertTrue(queryPort.bannerLang == Lang.JA) } @@ -84,7 +86,7 @@ class HomeRecommendationFacadeTest { fun shouldPassKoToHomeBannerQueryForNonJapan() { Mockito.doReturn("KR").`when`(preferenceService).resolveCountryCode(null) - facade.getHomeRecommendations(null) + facade.getHomeRecommendations(null, Lang.KO) assertTrue(queryPort.bannerLang == Lang.KO) } @@ -94,7 +96,7 @@ class HomeRecommendationFacadeTest { fun shouldPassCreatorRoleAndAuthGenderToHomeLiveQuery() { val member = createMember(id = 12L, role = MemberRole.CREATOR, gender = Gender.FEMALE, authGender = 1) - facade.getHomeRecommendations(member) + facade.getHomeRecommendations(member, Lang.KO) assertTrue(queryPort.liveIsViewerCreator == true) assertTrue(queryPort.liveEffectiveViewerGender == Gender.MALE) @@ -105,7 +107,7 @@ class HomeRecommendationFacadeTest { fun shouldPassCreatorRoleAndMemberGenderToLivePageQuery() { val member = createMember(id = 13L, role = MemberRole.CREATOR, gender = Gender.FEMALE) - facade.getLives(member, page = 0, size = 20) + facade.getLives(member, page = 0, size = 20, lang = Lang.KO) assertTrue(queryPort.liveIsViewerCreator == true) assertTrue(queryPort.liveEffectiveViewerGender == Gender.FEMALE) @@ -153,7 +155,8 @@ class HomeRecommendationFacadeTest { memberId: Long?, includeAdultLives: Boolean, isViewerCreator: Boolean, - effectiveViewerGender: Gender? + effectiveViewerGender: Gender?, + lang: Lang ): List { liveIsViewerCreator = isViewerCreator liveEffectiveViewerGender = effectiveViewerGender @@ -174,7 +177,8 @@ class HomeRecommendationFacadeTest { override fun findRecentlyActiveCreators( limit: Int, memberId: Long?, - includeAdultActivities: Boolean + includeAdultActivities: Boolean, + lang: Lang ): List = emptyList() override fun findRecentDebutCreators( @@ -182,7 +186,8 @@ class HomeRecommendationFacadeTest { offset: Long, limit: Int, memberId: Long?, - includeAdultContents: Boolean + includeAdultContents: Boolean, + lang: Lang ): List = emptyList() override fun findFirstAudioContents( @@ -220,20 +225,23 @@ class HomeRecommendationFacadeTest { override fun findCheerCreatorRecommendationDetails( creatorIds: List, - memberId: Long? + memberId: Long?, + lang: Lang ): List = emptyList() override fun findPopularCommunityRecommendationDetails( communityIds: List, memberId: Long?, - includeAdultCommunities: Boolean + includeAdultCommunities: Boolean, + lang: Lang ): List = emptyList() override fun findGenreCreatorRecommendations( memberId: Long?, includeAdultGenres: Boolean, genreLimit: Int, - creatorLimit: Int + creatorLimit: Int, + lang: Lang ): List = emptyList() } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt index 853d8231..cadc182f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.configs.SecurityConfig +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.jwt.JwtAccessDeniedHandler @@ -12,6 +13,7 @@ import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -39,6 +41,11 @@ class HomeFollowingControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -54,7 +61,7 @@ class HomeFollowingControllerTest @Autowired constructor( @Test @DisplayName("팔로잉 탭 조회는 비회원에게 200 OK와 로그인 필요 응답을 반환한다") fun shouldReturnLoginRequiredForAnonymous() { - Mockito.doReturn(HomeFollowingTabResponse.loginRequired()).`when`(facade).getFollowingTab(null) + Mockito.doReturn(HomeFollowingTabResponse.loginRequired()).`when`(facade).getFollowingTab(null, Lang.KO) mockMvc.perform(get("/api/v2/home/following")) .andExpect(status().isOk) @@ -76,14 +83,14 @@ class HomeFollowingControllerTest @Autowired constructor( nickname = "viewer", role = MemberRole.USER ).apply { id = 10L } - Mockito.doReturn(loggedInEmptyResponse()).`when`(facade).getFollowingTab(eqValue(member)) + Mockito.doReturn(loggedInEmptyResponse()).`when`(facade).getFollowingTab(eqValue(member), eqValue(Lang.KO)) mockMvc.perform(get("/api/v2/home/following").with(user(MemberAdapter(member)))) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.isLoginRequired").value(false)) - Mockito.verify(facade).getFollowingTab(eqValue(member)) + Mockito.verify(facade).getFollowingTab(eqValue(member), eqValue(Lang.KO)) } private fun loggedInEmptyResponse(): HomeFollowingTabResponse { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt index a4f36fc1..7b699107 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.following.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse @@ -24,12 +25,15 @@ import org.mockito.Mockito class HomeFollowingFacadeTest { private val queryService = Mockito.mock(HomeFollowingQueryService::class.java) private val chatRoomListService = Mockito.mock(ChatRoomListService::class.java) - private val facade = HomeFollowingFacade(queryService, chatRoomListService) + private val facade = HomeFollowingFacade( + queryService, + chatRoomListService + ) @Test @DisplayName("비로그인 회원은 로그인 필요 응답을 반환하고 조회/채팅 서비스를 호출하지 않는다") fun shouldReturnLoginRequiredWithoutCallingServicesForAnonymous() { - val response = facade.getFollowingTab(null) + val response = facade.getFollowingTab(null, Lang.KO) assertTrue(response.isLoginRequired) assertTrue(response.followingCreators.isEmpty()) @@ -58,12 +62,12 @@ class HomeFollowingFacadeTest { lastMessage = "hello", lastMessageAt = "2026-06-25T01:00:00Z" ) - Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member) + Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member, Lang.KO) Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null)) .`when`(chatRoomListService) - .getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) + .getRooms(member, Lang.KO, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) - val response = facade.getFollowingTab(member) + val response = facade.getFollowingTab(member, Lang.KO) assertFalse(response.isLoginRequired) assertEquals(1L, response.followingCreators.single().creatorId) @@ -71,9 +75,9 @@ class HomeFollowingFacadeTest { assertEquals(listOf(recentChat), response.recentChats) assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId) assertEquals("news-5", response.recentNews.single().newsId) - Mockito.verify(queryService).findHomeFollowing(member) + Mockito.verify(queryService).findHomeFollowing(member, Lang.KO) Mockito.verify(chatRoomListService) - .getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) + .getRooms(member, Lang.KO, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) } private fun homeFollowing(): HomeFollowing { @@ -87,6 +91,7 @@ class HomeFollowingFacadeTest { ), onAirLives = listOf( HomeFollowingLive( + creatorMemberId = 1L, liveId = 2L, creatorProfileImageUrl = "https://cdn.test/live.png", creatorNickname = "creator", @@ -110,6 +115,7 @@ class HomeFollowingFacadeTest { ), recentNews = listOf( HomeFollowingNews( + creatorMemberId = 1L, newsId = "news-5", type = FollowingNewsType.CREATOR_RANKING, visibleFromAtUtc = "2026-06-25T04:00:00Z", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt index 93e5cd5c..db506366 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt @@ -115,6 +115,7 @@ class HomeFollowingTabResponseTest { followingCreators = listOf(HomeFollowingCreator(1L, "creator", "https://cdn/profile.jpg")), onAirLives = listOf( HomeFollowingLive( + creatorMemberId = 101L, liveId = 10L, creatorProfileImageUrl = "https://cdn/live-profile.jpg", creatorNickname = "live-creator", @@ -147,6 +148,7 @@ class HomeFollowingTabResponseTest { ), recentNews = listOf( HomeFollowingNews( + creatorMemberId = 1L, newsId = "30", type = FollowingNewsType.CREATOR_RANKING, visibleFromAtUtc = "2026-06-25T09:00:00Z", @@ -158,6 +160,7 @@ class HomeFollowingTabResponseTest { ) ), HomeFollowingNews( + creatorMemberId = 2L, newsId = "31", type = FollowingNewsType.AUDIO_CONTENT, visibleFromAtUtc = "2026-06-26T00:00:00Z", @@ -170,6 +173,7 @@ class HomeFollowingTabResponseTest { ) ), HomeFollowingNews( + creatorMemberId = 3L, newsId = "32", type = FollowingNewsType.PHOTO_CONTENT, visibleFromAtUtc = "2026-06-27T00:00:00Z", @@ -182,6 +186,7 @@ class HomeFollowingTabResponseTest { ) ), HomeFollowingNews( + creatorMemberId = 4L, newsId = "33", type = FollowingNewsType.CONTENT_RANKING, visibleFromAtUtc = "2026-06-28T00:00:00Z", @@ -193,6 +198,7 @@ class HomeFollowingTabResponseTest { ) ), HomeFollowingNews( + creatorMemberId = 5L, newsId = "34", type = FollowingNewsType.COMMUNITY_POST, visibleFromAtUtc = "2026-06-25T03:00:00Z", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt index 2330d108..2a11ce23 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.configs.SecurityConfig +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.jwt.JwtAccessDeniedHandler @@ -13,6 +14,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacade import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -41,6 +43,11 @@ class HomeOnAirLiveControllerTest @Autowired constructor( @MockBean private lateinit var langContext: LangContext + @BeforeEach + fun setUpLanguage() { + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + } + @MockBean private lateinit var sodaMessageSource: SodaMessageSource @@ -61,7 +68,7 @@ class HomeOnAirLiveControllerTest @Autowired constructor( @DisplayName("현재 진행 중인 라이브 조회는 인증 회원과 page를 facade에 전달하고 성공 응답을 반환한다") fun shouldPassAuthenticatedMemberAndPageToFacade() { val member = createMember(100L) - Mockito.doReturn(createResponse()).`when`(facade).getOnAirLives(eqValue(member), eqValue(2)) + Mockito.doReturn(createResponse()).`when`(facade).getOnAirLives(eqValue(member), eqValue(2), eqValue(Lang.KO)) mockMvc.perform( get("/api/v2/home/on-air-lives") @@ -73,7 +80,7 @@ class HomeOnAirLiveControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.items[0].title").value("paid live")) - Mockito.verify(facade).getOnAirLives(eqValue(member), eqValue(2)) + Mockito.verify(facade).getOnAirLives(eqValue(member), eqValue(2), eqValue(Lang.KO)) } private fun createResponse() = HomeOnAirLivePageResponse( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt index db138bec..1d46b041 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.live.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -16,7 +17,11 @@ import java.time.LocalDateTime class HomeOnAirLiveFacadeTest { private val queryService = Mockito.mock(HomeRecommendationQueryService::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - private val facade = HomeOnAirLiveFacade(queryService, preferenceService, "https://cdn.test") + private val facade = HomeOnAirLiveFacade( + queryService, + preferenceService, + "https://cdn.test" + ) @Test @DisplayName("현재 진행 중인 라이브 facade는 20개 고정 page와 hasNext를 조립한다") @@ -29,10 +34,11 @@ class HomeOnAirLiveFacadeTest { eqValue(member.id), eqValue(true), eqValue(false), - eqValue(Gender.NONE) + eqValue(Gender.NONE), + eqValue(Lang.KO) ) - val response = facade.getOnAirLives(member, page = 0) + val response = facade.getOnAirLives(member, page = 0, lang = Lang.KO) assertEquals(0, response.page) assertEquals(20, response.size) @@ -44,7 +50,8 @@ class HomeOnAirLiveFacadeTest { eqValue(member.id), eqValue(true), eqValue(false), - eqValue(Gender.NONE) + eqValue(Gender.NONE), + eqValue(Lang.KO) ) } @@ -59,10 +66,11 @@ class HomeOnAirLiveFacadeTest { eqValue(member.id), eqValue(false), eqValue(false), - eqValue(Gender.NONE) + eqValue(Gender.NONE), + eqValue(Lang.KO) ) - val response = facade.getOnAirLives(member, page = 0) + val response = facade.getOnAirLives(member, page = 0, lang = Lang.KO) assertEquals("https://cdn.test/profile/default-profile.png", response.items.single().creatorProfileImage) } @@ -79,10 +87,11 @@ class HomeOnAirLiveFacadeTest { eqValue(member.id), eqValue(false), eqValue(false), - eqValue(Gender.NONE) + eqValue(Gender.NONE), + eqValue(Lang.KO) ) - val response = facade.getOnAirLives(member, page = 0) + val response = facade.getOnAirLives(member, page = 0, lang = Lang.KO) assertEquals("2026-06-26T12:30:00Z", response.items.single().beginDateTimeUtc) } @@ -98,10 +107,11 @@ class HomeOnAirLiveFacadeTest { eqValue(member.id), eqValue(true), eqValue(true), - eqValue(Gender.MALE) + eqValue(Gender.MALE), + eqValue(Lang.KO) ) - facade.getOnAirLives(member, page = 0) + facade.getOnAirLives(member, page = 0, lang = Lang.KO) Mockito.verify(queryService).findLiveRecommendations( eqValue(0L), @@ -109,7 +119,8 @@ class HomeOnAirLiveFacadeTest { eqValue(member.id), eqValue(true), eqValue(true), - eqValue(Gender.MALE) + eqValue(Gender.MALE), + eqValue(Lang.KO) ) } @@ -118,6 +129,7 @@ class HomeOnAirLiveFacadeTest { creatorProfileImage: String? = "profile.png", beginDateTime: LocalDateTime = LocalDateTime.of(2026, 6, 26, 12, 30) ) = HomeLiveRecommendationRecord( + creatorMemberId = id + 100L, liveRoomId = id, creatorNickname = "creator-$id", creatorProfileImage = creatorProfileImage, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt index 6af6d61f..6926e865 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.v2.chat +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.chat.controller.ChatRoomListController import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse @@ -21,7 +23,7 @@ class ChatRoomListControllerTest { @BeforeEach fun setUp() { service = Mockito.mock(ChatRoomListService::class.java) - controller = ChatRoomListController(service) + controller = ChatRoomListController(service, LangContext()) } @Test @@ -45,12 +47,12 @@ class ChatRoomListControllerTest { hasMore = false, nextCursor = null ) - Mockito.`when`(service.getRooms(member, "DM", "cursor", 10)).thenReturn(serviceResponse) + Mockito.`when`(service.getRooms(member, Lang.KO, "DM", "cursor", 10)).thenReturn(serviceResponse) val response = controller.getRooms(member = member, filter = "DM", cursor = "cursor", limit = 10) assertTrue(response.success) assertEquals(serviceResponse, response.data) - Mockito.verify(service).getRooms(member, "DM", "cursor", 10) + Mockito.verify(service).getRooms(member, Lang.KO, "DM", "cursor", 10) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt index f2547819..a240923f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.chat import kr.co.vividnext.sodalive.chat.room.ChatMessageType import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService @@ -55,7 +56,7 @@ class ChatRoomListServiceTest { ) ) Mockito.`when`( - dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31)) + dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, Lang.KO, PageRequest.of(0, 31)) ).thenReturn( listOf( ChatRoomListQueryDto( @@ -70,7 +71,7 @@ class ChatRoomListServiceTest { ) ) - val response = service.getRooms(member, filter = "ALL", cursor = null, limit = 30) + val response = service.getRooms(member, Lang.KO, filter = "ALL", cursor = null, limit = 30) assertFalse(response.hasMore) assertEquals(listOf("DM", "AI"), response.rooms.map { it.chatType }) @@ -85,7 +86,7 @@ class ChatRoomListServiceTest { fun shouldReturnOnlyDmRoomsWithVoicePreview() { val member = member(1L) Mockito.`when`( - dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31)) + dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, Lang.KO, PageRequest.of(0, 31)) ).thenReturn( listOf( ChatRoomListQueryDto( @@ -100,7 +101,7 @@ class ChatRoomListServiceTest { ) ) - val response = service.getRooms(member, filter = "DM", cursor = null, limit = 30) + val response = service.getRooms(member, Lang.KO, filter = "DM", cursor = null, limit = 30) assertEquals(1, response.rooms.size) assertEquals("DM", response.rooms[0].chatType) @@ -128,7 +129,7 @@ class ChatRoomListServiceTest { aiRoomRepository.findAiChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31)) ).thenReturn(rows) - val response = service.getRooms(member, filter = "AI", cursor = null, limit = 30) + val response = service.getRooms(member, Lang.KO, filter = "AI", cursor = null, limit = 30) assertEquals(30, response.rooms.size) assertTrue(response.hasMore) @@ -157,7 +158,7 @@ class ChatRoomListServiceTest { ) ) Mockito.`when`( - dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, false, PageRequest.of(0, 31)) + dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, false, Lang.KO, PageRequest.of(0, 31)) ).thenReturn( listOf( ChatRoomListQueryDto( @@ -181,7 +182,7 @@ class ChatRoomListServiceTest { ) ) - val response = service.getRooms(member, filter = "ALL", cursor = "2026-05-14T12:00:00:DM:20", limit = 30) + val response = service.getRooms(member, Lang.KO, filter = "ALL", cursor = "2026-05-14T12:00:00:DM:20", limit = 30) assertEquals(listOf(30L, 10L), response.rooms.map { it.roomId }) assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl) @@ -194,13 +195,13 @@ class ChatRoomListServiceTest { val pageable = PageRequest.of(0, 11) Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, true, pageable)) .thenReturn(emptyList()) - Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, true, pageable)) + Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, true, Lang.KO, pageable)) .thenReturn(emptyList()) - service.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) + service.getRooms(member, Lang.KO, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) Mockito.verify(aiRoomRepository).findAiChatListRooms(1L, null, null, null, true, pageable) - Mockito.verify(dmRoomRepository).findDmChatListRooms(1L, null, null, null, true, pageable) + Mockito.verify(dmRoomRepository).findDmChatListRooms(1L, null, null, null, true, Lang.KO, pageable) } private fun member(id: Long) = Member(password = "pw", nickname = "user").apply { this.id = id } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt index f8691bb0..2fdbdaa1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt @@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.v2.content.all.application import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService @@ -27,7 +26,15 @@ class MainContentAllQueryServiceTest { val port = FakeMainContentAllQueryPort() val service = createService(port) - val tab = service.getContents(type = "AUDIO", sort = "POPULAR", dayOfWeek = "MON", page = 1, size = 20, member = null) + val tab = service.getContents( + type = "AUDIO", + sort = "POPULAR", + dayOfWeek = "MON", + page = 1, + size = 20, + member = null, + lang = Lang.EN + ) assertEquals(MainContentAllType.AUDIO, tab.type) assertEquals("audio", port.lastListKind) @@ -50,7 +57,15 @@ class MainContentAllQueryServiceTest { val port = FakeMainContentAllQueryPort() val service = createService(port) - val tab = service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null) + val tab = service.getContents( + type = "FREE", + sort = "LATEST", + dayOfWeek = null, + page = 0, + size = 20, + member = null, + lang = Lang.EN + ) assertEquals(MainContentAllType.FREE, tab.type) assertEquals("audio", port.lastListKind) @@ -69,7 +84,15 @@ class MainContentAllQueryServiceTest { val port = FakeMainContentAllQueryPort() val service = createService(port) - val tab = service.getContents(type = "POINT", sort = "PRICE_LOW", dayOfWeek = null, page = 0, size = 20, member = null) + val tab = service.getContents( + type = "POINT", + sort = "PRICE_LOW", + dayOfWeek = null, + page = 0, + size = 20, + member = null, + lang = Lang.EN + ) assertEquals(MainContentAllType.POINT, tab.type) assertEquals("audio", port.lastListKind) @@ -87,9 +110,9 @@ class MainContentAllQueryServiceTest { @DisplayName("SERIES는 요일을 전달하고 ORIGINAL은 original 필터와 dayOfWeek null을 전달한다") fun shouldQuerySeriesByType() { val seriesPort = FakeMainContentAllQueryPort() - val service = createService(seriesPort, lang = Lang.JA) + val service = createService(seriesPort) - val seriesTab = service.getContents("SERIES", "POPULAR", "MON", 0, 20, null) + val seriesTab = service.getContents("SERIES", "POPULAR", "MON", 0, 20, null, Lang.JA) assertEquals(MainContentAllType.SERIES, seriesTab.type) assertEquals("series", seriesPort.lastListKind) @@ -100,7 +123,7 @@ class MainContentAllQueryServiceTest { val originalPort = FakeMainContentAllQueryPort() val originalService = createService(originalPort) - val originalTab = originalService.getContents("ORIGINAL", "POPULAR", "MON", 0, 20, null) + val originalTab = originalService.getContents("ORIGINAL", "POPULAR", "MON", 0, 20, null, Lang.EN) assertEquals(MainContentAllType.ORIGINAL, originalTab.type) assertEquals("series", originalPort.lastListKind) @@ -112,7 +135,7 @@ class MainContentAllQueryServiceTest { @DisplayName("비회원은 성인 콘텐츠 비노출로 조회하고 회원은 preference 결과를 전달한다") fun shouldPassAdultVisibilityByMember() { val anonymousPort = FakeMainContentAllQueryPort() - createService(anonymousPort).getContents("AUDIO", null, null, null, null, null) + createService(anonymousPort).getContents("AUDIO", null, null, null, null, null, Lang.EN) assertEquals(null, anonymousPort.lastMemberId) assertFalse(anonymousPort.lastCanViewAdultContent) @@ -127,7 +150,7 @@ class MainContentAllQueryServiceTest { val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - createService(memberPort, preferenceService).getContents("AUDIO", null, null, null, null, member) + createService(memberPort, preferenceService).getContents("AUDIO", null, null, null, null, member, Lang.EN) assertEquals(10L, memberPort.lastMemberId) assertTrue(memberPort.lastCanViewAdultContent) @@ -136,16 +159,12 @@ class MainContentAllQueryServiceTest { private fun createService( port: MainContentAllQueryPort, - preferenceService: MemberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), - lang: Lang = Lang.EN + preferenceService: MemberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) ): MainContentAllQueryService { - val langContext = LangContext() - langContext.setLang(lang) return MainContentAllQueryService( queryPort = port, memberContentPreferenceService = preferenceService, - queryPolicy = MainContentAllQueryPolicy(), - langContext = langContext + queryPolicy = MainContentAllQueryPolicy() ) } } @@ -204,6 +223,7 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort { lastOnlyPointAvailable = onlyPointAvailable return (1L..limit.toLong()).map { id -> MainContentAllAudio( + creatorMemberId = 10L, audioContentId = id, title = "audio-$id", imageUrl = null, @@ -253,6 +273,7 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort { lastLocale = locale return listOf( MainContentAllSeries( + creatorMemberId = 10L, seriesId = 1L, title = "series", coverImageUrl = null, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt index d5f4f013..98ffc534 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence +import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload @@ -31,7 +32,11 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( private val entityManager: EntityManager, private val entityManagerFactory: EntityManagerFactory ) { - private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository, contentTranslationRepository) + private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter( + repository, + contentTranslationRepository, + JPAQueryFactory(entityManager) + ) @Test @DisplayName("SQL NULL 번역 payload는 snapshot 제목으로 fallback한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt index 6ee488fe..186ae6ad 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.content.ranking.application import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType @@ -35,14 +34,11 @@ class AudioRankingQueryServiceTest { "en" to originalLatestSnapshots.map { it.copy(title = "en-title-${it.contentId}") }, "ja" to originalLatestSnapshots.map { it.copy(title = "ja-title-${it.contentId}") } ) - val langContext = LangContext().apply { setLang(Lang.EN) } - val service = service(snapshotPort, langContext = langContext) + val service = service(snapshotPort) - val english = service.getRankings(AudioRankingType.REVENUE, member = null) - langContext.setLang(Lang.JA) - val japanese = service.getRankings(AudioRankingType.REVENUE, member = null) - langContext.setLang(Lang.EN) - val englishAgain = service.getRankings(AudioRankingType.REVENUE, member = null) + val english = service.getRankings(AudioRankingType.REVENUE, member = null, lang = Lang.EN) + val japanese = service.getRankings(AudioRankingType.REVENUE, member = null, lang = Lang.JA) + val englishAgain = service.getRankings(AudioRankingType.REVENUE, member = null, lang = Lang.EN) assertEquals(listOf("en-title-2", "en-title-1"), english.items.map { it.title }) assertEquals(listOf("ja-title-2", "ja-title-1"), japanese.items.map { it.title }) @@ -64,7 +60,8 @@ class AudioRankingQueryServiceTest { val method = AudioRankingQueryService::class.java.getDeclaredMethod( "getRankings", AudioRankingType::class.java, - Member::class.java + Member::class.java, + Lang::class.java ) assertEquals(null, method.getAnnotation(Transactional::class.java)) @@ -84,7 +81,7 @@ class AudioRankingQueryServiceTest { ) val service = service(snapshotPort) - val result = service.getRankings(AudioRankingType.REVENUE, member = null) + val result = service.getRankings(AudioRankingType.REVENUE, member = null, lang = Lang.KO) assertTrue(result.showRankChange) assertEquals(AudioRankingType.REVENUE, result.type) @@ -106,7 +103,7 @@ class AudioRankingQueryServiceTest { snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1)) val service = service(snapshotPort) - val result = service.getRankings(AudioRankingType.WEEKLY_POPULAR, member = null) + val result = service.getRankings(AudioRankingType.WEEKLY_POPULAR, member = null, lang = Lang.KO) assertFalse(result.showRankChange) assertEquals(listOf(1L), result.items.map { it.contentId }) @@ -116,7 +113,11 @@ class AudioRankingQueryServiceTest { @Test fun shouldReturnEmptyRankingWhenLatestSnapshotDoesNotExist() { - val result = service(FakeAudioRankingQuerySnapshotPort()).getRankings(AudioRankingType.LIKE_COUNT, member = null) + val result = service(FakeAudioRankingQuerySnapshotPort()).getRankings( + AudioRankingType.LIKE_COUNT, + member = null, + lang = Lang.KO + ) assertFalse(result.showRankChange) assertEquals(AudioRankingType.LIKE_COUNT, result.type) @@ -136,7 +137,7 @@ class AudioRankingQueryServiceTest { snapshot(contentId = 2L, rank = 2) ) - val result = service(snapshotPort).getRankings(AudioRankingType.REVENUE, member = null) + val result = service(snapshotPort).getRankings(AudioRankingType.REVENUE, member = null, lang = Lang.KO) assertEquals(listOf(2L, 3L), result.items.map { it.contentId }) assertEquals(listOf(1, 2), result.items.map { it.rank }) @@ -150,7 +151,7 @@ class AudioRankingQueryServiceTest { val member = Mockito.mock(Member::class.java) snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1, isAdult = true)) - val result = service(snapshotPort, adultMember = member).getRankings(AudioRankingType.REVENUE, member) + val result = service(snapshotPort, adultMember = member).getRankings(AudioRankingType.REVENUE, member, Lang.KO) assertEquals(listOf(1L), result.items.map { it.contentId }) } @@ -170,7 +171,7 @@ class AudioRankingQueryServiceTest { snapshot(contentId = 1L, rank = 2, creatorMemberId = 101L) ) - val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member) + val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member, Lang.KO) assertEquals(listOf(1L, 3L), result.items.map { it.contentId }) assertEquals(listOf(1, 2), result.items.map { it.rank }) @@ -185,7 +186,11 @@ class AudioRankingQueryServiceTest { val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(101L)) snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L)) - val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member = null) + val result = service(snapshotPort, blockPort = blockPort).getRankings( + AudioRankingType.REVENUE, + member = null, + lang = Lang.KO + ) assertEquals(listOf(1L), result.items.map { it.contentId }) assertEquals(0, blockPort.callCount) @@ -200,7 +205,11 @@ class AudioRankingQueryServiceTest { listOf(snapshot(contentId = 1L, rank = 1)) ) - val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null) + val result = service(snapshotPort, jobService = jobService).getRankings( + AudioRankingType.LIKE_COUNT, + member = null, + lang = Lang.KO + ) assertEquals(listOf(1L), result.items.map { it.contentId }) assertEquals(2, snapshotPort.latestCallCount) @@ -214,7 +223,11 @@ class AudioRankingQueryServiceTest { Mockito.doThrow(IllegalStateException("aggregate failed")) .`when`(jobService).refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT) - val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null) + val result = service(snapshotPort, jobService = jobService).getRankings( + AudioRankingType.LIKE_COUNT, + member = null, + lang = Lang.KO + ) assertFalse(result.showRankChange) assertEquals(AudioRankingType.LIKE_COUNT, result.type) @@ -230,7 +243,7 @@ class AudioRankingQueryServiceTest { val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1)) - service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.REVENUE, member = null) + service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.REVENUE, member = null, lang = Lang.KO) Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback(AudioRankingType.REVENUE) } @@ -250,7 +263,7 @@ class AudioRankingQueryServiceTest { snapshot(contentId = 2L, rank = 3, creatorMemberId = 102L) ) - val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member) + val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member, Lang.KO) assertEquals(setOf(101L, 102L, 999L), blockPort.creatorMemberIds) assertEquals(listOf(0, 0), result.items.map { it.rankChange }) @@ -261,8 +274,7 @@ class AudioRankingQueryServiceTest { snapshotPort: FakeAudioRankingQuerySnapshotPort, adultMember: Member? = null, blockPort: AudioRankingBlockPort = FakeAudioRankingBlockPort(), - jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java), - langContext: LangContext = LangContext() + jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) ): AudioRankingQueryService { val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) if (adultMember != null) { @@ -270,7 +282,6 @@ class AudioRankingQueryServiceTest { } return AudioRankingQueryService( snapshotPort = snapshotPort, - langContext = langContext, memberContentPreferenceService = memberContentPreferenceService, blockPort = blockPort, jobService = jobService, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt index e333b67d..5f773f85 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService @@ -20,12 +19,10 @@ class AudioRecommendationQueryServiceTest { private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) private val fallbackService = Mockito.mock(AudioRecommendationSnapshotFallbackService::class.java) - private val langContext = LangContext().apply { setLang(Lang.EN) } private val service = AudioRecommendationQueryService( queryPort, preferenceService, - fallbackService, - langContext + fallbackService ) @Test @@ -63,7 +60,7 @@ class AudioRecommendationQueryServiceTest { emptyList() ) - val recommendations = service.getRecommendations(null) + val recommendations = service.getRecommendations(null, Lang.EN) assertEquals(0, recommendations.mostCommentedAudios.size) verifyFallback( @@ -134,7 +131,7 @@ class AudioRecommendationQueryServiceTest { listOf(snapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, 2L)) ) - service.getRecommendations(null) + service.getRecommendations(null, Lang.EN) verifyFallback( RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, @@ -156,7 +153,7 @@ class AudioRecommendationQueryServiceTest { listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)) ) - service.getRecommendations(member) + service.getRecommendations(member, Lang.EN) Mockito.verify(preferenceService).canViewAdultContent(member) Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, member.id, true, Lang.JA) @@ -180,7 +177,7 @@ class AudioRecommendationQueryServiceTest { listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)) ) - service.getRecommendations(member) + service.getRecommendations(member, Lang.EN) verifyFallback( RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, @@ -209,7 +206,7 @@ class AudioRecommendationQueryServiceTest { eqValue(Lang.EN.code) ) - val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21) + val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21, lang = Lang.EN) assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId }) verifyFallback(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) @@ -261,6 +258,7 @@ class AudioRecommendationQueryServiceTest { private fun audioCard(id: Long): AudioCard { return AudioCard( + creatorMemberId = id + 100L, audioContentId = id, title = "audio$id", duration = "00:01", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt index 13753396..f69d32e8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt @@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember @@ -62,7 +63,7 @@ class DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor( saveBlock(creator, viewer) flushAndClear() - val record = repository.findCreator(creator.id!!, viewer.id!!) + val record = repository.findCreator(creator.id!!, viewer.id!!, Lang.KO) val themes = repository.findAudioThemes(creator.id!!, now, canViewAdultContent = false, "en") assertEquals(creator.id, record!!.creatorId) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt index 6ad0f695..c053f495 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt @@ -45,7 +45,8 @@ class CreatorChannelAudioQueryServiceTest { themeId = 999L, page = -1, size = 100, - now = now + now = now, + lang = Lang.EN ) assertEquals(ContentSort.LATEST, tab.sort) @@ -78,7 +79,7 @@ class CreatorChannelAudioQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L) - val tab = service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + val tab = service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0), Lang.EN) assertEquals(0.0, tab.purchasedAudioContentRate) } @@ -91,7 +92,7 @@ class CreatorChannelAudioQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0), Lang.EN) } assertEquals("member.validation.user_not_found", exception.messageKey) @@ -105,7 +106,7 @@ class CreatorChannelAudioQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0), Lang.EN) } assertEquals("member.validation.creator_not_found", exception.messageKey) @@ -119,7 +120,7 @@ class CreatorChannelAudioQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0), Lang.EN) } assertNull(exception.messageKey) @@ -191,7 +192,7 @@ private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort { var themeCanViewAdultContent: Boolean? = null var themeLocale: String? = null - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? = creator + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelAudioCreatorRecord? = creator override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt index 33ab5835..5f4486dc 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole @@ -46,8 +47,8 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( val inactiveCreator = saveMember("inactive-creator", MemberRole.CREATOR, isActive = false) flushAndClear() - val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!) - val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!) + val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!, Lang.KO) + val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!, Lang.KO) assertNotNull(activeRecord) assertEquals(activeCreator.id, activeRecord!!.creatorId) @@ -92,7 +93,8 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = viewer.id!!, canViewAdultContent = false, offset = 0, - limit = 10 + limit = 10, + lang = Lang.KO ) val visiblePostIds = visiblePosts.map { it.postId } @@ -125,14 +127,16 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = viewer.id!!, canViewAdultContent = true, offset = 0, - limit = 3 + limit = 3, + lang = Lang.KO ) val secondPage: List = repository.findCommunityPosts( creatorId = creator.id!!, viewerId = viewer.id!!, canViewAdultContent = true, offset = 3, - limit = 2 + limit = 2, + lang = Lang.KO ) assertEquals( @@ -168,7 +172,8 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = viewer.id!!, canViewAdultContent = true, offset = 0, - limit = 10 + limit = 10, + lang = Lang.KO ) val record = records.single { it.postId == post.id } val otherMemberOnlyLikedRecord = records.single { it.postId == otherMemberOnlyLikedPost.id } @@ -203,21 +208,24 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = viewer.id!!, canViewAdultContent = true, offset = 0, - limit = 10 + limit = 10, + lang = Lang.KO ).single() val writerRecord = repository.findCommunityPosts( creatorId = creator.id!!, viewerId = secretWriter.id!!, canViewAdultContent = true, offset = 0, - limit = 10 + limit = 10, + lang = Lang.KO ).single() val creatorRecord = repository.findCommunityPosts( creatorId = creator.id!!, viewerId = creator.id!!, canViewAdultContent = true, offset = 0, - limit = 10 + limit = 10, + lang = Lang.KO ).single() assertEquals(1, viewerRecord.commentCount) @@ -247,7 +255,8 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = viewer.id!!, canViewAdultContent = true, offset = 0, - limit = 10 + limit = 10, + lang = Lang.KO ) val recordsById = records.associateBy { it.postId } @@ -271,7 +280,8 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = creator.id!!, canViewAdultContent = true, offset = 0, - limit = 10 + limit = 10, + lang = Lang.KO ).single() assertTrue(record.existOrdered) @@ -297,14 +307,16 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = viewer.id!!, isPinned = true, canViewAdultContent = false, - limit = 3 + limit = 3, + lang = Lang.KO ) val normalPosts: List = repository.findHomeCommunityPosts( creatorId = creator.id!!, viewerId = viewer.id!!, isPinned = false, canViewAdultContent = false, - limit = 3 + limit = 3, + lang = Lang.KO ) assertEquals(listOf(pinned.id), pinnedPosts.map { it.postId }) @@ -337,14 +349,16 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( viewerId = viewer.id!!, isPinned = false, canViewAdultContent = true, - limit = 10 + limit = 10, + lang = Lang.KO ) val adultBlockedPosts: List = repository.findHomeCommunityPosts( creatorId = creator.id!!, viewerId = viewer.id!!, isPinned = false, canViewAdultContent = false, - limit = 10 + limit = 10, + lang = Lang.KO ) assertTrue(inactivePurchasedPost.id in adultAllowedPosts.map { it.postId }) @@ -368,9 +382,19 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( saveCommunityComment(viewer, paidPost, isActive = true) flushAndClear() - val record = repository.findCommunityPost(paidPost.id!!, viewer.id!!, canViewAdultContent = true) - val blockedAdultRecord = repository.findCommunityPost(adultPost.id!!, viewer.id!!, canViewAdultContent = false) - val inactiveRecord = repository.findCommunityPost(inactivePost.id!!, viewer.id!!, canViewAdultContent = true) + val record = repository.findCommunityPost(paidPost.id!!, viewer.id!!, canViewAdultContent = true, lang = Lang.KO) + val blockedAdultRecord = repository.findCommunityPost( + adultPost.id!!, + viewer.id!!, + canViewAdultContent = false, + lang = Lang.KO + ) + val inactiveRecord = repository.findCommunityPost( + inactivePost.id!!, + viewer.id!!, + canViewAdultContent = true, + lang = Lang.KO + ) assertNotNull(record) assertEquals(paidPost.id, record!!.postId) @@ -412,8 +436,15 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( val viewerCount = repository.countCommunityComments(post.id!!, viewer.id!!, creator.id!!) val creatorCount = repository.countCommunityComments(post.id!!, creator.id!!, creator.id!!) - val comments = repository.findCommunityComments(post.id!!, viewer.id!!, creator.id!!, offset = 0, limit = 10) - val latestReplies = repository.findLatestRepliesByCommentIds(comments.map { it.commentId }, viewer.id!!) + val comments = repository.findCommunityComments( + post.id!!, + viewer.id!!, + creator.id!!, + offset = 0, + limit = 10, + lang = Lang.KO + ) + val latestReplies = repository.findLatestRepliesByCommentIds(comments.map { it.commentId }, viewer.id!!, Lang.KO) assertEquals(2, viewerCount) assertEquals(4, creatorCount) @@ -448,7 +479,7 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( val invisibleSecretContext = repository.findRootCommentContext(secretRoot.id!!, viewer.id!!, canViewAdultContent = true) val replyContext = repository.findRootCommentContext(newReply.id!!, viewer.id!!, canViewAdultContent = true) val count = repository.countCommunityReplies(root.id!!, viewer.id!!) - val replies = repository.findCommunityReplies(root.id!!, viewer.id!!, offset = 0, limit = 10) + val replies = repository.findCommunityReplies(root.id!!, viewer.id!!, offset = 0, limit = 10, lang = Lang.KO) assertNotNull(context) assertEquals(post.id, context!!.postId) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt index 2a6b8dc9..0229172d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt @@ -47,7 +47,8 @@ class CreatorChannelCommunityQueryServiceTest { viewer = viewer, page = -1, size = 100, - now = now + now = now, + lang = Lang.EN ) assertEquals(60, tab.communityPostCount) @@ -72,7 +73,7 @@ class CreatorChannelCommunityQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) } assertEquals("member.validation.user_not_found", exception.messageKey) @@ -86,7 +87,7 @@ class CreatorChannelCommunityQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) } assertEquals("member.validation.creator_not_found", exception.messageKey) @@ -100,7 +101,7 @@ class CreatorChannelCommunityQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) } assertNull(exception.messageKey) @@ -129,7 +130,7 @@ class CreatorChannelCommunityQueryServiceTest { val service = createService(port, audioContentCloudFront) val viewer = createMember(id = 10L) - val posts = service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + val posts = service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) .communityPosts assertEquals("https://cdn.test/image/1.png", posts[0].imageUrl) @@ -165,7 +166,8 @@ class CreatorChannelCommunityQueryServiceTest { viewerId = 10L, isPinned = true, canViewAdultContent = false, - limit = 3 + limit = 3, + lang = Lang.KO ) assertEquals(1, posts.size) @@ -203,7 +205,7 @@ class CreatorChannelCommunityQueryServiceTest { ) ) - val posts = service.getCommunityTab(1L, createMember(id = 10L), 0, 20).communityPosts + val posts = service.getCommunityTab(1L, createMember(id = 10L), 0, 20, lang = Lang.EN).communityPosts assertEquals( listOf( @@ -230,7 +232,7 @@ class CreatorChannelCommunityQueryServiceTest { val returnedPostIds = (1L..50L).toList() Mockito.`when`(translationService.findDisplayContents(returnedPostIds, "en")).thenReturn(emptyMap()) - val tab = service.getCommunityTab(1L, createMember(id = 10L), page = 0, size = 50) + val tab = service.getCommunityTab(1L, createMember(id = 10L), page = 0, size = 50, lang = Lang.EN) assertEquals(50, tab.communityPosts.size) assertTrue(tab.hasNext) @@ -248,13 +250,13 @@ class CreatorChannelCommunityQueryServiceTest { Mockito.`when`(translationService.findDisplayContents(listOf(21L), "en")) .thenReturn(mapOf(21L to CreatorCommunityDisplayContent("translated pinned", true))) - val pinnedPosts = service.findHomeCommunityPosts(1L, 10L, true, false, 3) + val pinnedPosts = service.findHomeCommunityPosts(1L, 10L, true, false, 3, Lang.EN) port.homeCommunityPosts = listOf(communityPostRecord(22L, price = 0).copy(content = "source normal")) Mockito.`when`(translationService.findDisplayContents(listOf(22L), "en")) .thenReturn(mapOf(22L to CreatorCommunityDisplayContent("translated normal", true))) - val normalPosts = service.findHomeCommunityPosts(1L, 10L, false, false, 3) + val normalPosts = service.findHomeCommunityPosts(1L, 10L, false, false, 3, Lang.EN) assertEquals("translated pinned", pinnedPosts.single().content) assertEquals("translated normal", normalPosts.single().content) @@ -275,7 +277,7 @@ class CreatorChannelCommunityQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L) - val detail = service.getCommunityPostDetail(1L, viewer, LocalDateTime.of(2026, 7, 6, 10, 0)) + val detail = service.getCommunityPostDetail(1L, viewer, LocalDateTime.of(2026, 7, 6, 10, 0), Lang.KO) assertEquals(1L, detail.post.postId) assertEquals(true, detail.post.isLiked) @@ -298,7 +300,7 @@ class CreatorChannelCommunityQueryServiceTest { Mockito.`when`(translationService.findDisplayContents(listOf(1L), "en")) .thenReturn(mapOf(1L to CreatorCommunityDisplayContent("content-1", true))) - service.getCommunityPostDetail(1L, createMember(id = 10L), LocalDateTime.of(2026, 7, 6, 10, 0)) + service.getCommunityPostDetail(1L, createMember(id = 10L), LocalDateTime.of(2026, 7, 6, 10, 0), Lang.KO) Mockito.verify(translationService, Mockito.never()).requestTranslations(1L, "en") } @@ -311,7 +313,14 @@ class CreatorChannelCommunityQueryServiceTest { } val service = createService(port) - val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0)) + val comments = service.getCommunityComments( + 1L, + createMember(id = 10L), + -1, + 100, + LocalDateTime.of(2026, 7, 6, 10, 0), + Lang.KO + ) assertEquals(0, comments.commentCount) assertEquals(0, comments.comments.size) @@ -332,7 +341,14 @@ class CreatorChannelCommunityQueryServiceTest { } val service = createService(port) - val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0)) + val comments = service.getCommunityComments( + 1L, + createMember(id = 10L), + -1, + 100, + LocalDateTime.of(2026, 7, 6, 10, 0), + Lang.KO + ) assertEquals(0L, port.commentListOffset) assertEquals(51, port.commentListLimit) @@ -356,7 +372,14 @@ class CreatorChannelCommunityQueryServiceTest { } val service = createService(port) - val replies = service.getCommunityReplies(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0)) + val replies = service.getCommunityReplies( + 1L, + createMember(id = 10L), + -1, + 100, + LocalDateTime.of(2026, 7, 6, 10, 0), + Lang.KO + ) assertEquals(51, replies.replyCount) assertEquals(50, replies.replies.size) @@ -373,7 +396,7 @@ class CreatorChannelCommunityQueryServiceTest { val service = createService(port) val exception = assertThrows(SodaException::class.java) { - service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0)) + service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0), Lang.KO) } assertEquals("creator.community.invalid_request_retry", exception.messageKey) @@ -389,7 +412,14 @@ class CreatorChannelCommunityQueryServiceTest { } val service = createService(port) - val replies = service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0)) + val replies = service.getCommunityReplies( + 1L, + createMember(id = 10L), + null, + null, + LocalDateTime.of(2026, 7, 6, 10, 0), + Lang.KO + ) assertEquals(0, replies.replyCount) assertEquals(0, replies.replies.size) @@ -484,7 +514,7 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer var homeCanViewAdultContent: Boolean? = null var homeLimit: Int? = null - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? = creator + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCommunityCreatorRecord? = creator override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked @@ -502,7 +532,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer viewerId: Long, canViewAdultContent: Boolean, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { listCanViewAdultContent = canViewAdultContent listOffset = offset @@ -515,7 +546,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer viewerId: Long, isPinned: Boolean, canViewAdultContent: Boolean, - limit: Int + limit: Int, + lang: Lang ): List { homeCreatorId = creatorId homeViewerId = viewerId @@ -528,7 +560,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer override fun findCommunityPost( postId: Long, viewerId: Long, - canViewAdultContent: Boolean + canViewAdultContent: Boolean, + lang: Lang ): CreatorChannelCommunityPostRecord? = communityPost override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = communityCommentCount @@ -538,7 +571,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer viewerId: Long, creatorId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { commentListCalled = true commentListOffset = offset @@ -548,7 +582,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer override fun findLatestRepliesByCommentIds( commentIds: List, - viewerId: Long + viewerId: Long, + lang: Lang ): List { latestReplyCommentIds = commentIds return latestReplies @@ -566,7 +601,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer commentId: Long, viewerId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { replyListOffset = offset replyListLimit = limit diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt index eaa5b531..adc88c78 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persist import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -48,9 +49,9 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( val nonCreator = saveMember("donation-non-creator", MemberRole.USER) flushAndClear() - val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!) - val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!) - val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!) + val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!, Lang.KO) + val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!, Lang.KO) + val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!, Lang.KO) assertNotNull(creatorRecord) assertEquals(creator.id, creatorRecord!!.creatorId) @@ -91,7 +92,7 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( flushAndClear() val count = repository.countChannelDonations(creator.id!!, creator.id!!, now) - val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10) + val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10, lang = Lang.KO) assertEquals(2, count) assertEquals(listOf(secret.can, monthStart.can), records.map { it.can }) @@ -121,7 +122,7 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( flushAndClear() val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now) - val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10) + val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10, lang = Lang.KO) assertEquals(2, count) assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can }) @@ -141,7 +142,7 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest") flushAndClear() - val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 1, limit = 2) + val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 1, limit = 2, lang = Lang.KO) assertEquals(listOf(second.can, first.can), records.map { it.can }) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt index a610c647..b7807554 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt @@ -1,7 +1,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation.application import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member @@ -34,7 +34,8 @@ class CreatorChannelDonationQueryServiceTest { viewer = createMember(VIEWER_ID), page = 0, size = 20, - now = NOW + now = NOW, + lang = Lang.KO ) } @@ -55,7 +56,8 @@ class CreatorChannelDonationQueryServiceTest { viewer = createMember(VIEWER_ID), page = 0, size = 20, - now = NOW + now = NOW, + lang = Lang.KO ) } @@ -74,7 +76,8 @@ class CreatorChannelDonationQueryServiceTest { viewer = createMember(VIEWER_ID), page = 0, size = 20, - now = NOW + now = NOW, + lang = Lang.KO ) } @@ -97,7 +100,8 @@ class CreatorChannelDonationQueryServiceTest { viewer = createMember(VIEWER_ID), page = -1, size = 10, - now = NOW + now = NOW, + lang = Lang.KO ) assertEquals(0L, queryPort.lastFindDonationRequest?.offset) @@ -138,7 +142,8 @@ class CreatorChannelDonationQueryServiceTest { viewer = createMember(VIEWER_ID), page = 0, size = 20, - now = NOW + now = NOW, + lang = Lang.KO ) assertEquals("donor", tab.donations[0].nickname) @@ -163,7 +168,8 @@ class CreatorChannelDonationQueryServiceTest { viewer = createMember(CREATOR_ID), page = 0, size = 20, - now = NOW + now = NOW, + lang = Lang.KO ) assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, true), rankingPort.requests.single()) @@ -186,7 +192,7 @@ class CreatorChannelDonationQueryServiceTest { ) ), rankingPort = weeklyRankingPort - ).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + ).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW, Lang.KO) val cumulativeRankingPort = FakeDonationRankingPort() createService( @@ -197,7 +203,7 @@ class CreatorChannelDonationQueryServiceTest { ) ), rankingPort = cumulativeRankingPort - ).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + ).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW, Lang.KO) assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, false), weeklyRankingPort.requests.single()) assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), cumulativeRankingPort.requests.single()) @@ -214,7 +220,7 @@ class CreatorChannelDonationQueryServiceTest { rankingPort = rankingPort ) - service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW, Lang.KO) assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), rankingPort.requests.single()) } @@ -233,7 +239,7 @@ class CreatorChannelDonationQueryServiceTest { val rankingPort = FakeDonationRankingPort() val service = createService(queryPort = queryPort, rankingPort = rankingPort) - val tab = service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + val tab = service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW, Lang.KO) assertEquals(emptyList(), rankingPort.requests) assertEquals(emptyList(), tab.rankings) @@ -255,7 +261,6 @@ class CreatorChannelDonationQueryServiceTest { rankingPort = rankingPort, queryPolicy = CreatorChannelDonationQueryPolicy(), messageSource = SodaMessageSource(), - langContext = LangContext(), cloudFrontHost = "https://cdn.test" ) } @@ -282,6 +287,7 @@ class CreatorChannelDonationQueryServiceTest { createdAt: LocalDateTime = NOW ): CreatorChannelDonationRecord { return CreatorChannelDonationRecord( + memberId = VIEWER_ID, nickname = nickname, profileImagePath = profileImagePath, can = can, @@ -319,7 +325,7 @@ class CreatorChannelDonationQueryServiceTest { var lastFindDonationRequest: FindDonationRequest? = null private set - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelDonationCreatorRecord? { return creator } @@ -337,7 +343,8 @@ class CreatorChannelDonationQueryServiceTest { viewerId: Long, now: LocalDateTime, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { lastFindDonationRequest = FindDonationRequest(creatorId, viewerId, now, offset, limit) return donations diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt index d4020312..e990c9c3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt @@ -78,6 +78,7 @@ class CreatorChannelDonationQueryPolicyTest { donationCan = 100 ) val donation = CreatorChannelDonation( + memberId = 10L, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", can = 50, @@ -99,6 +100,7 @@ class CreatorChannelDonationQueryPolicyTest { donationRankingPeriod = DonationRankingPeriod.CUMULATIVE ) val donationRecord = CreatorChannelDonationRecord( + memberId = 10L, nickname = "donor", profileImagePath = null, can = 50, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt index 53dcda76..d5aee793 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persiste import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember @@ -41,9 +42,9 @@ class DefaultCreatorChannelFanTalkQueryRepositoryTest @Autowired constructor( val nonCreator = saveMember("fantalk-non-creator", MemberRole.USER) flushAndClear() - val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!) - val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!) - val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!) + val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!, Lang.KO) + val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!, Lang.KO) + val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!, Lang.KO) assertNotNull(activeRecord) assertEquals(activeCreator.id, activeRecord!!.creatorId) @@ -90,8 +91,8 @@ class DefaultCreatorChannelFanTalkQueryRepositoryTest @Autowired constructor( flushAndClear() val count = repository.countFanTalks(creator.id!!, viewer.id!!) - val firstPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 1) - val secondPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 1, limit = 2) + val firstPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 1, lang = Lang.KO) + val secondPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 1, limit = 2, lang = Lang.KO) assertEquals(2, count) assertEquals(listOf(newer.id), firstPage.map { it.fanTalkId }) @@ -114,7 +115,7 @@ class DefaultCreatorChannelFanTalkQueryRepositoryTest @Autowired constructor( val newest = saveCheers(writer, creator, "newest", isActive = true, createdAt = sameCreatedAt.plusMinutes(1)) flushAndClear() - val records = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 10) + val records = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 10, lang = Lang.KO) assertEquals(listOf(newest.id, second.id, first.id), records.map { it.fanTalkId }) } @@ -157,8 +158,8 @@ class DefaultCreatorChannelFanTalkQueryRepositoryTest @Autowired constructor( ) flushAndClear() - val replies = repository.findCreatorReplies(creator.id!!, listOf(parent.id!!)) - val emptyReplies = repository.findCreatorReplies(creator.id!!, emptyList()) + val replies = repository.findCreatorReplies(creator.id!!, listOf(parent.id!!), Lang.KO) + val emptyReplies = repository.findCreatorReplies(creator.id!!, emptyList(), Lang.KO) assertEquals(listOf(olderReply.id, newerReply.id), replies.map { it.fanTalkId }) assertEquals(parent.id, replies.first().parentFanTalkId) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt index 67ea009c..0fb01aa7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt @@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application import kr.co.vividnext.sodalive.common.SodaException 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.Member import kr.co.vividnext.sodalive.member.MemberProvider @@ -30,7 +29,7 @@ class CreatorChannelFanTalkQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) } assertEquals("member.validation.user_not_found", exception.messageKey) @@ -44,7 +43,7 @@ class CreatorChannelFanTalkQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) } assertEquals("member.validation.creator_not_found", exception.messageKey) @@ -58,7 +57,7 @@ class CreatorChannelFanTalkQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) } assertNull(exception.messageKey) @@ -85,7 +84,8 @@ class CreatorChannelFanTalkQueryServiceTest { viewer = viewer, page = -1, size = 10, - now = LocalDateTime.of(2026, 6, 21, 10, 0) + now = LocalDateTime.of(2026, 6, 21, 10, 0), + lang = Lang.EN ) assertEquals(60, tab.fanTalkCount) @@ -114,7 +114,7 @@ class CreatorChannelFanTalkQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L) - val tab = service.getFanTalkTab(1L, viewer, 3, 20, LocalDateTime.of(2026, 6, 21, 10, 0)) + val tab = service.getFanTalkTab(1L, viewer, 3, 20, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) assertEquals(5, tab.fanTalkCount) assertEquals(emptyList(), tab.fanTalks.map { it.fanTalkId }) @@ -156,7 +156,7 @@ class CreatorChannelFanTalkQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L) - val fanTalks = service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + val fanTalks = service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0), Lang.EN) .fanTalks assertEquals("fan", fanTalks[0].writerNickname) @@ -169,13 +169,10 @@ class CreatorChannelFanTalkQueryServiceTest { } private fun createService(port: FakeCreatorChannelFanTalkQueryPort): CreatorChannelFanTalkQueryService { - val langContext = LangContext() - langContext.setLang(Lang.EN) return CreatorChannelFanTalkQueryService( queryPortProvider = FixedCreatorChannelFanTalkQueryPortProvider(port), queryPolicy = CreatorChannelFanTalkQueryPolicy(), messageSource = SodaMessageSource(), - langContext = langContext, cloudFrontHost = "https://cdn.test" ) } @@ -216,7 +213,7 @@ private class FakeCreatorChannelFanTalkQueryPort : CreatorChannelFanTalkQueryPor var listLimit: Int? = null var replyParentFanTalkIds: List? = null - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? = creator + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelFanTalkCreatorRecord? = creator override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked @@ -226,7 +223,8 @@ private class FakeCreatorChannelFanTalkQueryPort : CreatorChannelFanTalkQueryPor creatorId: Long, viewerId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List { listOffset = offset listLimit = limit @@ -235,7 +233,8 @@ private class FakeCreatorChannelFanTalkQueryPort : CreatorChannelFanTalkQueryPor override fun findCreatorReplies( creatorId: Long, - parentFanTalkIds: List + parentFanTalkIds: List, + lang: Lang ): List { replyParentFanTalkIds = parentFanTalkIds return creatorReplies diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index 9922dfec..a49b84c4 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -18,6 +18,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit @@ -70,8 +71,8 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( val aiCreator = saveMember("ai-creator", MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER) flushAndClear() - val record = repository.findCreator(creator.id!!, viewer.id!!) - val aiRecord = repository.findCreator(aiCreator.id!!, viewer.id!!) + val record = repository.findCreator(creator.id!!, viewer.id!!, Lang.KO) + val aiRecord = repository.findCreator(aiCreator.id!!, viewer.id!!, Lang.KO) assertNotNull(record) assertEquals(2, record!!.followerCount) @@ -91,7 +92,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveCharacter(creator, isActive = false) flushAndClear() - val record = repository.findCreator(creator.id!!, viewerId = null) + val record = repository.findCreator(creator.id!!, viewerId = null, lang = Lang.KO) assertEquals(null, record!!.characterId) assertFalse(record.isAiChatAvailable) @@ -108,7 +109,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveFollowing(saveMember("inactive-member-follower", MemberRole.USER, isActive = false), creator, isActive = true) flushAndClear() - val record = repository.findCreator(creator.id!!, viewerId = null) + val record = repository.findCreator(creator.id!!, viewerId = null, lang = Lang.KO) assertEquals(5, record!!.followerCount) } @@ -119,7 +120,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( val user = saveMember("non-creator-target", MemberRole.USER) flushAndClear() - val record = repository.findCreator(user.id!!, viewerId = null) + val record = repository.findCreator(user.id!!, viewerId = null, lang = Lang.KO) assertNotNull(record) assertEquals(MemberRole.USER, record!!.role) @@ -216,7 +217,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( updateUpdatedAt("LiveRoom", currentLive.id!!, now.minusHours(1)) flushAndClear() - val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!) + val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!, Lang.KO) val currentLiveRecord = repository.findCurrentLive( creator.id!!, now, @@ -231,7 +232,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( canViewAdultContent = false, viewerId = viewer.id!! ) - val donations = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8) + val donations = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO) val schedules = repository.findSchedules( creator.id!!, now, @@ -250,7 +251,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( limit = 9 ) val seriesRecords = repository.findSeries(creator.id!!, viewer.id!!, now, false, ContentType.ALL, limit = 8) - val fanTalkSummary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + val fanTalkSummary = repository.findFanTalkSummary(creator.id!!, viewer.id!!, Lang.KO) val activity = repository.findActivity(creator.id!!, now) val sns = repository.findSns(creator.id!!) @@ -289,7 +290,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveFollowing(viewer, creator, isActive = false, isNotify = true) flushAndClear() - val record = repository.findCreator(creator.id!!, viewer.id!!) + val record = repository.findCreator(creator.id!!, viewer.id!!, Lang.KO) assertFalse(record!!.isFollow) assertFalse(record.isNotify) @@ -770,7 +771,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveCheers(writer, creator, "reply", isActive = true, LocalDateTime.of(2026, 6, 12, 14, 0), parent = older) flushAndClear() - val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!, Lang.KO) assertEquals(2, summary.totalCount) assertEquals(latest.id, summary.latestFanTalk!!.fanTalkId) @@ -788,9 +789,9 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( val thirdParty = saveMember("kst-donation-third-party", MemberRole.USER) flushAndClear() - val creatorView = repository.findChannelDonations(creator.id!!, creator.id!!, now, limit = 8) - val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8) - val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8) + val creatorView = repository.findChannelDonations(creator.id!!, creator.id!!, now, limit = 8, lang = Lang.KO) + val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO) + val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8, lang = Lang.KO) assertEquals(listOf(300), creatorView.map { it.can }) assertEquals(listOf(300), donorView.map { it.can }) @@ -821,7 +822,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( ) flushAndClear() - val records = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8) + val records = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO) assertEquals(listOf(1000, 3), records.map { it.can }) assertEquals("응원합니다", records.first().message) @@ -837,7 +838,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveDonation(creator, donor, 1000, now.minusMinutes(1), isSecret = true, additionalMessage = "cheer") flushAndClear() - val records = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8) + val records = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO) assertEquals("cheer", records.single().message) } @@ -852,7 +853,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveBlock(writer, viewer) flushAndClear() - val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!, Lang.KO) assertEquals(0, summary.totalCount) assertEquals(null, summary.latestFanTalk) @@ -1042,7 +1043,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveBlock(writerBlockedViewer, viewer) flushAndClear() - val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!, Lang.KO) assertEquals(1, summary.totalCount) assertEquals(visibleTalk.id, summary.latestFanTalk!!.fanTalkId) @@ -1057,7 +1058,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( saveCheers(writer, creator, "visible", isActive = true, LocalDateTime.of(2026, 6, 12, 12, 0)) flushAndClear() - val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!, Lang.KO) assertEquals("fan", summary.latestFanTalk!!.nickname) } @@ -1085,7 +1086,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( updateUpdatedAt("LiveRoom", futureLive.id!!, now.plusDays(1).plusHours(1)) flushAndClear() - val creatorRecord = repository.findCreator(creator.id!!, viewerId = null) + val creatorRecord = repository.findCreator(creator.id!!, viewerId = null, lang = Lang.KO) val activity = repository.findActivity(creator.id!!, now) val sns = repository.findSns(creator.id!!) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index 50fbc451..94ccaf13 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -70,7 +70,7 @@ class CreatorChannelHomeQueryServiceTest { val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1) val now = LocalDateTime.of(2026, 6, 13, 10, 0) - val home = service.getHome(creatorId = 1L, viewer = viewer, now = now) + val home = service.getHome(creatorId = 1L, viewer = viewer, now = now, lang = Lang.KO) assertEquals(1L, home.creator.creatorId) assertEquals("https://cdn.test/profile/creator.png", home.creator.profileImageUrl) @@ -115,7 +115,7 @@ class CreatorChannelHomeQueryServiceTest { val service = createService(port, lang = Lang.JA) val viewer = createMember(id = 10L) - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.JA) assertEquals("ja", port.latestAudioLocale) assertEquals("ja", port.audioContentsLocale) @@ -130,7 +130,7 @@ class CreatorChannelHomeQueryServiceTest { val service = createService(port) val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null, role = MemberRole.CREATOR) - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) assertTrue(port.currentLiveIsViewerCreator == true) assertTrue(port.schedulesIsViewerCreator == true) @@ -145,7 +145,7 @@ class CreatorChannelHomeQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L, role = MemberRole.CREATOR) - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) assertTrue(port.currentLiveIsViewerCreator == true) assertTrue(port.schedulesIsViewerCreator == true) @@ -158,7 +158,7 @@ class CreatorChannelHomeQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L, role = MemberRole.USER) - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) assertFalse(port.currentLiveIsViewerCreator == true) assertFalse(port.schedulesIsViewerCreator == true) @@ -173,7 +173,7 @@ class CreatorChannelHomeQueryServiceTest { val service = createService(port, communityPort, preferenceService = preferenceService) val viewer = createMember(id = 10L) - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) assertEquals(false, port.currentLiveCanViewAdultContent) assertEquals(false, port.schedulesCanViewAdultContent) @@ -191,7 +191,7 @@ class CreatorChannelHomeQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L) - val home = service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + val home = service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) assertEquals("donor", home.channelDonations.first().nickname) } @@ -206,7 +206,7 @@ class CreatorChannelHomeQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getHome(creatorId = 999L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(999L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) } assertEquals("member.validation.user_not_found", exception.messageKey) @@ -222,7 +222,7 @@ class CreatorChannelHomeQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) } assertEquals("member.validation.creator_not_found", exception.messageKey) @@ -239,7 +239,7 @@ class CreatorChannelHomeQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) } assertNull(exception.messageKey) @@ -256,7 +256,7 @@ class CreatorChannelHomeQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + service.getHome(1L, viewer, LocalDateTime.of(2026, 6, 13, 10, 0), Lang.KO) } assertNull(exception.messageKey) @@ -408,6 +408,7 @@ class CreatorChannelHomeQueryServiceTest { ), channelDonations = listOf( CreatorChannelDonation( + memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", can = 50, @@ -593,7 +594,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { var seriesLocale: String? = null var channelDonationNickname: String = "fan" - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? = creator override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked @@ -634,9 +635,11 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { creatorId: Long, viewerId: Long?, now: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List = listOf( CreatorChannelDonationRecord( + memberId = 20L, nickname = channelDonationNickname, profileImagePath = "profile/fan.png", can = 30, @@ -710,7 +713,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { ) } - override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord { + override fun findFanTalkSummary(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelFanTalkSummaryRecord { return CreatorChannelFanTalkSummaryRecord( totalCount = 1, latestFanTalk = CreatorChannelFanTalkRecord( @@ -798,7 +801,7 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer val homeCanViewAdultContentValues = mutableListOf() val homeLimits = mutableListOf() - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCommunityCreatorRecord? { return CreatorChannelCommunityCreatorRecord(creatorId = creatorId, role = MemberRole.CREATOR, nickname = "creator") } @@ -811,7 +814,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer viewerId: Long, canViewAdultContent: Boolean, offset: Long, - limit: Int + limit: Int, + lang: Lang ): List = emptyList() override fun findHomeCommunityPosts( @@ -819,7 +823,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer viewerId: Long, isPinned: Boolean, canViewAdultContent: Boolean, - limit: Int + limit: Int, + lang: Lang ): List { homeCreatorIds += creatorId homeViewerIds += viewerId @@ -850,7 +855,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer override fun findCommunityPost( postId: Long, viewerId: Long, - canViewAdultContent: Boolean + canViewAdultContent: Boolean, + lang: Lang ): CreatorChannelCommunityPostRecord? = null override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = 0 @@ -860,12 +866,14 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer viewerId: Long, creatorId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ) = emptyList() override fun findLatestRepliesByCommentIds( commentIds: List, - viewerId: Long + viewerId: Long, + lang: Lang ) = emptyList() override fun findRootCommentContext( @@ -880,6 +888,7 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer commentId: Long, viewerId: Long, offset: Long, - limit: Int + limit: Int, + lang: Lang ) = emptyList() } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt index 47a28b97..fe7b0e4d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Gender @@ -274,7 +275,7 @@ class DefaultCreatorChannelLiveQueryRepositoryTest @Autowired constructor( saveBlock(creator, viewer) flushAndClear() - val record = repository.findCreator(creator.id!!, viewer.id!!) + val record = repository.findCreator(creator.id!!, viewer.id!!, Lang.KO) assertEquals(creator.id, record!!.creatorId) assertEquals(MemberRole.CREATOR, record.role) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt index 9d462af7..0f9b587b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt @@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.v2.creator.channel.live.application import kr.co.vividnext.sodalive.common.SodaException 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.Gender import kr.co.vividnext.sodalive.member.Member @@ -42,7 +41,8 @@ class CreatorChannelLiveQueryServiceTest { sort = ContentSort.LATEST, page = 0, size = 20, - now = now + now = now, + lang = Lang.KO ) assertEquals(1L, port.findCreatorCreatorId) @@ -75,7 +75,8 @@ class CreatorChannelLiveQueryServiceTest { sort = ContentSort.LATEST, page = 0, size = 20, - now = LocalDateTime.of(2026, 6, 17, 10, 0) + now = LocalDateTime.of(2026, 6, 17, 10, 0), + lang = Lang.KO ) assertTrue(port.currentLiveIsViewerCreator == true) @@ -96,7 +97,8 @@ class CreatorChannelLiveQueryServiceTest { sort = ContentSort.LATEST, page = 0, size = 20, - now = LocalDateTime.of(2026, 6, 17, 10, 0) + now = LocalDateTime.of(2026, 6, 17, 10, 0), + lang = Lang.KO ) assertEquals(30, tab.liveReplayContentCount) @@ -127,7 +129,8 @@ class CreatorChannelLiveQueryServiceTest { sort = ContentSort.PRICE_LOW, page = 2, size = 20, - now = LocalDateTime.of(2026, 6, 17, 10, 0) + now = LocalDateTime.of(2026, 6, 17, 10, 0), + lang = Lang.KO ) assertEquals(30, tab.liveReplayContentCount) @@ -151,7 +154,7 @@ class CreatorChannelLiveQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0), Lang.KO) } assertEquals("member.validation.user_not_found", exception.messageKey) @@ -167,7 +170,7 @@ class CreatorChannelLiveQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0), Lang.KO) } assertEquals("member.validation.creator_not_found", exception.messageKey) @@ -184,7 +187,7 @@ class CreatorChannelLiveQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0), Lang.KO) } assertNull(exception.messageKey) @@ -198,7 +201,7 @@ class CreatorChannelLiveQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getLiveTab(1L, viewer, ContentSort.LATEST, -1, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + service.getLiveTab(1L, viewer, ContentSort.LATEST, -1, 20, LocalDateTime.of(2026, 6, 17, 10, 0), Lang.KO) } assertEquals("common.error.invalid_request", exception.messageKey) @@ -211,7 +214,7 @@ class CreatorChannelLiveQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 51, LocalDateTime.of(2026, 6, 17, 10, 0)) + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 51, LocalDateTime.of(2026, 6, 17, 10, 0), Lang.KO) } assertEquals("common.error.invalid_request", exception.messageKey) @@ -240,14 +243,11 @@ class CreatorChannelLiveQueryServiceTest { preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L)) ).thenReturn(canViewAdultContent) val messageSource = SodaMessageSource() - val langContext = LangContext() - langContext.setLang(Lang.KO) return CreatorChannelLiveQueryService( queryPortProvider = portProvider, queryPolicy = CreatorChannelLiveReplayQueryPolicy(), memberContentPreferenceService = preferenceService, messageSource = messageSource, - langContext = langContext, cloudFrontHost = "https://cdn.test" ) } @@ -337,7 +337,7 @@ private class FakeCreatorChannelLiveQueryPort : CreatorChannelLiveQueryPort { var listOffset: Long? = null var listLimit: Int? = null - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? { + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? { findCreatorCreatorId = creatorId findCreatorViewerId = viewerId return creator diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt index b0a67dfa..c47c2aaa 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt @@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember @@ -50,8 +51,8 @@ class DefaultCreatorChannelSeriesQueryRepositoryTest @Autowired constructor( saveBlock(creator, viewer) flushAndClear() - val record = repository.findCreator(creator.id!!, viewer.id!!) - val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!) + val record = repository.findCreator(creator.id!!, viewer.id!!, Lang.KO) + val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!, Lang.KO) assertEquals(creator.id, record!!.creatorId) assertEquals(MemberRole.CREATOR, record.role) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt index 67f1a03f..9a17a6dd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt @@ -42,7 +42,8 @@ class CreatorChannelSeriesQueryServiceTest { sort = "UNKNOWN", page = -1, size = 100, - now = now + now = now, + lang = Lang.EN ) assertEquals(ContentSort.LATEST, tab.sort) @@ -70,7 +71,7 @@ class CreatorChannelSeriesQueryServiceTest { val service = createService(port) val viewer = createMember(id = 1L) - val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0), Lang.EN) assertNull(tab.series.first().purchasedContentCount) assertNull(tab.series.first().paidContentCount) @@ -86,7 +87,7 @@ class CreatorChannelSeriesQueryServiceTest { val service = createService(port) val viewer = createMember(id = 10L) - val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0), Lang.EN) assertNull(tab.series.first().coverImageUrl) assertEquals(0, tab.series.first().purchasedPaidContentRate) @@ -100,7 +101,7 @@ class CreatorChannelSeriesQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0), Lang.EN) } assertEquals("member.validation.user_not_found", exception.messageKey) @@ -114,7 +115,7 @@ class CreatorChannelSeriesQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0), Lang.EN) } assertEquals("member.validation.creator_not_found", exception.messageKey) @@ -128,7 +129,7 @@ class CreatorChannelSeriesQueryServiceTest { val viewer = createMember(id = 10L) val exception = assertThrows(SodaException::class.java) { - service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0), Lang.EN) } assertNull(exception.messageKey) @@ -192,7 +193,7 @@ private class FakeCreatorChannelSeriesQueryPort : CreatorChannelSeriesQueryPort var listLimit: Int? = null var listCanViewAdultContent: Boolean? = null - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? = creator + override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelSeriesCreatorRecord? = creator override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked override fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int = seriesCount diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt new file mode 100644 index 00000000..f20d6384 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt @@ -0,0 +1,283 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.`in`.web + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.creatorname.support.CreatorLocalizedNameH2Schema +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.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.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import java.util.UUID + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AdminCreatorLocalizedNameControllerTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper, + private val memberRepository: MemberRepository, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository, + private val jdbcTemplate: JdbcTemplate +) { + @LocalServerPort + private var port: Int = 0 + + private val memberIds = mutableListOf() + + @BeforeEach + fun prepareCodePointStorage() { + CreatorLocalizedNameH2Schema.prepare(jdbcTemplate) + } + + @AfterEach + fun cleanUp() { + tokenRepository.deleteAll() + jdbcTemplate.update("delete from creator_localized_name") + memberRepository.deleteAllById(memberIds) + } + + @Test + @DisplayName("실제 HTTP에서 ADMIN은 PUT으로 저장·해제·재활성화하고 회원 목록에서 모든 언어를 헤더와 무관하게 조회한다") + fun shouldReturnOriginalAndLocalizedNameForAdmin() { + val creator = saveMember(MemberRole.CREATOR) + val token = tokenFor(saveMember(MemberRole.ADMIN)) + val creatorId = requireNotNull(creator.id) + + assertListNames(creatorId, token, emptyMap()) + val originalInput = " 手動 Name 😀 " + assertName( + exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to originalInput)), + creatorId, + "original", + originalInput + ) + val savedId = rowId(creatorId) + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, 'EN', ?, true, current_timestamp, current_timestamp)", + creatorId, + "English" + ) + assertListNames(creatorId, token, mapOf("ja" to originalInput, "en" to "English")) + assertEquals( + "JA", + jdbcTemplate.queryForObject("select lang from creator_localized_name where id = ?", String::class.java, savedId) + ) + + listOf("", " \t\n").forEach { blank -> + assertName(exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to blank)), creatorId, "original", null) + assertListNames(creatorId, token, mapOf("en" to "English")) + assertEquals(savedId, rowId(creatorId)) + assertEquals( + originalInput, + jdbcTemplate.queryForObject( + "select display_name from creator_localized_name where id = ?", + String::class.java, + savedId + ) + ) + assertFalse( + requireNotNull( + jdbcTemplate.queryForObject( + "select is_active from creator_localized_name where id = ?", + Boolean::class.java, + savedId + ) + ) + ) + assertName( + exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to originalInput)), + creatorId, + "original", + originalInput + ) + assertListNames(creatorId, token, mapOf("ja" to originalInput, "en" to "English")) + } + assertEquals(savedId, rowId(creatorId)) + assertEquals( + 2L, + jdbcTemplate.queryForObject( + "select count(*) from creator_localized_name where member_id = ?", + Long::class.java, + creatorId + ) + ) + assertEquals("original", memberRepository.findById(creatorId).get().nickname) + } + + @Test + @DisplayName("실제 HTTP에서 AI CREATOR는 허용하고 USER 및 AI USER는 저장을 거부한다") + fun shouldRejectNonCreatorIncludingAiUser() { + val token = tokenFor(saveMember(MemberRole.ADMIN)) + val aiCreator = saveMember(MemberRole.CREATOR, MemberKind.AI_CHARACTER) + val creatorId = requireNotNull(aiCreator.id) + assertName(exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to "AI 名")), creatorId, "original", "AI 名") + + listOf(MemberKind.HUMAN, MemberKind.AI_CHARACTER).forEach { kind -> + val userId = requireNotNull(saveMember(MemberRole.USER, kind).id) + val response = exchange(userId, HttpMethod.PUT, token, mapOf("displayName" to "denied")) + assertFalse(response.path("success").asBoolean()) + assertEquals("Please select a valid creator.", response.path("message").asText()) + assertEquals( + 0L, + jdbcTemplate.queryForObject( + "select count(*) from creator_localized_name where member_id = ?", + Long::class.java, + userId + ) + ) + } + } + + @Test + @DisplayName("실제 JWT HTTP에서 모든 비관리자 역할의 회원 목록·이름 PUT을 거부하고 비회원은 401이다") + fun shouldRejectNonAdminAndAnonymous() { + val creatorId = requireNotNull(saveMember(MemberRole.CREATOR).id) + MemberRole.values().filter { it != MemberRole.ADMIN }.forEach { role -> + val token = tokenFor(saveMember(role)) + listOf( + memberList(token, "en"), + exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to "denied")) + ).forEach { response -> + assertFalse(response.path("success").asBoolean()) + assertEquals("You do not have permission.", response.path("message").asText()) + } + } + listOf(HttpMethod.GET to memberListUrl(), HttpMethod.PUT to url(creatorId)).forEach { (method, endpoint) -> + val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON } + val response = restTemplate.exchange( + endpoint, + method, + HttpEntity(mapOf("displayName" to "denied"), headers), + String::class.java + ) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + } + assertEquals(0L, jdbcTemplate.queryForObject("select count(*) from creator_localized_name", Long::class.java)) + } + + @Test + @DisplayName("실제 HTTP에서 누락 대상·필수 입력 누락·null·256 code point 입력은 데이터를 변경하지 않는다") + fun shouldRejectMissingTargetAndInvalidInputWithoutMutation() { + val creatorId = requireNotNull(saveMember(MemberRole.CREATOR).id) + val token = tokenFor(saveMember(MemberRole.ADMIN)) + val response = exchange(Long.MAX_VALUE, HttpMethod.PUT, token, mapOf("displayName" to "name")) + assertFalse(response.path("success").asBoolean()) + assertEquals("User not found.", response.path("message").asText()) + val accepted = "😀".repeat(255) + assertName(exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to accepted)), creatorId, "original", accepted) + val savedId = rowId(creatorId) + listOf(emptyMap(), mapOf("displayName" to null), mapOf("displayName" to "😀".repeat(256))).forEach { body -> + assertFalse(exchange(creatorId, HttpMethod.PUT, token, body).path("success").asBoolean()) + assertListNames(creatorId, token, mapOf("ja" to accepted)) + assertEquals(savedId, rowId(creatorId)) + } + } + + private fun assertListNames(creatorId: Long, token: String, expected: Map) { + listOf(null, "ko", "ja", "en").forEach { language -> + val response = memberList(token, language) + assertTrue(response.path("success").asBoolean(), response.toString()) + val item = response.path("data").path("items").single { it.path("id").asLong() == creatorId } + assertEquals("original", item.path("nickname").asText()) + assertEquals(objectMapper.valueToTree(expected), item.path("localizedNames")) + } + } + + private fun memberList(token: String, language: String?): JsonNode { + val headers = HttpHeaders().apply { + setBearerAuth(token) + language?.let { set(HttpHeaders.ACCEPT_LANGUAGE, it) } + } + val response = restTemplate.exchange( + memberListUrl(), + HttpMethod.GET, + HttpEntity(headers), + String::class.java + ) + assertEquals(HttpStatus.OK, response.statusCode, response.body) + return objectMapper.readTree(response.body) + } + + private fun exchange(creatorId: Long, method: HttpMethod, token: String, body: Map? = null): JsonNode { + val headers = HttpHeaders().apply { + setBearerAuth(token) + contentType = MediaType.APPLICATION_JSON + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + } + val response = restTemplate.exchange( + url(creatorId), + method, + HttpEntity(body?.let { objectMapper.writeValueAsString(it) }, headers), + String::class.java + ) + assertEquals(HttpStatus.OK, response.statusCode, response.body) + return objectMapper.readTree(response.body) + } + + private fun assertName(response: JsonNode, creatorId: Long, original: String, displayName: String?) { + assertTrue(response.path("success").asBoolean(), response.toString()) + val data = response.path("data") + assertEquals(creatorId, data.path("creatorId").asLong()) + assertEquals("ja", data.path("locale").asText()) + assertEquals(original, data.path("originalNickname").asText()) + assertTrue(data.has("displayName")) + if (displayName == null) { + assertTrue(data.path("displayName").isNull) + } else { + assertEquals(displayName, data.path("displayName").asText()) + } + } + + private fun tokenFor(member: Member): String = tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))), + requireNotNull(member.id) + ) + + private fun saveMember(role: MemberRole, kind: MemberKind = MemberKind.HUMAN): Member = memberRepository.saveAndFlush( + Member( + email = "${UUID.randomUUID()}@test.com", + password = "password", + nickname = "original", + role = role, + memberKind = kind + ) + ).also { memberIds.add(requireNotNull(it.id)) } + + private fun rowId(creatorId: Long): Long = requireNotNull( + jdbcTemplate.queryForObject( + "select id from creator_localized_name where member_id = ? and lang = 'JA'", + Long::class.java, + creatorId + ) + ) + + private fun url(creatorId: Long): String = "http://127.0.0.1:$port/api/v2/admin/creators/$creatorId/localized-names/ja" + + private fun memberListUrl(): String = "http://127.0.0.1:$port/admin/member/creator/list?page=0&size=20" +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt new file mode 100644 index 00000000..22d9b01e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt @@ -0,0 +1,148 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration +import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName +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.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import java.util.UUID +import javax.persistence.EntityManager +import javax.persistence.PersistenceException + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class) +class CreatorLocalizedNamePersistenceTest @Autowired constructor( + private val jdbcTemplate: JdbcTemplate, + private val entityManager: EntityManager, + private val repository: DefaultCreatorLocalizedNameRepository +) { + @Test + @DisplayName("별칭 저장 테이블은 표시 이름과 Lang 컬럼 길이를 승인 스키마와 일치시킨다") + fun shouldMapApprovedStorageLengths() { + assertEquals( + 1L, + jdbcTemplate.queryForObject( + "select count(*) from INFORMATION_SCHEMA.TABLES where TABLE_NAME = 'creator_localized_name'", + Long::class.java + ), + "Creator localized-name persistence must exist" + ) + assertEquals(255L, columnLength("display_name")) + assertEquals(10L, columnLength("lang")) + } + + @Test + @DisplayName("비활성 저장·재저장 반복도 동일 ID와 값·생성 시각을 보존하고 원본과 다른 언어는 변경하지 않는다") + fun shouldPersistSoftLifecycleOnSameRow() { + val memberId = saveMember() + val english = repository.save(CreatorLocalizedName(memberId, Lang.EN, "English")) + var saved = repository.save(CreatorLocalizedName(memberId, Lang.JA, "first")) + flushAndClear() + val id = requireNotNull(saved.id) + val createdAt = entityManager.find(CreatorLocalizedNameEntity::class.java, id).createdAt + assertNotNull(createdAt) + repeat(3) { index -> + repository.save(saved.copy(isActive = false)) + flushAndClear() + val inactive = requireNotNull(repository.findByMemberIdAndLang(memberId, Lang.JA)) + assertFalse(inactive.isActive) + assertEquals(saved.displayName, inactive.displayName) + assertEquals(id, inactive.id) + assertEquals(emptyMap(), repository.findActiveLocalizedNames(listOf(memberId), Lang.JA)) + saved = repository.save(inactive.copy(displayName = "next-$index", isActive = true)) + flushAndClear() + assertEquals(id, saved.id) + assertEquals(mapOf(memberId to "next-$index"), repository.findActiveLocalizedNames(listOf(memberId), Lang.JA)) + } + assertEquals(english, repository.findByMemberIdAndLang(memberId, Lang.EN)) + assertEquals( + 2L, + jdbcTemplate.queryForObject( + "select count(*) from creator_localized_name where member_id = ?", + Long::class.java, + memberId + ) + ) + val entity = entityManager.find(CreatorLocalizedNameEntity::class.java, id) + assertEquals(createdAt, entity.createdAt) + assertNotNull(entity.updatedAt) + assertEquals( + "JA", + jdbcTemplate.queryForObject("select lang from creator_localized_name where id = ?", String::class.java, id) + ) + assertEquals("original", entityManager.find(Member::class.java, memberId).nickname) + assertNull(repository.findByMemberIdAndLang(memberId, Lang.KO)) + } + + @Test + @DisplayName("동일 표시 이름은 서로 다른 크리에이터에 중복 저장할 수 있다") + fun shouldAllowDuplicateDisplayNamesAcrossMembers() { + val first = saveMember() + val second = saveMember() + repository.save(CreatorLocalizedName(first, Lang.JA, "同じ名前")) + repository.save(CreatorLocalizedName(second, Lang.JA, "同じ名前")) + flushAndClear() + assertEquals( + mapOf(first to "同じ名前", second to "同じ名前"), + repository.findActiveLocalizedNames(listOf(first, second), Lang.JA) + ) + } + + @Test + @DisplayName("member+lang 고유 제약은 비활성 행과 활성 행 사이의 중복도 거부한다") + fun shouldEnforceUniqueMemberAndLangAcrossActiveStates() { + val memberId = saveMember() + repository.save(CreatorLocalizedName(memberId, Lang.JA, "inactive", false)) + flushAndClear() + assertThrows(PersistenceException::class.java) { + repository.save(CreatorLocalizedName(memberId, Lang.JA, "duplicate", true)) + entityManager.flush() + } + } + + @Test + @DisplayName("회원 외래키는 없는 회원의 별칭 저장을 거부한다") + fun shouldEnforceMemberForeignKey() { + assertThrows(PersistenceException::class.java) { + repository.save(CreatorLocalizedName(Long.MAX_VALUE, Lang.JA, "orphan")) + entityManager.flush() + } + } + + private fun saveMember(): Long { + val member = Member( + email = "${UUID.randomUUID()}@test.com", + password = "password", + nickname = "original", + role = MemberRole.CREATOR + ) + entityManager.persist(member) + return requireNotNull(member.id) + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } + + private fun columnLength(column: String): Long? = jdbcTemplate.queryForObject( + "select CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS " + + "where TABLE_NAME = 'creator_localized_name' and COLUMN_NAME = ?", + Long::class.java, + column + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoinTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoinTest.kt new file mode 100644 index 00000000..7209fdde --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoinTest.kt @@ -0,0 +1,151 @@ +package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember +import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration +import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName +import org.hibernate.SessionFactory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none", "spring.jpa.properties.hibernate.generate_statistics=true"]) +@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class) +class CreatorNameJoinTest @Autowired constructor( + private val entityManager: EntityManager, + private val queryFactory: JPAQueryFactory, + private val repository: DefaultCreatorLocalizedNameRepository +) { + @ParameterizedTest + @ValueSource(strings = ["하늘", "さくら", "Alice"]) + @DisplayName("직접 LEFT JOIN은 원본 언어와 무관하게 요청 Lang 별칭과 원본 fallback을 한 SELECT로 반환하며 페이지 순서를 유지한다") + fun shouldProjectNamesWithoutDroppingFallbackRowsOrDuplicatingPages(originalNickname: String) { + val creator = saveMember(originalNickname) + val user = saveMember(originalNickname, MemberRole.USER) + val inactiveAlias = saveMember(originalNickname) + val fallback = saveMember(originalNickname) + val deletedUser = saveMember(originalNickname, MemberRole.USER).apply { isActive = false } + val aliases = mapOf(Lang.KO to "한국어 별칭", Lang.JA to "別名", Lang.EN to "English alias") + aliases.forEach { (aliasLang, displayName) -> + repository.save(CreatorLocalizedName(creator.id!!, aliasLang, displayName)) + repository.save(CreatorLocalizedName(user.id!!, aliasLang, "must not replace USER")) + repository.save(CreatorLocalizedName(inactiveAlias.id!!, aliasLang, "inactive alias", false)) + repository.save(CreatorLocalizedName(deletedUser.id!!, aliasLang, "must not replace inactive USER")) + } + entityManager.flush() + entityManager.clear() + val owner = QMember("displayOwner") + val ids = listOf(creator.id!!, user.id!!, inactiveAlias.id!!, fallback.id!!, deletedUser.id!!, Long.MAX_VALUE) + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + Lang.values().forEach { lang -> + val join = CreatorNameJoin(owner, lang, "displayOwnerName") + val query = queryFactory.select(owner.id, join.displayName) + .from(owner) + .leftJoin(join.name).on(join.condition) + .where(owner.id.`in`(ids)) + .orderBy(owner.id.desc()) + statistics.clear() + + val rows = query.fetch().map { row -> row[owner.id] to row[join.displayName] } + + assertEquals( + listOf( + deletedUser.id to originalNickname, + fallback.id to originalNickname, + inactiveAlias.id to originalNickname, + user.id to originalNickname, + creator.id to aliases.getValue(lang) + ), + rows + ) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + statistics.clear() + val page = query.clone().offset(1).limit(2).fetch().map { row -> row[owner.id] to row[join.displayName] } + assertEquals(listOf(fallback.id to originalNickname, inactiveAlias.id to originalNickname), page) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + } + } + + @Test + @DisplayName("하나의 쿼리에서 서로 다른 owner와 이름 alias를 독립적으로 조인한다") + fun shouldJoinSeparateOwnerAliasesInOneQuery() { + val sender = saveMember("join-sender") + val recipient = saveMember("join-recipient") + repository.save(CreatorLocalizedName(sender.id!!, Lang.EN, "English sender")) + repository.save(CreatorLocalizedName(recipient.id!!, Lang.EN, "English recipient")) + entityManager.flush() + entityManager.clear() + val senderOwner = QMember("senderOwner") + val recipientOwner = QMember("recipientOwner") + val senderName = CreatorNameJoin(senderOwner, Lang.EN, "senderName") + val recipientName = CreatorNameJoin(recipientOwner, Lang.EN, "recipientName") + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + val row = requireNotNull( + queryFactory.select(senderOwner.id, senderName.displayName, recipientOwner.id, recipientName.displayName) + .from(senderOwner) + .innerJoin(recipientOwner).on(recipientOwner.id.eq(recipient.id)) + .leftJoin(senderName.name).on(senderName.condition) + .leftJoin(recipientName.name).on(recipientName.condition) + .where(senderOwner.id.eq(sender.id)) + .fetchOne() + ) + + assertEquals(sender.id, row[senderOwner.id]) + assertEquals("English sender", row[senderName.displayName]) + assertEquals(recipient.id, row[recipientOwner.id]) + assertEquals("English recipient", row[recipientName.displayName]) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + } + + @ParameterizedTest + @ValueSource(strings = ["하늘", "さくら", "Alice"]) + @DisplayName("sparse 저장소 읽기는 원본 언어와 무관하게 요청 Lang의 활성 현재 CREATOR만 한 SQL로 반환한다") + fun shouldReadSparseActiveCreatorOverridesForEveryLanguage(originalNickname: String) { + val creator = saveMember(originalNickname) + val user = saveMember(originalNickname) + val inactiveAlias = saveMember(originalNickname) + val fallback = saveMember(originalNickname) + val aliases = mapOf(Lang.KO to "한국어 별칭", Lang.JA to "別名", Lang.EN to "English alias") + aliases.forEach { (aliasLang, displayName) -> + repository.save(CreatorLocalizedName(creator.id!!, aliasLang, displayName)) + repository.save(CreatorLocalizedName(user.id!!, aliasLang, "must not replace former creator")) + repository.save(CreatorLocalizedName(inactiveAlias.id!!, aliasLang, "inactive", false)) + } + user.role = MemberRole.USER + entityManager.flush() + entityManager.clear() + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + Lang.values().forEach { lang -> + statistics.clear() + + assertEquals( + mapOf(creator.id to aliases.getValue(lang)), + repository.findActiveLocalizedNames( + listOf(creator.id!!, user.id!!, inactiveAlias.id!!, fallback.id!!, Long.MAX_VALUE, creator.id!!), + lang + ) + ) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + } + } + + private fun saveMember(nickname: String, role: MemberRole = MemberRole.CREATOR): Member = + Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role) + .also { entityManager.persist(it) } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt new file mode 100644 index 00000000..f171bebe --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt @@ -0,0 +1,404 @@ +package kr.co.vividnext.sodalive.v2.creatorname.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName +import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository +import kr.co.vividnext.sodalive.v2.creatorname.support.CreatorLocalizedNameH2Schema +import org.hibernate.SessionFactory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.persistence.EntityManager + +@SpringBootTest +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class CreatorLocalizedNameServiceTest @Autowired constructor( + private val service: CreatorLocalizedNameService, + private val repository: CreatorLocalizedNameRepository, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @BeforeEach + fun prepareCodePointStorage() { + CreatorLocalizedNameH2Schema.prepare(jdbcTemplate) + } + + @Test + @DisplayName("공유 서비스는 명시 Lang과 빈 회원 ID로 빈 별칭 map을 반환한다") + fun shouldReturnEmptyMapForEmptyMemberIds() { + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.isStatisticsEnabled = true + statistics.clear() + try { + assertEquals(emptyMap>(), service.findAllLocalizedNames(emptyList())) + assertEquals(emptyMap>(), repository.findAllActiveLocalizedNames(emptyList())) + Lang.values().forEach { lang -> + assertEquals(emptyMap(), service.findLocalizedNames(emptyList(), lang)) + assertEquals(emptyMap(), repository.findActiveLocalizedNames(emptyList(), lang)) + assertEquals(emptyMap(), service.findCurrentNames(emptyList(), lang)) + assertEquals(emptyMap(), repository.findCurrentNames(emptyList(), lang)) + } + assertEquals(0L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + } finally { + statistics.isStatisticsEnabled = false + } + } + + @Test + @DisplayName("관리자 batch는 모든 활성 언어를 원문으로 한 SQL에 조회하고 USER·해제 이름과 원본 fallback은 제외한다") + fun shouldFindAllActiveCreatorLanguagesInOneScalarBatchQuery() { + val creatorId = saveMember() + val aiCreatorId = saveMember(kind = MemberKind.AI_CHARACTER) + val inactiveAliasId = saveMember() + val missingId = saveMember() + val userId = saveMember(MemberRole.USER) + val aiUserId = saveMember(MemberRole.USER, MemberKind.AI_CHARACTER) + memberRepository.findById(aiCreatorId).get().isActive = false + repository.save(CreatorLocalizedName(creatorId, Lang.JA, " 日本 Name 😀 ")) + repository.save(CreatorLocalizedName(creatorId, Lang.EN, "English")) + repository.save(CreatorLocalizedName(creatorId, Lang.KO, "해제됨", false)) + repository.save(CreatorLocalizedName(aiCreatorId, Lang.JA, "AI 日本")) + repository.save(CreatorLocalizedName(aiCreatorId, Lang.KO, "AI 한국어")) + repository.save(CreatorLocalizedName(inactiveAliasId, Lang.JA, "inactive", false)) + repository.save(CreatorLocalizedName(userId, Lang.JA, "must not expose USER")) + repository.save(CreatorLocalizedName(aiUserId, Lang.EN, "must not expose AI USER")) + entityManager.flush() + entityManager.clear() + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.isStatisticsEnabled = true + val ids = listOf(creatorId, aiCreatorId, inactiveAliasId, missingId, userId, aiUserId, Long.MAX_VALUE, creatorId) + val expected = mapOf( + creatorId to mapOf("ja" to " 日本 Name 😀 ", "en" to "English"), + aiCreatorId to mapOf("ja" to "AI 日本", "ko" to "AI 한국어") + ) + try { + statistics.clear() + assertEquals(expected, service.findAllLocalizedNames(ids)) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + statistics.clear() + assertEquals(expected, repository.findAllActiveLocalizedNames(ids)) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + } finally { + statistics.isStatisticsEnabled = false + } + } + + @Test + @DisplayName("JA 배치 조회는 활성 CREATOR만 포함하고 AI kind로 USER를 허용하지 않으며 한 번만 조회한다") + fun shouldFindOnlyActiveJapaneseCreatorAliasesInOneBatchQuery() { + val creatorId = saveMember() + val aiCreatorId = saveMember(kind = MemberKind.AI_CHARACTER) + val inactiveId = saveMember() + val missingId = saveMember() + val userId = saveMember(MemberRole.USER) + val aiUserId = saveMember(MemberRole.USER, MemberKind.AI_CHARACTER) + val otherLanguageId = saveMember() + repository.save(CreatorLocalizedName(creatorId, Lang.JA, "同じ名前")) + repository.save(CreatorLocalizedName(aiCreatorId, Lang.JA, "同じ名前")) + repository.save(CreatorLocalizedName(inactiveId, Lang.JA, "inactive", false)) + repository.save(CreatorLocalizedName(userId, Lang.JA, "user")) + repository.save(CreatorLocalizedName(aiUserId, Lang.JA, "ai-user")) + repository.save(CreatorLocalizedName(otherLanguageId, Lang.EN, "english")) + entityManager.flush() + entityManager.clear() + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.isStatisticsEnabled = true + statistics.clear() + try { + val ids = listOf( + creatorId, aiCreatorId, inactiveId, missingId, userId, aiUserId, otherLanguageId, Long.MAX_VALUE, creatorId + ) + assertEquals(mapOf(creatorId to "同じ名前", aiCreatorId to "同じ名前"), service.findLocalizedNames(ids, Lang.JA)) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + statistics.clear() + assertEquals(emptyMap(), service.findLocalizedNames(ids, Lang.KO)) + assertEquals(1L, statistics.prepareStatementCount) + statistics.clear() + assertEquals(mapOf(otherLanguageId to "english"), service.findLocalizedNames(ids, Lang.EN)) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + } finally { + statistics.isStatisticsEnabled = false + } + } + + @ParameterizedTest + @ValueSource(strings = ["하늘", "さくら", "Alice"]) + @DisplayName("현재 이름 batch는 원본 언어와 무관하게 요청 Lang 별칭을 한 SQL로 반환하고 USER·미등록·비활성은 원본이다") + fun shouldFindCurrentNamesWithOneScalarQueryForEveryLanguage(originalNickname: String) { + val creatorId = saveMember(nickname = originalNickname) + val fallbackId = saveMember(nickname = originalNickname) + val inactiveAliasId = saveMember(nickname = originalNickname) + val userId = saveMember(MemberRole.USER, nickname = originalNickname) + val deletedUserId = saveMember(MemberRole.USER, nickname = originalNickname) + memberRepository.findById(deletedUserId).get().apply { + isActive = false + nickname = "_deleted_$originalNickname" + } + val aliases = mapOf(Lang.KO to "한국어 별칭", Lang.JA to "別名", Lang.EN to "English alias") + aliases.forEach { (aliasLang, displayName) -> + repository.save(CreatorLocalizedName(creatorId, aliasLang, displayName)) + repository.save(CreatorLocalizedName(inactiveAliasId, aliasLang, "inactive", false)) + repository.save(CreatorLocalizedName(userId, aliasLang, "must not replace USER")) + repository.save(CreatorLocalizedName(deletedUserId, aliasLang, "must not replace inactive USER")) + } + entityManager.flush() + entityManager.clear() + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.isStatisticsEnabled = true + val ids = listOf(creatorId, fallbackId, inactiveAliasId, userId, deletedUserId, Long.MAX_VALUE, creatorId) + try { + Lang.values().forEach { lang -> + val expectedCreatorName = aliases.getValue(lang) + val expected = mapOf( + creatorId to expectedCreatorName, + fallbackId to originalNickname, + inactiveAliasId to originalNickname, + userId to originalNickname, + deletedUserId to "_deleted_$originalNickname" + ) + statistics.clear() + assertEquals(mapOf(creatorId to expectedCreatorName), service.findCurrentNames(listOf(creatorId), lang)) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + statistics.clear() + assertEquals(expected, service.findCurrentNames(ids, lang)) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + statistics.clear() + assertEquals(expected, repository.findCurrentNames(ids, lang)) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals(0L, statistics.entityLoadCount) + } + } finally { + statistics.isStatisticsEnabled = false + } + } + + @Test + @DisplayName("입력은 원문 그대로 저장하고 원본 닉네임 변경 후에도 별칭을 유지하며 해제하면 현재 원본만 노출한다") + fun shouldPreserveAliasAcrossOriginalNicknameChanges() { + val creatorId = saveMember(nickname = "さくら") + val rawName = " Name 日本 😀 ! " + service.saveLocalizedName(creatorId, Lang.JA, rawName) + memberRepository.findById(creatorId).get().nickname = "Alice" + entityManager.flush() + entityManager.clear() + val active = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)) + assertTrue(active.isActive) + assertEquals("Alice", memberRepository.findById(creatorId).get().nickname) + assertEquals(rawName, active.displayName) + assertEquals(mapOf(creatorId to rawName), service.findLocalizedNames(listOf(creatorId), Lang.JA)) + assertEquals(mapOf(creatorId to mapOf("ja" to rawName)), service.findAllLocalizedNames(listOf(creatorId))) + Lang.values().forEach { lang -> + val expected = if (lang == Lang.JA) rawName else "Alice" + assertEquals(mapOf(creatorId to expected), service.findCurrentNames(listOf(creatorId), lang)) + } + val cleared = service.saveLocalizedName(creatorId, Lang.JA, "") + assertEquals("Alice", cleared.originalNickname) + assertNull(cleared.displayName) + assertEquals(emptyMap(), service.findLocalizedNames(listOf(creatorId), Lang.JA)) + assertEquals(emptyMap>(), service.findAllLocalizedNames(listOf(creatorId))) + entityManager.flush() + entityManager.clear() + val inactive = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)) + assertEquals(active.id, inactive.id) + assertEquals(rawName, inactive.displayName) + assertFalse(inactive.isActive) + Lang.values().forEach { lang -> + assertEquals(mapOf(creatorId to "Alice"), service.findCurrentNames(listOf(creatorId), lang)) + } + assertEquals("Alice", memberRepository.findById(creatorId).get().nickname) + } + + @Test + @DisplayName("save·clear 반복은 동일 행 ID와 마지막 값을 보존하고 다른 언어 행은 변경하지 않는다") + fun shouldSoftClearAndReactivateSameRowWithoutChangingOtherLanguages() { + val creatorId = saveMember() + val english = repository.save(CreatorLocalizedName(creatorId, Lang.EN, "english")) + service.saveLocalizedName(creatorId, Lang.JA, "first") + val id = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)).id + listOf("second", "third", "fourth").forEach { name -> + service.saveLocalizedName(creatorId, Lang.JA, " \t\n") + entityManager.flush() + entityManager.clear() + val cleared = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)) + assertFalse(cleared.isActive) + assertEquals(id, cleared.id) + assertTrue(cleared.displayName.isNotBlank()) + service.saveLocalizedName(creatorId, Lang.JA, name) + entityManager.flush() + entityManager.clear() + val active = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)) + assertEquals(id, active.id) + assertEquals(name, active.displayName) + assertTrue(active.isActive) + } + assertEquals(english, repository.findByMemberIdAndLang(creatorId, Lang.EN)) + assertEquals( + 2L, + jdbcTemplate.queryForObject( + "select count(*) from creator_localized_name where member_id = ?", + Long::class.java, + creatorId + ) + ) + assertEquals("original", memberRepository.findById(creatorId).get().nickname) + } + + @Test + @DisplayName("없는 별칭을 빈 값 또는 공백으로 해제해도 행을 생성하지 않는다") + fun shouldNotInsertWhenClearingAbsentAlias() { + val creatorId = saveMember() + listOf("", " \t\n").forEach { blank -> + assertNull(service.saveLocalizedName(creatorId, Lang.JA, blank).displayName) + assertNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)) + } + assertEquals( + 0L, + jdbcTemplate.queryForObject( + "select count(*) from creator_localized_name where member_id = ?", + Long::class.java, + creatorId + ) + ) + } + + @Test + @DisplayName("255 code point BMP·이모지는 저장하고 256 code point 입력은 기존 행과 원본을 변경하지 않는다") + fun shouldValidateUnicodeCodePointsBeforeMutation() { + val creatorId = saveMember() + listOf("名".repeat(255), "😀".repeat(255)).forEach { accepted -> + assertEquals(accepted, service.saveLocalizedName(creatorId, Lang.JA, accepted).displayName) + entityManager.flush() + entityManager.clear() + assertEquals(accepted, repository.findByMemberIdAndLang(creatorId, Lang.JA)?.displayName) + } + val before = repository.findByMemberIdAndLang(creatorId, Lang.JA) + listOf("名".repeat(256), "😀".repeat(256), " ".repeat(256)).forEach { rejected -> + val error = assertThrows(SodaException::class.java) { service.saveLocalizedName(creatorId, Lang.JA, rejected) } + assertEquals("displayName", error.errorProperty) + assertEquals(before, repository.findByMemberIdAndLang(creatorId, Lang.JA)) + } + assertEquals("original", memberRepository.findById(creatorId).get().nickname) + } + + @Test + @DisplayName("CREATOR의 AI kind는 허용하지만 그 외 역할·USER AI·누락 대상·KO/EN 쓰기는 거부한다") + fun shouldRejectInvalidTargetsAndNonJapaneseWrites() { + val aiCreatorId = saveMember(kind = MemberKind.AI_CHARACTER) + assertEquals("AI", service.saveLocalizedName(aiCreatorId, Lang.JA, "AI").displayName) + MemberRole.values().filter { it != MemberRole.CREATOR }.forEach { role -> + val memberId = saveMember(role) + assertThrows(SodaException::class.java) { service.saveLocalizedName(memberId, Lang.JA, "denied") } + assertNull(repository.findByMemberIdAndLang(memberId, Lang.JA)) + } + val aiUserId = saveMember(MemberRole.USER, MemberKind.AI_CHARACTER) + assertThrows(SodaException::class.java) { service.saveLocalizedName(aiUserId, Lang.JA, "denied") } + assertNull(repository.findByMemberIdAndLang(aiUserId, Lang.JA)) + assertThrows(SodaException::class.java) { service.saveLocalizedName(Long.MAX_VALUE, Lang.JA, "name") } + listOf(Lang.KO, Lang.EN).forEach { lang -> + assertThrows(SodaException::class.java) { service.saveLocalizedName(aiCreatorId, lang, "denied") } + assertNull(repository.findByMemberIdAndLang(aiCreatorId, lang)) + } + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동시 최초 저장은 회원 행 잠금으로 직렬화되어 하나의 별칭 행만 생성한다") + fun shouldSerializeConcurrentInitialSaves() { + val creatorId = saveMember() + val executor = Executors.newFixedThreadPool(4) + val ready = CountDownLatch(4) + val start = CountDownLatch(1) + try { + val names = listOf("first", "second", "third", "fourth") + val futures = names.map { name -> + executor.submit { + ready.countDown() + check(start.await(10, TimeUnit.SECONDS)) + service.saveLocalizedName(creatorId, Lang.JA, name).displayName + } + } + assertTrue(ready.await(10, TimeUnit.SECONDS)) + start.countDown() + assertEquals(names, futures.map { it.get(10, TimeUnit.SECONDS) }) + assertEquals( + 1L, + jdbcTemplate.queryForObject( + "select count(*) from creator_localized_name where member_id = ?", + Long::class.java, + creatorId + ) + ) + val saved = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)) + assertTrue(saved.isActive) + assertTrue(names.contains(saved.displayName)) + assertEquals("original", memberRepository.findById(creatorId).get().nickname) + } finally { + start.countDown() + executor.shutdownNow() + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)) + jdbcTemplate.update("delete from creator_localized_name where member_id = ?", creatorId) + memberRepository.deleteById(creatorId) + } + } + + @Test + @DisplayName("테스트 H2 저장 제약도 256 code point를 거부해 MySQL 저장 한도를 약화하지 않는다") + fun shouldKeepCodePointStorageLimitInH2Fixture() { + val creatorId = saveMember() + assertThrows(org.springframework.dao.DataIntegrityViolationException::class.java) { + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active) values (?, 'JA', ?, true)", + creatorId, + "😀".repeat(256) + ) + } + } + + private fun saveMember( + role: MemberRole = MemberRole.CREATOR, + kind: MemberKind = MemberKind.HUMAN, + nickname: String = "original" + ): Long = requireNotNull( + memberRepository.saveAndFlush( + Member( + email = "${UUID.randomUUID()}@test.com", + password = "password", + nickname = nickname, + role = role, + memberKind = kind + ) + ).id + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt new file mode 100644 index 00000000..4011f226 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt @@ -0,0 +1,467 @@ +package kr.co.vividnext.sodalive.v2.creatorname.application + +import kr.co.vividnext.sodalive.audition.Audition +import kr.co.vividnext.sodalive.audition.AuditionApplicant +import kr.co.vividnext.sodalive.audition.AuditionRole +import kr.co.vividnext.sodalive.audition.applicant.AuditionApplicantService +import kr.co.vividnext.sodalive.audition.applicant.AuditionApplicantSortType +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.can.CanService +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderService +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylist +import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistRedisRepository +import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistService +import kr.co.vividnext.sodalive.content.playlist.PlaylistContentIdAndOrder +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.explorer.ExplorerService +import kr.co.vividnext.sodalive.explorer.MemberDonationRankingListResponse +import kr.co.vividnext.sodalive.explorer.MemberDonationRankingResponse +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +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.block.BlockMember +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +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.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.data.domain.PageRequest +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.datasource.url=jdbc:h2:mem:legacy-current-name-reads;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class LegacyCurrentNameReadIntegrationTest @Autowired constructor( + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate, + private val memberService: MemberService, + private val orderService: OrderService, + private val playlistService: AudioContentPlaylistService, + private val playlistRepository: AudioContentPlaylistRedisRepository, + private val communityService: CreatorCommunityService, + private val donationService: ChannelDonationService, + private val explorerService: ExplorerService, + private val canService: CanService, + private val contentService: AudioContentService, + private val applicantService: AuditionApplicantService, + private val redisTemplate: RedisTemplate +) { + @MockBean + private lateinit var langContext: LangContext + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var cloudFront: AudioContentCloudFront + + @BeforeEach + fun setUp() { + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ) + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ) + Mockito.doReturn(Lang.JA).`when`(langContext).lang + Mockito.doReturn("US").`when`(countryContext).countryCode + Mockito.doReturn("https://cdn.test/signed-audio").`when`(cloudFront) + .generateSignedURL(Mockito.anyString(), Mockito.anyLong()) + } + + @Test + @DisplayName("다른 회원 프로필과 차단 목록은 JA 이름을 표시하고 편집용 원본 프로필과 USER는 보존한다") + fun shouldLocalizeMemberDisplayAndBlockedListWithoutChangingRawEditorOrUserName() { + val viewer = saveMember("blocked-viewer", MemberRole.USER) + val creator = saveMember("blocked-creator", MemberRole.CREATOR) + val user = saveMember("blocked-user", MemberRole.USER) + listOf(creator, user).forEach { target -> + entityManager.persist( + BlockMember().apply { + member = viewer + blockedMember = target + } + ) + } + saveAlias(creator, "Japanese blocked creator") + saveAlias(user, "Must not replace blocked USER") + entityManager.clear() + + assertEquals("Japanese blocked creator", memberService.getMemberProfile(creator.id!!, viewer.id!!).nickname) + assertEquals("blocked-creator", memberService.getMember(creator.id!!, "web").nickname) + assertEquals("Japanese blocked creator", memberService.getMyPage(creator, "web").nickname) + val blocked = memberService.getBlockedMemberList(viewer.id!!, 0, 20) + assertEquals(2, blocked.totalCount) + assertEquals( + mapOf(creator.id to "Japanese blocked creator", user.id to "blocked-user"), + blocked.items.associate { it.memberId to it.nickname } + ) + assertEquals("blocked-creator", entityManager.find(Member::class.java, creator.id).nickname) + } + + @Test + @DisplayName("주문과 Redis 플레이리스트 상세는 콘텐츠 ID와 다른 소유자 이름을 현재 언어로 표시한다") + fun shouldLocalizeOrderAndPlaylistOwnersWithoutChangingTitlesOrStoredPlaylist() { + val viewer = saveMember("playlist-viewer", MemberRole.USER) + val creator = saveMember("playlist-creator", MemberRole.CREATOR) + val content = saveContent(creator, "Unchanged playlist title") + entityManager.persist( + Order(type = OrderType.KEEP).apply { + member = viewer + this.creator = creator + audioContent = content + } + ) + saveAlias(creator, "Japanese playlist creator") + entityManager.clear() + assertNotEquals(creator.id, content.id) + val playlist = AudioContentPlaylist( + id = 100000L + viewer.id!!, + memberId = viewer.id!!, + title = "playlist metadata", + contentIdAndOrderList = listOf(PlaylistContentIdAndOrder(content.id!!, 1)) + ) + playlistRepository.save(playlist) + try { + val order = orderService.getAudioContentOrderList(viewer, 0, 20) + assertEquals("Japanese playlist creator", order.items.single().creatorNickname) + assertEquals("Unchanged playlist title", order.items.single().title) + val detail = playlistService.getPlaylistDetail(playlist.id, viewer) + assertEquals("Japanese playlist creator", detail.contentList.single().creatorNickname) + assertEquals("Unchanged playlist title", detail.contentList.single().title) + assertEquals(playlist, playlistRepository.findById(playlist.id).orElseThrow()) + Mockito.doReturn(Lang.KO).`when`(langContext).lang + val korean = playlistService.getPlaylistDetail(playlist.id, viewer) + assertEquals("playlist-creator", korean.contentList.single().creatorNickname) + assertEquals("Japanese playlist creator", detail.contentList.single().creatorNickname) + } finally { + playlistRepository.delete(playlist) + } + } + + @Test + @DisplayName("한정 콘텐츠 상세의 구매자 목록은 콘텐츠 소유자가 아닌 각 구매자 CREATOR 이름만 대체한다") + fun shouldLocalizeMixedBuyerNamesByBuyerIdWithoutAddingPublicOwnerFields() { + val owner = saveMember("buyer-content-owner", MemberRole.CREATOR) + val creatorBuyer = saveMember("buyer-creator", MemberRole.CREATOR) + val userBuyer = saveMember("buyer-user", MemberRole.USER) + val content = saveContent(owner, "Unchanged limited content title", limited = 5) + listOf(creatorBuyer, userBuyer).forEach { buyer -> + entityManager.persist( + Order(type = OrderType.KEEP).apply { + member = buyer + creator = owner + audioContent = content + } + ) + } + saveAlias(owner, "Japanese content owner") + saveAlias(creatorBuyer, "Japanese buyer") + saveAlias(userBuyer, "Must not replace buyer USER") + entityManager.clear() + + val detail = contentService.getDetail(content.id!!, owner, false, "Asia/Seoul") + + assertEquals("Japanese content owner", detail.creator.nickname) + assertEquals(setOf("Japanese buyer", "buyer-user"), detail.buyerList.map { it.nickname }.toSet()) + assertEquals("Unchanged limited content title", detail.title) + assertEquals("https://cdn.test/signed-audio", detail.contentUrl) + } + + @Test + @DisplayName("레거시 커뮤니티 게시글·댓글·답글·구매 응답은 활성 JA·KO 별칭이 있어도 원본 이름을 유지한다") + fun shouldKeepOriginalCommunityNamesDespiteActiveJaAndKoAliases() { + val viewer = saveMember("community-viewer", MemberRole.USER) + val creator = saveMember("community-owner", MemberRole.CREATOR) + val peer = saveMember("deleted_community-peer", MemberRole.CREATOR) + val post = savePost(creator) + val parent = CreatorCommunityComment(comment = "unchanged creator comment").apply { + member = peer + creatorCommunity = post + entityManager.persist(this) + } + val reply = CreatorCommunityComment(comment = "unchanged USER reply").apply { + member = viewer + creatorCommunity = post + this.parent = parent + entityManager.persist(this) + } + val creatorReply = CreatorCommunityComment(comment = "unchanged creator reply").apply { + member = creator + creatorCommunity = post + this.parent = parent + entityManager.persist(this) + } + saveReceipt(viewer, CanUsage.PAID_COMMUNITY_POST, creator).communityPost = post + saveAlias(creator, "Japanese community owner") + saveAlias(peer, "Japanese community writer") + saveAlias(viewer, "Must not replace community USER") + listOf(creator, peer).forEach { writer -> + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, 'KO', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + writer.id!!, + "Korean ${writer.nickname}" + ) + } + entityManager.clear() + + listOf(Lang.JA, Lang.KO).forEach { lang -> + Mockito.doReturn(lang).`when`(langContext).lang + val posts = communityService.getCommunityPostList(creator.id!!, viewer.id!!, "Asia/Seoul", 0, 20, false) + assertEquals("community-owner", posts.single().creatorNickname) + assertEquals("community-peer", posts.single().firstComment!!.nickname) + assertEquals("unchanged post content", posts.single().content) + val detail = communityService.getCommunityPostDetail(post.id!!, viewer.id!!, "Asia/Seoul", false) + assertEquals("community-owner", detail.creatorNickname) + val comments = communityService.getCommunityPostCommentList(post.id!!, viewer.id!!, "Asia/Seoul", 0, 20, false) + assertEquals("community-peer", comments.items.single().nickname) + val replies = communityService.getCommentReplyList(parent.id!!, viewer.id!!, "Asia/Seoul", 0, 20, false) + assertEquals( + mapOf(reply.id to "community-viewer", creatorReply.id to "community-owner"), + replies.items.associate { it.id to it.nickname } + ) + assertEquals("unchanged USER reply", replies.items.single { it.id == reply.id }.comment) + val purchased = communityService.purchasePost(post.id!!, viewer.id!!, "Asia/Seoul", false, "web") + assertEquals("community-owner", purchased.creatorNickname) + assertEquals("community-peer", purchased.firstComment!!.nickname) + } + } + + @Test + @DisplayName("채널 후원 목록의 이름 소유자는 채널이 아니라 후원자이며 USER는 원본이다") + fun shouldLocalizeCreatorDonorsIndependentlyOfTheChannelOwner() { + val viewer = saveMember("donation-viewer", MemberRole.USER) + val creator = saveMember("donation-channel", MemberRole.CREATOR) + val donor = saveMember("donation-creator-donor", MemberRole.CREATOR) + listOf(viewer, donor).forEach { writer -> + entityManager.persist( + ChannelDonationMessage(can = 100, isSecret = false).apply { + member = writer + this.creator = creator + } + ) + } + saveAlias(creator, "Wrong channel owner name") + saveAlias(donor, "Japanese donor") + saveAlias(viewer, "Must not replace donor USER") + entityManager.clear() + + val response = donationService.getChannelDonationList(creator.id!!, viewer, 0, 20) + assertEquals(2, response.totalCount) + assertEquals( + mapOf(donor.id to "Japanese donor", viewer.id to "donation-viewer"), + response.items.associate { it.memberId to it.nickname } + ) + assertEquals(listOf(100, 100), response.items.map { it.can }) + } + + @Test + @DisplayName("receipt는 각 recipient·audio·community 소유자의 현재 이름을 쓰고 리소스 제목과 금액을 보존한다") + fun shouldLocalizeReceiptOwnerNamesWithoutReplacingResourceTitlesOrAmounts() { + val viewer = saveMember("receipt-viewer", MemberRole.USER) + val owner = saveMember("receipt-owner", MemberRole.CREATOR) + val recipient = saveMember("receipt-recipient", MemberRole.CREATOR) + val content = saveContent(owner, "Unchanged receipt content title") + val post = savePost(owner) + saveReceipt(viewer, CanUsage.DONATION, owner).audioContent = content + saveReceipt(viewer, CanUsage.PAID_COMMUNITY_POST, owner).communityPost = post + saveReceipt(viewer, CanUsage.CHANNEL_DONATION, recipient) + saveReceipt(viewer, CanUsage.CHANNEL_DONATION, viewer) + saveReceipt(viewer, CanUsage.ORDER_CONTENT, owner).audioContent = content + saveAlias(owner, "Japanese receipt owner") + saveAlias(recipient, "Japanese recipient") + saveAlias(viewer, "Must not replace receipt USER") + entityManager.clear() + + val rows = canService.getCanUseStatus(viewer, PageRequest.of(0, 20), "UTC", "web") + assertEquals( + setOf( + "[콘텐츠 후원] Japanese receipt owner", + "[게시글 보기] Japanese receipt owner", + "[채널 후원] Japanese recipient", + "[채널 후원] receipt-viewer", + "[콘텐츠 구매] Unchanged receipt content title" + ), + rows.map { it.title }.toSet() + ) + assertEquals(5, rows.size) + assertEquals(listOf(100, 100, 100, 100, 100), rows.map { it.can }) + } + + @Test + @DisplayName("오디션 지원자는 applicant ID가 아닌 member ID로 이름을 표시하고 USER·count·정렬을 보존한다") + fun shouldLocalizeAuditionApplicantsByMemberIdAndKeepUserNamesAndCount() { + saveMember("applicant-unused-viewer", MemberRole.USER) + val creator = saveMember("applicant-creator", MemberRole.CREATOR) + val user = saveMember("applicant-user", MemberRole.USER) + val audition = Audition(title = "Unchanged audition title", information = "information") + entityManager.persist(audition) + val role = AuditionRole(name = "Unchanged role name", information = "information").apply { + this.audition = audition + entityManager.persist(this) + } + val creatorApplicant = AuditionApplicant(phoneNumber = "01000000000", voicePath = "voice.m4a").apply { + member = creator + this.role = role + entityManager.persist(this) + } + val userApplicant = AuditionApplicant(phoneNumber = "01000000001", voicePath = "voice.m4a").apply { + member = user + this.role = role + entityManager.persist(this) + } + saveAlias(creator, "Japanese applicant") + saveAlias(user, "Must not replace applicant USER") + entityManager.clear() + assertNotEquals(creator.id, creatorApplicant.id) + + val response = applicantService.getAuditionApplicantList(role.id!!, AuditionApplicantSortType.NEWEST, 0, 20) + + assertEquals(2, response.totalCount) + assertEquals(listOf(userApplicant.id, creatorApplicant.id), response.items.map { it.applicantId }) + assertEquals(listOf("applicant-user", "Japanese applicant"), response.items.map { it.nickname }) + } + + @Test + @DisplayName("중립 후원랭킹 cache hit는 요청별 현재 이름을 복사하고 원본 변경·해제 후 현재 원본으로 돌아간다") + fun shouldRenderCachedDonorNamesAfterReadAndKeepCachedRowsImmutable() { + val viewer = saveMember("cached-viewer", MemberRole.USER) + val channel = saveMember("cached-channel", MemberRole.CREATOR) + val donor = saveMember("cached-donor-original", MemberRole.CREATOR) + saveAlias(donor, "Japanese cached donor") + entityManager.clear() + val key = "creator_donation_ranking_v2:${channel.id}:CUMULATIVE:0:20:false" + val totalKey = "creator_donation_ranking_member_total_v2:${channel.id}:CUMULATIVE" + val cached = MemberDonationRankingListResponse( + listOf(MemberDonationRankingResponse(donor.id!!, "stale cached original", "image.png", 0)) + ) + redisTemplate.opsForValue().set(key, cached) + redisTemplate.opsForValue().set(totalKey, 1) + try { + val japanese = explorerService.getCreatorProfileDonationRanking( + channel.id!!, + DonationRankingPeriod.CUMULATIVE, + PageRequest.of(0, 20), + viewer + ) + assertEquals("Japanese cached donor", japanese.userDonationRanking.single().nickname) + jdbcTemplate.update("update member set nickname = ? where id = ?", "renamed donor", donor.id!!) + jdbcTemplate.update("update creator_localized_name set is_active = false where member_id = ?", donor.id!!) + entityManager.clear() + val cleared = explorerService.getCreatorProfileDonationRanking( + channel.id!!, + DonationRankingPeriod.CUMULATIVE, + PageRequest.of(0, 20), + viewer + ) + assertEquals("renamed donor", cleared.userDonationRanking.single().nickname) + assertEquals("Japanese cached donor", japanese.userDonationRanking.single().nickname) + assertEquals(cached, redisTemplate.opsForValue().get(key)) + } finally { + redisTemplate.delete(listOf(key, totalKey)) + } + } + + private fun saveMember(nickname: String, role: MemberRole): Member = + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "profile.png", + role = role + ).also { entityManager.persist(it) } + + private fun saveContent(owner: Member, title: String, limited: Int? = null): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1), + limited = limited, + remaining = limited + ) + .apply { + member = owner + this.theme = theme + isActive = true + content = "output/audio.m4a" + duration = "00:10:00" + coverImage = "content.png" + entityManager.persist(this) + } + } + + private fun savePost(owner: Member): CreatorCommunity = + CreatorCommunity( + content = "unchanged post content", + price = 0, + isCommentAvailable = true, + isAdult = false, + languageCode = "ko" + ).apply { + member = owner + entityManager.persist(this) + } + + private fun saveReceipt(viewer: Member, usage: CanUsage, recipient: Member): UseCan { + entityManager.flush() + val receipt = UseCan(canUsage = usage, can = 100, rewardCan = 0).apply { + member = viewer + entityManager.persist(this) + } + entityManager.persist( + UseCanCalculate(100, PaymentGateway.PG, UseCanCalculateStatus.RECEIVED).apply { + useCan = receipt + recipientCreatorId = recipient.id!! + } + ) + return receipt + } + + private fun saveAlias(member: Member, name: String) { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + name + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/support/CreatorLocalizedNameH2Schema.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/support/CreatorLocalizedNameH2Schema.kt new file mode 100644 index 00000000..752656a5 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/support/CreatorLocalizedNameH2Schema.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.v2.creatorname.support + +import org.springframework.jdbc.core.JdbcTemplate + +object CreatorLocalizedNameH2Schema { + fun prepare(jdbcTemplate: JdbcTemplate) { + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS creator_name_code_point_count FOR " + + "'kr.co.vividnext.sodalive.v2.creatorname.support.CreatorLocalizedNameH2Schema.codePointCount'" + ) + jdbcTemplate.execute("ALTER TABLE creator_localized_name ALTER COLUMN display_name VARCHAR(510) NOT NULL") + jdbcTemplate.execute( + "ALTER TABLE creator_localized_name ADD CONSTRAINT IF NOT EXISTS ck_creator_name_code_points " + + "CHECK (creator_name_code_point_count(display_name) <= 255)" + ) + } + + @JvmStatic + fun codePointCount(value: String): Int = value.codePointCount(0, value.length) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index 8ad5cd41..e1c06d67 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -2,8 +2,11 @@ package kr.co.vividnext.sodalive.v2.gift.application import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository @@ -35,14 +38,15 @@ import java.time.LocalDateTime "spring.datasource.url=jdbc:h2:mem:gift-query;MODE=MySQL;NON_KEYWORDS=VALUE" ] ) -@Import(QueryDslConfig::class) +@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class) class GiftQueryServiceTest @Autowired constructor( private val categoryRepository: GiftCategoryRepository, private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val deliveryRepository: GiftDeliveryRepository, private val memberRepository: MemberRepository, - private val giftMailboxRepository: GiftMailboxRepository + private val giftMailboxRepository: GiftMailboxRepository, + private val creatorLocalizedNameService: CreatorLocalizedNameService ) { @Test fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() { @@ -79,7 +83,8 @@ class GiftQueryServiceTest @Autowired constructor( memberId = sender.id!!, type = GiftListType.SENT, page = 0, - size = 20 + size = 20, + lang = Lang.KO ) assertEquals(1L, result.totalCount) @@ -122,7 +127,8 @@ class GiftQueryServiceTest @Autowired constructor( memberId = recipient.id!!, type = GiftListType.RECEIVED, page = 0, - size = 20 + size = 20, + lang = Lang.KO ) assertEquals(1L, result.totalCount) @@ -162,7 +168,8 @@ class GiftQueryServiceTest @Autowired constructor( memberId = member.id!!, type = GiftListType.ALL, page = 0, - size = 20 + size = 20, + lang = Lang.KO ) assertEquals(2L, result.totalCount) @@ -220,7 +227,8 @@ class GiftQueryServiceTest @Autowired constructor( memberId = member.id!!, type = GiftListType.ALL, page = 0, - size = 20 + size = 20, + lang = Lang.KO ) val items = result.items.associateBy { it.applicationNo } @@ -245,7 +253,8 @@ class GiftQueryServiceTest @Autowired constructor( memberId = sender.id!!, type = GiftListType.SENT, page = -1, - size = 0 + size = 0, + lang = Lang.KO ) assertEquals(0, result.page) @@ -272,7 +281,7 @@ class GiftQueryServiceTest @Autowired constructor( delivery.recipientAddress = "서울시 중구" delivery.recipientAddressDetail = "202호" - val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo) + val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo, Lang.KO) assertEquals(GiftDirection.SENT, result.direction) assertEquals(recipient.nickname, result.giftInfo.recipientCreatorNickname) @@ -335,7 +344,7 @@ class GiftQueryServiceTest @Autowired constructor( ) saveDelivery(gift).applyStatusTimes(status) - val result = service.getGiftDetail(sender.id!!, gift.applicationNo) + val result = service.getGiftDetail(sender.id!!, gift.applicationNo, Lang.KO) assertEquals("소다라이브 선물 담당자", result.mailbox?.name) assertEquals("01012345678", result.mailbox?.phoneNumber) @@ -370,7 +379,7 @@ class GiftQueryServiceTest @Autowired constructor( ) saveDelivery(gift).applyStatusTimes(status) - val result = service.getGiftDetail(sender.id!!, gift.applicationNo) + val result = service.getGiftDetail(sender.id!!, gift.applicationNo, Lang.KO) assertNull(result.mailbox) } @@ -383,7 +392,7 @@ class GiftQueryServiceTest @Autowired constructor( ) saveDelivery(receivedGift).applyStatusTimes(GiftStatus.TRACKING_REGISTERED) - val recipientResult = service.getGiftDetail(recipient.id!!, receivedGift.applicationNo) + val recipientResult = service.getGiftDetail(recipient.id!!, receivedGift.applicationNo, Lang.KO) assertNull(recipientResult.mailbox) } @@ -401,7 +410,7 @@ class GiftQueryServiceTest @Autowired constructor( ) saveDelivery(gift).applyStatusTimes(GiftStatus.INSPECTION_COMPLETED) - val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo) + val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo, Lang.KO) assertNull(result.mailbox) } @@ -426,7 +435,7 @@ class GiftQueryServiceTest @Autowired constructor( recipientAddressDetail = "202호" } - val result = queryService().getGiftDetail(recipient.id!!, gift.applicationNo) + val result = queryService().getGiftDetail(recipient.id!!, gift.applicationNo, Lang.KO) assertEquals(GiftDirection.RECEIVED, result.direction) assertNull(result.giftInfo.recipientCreatorNickname) @@ -478,13 +487,13 @@ class GiftQueryServiceTest @Autowired constructor( val service = queryService() val hiddenException = assertThrows(SodaException::class.java) { - service.getGiftDetail(recipient.id!!, hiddenGift.applicationNo) + service.getGiftDetail(recipient.id!!, hiddenGift.applicationNo, Lang.KO) } val unrelatedException = assertThrows(SodaException::class.java) { - service.getGiftDetail(other.id!!, visibleGift.applicationNo) + service.getGiftDetail(other.id!!, visibleGift.applicationNo, Lang.KO) } val missingDeliveryException = assertThrows(SodaException::class.java) { - service.getGiftDetail(sender.id!!, missingDeliveryGift.applicationNo) + service.getGiftDetail(sender.id!!, missingDeliveryGift.applicationNo, Lang.KO) } assertEquals("common.error.invalid_request", hiddenException.messageKey) @@ -517,8 +526,8 @@ class GiftQueryServiceTest @Autowired constructor( } val service = queryService() - val pendingResult = service.getGiftDetail(recipient.id!!, pendingGift.applicationNo) - val terminalResult = service.getGiftDetail(recipient.id!!, terminalGift.applicationNo) + val pendingResult = service.getGiftDetail(recipient.id!!, pendingGift.applicationNo, Lang.KO) + val terminalResult = service.getGiftDetail(recipient.id!!, terminalGift.applicationNo, Lang.KO) assertEquals(true, pendingResult.recipientAddressRequired) assertNull(pendingResult.recipientAddress) @@ -551,8 +560,8 @@ class GiftQueryServiceTest @Autowired constructor( } val service = queryService() - val canceledResult = service.getGiftDetail(sender.id!!, canceledGift.applicationNo) - val undeliverableResult = service.getGiftDetail(sender.id!!, undeliverableGift.applicationNo) + val canceledResult = service.getGiftDetail(sender.id!!, canceledGift.applicationNo, Lang.KO) + val undeliverableResult = service.getGiftDetail(sender.id!!, undeliverableGift.applicationNo, Lang.KO) assertEquals(NOW.plusHours(1), canceledResult.delivery.canceledAt) assertNull(canceledResult.statusTimeline[1].occurredAt) @@ -639,7 +648,8 @@ class GiftQueryServiceTest @Autowired constructor( deliveryRepository, memberRepository, giftMailboxRepository, - "https://cdn.test" + "https://cdn.test", + creatorLocalizedNameService ) private companion object { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt index 461212b7..8bb24ff3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Gender @@ -62,7 +63,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( activeFollow.createdAt = LocalDateTime.of(2026, 6, 25, 0, 0) flushAndClear() - val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20) + val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20, lang = Lang.KO) assertEquals(listOf(olderCreator.id!!, activeCreator.id!!), creators.map { it.creatorId }) assertEquals("https://cdn.test/active.png", creators.last().creatorProfileImageUrl) @@ -82,7 +83,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( saveBlock(creatorBlockedViewer, viewer) flushAndClear() - val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20) + val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20, lang = Lang.KO) assertEquals(listOf(visibleCreator.id!!), creators.map { it.creatorId }) } @@ -110,7 +111,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( canViewAdultContent = false, effectiveViewerGender = Gender.NONE, isViewerCreator = false, - limit = 10 + limit = 10, + lang = Lang.KO ) assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId }) @@ -143,7 +145,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( canViewAdultContent = true, effectiveViewerGender = Gender.FEMALE, isViewerCreator = true, - limit = 10 + limit = 10, + lang = Lang.KO ) assertEquals(listOf(visible.id!!), lives.map { it.liveId }) @@ -175,7 +178,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( effectiveViewerGender = Gender.NONE, isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 12, 0), - limit = 3 + limit = 3, + lang = Lang.KO ) assertEquals(listOf("LIVE:${live.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId }) @@ -210,7 +214,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( effectiveViewerGender = Gender.NONE, isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 0, 0), - limit = 3 + limit = 3, + lang = Lang.KO ) assertEquals(listOf("AUDIO:${scheduledAudio.id!!}"), schedules.map { it.scheduleId }) @@ -233,7 +238,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( effectiveViewerGender = Gender.NONE, isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 14, 30), - limit = 3 + limit = 3, + lang = Lang.KO ) assertTrue(schedules.map { it.scheduleId }.contains("AUDIO:${sameKstDayEvening.id!!}")) @@ -259,7 +265,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( effectiveViewerGender = Gender.NONE, isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 0, 0), - limit = 10 + limit = 10, + lang = Lang.KO ) assertEquals( @@ -302,7 +309,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( effectiveViewerGender = Gender.FEMALE, isViewerCreator = true, now = LocalDateTime.of(2026, 6, 25, 0, 0), - limit = 10 + limit = 10, + lang = Lang.KO ) assertEquals(listOf("LIVE:${visibleLive.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId }) @@ -332,7 +340,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = false, nowUtc = LocalDateTime.of(2026, 6, 25, 9, 30), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals(listOf(latestVisible.id!!.toString()), news.map { it.newsId }) @@ -377,13 +386,15 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 14, 23, 59), - limit = 30 + limit = 30, + lang = Lang.KO ) val afterLatestVisible = repository.findRecentNews( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals(listOf(oldSecondNews.id!!.toString(), oldFirstNews.id!!.toString()), beforeLatestVisible.map { it.newsId }) @@ -412,7 +423,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertTrue(news.none { it.type == FollowingNewsType.CREATOR_RANKING }) @@ -475,7 +487,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), - limit = 2 + limit = 2, + lang = Lang.KO ) assertEquals(listOf(latestFirst.id!!.toString(), latestSecond.id!!.toString()), news.map { it.newsId }) @@ -516,7 +529,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId }) @@ -556,7 +570,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = false, nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId }) @@ -577,7 +592,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = false, nowUtc = LocalDateTime.of(2026, 6, 25, 14, 30), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId }) @@ -628,7 +644,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) val communityNews = news.first { it.type == FollowingNewsType.COMMUNITY_POST }.communityPost!! @@ -668,7 +685,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertTrue(news.isEmpty()) @@ -713,7 +731,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals(listOf(negativeFreePost.id!!, freePost.id!!), news.map { it.communityPost?.postId }) @@ -759,13 +778,15 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = false, nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) val visibleNews = repository.findRecentNews( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals(emptyList(), hiddenNews.map { it.communityPost?.postId ?: it.audioContent?.contentId }) @@ -824,7 +845,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, canViewAdultContent = true, nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), - limit = 30 + limit = 30, + lang = Lang.KO ) assertEquals( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt index cff15ee5..3e51e424 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.home.following.application import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -29,13 +28,11 @@ import java.util.TimeZone class HomeFollowingQueryServiceTest { private val queryPort = RecordingHomeFollowingQueryPort() private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - private val langContext = LangContext() private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java) private val now = LocalDateTime.of(2026, 6, 25, 12, 0) private val service = HomeFollowingQueryService( queryPort, memberContentPreferenceService, - langContext, creatorCommunityTranslationService ) { now } @@ -45,7 +42,7 @@ class HomeFollowingQueryServiceTest { val member = member(10L) Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) - val home = service.findHomeFollowing(member) + val home = service.findHomeFollowing(member, Lang.KO) assertEquals(listOf(HomeFollowingCreator(1L, "creator", "profile")), home.followingCreators) assertEquals(emptyList(), home.recentChats) @@ -63,7 +60,7 @@ class HomeFollowingQueryServiceTest { val member = member(11L) Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(false) - service.findHomeFollowing(member) + service.findHomeFollowing(member, Lang.KO) assertEquals(false, queryPort.onAirCanViewAdultContent) assertEquals(false, queryPort.monthlySchedulesCanViewAdultContent) @@ -81,7 +78,7 @@ class HomeFollowingQueryServiceTest { } Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) - service.findHomeFollowing(member) + service.findHomeFollowing(member, Lang.KO) assertEquals(Gender.FEMALE, queryPort.onAirEffectiveViewerGender) assertEquals(Gender.FEMALE, queryPort.monthlySchedulesEffectiveViewerGender) @@ -99,14 +96,13 @@ class HomeFollowingQueryServiceTest { val defaultService = HomeFollowingQueryService( defaultQueryPort, memberContentPreferenceService, - langContext, creatorCommunityTranslationService ) val member = member(12L) Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1) - defaultService.findHomeFollowing(member) + defaultService.findHomeFollowing(member, Lang.KO) val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1) val capturedNow = defaultQueryPort.recentNewsNow!! @@ -121,9 +117,9 @@ class HomeFollowingQueryServiceTest { @Test @DisplayName("팔로잉 최근 소식은 커뮤니티 게시물만 영어 표시 본문으로 일괄 치환하고 다른 소식과 원문 폴백을 유지한다") fun shouldTranslateOnlyCommunityPostNewsInOneEnglishBatch() { - langContext.setLang(Lang.EN) val translatedNews = communityNews("community-1", 101L, "original-1") val audioNews = HomeFollowingNews( + creatorMemberId = 10L, newsId = "audio-1", type = FollowingNewsType.AUDIO_CONTENT, visibleFromAtUtc = "2026-06-25T11:59:00Z" @@ -138,7 +134,7 @@ class HomeFollowingQueryServiceTest { ) ) - val home = service.findHomeFollowing(member(14L)) + val home = service.findHomeFollowing(member(14L), Lang.EN) assertEquals("translated-1-en", home.recentNews[0].communityPost?.content) assertEquals(audioNews, home.recentNews[1]) @@ -153,6 +149,7 @@ class HomeFollowingQueryServiceTest { private fun communityNews(newsId: String, postId: Long, content: String): HomeFollowingNews { return HomeFollowingNews( + creatorMemberId = 10L, newsId = newsId, type = FollowingNewsType.COMMUNITY_POST, visibleFromAtUtc = "2026-06-25T12:00:00Z", @@ -186,7 +183,7 @@ class HomeFollowingQueryServiceTest { var recentNewsCanViewAdultContent: Boolean? = null var recentNews: List = emptyList() - override fun findFollowingCreators(memberId: Long, limit: Int): List { + override fun findFollowingCreators(memberId: Long, limit: Int, lang: Lang): List { this.memberId = memberId followingCreatorsLimit = limit return listOf(HomeFollowingCreator(1L, "creator", "profile")) @@ -197,7 +194,8 @@ class HomeFollowingQueryServiceTest { canViewAdultContent: Boolean, effectiveViewerGender: Gender, isViewerCreator: Boolean, - limit: Int + limit: Int, + lang: Lang ): List { onAirCanViewAdultContent = canViewAdultContent onAirEffectiveViewerGender = effectiveViewerGender @@ -212,7 +210,8 @@ class HomeFollowingQueryServiceTest { effectiveViewerGender: Gender, isViewerCreator: Boolean, now: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List { monthlySchedulesCanViewAdultContent = canViewAdultContent monthlySchedulesEffectiveViewerGender = effectiveViewerGender @@ -226,7 +225,8 @@ class HomeFollowingQueryServiceTest { memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, - limit: Int + limit: Int, + lang: Lang ): List { recentNewsCanViewAdultContent = canViewAdultContent recentNewsNow = nowUtc diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt index 1d3e1a95..cf36c383 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord @@ -98,7 +99,7 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( ) ) - val latestVisible = adapter.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, NOW) + val latestVisible = adapter.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, NOW, Lang.KO) assertEquals(listOf(3L, 4L, 2L), latestVisible.map { it.creatorId }) assertEquals(listOf(1, 2, 3), latestVisible.map { it.rankNo }) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt index 0f06c6ee..ecb7f326 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.ranking.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort @@ -19,7 +20,11 @@ class CreatorRankingQueryServiceTest { @Test @DisplayName("fallback refresh 후 재조회가 같은 read-only transaction에 묶이지 않도록 조회 메서드에는 Transactional이 없다") fun shouldNotWrapQueryMethodWithTransactional() { - val method = CreatorRankingQueryService::class.java.getDeclaredMethod("getCreatorRankings", java.lang.Long::class.java) + val method = CreatorRankingQueryService::class.java.getDeclaredMethod( + "getCreatorRankings", + java.lang.Long::class.java, + Lang::class.java + ) assertFalse(method.isAnnotationPresent(Transactional::class.java)) } @@ -32,7 +37,7 @@ class CreatorRankingQueryServiceTest { snapshotPort.latestResponses = ArrayDeque(listOf(emptyList(), listOf(snapshot(1, rankNo = 1)))) val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService) - val result = service.getCreatorRankings(viewerMemberId = null) + val result = service.getCreatorRankings(viewerMemberId = null, lang = Lang.KO) Mockito.verify(jobService).refreshLastCompletedWeekByFallback() assertEquals(2, snapshotPort.latestCallCount) @@ -48,7 +53,7 @@ class CreatorRankingQueryServiceTest { Mockito.doThrow(IllegalStateException("fallback failed")).`when`(jobService).refreshLastCompletedWeekByFallback() val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService) - val result = service.getCreatorRankings(viewerMemberId = null) + val result = service.getCreatorRankings(viewerMemberId = null, lang = Lang.KO) assertFalse(result.showRankChange) assertTrue(result.items.isEmpty()) @@ -66,7 +71,7 @@ class CreatorRankingQueryServiceTest { snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2)) val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService) - val result = service.getCreatorRankings(viewerMemberId = null) + val result = service.getCreatorRankings(viewerMemberId = null, lang = Lang.KO) Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback() assertTrue(result.showRankChange) @@ -86,7 +91,7 @@ class CreatorRankingQueryServiceTest { blockPort.blockedCreatorIds = setOf(2L) val service = service(snapshotPort = snapshotPort, blockPort = blockPort) - val result = service.getCreatorRankings(viewerMemberId = 99L) + val result = service.getCreatorRankings(viewerMemberId = 99L, lang = Lang.KO) assertEquals(listOf(1, 2), result.items.map { it.rank }) assertEquals(0L, result.items[1].creatorId) @@ -133,7 +138,8 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { override fun findLatestVisibleSnapshots( rankingType: CreatorRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + lang: Lang ): List { latestCallCount++ return if (latestResponses.size > 1) { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt index aa273d4c..8e3e5f8e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.ranking.application +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate @@ -120,7 +121,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort { ) = snapshots override fun findLatestSnapshots() = snapshots override fun findPreviousCompletedSnapshots() = snapshots - override fun findLatestVisibleSnapshots(rankingType: CreatorRankingType, nowUtc: LocalDateTime) = snapshots + override fun findLatestVisibleSnapshots(rankingType: CreatorRankingType, nowUtc: LocalDateTime, lang: Lang) = snapshots override fun findPreviousVisibleSnapshots( rankingType: CreatorRankingType, currentAggregationStartAtUtc: LocalDateTime, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 05ee5da9..50ec4da8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -89,7 +89,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( } flushAndClear() - val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false) + val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false, lang = Lang.KO) assertEquals(20, lives.size) assertEquals(true, lives.zipWithNext().all { it.first.liveRoomId > it.second.liveRoomId }) @@ -109,8 +109,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val oldest = saveLiveRoom(creator, baseAt, channelName = "paged-live-oldest", isAdult = false) flushAndClear() - val page0 = repository.findLiveRecommendations(offset = 0L, limit = 3, includeAdultLives = false) - val page1 = repository.findLiveRecommendations(offset = 2L, limit = 3, includeAdultLives = false) + val page0 = repository.findLiveRecommendations(offset = 0L, limit = 3, includeAdultLives = false, lang = Lang.KO) + val page1 = repository.findLiveRecommendations(offset = 2L, limit = 3, includeAdultLives = false, lang = Lang.KO) assertEquals(listOf(newest.id, middle.id, oldest.id), page0.map { it.liveRoomId }) assertEquals(listOf(oldest.id), page1.map { it.liveRoomId }) @@ -135,7 +135,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( offset = 0, limit = 1, memberId = viewer.id, - includeAdultLives = true + includeAdultLives = true, + lang = Lang.KO ) assertEquals(listOf(live.id), lives.map { it.liveRoomId }) @@ -170,7 +171,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( offset = 0, limit = 10, memberId = viewer.id, - includeAdultLives = false + includeAdultLives = false, + lang = Lang.KO ) assertEquals(listOf(newerVisibleLive.id, olderVisibleLive.id), lives.map { it.liveRoomId }) @@ -191,7 +193,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( saveBlock(creatorBlockedViewer, viewer) flushAndClear() - val lives = repository.findLiveRecommendations(limit = 10, memberId = viewer.id) + val lives = repository.findLiveRecommendations(limit = 10, memberId = viewer.id, lang = Lang.KO) assertEquals(listOf(visibleLive.id), lives.map { it.liveRoomId }) } @@ -221,7 +223,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( limit = 10, memberId = viewer.id, isViewerCreator = true, - effectiveViewerGender = Gender.NONE + effectiveViewerGender = Gender.NONE, + lang = Lang.KO ) assertEquals(listOf(ownerLive.id, availableLive.id), lives.map { it.liveRoomId }) @@ -244,7 +247,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( limit = 10, memberId = viewer.id, isViewerCreator = false, - effectiveViewerGender = Gender.NONE + effectiveViewerGender = Gender.NONE, + lang = Lang.KO ) assertEquals(listOf(restrictedLive.id), lives.map { it.liveRoomId }) @@ -281,7 +285,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( limit = 10, memberId = viewer.id, isViewerCreator = true, - effectiveViewerGender = Gender.MALE + effectiveViewerGender = Gender.MALE, + lang = Lang.KO ) assertEquals(listOf(ownerLive.id, allLive.id, maleOnlyLive.id), lives.map { it.liveRoomId }) @@ -311,7 +316,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( limit = 10, memberId = viewer.id, isViewerCreator = true, - effectiveViewerGender = Gender.FEMALE + effectiveViewerGender = Gender.FEMALE, + lang = Lang.KO ) assertEquals(listOf(ownerLive.id, femaleOnlyLive.id), lives.map { it.liveRoomId }) @@ -341,7 +347,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val lives = repository.findLiveRecommendations( limit = 10, memberId = viewer.id, - effectiveViewerGender = Gender.FEMALE + effectiveViewerGender = Gender.FEMALE, + lang = Lang.KO ) assertEquals(listOf(femaleOnlyLive.id, allLive.id), lives.map { it.liveRoomId }) @@ -371,12 +378,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val noneGenderLives = repository.findLiveRecommendations( limit = 10, memberId = viewer.id, - effectiveViewerGender = Gender.NONE + effectiveViewerGender = Gender.NONE, + lang = Lang.KO ) val nullGenderLives = repository.findLiveRecommendations( limit = 10, memberId = viewer.id, - effectiveViewerGender = null + effectiveViewerGender = null, + lang = Lang.KO ) val expectedIds = listOf(allLive.id, femaleOnlyLive.id, maleOnlyLive.id) @@ -659,7 +668,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunity", community.id!!, baseAt.minusHours(3)) flushAndClear() - val creators = repository.findRecentlyActiveCreators(limit = 10) + val creators = repository.findRecentlyActiveCreators(limit = 10, lang = Lang.KO) val byCreatorNickname = creators.associateBy { it.creatorNickname } assertEquals(4, creators.size) @@ -699,7 +708,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( saveLiveRoom(emptyChannelCreator, baseAt, channelName = "", isActive = false) flushAndClear() - val creators = repository.findRecentlyActiveCreators(limit = 10) + val creators = repository.findRecentlyActiveCreators(limit = 10, lang = Lang.KO) assertEquals(listOf(inactiveLiveCreator.nickname), creators.map { it.creatorNickname }) assertEquals(inactiveLiveCreator.id, creators.single().creatorId) @@ -723,8 +732,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunity", adultCommunity.id!!, baseAt) flushAndClear() - val hiddenCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = false) - val visibleCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = true) + val hiddenCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = false, lang = Lang.KO) + val visibleCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = true, lang = Lang.KO) assertEquals(listOf(normalLiveCreator.nickname), hiddenCreators.map { it.creatorNickname }) assertEquals( @@ -762,7 +771,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( saveBlock(creatorBlockedViewer, viewer) flushAndClear() - val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id) + val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id, lang = Lang.KO) assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname }) assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType) @@ -779,7 +788,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunity", community.id!!, baseAt.plusMinutes(1)) flushAndClear() - val creators = repository.findRecentlyActiveCreators(limit = 10) + val creators = repository.findRecentlyActiveCreators(limit = 10, lang = Lang.KO) assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname }) assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType) @@ -1597,7 +1606,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("Member", newHighScoreCreator.id!!, now.minusDays(60)) flushAndClear() - val creators = repository.findRecentDebutCreators(now, limit = 10) + val creators = repository.findRecentDebutCreators(now, limit = 10, lang = Lang.KO) assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId }) } @@ -1623,7 +1632,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ) flushAndClear() - val creators = repository.findRecentDebutCreators(now, limit = 10) + val creators = repository.findRecentDebutCreators(now, limit = 10, lang = Lang.KO) assertEquals(listOf(endedLiveCreator.id), creators.map { it.creatorId }) } @@ -1647,7 +1656,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("AudioContentLike", baseLike2.id!!, now.minusDays(1)) flushAndClear() - val creators = repository.findRecentDebutCreators(now, limit = 2) + val creators = repository.findRecentDebutCreators(now, limit = 2, lang = Lang.KO) assertEquals(listOf(boostedCreator.id, baseCreator.id), creators.map { it.creatorId }) } @@ -1671,8 +1680,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("AudioContentLike", like.id!!, now.minusHours(1)) flushAndClear() - val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 2, includeAdultContents = false) - val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 2, includeAdultContents = false) + val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 2, includeAdultContents = false, lang = Lang.KO) + val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 2, includeAdultContents = false, lang = Lang.KO) assertEquals(listOf(normalNewest.id, normalOldest.id), page0.map { it.creatorId }) assertEquals(listOf(normalOldest.id), page1.map { it.creatorId }) @@ -1693,7 +1702,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( saveBlock(creatorBlockedViewer, viewer) flushAndClear() - val creators = repository.findRecentDebutCreators(now, limit = 10, memberId = viewer.id) + val creators = repository.findRecentDebutCreators(now, limit = 10, memberId = viewer.id, lang = Lang.KO) assertEquals(listOf(visibleCreator.id), creators.map { it.creatorId }) } @@ -1710,9 +1719,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( saveAudioContent(creator3, now.minusDays(5), isActive = true) flushAndClear() - val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 1, includeAdultContents = false) - val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 1, includeAdultContents = false) - val page2 = repository.findRecentDebutCreators(now, offset = 2L, limit = 1, includeAdultContents = false) + val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 1, includeAdultContents = false, lang = Lang.KO) + val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 1, includeAdultContents = false, lang = Lang.KO) + val page2 = repository.findRecentDebutCreators(now, offset = 2L, limit = 1, includeAdultContents = false, lang = Lang.KO) val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId } assertEquals(setOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds.toSet()) @@ -1729,7 +1738,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( saveAudioContent(creator2, now.minusDays(5), isActive = true) flushAndClear() - val creators = repository.findRecentDebutCreators(now, limit = 10) + val creators = repository.findRecentDebutCreators(now, limit = 10, lang = Lang.KO) assertEquals(2, creators.size) assertEquals(setOf(creator1.id, creator2.id), creators.map { it.creatorId }.toSet()) @@ -2111,7 +2120,10 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val inactiveCreator = saveMember("cheer-detail-inactive", MemberRole.CREATOR, isActive = false) flushAndClear() - val details = repository.findCheerCreatorRecommendationDetails(listOf(activeCreator.id!!, inactiveCreator.id!!, 999L)) + val details = repository.findCheerCreatorRecommendationDetails( + listOf(activeCreator.id!!, inactiveCreator.id!!, 999L), + lang = Lang.KO + ) val detailById = details.associateBy { it.creatorId } assertEquals(setOf(activeCreator.id), detailById.keys) @@ -2131,7 +2143,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val details = repository.findCheerCreatorRecommendationDetails( listOf(viewerBlockedCreator.id!!, creatorBlockedViewer.id!!, visibleCreator.id!!), - memberId = viewer.id + memberId = viewer.id, + lang = Lang.KO ) assertEquals(listOf(visibleCreator.id), details.map { it.creatorId }) @@ -2150,7 +2163,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val details = repository.findCheerCreatorRecommendationDetails( listOf(viewer.id!!, activeFollowedCreator.id!!, inactiveFollowedCreator.id!!, visibleCreator.id!!), - memberId = viewer.id + memberId = viewer.id, + lang = Lang.KO ) assertEquals(listOf(inactiveFollowedCreator.id, visibleCreator.id), details.map { it.creatorId }) @@ -2167,7 +2181,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val details = repository.findCheerCreatorRecommendationDetails( listOf(viewer.id!!, activeFollowedCreator.id!!, visibleCreator.id!!), - memberId = null + memberId = null, + lang = Lang.KO ) assertEquals(listOf(viewer.id, activeFollowedCreator.id, visibleCreator.id), details.map { it.creatorId }) @@ -2178,7 +2193,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() { assertEquals( emptyList(), - repository.findCheerCreatorRecommendationDetails(emptyList()) + repository.findCheerCreatorRecommendationDetails(emptyList(), lang = Lang.KO) ) } @@ -2216,7 +2231,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val details = repository.findPopularCommunityRecommendationDetails( listOf(eligible.id!!, paid.id!!, fixed.id!!, adult.id!!, inactivePost.id!!, inactiveCreatorPost.id!!, 999L), memberId = member.id, - includeAdultCommunities = false + includeAdultCommunities = false, + lang = Lang.KO ) val detailById = details.associateBy { it.communityId } @@ -2244,7 +2260,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val details = repository.findPopularCommunityRecommendationDetails( listOf(free.id!!), memberId = null, - includeAdultCommunities = false + includeAdultCommunities = false, + lang = Lang.KO ) assertEquals(listOf(free.id), details.map { it.communityId }) @@ -2265,7 +2282,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val details = repository.findPopularCommunityRecommendationDetails( listOf(adult.id!!), - includeAdultCommunities = true + includeAdultCommunities = true, + lang = Lang.KO ) val detailById = details.associateBy { it.communityId } @@ -2290,7 +2308,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val details = repository.findPopularCommunityRecommendationDetails( listOf(viewerBlockedPost.id!!, creatorBlockedPost.id!!, visiblePost.id!!), memberId = viewer.id, - includeAdultCommunities = false + includeAdultCommunities = false, + lang = Lang.KO ) assertEquals(listOf(visiblePost.id), details.map { it.communityId }) @@ -2301,7 +2320,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldReturnEmptyPopularCommunityRecommendationDetailsWhenIdsAreEmpty() { assertEquals( emptyList(), - repository.findPopularCommunityRecommendationDetails(emptyList(), includeAdultCommunities = false) + repository.findPopularCommunityRecommendationDetails(emptyList(), includeAdultCommunities = false, lang = Lang.KO) ) } @@ -2347,7 +2366,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, includeAdultGenres = false, genreLimit = 2, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(2, recommendations.size) @@ -2377,7 +2397,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, includeAdultGenres = false, genreLimit = 1, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(listOf(theme.id), recommendations.map { it.genreId }) @@ -2417,7 +2438,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, includeAdultGenres = false, genreLimit = 1, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(listOf(fallbackTheme.id), recommendations.map { it.genreId }) @@ -2446,7 +2468,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, includeAdultGenres = false, genreLimit = 1, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) val creatorIds = recommendations.single().creators.map { it.creatorId } @@ -2477,7 +2500,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, includeAdultGenres = false, genreLimit = 1, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) val creatorIds = recommendations.single().creators.map { it.creatorId } @@ -2500,7 +2524,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = null, includeAdultGenres = false, genreLimit = 1, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(theme.id, recommendations.single().genreId) @@ -2551,7 +2576,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, includeAdultGenres = false, genreLimit = 2, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(2, recommendations.size) @@ -2589,7 +2615,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = null, includeAdultGenres = false, genreLimit = 5, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(true, recommendations.size >= 5) @@ -2633,7 +2660,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = viewer.id!!, includeAdultGenres = false, genreLimit = 5, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(true, recommendations.size >= 5) @@ -2664,7 +2692,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = null, includeAdultGenres = false, genreLimit = 2, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(3, recommendations.size) @@ -2695,7 +2724,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = null, includeAdultGenres = false, genreLimit = 1, - creatorLimit = 2 + creatorLimit = 2, + lang = Lang.KO ) assertEquals(1, recommendations.size) @@ -2724,13 +2754,15 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( memberId = null, includeAdultGenres = false, genreLimit = 5, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) val visible = repository.findGenreCreatorRecommendations( memberId = null, includeAdultGenres = true, genreLimit = 5, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(false, hidden.any { it.genreId == adultTheme.id }) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index bbc96032..a17a7de0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent @@ -32,12 +31,10 @@ import java.time.ZoneOffset class HomeRecommendationQueryServiceTest { private val port = FakeHomeRecommendationQueryPort() private val snapshotPort = FakeHomeRecommendationSnapshotPort() - private val langContext = LangContext() private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java) private val service = HomeRecommendationQueryService( port, snapshotPort, - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) @@ -79,7 +76,7 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("홈 라이브 추천은 기본 20개를 최신순 조회 포트에 위임한다") fun shouldFindLatestLiveRecommendationsWithDefaultLimit() { - val recommendations = service.findLiveRecommendations(memberId = 100L) + val recommendations = service.findLiveRecommendations(memberId = 100L, lang = Lang.KO) assertEquals(20, port.liveLimit) assertEquals(100L, port.liveMemberId) @@ -95,7 +92,8 @@ class HomeRecommendationQueryServiceTest { memberId = 100L, includeAdultLives = true, isViewerCreator = true, - effectiveViewerGender = Gender.MALE + effectiveViewerGender = Gender.MALE, + lang = Lang.KO ) assertEquals(40, port.liveOffset) @@ -131,7 +129,7 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("최근 활동 크리에이터는 기본 10개를 최신 활동 조회 포트에 위임한다") fun shouldFindRecentlyActiveCreatorsWithDefaultLimit() { - val creators = service.findRecentlyActiveCreators(memberId = 100L) + val creators = service.findRecentlyActiveCreators(memberId = 100L, lang = Lang.KO) assertEquals(10, port.activeCreatorLimit) assertEquals(100L, port.activeCreatorMemberId) @@ -142,7 +140,12 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("최근 활동 크리에이터는 성인 활동 노출 여부를 조회 포트에 위임한다") fun shouldFindRecentlyActiveCreatorsWithAdultVisibilityPolicy() { - val creators = service.findRecentlyActiveCreators(limit = 8, memberId = 101L, includeAdultActivities = true) + val creators = service.findRecentlyActiveCreators( + limit = 8, + memberId = 101L, + includeAdultActivities = true, + lang = Lang.KO + ) assertEquals(8, port.activeCreatorLimit) assertEquals(101L, port.activeCreatorMemberId) @@ -155,7 +158,7 @@ class HomeRecommendationQueryServiceTest { fun shouldFindRecentDebutCreatorsWithDefaultLimitAndNow() { val now = LocalDateTime.of(2026, 5, 31, 10, 0) - val creators = service.findRecentDebutCreators(now, memberId = 102L) + val creators = service.findRecentDebutCreators(now, memberId = 102L, lang = Lang.KO) assertEquals(now, port.recentDebutNow) assertEquals(102L, port.recentDebutMemberId) @@ -168,7 +171,7 @@ class HomeRecommendationQueryServiceTest { fun shouldFindFirstAudioContentsWithDefaultLimitAndNow() { val now = LocalDateTime.of(2026, 5, 31, 10, 0) - val contents = service.findFirstAudioContents(now, memberId = 103L) + val contents = service.findFirstAudioContents(now, memberId = 103L, lang = Lang.KO) assertEquals(now, port.firstAudioNow) assertEquals(103L, port.firstAudioMemberId) @@ -185,7 +188,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, fallback, - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) @@ -293,7 +295,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, fallback, - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) port.aiCharacterDetails = listOf( @@ -324,7 +325,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, fallback, - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) @@ -346,7 +346,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, fallback, - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) snapshotPort.replaceSnapshots( @@ -375,7 +374,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, fallback, - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) @@ -408,7 +406,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, fallback, - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) @@ -435,7 +432,6 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) @@ -459,7 +455,7 @@ class HomeRecommendationQueryServiceTest { ) ) - val creators = service.findCheerCreatorRecommendations(memberId = 104L) + val creators = service.findCheerCreatorRecommendations(memberId = 104L, lang = Lang.KO) assertEquals(null, fallback.offset) assertEquals(null, fallback.limit) @@ -480,7 +476,6 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) port.cheerCreatorDetails = listOf( @@ -491,7 +486,7 @@ class HomeRecommendationQueryServiceTest { ) ) - val creators = service.findCheerCreatorRecommendations(memberId = 104L) + val creators = service.findCheerCreatorRecommendations(memberId = 104L, lang = Lang.KO) assertEquals(0L, fallback.offset) assertEquals(16, fallback.limit) @@ -511,7 +506,6 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) @@ -528,7 +522,7 @@ class HomeRecommendationQueryServiceTest { ) ) - val creators = service.findCheerCreatorRecommendations(memberId = 104L) + val creators = service.findCheerCreatorRecommendations(memberId = 104L, lang = Lang.KO) assertEquals(0L, fallback.offset) assertEquals(16, fallback.limit) @@ -548,7 +542,6 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) snapshotPort.replaceSnapshots( @@ -557,7 +550,7 @@ class HomeRecommendationQueryServiceTest { emptyList() ) - val creators = service.findCheerCreatorRecommendations(memberId = 104L) + val creators = service.findCheerCreatorRecommendations(memberId = 104L, lang = Lang.KO) assertEquals(null, fallback.offset) assertEquals(null, fallback.limit) @@ -572,7 +565,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) @@ -631,7 +623,11 @@ class HomeRecommendationQueryServiceTest { ) ) - val communities = service.findPopularCommunityRecommendations(memberId = 105L, includeAdultCommunities = true) + val communities = service.findPopularCommunityRecommendations( + memberId = 105L, + includeAdultCommunities = true, + lang = Lang.KO + ) assertEquals((1L..11L).toList(), port.popularCommunityDetailIds) assertEquals(105L, port.popularCommunityMemberId) @@ -652,7 +648,6 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) @@ -681,7 +676,7 @@ class HomeRecommendationQueryServiceTest { ) } - val communities = service.findPopularCommunityRecommendations() + val communities = service.findPopularCommunityRecommendations(lang = Lang.KO) assertEquals(20, port.popularCommunityDetailIds.size) assertEquals(10, communities.size) @@ -692,7 +687,6 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("인기 커뮤니티 추천은 최종 노출 게시물만 일본어 표시 본문으로 일괄 치환하고 원문 폴백을 유지한다") fun shouldTranslateOnlyFinalPopularCommunityRecommendationsInOneJapaneseBatch() { - langContext.setLang(Lang.JA) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.POPULAR_COMMUNITY, @@ -718,11 +712,10 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) - val communities = translatedService.findPopularCommunityRecommendations(limit = 2) + val communities = translatedService.findPopularCommunityRecommendations(limit = 2, lang = Lang.JA) assertEquals(listOf(1L, 3L), communities.map { it.communityId }) assertEquals(listOf("translated-1-ja", "current-original-3"), communities.map { it.content }) @@ -741,7 +734,6 @@ class HomeRecommendationQueryServiceTest { snapshotPort, popularCommunitySnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) @@ -768,7 +760,7 @@ class HomeRecommendationQueryServiceTest { ) ) - val communities = service.findPopularCommunityRecommendations(memberId = 105L) + val communities = service.findPopularCommunityRecommendations(memberId = 105L, lang = Lang.KO) assertEquals(0L, fallback.offset) assertEquals(20, fallback.limit) @@ -788,7 +780,6 @@ class HomeRecommendationQueryServiceTest { snapshotPort, popularCommunitySnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext, creatorCommunityTranslationService = creatorCommunityTranslationService ) snapshotPort.replaceSnapshots( @@ -797,7 +788,7 @@ class HomeRecommendationQueryServiceTest { emptyList() ) - val communities = service.findPopularCommunityRecommendations(memberId = 105L) + val communities = service.findPopularCommunityRecommendations(memberId = 105L, lang = Lang.KO) assertEquals(null, fallback.offset) assertEquals(null, fallback.limit) @@ -809,8 +800,11 @@ class HomeRecommendationQueryServiceTest { @DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다") fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() { assertEquals(emptyList(), service.findAiCharacterRecommendations()) - assertEquals(emptyList(), service.findCheerCreatorRecommendations()) - assertEquals(emptyList(), service.findPopularCommunityRecommendations()) + assertEquals(emptyList(), service.findCheerCreatorRecommendations(lang = Lang.KO)) + assertEquals( + emptyList(), + service.findPopularCommunityRecommendations(lang = Lang.KO) + ) } @Test @@ -858,7 +852,8 @@ class HomeRecommendationQueryServiceTest { val recommendations = service.findGenreCreatorRecommendations( memberId = 100L, - includeAdultGenres = true + includeAdultGenres = true, + lang = Lang.KO ) assertEquals(100L, port.genreCreatorMemberId) @@ -912,7 +907,8 @@ class HomeRecommendationQueryServiceTest { memberId = 100L, includeAdultGenres = false, genreLimit = 2, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(listOf(1L, 3L), recommendations.map { it.genreId }) @@ -958,7 +954,8 @@ class HomeRecommendationQueryServiceTest { memberId = null, includeAdultGenres = false, genreLimit = 2, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(listOf(1L, 3L), recommendations.map { it.genreId }) @@ -1009,7 +1006,8 @@ class HomeRecommendationQueryServiceTest { memberId = null, includeAdultGenres = false, genreLimit = 2, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(listOf(1L, 3L), recommendations.map { it.genreId }) @@ -1038,7 +1036,8 @@ class HomeRecommendationQueryServiceTest { memberId = 100L, includeAdultGenres = false, genreLimit = 5, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(listOf(1L), recommendations.map { it.genreId }) @@ -1085,7 +1084,8 @@ class HomeRecommendationQueryServiceTest { memberId = 100L, includeAdultGenres = false, genreLimit = 2, - creatorLimit = 8 + creatorLimit = 8, + lang = Lang.KO ) assertEquals(listOf(1L, 2L), recommendations.map { it.genreId }) @@ -1130,6 +1130,7 @@ class HomeRecommendationQueryServiceTest { var genreCreatorCreatorLimit: Int? = null val liveRecommendations = listOf( HomeLiveRecommendationRecord( + creatorMemberId = 10L, liveRoomId = 1L, creatorNickname = "creator", creatorProfileImage = "profile.png", @@ -1196,7 +1197,8 @@ class HomeRecommendationQueryServiceTest { memberId: Long?, includeAdultLives: Boolean, isViewerCreator: Boolean, - effectiveViewerGender: Gender? + effectiveViewerGender: Gender?, + lang: Lang ): List { liveOffset = offset liveLimit = limit @@ -1223,7 +1225,8 @@ class HomeRecommendationQueryServiceTest { override fun findRecentlyActiveCreators( limit: Int, memberId: Long?, - includeAdultActivities: Boolean + includeAdultActivities: Boolean, + lang: Lang ): List { activeCreatorLimit = limit activeCreatorMemberId = memberId @@ -1236,7 +1239,8 @@ class HomeRecommendationQueryServiceTest { offset: Long, limit: Int, memberId: Long?, - includeAdultContents: Boolean + includeAdultContents: Boolean, + lang: Lang ): List { recentDebutNow = now recentDebutOffset = offset @@ -1299,7 +1303,8 @@ class HomeRecommendationQueryServiceTest { override fun findCheerCreatorRecommendationDetails( creatorIds: List, - memberId: Long? + memberId: Long?, + lang: Lang ): List { cheerCreatorDetailIds = creatorIds cheerCreatorMemberId = memberId @@ -1309,7 +1314,8 @@ class HomeRecommendationQueryServiceTest { override fun findPopularCommunityRecommendationDetails( communityIds: List, memberId: Long?, - includeAdultCommunities: Boolean + includeAdultCommunities: Boolean, + lang: Lang ): List { popularCommunityDetailIds = communityIds popularCommunityMemberId = memberId @@ -1321,7 +1327,8 @@ class HomeRecommendationQueryServiceTest { memberId: Long?, includeAdultGenres: Boolean, genreLimit: Int, - creatorLimit: Int + creatorLimit: Int, + lang: Lang ): List { genreCreatorMemberId = memberId genreCreatorIncludeAdultGenres = includeAdultGenres diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatNameJoinRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatNameJoinRepositoryTest.kt new file mode 100644 index 00000000..5e98f09c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatNameJoinRepositoryTest.kt @@ -0,0 +1,167 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.room.ChatMessage +import kr.co.vividnext.sodalive.chat.room.ChatParticipant +import kr.co.vividnext.sodalive.chat.room.ChatRoom +import kr.co.vividnext.sodalive.chat.room.ParticipantType +import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository +import org.hibernate.SessionFactory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.data.domain.PageRequest +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none", "spring.jpa.properties.hibernate.generate_statistics=true"]) +@Import(QueryDslConfig::class) +class UserCreatorChatNameJoinRepositoryTest @Autowired constructor( + private val roomRepository: UserCreatorChatRoomRepository, + private val messageRepository: UserCreatorChatMessageRepository, + private val participantRepository: UserCreatorChatParticipantRepository, + private val aiRoomRepository: ChatRoomRepository, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @ParameterizedTest + @EnumSource(Lang::class) + @DisplayName("DM 목록·히스토리·상대 프로필은 요청 Lang을 조인하고 USER·미등록·비활성 행을 유지한다") + fun shouldJoinRequestedNamesWithoutStandaloneNameQueries(lang: Lang) { + val viewer = member("viewer", MemberRole.USER) + val opponents = listOf( + member("creator", MemberRole.CREATOR), + member("inactive", MemberRole.CREATOR, false), + member("missing", MemberRole.CREATOR, null), + member("user", MemberRole.USER) + ) + val rooms = opponents.map { dmRoom(viewer, it) } + entityManager.flush() + entityManager.clear() + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + val listed = roomRepository.findDmChatListRooms( + viewer.id!!, + null, + null, + null, + false, + lang, + PageRequest.of(0, 20) + ) + + val expectedCreator = if (lang == Lang.KO) "creator" else "${lang.name}-creator" + assertEquals(setOf(expectedCreator, "inactive", "missing", "user"), listed.map { it.targetName }.toSet()) + assertEquals(4, listed.size) + assertEquals(1L, statistics.queryExecutionCount) + assertEquals(0L, statistics.entityLoadCount) + rooms.zip(opponents).forEach { (room, opponent) -> + statistics.clear() + val expectedName = if (opponent.id == opponents.first().id) expectedCreator else opponent.nickname + val messages = messageRepository.findMessageHistory(room, null, lang, PageRequest.of(0, 20)) + assertEquals(listOf(expectedName, "viewer"), messages.map { it.senderNickname }) + assertEquals(listOf("opponent body", "viewer body"), messages.map { it.textMessage }) + assertEquals(1L, statistics.queryExecutionCount) + assertEquals(0L, statistics.entityLoadCount) + + statistics.clear() + val previous = messageRepository.findMessageHistory(room, messages.first().messageId, lang, PageRequest.of(0, 20)) + assertEquals(listOf(messages.last().messageId), previous.map { it.messageId }) + assertEquals(1L, statistics.queryExecutionCount) + + statistics.clear() + val profile = participantRepository.findActiveOpponentProfile(room.id!!, viewer.id!!, lang) + assertEquals(expectedName, profile!!.nickname) + assertEquals(1L, statistics.queryExecutionCount) + assertEquals(0L, statistics.entityLoadCount) + } + } + + @Test + @DisplayName("통합 채팅 목록은 DM만 EN 별칭을 조인하고 AI 캐릭터 이름·팔로우 필터·커서를 유지한다") + fun shouldKeepAiCharacterNameAndFollowingCursorWhenJoiningDmNames() { + val viewer = member("viewer", MemberRole.USER) + val creator = member("creator", MemberRole.CREATOR) + val room = dmRoom(viewer, creator) + val character = ChatCharacter("join-character", "AI character", "description", systemPrompt = "prompt").apply { + creatorMember = creator + } + entityManager.persist(character) + val aiRoom = ChatRoom("join-ai-session", "AI room") + entityManager.persist(aiRoom) + entityManager.persist(ChatParticipant(aiRoom, ParticipantType.USER, member = viewer)) + val characterParticipant = ChatParticipant(aiRoom, ParticipantType.CHARACTER, character = character) + entityManager.persist(characterParticipant) + entityManager.persist(ChatMessage("AI body", aiRoom, characterParticipant)) + entityManager.persist( + CreatorFollowing().apply { + member = viewer + this.creator = creator + } + ) + entityManager.flush() + entityManager.clear() + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val service = ChatRoomListService(aiRoomRepository, roomRepository, "https://cdn.test") + + val first = service.getRooms(viewer, Lang.EN, limit = 1, followedCreatorsOnly = true) + val second = service.getRooms(viewer, Lang.EN, cursor = first.nextCursor, limit = 1, followedCreatorsOnly = true) + + assertEquals(true, first.hasMore) + assertEquals(false, second.hasMore) + assertEquals(listOf("AI character", "EN-creator"), (first.rooms + second.rooms).map { it.targetName }) + assertEquals(listOf(aiRoom.id, room.id), (first.rooms + second.rooms).map { it.roomId }) + assertEquals(4L, statistics.queryExecutionCount, statistics.queries.joinToString("\n")) + assertEquals(0L, statistics.entityLoadCount) + } + + private fun member(nickname: String, role: MemberRole, activeAlias: Boolean? = true): Member { + val member = Member(password = "pw", nickname = nickname, role = role) + entityManager.persist(member) + if (activeAlias != null) { + listOf(Lang.JA, Lang.EN).forEach { lang -> + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang.name, + "${lang.name}-$nickname", + activeAlias + ) + } + } + return member + } + + private fun dmRoom(viewer: Member, opponent: Member): UserCreatorChatRoom { + val room = roomRepository.save(UserCreatorChatRoom()) + val mine = participantRepository.save(UserCreatorChatParticipant(room, viewer)) + val theirs = participantRepository.save(UserCreatorChatParticipant(room, opponent)) + val now = LocalDateTime.now() + messageRepository.save(UserCreatorChatMessage(room, mine, UserCreatorChatMessageType.TEXT, "viewer body")) + .createdAt = now.minusMinutes(2) + messageRepository.save(UserCreatorChatMessage(room, theirs, UserCreatorChatMessageType.TEXT, "opponent body")) + .createdAt = now.minusMinutes(1) + messageRepository.save( + UserCreatorChatMessage(room, theirs, UserCreatorChatMessageType.TEXT, "inactive body", isActive = false) + ) + return room + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt index cff277d0..14502a92 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRepository @@ -112,7 +113,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor( entityManager.clear() val exception = assertThrows(SodaException::class.java) { - service.sendTextMessageByWebSocket(user.id!!, room.id!!, "hello") + service.sendTextMessageByWebSocket(user.id!!, room.id!!, "hello", Lang.KO) } assertEquals("message.error.recipient_not_found", exception.messageKey) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt index 5ef9bd4b..aae9b6cc 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt @@ -6,11 +6,16 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext 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.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageRow +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatOpponentProfile import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository @@ -70,6 +75,8 @@ class UserCreatorChatServiceTest { applicationEventPublisher = eventPublisher, objectMapper = ObjectMapper(), s3Uploader = s3Uploader, + localizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java), + langContext = LangContext(), bucket = "test-bucket", cloudFrontHost = "https://cdn.test" ) @@ -140,13 +147,15 @@ class UserCreatorChatServiceTest { } val room = room(10L) val userParticipant = participant(100L, room, user) - val creatorParticipant = participant(101L, room, creator) Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant) - Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant) + Mockito.`when`(participantRepository.findActiveOpponentProfile(10L, 1L, Lang.KO)) + .thenReturn(UserCreatorChatOpponentProfile(creator.nickname, creator.profileImage)) Mockito.`when`( - messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc( + messageRepository.findMessageHistory( room, + null, + Lang.KO, PageRequest.of(0, 20) ) ).thenReturn(emptyList()) @@ -159,7 +168,7 @@ class UserCreatorChatServiceTest { assertEquals(emptyList(), response.messages) assertFalse(response.hasMore) assertEquals(null, response.nextCursor) - Mockito.verify(participantRepository).findActiveOpponent(10L, 1L) + Mockito.verify(participantRepository).findActiveOpponentProfile(10L, 1L, Lang.KO) } @Test @@ -169,13 +178,15 @@ class UserCreatorChatServiceTest { val creator = member(2L, "creator") val room = room(10L) val userParticipant = participant(100L, room, user) - val creatorParticipant = participant(101L, room, creator) Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant) - Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant) + Mockito.`when`(participantRepository.findActiveOpponentProfile(10L, 1L, Lang.KO)) + .thenReturn(UserCreatorChatOpponentProfile(creator.nickname, creator.profileImage)) Mockito.`when`( - messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc( + messageRepository.findMessageHistory( room, + null, + Lang.KO, PageRequest.of(0, 20) ) ).thenReturn(emptyList()) @@ -202,7 +213,7 @@ class UserCreatorChatServiceTest { (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 203L } } - val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello") + val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello", lang = Lang.KO) assertEquals(203L, response.messageId) assertEquals("hello", response.textMessage) @@ -230,7 +241,7 @@ class UserCreatorChatServiceTest { (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 204L } } - service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello") + service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello", lang = Lang.KO) val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) @@ -263,7 +274,7 @@ class UserCreatorChatServiceTest { (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 207L } } - val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello") + val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello", lang = Lang.KO) assertEquals(207L, response.messageId) val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) @@ -421,12 +432,13 @@ class UserCreatorChatServiceTest { Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant) Mockito.`when`( - messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + messageRepository.findMessageHistory( room, 300L, + Lang.KO, PageRequest.of(0, 20) ) - ).thenReturn(listOf(newerMessage, olderMessage)) + ).thenReturn(listOf(messageRow(newerMessage), messageRow(olderMessage))) Mockito.`when`(messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, 298L)).thenReturn(true) val response = service.getMessages(user, roomId = 10L, cursor = 300L) @@ -435,15 +447,30 @@ class UserCreatorChatServiceTest { assertEquals(listOf("older", "newer"), response.messages.map { it.textMessage }) assertTrue(response.hasMore) assertEquals(298L, response.nextCursor) - Mockito.verify(messageRepository).findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + Mockito.verify(messageRepository).findMessageHistory( room, 300L, + Lang.KO, PageRequest.of(0, 20) ) } private fun member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id } + private fun messageRow(message: UserCreatorChatMessage): UserCreatorChatMessageRow { + val sender = message.participant.member + return UserCreatorChatMessageRow( + message.id!!, + message.messageType, + message.createdAt, + message.textMessage, + message.voiceMessage, + sender.id!!, + sender.nickname, + sender.profileImage + ) + } + private fun anyMessageItem(): UserCreatorChatMessageItemDto { return Mockito.any(UserCreatorChatMessageItemDto::class.java) ?: UserCreatorChatMessageItemDto( messageId = 0L, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatLocalizedNameIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatLocalizedNameIntegrationTest.kt new file mode 100644 index 00000000..f393955d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatLocalizedNameIntegrationTest.kt @@ -0,0 +1,367 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.fcm.FcmService +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.server.ServletServerHttpRequest +import org.springframework.http.server.ServletServerHttpResponse +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import org.springframework.web.socket.TextMessage +import org.springframework.web.socket.WebSocketHttpHeaders +import org.springframework.web.socket.WebSocketSession +import org.springframework.web.socket.client.standard.StandardWebSocketClient +import org.springframework.web.socket.handler.TextWebSocketHandler +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.net.URI +import java.util.UUID +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cache.type=none", + "jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class UserCreatorChatLocalizedNameIntegrationTest @Autowired constructor( + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository, + private val authInterceptor: UserCreatorChatWebSocketAuthInterceptor, + private val handler: UserCreatorChatWebSocketHandler, + private val service: UserCreatorChatService, + private val memberRepository: MemberRepository, + private val roomRepository: UserCreatorChatRoomRepository, + private val participantRepository: UserCreatorChatParticipantRepository, + private val messageRepository: UserCreatorChatMessageRepository, + private val objectMapper: ObjectMapper, + private val jdbcTemplate: JdbcTemplate, + private val transactionTemplate: TransactionTemplate, + private val restTemplate: TestRestTemplate, + private val langContext: LangContext +) { + @LocalServerPort + private var port: Int = 0 + + @MockBean + private lateinit var fcmService: FcmService + + @MockBean + private lateinit var s3Uploader: S3Uploader + + private lateinit var creator: Member + private lateinit var recipient: Member + private lateinit var creatorToken: String + private lateinit var recipientToken: String + private var roomId: Long = 0 + private val connections = mutableListOf() + + @BeforeEach + fun createCommittedFixture() { + transactionTemplate.executeWithoutResult { + val suffix = UUID.randomUUID().toString() + creator = memberRepository.saveAndFlush( + Member( + email = "creator-$suffix@localized-ws.test", + password = "pw", + nickname = "original-creator", + role = MemberRole.CREATOR + ) + ) + recipient = memberRepository.saveAndFlush( + Member(email = "recipient-$suffix@localized-ws.test", password = "pw", nickname = "original-user") + ) + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'JA', '日本のクリエイター', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + creator.id!! + ) + roomId = service.createOrGetRoom(creator, recipient.id!!).roomId + } + creatorToken = token(creator) + recipientToken = token(recipient) + } + + @AfterEach + fun closeConnectionsAndRemoveTokens() { + connections.forEach { it.session.close() } + tokenRepository.deleteById(creator.id!!) + tokenRepository.deleteById(recipient.id!!) + } + + @ParameterizedTest + @CsvSource("ja-JP, JA", "ko-KR, KO", "en-US, EN", "fr-FR, KO") + @DisplayName("실제 JWT handshake는 Accept-Language를 Lang 세션 속성으로 저장한다") + fun shouldStoreExplicitLanguageOnAuthenticatedHandshake(header: String, expected: Lang) { + val request = MockHttpServletRequest().apply { + addHeader(HttpHeaders.AUTHORIZATION, "Bearer $recipientToken") + addHeader(HttpHeaders.ACCEPT_LANGUAGE, header) + } + val attributes = mutableMapOf() + + val accepted = authInterceptor.beforeHandshake( + ServletServerHttpRequest(request), + ServletServerHttpResponse(MockHttpServletResponse()), + handler, + attributes + ) + + assertTrue(accepted) + assertEquals(recipient.id, attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE]) + assertEquals(expected, attributes["lang"]) + } + + @Test + @DisplayName("Accept-Language가 없는 실제 인증 handshake는 KO 세션으로 연결한다") + fun shouldDefaultHandshakeLanguageToKoreanWhenHeaderIsMissing() { + val request = MockHttpServletRequest().apply { addHeader(HttpHeaders.AUTHORIZATION, "Bearer $recipientToken") } + val attributes = mutableMapOf() + + val accepted = authInterceptor.beforeHandshake( + ServletServerHttpRequest(request), + ServletServerHttpResponse(MockHttpServletResponse()), + handler, + attributes + ) + + assertTrue(accepted) + assertEquals(Lang.KO, attributes["lang"]) + } + + @Test + @DisplayName("localhost WS와 Redis를 거친 같은 회원의 KO JA 세션은 이름만 다르고 원본 본문과 ID를 공유한다") + fun shouldDeliverDifferentNamesToSimultaneousSessionsOfTheSameRecipient() { + val korean = connect(recipientToken, "ko-KR") + val japanese = connect(recipientToken, "ja-JP") + val sender = connect(creatorToken, "ja-JP") + join(korean) + join(japanese) + join(sender) + sendText(sender, "original-creator 본문", "send-multilang") + + val koreanMessage = receive(korean, "MESSAGE")["payload"] + val japaneseMessage = receive(japanese, "MESSAGE")["payload"] + val ack = receive(sender, "SEND_ACK") + + assertAll( + { assertEquals("original-creator", koreanMessage["senderNickname"].asText()) }, + { assertEquals("日本のクリエイター", japaneseMessage["senderNickname"].asText()) }, + { assertEquals("original-creator 본문", koreanMessage["textMessage"].asText()) }, + { assertEquals("original-creator 본문", japaneseMessage["textMessage"].asText()) }, + { assertEquals(koreanMessage["messageId"], japaneseMessage["messageId"]) }, + { assertEquals(creator.id!!, japaneseMessage["senderId"].asLong()) }, + { assertEquals(false, japaneseMessage["mine"].asBoolean()) }, + { assertEquals("send-multilang", ack["requestId"].asText()) }, + { assertEquals("original-creator", memberRepository.findById(creator.id!!).orElseThrow().nickname) } + ) + } + + @Test + @DisplayName("JA 발신자 SEND_ACK는 KO 수신자 전달과 독립적으로 현지화하고 mine과 requestId를 유지한다") + fun shouldRenderSenderAckInSenderSessionLanguageIndependentlyOfRecipient() { + val receiver = connect(recipientToken, "ko-KR") + val sender = connect(creatorToken, "ja-JP") + join(receiver) + join(sender) + sendText(sender, "ACK 본문", "sender-ja") + + val received = receive(receiver, "MESSAGE")["payload"] + val ack = receive(sender, "SEND_ACK") + + assertAll( + { assertEquals("日本のクリエイター", ack["payload"]["senderNickname"].asText()) }, + { assertEquals("original-creator", received["senderNickname"].asText()) }, + { assertEquals(true, ack["payload"]["mine"].asBoolean()) }, + { assertEquals(false, received["mine"].asBoolean()) }, + { assertEquals(received["messageId"], ack["payload"]["messageId"]) }, + { assertEquals("sender-ja", ack["requestId"].asText()) } + ) + } + + @Test + @DisplayName("HTTP open과 메시지 페이지는 현재 요청 언어를 사용하고 이전 JA 응답을 후속 KO 조회가 바꾸지 않는다") + fun shouldRenderHttpRoomAndMessageNamesAtEachRequestBoundary() { + transactionTemplate.executeWithoutResult { + val room = roomRepository.findById(roomId).orElseThrow() + val participant = participantRepository.findActiveByRoomIdAndMemberId(roomId, creator.id!!)!! + messageRepository.saveAndFlush( + UserCreatorChatMessage( + chatRoom = room, + participant = participant, + messageType = UserCreatorChatMessageType.TEXT, + textMessage = "original-creator text" + ) + ) + } + + val japanese = getRoomResource("open", "ja-JP") + val korean = getRoomResource("open", "ko-KR") + val messages = getRoomResource("messages", "ja-JP") + + assertAll( + { assertEquals("日本のクリエイター", japanese["opponentNickname"].asText()) }, + { assertEquals("日本のクリエイター", japanese["messages"][0]["senderNickname"].asText()) }, + { assertEquals("original-creator", korean["opponentNickname"].asText()) }, + { assertEquals("original-creator", korean["messages"][0]["senderNickname"].asText()) }, + { assertEquals("日本のクリエイター", messages["messages"][0]["senderNickname"].asText()) }, + { assertEquals("original-creator text", japanese["messages"][0]["textMessage"].asText()) } + ) + } + + @Test + @DisplayName("음성 REST 응답의 JA 이름과 KO WebSocket 전달은 같은 저장 메시지를 독립 렌더링한다") + fun shouldLocalizeVoiceHttpResponseWithoutLocalizingNeutralPublishedPayload() { + val receiver = connect(recipientToken, "ko-KR") + join(receiver) + Mockito.`when`( + s3Uploader.upload( + Mockito.any(InputStream::class.java) ?: ByteArrayInputStream(byteArrayOf()), + Mockito.anyString(), + Mockito.anyString(), + Mockito.any(ObjectMetadata::class.java) + ) + ).thenReturn("voice/localized.aac") + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) + try { + langContext.setLang(Lang.JA) + val response = service.sendVoiceMessage( + creator, + roomId, + MockMultipartFile("voiceMessageFile", "voice.aac", "audio/aac", byteArrayOf(1, 2)), + "{}" + ) + val delivered = receive(receiver, "MESSAGE")["payload"] + + assertEquals("日本のクリエイター", response.message.senderNickname) + assertEquals("original-creator", delivered["senderNickname"].asText()) + assertEquals(response.message.messageId, delivered["messageId"].asLong()) + assertEquals(response.message.voiceMessageUrl, delivered["voiceMessageUrl"].asText()) + assertTrue(response.deliveredRealtime) + assertEquals(false, response.pushSent) + } finally { + RequestContextHolder.resetRequestAttributes() + } + } + + private fun token(member: Member): String { + val principal = MemberAdapter(member) + return tokenProvider.createToken( + UsernamePasswordAuthenticationToken(principal, "pw", principal.authorities), + member.id!! + ) + } + + private fun connect(token: String, language: String): ClientConnection { + val messages = LinkedBlockingQueue() + val clientHandler = object : TextWebSocketHandler() { + override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { + messages.add(objectMapper.readTree(message.payload)) + } + } + val headers = WebSocketHttpHeaders().apply { + setBearerAuth(token) + set(HttpHeaders.ACCEPT_LANGUAGE, language) + } + val session = StandardWebSocketClient().doHandshake( + clientHandler, + headers, + URI("ws://localhost:$port${UserCreatorChatWebSocketConfig.ENDPOINT}") + ).get(5, TimeUnit.SECONDS) + return ClientConnection(session, messages).also { connections.add(it) } + } + + private fun join(connection: ClientConnection) { + connection.session.sendMessage(TextMessage("""{"type":"JOIN_ROOM","roomId":$roomId,"payload":{}}""")) + receive(connection, "JOINED") + } + + private fun sendText(connection: ClientConnection, text: String, requestId: String) { + connection.session.sendMessage( + TextMessage( + objectMapper.writeValueAsString( + mapOf( + "type" to "SEND_TEXT", + "requestId" to requestId, + "roomId" to roomId, + "payload" to mapOf("textMessage" to text) + ) + ) + ) + ) + } + + private fun receive(connection: ClientConnection, expectedType: String): JsonNode { + val envelope = connection.messages.poll(5, TimeUnit.SECONDS) + assertNotNull(envelope, "Expected $expectedType from localhost WebSocket") + assertEquals(expectedType, envelope!!["type"].asText(), envelope.toString()) + return envelope + } + + private fun getRoomResource(resource: String, language: String): JsonNode { + val headers = HttpHeaders().apply { + setBearerAuth(recipientToken) + set(HttpHeaders.ACCEPT_LANGUAGE, language) + } + val response = restTemplate.exchange( + "http://localhost:$port/api/v2/user-creator-chat/rooms/$roomId/$resource", + HttpMethod.GET, + HttpEntity(headers), + String::class.java + ) + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertTrue(body["success"].asBoolean(), response.body) + return body["data"] + } + + private data class ClientConnection(val session: WebSocketSession, val messages: LinkedBlockingQueue) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt index 92326b37..f318a3c3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisTestConfiguration +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -14,6 +15,7 @@ import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.data.redis.core.StringRedisTemplate import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource @@ -35,6 +37,9 @@ import java.util.concurrent.TimeUnit @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) @TestPropertySource(properties = ["user-creator-chat.websocket.server-id=redis-test-server"]) class UserCreatorChatRedisIntegrationTest { + @MockBean + private lateinit var localizedNameService: CreatorLocalizedNameService + @Autowired private lateinit var stringRedisTemplate: StringRedisTemplate diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt index 7deeb828..404a4649 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -25,7 +26,8 @@ class UserCreatorChatRoomMessageBrokerTest { stringRedisTemplate = stringRedisTemplate, sessionRegistry = registry, objectMapper = objectMapper, - listenerContainer = listenerContainer + listenerContainer = listenerContainer, + localizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java) ) @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt index 086aaba7..ef4071e4 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService import org.junit.jupiter.api.Assertions.assertEquals @@ -66,7 +67,7 @@ class UserCreatorChatWebSocketHandlerTest { @DisplayName("SEND_TEXT는 메시지 저장 후 sender에게 SEND_ACK를 응답한다") fun shouldSendAckToSenderWhenTextMessageIsSaved() { val session = session("session-1", memberId = 1L) - Mockito.`when`(service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")) + Mockito.`when`(service.sendTextMessageByWebSocket(1L, 10L, "hello", Lang.KO)) .thenReturn(messageItem()) handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) Mockito.clearInvocations(session) @@ -103,7 +104,8 @@ class UserCreatorChatWebSocketHandlerTest { Mockito.verify(service, Mockito.never()).sendTextMessageByWebSocket( Mockito.anyLong(), Mockito.anyLong(), - Mockito.anyString() + Mockito.anyString(), + Mockito.eq(Lang.KO) ?: Lang.KO ) }