Compare commits

...

141 Commits

Author SHA1 Message Date
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
128 changed files with 4233 additions and 316 deletions

View File

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

View File

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

View File

@@ -1,6 +1,38 @@
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.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.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.CanStatus
import kr.co.vividnext.sodalive.extensions.moneyFormat
import java.math.BigDecimal
data class AdminCanRequest(
val can: Int,
val rewardCan: Int,
val price: Int
val price: BigDecimal,
val currency: String
) {
fun toEntity(): Can {
var title = "${can.moneyFormat()}"
@@ -20,6 +22,7 @@ data class AdminCanRequest(
can = can,
rewardCan = rewardCan,
price = price,
currency = currency,
status = CanStatus.SALE
)
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.admin.can
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.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
@@ -20,6 +21,10 @@ class AdminCanService(
private val chargeRepository: ChargeRepository,
private val memberRepository: AdminMemberRepository
) {
fun getCans(): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE)
}
@Transactional
fun saveCan(request: AdminCanRequest) {
repository.save(request.toEntity())
@@ -35,12 +40,16 @@ class AdminCanService(
@Transactional
fun charge(request: AdminCanChargeRequest) {
val member = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 회원번호 입니다.")
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
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)
charge.title = "${request.can.moneyFormat()}"
charge.member = member
@@ -53,4 +62,5 @@ class AdminCanService(
member.pgRewardCan += charge.rewardCan
}
}
}

View File

@@ -21,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
@GetMapping("/detail")
fun getChargeStatusDetail(
@RequestParam startDateStr: String,
@RequestParam paymentGateway: PaymentGateway
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
@RequestParam paymentGateway: 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
import com.querydsl.core.BooleanBuilder
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.QCan.can1
@@ -14,7 +15,7 @@ import java.time.LocalDateTime
@Repository
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(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
@@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
),
"%Y-%m-%d"
)
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
return queryFactory
.select(
QGetChargeStatusQueryDto(
QGetChargeStatusResponse(
formattedDate,
payment.price.sum(),
can1.price.sum(),
payment.id.count(),
payment.paymentGateway
payment.paymentGateway.stringValue(),
currency.coalesce("KRW")
)
)
.from(payment)
@@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.groupBy(formattedDate, payment.paymentGateway)
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
.orderBy(formattedDate.desc())
.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(
startDate: LocalDateTime,
endDate: LocalDateTime,
paymentGateway: PaymentGateway
paymentGateway: PaymentGateway,
currency: String? = null
): List<GetChargeStatusDetailQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
@@ -67,6 +100,20 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
),
"%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
.select(
@@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
member.nickname,
payment.method.coalesce(""),
payment.price,
can1.price,
payment.locale.coalesce(""),
currencyExpr,
formattedDate
)
)
@@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.innerJoin(charge.member, member)
.innerJoin(charge.payment, payment)
.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))
.and(payment.paymentGateway.eq(paymentGateway))
)
.where(whereBuilder)
.orderBy(formattedDate.desc())
.fetch()
}

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge
import java.math.BigDecimal
data class GetChargeStatusDetailResponse(
val memberId: Long,
val nickname: String,
val method: String,
val amount: Int,
val amount: BigDecimal,
val locale: 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
data class GetChargeStatusResponse(
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetChargeStatusResponse @QueryProjection constructor(
val date: String,
val chargeAmount: Int,
val chargeAmount: BigDecimal,
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.common.ApiResponse
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 org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
@@ -40,6 +45,7 @@ class AdminChatCharacterController(
private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${weraser.api-key}")
private val apiKey: String,
@@ -165,6 +171,18 @@ class AdminChatCharacterController(
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)
}
@@ -315,6 +333,13 @@ class AdminChatCharacterController(
request = request
)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.id,
targetType = LanguageTranslationTargetType.CHARACTER
)
)
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
// 서비스에서 유효성 검증 및 저장까지 처리

View File

@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
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.RequestParam
import org.springframework.web.bind.annotation.RestController
@@ -19,4 +21,9 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
fun searchSeriesList(
@RequestParam(value = "search_word") searchWord: String
) = 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
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.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
class AdminContentSeriesService(
private val repository: AdminContentSeriesRepository,
private val genreRepository: AdminContentSeriesGenreRepository
) {
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
val totalCount = repository.getSeriesTotalCount()
val items = repository.getSeriesList(
@@ -12,10 +19,53 @@ class AdminContentSeriesService(private val repository: AdminContentSeriesReposi
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)
}
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
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
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
data class GetAdminSeriesListResponse(
val totalCount: Int,
@@ -17,7 +18,10 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
val numberOfWorks: Long,
val state: String,
val isAdult: Boolean
)
) {
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
var isOriginal: Boolean = false
}
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
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 {
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
}
class AdminContentSeriesGenreQueryRepositoryImpl(
@@ -21,4 +22,14 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
.orderBy(seriesGenre.orders.asc())
.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.content.theme.AudioContentTheme
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 org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -18,6 +21,8 @@ class AdminContentThemeService(
private val objectMapper: ObjectMapper,
private val repository: AdminContentThemeRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@@ -37,7 +42,14 @@ class AdminContentThemeService(
}
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) {

View File

@@ -36,6 +36,12 @@ class AdminMemberController(private val service: AdminMemberService) {
pageable: 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")
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())

View File

@@ -16,6 +16,7 @@ interface AdminMemberQueryRepository {
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
fun findByIdAndActive(memberId: Long): Member?
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
}
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
@@ -121,4 +122,22 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
.orderBy(member.id.desc())
.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()
}
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
fun resetPassword(request: ResetPasswordRequest) {
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.DateTimePath
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.NumberExpression
import com.querydsl.core.types.dsl.StringTemplate
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
@@ -67,7 +66,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val firstPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
.then(adTrackingHistory.price)
.otherwise(Expressions.constant(0.0))
.otherwise(0.toBigDecimal())
.sum()
val repeatPaymentCount = CaseBuilder()
@@ -79,7 +78,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val repeatPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
.then(adTrackingHistory.price)
.otherwise(Expressions.constant(0.0))
.otherwise(0.toBigDecimal())
.sum()
val allPaymentCount = CaseBuilder()
@@ -97,7 +96,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
)
.then(adTrackingHistory.price)
.otherwise(Expressions.constant(0.0))
.otherwise(0.toBigDecimal())
.sum()
return queryFactory
@@ -111,11 +110,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
loginCount,
signUpCount,
firstPaymentCount,
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
firstPaymentTotalAmount,
repeatPaymentCount,
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
repeatPaymentTotalAmount,
allPaymentCount,
roundedValueDecimalPlaces2(allPaymentTotalAmount)
allPaymentTotalAmount
)
)
.from(adTrackingHistory)
@@ -148,13 +147,4 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
"%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
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetAdminAdStatisticsResponse(
val totalCount: Int,
@@ -16,9 +17,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
val loginCount: Int,
val signUpCount: Int,
val firstPaymentCount: Int,
val firstPaymentTotalAmount: Double,
val firstPaymentTotalAmount: BigDecimal,
val repeatPaymentCount: Int,
val repeatPaymentTotalAmount: Double,
val repeatPaymentTotalAmount: BigDecimal,
val allPaymentCount: Int,
val allPaymentTotalAmount: Double
val allPaymentTotalAmount: BigDecimal
)

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.api.home
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.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
@@ -21,8 +22,11 @@ data class GetHomeResponse(
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
val auditionList: List<GetAuditionListItem>,
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
val popularCharacters: List<Character>,
val contentRanking: List<GetAudioContentRankingItem>,
val recommendChannelList: List<RecommendChannelResponse>,
val freeContentList: List<AudioContentMainItem>,
val pointAvailableContentList: List<AudioContentMainItem>,
val recommendContentList: List<AudioContentMainItem>,
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.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
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,29 @@
package kr.co.vividnext.sodalive.api.home
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.AudioContentService
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.curation.AudioContentCurationService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
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.event.GetEventResponse
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.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import kr.co.vividnext.sodalive.rank.RankingRepository
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.beans.factory.annotation.Value
@@ -39,13 +46,24 @@ class HomeService(
private val contentThemeService: AudioContentThemeService,
private val recommendChannelService: RecommendChannelQueryService,
private val characterService: ChatCharacterService,
private val rankingService: RankingService,
private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
companion object {
private const val RECOMMEND_TARGET_SIZE = 30
private const val RECOMMEND_MAX_ATTEMPTS = 3
}
fun fetchData(
timezone: String,
isAdultContentVisible: Boolean,
@@ -102,6 +120,8 @@ class HomeService(
}
}
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
val eventBannerList = GetEventResponse(
totalCount = 0,
eventList = emptyList()
@@ -115,7 +135,8 @@ class HomeService(
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult,
contentType = contentType
contentType = contentType,
orderByRandom = true
)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
@@ -127,6 +148,9 @@ class HomeService(
dayOfWeek = getDayOfWeekByTimezone(timezone)
)
// 인기 캐릭터 조회
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
@@ -143,10 +167,26 @@ class HomeService(
contentType = contentType,
startDate = startDate.minusDays(1),
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(
memberId = memberId,
@@ -154,6 +194,40 @@ class HomeService(
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(
theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
@@ -162,7 +236,8 @@ class HomeService(
),
contentType = contentType,
isFree = true,
isAdult = isAdult
isAdult = isAdult,
orderByRandom = true
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
@@ -171,6 +246,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(
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
isAdult = isAdult,
@@ -182,15 +277,22 @@ class HomeService(
liveList = liveList,
creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList,
latestContentList = latestContentList,
latestContentList = translatedLatestContentList,
bannerList = bannerList,
eventBannerList = eventBannerList,
originalAudioDramaList = originalAudioDramaList,
auditionList = auditionList,
dayOfWeekSeriesList = dayOfWeekSeriesList,
contentRanking = contentRanking,
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
popularCharacters = translatedPopularCharacters,
contentRanking = translatedContentRanking,
recommendChannelList = translatedRecommendChannelList,
freeContentList = translatedFreeContentList,
pointAvailableContentList = translatedPointAvailableContentList,
recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member = member
),
curationList = curationList
)
}
@@ -214,7 +316,7 @@ class HomeService(
listOf(theme)
}
return contentService.getLatestContentByTheme(
val contentList = contentService.getLatestContentByTheme(
theme = themeList,
contentType = contentType,
isFree = false,
@@ -226,6 +328,8 @@ class HomeService(
true
}
}
return getTranslatedContentList(contentList = contentList)
}
fun getDayOfWeekSeriesList(
@@ -245,6 +349,40 @@ class HomeService(
)
}
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 {
val systemTime = LocalDateTime.now()
val zoneId = ZoneId.of(timezone)
@@ -262,4 +400,116 @@ class HomeService(
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
}
}
/**
* 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
import kr.co.vividnext.sodalive.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@@ -10,7 +12,10 @@ data class Can(
var title: String,
var can: 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)
var status: CanStatus
) : BaseEntity()

View File

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

View File

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

View File

@@ -7,5 +7,7 @@ data class CanResponse @QueryProjection constructor(
val title: String,
val can: 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.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.GeoCountry
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter
@Service
class CanService(private val repository: CanRepository) {
fun getCans(): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE)
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
val currency = when (geoCountry) {
GeoCountry.KR -> "KRW"
else -> "USD"
}
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
}
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
@@ -35,6 +40,7 @@ class CanService(private val repository: CanRepository) {
"aos" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
}
}
@@ -42,12 +48,14 @@ class CanService(private val repository: CanRepository) {
"ios" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
}
}
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
import java.math.BigDecimal
data class ChargeCompleteResponse(
val price: Double,
val price: BigDecimal,
val currencyCode: String,
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.AdTrackingService
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.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDateTime
import javax.servlet.http.HttpServletRequest
@RestController
@RequestMapping("/charge")
class ChargeController(
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
fun charge(
@RequestBody chargeRequest: ChargeRequest,
@@ -111,8 +168,7 @@ class ChargeController(
memberId = member.id!!,
chargeId = chargeId,
productId = request.productId,
purchaseToken = request.purchaseToken,
paymentGateway = request.paymentGateway
purchaseToken = request.purchaseToken
)
trackingCharge(member, response)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
@@ -20,14 +21,14 @@ data class VerifyResult(
val method: String,
val pg: String,
val status: Int,
val price: Int
val price: BigDecimal
)
data class AppleChargeRequest(
val title: String,
val chargeCan: Int,
val paymentGateway: PaymentGateway,
var price: Double? = null,
var price: BigDecimal? = null,
var locale: String? = null
)
@@ -38,9 +39,53 @@ data class AppleVerifyResponse(val status: Int)
data class GoogleChargeRequest(
val title: String,
val chargeCan: Int,
val price: Double,
val price: BigDecimal,
val currencyCode: String,
val productId: String,
val purchaseToken: String,
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) {
"aos" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
}
"ios" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
}
else -> payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
}
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.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.codec.digest.DigestUtils
import org.json.JSONObject
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
@@ -34,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Service
@Transactional(readOnly = true)
@@ -63,9 +65,112 @@ class ChargeService(
@Value("\${apple.iap-verify-sandbox-url}")
private val appleInAppVerifySandBoxUrl: String,
@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
fun chargeByCoupon(couponNumber: String, member: Member): String {
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
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
val can = canRepository.findByIdOrNull(request.canId)
@@ -137,7 +413,7 @@ class ChargeService(
charge.can = can
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = can.price.toDouble()
payment.price = can.price
charge.payment = payment
chargeRepository.save(charge)
@@ -176,14 +452,14 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (e: Exception) {
} catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
@@ -208,7 +484,7 @@ class ChargeService(
VerifyResult::class.java
)
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
if (verifyResult.status == 1) {
charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
"${verifyResult.pg}-${verifyResult.method}"
@@ -226,14 +502,14 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (e: Exception) {
} catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
@@ -251,7 +527,7 @@ class ChargeService(
payment.price = if (request.price != null) {
request.price!!
} else {
0.toDouble()
0.toBigDecimal()
}
payment.locale = request.locale
@@ -286,7 +562,7 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
@@ -303,7 +579,7 @@ class ChargeService(
member: Member,
title: String,
chargeCan: Int,
price: Double,
price: BigDecimal,
currencyCode: String,
productId: String,
purchaseToken: String,
@@ -331,8 +607,7 @@ class ChargeService(
memberId: Long,
chargeId: Long,
productId: String,
purchaseToken: String,
paymentGateway: PaymentGateway
purchaseToken: String
): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(id = chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.")
@@ -354,7 +629,7 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
@@ -436,4 +711,13 @@ class ChargeService(
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
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeTempRequest(
val can: Int,
val price: Int,
val price: BigDecimal,
val paymentGateway: PaymentGateway
)

View File

@@ -41,7 +41,7 @@ class ChargeTempService(
charge.member = member
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = request.price.toDouble()
payment.price = request.price
charge.payment = payment
chargeRepository.save(charge)
@@ -66,7 +66,7 @@ class ChargeTempService(
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?.method = verifyResult.method
charge.payment?.status = PaymentStatus.COMPLETE
@@ -74,7 +74,7 @@ class ChargeTempService(
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (e: Exception) {
} catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {

View File

@@ -127,6 +127,7 @@ class CanPaymentService(
useCanRepository.save(useCan)
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
setUseCanCalculate(
recipientId,
useRewardCan,
@@ -379,6 +380,7 @@ class CanPaymentService(
useCanRepository.save(useCan)
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.GOOGLE_IAP)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
@@ -428,6 +430,7 @@ class CanPaymentService(
useCanRepository.save(useCan)
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.GOOGLE_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.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
@@ -25,7 +26,8 @@ data class Payment(
var receiptId: 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 orderId: String? = null
}

View File

@@ -1,5 +1,5 @@
package kr.co.vividnext.sodalive.can.payment
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 languageCode: String? = null,
// AI 시스템 프롬프트
@Column(columnDefinition = "TEXT", nullable = false)
var systemPrompt: String,

View File

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

View File

@@ -47,7 +47,7 @@ class CharacterCommentController(
if (member.auth == null) 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)
}

View File

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

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.controller
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.CharacterBackgroundResponse
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.OtherCharacter
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.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.common.ApiResponse
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 org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
@@ -32,7 +43,12 @@ class ChatCharacterController(
private val bannerService: ChatCharacterBannerService,
private val chatRoomService: ChatRoomService,
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}")
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()
@@ -74,6 +108,13 @@ class ChatCharacterController(
size = 50
).content
// 추천 캐릭터 조회
// 최근 대화한 캐릭터를 제외한 랜덤 30개 조회
// Controller에서는 호출만
// 세부로직은 추후에 변경될 수 있으므로 Service에 별도로 생성
val excludeIds = recentCharacters.map { it.characterId }
val recommendCharacters = service.getRecommendCharacters(excludeIds, 30)
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
.map { agg ->
@@ -85,7 +126,8 @@ class ChatCharacterController(
characterId = it.id!!,
name = it.name,
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(
CharacterMainResponse(
banners = banners,
recentCharacters = recentCharacters,
popularCharacters = popularCharacters,
newCharacters = newCharacters,
recentCharacters = translatedRecentCharacters,
popularCharacters = getTranslatedAiCharacterList(popularCharacters),
newCharacters = getTranslatedAiCharacterList(newCharacters),
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
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개, 현재 캐릭터 제외)
val others = service.getOtherCharactersBySharedTags(characterId, 10)
.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개 조회
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
@@ -162,6 +346,7 @@ class ChatCharacterController(
characterId = character.id!!,
name = character.name,
description = character.description,
languageCode = character.languageCode,
mbti = character.mbti,
gender = character.gender,
age = character.age,
@@ -172,9 +357,10 @@ class ChatCharacterController(
originalTitle = character.originalTitle,
originalLink = character.originalLink,
characterType = character.characterType,
others = others,
others = translatedOthers,
latestComment = latestComment,
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
translated = translated
)
)
}
@@ -185,12 +371,80 @@ class ChatCharacterController(
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
*/
@GetMapping("/recent")
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
ApiResponse.ok(
service.getRecentCharactersPage(
fun getRecentCharacters(
@RequestParam("page", required = false) page: Int?
): ApiResponse<RecentCharactersResponse> = run {
val characterPage = service.getRecentCharactersPage(
page = page ?: 0,
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.comment.CharacterCommentResponse
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
data class CharacterDetailResponse(
val characterId: Long,
val name: String,
val description: String,
val languageCode: String?,
val mbti: String?,
val gender: String?,
val age: Int?,
@@ -19,7 +21,8 @@ data class CharacterDetailResponse(
val characterType: CharacterType,
val others: List<OtherCharacter>,
val latestComment: CharacterCommentResponse?,
val totalComments: Int
val totalComments: Int,
val translated: TranslatedAiCharacterDetail?
)
data class OtherCharacter(

View File

@@ -7,6 +7,7 @@ data class CharacterMainResponse(
val recentCharacters: List<RecentCharacter>,
val popularCharacters: List<Character>,
val newCharacters: List<Character>,
val recommendCharacters: List<Character>,
val curationSections: List<CurationSection>
)
@@ -20,7 +21,8 @@ data class Character(
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("name") val name: String,
@JsonProperty("description") val description: String,
@JsonProperty("imageUrl") val imageUrl: String
@JsonProperty("imageUrl") val imageUrl: String,
@JsonProperty("isNew") val new: Boolean
)
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.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
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"
)
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 {

View File

@@ -74,5 +74,29 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
pageable: Pageable
): 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>
}

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.dto.Character
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.ChatCharacterHobbyRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
@@ -34,10 +35,42 @@ class ChatCharacterService(
private val hobbyRepository: ChatCharacterHobbyRepository,
private val goalRepository: ChatCharacterGoalRepository,
private val popularCharacterQuery: PopularCharacterQuery,
private val imageRepository: CharacterImageRepository,
@Value("\${cloud.aws.cloud-front.host}")
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 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
@@ -51,12 +84,25 @@ class ChatCharacterService(
val window = RankingWindowCalculator.now("popular-character")
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
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 {
Character(
characterId = it.id!!,
name = it.name,
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()
)
}
val fallback = chatCharacterRepository.findByIsActiveTrue(
val chars = chatCharacterRepository.findByIsActiveTrue(
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(
characterId = it.id!!,
name = it.name,
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(
@@ -108,16 +167,29 @@ class ChatCharacterService(
)
}
val pageResult = chatCharacterRepository.findRecentSince(
val chars = chatCharacterRepository.findRecentSince(
since,
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(
characterId = it.id!!,
name = it.name,
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

@@ -1,6 +1,8 @@
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.image.CharacterImageRepository
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.OriginalWorkListResponse
@@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
/**
* 앱용 원작(오리지널 작품) 공개 API
@@ -25,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/chat/original")
class OriginalWorkController(
private val queryService: OriginalWorkQueryService,
private val characterImageRepository: CharacterImageRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -65,17 +70,34 @@ class OriginalWorkController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val ow = queryService.getOriginalWork(id)
val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20)
val characters = pageRes.content.map {
val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
val recentSet = if (chars.isNotEmpty()) {
characterImageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
ApiResponse.ok(
OriginalWorkDetailResponse.from(
ow,
imageHost,
chars.map<ChatCharacter, Character> {
val path = it.imagePath ?: "profile/default-profile.png"
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/$path"
imageUrl = "$imageHost/$path",
new = recentSet.contains(it.id)
)
}
val response = OriginalWorkDetailResponse.from(ow, imageHost, characters)
ApiResponse.ok(response)
)
)
}
}

View File

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

View File

@@ -54,7 +54,7 @@ class ChatRoomQuotaController(
): ApiResponse<PurchaseRoomQuotaResponse> = run {
if (member == 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)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
@@ -79,7 +79,7 @@ class ChatRoomQuotaController(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId,
addPaid = 40,
addPaid = 12,
container = req.container
)

View File

@@ -126,13 +126,13 @@ class ChatRoomQuotaService(
memberId: Long,
chatRoomId: Long,
characterId: Long,
addPaid: Int = 40,
addPaid: Int = 12,
container: String
): RoomQuotaStatus {
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
canPaymentService.spendCan(
memberId = memberId,
needCan = 30,
needCan = 10,
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
chatRoomId = chatRoomId,
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.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException
import org.springframework.web.server.ResponseStatusException
@RestControllerAdvice
class SodaExceptionHandler {
@@ -63,6 +64,7 @@ class SodaExceptionHandler {
@ExceptionHandler(Exception::class)
fun handleException(e: Exception) = run {
if (e is ResponseStatusException) throw e
logger.error("API error", e)
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}

View File

@@ -83,6 +83,7 @@ class SecurityConfig(
.antMatchers("/api/home").permitAll()
.antMatchers("/api/home/latest-content").permitAll()
.antMatchers("/api/home/day-of-week-series").permitAll()
.antMatchers("/api/home/content-ranking").permitAll()
.antMatchers(HttpMethod.GET, "/api/live").permitAll()
.antMatchers(HttpMethod.GET, "/faq").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/room/list").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
.anyRequest().authenticated()
.and()
.build()

View File

@@ -1,11 +1,19 @@
package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.i18n.LangInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@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) {
registry.addMapping("/**")
.allowedOrigins(

View File

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

View File

@@ -237,6 +237,33 @@ class AudioContentController(private val service: AudioContentService) {
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")
fun replayLive(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,

View File

@@ -109,7 +109,6 @@ interface AudioContentQueryRepository {
): Int
fun findByThemeFor2Weeks(
isFree: Boolean = false,
cloudfrontHost: String,
memberId: Long,
theme: List<String> = emptyList(),
@@ -120,7 +119,6 @@ interface AudioContentQueryRepository {
): List<GetAudioContentMainItem>
fun totalCountNewContentFor2Weeks(
isFree: Boolean = false,
theme: List<String> = emptyList(),
memberId: Long,
isAdult: Boolean,
@@ -182,8 +180,11 @@ interface AudioContentQueryRepository {
contentType: ContentType,
offset: Long,
limit: Long,
sortType: SortType,
isFree: Boolean,
isAdult: Boolean
isAdult: Boolean,
orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false
): List<AudioContentMainItem>
fun findContentByCurationId(
@@ -193,6 +194,11 @@ interface AudioContentQueryRepository {
offset: Long = 0,
limit: Long = 20
): List<GetAudioContentMainItem>
fun findLatestContentByCreatorId(
creatorId: Long,
isAdult: Boolean = false
): AudioContent?
}
@Repository
@@ -236,6 +242,7 @@ class AudioContentQueryRepositoryImpl(
SortType.NEWEST -> audioContent.releaseDate.desc()
SortType.PRICE_HIGH -> audioContent.price.desc()
SortType.PRICE_LOW -> audioContent.price.asc()
SortType.POPULARITY -> audioContent.playCount.desc()
}
var where = audioContent.member.id.eq(creatorId)
@@ -457,6 +464,12 @@ class AudioContentQueryRepositoryImpl(
audioContent.releaseDate.asc(),
audioContent.id.asc()
)
SortType.POPULARITY -> listOf(
audioContent.playCount.desc(),
audioContent.releaseDate.asc(),
audioContent.id.asc()
)
}
var where = audioContent.isActive.isTrue
@@ -688,7 +701,6 @@ class AudioContentQueryRepositoryImpl(
}
override fun totalCountNewContentFor2Weeks(
isFree: Boolean,
theme: List<String>,
memberId: Long,
isAdult: Boolean,
@@ -725,10 +737,6 @@ class AudioContentQueryRepositoryImpl(
where = where.and(audioContentTheme.theme.`in`(theme))
}
if (isFree) {
where = where.and(audioContent.price.loe(0))
}
return queryFactory
.select(audioContent.id)
.from(audioContent)
@@ -740,7 +748,6 @@ class AudioContentQueryRepositoryImpl(
}
override fun findByThemeFor2Weeks(
isFree: Boolean,
cloudfrontHost: String,
memberId: Long,
theme: List<String>,
@@ -780,10 +787,6 @@ class AudioContentQueryRepositoryImpl(
where = where.and(audioContentTheme.theme.`in`(theme))
}
if (isFree) {
where = where.and(audioContent.price.loe(0))
}
return queryFactory
.select(
QGetAudioContentMainItem(
@@ -1302,8 +1305,11 @@ class AudioContentQueryRepositoryImpl(
contentType: ContentType,
offset: Long,
limit: Long,
sortType: SortType,
isFree: Boolean,
isAdult: Boolean
isAdult: Boolean,
orderByRandom: Boolean,
isPointAvailableOnly: Boolean
): List<AudioContentMainItem> {
var where = audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
@@ -1338,6 +1344,31 @@ class AudioContentQueryRepositoryImpl(
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
.select(
QAudioContentMainItem(
@@ -1355,7 +1386,7 @@ class AudioContentQueryRepositoryImpl(
.where(where)
.offset(offset)
.limit(limit)
.orderBy(audioContent.id.desc())
.orderBy(orderBy)
.fetch()
}
@@ -1416,4 +1447,26 @@ class AudioContentQueryRepositoryImpl(
.limit(limit)
.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,19 @@ import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
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.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmEvent
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.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
@@ -56,11 +65,16 @@ class AudioContentService(
private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository,
private val translationService: PapagoTranslationService,
private val contentTranslationRepository: ContentTranslationRepository,
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
private val audioContentCloudFront: AudioContentCloudFront,
private val applicationEventPublisher: ApplicationEventPublisher,
private val langContext: LangContext,
@Value("\${cloud.aws.s3.content-bucket}")
private val audioContentBucket: String,
@@ -160,6 +174,13 @@ class AudioContentService(
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
}
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.contentId,
targetType = LanguageTranslationTargetType.CONTENT
)
)
}
@Transactional
@@ -238,6 +259,7 @@ class AudioContentService(
val audioContent = AudioContent(
title = request.title.trim(),
detail = request.detail.trim(),
languageCode = request.languageCode,
price = if (request.price > 0) {
request.price
} else {
@@ -331,6 +353,31 @@ class AudioContentService(
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
)
)
}
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = audioContent.id!!,
targetType = LanguageTranslationTargetType.CONTENT
)
)
return CreateAudioContentResponse(contentId = audioContent.id!!)
}
@@ -386,7 +433,7 @@ class AudioContentService(
// Check if the time difference is greater than 30 seconds (30000 milliseconds)
return date2.time - date1.time
} catch (e: Exception) {
} catch (_: Exception) {
// Handle invalid time formats or parsing errors
return 0
}
@@ -477,6 +524,7 @@ class AudioContentService(
}
}
@Transactional
fun getDetail(
id: Long,
member: Member,
@@ -699,10 +747,89 @@ class AudioContentService(
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
)
}
}
}
return GetAudioContentDetailResponse(
contentId = audioContent.id!!,
title = audioContent.title,
detail = contentDetail,
languageCode = audioContent.languageCode,
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
contentUrl = audioContentUrl,
themeStr = audioContent.theme!!.theme,
@@ -745,7 +872,51 @@ class AudioContentService(
previousContent = previousContent,
nextContent = nextContent,
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 +980,27 @@ class AudioContentService(
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(
totalCount = totalCount,
items = items
items = translatedContentList
)
}
@@ -945,16 +1134,40 @@ class AudioContentService(
contentType: ContentType,
offset: Long = 0,
limit: Long = 20,
sortType: SortType = SortType.NEWEST,
isFree: Boolean = false,
isAdult: Boolean = false
isAdult: Boolean = false,
orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false
): List<AudioContentMainItem> {
return repository.getLatestContentByTheme(
val contentList = repository.getLatestContentByTheme(
theme = theme,
contentType = contentType,
offset = offset,
limit = limit,
sortType = sortType,
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
}
}
}

View File

@@ -17,5 +17,6 @@ data class CreateAudioContentRequest(
val isCommentAvailable: Boolean = false,
val isFullDetailVisible: Boolean = true,
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 kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
data class GetAudioContentDetailResponse(
val contentId: Long,
val title: String,
val detail: String,
val languageCode: String?,
val coverImageUrl: String,
val contentUrl: String,
val themeStr: String,
@@ -39,7 +41,8 @@ data class GetAudioContentDetailResponse(
val previousContent: OtherContentResponse?,
val nextContent: OtherContentResponse?,
val buyerList: List<ContentBuyer>,
val isAvailableUsePoint: Boolean
val isAvailableUsePoint: Boolean,
val translated: TranslatedContent?
)
data class OtherContentResponse @QueryProjection constructor(

View File

@@ -0,0 +1,308 @@
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.content.comment.AudioContentCommentRepository
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.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
}
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 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)
}
}
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 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(
@Column(columnDefinition = "TEXT", nullable = false)
var comment: String,
var languageCode: String?,
@Column(nullable = true)
var donationCan: Int? = null,
val isSecret: Boolean = false,

View File

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

View File

@@ -85,6 +85,7 @@ class AudioContentCommentQueryRepositoryImpl(
audioContentComment.member.nickname,
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
audioContentComment.comment,
audioContentComment.languageCode,
audioContentComment.isSecret,
audioContentComment.donationCan.coalesce(0),
formattedDate,
@@ -166,6 +167,7 @@ class AudioContentCommentQueryRepositoryImpl(
audioContentComment.member.nickname,
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
audioContentComment.comment,
audioContentComment.languageCode,
audioContentComment.isSecret,
audioContentComment.donationCan.coalesce(0),
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.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.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
@@ -32,7 +34,8 @@ class AudioContentCommentService(
comment: String,
audioContentId: Long,
parentId: Long? = null,
isSecret: Boolean = false
isSecret: Boolean = false,
languageCode: String?
): Long {
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
@@ -50,7 +53,7 @@ class AudioContentCommentService(
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
}
val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret)
val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret)
audioContentComment.audioContent = audioContent
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!!
}

View File

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

View File

@@ -4,5 +4,6 @@ data class RegisterCommentRequest(
val comment: String,
val contentId: 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 donationCan: Int,
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.common.SodaException
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.AudioContentCommentRepository
import kr.co.vividnext.sodalive.member.Member
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -14,7 +17,8 @@ import org.springframework.transaction.annotation.Transactional
class AudioContentDonationService(
private val canPaymentService: CanPaymentService,
private val queryRepository: AudioContentRepository,
private val commentRepository: AudioContentCommentRepository
private val commentRepository: AudioContentCommentRepository,
private val applicationEventPublisher: ApplicationEventPublisher
) {
@Transactional
fun donation(request: AudioContentDonationRequest, member: Member) {
@@ -34,10 +38,23 @@ class AudioContentDonationService(
val audioContentComment = AudioContentComment(
comment = request.comment,
languageCode = request.languageCode,
donationCan = request.donationCan
)
audioContentComment.audioContent = audioContent
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")
fun getNewContentAllByTheme(
@RequestParam("isFree", required = false) isFree: Boolean? = null,
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@@ -110,7 +109,6 @@ class AudioContentMainController(
ApiResponse.ok(
service.getNewContentFor2WeeksByTheme(
isFree = isFree ?: false,
theme = theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,

View File

@@ -6,7 +6,9 @@ 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.curation.GetAudioContentCurationResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value
@@ -21,6 +23,10 @@ class AudioContentMainService(
private val blockMemberRepository: BlockMemberRepository,
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -28,16 +34,6 @@ class AudioContentMainService(
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
.filter {
it != "모닝콜" &&
it != "알람" &&
it != "슬립콜" &&
it != "다시듣기" &&
it != "ASMR" &&
it != "릴레이" &&
it != "챌린지" &&
it != "자기소개"
}
}
@Transactional(readOnly = true)
@@ -64,7 +60,6 @@ class AudioContentMainService(
@Transactional(readOnly = true)
fun getNewContentFor2WeeksByTheme(
isFree: Boolean,
theme: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
@@ -75,31 +70,19 @@ class AudioContentMainService(
val themeList = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdult,
isFree = isFree,
contentType = contentType
).filter {
it != "모닝콜" &&
it != "알람" &&
it != "슬립콜" &&
it != "다시듣기" &&
it != "ASMR" &&
it != "릴레이" &&
it != "챌린지" &&
it != "자기소개"
}
)
} else {
listOf(theme)
}
val totalCount = repository.totalCountNewContentFor2Weeks(
isFree,
themeList,
memberId = member.id!!,
isAdult = isAdult,
contentType = contentType
)
val items = repository.findByThemeFor2Weeks(
isFree,
val contentList = repository.findByThemeFor2Weeks(
cloudfrontHost = imageHost,
memberId = member.id!!,
theme = themeList,
@@ -110,7 +93,25 @@ class AudioContentMainService(
)
.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)
}
@Transactional(readOnly = true)

View File

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

View File

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

View File

@@ -18,7 +18,9 @@ import org.springframework.web.bind.annotation.RestController
class ContentSeriesController(private val service: ContentSeriesService) {
@GetMapping
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("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@@ -29,6 +31,8 @@ class ContentSeriesController(private val service: ContentSeriesService) {
ApiResponse.ok(
service.getSeriesList(
creatorId = creatorId,
isOriginal = isOriginal ?: false,
isCompleted = isCompleted ?: false,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
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.Series
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.member.MemberRole
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 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(
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,
contentType: ContentType,
offset: Long,
@@ -40,6 +66,7 @@ interface ContentSeriesQueryRepository {
fun getOriginalAudioDramaList(
isAdult: Boolean,
contentType: ContentType,
orderByRandom: Boolean = false,
offset: Long = 0,
limit: Long = 20
): List<Series>
@@ -59,9 +86,26 @@ interface ContentSeriesQueryRepository {
class ContentSeriesQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : ContentSeriesQueryRepository {
override fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int {
var where = series.member.id.eq(creatorId)
.and(series.isActive.isTrue)
override fun getSeriesTotalCount(
creatorId: Long?,
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) {
where = where.and(series.isAdult.isFalse)
@@ -92,14 +136,74 @@ class ContentSeriesQueryRepositoryImpl(
override fun getSeriesList(
imageHost: String,
creatorId: Long,
creatorId: Long?,
isAuth: Boolean,
contentType: ContentType,
isOriginal: Boolean,
isCompleted: Boolean,
orderByRandom: Boolean,
offset: Long,
limit: Long
): List<Series> {
var where = series.member.id.eq(creatorId)
.and(series.isActive.isTrue)
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) {
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) {
where = where.and(series.isAdult.isFalse)
@@ -120,10 +224,57 @@ class ContentSeriesQueryRepositoryImpl(
}
return queryFactory
.selectFrom(series)
.select(series.id)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.innerJoin(seriesContent.content, audioContent)
.innerJoin(series.member, member)
.innerJoin(series.genre, seriesGenre)
.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)
.limit(limit)
.fetch()
@@ -216,6 +367,7 @@ class ContentSeriesQueryRepositoryImpl(
override fun getOriginalAudioDramaList(
isAdult: Boolean,
contentType: ContentType,
orderByRandom: Boolean,
offset: Long,
limit: Long
): List<Series> {
@@ -244,7 +396,13 @@ class ContentSeriesQueryRepositoryImpl(
.selectFrom(series)
.innerJoin(series.member, member)
.where(where)
.orderBy(series.id.desc())
.orderBy(
if (orderByRandom) {
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
} else {
series.id.desc()
}
)
.offset(offset)
.limit(limit)
.fetch()

View File

@@ -37,10 +37,11 @@ class ContentSeriesService(
fun getOriginalAudioDramaList(
isAdult: Boolean,
contentType: ContentType,
orderByRandom: Boolean = false,
offset: Long = 0,
limit: Long = 20
): 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)
}
@@ -49,25 +50,63 @@ class ContentSeriesService(
}
fun getSeriesList(
creatorId: Long,
creatorId: Long?,
isOriginal: Boolean = false,
isCompleted: Boolean = false,
orderByRandom: Boolean = false,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member,
offset: Long = 0,
limit: Long = 10
limit: Long = 20
): GetSeriesListResponse {
val isAuth = member.auth != null && isAdultContentVisible
val totalCount = repository.getSeriesTotalCount(
creatorId = creatorId,
isAuth = isAuth,
contentType = contentType
contentType = contentType,
isOriginal = isOriginal,
isCompleted = isCompleted
)
val rawItems = repository.getSeriesList(
imageHost = coverImageHost,
creatorId = creatorId,
isAuth = isAuth,
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, 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,
limit = limit
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
@@ -201,7 +240,7 @@ class ContentSeriesService(
val seriesList = repository.getRecommendSeriesList(
isAuth = isAuth,
contentType = contentType,
limit = 10
limit = 20
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType)

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

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.content.series.main.banner
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface SeriesBannerRepository : JpaRepository<SeriesBanner, Long> {
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<SeriesBanner>
@Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true")
fun findMaxSortOrder(): Int?
}

View File

@@ -7,7 +7,7 @@ import javax.persistence.Table
@Entity
@Table(name = "content_theme")
data class AudioContentTheme(
class AudioContentTheme(
@Column(nullable = false)
var theme: String,
@Column(nullable = false)

View File

@@ -27,6 +27,26 @@ class AudioContentThemeController(private val service: AudioContentThemeService)
ApiResponse.ok(service.getThemes())
}
@GetMapping("/active")
fun getActiveThemes(
@RequestParam("isFree", required = false) isFree: Boolean? = null,
@RequestParam("isPointAvailableOnly", required = false) isPointAvailableOnly: Boolean? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(
service.getActiveThemeOfContent(
isAdult = member.auth != null && (isAdultContentVisible ?: true),
isFree = isFree ?: false,
isPointAvailableOnly = isPointAvailableOnly ?: false,
contentType = contentType ?: ContentType.ALL
)
)
}
@GetMapping("/{id}/content")
fun getContentByTheme(
@PathVariable id: Long,

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.content.theme
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
@@ -14,6 +15,10 @@ class AudioContentThemeQueryRepository(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
data class ThemeIdAndName(
val id: Long,
val theme: String
)
fun getActiveThemes(): List<GetAudioContentThemeResponse> {
return queryFactory
.select(
@@ -32,6 +37,7 @@ class AudioContentThemeQueryRepository(
fun getActiveThemeOfContent(
isAdult: Boolean = false,
isFree: Boolean = false,
isPointAvailableOnly: Boolean = false,
contentType: ContentType
): List<String> {
var where = audioContent.isActive.isTrue
@@ -59,15 +65,94 @@ class AudioContentThemeQueryRepository(
where = where.and(audioContent.price.loe(0))
}
return queryFactory
if (isPointAvailableOnly) {
where = where.and(audioContent.isPointAvailable.isTrue)
}
val query = queryFactory
.select(audioContentTheme.theme)
.from(audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(audioContent.theme, audioContentTheme)
.where(where)
.groupBy(audioContentTheme.id)
.orderBy(audioContentTheme.orders.asc())
.fetch()
if (isFree) {
query.orderBy(
CaseBuilder()
.`when`(audioContentTheme.theme.eq("자기소개")).then(0)
.otherwise(1)
.asc(),
audioContentTheme.orders.asc()
)
} else {
query.orderBy(audioContentTheme.orders.asc())
}
return query.fetch()
}
fun getActiveThemeWithIdsOfContent(
isAdult: Boolean = false,
isFree: Boolean = false,
isPointAvailableOnly: Boolean = false,
contentType: ContentType
): List<ThemeIdAndName> {
var where = audioContent.isActive.isTrue
.and(audioContentTheme.isActive.isTrue)
if (!isAdult) {
where = where.and(audioContent.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(
audioContent.member.isNull.or(
audioContent.member.auth.gender.eq(
if (contentType == ContentType.MALE) {
0
} else {
1
}
)
)
)
}
}
if (isFree) {
where = where.and(audioContent.price.loe(0))
}
if (isPointAvailableOnly) {
where = where.and(audioContent.isPointAvailable.isTrue)
}
val query = queryFactory
.select(audioContentTheme.id, audioContentTheme.theme)
.from(audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(audioContent.theme, audioContentTheme)
.where(where)
.groupBy(audioContentTheme.id)
if (isFree) {
query.orderBy(
CaseBuilder()
.`when`(audioContentTheme.theme.eq("자기소개")).then(0)
.otherwise(1)
.asc(),
audioContentTheme.orders.asc()
)
} else {
query.orderBy(audioContentTheme.orders.asc())
}
return query.fetch().map { tuple ->
ThemeIdAndName(
id = tuple.get(audioContentTheme.id)!!,
theme = tuple.get(audioContentTheme.theme)!!
)
}
}
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {

View File

@@ -5,6 +5,12 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
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 org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -12,24 +18,94 @@ import org.springframework.transaction.annotation.Transactional
@Service
class AudioContentThemeService(
private val queryRepository: AudioContentThemeQueryRepository,
private val contentRepository: AudioContentRepository
private val contentRepository: AudioContentRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val papagoTranslationService: PapagoTranslationService,
private val langContext: LangContext
) {
@Transactional(readOnly = true)
fun getThemes(): List<GetAudioContentThemeResponse> {
return queryRepository.getActiveThemes()
}
@Transactional(readOnly = true)
@Transactional
fun getActiveThemeOfContent(
isAdult: Boolean = false,
isFree: Boolean = false,
isPointAvailableOnly: Boolean = false,
contentType: ContentType
): List<String> {
return queryRepository.getActiveThemeOfContent(
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
isAdult = isAdult,
isFree = isFree,
isPointAvailableOnly = isPointAvailableOnly,
contentType = contentType
)
/**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
*/
val currentLang = langContext.lang
if (currentLang == Lang.EN || currentLang == Lang.JA) {
val targetLocale = currentLang.code
// 1) 기존 번역을 한 번에 조회
val ids = themesWithIds.map { it.id }
val existingTranslations = if (ids.isNotEmpty()) {
contentThemeTranslationRepository.findByContentThemeIdInAndLocale(ids, targetLocale)
} else {
emptyList()
}
val existingMap = existingTranslations.associateBy { it.contentThemeId }
// 2) 미번역 항목만 수집하여 한 번에 번역 요청
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
if (untranslatedPairs.isNotEmpty()) {
val texts = untranslatedPairs.map { it.theme }
val response = papagoTranslationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = "ko",
targetLanguage = targetLocale
)
)
val translatedTexts = response.translatedText
val entitiesToSave = mutableListOf<ContentThemeTranslation>()
// translatedTexts 크기가 다르면 안전하게 원문으로 대체
untranslatedPairs.forEachIndexed { index, pair ->
val translated = translatedTexts.getOrNull(index) ?: pair.theme
entitiesToSave.add(
ContentThemeTranslation(
contentThemeId = pair.id,
locale = targetLocale,
theme = translated
)
)
}
if (entitiesToSave.isNotEmpty()) {
contentThemeTranslationRepository.saveAll(entitiesToSave)
}
// 저장 후 맵을 갱신
entitiesToSave.forEach { entity ->
(existingMap as MutableMap)[entity.contentThemeId] = entity
}
}
// 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)
return themesWithIds.map { pair ->
existingMap[pair.id]?.theme ?: pair.theme
}
}
return themesWithIds.map { it.theme }
}
@Transactional(readOnly = true)

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.content.theme.translation
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
@Entity
class ContentThemeTranslation(
val contentThemeId: Long,
val locale: String,
var theme: String
) : BaseEntity()

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.content.theme.translation
import org.springframework.data.jpa.repository.JpaRepository
interface ContentThemeTranslationRepository : JpaRepository<ContentThemeTranslation, Long> {
fun findByContentThemeIdAndLocale(contentThemeId: Long, locale: String): ContentThemeTranslation?
fun findByContentThemeIdInAndLocale(contentThemeIds: Collection<Long>, locale: String): List<ContentThemeTranslation>
}

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