test #339
| @@ -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 | ||||||
|   | |||||||
| @@ -64,7 +64,7 @@ class ChatCharacterController( | |||||||
|                 } |                 } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // 인기 캐릭터 조회 (현재는 빈 리스트) |         // 인기 캐릭터 조회 | ||||||
|         val popularCharacters = service.getPopularCharacters() |         val popularCharacters = service.getPopularCharacters() | ||||||
|             .map { |             .map { | ||||||
|                 Character( |                 Character( | ||||||
|   | |||||||
| @@ -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.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.cache.annotation.Cacheable | ||||||
| import org.springframework.data.domain.PageRequest | import org.springframework.data.domain.PageRequest | ||||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||||
| @@ -26,16 +27,29 @@ 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 | ||||||
| ) { | ) { | ||||||
|     /** |     /** | ||||||
|      * 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회 |      * 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-chat-character').cacheKey" | ||||||
|  |     ) | ||||||
|  |     fun getPopularCharacters(limit: Long = 20): List<ChatCharacter> { | ||||||
|  |         val window = RankingWindowCalculator.now("popular-chat-character") | ||||||
|  |         val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) | ||||||
|  |         return loadCharactersInOrder(topIds) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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] } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|   | |||||||
| @@ -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() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user