Compare commits

..

70 Commits

Author SHA1 Message Date
59ca353b25 feat(calculate-ratio): 정산 비율 삭제 URL 수정 2025-09-22 14:17:18 +09:00
6bc65ec412 feat(calculate-ratio): 정산 비율 조회 - 결과에 memberId 추가 2025-09-22 14:01:28 +09:00
97e95b51ab feat(calculate-ratio): 정산 비율 수정/삭제(소프트 삭제)와 업서트, 쿼리 보강
- deletedAt 기반 소프트 삭제 도입 및 restore/updateValues 추가
- 생성 시 기존(삭제 포함) 레코드 복구 후 값 갱신(업서트)
- /admin/calculate/ratio/update, /delete 엔드포인트 추가
- 정산 쿼리 조인에 deletedAt.isNull 적용하여 삭제 데이터 배제
- 목록/카운트 조회에서도 삭제 데이터 제외
2025-09-22 13:36:36 +09:00
a6dfa81ba6 사용하지 않는 '지정 원작에 속한 활성 캐릭터 목록 조회 API' 제거 2025-09-18 22:49:35 +09:00
dad517a953 feat(admin-character-list): 캐릭터 검색결과
- 캐릭터 목록과 동일한 내용으로 변경
2025-09-18 19:58:12 +09:00
eb2d093b02 feat(admin-character-list): 캐릭터 검색에 페이징 추가 2025-09-18 19:29:34 +09:00
67186bba55 feat(original): 원작
- 원천 원작, 원천 원작 링크, 글/그림 작가, 제작사, 태그 추가
2025-09-18 18:04:59 +09:00
edeecad2ce feat(original-app): 원작 리스트
- 페이징 추가
2025-09-15 16:00:09 +09:00
387f5388d9 feat(original-app): 원작 상세, 캐릭터 리스트
- 원작 상세에 캐릭터 20개 조회
- 지정 원작에 속한 활성 캐릭터 목록 조회 API 추가
2025-09-15 15:32:20 +09:00
adcaa0a5fd fix(original): 캐릭터 수정
- 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다.
2025-09-15 06:43:40 +09:00
47b2c1cb93 fix(original): 캐릭터 수정
- 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다.
2025-09-15 06:17:55 +09:00
7f3589dcfb fix(original): 인기 캐릭터 조회
- 캐시 키 변경
2025-09-15 05:20:46 +09:00
b134c28c10 feat(original): 관리자 캐릭터 상세 조회
- 원작 데이터 추가
2025-09-15 05:18:01 +09:00
41c8d0367d feat(original): 원작별 캐릭터 조회 API 추가 2025-09-15 00:31:14 +09:00
3b148d549e feat(original-app): 앱용 원작 목록/상세 API 및 조회 로직 추가
- 공개 목록 API: 미인증 사용자는 19금 비노출, 활성 캐릭터가 1개 이상 연결된 원작만 반환, 총개수+리스트 제공
- 상세 API: 로그인/본인인증 필수, 원작 상세+소속 활성 캐릭터 리스트 반환
2025-09-14 23:27:58 +09:00
b6c96af8a2 feat(original): 원작 도메인과 캐릭터를 연결하기 위해 각각 검색 API 추가 2025-09-14 23:00:33 +09:00
4904625488 feat(original): 원작 도메인 추가, 관리자 CRUD/연결 API, 소프트 삭제, 서비스 계층 정비
- 원작 엔티티/레포지토리/관리자 API 구축(이미지 S3 업로드 포함)
- 캐릭터-원작 연관관계 및 관리자에서 배정/해제 API 제공
- 소프트 삭제(`isDeleted`) 도입 및 조회/수정/배정 로직에서 삭제 항목 필터링
- 컨트롤러-레포지토리 직접 접근 제거, `AdminOriginalWorkService`로 DB 접근 캡슐화
- 캐릭터 등록/수정에서 `originalWorkId` 지원 및 외부 API 업데이트 조건 분리
2025-09-14 22:33:30 +09:00
0574f4f629 feat(cache): 인기 캐릭터 조회에 윈도우 기반 동적 캐시 키 적용
- ChatCharacterService.getPopularCharacters()에 @Cacheable 추가
- 키: popular-chat-character:<windowStartEpoch>:<limit>
- 윈도우(매일 20:00 UTC) 전환 시 자동으로 신규 키 사용 → 전일 순위 캐시와 분리 보장

Why: 동일 윈도우 내 반복 요청의 DB 부하를 줄이고, 경계 전환 시 자연스러운 캐시 갱신을 보장.
2025-09-14 17:43:53 +09:00
4adc3e127c fix(popular): 일일 인기 캐릭터 집계 윈도우를 전날 완료 구간으로 고정
- UTC 20:00 경계 직후에도 [전날 20:00, 당일 20:00) 범위 사용으로 일일 순위 정확화
- RankingWindowCalculator.now(): lastBoundary 기반 [start, endExclusive) 계산
2025-09-14 17:28:33 +09:00
dd0a1c2293 fix(chat-character): 인기 캐릭터
- 캐시 제거
2025-09-14 16:46:56 +09:00
a07407417c fix(admin-chat-calculate): 캐릭터 정산 API
- ONLY_FULL_GROUP_BY 대응
- c2.image_path 집계식 적용
2025-09-13 05:01:52 +09:00
e33e3b43b7 fix(admin-chat-calculate): 캐릭터 정산 API
- ONLY_FULL_GROUP_BY 대응
2025-09-13 04:33:01 +09:00
634bf759ca feat(admin-chat-calculate): 캐릭터 정산 API에 채팅 횟수 구매(CHAT_QUOTA_PURCHASE) 추가 2025-09-13 03:54:24 +09:00
0ed29c6097 feat(admin-chat-calculate): 캐릭터 정산 API에 imagePath를 imageHost를 포함한 url로 변경 추가 2025-09-13 03:20:26 +09:00
b752434fbb feat(admin-chat-calculate): 캐릭터 정산 API에 totalCount 추가 2025-09-13 03:06:55 +09:00
eec63cc7b2 feat(admin-chat-calculate): 캐릭터별 정산 조회 API 추가 2025-09-13 02:00:30 +09:00
3dc9dd1f35 feat(character): 최근 등록된 캐릭터 전체보기 API
- 반환 값에 전체 개수 추가
2025-09-12 19:00:45 +09:00
88e287067b feat(character): 최근 등록된 캐릭터 전체보기 API 추가 2025-09-12 18:37:25 +09:00
27a3f450ef fix(character): 인기 캐릭터 응답을 DTO로 변경하여 jackson 직렬화 오류 해결
- ChatCharacterService.getPopularCharacters 반환을 List<ChatCharacter> → List<Character> DTO로 변경
- 캐시 대상도 DTO로 전환(@Cacheable 유지, 동적 키/고정 TTL 그대로 사용)
- 컨트롤러에서 불필요한 매핑 제거(서비스가 DTO로 반환)
- Character DTO 직렬화 안정성 확보(@JsonProperty 추가)
- 이미지 URL 생성 로직을 서비스로 이동하고 imageHost(@Value) 주입해 구성
2025-09-11 18:53:27 +09:00
58a46a09c3 fix(character): SpEL 정적 호출 오류로 @JvmStatic 추가 2025-09-11 18:21:13 +09:00
83a1316a64 feat(character): UTC 20시 경계 기반 인기 캐릭터 집계 구현 및 캐시 적용
- 집계 기준을 "채팅방 전체 메시지 수"로 변경하여 캐릭터별 인기 순위 산정
- Querydsl `PopularCharacterQuery` 추가: chat_message → chat_participant(CHARACTER) → chat_character 조인
- 시간 경계: UTC 20:00 기준 [windowStart, nextBoundary) 구간 사용(배타적 종료 `<`)
- `ChatCharacterService.getPopularCharacters`에 @Cacheable 적용
  - cacheNames: `popularCharacters_24h`
  - key: `RankingWindowCalculator.now('popular-chat-character').cacheKey`
  - 상위 20개 기본, `loadCharactersInOrder`로 랭킹 순서 보존
- `RankingWindowCalculator`: 경계별 동적 키 생성(`popular-chat-character:{windowStartEpoch}`) 및 윈도우 계산
- `RedisConfig`: 24시간 TTL 캐시 `popularCharacters_24h` 추가(문자열/JSON 직렬화 지정)
- `ChatCharacterController`: 메인 API에 인기 캐릭터 섹션 연동

WHY
- 20시(UTC) 경계 변경 시 키가 달라져 첫 조회에서 자동 재집계/재캐싱
- 방 전체 참여도를 반영해 보다 직관적인 인기 지표 제공
- 캐시(24h TTL)로 DB 부하 최소화, 경계 전환 후 자연 무효화
2025-09-11 18:06:40 +09:00
f05f146c89 fix(chat-quota): 유료 차감 후 무료·유료 동시 0일 때 next_recharge_at 설정 누락 수정
- 문제: 유료 잔여가 있을 때 유료 우선 차감 경로에서 `next_recharge_at` 설정 분기가 없어,
  무료/유료가 동시에 0이 되는 경우 다음 무료 충전 시점이 노출되지 않음
- 수정: `ChatRoomQuotaService.consumeOneForSend`의 유료 차감 분기에
  `remainingPaid==0 && remainingFree==0 && nextRechargeAt==null` 조건에서
  `now + 6h`로 `next_recharge_at`을 설정하도록 로직 추가
- 참고: 무료 차감 경로의 `next_recharge_at` 설정 및 입장 시 lazy refill 동작은 기존과 동일
2025-09-11 12:35:16 +09:00
3782062f4a fix(chat-room): 입장/전송 next 계산 보완 및 채팅 가능 시 next=null 처리
enter:
roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next
roomPaid==0 && roomFree==0 → (global<=0) ? max(roomNext, globalNext) : roomNext
채팅 가능(totalRemaining>0)인 경우 next=null 반환(유료>0 포함)
send:
totalRemaining==0 && global<=0 → max(roomNext, globalNext)
채팅 가능(totalRemaining>0)인 경우 next=null 반환
2025-09-10 13:31:27 +09:00
fd83abb46c feat(chat): 글로벌/방 쿼터 정책 개편, 결제/조회/차단/이관 로직 반영
글로벌: 무료 40, UTC 20:00 lazy refill(유료 제거)
방: 무료 10, 무료 0 순간 now+6h, 경과 시 lazy refill(무료=10, next=null)
전송: 유료 우선, 무료 사용 시 글로벌/룸 동시 차감, 조건 불충족 예외
API: 방 쿼터 조회/구매 추가(구매 시 30캔, UseCan에 roomId:characterId 기록)
next 계산: enter/send에서 경계 케이스 처리(max(room, global))
대화 초기화: 유료 쿼터 새 방으로 이관
2025-09-09 22:42:14 +09:00
a9d1b9f4a6 fix(character): 캐릭터 상세 조회 응답에 MBTI·성별·나이 필드 추가
- CharacterDetailResponse에 gender, age 필드 추가
- ChatCharacterController에서 gender, age 매핑
- 기존 엔티티(ChatCharacter)의 gender/age 활용
2025-09-05 16:55:50 +09:00
ad69dad725 fix(character-image): 리스트 응답 ownedCount에 프로필(+1) 반영
프로필 이미지는 무료로 항상 열람 가능하므로 보유 개수(ownedCount)에도
프로필 1장을 포함하도록 수정했습니다. 이를 통해 전체 개수(totalCount)와
보유 개수 산정 기준이 일관되게 맞춰집니다.
2025-09-01 16:33:53 +09:00
2f55303d16 feat(admin-curation): 리스트 정합성 개선 및 활성 캐릭터 수 DB 집계 적용
- 비활성(삭제) 큐레이션을 목록에서 제외: findByIsActiveTrueOrderBySortOrderAsc 사용
- 리스트 항목에 characterCount 추가 및 DB GROUP BY + COUNT로 직접 집계
- CharacterCurationMappingRepository: 집계용 프로젝션(CharacterCountPerCuration)과 countActiveCharactersByCurations 쿼리 추가
- CharacterCurationAdminService: listAll에서 집계 결과를 활용해 characterCount 매핑 (대량 엔티티 로딩 제거)
- CharacterCurationRepository: findMaxSortOrder 쿼리로 신규 등록 정렬 순서 계산에 활용
- 컨트롤러: 캐릭터 리스트 응답 DTO(CharacterCurationCharacterItemResponse) 사용, 이미지 URL은 CloudFront host + imagePath로 조립
2025-09-01 14:06:01 +09:00
3a9128a894 fix(character): 추가 정보 증분 업데이트 적용 및 값 필드 가변화
- 왜: 기존에는 추가 정보(memories, personalities, backgrounds, relationships) 수정 시 전체 삭제 후 재생성되어 변경 누락/DB 오버헤드가 발생함
- 무엇:
  - Memory/Personality/Background 값 필드(content/description/emotion)를 var로 전환해 in-place 업데이트 허용
  - 서비스 레이어에 증분 업데이트 로직 적용
    - 요청에 없는 항목만 제거, 기존 항목은 값만 갱신, 신규 키만 추가
    - relationships는 personName+relationshipName 복합 키 매칭(keyOf)으로 필드만 갱신
  - ChatCharacter 컬렉션에 orphanRemoval=true 설정하여 iterator.remove 시 고아 삭제 보장
  - updateChatCharacterWithDetails에서 clear/add 제거 → 증분 업데이트 메서드 호출로 변경
- 효과: DELETE+INSERT 제거로 성능 개선, ID/createdAt 유지로 감사 추적 용이, 데이터 정합성 향상
2025-09-01 12:29:26 +09:00
def6296d4d fix(chat-character): 캐릭터 등록/수정 API
- 재시도 규칙 제거
2025-09-01 11:03:46 +09:00
034472defa fix(chat-character): DB에서 speechStyle type을 varchar에서 text로의 변경에 따라 @Column(columnDefinition = "TEXT") 추가 2025-08-29 01:38:49 +09:00
550e4ac9ce fix(character-main): 최근 대화 캐릭터 조회에서 roomId 대신 characterId 반환 2025-08-28 19:50:20 +09:00
d26e0a89f6 feat(admin-curation): 큐레이션 캐릭터 다중 등록 및 검증 로직 개선
- 중복 ID 제거 및 0 이하 ID 필터링
- 조회 단계에서 활성 캐릭터만 조회하여 검증 포함
- 존재하지 않거나 비활성인 캐릭터는 건너뛰고 나머지만 등록
- 기존 매핑 있는 캐릭터는 무시, 다음 정렬 순서(nextOrder)로 일괄 추가
2025-08-28 19:22:31 +09:00
6767afdd35 feat(character-curation): 캐릭터 큐레이션 도메인/관리 API 추가 및 메인 화면 통합
- CharacterCuration/CharacterCurationMapping 엔티티 추가
- 리포지토리/서비스(조회·관리) 구현
- 관리자 컨트롤러에 등록/수정/삭제/정렬/캐릭터 추가·삭제·정렬 API 추가
- 앱 메인 API에 큐레이션 섹션 노출
- 정렬/소프트 삭제/활성 캐릭터 필터링 규칙 적용
2025-08-28 17:39:53 +09:00
a58de0cf92 feat(chat-room-list): 이미지 메시지면 최근 메시지를 [이미지]로 표시 2025-08-28 02:33:04 +09:00
df93f0e0ce feat(chat-quota): 30캔으로 충전시 유료 채팅 횟수
- 50 -> 40으로 변경
2025-08-28 00:22:15 +09:00
0b54b126db temp(chat-character): 최신 캐릭터 50개 조회 2025-08-28 00:21:07 +09:00
a94cf8dad9 feat(chat): 입장 라우팅 도입 및 라우팅 시 배경 이미지 URL 무시(null)
- baseRoom이 비활성/미참여면 동일 캐릭터의 내 활성 방으로 라우팅해 응답 구성
- 라우팅된 경우 bgImageUrl은 항상 null 처리; 대체 방 없으면 기존 예외 유지
2025-08-28 00:18:21 +09:00
2c3e12a42c fix(chat-room): 세션 종료 외부 API
- ContentType 설정 제거
2025-08-27 19:18:46 +09:00
c4dbdc1b8e fix(chat-room): 비활성 채팅방 접근 방지를 위해 조회 로직 일원화
- 이 변경으로 비활성화된 채팅방에 대한 메시지 전송/조회/입장/리셋 등 모든 경로에서 안전하게 접근이 차단됩니다.
2025-08-27 17:43:32 +09:00
42ed4692af feat(chat): 채팅방 초기화 API 추가 및 세션 종료 실패 시 롤백 처리
- /api/chat/room/{chatRoomId}/reset POST 엔드포인트 추가
- 초기화 절차: 30캔 결제 → 기존 방 나가기 → 동일 캐릭터로 새 방 생성 → 응답 반환
- 결제 시 CanUsage.CHAT_ROOM_RESET 신규 항목 사용(본인 귀속)
- ChatQuotaService.resetFreeToDefault 추가 및 초기화 성공 시 무료 10회로 리셋(nextRechargeAt 초기화)
- 사용내역 타이틀에 "캐릭터 톡 초기화" 노출(CanService)
- ChatRoomResetRequest DTO(container 포함) 추가
- leaveChatRoom에 throwOnSessionEndFailure 옵션 추가(기본 false 유지)
- endExternalSession에 throwOnFailure 옵션 추가: 최대 3회 재시도 후 실패 시 예외 전파 가능
- 채팅방 초기화 흐름에서는 외부 세션 종료 실패 시 예외를 던져 트랜잭션 롤백되도록 처리
2025-08-27 17:16:18 +09:00
258943535c feat(chat-room): 채팅방 입장 시 선택적 캐릭터 이미지 서명 URL 반환 및 파라미터 추가
enter API에 characterImageId 선택 파라미터 추가
동일 캐릭터/활성 여부/보유 여부 검증 후 5분 만료의 CloudFront 서명 URL 생성
ChatRoomEnterResponse에 bgImageUrl 필드 추가해 응답 포함
서명 URL 생성 실패 시 warn 로그만 남기고 null 반환하여 사용자 흐름 유지
기존 호출은 그대로 동작하며, 파라미터와 응답 필드 추가는 하위 호환됨
2025-08-27 15:18:24 +09:00
0347d767f0 feat(character-image): 캐릭터 이미지 리스트 첫 칸에 프로필 이미지 포함 및 페이징 보정
사용자 경험 향상을 위해 캐릭터 프로필 이미지를 이미지 리스트의 맨 앞에 노출하도록 변경.
2025-08-27 14:22:07 +09:00
48b0190242 feat(character-image): 보유 이미지 전용 목록 API 추가 및 DB 페이징 적용
- /api/chat/character/image/my-list 엔드포인트 추가
  - 로그인/본인인증 체크
  - 캐릭터 프로필 이미지를 리스트 맨 앞에 포함
  - 보유 이미지(무료 또는 구매 이력 존재)만 노출
  - CloudFront 서명 URL 발급로 접근 제어
