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