Compare commits

..

90 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
99386c6d53 fix: ImageBlurUtil.kt
- 블러 처리 방식 변경
2025-08-21 20:14:06 +09:00
abbd73ac00 fix: ImageBlurUtil.kt
- 블러 처리 방식 변경
2025-08-21 19:51:10 +09:00
4bee95c8a6 fix: ImageBlurUtil.kt
- 블러 적용 범위 radius 10 -> 50
2025-08-21 19:34:23 +09:00
090fc81829 fix: ImageBlurUtil.kt
- 블러 적용 범위 radius 10 -> 50
2025-08-21 19:10:37 +09:00
75100cacec fix: ImageContentCloudFront.kt
- host, key-pair-id, key-file-path 참조 변경
2025-08-21 18:09:17 +09:00
13fd262c94 feat(chat-character-image): 캐릭터 이미지 리스트 API 추가 및 보유 판단 로직 적용 2025-08-21 17:39:19 +09:00
8451cdfb80 fix(chat-character-image): 캐릭터 이미지 가격
- 이미지 단독 구매 가격과 메시지를 통한 구매 가겨으로 분리
2025-08-21 04:07:25 +09:00
c8841856c0 fix(chat-character-image): 캐릭터 이미지 트리거 수정
- triggers가 null이거나 빈 리스트이면 수정없이 실행종료
2025-08-21 04:01:47 +09:00
2a30b28e43 feat(chat-character-image): 캐릭터 이미지
- 등록시 블러 이미지를 생성하여 저장하는 기능 추가
2025-08-21 04:00:02 +09:00
dd6849b840 feat(chat-character-image): 캐릭터 이미지
- 등록, 리스트, 상세, 트리거 단어 업데이트, 삭제 기능 추가
2025-08-21 03:33:42 +09:00
ca27903e45 fix(character-comment): 캐릭터 상세 댓글 집계 및 최신 댓글 조회 기준 수정
- 최신 댓글 조회 시 원댓글(Parent=null)만 대상으로 조회하도록 Repository 메서드 및 Service 로직 변경

- 총 댓글 수를 "활성 원댓글 + 활성 부모를 가진 활성 답글"로 계산하여, 삭제된 원댓글의 답글은 집계에서 제외되도록 수정
2025-08-20 15:39:52 +09:00
aeab6eddc2 feat(chat-character-comment): 캐릭터 댓글의 답글에 댓글 내용 추가 2025-08-20 00:37:39 +09:00
1c0d40aed9 feat(chat-character-comment): 캐릭터 댓글에 글쓴이 ID 추가 2025-08-20 00:26:11 +09:00
1444afaae2 feat(chat-character-comment): 캐릭터 댓글 삭제 및 신고 API 추가
- 삭제 API: 본인 댓글에 대해 soft delete 처리
- 신고 API: 신고 내용을 그대로 저장하는 CharacterCommentReport 엔티티/리포지토리 도입
- Controller: 삭제, 신고 엔드포인트 추가 및 인증/본인인증 체크
- Service: 비즈니스 로직 구현 및 예외 처리 강화

왜: 캐릭터 댓글 관리 기능 요구사항(삭제/신고)을 충족하기 위함
무엇: 엔드포인트, 서비스 로직, DTO 및 JPA 엔티티/리포지토리 추가
2025-08-20 00:13:13 +09:00
a05bc369b7 feat(character-comment): 댓글/대댓글 API
- 커서를 추가하여 페이징 처리
2025-08-19 23:57:46 +09:00
6c7f411869 feat(character-comment): 캐릭터 댓글/답글 API 및 응답 확장
- 댓글 리스트에 댓글 개수 추가
2025-08-19 23:37:24 +09:00
f61c45e89a feat(character-comment): 캐릭터 댓글/답글 API 추가 및 상세 응답 확장
- 캐릭터 댓글 엔티티/레포지토리/서비스/컨트롤러 추가
  - 댓글 작성 POST /api/chat/character/{characterId}/comments
  - 답글 작성 POST /api/chat/character/{characterId}/comments/{commentId}/replies
  - 댓글 목록 GET /api/chat/character/{characterId}/comments?limit=20
  - 답글 목록 GET /api/chat/character/{characterId}/comments/{commentId}/replies?limit=20
- DTO 추가/확장
  - CharacterCommentResponse, CharacterReplyResponse, CharacterCommentRepliesResponse, CreateCharacterCommentRequest
- 캐릭터 상세 응답(CharacterDetailResponse) 확장
  - latestComment(최신 댓글 1건) 추가
  - totalComments(전체 활성 댓글 수) 추가
- 성능 최적화: getReplies에서 원본 댓글 replyCount 계산 시 DB 카운트 호출 제거
  - toCommentResponse(replyCountOverride) 도입으로 원본 댓글 replyCount=0 고정
- 공통 검증: 로그인/본인인증/빈 내용 체크, 비활성 캐릭터/댓글 차단

WHY
- 캐릭터 상세 화면에 댓글 경험 제공 및 전체 댓글 수 노출 요구사항 반영
- 답글 조회 시 불필요한 카운트 쿼리 제거로 DB 호출 최소화
2025-08-19 18:47:59 +09:00
27ed9f61d0 fix(chat): 채팅방 메시지 전송 API 반환값 수정
- 기존: SendChatMessageResponse으로 메시지 리스트를 한 번 더 Wrapping해서 보냄

- 수정: 메시지 리스트 반환
2025-08-14 22:00:42 +09:00
df77e31043 feat(chat): 채팅방 입장 API와 메시지 페이징 초기 로드 구현
- GET /api/chat/room/{chatRoomId}/enter 엔드포인트 추가
- 참여 검증 후 roomId, character(아이디/이름/프로필/타입) 제공
- 최신 20개 메시지 조회(내림차순 조회 후 createdAt 오름차순으로 정렬)
- hasMoreMessages 플래그 계산(가장 오래된 메시지 이전 존재 여부 판단)
2025-08-14 21:56:27 +09:00
2d65bdb8ee feat(chat): 채팅방 메시지 조회 API에 커서 기반 페이징 도입 및 createdAt 추가
cursor(< messageId) 기준의 커서 페이징 도입, 경계 exclusive 처리
limit 파라미터로 페이지 사이즈 가변화 (기본 20)
응답 스키마를 ChatMessagesPageResponse(messages, hasMore, nextCursor)로 변경
메시지 정렬을 createdAt 오름차순(표시 시간 순)으로 반환
ChatMessageItemDto에 createdAt(epoch millis) 필드 추가
레포지토리에 Pageable 기반 조회 및 이전 데이터 존재 여부 검사 메서드 추가
컨트롤러/서비스 시그니처 및 내부 로직 업데이트
2025-08-14 21:43:42 +09:00
92 changed files with 5215 additions and 282 deletions

View File

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

View File

@@ -39,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom) .innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate)) .and(useCan.createdAt.goe(startDate))
@@ -75,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
order.createdAt.goe(startDate) order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate)) .and(order.createdAt.loe(endDate))
@@ -142,7 +148,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(order.isActive.isTrue) .where(order.isActive.isTrue)
.groupBy( .groupBy(
member.id, member.id,
@@ -230,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member) .innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -251,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom) .innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate)) .and(useCan.createdAt.goe(startDate))
@@ -281,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom) .innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate)) .and(useCan.createdAt.goe(startDate))
@@ -301,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
order.createdAt.goe(startDate) order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate)) .and(order.createdAt.loe(endDate))
@@ -331,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
order.createdAt.goe(startDate) order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate)) .and(order.createdAt.loe(endDate))
@@ -351,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member) .innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -382,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member) .innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) .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.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.FetchType import javax.persistence.FetchType
import javax.persistence.JoinColumn import javax.persistence.JoinColumn
@@ -9,12 +10,29 @@ import javax.persistence.OneToOne
@Entity @Entity
data class CreatorSettlementRatio( data class CreatorSettlementRatio(
val subsidy: Int, var subsidy: Int,
val liveSettlementRatio: Int, var liveSettlementRatio: Int,
val contentSettlementRatio: Int, var contentSettlementRatio: Int,
val communitySettlementRatio: Int var communitySettlementRatio: Int
) : BaseEntity() { ) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false) @JoinColumn(name = "member_id", nullable = false)
var member: Member? = null 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.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
limit = pageable.pageSize.toLong() 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 : interface CreatorSettlementRatioRepository :
JpaRepository<CreatorSettlementRatio, Long>, JpaRepository<CreatorSettlementRatio, Long>,
CreatorSettlementRatioQueryRepository CreatorSettlementRatioQueryRepository {
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
}
interface CreatorSettlementRatioQueryRepository { interface CreatorSettlementRatioQueryRepository {
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
@@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory return queryFactory
.select( .select(
QGetCreatorSettlementRatioItem( QGetCreatorSettlementRatioItem(
member.id,
member.nickname, member.nickname,
creatorSettlementRatio.subsidy, creatorSettlementRatio.subsidy,
creatorSettlementRatio.liveSettlementRatio, creatorSettlementRatio.liveSettlementRatio,
@@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
) )
.from(creatorSettlementRatio) .from(creatorSettlementRatio)
.innerJoin(creatorSettlementRatio.member, member) .innerJoin(creatorSettlementRatio.member, member)
.where(creatorSettlementRatio.deletedAt.isNull)
.orderBy(creatorSettlementRatio.id.asc()) .orderBy(creatorSettlementRatio.id.asc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory return queryFactory
.select(creatorSettlementRatio.id) .select(creatorSettlementRatio.id)
.from(creatorSettlementRatio) .from(creatorSettlementRatio)
.where(creatorSettlementRatio.deletedAt.isNull)
.fetch() .fetch()
.size .size
} }

View File

@@ -14,8 +14,6 @@ class CreatorSettlementRatioService(
) { ) {
@Transactional @Transactional
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creatorSettlementRatio = request.toEntity()
val creator = memberRepository.findByIdOrNull(request.memberId) val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.") ?: throw SodaException("잘못된 크리에이터 입니다.")
@@ -23,10 +21,52 @@ class CreatorSettlementRatioService(
throw SodaException("잘못된 크리에이터 입니다.") 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 creatorSettlementRatio.member = creator
repository.save(creatorSettlementRatio) 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) @Transactional(readOnly = true)
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse { fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
val totalCount = repository.getCreatorSettlementRatioTotalCount() val totalCount = repository.getCreatorSettlementRatioTotalCount()

View File

@@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse(
) )
data class GetCreatorSettlementRatioItem @QueryProjection constructor( data class GetCreatorSettlementRatioItem @QueryProjection constructor(
val memberId: Long,
val nickname: String, val nickname: String,
val subsidy: Int, val subsidy: Int,
val liveSettlementRatio: 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.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper 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.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.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse 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.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.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.CharacterType import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
@@ -18,8 +20,6 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.client.SimpleClientHttpRequestFactory 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.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@@ -39,6 +39,7 @@ class AdminChatCharacterController(
private val service: ChatCharacterService, private val service: ChatCharacterService,
private val adminService: AdminChatCharacterService, private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService,
@Value("\${weraser.api-key}") @Value("\${weraser.api-key}")
private val apiKey: String, private val apiKey: String,
@@ -70,6 +71,26 @@ class AdminChatCharacterController(
ApiResponse.ok(response) 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 * 캐릭터 상세 정보 조회 API
* *
@@ -86,11 +107,6 @@ class AdminChatCharacterController(
} }
@PostMapping("/register") @PostMapping("/register")
@Retryable(
value = [Exception::class],
maxAttempts = 3,
backoff = Backoff(delay = 1000)
)
fun registerCharacter( fun registerCharacter(
@RequestPart("image") image: MultipartFile, @RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String @RequestPart("request") requestString: String
@@ -144,6 +160,11 @@ class AdminChatCharacterController(
chatCharacter.imagePath = imagePath chatCharacter.imagePath = imagePath
service.saveChatCharacter(chatCharacter) service.saveChatCharacter(chatCharacter)
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
}
ApiResponse.ok(null) ApiResponse.ok(null)
} }
@@ -236,11 +257,6 @@ class AdminChatCharacterController(
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우 * @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
*/ */
@PutMapping("/update") @PutMapping("/update")
@Retryable(
value = [Exception::class],
maxAttempts = 3,
backoff = Backoff(delay = 1000)
)
fun updateCharacter( fun updateCharacter(
@RequestPart(value = "image", required = false) image: MultipartFile?, @RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") requestString: String @RequestPart("request") requestString: String
@@ -259,7 +275,8 @@ class AdminChatCharacterController(
val hasDbOnlyChanges = val hasDbOnlyChanges =
request.originalTitle != null || request.originalTitle != null ||
request.originalLink != null || request.originalLink != null ||
request.characterType != null request.characterType != null ||
request.originalWorkId != null
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) { if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
throw SodaException("변경된 데이터가 없습니다.") throw SodaException("변경된 데이터가 없습니다.")
@@ -298,6 +315,12 @@ class AdminChatCharacterController(
request = request request = request
) )
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
// 서비스에서 유효성 검증 및 저장까지 처리
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
}
ApiResponse.ok(null) 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 import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/**
* 관리자 캐릭터 상세 응답 DTO
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
*/
data class ChatCharacterDetailResponse( data class ChatCharacterDetailResponse(
val id: Long, val id: Long,
val characterUUID: String, val characterUUID: String,
@@ -24,7 +28,8 @@ data class ChatCharacterDetailResponse(
val relationships: List<RelationshipResponse>, val relationships: List<RelationshipResponse>,
val personalities: List<PersonalityResponse>, val personalities: List<PersonalityResponse>,
val backgrounds: List<BackgroundResponse>, val backgrounds: List<BackgroundResponse>,
val memories: List<MemoryResponse> val memories: List<MemoryResponse>,
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
) { ) {
companion object { companion object {
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse { fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
@@ -34,6 +39,20 @@ data class ChatCharacterDetailResponse(
chatCharacter.imagePath ?: "" 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( return ChatCharacterDetailResponse(
id = chatCharacter.id!!, id = chatCharacter.id!!,
characterUUID = chatCharacter.characterUUID, characterUUID = chatCharacter.characterUUID,
@@ -71,7 +90,8 @@ data class ChatCharacterDetailResponse(
}, },
memories = chatCharacter.memories.map { memories = chatCharacter.memories.map {
MemoryResponse(it.title, it.content, it.emotion) MemoryResponse(it.title, it.content, it.emotion)
} },
originalWork = originalWorkBrief
) )
} }
} }
@@ -101,3 +121,12 @@ data class RelationshipResponse(
val relationshipType: String, val relationshipType: String,
val currentStatus: 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("appearance") val appearance: String?,
@JsonProperty("originalTitle") val originalTitle: String? = null, @JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null, @JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
@JsonProperty("characterType") val characterType: String? = null, @JsonProperty("characterType") val characterType: String? = null,
@JsonProperty("tags") val tags: List<String> = emptyList(), @JsonProperty("tags") val tags: List<String> = emptyList(),
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(), @JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
@@ -75,6 +76,7 @@ data class ChatCharacterUpdateRequest(
@JsonProperty("appearance") val appearance: String? = null, @JsonProperty("appearance") val appearance: String? = null,
@JsonProperty("originalTitle") val originalTitle: String? = null, @JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null, @JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
@JsonProperty("characterType") val characterType: String? = null, @JsonProperty("characterType") val characterType: String? = null,
@JsonProperty("isActive") val isActive: Boolean? = null, @JsonProperty("isActive") val isActive: Boolean? = null,
@JsonProperty("tags") val tags: List<String>? = 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 import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/** /**
* 캐릭터 검색 결과 응답 DTO * 원작 연결된 캐릭터 결과 응답 DTO
*/ */
data class ChatCharacterSearchResponse( data class OriginalWorkChatCharacterResponse(
val id: Long, val id: Long,
val name: String, val name: String,
val imagePath: String? val imagePath: String?
) { ) {
companion object { companion object {
fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse { fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
return ChatCharacterSearchResponse( return OriginalWorkChatCharacterResponse(
id = character.id!!, id = character.id!!,
name = character.name, name = character.name,
imagePath = character.imagePath?.let { "$imageHost/$it" } 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 totalCount: Long,
val content: List<ChatCharacterSearchResponse> val content: List<OriginalWorkChatCharacterResponse>
) )

View File

@@ -0,0 +1,170 @@
package kr.co.vividnext.sodalive.admin.chat.character.image
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
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
@RestController
@RequestMapping("/admin/chat/character/image")
@PreAuthorize("hasRole('ADMIN')")
class AdminCharacterImageController(
private val imageService: CharacterImageService,
private val s3Uploader: S3Uploader,
private val imageCloudFront: ImageContentCloudFront,
@Value("\${cloud.aws.s3.content-bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.s3.bucket}")
private val freeBucket: String
) {
@GetMapping("/list")
fun list(@RequestParam characterId: Long) = run {
val expiration = 5L * 60L * 1000L // 5분
val list = imageService.listActiveByCharacter(characterId)
.map { img ->
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
}
ApiResponse.ok(list)
}
@GetMapping("/{imageId}")
fun detail(@PathVariable imageId: Long) = run {
val img = imageService.getById(imageId)
val expiration = 5L * 60L * 1000L // 5분
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
}
@PostMapping("/register")
fun register(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
// 업로드 키 생성
val s3Key = buildS3Key(characterId = request.characterId)
// 원본 저장 (content-bucket)
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
// 블러 생성 및 저장 (무료 이미지 버킷)
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
imageService.registerImage(
characterId = request.characterId,
imagePath = imagePath,
blurImagePath = blurImagePath,
imagePriceCan = request.imagePriceCan,
messagePriceCan = request.messagePriceCan,
isAdult = request.isAdult,
triggers = request.triggers ?: emptyList()
)
ApiResponse.ok(null)
}
@PutMapping("/{imageId}/triggers")
fun updateTriggers(
@PathVariable imageId: Long,
@RequestBody request: UpdateCharacterImageTriggersRequest
) = run {
if (!request.triggers.isNullOrEmpty()) {
imageService.updateTriggers(imageId, request.triggers)
}
ApiResponse.ok(null)
}
@DeleteMapping("/{imageId}")
fun delete(@PathVariable imageId: Long) = run {
imageService.deleteImage(imageId)
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
}
@PutMapping("/orders")
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
imageService.updateOrders(request.characterId, request.ids)
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
}
private fun buildS3Key(characterId: Long): String {
val fileName = generateFileName("character-image")
return "characters/$characterId/images/$fileName"
}
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = filePath,
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
try {
// 멀티파트를 BufferedImage로 읽기
val bytes = image.bytes
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
val blurred = ImageBlurUtil.blurFast(bimg)
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
val baos = java.io.ByteArrayOutputStream()
val format = when (image.contentType?.lowercase()) {
"image/png" -> "png"
else -> "jpg"
}
javax.imageio.ImageIO.write(blurred, format, baos)
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
val metadata = ObjectMetadata()
metadata.contentLength = baos.size().toLong()
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
return s3Uploader.upload(
inputStream = inputStream,
bucket = bucket,
filePath = filePath,
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
}
}
}

View File

@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
// 요청 DTOs
data class RegisterCharacterImageRequest(
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
@JsonProperty("isAdult") val isAdult: Boolean = false,
@JsonProperty("triggers") val triggers: List<String>? = null
)
data class UpdateCharacterImageTriggersRequest(
@JsonProperty("triggers") val triggers: List<String>? = null
)
data class UpdateCharacterImageOrdersRequest(
@JsonProperty("characterId") val characterId: Long?,
@JsonProperty("ids") val ids: List<Long>
)
// 응답 DTOs
data class AdminCharacterImageResponse(
val id: Long,
val characterId: Long,
val imagePriceCan: Long,
val messagePriceCan: Long,
val imageUrl: String,
val triggers: List<String>,
val isAdult: Boolean
) {
companion object {
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
return base(entity, signedUrl)
}
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
return AdminCharacterImageResponse(
id = entity.id!!,
characterId = entity.chatCharacter.id!!,
imagePriceCan = entity.imagePriceCan,
messagePriceCan = entity.messagePriceCan,
imageUrl = url,
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.ChatCharacterDetailResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse 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.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.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
@@ -65,20 +64,15 @@ class AdminChatCharacterService(
} }
/** /**
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) * 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
*
* @param searchTerm 검색어
* @param pageable 페이징 정보
* @param imageHost 이미지 호스트 URL
* @return 검색된 캐릭터 목록 (페이징)
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun searchCharacters( fun searchCharacters(
searchTerm: String, searchTerm: String,
pageable: Pageable, pageable: Pageable,
imageHost: String = "" imageHost: String = ""
): Page<ChatCharacterSearchResponse> { ): Page<ChatCharacterListResponse> {
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable) 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

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.aws.cloudfront
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Paths
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
/**
* 이미지(CloudFront) 서명 URL 생성기
* - cloud.aws.cloud-front.* 설정을 사용
*/
@Component
class ImageContentCloudFront(
@Value("\${cloud.aws.content-cloud-front.host}")
private val cloudfrontDomain: String,
@Value("\${cloud.aws.content-cloud-front.private-key-file-path}")
private val privateKeyFilePath: String,
@Value("\${cloud.aws.content-cloud-front.key-pair-id}")
private val keyPairId: String
) {
fun generateSignedURL(
resourcePath: String,
expirationTimeMillis: Long
): String {
val privateKey = loadPrivateKey(privateKeyFilePath)
return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
"$cloudfrontDomain/$resourcePath",
keyPairId,
privateKey,
Date(System.currentTimeMillis() + expirationTimeMillis)
)
}
private fun loadPrivateKey(resourceName: String): PrivateKey {
val path = Paths.get(resourceName)
val bytes = Files.readAllBytes(path)
val keySpec = PKCS8EncodedKeySpec(bytes)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePrivate(keySpec)
}
}

View File

@@ -72,6 +72,10 @@ class CanService(private val repository: CanRepository) {
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" 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!! 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.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository 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.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.content.order.Order
@@ -37,6 +38,8 @@ class CanPaymentService(
memberId: Long, memberId: Long,
needCan: Int, needCan: Int,
canUsage: CanUsage, canUsage: CanUsage,
chatRoomId: Long? = null,
characterId: Long? = null,
isSecret: Boolean = false, isSecret: Boolean = false,
liveRoom: LiveRoom? = null, liveRoom: LiveRoom? = null,
order: Order? = null, order: Order? = null,
@@ -109,6 +112,14 @@ class CanPaymentService(
recipientId = liveRoom.member!!.id!! recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom useCan.room = liveRoom
useCan.member = member 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 { } else {
throw SodaException("잘못된 요청입니다.") throw SodaException("잘못된 요청입니다.")
} }
@@ -327,4 +338,98 @@ class CanPaymentService(
chargeRepository.save(charge) 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

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

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.can.use package kr.co.vividnext.sodalive.can.use
import kr.co.vividnext.sodalive.audition.AuditionApplicant import kr.co.vividnext.sodalive.audition.AuditionApplicant
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.content.order.Order
@@ -28,7 +30,11 @@ data class UseCan(
var isRefund: Boolean = false, var isRefund: Boolean = false,
val isSecret: Boolean = false val isSecret: Boolean = false,
// 채팅 연동을 위한 식별자 (옵션)
var chatRoomId: Long? = null,
var characterId: Long? = null
) : BaseEntity() { ) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false) @JoinColumn(name = "member_id", nullable = false)
@@ -58,6 +64,16 @@ data class UseCan(
@JoinColumn(name = "audition_applicant_id", nullable = true) @JoinColumn(name = "audition_applicant_id", nullable = true)
var auditionApplicant: AuditionApplicant? = null var auditionApplicant: AuditionApplicant? = null
// 메시지를 통한 구매 연관 (옵션)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_message_id", nullable = true)
var chatMessage: ChatMessage? = null
// 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_image_id", nullable = true)
var characterImage: CharacterImage? = null
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf() val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
} }

View File

@@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository {
// 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외)
fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
memberId: Long,
imageId: Long,
usages: Collection<CanUsage>
): Boolean
}
interface UseCanQueryRepository { interface UseCanQueryRepository {
fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
fun countPurchasedActiveImagesByCharacter(
memberId: Long,
characterId: Long,
usages: Collection<CanUsage>
): Long
} }
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository { class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
@@ -26,4 +38,24 @@ class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Use
return useCanId != null && useCanId > 0 return useCanId != null && useCanId > 0
} }
override fun countPurchasedActiveImagesByCharacter(
memberId: Long,
characterId: Long,
usages: Collection<CanUsage>
): Long {
val count = queryFactory
.selectDistinct(useCan.characterImage.id)
.from(useCan)
.where(
useCan.member.id.eq(memberId)
.and(useCan.isRefund.isFalse)
.and(useCan.characterImage.chatCharacter.id.eq(characterId))
.and(useCan.characterImage.isActive.isTrue)
.and(useCan.canUsage.`in`(usages))
)
.fetch()
.size
return count.toLong()
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.chat.character.comment
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.Table
@Entity
@Table(name = "character_comment")
data class CharacterComment(
@Column(columnDefinition = "TEXT", nullable = false)
var comment: String,
var isActive: Boolean = true
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id", nullable = true)
var parent: CharacterComment? = null
set(value) {
value?.children?.add(this)
field = value
}
@OneToMany(mappedBy = "parent")
var children: MutableList<CharacterComment> = mutableListOf()
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_id", nullable = false)
var chatCharacter: ChatCharacter? = null
}

View File

@@ -0,0 +1,108 @@
package kr.co.vividnext.sodalive.chat.character.comment
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.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chat/character")
class CharacterCommentController(
private val service: CharacterCommentService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@PostMapping("/{characterId}/comments")
fun createComment(
@PathVariable characterId: Long,
@RequestBody request: CreateCharacterCommentRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val id = service.addComment(characterId, member, request.comment)
ApiResponse.ok(id)
}
@PostMapping("/{characterId}/comments/{commentId}/replies")
fun createReply(
@PathVariable characterId: Long,
@PathVariable commentId: Long,
@RequestBody request: CreateCharacterCommentRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val id = service.addReply(characterId, commentId, member, request.comment)
ApiResponse.ok(id)
}
@GetMapping("/{characterId}/comments")
fun listComments(
@PathVariable characterId: Long,
@RequestParam(required = false, defaultValue = "20") limit: Int,
@RequestParam(required = false) cursor: Long?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val data = service.listComments(imageHost, characterId, cursor, limit)
ApiResponse.ok(data)
}
@GetMapping("/{characterId}/comments/{commentId}/replies")
fun listReplies(
@PathVariable characterId: Long,
@PathVariable commentId: Long,
@RequestParam(required = false, defaultValue = "20") limit: Int,
@RequestParam(required = false) cursor: Long?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
// characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨
val data = service.getReplies(imageHost, commentId, cursor, limit)
ApiResponse.ok(data)
}
@DeleteMapping("/{characterId}/comments/{commentId}")
fun deleteComment(
@PathVariable characterId: Long,
@PathVariable commentId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
service.deleteComment(characterId, commentId, member)
ApiResponse.ok(true, "댓글이 삭제되었습니다.")
}
@PostMapping("/{characterId}/comments/{commentId}/reports")
fun reportComment(
@PathVariable characterId: Long,
@PathVariable commentId: Long,
@RequestBody request: ReportCharacterCommentRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
service.reportComment(characterId, commentId, member, request.content)
ApiResponse.ok(true, "신고가 접수되었습니다.")
}
}

View File

@@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.chat.character.comment
// Request DTOs
data class CreateCharacterCommentRequest(
val comment: String
)
// Response DTOs
// 댓글 Response
// - 댓글 ID
// - 댓글 쓴 Member 프로필 이미지
// - 댓글 쓴 Member 닉네임
// - 댓글 쓴 시간 timestamp(long)
// - 답글 수
data class CharacterCommentResponse(
val commentId: Long,
val memberId: Long,
val memberProfileImage: String,
val memberNickname: String,
val createdAt: Long,
val replyCount: Int,
val comment: String
)
// 답글 Response 단건(목록 원소)
// - 답글 ID
// - 답글 쓴 Member 프로필 이미지
// - 답글 쓴 Member 닉네임
// - 답글 쓴 시간 timestamp(long)
data class CharacterReplyResponse(
val replyId: Long,
val memberId: Long,
val memberProfileImage: String,
val memberNickname: String,
val createdAt: Long,
val comment: String
)
// 댓글의 답글 조회 Response 컨테이너
// - 원본 댓글 Response
// - 답글 목록(위 사양의 필드 포함)
data class CharacterCommentRepliesResponse(
val original: CharacterCommentResponse,
val replies: List<CharacterReplyResponse>,
val cursor: Long?
)
// 댓글 리스트 조회 Response 컨테이너
// - 전체 댓글 개수(totalCount)
// - 댓글 목록(comments)
data class CharacterCommentListResponse(
val totalCount: Int,
val comments: List<CharacterCommentResponse>,
val cursor: Long?
)
// 신고 Request
data class ReportCharacterCommentRequest(
val content: String
)

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.chat.character.comment
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.Table
@Entity
@Table(name = "character_comment_report")
data class CharacterCommentReport(
@Column(columnDefinition = "TEXT", nullable = false)
val content: String
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id", nullable = false)
var comment: CharacterComment? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.chat.character.comment
import org.springframework.data.jpa.repository.JpaRepository
interface CharacterCommentReportRepository : JpaRepository<CharacterCommentReport, Long>

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.chat.character.comment
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
interface CharacterCommentRepository : JpaRepository<CharacterComment, Long> {
fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
chatCharacterId: Long,
pageable: Pageable
): List<CharacterComment>
fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullAndIdLessThanOrderByCreatedAtDesc(
chatCharacterId: Long,
id: Long,
pageable: Pageable
): List<CharacterComment>
fun countByParent_IdAndIsActiveTrue(parentId: Long): Int
fun findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(parentId: Long, pageable: Pageable): List<CharacterComment>
fun findByParent_IdAndIsActiveTrueAndIdLessThanOrderByCreatedAtDesc(
parentId: Long,
id: Long,
pageable: Pageable
): List<CharacterComment>
// 최신 원댓글만 조회
fun findFirstByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
chatCharacterId: Long
): CharacterComment?
// 활성 원댓글 수
fun countByChatCharacter_IdAndIsActiveTrueAndParentIsNull(chatCharacterId: Long): Int
// 활성 부모를 가진 활성 답글 수 (부모가 null인 경우 제외됨)
fun countByChatCharacter_IdAndIsActiveTrueAndParent_IsActiveTrue(chatCharacterId: Long): Int
}

View File

@@ -0,0 +1,194 @@
package kr.co.vividnext.sodalive.chat.character.comment
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.ZoneId
@Service
class CharacterCommentService(
private val chatCharacterRepository: ChatCharacterRepository,
private val commentRepository: CharacterCommentRepository,
private val reportRepository: CharacterCommentReportRepository
) {
private fun profileUrl(imageHost: String, profileImage: String?): String {
return if (profileImage.isNullOrBlank()) {
"$imageHost/profile/default-profile.png"
} else {
"$imageHost/$profileImage"
}
}
private fun toEpochMilli(created: java.time.LocalDateTime?): Long {
return created?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L
}
private fun toCommentResponse(
imageHost: String,
entity: CharacterComment,
replyCountOverride: Int? = null
): CharacterCommentResponse {
val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.")
return CharacterCommentResponse(
commentId = entity.id!!,
memberId = member.id!!,
memberProfileImage = profileUrl(imageHost, member.profileImage),
memberNickname = member.nickname,
createdAt = toEpochMilli(entity.createdAt),
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
comment = entity.comment
)
}
private fun toReplyResponse(imageHost: String, entity: CharacterComment): CharacterReplyResponse {
val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.")
return CharacterReplyResponse(
replyId = entity.id!!,
memberId = member.id!!,
memberProfileImage = profileUrl(imageHost, member.profileImage),
memberNickname = member.nickname,
createdAt = toEpochMilli(entity.createdAt),
comment = entity.comment
)
}
@Transactional
fun addComment(characterId: Long, member: Member, text: String): Long {
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val entity = CharacterComment(comment = text)
entity.chatCharacter = character
entity.member = member
commentRepository.save(entity)
return entity.id!!
}
@Transactional
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long {
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
if (parent.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.")
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val entity = CharacterComment(comment = text)
entity.chatCharacter = character
entity.member = member
entity.parent = parent
commentRepository.save(entity)
return entity.id!!
}
@Transactional(readOnly = true)
fun listComments(
imageHost: String,
characterId: Long,
cursor: Long?,
limit: Int = 20
): CharacterCommentListResponse {
val pageable = PageRequest.of(0, limit)
val comments = if (cursor == null) {
commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
characterId,
pageable
)
} else {
commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullAndIdLessThanOrderByCreatedAtDesc(
characterId,
cursor,
pageable
)
}
val items = comments.map { toCommentResponse(imageHost, it) }
val total = getTotalCommentCount(characterId)
val nextCursor = if (items.size == limit) items.lastOrNull()?.commentId else null
return CharacterCommentListResponse(
totalCount = total,
comments = items,
cursor = nextCursor
)
}
@Transactional(readOnly = true)
fun getReplies(
imageHost: String,
commentId: Long,
cursor: Long?,
limit: Int = 20
): CharacterCommentRepliesResponse {
val original = commentRepository.findById(commentId).orElseThrow {
SodaException("댓글을 찾을 수 없습니다.")
}
if (!original.isActive) throw SodaException("비활성화된 댓글입니다.")
val pageable = PageRequest.of(0, limit)
val replies = if (cursor == null) {
commentRepository.findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(commentId, pageable)
} else {
commentRepository.findByParent_IdAndIsActiveTrueAndIdLessThanOrderByCreatedAtDesc(
commentId,
cursor,
pageable
)
}
val items = replies.map { toReplyResponse(imageHost, it) }
val nextCursor = if (items.size == limit) items.lastOrNull()?.replyId else null
return CharacterCommentRepliesResponse(
original = toCommentResponse(imageHost, original, 0),
replies = items,
cursor = nextCursor
)
}
@Transactional(readOnly = true)
fun getLatestComment(imageHost: String, characterId: Long): CharacterCommentResponse? {
return commentRepository
.findFirstByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(characterId)
?.let { toCommentResponse(imageHost, it) }
}
@Transactional(readOnly = true)
fun getTotalCommentCount(characterId: Long): Int {
// 활성 원댓글 수 + 활성 부모를 가진 활성 답글 수
val originalCount = commentRepository
.countByChatCharacter_IdAndIsActiveTrueAndParentIsNull(characterId)
val replyWithActiveParentCount = commentRepository
.countByChatCharacter_IdAndIsActiveTrueAndParent_IsActiveTrue(characterId)
return originalCount + replyWithActiveParentCount
}
@Transactional
fun deleteComment(characterId: Long, commentId: Long, member: Member) {
val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.")
if (!comment.isActive) return
val ownerId = comment.member?.id ?: throw SodaException("유효하지 않은 댓글입니다.")
if (ownerId != member.id) throw SodaException("삭제 권한이 없습니다.")
comment.isActive = false
commentRepository.save(comment)
}
@Transactional
fun reportComment(characterId: Long, commentId: Long, member: Member, content: String) {
val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.")
if (content.isBlank()) throw SodaException("신고 내용을 입력해주세요.")
val report = CharacterCommentReport(content = content)
report.comment = comment
report.member = member
reportRepository.save(report)
}
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.chat.character.controller package kr.co.vividnext.sodalive.chat.character.controller
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
@@ -21,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@@ -29,6 +31,8 @@ class ChatCharacterController(
private val service: ChatCharacterService, private val service: ChatCharacterService,
private val bannerService: ChatCharacterBannerService, private val bannerService: ChatCharacterBannerService,
private val chatRoomService: ChatRoomService, 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}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
@@ -54,38 +58,39 @@ class ChatCharacterController(
chatRoomService.listMyChatRooms(member, 0, 10) chatRoomService.listMyChatRooms(member, 0, 10)
.map { room -> .map { room ->
RecentCharacter( RecentCharacter(
roomId = room.chatRoomId, characterId = room.characterId,
name = room.title, name = room.title,
imageUrl = room.imageUrl imageUrl = room.imageUrl
) )
} }
} }
// 인기 캐릭터 조회 (현재는 빈 리스트) // 인기 캐릭터 조회
val popularCharacters = service.getPopularCharacters() val popularCharacters = service.getPopularCharacters()
.map {
Character( // 최근 등록된 캐릭터 리스트 조회
characterId = it.id!!, val newCharacters = service.getRecentCharactersPage(
name = it.name, page = 0,
description = it.description, size = 50
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" ).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,
description = it.description,
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( ApiResponse.ok(
CharacterMainResponse( CharacterMainResponse(
@@ -148,6 +153,9 @@ class ChatCharacterController(
) )
} }
// 최신 댓글 1개 조회
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
// 응답 생성 // 응답 생성
ApiResponse.ok( ApiResponse.ok(
CharacterDetailResponse( CharacterDetailResponse(
@@ -155,6 +163,8 @@ class ChatCharacterController(
name = character.name, name = character.name,
description = character.description, description = character.description,
mbti = character.mbti, mbti = character.mbti,
gender = character.gender,
age = character.age,
imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}", imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}",
personalities = personality, personalities = personality,
backgrounds = background, backgrounds = background,
@@ -162,7 +172,24 @@ class ChatCharacterController(
originalTitle = character.originalTitle, originalTitle = character.originalTitle,
originalLink = character.originalLink, originalLink = character.originalLink,
characterType = character.characterType, characterType = character.characterType,
others = others others = others,
latestComment = latestComment,
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
)
)
}
/**
* 최근 등록된 캐릭터 전체보기
* - 기준: 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

@@ -1,12 +1,15 @@
package kr.co.vividnext.sodalive.chat.character.dto package kr.co.vividnext.sodalive.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.CharacterType import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
data class CharacterDetailResponse( data class CharacterDetailResponse(
val characterId: Long, val characterId: Long,
val name: String, val name: String,
val description: String, val description: String,
val mbti: String?, val mbti: String?,
val gender: String?,
val age: Int?,
val imageUrl: String, val imageUrl: String,
val personalities: CharacterPersonalityResponse?, val personalities: CharacterPersonalityResponse?,
val backgrounds: CharacterBackgroundResponse?, val backgrounds: CharacterBackgroundResponse?,
@@ -14,7 +17,9 @@ data class CharacterDetailResponse(
val originalTitle: String?, val originalTitle: String?,
val originalLink: String?, val originalLink: String?,
val characterType: CharacterType, val characterType: CharacterType,
val others: List<OtherCharacter> val others: List<OtherCharacter>,
val latestComment: CharacterCommentResponse?,
val totalComments: Int
) )
data class OtherCharacter( data class OtherCharacter(

View File

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

@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
class CharacterImage(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_id")
var chatCharacter: ChatCharacter,
// 원본 이미지 경로 (S3 key - content-bucket)
var imagePath: String,
// 블러 이미지 경로 (S3 key - free/public bucket)
var blurImagePath: String,
// 이미지 단독 구매 가격 (단위: can)
var imagePriceCan: Long = 0L,
// 메시지를 통한 가격 (단위: can)
var messagePriceCan: Long = 0L,
// 성인 이미지 여부 (본인인증 필요)
var isAdult: Boolean = false,
// 갤러리/관리자 노출 순서 (낮을수록 먼저)
var sortOrder: Int = 0,
// 활성화 여부 (소프트 삭제)
var isActive: Boolean = true
) : BaseEntity() {
@OneToMany(mappedBy = "characterImage", cascade = [CascadeType.ALL], orphanRemoval = true)
var triggerMappings: MutableList<CharacterImageTriggerMapping> = mutableListOf()
}

View File

@@ -0,0 +1,226 @@
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
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
@RestController
@RequestMapping("/api/chat/character/image")
class CharacterImageController(
private val imageService: CharacterImageService,
private val imageCloudFront: ImageContentCloudFront,
private val canPaymentService: CanPaymentService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@GetMapping("/list")
fun list(
@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 totalActiveElements = imageService.pageActiveByCharacter(characterId, PageRequest.of(0, 1)).totalElements
// 프로필 이미지는 무료로 볼 수 있으므로 보유 개수에도 +1 반영
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) + 1
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 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,
sortOrder = img.sortOrder
)
)
}
}
ApiResponse.ok(
CharacterImageListResponse(
totalCount = totalCount,
ownedCount = ownedCount,
items = items
)
)
}
@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

@@ -0,0 +1,100 @@
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
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage>
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(
characterId: Long,
pageable: Pageable
): Page<CharacterImage>
fun countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId: Long, imagePriceCan: Long): Long
@Query(
"SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " +
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
)
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

@@ -0,0 +1,169 @@
package kr.co.vividnext.sodalive.chat.character.image
// ktlint-disable standard:max-line-length
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CharacterImageService(
private val characterRepository: ChatCharacterRepository,
private val imageRepository: CharacterImageRepository,
private val triggerTagRepository: CharacterImageTriggerRepository,
private val useCanRepository: kr.co.vividnext.sodalive.can.use.UseCanRepository
) {
fun listActiveByCharacter(characterId: Long): List<CharacterImage> {
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId)
}
// 페이징 조회(활성 이미지)
fun pageActiveByCharacter(characterId: Long, pageable: Pageable): Page<CharacterImage> {
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)
val purchasedCount = useCanRepository.countPurchasedActiveImagesByCharacter(
memberId,
characterId,
listOf(
CanUsage.CHAT_MESSAGE_PURCHASE,
CanUsage.CHARACTER_IMAGE_PURCHASE
)
)
return freeCount + purchasedCount
}
fun isOwnedImageByMember(imageId: Long, memberId: Long): Boolean {
// 무료이거나(컨트롤러에서 가격 확인) 구매 이력이 있으면 보유로 판단
val purchased = useCanRepository.existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
memberId,
imageId,
listOf(
CanUsage.CHAT_MESSAGE_PURCHASE,
CanUsage.CHARACTER_IMAGE_PURCHASE
)
)
return purchased
}
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,
imagePath: String,
blurImagePath: String,
imagePriceCan: Long,
messagePriceCan: Long,
isAdult: Boolean,
triggers: List<String>
): CharacterImage {
val character = characterRepository.findById(characterId)
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
if (imagePriceCan < 0 || messagePriceCan < 0) throw SodaException("가격은 0 can 이상이어야 합니다.")
if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId")
val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1
val entity = CharacterImage(
chatCharacter = character,
imagePath = imagePath,
blurImagePath = blurImagePath,
imagePriceCan = imagePriceCan,
messagePriceCan = messagePriceCan,
isAdult = isAdult,
sortOrder = nextOrder,
isActive = true
)
val saved = imageRepository.save(entity)
applyTriggers(saved, triggers)
return saved
}
/**
* 수정은 트리거만 가능
*/
@Transactional
fun updateTriggers(imageId: Long, triggers: List<String>): CharacterImage {
val image = getById(imageId)
if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId")
applyTriggers(image, triggers)
return image
}
private fun applyTriggers(image: CharacterImage, triggers: List<String>) {
// 입력 트리거 정규화
val newWords = triggers.mapNotNull { it.trim().lowercase().takeIf { s -> s.isNotBlank() } }.distinct().toSet()
// 현재 매핑 단어 집합
val currentMappings = image.triggerMappings
val currentWords = currentMappings.map { it.tag.word }.toSet()
// 제거되어야 할 매핑(현재는 있지만 새 입력에는 없는 단어)
val toRemove = currentMappings.filter { it.tag.word !in newWords }
currentMappings.removeAll(toRemove)
// 추가되어야 할 단어(새 입력에는 있지만 현재는 없는 단어)
val toAdd = newWords.minus(currentWords)
toAdd.forEach { w ->
val tag = triggerTagRepository.findByWord(w) ?: triggerTagRepository.save(CharacterImageTrigger(word = w))
currentMappings.add(CharacterImageTriggerMapping(characterImage = image, tag = tag))
}
}
@Transactional
fun deleteImage(imageId: Long) {
val image = getById(imageId)
image.isActive = false
}
@Transactional
fun updateOrders(characterId: Long, ids: List<Long>): List<CharacterImage> {
// 동일 캐릭터 소속 검증 및 순서 재지정
val updated = mutableListOf<CharacterImage>()
ids.forEachIndexed { idx, id ->
val img = getById(id)
if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id")
if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id")
img.sortOrder = idx + 1
updated.add(img)
}
return updated
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.OneToMany
import javax.persistence.Table
import javax.persistence.UniqueConstraint
/**
* 캐릭터 이미지 트리거 "태그" 엔티티
* - word를 전역 고유로 관리하여 중복 단어 저장을 방지한다.
* - 이미지와의 연결은 CharacterImageTriggerMapping을 사용한다.
*/
@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["word"])])
class CharacterImageTrigger(
@Column(nullable = false)
var word: String
) : BaseEntity() {
@OneToMany(mappedBy = "tag", fetch = FetchType.LAZY)
var mappings: MutableList<CharacterImageTriggerMapping> = mutableListOf()
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* CharacterImage 와 CharacterImageTrigger(태그) 사이의 매핑 엔티티
*/
@Entity
class CharacterImageTriggerMapping(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_image_id")
var characterImage: CharacterImage,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
var tag: CharacterImageTrigger
) : BaseEntity()

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.chat.character.image
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface CharacterImageTriggerRepository : JpaRepository<CharacterImageTrigger, Long> {
fun findByWord(word: String): CharacterImageTrigger?
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.chat.character.image.dto
import com.fasterxml.jackson.annotation.JsonProperty
data class CharacterImageListItemResponse(
@JsonProperty("id") val id: Long,
@JsonProperty("imageUrl") val imageUrl: String,
@JsonProperty("isOwned") val isOwned: Boolean,
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
@JsonProperty("sortOrder") val sortOrder: Int
)
data class CharacterImageListResponse(
@JsonProperty("totalCount") val totalCount: Long,
@JsonProperty("ownedCount") val ownedCount: Long,
@JsonProperty("items") val items: List<CharacterImageListItemResponse>
)

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 @Repository
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findByCharacterUUID(characterUUID: String): ChatCharacter?
fun findByName(name: String): ChatCharacter? fun findByName(name: String): ChatCharacter?
fun findByIsActiveTrue(pageable: Pageable): Page<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( @Query(
""" """
@@ -61,4 +73,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
@Param("characterId") characterId: Long, @Param("characterId") characterId: Long,
pageable: Pageable pageable: Pageable
): List<ChatCharacter> ): List<ChatCharacter>
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
} }

View File

@@ -1,5 +1,8 @@
package kr.co.vividnext.sodalive.chat.character.service 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.ChatCharacterRelationshipRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.chat.character.CharacterType 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.ChatCharacterHobby
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository 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.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service @Service
class ChatCharacterService( class ChatCharacterService(
@@ -23,24 +32,99 @@ class ChatCharacterService(
private val tagRepository: ChatCharacterTagRepository, private val tagRepository: ChatCharacterTagRepository,
private val valueRepository: ChatCharacterValueRepository, private val valueRepository: ChatCharacterValueRepository,
private val hobbyRepository: ChatCharacterHobbyRepository, 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) @Transactional(readOnly = true)
fun getPopularCharacters(): List<ChatCharacter> { @Cacheable(
// 채팅방 구현 전이므로 빈 리스트 반환 cacheNames = ["popularCharacters_24h"],
return emptyList() 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) @Transactional(readOnly = true)
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> { fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse {
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit)) 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) 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) return chatCharacterRepository.findByName(name)
} }
/**
* 모든 캐릭터 조회
*/
@Transactional(readOnly = true)
fun findAll(): List<ChatCharacter> {
return chatCharacterRepository.findAll()
}
/** /**
* ID로 캐릭터 조회 * ID로 캐릭터 조회
*/ */
@@ -331,57 +540,6 @@ class ChatCharacterService(
return saveChatCharacter(chatCharacter) 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 imagePath 이미지 경로 (null이면 이미지 변경 없음)
* @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능) * @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능)
* @return 수정된 ChatCharacter 객체 * @return 수정된 ChatCharacter 객체
* @throws SodaException 캐릭터를 찾을 수 없는 경우
*/ */
@Transactional @Transactional
fun updateChatCharacterWithDetails( fun updateChatCharacterWithDetails(
@@ -526,38 +683,19 @@ class ChatCharacterService(
// 추가 정보 설정 - 변경된 데이터만 업데이트 // 추가 정보 설정 - 변경된 데이터만 업데이트
if (request.memories != null) { if (request.memories != null) {
chatCharacter.memories.clear() updateMemoriesForCharacter(chatCharacter, request.memories)
request.memories.forEach { memory ->
chatCharacter.addMemory(memory.title, memory.content, memory.emotion)
}
} }
if (request.personalities != null) { if (request.personalities != null) {
chatCharacter.personalities.clear() updatePersonalitiesForCharacter(chatCharacter, request.personalities)
request.personalities.forEach { personality ->
chatCharacter.addPersonality(personality.trait, personality.description)
}
} }
if (request.backgrounds != null) { if (request.backgrounds != null) {
chatCharacter.backgrounds.clear() updateBackgroundsForCharacter(chatCharacter, request.backgrounds)
request.backgrounds.forEach { background ->
chatCharacter.addBackground(background.topic, background.description)
}
} }
if (request.relationships != null) { if (request.relationships != null) {
chatCharacter.relationships.clear() updateRelationshipsForCharacter(chatCharacter, request.relationships)
request.relationships.forEach { rr ->
chatCharacter.addRelationship(
rr.personName,
rr.relationshipName,
rr.description,
rr.importance,
rr.relationshipType,
rr.currentStatus
)
}
} }
return saveChatCharacter(chatCharacter) 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 package kr.co.vividnext.sodalive.chat.room
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType import javax.persistence.FetchType
import javax.persistence.JoinColumn import javax.persistence.JoinColumn
import javax.persistence.ManyToOne import javax.persistence.ManyToOne
@Entity @Entity
class ChatMessage( class ChatMessage(
// 텍스트 메시지 본문. 현재는 NOT NULL 유지. IMAGE 타입 등 비텍스트 메시지는 빈 문자열("") 저장 방침.
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
val message: String, val message: String,
@@ -20,5 +24,23 @@ class ChatMessage(
@JoinColumn(name = "participant_id", nullable = false) @JoinColumn(name = "participant_id", nullable = false)
val participant: ChatParticipant, 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() ) : 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( class ChatRoom(
val sessionId: String, val sessionId: String,
val title: String, val title: String,
val isActive: Boolean = true var isActive: Boolean = true
) : BaseEntity() { ) : BaseEntity() {
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
val messages: MutableList<ChatMessage> = mutableListOf() val messages: MutableList<ChatMessage> = mutableListOf()

View File

@@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.chat.room.controller 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.CreateChatRoomRequest
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
@@ -65,24 +67,6 @@ class ChatRoomController(
} }
} }
/**
* 채팅방 메시지 조회 API
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")
* - messageId가 있으면 해당 ID 이전 20개, 없으면 최신 20개
*/
@GetMapping("/{chatRoomId}/messages")
fun getChatMessages(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@RequestParam(required = false) messageId: Long?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val response = chatRoomService.getChatMessages(member, chatRoomId, messageId)
ApiResponse.ok(response)
}
/** /**
* 세션 상태 조회 API * 세션 상태 조회 API
* - 채팅방 참여 여부 검증 * - 채팅방 참여 여부 검증
@@ -100,6 +84,24 @@ class ChatRoomController(
ApiResponse.ok(isActive) ApiResponse.ok(isActive)
} }
/**
* 채팅방 입장 API
* - 참여 여부 검증
* - 최신 20개 메시지를 createdAt 오름차순으로 반환
*/
@GetMapping("/{chatRoomId}/enter")
fun enterChatRoom(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@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, characterImageId)
ApiResponse.ok(response)
}
/** /**
* 채팅방 나가기 API * 채팅방 나가기 API
* - URL에 chatRoomId 포함 * - URL에 chatRoomId 포함
@@ -119,6 +121,26 @@ class ChatRoomController(
ApiResponse.ok(true) ApiResponse.ok(true)
} }
/**
* 채팅방 메시지 조회 API
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")
* - cursor(메시지ID)보다 더 과거의 메시지에서 limit만큼 조회(경계 exclusive)
* - cursor 미지정 시 최신부터 limit만큼 기준으로 페이징
*/
@GetMapping("/{chatRoomId}/messages")
fun getChatMessages(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@RequestParam(defaultValue = "20") limit: Int,
@RequestParam(required = false) cursor: Long?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit)
ApiResponse.ok(response)
}
/** /**
* 채팅방 메시지 전송 API * 채팅방 메시지 전송 API
* - 참여 여부 검증(미참여시 "잘못된 접근입니다") * - 참여 여부 검증(미참여시 "잘못된 접근입니다")
@@ -140,4 +162,43 @@ class ChatRoomController(
ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message)) 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( data class ChatRoomListItemDto(
val chatRoomId: Long, val chatRoomId: Long,
val characterId: Long,
val title: String, val title: String,
val imageUrl: String, val imageUrl: String,
val opponentType: String, val opponentType: String,
@@ -38,7 +39,21 @@ data class ChatMessageItemDto(
val messageId: Long, val messageId: Long,
val message: String, val message: String,
val profileImageUrl: String, val profileImageUrl: String,
val mine: Boolean val mine: Boolean,
val createdAt: Long,
val messageType: String,
val imageUrl: String?,
val price: Int?,
val hasAccess: Boolean
)
/**
* 채팅방 메시지 페이지 응답 DTO
*/
data class ChatMessagesPageResponse(
val messages: List<ChatMessageItemDto>,
val hasMore: Boolean,
val nextCursor: Long?
) )
/** /**
@@ -47,6 +62,7 @@ data class ChatMessageItemDto(
data class ChatRoomListQueryDto( data class ChatRoomListQueryDto(
val chatRoomId: Long, val chatRoomId: Long,
val characterId: Long,
val title: String, val title: String,
val imagePath: String?, val imagePath: String?,
val characterType: CharacterType, val characterType: CharacterType,
@@ -117,10 +133,10 @@ data class SendChatMessageRequest(
) )
/** /**
* 채팅 메시지 전송 응답 DTO (캐릭터 메시지 리스트) * 유료 메시지 구매 요청 DTO
*/ */
data class SendChatMessageResponse( data class ChatMessagePurchaseRequest(
val characterMessages: List<ChatMessageItemDto> val container: String
) )
/** /**
@@ -151,3 +167,39 @@ data class ExternalCharacterMessage(
@JsonProperty("timestamp") val timestamp: String, @JsonProperty("timestamp") val timestamp: String,
@JsonProperty("messageType") val messageType: String @JsonProperty("messageType") val messageType: String
) )
/**
* 채팅방 입장 응답 DTO
*/
data class ChatRoomEnterCharacterDto(
val characterId: Long,
val name: String,
val profileImageUrl: String,
val characterType: String
)
data class ChatRoomEnterResponse(
val roomId: Long,
val character: ChatRoomEnterCharacterDto,
val messages: List<ChatMessageItemDto>,
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

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.room.repository
import kr.co.vividnext.sodalive.chat.room.ChatMessage import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ChatRoom
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -9,10 +10,23 @@ import org.springframework.stereotype.Repository
interface ChatMessageRepository : JpaRepository<ChatMessage, Long> { interface ChatMessageRepository : JpaRepository<ChatMessage, Long> {
fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage? fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage?
// 기존 20개 고정 메서드는 유지 (기존 호출 호환)
fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List<ChatMessage> fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List<ChatMessage>
fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
chatRoom: ChatRoom, chatRoom: ChatRoom,
id: Long id: Long
): List<ChatMessage> ): List<ChatMessage>
// 새로운 커서 기반 페이징용 메서드 (limit 가변)
fun findByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom, pageable: Pageable): List<ChatMessage>
fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
chatRoom: ChatRoom,
id: Long,
pageable: Pageable
): List<ChatMessage>
// 더 이전 데이터 존재 여부 확인
fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: ChatRoom, id: Long): Boolean
} }

View File

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

View File

@@ -1,12 +1,20 @@
package kr.co.vividnext.sodalive.chat.room.service package kr.co.vividnext.sodalive.chat.room.service
import com.fasterxml.jackson.databind.ObjectMapper 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.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.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatMessageType
import kr.co.vividnext.sodalive.chat.room.ChatParticipant import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagesPageResponse
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomEnterCharacterDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomEnterResponse
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
@@ -32,6 +40,7 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId
import java.util.UUID import java.util.UUID
@Service @Service
@@ -40,6 +49,11 @@ class ChatRoomService(
private val participantRepository: ChatParticipantRepository, private val participantRepository: ChatParticipantRepository,
private val messageRepository: ChatMessageRepository, private val messageRepository: ChatMessageRepository,
private val characterService: ChatCharacterService, 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}") @Value("\${weraser.api-key}")
private val apiKey: String, private val apiKey: String,
@@ -55,6 +69,45 @@ class ChatRoomService(
) { ) {
private val log = LoggerFactory.getLogger(ChatRoomService::class.java) 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)
}
/** /**
* 채팅방 생성 또는 조회 * 채팅방 생성 또는 조회
* *
@@ -200,8 +253,16 @@ class ChatRoomService(
).apply { id = q.chatRoomId } ).apply { id = q.chatRoomId }
val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room) val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room)
val preview = latest?.message?.let { msg -> val preview = if (latest?.message?.isNotBlank() == true) {
if (msg.length <= 30) msg else msg.take(30) + "..." 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 imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}"
@@ -211,6 +272,7 @@ class ChatRoomService(
ChatRoomListItemDto( ChatRoomListItemDto(
chatRoomId = q.chatRoomId, chatRoomId = q.chatRoomId,
characterId = q.characterId,
title = q.title, title = q.title,
imageUrl = imageUrl, imageUrl = imageUrl,
opponentType = opponentType, opponentType = opponentType,
@@ -236,9 +298,8 @@ class ChatRoomService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean { fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException("채팅방을 찾을 수 없습니다.")
}
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
if (participant == null) { if (participant == null) {
throw SodaException("잘못된 접근입니다") throw SodaException("잘못된 접근입니다")
@@ -246,6 +307,166 @@ class ChatRoomService(
return fetchSessionActive(room.sessionId) return fetchSessionActive(room.sessionId)
} }
@Transactional
fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse {
// 1) 활성 여부 무관하게 방 조회
val baseRoom = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
// 2) 기본 방 기준 참여/활성 여부 확인
val isActiveRoom = baseRoom.isActive
val isMyActiveParticipation =
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(baseRoom, member) != null
// 3) 기본 방의 캐릭터 식별 (활성 우선, 없으면 컬렉션에서 검색)
val baseCharacterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
baseRoom,
ParticipantType.CHARACTER
) ?: 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("오류가 발생했습니다. 다시 시도해 주세요.")
val imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}"
val characterDto = ChatRoomEnterCharacterDto(
characterId = character.id!!,
name = character.name,
profileImageUrl = imageUrl,
characterType = character.characterType.name
)
// 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환 (effectiveRoom 기준)
val pageable = PageRequest.of(0, 20)
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(effectiveRoom, nextCursor)
} else {
false
}
val messagesAsc = fetched.sortedBy { it.createdAt }
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 = effectiveRoom.id!!,
character = characterDto,
messages = items,
hasMoreMessages = hasMore,
totalRemaining = roomStatus.totalRemaining,
nextRechargeAtEpoch = nextForEnter,
bgImageUrl = signedUrl
)
}
private fun fetchSessionActive(sessionId: String): Boolean { private fun fetchSessionActive(sessionId: String): Boolean {
try { try {
val factory = SimpleClientHttpRequestFactory() val factory = SimpleClientHttpRequestFactory()
@@ -286,10 +507,9 @@ class ChatRoomService(
} }
@Transactional @Transactional
fun leaveChatRoom(member: Member, chatRoomId: Long) { fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException("채팅방을 찾을 수 없습니다.")
}
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException("잘못된 접근입니다")
@@ -305,12 +525,13 @@ class ChatRoomService(
// 3) 내가 마지막 USER였다면 외부 세션 종료 // 3) 내가 마지막 USER였다면 외부 세션 종료
if (userCount == 0L) { 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 val maxAttempts = 3
var attempt = 0 var attempt = 0
while (attempt < maxAttempts) { while (attempt < maxAttempts) {
@@ -324,7 +545,6 @@ class ChatRoomService(
val headers = HttpHeaders() val headers = HttpHeaders()
headers.set("x-api-key", apiKey) headers.set("x-api-key", apiKey)
headers.contentType = MediaType.APPLICATION_JSON
val httpEntity = HttpEntity(null, headers) val httpEntity = HttpEntity(null, headers)
@@ -353,48 +573,55 @@ class ChatRoomService(
log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message) log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message)
} }
} }
// 최종 실패 로그 (예외 미전파) // 최종 실패 처리
log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) 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) @Transactional(readOnly = true)
fun getChatMessages(member: Member, chatRoomId: Long, beforeMessageId: Long?): List<ChatMessageItemDto> { fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException("채팅방을 찾을 수 없습니다.")
} participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException("잘못된 접근입니다")
if (participant == null) {
throw SodaException("잘못된 접근입니다")
}
val messages = if (beforeMessageId != null) { val pageable = PageRequest.of(0, limit)
messageRepository.findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, beforeMessageId) val fetched = if (cursor != null) {
messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable)
} else { } else {
messageRepository.findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(room) messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable)
} }
return messages.map { msg -> // 가장 오래된 메시지 ID (nextCursor) 및 hasMore 계산
val sender = msg.participant val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id
val profilePath = when (sender.participantType) { val hasMore: Boolean = if (nextCursor != null) {
ParticipantType.USER -> sender.member?.profileImage messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor)
ParticipantType.CHARACTER -> sender.character?.imagePath } else {
} false
val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
ChatMessageItemDto(
messageId = msg.id!!,
message = msg.message,
profileImageUrl = imageUrl,
mine = sender.member?.id == member.id
)
} }
// createdAt 오름차순으로 정렬하여 반환
val messagesAsc = fetched.sortedBy { it.createdAt }
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
return ChatMessagesPageResponse(
messages = items,
hasMore = hasMore,
nextCursor = nextCursor
)
} }
@Transactional @Transactional
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse { fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
// 1) 방 존재 확인 // 1) 방 존재 확인
val room = chatRoomRepository.findById(chatRoomId).orElseThrow { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException("채팅방을 찾을 수 없습니다.")
}
// 2) 참여 여부 확인 (USER) // 2) 참여 여부 확인 (USER)
val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException("잘못된 접근입니다")
@@ -412,7 +639,15 @@ class ChatRoomService(
val sessionId = room.sessionId val sessionId = room.sessionId
val characterUUID = character.characterUUID 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) val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
// 6) 내 메시지 저장 // 6) 내 메시지 저장
@@ -424,27 +659,146 @@ class ChatRoomService(
) )
messageRepository.save(myMsgEntity) messageRepository.save(myMsgEntity)
// 7) 캐릭터 메시지 저장 // 7) 캐릭터 텍스트 메시지 항상 저장
val characterMsgEntity = ChatMessage( val characterTextMsg = messageRepository.save(
message = characterReply, ChatMessage(
chatRoom = room, message = characterReply,
participant = characterParticipant, chatRoom = room,
isActive = true participant = characterParticipant,
isActive = true
)
) )
val savedCharacterMsg = messageRepository.save(characterMsgEntity)
// 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소) // 응답 프로필 이미지 URL 공통 구성
val profilePath = characterParticipant.character?.imagePath val profilePath = characterParticipant.character?.imagePath
val defaultPath = profilePath ?: "profile/default-profile.png" val defaultPath = profilePath ?: "profile/default-profile.png"
val imageUrl = "$imageHost/$defaultPath" val senderImageUrl = "$imageHost/$defaultPath"
val dto = ChatMessageItemDto(
messageId = savedCharacterMsg.id!!, val textDto = ChatMessageItemDto(
message = savedCharacterMsg.message, messageId = characterTextMsg.id!!,
profileImageUrl = imageUrl, message = characterTextMsg.message,
mine = false profileImageUrl = senderImageUrl,
mine = false,
createdAt = characterTextMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()
?.toEpochMilli()
?: 0L,
messageType = ChatMessageType.TEXT.name,
imageUrl = null,
price = null,
hasAccess = true
) )
return SendChatMessageResponse(characterMessages = listOf(dto)) // 발송 후 최신 잔여 수량 및 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( private fun callExternalApiForChatSendWithRetry(
@@ -515,4 +869,60 @@ class ChatRoomService(
} }
return characterContent 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) return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig) .cacheDefaults(defaultCacheConfig)
.withInitialCacheConfigurations(cacheConfigMap) .withInitialCacheConfigurations(cacheConfigMap)

View File

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

View File

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

View File

@@ -0,0 +1,173 @@
package kr.co.vividnext.sodalive.utils
import java.awt.image.BufferedImage
import java.awt.image.DataBufferInt
import java.util.stream.IntStream
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* 가우시안 커널 기반 블러 유틸리티
* - 반경(radius)에 따라 커널 크기(2*radius+1) 생성
* - 시그마는 관례적으로 radius/3.0 적용
* - 수평/수직 분리 합성곱으로 품질과 성능 확보
*/
/**
* 고속 가우시안 블러 유틸
*
* - 원본 비율/해상도 그대로 두고 "큰 반경 블러"만 빠르게 적용하고 싶을 때 사용합니다.
* - 강한 익명화를 원하면(식별 불가 수준) 이 함수 대신
* "다운스케일 → 큰 반경 블러 → 원본 해상도로 업스케일"을 조합하세요.
* (예: ImageUtils.anonymizeStrongFast 처럼)
*/
object ImageBlurUtil {
/**
* 분리형(1D) 가우시안 블러(수평 → 수직 2패스), 배열 접근 기반 고속 구현.
*
* @param src 원본 이미지
* @param radius 가우시안 반경(>=1). 클수록 강하게 흐려짐. (권장 5~64)
* @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효.
* @return 블러된 새 이미지 (TYPE_INT_ARGB)
*/
fun blurFast(src: BufferedImage, radius: Int = 240, parallel: Boolean = true): BufferedImage {
require(radius > 0) { "radius must be > 0" }
// 1) 프리멀티 알파로 변환 (경계 품질↑)
val s = toPremultiplied(src) // TYPE_INT_ARGB_PRE
val w = s.width
val h = s.height
// 2) 중간/최종 버퍼(프리멀티 유지)
val tmp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE)
val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE)
val srcArr = (s.raster.dataBuffer as DataBufferInt).data
val tmpArr = (tmp.raster.dataBuffer as DataBufferInt).data
val dstArr = (dst.raster.dataBuffer as DataBufferInt).data
// 3) 1D 가우시안 커널(정규화)
// sigma는 일반적으로 radius/3.0이 자연스러운 값
val sigma = radius / 3.0
val kernel = buildGaussian1D(radius, sigma)
// 4) 수평 패스 (y 라인별)
if (parallel) {
IntStream.range(0, h).parallel().forEach { y ->
convolveRow(srcArr, tmpArr, w, h, y, kernel, radius)
}
} else {
for (y in 0 until h) convolveRow(srcArr, tmpArr, w, h, y, kernel, radius)
}
// 5) 수직 패스 (x 컬럼별)
if (parallel) {
IntStream.range(0, w).parallel().forEach { x ->
convolveCol(tmpArr, dstArr, w, h, x, kernel, radius)
}
} else {
for (x in 0 until w) convolveCol(tmpArr, dstArr, w, h, x, kernel, radius)
}
// 6) 비프리멀티(일반 ARGB)로 변환해서 반환 (파일 저장/그리기 호환성↑)
return toNonPremultiplied(dst)
}
// ─────────────────────────────────────────────────────────────────────────────
// 내부 구현
// ─────────────────────────────────────────────────────────────────────────────
// 수평 합성곱: 경계는 replicate(클램프)
private fun convolveRow(src: IntArray, dst: IntArray, w: Int, h: Int, y: Int, k: DoubleArray, r: Int) {
val base = y * w
for (x in 0 until w) {
var aAcc = 0.0
var rAcc = 0.0
var gAcc = 0.0
var bAcc = 0.0
var i = -r
while (i <= r) {
val xx = clamp(x + i, 0, w - 1)
val argb = src[base + xx]
val a = (argb ushr 24) and 0xFF
val rr = (argb ushr 16) and 0xFF
val gg = (argb ushr 8) and 0xFF
val bb = argb and 0xFF
val wgt = k[i + r]
aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt
i++
}
val a = aAcc.roundToInt().coerceIn(0, 255)
val rr = rAcc.roundToInt().coerceIn(0, 255)
val gg = gAcc.roundToInt().coerceIn(0, 255)
val bb = bAcc.roundToInt().coerceIn(0, 255)
dst[base + x] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb
}
}
// 수직 합성곱: 경계 replicate(클램프)
private fun convolveCol(src: IntArray, dst: IntArray, w: Int, h: Int, x: Int, k: DoubleArray, r: Int) {
var idx = x
for (y in 0 until h) {
var aAcc = 0.0
var rAcc = 0.0
var gAcc = 0.0
var bAcc = 0.0
var i = -r
while (i <= r) {
val yy = clamp(y + i, 0, h - 1)
val argb = src[yy * w + x]
val a = (argb ushr 24) and 0xFF
val rr = (argb ushr 16) and 0xFF
val gg = (argb ushr 8) and 0xFF
val bb = argb and 0xFF
val wgt = k[i + r]
aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt
i++
}
val a = aAcc.roundToInt().coerceIn(0, 255)
val rr = rAcc.roundToInt().coerceIn(0, 255)
val gg = gAcc.roundToInt().coerceIn(0, 255)
val bb = bAcc.roundToInt().coerceIn(0, 255)
dst[idx] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb
idx += w
}
}
// 1D 가우시안 커널 (정규화)
private fun buildGaussian1D(radius: Int, sigma: Double): DoubleArray {
val size = radius * 2 + 1
val kernel = DoubleArray(size)
val sigma2 = 2.0 * sigma * sigma
var sum = 0.0
for (i in -radius..radius) {
val v = exp(-(i * i) / sigma2)
kernel[i + radius] = v
sum += v
}
for (i in 0 until size) kernel[i] /= sum
return kernel
}
private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v))
// 프리멀티/비프리멀티 변환(빠른 방법: Graphics로 그리기)
private fun toPremultiplied(src: BufferedImage): BufferedImage {
if (src.type == BufferedImage.TYPE_INT_ARGB_PRE) return src
val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB_PRE)
val g = out.createGraphics()
g.drawImage(src, 0, 0, null)
g.dispose()
return out
}
private fun toNonPremultiplied(src: BufferedImage): BufferedImage {
if (src.type == BufferedImage.TYPE_INT_ARGB) return src
val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB)
val g = out.createGraphics()
g.drawImage(src, 0, 0, null)
g.dispose()
return out
}
}