- 페이징 로직 개선
  - 기존: 전체 조회 후 메모리에서 필터링/슬라이싱
  - 변경: QueryDSL로 DB 레벨에서 보유 이미지만 오프셋/리밋 조회
  - 프로필 아이템(인덱스 0) 포함을 고려하여 owned offset/limit 계산
  - 빈 페이지 요청 시 즉시 빈 결과 반환
- Repository
  - CharacterImageQueryRepository + Impl 추가
  - findOwnedActiveImagesByCharacterPaged(...) 구현
    - 구매 이력: CHAT_MESSAGE_PURCHASE, CHARACTER_IMAGE_PURCHASE만 인정, 환불 제외
    - 활성 이미지, sortOrder asc, id asc 정렬 + offset/limit
- Service
  - getCharacterImagePath(characterId) 추가
  - pageOwnedActiveByCharacterForMember(...) 추가
- Controller
  - my-list 응답 스키마는 list와 동일하게 totalCount/ownedCount/items 유지
  - 페이지 사이즈 상한 20 적용, 5분 만료 서명 URL
2025-08-26 23:52:30 +09:00
15d0952de8 fix(quota): 캐릭터 톡 채팅 쿼터 조회
- applyRefillOnEnterAndGetStatus를 적용하여 채팅 쿼터 조회 시 Lazy Refill 적용
2025-08-26 17:32:00 +09:00
84ebc1762b fix(quota): 채팅 쿼터 구매 시 사용 내역 문구
- 캐릭터 톡 이용권 구매
2025-08-26 17:28:06 +09:00
a096b16945 fix(quota): 채팅 쿼터 구매
- nextRechargeAt = null 설정
2025-08-26 17:06:35 +09:00
37ac52116a temp(quota): 기다리면 무료 쿼터 시간
- 테스트를 위해 임시로 1분으로 수정
2025-08-26 17:00:19 +09:00
fcb68be006 fix(chat-room): 채팅방 입장
- AI 채팅 쿼터 Lazy refill 적용을 위해 read/write 모두 가능하도록 Transaction 수정
2025-08-26 14:57:57 +09:00
048c48d754 fix(quota)!: AI 채팅 쿼터(무료/유료) 구매 Response를 ChatQuotaStatusResponse으로 변경 2025-08-26 13:57:02 +09:00
6ecac8d331 feat(quota)!: AI 채팅 쿼터(무료/유료) 도입 및 입장/전송 응답에 상태 포함
- ChatQuota 엔티티/레포/서비스/컨트롤러 추가
- 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함
- ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가
- SendChatMessageResponse 신설 및 send API 응답 스키마 변경
- CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영
2025-08-26 13:22:49 +09:00
8b1dd7cb95 temp: 임시로 최신 캐릭터 30개 보여주는 것으로 수정 2025-08-25 17:37:51 +09:00
5a58fe9077 feat(chat): 이미지 메시지 조회 시 CloudFront 서명 URL 적용 및 DTO 변환 로직 공통화
- 조회 가능한(보유/무료/결제완료) 이미지 메시지의 이미지 URL을 ImageContentCloudFront.generateSignedURL(만료 5분)로 생성
- 접근 불가(미보유, 유료 미구매) 이미지 메시지는 기존 공개 호스트 URL(블러/스냅샷 경로) 유지
- ChatRoomService에 ImageContentCloudFront를 주입하고, toChatMessageItemDto에서 이미지 URL/hasAccess 결정 로직 단일화
- enterChatRoom, getChatMessages, sendMessage 경로의 중복된 DTO 매핑 로직 제거
- purchaseMessage 결제 완료 시 forceHasAccess=true로 접근 가능 DTO 반환
2025-08-25 14:28:11 +09:00
12574dbe46 feat(chat-room, payment): 유료 메시지 구매 플로우 구현 및 결제 연동(이미지 보유 처리 포함)
- 채팅 유료 메시지 구매 API 추가: POST /api/chat/room/{chatRoomId}/messages/{messageId}/purchase
- ChatRoomService.purchaseMessage 구현: 참여/유효성/가격 검증, 이미지 메시지 보유 시 결제 생략, 결제 완료 시 ChatMessageItemDto 반환
- CanPaymentService.spendCanForChatMessage 추가: UseCan에 chatMessage(+이미지 메시지면 characterImage) 연동 저장 및 게이트웨이 별 정산 기록(setUseCanCalculate)
- Character Image 결제 경로에 정산 기록 호출 누락분 보강
- ChatMessageItemDto 변환 헬퍼(toChatMessageItemDto) 추가 및 접근권한(hasAccess) 계산 일원화
2025-08-25 14:01:10 +09:00
b3e7c00232 feat(chat): 이미지/유료(PPV) 메시지 도입 — 엔티티·서비스·DTO 확장 및 트리거 전송
- ChatMessageType(TEXT/IMAGE) 도입
- ChatMessage에 messageType/characterImage/imagePath/price 추가
- ChatMessageItemDto에 messageType/imageUrl/price/hasAccess 추가
- 캐릭터 답변 로직
  - 텍스트 메시지 항상 저장/전송
  - 트리거 일치 시 이미지 메시지 추가 저장/전송
  - 미보유 시 blur + price 스냅샷, 보유 시 원본 + price=null
- enterChatRoom/getChatMessages 응답에 확장된 필드 매핑 및 hasAccess 계산 반영
2025-08-23 05:34:02 +09:00
692e060f6d feat(character-image): 이미지 단독 구매 API 및 결제 연동 추가
- 구매 요청/응답 DTO 추가
- 미보유 시 캔 차감 및 구매 이력 저장
- 서명 URL(5분) 반환
2025-08-22 21:37:18 +09:00
2ac0a5f896 feat(character-image): 캐릭터 이미지 리스트
- isAdult 값 추가
2025-08-22 01:21:04 +09:00
f8be99547a fix: ImageBlurUtil.kt
- 블러 radius 200 -> 240
2025-08-21 21:18:29 +09:00
7dd585c3dd fix: ImageBlurUtil.kt
- 블러 radius 160 -> 200
2025-08-21 21:10:35 +09:00
7355949c1e fix: ImageBlurUtil.kt
- 블러 radius 100 -> 160
2025-08-21 20:54:44 +09:00
539b9fb2b2 fix: 유저/관리자 캐릭터 이미지 리스트
- 불필요한 Response 제거
2025-08-21 20:52:39 +09:00
77 changed files with 3783 additions and 321 deletions

View File

@@ -7,5 +7,5 @@ indent_size = 4
indent_style = space
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120
max_line_length = 130
tab_width = 4

View File

@@ -39,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -75,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -142,7 +148,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(order.isActive.isTrue)
.groupBy(
member.id,
@@ -230,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -251,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -281,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -301,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -331,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -351,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -382,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
@@ -9,12 +10,29 @@ import javax.persistence.OneToOne
@Entity
data class CreatorSettlementRatio(
val subsidy: Int,
val liveSettlementRatio: Int,
val contentSettlementRatio: Int,
val communitySettlementRatio: Int
var subsidy: Int,
var liveSettlementRatio: Int,
var contentSettlementRatio: Int,
var communitySettlementRatio: Int
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
var deletedAt: LocalDateTime? = null
fun softDelete() {
this.deletedAt = LocalDateTime.now()
}
fun restore() {
this.deletedAt = null
}
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
this.subsidy = subsidy
this.liveSettlementRatio = live
this.contentSettlementRatio = content
this.communitySettlementRatio = community
}
}

View File

@@ -4,6 +4,7 @@ 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.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
@@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
limit = pageable.pageSize.toLong()
)
)
@PostMapping("/update")
fun updateCreatorSettlementRatio(
@RequestBody request: CreateCreatorSettlementRatioRequest
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
@PostMapping("/delete/{memberId}")
fun deleteCreatorSettlementRatio(
@PathVariable memberId: Long
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
}

View File

@@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository
interface CreatorSettlementRatioRepository :
JpaRepository<CreatorSettlementRatio, Long>,
CreatorSettlementRatioQueryRepository
CreatorSettlementRatioQueryRepository {
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
}
interface CreatorSettlementRatioQueryRepository {
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
@@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory
.select(
QGetCreatorSettlementRatioItem(
member.id,
member.nickname,
creatorSettlementRatio.subsidy,
creatorSettlementRatio.liveSettlementRatio,
@@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
)
.from(creatorSettlementRatio)
.innerJoin(creatorSettlementRatio.member, member)
.where(creatorSettlementRatio.deletedAt.isNull)
.orderBy(creatorSettlementRatio.id.asc())
.offset(offset)
.limit(limit)
@@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory
.select(creatorSettlementRatio.id)
.from(creatorSettlementRatio)
.where(creatorSettlementRatio.deletedAt.isNull)
.fetch()
.size
}

View File

@@ -14,8 +14,6 @@ class CreatorSettlementRatioService(
) {
@Transactional
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creatorSettlementRatio = request.toEntity()
val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.")
@@ -23,10 +21,52 @@ class CreatorSettlementRatioService(
throw SodaException("잘못된 크리에이터 입니다.")
}
val existing = repository.findByMemberId(request.memberId)
if (existing != null) {
// revive if soft-deleted, then update values
existing.restore()
existing.updateValues(
request.subsidy,
request.liveSettlementRatio,
request.contentSettlementRatio,
request.communitySettlementRatio
)
repository.save(existing)
return
}
val creatorSettlementRatio = request.toEntity()
creatorSettlementRatio.member = creator
repository.save(creatorSettlementRatio)
}
@Transactional
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.")
if (creator.role != MemberRole.CREATOR) {
throw SodaException("잘못된 크리에이터 입니다.")
}
val existing = repository.findByMemberId(request.memberId)
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
existing.restore()
existing.updateValues(
request.subsidy,
request.liveSettlementRatio,
request.contentSettlementRatio,
request.communitySettlementRatio
)
repository.save(existing)
}
@Transactional
fun deleteCreatorSettlementRatio(memberId: Long) {
val existing = repository.findByMemberId(memberId)
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
existing.softDelete()
repository.save(existing)
}
@Transactional(readOnly = true)
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
val totalCount = repository.getCreatorSettlementRatioTotalCount()

View File

@@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse(
)
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val subsidy: Int,
val liveSettlementRatio: Int,

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/chat/calculate")
class AdminChatCalculateController(
private val service: AdminChatCalculateService
) {
@GetMapping("/characters")
fun getCharacterCalculate(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
pageable: Pageable
) = ApiResponse.ok(
service.getCharacterCalculate(
startDateStr,
endDateStr,
sort,
pageable.offset,
pageable.pageSize
)
)
}

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class AdminChatCalculateQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun getCharacterCalculate(
startUtc: LocalDateTime,
endInclusiveUtc: LocalDateTime,
sort: ChatCharacterCalculateSort,
offset: Long,
limit: Long
): List<ChatCharacterCalculateQueryData> {
val imageCanExpr = CaseBuilder()
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
.then(useCan.can.add(useCan.rewardCan))
.otherwise(0)
val messageCanExpr = CaseBuilder()
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
.then(useCan.can.add(useCan.rewardCan))
.otherwise(0)
val quotaCanExpr = CaseBuilder()
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
.then(useCan.can.add(useCan.rewardCan))
.otherwise(0)
val imageSum = imageCanExpr.sum()
val messageSum = messageCanExpr.sum()
val quotaSum = quotaCanExpr.sum()
val totalSum = imageSum.add(messageSum).add(quotaSum)
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
val c1 = QChatCharacter("c1")
val c2 = QChatCharacter("c2")
val characterIdExpr = c1.id.coalesce(c2.id)
val characterNameAgg = Expressions.stringTemplate(
"coalesce(max({0}), max({1}), '')",
c1.name,
c2.name
)
val characterImagePathAgg = Expressions.stringTemplate(
"coalesce(max({0}), max({1}))",
c1.imagePath,
c2.imagePath
)
val query = queryFactory
.select(
Projections.constructor(
ChatCharacterCalculateQueryData::class.java,
characterIdExpr,
characterNameAgg,
characterImagePathAgg.prepend("/").prepend(imageHost),
imageSum,
messageSum,
quotaSum
)
)
.from(useCan)
.leftJoin(useCan.characterImage, characterImage)
.leftJoin(characterImage.chatCharacter, c1)
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
.where(
useCan.isRefund.isFalse
.and(
useCan.canUsage.`in`(
CanUsage.CHARACTER_IMAGE_PURCHASE,
CanUsage.CHAT_MESSAGE_PURCHASE,
CanUsage.CHAT_QUOTA_PURCHASE
)
)
.and(useCan.createdAt.goe(startUtc))
.and(useCan.createdAt.loe(endInclusiveUtc))
)
.groupBy(characterIdExpr)
when (sort) {
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
query.orderBy(totalSum.desc(), characterIdExpr.desc())
ChatCharacterCalculateSort.LATEST_DESC ->
query.orderBy(characterIdExpr.desc(), totalSum.desc())
}
return query
.offset(offset)
.limit(limit)
.fetch()
}
fun getCharacterCalculateTotalCount(
startUtc: LocalDateTime,
endInclusiveUtc: LocalDateTime
): Int {
val c1 = QChatCharacter("c1")
val c2 = QChatCharacter("c2")
val characterIdExpr = c1.id.coalesce(c2.id)
return queryFactory
.select(characterIdExpr)
.from(useCan)
.leftJoin(useCan.characterImage, characterImage)
.leftJoin(characterImage.chatCharacter, c1)
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
.where(
useCan.isRefund.isFalse
.and(
useCan.canUsage.`in`(
CanUsage.CHARACTER_IMAGE_PURCHASE,
CanUsage.CHAT_MESSAGE_PURCHASE,
CanUsage.CHAT_QUOTA_PURCHASE
)
)
.and(useCan.createdAt.goe(startUtc))
.and(useCan.createdAt.loe(endInclusiveUtc))
)
.groupBy(characterIdExpr)
.fetch()
.size
}
}

View File

@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminChatCalculateService(
private val repository: AdminChatCalculateQueryRepository
) {
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
@Transactional(readOnly = true)
fun getCharacterCalculate(
startDateStr: String,
endDateStr: String,
sort: ChatCharacterCalculateSort,
offset: Long,
pageSize: Int
): ChatCharacterCalculateResponse {
// 날짜 유효성 검증 (KST 기준)
val startDate = LocalDate.parse(startDateStr, dateFormatter)
val endDate = LocalDate.parse(endDateStr, dateFormatter)
val todayKst = LocalDate.now(kstZone)
if (endDate.isAfter(todayKst)) {
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
}
if (startDate.isAfter(endDate)) {
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
}
if (endDate.isAfter(startDate.plusMonths(6))) {
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
}
val startUtc = startDateStr.convertLocalDateTime()
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
val items = rows.map { it.toItem() }
return ChatCharacterCalculateResponse(totalCount, items)
}
}

View File

@@ -0,0 +1,62 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
import com.fasterxml.jackson.annotation.JsonProperty
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
import java.math.RoundingMode
// 정렬 옵션
enum class ChatCharacterCalculateSort {
TOTAL_SALES_DESC,
LATEST_DESC
}
// QueryDSL 프로젝션용 DTO
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
val characterId: Long,
val characterName: String,
val characterImagePath: String?,
val imagePurchaseCan: Int?,
val messagePurchaseCan: Int?,
val quotaPurchaseCan: Int?
)
// 응답 DTO (아이템)
data class ChatCharacterCalculateItem(
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("characterImage") val characterImage: String?,
@JsonProperty("name") val name: String,
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
@JsonProperty("totalCan") val totalCan: Int,
@JsonProperty("totalKrw") val totalKrw: Int,
@JsonProperty("settlementKrw") val settlementKrw: Int
)
// 응답 DTO (전체)
data class ChatCharacterCalculateResponse(
@JsonProperty("totalCount") val totalCount: Int,
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
)
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
val image = imagePurchaseCan ?: 0
val message = messagePurchaseCan ?: 0
val quota = quotaPurchaseCan ?: 0
val total = image + message + quota
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
return ChatCharacterCalculateItem(
characterId = characterId,
characterImage = characterImagePath,
name = characterName,
imagePurchaseCan = image,
messagePurchaseCan = message,
quotaPurchaseCan = quota,
totalCan = total,
totalKrw = totalKrw.toInt(),
settlementKrw = settlement.toInt()
)
}

