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)