Compare commits

...

157 Commits

Author SHA1 Message Date
67a8de9e7a 캐릭터 상세 조회 - 원작 번역 및 캐릭터 소개 일괄 번역 기능 구현 2025-12-16 06:52:48 +09:00
0c52804f06 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역 기능 추가 2025-12-16 06:19:15 +09:00
7955be45da 원작 등록/수정시 번역 API 호출 2025-12-16 06:10:18 +09:00
8ae6943c2a 크리에이터 관리자에서 콘텐츠 수정시 번역 이벤트 호출하도록 수정 2025-12-16 04:14:13 +09:00
82f53ed8ab 콘텐츠 제목, 시리즈 장르 번역 반환 구현 2025-12-16 04:09:25 +09:00
4e4235369c 홈 요일별 시리즈 - 번역 데이터 조회 기능 적용 2025-12-16 03:40:28 +09:00
30a104981c 시리즈 상세 - 번역 데이터 조회 기능 추가 2025-12-16 03:29:02 +09:00
4c0be733d0 시리즈 상세 - 번역 데이터 조회 기능 추가 2025-12-16 02:52:14 +09:00
0eed29eadc 시리즈 리스트 - 번역 데이터 조회 기능 추가 2025-12-16 01:07:20 +09:00
db18d5c8b5 홈 - 오직 보이스온에서만, 요일별 시리즈 번역 데이터 조회 기능 추가 2025-12-16 00:43:36 +09:00
f58687ef3a 크리에이터 관리자에서 시리즈 등록/수정시 번역데이터 생성 기능 추가 2025-12-16 00:25:24 +09:00
9b2b156d40 SeriesTranslationPayload 키워드 리스트 변환 및 수정
- `SeriesTranslationPayload.keywords` 타입을 `String`에서 `List<String>`으로 변경했습니다.
- `SeriesTranslationPayloadConverter`의 `convertToEntityAttribute`를 하위 호환 가능하도록 수정했습니다.
  - DB에 저장된 JSON에서 `keywords`가 과거 스키마(String)인 경우와 신규 스키마(List)를 모두 안전하게 파싱합니다.
  - 파싱 실패 또는 공백 입력 시 기본값을 사용합니다(`keywords = []`).
- `convertToDatabaseColumn`은 변경 없이 `ObjectMapper`로 직렬화하여 `keywords`가 배열로 저장됩니다.
2025-12-15 23:55:50 +09:00
e00a9ccff5 시리즈 상세, 시리즈 키워드 번역 엔티티 추가 2025-12-15 16:32:21 +09:00
45ee55028f 콘텐츠 상세 - themeStr 언어별 번역 제공 기능 수정 2025-12-15 12:25:10 +09:00
dc0df81232 번역된 테마로 콘텐츠를 조회해도 한글 테마처럼 처리하기 2025-12-15 12:15:31 +09:00
c0c61da44b 콘텐츠 테마 조회 로직 수정 2025-12-15 11:45:56 +09:00
13029ab8d2 콘텐츠 테마 번역 N+1 제거
- 온라인 경로에서 콘텐츠 테마 번역을 배치 조회/번역/저장으로 처리.
- 기존 번역은 IN 조회, 미번역만 한 번의 번역 요청 후 저장.
- 결과 순서 보전, 번역 누락/실패 시 원문으로 폴백.
- 공개 API 변경 없음.
2025-12-13 00:51:07 +09:00
6f0619e482 콘텐츠 테마 저장시 번역 API로 자동 번역 하는 기능 추가 2025-12-13 00:19:48 +09:00
920a866ae0 신규 콘텐츠 조회 API에서 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:46:29 +09:00
de60a70733 크리에이터 프로필 조회 API에서 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:44:08 +09:00
59949e5aee AudioContent 조회 API에서 api 마다 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:40:21 +09:00
165640201f AI Character API에서 api 마다 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:09:33 +09:00
ba1844a6c2 Home API에서 api 마다 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 17:22:50 +09:00
082f255773 요청 스코프 언어 컨텍스트와 인터셉터 추가
- Interceptor에서 Accept-Language 헤더를 파싱
- 요청 단위 LangContext에 언어 정보 저장
- 서비스 및 예외 처리 계층에서 언어 컨텍스트 주입
- enum 및 when 기반 언어 정책을 한 곳으로 통합
2025-12-12 16:57:34 +09:00
04281817a5 크리에이터 채널 - languageCode에 따라 콘텐츠 번역 데이터 조회 2025-12-12 13:58:49 +09:00
236394e148 콘텐츠 전체보기 API - languageCode에 따라 번역 데이터 조회 2025-12-12 06:04:26 +09:00
7ab25470b6 콘텐츠 전체보기 API - languageCode에 따라 번역 데이터 조회 2025-12-12 05:57:04 +09:00
8fec60db11 AI 캐릭터, 콘텐츠 등록/수정 시 번역 데이터 생성 2025-12-12 04:52:02 +09:00
5d925e98e0 AI 캐릭터 번역 데이터, 콘텐츠 번역 데이터 엔티티에서 사용하지 않는 필드 제거 2025-12-12 03:05:50 +09:00
2355aa7c75 AI 캐릭터 리스트에 번역 데이터 제공 기능 추가 2025-12-12 01:32:02 +09:00
5bdb6d20a5 번역 - 지원되지 않는 언어이면 API를 호출하지 않고 빈 값을 반환하도록 수정 2025-12-12 01:00:41 +09:00
143ba2fbb2 HomeApi - languageCode에 따라 콘텐츠, 캐릭터의 번역 데이터를 제공하도록 수정 2025-12-11 23:58:17 +09:00
28fbdd7826 getDetail에 languageCode를 optional로 변경하여 languageCode가 없어도 정상 조회 되도록 수정 2025-12-11 22:33:26 +09:00
25169aaac3 getDetail에 @Transactional 을 추가하여 데이터 저장이 가능하도록 수정 2025-12-11 22:14:18 +09:00
608898eb0c Add translation support for audio content detail 2025-12-11 22:00:30 +09:00
1748b26318 파파고 번역 시 내용을 합쳐서 한번에 처리하지 않고 개별로 API를 호출해서 번역 처리 2025-12-11 19:35:05 +09:00
3ff38bb73a 파파고 번역 시 내용을 분리할 DELIMITER 변경 2025-12-11 18:57:46 +09:00
4498af4509 Fix AI character translation unique constraint column 2025-12-11 18:19:10 +09:00
8636a8cac0 캐릭터 번역 캐시 및 응답 필드 추가 2025-12-11 17:19:00 +09:00
304c001a27 파파고 번역 API 연동 2025-12-11 16:34:22 +09:00
fdac55ebdf AGENTS.md 파일 삭제 2025-12-11 15:50:52 +09:00
668d4f28cd AGENTS.md 파일에 AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙) 상세하게 추가 2025-12-10 00:22:53 +09:00
7b0644cb66 AGENTS.md 파일 추가 2025-12-09 14:56:14 +09:00
503802bcce feat(chat-character): 캐릭터 상세 조회 시 언어 코드 추가 2025-11-26 11:53:40 +09:00
899f2865b3 feat(chat-character): 캐릭터 등록시 파파고 언어 감지 API를 호출하여 languageCode를 기록하는 기능 추가 2025-11-26 11:40:58 +09:00
e0dcbd16fc feat(character-comment): 캐릭터 댓글의 답글 조회시 결과에 언어 코드 추가 2025-11-25 19:35:59 +09:00
62ec994069 feat(character-comment): 캐릭터 댓글 조회시 결과에 언어 코드 추가 2025-11-25 18:12:50 +09:00
8ec6d50dd8 feat(content-comment): 콘텐츠 댓글 조회시 결과에 언어 코드 추가 2025-11-25 18:10:36 +09:00
ddd46d585e feat(creator-cheers): 팬 Talk 응원글 조회시 결과에 언어 코드 추가 2025-11-25 18:05:08 +09:00
c5fa260a0d feat(creator-cheers): 팬 Talk 응원글 등록 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:42:26 +09:00
412c52e754 feat(creator-cheers): 팬 Talk 응원글 등록 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 16:36:39 +09:00
8f4544ad71 refactor(lang-detect): LanguageDetectEvent ID 필드를 단일 id로 통합
- LanguageDetectEvent의 contentId/commentId를 제거하고 공통 id(Long) 필드로 단순화
- LanguageDetectListener에서 targetType에 따라 id를 AudioContent/AudioContentComment/CharacterComment 조회에 사용하도록 수정
- AudioContentService, AudioContentCommentService, AudioContentDonationService, CharacterCommentService 등 이벤트 발행부를 새 시그니처(id + targetType)로 정리
2025-11-25 16:32:29 +09:00
619ceeea24 feat(character-comment): 캐릭터 댓글 등록 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:19:08 +09:00
a2998002e5 feat(character-comment): 캐릭터 댓글 등록 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 16:10:20 +09:00
da9b89a6cf feat(content-comment): 콘텐츠 댓글/후원 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:03:52 +09:00
5ee5107364 feat(content-comment): 콘텐츠 댓글/후원 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 15:54:01 +09:00
ae2c699748 refactor(LanguageDetectEvent): 언어 감지 요청 이벤트 클래스명 수정
- AudioContentLanguageDetectEvent -> LanguageDetectEvent
2025-11-25 15:42:32 +09:00
93ccb666c4 feat(content): 콘텐츠 업로드 후 languageCode가 null이면 naver papago 언어 감지 API 호출 기능 추가 2025-11-25 15:11:27 +09:00
edaea84a5b feat(content): 콘텐츠 업로드 request, 상세 조회 response에 languageCode 추가
- CreateAudioContentRequest, GetAudioContentDetailResponse
2025-11-24 12:31:49 +09:00
76806e2e90 feat(content-theme): 무료 콘텐츠의 테마를 조회할 때 '자기소개'가 가장 먼저 표시되도록 수정 2025-11-21 00:49:17 +09:00
39c51825da feat(content-theme): 무료 콘텐츠의 테마를 조회할 때 '자기소개'가 가장 먼저 표시되도록 수정 2025-11-21 00:37:29 +09:00
9a58b7b95f feat(latest-content-by-creator): 최신 콘텐츠 1개 조회시 오픈 되어 있는 콘텐츠만 조회하도록 수정 2025-11-20 21:19:29 +09:00
26eae4b06e feat(latest-content-by-creator): 최신 콘텐츠 1개 조회시 오픈 되어 있는 콘텐츠만 조회하도록 수정 2025-11-20 20:59:09 +09:00
60989391f6 feat(content-sort-type): 콘텐츠가 있는 active 테마 조회 API 추가 2025-11-20 00:51:09 +09:00
88d90eec2f feat(content-sort-type): getLatestContentByTheme(테마별 콘텐츠 조회)시 정렬 타입 추가 2025-11-20 00:26:24 +09:00
b6eb13df06 feat(content-sort-type): 콘텐츠 정렬 타입 인기순(POPULARITY) 추가 2025-11-20 00:05:33 +09:00
a6b815ad05 fix(series-main): 완료 시리즈 랜덤 정렬 2025-11-19 17:48:07 +09:00
d89122802a fix(series): 시리즈 리스트 랜덤 정렬로 조회할 수 있도록 기능 추가 2025-11-19 17:45:46 +09:00
690432d6ee fix(latest-content): 최신 콘텐츠 전체보기에서 유/무료 모두 조회되도록 수정 2025-11-18 19:21:15 +09:00
bc358d18de fix(latest-content): 최신 콘텐츠 전체보기에서 사용하는 theme에서 제외하는 theme 없이 모두 조회하도록 수정 2025-11-18 18:56:09 +09:00
add88aca35 fix(series-list): 시리즈 리스트 조회시 정렬 수정 2025-11-17 22:24:01 +09:00
b6971f6a8d fix(series-list): creator의 시리즈를 볼 떄와 다른 페이지에서 시리즈 리스트를 볼 때 정렬 순서 분리 2025-11-17 21:13:52 +09:00
f83dd47c7c fix(security-config): 홈 > 콘텐츠 랭킹을 로그인 하지 않아도 조회가 가능하도록 수정 2025-11-17 15:58:23 +09:00
146f733f5d feat(chat-character): 추천 캐릭터 개수 20 -> 30개로 변경 2025-11-17 15:50:40 +09:00
806fcfe7db feat(home): 추천 콘텐츠 개수 20 -> 30개로 변경 2025-11-17 15:49:06 +09:00
04e7c90407 fix(character): isNew -> new로 변경 2025-11-14 05:39:56 +09:00
f278497526 fix(character): isNew -> new로 변경 2025-11-14 05:37:24 +09:00
597bd8f8ae feat(chat-character): Character DTO에 isNew 매핑 적용(N+1 제거)
- 내용: 서비스 매핑에서 보조 쿼리 결과를 이용해 `isNew` 채움
2025-11-13 22:44:13 +09:00
e4c1cf5a9a feat(repo): 최근 3일 내 이미지 보유 캐릭터 id 일괄 조회 쿼리 추가
- 내용: `findCharacterIdsWithRecentImages(characterIds, since)` 추가
- 본문: 왜(이유) – N+1 제거, 무엇 – IN 기반 벌크 조회
2025-11-13 22:41:20 +09:00
9f6bdf6ed8 feat(series-main): 장르별 시리즈 group 조건 수정
- audioContent.id를 그룹 조건에서 제거
2025-11-13 19:59:54 +09:00
4f89b0189e feat(series-main): 시리즈 홈, 요일별 시리즈, 장르별 시리즈 API 추가 2025-11-13 16:02:11 +09:00
27be9a4fc2 feat(series-banner): 시리즈 배너의 등록, 수정, 삭제, 조회 및 정렬 순서 일괄 변경 기능이 추가 2025-11-13 11:37:46 +09:00
9464cc5ed4 feat(series): 완결된 시리즈를 조회할 수 있도록 isCompleted 파라미터 추가 2025-11-13 10:22:55 +09:00
39760e16ff feat(series): 오직 보이스온에서만(오리지널) 제공하는 콘텐츠도 조회할 수 있도록 isOriginal 파라미터 추가 2025-11-12 17:25:38 +09:00
bf149c45ad feat(admin-series): 관리자 시리즈 리스트 응답에 publishedDaysOfWeek(리스트)와 isOriginal(Boolean) 추가 2025-11-12 16:37:28 +09:00
4f52ec0663 fix(admin-series): 시리즈 수정 API 추가 2025-11-12 14:58:48 +09:00
3ed306ae8c fix(content): 콘텐츠 리스트 조회 API
- 로그인 된 사용자만 사용할 수 있도록 수정
2025-11-12 13:56:37 +09:00
ee35244296 feat(content): 콘텐츠 리스트 조회 API 2025-11-12 13:47:30 +09:00
fe76ecdfa9 feat(chat-character): 보온 주간 차트 콘텐츠 정렬 기준 추가
- 매출, 판매량, 댓글 수, 좋아요 수, 후원
2025-11-11 23:02:58 +09:00
16b6c13309 feat(chat-character): 추천 캐릭터 조회 및 메인/새로고침 API 반영 2025-11-11 17:01:50 +09:00
80c44373c7 refactor(home): 추천 dedup 자료구조를 LinkedHashMap에서 Set+List로 교체 2025-11-11 14:46:36 +09:00
a538bb766d feat(home): 홈 추천 콘텐츠 조회 및 전용 엔드포인트 추가
- HomeService: getRecommendContentList 추가 및 fetchData에 recommendContentList 주입
- HomeController: GET /api/home/recommend-contents 엔드포인트 추가
- 추천 로직은 랜덤 20개, 성인/타입/차단 필터 반영
2025-11-11 14:21:37 +09:00
26c09de7c9 feat(admin-can): 관리자 캔 충전 API를 다중 회원 일괄 충전으로 확장
- AdminCanChargeRequest: memberId → memberIds(List<Long>)로 변경
- AdminCanService.charge: memberIds 선조회 후 다건 충전 로직 추가
- 잘못된/비어있는 회원번호 검증 및 트랜잭션 롤백으로 정합성 보장

배경: 관리자 일괄 충전 요구사항 반영으로 여러 회원에게 동일 수량의 캔을 한 번에 충전할 수 있도록 개선. 중복 ID는 제거하여 중복 충전을 방지하고, 하나라도 유효하지 않으면 전체 롤백되도록 처리하여 데이터 정합성 확보.
2025-11-10 15:15:10 +09:00
82bd93c1ae feat(admin-member): 닉네임 검색으로 회원 id, nickname 반환 API 추가 2025-11-10 14:39:44 +09:00
e24e8372a8 feat(home): 포인트 사용 가능 콘텐츠 리스트 추가 2025-11-10 13:58:17 +09:00
eab7dc4521 feat(home-free-content): 최신 콘텐츠 조회 함수 getLatestContentByTheme에 orderbyRandom flag를 추가하여 랜덤으로 정렬한 후 데이터를 가져올 수 있도록 수정 2025-11-10 12:14:24 +09:00
5ca666c7fa feat(home-latest-content): 최신 콘텐츠 조회시 정렬 조건 변경
- 기존: id 내림차순
- 변경: 오픈일 내림차순
2025-11-07 20:48:08 +09:00
8fb3bd578f feat(live-room-heart): like-heart API의 request에 heartCount를 추가하여 왕하트(100개)를 쓸 수 있도록 수정 2025-11-03 11:30:42 +09:00
01fad8d93c feat(change message): 비비드 넥스트 -> 주식회사 소다라이브 2025-11-03 11:24:48 +09:00
a05ada5df0 feat(can-use-status): PAYVERSE로 충전한 캔을 사용한 내역도 포함되도록 수정 2025-10-22 23:13:24 +09:00
34480385d3 UseCalculate에 PAYVERSE로 충전한 캔 로그 데이터를 쌓도록 수정 2025-10-22 22:22:03 +09:00
fd68ed87a3 fix(can-use): PAYVERSE로 충전한 캔이 사용되지 않는 버그 수정 2025-10-22 21:34:39 +09:00
779fc5c5a5 feat(chat): 채팅권 구매 가격과 채팅횟수 변경
- 기존: 30캔, 채팅 40개
- 변경: 10캔, 채팅 12개
2025-10-22 16:40:54 +09:00
08ebb311fb feat(home): 인기 캐릭터 추가 2025-10-20 14:47:13 +09:00
12cdd25be7 feat(creator-profile-live): LiveRoomResponse에 utc 기반의 라이브 시작 시간 추가 2025-10-16 15:05:23 +09:00
59700493eb feat(explorer): 크리에이터 프로필에 최신/총/소장 콘텐츠 정보 추가
- ExplorerService.getCreatorProfile에서 다음 정보 계산/반환
  - 최신 오디오 콘텐츠 1개(`latestContent`)
  - 전체 콘텐츠 수(`totalContentCount`)
  - 조회 유저의 소장 콘텐츠 수(`ownedContentCount`)
- ExplorerQueryRepository.getOwnedContentCount 추가
  - 활성 KEEP 또는 유효한 RENTAL 주문 기준으로 distinct 카운트
- GetCreatorProfileResponse 스키마 확장
  - `latestContent`, `totalContentCount`, `ownedContentCount` 필드 추가
- AudioContentService.getLatestCreatorAudioContent 사용해 최신 콘텐츠 조회 로직 보강
  - 성인 콘텐츠 노출 여부 및 구매/대여 상태 반영
- OrderRepository의 주문 타입 조회 로직을 활용해 보유/대여 상태 표시

