Merge pull request 'test' (#339) from test into main

Reviewed-on: #339
This commit is contained in:
klaus 2025-09-11 17:05:45 +00:00
commit eb18e2d009
8 changed files with 152 additions and 25 deletions

View File

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

View File

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

View File

@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.dto
import com.fasterxml.jackson.annotation.JsonProperty
data class CharacterMainResponse(
val banners: List<CharacterBannerResponse>,
val recentCharacters: List<RecentCharacter>,
@ -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(

View File

@ -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<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<Character> {
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<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,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)
}
}

View File

@ -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("무료 채팅이 모두 소진되었습니다.")
}
// 둘 다 가능 → 차감

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)
.cacheDefaults(defaultCacheConfig)
.withInitialCacheConfigurations(cacheConfigMap)