test #339

Merged
klaus merged 4 commits from test into main 2025-09-11 17:05:45 +00:00
6 changed files with 123 additions and 8 deletions
Showing only changes of commit 83a1316a64 - Show all commits

View File

@ -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

View File

@ -64,7 +64,7 @@ class ChatCharacterController(
} }
} }
// 인기 캐릭터 조회 (현재는 빈 리스트) // 인기 캐릭터 조회
val popularCharacters = service.getPopularCharacters() val popularCharacters = service.getPopularCharacters()
.map { .map {
Character( Character(

View File

@ -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] }
} }
/** /**

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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)