API 응답 필드가 추가되어 프로필 화면 구성 정보를 보강합니다. (호환성 유지)
2025-10-14 15:35:15 +09:00
88c3a84972 perf(admin-charge): 통화별 합계를 DB 그룹 집계로 이관하여 전송량/CPU 감소 2025-10-11 05:41:14 +09:00
db0d3a6ef3 refactor(admin-charge): QGetChargeStatusQueryDto의 currency가 null이면 KRW로 설정되도록 coalesce("KRW") 적용 2025-10-11 05:07:21 +09:00
3d29d27441 refactor(admin-charge): QGetChargeStatusQueryDto의 currency가 null이 되지 않도록 coalesce("") 사용 2025-10-11 04:52:58 +09:00
b5f66603bd refactor(admin-charge): QGetChargeStatusQueryDto의 currency가 null이 되지 않도록 coalesce("") 사용 2025-10-11 04:39:15 +09:00
976eeaa443 refactor(admin-charge): GetChargeStatusDetailResponse의 amount 타입을 Int에서 BigDecimal로 변경
- 충전 금액 계산을 좀 더 명확하게 하기 위해서 변경
2025-10-11 03:54:47 +09:00
25d1d813f1 refactor(admin-charge): HQL 파싱 에러 해결 위해 RIGHT → substring/length 치환 2025-10-11 03:37:46 +09:00
778f0c3ba2 fix(admin/charge): 통화별 리스트와 합계 행 추가 및 전체 합계 로직 수정
- 기존 로직은 통화 구분 없이 전체 합계를 계산·표시하여 통화가 혼재된 데이터에서 오해의 소지가 있었음.
- 관리 화면 요구사항: 통화(currency)별 합계를 명확히 제공.
2025-10-11 03:12:10 +09:00
38c50a4f8a fix(admin/charge): 통화별 리스트와 합계 행 추가 및 전체 합계 로직 수정
- 기존 로직은 통화 구분 없이 전체 합계를 계산·표시하여 통화가 혼재된 데이터에서 오해의 소지가 있었음.
- 관리 화면 요구사항: 통화(currency)별 합계를 명확히 제공.
2025-10-11 02:31:15 +09:00
c497f321bb fix(admin-charge-status-detail): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정 2025-10-10 23:32:36 +09:00
84c0768c8b fix(admin-charge-status): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정 2025-10-10 22:31:36 +09:00
efb8d8115f fix(verify-hecto): 데이터 검증시 가격비교 제거 2025-10-10 18:49:54 +09:00
41183b4648 fix(can-list): 국가별로 통화가 표시되도록 수정 2025-10-10 14:32:12 +09:00
36e20bf0d1 fix(payverse-webhook): orderId 비교 추가
- orderId와 chargeId 비교 로직 추가
2025-10-03 02:17:48 +09:00
0308e9ad83 fix(payverse): productName 비교 로직 제거
- productName에 +가 있는 경우 저장된 데이터와 검증을 위한 데이터가 다르게 나오기 때문에 비교 불가능
2025-10-03 02:10:30 +09:00
06c0374f16 사용하지 않는 print 제거 2025-10-03 01:56:55 +09:00
c5bc610e2f webhook 호출 IP 확인을 위해 print 추가 2025-10-03 01:48:19 +09:00
a86a24ca34 사용하지 않는 print 제거 2025-10-03 01:46:52 +09:00
cb2e3ea581 fix(payverse-wehook): 한국 원화일 때와 USD일 때 mid 값이 달라야 하는데 성공 여부 비교시 원화 mid로 고정하여 비교하던 로직 수정 2025-10-03 01:29:59 +09:00
42eaf1d5e3 fix(payverse-verify): 결제 성공 여부 판단 로직 수정
- processingAmount 대신 requestAmount와 can 가격 비교
- productName, customerId 비교 추가
2025-10-03 01:25:27 +09:00
02ef706fc2 temp: 디버깅을 위해 print 추가 2025-10-03 00:57:50 +09:00
085b217abb fix(can): 이전 버전의 호환성을 위해 기존의 int price를 유지하도록 수정 2025-10-03 00:02:47 +09:00
0866e0972a 값 확인을 위해 추가했던 println 제거 2025-10-02 22:31:23 +09:00
4b13265737 fix(charge): payverseVerify 결제금액 비교로직 수정
- BigDecimal끼리 비교하는데 casting 로직이 추가되어 문제가 생기던 버그 수정
2025-10-02 22:23:06 +09:00
79cd2b8123 debug(charge): 해외결제 DEBUG를 위해 print 임시 추가 2025-10-02 20:40:34 +09:00
8cc9641bbf feat(charge): payloadJson의 amount
- 소수점 아래 불필요한 0을 제거
2025-10-02 20:29:49 +09:00
32935aed88 feat(charge): payloadJson의 amount
- 소수점 아래 불필요한 0을 제거
2025-10-02 19:59:04 +09:00
c72adbfc4b temp(charge): 캔 리스트
- 해외 충전 테스트를 위해 전체 캔 리스트 표시
2025-10-02 19:56:23 +09:00
bc378cc619 temp(charge): 캔 리스트
- 해외 충전 테스트를 위해 전체 캔 리스트 표시
2025-10-02 19:03:42 +09:00
6327a5d2bf feat(charge): 캔 충전시 통화(KRW, USD)별로 분기 처리 2025-10-02 18:59:52 +09:00
2ab2a04748 feat(can): 캔 응답 - String 형태 가격 필드 추가 2025-10-02 15:07:57 +09:00
fb0a9e98a1 사용하지 않는 print 제거 2025-10-02 12:20:25 +09:00
e45fe1bf10 feat: 일반 유저용 캔 리스트 조회 API 추가, GeoCountryFilter(GeoCountry.OTHER, GeoCountry.KR 구분용) 추가 2025-10-01 22:29:39 +09:00
3d852a8356 feat: 관리자용 캔 리스트 조회 API 추가 2025-10-01 22:16:44 +09:00
b244944f41 feat: 캔 엔티티 currency - length 3으로 고정하여 CHAR(3)에 대응되도록 수정 2025-10-01 21:21:57 +09:00
3c7ba669e2 feat: Payment, AdTrackingHistory 엔티티 price - Decimal(10, 4)에 대응되도록 Column 추가 2025-10-01 21:08:35 +09:00
81e7e7129c feat: 캔 엔티티 currency - length 3으로 고정하여 CHAR(3)에 대응되도록 수정 2025-10-01 21:05:51 +09:00
d7ad110b9e feat: 캔 등록/조회 - currency 추가 2025-10-01 20:55:52 +09:00
0c17ea2dcd fix: 캔 가격, Payment의 price 타입 Int, Double -> BigDecimal로 변경 2025-10-01 20:37:53 +09:00
78ff13a654 temp: 캔 가격 타입 Int -> Double로 변경 2025-10-01 16:07:34 +09:00
863c285049 fix: 불필요한 print 제거 2025-09-30 18:32:12 +09:00
a3d74c0b57 fix: Payverse Webhook 엔드포인트에서 실제 클라이언트 IP를 가져올 수 있도록 수정 2025-09-30 18:22:46 +09:00
9016a72046 fix: ResponseStatusException이 ApiResponse로 래핑되지 않도록 수정
- 기본 에러 JSON 반환 유지
2025-09-30 18:16:13 +09:00
3c32614d1c temp(Exception): ResponseStatusException은 ApiResponse로 래핑하지 않고 그대로 전달 2025-09-30 17:59:55 +09:00
51988471cf temp(payverse): 호출되는 INBOUND_IP 확인하기 위해 출력 2025-09-30 17:55:31 +09:00
8990bd0722 fix(payverse): webhook 엔드포인트는 로그인 하지 않더라도 실행되도록 수정 2025-09-30 17:37:15 +09:00
aab2417976 fix(payverse): print 제거 2025-09-30 17:22:39 +09:00
1bd6f8da4e fix(payverse): PVKR 카드 코드면 method를 "카드"로 저장 2025-09-30 17:02:02 +09:00
22bd1bf042 fix(payverse): 결제 payload에 customerId 길이 30자로 제한
- customerId를 sha1 기반 30자 이내로 생성하도록 변경하여 스펙 준수
- 불필요한 billkeyReq 제거
2025-09-26 16:51:54 +09:00
d536a65fb4 fix(charge): payverse pg payload
- requestAmount의 값을 BigDecimal로 처리
2025-09-26 16:23:11 +09:00
03149a637d feat(charge): payverse pg - webhook API 추가 2025-09-25 21:18:45 +09:00
bc6c05b3ea feat(charge): payverse pg - 충전/검증 API 추가 2025-09-25 20:37:39 +09:00
142 changed files with 5656 additions and 354 deletions

View File

@@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.admin.can package kr.co.vividnext.sodalive.admin.can
data class AdminCanChargeRequest( data class AdminCanChargeRequest(
val memberId: Long, val memberIds: List<Long>,
val method: String, val method: String,
val can: Int val can: Int
) )

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.admin.can package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.CanResponse
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
@@ -13,6 +15,11 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/admin/can") @RequestMapping("/admin/can")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class AdminCanController(private val service: AdminCanService) { class AdminCanController(private val service: AdminCanService) {
@GetMapping
fun getCans(): ApiResponse<List<CanResponse>> {
return ApiResponse.ok(service.getCans())
}
@PostMapping @PostMapping
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request)) fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))

View File

@@ -1,6 +1,38 @@
package kr.co.vividnext.sodalive.admin.can package kr.co.vividnext.sodalive.admin.can
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.Can import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.CanResponse
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.QCanResponse
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
interface AdminCanRepository : JpaRepository<Can, Long> interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
interface AdminCanQueryRepository {
fun findAllByStatus(status: CanStatus): List<CanResponse>
}
@Repository
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
return queryFactory
.select(
QCanResponse(
can1.id,
can1.title,
can1.can,
can1.rewardCan,
can1.price.intValue(),
can1.currency,
can1.price.stringValue()
)
)
.from(can1)
.where(can1.status.eq(status))
.orderBy(can1.currency.asc(), can1.price.asc())
.fetch()
}
}

View File

@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.Can import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.CanStatus import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import java.math.BigDecimal
data class AdminCanRequest( data class AdminCanRequest(
val can: Int, val can: Int,
val rewardCan: Int, val rewardCan: Int,
val price: Int val price: BigDecimal,
val currency: String
) { ) {
fun toEntity(): Can { fun toEntity(): Can {
var title = "${can.moneyFormat()}" var title = "${can.moneyFormat()}"
@@ -20,6 +22,7 @@ data class AdminCanRequest(
can = can, can = can,
rewardCan = rewardCan, rewardCan = rewardCan,
price = price, price = price,
currency = currency,
status = CanStatus.SALE status = CanStatus.SALE
) )
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.admin.can package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
import kr.co.vividnext.sodalive.can.CanResponse
import kr.co.vividnext.sodalive.can.CanStatus import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository import kr.co.vividnext.sodalive.can.charge.ChargeRepository
@@ -20,6 +21,10 @@ class AdminCanService(
private val chargeRepository: ChargeRepository, private val chargeRepository: ChargeRepository,
private val memberRepository: AdminMemberRepository private val memberRepository: AdminMemberRepository
) { ) {
fun getCans(): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE)
}
@Transactional @Transactional
fun saveCan(request: AdminCanRequest) { fun saveCan(request: AdminCanRequest) {
repository.save(request.toEntity()) repository.save(request.toEntity())
@@ -35,12 +40,16 @@ class AdminCanService(
@Transactional @Transactional
fun charge(request: AdminCanChargeRequest) { fun charge(request: AdminCanChargeRequest) {
val member = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 회원번호 입니다.")
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.") if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.") if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
val ids = request.memberIds.distinct()
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
val members = memberRepository.findAllById(ids).toList()
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
members.forEach { member ->
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN) val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
charge.title = "${request.can.moneyFormat()}" charge.title = "${request.can.moneyFormat()}"
charge.member = member charge.member = member
@@ -54,3 +63,4 @@ class AdminCanService(
member.pgRewardCan += charge.rewardCan member.pgRewardCan += charge.rewardCan
} }
} }
}

View File

@@ -21,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
@GetMapping("/detail") @GetMapping("/detail")
fun getChargeStatusDetail( fun getChargeStatusDetail(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@RequestParam paymentGateway: PaymentGateway @RequestParam paymentGateway: PaymentGateway,
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway)) @RequestParam(value = "currency", required = false) currency: String? = null
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
} }

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.charge package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.BooleanBuilder
import com.querydsl.core.types.dsl.Expressions import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.QCan.can1 import kr.co.vividnext.sodalive.can.QCan.can1
@@ -14,7 +15,7 @@ import java.time.LocalDateTime
@Repository @Repository
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) { class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> { fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
val formattedDate = Expressions.stringTemplate( val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})", "DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate( Expressions.dateTimeTemplate(
@@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
), ),
"%Y-%m-%d" "%Y-%m-%d"
) )
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
return queryFactory return queryFactory
.select( .select(
QGetChargeStatusQueryDto( QGetChargeStatusResponse(
formattedDate, formattedDate,
payment.price.sum(), payment.price.sum(),
can1.price.sum(),
payment.id.count(), payment.id.count(),
payment.paymentGateway payment.paymentGateway.stringValue(),
currency.coalesce("KRW")
) )
) )
.from(payment) .from(payment)
@@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.and(charge.status.eq(ChargeStatus.CHARGE)) .and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE)) .and(payment.status.eq(PaymentStatus.COMPLETE))
) )
.groupBy(formattedDate, payment.paymentGateway) .groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
.orderBy(formattedDate.desc()) .orderBy(formattedDate.desc())
.fetch() .fetch()
} }
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
val currency = Expressions.stringTemplate(
"substring({0}, length({0}) - 2, 3)",
payment.locale
).coalesce("KRW")
return queryFactory
.select(
QGetChargeStatusResponse(
Expressions.stringTemplate("'합계'"), // date
payment.price.sum(),
payment.id.count(),
Expressions.stringTemplate("''"),
currency
)
)
.from(payment)
.innerJoin(payment.charge, charge)
.leftJoin(charge.can, can1)
.where(
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.groupBy(currency)
.orderBy(currency.asc())
.fetch()
}
fun getChargeStatusDetail( fun getChargeStatusDetail(
startDate: LocalDateTime, startDate: LocalDateTime,
endDate: LocalDateTime, endDate: LocalDateTime,
paymentGateway: PaymentGateway paymentGateway: PaymentGateway,
currency: String? = null
): List<GetChargeStatusDetailQueryDto> { ): List<GetChargeStatusDetailQueryDto> {
val formattedDate = Expressions.stringTemplate( val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})", "DATE_FORMAT({0}, {1})",
@@ -67,6 +100,20 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
), ),
"%Y-%m-%d %H:%i:%s" "%Y-%m-%d %H:%i:%s"
) )
val currencyExpr = Expressions.stringTemplate(
"substring({0}, length({0}) - 2, 3)",
payment.locale
).coalesce("KRW")
val whereBuilder = BooleanBuilder()
whereBuilder.and(charge.createdAt.goe(startDate))
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(payment.paymentGateway.eq(paymentGateway))
if (currency != null) {
whereBuilder.and(currencyExpr.eq(currency))
}
return queryFactory return queryFactory
.select( .select(
@@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
member.nickname, member.nickname,
payment.method.coalesce(""), payment.method.coalesce(""),
payment.price, payment.price,
can1.price, currencyExpr,
payment.locale.coalesce(""),
formattedDate formattedDate
) )
) )
@@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.innerJoin(charge.member, member) .innerJoin(charge.member, member)
.innerJoin(charge.payment, payment) .innerJoin(charge.payment, payment)
.leftJoin(charge.can, can1) .leftJoin(charge.can, can1)
.where( .where(whereBuilder)
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(payment.paymentGateway.eq(paymentGateway))
)
.orderBy(formattedDate.desc()) .orderBy(formattedDate.desc())
.fetch() .fetch()
} }

View File

@@ -20,48 +20,17 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.withZoneSameInstant(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime() .toLocalDateTime()
var totalChargeAmount = 0 val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
var totalChargeCount = 0L val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
chargeStatusList.addAll(0, summaryRows)
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
.asSequence()
.map {
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
it.pgChargeAmount
} else {
it.appleChargeAmount.toInt()
}
val chargeCount = it.chargeCount
totalChargeAmount += chargeAmount
totalChargeCount += chargeCount
GetChargeStatusResponse(
date = it.date,
chargeAmount = chargeAmount,
chargeCount = chargeCount,
pg = it.paymentGateWay.name
)
}
.toMutableList()
chargeStatusList.add(
0,
GetChargeStatusResponse(
date = "합계",
chargeAmount = totalChargeAmount,
chargeCount = totalChargeCount,
pg = ""
)
)
return chargeStatusList.toList() return chargeStatusList.toList()
} }
fun getChargeStatusDetail( fun getChargeStatusDetail(
startDateStr: String, startDateStr: String,
paymentGateway: PaymentGateway paymentGateway: PaymentGateway,
currency: String? = null
): List<GetChargeStatusDetailResponse> { ): List<GetChargeStatusDetailResponse> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0) val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
@@ -74,18 +43,16 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.withZoneSameInstant(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime() .toLocalDateTime()
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway) return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
.asSequence()
.map { .map {
GetChargeStatusDetailResponse( GetChargeStatusDetailResponse(
memberId = it.memberId, memberId = it.memberId,
nickname = it.nickname, nickname = it.nickname,
method = it.method, method = it.method,
amount = it.appleChargeAmount.toInt(), amount = it.amount,
locale = it.locale, locale = it.locale,
datetime = it.datetime datetime = it.datetime
) )
} }
.toList()
} }
} }

View File

@@ -1,13 +1,13 @@
package kr.co.vividnext.sodalive.admin.charge package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetChargeStatusDetailQueryDto @QueryProjection constructor( data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
val memberId: Long, val memberId: Long,
val nickname: String, val nickname: String,
val method: String, val method: String,
val appleChargeAmount: Double, val amount: BigDecimal,
val pgChargeAmount: Int,
val locale: String, val locale: String,
val datetime: String val datetime: String
) )

View File

@@ -1,10 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge package kr.co.vividnext.sodalive.admin.charge
import java.math.BigDecimal
data class GetChargeStatusDetailResponse( data class GetChargeStatusDetailResponse(
val memberId: Long, val memberId: Long,
val nickname: String, val nickname: String,
val method: String, val method: String,
val amount: Int, val amount: BigDecimal,
val locale: String, val locale: String,
val datetime: String val datetime: String
) )

View File

@@ -1,12 +0,0 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
data class GetChargeStatusQueryDto @QueryProjection constructor(
val date: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val chargeCount: Long,
val paymentGateWay: PaymentGateway
)

View File

@@ -1,8 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge package kr.co.vividnext.sodalive.admin.charge
data class GetChargeStatusResponse( import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetChargeStatusResponse @QueryProjection constructor(
val date: String, val date: String,
val chargeAmount: Int, val chargeAmount: BigDecimal,
val chargeCount: Long, val chargeCount: Long,
val pg: String val pg: String,
val currency: String
) )

View File

@@ -13,8 +13,13 @@ import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
@@ -40,6 +45,7 @@ class AdminChatCharacterController(
private val adminService: AdminChatCharacterService, private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService, private val originalWorkService: AdminOriginalWorkService,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${weraser.api-key}") @Value("\${weraser.api-key}")
private val apiKey: String, private val apiKey: String,
@@ -165,6 +171,18 @@ class AdminChatCharacterController(
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!) originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
} }
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = chatCharacter.id!!,
query = chatCharacter.description,
targetType = LanguageDetectTargetType.CHARACTER
)
)
}
ApiResponse.ok(null) ApiResponse.ok(null)
} }
@@ -315,6 +333,13 @@ class AdminChatCharacterController(
request = request request = request
) )
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.id,
targetType = LanguageTranslationTargetType.CHARACTER
)
)
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 // 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) { if (request.originalWorkId != null) {
// 서비스에서 유효성 검증 및 저장까지 처리 // 서비스에서 유효성 검증 및 저장까지 처리

View File

@@ -10,6 +10,11 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
@@ -24,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional
class AdminOriginalWorkService( class AdminOriginalWorkService(
private val originalWorkRepository: OriginalWorkRepository, private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository, private val chatCharacterRepository: ChatCharacterRepository,
private val originalWorkTagRepository: OriginalWorkTagRepository private val originalWorkTagRepository: OriginalWorkTagRepository,
private val applicationEventPublisher: ApplicationEventPublisher
) { ) {
/** 원작 등록 (중복 제목 방지 포함) */ /** 원작 등록 (중복 제목 방지 포함) */
@@ -56,7 +63,44 @@ class AdminOriginalWorkService(
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity)) entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
} }
} }
return originalWorkRepository.save(entity)
val originalWork = originalWorkRepository.save(entity)
/**
* 저장이 완료된 후
* originalWork의
*
* languageCode == null이면 언어 감지 이벤트 호출
* languageCode != null이면 번역 이벤트 호출
*
*/
if (originalWork.languageCode == null) {
val papagoQuery = listOf(
originalWork.title,
originalWork.contentType,
originalWork.category,
originalWork.description
)
.filter { it.isNotBlank() }
.joinToString(" ")
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = originalWork.id!!,
query = papagoQuery,
targetType = LanguageDetectTargetType.ORIGINAL_WORK
)
)
} else {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = originalWork.id!!,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
)
)
}
return originalWork
} }
/** 원작 수정 (이미지 경로 포함 선택적 변경) */ /** 원작 수정 (이미지 경로 포함 선택적 변경) */
@@ -107,6 +151,25 @@ class AdminOriginalWorkService(
if (imagePath != null) { if (imagePath != null) {
ow.imagePath = imagePath ow.imagePath = imagePath
} }
/**
* 번역 이벤트 호출
*/
if (
request.title != null ||
request.contentType != null ||
request.category != null ||
request.description != null ||
request.tags != null
) {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = ow.id!!,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
)
)
}
return originalWorkRepository.save(ow) return originalWorkRepository.save(ow)
} }

View File

@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -19,4 +21,9 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
fun searchSeriesList( fun searchSeriesList(
@RequestParam(value = "search_word") searchWord: String @RequestParam(value = "search_word") searchWord: String
) = ApiResponse.ok(service.searchSeriesList(searchWord)) ) = ApiResponse.ok(service.searchSeriesList(searchWord))
@PutMapping
fun modifySeries(
@RequestBody request: AdminModifySeriesRequest
) = ApiResponse.ok(service.modifySeries(request), "시리즈가 수정되었습니다.")
} }

View File

@@ -1,10 +1,17 @@
package kr.co.vividnext.sodalive.admin.content.series package kr.co.vividnext.sodalive.admin.content.series
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service @Service
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) { class AdminContentSeriesService(
private val repository: AdminContentSeriesRepository,
private val genreRepository: AdminContentSeriesGenreRepository
) {
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse { fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
val totalCount = repository.getSeriesTotalCount() val totalCount = repository.getSeriesTotalCount()
val items = repository.getSeriesList( val items = repository.getSeriesList(
@@ -12,10 +19,53 @@ class AdminContentSeriesService(private val repository: AdminContentSeriesReposi
limit = pageable.pageSize.toLong() limit = pageable.pageSize.toLong()
) )
if (items.isNotEmpty()) {
val ids = items.map { it.id }
val seriesList = repository.findAllById(ids)
val seriesMap = seriesList.associateBy { it.id }
items.forEach { item ->
val s = seriesMap[item.id]
if (s != null) {
item.publishedDaysOfWeek = s.publishedDaysOfWeek.toList().sortedBy { it.ordinal }
item.isOriginal = s.isOriginal
}
}
}
return GetAdminSeriesListResponse(totalCount, items) return GetAdminSeriesListResponse(totalCount, items)
} }
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> { fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
return repository.searchSeriesList(searchWord) return repository.searchSeriesList(searchWord)
} }
@Transactional
fun modifySeries(request: AdminModifySeriesRequest) {
val series = repository.findByIdAndActiveTrue(request.seriesId)
?: throw SodaException("잘못된 요청입니다.")
if (request.publishedDaysOfWeek != null) {
val days = request.publishedDaysOfWeek
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) {
throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.")
}
series.publishedDaysOfWeek.clear()
series.publishedDaysOfWeek.addAll(days)
}
if (request.genreId != null) {
val genre = genreRepository.findActiveSeriesGenreById(request.genreId)
?: throw SodaException("잘못된 요청입니다.")
series.genre = genre
}
if (request.isOriginal != null) {
series.isOriginal = request.isOriginal
}
if (request.isAdult != null) {
series.isAdult = request.isAdult
}
}
} }

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.content.series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
data class AdminModifySeriesRequest(
val seriesId: Long,
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>?,
val genreId: Long?,
val isOriginal: Boolean?,
val isAdult: Boolean?
)

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.admin.content.series package kr.co.vividnext.sodalive.admin.content.series
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
data class GetAdminSeriesListResponse( data class GetAdminSeriesListResponse(
val totalCount: Int, val totalCount: Int,
@@ -17,7 +18,10 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
val numberOfWorks: Long, val numberOfWorks: Long,
val state: String, val state: String,
val isAdult: Boolean val isAdult: Boolean
) ) {
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
var isOriginal: Boolean = false
}
data class GetAdminSearchSeriesListItem @QueryProjection constructor( data class GetAdminSearchSeriesListItem @QueryProjection constructor(
val id: Long, val id: Long,

View File

@@ -0,0 +1,145 @@
package kr.co.vividnext.sodalive.admin.content.series.banner
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
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.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/audio-content/series/banner")
@PreAuthorize("hasRole('ADMIN')")
class AdminContentSeriesBannerController(
private val bannerService: ContentSeriesBannerService,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 활성화된 배너 목록 조회 API
*/
@GetMapping("/list")
fun getBannerList(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = PageRequest.of(page, size)
val banners = bannerService.getActiveBanners(pageable)
val response = SeriesBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map { SeriesBannerResponse.from(it, imageHost) }
)
ApiResponse.ok(response)
}
/**
* 배너 상세 조회 API
*/
@GetMapping("/{bannerId}")
fun getBannerDetail(@PathVariable bannerId: Long) = run {
val banner = bannerService.getBannerById(bannerId)
val response = SeriesBannerResponse.from(banner, imageHost)
ApiResponse.ok(response)
}
/**
* 배너 등록 API
*/
@PostMapping("/register")
fun registerBanner(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java)
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "")
val imagePath = saveImage(banner.id!!, image)
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
val response = SeriesBannerResponse.from(updatedBanner, imageHost)
ApiResponse.ok(response)
}
/**
* 배너 수정 API
*/
@PutMapping("/update")
fun updateBanner(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, SeriesBannerUpdateRequest::class.java)
// 배너 존재 확인
bannerService.getBannerById(request.bannerId)
val imagePath = saveImage(request.bannerId, image)
val updated = bannerService.updateBanner(
bannerId = request.bannerId,
imagePath = imagePath,
seriesId = request.seriesId
)
val response = SeriesBannerResponse.from(updated, imageHost)
ApiResponse.ok(response)
}
/**
* 배너 삭제 API (소프트 삭제)
*/
@DeleteMapping("/{bannerId}")
fun deleteBanner(@PathVariable bannerId: Long) = run {
bannerService.deleteBanner(bannerId)
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
}
/**
* 배너 정렬 순서 일괄 변경 API
*/
@PutMapping("/orders")
fun updateBannerOrders(
@RequestBody request: UpdateBannerOrdersRequest
) = run {
bannerService.updateBannerOrders(request.ids)
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
}
private fun saveImage(bannerId: Long, image: MultipartFile): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
val fileName = generateFileName("series-banner")
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "series_banner/$bannerId/$fileName",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
}

