feat(admin-calculate): 관리자 라이브 환불 처리와 정산 응답 식별자를 추가한다

This commit is contained in:
2026-03-16 12:25:50 +09:00
parent 02196eba4c
commit e2cbca1b84
10 changed files with 412 additions and 20 deletions

View File

@@ -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개의 테스트 모두 성공 확인

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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())
}
}
}

View File

@@ -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?
)

View File

@@ -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,

View File

@@ -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,

View File

@@ -28,6 +28,7 @@ interface CanQueryRepository {
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
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<UseCan>
}
@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<UseCan> {
return queryFactory
.selectFrom(useCan)
.innerJoin(useCan.room, liveRoom)
.where(
liveRoom.id.eq(roomId)
.and(useCan.canUsage.eq(canUsage))
.and(useCan.isRefund.isFalse)
)
.fetch()
}
}

View File

@@ -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(),

View File

@@ -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)
}
}