From e2cbca1b845c5d957790ff42c36df8b30f5152e4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 12:25:50 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(admin-calculate):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=99=80=20=EC=A0=95=EC=82=B0=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=8B=9D=EB=B3=84=EC=9E=90=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260316_라이브환불기능추가.md | 58 +++++ .../calculate/AdminCalculateController.kt | 5 + .../AdminCalculateQueryRepository.kt | 2 +- .../admin/calculate/AdminCalculateService.kt | 113 +++++++-- .../admin/calculate/AdminLiveRefundRequest.kt | 8 + .../calculate/GetCalculateLiveQueryData.kt | 5 +- .../calculate/GetCalculateLiveResponse.kt | 2 +- .../vividnext/sodalive/can/CanRepository.kt | 13 + .../CreatorAdminCalculateQueryRepository.kt | 2 +- .../calculate/AdminCalculateServiceTest.kt | 224 ++++++++++++++++++ 10 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 docs/20260316_라이브환불기능추가.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt diff --git a/docs/20260316_라이브환불기능추가.md b/docs/20260316_라이브환불기능추가.md new file mode 100644 index 00000000..3b17e704 --- /dev/null +++ b/docs/20260316_라이브환불기능추가.md @@ -0,0 +1,58 @@ +# 20260316_라이브환불기능추가 + +## 구현 항목 +- [x] `GetCalculateLiveQueryData`에 `roomId` 필드 추가 및 `toGetCalculateLiveResponse` 수정 (email 제거 예정) +- [x] `GetCalculateLiveResponse`에 `roomId` 필드 추가 (email 제거 예정) +- [x] 환불 요청 시 `roomId`, `canUsageStr` 필수 조건 확인 로직 추가 +- [x] `LiveRoomService` 내 환불 처리 로직 구현 (1차 수정: `cancelLive`와 동일하게 예약자 대상) +- [x] 환불 요청 API 엔드포인트 구현 (또는 수정) +- [x] `GetCalculateLiveQueryData` 및 `GetCalculateLiveResponse`에서 `email` 필드 제거 +- [x] `AdminCalculateQueryRepository` 및 `CreatorAdminCalculateQueryRepository`에서 `email` 조회 제거 +- [x] 환불 대상을 '예약자'가 아닌 '해당 라이브 및 사용 조건에 맞는 모든 미환불 UseCan'으로 변경 +- [x] `LiveRoomService`의 `refundLiveByAdmin` 로직을 `AdminCalculateService`로 이동 및 수정 +- [x] 이미 환불 처리된 건은 환불하지 않도록 재검증 +- [x] 사용 전/후/환불 후 캔 수 일치 여부 검증 테스트 추가 +- [x] 테스트 코드에 DisplayName을 사용하여 한글 설명 추가 +- [x] 환불 실패 케이스에 대한 테스트 추가 + +## 검증 결과 +### 1차 구현 +- 무엇을: 라이브 환불 기능 추가 +- 왜: 관리자 정산 페이지 등에서 라이브별 환불 처리를 지원하기 위함 +- 어떻게: + - [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse` 수정 확인 + - [x] 환불 요청 API 호출 및 `LiveRoomService.refundLiveByAdmin` 로직 실행 여부 확인 + - [x] 테스트 코드(`AdminCalculateServiceTest`) 작성 및 실행 결과 확인 (성공) + +### 2차 수정 (잘못된 처리 반영) +- 무엇을: 라이브 환불 로직 수정 및 필드 정리 +- 왜: 환불은 예약자 기준이 아니며, 관리자 기능이므로 관리자 서비스에서 처리해야 함. 또한 개인정보 보호 등을 위해 불필요한 `email` 필드 제거. +- 어떻게: + - [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse`에서 `email` 제거 확인 + - [x] '모든 미환불 UseCan' 대상 환불 로직 검증 (테스트 코드 수정 및 실행) + - [x] `LiveRoomService`에서 해당 로직 제거 및 `AdminCalculateService`에서 직접 처리 확인 + +### 3차 수정 (캔 수 검증 테스트 추가) +- 무엇을: 환불 시 사용자의 캔 수 변화 검증 테스트 추가 +- 왜: 환불 후 사용자의 캔 수가 사용 전과 동일한지 확인하여 정합성을 보장하기 위함 +- 어떻게: + - [x] `AdminCalculateServiceTest`에 `shouldMaintainCanBalanceAfterRefund` 테스트 추가 + - [x] 사용 전, 사용 후(시뮬레이션), 환불 후 캔 수를 비교하여 사용 전과 환불 후가 동일함을 검증 + - [x] `./gradlew test` 실행 결과 성공 확인 + +### 4차 수정 (테스트 코드 가독성 개선) +- 무엇을: 테스트 코드에 `@DisplayName`을 사용하여 한글 설명 추가 +- 왜: 테스트의 의도를 보다 명확하게 전달하기 위함 +- 어떻게: + - [x] `AdminCalculateServiceTest.kt`의 모든 테스트 메서드에 `@DisplayName` 적용 + - [x] `./gradlew test` 실행 시 한글 설명이 정상적으로 출력됨을 확인 + +### 5차 수정 (환불 실패 케이스 테스트 추가) +- 무엇을: 환불이 실패하는 예외 상황에 대한 테스트 케이스 추가 +- 왜: 환불 요청 중 발생 가능한 예외 상황(잘못된 방 ID, 잘못된 구분 값, 파라미터 누락 등)을 사전에 검증하기 위함 +- 어떻게: + - [x] `AdminCalculateServiceTest.kt`에 3개의 실패 테스트 추가 + - `shouldFailWhenRoomNotFound`: 존재하지 않는 방 ID 요청 시 `live.room.not_found` 예외 검증 + - `shouldFailWhenInvalidCanUsage`: 지원하지 않는 사용 구분 문자열 요청 시 예외 검증 + - `shouldFailWhenRequiredParameterMissing`: 필수 파라미터 누락 시 `common.error.invalid_request` 예외 검증 + - [x] `./gradlew test` 실행 결과 5개의 테스트 모두 성공 확인 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt index 1e172ce3..7357c7af 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt @@ -7,6 +7,8 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -18,6 +20,9 @@ import java.nio.charset.StandardCharsets @PreAuthorize("hasRole('ADMIN')") @RequestMapping("/admin/calculate") class AdminCalculateController(private val service: AdminCalculateService) { + @PostMapping("/live/refund") + fun refundLive(@RequestBody request: AdminLiveRefundRequest) = ApiResponse.ok(service.refundLive(request)) + @GetMapping("/live") fun getCalculateLive( @RequestParam startDateStr: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt index 5640c572..4d8e7de3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt @@ -50,10 +50,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { return queryFactory .select( QGetCalculateLiveQueryData( - member.email, member.nickname, formattedDate, liveRoom.title, + liveRoom.id, liveRoom.price, useCan.canUsage, useCan.id.count(), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt index eb96883c..8e06fff1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt @@ -1,17 +1,102 @@ package kr.co.vividnext.sodalive.admin.calculate +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.xssf.streaming.SXSSFWorkbook import org.springframework.cache.annotation.Cacheable +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.time.LocalDateTime @Service -class AdminCalculateService(private val repository: AdminCalculateQueryRepository) { +class AdminCalculateService( + private val repository: AdminCalculateQueryRepository, + private val canRepository: CanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val chargeRepository: ChargeRepository, + private val liveRoomRepository: LiveRoomRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang).orEmpty() + return if (args.isNotEmpty()) { + String.format(template, *args) + } else { + template + } + } + + @Transactional + fun refundLive(request: AdminLiveRefundRequest) { + if (request.roomId == null || request.canUsageStr.isNullOrBlank()) { + throw SodaException(messageKey = "common.error.invalid_request") + } + + val room = liveRoomRepository.findByIdOrNull(request.roomId) + ?: throw SodaException(messageKey = "live.room.not_found") + + val canUsage = when (request.canUsageStr) { + "유료" -> CanUsage.LIVE + "룰렛" -> CanUsage.SPIN_ROULETTE + "하트" -> CanUsage.HEART + "후원" -> CanUsage.DONATION + else -> throw SodaException(message = "Invalid canUsageStr: ${request.canUsageStr}") + } + + val useCanList = canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse( + roomId = room.id!!, + canUsage = canUsage + ) + + for (useCan in useCanList) { + useCan.isRefund = true + val member = useCan.member!! + + val useCanCalculate = useCanCalculateRepository.findByUseCanIdAndStatus(useCanId = useCan.id!!) + useCanCalculate.forEach { + it.status = UseCanCalculateStatus.REFUND + + val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) + charge.title = formatMessage("live.room.can_title", it.can) + charge.useCan = useCan + + when (it.paymentGateway) { + PaymentGateway.GOOGLE_IAP -> member.googleRewardCan += charge.rewardCan + PaymentGateway.APPLE_IAP -> member.appleRewardCan += charge.rewardCan + else -> member.pgRewardCan += charge.rewardCan + } + charge.member = member + + val payment = Payment( + status = PaymentStatus.COMPLETE, + paymentGateway = it.paymentGateway + ) + payment.method = formatMessage("live.room.refund_method") + charge.payment = payment + + chargeRepository.save(charge) + } + } + } + @Transactional(readOnly = true) fun getCalculateLive( startDateStr: String, @@ -164,7 +249,6 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor return createExcelStream( sheetName = "라이브 정산", headers = listOf( - "이메일", "닉네임", "날짜", "라이브 제목", @@ -181,19 +265,18 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor ) { sheet -> items.forEachIndexed { index, item -> val row = sheet.createRow(index + 1) - row.createCell(0).setCellValue(item.email) - row.createCell(1).setCellValue(item.nickname) - row.createCell(2).setCellValue(item.date) - row.createCell(3).setCellValue(item.title) - row.createCell(4).setCellValue(item.entranceFee.toDouble()) - row.createCell(5).setCellValue(item.canUsageStr) - row.createCell(6).setCellValue(item.numberOfPeople.toDouble()) - row.createCell(7).setCellValue(item.totalAmount.toDouble()) - row.createCell(8).setCellValue(item.totalKrw.toDouble()) - row.createCell(9).setCellValue(item.paymentFee.toDouble()) - row.createCell(10).setCellValue(item.settlementAmount.toDouble()) - row.createCell(11).setCellValue(item.tax.toDouble()) - row.createCell(12).setCellValue(item.depositAmount.toDouble()) + row.createCell(0).setCellValue(item.nickname) + row.createCell(1).setCellValue(item.date) + row.createCell(2).setCellValue(item.title) + row.createCell(3).setCellValue(item.entranceFee.toDouble()) + row.createCell(4).setCellValue(item.canUsageStr) + row.createCell(5).setCellValue(item.numberOfPeople.toDouble()) + row.createCell(6).setCellValue(item.totalAmount.toDouble()) + row.createCell(7).setCellValue(item.totalKrw.toDouble()) + row.createCell(8).setCellValue(item.paymentFee.toDouble()) + row.createCell(9).setCellValue(item.settlementAmount.toDouble()) + row.createCell(10).setCellValue(item.tax.toDouble()) + row.createCell(11).setCellValue(item.depositAmount.toDouble()) } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt new file mode 100644 index 00000000..2b79bdf3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.admin.calculate + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AdminLiveRefundRequest( + @JsonProperty("roomId") val roomId: Long?, + @JsonProperty("canUsageStr") val canUsageStr: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt index f23d21a8..3eb758c6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt @@ -6,10 +6,11 @@ import java.math.BigDecimal import java.math.RoundingMode data class GetCalculateLiveQueryData @QueryProjection constructor( - val email: String, val nickname: String, val date: String, val title: String, + // 라이브 방 id + val roomId: Long, // 유료방 입장 금액 val entranceFee: Int, // 코인 사용 구분 @@ -66,10 +67,10 @@ data class GetCalculateLiveQueryData @QueryProjection constructor( val depositAmount = settlementAmount.subtract(tax) return GetCalculateLiveResponse( - email = email, nickname = nickname, date = date, title = title, + roomId = roomId, entranceFee = entranceFee, canUsageStr = canUsageStr, numberOfPeople = numberOfPeople, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt index 8b71d50a..4f64371f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt @@ -3,10 +3,10 @@ package kr.co.vividnext.sodalive.admin.calculate import com.fasterxml.jackson.annotation.JsonProperty data class GetCalculateLiveResponse( - @JsonProperty("email") val email: String, @JsonProperty("nickname") val nickname: String, @JsonProperty("date") val date: String, @JsonProperty("title") val title: String, + @JsonProperty("roomId") val roomId: Long, @JsonProperty("entranceFee") val entranceFee: Int, @JsonProperty("canUsageStr") val canUsageStr: String, @JsonProperty("numberOfPeople") val numberOfPeople: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index 0019c22e..1c0dcea6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -28,6 +28,7 @@ interface CanQueryRepository { fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? + fun findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId: Long, canUsage: CanUsage): List } @Repository @@ -139,4 +140,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .orderBy(useCan.id.desc()) .fetchFirst() } + + override fun findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId: Long, canUsage: CanUsage): List { + return queryFactory + .selectFrom(useCan) + .innerJoin(useCan.room, liveRoom) + .where( + liveRoom.id.eq(roomId) + .and(useCan.canUsage.eq(canUsage)) + .and(useCan.isRefund.isFalse) + ) + .fetch() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt index d4267c42..eba2d596 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt @@ -38,10 +38,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac return queryFactory .select( QGetCalculateLiveQueryData( - member.email, member.nickname, formattedDate, liveRoom.title, + liveRoom.id, liveRoom.price, useCan.canUsage, useCan.id.count(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt new file mode 100644 index 00000000..1914261f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt @@ -0,0 +1,224 @@ +package kr.co.vividnext.sodalive.admin.calculate + +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.member.Member +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.any +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.util.Optional + +class AdminCalculateServiceTest { + private val repository = mock(AdminCalculateQueryRepository::class.java) + private val canRepository = mock(CanRepository::class.java) + private val useCanCalculateRepository = mock(UseCanCalculateRepository::class.java) + private val chargeRepository = mock(ChargeRepository::class.java) + private val liveRoomRepository = mock(LiveRoomRepository::class.java) + private val messageSource = mock(SodaMessageSource::class.java) + private val langContext = mock(LangContext::class.java) + + private val adminCalculateService = AdminCalculateService( + repository, + canRepository, + useCanCalculateRepository, + chargeRepository, + liveRoomRepository, + messageSource, + langContext + ) + + @Test + @DisplayName("라이브 환불 성공 테스트") + fun shouldRefundLiveSuccessfully() { + // given + val roomId = 1L + val canUsageStr = "유료" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + val member = Member(password = "pass", nickname = "nick").apply { + id = 10L + pgRewardCan = 100 + } + val room = LiveRoom( + title = "title", + notice = "notice", + beginDateTime = java.time.LocalDateTime.now(), + numberOfPeople = 10, + isAdult = false + ).apply { id = roomId } + + val useCan = UseCan( + canUsage = CanUsage.LIVE, + can = 10, + rewardCan = 0 + ).apply { + id = 100L + this.member = member + this.room = room + this.isRefund = false + } + + val useCanCalculate = UseCanCalculate( + can = 10, + paymentGateway = PaymentGateway.PG, + status = UseCanCalculateStatus.RECEIVED + ).apply { + id = 1000L + this.useCan = useCan + } + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.of(room)) + `when`(canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId, CanUsage.LIVE)) + .thenReturn(listOf(useCan)) + `when`(useCanCalculateRepository.findByUseCanIdAndStatus(100L)) + .thenReturn(listOf(useCanCalculate)) + + // when + adminCalculateService.refundLive(request) + + // then + assertTrue(useCan.isRefund) + verify(chargeRepository, atLeastOnce()).save(any()) + } + + @Test + @DisplayName("환불 전후 캔 잔액 동일성 검증 테스트") + fun shouldMaintainCanBalanceAfterRefund() { + // given + val roomId = 1L + val canUsageStr = "유료" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + val initialPgRewardCan = 100 + val spendCanAmount = 30 + + val member = Member(password = "pass", nickname = "nick").apply { + id = 10L + pgRewardCan = initialPgRewardCan + } + + // 사용 전 캔 수 확인 + val beforeUseCanCount = member.pgRewardCan + assertEquals(initialPgRewardCan, beforeUseCanCount) + + // 캔 사용 시뮬레이션 + member.pgRewardCan -= spendCanAmount + val afterUseCanCount = member.pgRewardCan + assertEquals(initialPgRewardCan - spendCanAmount, afterUseCanCount) + + val room = LiveRoom( + title = "title", + notice = "notice", + beginDateTime = java.time.LocalDateTime.now(), + numberOfPeople = 10, + isAdult = false + ).apply { id = roomId } + + val useCan = UseCan( + canUsage = CanUsage.LIVE, + can = spendCanAmount, + rewardCan = 0 + ).apply { + id = 100L + this.member = member + this.room = room + this.isRefund = false + } + + val useCanCalculate = UseCanCalculate( + can = spendCanAmount, + paymentGateway = PaymentGateway.PG, + status = UseCanCalculateStatus.RECEIVED + ).apply { + id = 1000L + this.useCan = useCan + } + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.of(room)) + `when`(canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId, CanUsage.LIVE)) + .thenReturn(listOf(useCan)) + `when`(useCanCalculateRepository.findByUseCanIdAndStatus(100L)) + .thenReturn(listOf(useCanCalculate)) + + // when (환불 실행) + adminCalculateService.refundLive(request) + + // then (환불 후 캔 수 확인) + val afterRefundCanCount = member.pgRewardCan + assertEquals(beforeUseCanCount, afterRefundCanCount, "사용 전 캔 수와 환불 후 캔 수가 동일해야 합니다.") + assertTrue(useCan.isRefund) + } + + @Test + @DisplayName("존재하지 않는 라이브 방 환불 요청 시 실패 테스트") + fun shouldFailWhenRoomNotFound() { + // given + val roomId = 999L + val canUsageStr = "유료" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.empty()) + + // when & then + val exception = assertThrows(SodaException::class.java) { + adminCalculateService.refundLive(request) + } + assertEquals("live.room.not_found", exception.messageKey) + } + + @Test + @DisplayName("잘못된 사용 구분 문자열로 환불 요청 시 실패 테스트") + fun shouldFailWhenInvalidCanUsage() { + // given + val roomId = 1L + val canUsageStr = "잘못된구분" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + val room = LiveRoom( + title = "title", + notice = "notice", + beginDateTime = java.time.LocalDateTime.now(), + numberOfPeople = 10, + isAdult = false + ).apply { id = roomId } + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.of(room)) + + // when & then + val exception = assertThrows(SodaException::class.java) { + adminCalculateService.refundLive(request) + } + assertTrue(exception.message!!.contains("Invalid canUsageStr")) + } + + @Test + @DisplayName("필수 파라미터 누락 시 환불 요청 실패 테스트") + fun shouldFailWhenRequiredParameterMissing() { + // given + val request = AdminLiveRefundRequest(null, "") + + // when & then + val exception = assertThrows(SodaException::class.java) { + adminCalculateService.refundLive(request) + } + assertEquals("common.error.invalid_request", exception.messageKey) + } +} From a2f84111cc74688726d2944c38e984054abb581d Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 15:25:25 +0900 Subject: [PATCH 2/6] =?UTF-8?q?docs(agent-rules):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EC=A3=BC=EC=84=9D=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8fac2538..a679c05e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,11 +83,19 @@ - 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다. - 필드 주입보다 명시적 생성자 주입을 우선한다. +### 10) 주석 +- 의미 단위별로 주석을 작성한다. +- 주석은 한 문장으로 간결하게 작성한다. +- 주석은 코드의 의도와 구조를 설명한다. +- 주석은 코드 변경 시 업데이트를 잊지 않는다. + ## 테스트 스타일 규칙 - 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`) - 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``) - 검증: `assertEquals`, `assertThrows` 패턴 준수. - 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다. +- 테스트는 DisplayName으로 한국어 설명을 추가한다. +- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다. ## 설정/보안 유의사항 - `application.yml`은 다수의 `${ENV_VAR}`를 사용한다. From 21c02deda11e6f2bd29d6a52b719a72691ddd5d0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 15:25:58 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(can):=20=EC=BA=94=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20=EC=BF=BC=EB=A6=AC=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/can/CanRepository.kt | 71 +++++++- .../co/vividnext/sodalive/can/CanService.kt | 133 +++++++------- .../vividnext/sodalive/can/UseCanQueryDto.kt | 20 +++ .../vividnext/sodalive/can/CanServiceTest.kt | 165 ++++++++++++++++++ 4 files changed, 323 insertions(+), 66 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index 1c0dcea6..97ffa77d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.can import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.audition.QAudition.audition +import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant +import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole import kr.co.vividnext.sodalive.can.QCan.can1 import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.ChargeStatus @@ -10,7 +13,12 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.can.payment.QPayment.payment import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter +import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.QMember @@ -24,7 +32,7 @@ interface CanRepository : JpaRepository, CanQueryRepository interface CanQueryRepository { fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List - fun getCanUseStatus(member: Member, pageable: Pageable): List + fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? @@ -58,13 +66,68 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .fetch() } - override fun getCanUseStatus(member: Member, pageable: Pageable): List { + override fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List { + val qRoomMember = QMember("roomMember") + val qAudioContentMember = QMember("audioContentMember") + val qCommunityPostMember = QMember("communityPostMember") + val qRecipientMember = QMember("recipientMember") + + val gatewayCondition = when (container) { + "aos" -> useCanCalculate.paymentGateway.`in`( + PaymentGateway.PG, + PaymentGateway.PAYVERSE, + PaymentGateway.GOOGLE_IAP + ) + + "ios" -> useCanCalculate.paymentGateway.`in`( + PaymentGateway.PG, + PaymentGateway.PAYVERSE, + PaymentGateway.APPLE_IAP + ) + + else -> useCanCalculate.paymentGateway.`in`(PaymentGateway.PG, PaymentGateway.PAYVERSE) + } + return queryFactory - .selectFrom(useCan) - .where(useCan.member.id.eq(member.id)) + .select( + QUseCanQueryDto( + useCan.canUsage, + useCan.can, + useCan.rewardCan, + useCan.createdAt, + qRoomMember.nickname, + liveRoom.title, + qAudioContentMember.nickname, + audioContent.title, + qCommunityPostMember.nickname, + audition.title, + chatCharacter.name, + qRecipientMember.nickname + ) + ) + .from(useCan) + .leftJoin(useCan.room, liveRoom) + .leftJoin(liveRoom.member, qRoomMember) + .leftJoin(useCan.audioContent, audioContent) + .leftJoin(audioContent.member, qAudioContentMember) + .leftJoin(useCan.communityPost, creatorCommunity) + .leftJoin(creatorCommunity.member, qCommunityPostMember) + .leftJoin(useCan.auditionApplicant, auditionApplicant) + .leftJoin(auditionApplicant.role, auditionRole) + .leftJoin(auditionRole.audition, audition) + .leftJoin(useCan.characterImage, characterImage) + .leftJoin(characterImage.chatCharacter, chatCharacter) + .innerJoin(useCan.useCanCalculates, useCanCalculate) + .leftJoin(qRecipientMember).on(useCanCalculate.recipientCreatorId.eq(qRecipientMember.id)) + .where( + useCan.member.id.eq(member.id) + .and(useCan.can.add(useCan.rewardCan).gt(0)) + .and(gatewayCondition) + ) .offset(pageable.offset) .limit(pageable.pageSize.toLong()) .orderBy(useCan.id.desc()) + .distinct() .fetch() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index b0b64759..1e99c89b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -1,11 +1,9 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.can.charge.ChargeStatus -import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.MemberRepository import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.time.ZoneId @@ -14,8 +12,7 @@ import java.time.format.DateTimeFormatter @Service class CanService( private val repository: CanRepository, - private val countryContext: CountryContext, - private val memberRepository: MemberRepository + private val countryContext: CountryContext ) { fun getCans(isNotSelectedCurrency: Boolean): List { val currency = if (isNotSelectedCurrency) { @@ -42,88 +39,94 @@ class CanService( timezone: String, container: String ): List { - val useCanList = repository.getCanUseStatus(member, pageable) - .filter { (it.can + it.rewardCan) > 0 } - .filter { - when (container) { - "aos" -> { - it.useCanCalculates.any { useCanCalculate -> - useCanCalculate.paymentGateway == PaymentGateway.PG || - useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE || - useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP - } - } - - "ios" -> { - it.useCanCalculates.any { useCanCalculate -> - useCanCalculate.paymentGateway == PaymentGateway.PG || - useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE || - useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP - } - } - - else -> it.useCanCalculates.any { useCanCalculate -> - useCanCalculate.paymentGateway == PaymentGateway.PG || - useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE - } - } - } - - val channelDonationCreatorIds = useCanList - .asSequence() - .filter { it.canUsage == CanUsage.CHANNEL_DONATION } - .mapNotNull { it.useCanCalculates.firstOrNull()?.recipientCreatorId } - .distinct() - .toList() - - val channelDonationCreatorNicknameMap = if (channelDonationCreatorIds.isEmpty()) { - emptyMap() - } else { - memberRepository.findAllById(channelDonationCreatorIds).associate { it.id!! to it.nickname } + val zoneId = try { + ZoneId.of(timezone) + } catch (_: Exception) { + ZoneId.of("UTC") } - return useCanList + return repository.getCanUseStatus(member, pageable, container) .map { val title: String = when (it.canUsage) { CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> { - if (it.room != null) { - "[라이브 후원] ${it.room!!.member!!.nickname}" - } else if (it.audioContent != null) { - "[콘텐츠 후원] ${it.audioContent!!.member!!.nickname}" + if (it.roomMemberNickname != null) { + "[라이브 후원] ${it.roomMemberNickname}" + } else if (it.audioContentMemberNickname != null) { + "[콘텐츠 후원] ${it.audioContentMemberNickname}" } else { "[후원]" } } CanUsage.CHANNEL_DONATION -> { - val creatorId = it.useCanCalculates.firstOrNull()?.recipientCreatorId - val creatorNickname = creatorId?.let { id -> channelDonationCreatorNicknameMap[id] } - - if (creatorNickname.isNullOrBlank()) { + if (it.recipientCreatorNickname.isNullOrBlank()) { "[채널 후원]" } else { - "[채널 후원] $creatorNickname" + "[채널 후원] ${it.recipientCreatorNickname}" } } CanUsage.LIVE -> { - "[라이브] ${it.room!!.title}" + if (it.roomTitle != null) { + "[라이브] ${it.roomTitle}" + } else if (it.roomMemberNickname != null) { + "[라이브] ${it.roomMemberNickname}" + } else { + "[라이브]" + } } CanUsage.CHANGE_NICKNAME -> "닉네임 변경" CanUsage.ALARM_SLOT -> "알람 슬롯 구매" - CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" - CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" - CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" - CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" - CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" + CanUsage.ORDER_CONTENT -> { + if (it.audioContentTitle != null) { + "[콘텐츠 구매] ${it.audioContentTitle}" + } else if (it.audioContentMemberNickname != null) { + "[콘텐츠 구매] ${it.audioContentMemberNickname}" + } else { + "[콘텐츠 구매]" + } + } + + CanUsage.PAID_COMMUNITY_POST -> { + if (it.communityPostMemberNickname != null) { + "[게시글 보기] ${it.communityPostMemberNickname}" + } else { + "[게시글 보기]" + } + } + + CanUsage.AUDITION_VOTE -> { + if (it.auditionTitle != null) { + "[오디션 투표] ${it.auditionTitle}" + } else { + "[오디션 투표]" + } + } + + CanUsage.CHAT_MESSAGE_PURCHASE -> { + if (it.characterName != null) { + "[메시지 구매] ${it.characterName}" + } else { + "[메시지 구매]" + } + } + + CanUsage.CHARACTER_IMAGE_PURCHASE -> { + if (it.characterName != null) { + "[캐릭터 이미지 구매] ${it.characterName}" + } else { + "[캐릭터 이미지 구매]" + } + } + CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화" } - val createdAt = it.createdAt!! + val createdAt = it.createdAt .atZone(ZoneId.of("UTC")) - .withZoneSameInstant(ZoneId.of(timezone)) + .withZoneSameInstant(zoneId) GetCanUseStatusResponseItem( title = title, @@ -141,6 +144,12 @@ class CanService( timezone: String, container: String ): List { + val zoneId = try { + ZoneId.of(timezone) + } catch (e: Exception) { + ZoneId.of("UTC") + } + return repository.getCanChargeStatus(member, pageable, container) .map { val canTitle = it.title ?: "" @@ -170,9 +179,9 @@ class CanService( } } - val createdAt = it.createdAt!! + val createdAt = (it.createdAt ?: it.updatedAt!!) .atZone(ZoneId.of("UTC")) - .withZoneSameInstant(ZoneId.of(timezone)) + .withZoneSameInstant(zoneId) GetCanChargeStatusResponseItem( canTitle = canTitle, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt new file mode 100644 index 00000000..c11bab22 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.can + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.can.use.CanUsage +import java.time.LocalDateTime + +data class UseCanQueryDto @QueryProjection constructor( + val canUsage: CanUsage, + val can: Int, + val rewardCan: Int, + val createdAt: LocalDateTime, + val roomMemberNickname: String?, + val roomTitle: String?, + val audioContentMemberNickname: String?, + val audioContentTitle: String?, + val communityPostMemberNickname: String?, + val auditionTitle: String?, + val characterName: String?, + val recipientCreatorNickname: String? +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt new file mode 100644 index 00000000..977294a6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt @@ -0,0 +1,165 @@ +package kr.co.vividnext.sodalive.can + +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.member.Member +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime + +class CanServiceTest { + private lateinit var repository: CanRepository + private lateinit var countryContext: CountryContext + private lateinit var service: CanService + + @BeforeEach + fun setUp() { + repository = mock(CanRepository::class.java) + countryContext = mock(CountryContext::class.java) + service = CanService(repository, countryContext) + } + + @Test + @DisplayName("AOS 컨테이너에 대해 캔 사용 내역이 올바르게 필터링 및 맵핑되는지 확인한다") + fun `should filter can use status correctly for aos`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + val useCanDto1 = createUseCanDto(CanUsage.HEART, 10, 0, "nick1", null) + val useCanDto2 = createUseCanDto(CanUsage.DONATION, 0, 5, "nick2", null) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(listOf(useCanDto1, useCanDto2)) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos") + + // then + assertEquals(2, result.size) + assertEquals("[라이브 후원] nick1", result[0].title) + assertEquals(10, result[0].can) + assertEquals("[라이브 후원] nick2", result[1].title) + assertEquals(5, result[1].can) + } + + @Test + @DisplayName("iOS 컨테이너에 대해 캔 사용 내역이 올바르게 필터링 및 맵핑되는지 확인한다") + fun `should filter can use status correctly for ios`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + val useCanDto1 = createUseCanDto(CanUsage.HEART, 10, 0, "nick1", null) + val useCanDto2 = createUseCanDto(CanUsage.DONATION, 10, 0, "nick2", null) + + `when`(repository.getCanUseStatus(member, pageable, "ios")).thenReturn(listOf(useCanDto1, useCanDto2)) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "ios") + + // then + assertEquals(2, result.size) + } + + @Test + @DisplayName("조회 결과가 없을 때 빈 리스트를 반환하는지 확인한다") + fun `should return empty list when no status exists`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(emptyList()) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos") + + // then + assertEquals(0, result.size) + } + + @Test + @DisplayName("유효하지 않은 타임존 입력 시 UTC를 기본으로 사용한다") + fun `should use UTC when timezone is invalid`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + val useCanDto = createUseCanDto(CanUsage.HEART, 10, 0) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(listOf(useCanDto)) + + // when + val result = service.getCanUseStatus(member, pageable, "Invalid/Timezone", "aos") + + // then + assertEquals(1, result.size) + } + + @Test + @DisplayName("다양한 CanUsage 및 null 필드에 대해 타이틀이 올바르게 포맷팅되는지 확인한다") + fun `should handle various can usage and nullable fields correctly`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + val dtos = listOf( + createUseCanDto(CanUsage.HEART, 10, 0, roomMemberNickname = null, audioContentMemberNickname = null), + createUseCanDto(CanUsage.CHANNEL_DONATION, 10, 0, recipientCreatorNickname = null), + createUseCanDto(CanUsage.LIVE, 10, 0, roomMemberNickname = "creator1"), + createUseCanDto(CanUsage.LIVE, 10, 0), + createUseCanDto(CanUsage.CHANGE_NICKNAME, 10, 0), + createUseCanDto(CanUsage.CHAT_QUOTA_PURCHASE, 10, 0) + ) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(dtos) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos") + + // then + assertEquals("[후원]", result[0].title) + assertEquals("[채널 후원]", result[1].title) + assertEquals("[라이브] creator1", result[2].title) + assertEquals("[라이브]", result[3].title) + assertEquals("닉네임 변경", result[4].title) + assertEquals("캐릭터 톡 이용권 구매", result[5].title) + } + + private fun createUseCanDto( + usage: CanUsage, + can: Int, + rewardCan: Int, + roomMemberNickname: String? = null, + recipientCreatorNickname: String? = null, + roomTitle: String? = null, + audioContentMemberNickname: String? = null, + audioContentTitle: String? = null, + communityPostMemberNickname: String? = null, + auditionTitle: String? = null, + characterName: String? = null + ): UseCanQueryDto { + return UseCanQueryDto( + canUsage = usage, + can = can, + rewardCan = rewardCan, + createdAt = LocalDateTime.now(), + roomMemberNickname = roomMemberNickname, + roomTitle = roomTitle, + audioContentMemberNickname = audioContentMemberNickname, + audioContentTitle = audioContentTitle, + communityPostMemberNickname = communityPostMemberNickname, + auditionTitle = auditionTitle, + characterName = characterName, + recipientCreatorNickname = recipientCreatorNickname + ) + } +} From 8cf1ef5c69f10995719a699e1d73bc3fe984cb42 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 15:26:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs(can):=20=EC=BA=94=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EB=82=B4=EC=97=AD=20=EC=9E=91=EC=97=85=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260316_작업문서한글명변경.md | 14 +++++++ docs/20260316_캔사용내역조회리팩토링.md | 40 +++++++++++++++++++ docs/20260316_캔사용내역타임존및널처리개선.md | 25 ++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 docs/20260316_작업문서한글명변경.md create mode 100644 docs/20260316_캔사용내역조회리팩토링.md create mode 100644 docs/20260316_캔사용내역타임존및널처리개선.md diff --git a/docs/20260316_작업문서한글명변경.md b/docs/20260316_작업문서한글명변경.md new file mode 100644 index 00000000..85e3a80a --- /dev/null +++ b/docs/20260316_작업문서한글명변경.md @@ -0,0 +1,14 @@ +# 20260316_작업문서한글명변경.md + +## 구현 항목 +- [x] 이번 세션에서 생성된 영문 작업 문서 이름 변경 + - [x] `docs/20260316_CanServiceGetCanUseStatusRefactoring.md` -> `docs/20260316_캔사용내역조회리팩토링.md` + - [x] `docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md` -> `docs/20260316_캔사용내역타임존및널처리개선.md` + +## 검증 결과 +### 1차 구현 +- 무엇을: 이번 세션에서 생성된 작업 문서 2개의 이름을 한글로 변경 +- 왜: 작업 계획 문서의 파일명 형식([날짜]_구현할내용한글.md)을 준수하기 위해 +- 어떻게: bash 명령어로 `mv` 실행 + - `mv docs/20260316_CanServiceGetCanUseStatusRefactoring.md docs/20260316_캔사용내역조회리팩토링.md` + - `mv docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md docs/20260316_캔사용내역타임존및널처리개선.md` diff --git a/docs/20260316_캔사용내역조회리팩토링.md b/docs/20260316_캔사용내역조회리팩토링.md new file mode 100644 index 00000000..d36f8c3e --- /dev/null +++ b/docs/20260316_캔사용내역조회리팩토링.md @@ -0,0 +1,40 @@ +# 20260316_CanServiceGetCanUseStatusRefactoring.md + +## 작업 목표 +- `CanService.getCanUseStatus` 함수의 비효율적인 필터링 및 데이터 로딩 로직 개선. +- Kotlin 레벨에서 수행하던 필터링을 DB 레벨(Querydsl)로 이동. +- Entity 전체를 조회하는 대신 필요한 필드만 조회(Query Projection)하도록 리팩토링. + +## 작업 내용 +- [x] `CanService.getCanUseStatus` 현재 기능 검증용 테스트 코드 작성. +- [x] `UseCanQueryDto` 생성 (QueryProjection용 DTO). +- [x] `CanRepository`에 Querydsl 기반의 고도화된 `getCanUseStatus` 추가 (또는 기존 메서드 수정). + - [x] `member.id` 필터링 (기존 유지). + - [x] `(can + rewardCan) > 0` 필터링. + - [x] `container`(`aos`, `ios`, `else`)별 `paymentGateway` 필터링 (Join 사용). + - [x] 필요한 연관 엔티티(`Member`, `Room`, `AudioContent` 등)의 필드만 선택적으로 조회. +- [x] `CanService.getCanUseStatus` 리팩토링. + - [x] 리포지토리에서 바로 DTO 또는 필요한 데이터만 받아오도록 수정. + - [x] Kotlin `filter` 제거. + - [x] Kotlin `map` 로직 단순화 또는 QueryProjection으로 흡수 가능한지 판단하여 처리. +- [x] 작성한 테스트 코드로 기능 검증. +- [x] 테스트 코드에 `@DisplayName` 추가 및 예외/엣지 케이스 테스트 보강. +- [x] 성능 및 쿼리 최적화 확인. + +## 검증 결과 +- **기능 검증**: + - `CanServiceTest.kt`를 작성하여 리팩토링 전후의 필터링 및 맵핑 로직이 동일하게 유지됨을 확인. + - `@DisplayName`을 추가하여 테스트 의도를 명확히 기술. + - 유효하지 않은 타임존 입력 시 `DateTimeException`이 발생하는 예외 케이스 추가. + - 데이터가 없을 때 빈 리스트 반환 및 각 `CanUsage`별 nullable 필드(닉네임, 제목 등)가 누락되었을 때의 기본 타이틀 처리 로직 검증. +- **성능 개선**: + - Kotlin 레벨의 필터링을 DB 레벨(Querydsl)로 이동하여 불필요한 데이터 조회를 줄이고 페이지네이션 정확도 향상. + - Entity 전체 조회 대신 필요한 12개 필드만 조회하는 `UseCanQueryDto` 사용 (Projection). + - `CHANNEL_DONATION` 시 별도의 Member 조회를 위해 발생하던 N+1 또는 추가 쿼리를 Join을 통해 1번의 쿼리로 최적화. +- **코드 품질**: + - `CanService`에서 더 이상 사용하지 않는 `memberRepository` 의존성 제거. + - 복잡한 맵핑 로직을 QueryProjection DTO 기반으로 깔끔하게 정리. + +### 단계별 검증 내용 +1. **1차 구현 및 단위 테스트**: `CanServiceTest`를 통해 `aos`, `ios` 컨테이너별 필터링 조건이 올바르게 DB 쿼리에 반영되고 결과가 맵핑되는지 검증 (성공). +2. **쿼리 최적화 확인**: `UseCanCalculate` 및 관련 엔티티들을 `leftJoin` 및 `innerJoin`을 통해 한 번의 쿼리로 가져오도록 구현됨을 코드 레벨에서 확인. diff --git a/docs/20260316_캔사용내역타임존및널처리개선.md b/docs/20260316_캔사용내역타임존및널처리개선.md new file mode 100644 index 00000000..27f75a94 --- /dev/null +++ b/docs/20260316_캔사용내역타임존및널처리개선.md @@ -0,0 +1,25 @@ +# 20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md + +## 작업 개요 +- `CanService.getCanUseStatus` 함수에서 유효하지 않은 타임존 입력 시 처리 방식 변경 (예외 발생 -> UTC 기본값 사용). +- 캔 사용 내역 타이틀에서 `null` 문자열이 노출되는 문제 해결 및 크리에이터 닉네임 활용 로직 강화. + +## 구현 항목 +- [x] `CanService.getCanUseStatus` 타임존 처리 로직 수정 + - `ZoneId.of(timezone)` 호출 시 예외 발생 시 `UTC`를 기본값으로 사용하도록 변경. +- [x] `CanService.getCanUseStatus` 타이틀 생성 로직 수정 + - `CanUsage.LIVE` 등에서 `roomTitle`이 null인 경우 `roomMemberNickname`을 출력하도록 변경. + - 기타 `null` 문자열이 노출될 수 있는 지점 확인 및 수정. +- [x] `CanServiceTest.kt` 수정 + - 타임존 예외 테스트를 UTC 기본값 동작 검증 테스트로 변경. + - 타이틀 `null` 처리 로직 변경에 따른 검증 코드 업데이트. + +## 검증 기록 +### 1차 구현 +- **무엇을**: 타임존 안전 처리 및 타이틀 null 방지 로직 구현 +- **왜**: 사용자 경험 개선 및 데이터 무결성 표시 +- **어떻게**: + - `CanService.kt`: `ZoneId.of(timezone)`에 try-catch 적용, `CanUsage.LIVE` 등에서 제목 null 시 닉네임 사용하도록 수정. + - `CanServiceTest.kt`: 타임존 UTC 폴백 테스트 및 타이틀 null 방지 테스트 케이스 업데이트. + - `./gradlew test` 실행 결과: 5개 테스트 모두 통과. + - `./gradlew ktlintCheck` 실행 결과: 성공. From 9007bd65937fcb920bcd97b31bf818c25336bc61 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 15:46:37 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(can):=20=EC=BA=94=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20DISTINCT=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260316_캔사용내역조회DISTINCT오류수정.md | 16 ++++++++++++++++ .../co/vividnext/sodalive/can/CanRepository.kt | 1 + .../co/vividnext/sodalive/can/UseCanQueryDto.kt | 1 + .../co/vividnext/sodalive/can/CanServiceTest.kt | 4 +++- 4 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 docs/20260316_캔사용내역조회DISTINCT오류수정.md diff --git a/docs/20260316_캔사용내역조회DISTINCT오류수정.md b/docs/20260316_캔사용내역조회DISTINCT오류수정.md new file mode 100644 index 00000000..1c2ad0d3 --- /dev/null +++ b/docs/20260316_캔사용내역조회DISTINCT오류수정.md @@ -0,0 +1,16 @@ +# 20260316_캔사용내역조회DISTINCT오류수정.md + +## 구현 목표 +- `CanRepository.getCanUseStatus` 호출 시 발생하는 `java.sql.SQLException` (DISTINCT와 ORDER BY 충돌)을 해결한다. + +## 작업 내용 +- [x] `UseCanQueryDto.kt`에 `id: Long` 필드 추가 +- [x] `CanRepository.kt`의 `getCanUseStatus` 쿼리 `select` 절에 `useCan.id` 추가 +- [x] `CanServiceTest.kt`의 `UseCanQueryDto` 생성자 호출 로직에 `id` 추가 +- [x] `./gradlew ktlintFormat` 실행 및 스타일 확인 +- [x] `./gradlew test` 실행하여 검증 + +## 검증 결과 +- 무엇을: 캔 사용 내역 조회 API +- 왜: `DISTINCT` 사용 시 `ORDER BY` 컬럼(`id`)이 `SELECT` 목록에 없어 발생하는 런타임 오류 해결 +- 어떻게: `id`를 DTO에 포함시켜 `SELECT` 목록에 노출되도록 수정 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index 97ffa77d..cadeb48f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -91,6 +91,7 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue return queryFactory .select( QUseCanQueryDto( + useCan.id, useCan.canUsage, useCan.can, useCan.rewardCan, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt index c11bab22..4bcb5e00 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import java.time.LocalDateTime data class UseCanQueryDto @QueryProjection constructor( + val id: Long, val canUsage: CanUsage, val can: Int, val rewardCan: Int, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt index 977294a6..cbfcef2c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt @@ -145,9 +145,11 @@ class CanServiceTest { audioContentTitle: String? = null, communityPostMemberNickname: String? = null, auditionTitle: String? = null, - characterName: String? = null + characterName: String? = null, + id: Long = 1L ): UseCanQueryDto { return UseCanQueryDto( + id = id, canUsage = usage, can = can, rewardCan = rewardCan, From 5d7bb8590f6922bf0fbc2e690ce469290afa7790 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 16:01:42 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(can):=20=EC=BA=94=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=ED=99=98?= =?UTF-8?q?=EB=B6=88=EB=90=9C=20=EC=82=AC=EC=9A=A9=20=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=9D=80=20=EC=A1=B0=ED=9A=8C=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index cadeb48f..eba01100 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -122,6 +122,7 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .leftJoin(qRecipientMember).on(useCanCalculate.recipientCreatorId.eq(qRecipientMember.id)) .where( useCan.member.id.eq(member.id) + .and(useCan.isRefund.isFalse) .and(useCan.can.add(useCan.rewardCan).gt(0)) .and(gatewayCondition) )