View File

@@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.admin.content.series.banner.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
// 시리즈 배너 등록 요청 DTO
data class SeriesBannerRegisterRequest(
@JsonProperty("seriesId") val seriesId: Long
)
// 시리즈 배너 수정 요청 DTO
data class SeriesBannerUpdateRequest(
@JsonProperty("bannerId") val bannerId: Long,
@JsonProperty("seriesId") val seriesId: Long? = null
)
// 시리즈 배너 응답 DTO
data class SeriesBannerResponse(
val id: Long,
val imagePath: String,
val seriesId: Long,
val seriesTitle: String
) {
companion object {
fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse {
return SeriesBannerResponse(
id = banner.id!!,
imagePath = "$imageHost/${banner.imagePath}",
seriesId = banner.series.id!!,
seriesTitle = banner.series.title
)
}
}
}
// 시리즈 배너 목록 페이지 응답 DTO
data class SeriesBannerListPageResponse(
val totalCount: Long,
val content: List<SeriesBannerResponse>
)

View File

@@ -8,6 +8,7 @@ interface AdminContentSeriesGenreRepository : JpaRepository<SeriesGenre, Long>,
interface AdminContentSeriesGenreQueryRepository { interface AdminContentSeriesGenreQueryRepository {
fun getSeriesGenreList(): List<GetSeriesGenreListResponse> fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
} }
class AdminContentSeriesGenreQueryRepositoryImpl( class AdminContentSeriesGenreQueryRepositoryImpl(
@@ -21,4 +22,14 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
.orderBy(seriesGenre.orders.asc()) .orderBy(seriesGenre.orders.asc())
.fetch() .fetch()
} }
override fun findActiveSeriesGenreById(id: Long): SeriesGenre? {
return queryFactory
.selectFrom(seriesGenre)
.where(
seriesGenre.id.eq(id)
.and(seriesGenre.isActive.isTrue)
)
.fetchFirst()
}
} }

View File

@@ -5,8 +5,11 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -18,6 +21,8 @@ class AdminContentThemeService(
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val repository: AdminContentThemeRepository, private val repository: AdminContentThemeRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val bucket: String private val bucket: String
) { ) {
@@ -37,7 +42,14 @@ class AdminContentThemeService(
} }
fun createTheme(theme: String, imagePath: String) { fun createTheme(theme: String, imagePath: String) {
repository.save(AudioContentTheme(theme = theme, image = imagePath)) val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = savedTheme.id!!,
targetType = LanguageTranslationTargetType.CONTENT_THEME
)
)
} }
fun themeExistCheck(request: CreateContentThemeRequest) { fun themeExistCheck(request: CreateContentThemeRequest) {

View File

@@ -36,6 +36,12 @@ class AdminMemberController(private val service: AdminMemberService) {
pageable: Pageable pageable: Pageable
) = ApiResponse.ok(service.searchMember(searchWord, pageable)) ) = ApiResponse.ok(service.searchMember(searchWord, pageable))
@GetMapping("/search-by-nickname")
fun searchMemberByNickname(
@RequestParam(value = "search_word") searchWord: String,
@RequestParam(value = "size", required = false) size: Int?
) = ApiResponse.ok(service.searchMemberByNickname(searchWord = searchWord, size = size ?: 20))
@GetMapping("/creator/all/list") @GetMapping("/creator/all/list")
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList()) fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())

View File

@@ -16,6 +16,7 @@ interface AdminMemberQueryRepository {
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse> fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
fun findByIdAndActive(memberId: Long): Member? fun findByIdAndActive(memberId: Long): Member?
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
} }
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository { class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
@@ -121,4 +122,22 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
.orderBy(member.id.desc()) .orderBy(member.id.desc())
.fetchFirst() .fetchFirst()
} }
override fun searchMemberByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
return queryFactory
.select(
QAdminSimpleMemberResponse(
member.id,
member.nickname
)
)
.from(member)
.where(
member.nickname.contains(searchWord)
.and(member.isActive.isTrue)
)
.orderBy(member.id.desc())
.limit(limit)
.fetch()
}
} }

View File

@@ -145,6 +145,12 @@ class AdminMemberService(
return repository.getCreatorAllList() return repository.getCreatorAllList()
} }
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val limit = if (size <= 0) 20 else size
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
}
@Transactional @Transactional
fun resetPassword(request: ResetPasswordRequest) { fun resetPassword(request: ResetPasswordRequest) {
val member = repository.findByIdAndActive(memberId = request.memberId) val member = repository.findByIdAndActive(memberId = request.memberId)

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.member
import com.querydsl.core.annotations.QueryProjection
/**
* 관리자용 간단 회원 응답 DTO
* 닉네임 검색 결과로 사용되며 charge 등에서 memberId 선택에 활용된다.
*/
data class AdminSimpleMemberResponse @QueryProjection constructor(
val id: Long,
val nickname: String
)

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.admin.statistics.ad
import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.core.types.dsl.DateTimePath import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.core.types.dsl.Expressions import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.NumberExpression
import com.querydsl.core.types.dsl.StringTemplate import com.querydsl.core.types.dsl.StringTemplate
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
@@ -67,7 +66,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val firstPaymentTotalAmount = CaseBuilder() val firstPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
.then(adTrackingHistory.price) .then(adTrackingHistory.price)
.otherwise(Expressions.constant(0.0)) .otherwise(0.toBigDecimal())
.sum() .sum()
val repeatPaymentCount = CaseBuilder() val repeatPaymentCount = CaseBuilder()
@@ -79,7 +78,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val repeatPaymentTotalAmount = CaseBuilder() val repeatPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
.then(adTrackingHistory.price) .then(adTrackingHistory.price)
.otherwise(Expressions.constant(0.0)) .otherwise(0.toBigDecimal())
.sum() .sum()
val allPaymentCount = CaseBuilder() val allPaymentCount = CaseBuilder()
@@ -97,7 +96,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) .or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
) )
.then(adTrackingHistory.price) .then(adTrackingHistory.price)
.otherwise(Expressions.constant(0.0)) .otherwise(0.toBigDecimal())
.sum() .sum()
return queryFactory return queryFactory
@@ -111,11 +110,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
loginCount, loginCount,
signUpCount, signUpCount,
firstPaymentCount, firstPaymentCount,
roundedValueDecimalPlaces2(firstPaymentTotalAmount), firstPaymentTotalAmount,
repeatPaymentCount, repeatPaymentCount,
roundedValueDecimalPlaces2(repeatPaymentTotalAmount), repeatPaymentTotalAmount,
allPaymentCount, allPaymentCount,
roundedValueDecimalPlaces2(allPaymentTotalAmount) allPaymentTotalAmount
) )
) )
.from(adTrackingHistory) .from(adTrackingHistory)
@@ -148,13 +147,4 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
"%Y-%m-%d" "%Y-%m-%d"
) )
} }
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
return Expressions.numberTemplate(
Double::class.java,
"ROUND({0}, {1})",
valueExpression,
2
)
}
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.admin.statistics.ad package kr.co.vividnext.sodalive.admin.statistics.ad
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetAdminAdStatisticsResponse( data class GetAdminAdStatisticsResponse(
val totalCount: Int, val totalCount: Int,
@@ -16,9 +17,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
val loginCount: Int, val loginCount: Int,
val signUpCount: Int, val signUpCount: Int,
val firstPaymentCount: Int, val firstPaymentCount: Int,
val firstPaymentTotalAmount: Double, val firstPaymentTotalAmount: BigDecimal,
val repeatPaymentCount: Int, val repeatPaymentCount: Int,
val repeatPaymentTotalAmount: Double, val repeatPaymentTotalAmount: BigDecimal,
val allPaymentCount: Int, val allPaymentCount: Int,
val allPaymentTotalAmount: Double val allPaymentTotalAmount: BigDecimal
) )

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.api.home package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.GetAuditionListItem import kr.co.vividnext.sodalive.audition.GetAuditionListItem
import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.content.AudioContentMainItem import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
@@ -21,8 +22,11 @@ data class GetHomeResponse(
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>, val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
val auditionList: List<GetAuditionListItem>, val auditionList: List<GetAuditionListItem>,
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>, val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
val popularCharacters: List<Character>,
val contentRanking: List<GetAudioContentRankingItem>, val contentRanking: List<GetAudioContentRankingItem>,
val recommendChannelList: List<RecommendChannelResponse>, val recommendChannelList: List<RecommendChannelResponse>,
val freeContentList: List<AudioContentMainItem>, val freeContentList: List<AudioContentMainItem>,
val pointAvailableContentList: List<AudioContentMainItem>,
val recommendContentList: List<AudioContentMainItem>,
val curationList: List<GetContentCurationResponse> val curationList: List<GetContentCurationResponse>
) )

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@@ -63,4 +64,44 @@ class HomeController(private val service: HomeService) {
) )
) )
} }
// 추천 콘텐츠만 새로고침하기 위한 엔드포인트
@GetMapping("/recommend-contents")
fun getRecommendContents(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getRecommendContentList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
)
}
// 콘텐츠 랭킹 엔드포인트
@GetMapping("/content-ranking")
fun getContentRanking(
@RequestParam("sort", required = false) sort: ContentRankingSortType? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam("offset", required = false) offset: Long? = null,
@RequestParam("limit", required = false) limit: Long? = null,
@RequestParam("theme", required = false) theme: String? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getContentRankingBySort(
sort = sort ?: ContentRankingSortType.REVENUE,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
offset = offset,
limit = limit,
theme = theme,
member = member
)
)
}
} }

View File

@@ -1,22 +1,30 @@
package kr.co.vividnext.sodalive.api.home package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.AuditionService import kr.co.vividnext.sodalive.audition.AuditionService
import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.content.AudioContentMainItem import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.live.room.LiveRoomService import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService 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.RankingRepository
import kr.co.vividnext.sodalive.rank.RankingService import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -39,13 +47,25 @@ class HomeService(
private val contentThemeService: AudioContentThemeService, private val contentThemeService: AudioContentThemeService,
private val recommendChannelService: RecommendChannelQueryService, private val recommendChannelService: RecommendChannelQueryService,
private val characterService: ChatCharacterService,
private val rankingService: RankingService, private val rankingService: RankingService,
private val rankingRepository: RankingRepository, private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository, private val explorerQueryRepository: ExplorerQueryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
companion object {
private const val RECOMMEND_TARGET_SIZE = 30
private const val RECOMMEND_MAX_ATTEMPTS = 3
}
fun fetchData( fun fetchData(
timezone: String, timezone: String,
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
@@ -102,6 +122,8 @@ class HomeService(
} }
} }
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
val eventBannerList = GetEventResponse( val eventBannerList = GetEventResponse(
totalCount = 0, totalCount = 0,
eventList = emptyList() eventList = emptyList()
@@ -113,19 +135,28 @@ class HomeService(
isAdult = isAdult isAdult = isAdult
) )
// 오직 보이스온에서만
val originalAudioDramaList = seriesService.getOriginalAudioDramaList( val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType,
orderByRandom = true
) )
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult) val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
// 요일별 시리즈
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
dayOfWeek = getDayOfWeekByTimezone(timezone) dayOfWeek = getDayOfWeekByTimezone(timezone)
) )
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
// 인기 캐릭터 조회
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
val currentDateTime = LocalDateTime.now() val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime val startDate = currentDateTime
@@ -143,10 +174,26 @@ class HomeService(
contentType = contentType, contentType = contentType,
startDate = startDate.minusDays(1), startDate = startDate.minusDays(1),
endDate = endDate, endDate = endDate,
sortType = "매출" sort = ContentRankingSortType.REVENUE
) )
// TODO 오디오 북 val contentRankingContentIds = contentRanking.map { it.contentId }
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentRanking.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentRanking
}
val recommendChannelList = recommendChannelService.getRecommendChannel( val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId, memberId = memberId,
@@ -154,6 +201,40 @@ class HomeService(
contentType = contentType contentType = contentType
) )
/**
* recommendChannelList의 콘텐츠 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
*/
val channelContentIds = recommendChannelList
.flatMap { it.contentList }
.map { it.contentId }
.distinct()
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
recommendChannelList.map { channel ->
val translatedContentList = channel.contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
channel.copy(contentList = translatedContentList)
}
} else {
recommendChannelList
}
val freeContentList = contentService.getLatestContentByTheme( val freeContentList = contentService.getLatestContentByTheme(
theme = contentThemeService.getActiveThemeOfContent( theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult, isAdult = isAdult,
@@ -162,7 +243,8 @@ class HomeService(
), ),
contentType = contentType, contentType = contentType,
isFree = true, isFree = true,
isAdult = isAdult isAdult = isAdult,
orderByRandom = true
).filter { ).filter {
if (memberId != null) { if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
@@ -171,6 +253,26 @@ class HomeService(
} }
} }
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
val pointAvailableContentList = contentService.getLatestContentByTheme(
theme = emptyList(),
contentType = contentType,
isFree = false,
isAdult = isAdult,
orderByRandom = true,
isPointAvailableOnly = true
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
val curationList = curationService.getContentCurationList( val curationList = curationService.getContentCurationList(
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용 tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
isAdult = isAdult, isAdult = isAdult,
@@ -182,15 +284,22 @@ class HomeService(
liveList = liveList, liveList = liveList,
creatorRanking = creatorRanking, creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList, latestContentThemeList = latestContentThemeList,
latestContentList = latestContentList, latestContentList = translatedLatestContentList,
bannerList = bannerList, bannerList = bannerList,
eventBannerList = eventBannerList, eventBannerList = eventBannerList,
originalAudioDramaList = originalAudioDramaList, originalAudioDramaList = translatedOriginalAudioDramaList,
auditionList = auditionList, auditionList = auditionList,
dayOfWeekSeriesList = dayOfWeekSeriesList, dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
contentRanking = contentRanking, popularCharacters = translatedPopularCharacters,
recommendChannelList = recommendChannelList, contentRanking = translatedContentRanking,
freeContentList = freeContentList, recommendChannelList = translatedRecommendChannelList,
freeContentList = translatedFreeContentList,
pointAvailableContentList = translatedPointAvailableContentList,
recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member = member
),
curationList = curationList curationList = curationList
) )
} }
@@ -214,7 +323,7 @@ class HomeService(
listOf(theme) listOf(theme)
} }
return contentService.getLatestContentByTheme( val contentList = contentService.getLatestContentByTheme(
theme = themeList, theme = themeList,
contentType = contentType, contentType = contentType,
isFree = false, isFree = false,
@@ -226,6 +335,8 @@ class HomeService(
true true
} }
} }
return getTranslatedContentList(contentList = contentList)
} }
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@@ -237,12 +348,48 @@ class HomeService(
val memberId = member?.id val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible val isAdult = member?.auth != null && isAdultContentVisible
return seriesService.getDayOfWeekSeriesList( val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
dayOfWeek = dayOfWeek dayOfWeek = dayOfWeek
) )
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
}
fun getContentRankingBySort(
sort: ContentRankingSortType,
isAdultContentVisible: Boolean,
contentType: ContentType,
offset: Long?,
limit: Long?,
theme: String?,
member: Member?
): List<GetAudioContentRankingItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
.withMinute(0)
.withSecond(0)
.minusWeeks(1)
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val endDate = startDate.plusDays(6)
return rankingService.getContentRanking(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
startDate = startDate.minusDays(1),
endDate = endDate,
offset = offset ?: 0,
limit = limit ?: 12,
sort = sort,
theme = theme ?: ""
)
} }
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek { private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
@@ -262,4 +409,154 @@ class HomeService(
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
} }
// 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다.
fun getRecommendContentList(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<AudioContentMainItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
// Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회
val seen = HashSet<Long>(RECOMMEND_TARGET_SIZE * 2)
val result = ArrayList<AudioContentMainItem>(RECOMMEND_TARGET_SIZE)
var attempt = 0
while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) {
attempt += 1
val batch = contentService.getLatestContentByTheme(
theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회
contentType = contentType,
offset = 0,
limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회
isFree = false,
isAdult = isAdult,
orderByRandom = true
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
for (item in batch) {
if (result.size >= RECOMMEND_TARGET_SIZE) break
if (seen.add(item.contentId)) {
result.add(item)
}
}
}
return getTranslatedContentList(contentList = result)
}
/**
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param contentList 번역 대상 AudioContentMainItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedContentList(contentList: List<AudioContentMainItem>): List<AudioContentMainItem> {
val contentIds = contentList.map { it.contentId }
return if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentList
}
}
/**
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
return if (seriesIds.isNotEmpty()) {
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
seriesList
}
}
/**
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
* 번역 데이터를 한 번에 조회한다.
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
*
* @param aiCharacterList 번역 대상 캐릭터 목록
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
*/
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
val characterIds = aiCharacterList.map { it.characterId }
return if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
.associateBy { it.characterId }
aiCharacterList.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
aiCharacterList
}
}
} }

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.can package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType import javax.persistence.EnumType
import javax.persistence.Enumerated import javax.persistence.Enumerated
@@ -10,7 +12,10 @@ data class Can(
var title: String, var title: String,
var can: Int, var can: Int,
var rewardCan: Int, var rewardCan: Int,
var price: Int, @Column(precision = 10, scale = 4, nullable = false)
var price: BigDecimal,
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
var currency: String,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var status: CanStatus var status: CanStatus
) : BaseEntity() ) : BaseEntity()

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.can package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.GeoCountry
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
@@ -9,13 +10,15 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest
@RestController @RestController
@RequestMapping("/can") @RequestMapping("/can")
class CanController(private val service: CanService) { class CanController(private val service: CanService) {
@GetMapping @GetMapping
fun getCans(): ApiResponse<List<CanResponse>> { fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
return ApiResponse.ok(service.getCans()) val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
return ApiResponse.ok(service.getCans(geoCountry))
} }
@GetMapping("/status") @GetMapping("/status")

View File

@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
interface CanQueryRepository { interface CanQueryRepository {
fun findAllByStatus(status: CanStatus): List<CanResponse> fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
@@ -32,7 +32,7 @@ interface CanQueryRepository {
@Repository @Repository
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository { class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
override fun findAllByStatus(status: CanStatus): List<CanResponse> { override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
return queryFactory return queryFactory
.select( .select(
QCanResponse( QCanResponse(
@@ -40,11 +40,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
can1.title, can1.title,
can1.can, can1.can,
can1.rewardCan, can1.rewardCan,
can1.price can1.price.intValue(),
can1.currency,
can1.price.stringValue()
) )
) )
.from(can1) .from(can1)
.where(can1.status.eq(status)) .where(
can1.status.eq(status),
can1.currency.eq(currency)
)
.orderBy(can1.can.asc()) .orderBy(can1.can.asc())
.fetch() .fetch()
} }
@@ -64,11 +69,13 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
val chargeStatusCondition = when (container) { val chargeStatusCondition = when (container) {
"aos" -> { "aos" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG) charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) .or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
} }
"ios" -> { "ios" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG) charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) .or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
} }

View File

@@ -7,5 +7,7 @@ data class CanResponse @QueryProjection constructor(
val title: String, val title: String,
val can: Int, val can: Int,
val rewardCan: Int, val rewardCan: Int,
val price: Int val price: Int,
val currency: String,
val priceStr: String
) )

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.GeoCountry
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter
@Service @Service
class CanService(private val repository: CanRepository) { class CanService(private val repository: CanRepository) {
fun getCans(): List<CanResponse> { fun getCans(geoCountry: GeoCountry): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE) val currency = when (geoCountry) {
GeoCountry.KR -> "KRW"
else -> "USD"
}
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
} }
fun getCanStatus(member: Member, container: String): GetCanStatusResponse { fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
@@ -35,6 +40,7 @@ class CanService(private val repository: CanRepository) {
"aos" -> { "aos" -> {
it.useCanCalculates.any { useCanCalculate -> it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG || useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
} }
} }
@@ -42,12 +48,14 @@ class CanService(private val repository: CanRepository) {
"ios" -> { "ios" -> {
it.useCanCalculates.any { useCanCalculate -> it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG || useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
} }
} }
else -> it.useCanCalculates.any { useCanCalculate -> else -> it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE
} }
} }
} }

