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,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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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] }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,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)
|
||||
}
|
||||
}
|
|
@ -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("무료 채팅이 모두 소진되었습니다.")
|
||||
}
|
||||
|
||||
// 둘 다 가능 → 차감
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue