test #340
| @@ -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,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<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 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() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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<ChatCharacterCalculateItem> { | ||||||
|  |         // 날짜 유효성 검증 (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() } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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() | ||||||
|  |     ) | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user