From 88e287067b5b099d386f22fcbd3bb340515455d9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Sep 2025 18:37:25 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(character):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EB=90=9C=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=B3=B4=EA=B8=B0=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatCharacterController.kt | 31 ++++++++---- .../repository/ChatCharacterRepository.kt | 17 +++++-- .../character/service/ChatCharacterService.kt | 48 +++++++++++++++++-- 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index aaefbce..388fc86 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -22,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @@ -67,16 +68,11 @@ class ChatCharacterController( // 인기 캐릭터 조회 val popularCharacters = service.getPopularCharacters() - // 최신 캐릭터 조회 (최대 10개) - val newCharacters = service.getNewCharacters(50) - .map { - Character( - characterId = it.id!!, - name = it.name, - description = it.description, - imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" - ) - } + // 최근 등록된 캐릭터 리스트 조회 + val newCharacters = service.getRecentCharacters( + page = 0, + size = 50 + ) // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) val curationSections = curationQueryService.getActiveCurationsWithCharacters() @@ -182,4 +178,19 @@ class ChatCharacterController( ) ) } + + /** + * 최근 등록된 캐릭터 전체보기 + * - 기준: 2주 이내 등록된 캐릭터만 페이징 조회 + * - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공 + */ + @GetMapping("/recent") + fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run { + ApiResponse.ok( + service.getRecentCharacters( + page = page ?: 0, + size = 20 + ) + ) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index d03ee4f..20b74b9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -10,14 +10,25 @@ import org.springframework.stereotype.Repository @Repository interface ChatCharacterRepository : JpaRepository { - fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByName(name: String): ChatCharacter? fun findByIsActiveTrue(pageable: Pageable): Page /** - * 활성화된 캐릭터를 생성일 기준 내림차순으로 조회 + * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 */ - fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List + @Query( + """ + SELECT c FROM ChatCharacter c + WHERE c.isActive = true AND c.createdAt >= :since + ORDER BY c.createdAt DESC + """ + ) + fun findRecentSince(@Param("since") since: java.time.LocalDateTime, pageable: Pageable): Page + + /** + * 2주 이내(파라미터 since 이상) 활성 캐릭터 개수 + */ + fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long /** * 이름, 설명, MBTI, 태그로 캐릭터 검색 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index c72c17a..064d29b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -20,8 +20,10 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepo import org.springframework.beans.factory.annotation.Value import org.springframework.cache.annotation.Cacheable import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime @Service class ChatCharacterService( @@ -66,11 +68,51 @@ class ChatCharacterService( } /** - * 최근 등록된 캐릭터 목록 조회 (최대 10개) + * 최근 등록된 캐릭터 전체보기 (페이징) + * - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터 + * - 2주 이내 캐릭터가 0개라면: 최근 등록한 캐릭터 20개 반환(페이지 무시) */ @Transactional(readOnly = true) - fun getNewCharacters(limit: Int = 10): List { - return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit)) + fun getRecentCharacters(page: Int = 0, size: Int = 20): List { + val safePage = if (page < 0) 0 else page + val safeSize = when { + size <= 0 -> 20 + size > 50 -> 50 // 과도한 page size 방지 + else -> size + } + val since = LocalDateTime.now().minusWeeks(2) + + val totalRecent = chatCharacterRepository.countByIsActiveTrueAndCreatedAtGreaterThanEqual(since) + if (totalRecent == 0L) { + if (safePage > 0) { + return emptyList() + } + + val fallback = chatCharacterRepository.findByIsActiveTrue( + PageRequest.of(0, 20, Sort.by("createdAt").descending()) + ) + return fallback.content.map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + } + + val pageResult = chatCharacterRepository.findRecentSince( + since, + PageRequest.of(safePage, safeSize) + ) + return pageResult.content.map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } } /** From 3dc9dd1f35ad73a7685541b7bc24d92af1d7b533 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Sep 2025 19:00:45 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(character):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EB=90=9C=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=B3=B4=EA=B8=B0=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반환 값에 전체 개수 추가 --- .../controller/ChatCharacterController.kt | 6 ++--- .../character/dto/RecentCharactersResponse.kt | 9 +++++++ .../character/service/ChatCharacterService.kt | 26 ++++++++++++++----- 3 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/RecentCharactersResponse.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 388fc86..d86ac8d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -69,10 +69,10 @@ class ChatCharacterController( val popularCharacters = service.getPopularCharacters() // 최근 등록된 캐릭터 리스트 조회 - val newCharacters = service.getRecentCharacters( + val newCharacters = service.getRecentCharactersPage( page = 0, size = 50 - ) + ).content // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) val curationSections = curationQueryService.getActiveCurationsWithCharacters() @@ -187,7 +187,7 @@ class ChatCharacterController( @GetMapping("/recent") fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run { ApiResponse.ok( - service.getRecentCharacters( + service.getRecentCharactersPage( page = page ?: 0, size = 20 ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/RecentCharactersResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/RecentCharactersResponse.kt new file mode 100644 index 0000000..d1628a7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/RecentCharactersResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.chat.character.dto + +/** + * 최근 등록된 캐릭터 전체보기 페이지 응답 DTO + */ +data class RecentCharactersResponse( + val totalCount: Long, + val content: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 064d29b..ea0f5c1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -12,6 +12,7 @@ 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.dto.RecentCharactersResponse 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 @@ -68,12 +69,12 @@ class ChatCharacterService( } /** - * 최근 등록된 캐릭터 전체보기 (페이징) + * 최근 등록된 캐릭터 전체보기 (페이징) - 전체 개수 포함 * - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터 - * - 2주 이내 캐릭터가 0개라면: 최근 등록한 캐릭터 20개 반환(페이지 무시) + * - 2주 이내 캐릭터가 0개라면: totalCount=20, 첫 페이지는 최근 등록 활성 캐릭터 20개, 그 외 페이지는 빈 리스트 */ @Transactional(readOnly = true) - fun getRecentCharacters(page: Int = 0, size: Int = 20): List { + fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse { val safePage = if (page < 0) 0 else page val safeSize = when { size <= 0 -> 20 @@ -85,13 +86,15 @@ class ChatCharacterService( val totalRecent = chatCharacterRepository.countByIsActiveTrueAndCreatedAtGreaterThanEqual(since) if (totalRecent == 0L) { if (safePage > 0) { - return emptyList() + return RecentCharactersResponse( + totalCount = 20, + content = emptyList() + ) } - val fallback = chatCharacterRepository.findByIsActiveTrue( PageRequest.of(0, 20, Sort.by("createdAt").descending()) ) - return fallback.content.map { + val content = fallback.content.map { Character( characterId = it.id!!, name = it.name, @@ -99,13 +102,17 @@ class ChatCharacterService( imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" ) } + return RecentCharactersResponse( + totalCount = 20, + content = content + ) } val pageResult = chatCharacterRepository.findRecentSince( since, PageRequest.of(safePage, safeSize) ) - return pageResult.content.map { + val content = pageResult.content.map { Character( characterId = it.id!!, name = it.name, @@ -113,6 +120,11 @@ class ChatCharacterService( imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" ) } + + return RecentCharactersResponse( + totalCount = totalRecent, + content = content + ) } /** From eec63cc7b264b54a32343922bd3a209663b84c5b Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Sep 2025 02:00:30 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(admin-chat-calculate):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=EB=B3=84=20=EC=A0=95=EC=82=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calculate/AdminChatCalculateController.kt | 32 +++++++++ .../AdminChatCalculateQueryRepository.kt | 71 +++++++++++++++++++ .../calculate/AdminChatCalculateService.kt | 41 +++++++++++ .../calculate/ChatCharacterCalculateDtos.kt | 52 ++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateController.kt new file mode 100644 index 0000000..2f7393b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateController.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.admin.chat.calculate + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/chat/calculate") +class AdminChatCalculateController( + private val service: AdminChatCalculateService +) { + @GetMapping("/characters") + fun getCharacterCalculate( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort, + pageable: Pageable + ) = ApiResponse.ok( + service.getCharacterCalculate( + startDateStr, + endDateStr, + sort, + pageable.offset, + pageable.pageSize + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt new file mode 100644 index 0000000..241310c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt @@ -0,0 +1,71 @@ +package kr.co.vividnext.sodalive.admin.chat.calculate + +import com.querydsl.core.types.dsl.CaseBuilder +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter +import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class AdminChatCalculateQueryRepository( + private val queryFactory: JPAQueryFactory +) { + fun getCharacterCalculate( + startUtc: LocalDateTime, + endInclusiveUtc: LocalDateTime, + sort: ChatCharacterCalculateSort, + offset: Long, + limit: Long + ): List { + val imageCanExpr = CaseBuilder() + .`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE)) + .then(useCan.can.add(useCan.rewardCan)) + .otherwise(0) + + val messageCanExpr = CaseBuilder() + .`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE)) + .then(useCan.can.add(useCan.rewardCan)) + .otherwise(0) + + val imageSum = imageCanExpr.sum() + val messageSum = messageCanExpr.sum() + val totalSum = imageSum.add(messageSum) + + val query = queryFactory + .select( + QChatCharacterCalculateQueryData( + chatCharacter.id, + chatCharacter.name, + chatCharacter.imagePath, + imageSum, + messageSum + ) + ) + .from(useCan) + .innerJoin(useCan.characterImage, characterImage) + .innerJoin(characterImage.chatCharacter, chatCharacter) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.`in`(CanUsage.CHARACTER_IMAGE_PURCHASE, CanUsage.CHAT_MESSAGE_PURCHASE)) + .and(useCan.createdAt.goe(startUtc)) + .and(useCan.createdAt.loe(endInclusiveUtc)) + ) + .groupBy(chatCharacter.id, chatCharacter.name, chatCharacter.imagePath) + + when (sort) { + ChatCharacterCalculateSort.TOTAL_SALES_DESC -> + query.orderBy(totalSum.desc(), chatCharacter.id.desc()) + + ChatCharacterCalculateSort.LATEST_DESC -> + query.orderBy(chatCharacter.id.desc(), totalSum.desc()) + } + + return query + .offset(offset) + .limit(limit) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt new file mode 100644 index 0000000..39aad0d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.admin.chat.calculate + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +class AdminChatCalculateService( + private val repository: AdminChatCalculateQueryRepository +) { + private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val kstZone: ZoneId = ZoneId.of("Asia/Seoul") + + @Transactional(readOnly = true) + fun getCharacterCalculate( + startDateStr: String, + endDateStr: String, + sort: ChatCharacterCalculateSort, + offset: Long, + pageSize: Int + ): List { + // 날짜 유효성 검증 (KST 기준) + val startDate = LocalDate.parse(startDateStr, dateFormatter) + val endDate = LocalDate.parse(endDateStr, dateFormatter) + val todayKst = LocalDate.now(kstZone) + + require(!endDate.isAfter(todayKst)) { "끝 날짜는 오늘 날짜까지만 입력 가능합니다." } + require(!startDate.isAfter(endDate)) { "시작 날짜는 끝 날짜보다 이후일 수 없습니다." } + require(!endDate.isAfter(startDate.plusMonths(6))) { "조회 가능 기간은 최대 6개월입니다." } + + // 입력은 KST, UTC로 변환해 [start, end(미포함)] 조회 + val startUtc = startDateStr.convertLocalDateTime() + val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + + val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong()) + return rows.map { it.toItem() } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt new file mode 100644 index 0000000..17076a9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt @@ -0,0 +1,52 @@ +package kr.co.vividnext.sodalive.admin.chat.calculate + +import com.fasterxml.jackson.annotation.JsonProperty +import com.querydsl.core.annotations.QueryProjection +import java.math.BigDecimal +import java.math.RoundingMode + +// 정렬 옵션 +enum class ChatCharacterCalculateSort { + TOTAL_SALES_DESC, + LATEST_DESC +} + +// QueryDSL 프로젝션용 DTO +data class ChatCharacterCalculateQueryData @QueryProjection constructor( + val characterId: Long, + val characterName: String, + val characterImagePath: String?, + val imagePurchaseCan: Int?, + val messagePurchaseCan: Int? +) + +// 응답 DTO +data class ChatCharacterCalculateItem( + @JsonProperty("characterId") val characterId: Long, + @JsonProperty("characterImage") val characterImage: String?, + @JsonProperty("name") val name: String, + @JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int, + @JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int, + @JsonProperty("totalCan") val totalCan: Int, + @JsonProperty("totalKrw") val totalKrw: Int, + @JsonProperty("settlementKrw") val settlementKrw: Int +) + +fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem { + val image = imagePurchaseCan ?: 0 + val message = messagePurchaseCan ?: 0 + val total = image + message + val totalKrw = BigDecimal(total).multiply(BigDecimal(100)) + val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP) + + return ChatCharacterCalculateItem( + characterId = characterId, + characterImage = characterImagePath, + name = characterName, + imagePurchaseCan = image, + messagePurchaseCan = message, + totalCan = total, + totalKrw = totalKrw.toInt(), + settlementKrw = settlement.toInt() + ) +} From b752434fbbb922f869d5a86eb7d73fe5d86e421d Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Sep 2025 03:06:55 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(admin-chat-calculate):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=A0=95=EC=82=B0=20API=EC=97=90=20totalC?= =?UTF-8?q?ount=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminChatCalculateQueryRepository.kt | 20 +++++++++++++++++++ .../calculate/AdminChatCalculateService.kt | 6 ++++-- .../calculate/ChatCharacterCalculateDtos.kt | 8 +++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt index 241310c..402a07e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt @@ -68,4 +68,24 @@ class AdminChatCalculateQueryRepository( .limit(limit) .fetch() } + + fun getCharacterCalculateTotalCount( + startUtc: LocalDateTime, + endInclusiveUtc: LocalDateTime + ): Int { + return queryFactory + .select(chatCharacter.id) + .from(useCan) + .innerJoin(useCan.characterImage, characterImage) + .innerJoin(characterImage.chatCharacter, chatCharacter) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.`in`(CanUsage.CHARACTER_IMAGE_PURCHASE, CanUsage.CHAT_MESSAGE_PURCHASE)) + .and(useCan.createdAt.goe(startUtc)) + .and(useCan.createdAt.loe(endInclusiveUtc)) + ) + .groupBy(chatCharacter.id) + .fetch() + .size + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt index 39aad0d..e9b95c4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt @@ -21,7 +21,7 @@ class AdminChatCalculateService( sort: ChatCharacterCalculateSort, offset: Long, pageSize: Int - ): List { + ): ChatCharacterCalculateResponse { // 날짜 유효성 검증 (KST 기준) val startDate = LocalDate.parse(startDateStr, dateFormatter) val endDate = LocalDate.parse(endDateStr, dateFormatter) @@ -35,7 +35,9 @@ class AdminChatCalculateService( val startUtc = startDateStr.convertLocalDateTime() val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc) val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong()) - return rows.map { it.toItem() } + val items = rows.map { it.toItem() } + return ChatCharacterCalculateResponse(totalCount, items) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt index 17076a9..8fa38b7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt @@ -20,7 +20,7 @@ data class ChatCharacterCalculateQueryData @QueryProjection constructor( val messagePurchaseCan: Int? ) -// 응답 DTO +// 응답 DTO (아이템) data class ChatCharacterCalculateItem( @JsonProperty("characterId") val characterId: Long, @JsonProperty("characterImage") val characterImage: String?, @@ -32,6 +32,12 @@ data class ChatCharacterCalculateItem( @JsonProperty("settlementKrw") val settlementKrw: Int ) +// 응답 DTO (전체) +data class ChatCharacterCalculateResponse( + @JsonProperty("totalCount") val totalCount: Int, + @JsonProperty("items") val items: List +) + fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem { val image = imagePurchaseCan ?: 0 val message = messagePurchaseCan ?: 0 From 0ed29c609778ee8e2278e351a08abe76ea8923dd Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Sep 2025 03:20:26 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(admin-chat-calculate):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=A0=95=EC=82=B0=20API=EC=97=90=20imageP?= =?UTF-8?q?ath=EB=A5=BC=20imageHost=EB=A5=BC=20=ED=8F=AC=ED=95=A8=ED=95=9C?= =?UTF-8?q?=20url=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/calculate/AdminChatCalculateQueryRepository.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt index 402a07e..d285902 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt @@ -6,12 +6,16 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @Repository class AdminChatCalculateQueryRepository( - private val queryFactory: JPAQueryFactory + private val queryFactory: JPAQueryFactory, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String ) { fun getCharacterCalculate( startUtc: LocalDateTime, @@ -39,7 +43,7 @@ class AdminChatCalculateQueryRepository( QChatCharacterCalculateQueryData( chatCharacter.id, chatCharacter.name, - chatCharacter.imagePath, + chatCharacter.imagePath.prepend("/").prepend(imageHost), imageSum, messageSum ) From 634bf759cab7fdf5f4ac39b37a13edc0a3852767 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Sep 2025 03:54:24 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(admin-chat-calculate):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=A0=95=EC=82=B0=20API=EC=97=90=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=ED=9A=9F=EC=88=98=20=EA=B5=AC=EB=A7=A4(CH?= =?UTF-8?q?AT=5FQUOTA=5FPURCHASE)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminChatCalculateQueryRepository.kt | 71 ++++++++++++++----- .../calculate/AdminChatCalculateService.kt | 14 ++-- .../calculate/ChatCharacterCalculateDtos.kt | 8 ++- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt index d285902..3eecc5e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt @@ -1,10 +1,11 @@ package kr.co.vividnext.sodalive.admin.chat.calculate +import com.querydsl.core.types.Projections import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan -import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter +import kr.co.vividnext.sodalive.chat.character.QChatCharacter import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository @@ -34,37 +35,60 @@ class AdminChatCalculateQueryRepository( .then(useCan.can.add(useCan.rewardCan)) .otherwise(0) + val quotaCanExpr = CaseBuilder() + .`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE)) + .then(useCan.can.add(useCan.rewardCan)) + .otherwise(0) + val imageSum = imageCanExpr.sum() val messageSum = messageCanExpr.sum() - val totalSum = imageSum.add(messageSum) + val quotaSum = quotaCanExpr.sum() + val totalSum = imageSum.add(messageSum).add(quotaSum) + + // 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2) + val c1 = QChatCharacter("c1") + val c2 = QChatCharacter("c2") + + val characterIdExpr = c1.id.coalesce(c2.id) + val characterNameExpr = c1.name.coalesce(c2.name) + val characterImagePathExpr = c1.imagePath.coalesce(c2.imagePath) val query = queryFactory .select( - QChatCharacterCalculateQueryData( - chatCharacter.id, - chatCharacter.name, - chatCharacter.imagePath.prepend("/").prepend(imageHost), + Projections.constructor( + ChatCharacterCalculateQueryData::class.java, + characterIdExpr, + characterNameExpr, + characterImagePathExpr.prepend("/").prepend(imageHost), imageSum, - messageSum + messageSum, + quotaSum ) ) .from(useCan) - .innerJoin(useCan.characterImage, characterImage) - .innerJoin(characterImage.chatCharacter, chatCharacter) + .leftJoin(useCan.characterImage, characterImage) + .leftJoin(characterImage.chatCharacter, c1) + .leftJoin(c2).on(c2.id.eq(useCan.characterId)) .where( useCan.isRefund.isFalse - .and(useCan.canUsage.`in`(CanUsage.CHARACTER_IMAGE_PURCHASE, CanUsage.CHAT_MESSAGE_PURCHASE)) + .and( + useCan.canUsage.`in`( + CanUsage.CHARACTER_IMAGE_PURCHASE, + CanUsage.CHAT_MESSAGE_PURCHASE, + CanUsage.CHAT_QUOTA_PURCHASE + ) + ) .and(useCan.createdAt.goe(startUtc)) .and(useCan.createdAt.loe(endInclusiveUtc)) ) - .groupBy(chatCharacter.id, chatCharacter.name, chatCharacter.imagePath) + .groupBy(characterIdExpr, characterNameExpr, characterImagePathExpr) when (sort) { ChatCharacterCalculateSort.TOTAL_SALES_DESC -> - query.orderBy(totalSum.desc(), chatCharacter.id.desc()) + query.orderBy(totalSum.desc(), characterIdExpr.desc()) ChatCharacterCalculateSort.LATEST_DESC -> - query.orderBy(chatCharacter.id.desc(), totalSum.desc()) + query.orderBy(characterIdExpr.desc(), totalSum.desc()) } return query @@ -77,18 +101,29 @@ class AdminChatCalculateQueryRepository( startUtc: LocalDateTime, endInclusiveUtc: LocalDateTime ): Int { + val c1 = QChatCharacter("c1") + val c2 = QChatCharacter("c2") + val characterIdExpr = c1.id.coalesce(c2.id) + return queryFactory - .select(chatCharacter.id) + .select(characterIdExpr) .from(useCan) - .innerJoin(useCan.characterImage, characterImage) - .innerJoin(characterImage.chatCharacter, chatCharacter) + .leftJoin(useCan.characterImage, characterImage) + .leftJoin(characterImage.chatCharacter, c1) + .leftJoin(c2).on(c2.id.eq(useCan.characterId)) .where( useCan.isRefund.isFalse - .and(useCan.canUsage.`in`(CanUsage.CHARACTER_IMAGE_PURCHASE, CanUsage.CHAT_MESSAGE_PURCHASE)) + .and( + useCan.canUsage.`in`( + CanUsage.CHARACTER_IMAGE_PURCHASE, + CanUsage.CHAT_MESSAGE_PURCHASE, + CanUsage.CHAT_QUOTA_PURCHASE + ) + ) .and(useCan.createdAt.goe(startUtc)) .and(useCan.createdAt.loe(endInclusiveUtc)) ) - .groupBy(chatCharacter.id) + .groupBy(characterIdExpr) .fetch() .size } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt index e9b95c4..34593e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.admin.chat.calculate +import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -27,11 +28,16 @@ class AdminChatCalculateService( val endDate = LocalDate.parse(endDateStr, dateFormatter) val todayKst = LocalDate.now(kstZone) - require(!endDate.isAfter(todayKst)) { "끝 날짜는 오늘 날짜까지만 입력 가능합니다." } - require(!startDate.isAfter(endDate)) { "시작 날짜는 끝 날짜보다 이후일 수 없습니다." } - require(!endDate.isAfter(startDate.plusMonths(6))) { "조회 가능 기간은 최대 6개월입니다." } + if (endDate.isAfter(todayKst)) { + throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.") + } + if (startDate.isAfter(endDate)) { + throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.") + } + if (endDate.isAfter(startDate.plusMonths(6))) { + throw SodaException("조회 가능 기간은 최대 6개월입니다.") + } - // 입력은 KST, UTC로 변환해 [start, end(미포함)] 조회 val startUtc = startDateStr.convertLocalDateTime() val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt index 8fa38b7..28218f9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/ChatCharacterCalculateDtos.kt @@ -17,7 +17,8 @@ data class ChatCharacterCalculateQueryData @QueryProjection constructor( val characterName: String, val characterImagePath: String?, val imagePurchaseCan: Int?, - val messagePurchaseCan: Int? + val messagePurchaseCan: Int?, + val quotaPurchaseCan: Int? ) // 응답 DTO (아이템) @@ -27,6 +28,7 @@ data class ChatCharacterCalculateItem( @JsonProperty("name") val name: String, @JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int, @JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int, + @JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int, @JsonProperty("totalCan") val totalCan: Int, @JsonProperty("totalKrw") val totalKrw: Int, @JsonProperty("settlementKrw") val settlementKrw: Int @@ -41,7 +43,8 @@ data class ChatCharacterCalculateResponse( fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem { val image = imagePurchaseCan ?: 0 val message = messagePurchaseCan ?: 0 - val total = image + message + val quota = quotaPurchaseCan ?: 0 + val total = image + message + quota val totalKrw = BigDecimal(total).multiply(BigDecimal(100)) val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP) @@ -51,6 +54,7 @@ fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem { name = characterName, imagePurchaseCan = image, messagePurchaseCan = message, + quotaPurchaseCan = quota, totalCan = total, totalKrw = totalKrw.toInt(), settlementKrw = settlement.toInt() From e33e3b43b7c85b271844dd27ce3d2963d7b23b39 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Sep 2025 04:33:01 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix(admin-chat-calculate):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=A0=95=EC=82=B0=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ONLY_FULL_GROUP_BY 대응 --- .../AdminChatCalculateQueryRepository.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt index 3eecc5e..defc36b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.chat.calculate import com.querydsl.core.types.Projections import com.querydsl.core.types.dsl.CaseBuilder +import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan @@ -50,16 +51,25 @@ class AdminChatCalculateQueryRepository( val c2 = QChatCharacter("c2") val characterIdExpr = c1.id.coalesce(c2.id) - val characterNameExpr = c1.name.coalesce(c2.name) - val characterImagePathExpr = c1.imagePath.coalesce(c2.imagePath) + // ONLY_FULL_GROUP_BY 대응: name/imagePath는 집계 함수로 선택 + val characterNameAgg = Expressions.stringTemplate( + "coalesce(max({0}), max({1}))", + c1.name, + c2.name + ) + val characterImagePathAgg = Expressions.stringTemplate( + "coalesce(max({0}), max({1}))", + c1.imagePath, + c2.imagePath + ) val query = queryFactory .select( Projections.constructor( ChatCharacterCalculateQueryData::class.java, characterIdExpr, - characterNameExpr, - characterImagePathExpr.prepend("/").prepend(imageHost), + characterNameAgg, + characterImagePathAgg.prepend("/").prepend(imageHost), imageSum, messageSum, quotaSum @@ -81,7 +91,7 @@ class AdminChatCalculateQueryRepository( .and(useCan.createdAt.goe(startUtc)) .and(useCan.createdAt.loe(endInclusiveUtc)) ) - .groupBy(characterIdExpr, characterNameExpr, characterImagePathExpr) + .groupBy(characterIdExpr) when (sort) { ChatCharacterCalculateSort.TOTAL_SALES_DESC -> From a07407417cc411606fa4ce059076521469128ed7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Sep 2025 05:01:52 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(admin-chat-calculate):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=A0=95=EC=82=B0=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ONLY_FULL_GROUP_BY 대응 - c2.image_path 집계식 적용 --- .../admin/chat/calculate/AdminChatCalculateQueryRepository.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt index defc36b..078abc2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateQueryRepository.kt @@ -51,9 +51,8 @@ class AdminChatCalculateQueryRepository( val c2 = QChatCharacter("c2") val characterIdExpr = c1.id.coalesce(c2.id) - // ONLY_FULL_GROUP_BY 대응: name/imagePath는 집계 함수로 선택 val characterNameAgg = Expressions.stringTemplate( - "coalesce(max({0}), max({1}))", + "coalesce(max({0}), max({1}), '')", c1.name, c2.name ) From dd0a1c22935d9da2a3546baf1322ea7214f26281 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 14 Sep 2025 16:46:56 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix(chat-character):=20=EC=9D=B8=EA=B8=B0?= =?UTF-8?q?=20=EC=BA=90=EB=A6=AD=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐시 제거 --- .../chat/character/service/ChatCharacterService.kt | 5 ----- .../kr/co/vividnext/sodalive/configs/RedisConfig.kt | 10 ---------- 2 files changed, 15 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index ea0f5c1..e48bf21 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -19,7 +19,6 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepositor 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.data.domain.Sort import org.springframework.stereotype.Service @@ -43,10 +42,6 @@ class ChatCharacterService( * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용 */ @Transactional(readOnly = true) - @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 { val window = RankingWindowCalculator.now("popular-chat-character") val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt index eea5eab..04a6c2c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt @@ -123,16 +123,6 @@ 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) From 4adc3e127cddaca6c717794b4c089408cdfb11d7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 14 Sep 2025 17:28:33 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(popular):=20=EC=9D=BC=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EC=9C=88=EB=8F=84=EC=9A=B0=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EB=82=A0=20=EC=99=84=EB=A3=8C=20=EA=B5=AC=EA=B0=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UTC 20:00 경계 직후에도 [전날 20:00, 당일 20:00) 범위 사용으로 일일 순위 정확화 - RankingWindowCalculator.now(): lastBoundary 기반 [start, endExclusive) 계산 --- .../service/RankingWindowCalculator.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt index 8057d85..56dad23 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt @@ -23,16 +23,24 @@ object RankingWindowCalculator { 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) + + // 일일 순위는 "전날" 완료 구간을 보여주기 위해, 언제든 직전 경계까지만 집계한다. + // 예) 2025-09-14 20:00:00 직후에도 [2025-09-13 20:00, 2025-09-14 20:00) 윈도우를 사용 + val lastBoundary = if (now.isBefore(todayBoundary)) { + // 아직 오늘 20:00 이전이면, 직전 경계는 어제 20:00 + todayBoundary.minusDays(1) } else { - val next = todayBoundary.plusDays(1) - Triple(todayBoundary, next, next) + // 오늘 20:00을 지났거나 같으면, 직전 경계는 오늘 20:00 + todayBoundary } + + val start = lastBoundary.minusDays(1) + val endExclusive = lastBoundary + val windowStart = start.toInstant() - val windowEnd = endExclusive.minusNanos(1).toInstant() // [start, end] + val windowEnd = endExclusive.minusSeconds(1).toInstant() // [start, end] val cacheKey = "$prefix:${windowStart.epochSecond}" - return RankingWindow(windowStart, windowEnd, nextBoundary.toInstant(), cacheKey) + // nextBoundary 필드는 기존 시그니처 유지를 위해 endExclusive(=lastBoundary)를 그대로 전달한다. + return RankingWindow(windowStart, windowEnd, endExclusive.toInstant(), cacheKey) } } From 0574f4f6290ec365c2308e37ae3fc70cae1fa23c Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 14 Sep 2025 17:43:53 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat(cache):=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A1=B0=ED=9A=8C=EC=97=90=20?= =?UTF-8?q?=EC=9C=88=EB=8F=84=EC=9A=B0=20=EA=B8=B0=EB=B0=98=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EC=BA=90=EC=8B=9C=20=ED=82=A4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatCharacterService.getPopularCharacters()에 @Cacheable 추가 - 키: popular-chat-character:: - 윈도우(매일 20:00 UTC) 전환 시 자동으로 신규 키 사용 → 전일 순위 캐시와 분리 보장 Why: 동일 윈도우 내 반복 요청의 DB 부하를 줄이고, 경계 전환 시 자연스러운 캐시 갱신을 보장. --- .../chat/character/service/ChatCharacterService.kt | 5 +++++ .../kr/co/vividnext/sodalive/configs/RedisConfig.kt | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index e48bf21..ea0f5c1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepositor 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.data.domain.Sort import org.springframework.stereotype.Service @@ -42,6 +43,10 @@ class ChatCharacterService( * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용 */ @Transactional(readOnly = true) + @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 { val window = RankingWindowCalculator.now("popular-chat-character") val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt index 04a6c2c..eea5eab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt @@ -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)