From f05f146c89f79b93e55ae435c7fae8828b19e05e Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Sep 2025 12:35:16 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(chat-quota):=20=EC=9C=A0=EB=A3=8C=20?= =?UTF-8?q?=EC=B0=A8=EA=B0=90=20=ED=9B=84=20=EB=AC=B4=EB=A3=8C=C2=B7?= =?UTF-8?q?=EC=9C=A0=EB=A3=8C=20=EB=8F=99=EC=8B=9C=200=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=20next=5Frecharge=5Fat=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문제: 유료 잔여가 있을 때 유료 우선 차감 경로에서 `next_recharge_at` 설정 분기가 없어, 무료/유료가 동시에 0이 되는 경우 다음 무료 충전 시점이 노출되지 않음 - 수정: `ChatRoomQuotaService.consumeOneForSend`의 유료 차감 분기에 `remainingPaid==0 && remainingFree==0 && nextRechargeAt==null` 조건에서 `now + 6h`로 `next_recharge_at`을 설정하도록 로직 추가 - 참고: 무료 차감 경로의 `next_recharge_at` 설정 및 입장 시 lazy refill 동작은 기존과 동일 --- .../chat/quota/room/ChatRoomQuotaService.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt index db24ea6..f3db435 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt @@ -86,6 +86,10 @@ class ChatRoomQuotaService( // 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) } @@ -94,16 +98,16 @@ class ChatRoomQuotaService( val globalFree = globalFreeProvider() if (globalFree <= 0) { // 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가 - throw SodaException("무료 쿼터가 소진되었습니다. 글로벌 무료 충전 이후 이용해 주세요.") + throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.") } if (quota.remainingFree <= 0) { // 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가 val waitMillis = quota.nextRechargeAt - if (waitMillis != null && waitMillis > nowMillis) { - throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 무료 충전 이후 이용해 주세요.") - } else { - throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 잠시 후 다시 시도해 주세요.") + if (waitMillis == null) { + quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli() } + + throw SodaException("무료 채팅이 모두 소진되었습니다.") } // 둘 다 가능 → 차감 -- 2.40.1 From 83a1316a647f1159bb4308fb0de8ab40a814aa2a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Sep 2025 18:06:40 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(character):=20UTC=2020=EC=8B=9C=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A7=91=EA=B3=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 집계 기준을 "채팅방 전체 메시지 수"로 변경하여 캐릭터별 인기 순위 산정 - 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 부하 최소화, 경계 전환 후 자연 무효화 --- .editorconfig | 2 +- .../controller/ChatCharacterController.kt | 2 +- .../character/service/ChatCharacterService.kt | 26 ++++++--- .../service/PopularCharacterQuery.kt | 54 +++++++++++++++++++ .../service/RankingWindowCalculator.kt | 37 +++++++++++++ .../vividnext/sodalive/configs/RedisConfig.kt | 10 ++++ 6 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt diff --git a/.editorconfig b/.editorconfig index ebb76ef..bb0fdf4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,5 +7,5 @@ indent_size = 4 indent_style = space trim_trailing_whitespace = true insert_final_newline = true -max_line_length = 120 +max_line_length = 130 tab_width = 4 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 930a9a4..65b551f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -64,7 +64,7 @@ class ChatCharacterController( } } - // 인기 캐릭터 조회 (현재는 빈 리스트) + // 인기 캐릭터 조회 val popularCharacters = service.getPopularCharacters() .map { Character( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index c052972..84422d5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepo import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository +import org.springframework.cache.annotation.Cacheable import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,16 +27,29 @@ class ChatCharacterService( private val tagRepository: ChatCharacterTagRepository, private val valueRepository: ChatCharacterValueRepository, private val hobbyRepository: ChatCharacterHobbyRepository, - private val goalRepository: ChatCharacterGoalRepository + private val goalRepository: ChatCharacterGoalRepository, + private val popularCharacterQuery: PopularCharacterQuery ) { /** - * 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회 - * 현재는 채팅방 구현 전이므로 빈 리스트 반환 + * UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회 + * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용 */ @Transactional(readOnly = true) - fun getPopularCharacters(): List { - // 채팅방 구현 전이므로 빈 리스트 반환 - return emptyList() + @Cacheable( + cacheNames = ["popularCharacters_24h"], + key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-chat-character').cacheKey" + ) + fun getPopularCharacters(limit: Long = 20): List { + val window = RankingWindowCalculator.now("popular-chat-character") + val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) + return loadCharactersInOrder(topIds) + } + + private fun loadCharactersInOrder(ids: List): List { + if (ids.isEmpty()) return emptyList() + val list = chatCharacterRepository.findAllById(ids) + val map = list.associateBy { it.id } + return ids.mapNotNull { map[it] } } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt new file mode 100644 index 0000000..ea542f7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt @@ -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 { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt new file mode 100644 index 0000000..0767e7d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt @@ -0,0 +1,37 @@ +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 + + fun now(prefix: String = "popular-chat-character"): RankingWindow { + val now = ZonedDateTime.now(ZONE) + val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE) + val (start, endExclusive, nextBoundary) = if (now.isBefore(todayBoundary)) { + val start = todayBoundary.minusDays(1) + Triple(start, todayBoundary, todayBoundary) + } else { + val next = todayBoundary.plusDays(1) + Triple(todayBoundary, next, next) + } + val windowStart = start.toInstant() + val windowEnd = endExclusive.minusNanos(1).toInstant() // [start, end] + val cacheKey = "$prefix:${windowStart.epochSecond}" + return RankingWindow(windowStart, windowEnd, nextBoundary.toInstant(), cacheKey) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt index 04a6c2c..eea5eab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt @@ -123,6 +123,16 @@ class RedisConfig( ) ) + // 24시간 TTL 캐시: 인기 캐릭터 집계용 + cacheConfigMap["popularCharacters_24h"] = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(24)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + GenericJackson2JsonRedisSerializer() + ) + ) + return RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(defaultCacheConfig) .withInitialCacheConfigurations(cacheConfigMap) -- 2.40.1 From 58a46a09c3cc6169106254f02f7a7240966915f6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Sep 2025 18:21:13 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(character):=20SpEL=20=EC=A0=95=EC=A0=81?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EC=98=A4=EB=A5=98=EB=A1=9C=20@JvmStati?= =?UTF-8?q?c=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/service/RankingWindowCalculator.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt index 0767e7d..8057d85 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt @@ -19,6 +19,7 @@ object RankingWindowCalculator { private val ZONE: ZoneId = ZoneOffset.UTC private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC + @JvmStatic fun now(prefix: String = "popular-chat-character"): RankingWindow { val now = ZonedDateTime.now(ZONE) val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE) -- 2.40.1 From 27a3f450eff64fd2a9c94b869f5502bc031bed44 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Sep 2025 18:53:27 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(character):=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=9D=91=EB=8B=B5=EC=9D=84=20DTO?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20jackson=20?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatCharacterService.getPopularCharacters 반환을 List → List DTO로 변경 - 캐시 대상도 DTO로 전환(@Cacheable 유지, 동적 키/고정 TTL 그대로 사용) - 컨트롤러에서 불필요한 매핑 제거(서비스가 DTO로 반환) - Character DTO 직렬화 안정성 확보(@JsonProperty 추가) - 이미지 URL 생성 로직을 서비스로 이동하고 imageHost(@Value) 주입해 구성 --- .../controller/ChatCharacterController.kt | 8 -------- .../character/dto/CharacterHomeResponse.kt | 10 ++++++---- .../character/service/ChatCharacterService.kt | 19 ++++++++++++++++--- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 65b551f..aaefbce 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -66,14 +66,6 @@ class ChatCharacterController( // 인기 캐릭터 조회 val popularCharacters = service.getPopularCharacters() - .map { - Character( - characterId = it.id!!, - name = it.name, - description = it.description, - imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" - ) - } // 최신 캐릭터 조회 (최대 10개) val newCharacters = service.getNewCharacters(50) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt index b471315..e54ba93 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.dto +import com.fasterxml.jackson.annotation.JsonProperty + data class CharacterMainResponse( val banners: List, val recentCharacters: List, @@ -15,10 +17,10 @@ data class CurationSection( ) data class Character( - val characterId: Long, - val name: String, - val description: String, - val imageUrl: String + @JsonProperty("characterId") val characterId: Long, + @JsonProperty("name") val name: String, + @JsonProperty("description") val description: String, + @JsonProperty("imageUrl") val imageUrl: String ) data class RecentCharacter( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 84422d5..c72c17a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -11,11 +11,13 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue +import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository +import org.springframework.beans.factory.annotation.Value import org.springframework.cache.annotation.Cacheable import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service @@ -28,7 +30,10 @@ class ChatCharacterService( private val valueRepository: ChatCharacterValueRepository, private val hobbyRepository: ChatCharacterHobbyRepository, private val goalRepository: ChatCharacterGoalRepository, - private val popularCharacterQuery: PopularCharacterQuery + private val popularCharacterQuery: PopularCharacterQuery, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String ) { /** * UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회 @@ -39,10 +44,18 @@ class ChatCharacterService( cacheNames = ["popularCharacters_24h"], key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-chat-character').cacheKey" ) - fun getPopularCharacters(limit: Long = 20): List { + fun getPopularCharacters(limit: Long = 20): List { val window = RankingWindowCalculator.now("popular-chat-character") val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) - return loadCharactersInOrder(topIds) + 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): List { -- 2.40.1