View File

@@ -3,9 +3,11 @@ package kr.co.vividnext.sodalive.admin.chat.character
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
@@ -18,8 +20,6 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -39,6 +39,7 @@ class AdminChatCharacterController(
private val service: ChatCharacterService,
private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService,
@Value("\${weraser.api-key}")
private val apiKey: String,
@@ -70,6 +71,26 @@ class AdminChatCharacterController(
ApiResponse.ok(response)
}
/**
* 캐릭터 검색(관리자)
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
* - 페이징 지원: page, size 파라미터 사용
*/
@GetMapping("/search")
fun searchCharacters(
@RequestParam("searchTerm") searchTerm: String,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminService.createDefaultPageRequest(page, size)
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
val response = ChatCharacterSearchListPageResponse(
totalCount = resultPage.totalElements,
content = resultPage.content
)
ApiResponse.ok(response)
}
/**
* 캐릭터 상세 정보 조회 API
*
@@ -86,11 +107,6 @@ class AdminChatCharacterController(
}
@PostMapping("/register")
@Retryable(
value = [Exception::class],
maxAttempts = 3,
backoff = Backoff(delay = 1000)
)
fun registerCharacter(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
@@ -144,6 +160,11 @@ class AdminChatCharacterController(
chatCharacter.imagePath = imagePath
service.saveChatCharacter(chatCharacter)
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
}
ApiResponse.ok(null)
}
@@ -236,11 +257,6 @@ class AdminChatCharacterController(
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
*/
@PutMapping("/update")
@Retryable(
value = [Exception::class],
maxAttempts = 3,
backoff = Backoff(delay = 1000)
)
fun updateCharacter(
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") requestString: String
@@ -259,7 +275,8 @@ class AdminChatCharacterController(
val hasDbOnlyChanges =
request.originalTitle != null ||
request.originalLink != null ||
request.characterType != null
request.characterType != null ||
request.originalWorkId != null
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
throw SodaException("변경된 데이터가 없습니다.")
@@ -298,6 +315,12 @@ class AdminChatCharacterController(
request = request
)
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
// 서비스에서 유효성 검증 및 저장까지 처리
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
}
ApiResponse.ok(null)
}

View File

@@ -0,0 +1,82 @@
package kr.co.vividnext.sodalive.admin.chat.character.curation
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value
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.RestController
@RestController
@RequestMapping("/admin/chat/character/curation")
@PreAuthorize("hasRole('ADMIN')")
class CharacterCurationAdminController(
private val service: CharacterCurationAdminService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@GetMapping("/list")
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
ApiResponse.ok(service.listAll())
@GetMapping("/{curationId}/characters")
fun listCharacters(
@PathVariable curationId: Long
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
val characters = service.listCharacters(curationId)
val items = characters.map {
CharacterCurationCharacterItemResponse(
id = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
return ApiResponse.ok(items)
}
@PostMapping("/register")
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
ApiResponse.ok(service.register(request).id)
@PutMapping("/update")
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
ApiResponse.ok(service.update(request).id)
@DeleteMapping("/{curationId}")
fun delete(@PathVariable curationId: Long) =
ApiResponse.ok(service.softDelete(curationId))
@PutMapping("/reorder")
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
ApiResponse.ok(service.reorder(request.ids))
@PostMapping("/{curationId}/characters")
fun addCharacter(
@PathVariable curationId: Long,
@RequestBody request: CharacterCurationAddCharacterRequest
): ApiResponse<Boolean> {
val ids = request.characterIds.filter { it > 0 }.distinct()
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
service.addCharacters(curationId, ids)
return ApiResponse.ok(true)
}
@DeleteMapping("/{curationId}/characters/{characterId}")
fun removeCharacter(
@PathVariable curationId: Long,
@PathVariable characterId: Long
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
@PutMapping("/{curationId}/characters/reorder")
fun reorderCharacters(
@PathVariable curationId: Long,
@RequestBody request: CharacterCurationReorderCharactersRequest
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.admin.chat.character.curation
data class CharacterCurationRegisterRequest(
val title: String,
val isAdult: Boolean = false,
val isActive: Boolean = true
)
data class CharacterCurationUpdateRequest(
val id: Long,
val title: String? = null,
val isAdult: Boolean? = null,
val isActive: Boolean? = null
)
data class CharacterCurationOrderUpdateRequest(
val ids: List<Long>
)
data class CharacterCurationAddCharacterRequest(
val characterIds: List<Long>
)
data class CharacterCurationReorderCharactersRequest(
val characterIds: List<Long>
)
data class CharacterCurationListItemResponse(
val id: Long,
val title: String,
val isAdult: Boolean,
val isActive: Boolean,
val characterCount: Int
)
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
// id, name, description, 이미지 URL
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
data class CharacterCurationCharacterItemResponse(
val id: Long,
val name: String,
val description: String,
val imageUrl: String
)

View File

@@ -0,0 +1,153 @@
package kr.co.vividnext.sodalive.admin.chat.character.curation
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CharacterCurationAdminService(
private val curationRepository: CharacterCurationRepository,
private val mappingRepository: CharacterCurationMappingRepository,
private val characterRepository: ChatCharacterRepository
) {
@Transactional
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
val curation = CharacterCuration(
title = request.title,
isAdult = request.isAdult,
isActive = request.isActive,
sortOrder = sortOrder
)
return curationRepository.save(curation)
}
@Transactional
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
val curation = curationRepository.findById(request.id)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
request.title?.let { curation.title = it }
request.isAdult?.let { curation.isAdult = it }
request.isActive?.let { curation.isActive = it }
return curationRepository.save(curation)
}
@Transactional
fun softDelete(curationId: Long) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
curation.isActive = false
curationRepository.save(curation)
}
@Transactional
fun reorder(ids: List<Long>) {
ids.forEachIndexed { index, id ->
val curation = curationRepository.findById(id)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
curation.sortOrder = index + 1
curationRepository.save(curation)
}
}
@Transactional
fun addCharacters(curationId: Long, characterIds: List<Long>) {
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
val uniqueIds = characterIds.filter { it > 0 }.distinct()
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
val characterMap = characters.associateBy { it.id!! }
// 조회 결과에 존재하는 캐릭터만 유효
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
val existingMappings = mappingRepository.findByCuration(curation)
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
val toSave = mutableListOf<CharacterCurationMapping>()
validIds.forEach { id ->
if (!existingCharacterIds.contains(id)) {
val character = characterMap[id] ?: return@forEach
toSave += CharacterCurationMapping(
curation = curation,
chatCharacter = character,
sortOrder = nextOrder++
)
}
}
if (toSave.isNotEmpty()) {
mappingRepository.saveAll(toSave)
}
}
@Transactional
fun removeCharacter(curationId: Long, characterId: Long) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
val mappings = mappingRepository.findByCuration(curation)
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
mappingRepository.delete(target)
}
@Transactional
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
val mappings = mappingRepository.findByCuration(curation)
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
characterIds.forEachIndexed { index, cid ->
val mapping = mappingByCharacterId[cid]
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
mapping.sortOrder = index + 1
mappingRepository.save(mapping)
}
}
@Transactional(readOnly = true)
fun listAll(): List<CharacterCurationListItemResponse> {
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
if (curations.isEmpty()) return emptyList()
// DB 집계로 활성 캐릭터 수 카운트
val counts = mappingRepository.countActiveCharactersByCurations(curations)
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
return curations.map { curation ->
CharacterCurationListItemResponse(
id = curation.id!!,
title = curation.title,
isAdult = curation.isAdult,
isActive = curation.isActive,
characterCount = countByCurationId[curation.id!!] ?: 0
)
}
}
@Transactional(readOnly = true)
fun listCharacters(curationId: Long): List<ChatCharacter> {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
return mappings.map { it.chatCharacter }
}
}

View File

@@ -2,6 +2,10 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/**
* 관리자 캐릭터 상세 응답 DTO
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
*/
data class ChatCharacterDetailResponse(
val id: Long,
val characterUUID: String,
@@ -24,7 +28,8 @@ data class ChatCharacterDetailResponse(
val relationships: List<RelationshipResponse>,
val personalities: List<PersonalityResponse>,
val backgrounds: List<BackgroundResponse>,
val memories: List<MemoryResponse>
val memories: List<MemoryResponse>,
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
) {
companion object {
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
@@ -34,6 +39,20 @@ data class ChatCharacterDetailResponse(
chatCharacter.imagePath ?: ""
}
val ow = chatCharacter.originalWork
val originalWorkBrief = ow?.let {
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${it.imagePath}"
} else {
it.imagePath
}
OriginalWorkBriefResponse(
id = it.id!!,
imageUrl = owImage,
title = it.title
)
}
return ChatCharacterDetailResponse(
id = chatCharacter.id!!,
characterUUID = chatCharacter.characterUUID,
@@ -71,7 +90,8 @@ data class ChatCharacterDetailResponse(
},
memories = chatCharacter.memories.map {
MemoryResponse(it.title, it.content, it.emotion)
}
},
originalWork = originalWorkBrief
)
}
}
@@ -101,3 +121,12 @@ data class RelationshipResponse(
val relationshipType: String,
val currentStatus: String
)
/**
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
*/
data class OriginalWorkBriefResponse(
val id: Long,
val imageUrl: String?,
val title: String
)

View File

@@ -40,6 +40,7 @@ data class ChatCharacterRegisterRequest(
@JsonProperty("appearance") val appearance: String?,
@JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
@JsonProperty("characterType") val characterType: String? = null,
@JsonProperty("tags") val tags: List<String> = emptyList(),
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
@@ -75,6 +76,7 @@ data class ChatCharacterUpdateRequest(
@JsonProperty("appearance") val appearance: String? = null,
@JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
@JsonProperty("characterType") val characterType: String? = null,
@JsonProperty("isActive") val isActive: Boolean? = null,
@JsonProperty("tags") val tags: List<String>? = null,

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
/**
* 캐릭터 검색 결과 페이지 응답 DTO
*/
data class ChatCharacterSearchListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterListResponse>
)

View File

@@ -3,16 +3,16 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/**
* 캐릭터 검색 결과 응답 DTO
* 원작 연결된 캐릭터 결과 응답 DTO
*/
data class ChatCharacterSearchResponse(
data class OriginalWorkChatCharacterResponse(
val id: Long,
val name: String,
val imagePath: String?
) {
companion object {
fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse {
return ChatCharacterSearchResponse(
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
return OriginalWorkChatCharacterResponse(
id = character.id!!,
name = character.name,
imagePath = character.imagePath?.let { "$imageHost/$it" }
@@ -22,9 +22,9 @@ data class ChatCharacterSearchResponse(
}
/**
* 캐릭터 검색 결과 페이지 응답 DTO
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
*/
data class ChatCharacterSearchListPageResponse(
data class OriginalWorkChatCharacterListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterSearchResponse>
val content: List<OriginalWorkChatCharacterResponse>
)

View File

@@ -29,11 +29,9 @@ data class AdminCharacterImageResponse(
val characterId: Long,
val imagePriceCan: Long,
val messagePriceCan: Long,
val isAdult: Boolean,
val sortOrder: Int,
val active: Boolean,
val imageUrl: String,
val triggers: List<String>
val triggers: List<String>,
val isAdult: Boolean
) {
companion object {
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
@@ -46,11 +44,9 @@ data class AdminCharacterImageResponse(
characterId = entity.chatCharacter.id!!,
imagePriceCan = entity.imagePriceCan,
messagePriceCan = entity.messagePriceCan,
isAdult = entity.isAdult,
sortOrder = entity.sortOrder,
active = entity.isActive,
imageUrl = url,
triggers = entity.triggerMappings.map { it.tag.word }
triggers = entity.triggerMappings.map { it.tag.word },
isAdult = entity.isAdult
)
}
}

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.admin.chat.character.service
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
@@ -65,20 +64,15 @@ class AdminChatCharacterService(
}
/**
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반)
*
* @param searchTerm 검색어
* @param pageable 페이징 정보
* @param imageHost 이미지 호스트 URL
* @return 검색된 캐릭터 목록 (페이징)
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
*/
@Transactional(readOnly = true)
fun searchCharacters(
searchTerm: String,
pageable: Pageable,
imageHost: String = ""
): Page<ChatCharacterSearchResponse> {
): Page<ChatCharacterListResponse> {
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
return characters.map { ChatCharacterSearchResponse.from(it, imageHost) }
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
}
}

View File

@@ -0,0 +1,199 @@
package kr.co.vividnext.sodalive.admin.chat.original
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
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.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
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
/**
* 원작(오리지널 작품) 관리자 API
* - 원작 등록/수정/삭제
* - 원작과 캐릭터 연결(배정) 및 해제
*/
@RestController
@RequestMapping("/admin/chat/original")
@PreAuthorize("hasRole('ADMIN')")
class AdminOriginalWorkController(
private val originalWorkService: AdminOriginalWorkService,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 원작 등록
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
*/
@PostMapping("/register")
fun register(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
// 서비스 계층을 통해 원작을 생성
val saved = originalWorkService.createOriginalWork(request)
// 이미지 업로드 후 이미지 경로 업데이트
val imagePath = uploadImage(saved.id!!, image)
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
ApiResponse.ok(null)
}
/**
* 원작 수정
* - 이미지가 있으면 교체, 없으면 유지
*/
@PutMapping("/update")
fun update(
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
val imagePath = if (image != null && !image.isEmpty) {
uploadImage(request.id, image)
} else {
null
}
originalWorkService.updateOriginalWork(request, imagePath)
ApiResponse.ok(null)
}
/**
* 원작 삭제
*/
@DeleteMapping("/{id}")
fun delete(@PathVariable id: Long) = run {
originalWorkService.deleteOriginalWork(id)
ApiResponse.ok(null)
}
/**
* 원작 목록(페이징)
*/
@GetMapping("/list")
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
}
/**
* 원작 검색(관리자)
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
* - 페이징 제거: 전체 목록 반환
*/
@GetMapping("/search")
fun search(
@RequestParam("searchTerm") searchTerm: String
) = run {
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
ApiResponse.ok(content)
}
/**
* 원작 상세
*/
@GetMapping("/{id}")
fun detail(@PathVariable id: Long) = run {
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
}
/**
* 원작에 기존 캐릭터들을 배정
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
*/
@PostMapping("/{id}/assign-characters")
fun assignCharacters(
@PathVariable id: Long,
@RequestBody body: OriginalWorkAssignCharactersRequest
) = run {
originalWorkService.assignCharacters(id, body.characterIds)
ApiResponse.ok(null)
}
/**
* 원작에서 캐릭터들 해제
* - 캐릭터들의 originalWork를 null로 설정
*/
@PostMapping("/{id}/unassign-characters")
fun unassignCharacters(
@PathVariable id: Long,
@RequestBody body: OriginalWorkAssignCharactersRequest
) = run {
originalWorkService.unassignCharacters(id, body.characterIds)
ApiResponse.ok(null)
}
/**
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
* - 활성 캐릭터만 포함
* - 응답 항목: 캐릭터 이미지(URL), 이름
*/
@GetMapping("/{id}/characters")
fun listCharactersOfOriginal(
@PathVariable id: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
ApiResponse.ok(
OriginalWorkChatCharacterListPageResponse(
totalCount = pageRes.totalElements,
content = content
)
)
}
/** 이미지 업로드 공통 처리 */
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
}

View File

@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.admin.chat.original.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.original.OriginalWork
/**
* 원작 등록 요청 DTO
*/
data class OriginalWorkRegisterRequest(
@JsonProperty("title") val title: String,
@JsonProperty("contentType") val contentType: String,
@JsonProperty("category") val category: String,
@JsonProperty("isAdult") val isAdult: Boolean = false,
@JsonProperty("description") val description: String = "",
@JsonProperty("originalWork") val originalWork: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("writer") val writer: String? = null,
@JsonProperty("studio") val studio: String? = null,
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
@JsonProperty("tags") val tags: List<String>? = null
)
/**
* 원작 수정 요청 DTO (부분 수정 가능)
*/
data class OriginalWorkUpdateRequest(
@JsonProperty("id") val id: Long,
@JsonProperty("title") val title: String? = null,
@JsonProperty("contentType") val contentType: String? = null,
@JsonProperty("category") val category: String? = null,
@JsonProperty("isAdult") val isAdult: Boolean? = null,
@JsonProperty("description") val description: String? = null,
@JsonProperty("originalWork") val originalWork: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("writer") val writer: String? = null,
@JsonProperty("studio") val studio: String? = null,
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
@JsonProperty("tags") val tags: List<String>? = null
)
/**
* 원작 상세/목록 응답 DTO
*/
data class OriginalWorkResponse(
val id: Long,
val title: String,
val contentType: String,
val category: String,
val isAdult: Boolean,
val description: String,
val originalWork: String?,
val originalLink: String?,
val writer: String?,
val studio: String?,
val originalLinks: List<String>,
val tags: List<String>,
val imageUrl: String?
) {
companion object {
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}"
} else {
entity.imagePath
}
return OriginalWorkResponse(
id = entity.id!!,
title = entity.title,
contentType = entity.contentType,
category = entity.category,
isAdult = entity.isAdult,
description = entity.description,
originalWork = entity.originalWork,
originalLink = entity.originalLink,
writer = entity.writer,
studio = entity.studio,
originalLinks = entity.originalLinks.map { it.url },
tags = entity.tagMappings.map { it.tag.tag },
imageUrl = fullImagePath
)
}
}
}
data class OriginalWorkPageResponse(
val totalCount: Long,
val content: List<OriginalWorkResponse>
)
/**
* 원작-캐릭터 연결/해제 요청 DTO
*/
data class OriginalWorkAssignCharactersRequest(
@JsonProperty("characterIds") val characterIds: List<Long>
)

View File

@@ -0,0 +1,213 @@
package kr.co.vividnext.sodalive.admin.chat.original.service
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 원작(오리지널 작품) 관련 관리자 서비스
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
*/
@Service
class AdminOriginalWorkService(
private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val originalWorkTagRepository: OriginalWorkTagRepository
) {
/** 원작 등록 (중복 제목 방지 포함) */
@Transactional
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
}
val entity = OriginalWork(
title = request.title,
contentType = request.contentType,
category = request.category,
isAdult = request.isAdult,
description = request.description,
originalWork = request.originalWork,
originalLink = request.originalLink,
writer = request.writer,
studio = request.studio
)
// 링크 리스트 생성
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
}
// 태그 매핑 생성 (기존 태그 재사용)
request.tags?.let { tags ->
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
normalized.forEach { t ->
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
}
}
return originalWorkRepository.save(entity)
}
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
@Transactional
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
request.title?.let { ow.title = it }
request.contentType?.let { ow.contentType = it }
request.category?.let { ow.category = it }
request.isAdult?.let { ow.isAdult = it }
request.description?.let { ow.description = it }
request.originalWork?.let { ow.originalWork = it }
request.originalLink?.let { ow.originalLink = it }
request.writer?.let { ow.writer = it }
request.studio?.let { ow.studio = it }
// 링크 리스트가 전달되면 기존 것을 교체
request.originalLinks?.let { links ->
ow.originalLinks.clear()
links.filter { it.isNotBlank() }.forEach { link ->
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
}
}
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
request.tags?.let { tags ->
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
val current = ow.tagMappings.map { it.tag.tag }.toSet()
val toAdd = normalized.minus(current)
val toRemove = current.minus(normalized)
if (toRemove.isNotEmpty()) {
val itr = ow.tagMappings.iterator()
while (itr.hasNext()) {
val m = itr.next()
if (toRemove.contains(m.tag.tag)) {
itr.remove() // orphanRemoval=true로 매핑 삭제
}
}
}
if (toAdd.isNotEmpty()) {
toAdd.forEach { t ->
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
}
}
}
if (imagePath != null) {
ow.imagePath = imagePath
}
return originalWorkRepository.save(ow)
}
/** 원작 이미지 경로만 별도 갱신 */
@Transactional
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
ow.imagePath = imagePath
return originalWorkRepository.save(ow)
}
/** 원작 삭제 (소프트 삭제) */
@Transactional
fun deleteOriginalWork(id: Long) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
ow.isDeleted = true
originalWorkRepository.save(ow)
}
/** 원작 상세 조회 (소프트 삭제 제외) */
@Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
}
/** 원작 페이징 조회 */
@Transactional(readOnly = true)
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 100 -> 100
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return originalWorkRepository.findByIsDeletedFalse(pageable)
}
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
@Transactional(readOnly = true)
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
// 원작 존재 및 소프트 삭제 여부 확인
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 100 -> 100
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
}
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
@Transactional(readOnly = true)
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
return originalWorkRepository.searchNoPaging(searchTerm)
}
/** 원작에 기존 캐릭터들을 배정 */
@Transactional
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = ow }
chatCharacterRepository.saveAll(characters)
}
/** 원작에서 캐릭터들 해제 */
@Transactional
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
// 원작 존재 확인 (소프트 삭제 제외)
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = null }
chatCharacterRepository.saveAll(characters)
}
/** 단일 캐릭터를 지정 원작에 배정 */
@Transactional
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
val character = chatCharacterRepository.findById(characterId)
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
if (originalWorkId == 0L) {
character.originalWork = null
} else {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
character.originalWork = ow
}
chatCharacterRepository.save(character)
}
}

