From 6ac94174c851e4982adb3d727aa588f2a51d6471 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 5 Mar 2026 12:21:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(calculate):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EC=82=B0=20=EC=97=91=EC=85=80=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EB=A5=BC=20=EC=8A=A4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=8D=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260305_관리자정산엑셀다운로드추가.md | 32 ++ docs/20260305_정산엑셀스트리밍전환.md | 27 ++ .../calculate/AdminCalculateController.kt | 84 +++++ .../admin/calculate/AdminCalculateService.kt | 286 ++++++++++++++++++ ...AdminChannelDonationCalculateController.kt | 33 +- .../AdminChannelDonationCalculateService.kt | 101 +++++-- ...nChannelDonationCalculateControllerTest.kt | 39 ++- ...dminChannelDonationCalculateServiceTest.kt | 53 +++- 8 files changed, 619 insertions(+), 36 deletions(-) create mode 100644 docs/20260305_관리자정산엑셀다운로드추가.md create mode 100644 docs/20260305_정산엑셀스트리밍전환.md diff --git a/docs/20260305_관리자정산엑셀다운로드추가.md b/docs/20260305_관리자정산엑셀다운로드추가.md new file mode 100644 index 00000000..ce4f67ee --- /dev/null +++ b/docs/20260305_관리자정산엑셀다운로드추가.md @@ -0,0 +1,32 @@ +# 관리자 정산 엑셀 다운로드 추가 작업 계획 + +- [x] 기존 정산 API 구조와 엑셀 다운로드 응답 패턴(`ResponseEntity`)을 기준으로 구현 범위를 확정한다. +- [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` → 성공 diff --git a/docs/20260305_정산엑셀스트리밍전환.md b/docs/20260305_정산엑셀스트리밍전환.md new file mode 100644 index 00000000..291f2b47 --- /dev/null +++ b/docs/20260305_정산엑셀스트리밍전환.md @@ -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`로 변경하고, 기존 파일명 인코딩/`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` → 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt index ddcb0511..ac079377 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt @@ -2,11 +2,17 @@ package kr.co.vividnext.sodalive.admin.calculate import kr.co.vividnext.sodalive.common.ApiResponse 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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.net.URLEncoder +import java.nio.charset.StandardCharsets @RestController @PreAuthorize("hasRole('ADMIN')") @@ -18,12 +24,30 @@ class AdminCalculateController(private val service: AdminCalculateService) { @RequestParam endDateStr: String ) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr)) + @GetMapping("/live/excel") + fun downloadCalculateLiveExcel( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String + ): ResponseEntity = createExcelResponse( + fileName = "live.xlsx", + response = service.downloadCalculateLiveExcel(startDateStr, endDateStr) + ) + @GetMapping("/content-list") fun getCalculateContentList( @RequestParam startDateStr: String, @RequestParam endDateStr: String ) = ApiResponse.ok(service.getCalculateContentList(startDateStr, endDateStr)) + @GetMapping("/content-list/excel") + fun downloadCalculateContentListExcel( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String + ): ResponseEntity = createExcelResponse( + fileName = "content-list.xlsx", + response = service.downloadCalculateContentListExcel(startDateStr, endDateStr) + ) + @GetMapping("/cumulative-sales-by-content") fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok( service.getCumulativeSalesByContent(pageable.offset, pageable.pageSize.toLong()) @@ -35,6 +59,15 @@ class AdminCalculateController(private val service: AdminCalculateService) { @RequestParam endDateStr: String ) = ApiResponse.ok(service.getCalculateContentDonationList(startDateStr, endDateStr)) + @GetMapping("/content-donation-list/excel") + fun downloadCalculateContentDonationListExcel( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String + ): ResponseEntity = createExcelResponse( + fileName = "content-donation-list.xlsx", + response = service.downloadCalculateContentDonationListExcel(startDateStr, endDateStr) + ) + @GetMapping("/community-post") fun getCalculateCommunityPost( @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 = createExcelResponse( + fileName = "community-post.xlsx", + response = service.downloadCalculateCommunityPostExcel(startDateStr, endDateStr) + ) + @GetMapping("/live-by-creator") fun getCalculateLiveByCreator( @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 = createExcelResponse( + fileName = "live-by-creator.xlsx", + response = service.downloadCalculateLiveByCreatorExcel(startDateStr, endDateStr) + ) + @GetMapping("/content-by-creator") fun getCalculateContentByCreator( @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 = createExcelResponse( + fileName = "content-by-creator.xlsx", + response = service.downloadCalculateContentByCreatorExcel(startDateStr, endDateStr) + ) + @GetMapping("/community-by-creator") fun getCalculateCommunityByCreator( @RequestParam startDateStr: String, @@ -90,4 +150,28 @@ class AdminCalculateController(private val service: AdminCalculateService) { pageable.pageSize.toLong() ) ) + + @GetMapping("/community-by-creator/excel") + fun downloadCalculateCommunityByCreatorExcel( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String + ): ResponseEntity = createExcelResponse( + fileName = "community-by-creator.xlsx", + response = service.downloadCalculateCommunityByCreatorExcel(startDateStr, endDateStr) + ) + + private fun createExcelResponse( + fileName: String, + response: StreamingResponseBody + ): ResponseEntity { + 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) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt index 255d4564..b233f84e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt @@ -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.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.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) { @@ -139,4 +143,286 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor 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 + ): 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, + 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 { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + + return startDate to endDate + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt index debdf252..d1be447a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt @@ -1,7 +1,6 @@ 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 @@ -11,6 +10,7 @@ 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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.net.URLEncoder 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 = createExcelResponse( + fileName = "channel-donation-by-date.xlsx", + response = service.downloadChannelDonationByDateExcel(startDateStr, endDateStr) + ) + @GetMapping("/channel-donation-by-creator") fun getChannelDonationByCreator( @RequestParam startDateStr: String, @@ -52,23 +61,23 @@ class AdminChannelDonationCalculateController( fun downloadChannelDonationByCreatorExcel( @RequestParam startDateStr: String, @RequestParam endDateStr: String - ): ResponseEntity { - val encodedFileName = URLEncoder.encode( - "channel-donation-by-creator.xlsx", - StandardCharsets.UTF_8.toString() - ).replace("+", "%20") + ): ResponseEntity = createExcelResponse( + fileName = "channel-donation-by-creator.xlsx", + response = service.downloadChannelDonationByCreatorExcel(startDateStr, endDateStr) + ) + + private fun createExcelResponse( + fileName: String, + response: StreamingResponseBody + ): ResponseEntity { + val encodedFileName = URLEncoder.encode(fileName, 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)) + .body(response) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt index 22a9d54a..4c68d5a5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt @@ -1,11 +1,12 @@ package kr.co.vividnext.sodalive.admin.calculate.channelDonation 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.transaction.annotation.Transactional -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.time.LocalDateTime @Service class AdminChannelDonationCalculateService( @@ -50,17 +51,21 @@ class AdminChannelDonationCalculateService( } @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() + fun downloadChannelDonationByDateExcel(startDateStr: String, endDateStr: String): StreamingResponseBody { + val (startDate, endDate) = toDateRange(startDateStr, endDateStr) + val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate) + val items = if (totalCount == 0) { + emptyList() + } else { + repository + .getChannelDonationByDate(startDate, endDate, 0L, totalCount.toLong()) + .map { it.toResponseItem() } + } - XSSFWorkbook().use { workbook -> - val sheet = workbook.createSheet("크리에이터별 채널후원 정산") - val header = listOf( + return createExcelStream( + sheetName = "채널후원 정산", + headers = listOf( + "날짜", "크리에이터", "건수", "총 받은 캔 수", @@ -70,11 +75,42 @@ class AdminChannelDonationCalculateService( "원천세", "입금액" ) - val headerRow = sheet.createRow(0) - header.forEachIndexed { index, value -> - headerRow.createCell(index).setCellValue(value) + ) { sheet -> + items.forEachIndexed { index, item -> + 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 -> val row = sheet.createRow(index + 1) row.createCell(0).setCellValue(item.creator) @@ -86,10 +122,37 @@ class AdminChannelDonationCalculateService( row.createCell(6).setCellValue(item.withholdingTax.toDouble()) row.createCell(7).setCellValue(item.depositAmount.toDouble()) } - - workbook.write(byteArrayOutputStream) } + } - return ByteArrayInputStream(byteArrayOutputStream.toByteArray()) + private fun createExcelStream( + sheetName: String, + headers: List, + 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 { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + + return startDate to endDate } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt index 38e76c62..8e7dc1c5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt @@ -6,10 +6,9 @@ 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 +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody class AdminChannelDonationCalculateControllerTest { 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 @DisplayName("관리자 컨트롤러는 크리에이터별 정산 엑셀을 다운로드한다") fun shouldDownloadCreatorSettlementExcel() { @@ -143,7 +174,7 @@ class AdminChannelDonationCalculateControllerTest { startDateStr = "2026-02-01", endDateStr = "2026-02-29" ) - ).thenReturn(ByteArrayInputStream(byteArrayOf(1, 2, 3))) + ).thenReturn(StreamingResponseBody { outputStream -> outputStream.write(byteArrayOf(1, 2, 3)) }) val response = controller.downloadChannelDonationByCreatorExcel( startDateStr = "2026-02-01", @@ -159,7 +190,7 @@ class AdminChannelDonationCalculateControllerTest { response.headers.contentType.toString() ) assertNotNull(response.body) - assertEquals(true, response.body is InputStreamResource) + assertEquals(true, response.body is StreamingResponseBody) Mockito.verify(service).downloadChannelDonationByCreatorExcel( startDateStr = "2026-02-01", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt index 28a8b221..7a0fbf90 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito +import java.io.ByteArrayOutputStream class AdminChannelDonationCalculateServiceTest { 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 @DisplayName("관리자 크리에이터별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다") fun shouldGenerateCreatorSettlementExcelBytes() { @@ -175,8 +224,10 @@ class AdminChannelDonationCalculateServiceTest { startDateStr = "2026-02-20", endDateStr = "2026-02-21" ) + val outputStream = ByteArrayOutputStream() + response.writeTo(outputStream) - assertTrue(response.readAllBytes().isNotEmpty()) + assertTrue(outputStream.toByteArray().isNotEmpty()) Mockito.verify(repository).getChannelDonationByCreatorForExcel( "2026-02-20".convertLocalDateTime(),