View File

@@ -1,7 +1,9 @@
package kr.co.vividnext.sodalive.can.charge package kr.co.vividnext.sodalive.can.charge
import java.math.BigDecimal
data class ChargeCompleteResponse( data class ChargeCompleteResponse(
val price: Double, val price: BigDecimal,
val currencyCode: String, val currencyCode: String,
val isFirstCharged: Boolean val isFirstCharged: Boolean
) )

View File

@@ -6,20 +6,77 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
import kr.co.vividnext.sodalive.marketing.AdTrackingService import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.servlet.http.HttpServletRequest
@RestController @RestController
@RequestMapping("/charge") @RequestMapping("/charge")
class ChargeController( class ChargeController(
private val service: ChargeService, private val service: ChargeService,
private val trackingService: AdTrackingService private val trackingService: AdTrackingService,
@Value("\${payverse.inbound-ip}")
private val payverseInboundIp: String
) { ) {
@PostMapping("/payverse")
fun payverseCharge(
@RequestBody request: PayverseChargeRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.payverseCharge(member, request))
}
@PostMapping("/payverse/verify")
fun payverseVerify(
@RequestBody verifyRequest: PayverseVerifyRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
trackingCharge(member, response)
ApiResponse.ok(Unit)
}
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
@PostMapping("/payverse/webhook")
fun payverseWebhook(
@RequestBody request: PayverseWebhookRequest,
servletRequest: HttpServletRequest
): PayverseWebhookResponse {
val header = servletRequest.getHeader("X-Forwarded-For")
val remoteIp = if (header.isNullOrEmpty()) {
servletRequest.remoteAddr
} else {
header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP
}
if (remoteIp != payverseInboundIp) {
throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
val success = service.payverseWebhook(request)
if (!success) {
throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
return PayverseWebhookResponse(receiveResult = "SUCCESS")
}
@PostMapping @PostMapping
fun charge( fun charge(
@RequestBody chargeRequest: ChargeRequest, @RequestBody chargeRequest: ChargeRequest,
@@ -111,8 +168,7 @@ class ChargeController(
memberId = member.id!!, memberId = member.id!!,
chargeId = chargeId, chargeId = chargeId,
productId = request.productId, productId = request.productId,
purchaseToken = request.purchaseToken, purchaseToken = request.purchaseToken
paymentGateway = request.paymentGateway
) )
trackingCharge(member, response) trackingCharge(member, response)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway) data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
@@ -20,14 +21,14 @@ data class VerifyResult(
val method: String, val method: String,
val pg: String, val pg: String,
val status: Int, val status: Int,
val price: Int val price: BigDecimal
) )
data class AppleChargeRequest( data class AppleChargeRequest(
val title: String, val title: String,
val chargeCan: Int, val chargeCan: Int,
val paymentGateway: PaymentGateway, val paymentGateway: PaymentGateway,
var price: Double? = null, var price: BigDecimal? = null,
var locale: String? = null var locale: String? = null
) )
@@ -38,9 +39,53 @@ data class AppleVerifyResponse(val status: Int)
data class GoogleChargeRequest( data class GoogleChargeRequest(
val title: String, val title: String,
val chargeCan: Int, val chargeCan: Int,
val price: Double, val price: BigDecimal,
val currencyCode: String, val currencyCode: String,
val productId: String, val productId: String,
val purchaseToken: String, val purchaseToken: String,
val paymentGateway: PaymentGateway val paymentGateway: PaymentGateway
) )
data class PayverseChargeRequest(
val canId: Long
)
data class PayverseChargeResponse(
val chargeId: Long,
val payloadJson: String
)
data class PayverseVerifyRequest(
val transactionId: String,
val orderId: String
)
data class PayverseVerifyResponse(
val resultStatus: String,
val tid: String,
val schemeGroup: String,
val schemeCode: String,
val transactionType: String,
val transactionStatus: String,
val transactionMessage: String,
val orderId: String,
val customerId: String,
val requestCurrency: String,
val requestAmount: BigDecimal
)
data class PayverseWebhookRequest(
val type: String,
val mid: String,
val tid: String,
val schemeGroup: String,
val schemeCode: String,
val orderId: String,
val requestCurrency: String,
val requestAmount: BigDecimal,
val resultStatus: String,
val approvalDay: String,
val sign: String
)
data class PayverseWebhookResponse(val receiveResult: String)

View File

@@ -113,15 +113,18 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
val paymentGatewayCondition = when (container) { val paymentGatewayCondition = when (container) {
"aos" -> { "aos" -> {
payment.paymentGateway.eq(PaymentGateway.PG) payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) .or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
} }
"ios" -> { "ios" -> {
payment.paymentGateway.eq(PaymentGateway.PG) payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) .or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
} }
else -> payment.paymentGateway.eq(PaymentGateway.PG) else -> payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
} }
return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD)) return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD))

View File

@@ -22,6 +22,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.codec.digest.DigestUtils
import org.json.JSONObject import org.json.JSONObject
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
@@ -34,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -63,9 +65,112 @@ class ChargeService(
@Value("\${apple.iap-verify-sandbox-url}") @Value("\${apple.iap-verify-sandbox-url}")
private val appleInAppVerifySandBoxUrl: String, private val appleInAppVerifySandBoxUrl: String,
@Value("\${apple.iap-verify-url}") @Value("\${apple.iap-verify-url}")
private val appleInAppVerifyUrl: String private val appleInAppVerifyUrl: String,
@Value("\${payverse.mid}")
private val payverseMid: String,
@Value("\${payverse.client-key}")
private val payverseClientKey: String,
@Value("\${payverse.secret-key}")
private val payverseSecretKey: String,
@Value("\${payverse.usd-mid}")
private val payverseUsdMid: String,
@Value("\${payverse.usd-client-key}")
private val payverseUsdClientKey: String,
@Value("\${payverse.usd-secret-key}")
private val payverseUsdSecretKey: String,
@Value("\${payverse.host}")
private val payverseHost: String,
@Value("\${server.env}")
private val serverEnv: String
) { ) {
@Transactional
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
val chargeId = request.orderId.toLongOrNull() ?: return false
val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false
// 결제수단 확인
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
return false
}
// 결제 상태 분기 처리
return when (charge.payment?.status) {
PaymentStatus.REQUEST -> {
// 성공 조건 검증
val mid = if (request.requestCurrency == "KRW") {
payverseMid
} else {
payverseUsdMid
}
val expectedSign = DigestUtils.sha512Hex(
String.format(
"||%s||%s||%s||%s||%s||",
if (request.requestCurrency == "KRW") {
payverseSecretKey
} else {
payverseUsdSecretKey
},
mid,
request.orderId,
request.requestAmount,
request.approvalDay
)
)
val isAmountMatch = request.requestAmount.compareTo(
charge.payment!!.price
) == 0
val isSuccess = request.resultStatus == "SUCCESS" &&
request.mid == mid &&
request.orderId.toLongOrNull() == charge.id &&
isAmountMatch &&
request.sign == expectedSign
if (isSuccess) {
// payverseVerify의 226~246 라인과 동일 처리
charge.payment?.receiptId = request.tid
val mappedMethod = if (request.schemeGroup == "PVKR") {
mapPayverseSchemeToMethodByCode(request.schemeCode)
} else {
null
}
charge.payment?.method = mappedMethod ?: request.schemeCode
charge.payment?.status = PaymentStatus.COMPLETE
charge.payment?.locale = request.requestCurrency
val member = charge.member!!
member.charge(charge.chargeCan, charge.rewardCan, "pg")
applicationEventPublisher.publishEvent(
ChargeSpringEvent(
chargeId = charge.id!!,
memberId = member.id!!
)
)
true
} else {
false
}
}
PaymentStatus.COMPLETE -> {
// 이미 결제가 완료된 경우 성공 처리(idempotent)
true
}
else -> {
// 그 외 상태는 404
false
}
}
}
@Transactional @Transactional
fun chargeByCoupon(couponNumber: String, member: Member): String { fun chargeByCoupon(couponNumber: String, member: Member): String {
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber) val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
@@ -126,6 +231,177 @@ class ChargeService(
} }
} }
@Transactional
fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse {
val can = canRepository.findByIdOrNull(request.canId)
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
val requestCurrency = can.currency
val isKrw = requestCurrency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
}
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
}
val secretKey = if (isKrw) {
payverseSecretKey
} else {
payverseUsdSecretKey
}
val charge = Charge(can.can, can.rewardCan)
charge.title = can.title
charge.member = member
charge.can = can
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
payment.price = can.price
charge.payment = payment
val savedCharge = chargeRepository.save(charge)
val chargeId = savedCharge.id!!
val amount = BigDecimal(
savedCharge.payment!!.price
.setScale(4, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString()
)
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
val sign = DigestUtils.sha512Hex(
String.format(
"||%s||%s||%s||%s||%s||",
secretKey,
mid,
chargeId,
amount,
reqDate
)
)
val customerId = "${serverEnv}_user_${member.id!!}"
val payload = linkedMapOf(
"mid" to mid,
"clientKey" to clientKey,
"orderId" to chargeId.toString(),
"customerId" to customerId,
"productName" to can.title,
"requestCurrency" to requestCurrency,
"requestAmount" to amount,
"reqDate" to reqDate,
"sign" to sign
)
val payloadJson = objectMapper.writeValueAsString(payload)
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
}
@Transactional
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException("로그인 정보를 확인해주세요.")
val isKrw = charge.can?.currency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
}
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
}
// 결제수단 확인
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
throw SodaException("결제정보에 오류가 있습니다.")
}
// 결제 상태에 따른 분기 처리
when (charge.payment?.status) {
PaymentStatus.REQUEST -> {
try {
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
val request = Request.Builder()
.url(url)
.addHeader("mid", mid)
.addHeader("clientKey", clientKey)
.get()
.build()
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
throw SodaException("결제정보에 오류가 있습니다.")
}
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
val customerId = "${serverEnv}_user_${member.id!!}"
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
verifyResponse.transactionStatus == "SUCCESS" &&
verifyResponse.orderId.toLongOrNull() == charge.id &&
verifyResponse.customerId == customerId &&
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
if (isSuccess) {
// verify 함수의 232~248 라인과 동일 처리
charge.payment?.receiptId = verifyResponse.tid
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
mapPayverseSchemeToMethodByCode(verifyResponse.schemeCode)
} else {
null
}
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
charge.payment?.status = PaymentStatus.COMPLETE
// 통화코드 설정
charge.payment?.locale = verifyResponse.requestCurrency
member.charge(charge.chargeCan, charge.rewardCan, "pg")
applicationEventPublisher.publishEvent(
ChargeSpringEvent(
chargeId = charge.id!!,
memberId = member.id!!
)
)
return ChargeCompleteResponse(
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
PaymentStatus.COMPLETE -> {
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
return ChargeCompleteResponse(
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
}
else -> {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
}
@Transactional @Transactional
fun charge(member: Member, request: ChargeRequest): ChargeResponse { fun charge(member: Member, request: ChargeRequest): ChargeResponse {
val can = canRepository.findByIdOrNull(request.canId) val can = canRepository.findByIdOrNull(request.canId)
@@ -137,7 +413,7 @@ class ChargeService(
charge.can = can charge.can = can
val payment = Payment(paymentGateway = request.paymentGateway) val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = can.price.toDouble() payment.price = can.price
charge.payment = payment charge.payment = payment
chargeRepository.save(charge) chargeRepository.save(charge)
@@ -176,14 +452,14 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
} else { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} catch (e: Exception) { } catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} else { } else {
@@ -208,7 +484,7 @@ class ChargeService(
VerifyResult::class.java VerifyResult::class.java
) )
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) { if (verifyResult.status == 1) {
charge.payment?.receiptId = verifyResult.receiptId charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = if (verifyResult.pg.contains("카카오")) { charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
"${verifyResult.pg}-${verifyResult.method}" "${verifyResult.pg}-${verifyResult.method}"
@@ -226,14 +502,14 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
} else { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} catch (e: Exception) { } catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} else { } else {
@@ -251,7 +527,7 @@ class ChargeService(
payment.price = if (request.price != null) { payment.price = if (request.price != null) {
request.price!! request.price!!
} else { } else {
0.toDouble() 0.toBigDecimal()
} }
payment.locale = request.locale payment.locale = request.locale
@@ -286,7 +562,7 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
@@ -303,7 +579,7 @@ class ChargeService(
member: Member, member: Member,
title: String, title: String,
chargeCan: Int, chargeCan: Int,
price: Double, price: BigDecimal,
currencyCode: String, currencyCode: String,
productId: String, productId: String,
purchaseToken: String, purchaseToken: String,
@@ -331,8 +607,7 @@ class ChargeService(
memberId: Long, memberId: Long,
chargeId: Long, chargeId: Long,
productId: String, productId: String,
purchaseToken: String, purchaseToken: String
paymentGateway: PaymentGateway
): ChargeCompleteResponse { ): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(id = chargeId) val charge = chargeRepository.findByIdOrNull(id = chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.") ?: throw SodaException("결제정보에 오류가 있습니다.")
@@ -354,7 +629,7 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
@@ -436,4 +711,13 @@ class ChargeService(
throw SodaException("결제를 완료하지 못했습니다.") throw SodaException("결제를 완료하지 못했습니다.")
} }
} }
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
val cardCodes = setOf(
"041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381",
"218", "071", "002", "089", "045", "050", "048", "090", "092"
)
return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null
}
} }

View File

@@ -1,9 +1,10 @@
package kr.co.vividnext.sodalive.can.charge.temp package kr.co.vividnext.sodalive.can.charge.temp
import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeTempRequest( data class ChargeTempRequest(
val can: Int, val can: Int,
val price: Int, val price: BigDecimal,
val paymentGateway: PaymentGateway val paymentGateway: PaymentGateway
) )

View File

@@ -41,7 +41,7 @@ class ChargeTempService(
charge.member = member charge.member = member
val payment = Payment(paymentGateway = request.paymentGateway) val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = request.price.toDouble() payment.price = request.price
charge.payment = payment charge.payment = payment
chargeRepository.save(charge) chargeRepository.save(charge)
@@ -66,7 +66,7 @@ class ChargeTempService(
VerifyResult::class.java VerifyResult::class.java
) )
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) { if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) {
charge.payment?.receiptId = verifyResult.receiptId charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = verifyResult.method charge.payment?.method = verifyResult.method
charge.payment?.status = PaymentStatus.COMPLETE charge.payment?.status = PaymentStatus.COMPLETE
@@ -74,7 +74,7 @@ class ChargeTempService(
} else { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} catch (e: Exception) { } catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} else { } else {

View File

@@ -127,6 +127,7 @@ class CanPaymentService(
useCanRepository.save(useCan) useCanRepository.save(useCan)
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
setUseCanCalculate( setUseCanCalculate(
recipientId, recipientId,
useRewardCan, useRewardCan,
@@ -379,6 +380,7 @@ class CanPaymentService(
useCanRepository.save(useCan) useCanRepository.save(useCan)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
@@ -428,6 +430,7 @@ class CanPaymentService(
useCanRepository.save(useCan) useCanRepository.save(useCan)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.payment
import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType import javax.persistence.EnumType
@@ -25,7 +26,8 @@ data class Payment(
var receiptId: String? = null var receiptId: String? = null
var method: String? = null var method: String? = null
var price: Double = 0.toDouble() @Column(precision = 10, scale = 4, nullable = false)
var price: BigDecimal = 0.toBigDecimal()
var locale: String? = null var locale: String? = null
var orderId: String? = null var orderId: String? = null
} }

View File

@@ -1,5 +1,5 @@
package kr.co.vividnext.sodalive.can.payment package kr.co.vividnext.sodalive.can.payment
enum class PaymentGateway { enum class PaymentGateway {
PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
} }

View File

@@ -22,6 +22,8 @@ class ChatCharacter(
// 캐릭터 한 줄 소개 // 캐릭터 한 줄 소개
var description: String, var description: String,
var languageCode: String? = null,
// AI 시스템 프롬프트 // AI 시스템 프롬프트
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
var systemPrompt: String, var systemPrompt: String,

View File

@@ -16,6 +16,7 @@ import javax.persistence.Table
data class CharacterComment( data class CharacterComment(
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
var comment: String, var comment: String,
var languageCode: String?,
var isActive: Boolean = true var isActive: Boolean = true
) : BaseEntity() { ) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)

View File

@@ -47,7 +47,7 @@ class CharacterCommentController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val id = service.addReply(characterId, commentId, member, request.comment) val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
ApiResponse.ok(id) ApiResponse.ok(id)
} }

View File

@@ -2,7 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment
// Request DTOs // Request DTOs
data class CreateCharacterCommentRequest( data class CreateCharacterCommentRequest(
val comment: String val comment: String,
val languageCode: String? = null
) )
// Response DTOs // Response DTOs
@@ -20,7 +21,8 @@ data class CharacterCommentResponse(
val memberNickname: String, val memberNickname: String,
val createdAt: Long, val createdAt: Long,
val replyCount: Int, val replyCount: Int,
val comment: String val comment: String,
val languageCode: String?
) )
// 답글 Response 단건(목록 원소) // 답글 Response 단건(목록 원소)
@@ -35,7 +37,8 @@ data class CharacterReplyResponse(
val memberProfileImage: String, val memberProfileImage: String,
val memberNickname: String, val memberNickname: String,
val createdAt: Long, val createdAt: Long,
val comment: String val comment: String,
val languageCode: String?
) )
// 댓글의 답글 조회 Response 컨테이너 // 댓글의 답글 조회 Response 컨테이너

View File

@@ -2,7 +2,10 @@ package kr.co.vividnext.sodalive.chat.character.comment
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -12,7 +15,8 @@ import java.time.ZoneId
class CharacterCommentService( class CharacterCommentService(
private val chatCharacterRepository: ChatCharacterRepository, private val chatCharacterRepository: ChatCharacterRepository,
private val commentRepository: CharacterCommentRepository, private val commentRepository: CharacterCommentRepository,
private val reportRepository: CharacterCommentReportRepository private val reportRepository: CharacterCommentReportRepository,
private val applicationEventPublisher: ApplicationEventPublisher
) { ) {
private fun profileUrl(imageHost: String, profileImage: String?): String { private fun profileUrl(imageHost: String, profileImage: String?): String {
@@ -40,7 +44,8 @@ class CharacterCommentService(
memberNickname = member.nickname, memberNickname = member.nickname,
createdAt = toEpochMilli(entity.createdAt), createdAt = toEpochMilli(entity.createdAt),
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!), replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
comment = entity.comment comment = entity.comment,
languageCode = entity.languageCode
) )
} }
@@ -52,25 +57,44 @@ class CharacterCommentService(
memberProfileImage = profileUrl(imageHost, member.profileImage), memberProfileImage = profileUrl(imageHost, member.profileImage),
memberNickname = member.nickname, memberNickname = member.nickname,
createdAt = toEpochMilli(entity.createdAt), createdAt = toEpochMilli(entity.createdAt),
comment = entity.comment comment = entity.comment,
languageCode = entity.languageCode
) )
} }
@Transactional @Transactional
fun addComment(characterId: Long, member: Member, text: String): Long { fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val entity = CharacterComment(comment = text) val entity = CharacterComment(comment = text, languageCode = languageCode)
entity.chatCharacter = character entity.chatCharacter = character
entity.member = member entity.member = member
commentRepository.save(entity) commentRepository.save(entity)
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = entity.id!!,
query = text,
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
)
)
}
return entity.id!! return entity.id!!
} }
@Transactional @Transactional
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long { fun addReply(
characterId: Long,
parentCommentId: Long,
member: Member,
text: String,
languageCode: String? = null
): Long {
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
@@ -78,11 +102,23 @@ class CharacterCommentService(
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.") if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val entity = CharacterComment(comment = text) val entity = CharacterComment(comment = text, languageCode = languageCode)
entity.chatCharacter = character entity.chatCharacter = character
entity.member = member entity.member = member
entity.parent = parent entity.parent = parent
commentRepository.save(entity) commentRepository.save(entity)
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = entity.id!!,
query = text,
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
)
)
}
return entity.id!! return entity.id!!
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.controller package kr.co.vividnext.sodalive.chat.character.controller
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
@@ -10,11 +11,21 @@ import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
@@ -32,7 +43,12 @@ class ChatCharacterController(
private val bannerService: ChatCharacterBannerService, private val bannerService: ChatCharacterBannerService,
private val chatRoomService: ChatRoomService, private val chatRoomService: ChatRoomService,
private val characterCommentService: CharacterCommentService, private val characterCommentService: CharacterCommentService,
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService, private val curationQueryService: CharacterCurationQueryService,
private val translationService: PapagoTranslationService,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
@@ -65,6 +81,24 @@ class ChatCharacterController(
} }
} }
val characterIds = recentCharacters.map { it.characterId }
val translatedRecentCharacters = if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
.associateBy { it.characterId }
recentCharacters.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
if (translatedName.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName)
}
}
} else {
recentCharacters
}
// 인기 캐릭터 조회 // 인기 캐릭터 조회
val popularCharacters = service.getPopularCharacters() val popularCharacters = service.getPopularCharacters()
@@ -74,6 +108,13 @@ class ChatCharacterController(
size = 50 size = 50
).content ).content
// 추천 캐릭터 조회
// 최근 대화한 캐릭터를 제외한 랜덤 30개 조회
// Controller에서는 호출만
// 세부로직은 추후에 변경될 수 있으므로 Service에 별도로 생성
val excludeIds = recentCharacters.map { it.characterId }
val recommendCharacters = service.getRecommendCharacters(excludeIds, 30)
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
val curationSections = curationQueryService.getActiveCurationsWithCharacters() val curationSections = curationQueryService.getActiveCurationsWithCharacters()
.map { agg -> .map { agg ->
@@ -85,7 +126,8 @@ class ChatCharacterController(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
new = false
) )
} }
) )
@@ -95,9 +137,10 @@ class ChatCharacterController(
ApiResponse.ok( ApiResponse.ok(
CharacterMainResponse( CharacterMainResponse(
banners = banners, banners = banners,
recentCharacters = recentCharacters, recentCharacters = translatedRecentCharacters,
popularCharacters = popularCharacters, popularCharacters = getTranslatedAiCharacterList(popularCharacters),
newCharacters = newCharacters, newCharacters = getTranslatedAiCharacterList(newCharacters),
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
curationSections = curationSections curationSections = curationSections
) )
) )
@@ -139,6 +182,118 @@ class ChatCharacterController(
) )
} }
var translated: TranslatedAiCharacterDetail? = null
if (langContext.lang.code != character.languageCode) {
val existing = aiCharacterTranslationRepository
.findByCharacterIdAndLocale(character.id!!, langContext.lang.code)
if (existing != null) {
val payload = existing.renderedPayload
translated = TranslatedAiCharacterDetail(
name = payload.name,
description = payload.description,
gender = payload.gender,
personality = TranslatedAiCharacterPersonality(
trait = payload.personalityTrait,
description = payload.personalityDescription
).takeIf {
(it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
},
background = TranslatedAiCharacterBackground(
topic = payload.backgroundTopic,
description = payload.backgroundDescription
).takeIf {
(it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
},
tags = payload.tags
)
} else {
val texts = mutableListOf<String>()
texts.add(character.name)
texts.add(character.description)
texts.add(character.gender ?: "")
val hasPersonality = personality != null
if (hasPersonality) {
texts.add(personality!!.trait)
texts.add(personality.description)
}
val hasBackground = background != null
if (hasBackground) {
texts.add(background!!.topic)
texts.add(background.description)
}
texts.add(tags)
val sourceLanguage = character.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = langContext.lang.code
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedName = translatedTexts[index++]
val translatedDescription = translatedTexts[index++]
val translatedGender = translatedTexts[index++]
var translatedPersonality: TranslatedAiCharacterPersonality? = null
if (hasPersonality) {
translatedPersonality = TranslatedAiCharacterPersonality(
trait = translatedTexts[index++],
description = translatedTexts[index++]
)
}
var translatedBackground: TranslatedAiCharacterBackground? = null
if (hasBackground) {
translatedBackground = TranslatedAiCharacterBackground(
topic = translatedTexts[index++],
description = translatedTexts[index++]
)
}
val translatedTags = translatedTexts[index]
val payload = AiCharacterTranslationRenderedPayload(
name = translatedName,
description = translatedDescription,
gender = translatedGender,
personalityTrait = translatedPersonality?.trait ?: "",
personalityDescription = translatedPersonality?.description ?: "",
backgroundTopic = translatedBackground?.topic ?: "",
backgroundDescription = translatedBackground?.description ?: "",
tags = translatedTags
)
val entity = AiCharacterTranslation(
characterId = character.id!!,
locale = langContext.lang.code,
renderedPayload = payload
)
aiCharacterTranslationRepository.save(entity)
translated = TranslatedAiCharacterDetail(
name = translatedName,
description = translatedDescription,
gender = translatedGender,
personality = translatedPersonality,
background = translatedBackground,
tags = translatedTags
)
}
}
}
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외) // 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
val others = service.getOtherCharactersBySharedTags(characterId, 10) val others = service.getOtherCharactersBySharedTags(characterId, 10)
.map { other -> .map { other ->
@@ -153,6 +308,35 @@ class ChatCharacterController(
) )
} }
/**
* 다른 캐릭터 이름, 태그 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다
*/
val characterIds = others.map { it.characterId }
val translatedOthers = if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
.associateBy { it.characterId }
others.map { other ->
val payload = translations[other.characterId]?.renderedPayload
val translatedName = payload?.name
val translatedTags = payload?.tags
if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) {
other
} else {
other.copy(name = translatedName, tags = translatedTags)
}
}
} else {
others
}
// 최신 댓글 1개 조회 // 최신 댓글 1개 조회
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!) val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
@@ -162,6 +346,7 @@ class ChatCharacterController(
characterId = character.id!!, characterId = character.id!!,
name = character.name, name = character.name,
description = character.description, description = character.description,
languageCode = character.languageCode,
mbti = character.mbti, mbti = character.mbti,
gender = character.gender, gender = character.gender,
age = character.age, age = character.age,
@@ -172,9 +357,10 @@ class ChatCharacterController(
originalTitle = character.originalTitle, originalTitle = character.originalTitle,
originalLink = character.originalLink, originalLink = character.originalLink,
characterType = character.characterType, characterType = character.characterType,
others = others, others = translatedOthers,
latestComment = latestComment, latestComment = latestComment,
totalComments = characterCommentService.getTotalCommentCount(character.id!!) totalComments = characterCommentService.getTotalCommentCount(character.id!!),
translated = translated
) )
) )
} }
@@ -185,12 +371,80 @@ class ChatCharacterController(
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공 * - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
*/ */
@GetMapping("/recent") @GetMapping("/recent")
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run { fun getRecentCharacters(
ApiResponse.ok( @RequestParam("page", required = false) page: Int?
service.getRecentCharactersPage( ): ApiResponse<RecentCharactersResponse> = run {
val characterPage = service.getRecentCharactersPage(
page = page ?: 0, page = page ?: 0,
size = 20 size = 20
) )
val translatedCharacterPage = RecentCharactersResponse(
totalCount = characterPage.totalCount,
content = getTranslatedAiCharacterList(characterPage.content)
)
ApiResponse.ok(translatedCharacterPage)
}
/**
* 추천 캐릭터 새로고침 API
* - 최근 대화한 캐릭터를 제외하고 랜덤 20개 반환
* - 비회원 또는 본인인증되지 않은 경우: 최근 대화 목록 없음 → 전체 활성 캐릭터 중 랜덤 20개
*/
@GetMapping("/recommend")
fun getRecommendCharacters(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val recent = if (member == null || member.auth == null) {
emptyList()
} else {
chatRoomService
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
.map { it.characterId }
}
ApiResponse.ok(
getTranslatedAiCharacterList(
service.getRecommendCharacters(
recent,
20
)
)
) )
} }
/**
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
* 번역 데이터를 한 번에 조회한다.
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
*
* @param aiCharacterList 번역 대상 캐릭터 목록
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
*/
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
val characterIds = aiCharacterList.map { it.characterId }
return if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
.associateBy { it.characterId }
aiCharacterList.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
aiCharacterList
}
}
} }

