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
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.core.io.InputStreamResource
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.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@RestController
@PreAuthorize("hasRole('ADMIN')")
@@ -14,6 +20,20 @@ import org.springframework.web.bind.annotation.RestController
class AdminChannelDonationCalculateController(
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")
fun getChannelDonationByCreator(
@RequestParam startDateStr: String,
@@ -27,4 +47,28 @@ class AdminChannelDonationCalculateController(
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(
private val queryFactory: JPAQueryFactory
) {
fun getChannelDonationByDateTotal(
startDate: LocalDateTime,
endDate: LocalDateTime
): GetAdminChannelDonationSettlementTotalQueryData {
return getChannelDonationSettlementTotal(startDate, endDate)
}
fun getChannelDonationByCreatorTotal(
startDate: LocalDateTime,
endDate: LocalDateTime
): GetAdminChannelDonationSettlementTotalQueryData {
return getChannelDonationSettlementTotal(startDate, endDate)
}
private fun getChannelDonationSettlementTotal(
startDate: LocalDateTime,
endDate: LocalDateTime
): GetAdminChannelDonationSettlementTotalQueryData {
return queryFactory
.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 distinctGroupKey = Expressions.stringTemplate(
"CONCAT({0}, '-', {1})",
@@ -59,7 +73,20 @@ class AdminChannelDonationCalculateQueryRepository(
?: 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,
endDate: LocalDateTime,
offset: Long,
@@ -88,6 +115,54 @@ class AdminChannelDonationCalculateQueryRepository(
.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(
startDate: LocalDateTime,
endDate: LocalDateTime

View File

@@ -1,15 +1,18 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@Service
class AdminChannelDonationCalculateService(
private val repository: AdminChannelDonationCalculateQueryRepository
) {
@Transactional(readOnly = true)
fun getChannelDonationByCreator(
fun getChannelDonationByDate(
startDateStr: String,
endDateStr: String,
offset: Long,
@@ -18,12 +21,75 @@ class AdminChannelDonationCalculateService(
val startDate = startDateStr.convertLocalDateTime()
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 totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate)
val items = repository
.getChannelDonationByCreator(startDate, endDate, offset, limit)
.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
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.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.core.io.InputStreamResource
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpHeaders
import java.io.ByteArrayInputStream
class AdminChannelDonationCalculateControllerTest {
private lateinit var service: AdminChannelDonationCalculateService
@@ -18,8 +22,8 @@ class AdminChannelDonationCalculateControllerTest {
}
@Test
@DisplayName("관리자 컨트롤러는 날짜/페이지 파라미터를 서비스로 전달한다")
fun shouldForwardDateRangeAndPageableToService() {
@DisplayName("관리자 컨트롤러는 날짜별 조회 파라미터를 서비스로 전달한다")
fun shouldForwardDateRangeAndPageableToDateService() {
val response = GetAdminChannelDonationSettlementResponse(
totalCount = 1,
total = GetAdminChannelDonationSettlementTotal(
@@ -47,7 +51,7 @@ class AdminChannelDonationCalculateControllerTest {
)
Mockito.`when`(
service.getChannelDonationByCreator(
service.getChannelDonationByDate(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
offset = 15L,
@@ -55,7 +59,7 @@ class AdminChannelDonationCalculateControllerTest {
)
).thenReturn(response)
val apiResponse = controller.getChannelDonationByCreator(
val apiResponse = controller.getChannelDonationByDate(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
pageable = PageRequest.of(1, 15)
@@ -68,11 +72,98 @@ class AdminChannelDonationCalculateControllerTest {
assertEquals(2, apiResponse.data!!.items[0].count)
assertEquals(20, apiResponse.data!!.items[0].totalCan)
Mockito.verify(service).getChannelDonationByCreator(
Mockito.verify(service).getChannelDonationByDate(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
offset = 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
@DisplayName("동일 후원의 분할 정산 레코드 건수 중복 집계하지 않는다")
fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculations() {
@DisplayName("날짜별 조회는 동일 후원의 분할 정산 레코드 건수 중복 집계하지 않는다")
fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculationsByDate() {
val creator = saveMember(nickname = "creator-admin", role = MemberRole.CREATOR)
val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER)
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 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 total = repository.getChannelDonationByDateTotal(startDate, endDate)
val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate)
val items = repository.getChannelDonationByDate(startDate, endDate, offset = 0, limit = 20)
assertEquals(1L, total.count)
assertEquals(50, total.totalCan)
@@ -79,6 +79,55 @@ class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor(
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 {
return memberRepository.saveAndFlush(
Member(

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.channelDonation
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
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.DisplayName
import org.junit.jupiter.api.Test
@@ -18,8 +19,8 @@ class AdminChannelDonationCalculateServiceTest {
}
@Test
@DisplayName("관리자 정산 조회는 날짜 범위를 변환하고 크리에이터 그룹 응답을 반환한다")
fun shouldConvertDateRangeAndReturnCreatorGroupedItems() {
@DisplayName("관리자 날짜별 정산 조회는 날짜 범위를 변환하고 그룹 응답을 반환한다")
fun shouldConvertDateRangeAndReturnDateGroupedItems() {
val queryData = GetAdminChannelDonationSettlementQueryData(
date = "2026-02-26",
creator = "creator-a",
@@ -31,6 +32,75 @@ class AdminChannelDonationCalculateServiceTest {
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`(
repository.getChannelDonationByCreatorTotal(
"2026-02-20".convertLocalDateTime(),
@@ -62,14 +132,10 @@ class AdminChannelDonationCalculateServiceTest {
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).getChannelDonationByCreatorTotal(
"2026-02-20".convertLocalDateTime(),
@@ -86,4 +152,35 @@ class AdminChannelDonationCalculateServiceTest {
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)
)
}
}