View File

@@ -74,6 +74,8 @@ class CanService(private val repository: CanRepository) {
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
}
val createdAt = it.createdAt!!

View File

@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
@@ -37,6 +38,8 @@ class CanPaymentService(
memberId: Long,
needCan: Int,
canUsage: CanUsage,
chatRoomId: Long? = null,
characterId: Long? = null,
isSecret: Boolean = false,
liveRoom: LiveRoom? = null,
order: Order? = null,
@@ -109,6 +112,14 @@ class CanPaymentService(
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) {
useCan.member = member
useCan.chatRoomId = chatRoomId
useCan.characterId = characterId
} else if (canUsage == CanUsage.CHAT_ROOM_RESET) {
useCan.member = member
useCan.chatRoomId = chatRoomId
useCan.characterId = characterId
} else {
throw SodaException("잘못된 요청입니다.")
}
@@ -327,4 +338,98 @@ class CanPaymentService(
chargeRepository.save(charge)
}
}
@Transactional
fun spendCanForCharacterImage(
memberId: Long,
needCan: Int,
image: CharacterImage,
container: String
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val useRewardCan = spendRewardCan(member, needCan, container)
val useChargeCan = if (needCan - useRewardCan.total > 0) {
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
} else {
null
}
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
throw SodaException(
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
"캔이 부족합니다. 충전 후 이용해 주세요."
)
}
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
}
val useCan = UseCan(
canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total,
isSecret = false
)
useCan.member = member
useCan.characterImage = image
useCanRepository.save(useCan)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
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)
}
@Transactional
fun spendCanForChatMessage(
memberId: Long,
needCan: Int,
message: kr.co.vividnext.sodalive.chat.room.ChatMessage,
container: String
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val useRewardCan = spendRewardCan(member, needCan, container)
val useChargeCan = if (needCan - useRewardCan.total > 0) {
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
} else {
null
}
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
throw SodaException(
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
"캔이 부족합니다. 충전 후 이용해 주세요."
)
}
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
}
val useCan = UseCan(
canUsage = CanUsage.CHAT_MESSAGE_PURCHASE,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total,
isSecret = false
)
useCan.member = member
useCan.chatMessage = message
// 이미지 메시지의 경우 이미지 연관도 함께 기록
message.characterImage?.let { img ->
useCan.characterImage = img
}
useCanRepository.save(useCan)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
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

@@ -11,5 +11,7 @@ enum class CanUsage {
ALARM_SLOT,
AUDITION_VOTE,
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
}

View File