View File

@@ -2,11 +2,13 @@ package kr.co.vividnext.sodalive.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.CharacterType import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
data class CharacterDetailResponse( data class CharacterDetailResponse(
val characterId: Long, val characterId: Long,
val name: String, val name: String,
val description: String, val description: String,
val languageCode: String?,
val mbti: String?, val mbti: String?,
val gender: String?, val gender: String?,
val age: Int?, val age: Int?,
@@ -19,7 +21,8 @@ data class CharacterDetailResponse(
val characterType: CharacterType, val characterType: CharacterType,
val others: List<OtherCharacter>, val others: List<OtherCharacter>,
val latestComment: CharacterCommentResponse?, val latestComment: CharacterCommentResponse?,
val totalComments: Int val totalComments: Int,
val translated: TranslatedAiCharacterDetail?
) )
data class OtherCharacter( data class OtherCharacter(

View File

@@ -7,6 +7,7 @@ data class CharacterMainResponse(
val recentCharacters: List<RecentCharacter>, val recentCharacters: List<RecentCharacter>,
val popularCharacters: List<Character>, val popularCharacters: List<Character>,
val newCharacters: List<Character>, val newCharacters: List<Character>,
val recommendCharacters: List<Character>,
val curationSections: List<CurationSection> val curationSections: List<CurationSection>
) )
@@ -20,7 +21,8 @@ data class Character(
@JsonProperty("characterId") val characterId: Long, @JsonProperty("characterId") val characterId: Long,
@JsonProperty("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("description") val description: String, @JsonProperty("description") val description: String,
@JsonProperty("imageUrl") val imageUrl: String @JsonProperty("imageUrl") val imageUrl: String,
@JsonProperty("isNew") val new: Boolean
) )
data class RecentCharacter( data class RecentCharacter(

View File

@@ -8,7 +8,9 @@ import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository @Repository
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository { interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
@@ -26,6 +28,21 @@ interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, Charac
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
) )
fun findMaxSortOrderByCharacterId(characterId: Long): Int fun findMaxSortOrderByCharacterId(characterId: Long): Int
@Query(
"""
select distinct c.id
from CharacterImage ci
join ci.chatCharacter c
where ci.isActive = true
and ci.createdAt >= :since
and c.id in :characterIds
"""
)
fun findCharacterIdsWithRecentImages(
@Param("characterIds") characterIds: List<Long>,
@Param("since") since: LocalDateTime
): List<Long>
} }
interface CharacterImageQueryRepository { interface CharacterImageQueryRepository {

View File

@@ -74,5 +74,29 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
pageable: Pageable pageable: Pageable
): List<ChatCharacter> ): List<ChatCharacter>
/**
* 활성 캐릭터 무작위 조회
*/
@Query(
"""
SELECT c FROM ChatCharacter c
WHERE c.isActive = true
ORDER BY function('RAND')
"""
)
fun findRandomActive(pageable: Pageable): List<ChatCharacter>
/**
* 제외할 캐릭터를 뺀 활성 캐릭터 무작위 조회
*/
@Query(
"""
SELECT c FROM ChatCharacter c
WHERE c.isActive = true AND c.id NOT IN :excludeIds
ORDER BY function('RAND')
"""
)
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter> fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
} }

View File

@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
@@ -34,10 +35,42 @@ class ChatCharacterService(
private val hobbyRepository: ChatCharacterHobbyRepository, private val hobbyRepository: ChatCharacterHobbyRepository,
private val goalRepository: ChatCharacterGoalRepository, private val goalRepository: ChatCharacterGoalRepository,
private val popularCharacterQuery: PopularCharacterQuery, private val popularCharacterQuery: PopularCharacterQuery,
private val imageRepository: CharacterImageRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
@Transactional(readOnly = true)
fun getRecommendCharacters(excludeCharacterIds: List<Long> = emptyList(), limit: Int = 20): List<Character> {
val safeLimit = if (limit <= 0) 20 else if (limit > 50) 50 else limit
val chars = if (excludeCharacterIds.isNotEmpty()) {
chatCharacterRepository.findRandomActiveExcluding(excludeCharacterIds, PageRequest.of(0, safeLimit))
} else {
chatCharacterRepository.findRandomActive(PageRequest.of(0, safeLimit))
}
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
return chars.map {
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
new = recentSet.contains(it.id)
)
}
}
/** /**
* UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회 * UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용 * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
@@ -51,12 +84,25 @@ class ChatCharacterService(
val window = RankingWindowCalculator.now("popular-character") val window = RankingWindowCalculator.now("popular-character")
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
val list = loadCharactersInOrder(topIds) val list = loadCharactersInOrder(topIds)
val recentSet = if (list.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
list.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
return list.map { return list.map {
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
new = recentSet.contains(it.id)
) )
} }
} }
@@ -91,15 +137,28 @@ class ChatCharacterService(
content = emptyList() content = emptyList()
) )
} }
val fallback = chatCharacterRepository.findByIsActiveTrue( val chars = chatCharacterRepository.findByIsActiveTrue(
PageRequest.of(0, 20, Sort.by("createdAt").descending()) PageRequest.of(0, 20, Sort.by("createdAt").descending())
).content
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
) )
val content = fallback.content.map { .toSet()
} else {
emptySet()
}
val content = chars.map {
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
new = recentSet.contains(it.id)
) )
} }
return RecentCharactersResponse( return RecentCharactersResponse(
@@ -108,16 +167,29 @@ class ChatCharacterService(
) )
} }
val pageResult = chatCharacterRepository.findRecentSince( val chars = chatCharacterRepository.findRecentSince(
since, since,
PageRequest.of(safePage, safeSize) PageRequest.of(safePage, safeSize)
).content
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
) )
val content = pageResult.content.map { .toSet()
} else {
emptySet()
}
val content = chars.map {
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
new = recentSet.contains(it.id)
) )
} }

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.chat.character.translate
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(columnNames = ["characterId", "locale"])
]
)
class AiCharacterTranslation(
val characterId: Long,
val locale: String,
@Column(columnDefinition = "json")
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
var renderedPayload: AiCharacterTranslationRenderedPayload
) : BaseEntity()
data class AiCharacterTranslationRenderedPayload(
val name: String,
val description: String,
val gender: String,
val personalityTrait: String,
val personalityDescription: String,
val backgroundTopic: String,
val backgroundDescription: String,
val tags: String
)
@Converter(autoApply = false)
class AiCharacterTranslationRenderedPayloadConverter :
AttributeConverter<AiCharacterTranslationRenderedPayload, String> {
override fun convertToDatabaseColumn(attribute: AiCharacterTranslationRenderedPayload?): String {
if (attribute == null) return "{}"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): AiCharacterTranslationRenderedPayload {
if (dbData.isNullOrBlank()) {
return AiCharacterTranslationRenderedPayload(
name = "",
description = "",
gender = "",
personalityTrait = "",
personalityDescription = "",
backgroundTopic = "",
backgroundDescription = "",
tags = ""
)
}
return objectMapper.readValue(dbData)
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}
data class TranslatedAiCharacterDetail(
val name: String?,
val description: String?,
val gender: String?,
val personality: TranslatedAiCharacterPersonality?,
val background: TranslatedAiCharacterBackground?,
val tags: String?
)
data class TranslatedAiCharacterPersonality(
val trait: String?,
val description: String?
)
data class TranslatedAiCharacterBackground(
val topic: String?,
val description: String?
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.chat.character.translate
import org.springframework.data.jpa.repository.JpaRepository
interface AiCharacterTranslationRepository : JpaRepository<AiCharacterTranslation, Long> {
fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation?
fun findByCharacterIdInAndLocale(characterIds: List<Long>, locale: String): List<AiCharacterTranslation>
}

View File

@@ -33,6 +33,10 @@ class OriginalWork(
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
var description: String = "", var description: String = "",
/** 언어 코드 */
@Column(nullable = true)
var languageCode: String? = null,
/** 원천 원작 */ /** 원천 원작 */
@Column(nullable = true) @Column(nullable = true)
var originalWork: String? = null, var originalWork: String? = null,

View File

@@ -1,12 +1,18 @@
package kr.co.vividnext.sodalive.chat.original.controller package kr.co.vividnext.sodalive.chat.original.controller
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException 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.member.Member
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -15,6 +21,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
/** /**
* 앱용 원작(오리지널 작품) 공개 API * 앱용 원작(오리지널 작품) 공개 API
@@ -25,6 +32,14 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/chat/original") @RequestMapping("/api/chat/original")
class OriginalWorkController( class OriginalWorkController(
private val queryService: OriginalWorkQueryService, private val queryService: OriginalWorkQueryService,
private val characterImageRepository: CharacterImageRepository,
private val langContext: LangContext,
private val originalWorkTranslationService: OriginalWorkTranslationService,
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
@@ -46,7 +61,57 @@ class OriginalWorkController(
val includeAdult = member?.auth != null val includeAdult = member?.auth != null
val pageRes = queryService.listForAppPage(includeAdult, page, size) val pageRes = queryService.listForAppPage(includeAdult, page, size)
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) } val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content))
/**
* 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 원작들의 originalWorkId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* originalWorkTranslationRepository 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목과 콘텐츠 타입이 존재하고 비어있지 않으면 title과 contentType을 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*/
val translatedContent = run {
if (content.isEmpty()) {
content
} else {
val ids = content.map { it.id }.toSet()
val locale = langContext.lang.code
val translations = originalWorkTranslationRepository
.findByOriginalWorkIdInAndLocale(ids, locale)
.associateBy { it.originalWorkId }
content.map { item ->
val payload = translations[item.id]?.renderedPayload
if (payload != null) {
val newTitle = payload.title.trim()
val newContentType = payload.contentType.trim()
val hasTitle = newTitle.isNotEmpty()
val hasContentType = newContentType.isNotEmpty()
if (hasTitle || hasContentType) {
item.copy(
title = if (hasTitle) newTitle else item.title,
contentType = if (hasContentType) newContentType else item.contentType
)
} else {
item
}
} else {
item
}
}
}
}
ApiResponse.ok(
OriginalWorkListResponse(
totalCount = pageRes.totalElements,
content = translatedContent
)
)
} }
/** /**
@@ -65,17 +130,70 @@ class OriginalWorkController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val ow = queryService.getOriginalWork(id) val ow = queryService.getOriginalWork(id)
val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20) val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
val characters = pageRes.content.map {
val recentSet = if (chars.isNotEmpty()) {
characterImageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
val translatedOriginal = originalWorkTranslationService.ensureTranslated(
originalWork = ow,
targetLocale = langContext.lang.code
)
/**
* 캐릭터 리스트의 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)를 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 콘텐츠들의 characterId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* AiCharacterTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)가 존재하고 비어있지 않으면 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*/
val translatedCharacters = run {
if (chars.isEmpty()) {
emptyList<Character>()
} else {
val ids = chars.mapNotNull { it.id }
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(ids, langContext.lang.code)
.associateBy { it.characterId }
chars.map<ChatCharacter, Character> {
val path = it.imagePath ?: "profile/default-profile.png" val path = it.imagePath ?: "profile/default-profile.png"
val tr = translations[it.id!!]?.renderedPayload
val newName = tr?.name?.trim().orEmpty()
val newDesc = tr?.description?.trim().orEmpty()
val hasName = newName.isNotEmpty()
val hasDesc = newDesc.isNotEmpty()
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = if (hasName) newName else it.name,
description = it.description, description = if (hasDesc) newDesc else it.description,
imageUrl = "$imageHost/$path" imageUrl = "$imageHost/$path",
new = recentSet.contains(it.id)
) )
} }
val response = OriginalWorkDetailResponse.from(ow, imageHost, characters) }
ApiResponse.ok(response) }
ApiResponse.ok(
OriginalWorkDetailResponse.from(
ow,
imageHost,
translatedCharacters,
translated = translatedOriginal
)
)
} }
} }

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.chat.original.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.original.OriginalWork import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
/** /**
* 앱용 원작 목록 아이템 응답 DTO * 앱용 원작 목록 아이템 응답 DTO
@@ -54,13 +55,15 @@ data class OriginalWorkDetailResponse(
@JsonProperty("studio") val studio: String?, @JsonProperty("studio") val studio: String?,
@JsonProperty("originalLinks") val originalLinks: List<String>, @JsonProperty("originalLinks") val originalLinks: List<String>,
@JsonProperty("tags") val tags: List<String>, @JsonProperty("tags") val tags: List<String>,
@JsonProperty("characters") val characters: List<Character> @JsonProperty("characters") val characters: List<Character>,
@JsonProperty("translated") val translated: TranslatedOriginalWork?
) { ) {
companion object { companion object {
fun from( fun from(
entity: OriginalWork, entity: OriginalWork,
imageHost: String = "", imageHost: String = "",
characters: List<Character> characters: List<Character>,
translated: TranslatedOriginalWork?
): OriginalWorkDetailResponse { ): OriginalWorkDetailResponse {
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}" "$imageHost/${entity.imagePath}"
@@ -80,7 +83,8 @@ data class OriginalWorkDetailResponse(
studio = entity.studio, studio = entity.studio,
originalLinks = entity.originalLinks.map { it.url }, originalLinks = entity.originalLinks.map { it.url },
tags = entity.tagMappings.map { it.tag.tag }, tags = entity.tagMappings.map { it.tag.tag },
characters = characters characters = characters,
translated = translated
) )
} }
} }

View File

@@ -59,7 +59,7 @@ class OriginalWorkQueryService(
val safePage = if (page < 0) 0 else page val safePage = if (page < 0) 0 else page
val safeSize = when { val safeSize = when {
size <= 0 -> 20 size <= 0 -> 20
size > 50 -> 50 size > 20 -> 20
else -> size else -> size
} }
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())

View File

@@ -0,0 +1,124 @@
package kr.co.vividnext.sodalive.chat.original.service
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class OriginalWorkTranslationService(
private val translationRepository: OriginalWorkTranslationRepository,
private val papagoTranslationService: PapagoTranslationService
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
* - 기존 번역이 있으면 그대로 사용
* - 없으면 파파고 번역 수행 후 저장
* - 실패/불필요 시 null 반환
*/
@Transactional
fun ensureTranslated(originalWork: OriginalWork, targetLocale: String): TranslatedOriginalWork? {
val source = originalWork.languageCode?.lowercase()
val target = targetLocale.lowercase()
if (source.isNullOrBlank() || source == target) {
return null
}
// 기존 번역 조회
val existed = translationRepository.findByOriginalWorkIdAndLocale(originalWork.id!!, target)
val existedPayload = existed?.renderedPayload
if (existedPayload != null) {
val t = existedPayload.title.trim()
val ct = existedPayload.contentType.trim()
val cat = existedPayload.category.trim()
val desc = existedPayload.description.trim()
val tags = existedPayload.tags
val hasAny = t.isNotEmpty() || ct.isNotEmpty() || cat.isNotEmpty() || desc.isNotEmpty() || tags.isNotEmpty()
if (hasAny) {
return TranslatedOriginalWork(
title = t,
contentType = ct,
category = cat,
description = desc,
tags = tags
)
}
}
// 파파고 번역 수행
return try {
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
val texts = buildList {
add(originalWork.title)
add(originalWork.contentType)
add(originalWork.category)
add(originalWork.description)
addAll(tags)
}
val response = papagoTranslationService.translate(
TranslateRequest(
texts = texts,
sourceLanguage = source,
targetLanguage = target
)
)
val out = response.translatedText
if (out.isEmpty()) return null
// 앞 4개는 필드, 나머지는 태그
val title = out.getOrNull(0)?.trim().orEmpty()
val contentType = out.getOrNull(1)?.trim().orEmpty()
val category = out.getOrNull(2)?.trim().orEmpty()
val description = out.getOrNull(3)?.trim().orEmpty()
val translatedTags = if (out.size > 4) {
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
} else {
emptyList()
}
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
if (!hasAny) return null
val payload = OriginalWorkTranslationPayload(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
val entity = existed?.apply { this.renderedPayload = payload }
?: OriginalWorkTranslation(
originalWorkId = originalWork.id!!,
locale = target,
renderedPayload = payload
)
translationRepository.save(entity)
TranslatedOriginalWork(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
} catch (e: Exception) {
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
null
}
}
}

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.chat.original.translation
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(columnNames = ["original_work_id", "locale"])
]
)
class OriginalWorkTranslation(
@Column(name = "original_work_id")
val originalWorkId: Long,
@Column(name = "locale")
val locale: String,
@Column(columnDefinition = "json")
@Convert(converter = OriginalWorkTranslationPayloadConverter::class)
var renderedPayload: OriginalWorkTranslationPayload
) : BaseEntity()
data class OriginalWorkTranslationPayload(
val title: String,
val contentType: String,
val category: String,
val description: String,
val tags: List<String>
)
data class TranslatedOriginalWork(
val title: String,
val contentType: String,
val category: String,
val description: String,
val tags: List<String>
)
@Converter(autoApply = false)
class OriginalWorkTranslationPayloadConverter : AttributeConverter<OriginalWorkTranslationPayload, String> {
override fun convertToDatabaseColumn(attribute: OriginalWorkTranslationPayload?): String {
if (attribute == null) return "{}"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): OriginalWorkTranslationPayload {
if (dbData.isNullOrBlank()) {
return OriginalWorkTranslationPayload(
title = "",
contentType = "",
category = "",
description = "",
tags = emptyList()
)
}
return try {
val node = objectMapper.readTree(dbData)
val title = node.get("title")?.asText() ?: ""
val contentType = node.get("contentType")?.asText() ?: ""
val category = node.get("category")?.asText() ?: ""
val description = node.get("description")?.asText() ?: ""
val tagsNode = node.get("tags")
val tags: List<String> = when {
tagsNode == null || tagsNode.isNull -> emptyList()
tagsNode.isArray -> tagsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() }
tagsNode.isTextual -> tagsNode.asText()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
else -> emptyList()
}
OriginalWorkTranslationPayload(
title = title,
contentType = contentType,
category = category,
description = description,
tags = tags
)
} catch (_: Exception) {
OriginalWorkTranslationPayload(
title = "",
contentType = "",
category = "",
description = "",
tags = emptyList()
)
}
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.chat.original.translation
import org.springframework.data.jpa.repository.JpaRepository
interface OriginalWorkTranslationRepository : JpaRepository<OriginalWorkTranslation, Long> {
fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation?
fun findByOriginalWorkIdInAndLocale(originalWorkIds: Set<Long>, locale: String): List<OriginalWorkTranslation>
}

View File

@@ -54,7 +54,7 @@ class ChatRoomQuotaController(
): ApiResponse<PurchaseRoomQuotaResponse> = run { ): ApiResponse<PurchaseRoomQuotaResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (req.container.isBlank()) throw SodaException("container를 확인해주세요.") if (req.container.isBlank()) throw SodaException("잘못된 접근입니다")
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException("채팅방을 찾을 수 없습니다.")
@@ -79,7 +79,7 @@ class ChatRoomQuotaController(
memberId = member.id!!, memberId = member.id!!,
chatRoomId = chatRoomId, chatRoomId = chatRoomId,
characterId = characterId, characterId = characterId,
addPaid = 40, addPaid = 12,
container = req.container container = req.container
) )

