commit
08b5fd23ab
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue