Compare commits

..

428 Commits

Author SHA1 Message Date
b69756ef81 Merge pull request 'test' (#343) from test into main
Reviewed-on: #343
2025-09-18 19:25:50 +00: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
1a3a9149a2 Merge pull request 'test' (#342) from test into main
Reviewed-on: #342
2025-09-16 06:11:32 +00: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
ce120a6d5d Merge pull request 'test' (#341) from test into main
Reviewed-on: #341
2025-09-14 20:33:50 +00: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
08b5fd23ab Merge pull request 'test' (#340) from test into main
Reviewed-on: #340
2025-09-14 08:51:11 +00: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
eb18e2d009 Merge pull request 'test' (#339) from test into main
Reviewed-on: #339
2025-09-11 17:05:45 +00: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
a27852ed44 Merge pull request '캐릭터 챗봇' (#338) from test into main
Reviewed-on: #338
2025-09-10 06:08:47 +00: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
c7925c1706 Merge pull request 'feat: 최근 공지사항 API 추가' (#337) from test into main
Reviewed-on: #337
2025-07-28 02:16:19 +00:00
be59bd7e89 Merge pull request 'fix: 크리에이터 팔로우 API' (#336) from test into main
Reviewed-on: #336
2025-07-21 13:52:34 +00:00
51ce143fc2 Merge pull request 'test' (#335) from test into main
Reviewed-on: #335
2025-07-21 11:46:56 +00:00
89eb11f808 Merge pull request 'fix: 라이브 메인 API - 최근 종료된 라이브' (#334) from test into main
Reviewed-on: #334
2025-07-21 10:59:38 +00:00
30d89987a4 Merge pull request 'test' (#333) from test into main
Reviewed-on: #333
2025-07-21 09:54:56 +00:00
7959d3e5ed Merge pull request 'test' (#332) from test into main
Reviewed-on: #332
2025-07-18 12:33:22 +00:00
1e29573ef7 Merge pull request 'fix: 검색 API' (#331) from test into main
Reviewed-on: #331
2025-07-16 10:58:56 +00:00
cc2f533dc6 Merge pull request 'fix: 메인 홈 API - 요일별 시리즈' (#330) from test into main
Reviewed-on: #330
2025-07-14 19:14:06 +00:00
32b0c19f9d Merge pull request 'test' (#329) from test into main
Reviewed-on: #329
2025-07-14 17:57:26 +00:00
9af2d768e8 Merge pull request 'test' (#327) from test into main
Reviewed-on: #327
2025-07-14 11:07:57 +00:00
5677824cde Merge pull request 'test' (#326) from test into main
Reviewed-on: #326
2025-06-13 11:37:26 +00:00
e8f1bc09f9 Merge pull request 'test' (#325) from test into main
Reviewed-on: #325
2025-06-12 05:00:31 +00:00
d1a936d55b Merge pull request 'test' (#324) from test into main
Reviewed-on: #324
2025-06-10 11:01:31 +00:00
dc97eaa835 Merge pull request 'fix: 앱 콘텐츠 수정' (#323) from test into main
Reviewed-on: #323
2025-06-05 02:36:25 +00:00
dcbe57806c Merge pull request 'test' (#322) from test into main
Reviewed-on: #322
2025-06-02 12:41:46 +00:00
b14438cc15 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#321) from test into main
Reviewed-on: #321
2025-05-28 07:19:27 +00:00
b27d3bd5c6 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#320) from test into main
Reviewed-on: #320
2025-05-26 10:33:16 +00:00
03ebc9cfe9 Merge pull request 'fix: 큐레이션 아이템 조회' (#319) from test into main
Reviewed-on: #319
2025-05-23 05:43:37 +00:00
24841b9850 Merge pull request 'fix: 코루틴 내 트랜잭션 간 조회 안 되는 문제 해결' (#318) from test into main
Reviewed-on: #318
2025-05-22 04:31:42 +00:00
d35a3d1a8c Merge pull request 'test' (#317) from test into main
Reviewed-on: #317
2025-05-20 10:26:16 +00:00
60c4e0b528 Merge pull request 'test' (#316) from test into main
Reviewed-on: #316
2025-05-20 06:03:10 +00:00
84f33d1bc2 Merge pull request 'fix: 소셜로그인시 유저 행동데이터 SIGN_UP 중복 기록 버그' (#315) from test into main
Reviewed-on: #315
2025-05-12 08:24:53 +00:00
c4e1709b99 Merge pull request 'test' (#314) from test into main
Reviewed-on: #314
2025-05-12 02:12:47 +00:00
e7a5fd5819 Merge pull request 'fix: 구글/카카오 로그인 회원가입 오류 수정' (#313) from test into main
Reviewed-on: #313
2025-05-02 10:58:04 +00:00
4bde03643c Merge pull request 'test' (#312) from test into main
Reviewed-on: #312
2025-04-29 02:56:16 +00:00
1bc52b56af Merge pull request 'fix: 콘텐츠 업로드 - 제목과 내용에서 trim 함수를 적용하여 앞/뒤 빈칸 제거' (#311) from test into main
Reviewed-on: #311
2025-04-25 09:43:31 +00:00
9c33fd93f7 Merge pull request 'refactor: 본인인증 - 본인인증이 완료된 후 유저 행동 데이터를 기록하도록 수정' (#310) from test into main
Reviewed-on: #310
2025-04-24 11:10:17 +00:00
3c087bc275 Merge pull request '유저 행동 데이터, 포인트 추가' (#309) from test into main
Reviewed-on: #309
2025-04-24 02:44:57 +00:00
8ad13c289e Merge pull request '회원탈퇴' (#308) from test into main
Reviewed-on: #308
2025-04-15 10:42:37 +00:00
7577f48a09 Merge pull request '한정판 콘텐츠' (#307) from test into main
Reviewed-on: #307
2025-04-15 09:44:12 +00:00
0251906964 Merge pull request '비밀번호 찾기' (#306) from test into main
Reviewed-on: #306
2025-04-10 06:28:57 +00:00
2723a5f134 Merge pull request '일별 전체 회원 수' (#305) from test into main
Reviewed-on: #305
2025-04-10 02:30:00 +00:00
c3c60605fd Merge pull request '관리자 - 회원리스트, 크리에이터 리스트' (#304) from test into main
Reviewed-on: #304
2025-04-09 10:35:01 +00:00
238f704b22 Merge pull request '소셜 로그인, 회원가입 - 이메일 체크 로직 수정' (#303) from test into main
Reviewed-on: #303
2025-04-08 07:04:11 +00:00
5639d8ac8e Merge pull request 'test' (#302) from test into main
Reviewed-on: #302
2025-04-07 10:23:13 +00:00
9aac591591 Merge pull request 'test' (#301) from test into main
Reviewed-on: #301
2025-04-01 13:31:24 +00:00
ffa8e5aebb Merge pull request '일별 전체 회원 수 통계' (#300) from test into main
Reviewed-on: #300
2025-03-31 03:50:18 +00:00
cbbfe014cc Merge pull request '광고 통계' (#299) from test into main
Reviewed-on: #299
2025-03-28 05:29:40 +00:00
83028f7817 Merge pull request 'test' (#298) from test into main
Reviewed-on: #298
2025-03-26 21:08:29 +00:00
70d1795557 Merge pull request 'test' (#297) from test into main
Reviewed-on: #297
2025-03-26 04:23:28 +00:00
8c6c681424 Merge pull request 'marketing 정보 업데이트 시 pid 값이 있으면 항상 로그인 기록 남기기' (#296) from test into main
Reviewed-on: #296
2025-03-25 11:25:46 +00:00
50bc9f4ff3 Merge pull request '라이브 방 - 예약 중 조회' (#295) from test into main
Reviewed-on: #295
2025-03-24 10:04:08 +00:00
f00ea03fad Merge pull request 'test' (#294) from test into main
Reviewed-on: #294
2025-03-24 09:09:16 +00:00
f22e7b9ad1 Merge pull request '자동생성 닉네임에 사용될 형용사, 명사 값 추가' (#293) from test into main
Reviewed-on: #293
2025-03-21 10:27:30 +00:00
c7ec95f4bb Merge pull request 'test' (#292) from test into main
Reviewed-on: #292
2025-03-20 19:24:03 +00:00
229e7a8ccc Merge pull request '시리즈 상세, 채널 상세' (#291) from test into main
Reviewed-on: #291
2025-03-19 09:43:06 +00:00
3c616474ff Merge pull request 'test' (#290) from test into main
Reviewed-on: #290
2025-03-19 07:51:25 +00:00
56eb6b3ce3 Merge pull request '19금 콘텐츠 보기 설정 적용' (#289) from test into main
Reviewed-on: #289
2025-03-19 02:05:17 +00:00
545836d43c Merge pull request '관리자 광고통계, 일별 전체 회원 수' (#288) from test into main
Reviewed-on: #288
2025-03-17 08:50:59 +00:00
219f83dec0 Merge pull request 'test' (#287) from test into main
Reviewed-on: #287
2025-03-17 05:54:05 +00:00
a76a841238 Merge pull request 'test' (#286) from test into main
Reviewed-on: #286
2025-03-14 16:11:17 +00:00
c26680de84 Merge pull request '이벤트 배너, 충전 이벤트 - 기간 설정에 시간 추가' (#285) from test into main
Reviewed-on: #285
2025-03-14 03:40:07 +00:00
8fffad9d3a Merge pull request 'test' (#284) from test into main
Reviewed-on: #284
2025-03-13 12:25:35 +00:00
f4f0f203a2 Merge pull request '유저 정보 조회' (#283) from test into main
Reviewed-on: #283
2025-03-12 08:00:13 +00:00
b7196f5a0c Merge pull request 'test' (#282) from test into main
Reviewed-on: #282
2025-03-11 08:01:05 +00:00
5d33a18890 Merge pull request 'test' (#281) from test into main
Reviewed-on: #281
2025-03-10 05:35:30 +00:00
96186a1a50 Merge pull request '마케팅 - 매체 파트너 코드 조회 API - link 값 수정' (#280) from test into main
Reviewed-on: #280
2025-03-07 06:27:08 +00:00
bc8bc479d1 Merge pull request 'test' (#279) from test into main
Reviewed-on: #279
2025-03-06 17:58:32 +00:00
47595b1291 Merge pull request 'test' (#278) from test into main
Reviewed-on: #278
2025-03-05 14:05:47 +00:00
01a88964df Merge pull request 'test' (#277) from test into main
Reviewed-on: #277
2025-03-05 09:44:59 +00:00
3a2b77379f Merge pull request '콘텐츠 업로드' (#276) from test into main
Reviewed-on: #276
2025-02-28 04:45:04 +00:00
dc4e5f75cd Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#275) from test into main
Reviewed-on: #275
2025-02-26 03:14:33 +00:00
d0178d551c Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#274) from test into main
Reviewed-on: #274
2025-02-25 14:54:53 +00:00
827333108d Merge pull request '콘텐츠 대여기간' (#273) from test into main
Reviewed-on: #273
2025-02-25 14:02:18 +00:00
587b90bd27 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 콘텐츠' (#272) from test into main
Reviewed-on: #272
2025-02-22 01:56:49 +00:00
4dc20c5e90 Merge pull request '콘텐츠 메인 무료 탭' (#271) from test into main
Reviewed-on: #271
2025-02-22 00:39:09 +00:00
ac25782f2b Merge pull request '관리자 태그 큐레이션 - 콘텐츠 검색' (#270) from test into main
Reviewed-on: #270
2025-02-21 21:46:15 +00:00
20437d56e7 Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#269) from test into main
Reviewed-on: #269
2025-02-21 21:15:52 +00:00
f0b412828a Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#268) from test into main
Reviewed-on: #268
2025-02-21 19:27:33 +00:00
367faac5c3 Merge pull request 'test' (#267) from test into main
Reviewed-on: #267
2025-02-20 18:24:35 +00:00
84deaaa970 Merge pull request '콘텐츠 메인 시리즈 탭 - 장르별 시리즈' (#266) from test into main
Reviewed-on: #266
2025-02-19 12:52:17 +00:00
a2b39466c2 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#265) from test into main
Reviewed-on: #265
2025-02-19 11:34:02 +00:00
03586c4005 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#264) from test into main
Reviewed-on: #264
2025-02-19 09:49:04 +00:00
6ea69e1510 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠' (#263) from test into main
Reviewed-on: #263
2025-02-19 09:24:24 +00:00
553c6dc539 Merge pull request '콘텐츠 메인 단편 탭 - 새로운 단편' (#262) from test into main
Reviewed-on: #262
2025-02-19 08:20:14 +00:00
6cc22f5b6d Merge pull request '콘텐츠 메인 홈, 무료 탭' (#261) from test into main
Reviewed-on: #261
2025-02-19 06:34:53 +00:00
9103d67cc1 Merge pull request 'test' (#260) from test into main
Reviewed-on: #260
2025-02-18 18:13:25 +00:00
25083fb0e4 Merge pull request 'test' (#259) from test into main
Reviewed-on: #259
2025-02-18 14:48:09 +00:00
d2dc045255 Merge pull request 'test' (#258) from test into main
Reviewed-on: #258
2025-02-14 18:09:11 +00:00
b8621dfbb0 Merge pull request 'test' (#257) from test into main
Reviewed-on: #257
2025-02-09 13:36:21 +00:00
93633940dd Merge pull request 'test' (#256) from test into main
Reviewed-on: #256
2025-02-03 07:20:32 +00:00
b6f5325351 Merge pull request 'test' (#255) from test into main
Reviewed-on: #255
2025-01-31 15:22:23 +00:00
7c32c08f1f Merge pull request 'test' (#254) from test into main
Reviewed-on: #254
2025-01-17 05:46:00 +00:00
1d268da08d Merge pull request '오디션 등록 푸시알림 메시지 수정' (#253) from test into main
Reviewed-on: #253
2025-01-10 10:23:19 +00:00
797666ae0d Merge pull request 'test' (#252) from test into main
Reviewed-on: #252
2025-01-08 14:11:08 +00:00
dcf470997e Merge pull request 'test' (#251) from test into main
Reviewed-on: #251
2025-01-08 06:29:33 +00:00
0974d1dbf8 Merge pull request '관리자 오디션 지원 리스트' (#250) from test into main
Reviewed-on: #250
2025-01-07 19:44:39 +00:00
12a35db6cd Merge pull request '오디션' (#249) from test into main
Reviewed-on: #249
2025-01-07 17:24:40 +00:00
9abbb05ad8 Merge pull request 'test' (#248) from test into main
Reviewed-on: #248
2024-12-18 07:10:01 +00:00
1ecaf69b0b Merge pull request 'test' (#247) from test into main
Reviewed-on: #247
2024-12-17 13:43:45 +00:00
e334d1e5d9 Merge pull request '콘텐츠 댓글 푸시 대상자' (#246) from test into main
Reviewed-on: #246
2024-12-03 15:54:34 +00:00
b735e861d0 Merge pull request '콘텐츠 댓글 푸시 대상자 조회' (#245) from test into main
Reviewed-on: #245
2024-12-02 15:06:49 +00:00
4eb433d372 Merge pull request 'test' (#244) from test into main
Reviewed-on: #244
2024-12-02 12:05:30 +00:00
2416ae61f3 Merge pull request 'test' (#243) from test into main
Reviewed-on: #243
2024-12-02 04:29:50 +00:00
01fb336985 Merge pull request '콘텐츠 등록' (#242) from test into main
Reviewed-on: #242
2024-11-26 12:46:24 +00:00
b6af88a732 Merge pull request 'test' (#241) from test into main
Reviewed-on: #241
2024-11-26 05:33:45 +00:00
58a2a17d6d Merge pull request 'test' (#240) from test into main
Reviewed-on: #240
2024-11-23 17:59:23 +00:00
79f5a0f520 Merge pull request '내 콘텐츠 수정, 삭제 시 콘텐츠 조회 함수' (#239) from test into main
Reviewed-on: #239
2024-11-21 06:34:30 +00:00
7f6c0f7f04 Merge pull request 'Redis connection 수정' (#238) from test into main
Reviewed-on: #238
2024-11-20 09:58:56 +00:00
f658df4dca Merge pull request 'Redis connection' (#237) from test into main
Reviewed-on: #237
2024-11-20 07:52:58 +00:00
9d43b8e23a Merge pull request 'Redis connection' (#236) from test into main
Reviewed-on: #236
2024-11-20 06:47:52 +00:00
4270aef79b Merge pull request 'test' (#235) from test into main
Reviewed-on: #235
2024-11-11 15:34:35 +00:00
1c0dc82d44 Merge pull request '콘텐츠 구매 - 소장만 추가' (#234) from test into main
Reviewed-on: #234
2024-11-08 12:40:29 +00:00
c1e325aadf Merge pull request 'test' (#233) from test into main
Reviewed-on: #233
2024-11-05 07:26:19 +00:00
cec87da69d Merge pull request '콘텐츠 대여가격' (#232) from test into main
Reviewed-on: #232
2024-10-31 05:23:01 +00:00
f68f24cb2c Merge pull request 'test' (#231) from test into main
Reviewed-on: #231
2024-10-31 03:09:13 +00:00
ed094347fc Merge pull request '라이브 방 후원랭킹' (#230) from test into main
Reviewed-on: #230
2024-10-30 05:16:05 +00:00
b8afdffbe1 Merge pull request 'test' (#229) from test into main
Reviewed-on: #229
2024-10-29 08:35:58 +00:00
f6ba79f31c Merge pull request 'test' (#228) from test into main
Reviewed-on: #228
2024-10-25 03:45:26 +00:00
5f3b1663d2 Merge pull request '관리자 - 콘텐츠 리스트' (#227) from test into main
Reviewed-on: #227
2024-10-16 03:33:23 +00:00
66e786b4bb Merge pull request '관리자 - 시리즈 리스트 API' (#226) from test into main
Reviewed-on: #226
2024-10-14 15:39:45 +00:00
f671114574 Merge pull request '관리자 - 시리즈 리스트 API' (#225) from test into main
Reviewed-on: #225
2024-10-14 10:37:28 +00:00
ce37060d94 Merge pull request '관리자 - 시리즈 리스트 API 추가' (#224) from test into main
Reviewed-on: #224
2024-10-14 10:10:43 +00:00
7d19a4d184 Merge pull request 'test' (#223) from test into main
Reviewed-on: #223
2024-10-13 17:30:25 +00:00
22f28a2f8a Merge pull request '콘텐츠 메인 - 추천시리즈, 새로운 콘텐츠, 큐레이션' (#222) from test into main
Reviewed-on: #222
2024-10-13 16:33:15 +00:00
ceef9ca979 Merge pull request 'test' (#221) from test into main
Reviewed-on: #221
2024-10-11 05:06:22 +00:00
efe8f4f939 Merge pull request '콘텐츠 메인 - 새로운 콘텐츠 섹션 두번째 정렬 조건 추가' (#220) from test into main
Reviewed-on: #220
2024-10-04 07:21:38 +00:00
ba692a1195 Merge pull request '시리즈 상세 - 콘텐츠 리스트 두번째 정렬 조건 추가' (#219) from test into main
Reviewed-on: #219
2024-10-04 02:41:28 +00:00
d732bad042 Merge pull request '시리즈 상세' (#218) from test into main
Reviewed-on: #218
2024-10-02 09:18:19 +00:00
4c935c3bee Merge pull request '예약 라이브 개수 제한' (#217) from test into main
Reviewed-on: #217
2024-09-25 05:42:45 +00:00
c160dd791f Merge pull request 'test' (#216) from test into main
Reviewed-on: #216
2024-09-24 10:17:58 +00:00
23cd1b4601 Merge pull request '라이브 후원현황 API' (#215) from test into main
Reviewed-on: #215
2024-09-23 13:58:14 +00:00
031fc8ba1b Merge pull request 'test' (#214) from test into main
Reviewed-on: #214
2024-09-23 06:24:12 +00:00
c6853289ad Merge pull request 'test' (#213) from test into main
Reviewed-on: #213
2024-09-11 08:23:08 +00:00
2497bb69bc Merge pull request 'test' (#212) from test into main
Reviewed-on: #212
2024-09-11 07:47:35 +00:00
a58a67e0a2 Merge pull request 'test' (#211) from test into main
Reviewed-on: #211
2024-09-11 06:00:31 +00:00
4315fe12a5 Merge pull request 'test' (#210) from test into main
Reviewed-on: #210
2024-09-06 19:00:39 +00:00
42f10a8899 Merge pull request 'test' (#209) from test into main
Reviewed-on: #209
2024-09-05 10:12:14 +00:00
1e4b47f989 Merge pull request 'test' (#208) from test into main
Reviewed-on: #208
2024-08-30 09:17:41 +00:00
ff255dbfae Merge pull request 'test' (#207) from test into main
Reviewed-on: #207
2024-08-27 07:31:05 +00:00
dbe9b72feb Merge pull request 'test' (#206) from test into main
Reviewed-on: #206
2024-08-23 13:48:24 +00:00
95a714b391 Merge pull request '탐색' (#205) from test into main
Reviewed-on: #205
2024-08-19 13:21:31 +00:00
28f58c7f56 Merge pull request '라이브' (#204) from test into main
Reviewed-on: #204
2024-08-14 09:34:52 +00:00
8bd46d8f21 Merge pull request '크리에이터 관리자 시리즈' (#203) from test into main
Reviewed-on: #203
2024-08-14 07:41:33 +00:00
e1bb8e54ed Merge pull request '크리에이터 커뮤니티' (#202) from test into main
Reviewed-on: #202
2024-08-06 11:41:11 +00:00
1de705b063 Merge pull request '크리에이터 커뮤니티' (#201) from test into main
Reviewed-on: #201
2024-08-06 06:37:30 +00:00
f6926ad356 Merge pull request '남/여 크리에이터에서 특정 크리에이터 제거' (#200) from test into main
Reviewed-on: #200
2024-07-26 07:54:18 +00:00
2cdbbb1b37 Merge pull request 'test' (#199) from test into main
Reviewed-on: #199
2024-07-25 16:08:20 +00:00
4dce8c8f03 Merge pull request '크리에이터 커뮤니티' (#198) from test into main
Reviewed-on: #198
2024-07-10 05:24:58 +00:00
97a5bace6f Merge pull request 'test' (#197) from test into main
Reviewed-on: #197
2024-07-08 14:17:42 +00:00
d4d51ec48f Merge pull request 'test' (#196) from test into main
Reviewed-on: #196
2024-07-02 08:57:11 +00:00
fb91398462 Merge pull request '커뮤니티 게시물' (#195) from test into main
Reviewed-on: #195
2024-06-17 14:09:26 +00:00
105dadd798 Merge pull request '커뮤니티 게시물' (#194) from test into main
Reviewed-on: #194
2024-06-15 11:57:33 +00:00
2abf2837d3 Merge pull request '커뮤니티 게시물' (#193) from test into main
Reviewed-on: #193
2024-06-11 12:13:47 +00:00
422aa67af6 Merge pull request '커뮤니티 게시물' (#192) from test into main
Reviewed-on: #192
2024-06-11 11:55:05 +00:00
7fffab6985 Merge pull request '크리에이터 정산 - 입력된 비율로 계산' (#191) from test into main
Reviewed-on: #191
2024-06-11 08:07:22 +00:00
5a4be3d2c1 Merge pull request '콘텐츠 상세' (#190) from test into main
Reviewed-on: #190
2024-06-07 10:30:06 +00:00
f39a7681db Merge pull request 'test' (#189) from test into main
Reviewed-on: #189
2024-06-04 03:39:23 +00:00
c60a7580ba Merge pull request 'test' (#188) from test into main
Reviewed-on: #188
2024-06-03 22:13:56 +00:00
97edb56edc Merge pull request 'test' (#187) from test into main
Reviewed-on: #187
2024-05-29 17:04:39 +00:00
6ebca8d22b Merge pull request '관리자 - 라이브 리스트' (#186) from test into main
Reviewed-on: #186
2024-05-28 18:18:44 +00:00
95371ad934 Merge pull request '(크리에이터)관리자 커뮤니티 게시물 정산' (#185) from test into main
Reviewed-on: #185
2024-05-28 16:54:44 +00:00
2c176825fd Merge pull request 'test' (#184) from test into main
Reviewed-on: #184
2024-05-28 16:09:51 +00:00
fae7de48d3 Merge pull request 'test' (#183) from test into main
Reviewed-on: #183
2024-05-27 08:28:27 +00:00
b8230646a2 Merge pull request '커뮤니티 게시글 유료화' (#182) from test into main
Reviewed-on: #182
2024-05-24 14:44:14 +00:00
43279541dd Merge pull request '콘텐츠별 누적정산' (#181) from test into main
Reviewed-on: #181
2024-05-21 06:37:51 +00:00
b4791977c1 Merge pull request 'PG 심사를 위한 캔 충전 로직 추가' (#180) from test into main
Reviewed-on: #180
2024-05-20 06:38:40 +00:00
ef917ecc25 Merge pull request '라이브 방 - 크리에이터 입장 가능 설정 추가' (#179) from test into main
Reviewed-on: #179
2024-05-14 12:09:53 +00:00
a93faad951 Merge pull request '룰렛 방식 수정' (#178) from test into main
Reviewed-on: #178
2024-05-10 18:00:40 +00:00
fd001d24d3 Merge pull request '추천시리즈 API 추가' (#177) from test into main
Reviewed-on: #177
2024-05-07 10:34:10 +00:00
7aa5884797 Merge pull request '구글 인 앱 결제 검증코드 수정' (#176) from test into main
Reviewed-on: #176
2024-05-03 10:06:39 +00:00
5b237a1547 Merge pull request 'test' (#175) from test into main
Reviewed-on: #175
2024-05-03 06:08:55 +00:00
2e37990d87 Merge pull request '탐색 - 남/여 크리에이터 리스트' (#174) from test into main
Reviewed-on: #174
2024-05-02 17:24:18 +00:00
dd07d724a8 Merge pull request 'test' (#173) from test into main
Reviewed-on: #173
2024-05-02 16:41:50 +00:00
03ce8618e7 Merge pull request '관리자 시그니처 설정' (#172) from test into main
Reviewed-on: #172
2024-05-02 07:13:21 +00:00
db1a7a7fd6 Merge pull request '시그니처 후원 시간 추가' (#171) from test into main
Reviewed-on: #171
2024-05-02 06:23:22 +00:00
36a82d7f53 Merge pull request '시리즈, 시리즈 콘텐츠' (#170) from test into main
Reviewed-on: #170
2024-04-30 14:00:01 +00:00
3a34401113 Merge pull request '시리즈 상세' (#169) from test into main
Reviewed-on: #169
2024-04-30 09:44:55 +00:00
9927268330 Merge pull request '구글 인 앱 결제' (#168) from test into main
Reviewed-on: #168
2024-04-30 08:24:03 +00:00
c45c97e29d Merge pull request '시리즈' (#167) from test into main
Reviewed-on: #167
2024-04-26 18:51:10 +00:00
c64a315226 Merge pull request 'test' (#166) from test into main
Reviewed-on: #166
2024-04-18 16:40:55 +00:00
a4cafca6ab Merge pull request 'test' (#165) from test into main
Reviewed-on: #165
2024-04-18 10:02:45 +00:00
46284a0660 Merge pull request 'test' (#164) from test into main
Reviewed-on: #164
2024-04-15 12:31:42 +00:00
05df86e15a Merge pull request 'test' (#163) from test into main
Reviewed-on: #163
2024-04-09 14:40:33 +00:00
8b433027e2 Merge pull request 'test' (#162) from test into main
Reviewed-on: #162
2024-04-09 13:27:24 +00:00
5bd4ff7610 Merge pull request '결제 테이블에 구글결제의 경우 orderId 추가' (#161) from test into main
Reviewed-on: #161
2024-04-05 03:10:00 +00:00
d693c397ea Merge pull request '.' (#160) from test into main
Reviewed-on: #160
2024-04-03 06:49:36 +00:00
1d8d1ec9a5 Merge pull request 'test' (#159) from test into main
Reviewed-on: #159
2024-04-03 06:27:26 +00:00
5e491f11ee Merge pull request '크리에이터 관리자 라이브 정산' (#158) from test into main
Reviewed-on: #158
2024-04-03 03:45:31 +00:00
7cedea06ac Merge pull request '캔 사용' (#157) from test into main
Reviewed-on: #157
2024-04-01 12:42:44 +00:00
2e5f750e50 Merge pull request 'test' (#156) from test into main
Reviewed-on: #156
2024-04-01 10:20:09 +00:00
20289cad10 Merge pull request '콘텐츠 상세' (#155) from test into main
Reviewed-on: #155
2024-03-29 10:10:04 +00:00
e0d64c31c7 Merge pull request '콘텐츠 상세' (#154) from test into main
Reviewed-on: #154
2024-03-29 08:18:15 +00:00
8c1b95dc97 Merge pull request '구글 인 앱구매 검증' (#153) from test into main
Reviewed-on: #153
2024-03-28 17:13:57 +00:00
fb5641343e Merge pull request 'test' (#152) from test into main
Reviewed-on: #152
2024-03-28 06:31:43 +00:00
87765941eb Merge pull request '구글 인 앱 결제 검증' (#151) from test into main
Reviewed-on: #151
2024-03-22 20:10:01 +00:00
1809862c16 Merge pull request '구글 인 앱 결제 처리과정 축소' (#150) from test into main
Reviewed-on: #150
2024-03-22 15:27:25 +00:00
300f784f7d Merge pull request '구글 인 앱 결제 검증 과정 try/catch로 예외 처리' (#149) from test into main
Reviewed-on: #149
2024-03-22 11:59:37 +00:00
67a045eae6 Merge pull request '구글 인 앱 결제 검증 수정' (#148) from test into main
Reviewed-on: #148
2024-03-22 11:36:35 +00:00
2a79903a28 Merge pull request 'test' (#147) from test into main
Reviewed-on: #147
2024-03-22 10:08:00 +00:00
d3222ce083 Merge pull request '관리자 캔 충전현황' (#146) from test into main
Reviewed-on: #146
2024-03-21 15:56:02 +00:00
406a421742 Merge pull request '구글 인 앱 결제 검증 코드 수정' (#145) from test into main
Reviewed-on: #145
2024-03-21 15:22:34 +00:00
10bf728faf Merge pull request '구글 인 앱 결제 검증 코드 수정' (#144) from test into main
Reviewed-on: #144
2024-03-21 14:37:09 +00:00
607617747c Merge pull request '구글 인 앱 결제 검증 코드 수정' (#143) from test into main
Reviewed-on: #143
2024-03-21 12:47:51 +00:00
f0a69eb1a2 Merge pull request 'test' (#142) from test into main
Reviewed-on: #142
2024-03-21 07:45:02 +00:00
6b307a6e17 Merge pull request 'test' (#141) from test into main
Reviewed-on: #141
2024-03-13 11:28:13 +00:00
08d08a934a Merge pull request 'test' (#140) from test into main
Reviewed-on: #140
2024-03-12 06:20:02 +00:00
c500c12668 Merge pull request 'test' (#139) from test into main
Reviewed-on: #139
2024-03-08 13:40:27 +00:00
62060adeba Merge pull request '채널 후원 랭킹' (#138) from test into main
Reviewed-on: #138
2024-02-29 10:28:51 +00:00
b2fc75edb8 Merge pull request '룰렛 정렬 순서 수정' (#137) from test into main
Reviewed-on: #137
2024-02-27 17:29:36 +00:00
a999dd2085 Merge pull request 'test' (#136) from test into main
Reviewed-on: #136
2024-02-27 16:16:30 +00:00
49f95ab100 Merge pull request '회원테이블에 adid 추가' (#135) from test into main
Reviewed-on: #135
2024-02-27 05:49:47 +00:00
1a84d5b30c Merge pull request 'test' (#134) from test into main
Reviewed-on: #134
2024-02-24 20:35:57 +00:00
3b65050632 Merge pull request 'redis ssl false' (#133) from test into main
Reviewed-on: #133
2024-02-17 13:00:25 +00:00
d0df31674c Merge pull request 'test' (#132) from test into main
Reviewed-on: #132
2024-02-17 12:44:52 +00:00
1fe88402e2 Merge pull request 'test' (#131) from test into main
Reviewed-on: #131
2024-02-14 07:12:41 +00:00
67097696e6 Merge pull request '커뮤니티 게시물 시간' (#130) from test into main
Reviewed-on: #130
2024-02-12 08:14:24 +00:00
8e7e77067a Merge pull request 'test' (#129) from test into main
Reviewed-on: #129
2024-02-12 07:53:26 +00:00
9899390b61 Merge pull request '관리자 콘텐츠 리스트, 수정' (#128) from test into main
Reviewed-on: #128
2024-02-08 18:19:50 +00:00
80c476a908 Merge pull request '관리자 콘텐츠 리스트' (#127) from test into main
Reviewed-on: #127
2024-02-08 14:45:54 +00:00
59da1d6e49 Merge pull request '카테고리 콘텐츠' (#126) from test into main
Reviewed-on: #126
2024-02-07 13:33:37 +00:00
5aef7dac33 Merge pull request 'test' (#125) from test into main
Reviewed-on: #125
2024-02-07 09:39:09 +00:00
faf7aa06b6 Merge pull request 'test' (#124) from test into main
Reviewed-on: #124
2024-02-05 07:03:45 +00:00
38ef6e5583 Merge pull request 'test' (#123) from test into main
Reviewed-on: #123
2024-02-05 02:12:10 +00:00
c0b15b5d94 Merge pull request '콘텐츠 업로드' (#122) from test into main
Reviewed-on: #122
2024-01-30 03:45:31 +00:00
2cfc067ea1 Merge pull request '콘텐츠 전체 리스트' (#121) from test into main
Reviewed-on: #121
2024-01-29 09:00:23 +00:00
a91db4f956 Merge pull request '콘텐츠 상단 고정 기능 추가' (#120) from test into main
Reviewed-on: #120
2024-01-29 02:45:41 +00:00
8a09780a02 Merge pull request 'test' (#119) from test into main
Reviewed-on: #119
2024-01-26 06:19:49 +00:00
45e8ec6505 Merge pull request '콘텐츠 정렬 기준' (#118) from test into main
Reviewed-on: #118
2024-01-24 15:03:06 +00:00
4554b85914 Merge pull request '회원가입 시 닉네임 validation 조건' (#117) from test into main
Reviewed-on: #117
2024-01-24 07:11:23 +00:00
8aa79c4a9c Merge pull request '콘텐츠 등록 - 태그 등록' (#116) from test into main
Reviewed-on: #116
2024-01-16 15:19:59 +00:00
c8d3210b57 Merge pull request 'test' (#115) from test into main
Reviewed-on: #115
2024-01-11 09:05:44 +00:00
2282a49563 Merge pull request 'test' (#114) from test into main
Reviewed-on: #114
2024-01-11 03:49:53 +00:00
b82fdfb2c8 Merge pull request '예약 업로드' (#113) from test into main
Reviewed-on: #113
2024-01-10 16:59:51 +00:00
2d17eac199 Merge pull request '19세 미만이 인증처리 되던 버그 수정' (#112) from test into main
Reviewed-on: #112
2024-01-08 10:07:21 +00:00
e482bc3aad Merge pull request '콘텐츠를 올린 크리에이터가 댓글을 삭제할 수 있도록 수정' (#111) from test into main
Reviewed-on: #111
2024-01-04 11:36:43 +00:00
ec022b74d1 Merge pull request '캔 쿠폰 조회 로직 수정' (#110) from test into main
Reviewed-on: #110
2024-01-04 09:59:20 +00:00
dc42c09ce3 Merge pull request '캔 쿠폰 조회' (#109) from test into main
Reviewed-on: #109
2024-01-03 15:48:09 +00:00
046a34d2a4 Merge pull request 'test' (#108) from test into main
Reviewed-on: #108
2024-01-03 15:19:42 +00:00
9ff6ec1888 Merge pull request '캔 쿠폰 시스템' (#107) from test into main
Reviewed-on: #107
2024-01-03 11:28:48 +00:00
d2950106ec Merge pull request '콘텐츠 랭킹 - 후원 순위 제거, 룰렛 아이템 개수 10로 변경' (#106) from test into main
Reviewed-on: #106
2023-12-26 12:50:09 +00:00
962f800d2e Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 인증하지 않은 사람은 19금이 아닌 최신 게시물이 조회되도록 수정' (#105) from test into main
Reviewed-on: #105
2023-12-25 08:19:43 +00:00
962107e507 Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#104) from test into main
Reviewed-on: #104
2023-12-21 19:28:10 +00:00
039bd11963 Merge pull request '커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#103) from test into main
Reviewed-on: #103
2023-12-21 19:05:12 +00:00
5c250ea4ae Merge pull request '크리에이터 커뮤니티' (#102) from test into main
Reviewed-on: #102
2023-12-21 15:10:55 +00:00
e3405bcec6 Merge pull request 'test' (#101) from test into main
Reviewed-on: #101
2023-12-13 16:14:50 +00:00
0fd1c2235f Merge pull request '라이브 정산 - 정렬 순서 추가 (라이브 방 id, 구분)' (#100) from test into main
Reviewed-on: #100
2023-12-10 16:58:05 +00:00
b20c29b022 Merge pull request 'test' (#99) from test into main
Reviewed-on: #99
2023-12-10 12:34:51 +00:00
12d5dcd298 Merge pull request 'test' (#98) from test into main
Reviewed-on: #98
2023-12-10 09:02:29 +00:00
2c305dc6c6 Merge pull request 'test' (#97) from test into main
Reviewed-on: #97
2023-12-10 06:48:43 +00:00
62f76f7433 Merge pull request 'test' (#96) from test into main
Reviewed-on: #96
2023-12-07 01:46:33 +00:00
858ce524f9 Merge pull request 'test' (#95) from test into main
Reviewed-on: #95
2023-11-27 12:48:24 +00:00
3795fb4a40 Merge pull request 'test' (#94) from test into main
Reviewed-on: #94
2023-11-24 07:03:10 +00:00
0c01aeec50 Merge pull request '관리자 - 이벤트 배너 등록' (#93) from test into main
Reviewed-on: #93
2023-11-21 16:21:19 +00:00
892206744d Merge pull request '이벤트 배너, 팝업' (#92) from test into main
Reviewed-on: #92
2023-11-21 12:59:30 +00:00
9e2c1474db Merge pull request '메시지 보내기 유저 검색' (#91) from test into main
Reviewed-on: #91
2023-11-20 05:41:57 +00:00
16328f73d9 Merge pull request '크리에이터 관리자, 관리자 - 일자별 콘텐츠 후원 정산 API' (#90) from test into main
Reviewed-on: #90
2023-11-14 13:14:19 +00:00
e0d4f53cf4 Merge pull request 'test' (#89) from test into main
Reviewed-on: #89
2023-11-14 12:15:51 +00:00
e09a59c5b4 Merge pull request 'test' (#88) from test into main
Reviewed-on: #88
2023-11-14 11:09:14 +00:00
049e654535 Merge pull request '관리자 일자별 콘텐츠 후원 정산 - 크리에이터 순으로 정렬' (#87) from test into main
Reviewed-on: #87
2023-11-14 09:22:38 +00:00
c927dc4ecd Merge pull request '관리자 - 일자별 콘텐츠 후원 정산 페이지 추가' (#86) from test into main
Reviewed-on: #86
2023-11-14 09:03:35 +00:00
fe4ecd0ad8 Merge pull request '크리에이터 관리자 - 일자별 콘텐츠 정산 페이징 안되는 버그 수정' (#85) from test into main
Reviewed-on: #85
2023-11-14 05:17:58 +00:00
78d476fe80 Merge pull request '라이브 상세 - 시작 시간 dateformat yyyy.MM.dd E hh:mm a 로 복구' (#84) from test into main
Reviewed-on: #84
2023-11-14 03:31:54 +00:00
a11c8465d5 Merge pull request '크리에이터 관리자 - @JsonProperty 추가' (#83) from test into main
Reviewed-on: #83
2023-11-13 16:10:57 +00:00
366304a9b7 Merge pull request '크리에이터 관리자 - 정산 API 캐시 추가' (#82) from test into main
Reviewed-on: #82
2023-11-13 15:56:58 +00:00
4356663688 Merge pull request '크리에이터 관리자 - 콘텐츠 누적 매출 API' (#81) from test into main
Reviewed-on: #81
2023-11-13 15:23:53 +00:00
26b55e6fcf Merge pull request '콘텐츠 누적 매출 API - orderType 추가' (#80) from test into main
Reviewed-on: #80
2023-11-13 14:48:47 +00:00
0d743f7204 Merge pull request '콘텐츠 누적 매출 API 추가' (#79) from test into main
Reviewed-on: #79
2023-11-13 13:42:17 +00:00
6cbe113b3e Merge pull request '크리에이터 콘텐츠 정산 - API 추가' (#78) from test into main
Reviewed-on: #78
2023-11-13 10:03:02 +00:00
6409b69d6c Merge pull request '콘텐츠 정산 - 결과값에 JsonProperty 를 추가하여 데이터 파싱이 진행 되도록 수정' (#77) from test into main
Reviewed-on: #77
2023-11-10 13:19:06 +00:00
c5164c76fc Merge pull request '콘텐츠 정산 - group by 날짜 수정' (#76) from test into main
Reviewed-on: #76
2023-11-10 12:46:07 +00:00
baade8e138 Merge pull request 'test' (#75) from test into main
Reviewed-on: #75
2023-11-10 10:47:11 +00:00
b848d6b4e0 Merge pull request 'test' (#74) from test into main
Reviewed-on: #74
2023-11-09 11:18:20 +00:00
d8139d2ab0 Merge pull request '라이브 리스트, 라이브 상세' (#73) from test into main
Reviewed-on: #73
2023-11-07 16:48:25 +00:00
e96d8f7469 Merge pull request 'test' (#72) from test into main
Reviewed-on: #72
2023-11-07 16:23:11 +00:00
2acffd8afc Merge pull request '콘텐츠 메인 API - 캐싱을 적용하기 위해 AudioContentMainManageService 추가' (#71) from test into main
Reviewed-on: #71
2023-11-07 11:24:40 +00:00
3c8e72073c Merge pull request '콘텐츠 메인 API - @Transactional(readOnly = true) 추가' (#70) from test into main
Reviewed-on: #70
2023-11-07 08:47:47 +00:00
724d7a9d9b Merge pull request 'test' (#69) from test into main
Reviewed-on: #69
2023-11-07 08:21:56 +00:00
2da3b0db78 Merge pull request 'test' (#68) from test into main
Reviewed-on: #68
2023-11-06 09:26:36 +00:00
685ad7afaf Merge pull request '콘텐츠 메인 캐싱전략 수정' (#67) from test into main
Reviewed-on: #67
2023-11-06 08:46:55 +00:00
264cf75964 Merge pull request '콘텐츠 메인 - 큐레이션 개수 15개만 노출' (#66) from test into main
Reviewed-on: #66
2023-11-06 08:11:00 +00:00
c773dbc7b5 Merge pull request '콘텐츠 랭킹 - 후원 랭킹 조회 로직 수정' (#65) from test into main
Reviewed-on: #65
2023-11-04 14:24:46 +00:00
37cbc64f52 Merge pull request '본인인증' (#64) from test into main
Reviewed-on: #64
2023-11-03 07:48:02 +00:00
cb1dde17bb Merge pull request 'test' (#63) from test into main
Reviewed-on: #63
2023-11-02 12:18:29 +00:00
c29988acf4 Merge pull request '콘텐츠 주문 - 대여만 가능한 콘텐츠의 경우 소장으로 주문이 들어오더라도 대여로 처리되도록 로직 수정' (#62) from test into main
Reviewed-on: #62
2023-11-01 04:49:18 +00:00
eadbf56dae Merge pull request '정산테이블에 무료충전 코인도 반영되도록 수정' (#61) from test into main
Reviewed-on: #61
2023-10-28 08:42:29 +00:00
4b3b455135 Merge pull request '캔 사용 시 제휴보상 캔도 사용할 수 있도록 수정' (#60) from test into main
Reviewed-on: #60
2023-10-27 13:48:33 +00:00
e6ac177396 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#59) from test into main
Reviewed-on: #59
2023-10-26 18:48:46 +00:00
3d0e29003f Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#58) from test into main
Reviewed-on: #58
2023-10-26 18:22:10 +00:00
78b9b00f77 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#57) from test into main
Reviewed-on: #57
2023-10-26 18:02:16 +00:00
0ee7faa551 Merge pull request '카울리를 이용한 무료충전 테이블 adProfit 과 point 타입 int -> float 로 변경' (#56) from test into main
Reviewed-on: #56
2023-10-26 16:55:39 +00:00
e5fdced681 Merge pull request '콘텐츠 메인 캐싱 전략 변경' (#55) from test into main
Reviewed-on: #55
2023-10-26 16:14:37 +00:00
afb99fef64 Merge pull request 'GetAudioContentMainItem - adult를 isAdult로 변경, 캐시 제거' (#54) from test into main
Reviewed-on: #54
2023-10-24 11:39:56 +00:00
7dfaa36024 Merge pull request 'test' (#53) from test into main
Reviewed-on: #53
2023-10-24 10:42:10 +00:00
0496f665aa Merge pull request 'getAudioContentMainBannerList 부분 캐시 제거' (#52) from test into main
Reviewed-on: #52
2023-10-17 10:02:53 +00:00
0d19e1be74 Merge pull request 'audio content banner - lazy 옵션으로 인해 발생하는 com.fasterxml.jackson.databind.exc.InvalidDefinitionException 문제 수정' (#51) from test into main
Reviewed-on: #51
2023-10-17 09:47:44 +00:00
4aff0111aa Merge pull request '로딩 속도를 위해 @Cacheable 적용' (#50) from test into main
Reviewed-on: #50
2023-10-17 09:31:08 +00:00
63b3ba2bb2 Merge pull request '인기 콘텐츠 전체보기 집계날짜 수정' (#49) from test into main
Reviewed-on: #49
2023-10-16 13:59:28 +00:00
7444b41f60 Merge pull request '콘텐츠 메인 - 인기 콘텐츠 집계날짜 수정' (#48) from test into main
Reviewed-on: #48
2023-10-16 13:42:31 +00:00
8e90dbc8b6 Merge pull request '구매목록 - isActive 가 true 인 것만 조회되도록 수정' (#47) from test into main
Reviewed-on: #47
2023-10-16 03:30:10 +00:00
9f70722521 Merge pull request '탐색 인기 크리에이터 - 날짜 설명 글 수정' (#46) from test into main
Reviewed-on: #46
2023-10-14 21:43:48 +00:00
52fae596fa Merge pull request '콘텐츠 랭킹 데이터 전체보기 API - 페이징 추가' (#45) from test into main
Reviewed-on: #45
2023-10-14 21:20:19 +00:00
ccb67957bc Merge pull request '콘텐츠 랭킹 추가' (#44) from test into main
Reviewed-on: #44
2023-10-14 19:37:55 +00:00
fb82538d0d Merge pull request '캔 소비 - 콘텐츠 주문시 캔 소비내역에 콘텐츠 내용 추가' (#43) from test into main
Reviewed-on: #43
2023-10-14 11:33:59 +00:00
72ee39612e Merge pull request '탐색 - 인기 급상승 제거, 인기 크리에이터 섹션 추가' (#42) from test into main
Reviewed-on: #42
2023-10-13 15:41:25 +00:00
51fd5408dc Merge pull request 'test' (#41) from test into main
Reviewed-on: #41
2023-10-06 08:50:32 +00:00
3fae40fbef Merge pull request '콘텐츠 상세 - 댓글 수 로직 답글 포함하지 않도록 수정' (#40) from test into main
Reviewed-on: #40
2023-10-05 02:49:22 +00:00
0745890af0 Merge pull request 'test' (#39) from test into main
Reviewed-on: #39
2023-10-04 03:24:41 +00:00
4abe1730a7 Merge pull request '관리자 라이브 정산 API - 인원 추가' (#38) from test into main
Reviewed-on: #38
2023-10-03 12:10:51 +00:00
626f0e6989 Merge pull request '관리자 - 라이브 정산 API 추가' (#37) from test into main
Reviewed-on: #37
2023-10-03 09:28:04 +00:00
9f42d9d173 Merge pull request '라이브 시작 알림 - 알림 받을 유저 조회에서 에러가 발생하는 버그 수정' (#36) from test into main
Reviewed-on: #36
2023-10-02 13:00:11 +00:00
f90a93c4bc Merge pull request '후원순위 - 유료라이브 입장 캔 반영' (#35) from test into main
Reviewed-on: #35
2023-09-27 14:57:27 +00:00
8000ad6c6a Merge pull request 'test' (#34) from test into main
Reviewed-on: #34
2023-09-27 06:49:21 +00:00
1f1f1bea1a Merge pull request 'test' (#33) from test into main
Reviewed-on: #33
2023-09-27 05:28:04 +00:00
d95460c7cd Merge pull request '닉네임 변경 가격 100 캔으로 변경' (#32) from test into main
Reviewed-on: #32
2023-09-22 07:01:27 +00:00
a3d93d4b08 Merge pull request 'test' (#31) from test into main
Reviewed-on: #31
2023-09-19 06:32:22 +00:00
07a92af982 Merge pull request '라이브 시작시간 4시간이 지나도 라이브를 시작하지 않은 경우 자동취소로직 추가' (#30) from test into main
Reviewed-on: #30
2023-09-12 14:09:37 +00:00
f4618877d4 Merge pull request '주문목록 - 크리에이터 닉네임 추가' (#29) from test into main
Reviewed-on: #29
2023-09-08 16:29:30 +00:00
2b914fd222 Merge pull request 'test' (#28) from test into main
Reviewed-on: #28
2023-09-02 16:12:08 +00:00
109e42a5a3 Merge pull request 'test' (#27) from test into main
Reviewed-on: #27
2023-08-31 08:37:42 +00:00
fa515ad39c Merge pull request '관리자 캔 충전내역 - 애플 인 앱 결제에 PG결제가 같이 나오던 버그 수정' (#26) from test into main
Reviewed-on: #26
2023-08-30 10:02:32 +00:00
f09673a795 Merge pull request 'test' (#25) from test into main
Reviewed-on: #25
2023-08-30 08:10:29 +00:00
f71536c614 Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2023-08-30 07:24:14 +00:00
7bdddc7ae8 Merge pull request '후원 전체보기 - 하단 랭킹에 콘텐츠 후원도 포함' (#23) from test into main
Reviewed-on: #23
2023-08-29 15:22:09 +00:00
aa8926a624 Merge pull request '후원 전체보기 - 상단 캔 현황 을 후원 캔만 반영하도록 수정' (#22) from test into main
Reviewed-on: #22
2023-08-29 14:53:44 +00:00
be71e59be2 Merge pull request '무료 콘텐츠를 못올리는 버그 수정' (#21) from test into main
Reviewed-on: #21
2023-08-29 13:32:13 +00:00
4d7753378f Merge pull request 'test' (#20) from test into main
Reviewed-on: #20
2023-08-29 07:33:57 +00:00
60257c4ef4 Merge pull request 'test' (#19) from test into main
Reviewed-on: #19
2023-08-28 08:54:08 +00:00
1e0b79bf62 Merge pull request 'test' (#18) from test into main
Reviewed-on: #18
2023-08-27 12:28:42 +00:00
6883434d0d Merge pull request '유저 관심사, 라이브 관심사 - 연령제한 설정 추가' (#17) from test into main
Reviewed-on: #17
2023-08-25 08:38:51 +00:00
eda2193e64 Merge pull request 'test' (#16) from test into main
Reviewed-on: #16
2023-08-25 07:50:55 +00:00
99bf829c88 Merge pull request '첫 충전 이벤트 - 본인인증한 전체 계정 중 첫 충전 시에만 첫충전 이벤트 적용' (#15) from test into main
Reviewed-on: #15
2023-08-24 16:26:54 +00:00
5feafe1b48 Merge pull request 'test' (#14) from test into main
Reviewed-on: #14
2023-08-24 14:54:37 +00:00
c9292b7d04 Merge pull request 'test' (#13) from test into main
Reviewed-on: #13
2023-08-23 14:05:00 +00:00
ae7e1a91c1 Merge pull request '캔 충전로직 수정' (#12) from test into main
Reviewed-on: #12
2023-08-21 15:12:44 +00:00
3e1887e0d1 Merge pull request 'test' (#11) from test into main
Reviewed-on: #11
2023-08-21 13:19:03 +00:00
474646db47 Merge pull request '스피커 최대 10 -> 5명으로 수정' (#10) from test into main
Reviewed-on: #10
2023-08-20 20:40:23 +00:00
56f7b6c449 Merge pull request 'test' (#9) from test into main
Reviewed-on: #9
2023-08-20 19:22:23 +00:00
76b2b5f7e3 Merge pull request '캔 사용내역 - 후원을 콘텐츠 후원, 라이브 후원으로 분리' (#8) from test into main
Reviewed-on: #8
2023-08-20 15:46:30 +00:00
e918d809eb Merge pull request '충전내역 - 관리자 지급 추가' (#7) from test into main
Reviewed-on: #7
2023-08-20 15:08:35 +00:00
7af059e543 Merge pull request 'test' (#6) from test into main
Reviewed-on: #6
2023-08-20 14:45:39 +00:00
897726e1ec Merge pull request 'test' (#5) from test into main
Reviewed-on: #5
2023-08-19 17:47:16 +00:00
8b98a2dd07 Merge pull request '비밀번호 찾기 API 추가' (#4) from test into main
Reviewed-on: #4
2023-08-19 07:05:17 +00:00
cca75420f0 Merge pull request '큐레이션 - 조건 추가' (#3) from test into main
Reviewed-on: #3
2023-08-18 14:07:34 +00:00
86c627ed1d Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-18 12:54:09 +00:00
d55514e3a7 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-08-16 02:30:36 +00:00
85 changed files with 5111 additions and 261 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

@@ -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,115 @@
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.OriginalWorkCharactersPageResponse
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)
}
/**
* 지정 원작에 속한 활성 캐릭터 목록 조회 (페이징)
* - 로그인 및 본인인증 필수
* - 기본 페이지 사이즈 20
*/
@GetMapping("/{id}/characters")
fun listCharacters(
@PathVariable id: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(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 pageRes = queryService.getActiveCharactersPage(id, page, size)
val content = pageRes.content.map {
val path = it.imagePath ?: "profile/default-profile.png"
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/$path"
)
}
ApiResponse.ok(
OriginalWorkCharactersPageResponse(
totalCount = pageRes.totalElements,
content = content
)
)
}
}

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

@@ -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
}
}