| @@ -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) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user