View File

@@ -126,13 +126,13 @@ class ChatRoomQuotaService(
memberId: Long, memberId: Long,
chatRoomId: Long, chatRoomId: Long,
characterId: Long, characterId: Long,
addPaid: Int = 40, addPaid: Int = 12,
container: String container: String
): RoomQuotaStatus { ): RoomQuotaStatus {
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록 // 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
canPaymentService.spendCan( canPaymentService.spendCan(
memberId = memberId, memberId = memberId,
needCan = 30, needCan = 10,
canUsage = CanUsage.CHAT_QUOTA_PURCHASE, canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
chatRoomId = chatRoomId, chatRoomId = chatRoomId,
characterId = characterId, characterId = characterId,

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.common
const val WAF_GEO_HEADER = "x-amzn-waf-geo-country"
enum class GeoCountry { KR, OTHER }
fun parseGeo(headerValue: String?): GeoCountry =
if (headerValue?.trim()?.uppercase() == "KR") GeoCountry.KR else GeoCountry.OTHER

View File

@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.common
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@Component
class GeoCountryFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val country = parseGeo(request.getHeader(WAF_GEO_HEADER))
request.setAttribute("geoCountry", country)
filterChain.doFilter(request, response)
}
}

View File

@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException import org.springframework.web.multipart.MaxUploadSizeExceededException
import org.springframework.web.server.ResponseStatusException
@RestControllerAdvice @RestControllerAdvice
class SodaExceptionHandler { class SodaExceptionHandler {
@@ -63,6 +64,7 @@ class SodaExceptionHandler {
@ExceptionHandler(Exception::class) @ExceptionHandler(Exception::class)
fun handleException(e: Exception) = run { fun handleException(e: Exception) = run {
if (e is ResponseStatusException) throw e
logger.error("API error", e) logger.error("API error", e)
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
} }

View File

@@ -83,6 +83,7 @@ class SecurityConfig(
.antMatchers("/api/home").permitAll() .antMatchers("/api/home").permitAll()
.antMatchers("/api/home/latest-content").permitAll() .antMatchers("/api/home/latest-content").permitAll()
.antMatchers("/api/home/day-of-week-series").permitAll() .antMatchers("/api/home/day-of-week-series").permitAll()
.antMatchers("/api/home/content-ranking").permitAll()
.antMatchers(HttpMethod.GET, "/api/live").permitAll() .antMatchers(HttpMethod.GET, "/api/live").permitAll()
.antMatchers(HttpMethod.GET, "/faq").permitAll() .antMatchers(HttpMethod.GET, "/faq").permitAll()
.antMatchers(HttpMethod.GET, "/faq/category").permitAll() .antMatchers(HttpMethod.GET, "/faq/category").permitAll()
@@ -96,6 +97,7 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
.and() .and()
.build() .build()

View File

@@ -1,11 +1,19 @@
package kr.co.vividnext.sodalive.configs package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.i18n.LangInterceptor
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration @Configuration
class WebConfig : WebMvcConfigurer { class WebConfig(
private val langInterceptor: LangInterceptor
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(langInterceptor).addPathPatterns("/**")
}
override fun addCorsMappings(registry: CorsRegistry) { override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**") registry.addMapping("/**")
.allowedOrigins( .allowedOrigins(

View File

@@ -23,7 +23,7 @@ enum class PurchaseOption {
} }
enum class SortType { enum class SortType {
NEWEST, PRICE_HIGH, PRICE_LOW NEWEST, PRICE_HIGH, PRICE_LOW, POPULARITY
} }
@Entity @Entity
@@ -32,6 +32,7 @@ data class AudioContent(
var title: String, var title: String,
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
var detail: String, var detail: String,
var languageCode: String?,
var playCount: Long = 0, var playCount: Long = 0,
var price: Int = 0, var price: Int = 0,
var releaseDate: LocalDateTime? = null, var releaseDate: LocalDateTime? = null,

View File

@@ -237,6 +237,33 @@ class AudioContentController(private val service: AudioContentService) {
ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member)) ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member))
} }
@GetMapping("/all")
fun getAllContents(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam("isFree", required = false) isFree: Boolean? = null,
@RequestParam("isPointAvailableOnly", required = false) isPointAvailableOnly: Boolean? = null,
@RequestParam("sort-type", required = false) sortType: SortType? = SortType.NEWEST,
@RequestParam("theme", required = false) theme: String? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(
service.getLatestContentByTheme(
theme = if (theme == null) listOf() else listOf(theme),
contentType = contentType ?: ContentType.ALL,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
sortType = sortType ?: SortType.NEWEST,
isFree = isFree ?: false,
isAdult = (isAdultContentVisible ?: true) && member.auth != null,
isPointAvailableOnly = isPointAvailableOnly ?: false
)
)
}
@GetMapping("/replay-live") @GetMapping("/replay-live")
fun replayLive( fun replayLive(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,

View File

@@ -109,7 +109,6 @@ interface AudioContentQueryRepository {
): Int ): Int
fun findByThemeFor2Weeks( fun findByThemeFor2Weeks(
isFree: Boolean = false,
cloudfrontHost: String, cloudfrontHost: String,
memberId: Long, memberId: Long,
theme: List<String> = emptyList(), theme: List<String> = emptyList(),
@@ -120,7 +119,6 @@ interface AudioContentQueryRepository {
): List<GetAudioContentMainItem> ): List<GetAudioContentMainItem>
fun totalCountNewContentFor2Weeks( fun totalCountNewContentFor2Weeks(
isFree: Boolean = false,
theme: List<String> = emptyList(), theme: List<String> = emptyList(),
memberId: Long, memberId: Long,
isAdult: Boolean, isAdult: Boolean,
@@ -182,8 +180,11 @@ interface AudioContentQueryRepository {
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
limit: Long, limit: Long,
sortType: SortType,
isFree: Boolean, isFree: Boolean,
isAdult: Boolean isAdult: Boolean,
orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false
): List<AudioContentMainItem> ): List<AudioContentMainItem>
fun findContentByCurationId( fun findContentByCurationId(
@@ -193,6 +194,11 @@ interface AudioContentQueryRepository {
offset: Long = 0, offset: Long = 0,
limit: Long = 20 limit: Long = 20
): List<GetAudioContentMainItem> ): List<GetAudioContentMainItem>
fun findLatestContentByCreatorId(
creatorId: Long,
isAdult: Boolean = false
): AudioContent?
} }
@Repository @Repository
@@ -236,6 +242,7 @@ class AudioContentQueryRepositoryImpl(
SortType.NEWEST -> audioContent.releaseDate.desc() SortType.NEWEST -> audioContent.releaseDate.desc()
SortType.PRICE_HIGH -> audioContent.price.desc() SortType.PRICE_HIGH -> audioContent.price.desc()
SortType.PRICE_LOW -> audioContent.price.asc() SortType.PRICE_LOW -> audioContent.price.asc()
SortType.POPULARITY -> audioContent.playCount.desc()
} }
var where = audioContent.member.id.eq(creatorId) var where = audioContent.member.id.eq(creatorId)
@@ -457,6 +464,12 @@ class AudioContentQueryRepositoryImpl(
audioContent.releaseDate.asc(), audioContent.releaseDate.asc(),
audioContent.id.asc() audioContent.id.asc()
) )
SortType.POPULARITY -> listOf(
audioContent.playCount.desc(),
audioContent.releaseDate.asc(),
audioContent.id.asc()
)
} }
var where = audioContent.isActive.isTrue var where = audioContent.isActive.isTrue
@@ -688,7 +701,6 @@ class AudioContentQueryRepositoryImpl(
} }
override fun totalCountNewContentFor2Weeks( override fun totalCountNewContentFor2Weeks(
isFree: Boolean,
theme: List<String>, theme: List<String>,
memberId: Long, memberId: Long,
isAdult: Boolean, isAdult: Boolean,
@@ -725,10 +737,6 @@ class AudioContentQueryRepositoryImpl(
where = where.and(audioContentTheme.theme.`in`(theme)) where = where.and(audioContentTheme.theme.`in`(theme))
} }
if (isFree) {
where = where.and(audioContent.price.loe(0))
}
return queryFactory return queryFactory
.select(audioContent.id) .select(audioContent.id)
.from(audioContent) .from(audioContent)
@@ -740,7 +748,6 @@ class AudioContentQueryRepositoryImpl(
} }
override fun findByThemeFor2Weeks( override fun findByThemeFor2Weeks(
isFree: Boolean,
cloudfrontHost: String, cloudfrontHost: String,
memberId: Long, memberId: Long,
theme: List<String>, theme: List<String>,
@@ -780,10 +787,6 @@ class AudioContentQueryRepositoryImpl(
where = where.and(audioContentTheme.theme.`in`(theme)) where = where.and(audioContentTheme.theme.`in`(theme))
} }
if (isFree) {
where = where.and(audioContent.price.loe(0))
}
return queryFactory return queryFactory
.select( .select(
QGetAudioContentMainItem( QGetAudioContentMainItem(
@@ -1302,8 +1305,11 @@ class AudioContentQueryRepositoryImpl(
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
limit: Long, limit: Long,
sortType: SortType,
isFree: Boolean, isFree: Boolean,
isAdult: Boolean isAdult: Boolean,
orderByRandom: Boolean,
isPointAvailableOnly: Boolean
): List<AudioContentMainItem> { ): List<AudioContentMainItem> {
var where = audioContent.isActive.isTrue var where = audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull) .and(audioContent.duration.isNotNull)
@@ -1338,6 +1344,31 @@ class AudioContentQueryRepositoryImpl(
where = where.and(audioContent.price.loe(0)) where = where.and(audioContent.price.loe(0))
} }
if (isPointAvailableOnly) {
where = where.and(audioContent.isPointAvailable.isTrue)
}
val orderBy = if (orderByRandom) {
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
} else {
when (sortType) {
SortType.NEWEST -> audioContent.releaseDate.desc()
SortType.PRICE_HIGH -> if (isFree) {
audioContent.releaseDate.desc()
} else {
audioContent.price.desc()
}
SortType.PRICE_LOW -> if (isFree) {
audioContent.releaseDate.asc()
} else {
audioContent.price.desc()
}
SortType.POPULARITY -> audioContent.playCount.desc()
}
}
return queryFactory return queryFactory
.select( .select(
QAudioContentMainItem( QAudioContentMainItem(
@@ -1355,7 +1386,7 @@ class AudioContentQueryRepositoryImpl(
.where(where) .where(where)
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
.orderBy(audioContent.id.desc()) .orderBy(orderBy)
.fetch() .fetch()
} }
@@ -1416,4 +1447,26 @@ class AudioContentQueryRepositoryImpl(
.limit(limit) .limit(limit)
.fetch() .fetch()
} }
override fun findLatestContentByCreatorId(
creatorId: Long,
isAdult: Boolean
): AudioContent? {
var where = audioContent.member.id.eq(creatorId)
.and(audioContent.isActive.isTrue)
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(LocalDateTime.now()))
if (!isAdult) {
where = where.and(audioContent.isAdult.isFalse)
}
return queryFactory
.selectFrom(audioContent)
.where(where)
.orderBy(audioContent.releaseDate.desc())
.limit(1)
.fetchFirst()
}
} }

View File

@@ -21,10 +21,20 @@ import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContent import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.pin.PinContentRepository import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
@@ -56,11 +66,18 @@ class AudioContentService(
private val audioContentLikeRepository: AudioContentLikeRepository, private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository, private val pinContentRepository: PinContentRepository,
private val translationService: PapagoTranslationService,
private val contentTranslationRepository: ContentTranslationRepository,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val audioContentCloudFront: AudioContentCloudFront, private val audioContentCloudFront: AudioContentCloudFront,
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
private val langContext: LangContext,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
@Value("\${cloud.aws.s3.content-bucket}") @Value("\${cloud.aws.s3.content-bucket}")
private val audioContentBucket: String, private val audioContentBucket: String,
@@ -160,6 +177,13 @@ class AudioContentService(
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList) audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
} }
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.contentId,
targetType = LanguageTranslationTargetType.CONTENT
)
)
} }
@Transactional @Transactional
@@ -238,6 +262,7 @@ class AudioContentService(
val audioContent = AudioContent( val audioContent = AudioContent(
title = request.title.trim(), title = request.title.trim(),
detail = request.detail.trim(), detail = request.detail.trim(),
languageCode = request.languageCode,
price = if (request.price > 0) { price = if (request.price > 0) {
request.price request.price
} else { } else {
@@ -331,6 +356,31 @@ class AudioContentService(
audioContent.content = contentPath audioContent.content = contentPath
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (audioContent.languageCode.isNullOrBlank()) {
val papagoQuery = listOf(
request.title.trim(),
request.detail.trim(),
request.tags.trim()
)
.filter { it.isNotBlank() }
.joinToString(" ")
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = audioContent.id!!,
query = papagoQuery
)
)
} else {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = audioContent.id!!,
targetType = LanguageTranslationTargetType.CONTENT
)
)
}
return CreateAudioContentResponse(contentId = audioContent.id!!) return CreateAudioContentResponse(contentId = audioContent.id!!)
} }
@@ -386,7 +436,7 @@ class AudioContentService(
// Check if the time difference is greater than 30 seconds (30000 milliseconds) // Check if the time difference is greater than 30 seconds (30000 milliseconds)
return date2.time - date1.time return date2.time - date1.time
} catch (e: Exception) { } catch (_: Exception) {
// Handle invalid time formats or parsing errors // Handle invalid time formats or parsing errors
return 0 return 0
} }
@@ -477,6 +527,7 @@ class AudioContentService(
} }
} }
@Transactional
fun getDetail( fun getDetail(
id: Long, id: Long,
member: Member, member: Member,
@@ -699,13 +750,108 @@ class AudioContentService(
listOf() listOf()
} }
var translated: TranslatedContent? = null
/**
* audioContent.languageCode != languageCode
*
* 번역 콘텐츠를 조회한다. - contentId, locale
* 번역 콘텐츠가 있으면
* TranslatedContent로 가공한다
*
* 번역 콘텐츠가 없으면
* 파파고 API를 통해 번역한 후 저장한다.
*
* 번역 대상: title, detail, tags
*
* 파파고로 번역한 데이터를 TranslatedContent로 가공한다
*/
if (
audioContent.languageCode != null &&
audioContent.languageCode!!.isNotBlank() &&
audioContent.languageCode != langContext.lang.code
) {
val existing = contentTranslationRepository
.findByContentIdAndLocale(audioContent.id!!, langContext.lang.code)
if (existing != null) {
val payload = existing.renderedPayload
translated = TranslatedContent(
title = payload.title,
detail = payload.detail,
tags = payload.tags
)
} else {
val texts = mutableListOf<String>()
texts.add(audioContent.title)
texts.add(audioContent.detail)
texts.add(tag)
val sourceLanguage = audioContent.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = langContext.lang.code
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedDetail = translatedTexts[index++]
val translatedTags = translatedTexts[index]
val payload = ContentTranslationPayload(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
contentTranslationRepository.save(
ContentTranslation(
contentId = audioContent.id!!,
locale = langContext.lang.code,
renderedPayload = payload
)
)
translated = TranslatedContent(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
}
}
}
/**
* themeStr 번역 처리
*/
val themeStrTranslated = run {
val theme = audioContent.theme
if (theme?.id != null) {
val locale = langContext.lang.code
val translated = contentThemeTranslationRepository
.findByContentThemeIdAndLocale(theme.id!!, locale)
val text = translated?.theme
if (!text.isNullOrBlank()) text else theme.theme
} else {
audioContent.theme!!.theme
}
}
return GetAudioContentDetailResponse( return GetAudioContentDetailResponse(
contentId = audioContent.id!!, contentId = audioContent.id!!,
title = audioContent.title, title = audioContent.title,
detail = contentDetail, detail = contentDetail,
languageCode = audioContent.languageCode,
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}", coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
contentUrl = audioContentUrl, contentUrl = audioContentUrl,
themeStr = audioContent.theme!!.theme, themeStr = themeStrTranslated,
tag = tag, tag = tag,
price = audioContent.price, price = audioContent.price,
duration = audioContent.duration ?: "", duration = audioContent.duration ?: "",
@@ -745,7 +891,51 @@ class AudioContentService(
previousContent = previousContent, previousContent = previousContent,
nextContent = nextContent, nextContent = nextContent,
buyerList = buyerList, buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable isAvailableUsePoint = audioContent.isPointAvailable,
translated = translated
)
}
fun getLatestCreatorAudioContent(
creatorId: Long,
member: Member,
isAdultContentVisible: Boolean
): GetAudioContentListItem? {
val isAdult = member.auth != null && isAdultContentVisible
val audioContent = repository.findLatestContentByCreatorId(creatorId, isAdult) ?: return null
val commentCount = commentRepository
.totalCountCommentByContentId(
audioContent.id!!,
memberId = member.id!!,
isContentCreator = creatorId == member.id!!
)
val likeCount = audioContentLikeRepository
.totalCountAudioContentLike(audioContent.id!!)
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
memberId = member.id!!,
contentId = audioContent.id!!
)
return GetAudioContentListItem(
contentId = audioContent.id!!,
coverImageUrl = "$coverImageHost/${audioContent.coverImage}",
title = audioContent.title,
price = audioContent.price,
themeStr = audioContent.theme!!.theme,
duration = audioContent.duration,
likeCount = likeCount,
commentCount = commentCount,
isPin = false,
isAdult = audioContent.isAdult,
isScheduledToOpen = audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now(),
isRented = isExistsAudioContent && orderType == OrderType.RENTAL,
isOwned = isExistsAudioContent && orderType == OrderType.KEEP,
isSoldOut = audioContent.remaining != null && audioContent.remaining!! <= 0,
isPointAvailable = audioContent.isPointAvailable
) )
} }
@@ -809,9 +999,27 @@ class AudioContentService(
it it
} }
val contentIds = items.map { it.contentId }
val translatedContentList = if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
items.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
items
}
return GetAudioContentListResponse( return GetAudioContentListResponse(
totalCount = totalCount, totalCount = totalCount,
items = items items = translatedContentList
) )
} }
@@ -945,16 +1153,108 @@ class AudioContentService(
contentType: ContentType, contentType: ContentType,
offset: Long = 0, offset: Long = 0,
limit: Long = 20, limit: Long = 20,
sortType: SortType = SortType.NEWEST,
isFree: Boolean = false, isFree: Boolean = false,
isAdult: Boolean = false isAdult: Boolean = false,
orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false
): List<AudioContentMainItem> { ): List<AudioContentMainItem> {
return repository.getLatestContentByTheme( /**
theme = theme, * - AS-IS theme은 한글만 처리하도록 되어 있음
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
*/
val normalizedTheme = normalizeThemeForQuery(
themes = theme,
contentType = contentType,
isFree = isFree,
isAdult = isAdult,
isPointAvailableOnly = isPointAvailableOnly
)
val contentList = repository.getLatestContentByTheme(
theme = normalizedTheme,
contentType = contentType, contentType = contentType,
offset = offset, offset = offset,
limit = limit, limit = limit,
sortType = sortType,
isFree = isFree, isFree = isFree,
isAdult = isAdult isAdult = isAdult,
orderByRandom = orderByRandom,
isPointAvailableOnly = isPointAvailableOnly
) )
val contentIds = contentList.map { it.contentId }
return if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentList
}
}
/**
* theme 파라미터로 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다.
* - 현재 언어(locale)에 해당하는 테마 번역 목록을 활성 테마 집합과 매칭하여 역매핑한다.
* - 입력이 이미 한글인 경우 그대로 유지한다.
* - 매칭 실패 시 원본 값을 유지한다.
*/
private fun normalizeThemeForQuery(
themes: List<String>,
contentType: ContentType,
isFree: Boolean,
isAdult: Boolean,
isPointAvailableOnly: Boolean
): List<String> {
if (themes.isEmpty()) return themes
val themesWithIds = themeQueryRepository.getActiveThemeWithIdsOfContent(
isAdult = isAdult,
isFree = isFree,
isPointAvailableOnly = isPointAvailableOnly,
contentType = contentType
)
if (themesWithIds.isEmpty()) return themes
val idByKorean = themesWithIds.associate { it.theme to it.id }
val koreanById = themesWithIds.associate { it.id to it.theme }
val locale = langContext.lang.code
// 번역 테마를 역매핑하기 위해 현재 locale의 번역 목록을 조회
val translatedByTextToId = run {
val ids = themesWithIds.map { it.id }
if (ids.isEmpty()) {
emptyMap()
} else {
contentThemeTranslationRepository
.findByContentThemeIdInAndLocale(ids, locale)
.associate { it.theme to it.contentThemeId }
}
}
return themes.asSequence()
.map { input ->
when {
idByKorean.containsKey(input) -> input // 이미 한글 원문
translatedByTextToId.containsKey(input) -> {
val id = translatedByTextToId[input]!!
koreanById[id] ?: input
}
else -> input
}
}
.distinct()
.toList()
} }
} }

View File

@@ -17,5 +17,6 @@ data class CreateAudioContentRequest(
val isCommentAvailable: Boolean = false, val isCommentAvailable: Boolean = false,
val isFullDetailVisible: Boolean = true, val isFullDetailVisible: Boolean = true,
val previewStartTime: String? = null, val previewStartTime: String? = null,
val previewEndTime: String? = null val previewEndTime: String? = null,
val languageCode: String? = null
) )

View File

@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.content
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
data class GetAudioContentDetailResponse( data class GetAudioContentDetailResponse(
val contentId: Long, val contentId: Long,
val title: String, val title: String,
val detail: String, val detail: String,
val languageCode: String?,
val coverImageUrl: String, val coverImageUrl: String,
val contentUrl: String, val contentUrl: String,
val themeStr: String, val themeStr: String,
@@ -39,7 +41,8 @@ data class GetAudioContentDetailResponse(
val previousContent: OtherContentResponse?, val previousContent: OtherContentResponse?,
val nextContent: OtherContentResponse?, val nextContent: OtherContentResponse?,
val buyerList: List<ContentBuyer>, val buyerList: List<ContentBuyer>,
val isAvailableUsePoint: Boolean val isAvailableUsePoint: Boolean,
val translated: TranslatedContent?
) )
data class OtherContentResponse @QueryProjection constructor( data class OtherContentResponse @QueryProjection constructor(

View File

@@ -0,0 +1,394 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestTemplate
/**
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
*/
enum class LanguageDetectTargetType {
CONTENT,
COMMENT,
CHARACTER,
CHARACTER_COMMENT,
CREATOR_CHEERS,
SERIES,
ORIGINAL_WORK
}
class LanguageDetectEvent(
val id: Long,
val query: String,
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
)
data class PapagoLanguageDetectResponse(
val langCode: String?
)
@Component
class LanguageDetectListener(
private val audioContentRepository: AudioContentRepository,
private val audioContentCommentRepository: AudioContentCommentRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val characterCommentRepository: CharacterCommentRepository,
private val creatorCheersRepository: CreatorCheersRepository,
private val seriesRepository: ContentSeriesRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.naver.papago-client-id}")
private val papagoClientId: String,
@Value("\${cloud.naver.papago-client-secret}")
private val papagoClientSecret: String
) {
private val log = LoggerFactory.getLogger(LanguageDetectListener::class.java)
private val restTemplate: RestTemplate = RestTemplate()
private val papagoDetectUrl = "https://papago.apigw.ntruss.com/langs/v1/dect"
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun detectLanguage(event: LanguageDetectEvent) {
if (event.query.isBlank()) {
log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. event={}", event)
return
}
when (event.targetType) {
LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event)
LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event)
LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event)
LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event)
LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event)
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
}
}
private fun handleCharacterLanguageDetect(event: LanguageDetectEvent) {
val characterId = event.id
val character = chatCharacterRepository.findById(characterId).orElse(null)
if (character == null) {
log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!character.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}",
characterId,
character.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
character.languageCode = langCode
chatCharacterRepository.save(character)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = characterId,
targetType = LanguageTranslationTargetType.CHARACTER
)
)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
characterId,
langCode
)
}
private fun handleContentLanguageDetect(event: LanguageDetectEvent) {
val contentId = event.id
val audioContent = audioContentRepository.findById(contentId).orElse(null)
if (audioContent == null) {
log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!audioContent.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}",
contentId,
audioContent.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
audioContent.languageCode = langCode
// REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다.
audioContentRepository.save(audioContent)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = contentId,
targetType = LanguageTranslationTargetType.CONTENT
)
)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}",
contentId,
langCode
)
}
private fun handleCommentLanguageDetect(event: LanguageDetectEvent) {
val commentId = event.id
val comment = audioContentCommentRepository.findById(commentId).orElse(null)
if (comment == null) {
log.warn("[PapagoLanguageDetect] AudioContentComment not found. commentId={}", commentId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!comment.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}",
commentId,
comment.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
comment.languageCode = langCode
audioContentCommentRepository.save(comment)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. commentId={}, langCode={}",
commentId,
langCode
)
}
private fun handleCharacterCommentLanguageDetect(event: LanguageDetectEvent) {
val commentId = event.id
val comment = characterCommentRepository.findById(commentId).orElse(null)
if (comment == null) {
log.warn("[PapagoLanguageDetect] CharacterComment not found. commentId={}", commentId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!comment.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. " +
"characterCommentId={}, languageCode={}",
commentId,
comment.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
comment.languageCode = langCode
characterCommentRepository.save(comment)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. characterCommentId={}, langCode={}",
commentId,
langCode
)
}
private fun handleCreatorCheersLanguageDetect(event: LanguageDetectEvent) {
val cheersId = event.id
val cheers = creatorCheersRepository.findById(cheersId).orElse(null)
if (cheers == null) {
log.warn("[PapagoLanguageDetect] CreatorCheers not found. cheersId={}", cheersId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!cheers.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}",
cheersId,
cheers.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
cheers.languageCode = langCode
creatorCheersRepository.save(cheers)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. cheersId={}, langCode={}",
cheersId,
langCode
)
}
private fun handleSeriesLanguageDetect(event: LanguageDetectEvent) {
val seriesId = event.id
val series = seriesRepository.findByIdOrNull(seriesId)
if (series == null) {
log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!series.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}",
seriesId,
series.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
series.languageCode = langCode
seriesRepository.save(series)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = seriesId,
targetType = LanguageTranslationTargetType.SERIES
)
)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}",
seriesId,
langCode
)
}
private fun handleOriginalWorkLanguageDetect(event: LanguageDetectEvent) {
val originalWorkId = event.id
val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId)
if (originalWork == null) {
log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!originalWork.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}",
originalWorkId,
originalWork.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
originalWork.languageCode = langCode
originalWorkRepository.save(originalWork)
// 언어 감지가 완료된 후 언어 번역 이벤트 호출
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = originalWorkId,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
)
)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}",
originalWorkId,
langCode
)
}
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
return try {
val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_FORM_URLENCODED
set("X-NCP-APIGW-API-KEY-ID", papagoClientId)
set("X-NCP-APIGW-API-KEY", papagoClientSecret)
}
val body = LinkedMultiValueMap<String, String>().apply {
// 파파고 스펙에 따라 query 파라미터에 텍스트를 공백으로 구분하여 전달
add("query", query)
}
val requestEntity = HttpEntity(body, headers)
val response = restTemplate.postForEntity(
papagoDetectUrl,
requestEntity,
PapagoLanguageDetectResponse::class.java
)
if (!response.statusCode.is2xxSuccessful) {
log.warn(
"[PapagoLanguageDetect] Non-success status from Papago. status={}, targetId={}",
response.statusCode,
targetIdForLog
)
return null
}
val langCode = response.body?.langCode?.takeIf { it.isNotBlank() }
if (langCode == null) {
log.warn(
"[PapagoLanguageDetect] langCode is null or blank in Papago response. targetId={}",
targetIdForLog
)
return null
}
langCode
} catch (ex: Exception) {
// 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다.
log.error(
"[PapagoLanguageDetect] Failed to detect language via Papago. targetId={}",
targetIdForLog,
ex
)
null
}
}
}

View File

@@ -16,6 +16,7 @@ import javax.persistence.Table
data class AudioContentComment( data class AudioContentComment(
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
var comment: String, var comment: String,
var languageCode: String?,
@Column(nullable = true) @Column(nullable = true)
var donationCan: Int? = null, var donationCan: Int? = null,
val isSecret: Boolean = false, val isSecret: Boolean = false,

View File

@@ -32,7 +32,8 @@ class AudioContentCommentController(
audioContentId = request.contentId, audioContentId = request.contentId,
parentId = request.parentId, parentId = request.parentId,
isSecret = request.isSecret, isSecret = request.isSecret,
member = member member = member,
languageCode = request.languageCode
) )
try { try {

View File

@@ -85,6 +85,7 @@ class AudioContentCommentQueryRepositoryImpl(
audioContentComment.member.nickname, audioContentComment.member.nickname,
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
audioContentComment.comment, audioContentComment.comment,
audioContentComment.languageCode,
audioContentComment.isSecret, audioContentComment.isSecret,
audioContentComment.donationCan.coalesce(0), audioContentComment.donationCan.coalesce(0),
formattedDate, formattedDate,
@@ -166,6 +167,7 @@ class AudioContentCommentQueryRepositoryImpl(
audioContentComment.member.nickname, audioContentComment.member.nickname,
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
audioContentComment.comment, audioContentComment.comment,
audioContentComment.languageCode,
audioContentComment.isSecret, audioContentComment.isSecret,
audioContentComment.donationCan.coalesce(0), audioContentComment.donationCan.coalesce(0),
formattedDate, formattedDate,

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.content.comment
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.FcmEventType
@@ -32,7 +34,8 @@ class AudioContentCommentService(
comment: String, comment: String,
audioContentId: Long, audioContentId: Long,
parentId: Long? = null, parentId: Long? = null,
isSecret: Boolean = false isSecret: Boolean = false,
languageCode: String?
): Long { ): Long {
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
@@ -50,7 +53,7 @@ class AudioContentCommentService(
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.") throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
} }
val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret) val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret)
audioContentComment.audioContent = audioContent audioContentComment.audioContent = audioContent
audioContentComment.member = member audioContentComment.member = member
@@ -85,6 +88,17 @@ class AudioContentCommentService(
) )
) )
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = savedContentComment.id!!,
query = comment,
targetType = LanguageDetectTargetType.COMMENT
)
)
}
return savedContentComment.id!! return savedContentComment.id!!
} }

View File

@@ -13,6 +13,7 @@ data class GetAudioContentCommentListItem @QueryProjection constructor(
val nickname: String, val nickname: String,
val profileUrl: String, val profileUrl: String,
val comment: String, val comment: String,
val languageCode: String?,
val isSecret: Boolean, val isSecret: Boolean,
val donationCan: Int, val donationCan: Int,
val date: String, val date: String,

View File

@@ -4,5 +4,6 @@ data class RegisterCommentRequest(
val comment: String, val comment: String,
val contentId: Long, val contentId: Long,
val parentId: Long?, val parentId: Long?,
val isSecret: Boolean = false val isSecret: Boolean = false,
val languageCode: String? = null
) )

View File

@@ -4,5 +4,6 @@ data class AudioContentDonationRequest(
val contentId: Long, val contentId: Long,
val donationCan: Int, val donationCan: Int,
val comment: String, val comment: String,
val container: String val container: String,
val languageCode: String? = null
) )

View File

@@ -4,9 +4,12 @@ import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -14,7 +17,8 @@ import org.springframework.transaction.annotation.Transactional
class AudioContentDonationService( class AudioContentDonationService(
private val canPaymentService: CanPaymentService, private val canPaymentService: CanPaymentService,
private val queryRepository: AudioContentRepository, private val queryRepository: AudioContentRepository,
private val commentRepository: AudioContentCommentRepository private val commentRepository: AudioContentCommentRepository,
private val applicationEventPublisher: ApplicationEventPublisher
) { ) {
@Transactional @Transactional
fun donation(request: AudioContentDonationRequest, member: Member) { fun donation(request: AudioContentDonationRequest, member: Member) {
@@ -34,10 +38,23 @@ class AudioContentDonationService(
val audioContentComment = AudioContentComment( val audioContentComment = AudioContentComment(
comment = request.comment, comment = request.comment,
languageCode = request.languageCode,
donationCan = request.donationCan donationCan = request.donationCan
) )
audioContentComment.audioContent = audioContent audioContentComment.audioContent = audioContent
audioContentComment.member = member audioContentComment.member = member
commentRepository.save(audioContentComment)
val savedComment = commentRepository.save(audioContentComment)
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (request.languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = savedComment.id!!,
query = request.comment,
targetType = LanguageDetectTargetType.COMMENT
)
)
}
} }
} }

View File