@@ -30,7 +30,11 @@ data class UseCan(
var isRefund: Boolean = false,
val isSecret: Boolean = false
val isSecret: Boolean = false,
// 채팅 연동을 위한 식별자 (옵션)
var chatRoomId: Long? = null,
var characterId: Long? = null
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Column
@@ -7,6 +8,8 @@ import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
@@ -37,20 +40,26 @@ class ChatCharacter(
var speechPattern: String? = null,
// 대화 스타일
@Column(columnDefinition = "TEXT")
var speechStyle: String? = null,
// 외모 설명
@Column(columnDefinition = "TEXT")
var appearance: String? = null,
// 원작 (optional)
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
@Column(nullable = true)
var originalTitle: String? = null,
// 원작 링크 (optional)
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
@Column(nullable = true)
var originalLink: String? = null,
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_work_id")
var originalWork: OriginalWork? = null,
// 캐릭터 유형
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@@ -60,16 +69,16 @@ class ChatCharacter(
) : BaseEntity() {
var imagePath: String? = null
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var personalities: MutableList<ChatCharacterPersonality> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var backgrounds: MutableList<ChatCharacterBackground> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var relationships: MutableList<ChatCharacterRelationship> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)

View File

@@ -18,7 +18,7 @@ class ChatCharacterBackground(
// 배경 설명
@Column(columnDefinition = "TEXT", nullable = false)
val description: String,
var description: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_character_id")

View File

@@ -18,10 +18,10 @@ class ChatCharacterMemory(
// 기억 내용
@Column(columnDefinition = "TEXT", nullable = false)
val content: String,
var content: String,
// 감정
val emotion: String,
var emotion: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_character_id")

View File

@@ -18,7 +18,7 @@ class ChatCharacterPersonality(
// 성격 특성 설명
@Column(columnDefinition = "TEXT", nullable = false)
val description: String,
var description: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_character_id")

View File

@@ -22,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
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
@RestController
@@ -31,6 +32,7 @@ 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,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -56,16 +58,29 @@ class ChatCharacterController(
chatRoomService.listMyChatRooms(member, 0, 10)
.map { room ->
RecentCharacter(
roomId = room.chatRoomId,
characterId = room.characterId,
name = room.title,
imageUrl = room.imageUrl
)
}
}
// 인기 캐릭터 조회 (현재는 빈 리스트)
// 인기 캐릭터 조회
val popularCharacters = service.getPopularCharacters()
.map {
// 최근 등록된 캐릭터 리스트 조회
val newCharacters = service.getRecentCharactersPage(
page = 0,
size = 50
).content
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
.map { agg ->
CurationSection(
characterCurationId = agg.curation.id!!,
title = agg.curation.title,
characters = agg.characters.map {
Character(
characterId = it.id!!,
name = it.name,
@@ -73,21 +88,9 @@ class ChatCharacterController(
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
// 최신 캐릭터 조회 (최대 10개)
val newCharacters = service.getNewCharacters(10)
.map {
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
// 큐레이션 섹션 (현재는 빈 리스트)
val curationSections = emptyList<CurationSection>()
// 응답 생성
ApiResponse.ok(
CharacterMainResponse(
@@ -160,6 +163,8 @@ class ChatCharacterController(
name = character.name,
description = character.description,
mbti = character.mbti,
gender = character.gender,
age = character.age,
imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}",
personalities = personality,
backgrounds = background,
@@ -173,4 +178,19 @@ class ChatCharacterController(
)
)
}
/**
* 최근 등록된 캐릭터 전체보기
* - 기준: 2주 이내 등록된 캐릭터만 페이징 조회
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
*/
@GetMapping("/recent")
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
ApiResponse.ok(
service.getRecentCharactersPage(
page = page ?: 0,
size = 20
)
)
}
}

View File

@@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.chat.character.curation
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
class CharacterCuration(
@Column(nullable = false)
var title: String,
// 19금 여부
@Column(nullable = false)
var isAdult: Boolean = false,
// 활성화 여부 (소프트 삭제)
@Column(nullable = false)
var isActive: Boolean = true,
// 정렬 순서 (낮을수록 먼저)
@Column(nullable = false)
var sortOrder: Int = 0
) : BaseEntity() {
@OneToMany(mappedBy = "curation", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var characterMappings: MutableList<CharacterCurationMapping> = mutableListOf()
}
@Entity
class CharacterCurationMapping(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "curation_id")
var curation: CharacterCuration,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_id")
var chatCharacter: ChatCharacter,
// 정렬 순서 (낮을수록 먼저)
@Column(nullable = false)
var sortOrder: Int = 0
) : BaseEntity()

View File

@@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.chat.character.curation
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CharacterCurationQueryService(
private val curationRepository: CharacterCurationRepository,
private val mappingRepository: CharacterCurationMappingRepository
) {
data class CurationAgg(
val curation: CharacterCuration,
val characters: List<ChatCharacter>
)
@Transactional(readOnly = true)
fun getActiveCurationsWithCharacters(): List<CurationAgg> {
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
if (curations.isEmpty()) return emptyList()
// 매핑 + 캐릭터를 한 번에 조회(ch.isActive = true 필터 적용)하여 N+1 해소
val mappings = mappingRepository
.findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(curations)
val charactersByCurationId: Map<Long, List<ChatCharacter>> = mappings
.groupBy { it.curation.id!! }
.mapValues { (_, list) -> list.map { it.chatCharacter } }
return curations.map { curation ->
val characters = charactersByCurationId[curation.id!!] ?: emptyList()
CurationAgg(curation, characters)
}
}
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.chat.character.curation.repository
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface CharacterCurationMappingRepository : JpaRepository<CharacterCurationMapping, Long> {
fun findByCuration(curation: CharacterCuration): List<CharacterCurationMapping>
@Query(
"select m from CharacterCurationMapping m " +
"join fetch m.chatCharacter ch " +
"where m.curation in :curations and ch.isActive = true " +
"order by m.curation.id asc, m.sortOrder asc"
)
fun findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(
@Param("curations") curations: List<CharacterCuration>
): List<CharacterCurationMapping>
@Query(
"select m from CharacterCurationMapping m " +
"join fetch m.chatCharacter ch " +
"where m.curation = :curation " +
"order by m.sortOrder asc"
)
fun findByCurationWithCharacterOrderBySortOrderAsc(
@Param("curation") curation: CharacterCuration
): List<CharacterCurationMapping>
interface CharacterCountPerCuration {
val curationId: Long
val count: Long
}
@Query(
"select m.curation.id as curationId, count(m.id) as count " +
"from CharacterCurationMapping m join m.chatCharacter ch " +
"where m.curation in :curations and ch.isActive = true " +
"group by m.curation.id"
)
fun countActiveCharactersByCurations(
@Param("curations") curations: List<CharacterCuration>
): List<CharacterCountPerCuration>
}

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.chat.character.curation.repository
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface CharacterCurationRepository : JpaRepository<CharacterCuration, Long> {
fun findByIsActiveTrueOrderBySortOrderAsc(): List<CharacterCuration>
@Query("SELECT MAX(c.sortOrder) FROM CharacterCuration c WHERE c.isActive = true")
fun findMaxSortOrder(): Int?
}

View File

@@ -8,6 +8,8 @@ data class CharacterDetailResponse(
val name: String,
val description: String,
val mbti: String?,
val gender: String?,
val age: Int?,
val imageUrl: String,
val personalities: CharacterPersonalityResponse?,
val backgrounds: CharacterBackgroundResponse?,

View File

@@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.dto
import com.fasterxml.jackson.annotation.JsonProperty
data class CharacterMainResponse(
val banners: List<CharacterBannerResponse>,
val recentCharacters: List<RecentCharacter>,
@@ -15,14 +17,14 @@ data class CurationSection(
)
data class Character(
val characterId: Long,
val name: String,
val description: String,
val imageUrl: String
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("name") val name: String,
@JsonProperty("description") val description: String,
@JsonProperty("imageUrl") val imageUrl: String
)
data class RecentCharacter(
val roomId: Long,
val characterId: Long,
val name: String,
val imageUrl: String
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.chat.character.dto
/**
* 최근 등록된 캐릭터 전체보기 페이지 응답 DTO
*/
data class RecentCharactersResponse(
val totalCount: Long,
val content: List<Character>
)

View File

@@ -1,8 +1,11 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListItemResponse
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListResponse
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseRequest
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
@@ -10,6 +13,8 @@ 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.PostMapping
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,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController
class CharacterImageController(
private val imageService: CharacterImageService,
private val imageCloudFront: ImageContentCloudFront,
private val canPaymentService: CanPaymentService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -34,30 +40,72 @@ class CharacterImageController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val pageSize = if (size <= 0) 20 else minOf(size, 20)
val pageable = PageRequest.of(page, pageSize)
val pageResult = imageService.pageActiveByCharacter(characterId, pageable)
val totalCount = pageResult.totalElements
// 전체 활성 이미지 수(프로필 제외) 파악을 위해 최소 페이지 조회
val totalActiveElements = imageService.pageActiveByCharacter(characterId, PageRequest.of(0, 1)).totalElements
// 프로필 이미지는 무료로 볼 수 있으므로 보유 개수에도 +1 반영
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) + 1
// 현재 요구사항 기준 '무료=보유'로 계산 (구매 이력은 추후 확장)
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!)
val totalCount = totalActiveElements + 1 // 프로필 포함
val startIndex = page * pageSize
if (startIndex >= totalCount) {
return@run ApiResponse.ok(
CharacterImageListResponse(
totalCount = totalCount,
ownedCount = ownedCount,
items = emptyList()
)
)
}
val endExclusive = kotlin.math.min(startIndex + pageSize, totalCount.toInt())
val pageLength = endExclusive - startIndex
// 프로필 이미지 구성(맨 앞)
val profilePath = imageService.getCharacterImagePath(characterId) ?: "profile/default-profile.png"
val profileItem = CharacterImageListItemResponse(
id = 0L,
imageUrl = "$imageHost/$profilePath",
isOwned = true,
imagePriceCan = 0L,
sortOrder = 0
)
// 활성 이미지 offset/limit 계산 (결합 리스트 [프로필] + activeImages)
val activeOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong()
val activeLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong()
val expiration = 5L * 60L * 1000L // 5분
val items = pageResult.content.map { img ->
val activeImages = if (activeLimit > 0) {
imageService.pageActiveByCharacterOffset(
characterId,
activeOffset,
activeLimit
)
} else {
emptyList()
}
val items = buildList {
if (startIndex == 0 && pageLength > 0) add(profileItem)
activeImages.forEach { img ->
val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!)
val url = if (isOwned) {
imageCloudFront.generateSignedURL(img.imagePath, expiration)
} else {
"$imageHost/${img.blurImagePath}"
}
add(
CharacterImageListItemResponse(
id = img.id!!,
imageUrl = url,
isOwned = isOwned,
imagePriceCan = img.imagePriceCan,
isAdult = img.isAdult,
sortOrder = img.sortOrder
)
)
}
}
ApiResponse.ok(
@@ -68,4 +116,111 @@ class CharacterImageController(
)
)
}
@GetMapping("/my-list")
fun myList(
@RequestParam characterId: Long,
@RequestParam(required = false, defaultValue = "0") page: Int,
@RequestParam(required = false, defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val pageSize = if (size <= 0) 20 else minOf(size, 20)
val expiration = 5L * 60L * 1000L // 5분
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!)
val totalCount = ownedCount + 1 // 프로필 포함
// 빈 페이지 요청 처리
val startIndex = page * pageSize
if (startIndex >= totalCount) {
return@run ApiResponse.ok(
CharacterImageListResponse(
totalCount = totalCount,
ownedCount = ownedCount,
items = emptyList()
)
)
}
val endExclusive = kotlin.math.min(startIndex + pageSize, totalCount.toInt())
val pageLength = endExclusive - startIndex
// 프로필 이미지 경로 및 아이템
val profilePath = imageService.getCharacterImagePath(characterId) ?: "profile/default-profile.png"
val profileItem = CharacterImageListItemResponse(
id = 0L,
imageUrl = "$imageHost/$profilePath",
isOwned = true,
imagePriceCan = 0L,
sortOrder = 0
)
// 보유 이미지의 오프셋/리밋 계산 (결합 리스트 [프로필] + ownedImages)
val ownedOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong()
val ownedLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong()
val ownedImagesPage = if (ownedLimit > 0) {
imageService.pageOwnedActiveByCharacterForMember(characterId, member.id!!, ownedOffset, ownedLimit)
} else {
emptyList()
}
val items = buildList {
if (startIndex == 0 && pageLength > 0) add(profileItem)
ownedImagesPage.forEach { img ->
val url = imageCloudFront.generateSignedURL(img.imagePath, expiration)
add(
CharacterImageListItemResponse(
id = img.id!!,
imageUrl = url,
isOwned = true,
imagePriceCan = img.imagePriceCan,
sortOrder = img.sortOrder
)
)
}
}
ApiResponse.ok(
CharacterImageListResponse(
totalCount = totalCount,
ownedCount = ownedCount,
items = items
)
)
}
@PostMapping("/purchase")
fun purchase(
@RequestBody req: CharacterImagePurchaseRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val image = imageService.getById(req.imageId)
if (!image.isActive) throw SodaException("비활성화된 이미지입니다.")
val isOwned = (image.imagePriceCan == 0L) ||
imageService.isOwnedImageByMember(image.id!!, member.id!!)
if (!isOwned) {
val needCan = image.imagePriceCan.toInt()
if (needCan <= 0) throw SodaException("구매 가격이 잘못되었습니다.")
canPaymentService.spendCanForCharacterImage(
memberId = member.id!!,
needCan = needCan,
image = image,
container = req.container
)
}
val expiration = 5L * 60L * 1000L // 5분
val signedUrl = imageCloudFront.generateSignedURL(image.imagePath, expiration)
ApiResponse.ok(CharacterImagePurchaseResponse(imageUrl = signedUrl))
}
}

View File

@@ -1,5 +1,9 @@
package kr.co.vividnext.sodalive.chat.character.image
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
@@ -7,7 +11,7 @@ import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface CharacterImageRepository : JpaRepository<CharacterImage, Long> {
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage>
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(
@@ -23,3 +27,74 @@ interface CharacterImageRepository : JpaRepository<CharacterImage, Long> {
)
fun findMaxSortOrderByCharacterId(characterId: Long): Int
}
interface CharacterImageQueryRepository {
fun findOwnedActiveImagesByCharacterPaged(
characterId: Long,
memberId: Long,
offset: Long,
limit: Long
): List<CharacterImage>
fun findActiveImagesByCharacterPaged(
characterId: Long,
offset: Long,
limit: Long
): List<CharacterImage>
}
class CharacterImageQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : CharacterImageQueryRepository {
override fun findOwnedActiveImagesByCharacterPaged(
characterId: Long,
memberId: Long,
offset: Long,
limit: Long
): List<CharacterImage> {
val usages = listOf(CanUsage.CHAT_MESSAGE_PURCHASE, CanUsage.CHARACTER_IMAGE_PURCHASE)
val ci = QCharacterImage.characterImage
return queryFactory
.selectFrom(ci)
.where(
ci.chatCharacter.id.eq(characterId)
.and(ci.isActive.isTrue)
.and(
ci.imagePriceCan.eq(0L).or(
JPAExpressions
.selectOne()
.from(useCan)
.where(
useCan.member.id.eq(memberId)
.and(useCan.isRefund.isFalse)
.and(useCan.characterImage.id.eq(ci.id))
.and(useCan.canUsage.`in`(usages))
)
.exists()
)
)
)
.orderBy(ci.sortOrder.asc(), ci.id.asc())
.offset(offset)
.limit(limit)
.fetch()
}
override fun findActiveImagesByCharacterPaged(
characterId: Long,
offset: Long,
limit: Long
): List<CharacterImage> {
val ci = QCharacterImage.characterImage
return queryFactory
.selectFrom(ci)
.where(
ci.chatCharacter.id.eq(characterId)
.and(ci.isActive.isTrue)
)
.orderBy(ci.sortOrder.asc(), ci.id.asc())
.offset(offset)
.limit(limit)
.fetch()
}
}

View File

@@ -26,6 +26,16 @@ class CharacterImageService(
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable)
}
// 오프셋/리밋 조회(활성 이미지)
fun pageActiveByCharacterOffset(
characterId: Long,
offset: Long,
limit: Long
): List<CharacterImage> {
if (limit <= 0L) return emptyList()
return imageRepository.findActiveImagesByCharacterPaged(characterId, offset, limit)
}
// 구매 이력 + 무료로 계산된 보유 수
fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long {
val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L)
@@ -56,6 +66,23 @@ class CharacterImageService(
fun getById(id: Long): CharacterImage =
imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") }
fun getCharacterImagePath(characterId: Long): String? {
val character = characterRepository.findById(characterId)
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
return character.imagePath
}
// 보유한(무료+구매) 활성 이미지 페이징 조회
fun pageOwnedActiveByCharacterForMember(
characterId: Long,
memberId: Long,
offset: Long,
limit: Long
): List<CharacterImage> {
if (limit <= 0L) return emptyList()
return imageRepository.findOwnedActiveImagesByCharacterPaged(characterId, memberId, offset, limit)
}
@Transactional
fun registerImage(
characterId: Long,

View File

@@ -7,7 +7,6 @@ data class CharacterImageListItemResponse(
@JsonProperty("imageUrl") val imageUrl: String,
@JsonProperty("isOwned") val isOwned: Boolean,
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
@JsonProperty("isAdult") val isAdult: Boolean,
@JsonProperty("sortOrder") val sortOrder: Int
)

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.chat.character.image.dto
import com.fasterxml.jackson.annotation.JsonProperty
data class CharacterImagePurchaseRequest(
@JsonProperty("imageId") val imageId: Long,
@JsonProperty("container") val container: String
)
data class CharacterImagePurchaseResponse(
@JsonProperty("imageUrl") val imageUrl: String
)

View File

@@ -10,17 +10,29 @@ import org.springframework.stereotype.Repository
@Repository
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findByCharacterUUID(characterUUID: String): ChatCharacter?
fun findByName(name: String): ChatCharacter?
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter>
/**
* 활성화된 캐릭터를 생성일 기준 내림차순으로 조회
* 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회
*/
fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter>
@Query(
"""
SELECT c FROM ChatCharacter c
WHERE c.isActive = true AND c.createdAt >= :since
ORDER BY c.createdAt DESC
"""
)
fun findRecentSince(@Param("since") since: java.time.LocalDateTime, pageable: Pageable): Page<ChatCharacter>
/**
* 이름, 설명, MBTI, 태그로 캐릭터 검색
* 2주 이내(파라미터 since 이상) 활성 캐릭터 개수
*/
fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long
/**
* 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징
*/
@Query(
"""
@@ -61,4 +73,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
@Param("characterId") characterId: Long,
pageable: Pageable
): List<ChatCharacter>
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
}

View File

@@ -1,5 +1,8 @@
package kr.co.vividnext.sodalive.chat.character.service
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.chat.character.CharacterType
@@ -8,14 +11,20 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal
import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
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.repository.ChatCharacterGoalRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class ChatCharacterService(
@@ -23,24 +32,99 @@ class ChatCharacterService(
private val tagRepository: ChatCharacterTagRepository,
private val valueRepository: ChatCharacterValueRepository,
private val hobbyRepository: ChatCharacterHobbyRepository,
private val goalRepository: ChatCharacterGoalRepository
private val goalRepository: ChatCharacterGoalRepository,
private val popularCharacterQuery: PopularCharacterQuery,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회
* 현재는 채팅방 구현 전이므로 빈 리스트 반환
* UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
*/
@Transactional(readOnly = true)
fun getPopularCharacters(): List<ChatCharacter> {
// 채팅방 구현 전이므로 빈 리스트 반환
return emptyList()
@Cacheable(
cacheNames = ["popularCharacters_24h"],
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey"
)
fun getPopularCharacters(limit: Long = 20): List<Character> {
val window = RankingWindowCalculator.now("popular-character")
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
val list = loadCharactersInOrder(topIds)
return list.map {
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
}
private fun loadCharactersInOrder(ids: List<Long>): List<ChatCharacter> {
if (ids.isEmpty()) return emptyList()
val list = chatCharacterRepository.findAllById(ids)
val map = list.associateBy { it.id }
return ids.mapNotNull { map[it] }
}
/**
* 최근 등록된 캐릭터 목록 조회 (최대 10개)
* 최근 등록된 캐릭터 전체보기 (페이징) - 전체 개수 포함
* - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터
* - 2주 이내 캐릭터가 0개라면: totalCount=20, 첫 페이지는 최근 등록 활성 캐릭터 20개, 그 외 페이지는 빈 리스트
*/
@Transactional(readOnly = true)
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> {
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse {
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 50 -> 50 // 과도한 page size 방지
else -> size
}
val since = LocalDateTime.now().minusWeeks(2)
val totalRecent = chatCharacterRepository.countByIsActiveTrueAndCreatedAtGreaterThanEqual(since)
if (totalRecent == 0L) {
if (safePage > 0) {
return RecentCharactersResponse(
totalCount = 20,
content = emptyList()
)
}
val fallback = chatCharacterRepository.findByIsActiveTrue(
PageRequest.of(0, 20, Sort.by("createdAt").descending())
)
val content = fallback.content.map {
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
return RecentCharactersResponse(
totalCount = 20,
content = content
)
}
val pageResult = chatCharacterRepository.findRecentSince(
since,
PageRequest.of(safePage, safeSize)
)
val content = pageResult.content.map {
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
return RecentCharactersResponse(
totalCount = totalRecent,
content = content
)
}
/**
@@ -222,6 +306,147 @@ class ChatCharacterService(
}
}
/**
* 기억(memories) 증분 업데이트
*/
@Transactional
fun updateMemoriesForCharacter(chatCharacter: ChatCharacter, memories: List<ChatCharacterMemoryRequest>) {
val desiredByTitle = memories
.asSequence()
.distinctBy { it.title }
.associateBy { it.title }
val iterator = chatCharacter.memories.iterator()
while (iterator.hasNext()) {
val current = iterator.next()
val desired = desiredByTitle[current.title]
if (desired == null) {
// 요청에 없는 항목은 제거
iterator.remove()
} else {
// 값 필드만 in-place 업데이트
if (current.content != desired.content) current.content = desired.content
if (current.emotion != desired.emotion) current.emotion = desired.emotion
}
}
// 신규 추가
val existingTitles = chatCharacter.memories.map { it.title }.toSet()
desiredByTitle.values
.filterNot { existingTitles.contains(it.title) }
.forEach { chatCharacter.addMemory(it.title, it.content, it.emotion) }
}
/**
* 성격(personalities) 증분 업데이트
*/
@Transactional
fun updatePersonalitiesForCharacter(
chatCharacter: ChatCharacter,
personalities: List<ChatCharacterPersonalityRequest>
) {
val desiredByTrait = personalities
.asSequence()
.distinctBy { it.trait }
.associateBy { it.trait }
val iterator = chatCharacter.personalities.iterator()
while (iterator.hasNext()) {
val current = iterator.next()
val desired = desiredByTrait[current.trait]
if (desired == null) {
// 요청에 없는 항목은 제거
iterator.remove()
} else {
// 값 필드만 in-place 업데이트
if (current.description != desired.description) current.description = desired.description
}
}
// 신규 추가
val existingTraits = chatCharacter.personalities.map { it.trait }.toSet()
desiredByTrait.values
.filterNot { existingTraits.contains(it.trait) }
.forEach { chatCharacter.addPersonality(it.trait, it.description) }
}
/**
* 배경(backgrounds) 증분 업데이트
*/
@Transactional
fun updateBackgroundsForCharacter(chatCharacter: ChatCharacter, backgrounds: List<ChatCharacterBackgroundRequest>) {
val desiredByTopic = backgrounds
.asSequence()
.distinctBy { it.topic }
.associateBy { it.topic }
val iterator = chatCharacter.backgrounds.iterator()
while (iterator.hasNext()) {
val current = iterator.next()
val desired = desiredByTopic[current.topic]
if (desired == null) {
// 요청에 없는 항목은 제거
iterator.remove()
} else {
// 값 필드만 in-place 업데이트
if (current.description != desired.description) current.description = desired.description
}
}
// 신규 추가
val existingTopics = chatCharacter.backgrounds.map { it.topic }.toSet()
desiredByTopic.values
.filterNot { existingTopics.contains(it.topic) }
.forEach { chatCharacter.addBackground(it.topic, it.description) }
}
/**
* 관계(relationships) 증분 업데이트
*/
@Transactional
fun updateRelationshipsForCharacter(
chatCharacter: ChatCharacter,
relationships: List<ChatCharacterRelationshipRequest>
) {
fun keyOf(p: String, r: String) = "$" + "{" + p + "}" + "::" + "{" + r + "}"
val desiredByKey = relationships
.asSequence()
.distinctBy { keyOf(it.personName, it.relationshipName) }
.associateBy { keyOf(it.personName, it.relationshipName) }
val iterator = chatCharacter.relationships.iterator()
while (iterator.hasNext()) {
val current = iterator.next()
val key = keyOf(current.personName, current.relationshipName)
val desired = desiredByKey[key]
if (desired == null) {
iterator.remove()
} else {
if (current.description != desired.description) current.description = desired.description
if (current.importance != desired.importance) current.importance = desired.importance
if (current.relationshipType != desired.relationshipType) {
current.relationshipType = desired.relationshipType
}
if (current.currentStatus != desired.currentStatus) current.currentStatus = desired.currentStatus
}
}
val existingKeys = chatCharacter.relationships.map { keyOf(it.personName, it.relationshipName) }.toSet()
desiredByKey.values
.filterNot { existingKeys.contains(keyOf(it.personName, it.relationshipName)) }
.forEach { rr ->
chatCharacter.addRelationship(
rr.personName,
rr.relationshipName,
rr.description,
rr.importance,
rr.relationshipType,
rr.currentStatus
)
}
}
/**
* 캐릭터 저장
*/
@@ -230,14 +455,6 @@ class ChatCharacterService(
return chatCharacterRepository.save(chatCharacter)
}
/**
* UUID로 캐릭터 조회
*/
@Transactional(readOnly = true)
fun findByCharacterUUID(characterUUID: String): ChatCharacter? {
return chatCharacterRepository.findByCharacterUUID(characterUUID)
}
/**
* 이름으로 캐릭터 조회
*/
@@ -246,14 +463,6 @@ class ChatCharacterService(
return chatCharacterRepository.findByName(name)
}
/**
* 모든 캐릭터 조회
*/
@Transactional(readOnly = true)
fun findAll(): List<ChatCharacter> {
return chatCharacterRepository.findAll()
}
/**
* ID로 캐릭터 조회
*/
@@ -331,57 +540,6 @@ class ChatCharacterService(
return saveChatCharacter(chatCharacter)
}
/**
* 캐릭터에 기억 추가
*/
@Transactional
fun addMemoryToChatCharacter(chatCharacter: ChatCharacter, title: String, content: String, emotion: String) {
chatCharacter.addMemory(title, content, emotion)
saveChatCharacter(chatCharacter)
}
/**
* 캐릭터에 성격 특성 추가
*/
@Transactional
fun addPersonalityToChatCharacter(chatCharacter: ChatCharacter, trait: String, description: String) {
chatCharacter.addPersonality(trait, description)
saveChatCharacter(chatCharacter)
}
/**
* 캐릭터에 배경 정보 추가
*/
@Transactional
fun addBackgroundToChatCharacter(chatCharacter: ChatCharacter, topic: String, description: String) {
chatCharacter.addBackground(topic, description)
saveChatCharacter(chatCharacter)
}
/**
* 캐릭터에 관계 추가
*/
@Transactional
fun addRelationshipToChatCharacter(
chatCharacter: ChatCharacter,
personName: String,
relationshipName: String,
description: String,
importance: Int,
relationshipType: String,
currentStatus: String
) {
chatCharacter.addRelationship(
personName,
relationshipName,
description,
importance,
relationshipType,
currentStatus
)
saveChatCharacter(chatCharacter)
}
/**
* 캐릭터 생성 시 기본 정보와 함께 추가 정보도 설정
*/
@@ -464,7 +622,6 @@ class ChatCharacterService(
* @param imagePath 이미지 경로 (null이면 이미지 변경 없음)
* @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능)
* @return 수정된 ChatCharacter 객체
* @throws SodaException 캐릭터를 찾을 수 없는 경우
*/
@Transactional
fun updateChatCharacterWithDetails(
@@ -526,38 +683,19 @@ class ChatCharacterService(
// 추가 정보 설정 - 변경된 데이터만 업데이트
if (request.memories != null) {
chatCharacter.memories.clear()
request.memories.forEach { memory ->
chatCharacter.addMemory(memory.title, memory.content, memory.emotion)
}
updateMemoriesForCharacter(chatCharacter, request.memories)
}
if (request.personalities != null) {
chatCharacter.personalities.clear()
request.personalities.forEach { personality ->
chatCharacter.addPersonality(personality.trait, personality.description)
}
updatePersonalitiesForCharacter(chatCharacter, request.personalities)
}
if (request.backgrounds != null) {
chatCharacter.backgrounds.clear()
request.backgrounds.forEach { background ->
chatCharacter.addBackground(background.topic, background.description)
}
updateBackgroundsForCharacter(chatCharacter, request.backgrounds)
}
if (request.relationships != null) {
chatCharacter.relationships.clear()
request.relationships.forEach { rr ->
chatCharacter.addRelationship(
rr.personName,
rr.relationshipName,
rr.description,
rr.importance,
rr.relationshipType,
rr.currentStatus
)
}
updateRelationshipsForCharacter(chatCharacter, request.relationships)
}
return saveChatCharacter(chatCharacter)

View File

@@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.chat.character.service
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.chat.room.QChatMessage
import kr.co.vividnext.sodalive.chat.room.QChatParticipant
import org.springframework.stereotype.Repository
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
@Repository
class PopularCharacterQuery(
private val queryFactory: JPAQueryFactory
) {
/**
* 집계 기준: "채팅방 전체 메시지 수"로 캐릭터 인기 집계
* - 메시지 작성자(pMsg)가 누가 되었든 해당 방의 소유 캐릭터(p=CHARACTER)의 id로 그룹핑
* - 시간 종료 경계는 배타적(<) 비교로 단순화
*/
fun findPopularCharacterIds(
windowStart: Instant,
endExclusive: Instant,
limit: Long
): List<Long> {
val m = QChatMessage.chatMessage
val p = QChatParticipant.chatParticipant
val c = QChatCharacter.chatCharacter
val start = LocalDateTime.ofInstant(windowStart, ZoneOffset.UTC)
val end = LocalDateTime.ofInstant(endExclusive, ZoneOffset.UTC)
return queryFactory
.select(c.id)
.from(m)
// 방의 캐릭터 소유자 참가자(p=CHARACTER)를 통해 캐릭터 기준으로 그룹핑
.join(p).on(
p.chatRoom.id.eq(m.chatRoom.id)
.and(p.participantType.eq(ParticipantType.CHARACTER))
)
.join(c).on(c.id.eq(p.character.id))
.where(
m.createdAt.goe(start)
.and(m.createdAt.lt(end)) // 배타적 종료
.and(m.isActive.isTrue)
.and(c.isActive.isTrue)
)
.groupBy(c.id)
.orderBy(m.id.count().desc())
.limit(limit)
.fetch()
}
}

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.chat.character.service
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* UTC 20:00:00을 경계로 집계 윈도우와 캐시 키를 계산한다.
*/
data class RankingWindow(
val windowStart: Instant,
val windowEnd: Instant,
val nextBoundary: Instant,
val cacheKey: String
)
object RankingWindowCalculator {
private val ZONE: ZoneId = ZoneOffset.UTC
private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC
@JvmStatic
fun now(prefix: String = "popular-character"): RankingWindow {
val now = ZonedDateTime.now(ZONE)
val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE)
// 일일 순위는 "전날" 완료 구간을 보여주기 위해, 언제든 직전 경계까지만 집계한다.
// 예) 2025-09-14 20:00:00 직후에도 [2025-09-13 20:00, 2025-09-14 20:00) 윈도우를 사용
val lastBoundary = if (now.isBefore(todayBoundary)) {
// 아직 오늘 20:00 이전이면, 직전 경계는 어제 20:00
todayBoundary.minusDays(1)
} else {
// 오늘 20:00을 지났거나 같으면, 직전 경계는 오늘 20:00
todayBoundary
}
val start = lastBoundary.minusDays(1)
val endExclusive = lastBoundary
val windowStart = start.toInstant()
val windowEnd = endExclusive.minusSeconds(1).toInstant() // [start, end]
val cacheKey = "$prefix:${windowStart.epochSecond}"
// nextBoundary 필드는 기존 시그니처 유지를 위해 endExclusive(=lastBoundary)를 그대로 전달한다.
return RankingWindow(windowStart, windowEnd, endExclusive.toInstant(), cacheKey)
}
}

View File

@@ -0,0 +1,65 @@
package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.OneToMany
/**
* 원작(오리지널 작품) 엔티티
* - 캐릭터를 원작별로 묶기 위한 기준 엔티티
* - 각 필드는 운영에서 관리자가 입력/수정한다.
*/
@Entity
class OriginalWork(
/** 원작 제목 */
@Column(nullable = false)
var title: String,
/** 콘텐츠 타입 (예: 웹소설, 웹툰 등) */
@Column(nullable = false)
var contentType: String,
/** 카테고리/장르 (예: 로맨스, 판타지 등) */
@Column(nullable = false)
var category: String,
/** 19금 여부 */
@Column(nullable = false)
var isAdult: Boolean = false,
/** 작품 소개 */
@Column(columnDefinition = "TEXT")
var description: String = "",
/** 원천 원작 */
@Column(nullable = true)
var originalWork: String? = null,
/** 원천 원작 링크(단일) */
@Column(nullable = true)
var originalLink: String? = null,
/** 작가 */
@Column(nullable = true)
var writer: String? = null,
/** 제작사 */
@Column(nullable = true)
var studio: String? = null
) : BaseEntity() {
/** 원작 대표 이미지 S3 경로 */
var imagePath: String? = null
/** 소프트 삭제 여부 (true면 삭제된 것으로 간주) */
var isDeleted: Boolean = false
/** 원작 링크들 (1:N) */
@OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true)
var originalLinks: MutableList<OriginalWorkLink> = mutableListOf()
/** 원작 태그 매핑들 (1:N) */
@OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true)
var tagMappings: MutableList<OriginalWorkTagMapping> = mutableListOf()
}

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* 원작 원본 링크 엔티티
* - 하나의 원작(OriginalWork)에 여러 개의 링크가 연결될 수 있음 (1:N)
*/
@Entity
class OriginalWorkLink(
@Column(nullable = false)
var url: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_work_id")
var originalWork: OriginalWork? = null
) : BaseEntity()

View File

@@ -0,0 +1,63 @@
package kr.co.vividnext.sodalive.chat.original
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.util.Optional
@Repository
interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> {
fun findByTitleAndIsDeletedFalse(title: String): OriginalWork?
fun findByIdAndIsDeletedFalse(id: Long): Optional<OriginalWork>
fun findByIsDeletedFalse(pageable: Pageable): Page<OriginalWork>
/**
* 제목/콘텐츠타입/카테고리 기준 부분 검색 (소프트 삭제 제외) - 무페이징 전체 목록
*/
@Query(
"""
SELECT ow FROM OriginalWork ow
WHERE ow.isDeleted = false AND (
LOWER(ow.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(ow.contentType) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(ow.category) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
)
ORDER BY ow.createdAt DESC
"""
)
fun searchNoPaging(
@Param("searchTerm") searchTerm: String
): List<OriginalWork>
/**
* 앱용 원작 목록 조회 (페이징)
* - 소프트 삭제 제외
* - includeAdult=false이면 19금 제외
* - 활성 캐릭터가 하나라도 연결된 원작만 조회
*/
@Query(
value = """
SELECT ow FROM OriginalWork ow
WHERE ow.isDeleted = false
AND (:includeAdult = true OR ow.isAdult = false)
AND EXISTS (
SELECT 1 FROM ChatCharacter c
WHERE c.originalWork = ow AND c.isActive = true
)
ORDER BY ow.createdAt DESC
""",
countQuery = """
SELECT COUNT(ow) FROM OriginalWork ow
WHERE ow.isDeleted = false
AND (:includeAdult = true OR ow.isAdult = false)
AND EXISTS (
SELECT 1 FROM ChatCharacter c
WHERE c.originalWork = ow AND c.isActive = true
)
"""
)
fun findAllForAppPage(@Param("includeAdult") includeAdult: Boolean, pageable: Pageable): Page<OriginalWork>
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.OneToMany
import javax.persistence.Table
import javax.persistence.UniqueConstraint
/**
* 원작 태그 엔티티 (작품/시리즈 태그와 분리)
*/
@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["tag"])])
class OriginalWorkTag(
@Column(nullable = false)
val tag: String
) : BaseEntity() {
@OneToMany(mappedBy = "tag")
var tagMappings: MutableList<OriginalWorkTagMapping> = mutableListOf()
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* OriginalWork 와 OriginalWorkTag 매핑 엔티티
*/
@Entity
class OriginalWorkTagMapping(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_work_id")
val originalWork: OriginalWork,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
val tag: OriginalWorkTag
) : BaseEntity()

View File

@@ -0,0 +1,81 @@
package kr.co.vividnext.sodalive.chat.original.controller
import kr.co.vividnext.sodalive.chat.character.dto.Character
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
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
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
/**
* 앱용 원작(오리지널 작품) 공개 API
* 1) 목록: 로그인 불필요, 미인증 사용자는 19금 제외, 활성 캐릭터 연결된 원작만 노출
* 2) 상세: 로그인 + 본인인증 필수
*/
@RestController
@RequestMapping("/api/chat/original")
class OriginalWorkController(
private val queryService: OriginalWorkQueryService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 원작 목록 (페이징)
* - 로그인 불필요
* - 본인인증하지 않은 경우 19금 제외
* - 활성 캐릭터가 하나라도 연결된 원작만 노출
* - 요청: page(기본 0), size(기본 20)
* - 반환: totalCount + [imageUrl, title, contentType]
*/
@GetMapping("/list")
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val includeAdult = member?.auth != null
val pageRes = queryService.listForAppPage(includeAdult, page, size)
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content))
}
/**
* 원작 상세
* - 로그인 및 본인인증 필수
* - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크
* - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description]
* - 캐릭터는 페이징 적용: 첫 페이지 20개
*/
@GetMapping("/{id}")
fun detail(
@PathVariable id: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
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 path = it.imagePath ?: "profile/default-profile.png"
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/$path"
)
}
val response = OriginalWorkDetailResponse.from(ow, imageHost, characters)
ApiResponse.ok(response)
}
}

View File

@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.chat.original.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.original.OriginalWork
/**
* 앱용 원작 목록 아이템 응답 DTO
*/
data class OriginalWorkListItemResponse(
@JsonProperty("id") val id: Long,
@JsonProperty("imageUrl") val imageUrl: String?,
@JsonProperty("title") val title: String,
@JsonProperty("contentType") val contentType: String
) {
companion object {
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkListItemResponse {
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}"
} else {
entity.imagePath
}
return OriginalWorkListItemResponse(
id = entity.id!!,
imageUrl = fullImage,
title = entity.title,
contentType = entity.contentType
)
}
}
}
/**
* 앱용 원작 목록 응답 DTO
*/
data class OriginalWorkListResponse(
@JsonProperty("totalCount") val totalCount: Long,
@JsonProperty("content") val content: List<OriginalWorkListItemResponse>
)
/**
* 앱용 원작 상세 응답 DTO
*/
data class OriginalWorkDetailResponse(
@JsonProperty("imageUrl") val imageUrl: String?,
@JsonProperty("title") val title: String,
@JsonProperty("contentType") val contentType: String,
@JsonProperty("category") val category: String,
@JsonProperty("isAdult") val isAdult: Boolean,
@JsonProperty("description") val description: String,
@JsonProperty("originalWork") val originalWork: String?,
@JsonProperty("originalLink") val originalLink: String?,
@JsonProperty("writer") val writer: String?,
@JsonProperty("studio") val studio: String?,
@JsonProperty("originalLinks") val originalLinks: List<String>,
@JsonProperty("tags") val tags: List<String>,
@JsonProperty("characters") val characters: List<Character>
) {
companion object {
fun from(
entity: OriginalWork,
imageHost: String = "",
characters: List<Character>
): OriginalWorkDetailResponse {
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}"
} else {
entity.imagePath
}
return OriginalWorkDetailResponse(
imageUrl = fullImage,
title = entity.title,
contentType = entity.contentType,
category = entity.category,
isAdult = entity.isAdult,
description = entity.description,
originalWork = entity.originalWork,
originalLink = entity.originalLink,
writer = entity.writer,
studio = entity.studio,
originalLinks = entity.originalLinks.map { it.url },
tags = entity.tagMappings.map { it.tag.tag },
characters = characters
)
}
}
}
/**
* 앱용: 원작별 활성 캐릭터 페이징 응답 DTO
*/
data class OriginalWorkCharactersPageResponse(
@JsonProperty("totalCount") val totalCount: Long,
@JsonProperty("content") val content: List<Character>
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.chat.original.repository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface OriginalWorkTagRepository : JpaRepository<OriginalWorkTag, Long> {
fun findByTag(tag: String): OriginalWorkTag?
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.chat.original.service
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 앱 사용자용 원작(오리지널 작품) 조회 서비스
* - 목록/상세 조회 전용
*/
@Service
class OriginalWorkQueryService(
private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository
) {
/**
* 앱용 원작 목록 조회 (페이징)
* @param includeAdult true면 19금 포함, false면 제외
* @param page 페이지 번호(0부터)
* @param size 페이지 크기(기본 20, 최대 50)
*/
@Transactional(readOnly = true)
fun listForAppPage(includeAdult: Boolean, page: Int = 0, size: Int = 20): Page<OriginalWork> {
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 50 -> 50
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return originalWorkRepository.findAllForAppPage(includeAdult, pageable)
}
/**
* 원작 상세 조회 (소프트 삭제 제외)
*/
@Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
}
/**
* 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순)
*/
@Transactional(readOnly = true)
fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page<ChatCharacter> {
// 원작 존재 및 소프트 삭제 여부 확인
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 50 -> 50
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
}
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.quota
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Table
import javax.persistence.Version
@Entity
@Table(name = "chat_quota")
class ChatQuota(
@Id
val memberId: Long,
var remainingFree: Int = 10,
var remainingPaid: Int = 0,
var nextRechargeAt: LocalDateTime? = null,
@Version
var version: Long? = null
) {
fun total(): Int = remainingFree + remainingPaid
}

View File

@@ -0,0 +1,63 @@
package kr.co.vividnext.sodalive.chat.quota
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
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
@RestController
@RequestMapping("/api/chat/quota")
class ChatQuotaController(
private val chatQuotaService: ChatQuotaService,
private val canPaymentService: CanPaymentService
) {
data class ChatQuotaStatusResponse(
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?
)
data class ChatQuotaPurchaseRequest(
val container: String
)
@GetMapping("/me")
fun getMyQuota(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<ChatQuotaStatusResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val s = chatQuotaService.getStatus(member.id!!)
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
}
@PostMapping("/purchase")
fun purchaseQuota(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestBody request: ChatQuotaPurchaseRequest
): ApiResponse<ChatQuotaStatusResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (request.container.isBlank()) throw SodaException("container를 확인해주세요.")
// 30캔 차감 처리 (결제 기록 남김)
canPaymentService.spendCan(
memberId = member.id!!,
needCan = 30,
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
container = request.container
)
// 글로벌 유료 개념 제거됨: 구매 성공 시에도 글로벌 쿼터 증액 없음
val s = chatQuotaService.getStatus(member.id!!)
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.chat.quota
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import javax.persistence.LockModeType
interface ChatQuotaRepository : JpaRepository<ChatQuota, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select q from ChatQuota q where q.memberId = :memberId")
fun findForUpdate(@Param("memberId") memberId: Long): ChatQuota?
fun findByMemberId(memberId: Long): ChatQuota?
}

View File

@@ -0,0 +1,62 @@
package kr.co.vividnext.sodalive.chat.quota
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
@Service
class ChatQuotaService(
private val repo: ChatQuotaRepository
) {
companion object {
private const val FREE_BUCKET = 40
}
data class QuotaStatus(
val totalRemaining: Int,
val nextRechargeAtEpochMillis: Long?
)
private fun nextUtc20LocalDateTime(now: Instant = Instant.now()): LocalDateTime {
val nowUtc = LocalDateTime.ofInstant(now, ZoneOffset.UTC)
val today20 = nowUtc.withHour(20).withMinute(0).withSecond(0).withNano(0)
val target = if (nowUtc.isBefore(today20)) today20 else today20.plusDays(1)
// 저장은 시스템 기본 타임존의 LocalDateTime으로 보관
return LocalDateTime.ofInstant(target.toInstant(ZoneOffset.UTC), ZoneId.systemDefault())
}
@Transactional
fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus {
val now = Instant.now()
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
// Lazy refill: nextRechargeAt이 없거나 현재를 지났다면 무료 40 회복
val nextRecharge = nextUtc20LocalDateTime(now)
if (quota.nextRechargeAt == null || !LocalDateTime.now().isBefore(quota.nextRechargeAt)) {
quota.remainingFree = FREE_BUCKET
}
// 다음 UTC20 기준 시간으로 항상 갱신
quota.nextRechargeAt = nextRecharge
val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
// 글로벌은 유료 개념 제거: totalRemaining은 remainingFree만 사용
return QuotaStatus(totalRemaining = quota.remainingFree, nextRechargeAtEpochMillis = epoch)
}
@Transactional
fun consumeOneFree(memberId: Long) {
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
if (quota.remainingFree <= 0) {
// 소비 불가: 호출자는 상태 조회로 남은 시간을 판단
throw IllegalStateException("No global free quota")
}
quota.remainingFree -= 1
}
@Transactional
fun getStatus(memberId: Long): QuotaStatus {
return applyRefillOnEnterAndGetStatus(memberId)
}
}

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.chat.quota.room
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.Version
@Entity
@Table(name = "chat_room_quota")
class ChatRoomQuota(
val memberId: Long,
val chatRoomId: Long,
val characterId: Long,
var remainingFree: Int = 10,
var remainingPaid: Int = 0,
var nextRechargeAt: Long? = null,
@Version
var version: Long? = null
) : BaseEntity()

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.chat.quota.room
import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
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
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chat/rooms")
class ChatRoomQuotaController(
private val chatRoomRepository: ChatRoomRepository,
private val participantRepository: ChatParticipantRepository,
private val chatRoomQuotaService: ChatRoomQuotaService,
private val chatQuotaService: ChatQuotaService
) {
data class PurchaseRoomQuotaRequest(
val container: String
)
data class PurchaseRoomQuotaResponse(
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?,
val remainingFree: Int,
val remainingPaid: Int
)
data class RoomQuotaStatusResponse(
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?
)
/**
* 채팅방 유료 쿼터 구매 API
* - 참여 여부 검증(내가 USER로 참여 중인 활성 방)
* - 30캔 결제 (UseCan에 chatRoomId:characterId 기록)
* - 방 유료 쿼터 40 충전
*/
@PostMapping("/{chatRoomId}/quota/purchase")
fun purchaseRoomQuota(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@RequestBody req: PurchaseRoomQuotaRequest
): ApiResponse<PurchaseRoomQuotaResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (req.container.isBlank()) throw SodaException("container를 확인해주세요.")
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
// 내 참여 여부 확인
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
// 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조)
val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
val character = characterParticipant.character
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
val characterId = character.id
?: throw SodaException("잘못된 요청입니다. 캐릭터 정보를 확인해주세요.")
// 서비스에서 결제 포함하여 처리
val status = chatRoomQuotaService.purchase(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId,
addPaid = 40,
container = req.container
)
ApiResponse.ok(
PurchaseRoomQuotaResponse(
totalRemaining = status.totalRemaining,
nextRechargeAtEpoch = status.nextRechargeAtEpochMillis,
remainingFree = status.remainingFree,
remainingPaid = status.remainingPaid
)
)
}
@GetMapping("/{chatRoomId}/quota/me")
fun getMyRoomQuota(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long
): ApiResponse<RoomQuotaStatusResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
// 내 참여 여부 확인
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
// 캐릭터 확인
val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
val character = characterParticipant.character
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
// 글로벌 Lazy refill
val globalStatus = chatQuotaService.getStatus(member.id!!)
// 룸 Lazy refill 상태
val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = character.id!!,
globalFree = globalStatus.totalRemaining
)
val next: Long? = when {
roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis
globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis
else -> null
}
ApiResponse.ok(
RoomQuotaStatusResponse(
totalRemaining = roomStatus.totalRemaining,
nextRechargeAtEpoch = next
)
)
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.chat.quota.room
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import javax.persistence.LockModeType
interface ChatRoomQuotaRepository : JpaRepository<ChatRoomQuota, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select q from ChatRoomQuota q where q.memberId = :memberId and q.chatRoomId = :chatRoomId")
fun findForUpdate(
@Param("memberId") memberId: Long,
@Param("chatRoomId") chatRoomId: Long
): ChatRoomQuota?
}

View File

@@ -0,0 +1,176 @@
package kr.co.vividnext.sodalive.chat.quota.room
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Duration
import java.time.Instant
@Service
class ChatRoomQuotaService(
private val repo: ChatRoomQuotaRepository,
private val canPaymentService: CanPaymentService
) {
data class RoomQuotaStatus(
val totalRemaining: Int,
val nextRechargeAtEpochMillis: Long?,
val remainingFree: Int,
val remainingPaid: Int
)
private fun calculateAvailableForRoom(globalFree: Int, roomFree: Int, roomPaid: Int): Int {
// 유료가 있으면 글로벌 상관 없이 (유료 + 무료동시가능수)로 계산
// 무료만 있는 경우에는 글로벌과 룸 Free의 교집합으로 사용 가능 횟수 계산
val freeUsable = minOf(globalFree, roomFree)
return roomPaid + freeUsable
}
@Transactional
fun applyRefillOnEnterAndGetStatus(
memberId: Long,
chatRoomId: Long,
characterId: Long,
globalFree: Int
): RoomQuotaStatus {
val now = Instant.now()
val nowMillis = now.toEpochMilli()
val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save(
ChatRoomQuota(
memberId = memberId,
chatRoomId = chatRoomId,
characterId = characterId,
remainingFree = 10,
remainingPaid = 0,
nextRechargeAt = null
)
)
// Lazy refill: nextRechargeAt이 현재를 지났으면 무료 10으로 리셋하고 next=null
if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) {
quota.remainingFree = 10
quota.nextRechargeAt = null
}
val total = calculateAvailableForRoom(
globalFree = globalFree,
roomFree = quota.remainingFree,
roomPaid = quota.remainingPaid
)
return RoomQuotaStatus(
totalRemaining = total,
nextRechargeAtEpochMillis = quota.nextRechargeAt,
remainingFree = quota.remainingFree,
remainingPaid = quota.remainingPaid
)
}
@Transactional
fun consumeOneForSend(
memberId: Long,
chatRoomId: Long,
globalFreeProvider: () -> Int,
consumeGlobalFree: () -> Unit
): RoomQuotaStatus {
val now = Instant.now()
val nowMillis = now.toEpochMilli()
val quota = repo.findForUpdate(memberId, chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
// 충전 시간이 지났다면 무료 10으로 리셋하고 next=null
if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) {
quota.remainingFree = 10
quota.nextRechargeAt = null
}
// 1) 유료 우선 사용: 글로벌에 영향 없음
if (quota.remainingPaid > 0) {
quota.remainingPaid -= 1
// 유료 차감 후, 무료와 유료가 모두 0이 되는 시점이면 다음 무료 충전을 예약한다.
if (quota.remainingPaid == 0 && quota.remainingFree == 0 && quota.nextRechargeAt == null) {
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
}
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
}
// 2) 무료 사용: 글로벌과 룸 동시에 조건 충족 필요
val globalFree = globalFreeProvider()
if (globalFree <= 0) {
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.")
}
if (quota.remainingFree <= 0) {
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
val waitMillis = quota.nextRechargeAt
if (waitMillis == null) {
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
}
throw SodaException("무료 채팅이 모두 소진되었습니다.")
}
// 둘 다 가능 → 차감
consumeGlobalFree()
quota.remainingFree -= 1
if (quota.remainingFree == 0) {
// 무료가 0이 되는 순간 nextRechargeAt = 현재 + 6시간
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
}
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
}
@Transactional
fun purchase(
memberId: Long,
chatRoomId: Long,
characterId: Long,
addPaid: Int = 40,
container: String
): RoomQuotaStatus {
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록
canPaymentService.spendCan(
memberId = memberId,
needCan = 30,
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
chatRoomId = chatRoomId,
characterId = characterId,
container = container
)
val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save(
ChatRoomQuota(
memberId = memberId,
chatRoomId = chatRoomId,
characterId = characterId
)
)
quota.remainingPaid += addPaid
quota.nextRechargeAt = null
val total = quota.remainingPaid + quota.remainingFree
return RoomQuotaStatus(
totalRemaining = total,
nextRechargeAtEpochMillis = quota.nextRechargeAt,
remainingFree = quota.remainingFree,
remainingPaid = quota.remainingPaid
)
}
@Transactional
fun transferPaid(memberId: Long, fromChatRoomId: Long, toChatRoomId: Long, toCharacterId: Long) {
val from = repo.findForUpdate(memberId, fromChatRoomId) ?: return
if (from.remainingPaid <= 0) return
val to = repo.findForUpdate(memberId, toChatRoomId) ?: repo.save(
ChatRoomQuota(
memberId = memberId,
chatRoomId = toChatRoomId,
characterId = toCharacterId
)
)
to.remainingPaid += from.remainingPaid
from.remainingPaid = 0
// 유료 이관은 룸 무료 충전 시간에 영향을 주지 않음
}
}

View File

@@ -1,14 +1,18 @@
package kr.co.vividnext.sodalive.chat.room
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class ChatMessage(
// 텍스트 메시지 본문. 현재는 NOT NULL 유지. IMAGE 타입 등 비텍스트 메시지는 빈 문자열("") 저장 방침.
@Column(columnDefinition = "TEXT", nullable = false)
val message: String,
@@ -20,5 +24,23 @@ class ChatMessage(
@JoinColumn(name = "participant_id", nullable = false)
val participant: ChatParticipant,
val isActive: Boolean = true
val isActive: Boolean = true,
@Enumerated(EnumType.STRING)
@Column(name = "message_type", nullable = false)
val messageType: ChatMessageType = ChatMessageType.TEXT,
// 미리 저장된 캐릭터 이미지 참조 (옵션)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_image_id", nullable = true)
val characterImage: CharacterImage? = null,
// 이미지 정적 경로 스냅샷 (옵션)
@Column(name = "image_path", nullable = true, length = 1024)
val imagePath: String? = null,
// 메시지 가격 (옵션). 제공되는 경우 1 이상이어야 함.
// Bean Validation 사용 시 @field:Min(1) 추가 고려.
@Column(name = "price", nullable = true)
val price: Int? = null
) : BaseEntity()

View File

@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.chat.room
/**
* 채팅 메시지 타입
* - TEXT: 일반 텍스트 메시지
* - IMAGE: 이미지 메시지(캐릭터 이미지 등)
*
* 유의: 유료 여부는 별도 price 필드로 표현합니다.
*/
enum class ChatMessageType {
TEXT,
IMAGE
}

View File

@@ -10,7 +10,7 @@ import javax.persistence.OneToMany
class ChatRoom(
val sessionId: String,
val title: String,
val isActive: Boolean = true
var isActive: Boolean = true
) : BaseEntity() {
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
val messages: MutableList<ChatMessage> = mutableListOf()

View File

@@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.chat.room.controller
import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagePurchaseRequest
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomResetRequest
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
@@ -90,12 +92,13 @@ class ChatRoomController(
@GetMapping("/{chatRoomId}/enter")
fun enterChatRoom(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long
@PathVariable chatRoomId: Long,
@RequestParam(required = false) characterImageId: Long?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val response = chatRoomService.enterChatRoom(member, chatRoomId)
val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId)
ApiResponse.ok(response)
}
@@ -159,4 +162,43 @@ class ChatRoomController(
ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message))
}
}
/**
* 유료 메시지 구매 API
* - 참여 여부 검증
* - 이미지 메시지의 경우 이미 보유 시 결제 없이 true 반환
* - 그 외 가격 검증 후 CanPaymentService 통해 결제 처리
*/
@PostMapping("/{chatRoomId}/messages/{messageId}/purchase")
fun purchaseMessage(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@PathVariable messageId: Long,
@RequestBody request: ChatMessagePurchaseRequest
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container)
ApiResponse.ok(result)
}
/**
* 채팅방 초기화 API
* - 로그인 및 본인인증 확인
* - 내가 참여 중인 AI 캐릭터 채팅방인지 확인
* - 30캔 결제 → 현재 채팅방 나가기 → 동일 캐릭터와 새 채팅방 생성 → 생성된 채팅방 데이터 반환
*/
@PostMapping("/{chatRoomId}/reset")
fun resetChatRoom(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@RequestBody request: ChatRoomResetRequest
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container)
ApiResponse.ok(response)
}
}

View File

@@ -24,6 +24,7 @@ data class CreateChatRoomResponse(
*/
data class ChatRoomListItemDto(
val chatRoomId: Long,
val characterId: Long,
val title: String,
val imageUrl: String,
val opponentType: String,
@@ -39,7 +40,11 @@ data class ChatMessageItemDto(
val message: String,
val profileImageUrl: String,
val mine: Boolean,
val createdAt: Long
val createdAt: Long,
val messageType: String,
val imageUrl: String?,
val price: Int?,
val hasAccess: Boolean
)
/**
@@ -57,6 +62,7 @@ data class ChatMessagesPageResponse(
data class ChatRoomListQueryDto(
val chatRoomId: Long,
val characterId: Long,
val title: String,
val imagePath: String?,
val characterType: CharacterType,
@@ -126,6 +132,13 @@ data class SendChatMessageRequest(
val message: String
)
/**
* 유료 메시지 구매 요청 DTO
*/
data class ChatMessagePurchaseRequest(
val container: String
)
/**
* 외부 API 채팅 전송 응답 DTO
*/
@@ -169,5 +182,24 @@ data class ChatRoomEnterResponse(
val roomId: Long,
val character: ChatRoomEnterCharacterDto,
val messages: List<ChatMessageItemDto>,
val hasMoreMessages: Boolean
val hasMoreMessages: Boolean,
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?,
val bgImageUrl: String? = null
)
/**
* 채팅 메시지 전송 응답 DTO (메시지 + 쿼터 상태)
*/
data class SendChatMessageResponse(
val messages: List<ChatMessageItemDto>,
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?
)
/**
* 채팅방 초기화 요청 DTO
*/
data class ChatRoomResetRequest(
val container: String
)

View File

@@ -39,6 +39,7 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
value = """
SELECT new kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto(
r.id,
pc.character.id,
r.title,
pc.character.imagePath,
pc.character.characterType,
@@ -54,7 +55,7 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
AND pc.isActive = true
AND r.isActive = true
AND m.isActive = true
GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath, pc.character.characterType
GROUP BY r.id, r.title, r.createdAt, pc.character.id, pc.character.imagePath, pc.character.characterType
ORDER BY MAX(m.createdAt) DESC
"""
)
@@ -62,4 +63,6 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
@Param("member") member: Member,
pageable: Pageable
): List<ChatRoomListQueryDto>
fun findByIdAndIsActiveTrue(id: Long): ChatRoom?
}

View File

@@ -1,8 +1,13 @@
package kr.co.vividnext.sodalive.chat.room.service
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaService
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatMessageType
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
@@ -16,6 +21,7 @@ import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
@@ -34,6 +40,7 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.web.client.RestTemplate
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.UUID
@Service
@@ -42,6 +49,11 @@ class ChatRoomService(
private val participantRepository: ChatParticipantRepository,
private val messageRepository: ChatMessageRepository,
private val characterService: ChatCharacterService,
private val characterImageService: CharacterImageService,
private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService,
private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront,
private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService,
private val chatRoomQuotaService: ChatRoomQuotaService,
@Value("\${weraser.api-key}")
private val apiKey: String,
@@ -57,6 +69,45 @@ class ChatRoomService(
) {
private val log = LoggerFactory.getLogger(ChatRoomService::class.java)
@Transactional
fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto {
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
// 참여 여부 검증
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
val message = messageRepository.findById(messageId).orElseThrow {
SodaException("메시지를 찾을 수 없습니다.")
}
if (!message.isActive) throw SodaException("비활성화된 메시지입니다.")
if (message.chatRoom.id != room.id) throw SodaException("잘못된 접근입니다")
val price = message.price ?: throw SodaException("구매할 수 없는 메시지입니다.")
if (price <= 0) throw SodaException("구매 가격이 잘못되었습니다.")
// 이미지 메시지인 경우: 이미 소유했다면 결제 생략하고 DTO 반환
if (message.messageType == ChatMessageType.IMAGE) {
val image = message.characterImage
if (image != null) {
val alreadyOwned = characterImageService.isOwnedImageByMember(image.id!!, member.id!!)
if (alreadyOwned) {
return toChatMessageItemDto(message, member)
}
}
}
// 결제 진행 및 UseCan 기록 (이미지 메시지면 chatMessage + characterImage 동시 기록됨)
canPaymentService.spendCanForChatMessage(
memberId = member.id!!,
needCan = price,
message = message,
container = container
)
// 결제 완료 후 접근 가능 상태로 DTO 반환
return toChatMessageItemDto(message, member, forceHasAccess = true)
}
/**
* 채팅방 생성 또는 조회
*
@@ -202,9 +253,17 @@ class ChatRoomService(
).apply { id = q.chatRoomId }
val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room)
val preview = latest?.message?.let { msg ->
val preview = if (latest?.message?.isNotBlank() == true) {
latest.message.let { msg ->
if (msg.length <= 30) msg else msg.take(30) + "..."
}
} else {
if (latest?.message.isNullOrBlank() && latest?.characterImage != null) {
"[이미지]"
} else {
""
}
}
val imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}"
val opponentType = q.characterType.name // Clone or Character
@@ -213,6 +272,7 @@ class ChatRoomService(
ChatRoomListItemDto(
chatRoomId = q.chatRoomId,
characterId = q.characterId,
title = q.title,
imageUrl = imageUrl,
opponentType = opponentType,
@@ -238,9 +298,8 @@ class ChatRoomService(
@Transactional(readOnly = true)
fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
if (participant == null) {
throw SodaException("잘못된 접근입니다")
@@ -248,20 +307,51 @@ class ChatRoomService(
return fetchSessionActive(room.sessionId)
}
@Transactional(readOnly = true)
fun enterChatRoom(member: Member, chatRoomId: Long): ChatRoomEnterResponse {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
@Transactional
fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse {
// 1) 활성 여부 무관하게 방 조회
val baseRoom = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
// 참여 여부 검증
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
// 캐릭터 참여자 조회
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
room,
// 2) 기본 방 기준 참여/활성 여부 확인
val isActiveRoom = baseRoom.isActive
val isMyActiveParticipation =
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(baseRoom, member) != null
// 3) 기본 방의 캐릭터 식별 (활성 우선, 없으면 컬렉션에서 검색)
val baseCharacterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
baseRoom,
ParticipantType.CHARACTER
) ?: throw SodaException("잘못된 접근입니다")
) ?: baseRoom.participants.firstOrNull {
it.participantType == ParticipantType.CHARACTER
} ?: throw SodaException("잘못된 접근입니다")
val baseCharacter = baseCharacterParticipant.character
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
// 4) 유효한 입장 대상 방 결정
val effectiveRoom: ChatRoom = if (isActiveRoom && isMyActiveParticipation) {
baseRoom
} else {
// 동일 캐릭터 + 내가 참여 중인 활성 방을 찾는다
val alt = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, baseCharacter)
alt ?: ( // 대체 방이 없으면 기존과 동일하게 예외 처리
if (!isActiveRoom) {
throw SodaException("채팅방을 찾을 수 없습니다.")
} else {
throw SodaException("잘못된 접근입니다")
}
)
}
// 5) 응답 구성 시에는 effectiveRoom의 캐릭터(활성 우선) 사용
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
effectiveRoom,
ParticipantType.CHARACTER
) ?: effectiveRoom.participants.firstOrNull {
it.participantType == ParticipantType.CHARACTER
} ?: throw SodaException("잘못된 접근입니다")
val character = characterParticipant.character
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
@@ -274,41 +364,106 @@ class ChatRoomService(
characterType = character.characterType.name
)
// 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환
// 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환 (effectiveRoom 기준)
val pageable = PageRequest.of(0, 20)
val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable)
val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(effectiveRoom, pageable)
val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id
val hasMore: Boolean = if (nextCursor != null) {
messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor)
messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(effectiveRoom, nextCursor)
} else {
false
}
val messagesAsc = fetched.sortedBy { it.createdAt }
val items = messagesAsc.map { msg ->
val sender = msg.participant
val profilePath = when (sender.participantType) {
ParticipantType.USER -> sender.member?.profileImage
ParticipantType.CHARACTER -> sender.character?.imagePath
}
val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: 0L
ChatMessageItemDto(
messageId = msg.id!!,
message = msg.message,
profileImageUrl = senderImageUrl,
mine = sender.member?.id == member.id,
createdAt = createdAtMillis
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
// 5-1) 글로벌 쿼터 Lazy refill
val globalStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!)
// 5-2) 룸 쿼터 Lazy refill + 상태
val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus(
memberId = member.id!!,
chatRoomId = effectiveRoom.id!!,
characterId = character.id!!,
globalFree = globalStatus.totalRemaining
)
// 선택적 캐릭터 이미지 서명 URL 생성 처리
// 요구사항: baseRoom이 조건 불만족으로 동일 캐릭터의 내 활성 방으로 라우팅된 경우(bg 이미지 요청 무시)에는 null로 처리
val signedUrl: String? =
if (effectiveRoom.id != baseRoom.id) {
null
} else {
try {
if (characterImageId != null) {
val img = characterImageService.getById(characterImageId)
// 동일 캐릭터 소속 및 활성 검증
if (img.chatCharacter.id == character.id && img.isActive) {
val owned =
(img.imagePriceCan == 0L) || characterImageService.isOwnedImageByMember(
img.id!!,
member.id!!
)
if (owned) {
val expiration = 5L * 60L * 1000L // 5분
imageCloudFront.generateSignedURL(img.imagePath, expiration)
} else {
null
}
} else {
null
}
} else {
null
}
} catch (e: Exception) {
// 문제가 있어도 입장 자체는 가능해야 하므로 로그만 남기고 null 반환
log.warn(
"[chat] enter: signed url generation failed. roomId={}, imageId={}, reason={}",
effectiveRoom.id,
characterImageId,
e.message
)
null
}
}
// 권고안 + 이슈 보정: 채팅 가능(totalRemaining>0)인 경우 next=null
val nextForEnter: Long? = when {
// 채팅 가능: 유료>0 또는 무료 동시 사용 가능 → next는 표시하지 않음
roomStatus.totalRemaining > 0 -> null
// roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next
roomStatus.remainingPaid == 0 && roomStatus.remainingFree > 0 && globalStatus.totalRemaining <= 0 ->
globalStatus.nextRechargeAtEpochMillis
// roomPaid==0 && roomFree==0 → (global<=0) ? max(roomNext, globalNext) : roomNext
roomStatus.remainingPaid == 0 && roomStatus.remainingFree == 0 -> {
val roomNext = roomStatus.nextRechargeAtEpochMillis
val globalNext = globalStatus.nextRechargeAtEpochMillis
if (globalStatus.totalRemaining <= 0) {
if (roomNext == null) {
globalNext
} else if (globalNext == null) {
roomNext
} else {
maxOf(roomNext, globalNext)
}
} else {
roomNext
}
}
// 그 외 기존 규칙: room total==0 → room next, else if global<=0 → global next, else null
roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis
globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis
else -> null
}
return ChatRoomEnterResponse(
roomId = room.id!!,
roomId = effectiveRoom.id!!,
character = characterDto,
messages = items,
hasMoreMessages = hasMore
hasMoreMessages = hasMore,
totalRemaining = roomStatus.totalRemaining,
nextRechargeAtEpoch = nextForEnter,
bgImageUrl = signedUrl
)
}
@@ -352,10 +507,9 @@ class ChatRoomService(
}
@Transactional
fun leaveChatRoom(member: Member, chatRoomId: Long) {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) {
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
@@ -371,12 +525,13 @@ class ChatRoomService(
// 3) 내가 마지막 USER였다면 외부 세션 종료
if (userCount == 0L) {
endExternalSession(room.sessionId)
endExternalSession(room.sessionId, throwOnFailure = throwOnSessionEndFailure)
room.isActive = false
}
}
private fun endExternalSession(sessionId: String) {
// 사용자 흐름을 방해하지 않기 위해 실패 시 예외를 던지지 않고 내부 재시도 후 로그만 남깁니다.
private fun endExternalSession(sessionId: String, throwOnFailure: Boolean = false) {
// 기본 동작: 내부 재시도. throwOnFailure=true일 때는 최종 실패 시 예외 전파.
val maxAttempts = 3
var attempt = 0
while (attempt < maxAttempts) {
@@ -390,7 +545,6 @@ class ChatRoomService(
val headers = HttpHeaders()
headers.set("x-api-key", apiKey)
headers.contentType = MediaType.APPLICATION_JSON
val httpEntity = HttpEntity(null, headers)
@@ -419,15 +573,20 @@ class ChatRoomService(
log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message)
}
}
// 최종 실패 로그 (예외 미전파)
// 최종 실패 처리
val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요."
if (throwOnFailure) {
log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts)
throw SodaException(message)
} else {
log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts)
}
}
@Transactional(readOnly = true)
fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
@@ -449,23 +608,7 @@ class ChatRoomService(
// createdAt 오름차순으로 정렬하여 반환
val messagesAsc = fetched.sortedBy { it.createdAt }
val items = messagesAsc.map { msg ->
val sender = msg.participant
val profilePath = when (sender.participantType) {
ParticipantType.USER -> sender.member?.profileImage
ParticipantType.CHARACTER -> sender.character?.imagePath
}
val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: 0L
ChatMessageItemDto(
messageId = msg.id!!,
message = msg.message,
profileImageUrl = imageUrl,
mine = sender.member?.id == member.id,
createdAt = createdAtMillis
)
}
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
return ChatMessagesPageResponse(
messages = items,
@@ -475,11 +618,10 @@ class ChatRoomService(
}
@Transactional
fun sendMessage(member: Member, chatRoomId: Long, message: String): List<ChatMessageItemDto> {
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
// 1) 방 존재 확인
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
// 2) 참여 여부 확인 (USER)
val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
@@ -497,7 +639,15 @@ class ChatRoomService(
val sessionId = room.sessionId
val characterUUID = character.characterUUID
// 5) 외부 API 호출 (최대 3회 재시도)
// 5) 쿼터 확인 및 차감 (유료 우선, 무료 사용 시 글로벌과 룸 동시 차감)
val roomQuotaAfterConsume = chatRoomQuotaService.consumeOneForSend(
memberId = member.id!!,
chatRoomId = room.id!!,
globalFreeProvider = { chatQuotaService.getStatus(member.id!!).totalRemaining },
consumeGlobalFree = { chatQuotaService.consumeOneFree(member.id!!) }
)
// 6) 외부 API 호출 (최대 3회 재시도)
val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
// 6) 내 메시지 저장
@@ -509,30 +659,146 @@ class ChatRoomService(
)
messageRepository.save(myMsgEntity)
// 7) 캐릭터 메시지 저장
val characterMsgEntity = ChatMessage(
// 7) 캐릭터 텍스트 메시지 항상 저장
val characterTextMsg = messageRepository.save(
ChatMessage(
message = characterReply,
chatRoom = room,
participant = characterParticipant,
isActive = true
)
val savedCharacterMsg = messageRepository.save(characterMsgEntity)
// 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소)
val profilePath = characterParticipant.character?.imagePath
val defaultPath = profilePath ?: "profile/default-profile.png"
val imageUrl = "$imageHost/$defaultPath"
val dto = ChatMessageItemDto(
messageId = savedCharacterMsg.id!!,
message = savedCharacterMsg.message,
profileImageUrl = imageUrl,
mine = false,
createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()
?.toEpochMilli()
?: 0L
)
return listOf(dto)
// 응답 프로필 이미지 URL 공통 구성
val profilePath = characterParticipant.character?.imagePath
val defaultPath = profilePath ?: "profile/default-profile.png"
val senderImageUrl = "$imageHost/$defaultPath"
val textDto = ChatMessageItemDto(
messageId = characterTextMsg.id!!,
message = characterTextMsg.message,
profileImageUrl = senderImageUrl,
mine = false,
createdAt = characterTextMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()
?.toEpochMilli()
?: 0L,
messageType = ChatMessageType.TEXT.name,
imageUrl = null,
price = null,
hasAccess = true
)
// 발송 후 최신 잔여 수량 및 next 계산 규칙 적용
val statusTotalRemaining = roomQuotaAfterConsume.totalRemaining
val globalAfter = chatQuotaService.getStatus(member.id!!)
val statusNextRechargeAt: Long? = when {
// 채팅 가능: totalRemaining>0 → next 표시하지 않음
statusTotalRemaining > 0 -> null
// totalRemaining==0이고 (global<=0) → max(roomNext, globalNext)
statusTotalRemaining == 0 && globalAfter.totalRemaining <= 0 -> {
val roomNext = roomQuotaAfterConsume.nextRechargeAtEpochMillis
val globalNext = globalAfter.nextRechargeAtEpochMillis
if (roomNext == null) globalNext else if (globalNext == null) roomNext else maxOf(roomNext, globalNext)
}
statusTotalRemaining == 0 -> roomQuotaAfterConsume.nextRechargeAtEpochMillis
globalAfter.totalRemaining <= 0 -> globalAfter.nextRechargeAtEpochMillis
else -> null
}
// 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우)
val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply)
if (matchedImage != null) {
val owned = characterImageService.isOwnedImageByMember(matchedImage.id!!, member.id!!)
val priceInt: Int? = if (owned) {
null
} else {
val p = matchedImage.messagePriceCan
if (p <= 0L) null else if (p > Int.MAX_VALUE) Int.MAX_VALUE else p.toInt()
}
// 보유하지 않은 경우 블러 이미지로 전송
val snapshotPath = if (owned) matchedImage.imagePath else matchedImage.blurImagePath
val imageMsg = messageRepository.save(
ChatMessage(
message = "",
chatRoom = room,
participant = characterParticipant,
isActive = true,
messageType = ChatMessageType.IMAGE,
characterImage = matchedImage,
imagePath = snapshotPath,
price = priceInt
)
)
val imageDto = toChatMessageItemDto(imageMsg, member)
return SendChatMessageResponse(
messages = listOf(textDto, imageDto),
totalRemaining = statusTotalRemaining,
nextRechargeAtEpoch = statusNextRechargeAt
)
}
return SendChatMessageResponse(
messages = listOf(textDto),
totalRemaining = statusTotalRemaining,
nextRechargeAtEpoch = statusNextRechargeAt
)
}
private fun toChatMessageItemDto(
msg: ChatMessage,
member: Member,
forceHasAccess: Boolean = false
): ChatMessageItemDto {
val sender = msg.participant
val profilePath = when (sender.participantType) {
ParticipantType.USER -> sender.member?.profileImage
ParticipantType.CHARACTER -> sender.character?.imagePath
}
val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L
val hasAccess = if (forceHasAccess) {
true
} else if (msg.messageType == ChatMessageType.IMAGE) {
if (msg.price == null) {
true
} else {
msg.characterImage?.id?.let {
characterImageService.isOwnedImageByMember(it, member.id!!)
} ?: true
}
} else {
true
}
val expirationMs = 5L * 60L * 1000L
val resolvedImageUrl: String? = if (msg.messageType == ChatMessageType.IMAGE) {
val path = if (hasAccess) {
msg.characterImage?.imagePath ?: msg.imagePath
} else {
msg.imagePath
}
path?.let { p ->
if (hasAccess) {
imageCloudFront.generateSignedURL(p, expirationMs)
} else {
"$imageHost/$p"
}
}
} else {
null
}
return ChatMessageItemDto(
messageId = msg.id!!,
message = msg.message,
profileImageUrl = senderImageUrl,
mine = sender.member?.id == member.id,
createdAt = createdAtMillis,
messageType = msg.messageType.name,
imageUrl = resolvedImageUrl,
price = msg.price,
hasAccess = hasAccess
)
}
private fun callExternalApiForChatSendWithRetry(
@@ -603,4 +869,60 @@ class ChatRoomService(
}
return characterContent
}
private fun findTriggeredCharacterImage(characterId: Long, replyText: String): CharacterImage? {
val text = replyText.lowercase()
val images: List<CharacterImage> = characterImageService.listActiveByCharacter(characterId)
for (img in images) {
val triggers = img.triggerMappings
.map { it.tag.word.trim().lowercase() }
.filter { it.isNotBlank() }
if (triggers.isEmpty()) continue
val allIncluded = triggers.all { t -> text.contains(t) }
if (allIncluded) return img
}
return null
}
@Transactional
fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse {
// 0) 방 존재 및 내 참여 여부 확인
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.")
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
// 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인)
val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
val character = characterParticipant.character
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
// 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용)
canPaymentService.spendCan(
memberId = member.id!!,
needCan = 30,
canUsage = CanUsage.CHAT_ROOM_RESET,
chatRoomId = chatRoomId,
characterId = character.id!!,
container = container
)
// 3) 현재 채팅방 나가기 (세션 종료 실패 시 롤백되도록 설정)
leaveChatRoom(member, chatRoomId, true)
// 4) 동일한 캐릭터와 새로운 채팅방 생성
val created = createOrGetChatRoom(member, character.id!!)
// 5) 신규 채팅방 생성 성공 시: 기존 방의 유료 쿼터를 새 방으로 이관
chatRoomQuotaService.transferPaid(
memberId = member.id!!,
fromChatRoomId = chatRoomId,
toChatRoomId = created.chatRoomId,
toCharacterId = character.id!!
)
// 글로벌 무료 쿼터는 UTC 20:00 기준 lazy 충전이므로 별도의 초기화 불필요
return created
}
}

View File

@@ -123,6 +123,16 @@ class RedisConfig(
)
)
// 24시간 TTL 캐시: 인기 캐릭터 집계용
cacheConfigMap["popularCharacters_24h"] = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(24))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer()
)
)
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig)
.withInitialCacheConfigurations(cacheConfigMap)

View File

@@ -95,6 +95,7 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
.anyRequest().authenticated()
.and()
.build()

View File

@@ -53,7 +53,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -119,7 +122,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -196,7 +202,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
audioContent.member.id.eq(memberId)
.and(order.isActive.isTrue)
@@ -318,7 +327,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))

View File

@@ -31,7 +31,7 @@ object ImageBlurUtil {
* @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효.
* @return 블러된 새 이미지 (TYPE_INT_ARGB)
*/
fun blurFast(src: BufferedImage, radius: Int = 100, parallel: Boolean = true): BufferedImage {
fun blurFast(src: BufferedImage, radius: Int = 240, parallel: Boolean = true): BufferedImage {
require(radius > 0) { "radius must be > 0" }
// 1) 프리멀티 알파로 변환 (경계 품질↑)