Merge pull request 'fix(channel-donation): 관리자 채널후원 정산 조회를 날짜별과 크리에이터별로 분리하고 엑셀 다운로드를 추가한다' (#396) from test into main

Reviewed-on: #396
This commit is contained in:
2026-03-03 05:59:42 +00:00
11 changed files with 540 additions and 20 deletions

View File

@@ -0,0 +1,18 @@
# 관리자 채널후원 정산 리뷰 지적사항 반영 작업 계획
- [x] 하위 호환성 유지 이슈는 요구사항 재확인 결과, 기존 이름을 신규 목적 경로로 사용하기로 확정되어 작업 범위에서 제외한다.
- [x] 엑셀 다운로드 API 테스트에서 `Content-Disposition` 헤더를 실질적으로 검증하도록 보강한다.
- [x] 관련 테스트와 빌드를 실행해 회귀 여부를 확인한다.
## 검증 기록
### 1차 반영
- 무엇을: 엑셀 다운로드 컨트롤러 테스트에서 `Content-Disposition` 헤더를 `getFirst(HttpHeaders.CONTENT_DISPOSITION)`로 조회하고, `attachment; filename*=` 포함 여부를 검증하도록 수정했다.
- 왜: 기존 `response.headers.contentDisposition` null 체크만으로는 헤더 누락/형식 회귀를 충분히 잡지 못해 테스트 신뢰도를 높이기 위해서다.
- 어떻게:
- 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt`
- 범위 조정: 하위 호환성 유지 이슈는 요구사항 재확인 결과 작업 제외로 확정
- 실행 결과:
- `lsp_diagnostics (AdminChannelDonationCalculateControllerTest.kt)` → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*AdminChannelDonationCalculateControllerTest"` → 성공
- `./gradlew build` → 성공

View File

@@ -0,0 +1,34 @@
# 관리자 채널후원 크리에이터별 정산 조회 및 엑셀 API 작업 계획
- [x] 기존 관리자 채널후원 정산 API의 날짜별 조회 경로를 식별하고 URL 변경 범위를 확정한다.
- [x] 관리자 채널후원 정산 날짜별 조회 API URL을 목적에 맞게 변경한다.
- [x] 관리자 크리에이터별 채널후원 정산 조회 API(`GET /admin/calculate/channel-donation-by-creator`)를 구현한다.
- [x] 관리자 크리에이터별 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-creator/excel`)를 구현한다.
- [x] 크리에이터별 집계/카운트/합계 Query를 추가하고, 정산 계산 비율은 기존 채널후원 정산과 동일하게 적용한다.
- [x] 관련 테스트를 수정/추가하고 `./gradlew test`, `./gradlew build`로 검증한다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 채널후원 정산 API를 날짜별/크리에이터별로 분리하고, 크리에이터별 정산 엑셀 다운로드 API를 추가했다.
- 왜: 기존 `/admin/calculate/channel-donation-by-creator`가 날짜별 조회 성격이어서 URL 의미를 분리하고, 요청한 크리에이터별 목록/엑셀 기능을 제공하기 위해서다.
- 어떻게:
- 컨트롤러에서 기존 날짜별 조회 경로를 `GET /admin/calculate/channel-donation-by-date`로 변경했다.
- 신규 크리에이터별 조회 `GET /admin/calculate/channel-donation-by-creator`와 엑셀 다운로드 `GET /admin/calculate/channel-donation-by-creator/excel`를 추가했다.
- QueryRepository에 날짜별/크리에이터별 집계 메서드를 분리하고, 크리에이터별 총건수(distinct creator) 및 엑셀용 전체 조회를 추가했다.
- 서비스에서 크리에이터별 조회 응답 DTO와 엑셀(XSSFWorkbook) 생성 로직을 구현했다.
- 정산 비율/공식은 기존 `ChannelDonationSettlementCalculator`를 그대로 사용해 동일 정책을 유지했다.
- 테스트를 수정/추가해 날짜별 라우팅, 크리에이터별 조회, 엑셀 다운로드, Query 집계를 검증했다.
- 실행 결과:
- `lsp_diagnostics` (수정된 `.kt` 파일들) → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*channelDonation*"` → 성공
- `./gradlew build` → 성공
### 2차 수정
- 무엇을: 크리에이터별 정산 엑셀 다운로드 파일의 시트명과 헤더를 한글로 변경했다.
- 왜: 관리자 화면에서 다운로드한 엑셀의 컬럼 의미를 즉시 식별할 수 있도록 가독성을 높이기 위해서다.
- 어떻게:
- 시트명 `channel-donation-by-creator``크리에이터별 채널후원 정산`으로 변경했다.
- 헤더를 `크리에이터`, `건수`, `총 받은 캔 수`, `원화`, `수수료`, `정산금액`, `원천세`, `입금액`으로 변경했다.
- 실행 결과:
- `./gradlew test --tests "*channelDonation*"` → 성공

View File

@@ -1,12 +1,18 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.core.io.InputStreamResource
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
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.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@RestController @RestController
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@@ -14,6 +20,20 @@ import org.springframework.web.bind.annotation.RestController
class AdminChannelDonationCalculateController( class AdminChannelDonationCalculateController(
private val service: AdminChannelDonationCalculateService private val service: AdminChannelDonationCalculateService
) { ) {
@GetMapping("/channel-donation-by-date")
fun getChannelDonationByDate(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getChannelDonationByDate(
startDateStr = startDateStr,
endDateStr = endDateStr,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
@GetMapping("/channel-donation-by-creator") @GetMapping("/channel-donation-by-creator")
fun getChannelDonationByCreator( fun getChannelDonationByCreator(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@@ -27,4 +47,28 @@ class AdminChannelDonationCalculateController(
limit = pageable.pageSize.toLong() limit = pageable.pageSize.toLong()
) )
) )
@GetMapping("/channel-donation-by-creator/excel")
fun downloadChannelDonationByCreatorExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<InputStreamResource> {
val encodedFileName = URLEncoder.encode(
"channel-donation-by-creator.xlsx",
StandardCharsets.UTF_8.toString()
).replace("+", "%20")
val headers = HttpHeaders().apply {
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
}
val response = service.downloadChannelDonationByCreatorExcel(
startDateStr = startDateStr,
endDateStr = endDateStr
)
return ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(InputStreamResource(response))
}
} }

View File

@@ -16,9 +16,23 @@ import java.time.LocalDateTime
class AdminChannelDonationCalculateQueryRepository( class AdminChannelDonationCalculateQueryRepository(
private val queryFactory: JPAQueryFactory private val queryFactory: JPAQueryFactory
) { ) {
fun getChannelDonationByDateTotal(
startDate: LocalDateTime,
endDate: LocalDateTime
): GetAdminChannelDonationSettlementTotalQueryData {
return getChannelDonationSettlementTotal(startDate, endDate)
}
fun getChannelDonationByCreatorTotal( fun getChannelDonationByCreatorTotal(
startDate: LocalDateTime, startDate: LocalDateTime,
endDate: LocalDateTime endDate: LocalDateTime
): GetAdminChannelDonationSettlementTotalQueryData {
return getChannelDonationSettlementTotal(startDate, endDate)
}
private fun getChannelDonationSettlementTotal(
startDate: LocalDateTime,
endDate: LocalDateTime
): GetAdminChannelDonationSettlementTotalQueryData { ): GetAdminChannelDonationSettlementTotalQueryData {
return queryFactory return queryFactory
.select( .select(
@@ -39,7 +53,7 @@ class AdminChannelDonationCalculateQueryRepository(
) )
} }
fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { fun getChannelDonationByDateTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
val formattedDate = getFormattedDate(useCan.createdAt) val formattedDate = getFormattedDate(useCan.createdAt)
val distinctGroupKey = Expressions.stringTemplate( val distinctGroupKey = Expressions.stringTemplate(
"CONCAT({0}, '-', {1})", "CONCAT({0}, '-', {1})",
@@ -59,7 +73,20 @@ class AdminChannelDonationCalculateQueryRepository(
?: 0 ?: 0
} }
fun getChannelDonationByCreator( fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(member.id.countDistinct())
.from(useCanCalculate)
.innerJoin(useCanCalculate.useCan, useCan)
.innerJoin(member)
.on(member.id.eq(useCanCalculate.recipientCreatorId))
.where(baseWhereCondition(startDate, endDate))
.fetchOne()
?.toInt()
?: 0
}
fun getChannelDonationByDate(
startDate: LocalDateTime, startDate: LocalDateTime,
endDate: LocalDateTime, endDate: LocalDateTime,
offset: Long, offset: Long,
@@ -88,6 +115,54 @@ class AdminChannelDonationCalculateQueryRepository(
.fetch() .fetch()
} }
fun getChannelDonationByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
offset: Long,
limit: Long
): List<GetAdminChannelDonationSettlementByCreatorQueryData> {
return queryFactory
.select(
QGetAdminChannelDonationSettlementByCreatorQueryData(
member.nickname,
useCan.id.countDistinct(),
useCanCalculate.can.sum()
)
)
.from(useCanCalculate)
.innerJoin(useCanCalculate.useCan, useCan)
.innerJoin(member)
.on(member.id.eq(useCanCalculate.recipientCreatorId))
.where(baseWhereCondition(startDate, endDate))
.groupBy(member.id)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
}
fun getChannelDonationByCreatorForExcel(
startDate: LocalDateTime,
endDate: LocalDateTime
): List<GetAdminChannelDonationSettlementByCreatorQueryData> {
return queryFactory
.select(
QGetAdminChannelDonationSettlementByCreatorQueryData(
member.nickname,
useCan.id.countDistinct(),
useCanCalculate.can.sum()
)
)
.from(useCanCalculate)
.innerJoin(useCanCalculate.useCan, useCan)
.innerJoin(member)
.on(member.id.eq(useCanCalculate.recipientCreatorId))
.where(baseWhereCondition(startDate, endDate))
.groupBy(member.id)
.orderBy(member.id.desc())
.fetch()
}
private fun baseWhereCondition( private fun baseWhereCondition(
startDate: LocalDateTime, startDate: LocalDateTime,
endDate: LocalDateTime endDate: LocalDateTime

View File

@@ -1,15 +1,18 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@Service @Service
class AdminChannelDonationCalculateService( class AdminChannelDonationCalculateService(
private val repository: AdminChannelDonationCalculateQueryRepository private val repository: AdminChannelDonationCalculateQueryRepository
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getChannelDonationByCreator( fun getChannelDonationByDate(
startDateStr: String, startDateStr: String,
endDateStr: String, endDateStr: String,
offset: Long, offset: Long,
@@ -18,12 +21,75 @@ class AdminChannelDonationCalculateService(
val startDate = startDateStr.convertLocalDateTime() val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
val total = repository.getChannelDonationByDateTotal(startDate, endDate).toResponseTotal()
val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate)
val items = repository
.getChannelDonationByDate(startDate, endDate, offset, limit)
.map { it.toResponseItem() }
return GetAdminChannelDonationSettlementResponse(totalCount, total, items)
}
@Transactional(readOnly = true)
fun getChannelDonationByCreator(
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetAdminChannelDonationSettlementByCreatorResponse {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
val total = repository.getChannelDonationByCreatorTotal(startDate, endDate).toResponseTotal() val total = repository.getChannelDonationByCreatorTotal(startDate, endDate).toResponseTotal()
val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate)
val items = repository val items = repository
.getChannelDonationByCreator(startDate, endDate, offset, limit) .getChannelDonationByCreator(startDate, endDate, offset, limit)
.map { it.toResponseItem() } .map { it.toResponseItem() }
return GetAdminChannelDonationSettlementResponse(totalCount, total, items) return GetAdminChannelDonationSettlementByCreatorResponse(totalCount, total, items)
}
@Transactional(readOnly = true)
fun downloadChannelDonationByCreatorExcel(startDateStr: String, endDateStr: String): ByteArrayInputStream {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
val items = repository
.getChannelDonationByCreatorForExcel(startDate, endDate)
.map { it.toResponseItem() }
val byteArrayOutputStream = ByteArrayOutputStream()
XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("크리에이터별 채널후원 정산")
val header = listOf(
"크리에이터",
"건수",
"총 받은 캔 수",
"원화",
"수수료",
"정산금액",
"원천세",
"입금액"
)
val headerRow = sheet.createRow(0)
header.forEachIndexed { index, value ->
headerRow.createCell(index).setCellValue(value)
}
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.creator)
row.createCell(1).setCellValue(item.count.toDouble())
row.createCell(2).setCellValue(item.totalCan.toDouble())
row.createCell(3).setCellValue(item.krw.toDouble())
row.createCell(4).setCellValue(item.fee.toDouble())
row.createCell(5).setCellValue(item.settlementAmount.toDouble())
row.createCell(6).setCellValue(item.withholdingTax.toDouble())
row.createCell(7).setCellValue(item.depositAmount.toDouble())
}
workbook.write(byteArrayOutputStream)
}
return ByteArrayInputStream(byteArrayOutputStream.toByteArray())
} }
} }

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import com.fasterxml.jackson.annotation.JsonProperty
data class GetAdminChannelDonationSettlementByCreatorItem(
@JsonProperty("creator") val creator: String,
@JsonProperty("count") val count: Int,
@JsonProperty("totalCan") val totalCan: Int,
@JsonProperty("krw") val krw: Int,
@JsonProperty("fee") val fee: Int,
@JsonProperty("settlementAmount") val settlementAmount: Int,
@JsonProperty("withholdingTax") val withholdingTax: Int,
@JsonProperty("depositAmount") val depositAmount: Int
)

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
data class GetAdminChannelDonationSettlementByCreatorQueryData @QueryProjection constructor(
val creator: String,
val count: Long,
val totalCan: Int?
) {
fun toResponseItem(): GetAdminChannelDonationSettlementByCreatorItem {
val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0)
return GetAdminChannelDonationSettlementByCreatorItem(
creator = creator,
count = count.toInt(),
totalCan = totalCan ?: 0,
krw = settlement.krw,
fee = settlement.fee,
settlementAmount = settlement.settlementAmount,
withholdingTax = settlement.withholdingTax,
depositAmount = settlement.depositAmount
)
}
}

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
data class GetAdminChannelDonationSettlementByCreatorResponse(
val totalCount: Int,
val total: GetAdminChannelDonationSettlementTotal,
val items: List<GetAdminChannelDonationSettlementByCreatorItem>
)

View File

@@ -1,11 +1,15 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.core.io.InputStreamResource
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpHeaders
import java.io.ByteArrayInputStream
class AdminChannelDonationCalculateControllerTest { class AdminChannelDonationCalculateControllerTest {
private lateinit var service: AdminChannelDonationCalculateService private lateinit var service: AdminChannelDonationCalculateService
@@ -18,8 +22,8 @@ class AdminChannelDonationCalculateControllerTest {
} }
@Test @Test
@DisplayName("관리자 컨트롤러는 날짜/페이지 파라미터를 서비스로 전달한다") @DisplayName("관리자 컨트롤러는 날짜별 조회 파라미터를 서비스로 전달한다")
fun shouldForwardDateRangeAndPageableToService() { fun shouldForwardDateRangeAndPageableToDateService() {
val response = GetAdminChannelDonationSettlementResponse( val response = GetAdminChannelDonationSettlementResponse(
totalCount = 1, totalCount = 1,
total = GetAdminChannelDonationSettlementTotal( total = GetAdminChannelDonationSettlementTotal(
@@ -47,7 +51,7 @@ class AdminChannelDonationCalculateControllerTest {
) )
Mockito.`when`( Mockito.`when`(
service.getChannelDonationByCreator( service.getChannelDonationByDate(
startDateStr = "2026-02-20", startDateStr = "2026-02-20",
endDateStr = "2026-02-21", endDateStr = "2026-02-21",
offset = 15L, offset = 15L,
@@ -55,7 +59,7 @@ class AdminChannelDonationCalculateControllerTest {
) )
).thenReturn(response) ).thenReturn(response)
val apiResponse = controller.getChannelDonationByCreator( val apiResponse = controller.getChannelDonationByDate(
startDateStr = "2026-02-20", startDateStr = "2026-02-20",
endDateStr = "2026-02-21", endDateStr = "2026-02-21",
pageable = PageRequest.of(1, 15) pageable = PageRequest.of(1, 15)
@@ -68,11 +72,98 @@ class AdminChannelDonationCalculateControllerTest {
assertEquals(2, apiResponse.data!!.items[0].count) assertEquals(2, apiResponse.data!!.items[0].count)
assertEquals(20, apiResponse.data!!.items[0].totalCan) assertEquals(20, apiResponse.data!!.items[0].totalCan)
Mockito.verify(service).getChannelDonationByCreator( Mockito.verify(service).getChannelDonationByDate(
startDateStr = "2026-02-20", startDateStr = "2026-02-20",
endDateStr = "2026-02-21", endDateStr = "2026-02-21",
offset = 15L, offset = 15L,
limit = 15L limit = 15L
) )
} }
@Test
@DisplayName("관리자 컨트롤러는 크리에이터별 조회 파라미터를 서비스로 전달한다")
fun shouldForwardDateRangeAndPageableToCreatorService() {
val response = GetAdminChannelDonationSettlementByCreatorResponse(
totalCount = 1,
total = GetAdminChannelDonationSettlementTotal(
count = 5,
totalCan = 35,
krw = 3500,
fee = 231,
settlementAmount = 2770,
withholdingTax = 91,
depositAmount = 2679
),
items = listOf(
GetAdminChannelDonationSettlementByCreatorItem(
creator = "creator-a",
count = 2,
totalCan = 20,
krw = 2000,
fee = 132,
settlementAmount = 1588,
withholdingTax = 52,
depositAmount = 1536
)
)
)
Mockito.`when`(
service.getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
offset = 0L,
limit = 20L
)
).thenReturn(response)
val apiResponse = controller.getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
pageable = PageRequest.of(0, 20)
)
assertEquals(true, apiResponse.success)
assertEquals(1, apiResponse.data!!.totalCount)
assertEquals("creator-a", apiResponse.data!!.items[0].creator)
Mockito.verify(service).getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
offset = 0L,
limit = 20L
)
}
@Test
@DisplayName("관리자 컨트롤러는 크리에이터별 정산 엑셀을 다운로드한다")
fun shouldDownloadCreatorSettlementExcel() {
Mockito.`when`(
service.downloadChannelDonationByCreatorExcel(
startDateStr = "2026-02-01",
endDateStr = "2026-02-29"
)
).thenReturn(ByteArrayInputStream(byteArrayOf(1, 2, 3)))
val response = controller.downloadChannelDonationByCreatorExcel(
startDateStr = "2026-02-01",
endDateStr = "2026-02-29"
)
assertEquals(200, response.statusCode.value())
val contentDispositionHeader = response.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION)
assertNotNull(contentDispositionHeader)
assertEquals(true, contentDispositionHeader?.contains("attachment; filename*="))
assertEquals(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
response.headers.contentType.toString()
)
assertNotNull(response.body)
assertEquals(true, response.body is InputStreamResource)
Mockito.verify(service).downloadChannelDonationByCreatorExcel(
startDateStr = "2026-02-01",
endDateStr = "2026-02-29"
)
}
} }

View File

@@ -40,8 +40,8 @@ class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("동일 후원의 분할 정산 레코드 건수 중복 집계하지 않는다") @DisplayName("날짜별 조회는 동일 후원의 분할 정산 레코드 건수 중복 집계하지 않는다")
fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculations() { fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculationsByDate() {
val creator = saveMember(nickname = "creator-admin", role = MemberRole.CREATOR) val creator = saveMember(nickname = "creator-admin", role = MemberRole.CREATOR)
val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER) val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER)
val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0) val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0)
@@ -65,9 +65,9 @@ class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor(
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val total = repository.getChannelDonationByCreatorTotal(startDate, endDate) val total = repository.getChannelDonationByDateTotal(startDate, endDate)
val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate)
val items = repository.getChannelDonationByCreator(startDate, endDate, offset = 0, limit = 20) val items = repository.getChannelDonationByDate(startDate, endDate, offset = 0, limit = 20)
assertEquals(1L, total.count) assertEquals(1L, total.count)
assertEquals(50, total.totalCan) assertEquals(50, total.totalCan)
@@ -79,6 +79,55 @@ class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor(
assertEquals(50, items[0].totalCan) assertEquals(50, items[0].totalCan)
} }
@Test
@DisplayName("크리에이터별 조회는 크리에이터 기준으로 집계한다")
fun shouldAggregateByCreator() {
val creatorA = saveMember(nickname = "creator-a", role = MemberRole.CREATOR)
val creatorB = saveMember(nickname = "creator-b", role = MemberRole.CREATOR)
val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER)
val useCanA = saveUseCan(member = sender, can = 70, rewardCan = 0)
val useCanB = saveUseCan(member = sender, can = 30, rewardCan = 0)
saveUseCanCalculate(
useCan = useCanA,
recipientCreatorId = creatorA.id!!,
can = 70,
paymentGateway = PaymentGateway.PG
)
saveUseCanCalculate(
useCan = useCanB,
recipientCreatorId = creatorB.id!!,
can = 30,
paymentGateway = PaymentGateway.PG
)
updateUseCanCreatedAt(useCanA.id!!, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
updateUseCanCreatedAt(useCanB.id!!, LocalDateTime.of(2026, 2, 20, 11, 0, 0))
entityManager.flush()
entityManager.clear()
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val total = repository.getChannelDonationByCreatorTotal(startDate, endDate)
val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate)
val items = repository.getChannelDonationByCreator(startDate, endDate, offset = 0, limit = 20)
val excelItems = repository.getChannelDonationByCreatorForExcel(startDate, endDate)
assertEquals(2L, total.count)
assertEquals(100, total.totalCan)
assertEquals(2, totalCount)
assertEquals(2, items.size)
assertEquals(2, excelItems.size)
assertEquals("creator-b", items[0].creator)
assertEquals(1L, items[0].count)
assertEquals(30, items[0].totalCan)
assertEquals("creator-a", items[1].creator)
assertEquals(1L, items[1].count)
assertEquals(70, items[1].totalCan)
}
private fun saveMember(nickname: String, role: MemberRole): Member { private fun saveMember(nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush( return memberRepository.saveAndFlush(
Member( Member(

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -18,8 +19,8 @@ class AdminChannelDonationCalculateServiceTest {
} }
@Test @Test
@DisplayName("관리자 정산 조회는 날짜 범위를 변환하고 크리에이터 그룹 응답을 반환한다") @DisplayName("관리자 날짜별 정산 조회는 날짜 범위를 변환하고 그룹 응답을 반환한다")
fun shouldConvertDateRangeAndReturnCreatorGroupedItems() { fun shouldConvertDateRangeAndReturnDateGroupedItems() {
val queryData = GetAdminChannelDonationSettlementQueryData( val queryData = GetAdminChannelDonationSettlementQueryData(
date = "2026-02-26", date = "2026-02-26",
creator = "creator-a", creator = "creator-a",
@@ -31,6 +32,75 @@ class AdminChannelDonationCalculateServiceTest {
totalCan = 250 totalCan = 250
) )
Mockito.`when`(
repository.getChannelDonationByDateTotal(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
)
).thenReturn(totalQueryData)
Mockito.`when`(
repository.getChannelDonationByDateTotalCount(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
)
).thenReturn(1)
Mockito.`when`(
repository.getChannelDonationByDate(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
0L,
20L
)
).thenReturn(listOf(queryData))
val result = service.getChannelDonationByDate(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
offset = 0,
limit = 20
)
assertEquals(1, result.totalCount)
assertEquals(8, result.total.count)
assertEquals(250, result.total.totalCan)
assertEquals(25_000, result.total.krw)
assertEquals(1, result.items.size)
assertEquals("2026-02-26", result.items[0].date)
assertEquals("creator-a", result.items[0].creator)
assertEquals(3, result.items[0].count)
assertEquals(100, result.items[0].totalCan)
assertEquals(10_000, result.items[0].krw)
assertEquals(660, result.items[0].fee)
Mockito.verify(repository).getChannelDonationByDateTotal(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
)
Mockito.verify(repository).getChannelDonationByDateTotalCount(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
)
Mockito.verify(repository).getChannelDonationByDate(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
0L,
20L
)
}
@Test
@DisplayName("관리자 크리에이터별 정산 조회는 날짜 범위를 변환하고 크리에이터 그룹 응답을 반환한다")
fun shouldConvertDateRangeAndReturnCreatorGroupedItems() {
val queryData = GetAdminChannelDonationSettlementByCreatorQueryData(
creator = "creator-a",
count = 3L,
totalCan = 100
)
val totalQueryData = GetAdminChannelDonationSettlementTotalQueryData(
count = 8L,
totalCan = 250
)
Mockito.`when`( Mockito.`when`(
repository.getChannelDonationByCreatorTotal( repository.getChannelDonationByCreatorTotal(
"2026-02-20".convertLocalDateTime(), "2026-02-20".convertLocalDateTime(),
@@ -62,14 +132,10 @@ class AdminChannelDonationCalculateServiceTest {
assertEquals(1, result.totalCount) assertEquals(1, result.totalCount)
assertEquals(8, result.total.count) assertEquals(8, result.total.count)
assertEquals(250, result.total.totalCan) assertEquals(250, result.total.totalCan)
assertEquals(25_000, result.total.krw)
assertEquals(1, result.items.size) assertEquals(1, result.items.size)
assertEquals("2026-02-26", result.items[0].date)
assertEquals("creator-a", result.items[0].creator) assertEquals("creator-a", result.items[0].creator)
assertEquals(3, result.items[0].count) assertEquals(3, result.items[0].count)
assertEquals(100, result.items[0].totalCan) assertEquals(100, result.items[0].totalCan)
assertEquals(10_000, result.items[0].krw)
assertEquals(660, result.items[0].fee)
Mockito.verify(repository).getChannelDonationByCreatorTotal( Mockito.verify(repository).getChannelDonationByCreatorTotal(
"2026-02-20".convertLocalDateTime(), "2026-02-20".convertLocalDateTime(),
@@ -86,4 +152,35 @@ class AdminChannelDonationCalculateServiceTest {
20L 20L
) )
} }
@Test
@DisplayName("관리자 크리에이터별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다")
fun shouldGenerateCreatorSettlementExcelBytes() {
Mockito.`when`(
repository.getChannelDonationByCreatorForExcel(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
)
).thenReturn(
listOf(
GetAdminChannelDonationSettlementByCreatorQueryData(
creator = "creator-a",
count = 3L,
totalCan = 100
)
)
)
val response = service.downloadChannelDonationByCreatorExcel(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
assertTrue(response.readAllBytes().isNotEmpty())
Mockito.verify(repository).getChannelDonationByCreatorForExcel(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
)
}
} }