@@ -99,7 +99,6 @@ class AudioContentMainController(
@GetMapping("/new/all") @GetMapping("/new/all")
fun getNewContentAllByTheme( fun getNewContentAllByTheme(
@RequestParam("isFree", required = false) isFree: Boolean? = null,
@RequestParam("theme") theme: String, @RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null,
@@ -110,7 +109,6 @@ class AudioContentMainController(
ApiResponse.ok( ApiResponse.ok(
service.getNewContentFor2WeeksByTheme( service.getNewContentFor2WeeksByTheme(
isFree = isFree ?: false,
theme = theme, theme = theme,
isAdultContentVisible = isAdultContentVisible ?: true, isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL, contentType = contentType ?: ContentType.ALL,

View File

@@ -6,7 +6,11 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -20,24 +24,33 @@ class AudioContentMainService(
private val repository: AudioContentRepository, private val repository: AudioContentRepository,
private val blockMemberRepository: BlockMemberRepository, private val blockMemberRepository: BlockMemberRepository,
private val audioContentThemeRepository: AudioContentThemeQueryRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository,
private val audioContentThemeService: AudioContentThemeService,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> { fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType) /**
.filter { * 콘텐츠 테마 조회
it != "모닝콜" && *
it != "알람" && * - langContext에 따라 기본 한국어 데이터 혹은 번역된 콘텐츠 테마를 조회해야 함
it != "슬립콜" && *
it != "다시듣기" && * - 번역된 테마 데이터가 없다면 번역하여 반환
it != "ASMR" && * - 번역된 데이터가 있다면 번역된 데이터를 조회하여 반환
it != "릴레이" && */
it != "챌린지" && // 표시용 테마 목록은 언어 컨텍스트에 따라 번역된 값을 반환해야 한다.
it != "자기소개" // AudioContentThemeService가 번역/저장을 처리하므로 이를 사용한다.
} return audioContentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
contentType = contentType
)
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -64,42 +77,39 @@ class AudioContentMainService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getNewContentFor2WeeksByTheme( fun getNewContentFor2WeeksByTheme(
isFree: Boolean,
theme: String, theme: String,
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
contentType: ContentType, contentType: ContentType,
member: Member, member: Member,
pageable: Pageable pageable: Pageable
): GetNewContentAllResponse { ): GetNewContentAllResponse {
/**
* - AS-IS theme은 한글만 처리하도록 되어 있음
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
*/
val isAdult = member.auth != null && isAdultContentVisible val isAdult = member.auth != null && isAdultContentVisible
val themeList = if (theme.isBlank()) { val themeListRaw = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent( audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdult, isAdult = isAdult,
isFree = isFree,
contentType = contentType contentType = contentType
).filter { )
it != "모닝콜" &&
it != "알람" &&
it != "슬립콜" &&
it != "다시듣기" &&
it != "ASMR" &&
it != "릴레이" &&
it != "챌린지" &&
it != "자기소개"
}
} else { } else {
listOf(theme) listOf(theme)
} }
val themeList = normalizeThemeForQuery(
themes = themeListRaw,
contentType = contentType,
isAdult = isAdult
)
val totalCount = repository.totalCountNewContentFor2Weeks( val totalCount = repository.totalCountNewContentFor2Weeks(
isFree,
themeList, themeList,
memberId = member.id!!, memberId = member.id!!,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType
) )
val items = repository.findByThemeFor2Weeks( val contentList = repository.findByThemeFor2Weeks(
isFree,
cloudfrontHost = imageHost, cloudfrontHost = imageHost,
memberId = member.id!!, memberId = member.id!!,
theme = themeList, theme = themeList,
@@ -110,7 +120,75 @@ class AudioContentMainService(
) )
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
return GetNewContentAllResponse(totalCount, items) val contentIds = contentList.map { it.contentId }
val translatedContentList = if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentList
}
return GetNewContentAllResponse(totalCount, translatedContentList)
}
/**
* 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다.
*/
private fun normalizeThemeForQuery(
themes: List<String>,
contentType: ContentType,
isAdult: Boolean
): List<String> {
if (themes.isEmpty()) return themes
val themesWithIds = audioContentThemeRepository.getActiveThemeWithIdsOfContent(
isAdult = isAdult,
isFree = false,
isPointAvailableOnly = false,
contentType = contentType
)
if (themesWithIds.isEmpty()) return themes
val idByKorean = themesWithIds.associate { it.theme to it.id }
val koreanById = themesWithIds.associate { it.id to it.theme }
val locale = langContext.lang.code
val translatedByTextToId = run {
val ids = themesWithIds.map { it.id }
if (ids.isEmpty()) {
emptyMap()
} else {
contentThemeTranslationRepository
.findByContentThemeIdInAndLocale(ids, locale)
.associate { it.theme to it.contentThemeId }
}
}
return themes.asSequence()
.map { input ->
when {
idByKorean.containsKey(input) -> input
translatedByTextToId.containsKey(input) -> {
val id = translatedByTextToId[input]!!
koreanById[id] ?: input
}
else -> input
}
}
.distinct()
.toList()
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)

View File

@@ -58,6 +58,7 @@ class AudioContentCurationQueryRepository(private val queryFactory: JPAQueryFact
SortType.NEWEST -> audioContent.createdAt.desc() SortType.NEWEST -> audioContent.createdAt.desc()
SortType.PRICE_HIGH -> audioContent.price.desc() SortType.PRICE_HIGH -> audioContent.price.desc()
SortType.PRICE_LOW -> audioContent.price.asc() SortType.PRICE_LOW -> audioContent.price.asc()
SortType.POPULARITY -> audioContent.playCount.desc()
} }
var where = audioContent.isActive.isTrue var where = audioContent.isActive.isTrue

View File

@@ -187,6 +187,7 @@ class OrderQueryRepositoryImpl(
return queryFactory.select(order.id) return queryFactory.select(order.id)
.from(order) .from(order)
.where(where) .where(where)
.distinct()
.fetch() .fetch()
.size .size
} }

View File

@@ -18,7 +18,9 @@ import org.springframework.web.bind.annotation.RestController
class ContentSeriesController(private val service: ContentSeriesService) { class ContentSeriesController(private val service: ContentSeriesService) {
@GetMapping @GetMapping
fun getSeriesList( fun getSeriesList(
@RequestParam creatorId: Long, @RequestParam(required = false) creatorId: Long?,
@RequestParam(name = "isOriginal", required = false) isOriginal: Boolean? = null,
@RequestParam(name = "isCompleted", required = false) isCompleted: Boolean? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@@ -29,6 +31,8 @@ class ContentSeriesController(private val service: ContentSeriesService) {
ApiResponse.ok( ApiResponse.ok(
service.getSeriesList( service.getSeriesList(
creatorId = creatorId, creatorId = creatorId,
isOriginal = isOriginal ?: false,
isCompleted = isCompleted ?: false,
isAdultContentVisible = isAdultContentVisible ?: true, isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL, contentType = contentType ?: ContentType.ALL,
member = member, member = member,

View File

@@ -14,6 +14,7 @@ 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.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek 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.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
@@ -23,10 +24,35 @@ import org.springframework.data.jpa.repository.JpaRepository
interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository
interface ContentSeriesQueryRepository { interface ContentSeriesQueryRepository {
fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int fun getSeriesTotalCount(
creatorId: Long?,
isAuth: Boolean,
contentType: ContentType,
isOriginal: Boolean,
isCompleted: Boolean
): Int
fun getSeriesList( fun getSeriesList(
imageHost: String, imageHost: String,
creatorId: Long, creatorId: Long?,
isAuth: Boolean,
contentType: ContentType,
isOriginal: Boolean,
isCompleted: Boolean,
orderByRandom: Boolean,
offset: Long,
limit: Long
): List<Series>
fun getSeriesByGenreTotalCount(
genreId: Long,
isAuth: Boolean,
contentType: ContentType
): Int
fun getSeriesByGenreList(
imageHost: String,
genreId: Long,
isAuth: Boolean, isAuth: Boolean,
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
@@ -40,6 +66,7 @@ interface ContentSeriesQueryRepository {
fun getOriginalAudioDramaList( fun getOriginalAudioDramaList(
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
orderByRandom: Boolean = false,
offset: Long = 0, offset: Long = 0,
limit: Long = 20 limit: Long = 20
): List<Series> ): List<Series>
@@ -59,9 +86,26 @@ interface ContentSeriesQueryRepository {
class ContentSeriesQueryRepositoryImpl( class ContentSeriesQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory private val queryFactory: JPAQueryFactory
) : ContentSeriesQueryRepository { ) : ContentSeriesQueryRepository {
override fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int { override fun getSeriesTotalCount(
var where = series.member.id.eq(creatorId) creatorId: Long?,
.and(series.isActive.isTrue) isAuth: Boolean,
contentType: ContentType,
isOriginal: Boolean,
isCompleted: Boolean
): Int {
var where = series.isActive.isTrue
if (creatorId != null) {
where = where.and(series.member.id.eq(creatorId))
}
if (isOriginal) {
where = where.and(series.isOriginal.isTrue)
}
if (isCompleted) {
where = where.and(series.state.eq(SeriesState.COMPLETE))
}
if (!isAuth) { if (!isAuth) {
where = where.and(series.isAdult.isFalse) where = where.and(series.isAdult.isFalse)
@@ -92,14 +136,74 @@ class ContentSeriesQueryRepositoryImpl(
override fun getSeriesList( override fun getSeriesList(
imageHost: String, imageHost: String,
creatorId: Long, creatorId: Long?,
isAuth: Boolean, isAuth: Boolean,
contentType: ContentType, contentType: ContentType,
isOriginal: Boolean,
isCompleted: Boolean,
orderByRandom: Boolean,
offset: Long, offset: Long,
limit: Long limit: Long
): List<Series> { ): List<Series> {
var where = series.member.id.eq(creatorId) var where = series.isActive.isTrue
.and(series.isActive.isTrue)
if (creatorId != null) {
where = where.and(series.member.id.eq(creatorId))
}
if (isOriginal) {
where = where.and(series.isOriginal.isTrue)
}
if (isCompleted) {
where = where.and(series.state.eq(SeriesState.COMPLETE))
}
if (!isAuth) {
where = where.and(series.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(
series.member.isNull.or(
series.member.auth.gender.eq(
if (contentType == ContentType.MALE) {
0
} else {
1
}
)
)
)
}
}
val orderBy = if (orderByRandom) {
listOf(Expressions.numberTemplate(Double::class.java, "function('rand')").asc())
} else if (creatorId != null) {
listOf(series.orders.asc(), series.createdAt.asc())
} else {
listOf(audioContent.releaseDate.max().desc(), series.createdAt.asc())
}
return queryFactory
.selectFrom(series)
.innerJoin(series.member, member)
.innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id))
.innerJoin(seriesContent.content, audioContent)
.where(where)
.groupBy(series.id)
.orderBy(*orderBy.toTypedArray())
.offset(offset)
.limit(limit)
.fetch()
}
override fun getSeriesByGenreTotalCount(
genreId: Long,
isAuth: Boolean,
contentType: ContentType
): Int {
var where = series.isActive.isTrue
.and(series.genre.id.eq(genreId))
if (!isAuth) { if (!isAuth) {
where = where.and(series.isAdult.isFalse) where = where.and(series.isAdult.isFalse)
@@ -120,10 +224,57 @@ class ContentSeriesQueryRepositoryImpl(
} }
return queryFactory return queryFactory
.selectFrom(series) .select(series.id)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.innerJoin(seriesContent.content, audioContent)
.innerJoin(series.member, member) .innerJoin(series.member, member)
.innerJoin(series.genre, seriesGenre)
.where(where) .where(where)
.orderBy(series.orders.asc(), series.createdAt.asc()) .groupBy(series.id)
.fetch()
.size
}
override fun getSeriesByGenreList(
imageHost: String,
genreId: Long,
isAuth: Boolean,
contentType: ContentType,
offset: Long,
limit: Long
): List<Series> {
var where = series.isActive.isTrue
.and(series.genre.id.eq(genreId))
if (!isAuth) {
where = where.and(series.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(
series.member.isNull.or(
series.member.auth.gender.eq(
if (contentType == ContentType.MALE) {
0
} else {
1
}
)
)
)
}
}
return queryFactory
.select(series)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.innerJoin(seriesContent.content, audioContent)
.innerJoin(series.member, member)
.innerJoin(series.genre, seriesGenre)
.where(where)
.groupBy(series.id)
.orderBy(audioContent.releaseDate.max().desc(), series.createdAt.asc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
.fetch() .fetch()
@@ -216,6 +367,7 @@ class ContentSeriesQueryRepositoryImpl(
override fun getOriginalAudioDramaList( override fun getOriginalAudioDramaList(
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
orderByRandom: Boolean,
offset: Long, offset: Long,
limit: Long limit: Long
): List<Series> { ): List<Series> {
@@ -244,7 +396,13 @@ class ContentSeriesQueryRepositoryImpl(
.selectFrom(series) .selectFrom(series)
.innerJoin(series.member, member) .innerJoin(series.member, member)
.where(where) .where(where)
.orderBy(series.id.desc()) .orderBy(
if (orderByRandom) {
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
} else {
series.id.desc()
}
)
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
.fetch() .fetch()

View File

@@ -6,15 +6,26 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -27,6 +38,13 @@ class ContentSeriesService(
private val explorerQueryRepository: ExplorerQueryRepository, private val explorerQueryRepository: ExplorerQueryRepository,
private val seriesContentRepository: ContentSeriesContentRepository, private val seriesContentRepository: ContentSeriesContentRepository,
private val langContext: LangContext,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val translationService: PapagoTranslationService,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String private val coverImageHost: String
) { ) {
@@ -37,45 +55,151 @@ class ContentSeriesService(
fun getOriginalAudioDramaList( fun getOriginalAudioDramaList(
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
orderByRandom: Boolean = false,
offset: Long = 0, offset: Long = 0,
limit: Long = 20 limit: Long = 20
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, offset, limit) val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit)
return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType) return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType))
} }
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> { fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
return repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType) /**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
*/
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
val currentLang = langContext.lang
if (currentLang == Lang.EN || currentLang == Lang.JA) {
val targetLocale = currentLang.code
val ids = genres.map { it.id }
// 기존 번역 일괄 조회
val existing = if (ids.isNotEmpty()) {
// 메서드가 없을 경우 개별 조회로 대체되지만, 성능을 위해 우선 시도
try {
seriesGenreTranslationRepository
.findBySeriesGenreIdInAndLocale(ids, targetLocale)
} catch (_: Exception) {
// Spring Data 메서드 미존재 시 안전하게 개별 조회로 폴백
ids.mapNotNull { id ->
seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(id, targetLocale)
}
}
} else {
emptyList()
}
val existingMap = existing.associateBy { it.seriesGenreId }.toMutableMap()
// 미번역 항목 수집
val untranslated = genres.filter { existingMap[it.id] == null }
if (untranslated.isNotEmpty()) {
val texts = untranslated.map { it.genre }
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = "ko",
targetLanguage = targetLocale
)
)
val translatedTexts = response.translatedText
val toSave = mutableListOf<kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation>()
untranslated.forEachIndexed { index, item ->
val translated = translatedTexts.getOrNull(index) ?: item.genre
toSave.add(
kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation(
seriesGenreId = item.id,
locale = targetLocale,
genre = translated
)
)
}
if (toSave.isNotEmpty()) {
seriesGenreTranslationRepository.saveAll(toSave)
toSave.forEach { saved -> existingMap[saved.seriesGenreId] = saved }
}
}
// 원래 순서 보존하여 결과 조립
return genres.map { g ->
val translated = existingMap[g.id]?.genre ?: g.genre
GetSeriesGenreListResponse(id = g.id, genre = translated)
}
}
return genres
} }
fun getSeriesList( fun getSeriesList(
creatorId: Long, creatorId: Long?,
isOriginal: Boolean = false,
isCompleted: Boolean = false,
orderByRandom: Boolean = false,
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
contentType: ContentType, contentType: ContentType,
member: Member, member: Member,
offset: Long = 0, offset: Long = 0,
limit: Long = 10 limit: Long = 20
): GetSeriesListResponse { ): GetSeriesListResponse {
val isAuth = member.auth != null && isAdultContentVisible val isAuth = member.auth != null && isAdultContentVisible
val totalCount = repository.getSeriesTotalCount( val totalCount = repository.getSeriesTotalCount(
creatorId = creatorId, creatorId = creatorId,
isAuth = isAuth, isAuth = isAuth,
contentType = contentType contentType = contentType,
isOriginal = isOriginal,
isCompleted = isCompleted
) )
val rawItems = repository.getSeriesList( val rawItems = repository.getSeriesList(
imageHost = coverImageHost, imageHost = coverImageHost,
creatorId = creatorId, creatorId = creatorId,
isAuth = isAuth, isAuth = isAuth,
contentType = contentType, contentType = contentType,
isOriginal = isOriginal,
isCompleted = isCompleted,
orderByRandom = orderByRandom,
offset = offset,
limit = limit
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType)
return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
}
fun getSeriesListByGenre(
genreId: Long,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member,
offset: Long = 0,
limit: Long = 20
): GetSeriesListResponse {
val isAuth = member.auth != null && isAdultContentVisible
val totalCount = repository.getSeriesByGenreTotalCount(
genreId = genreId,
isAuth = isAuth,
contentType = contentType
)
val rawItems = repository.getSeriesByGenreList(
imageHost = coverImageHost,
genreId = genreId,
isAuth = isAuth,
contentType = contentType,
offset = offset, offset = offset,
limit = limit limit = limit
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType) val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType)
return GetSeriesListResponse(totalCount, items) return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
} }
@Transactional
fun getSeriesDetail( fun getSeriesDetail(
seriesId: Long, seriesId: Long,
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
@@ -117,7 +241,115 @@ class ContentSeriesService(
limit = 5 limit = 5
) )
/**
* series.languageCode != null && series.languageCode != languageCode
*
* 번역 시리즈를 조회한다. - series, locale
* 번역 콘텐츠가 있으면
* TranslatedSeries로 가공한다
*
* 번역 콘텐츠가 없으면
* 파파고 API를 통해 번역한 후 저장한다.
*
* 번역 대상: title, introduction, keywordList
*
* 파파고로 번역한 데이터를 TranslatedSeries 가공한다
*/
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
// 요청된 언어(locale)에 대한 시리즈 번역을 조회하거나, 없으면 동기 번역 후 저장한다.
var translated: TranslatedSeries? = null
run {
val locale = langContext.lang.code
val languageCode = series.languageCode
// 원본 언어가 존재하고, 요청 언어와 다를 때만 번역 처리
if (!languageCode.isNullOrBlank() && languageCode != locale) {
val existing = seriesTranslationRepository.findBySeriesIdAndLocale(seriesId = seriesId, locale = locale)
if (existing != null) {
val payload = existing.renderedPayload
val kws = payload.keywords.ifEmpty { keywordList }
translated = TranslatedSeries(
title = payload.title,
introduction = payload.introduction,
keywords = kws
)
} else {
val texts = mutableListOf<String>()
texts.add(series.title)
texts.add(series.introduction)
// 키워드는 개별 항목으로 번역 요청하여 N회 호출을 방지한다.
val keywordListForTranslate = keywordList
texts.addAll(keywordListForTranslate)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = languageCode,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedIntroduction = translatedTexts[index++]
val translatedKeywords = if (keywordListForTranslate.isNotEmpty()) {
translatedTexts.subList(index, translatedTexts.size)
} else {
// 번역할 키워드가 없으면 원본 키워드 반환 정책 적용
keywordList
}
val payload = SeriesTranslationPayload(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = translatedKeywords
)
seriesTranslationRepository.save(
SeriesTranslation(
seriesId = seriesId,
locale = locale,
renderedPayload = payload
)
)
val kws = translatedKeywords.ifEmpty { keywordList }
translated = TranslatedSeries(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = kws
)
}
}
}
}
// 장르 번역 조회 (있으면 반환)
val translatedGenre: String? = run {
val genreId = series.genre?.id
if (genreId != null) {
val locale = langContext.lang.code
val found = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(genreId, locale)
val text = found?.genre
if (!text.isNullOrBlank()) {
text
} else {
null
}
} else {
null
}
}
// publishedDateUtc는 ISO8601(Z 포함)로 반환
val publishedDateUtc = series.createdAt!!
.atZone(ZoneId.of("UTC"))
.toInstant()
.toString()
return GetSeriesDetailResponse( return GetSeriesDetailResponse(
seriesId = seriesId, seriesId = seriesId,
title = series.title, title = series.title,
@@ -132,6 +364,7 @@ class ContentSeriesService(
.withZoneSameInstant(ZoneId.of("Asia/Seoul")) .withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime() .toLocalDateTime()
.format(dateTimeFormatter), .format(dateTimeFormatter),
publishedDateUtc = publishedDateUtc,
creator = GetSeriesDetailResponse.GetSeriesDetailCreator( creator = GetSeriesDetailResponse.GetSeriesDetailCreator(
creatorId = series.member!!.id!!, creatorId = series.member!!.id!!,
nickname = series.member!!.nickname, nickname = series.member!!.nickname,
@@ -147,7 +380,9 @@ class ContentSeriesService(
keywordList = keywordList, keywordList = keywordList,
publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek), publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek),
contentList = seriesContentList.items, contentList = seriesContentList.items,
contentCount = seriesContentList.totalCount contentCount = seriesContentList.totalCount,
translated = translated,
translatedGenre = translatedGenre
) )
} }
@@ -189,7 +424,33 @@ class ContentSeriesService(
it it
} }
return GetSeriesContentListResponse(totalCount, contentList) /**
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*/
val contentIds = contentList.map { it.contentId }
val translatedItems = if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) item else item.copy(title = translatedTitle)
}
} else {
contentList
}
return GetSeriesContentListResponse(totalCount, translatedItems)
} }
fun getRecommendSeriesList( fun getRecommendSeriesList(
@@ -201,10 +462,16 @@ class ContentSeriesService(
val seriesList = repository.getRecommendSeriesList( val seriesList = repository.getRecommendSeriesList(
isAuth = isAuth, isAuth = isAuth,
contentType = contentType, contentType = contentType,
limit = 10 limit = 20
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType) return getTranslatedSeriesList(
seriesToSeriesListItem(
seriesList = seriesList,
isAdult = isAuth,
contentType = contentType
)
)
} }
fun fetchSeriesByCurationId( fun fetchSeriesByCurationId(
@@ -219,7 +486,7 @@ class ContentSeriesService(
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType
) )
return seriesToSeriesListItem(seriesList, isAdult, contentType) return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
} }
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@@ -249,7 +516,7 @@ class ContentSeriesService(
seriesList seriesList
} }
return seriesToSeriesListItem(seriesList, isAdult, contentType) return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
} }
private fun seriesToSeriesListItem( private fun seriesToSeriesListItem(
@@ -299,27 +566,105 @@ class ContentSeriesService(
} }
private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>): String { private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>): String {
/**
* i18n을 적용하여 언어별로 요일 표시를 변경한다.
*/
val lang = langContext.lang
val labelRandom = when (lang) {
Lang.EN -> "Random"
Lang.JA -> "ランダム"
else -> "랜덤"
}
val labels = when (lang) {
Lang.EN -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "Sun",
SeriesPublishedDaysOfWeek.MON to "Mon",
SeriesPublishedDaysOfWeek.TUE to "Tue",
SeriesPublishedDaysOfWeek.WED to "Wed",
SeriesPublishedDaysOfWeek.THU to "Thu",
SeriesPublishedDaysOfWeek.FRI to "Fri",
SeriesPublishedDaysOfWeek.SAT to "Sat",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
Lang.JA -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "",
SeriesPublishedDaysOfWeek.MON to "",
SeriesPublishedDaysOfWeek.TUE to "",
SeriesPublishedDaysOfWeek.WED to "",
SeriesPublishedDaysOfWeek.THU to "",
SeriesPublishedDaysOfWeek.FRI to "",
SeriesPublishedDaysOfWeek.SAT to "",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
else -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "",
SeriesPublishedDaysOfWeek.MON to "",
SeriesPublishedDaysOfWeek.TUE to "",
SeriesPublishedDaysOfWeek.WED to "",
SeriesPublishedDaysOfWeek.THU to "",
SeriesPublishedDaysOfWeek.FRI to "",
SeriesPublishedDaysOfWeek.SAT to "",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
}
val dayOfWeekText = publishedDaysOfWeek.toList().sortedBy { it.ordinal } val dayOfWeekText = publishedDaysOfWeek.toList().sortedBy { it.ordinal }
.map { .map { labels[it] ?: it.name }
when (it) {
SeriesPublishedDaysOfWeek.SUN -> ""
SeriesPublishedDaysOfWeek.MON -> ""
SeriesPublishedDaysOfWeek.TUE -> ""
SeriesPublishedDaysOfWeek.WED -> ""
SeriesPublishedDaysOfWeek.THU -> ""
SeriesPublishedDaysOfWeek.FRI -> ""
SeriesPublishedDaysOfWeek.SAT -> ""
SeriesPublishedDaysOfWeek.RANDOM -> "랜덤"
}
}
.joinToString(", ") { it } .joinToString(", ") { it }
return if (publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)) { val containsRandom = publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)
return if (containsRandom) {
dayOfWeekText dayOfWeekText
} else if (publishedDaysOfWeek.size < 7) { } else if (publishedDaysOfWeek.size < 7) {
"매주 $dayOfWeekText" when (lang) {
Lang.EN -> "Every $dayOfWeekText"
Lang.JA -> "毎週 $dayOfWeekText"
else -> "매주 $dayOfWeekText"
}
} else { } else {
"매일" when (lang) {
Lang.EN -> "Daily"
Lang.JA -> "毎日"
else -> "매일"
}
}
}
/**
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
if (seriesIds.isEmpty()) return seriesList
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
return seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
} }
} }
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.content.series package kr.co.vividnext.sodalive.content.series
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListItem import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListItem
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
data class GetSeriesDetailResponse( data class GetSeriesDetailResponse(
val seriesId: Long, val seriesId: Long,
@@ -12,6 +13,7 @@ data class GetSeriesDetailResponse(
val writer: String?, val writer: String?,
val studio: String?, val studio: String?,
val publishedDate: String, val publishedDate: String,
val publishedDateUtc: String,
val creator: GetSeriesDetailCreator, val creator: GetSeriesDetailCreator,
var rentalMinPrice: Int, var rentalMinPrice: Int,
var rentalMaxPrice: Int, var rentalMaxPrice: Int,
@@ -21,7 +23,9 @@ data class GetSeriesDetailResponse(
val keywordList: List<String>, val keywordList: List<String>,
val publishedDaysOfWeek: String, val publishedDaysOfWeek: String,
val contentList: List<GetSeriesContentListItem>, val contentList: List<GetSeriesContentListItem>,
val contentCount: Int val contentCount: Int,
val translated: TranslatedSeries?,
val translatedGenre: String?
) { ) {
data class GetSeriesDetailCreator( data class GetSeriesDetailCreator(
val creatorId: Long, val creatorId: Long,

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.content.series.main
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
data class SeriesHomeResponse(
val banners: List<SeriesBannerResponse>,
val completedSeriesList: List<GetSeriesListResponse.SeriesListItem>,
val recommendSeriesList: List<GetSeriesListResponse.SeriesListItem>
)

View File

@@ -0,0 +1,151 @@
package kr.co.vividnext.sodalive.content.series.main
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/audio-content/series/main")
class SeriesMainController(
private val contentSeriesService: ContentSeriesService,
private val bannerService: ContentSeriesBannerService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@GetMapping
fun fetchData(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
.content
.map {
SeriesBannerResponse.from(it, imageHost)
}
val completedSeriesList = contentSeriesService.getSeriesList(
creatorId = null,
isCompleted = true,
orderByRandom = true,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
).items
val recommendSeriesList = contentSeriesService.getRecommendSeriesList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
ApiResponse.ok(
SeriesHomeResponse(
banners = banners,
completedSeriesList = completedSeriesList,
recommendSeriesList = recommendSeriesList
)
)
}
@GetMapping("/recommend")
fun getRecommendSeriesList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(
contentSeriesService.getRecommendSeriesList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
)
)
}
@GetMapping("/day-of-week")
fun getDayOfWeekSeriesList(
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val pageable = PageRequest.of(page, size)
ApiResponse.ok(
contentSeriesService.getDayOfWeekSeriesList(
memberId = member.id,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
dayOfWeek = dayOfWeek,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
@GetMapping("/genre-list")
fun getGenreList(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val memberId = member.id!!
val isAdult = member.auth != null && (isAdultContentVisible ?: true)
ApiResponse.ok(
contentSeriesService.getGenreList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType ?: ContentType.ALL
)
)
}
@GetMapping("/list-by-genre")
fun getSeriesListByGenre(
@RequestParam("genreId") genreId: Long,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val pageable = PageRequest.of(page, size)
ApiResponse.ok(
contentSeriesService.getSeriesListByGenre(
genreId = genreId,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
}

View File

@@ -0,0 +1,80 @@
package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class ContentSeriesBannerService(
private val bannerRepository: SeriesBannerRepository,
private val seriesRepository: AdminContentSeriesRepository
) {
fun getActiveBanners(pageable: Pageable): Page<SeriesBanner> {
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
}
fun getBannerById(bannerId: Long): SeriesBanner {
return bannerRepository.findById(bannerId)
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
}
@Transactional
fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner {
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId")
val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1
val banner = SeriesBanner(
imagePath = imagePath,
series = series,
sortOrder = finalSortOrder
)
return bannerRepository.save(banner)
}
@Transactional
fun updateBanner(
bannerId: Long,
imagePath: String? = null,
seriesId: Long? = null
): SeriesBanner {
val banner = bannerRepository.findById(bannerId)
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId")
if (imagePath != null) banner.imagePath = imagePath
if (seriesId != null) {
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId")
banner.series = series
}
return bannerRepository.save(banner)
}
@Transactional
fun deleteBanner(bannerId: Long) {
val banner = bannerRepository.findById(bannerId)
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
banner.isActive = false
bannerRepository.save(banner)
}
@Transactional
fun updateBannerOrders(ids: List<Long>): List<SeriesBanner> {
val updated = mutableListOf<SeriesBanner>()
for (index in ids.indices) {
val banner = bannerRepository.findById(ids[index])
.orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") }
if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}")
banner.sortOrder = index + 1
updated.add(bannerRepository.save(banner))
}
return updated
}
}

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* 시리즈 배너 엔티티
* 이미지와 시리즈 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다.
* 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다.
*/
@Entity
class SeriesBanner(
// 배너 이미지 경로
var imagePath: String? = null,
// 연관된 캐릭터
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "series_id")
var series: Series,
// 정렬 순서 (낮을수록 먼저 표시)
var sortOrder: Int = 0,
// 활성화 여부 (소프트 삭제용)
var isActive: Boolean = true
) : BaseEntity()

Some files were not shown because too many files have changed in this diff Show More