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..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 @@ -64,16 +64,8 @@ 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 c052972..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,14 @@ 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 import org.springframework.transaction.annotation.Transactional @@ -26,16 +29,40 @@ class ChatCharacterService( private val tagRepository: ChatCharacterTagRepository, private val valueRepository: ChatCharacterValueRepository, private val hobbyRepository: ChatCharacterHobbyRepository, - private val goalRepository: ChatCharacterGoalRepository + private val goalRepository: ChatCharacterGoalRepository, + private val popularCharacterQuery: PopularCharacterQuery, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String ) { /** - * 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회 - * 현재는 채팅방 구현 전이므로 빈 리스트 반환 + * UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회 + * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용 */ @Transactional(readOnly = true) - fun getPopularCharacters(): List { - // 채팅방 구현 전이므로 빈 리스트 반환 - 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) + 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 { + 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..8057d85 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt @@ -0,0 +1,38 @@ +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-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/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("무료 채팅이 모두 소진되었습니다.") } // 둘 다 가능 → 차감 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)