fix(calculate): 관리자 정산 엑셀 다운로드를 스트리밍 방식으로 전환한다
This commit is contained in:
32
docs/20260305_관리자정산엑셀다운로드추가.md
Normal file
32
docs/20260305_관리자정산엑셀다운로드추가.md
Normal 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` → 성공
|
||||
27
docs/20260305_정산엑셀스트리밍전환.md
Normal file
27
docs/20260305_정산엑셀스트리밍전환.md
Normal 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` → 성공
|
||||
@@ -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<StreamingResponseBody> = 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<StreamingResponseBody> = 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<StreamingResponseBody> = 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<StreamingResponseBody> = 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<StreamingResponseBody> = 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<StreamingResponseBody> = 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<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StreamingResponseBody> = 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<InputStreamResource> {
|
||||
val encodedFileName = URLEncoder.encode(
|
||||
"channel-donation-by-creator.xlsx",
|
||||
StandardCharsets.UTF_8.toString()
|
||||
).replace("+", "%20")
|
||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
||||
fileName = "channel-donation-by-creator.xlsx",
|
||||
response = service.downloadChannelDonationByCreatorExcel(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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user