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,16 +64,8 @@ class ChatCharacterController(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인기 캐릭터 조회 (현재는 빈 리스트)
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = service.getPopularCharacters()
|
val popularCharacters = service.getPopularCharacters()
|
||||||
.map {
|
|
||||||
Character(
|
|
||||||
characterId = it.id!!,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최신 캐릭터 조회 (최대 10개)
|
// 최신 캐릭터 조회 (최대 10개)
|
||||||
val newCharacters = service.getNewCharacters(50)
|
val newCharacters = service.getNewCharacters(50)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package kr.co.vividnext.sodalive.chat.character.dto
|
package kr.co.vividnext.sodalive.chat.character.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
data class CharacterMainResponse(
|
data class CharacterMainResponse(
|
||||||
val banners: List<CharacterBannerResponse>,
|
val banners: List<CharacterBannerResponse>,
|
||||||
val recentCharacters: List<RecentCharacter>,
|
val recentCharacters: List<RecentCharacter>,
|
||||||
|
@ -15,10 +17,10 @@ data class CurationSection(
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Character(
|
data class Character(
|
||||||
val characterId: Long,
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
val description: String,
|
@JsonProperty("description") val description: String,
|
||||||
val imageUrl: String
|
@JsonProperty("imageUrl") val imageUrl: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentCharacter(
|
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.ChatCharacterHobby
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
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.ChatCharacterGoalRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
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.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.beans.factory.annotation.Value
|
||||||
|
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 +29,40 @@ 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,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회
|
* 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<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) 유료 우선 사용: 글로벌에 영향 없음
|
// 1) 유료 우선 사용: 글로벌에 영향 없음
|
||||||
if (quota.remainingPaid > 0) {
|
if (quota.remainingPaid > 0) {
|
||||||
quota.remainingPaid -= 1
|
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)
|
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
|
||||||
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
||||||
}
|
}
|
||||||
|
@ -94,16 +98,16 @@ class ChatRoomQuotaService(
|
||||||
val globalFree = globalFreeProvider()
|
val globalFree = globalFreeProvider()
|
||||||
if (globalFree <= 0) {
|
if (globalFree <= 0) {
|
||||||
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
|
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
|
||||||
throw SodaException("무료 쿼터가 소진되었습니다. 글로벌 무료 충전 이후 이용해 주세요.")
|
throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.")
|
||||||
}
|
}
|
||||||
if (quota.remainingFree <= 0) {
|
if (quota.remainingFree <= 0) {
|
||||||
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
|
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
|
||||||
val waitMillis = quota.nextRechargeAt
|
val waitMillis = quota.nextRechargeAt
|
||||||
if (waitMillis != null && waitMillis > nowMillis) {
|
if (waitMillis == null) {
|
||||||
throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 무료 충전 이후 이용해 주세요.")
|
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
|
||||||
} else {
|
|
||||||
throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 잠시 후 다시 시도해 주세요.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
return RedisCacheManager.builder(redisConnectionFactory)
|
||||||
.cacheDefaults(defaultCacheConfig)
|
.cacheDefaults(defaultCacheConfig)
|
||||||
.withInitialCacheConfigurations(cacheConfigMap)
|
.withInitialCacheConfigurations(cacheConfigMap)
|
||||||
|
|
Loading…
Reference in New Issue