| @@ -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 | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,139 @@ | |||||||
|  | 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 | ||||||
|  | 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 | ||||||
|  | import java.time.LocalDateTime | ||||||
|  |  | ||||||
|  | @Repository | ||||||
|  | class AdminChatCalculateQueryRepository( | ||||||
|  |     private val queryFactory: JPAQueryFactory, | ||||||
|  |  | ||||||
|  |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|  |     private val imageHost: String | ||||||
|  | ) { | ||||||
|  |     fun getCharacterCalculate( | ||||||
|  |         startUtc: LocalDateTime, | ||||||
|  |         endInclusiveUtc: LocalDateTime, | ||||||
|  |         sort: ChatCharacterCalculateSort, | ||||||
|  |         offset: Long, | ||||||
|  |         limit: Long | ||||||
|  |     ): List<ChatCharacterCalculateQueryData> { | ||||||
|  |         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 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 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 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, | ||||||
|  |                     characterNameAgg, | ||||||
|  |                     characterImagePathAgg.prepend("/").prepend(imageHost), | ||||||
|  |                     imageSum, | ||||||
|  |                     messageSum, | ||||||
|  |                     quotaSum | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             .from(useCan) | ||||||
|  |             .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, | ||||||
|  |                             CanUsage.CHAT_QUOTA_PURCHASE | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .and(useCan.createdAt.goe(startUtc)) | ||||||
|  |                     .and(useCan.createdAt.loe(endInclusiveUtc)) | ||||||
|  |             ) | ||||||
|  |             .groupBy(characterIdExpr) | ||||||
|  |  | ||||||
|  |         when (sort) { | ||||||
|  |             ChatCharacterCalculateSort.TOTAL_SALES_DESC -> | ||||||
|  |                 query.orderBy(totalSum.desc(), characterIdExpr.desc()) | ||||||
|  |  | ||||||
|  |             ChatCharacterCalculateSort.LATEST_DESC -> | ||||||
|  |                 query.orderBy(characterIdExpr.desc(), totalSum.desc()) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return query | ||||||
|  |             .offset(offset) | ||||||
|  |             .limit(limit) | ||||||
|  |             .fetch() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getCharacterCalculateTotalCount( | ||||||
|  |         startUtc: LocalDateTime, | ||||||
|  |         endInclusiveUtc: LocalDateTime | ||||||
|  |     ): Int { | ||||||
|  |         val c1 = QChatCharacter("c1") | ||||||
|  |         val c2 = QChatCharacter("c2") | ||||||
|  |         val characterIdExpr = c1.id.coalesce(c2.id) | ||||||
|  |  | ||||||
|  |         return queryFactory | ||||||
|  |             .select(characterIdExpr) | ||||||
|  |             .from(useCan) | ||||||
|  |             .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, | ||||||
|  |                             CanUsage.CHAT_QUOTA_PURCHASE | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .and(useCan.createdAt.goe(startUtc)) | ||||||
|  |                     .and(useCan.createdAt.loe(endInclusiveUtc)) | ||||||
|  |             ) | ||||||
|  |             .groupBy(characterIdExpr) | ||||||
|  |             .fetch() | ||||||
|  |             .size | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,49 @@ | |||||||
|  | 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 | ||||||
|  | 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 | ||||||
|  |     ): ChatCharacterCalculateResponse { | ||||||
|  |         // 날짜 유효성 검증 (KST 기준) | ||||||
|  |         val startDate = LocalDate.parse(startDateStr, dateFormatter) | ||||||
|  |         val endDate = LocalDate.parse(endDateStr, dateFormatter) | ||||||
|  |         val todayKst = LocalDate.now(kstZone) | ||||||
|  |  | ||||||
|  |         if (endDate.isAfter(todayKst)) { | ||||||
|  |             throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.") | ||||||
|  |         } | ||||||
|  |         if (startDate.isAfter(endDate)) { | ||||||
|  |             throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.") | ||||||
|  |         } | ||||||
|  |         if (endDate.isAfter(startDate.plusMonths(6))) { | ||||||
|  |             throw SodaException("조회 가능 기간은 최대 6개월입니다.") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         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()) | ||||||
|  |         val items = rows.map { it.toItem() } | ||||||
|  |         return ChatCharacterCalculateResponse(totalCount, items) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,62 @@ | |||||||
|  | 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?, | ||||||
|  |     val quotaPurchaseCan: 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("quotaPurchaseCan") val quotaPurchaseCan: Int, | ||||||
|  |     @JsonProperty("totalCan") val totalCan: Int, | ||||||
|  |     @JsonProperty("totalKrw") val totalKrw: Int, | ||||||
|  |     @JsonProperty("settlementKrw") val settlementKrw: Int | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // 응답 DTO (전체) | ||||||
|  | data class ChatCharacterCalculateResponse( | ||||||
|  |     @JsonProperty("totalCount") val totalCount: Int, | ||||||
|  |     @JsonProperty("items") val items: List<ChatCharacterCalculateItem> | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem { | ||||||
|  |     val image = imagePurchaseCan ?: 0 | ||||||
|  |     val message = messagePurchaseCan ?: 0 | ||||||
|  |     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) | ||||||
|  |  | ||||||
|  |     return ChatCharacterCalculateItem( | ||||||
|  |         characterId = characterId, | ||||||
|  |         characterImage = characterImagePath, | ||||||
|  |         name = characterName, | ||||||
|  |         imagePurchaseCan = image, | ||||||
|  |         messagePurchaseCan = message, | ||||||
|  |         quotaPurchaseCan = quota, | ||||||
|  |         totalCan = total, | ||||||
|  |         totalKrw = totalKrw.toInt(), | ||||||
|  |         settlementKrw = settlement.toInt() | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -22,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal | |||||||
| import org.springframework.web.bind.annotation.GetMapping | import org.springframework.web.bind.annotation.GetMapping | ||||||
| import org.springframework.web.bind.annotation.PathVariable | import org.springframework.web.bind.annotation.PathVariable | ||||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestParam | ||||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||||
|  |  | ||||||
| @RestController | @RestController | ||||||
| @@ -67,16 +68,11 @@ class ChatCharacterController( | |||||||
|         // 인기 캐릭터 조회 |         // 인기 캐릭터 조회 | ||||||
|         val popularCharacters = service.getPopularCharacters() |         val popularCharacters = service.getPopularCharacters() | ||||||
|  |  | ||||||
|         // 최신 캐릭터 조회 (최대 10개) |         // 최근 등록된 캐릭터 리스트 조회 | ||||||
|         val newCharacters = service.getNewCharacters(50) |         val newCharacters = service.getRecentCharactersPage( | ||||||
|             .map { |             page = 0, | ||||||
|                 Character( |             size = 50 | ||||||
|                     characterId = it.id!!, |         ).content | ||||||
|                     name = it.name, |  | ||||||
|                     description = it.description, |  | ||||||
|                     imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|         // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) |         // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) | ||||||
|         val curationSections = curationQueryService.getActiveCurationsWithCharacters() |         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.getRecentCharactersPage( | ||||||
|  |                 page = page ?: 0, | ||||||
|  |                 size = 20 | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.dto | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 최근 등록된 캐릭터 전체보기 페이지 응답 DTO | ||||||
|  |  */ | ||||||
|  | data class RecentCharactersResponse( | ||||||
|  |     val totalCount: Long, | ||||||
|  |     val content: List<Character> | ||||||
|  | ) | ||||||
| @@ -10,14 +10,25 @@ import org.springframework.stereotype.Repository | |||||||
|  |  | ||||||
| @Repository | @Repository | ||||||
| interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | ||||||
|     fun findByCharacterUUID(characterUUID: String): ChatCharacter? |  | ||||||
|     fun findByName(name: String): ChatCharacter? |     fun findByName(name: String): ChatCharacter? | ||||||
|     fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter> |     fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter> | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 활성화된 캐릭터를 생성일 기준 내림차순으로 조회 |      * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 | ||||||
|      */ |      */ | ||||||
|     fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter> |     @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<ChatCharacter> | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 2주 이내(파라미터 since 이상) 활성 캐릭터 개수 | ||||||
|  |      */ | ||||||
|  |     fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 이름, 설명, MBTI, 태그로 캐릭터 검색 |      * 이름, 설명, MBTI, 태그로 캐릭터 검색 | ||||||
|   | |||||||
| @@ -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.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.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.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 | ||||||
| @@ -20,8 +21,10 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepo | |||||||
| import org.springframework.beans.factory.annotation.Value | import org.springframework.beans.factory.annotation.Value | ||||||
| import org.springframework.cache.annotation.Cacheable | import org.springframework.cache.annotation.Cacheable | ||||||
| import org.springframework.data.domain.PageRequest | import org.springframework.data.domain.PageRequest | ||||||
|  | import org.springframework.data.domain.Sort | ||||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||||
|  | import java.time.LocalDateTime | ||||||
|  |  | ||||||
| @Service | @Service | ||||||
| class ChatCharacterService( | class ChatCharacterService( | ||||||
| @@ -66,11 +69,62 @@ class ChatCharacterService( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 최근 등록된 캐릭터 목록 조회 (최대 10개) |      * 최근 등록된 캐릭터 전체보기 (페이징) - 전체 개수 포함 | ||||||
|  |      * - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터 | ||||||
|  |      * - 2주 이내 캐릭터가 0개라면: totalCount=20, 첫 페이지는 최근 등록 활성 캐릭터 20개, 그 외 페이지는 빈 리스트 | ||||||
|      */ |      */ | ||||||
|     @Transactional(readOnly = true) |     @Transactional(readOnly = true) | ||||||
|     fun getNewCharacters(limit: Int = 10): List<ChatCharacter> { |     fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse { | ||||||
|         return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit)) |         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 RecentCharactersResponse( | ||||||
|  |                     totalCount = 20, | ||||||
|  |                     content = emptyList() | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |             val fallback = chatCharacterRepository.findByIsActiveTrue( | ||||||
|  |                 PageRequest.of(0, 20, Sort.by("createdAt").descending()) | ||||||
|  |             ) | ||||||
|  |             val content = fallback.content.map { | ||||||
|  |                 Character( | ||||||
|  |                     characterId = it.id!!, | ||||||
|  |                     name = it.name, | ||||||
|  |                     description = it.description, | ||||||
|  |                     imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |             return RecentCharactersResponse( | ||||||
|  |                 totalCount = 20, | ||||||
|  |                 content = content | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val pageResult = chatCharacterRepository.findRecentSince( | ||||||
|  |             since, | ||||||
|  |             PageRequest.of(safePage, safeSize) | ||||||
|  |         ) | ||||||
|  |         val content = pageResult.content.map { | ||||||
|  |             Character( | ||||||
|  |                 characterId = it.id!!, | ||||||
|  |                 name = it.name, | ||||||
|  |                 description = it.description, | ||||||
|  |                 imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return RecentCharactersResponse( | ||||||
|  |             totalCount = totalRecent, | ||||||
|  |             content = content | ||||||
|  |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|   | |||||||
| @@ -23,16 +23,24 @@ object RankingWindowCalculator { | |||||||
|     fun now(prefix: String = "popular-chat-character"): RankingWindow { |     fun now(prefix: String = "popular-chat-character"): RankingWindow { | ||||||
|         val now = ZonedDateTime.now(ZONE) |         val now = ZonedDateTime.now(ZONE) | ||||||
|         val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(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 { |         } else { | ||||||
|             val next = todayBoundary.plusDays(1) |             // 오늘 20:00을 지났거나 같으면, 직전 경계는 오늘 20:00 | ||||||
|             Triple(todayBoundary, next, next) |             todayBoundary | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         val start = lastBoundary.minusDays(1) | ||||||
|  |         val endExclusive = lastBoundary | ||||||
|  |  | ||||||
|         val windowStart = start.toInstant() |         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}" |         val cacheKey = "$prefix:${windowStart.epochSecond}" | ||||||
|         return RankingWindow(windowStart, windowEnd, nextBoundary.toInstant(), cacheKey) |         // nextBoundary 필드는 기존 시그니처 유지를 위해 endExclusive(=lastBoundary)를 그대로 전달한다. | ||||||
|  |         return RankingWindow(windowStart, windowEnd, endExclusive.toInstant(), cacheKey) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user