fix(calculate): 관리자 정산 엑셀 다운로드를 스트리밍 방식으로 전환한다

This commit is contained in:
2026-03-05 12:21:57 +09:00
parent 07f8d22024
commit 6ac94174c8
8 changed files with 619 additions and 36 deletions

View File

@@ -0,0 +1,32 @@
# 관리자 정산 엑셀 다운로드 추가 작업 계획
- [x] 기존 정산 API 구조와 엑셀 다운로드 응답 패턴(`ResponseEntity<InputStreamResource>`)을 기준으로 구현 범위를 확정한다.
- [x] 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live/excel`)를 추가한다.
- [x] 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-list/excel`)를 추가한다.
- [x] 콘텐츠 후원 정산 엑셀 다운로드 API(`GET /admin/calculate/content-donation-list/excel`)를 추가한다.
- [x] 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-post/excel`)를 추가한다.
- [x] 크리에이터별 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live-by-creator/excel`)를 추가한다.
- [x] 크리에이터별 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-by-creator/excel`)를 추가한다.
- [x] 크리에이터별 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-by-creator/excel`)를 추가한다.
- [x] 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-date/excel`)를 추가한다.
- [x] 각 엑셀 API가 시작/끝 날짜를 받아 전체 데이터를 내려주도록 서비스/리포지토리를 확장한다.
- [x] `lsp_diagnostics`, 테스트, 빌드로 변경사항을 검증하고 결과를 문서 하단에 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 정산 API 8종(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별)에 `/excel` 다운로드 엔드포인트를 추가하고, 전체 데이터 엑셀 생성 서비스를 구현했다.
- 왜: 페이지네이션 기반 조회 API와 별도로 시작일/종료일 기준의 전체 정산 데이터를 한 번에 내려받을 수 있어야 한다는 요구사항을 충족하기 위해서다.
- 어떻게:
- `AdminCalculateController`에 7개 엔드포인트(`.../excel`)를 추가하고 공통 다운로드 헤더(`Content-Disposition`, xlsx content type)를 적용했다.
- `AdminCalculateService`에 7개 엑셀 생성 메서드를 추가해 기간 변환 후 전체 데이터 조회 및 `XSSFWorkbook` 기반 시트/헤더/행 작성을 구현했다.
- 페이지네이션 대상(커뮤니티 정산, 크리에이터별 정산 3종)은 `totalCount`를 조회해 `offset=0`, `limit=totalCount`로 전체 행을 조회하도록 처리했다.
- `AdminChannelDonationCalculateController``GET /admin/calculate/channel-donation-by-date/excel`를 추가하고 기존 크리에이터별 엑셀 응답 로직과 동일한 규칙을 적용했다.
- `AdminChannelDonationCalculateService`에 날짜별 엑셀 다운로드 메서드를 추가해 전체 데이터 기준 시트를 생성했다.
- 테스트를 보강했다.
- `AdminChannelDonationCalculateControllerTest`: 날짜별 엑셀 다운로드 테스트 추가
- `AdminChannelDonationCalculateServiceTest`: 날짜별 엑셀 바이트 생성 테스트 추가
- 실행 결과:
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
- `./gradlew build` → 성공

View File

@@ -0,0 +1,27 @@
# 관리자 정산 엑셀 스트리밍 전환 작업 계획
- [x] 기존 정산 엑셀 다운로드 API의 요청/응답 계약(엔드포인트, 쿼리 파라미터, 헤더)을 유지한다.
- [x] `AdminCalculateController`의 엑셀 응답 타입을 `StreamingResponseBody` 기반으로 전환한다.
- [x] `AdminCalculateService`의 엑셀 생성 방식을 `XSSFWorkbook + ByteArrayOutputStream`에서 `SXSSFWorkbook + 스트리밍 write`로 전환한다.
- [x] `AdminChannelDonationCalculateController`의 날짜별/크리에이터별 엑셀 응답을 `StreamingResponseBody` 기반으로 전환한다.
- [x] `AdminChannelDonationCalculateService`의 날짜별/크리에이터별 엑셀 생성을 `SXSSFWorkbook` 스트리밍 방식으로 전환한다.
- [x] 관련 테스트를 스트리밍 응답 기준으로 수정한다.
- [x] `lsp_diagnostics`, 테스트, 빌드를 실행하고 결과를 검증 기록에 남긴다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 정산 엑셀 다운로드 API 전체(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별/채널후원 크리에이터별)의 서버 내부 생성/전송 방식을 스트리밍으로 전환했다.
- 왜: 기존 `XSSFWorkbook + ByteArrayOutputStream + InputStreamResource` 방식은 전체 워크북과 바이트 배열을 메모리에 유지해 대용량 다운로드 시 피크 메모리 사용량이 커지기 때문이다.
- 어떻게:
- 컨트롤러 응답 타입을 `ResponseEntity<StreamingResponseBody>`로 변경하고, 기존 파일명 인코딩/`Content-Disposition`/xlsx MIME 타입은 유지했다.
- 서비스 반환 타입을 `StreamingResponseBody`로 변경하고 `SXSSFWorkbook(100)`로 row window 기반 생성 후 `outputStream`에 직접 `write`하도록 변경했다.
- 스트리밍 완료 시 `workbook.dispose()``workbook.close()`를 호출해 임시 파일/리소스 해제를 보장했다.
- 채널후원 컨트롤러/서비스(날짜별, 크리에이터별)에도 동일 패턴을 적용했다.
- 테스트를 스트리밍 응답 기준으로 수정했다.
- 컨트롤러 테스트: `InputStreamResource` 검증 -> `StreamingResponseBody` 검증
- 서비스 테스트: `readAllBytes()` -> `StreamingResponseBody.writeTo(ByteArrayOutputStream)` 검증
- 실행 결과:
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
- `./gradlew build` → 성공

View File

@@ -2,11 +2,17 @@ package kr.co.vividnext.sodalive.admin.calculate
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@RestController @RestController
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@@ -18,12 +24,30 @@ class AdminCalculateController(private val service: AdminCalculateService) {
@RequestParam endDateStr: String @RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr)) ) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr))
@GetMapping("/live/excel")
fun downloadCalculateLiveExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "live.xlsx",
response = service.downloadCalculateLiveExcel(startDateStr, endDateStr)
)
@GetMapping("/content-list") @GetMapping("/content-list")
fun getCalculateContentList( fun getCalculateContentList(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@RequestParam endDateStr: String @RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateContentList(startDateStr, endDateStr)) ) = ApiResponse.ok(service.getCalculateContentList(startDateStr, endDateStr))
@GetMapping("/content-list/excel")
fun downloadCalculateContentListExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "content-list.xlsx",
response = service.downloadCalculateContentListExcel(startDateStr, endDateStr)
)
@GetMapping("/cumulative-sales-by-content") @GetMapping("/cumulative-sales-by-content")
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok( fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
service.getCumulativeSalesByContent(pageable.offset, pageable.pageSize.toLong()) service.getCumulativeSalesByContent(pageable.offset, pageable.pageSize.toLong())
@@ -35,6 +59,15 @@ class AdminCalculateController(private val service: AdminCalculateService) {
@RequestParam endDateStr: String @RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateContentDonationList(startDateStr, endDateStr)) ) = ApiResponse.ok(service.getCalculateContentDonationList(startDateStr, endDateStr))
@GetMapping("/content-donation-list/excel")
fun downloadCalculateContentDonationListExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "content-donation-list.xlsx",
response = service.downloadCalculateContentDonationListExcel(startDateStr, endDateStr)
)
@GetMapping("/community-post") @GetMapping("/community-post")
fun getCalculateCommunityPost( fun getCalculateCommunityPost(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@@ -49,6 +82,15 @@ class AdminCalculateController(private val service: AdminCalculateService) {
) )
) )
@GetMapping("/community-post/excel")
fun downloadCalculateCommunityPostExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "community-post.xlsx",
response = service.downloadCalculateCommunityPostExcel(startDateStr, endDateStr)
)
@GetMapping("/live-by-creator") @GetMapping("/live-by-creator")
fun getCalculateLiveByCreator( fun getCalculateLiveByCreator(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@@ -63,6 +105,15 @@ class AdminCalculateController(private val service: AdminCalculateService) {
) )
) )
@GetMapping("/live-by-creator/excel")
fun downloadCalculateLiveByCreatorExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "live-by-creator.xlsx",
response = service.downloadCalculateLiveByCreatorExcel(startDateStr, endDateStr)
)
@GetMapping("/content-by-creator") @GetMapping("/content-by-creator")
fun getCalculateContentByCreator( fun getCalculateContentByCreator(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@@ -77,6 +128,15 @@ class AdminCalculateController(private val service: AdminCalculateService) {
) )
) )
@GetMapping("/content-by-creator/excel")
fun downloadCalculateContentByCreatorExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "content-by-creator.xlsx",
response = service.downloadCalculateContentByCreatorExcel(startDateStr, endDateStr)
)
@GetMapping("/community-by-creator") @GetMapping("/community-by-creator")
fun getCalculateCommunityByCreator( fun getCalculateCommunityByCreator(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@@ -90,4 +150,28 @@ class AdminCalculateController(private val service: AdminCalculateService) {
pageable.pageSize.toLong() pageable.pageSize.toLong()
) )
) )
@GetMapping("/community-by-creator/excel")
fun downloadCalculateCommunityByCreatorExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "community-by-creator.xlsx",
response = service.downloadCalculateCommunityByCreatorExcel(startDateStr, endDateStr)
)
private fun createExcelResponse(
fileName: String,
response: StreamingResponseBody
): ResponseEntity<StreamingResponseBody> {
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
val headers = HttpHeaders().apply {
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
}
return ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(response)
}
} }

View File

@@ -2,9 +2,13 @@ package kr.co.vividnext.sodalive.admin.calculate
import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.time.LocalDateTime
@Service @Service
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) { class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
@@ -139,4 +143,286 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor
GetCalculateByCreatorResponse(totalCount, items) GetCalculateByCreatorResponse(totalCount, items)
} }
@Transactional(readOnly = true)
fun downloadCalculateLiveExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val items = repository
.getCalculateLive(startDate, endDate)
.map { it.toGetCalculateLiveResponse() }
return createExcelStream(
sheetName = "라이브 정산",
headers = listOf(
"이메일",
"닉네임",
"날짜",
"라이브 제목",
"입장료(캔)",
"사용구분",
"참여인원",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { 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())
}
}
}
@Transactional(readOnly = true)
fun downloadCalculateContentListExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val items = repository
.getCalculateContentList(startDate, endDate)
.map { it.toGetCalculateContentResponse() }
return createExcelStream(
sheetName = "콘텐츠 정산",
headers = listOf(
"크리에이터",
"콘텐츠 제목",
"등록일",
"판매일",
"구분",
"가격(캔)",
"인원",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.nickname)
row.createCell(1).setCellValue(item.title)
row.createCell(2).setCellValue(item.registrationDate)
row.createCell(3).setCellValue(item.saleDate)
row.createCell(4).setCellValue(item.orderType)
row.createCell(5).setCellValue(item.orderPrice.toDouble())
row.createCell(6).setCellValue(item.numberOfPeople.toDouble())
row.createCell(7).setCellValue(item.totalCan.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())
}
}
}
@Transactional(readOnly = true)
fun downloadCalculateContentDonationListExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val items = repository
.getCalculateContentDonationList(startDate, endDate)
.map { it.toGetCalculateContentDonationResponse() }
return createExcelStream(
sheetName = "콘텐츠 후원 정산",
headers = listOf(
"크리에이터",
"콘텐츠 제목",
"유무료",
"등록일",
"후원일",
"후원건수",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.nickname)
row.createCell(1).setCellValue(item.title)
row.createCell(2).setCellValue(item.paidOrFree)
row.createCell(3).setCellValue(item.registrationDate)
row.createCell(4).setCellValue(item.donationDate)
row.createCell(5).setCellValue(item.numberOfDonation.toDouble())
row.createCell(6).setCellValue(item.totalCan.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())
}
}
}
@Transactional(readOnly = true)
fun downloadCalculateCommunityPostExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateCommunityPostTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateCommunityPostList(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateCommunityPostResponse() }
}
return createExcelStream(
sheetName = "커뮤니티 정산",
headers = listOf(
"크리에이터",
"게시글",
"날짜",
"가격(캔)",
"구매건수",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.nickname)
row.createCell(1).setCellValue(item.title)
row.createCell(2).setCellValue(item.date)
row.createCell(3).setCellValue(item.can.toDouble())
row.createCell(4).setCellValue(item.numberOfPurchase.toDouble())
row.createCell(5).setCellValue(item.totalCan.toDouble())
row.createCell(6).setCellValue(item.totalKrw.toDouble())
row.createCell(7).setCellValue(item.paymentFee.toDouble())
row.createCell(8).setCellValue(item.settlementAmount.toDouble())
row.createCell(9).setCellValue(item.tax.toDouble())
row.createCell(10).setCellValue(item.depositAmount.toDouble())
}
}
}
@Transactional(readOnly = true)
fun downloadCalculateLiveByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateLiveByCreator(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateByCreator() }
}
return createCalculateByCreatorExcel("크리에이터별 라이브 정산", items)
}
@Transactional(readOnly = true)
fun downloadCalculateContentByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateContentByCreatorTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateContentByCreator(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateByCreator() }
}
return createCalculateByCreatorExcel("크리에이터별 콘텐츠 정산", items)
}
@Transactional(readOnly = true)
fun downloadCalculateCommunityByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateCommunityByCreator(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateByCreator() }
}
return createCalculateByCreatorExcel("크리에이터별 커뮤니티 정산", items)
}
private fun createCalculateByCreatorExcel(
sheetName: String,
items: List<GetCalculateByCreatorItem>
): StreamingResponseBody {
return createExcelStream(
sheetName = sheetName,
headers = listOf(
"이메일",
"닉네임",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { 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.totalCan.toDouble())
row.createCell(3).setCellValue(item.totalKrw.toDouble())
row.createCell(4).setCellValue(item.paymentFee.toDouble())
row.createCell(5).setCellValue(item.settlementAmount.toDouble())
row.createCell(6).setCellValue(item.tax.toDouble())
row.createCell(7).setCellValue(item.depositAmount.toDouble())
}
}
}
private fun createExcelStream(
sheetName: String,
headers: List<String>,
writeRows: (Sheet) -> Unit
): StreamingResponseBody {
return StreamingResponseBody { outputStream ->
val workbook = SXSSFWorkbook(100)
try {
val sheet = workbook.createSheet(sheetName)
val headerRow = sheet.createRow(0)
headers.forEachIndexed { index, value ->
headerRow.createCell(index).setCellValue(value)
}
writeRows(sheet)
workbook.write(outputStream)
outputStream.flush()
} finally {
workbook.dispose()
workbook.close()
}
}
}
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return startDate to endDate
}
} }

View File

@@ -1,7 +1,6 @@
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.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
@@ -11,6 +10,7 @@ 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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -34,6 +34,15 @@ class AdminChannelDonationCalculateController(
) )
) )
@GetMapping("/channel-donation-by-date/excel")
fun downloadChannelDonationByDateExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "channel-donation-by-date.xlsx",
response = service.downloadChannelDonationByDateExcel(startDateStr, endDateStr)
)
@GetMapping("/channel-donation-by-creator") @GetMapping("/channel-donation-by-creator")
fun getChannelDonationByCreator( fun getChannelDonationByCreator(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@@ -52,23 +61,23 @@ class AdminChannelDonationCalculateController(
fun downloadChannelDonationByCreatorExcel( fun downloadChannelDonationByCreatorExcel(
@RequestParam startDateStr: String, @RequestParam startDateStr: String,
@RequestParam endDateStr: String @RequestParam endDateStr: String
): ResponseEntity<InputStreamResource> { ): ResponseEntity<StreamingResponseBody> = createExcelResponse(
val encodedFileName = URLEncoder.encode( fileName = "channel-donation-by-creator.xlsx",
"channel-donation-by-creator.xlsx", response = service.downloadChannelDonationByCreatorExcel(startDateStr, endDateStr)
StandardCharsets.UTF_8.toString() )
).replace("+", "%20")
private fun createExcelResponse(
fileName: String,
response: StreamingResponseBody
): ResponseEntity<StreamingResponseBody> {
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
val headers = HttpHeaders().apply { val headers = HttpHeaders().apply {
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName") add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
} }
val response = service.downloadChannelDonationByCreatorExcel(
startDateStr = startDateStr,
endDateStr = endDateStr
)
return ResponseEntity.ok() return ResponseEntity.ok()
.headers(headers) .headers(headers)
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(InputStreamResource(response)) .body(response)
} }
} }

View File

@@ -1,11 +1,12 @@
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.apache.poi.ss.usermodel.Sheet
import org.apache.poi.xssf.streaming.SXSSFWorkbook
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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.io.ByteArrayOutputStream import java.time.LocalDateTime
@Service @Service
class AdminChannelDonationCalculateService( class AdminChannelDonationCalculateService(
@@ -50,17 +51,21 @@ class AdminChannelDonationCalculateService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun downloadChannelDonationByCreatorExcel(startDateStr: String, endDateStr: String): ByteArrayInputStream { fun downloadChannelDonationByDateExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val startDate = startDateStr.convertLocalDateTime() val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate)
val items = repository val items = if (totalCount == 0) {
.getChannelDonationByCreatorForExcel(startDate, endDate) emptyList()
.map { it.toResponseItem() } } else {
val byteArrayOutputStream = ByteArrayOutputStream() repository
.getChannelDonationByDate(startDate, endDate, 0L, totalCount.toLong())
.map { it.toResponseItem() }
}
XSSFWorkbook().use { workbook -> return createExcelStream(
val sheet = workbook.createSheet("크리에이터별 채널후원 정산") sheetName = "채널후원 정산",
val header = listOf( headers = listOf(
"날짜",
"크리에이터", "크리에이터",
"건수", "건수",
"총 받은 캔 수", "총 받은 캔 수",
@@ -70,11 +75,42 @@ class AdminChannelDonationCalculateService(
"원천세", "원천세",
"입금액" "입금액"
) )
val headerRow = sheet.createRow(0) ) { sheet ->
header.forEachIndexed { index, value -> items.forEachIndexed { index, item ->
headerRow.createCell(index).setCellValue(value) val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.date)
row.createCell(1).setCellValue(item.creator)
row.createCell(2).setCellValue(item.count.toDouble())
row.createCell(3).setCellValue(item.totalCan.toDouble())
row.createCell(4).setCellValue(item.krw.toDouble())
row.createCell(5).setCellValue(item.fee.toDouble())
row.createCell(6).setCellValue(item.settlementAmount.toDouble())
row.createCell(7).setCellValue(item.withholdingTax.toDouble())
row.createCell(8).setCellValue(item.depositAmount.toDouble())
} }
}
}
@Transactional(readOnly = true)
fun downloadChannelDonationByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val items = repository
.getChannelDonationByCreatorForExcel(startDate, endDate)
.map { it.toResponseItem() }
return createExcelStream(
sheetName = "크리에이터별 채널후원 정산",
headers = listOf(
"크리에이터",
"건수",
"총 받은 캔 수",
"원화",
"수수료",
"정산금액",
"원천세",
"입금액"
)
) { sheet ->
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1) val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.creator) row.createCell(0).setCellValue(item.creator)
@@ -86,10 +122,37 @@ class AdminChannelDonationCalculateService(
row.createCell(6).setCellValue(item.withholdingTax.toDouble()) row.createCell(6).setCellValue(item.withholdingTax.toDouble())
row.createCell(7).setCellValue(item.depositAmount.toDouble()) row.createCell(7).setCellValue(item.depositAmount.toDouble())
} }
workbook.write(byteArrayOutputStream)
} }
}
return ByteArrayInputStream(byteArrayOutputStream.toByteArray()) private fun createExcelStream(
sheetName: String,
headers: List<String>,
writeRows: (Sheet) -> Unit
): StreamingResponseBody {
return StreamingResponseBody { outputStream ->
val workbook = SXSSFWorkbook(100)
try {
val sheet = workbook.createSheet(sheetName)
val headerRow = sheet.createRow(0)
headers.forEachIndexed { index, value ->
headerRow.createCell(index).setCellValue(value)
}
writeRows(sheet)
workbook.write(outputStream)
outputStream.flush()
} finally {
workbook.dispose()
workbook.close()
}
}
}
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return startDate to endDate
} }
} }

View File

@@ -6,10 +6,9 @@ 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 org.springframework.http.HttpHeaders
import java.io.ByteArrayInputStream import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
class AdminChannelDonationCalculateControllerTest { class AdminChannelDonationCalculateControllerTest {
private lateinit var service: AdminChannelDonationCalculateService private lateinit var service: AdminChannelDonationCalculateService
@@ -135,6 +134,38 @@ class AdminChannelDonationCalculateControllerTest {
) )
} }
@Test
@DisplayName("관리자 컨트롤러는 날짜별 정산 엑셀을 다운로드한다")
fun shouldDownloadDateSettlementExcel() {
Mockito.`when`(
service.downloadChannelDonationByDateExcel(
startDateStr = "2026-02-01",
endDateStr = "2026-02-29"
)
).thenReturn(StreamingResponseBody { outputStream -> outputStream.write(byteArrayOf(1, 2, 3)) })
val response = controller.downloadChannelDonationByDateExcel(
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 StreamingResponseBody)
Mockito.verify(service).downloadChannelDonationByDateExcel(
startDateStr = "2026-02-01",
endDateStr = "2026-02-29"
)
}
@Test @Test
@DisplayName("관리자 컨트롤러는 크리에이터별 정산 엑셀을 다운로드한다") @DisplayName("관리자 컨트롤러는 크리에이터별 정산 엑셀을 다운로드한다")
fun shouldDownloadCreatorSettlementExcel() { fun shouldDownloadCreatorSettlementExcel() {
@@ -143,7 +174,7 @@ class AdminChannelDonationCalculateControllerTest {
startDateStr = "2026-02-01", startDateStr = "2026-02-01",
endDateStr = "2026-02-29" endDateStr = "2026-02-29"
) )
).thenReturn(ByteArrayInputStream(byteArrayOf(1, 2, 3))) ).thenReturn(StreamingResponseBody { outputStream -> outputStream.write(byteArrayOf(1, 2, 3)) })
val response = controller.downloadChannelDonationByCreatorExcel( val response = controller.downloadChannelDonationByCreatorExcel(
startDateStr = "2026-02-01", startDateStr = "2026-02-01",
@@ -159,7 +190,7 @@ class AdminChannelDonationCalculateControllerTest {
response.headers.contentType.toString() response.headers.contentType.toString()
) )
assertNotNull(response.body) assertNotNull(response.body)
assertEquals(true, response.body is InputStreamResource) assertEquals(true, response.body is StreamingResponseBody)
Mockito.verify(service).downloadChannelDonationByCreatorExcel( Mockito.verify(service).downloadChannelDonationByCreatorExcel(
startDateStr = "2026-02-01", startDateStr = "2026-02-01",

View File

@@ -7,6 +7,7 @@ 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 java.io.ByteArrayOutputStream
class AdminChannelDonationCalculateServiceTest { class AdminChannelDonationCalculateServiceTest {
private lateinit var repository: AdminChannelDonationCalculateQueryRepository private lateinit var repository: AdminChannelDonationCalculateQueryRepository
@@ -153,6 +154,54 @@ class AdminChannelDonationCalculateServiceTest {
) )
} }
@Test
@DisplayName("관리자 날짜별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다")
fun shouldGenerateDateSettlementExcelBytes() {
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,
1L
)
).thenReturn(
listOf(
GetAdminChannelDonationSettlementQueryData(
date = "2026-02-20",
creator = "creator-a",
count = 3L,
totalCan = 100
)
)
)
val response = service.downloadChannelDonationByDateExcel(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val outputStream = ByteArrayOutputStream()
response.writeTo(outputStream)
assertTrue(outputStream.toByteArray().isNotEmpty())
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,
1L
)
}
@Test @Test
@DisplayName("관리자 크리에이터별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다") @DisplayName("관리자 크리에이터별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다")
fun shouldGenerateCreatorSettlementExcelBytes() { fun shouldGenerateCreatorSettlementExcelBytes() {
@@ -175,8 +224,10 @@ class AdminChannelDonationCalculateServiceTest {
startDateStr = "2026-02-20", startDateStr = "2026-02-20",
endDateStr = "2026-02-21" endDateStr = "2026-02-21"
) )
val outputStream = ByteArrayOutputStream()
response.writeTo(outputStream)
assertTrue(response.readAllBytes().isNotEmpty()) assertTrue(outputStream.toByteArray().isNotEmpty())
Mockito.verify(repository).getChannelDonationByCreatorForExcel( Mockito.verify(repository).getChannelDonationByCreatorForExcel(
"2026-02-20".convertLocalDateTime(), "2026-02-20".convertLocalDateTime(),