test #339
| @@ -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 | ||||
|   | ||||
| @@ -64,7 +64,7 @@ class ChatCharacterController( | ||||
|                 } | ||||
|         } | ||||
|  | ||||
|         // 인기 캐릭터 조회 (현재는 빈 리스트) | ||||
|         // 인기 캐릭터 조회 | ||||
|         val popularCharacters = service.getPopularCharacters() | ||||
|             .map { | ||||
|                 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.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<ChatCharacter> { | ||||
|         // 채팅방 구현 전이므로 빈 리스트 반환 | ||||
|         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<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) | ||||
|             .cacheDefaults(defaultCacheConfig) | ||||
|             .withInitialCacheConfigurations(cacheConfigMap) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user