From eec63cc7b264b54a32343922bd3a209663b84c5b Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Sep 2025 02:00:30 +0900 Subject: [PATCH] =?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() + ) +}