From 249209e6bb982e874b65ab5885d2f3782255e2e1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 13:31:39 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(live-room):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=B0=A9=20=EC=A0=95=EB=B3=B4=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EB=AC=B4=EB=A3=8C=20=EC=97=AC=EB=B6=80=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260421_라이브방무료여부응답추가.md | 12 ++++++++++++ .../vividnext/sodalive/live/room/LiveRoomService.kt | 1 + .../sodalive/live/room/info/GetRoomInfoResponse.kt | 1 + 3 files changed, 14 insertions(+) create mode 100644 docs/20260421_라이브방무료여부응답추가.md diff --git a/docs/20260421_라이브방무료여부응답추가.md b/docs/20260421_라이브방무료여부응답추가.md new file mode 100644 index 00000000..6800b8b6 --- /dev/null +++ b/docs/20260421_라이브방무료여부응답추가.md @@ -0,0 +1,12 @@ +# 라이브방 무료 여부 응답 추가 + +- [x] `GetRoomInfoResponse`에 라이브방 무료 여부 필드 추가 +- [x] `GetRoomInfoResponse` 생성 경로에 무료 여부 매핑 반영 +- [x] 관련 검증 수행 및 결과 기록 + +## 검증 기록 + +### 1차 구현 +- 무엇을: `GetRoomInfoResponse`에 `isFreeRoom` 필드를 추가하고, `LiveRoomService`에서 `room.price == 0` 결과를 응답에 매핑했다. +- 왜: 라이브방 정보 응답에서 클라이언트가 무료방 여부를 직접 판별할 수 있어야 하기 때문이다. +- 어떻게: Kotlin `.kt` 파일은 현재 LSP 진단을 지원하지 않아 `lsp_diagnostics` 검증은 불가함을 확인했고, `./gradlew test`를 실행해 main/test 컴파일과 테스트를 함께 검증했으며 `BUILD SUCCESSFUL`을 확인했다. 로컬 실행 환경이 이 세션에 준비되지 않아 실제 API 호출 기반 수동 QA는 수행하지 못했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 92eac271..c724cbaa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -1076,6 +1076,7 @@ class LiveRoomService( donationRankingTop3UserIds = donationRankingTop3UserIds, menuPan = menuPan?.menu ?: "", creatorLanguageCode = creatorLanguageCode, + isFreeRoom = room.price == 0, isPrivateRoom = room.type == LiveRoomType.PRIVATE, password = room.password, isActiveRoulette = isActiveRoulette, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt index b968ff49..2225346f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt @@ -23,6 +23,7 @@ data class GetRoomInfoResponse( val donationRankingTop3UserIds: List, val menuPan: String, val creatorLanguageCode: String?, + val isFreeRoom: Boolean, val isPrivateRoom: Boolean = false, val password: String? = null, val isActiveRoulette: Boolean = false, From ae0bf769f75288922235b7c262cf9a6062552e80 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 18:01:11 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(original-series-calculate):=20?= =?UTF-8?q?=EC=98=A4=EB=A6=AC=EC=A7=80=EB=84=90=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=A0=95=EC=82=B0=20=EB=82=B4=EC=97=AD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminOriginalSeriesCalculateController.kt | 65 +++++ ...nOriginalSeriesCalculateQueryRepository.kt | 114 ++++++++ .../AdminOriginalSeriesCalculateService.kt | 116 ++++++++ .../GetAdminOriginalSeriesOwnerResponse.kt | 9 + ...OriginalSeriesSettlementDetailQueryData.kt | 27 ++ ...nOriginalSeriesSettlementDetailResponse.kt | 18 ++ ...inOriginalSeriesCalculateControllerTest.kt | 114 ++++++++ ...ginalSeriesCalculateQueryRepositoryTest.kt | 264 ++++++++++++++++++ ...AdminOriginalSeriesCalculateServiceTest.kt | 167 +++++++++++ 9 files changed, 894 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt new file mode 100644 index 00000000..4f5f826c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt @@ -0,0 +1,65 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +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')") +@RequestMapping("/admin/calculate") +class AdminOriginalSeriesCalculateController( + private val service: AdminOriginalSeriesCalculateService +) { + @GetMapping("/original-series/owners") + fun getOriginalSeriesOwners() = ApiResponse.ok(service.getOriginalSeriesOwners()) + + @GetMapping("/original-series/settlement-details") + fun getSettlementDetails( + @RequestParam("startDate") startDateStr: String, + @RequestParam("endDate") endDateStr: String, + @RequestParam memberId: Long, + pageable: Pageable + ) = ApiResponse.ok( + service.getSettlementDetails( + startDateStr = startDateStr, + endDateStr = endDateStr, + memberId = memberId, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + + @GetMapping("/original-series/settlement-details/excel") + fun downloadSettlementDetailsExcel( + @RequestParam("startDate") startDateStr: String, + @RequestParam("endDate") endDateStr: String + ): ResponseEntity = createExcelResponse( + fileName = "original-series-settlement-details.xlsx", + response = service.downloadSettlementDetailsExcel(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/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt new file mode 100644 index 00000000..9dd09739 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt @@ -0,0 +1,114 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.order.QOrder.order +import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series +import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class AdminOriginalSeriesCalculateQueryRepository( + private val queryFactory: JPAQueryFactory +) { + fun getOriginalSeriesOwners(): List { + return queryFactory + .select( + QGetAdminOriginalSeriesOwnerResponse( + member.id, + member.nickname + ) + ) + .from(series) + .innerJoin(series.member, member) + .where( + series.isOriginal.isTrue + .and(series.isActive.isTrue) + .and(member.isActive.isTrue) + ) + .groupBy(member.id, member.nickname) + .orderBy(member.id.asc()) + .fetch() + } + + fun getSettlementDetailTotalCount( + startDate: LocalDateTime, + endDate: LocalDateTime, + memberId: Long + ): Int { + return queryFactory + .select(audioContent.id) + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id)) + .innerJoin(seriesContent.series, series) + .innerJoin(series.member, member) + .where(baseWhereCondition(startDate, endDate, memberId)) + .groupBy( + series.id, + series.title, + audioContent.id, + audioContent.title, + order.type, + audioContent.price + ) + .fetch() + .size + } + + fun getSettlementDetails( + startDate: LocalDateTime, + endDate: LocalDateTime, + memberId: Long, + offset: Long, + limit: Long + ): List { + return queryFactory + .select( + QGetAdminOriginalSeriesSettlementDetailQueryData( + series.title, + audioContent.title, + audioContent.price, + order.type, + order.id.count(), + order.can.sum().coalesce(0), + order.point.sum().coalesce(0) + ) + ) + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id)) + .innerJoin(seriesContent.series, series) + .innerJoin(series.member, member) + .where(baseWhereCondition(startDate, endDate, memberId)) + .groupBy( + series.id, + series.title, + audioContent.id, + audioContent.title, + order.type, + audioContent.price + ) + .orderBy(series.id.asc(), audioContent.id.asc(), order.type.asc()) + .offset(offset) + .limit(limit) + .fetch() + } + + private fun baseWhereCondition( + startDate: LocalDateTime, + endDate: LocalDateTime, + memberId: Long + ): BooleanExpression { + return series.isOriginal.isTrue + .and(series.isActive.isTrue) + .and(member.isActive.isTrue) + .and(member.id.eq(memberId)) + .and(order.isActive.isTrue) + .and(order.createdAt.goe(startDate)) + .and(order.createdAt.loe(endDate)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt new file mode 100644 index 00000000..9c9310af --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt @@ -0,0 +1,116 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.apache.poi.ss.usermodel.Sheet +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +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 AdminOriginalSeriesCalculateService( + private val repository: AdminOriginalSeriesCalculateQueryRepository +) { + @Transactional(readOnly = true) + fun getOriginalSeriesOwners(): List { + return repository.getOriginalSeriesOwners() + } + + @Transactional(readOnly = true) + fun getSettlementDetails( + startDateStr: String, + endDateStr: String, + memberId: Long, + offset: Long, + limit: Long + ): GetAdminOriginalSeriesSettlementDetailListResponse { + val (startDate, endDate) = toDateRange(startDateStr, endDateStr) + val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, memberId) + val items = repository + .getSettlementDetails(startDate, endDate, memberId, offset, limit) + .map { it.toResponse() } + + return GetAdminOriginalSeriesSettlementDetailListResponse(totalCount, items) + } + + @Transactional(readOnly = true) + fun downloadSettlementDetailsExcel(startDateStr: String, endDateStr: String): StreamingResponseBody { + val (startDate, endDate) = toDateRange(startDateStr, endDateStr) + val owners = repository.getOriginalSeriesOwners() + + return StreamingResponseBody { outputStream -> + val workbook = SXSSFWorkbook(100) + try { + if (owners.isEmpty()) { + writeHeaders(workbook.createSheet("오리지널 시리즈 정산")) + } else { + owners.forEach { owner -> + val sheet = workbook.createSheet(toSheetName(owner)) + writeHeaders(sheet) + + val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.memberId) + val items = if (totalCount == 0) { + emptyList() + } else { + repository.getSettlementDetails(startDate, endDate, owner.memberId, 0L, totalCount.toLong()) + .map { it.toResponse() } + } + + writeRows(sheet, items) + } + } + + workbook.write(outputStream) + outputStream.flush() + } finally { + workbook.dispose() + workbook.close() + } + } + } + + private fun writeHeaders(sheet: Sheet) { + val headerRow = sheet.createRow(0) + EXCEL_HEADERS.forEachIndexed { index, value -> + headerRow.createCell(index).setCellValue(value) + } + } + + private fun writeRows(sheet: Sheet, items: List) { + items.forEachIndexed { index, item -> + val row = sheet.createRow(index + 1) + row.createCell(0).setCellValue(item.seriesTitle) + row.createCell(1).setCellValue(item.contentTitle) + row.createCell(2).setCellValue(item.price.toDouble()) + row.createCell(3).setCellValue(item.orderType) + row.createCell(4).setCellValue(item.salesCount.toDouble()) + row.createCell(5).setCellValue(item.totalCan.toDouble()) + row.createCell(6).setCellValue(item.totalPoint.toDouble()) + } + } + + private fun toSheetName(owner: GetAdminOriginalSeriesOwnerResponse): String { + return WorkbookUtil.createSafeSheetName("${owner.memberId}_${owner.nickname}") + } + + 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 + } + + companion object { + private val EXCEL_HEADERS = listOf( + "시리즈 제목", + "콘텐츠 제목", + "가격(캔)", + "구분", + "판매 수", + "합계(캔)", + "합계(포인트)" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt new file mode 100644 index 00000000..5d7f2d60 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import com.fasterxml.jackson.annotation.JsonProperty +import com.querydsl.core.annotations.QueryProjection + +data class GetAdminOriginalSeriesOwnerResponse @QueryProjection constructor( + @JsonProperty("memberId") val memberId: Long, + @JsonProperty("nickname") val nickname: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt new file mode 100644 index 00000000..1fd094d4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.content.order.OrderType +import kotlin.math.ceil + +data class GetAdminOriginalSeriesSettlementDetailQueryData @QueryProjection constructor( + val seriesTitle: String, + val contentTitle: String, + val basePrice: Int, + val orderType: OrderType, + val salesCount: Long, + val totalCan: Int?, + val totalPoint: Int? +) { + fun toResponse(): GetAdminOriginalSeriesSettlementDetailResponse { + return GetAdminOriginalSeriesSettlementDetailResponse( + seriesTitle = seriesTitle, + contentTitle = contentTitle, + price = if (orderType == OrderType.RENTAL) ceil(basePrice * 0.7).toInt() else basePrice, + orderType = if (orderType == OrderType.RENTAL) "대여" else "소장", + salesCount = salesCount.toInt(), + totalCan = totalCan ?: 0, + totalPoint = totalPoint ?: 0 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt new file mode 100644 index 00000000..2871d2ee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetAdminOriginalSeriesSettlementDetailResponse( + @JsonProperty("seriesTitle") val seriesTitle: String, + @JsonProperty("contentTitle") val contentTitle: String, + @JsonProperty("price") val price: Int, + @JsonProperty("orderType") val orderType: String, + @JsonProperty("salesCount") val salesCount: Int, + @JsonProperty("totalCan") val totalCan: Int, + @JsonProperty("totalPoint") val totalPoint: Int +) + +data class GetAdminOriginalSeriesSettlementDetailListResponse( + @JsonProperty("totalCount") val totalCount: Int, + @JsonProperty("items") val items: List +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt new file mode 100644 index 00000000..e4b667eb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt @@ -0,0 +1,114 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpHeaders +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody + +class AdminOriginalSeriesCalculateControllerTest { + private lateinit var service: AdminOriginalSeriesCalculateService + private lateinit var controller: AdminOriginalSeriesCalculateController + + @BeforeEach + fun setup() { + service = Mockito.mock(AdminOriginalSeriesCalculateService::class.java) + controller = AdminOriginalSeriesCalculateController(service) + } + + @Test + @DisplayName("관리자 컨트롤러는 오리지널 시리즈 소지 유저 목록을 반환한다") + fun shouldReturnOriginalSeriesOwners() { + val owners = listOf(GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a")) + Mockito.`when`(service.getOriginalSeriesOwners()).thenReturn(owners) + + val response = controller.getOriginalSeriesOwners() + + assertEquals(true, response.success) + assertEquals(1, response.data!!.size) + assertEquals("owner-a", response.data!![0].nickname) + Mockito.verify(service).getOriginalSeriesOwners() + } + + @Test + @DisplayName("관리자 컨트롤러는 정산 내역 조회 파라미터를 서비스로 전달한다") + fun shouldForwardSettlementDetailParamsToService() { + val detailResponse = GetAdminOriginalSeriesSettlementDetailListResponse( + totalCount = 1, + items = listOf( + GetAdminOriginalSeriesSettlementDetailResponse( + seriesTitle = "series", + contentTitle = "episode", + price = 70, + orderType = "대여", + salesCount = 2, + totalCan = 140, + totalPoint = 10 + ) + ) + ) + Mockito.`when`( + service.getSettlementDetails( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + memberId = 5L, + offset = 10L, + limit = 10L + ) + ).thenReturn(detailResponse) + + val response = controller.getSettlementDetails( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + memberId = 5L, + pageable = PageRequest.of(1, 10) + ) + + assertEquals(true, response.success) + assertEquals(1, response.data!!.totalCount) + assertEquals("series", response.data!!.items[0].seriesTitle) + Mockito.verify(service).getSettlementDetails( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + memberId = 5L, + offset = 10L, + limit = 10L + ) + } + + @Test + @DisplayName("관리자 컨트롤러는 오리지널 시리즈 정산 엑셀을 다운로드한다") + fun shouldDownloadOriginalSeriesSettlementExcel() { + Mockito.`when`( + service.downloadSettlementDetailsExcel( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30" + ) + ).thenReturn(StreamingResponseBody { outputStream -> outputStream.write(byteArrayOf(1, 2, 3)) }) + + val response = controller.downloadSettlementDetailsExcel( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30" + ) + + 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).downloadSettlementDetailsExcel( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30" + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..e0aeb12c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt @@ -0,0 +1,264 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest +@Import(QueryDslConfig::class) +class AdminOriginalSeriesCalculateQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val entityManager: EntityManager +) { + private lateinit var repository: AdminOriginalSeriesCalculateQueryRepository + + @BeforeEach + fun setup() { + repository = AdminOriginalSeriesCalculateQueryRepository(queryFactory) + } + + @Test + @DisplayName("오리지널 시리즈 소지 유저 조회는 중복 없이 오리지널 시리즈 보유자만 반환한다") + fun shouldGetDistinctOwnersOfOriginalSeries() { + val genre = saveSeriesGenre() + val ownerA = saveMember("owner-a", MemberRole.CREATOR) + val ownerB = saveMember("owner-b", MemberRole.CREATOR) + val inactiveOwner = saveMember("owner-c", MemberRole.CREATOR, isActive = false) + + saveSeries("series-a1", ownerA, genre, isOriginal = true) + saveSeries("series-a2", ownerA, genre, isOriginal = true) + saveSeries("series-b1", ownerB, genre, isOriginal = true) + saveSeries("series-c1", inactiveOwner, genre, isOriginal = true) + saveSeries("series-non-original", ownerB, genre, isOriginal = false) + + entityManager.flush() + entityManager.clear() + + val result = repository.getOriginalSeriesOwners() + + assertEquals(2, result.size) + assertEquals(ownerA.id, result[0].memberId) + assertEquals(ownerB.id, result[1].memberId) + } + + @Test + @DisplayName("오리지널 시리즈 정산 내역 조회는 콘텐츠와 주문 타입 기준으로 그룹화한다") + fun shouldAggregateSettlementDetailsByContentAndOrderType() { + val genre = saveSeriesGenre() + val theme = saveTheme() + val owner = saveMember("owner-a", MemberRole.CREATOR) + val otherOwner = saveMember("owner-b", MemberRole.CREATOR) + val buyerA = saveMember("buyer-a", MemberRole.USER) + val buyerB = saveMember("buyer-b", MemberRole.USER) + val otherBuyer = saveMember("buyer-c", MemberRole.USER) + + val originalSeries = saveSeries("오리지널 시리즈", owner, genre, isOriginal = true) + val otherSeries = saveSeries("다른 시리즈", otherOwner, genre, isOriginal = true) + val nonOriginalSeries = saveSeries("비오리지널 시리즈", owner, genre, isOriginal = false) + + val episode = saveContent("1화", 100, owner, theme) + val otherEpisode = saveContent("2화", 120, otherOwner, theme) + val excludedEpisode = saveContent("3화", 150, owner, theme) + + saveSeriesContent(originalSeries, episode) + saveSeriesContent(otherSeries, otherEpisode) + saveSeriesContent(nonOriginalSeries, excludedEpisode) + + val keepOrder1 = saveOrder( + OrderType.KEEP, + episode, + buyerA, + owner, + point = 20, + createdAt = LocalDateTime.of(2026, 4, 10, 10, 0, 0) + ) + val keepOrder2 = saveOrder( + OrderType.KEEP, + episode, + buyerB, + owner, + point = 5, + createdAt = LocalDateTime.of(2026, 4, 11, 10, 0, 0) + ) + val rentalOrder = saveOrder( + OrderType.RENTAL, + episode, + buyerA, + owner, + point = 0, + createdAt = LocalDateTime.of(2026, 4, 12, 10, 0, 0) + ) + val discountedRentalOrder = saveOrder( + OrderType.RENTAL, + episode, + buyerB, + owner, + point = 100, + createdAt = LocalDateTime.of(2026, 4, 15, 10, 0, 0), + canOverride = 60 + ) + + saveOrder( + OrderType.RENTAL, + episode, + buyerA, + owner, + point = 0, + createdAt = LocalDateTime.of(2026, 5, 1, 0, 0, 0) + ) + saveOrder( + OrderType.KEEP, + otherEpisode, + otherBuyer, + otherOwner, + point = 0, + createdAt = LocalDateTime.of(2026, 4, 13, 10, 0, 0) + ) + saveOrder( + OrderType.KEEP, + excludedEpisode, + buyerA, + owner, + point = 0, + createdAt = LocalDateTime.of(2026, 4, 14, 10, 0, 0) + ) + + entityManager.flush() + entityManager.clear() + + val startDate = LocalDateTime.of(2026, 4, 1, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 4, 30, 23, 59, 59) + + val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.id!!) + val result = repository.getSettlementDetails(startDate, endDate, owner.id!!, 0L, 20L) + + assertEquals(2, totalCount) + assertEquals(2, result.size) + + val keepRow = result.first { it.orderType == OrderType.KEEP } + assertEquals("오리지널 시리즈", keepRow.seriesTitle) + assertEquals("1화", keepRow.contentTitle) + assertEquals(100, keepRow.basePrice) + assertEquals(2L, keepRow.salesCount) + assertEquals(200, keepRow.totalCan) + assertEquals(25, keepRow.totalPoint) + + val rentalRow = result.first { it.orderType == OrderType.RENTAL } + assertEquals(100, rentalRow.basePrice) + assertEquals(2L, rentalRow.salesCount) + assertEquals(130, rentalRow.totalCan) + assertEquals(100, rentalRow.totalPoint) + assertEquals(70, rentalRow.toResponse().price) + + assertEquals(100, keepOrder1.can) + assertEquals(100, keepOrder2.can) + assertEquals(70, rentalOrder.can) + assertEquals(60, discountedRentalOrder.can) + } + + private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role, + isActive = isActive + ) + entityManager.persist(member) + return member + } + + private fun saveSeriesGenre(): SeriesGenre { + val genre = SeriesGenre(genre = "genre") + entityManager.persist(genre) + return genre + } + + private fun saveTheme(): AudioContentTheme { + val theme = AudioContentTheme(theme = "theme", image = "theme.png") + entityManager.persist(theme) + return theme + } + + private fun saveSeries(title: String, owner: Member, genre: SeriesGenre, isOriginal: Boolean): Series { + val series = Series( + title = title, + introduction = "introduction", + languageCode = "ko", + isOriginal = isOriginal + ) + series.member = owner + series.genre = genre + entityManager.persist(series) + return series + } + + private fun saveContent(title: String, price: Int, creator: Member, theme: AudioContentTheme): AudioContent { + val content = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = price + ) + content.theme = theme + content.member = creator + content.isActive = true + entityManager.persist(content) + return content + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = content + entityManager.persist(seriesContent) + return seriesContent + } + + private fun saveOrder( + type: OrderType, + content: AudioContent, + buyer: Member, + creator: Member, + point: Int, + createdAt: LocalDateTime, + canOverride: Int? = null + ): Order { + val order = Order(type = type) + order.member = buyer + order.creator = creator + order.audioContent = content + order.point = point + entityManager.persist(order) + entityManager.flush() + val paidCan = canOverride ?: order.can + entityManager.createQuery( + "update Order o set o.createdAt = :createdAt, o.can = :can, o.point = :point where o.id = :id" + ) + .setParameter("createdAt", createdAt) + .setParameter("can", paidCan) + .setParameter("point", point) + .setParameter("id", order.id) + .executeUpdate() + entityManager.clear() + + return entityManager.find(Order::class.java, order.id) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt new file mode 100644 index 00000000..b8b5c0e0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt @@ -0,0 +1,167 @@ +package kr.co.vividnext.sodalive.admin.calculate.originalSeries + +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +class AdminOriginalSeriesCalculateServiceTest { + private lateinit var repository: AdminOriginalSeriesCalculateQueryRepository + private lateinit var service: AdminOriginalSeriesCalculateService + + @BeforeEach + fun setup() { + repository = Mockito.mock(AdminOriginalSeriesCalculateQueryRepository::class.java) + service = AdminOriginalSeriesCalculateService(repository) + } + + @Test + @DisplayName("오리지널 시리즈 소지 유저 목록 조회는 리포지토리 결과를 반환한다") + fun shouldReturnOriginalSeriesOwners() { + val owners = listOf(GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a")) + Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners) + + val result = service.getOriginalSeriesOwners() + + assertEquals(1, result.size) + assertEquals(1L, result[0].memberId) + Mockito.verify(repository).getOriginalSeriesOwners() + } + + @Test + @DisplayName("정산 내역 조회는 날짜 범위를 UTC로 변환하고 페이지 응답을 반환한다") + fun shouldConvertDateRangeAndReturnSettlementDetails() { + val queryData = GetAdminOriginalSeriesSettlementDetailQueryData( + seriesTitle = "오리지널 시리즈", + contentTitle = "1화", + basePrice = 100, + orderType = OrderType.RENTAL, + salesCount = 2L, + totalCan = 140, + totalPoint = 10 + ) + Mockito.`when`( + repository.getSettlementDetailTotalCount( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 9L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getSettlementDetails( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 9L, + 0L, + 20L + ) + ).thenReturn(listOf(queryData)) + + val result = service.getSettlementDetails( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + memberId = 9L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, result.totalCount) + assertEquals(1, result.items.size) + assertEquals(70, result.items[0].price) + assertEquals("대여", result.items[0].orderType) + assertEquals(140, result.items[0].totalCan) + + Mockito.verify(repository).getSettlementDetailTotalCount( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 9L + ) + Mockito.verify(repository).getSettlementDetails( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 9L, + 0L, + 20L + ) + } + + @Test + @DisplayName("정산 엑셀 다운로드는 소지 유저별 시트를 생성한다") + fun shouldCreateOneSheetPerOwnerForExcel() { + val owners = listOf( + GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a"), + GetAdminOriginalSeriesOwnerResponse(memberId = 2L, nickname = "owner-b") + ) + Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners) + Mockito.`when`( + repository.getSettlementDetailTotalCount( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 1L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getSettlementDetailTotalCount( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 2L + ) + ).thenReturn(0) + Mockito.`when`( + repository.getSettlementDetails( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 1L, + 0L, + 1L + ) + ).thenReturn( + listOf( + GetAdminOriginalSeriesSettlementDetailQueryData( + seriesTitle = "오리지널 시리즈", + contentTitle = "1화", + basePrice = 100, + orderType = OrderType.KEEP, + salesCount = 2L, + totalCan = 200, + totalPoint = 30 + ) + ) + ) + + val response = service.downloadSettlementDetailsExcel( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30" + ) + val outputStream = ByteArrayOutputStream() + response.writeTo(outputStream) + + assertTrue(outputStream.toByteArray().isNotEmpty()) + + XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook -> + assertEquals(2, workbook.numberOfSheets) + assertEquals("1_owner-a", workbook.getSheetAt(0).sheetName) + assertEquals("2_owner-b", workbook.getSheetAt(1).sheetName) + assertEquals("시리즈 제목", workbook.getSheetAt(0).getRow(0).getCell(0).stringCellValue) + assertEquals("오리지널 시리즈", workbook.getSheetAt(0).getRow(1).getCell(0).stringCellValue) + assertEquals("시리즈 제목", workbook.getSheetAt(1).getRow(0).getCell(0).stringCellValue) + assertEquals(1, workbook.getSheetAt(1).physicalNumberOfRows) + } + + Mockito.verify(repository).getOriginalSeriesOwners() + Mockito.verify(repository).getSettlementDetails( + "2026-04-01".convertLocalDateTime(), + "2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 1L, + 0L, + 1L + ) + } +} From 604a6ac6811699f0531cd6323754453e47f5b569 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 18:01:30 +0900 Subject: [PATCH 3/8] =?UTF-8?q?docs(original-series-calculate):=20?= =?UTF-8?q?=EC=98=A4=EB=A6=AC=EC=A7=80=EB=84=90=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=A0=95=EC=82=B0=20=EC=9E=91=EC=97=85=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260421_오리지널시리즈정산내역.md | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/20260421_오리지널시리즈정산내역.md diff --git a/docs/20260421_오리지널시리즈정산내역.md b/docs/20260421_오리지널시리즈정산내역.md new file mode 100644 index 00000000..477d538f --- /dev/null +++ b/docs/20260421_오리지널시리즈정산내역.md @@ -0,0 +1,37 @@ +# 오리지널 시리즈 작품 화별 정산 내역 추가 작업 계획 + +- [x] 기존 관리자 정산 API 패턴(`/admin/calculate`, list/excel 쌍, `StreamingResponseBody`)을 유지하는 신규 패키지 구조를 확정한다. +- [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 소지 유저 조회 API를 추가한다. +- [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 정산 내역 조회 API를 추가한다. +- [x] 정산 내역 조회 API가 `startDateStr`, `endDateStr`, `memberId`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다. +- [x] 정산 내역 조회 쿼리가 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 경로를 사용해 `Content.id`, `Order.type` 기준으로 그룹화되도록 구현한다. +- [x] 정산 내역 결과에 시리즈 타이틀, 콘텐츠 타이틀, 가격, 대여/소장 여부, 판매 수, 합계(캔), 합계(포인트)를 포함하도록 DTO를 추가한다. +- [x] 가격은 주문에 저장된 값을 기준으로 사용하되, 대여는 `ceil(price * 0.7)` 규칙이 반영된 값으로 노출되도록 검증한다. +- [x] 오리지널 시리즈 정산 내역 엑셀 다운로드 API를 추가하고, 오리지널 시리즈를 소지한 유저별로 시트를 구성하도록 구현한다. +- [x] 엑셀 다운로드는 각 유저 시트에 헤더 행과 정산 내역 행을 기록하고, 데이터가 없어도 헤더가 있는 시트를 유지하도록 구현한다. +- [x] 신규 QueryRepository/Service/Controller에 대한 테스트를 먼저 작성하고 실패를 확인한 뒤 구현한다. +- [x] 관련 테스트, 정적 진단, 수동 검증 결과를 확인하고 이 문서 하단 검증 기록에 남긴다. + +## 구현 메모 + +- 오리지널 시리즈 여부는 `Series.isOriginal` 플래그를 기준으로 판단한다. +- 소지 유저는 현재 코드베이스 기준 `Series.member`를 의미하는 것으로 해석한다. +- 날짜 입력 파라미터는 `startDate`, `endDate`, `memberId`로 받고, 날짜 값은 KST 기준 `00:00:00` / `23:59:59`로 해석한 뒤 `convertLocalDateTime()`으로 UTC `LocalDateTime`으로 변환한다. +- 정산 내역 조회 API는 `memberId`를 필수 필터로 사용하고, 엑셀 API는 전체 소지 유저를 순회해 시트를 생성하는 것으로 해석한다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: `/admin/calculate/original-series/owners`, `/admin/calculate/original-series/settlement-details`, `/admin/calculate/original-series/settlement-details/excel` 3개 API와 전용 QueryRepository/Service/Controller/DTO를 추가했다. +- 왜: 관리자 페이지에서 오리지널 시리즈 소지 유저를 먼저 조회하고, 선택한 유저의 작품 화별 정산 내역을 기간 기준으로 확인하며, 전체 소지 유저별 시트로 엑셀 다운로드할 수 있어야 했기 때문이다. +- 어떻게: + - `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지를 생성하고 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 조인으로 정산 내역을 조회하도록 구현했다. + - 정산 내역은 `Content.id`, `Order.type` 기준으로 그룹화하고 결과에 시리즈 제목, 콘텐츠 제목, 가격, 구분, 판매 수, 합계 캔, 합계 포인트를 담도록 구성했다. + - 가격은 `audioContent.price`를 기준으로 계산하고, 대여는 응답 변환 시 `ceil(price * 0.7)`를 적용해 포인트 사용으로 `order.can`이 바뀐 주문도 동일한 표시 가격을 유지하도록 했다. + - 엑셀은 `SXSSFWorkbook(100)` 기반 스트리밍으로 생성하고, 오리지널 시리즈 소지 유저별로 시트를 만들며 데이터가 없으면 헤더만 기록하도록 했다. + - 테스트/검증 실행 결과: + - `lsp_diagnostics` (신규 Kotlin 파일 경로) → Kotlin LSP 미설정으로 진단 불가 + - `./gradlew test --tests "*AdminOriginalSeriesCalculate*"` → 성공 + - `./gradlew ktlintCheck` → 성공 + - `./gradlew build` → 성공 + - `./gradlew test --tests "*AdminOriginalSeriesCalculateServiceTest.shouldCreateOneSheetPerOwnerForExcel"` → 성공 From 6c49abc54e23dae35f3d709b7cc5317566c01fdf Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 18:29:49 +0900 Subject: [PATCH 4/8] =?UTF-8?q?docs(original-series-calculate):=20?= =?UTF-8?q?=EC=98=A4=EB=A6=AC=EC=A7=80=EB=84=90=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=A0=95=EC=82=B0=20API=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클라이언트 구현에 필요한 요청/응답 계약과 엑셀 시트 구성을 문서에 명확히 남긴다. 서버 API 계약을 문서만으로 그대로 사용할 수 있게 해 혼선을 줄인다. --- docs/20260421_오리지널시리즈정산내역.md | 209 +++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/docs/20260421_오리지널시리즈정산내역.md b/docs/20260421_오리지널시리즈정산내역.md index 477d538f..3c495fba 100644 --- a/docs/20260421_오리지널시리즈정산내역.md +++ b/docs/20260421_오리지널시리즈정산내역.md @@ -3,7 +3,7 @@ - [x] 기존 관리자 정산 API 패턴(`/admin/calculate`, list/excel 쌍, `StreamingResponseBody`)을 유지하는 신규 패키지 구조를 확정한다. - [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 소지 유저 조회 API를 추가한다. - [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 정산 내역 조회 API를 추가한다. -- [x] 정산 내역 조회 API가 `startDateStr`, `endDateStr`, `memberId`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다. +- [x] 정산 내역 조회 API가 `startDate`, `endDate`, `memberId`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다. - [x] 정산 내역 조회 쿼리가 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 경로를 사용해 `Content.id`, `Order.type` 기준으로 그룹화되도록 구현한다. - [x] 정산 내역 결과에 시리즈 타이틀, 콘텐츠 타이틀, 가격, 대여/소장 여부, 판매 수, 합계(캔), 합계(포인트)를 포함하도록 DTO를 추가한다. - [x] 가격은 주문에 저장된 값을 기준으로 사용하되, 대여는 `ceil(price * 0.7)` 규칙이 반영된 값으로 노출되도록 검증한다. @@ -19,6 +19,203 @@ - 날짜 입력 파라미터는 `startDate`, `endDate`, `memberId`로 받고, 날짜 값은 KST 기준 `00:00:00` / `23:59:59`로 해석한 뒤 `convertLocalDateTime()`으로 UTC `LocalDateTime`으로 변환한다. - 정산 내역 조회 API는 `memberId`를 필수 필터로 사용하고, 엑셀 API는 전체 소지 유저를 순회해 시트를 생성하는 것으로 해석한다. +## API 명세 + +### 1. 오리지널 시리즈 소지 유저 조회 + +#### URI +- `GET /admin/calculate/original-series/owners` + +#### Request +- Header + - 인증: 관리자 권한 필요 (`hasRole('ADMIN')`) +- Query Parameter + - 없음 +- Body + - 없음 + +#### Response +- Content-Type: `application/json` +- 응답 구조 + +```json +{ + "success": true, + "message": null, + "data": [ + { + "memberId": 1, + "nickname": "owner-a" + }, + { + "memberId": 2, + "nickname": "owner-b" + } + ], + "errorProperty": null +} +``` + +#### Response Field +- `success`: 성공 여부 +- `message`: 성공 메시지, 현재 구현에서는 `null` +- `data`: 오리지널 시리즈 소지 유저 목록 + - `memberId`: 정산 내역 조회에 사용할 멤버 ID + - `nickname`: 관리자 화면에 노출할 닉네임 +- `errorProperty`: 에러 시 사용되는 필드, 성공 시 `null` + +### 2. 오리지널 시리즈 정산 내역 조회 + +#### URI +- `GET /admin/calculate/original-series/settlement-details` + +#### Request +- Header + - 인증: 관리자 권한 필요 (`hasRole('ADMIN')`) +- Query Parameter + - `startDate`: 시작일, 형식 `yyyy-MM-dd`, KST 기준 + - `endDate`: 종료일, 형식 `yyyy-MM-dd`, KST 기준 + - `memberId`: 오리지널 시리즈 소지 유저 ID + - `page`: 페이지 번호, Spring `Pageable` 규칙 사용 + - `size`: 페이지 크기, Spring `Pageable` 규칙 사용 +- Body + - 없음 + +#### Request Example + +```text +GET /admin/calculate/original-series/settlement-details?startDate=2026-04-01&endDate=2026-04-30&memberId=1&page=0&size=20 +``` + +#### Response +- Content-Type: `application/json` +- 응답 구조 + +```json +{ + "success": true, + "message": null, + "data": { + "totalCount": 2, + "items": [ + { + "seriesTitle": "오리지널 시리즈", + "contentTitle": "1화", + "price": 70, + "orderType": "대여", + "salesCount": 2, + "totalCan": 130, + "totalPoint": 100 + }, + { + "seriesTitle": "오리지널 시리즈", + "contentTitle": "1화", + "price": 100, + "orderType": "소장", + "salesCount": 2, + "totalCan": 200, + "totalPoint": 25 + } + ] + }, + "errorProperty": null +} +``` + +#### Response Field +- `data.totalCount`: 조회 결과 전체 건수 +- `data.items`: 정산 내역 목록 + - `seriesTitle`: 시리즈 제목 + - `contentTitle`: 작품 화 제목 + - `price`: 표시 가격(캔) + - `소장`: `audioContent.price` + - `대여`: `ceil(audioContent.price * 0.7)` + - `orderType`: `대여` 또는 `소장` + - `salesCount`: 판매 건수 + - `totalCan`: 합계 캔 + - `totalPoint`: 합계 포인트 + +### 3. 오리지널 시리즈 정산 내역 엑셀 다운로드 + +#### URI +- `GET /admin/calculate/original-series/settlement-details/excel` + +#### Request +- Header + - 인증: 관리자 권한 필요 (`hasRole('ADMIN')`) +- Query Parameter + - `startDate`: 시작일, 형식 `yyyy-MM-dd`, KST 기준 + - `endDate`: 종료일, 형식 `yyyy-MM-dd`, KST 기준 +- Body + - 없음 + +#### Request Example + +```text +GET /admin/calculate/original-series/settlement-details/excel?startDate=2026-04-01&endDate=2026-04-30 +``` + +#### Response +- Content-Type: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` +- Header + - `Content-Disposition: attachment; filename*=UTF-8''original-series-settlement-details.xlsx` +- Body + - `.xlsx` 바이너리 스트림 + +#### Excel 구성 +- 오리지널 시리즈 소지 유저별로 시트 1개 생성 +- 시트명 형식: `{memberId}_{nickname}` +- 각 시트의 헤더 열 순서 + 1. `시리즈 제목` + 2. `콘텐츠 제목` + 3. `가격(캔)` + 4. `구분` + 5. `판매 수` + 6. `합계(캔)` + 7. `합계(포인트)` +- 해당 기간에 데이터가 없는 유저도 헤더만 있는 시트를 유지 + +## 클라이언트 기능 구현용 프롬프트 + +```text +관리자 페이지에서 오리지널 시리즈 작품 화별 정산 내역을 조회하고 엑셀 다운로드하는 클라이언트 기능을 구현한다. + +필수 API 계약은 아래와 같다. + +1. 소지 유저 조회 +- GET /admin/calculate/original-series/owners +- 응답은 ApiResponse> 형태다. + +2. 정산 내역 조회 +- GET /admin/calculate/original-series/settlement-details +- query: startDate(yyyy-MM-dd), endDate(yyyy-MM-dd), memberId(number), page(number), size(number) +- 응답은 ApiResponse<{ totalCount: number; items: Array<{ seriesTitle: string; contentTitle: string; price: number; orderType: string; salesCount: number; totalCan: number; totalPoint: number }> }> 형태다. + +3. 엑셀 다운로드 +- GET /admin/calculate/original-series/settlement-details/excel +- query: startDate(yyyy-MM-dd), endDate(yyyy-MM-dd) +- 응답은 xlsx 바이너리이며 파일명은 `original-series-settlement-details.xlsx` 이다. + +구현 요구사항: +- 화면 진입 시 owners API를 먼저 호출해 멤버 선택 드롭다운 데이터를 로드한다. +- 사용자가 startDate, endDate, memberId를 선택한 뒤 정산 내역 조회 API를 호출한다. +- 목록 테이블에는 시리즈 제목, 콘텐츠 제목, 가격, 구분, 판매 수, 합계(캔), 합계(포인트)를 그대로 표시한다. +- 페이지네이션은 page/size 기반으로 처리하고 totalCount를 사용해 총 페이지 수를 계산한다. +- 엑셀 다운로드 버튼은 현재 선택된 startDate/endDate만 사용해 excel API를 호출한다. +- JSON 응답은 항상 ApiResponse 래퍼의 success/data/message/errorProperty를 기준으로 처리한다. +- success가 false이면 message를 우선 노출한다. +- 날짜는 문자열 `yyyy-MM-dd` 형식으로 서버에 전달한다. + +산출물 요구사항: +- API 호출 함수 +- 타입 정의 +- 목록 조회 상태 관리 +- 엑셀 다운로드 처리 +- 에러 처리 로직 + +임의로 API 계약을 바꾸지 말고, 위 URI/Request/Response를 그대로 사용한다. +``` + ## 검증 기록 ### 1차 구현 @@ -35,3 +232,13 @@ - `./gradlew ktlintCheck` → 성공 - `./gradlew build` → 성공 - `./gradlew test --tests "*AdminOriginalSeriesCalculateServiceTest.shouldCreateOneSheetPerOwnerForExcel"` → 성공 + +### 2차 문서화 +- 무엇을: 기존 작업 계획 문서에 오리지널 시리즈 정산 API 3종의 URI, Request, Response 상세 명세와 클라이언트 기능 구현용 프롬프트를 추가했다. +- 왜: 클라이언트 기능 구현 시 서버 API 계약을 문서만 보고 그대로 사용할 수 있어야 하기 때문이다. +- 어떻게: + - `AdminOriginalSeriesCalculateController`, 응답 DTO, `ApiResponse` 구조를 기준으로 문서에 JSON 예시와 필드 설명을 정리했다. + - 엑셀 다운로드 API는 바이너리 응답과 헤더 규격, 시트 구성 규칙을 함께 기록했다. + - 클라이언트 개발자가 그대로 복사해 사용할 수 있도록 API 호출 순서와 상태 처리 조건을 포함한 프롬프트를 추가했다. + - 실행 결과: + - 수정 문서 재확인(`read`) → 성공 From 5098994f4bb9070794858c45526cab17e92e3ca8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 19:15:55 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor(original-series-calculate):=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EC=A1=B0=ED=9A=8C=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=EB=A5=BC=20snake=5Fcase=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminOriginalSeriesCalculateController.kt | 12 ++--- ...inOriginalSeriesCalculateControllerTest.kt | 44 +++++++++++++++++-- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt index 4f5f826c..3939430b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt @@ -25,15 +25,15 @@ class AdminOriginalSeriesCalculateController( @GetMapping("/original-series/settlement-details") fun getSettlementDetails( - @RequestParam("startDate") startDateStr: String, - @RequestParam("endDate") endDateStr: String, - @RequestParam memberId: Long, + @RequestParam("start_date") startDateStr: String, + @RequestParam("end_date") endDateStr: String, + @RequestParam("creator_id") creatorId: Long, pageable: Pageable ) = ApiResponse.ok( service.getSettlementDetails( startDateStr = startDateStr, endDateStr = endDateStr, - memberId = memberId, + creatorId = creatorId, offset = pageable.offset, limit = pageable.pageSize.toLong() ) @@ -41,8 +41,8 @@ class AdminOriginalSeriesCalculateController( @GetMapping("/original-series/settlement-details/excel") fun downloadSettlementDetailsExcel( - @RequestParam("startDate") startDateStr: String, - @RequestParam("endDate") endDateStr: String + @RequestParam("start_date") startDateStr: String, + @RequestParam("end_date") endDateStr: String ): ResponseEntity = createExcelResponse( fileName = "original-series-settlement-details.xlsx", response = service.downloadSettlementDetailsExcel(startDateStr, endDateStr) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt index e4b667eb..58eadce1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt @@ -23,13 +23,14 @@ class AdminOriginalSeriesCalculateControllerTest { @Test @DisplayName("관리자 컨트롤러는 오리지널 시리즈 소지 유저 목록을 반환한다") fun shouldReturnOriginalSeriesOwners() { - val owners = listOf(GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a")) + val owners = listOf(GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a")) Mockito.`when`(service.getOriginalSeriesOwners()).thenReturn(owners) val response = controller.getOriginalSeriesOwners() assertEquals(true, response.success) assertEquals(1, response.data!!.size) + assertEquals(1L, response.data!![0].creatorId) assertEquals("owner-a", response.data!![0].nickname) Mockito.verify(service).getOriginalSeriesOwners() } @@ -55,7 +56,7 @@ class AdminOriginalSeriesCalculateControllerTest { service.getSettlementDetails( startDateStr = "2026-04-01", endDateStr = "2026-04-30", - memberId = 5L, + creatorId = 5L, offset = 10L, limit = 10L ) @@ -64,7 +65,7 @@ class AdminOriginalSeriesCalculateControllerTest { val response = controller.getSettlementDetails( startDateStr = "2026-04-01", endDateStr = "2026-04-30", - memberId = 5L, + creatorId = 5L, pageable = PageRequest.of(1, 10) ) @@ -74,12 +75,47 @@ class AdminOriginalSeriesCalculateControllerTest { Mockito.verify(service).getSettlementDetails( startDateStr = "2026-04-01", endDateStr = "2026-04-30", - memberId = 5L, + creatorId = 5L, offset = 10L, limit = 10L ) } + @Test + @DisplayName("정산 내역 조회 API는 snake_case 파라미터를 사용한다") + fun shouldUseSnakeCaseQueryParameterNames() { + val method = AdminOriginalSeriesCalculateController::class.java + .getDeclaredMethod( + "getSettlementDetails", + String::class.java, + String::class.java, + java.lang.Long.TYPE, + org.springframework.data.domain.Pageable::class.java + ) + val parameters = method.parameters + val requestParamClass = org.springframework.web.bind.annotation.RequestParam::class.java + + assertEquals("start_date", parameters[0].getAnnotation(requestParamClass).value) + assertEquals("end_date", parameters[1].getAnnotation(requestParamClass).value) + assertEquals("creator_id", parameters[2].getAnnotation(requestParamClass).value) + } + + @Test + @DisplayName("정산 엑셀 다운로드 API는 snake_case 파라미터를 사용한다") + fun shouldUseSnakeCaseQueryParameterNamesForExcel() { + val method = AdminOriginalSeriesCalculateController::class.java + .getDeclaredMethod( + "downloadSettlementDetailsExcel", + String::class.java, + String::class.java + ) + val parameters = method.parameters + val requestParamClass = org.springframework.web.bind.annotation.RequestParam::class.java + + assertEquals("start_date", parameters[0].getAnnotation(requestParamClass).value) + assertEquals("end_date", parameters[1].getAnnotation(requestParamClass).value) + } + @Test @DisplayName("관리자 컨트롤러는 오리지널 시리즈 정산 엑셀을 다운로드한다") fun shouldDownloadOriginalSeriesSettlementExcel() { From 72f49f2471fd0a302c041b86117a4ba7381c2484 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 19:16:09 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor(original-series-calculate):=20?= =?UTF-8?q?=EC=86=8C=EC=A7=80=20=EC=9C=A0=EC=A0=80=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=8B=9D=EB=B3=84=EC=9E=90=EB=A5=BC=20creatorId=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminOriginalSeriesCalculateQueryRepository.kt | 12 ++++++------ .../GetAdminOriginalSeriesOwnerResponse.kt | 2 +- ...dminOriginalSeriesCalculateQueryRepositoryTest.kt | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt index 9dd09739..7fd8b630 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt @@ -37,7 +37,7 @@ class AdminOriginalSeriesCalculateQueryRepository( fun getSettlementDetailTotalCount( startDate: LocalDateTime, endDate: LocalDateTime, - memberId: Long + creatorId: Long ): Int { return queryFactory .select(audioContent.id) @@ -46,7 +46,7 @@ class AdminOriginalSeriesCalculateQueryRepository( .innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id)) .innerJoin(seriesContent.series, series) .innerJoin(series.member, member) - .where(baseWhereCondition(startDate, endDate, memberId)) + .where(baseWhereCondition(startDate, endDate, creatorId)) .groupBy( series.id, series.title, @@ -62,7 +62,7 @@ class AdminOriginalSeriesCalculateQueryRepository( fun getSettlementDetails( startDate: LocalDateTime, endDate: LocalDateTime, - memberId: Long, + creatorId: Long, offset: Long, limit: Long ): List { @@ -83,7 +83,7 @@ class AdminOriginalSeriesCalculateQueryRepository( .innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id)) .innerJoin(seriesContent.series, series) .innerJoin(series.member, member) - .where(baseWhereCondition(startDate, endDate, memberId)) + .where(baseWhereCondition(startDate, endDate, creatorId)) .groupBy( series.id, series.title, @@ -101,12 +101,12 @@ class AdminOriginalSeriesCalculateQueryRepository( private fun baseWhereCondition( startDate: LocalDateTime, endDate: LocalDateTime, - memberId: Long + creatorId: Long ): BooleanExpression { return series.isOriginal.isTrue .and(series.isActive.isTrue) .and(member.isActive.isTrue) - .and(member.id.eq(memberId)) + .and(member.id.eq(creatorId)) .and(order.isActive.isTrue) .and(order.createdAt.goe(startDate)) .and(order.createdAt.loe(endDate)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt index 5d7f2d60..ef90db94 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt @@ -4,6 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.querydsl.core.annotations.QueryProjection data class GetAdminOriginalSeriesOwnerResponse @QueryProjection constructor( - @JsonProperty("memberId") val memberId: Long, + @JsonProperty("creatorId") val creatorId: Long, @JsonProperty("nickname") val nickname: String ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt index e0aeb12c..8cb015dd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt @@ -54,8 +54,8 @@ class AdminOriginalSeriesCalculateQueryRepositoryTest @Autowired constructor( val result = repository.getOriginalSeriesOwners() assertEquals(2, result.size) - assertEquals(ownerA.id, result[0].memberId) - assertEquals(ownerB.id, result[1].memberId) + assertEquals(ownerA.id, result[0].creatorId) + assertEquals(ownerB.id, result[1].creatorId) } @Test From 49b1aa8f0c881535967e3ba0d267e506d38dd69e Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 19:16:34 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(original-series-calculate):=20=EC=86=8C?= =?UTF-8?q?=EC=A7=80=20=EC=9C=A0=EC=A0=80=EB=B3=84=20=EC=A0=95=EC=82=B0=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=8B=9C=ED=8A=B8=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminOriginalSeriesCalculateService.kt | 16 ++++----- ...AdminOriginalSeriesCalculateServiceTest.kt | 33 +++++++++++++++---- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt index 9c9310af..048cec36 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt @@ -22,14 +22,14 @@ class AdminOriginalSeriesCalculateService( fun getSettlementDetails( startDateStr: String, endDateStr: String, - memberId: Long, + creatorId: Long, offset: Long, limit: Long ): GetAdminOriginalSeriesSettlementDetailListResponse { val (startDate, endDate) = toDateRange(startDateStr, endDateStr) - val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, memberId) + val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, creatorId) val items = repository - .getSettlementDetails(startDate, endDate, memberId, offset, limit) + .getSettlementDetails(startDate, endDate, creatorId, offset, limit) .map { it.toResponse() } return GetAdminOriginalSeriesSettlementDetailListResponse(totalCount, items) @@ -43,18 +43,16 @@ class AdminOriginalSeriesCalculateService( return StreamingResponseBody { outputStream -> val workbook = SXSSFWorkbook(100) try { - if (owners.isEmpty()) { - writeHeaders(workbook.createSheet("오리지널 시리즈 정산")) - } else { + if (owners.isNotEmpty()) { owners.forEach { owner -> val sheet = workbook.createSheet(toSheetName(owner)) writeHeaders(sheet) - val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.memberId) + val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.creatorId) val items = if (totalCount == 0) { emptyList() } else { - repository.getSettlementDetails(startDate, endDate, owner.memberId, 0L, totalCount.toLong()) + repository.getSettlementDetails(startDate, endDate, owner.creatorId, 0L, totalCount.toLong()) .map { it.toResponse() } } @@ -92,7 +90,7 @@ class AdminOriginalSeriesCalculateService( } private fun toSheetName(owner: GetAdminOriginalSeriesOwnerResponse): String { - return WorkbookUtil.createSafeSheetName("${owner.memberId}_${owner.nickname}") + return WorkbookUtil.createSafeSheetName(owner.nickname) } private fun toDateRange(startDateStr: String, endDateStr: String): Pair { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt index b8b5c0e0..15cea8db 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt @@ -25,13 +25,13 @@ class AdminOriginalSeriesCalculateServiceTest { @Test @DisplayName("오리지널 시리즈 소지 유저 목록 조회는 리포지토리 결과를 반환한다") fun shouldReturnOriginalSeriesOwners() { - val owners = listOf(GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a")) + val owners = listOf(GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a")) Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners) val result = service.getOriginalSeriesOwners() assertEquals(1, result.size) - assertEquals(1L, result[0].memberId) + assertEquals(1L, result[0].creatorId) Mockito.verify(repository).getOriginalSeriesOwners() } @@ -67,7 +67,7 @@ class AdminOriginalSeriesCalculateServiceTest { val result = service.getSettlementDetails( startDateStr = "2026-04-01", endDateStr = "2026-04-30", - memberId = 9L, + creatorId = 9L, offset = 0L, limit = 20L ) @@ -96,8 +96,8 @@ class AdminOriginalSeriesCalculateServiceTest { @DisplayName("정산 엑셀 다운로드는 소지 유저별 시트를 생성한다") fun shouldCreateOneSheetPerOwnerForExcel() { val owners = listOf( - GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a"), - GetAdminOriginalSeriesOwnerResponse(memberId = 2L, nickname = "owner-b") + GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a"), + GetAdminOriginalSeriesOwnerResponse(creatorId = 2L, nickname = "owner-b") ) Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners) Mockito.`when`( @@ -147,8 +147,8 @@ class AdminOriginalSeriesCalculateServiceTest { XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook -> assertEquals(2, workbook.numberOfSheets) - assertEquals("1_owner-a", workbook.getSheetAt(0).sheetName) - assertEquals("2_owner-b", workbook.getSheetAt(1).sheetName) + assertEquals("owner-a", workbook.getSheetAt(0).sheetName) + assertEquals("owner-b", workbook.getSheetAt(1).sheetName) assertEquals("시리즈 제목", workbook.getSheetAt(0).getRow(0).getCell(0).stringCellValue) assertEquals("오리지널 시리즈", workbook.getSheetAt(0).getRow(1).getCell(0).stringCellValue) assertEquals("시리즈 제목", workbook.getSheetAt(1).getRow(0).getCell(0).stringCellValue) @@ -164,4 +164,23 @@ class AdminOriginalSeriesCalculateServiceTest { 1L ) } + + @Test + @DisplayName("소지 유저가 없으면 엑셀에 시트를 생성하지 않는다") + fun shouldCreateWorkbookWithoutSheetsWhenNoOwners() { + Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(emptyList()) + + val response = service.downloadSettlementDetailsExcel( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30" + ) + val outputStream = ByteArrayOutputStream() + response.writeTo(outputStream) + + assertTrue(outputStream.toByteArray().isNotEmpty()) + + XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook -> + assertEquals(0, workbook.numberOfSheets) + } + } } From 19bd07fe1413a95b9c6a9e32062549b37886c88d Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 19:16:58 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs(original-series-calculate):=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20API=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=EC=99=80=20=EC=9D=91=EB=8B=B5=20=EC=98=88=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260421_오리지널시리즈정산내역.md | 38 ++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/20260421_오리지널시리즈정산내역.md b/docs/20260421_오리지널시리즈정산내역.md index 3c495fba..e0d58056 100644 --- a/docs/20260421_오리지널시리즈정산내역.md +++ b/docs/20260421_오리지널시리즈정산내역.md @@ -3,7 +3,7 @@ - [x] 기존 관리자 정산 API 패턴(`/admin/calculate`, list/excel 쌍, `StreamingResponseBody`)을 유지하는 신규 패키지 구조를 확정한다. - [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 소지 유저 조회 API를 추가한다. - [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 정산 내역 조회 API를 추가한다. -- [x] 정산 내역 조회 API가 `startDate`, `endDate`, `memberId`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다. +- [x] 정산 내역 조회 API가 `start_date`, `end_date`, `creator_id`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다. - [x] 정산 내역 조회 쿼리가 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 경로를 사용해 `Content.id`, `Order.type` 기준으로 그룹화되도록 구현한다. - [x] 정산 내역 결과에 시리즈 타이틀, 콘텐츠 타이틀, 가격, 대여/소장 여부, 판매 수, 합계(캔), 합계(포인트)를 포함하도록 DTO를 추가한다. - [x] 가격은 주문에 저장된 값을 기준으로 사용하되, 대여는 `ceil(price * 0.7)` 규칙이 반영된 값으로 노출되도록 검증한다. @@ -16,8 +16,8 @@ - 오리지널 시리즈 여부는 `Series.isOriginal` 플래그를 기준으로 판단한다. - 소지 유저는 현재 코드베이스 기준 `Series.member`를 의미하는 것으로 해석한다. -- 날짜 입력 파라미터는 `startDate`, `endDate`, `memberId`로 받고, 날짜 값은 KST 기준 `00:00:00` / `23:59:59`로 해석한 뒤 `convertLocalDateTime()`으로 UTC `LocalDateTime`으로 변환한다. -- 정산 내역 조회 API는 `memberId`를 필수 필터로 사용하고, 엑셀 API는 전체 소지 유저를 순회해 시트를 생성하는 것으로 해석한다. +- 날짜 입력 파라미터는 `start_date`, `end_date`, `creator_id`로 받고, 날짜 값은 KST 기준 `00:00:00` / `23:59:59`로 해석한 뒤 `convertLocalDateTime()`으로 UTC `LocalDateTime`으로 변환한다. +- 정산 내역 조회 API는 `creator_id`를 필수 필터로 사용하고, 엑셀 API는 전체 소지 유저를 순회해 시트를 생성하는 것으로 해석한다. ## API 명세 @@ -44,11 +44,11 @@ "message": null, "data": [ { - "memberId": 1, + "creatorId": 1, "nickname": "owner-a" }, { - "memberId": 2, + "creatorId": 2, "nickname": "owner-b" } ], @@ -60,7 +60,7 @@ - `success`: 성공 여부 - `message`: 성공 메시지, 현재 구현에서는 `null` - `data`: 오리지널 시리즈 소지 유저 목록 - - `memberId`: 정산 내역 조회에 사용할 멤버 ID + - `creatorId`: 정산 내역 조회에 사용할 크리에이터 ID - `nickname`: 관리자 화면에 노출할 닉네임 - `errorProperty`: 에러 시 사용되는 필드, 성공 시 `null` @@ -73,9 +73,9 @@ - Header - 인증: 관리자 권한 필요 (`hasRole('ADMIN')`) - Query Parameter - - `startDate`: 시작일, 형식 `yyyy-MM-dd`, KST 기준 - - `endDate`: 종료일, 형식 `yyyy-MM-dd`, KST 기준 - - `memberId`: 오리지널 시리즈 소지 유저 ID + - `start_date`: 시작일, 형식 `yyyy-MM-dd`, KST 기준 + - `end_date`: 종료일, 형식 `yyyy-MM-dd`, KST 기준 + - `creator_id`: 오리지널 시리즈 소지 유저 ID - `page`: 페이지 번호, Spring `Pageable` 규칙 사용 - `size`: 페이지 크기, Spring `Pageable` 규칙 사용 - Body @@ -84,7 +84,7 @@ #### Request Example ```text -GET /admin/calculate/original-series/settlement-details?startDate=2026-04-01&endDate=2026-04-30&memberId=1&page=0&size=20 +GET /admin/calculate/original-series/settlement-details?start_date=2026-04-01&end_date=2026-04-30&creator_id=1&page=0&size=20 ``` #### Response @@ -144,15 +144,15 @@ GET /admin/calculate/original-series/settlement-details?startDate=2026-04-01&end - Header - 인증: 관리자 권한 필요 (`hasRole('ADMIN')`) - Query Parameter - - `startDate`: 시작일, 형식 `yyyy-MM-dd`, KST 기준 - - `endDate`: 종료일, 형식 `yyyy-MM-dd`, KST 기준 + - `start_date`: 시작일, 형식 `yyyy-MM-dd`, KST 기준 + - `end_date`: 종료일, 형식 `yyyy-MM-dd`, KST 기준 - Body - 없음 #### Request Example ```text -GET /admin/calculate/original-series/settlement-details/excel?startDate=2026-04-01&endDate=2026-04-30 +GET /admin/calculate/original-series/settlement-details/excel?start_date=2026-04-01&end_date=2026-04-30 ``` #### Response @@ -164,7 +164,7 @@ GET /admin/calculate/original-series/settlement-details/excel?startDate=2026-04- #### Excel 구성 - 오리지널 시리즈 소지 유저별로 시트 1개 생성 -- 시트명 형식: `{memberId}_{nickname}` +- 시트명 형식: `{nickname}` - 각 시트의 헤더 열 순서 1. `시리즈 제목` 2. `콘텐츠 제목` @@ -184,24 +184,24 @@ GET /admin/calculate/original-series/settlement-details/excel?startDate=2026-04- 1. 소지 유저 조회 - GET /admin/calculate/original-series/owners -- 응답은 ApiResponse> 형태다. +- 응답은 ApiResponse> 형태다. 2. 정산 내역 조회 - GET /admin/calculate/original-series/settlement-details -- query: startDate(yyyy-MM-dd), endDate(yyyy-MM-dd), memberId(number), page(number), size(number) +- query: start_date(yyyy-MM-dd), end_date(yyyy-MM-dd), creator_id(number), page(number), size(number) - 응답은 ApiResponse<{ totalCount: number; items: Array<{ seriesTitle: string; contentTitle: string; price: number; orderType: string; salesCount: number; totalCan: number; totalPoint: number }> }> 형태다. 3. 엑셀 다운로드 - GET /admin/calculate/original-series/settlement-details/excel -- query: startDate(yyyy-MM-dd), endDate(yyyy-MM-dd) +- query: start_date(yyyy-MM-dd), end_date(yyyy-MM-dd) - 응답은 xlsx 바이너리이며 파일명은 `original-series-settlement-details.xlsx` 이다. 구현 요구사항: - 화면 진입 시 owners API를 먼저 호출해 멤버 선택 드롭다운 데이터를 로드한다. -- 사용자가 startDate, endDate, memberId를 선택한 뒤 정산 내역 조회 API를 호출한다. +- 사용자가 start_date, end_date, creator_id를 선택한 뒤 정산 내역 조회 API를 호출한다. - 목록 테이블에는 시리즈 제목, 콘텐츠 제목, 가격, 구분, 판매 수, 합계(캔), 합계(포인트)를 그대로 표시한다. - 페이지네이션은 page/size 기반으로 처리하고 totalCount를 사용해 총 페이지 수를 계산한다. -- 엑셀 다운로드 버튼은 현재 선택된 startDate/endDate만 사용해 excel API를 호출한다. +- 엑셀 다운로드 버튼은 현재 선택된 start_date/end_date만 사용해 excel API를 호출한다. - JSON 응답은 항상 ApiResponse 래퍼의 success/data/message/errorProperty를 기준으로 처리한다. - success가 false이면 message를 우선 노출한다. - 날짜는 문자열 `yyyy-MM-dd` 형식으로 서버에 전달한다.