test #397

Merged
klaus merged 15 commits from test into main 2026-03-05 09:17:38 +00:00
36 changed files with 1657 additions and 105 deletions

View File

@@ -0,0 +1,34 @@
- [x] 관리자 차단 신규 API/DTO/서비스 파일 생성
- [x] 차단 처리 시 탈퇴 이유 저장 및 회원 비활성화 처리
- [x] 차단 처리 시 Redis 로그인 토큰 전체 삭제
- [x] 본인인증 회원 BlockAuth 기록 처리
- [x] 동일 본인인증 정보 계정 일괄 탈퇴 처리
- [x] 활성 계정 조회 조건을 `name + birth + di + uniqueCi`로 강화
- [x] 관리자 차단 서비스 테스트 추가
- [x] 정적 진단 및 테스트/빌드 검증
## 검증 기록
### 1차 구현
- 무엇을: `kr.co.vividnext.sodalive.admin.member` 패키지에 신규 관리자 차단 API(`AdminMemberBlockController`), 요청 DTO(`AdminMemberBlockRequest`), 서비스(`AdminMemberBlockService`)를 추가했다. 서비스에서 탈퇴 이유 저장/회원 비활성화, Redis 로그인 토큰 전체 삭제, 본인인증 정보 `BlockAuth` 기록을 순서대로 처리하고, 서비스 단위 테스트(`AdminMemberBlockServiceTest`)를 추가했다.
- 왜: 관리자 페이지에서 사용자 차단 시 계정 비활성화 이력, 세션 무효화, 본인인증 기반 재가입 차단 정보를 한 번의 동작으로 일관되게 처리하기 위해서다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
### 2차 수정
- 무엇을: 관리자 차단 시 차단 대상 회원의 본인인증 정보(`di`)와 동일한 활성 계정을 모두 조회해 일괄 탈퇴 처리하도록 `AdminMemberBlockService`를 수정했다. 각 대상 계정마다 탈퇴 사유(`SignOut`) 저장, 회원 비활성화, Redis 로그인 토큰 전체 삭제를 수행하고, 기존 `BlockAuth` 저장 로직은 유지했다. 테스트도 동일 본인인증 다계정 탈퇴 시나리오를 포함하도록 확장했다.
- 왜: 본인인증 정보를 공유하는 다중 계정을 관리자 차단 시 함께 정리해야 우회 가입 계정이 활성 상태로 남지 않기 때문이다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
### 3차 수정
- 무엇을: 활성 계정 조회 조건을 `di` 단일 조건에서 `name + birth + di + uniqueCi` AND 조건으로 강화했다. 이를 위해 `AuthRepository`의 활성 계정 조회 메서드를 `getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(...)`로 변경하고, 호출부인 `AdminMemberBlockService`, `AuthService.authenticate`를 모두 신규 메서드로 교체했다. `AdminMemberBlockServiceTest`도 신규 시그니처 기준으로 스텁/검증을 수정했다.
- 왜: `di`만으로 동일인을 판단하면 과매칭 리스크가 있어, 본인인증 핵심 식별 속성을 함께 사용해 활성 계정 판별 정확도를 높이기 위해서다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.

View File

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

View File

@@ -0,0 +1,20 @@
# 관리자 정산 콘텐츠 크리에이터별 조회 SQL 오류 수정 작업 계획
- [x] `/admin/calculate/content-by-creator` 호출 경로(Controller/Service/Repository)와 SQL 생성 지점을 확인한다.
- [x] `ONLY_FULL_GROUP_BY` 위반 원인(`content_settlement_ratio` 비집계 컬럼)을 제거하는 최소 수정안을 적용한다.
- [x] 수정된 쿼리가 기존 응답 스키마/정산 계산 로직과 호환되는지 코드 레벨로 검증한다.
- [x] `lsp_diagnostics`, 관련 테스트, 빌드를 실행해 정상 동작을 검증한다.
## 검증 기록
### 1차 수정
- 무엇을: `AdminCalculateQueryRepository#getCalculateContentByCreator``groupBy``member.id`에서 `member.id, creatorSettlementRatio.contentSettlementRatio`로 수정해 SELECT의 비집계 컬럼(`contentSettlementRatio`)이 GROUP BY에 포함되도록 변경했다.
- 왜: `/admin/calculate/content-by-creator` 조회 시 `creator_settlement_ratio.content_settlement_ratio`가 SELECT 절에 존재하지만 GROUP BY에 없어 MySQL `ONLY_FULL_GROUP_BY` 모드에서 SQLSyntaxErrorException이 발생했기 때문이다.
- 어떻게:
- 경로/원인 확인: `AdminCalculateController#getCalculateContentByCreator` -> `AdminCalculateService#getCalculateContentByCreator` -> `AdminCalculateQueryRepository#getCalculateContentByCreator` 호출 체인을 확인했다.
- 코드 수정: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`의 콘텐츠 크리에이터별 조회 쿼리 `groupBy`를 보완했다.
- 검증 실행 결과:
- `lsp_diagnostics` (`AdminCalculateQueryRepository.kt`) -> Kotlin LSP 미설정으로 진단 불가
- `./gradlew test` -> 성공
- `./gradlew build -x test` -> 성공
- `./gradlew tasks --all` -> 성공

View File

@@ -0,0 +1,14 @@
- [x] 페이징 미적용 관리자 정산 API 식별
- [x] Controller에 Pageable 파라미터 추가 및 Service 호출에 offset/limit 전달
- [x] Service/Repository 쿼리에 offset/limit 반영
- [x] 정적 진단 및 테스트/빌드 검증
## 검증 기록
### 1차 구현
- 무엇을: 관리자 정산 API 중 페이징이 없던 `/admin/calculate/live`, `/admin/calculate/content-list`, `/admin/calculate/content-donation-list``Pageable` 기반 페이징을 추가하고, 응답을 `totalCount + items` 구조로 변경했다. 또한 동일 쿼리를 사용하는 엑셀 다운로드 로직이 기존과 동일하게 전체 데이터를 내려주도록 totalCount 기반 전체 조회 방식으로 맞췄다.
- 왜: 조회 건수가 많아질 수 있는 정산 목록 API에서 페이지 단위 조회를 지원해 응답 크기와 조회 성능을 안정적으로 관리하기 위해서다.
- 어떻게:
- 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.

View File

@@ -0,0 +1,12 @@
# 관리자 충전 상태 상세 응답 필드 수정
- [x] `GetChargeStatusDetailResponse`에서 `memberId` 제거
- [x] `GetChargeStatusDetailResponse``chargeId` 추가
- [x] 연관 매핑 코드 반영 및 빌드 검증
## 검증 기록
### 1차 구현
- 무엇을: 관리자 충전 상세 응답 DTO의 식별자를 `memberId`에서 `chargeId`로 변경하고, Query DTO/서비스 매핑/QueryDSL select 값을 동일하게 정합성 맞춰 수정했다.
- 왜: 충전 상세 응답에서 회원 식별자 대신 충전 건 식별자를 내려주도록 요구사항이 변경되었기 때문이다.
- 어떻게: `lsp_diagnostics``.kt` 확장자 LSP 미설정으로 도구 검증이 불가해 사유를 확인했고, `./gradlew build`를 실행해 컴파일/테스트/체크를 통합 검증했으며 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,12 @@
# 관리자 충전 상세 캔 개수 추가
- [x] `GetChargeStatusDetailResponse``chargeCan`, `rewardCan` 필드 추가
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` QueryProjection 인자에 캔 개수 매핑 추가
- [x] 관련 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
## 검증 기록
### 1차 구현
- 무엇을: 관리자 충전 상세 응답 DTO에 `chargeCan`, `rewardCan` 필드를 추가하고, 상세 조회 QueryProjection(`QGetChargeStatusDetailResponse`) 인자에 `charge.chargeCan`, `charge.rewardCan` 매핑을 추가했다.
- 왜: 충전 상세 응답에 유료 캔/보너스 캔 수량 정보를 함께 내려주기 위한 요구사항을 반영하기 위해서다.
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test``./gradlew build -x test`를 실행해 테스트/빌드 모두 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,13 @@
# 관리자 충전 상세 QueryProjection 리팩토링
- [x] `AdminChargeStatusService.getChargeStatusDetail` 후처리 매핑 제거
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` 반환 타입을 응답 DTO QueryProjection으로 변경
- [x] 관련 DTO/QueryDSL 생성 타입 정합성 확인
- [x] 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
## 검증 기록
### 1차 구현
- 무엇을: `GetChargeStatusDetailResponse``@QueryProjection`을 적용하고, `AdminChargeStatusQueryRepository`가 해당 DTO를 직접 select 하도록 변경했으며, 서비스의 후처리 `map`을 제거했다. 또한 불필요해진 `GetChargeStatusDetailQueryDto.kt` 파일을 삭제했다.
- 왜: 상세 응답 가공을 서비스에서 한 번 더 수행하지 않고 DB 조회 시점(QueryProjection)에서 완성된 응답 형태를 가져오도록 구조를 단순화하기 위해서다.
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test``./gradlew build -x test`를 실행해 테스트/빌드 성공(`BUILD SUCCESSFUL`)을 확인했다.

View File

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

View File

@@ -0,0 +1,38 @@
- [x] 기존 charge/payment/member 및 admin API 패턴 확인
- [x] `kr.co.vividnext.sodalive.admin.charge` 패키지에 캔 환불 API 생성
- [x] 환불 조건 검증 구현 (미사용, 7일 이내)
- [x] ChargeEntity/PaymentEntity/MemberEntity 환불 반영 로직 구현
- [x] 캔 환불 API 테스트 코드 작성
- [x] 검증 실행 및 결과 기록
## 환불 조건 상세
- 환불 가능 충전내역 조건: `charge.status == CHARGE` 그리고 `payment.status == COMPLETE`
- 이미 사용한 캔 판정 조건: `charge.title`에서 숫자를 추출해 현재 `chargeCan/rewardCan`과 비교
- 예시1) `100 캔 + 50 캔` -> `chargeCan = 100`, `rewardCan = 50`
- 예시2) `5,000 캔 + 500 캔` -> `chargeCan = 5000`, `rewardCan = 500`
- 예시3) `500캔` -> `chargeCan = 500`
- 예시4) `4,000 캔` -> `chargeCan = 4000`
## 검증 기록
### 1차 구현
- 무엇을: 관리자 캔 환불 API(`POST /admin/charge/refund`)와 환불 서비스/요청 DTO, i18n 메시지, 단위 테스트를 추가했다.
- 왜: 사용하지 않은 캔만 7일 이내 환불 가능하도록 하고, 환불 시 Charge/Payment/Member 상태를 요구사항대로 갱신하기 위해.
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
- `./gradlew build` 실행 → 성공 (ktlint/check/test/build 포함)
- LSP 진단 시도(`lsp_diagnostics`) → Kotlin LSP 미설정으로 불가, 대신 Gradle 컴파일/ktlint/test/build로 검증
### 2차 수정
- 무엇을: `AdminChargeRefundServiceTest`에 한글 `@DisplayName`을 추가하고, 각 테스트 문단에 given/when/then 역할 주석을 보강했다.
- 왜: 테스트 의도를 한눈에 파악하고, 문단별 책임을 명확히 하기 위해.
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
- `./gradlew ktlintTestSourceSetCheck` 실행 → 성공
### 3차 수정
- 무엇을: 이미 사용한 캔 판정을 `charge.title` 숫자 파싱 비교 방식으로 변경하고, 단일 숫자/콤마 포함 제목 테스트 케이스를 추가했다.
- 왜: 환불 조건을 충전 제목 기반 비교 규칙(단일/복수 숫자, 콤마 포함)으로 명확하게 적용하기 위해.
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
- `./gradlew build` 실행 → 성공

View File

@@ -0,0 +1,16 @@
- [x] `getCalculateContentDonationList` 호출 경로(Controller → Service → QueryData) 확인
- [x] 유료/무료 콘텐츠 후원 정산 비율이 모두 70%로 적용되는지 검증
- [x] `GetCalculateContentDonationQueryData` 계산 로직의 불필요 분기/중복 제거 및 가독성 개선
- [x] 관련 테스트/빌드/정적 진단 실행 및 결과 확인
---
## 검증 기록
### 1차 구현
- 무엇을: `GetCalculateContentDonationQueryData`에서 유료/무료 공통 정산 비율 70% 적용 상태를 확인하고, 정산 계산 상수(`KRW_PER_CAN`, `PAYMENT_FEE_RATE`, `SETTLEMENT_RATE`, `TAX_RATE`)를 `companion object`로 추출해 계산 로직을 정리했다.
- 왜: 유료/무료 분기 제거 후 동일 70% 정책을 명확히 유지하고, `BigDecimal` 상수 재사용으로 계산 의도와 유지보수성을 높이기 위해서다.
- 어떻게: 호출 경로(`AdminCalculateController``AdminCalculateService``AdminCalculateQueryRepository``GetCalculateContentDonationQueryData`)를 확인했고, 정적 진단은 `.kt` LSP 미구성으로 대체 검증했다. 실행 명령과 결과는 아래와 같다.
- `lsp_diagnostics` (`GetCalculateContentDonationQueryData.kt`): Kotlin LSP 미지원으로 실행 불가(환경 제약 확인)
- `./gradlew test`: 성공 (`BUILD SUCCESSFUL`)
- `./gradlew build`: 성공 (`BUILD SUCCESSFUL`, `ktlintMainSourceSetCheck` 포함)

View File

@@ -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')")
@@ -14,15 +20,49 @@ import org.springframework.web.bind.annotation.RestController
class AdminCalculateController(private val service: AdminCalculateService) {
@GetMapping("/live")
fun getCalculateLive(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getCalculateLive(
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
@GetMapping("/live/excel")
fun downloadCalculateLiveExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr))
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "live.xlsx",
response = service.downloadCalculateLiveExcel(startDateStr, endDateStr)
)
@GetMapping("/content-list")
fun getCalculateContentList(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getCalculateContentList(
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
@GetMapping("/content-list/excel")
fun downloadCalculateContentListExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateContentList(startDateStr, endDateStr))
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "content-list.xlsx",
response = service.downloadCalculateContentListExcel(startDateStr, endDateStr)
)
@GetMapping("/cumulative-sales-by-content")
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
@@ -31,9 +71,26 @@ class AdminCalculateController(private val service: AdminCalculateService) {
@GetMapping("/content-donation-list")
fun getCalculateContentDonationList(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getCalculateContentDonationList(
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
@GetMapping("/content-donation-list/excel")
fun downloadCalculateContentDonationListExcel(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateContentDonationList(startDateStr, endDateStr))
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "content-donation-list.xlsx",
response = service.downloadCalculateContentDonationListExcel(startDateStr, endDateStr)
)
@GetMapping("/community-post")
fun getCalculateCommunityPost(
@@ -49,6 +106,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 +129,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 +152,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 +174,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)
}
}

View File

@@ -18,7 +18,33 @@ import java.time.LocalDateTime
@Repository
class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getCalculateLive(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateLiveQueryData> {
fun getCalculateLiveTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(liveRoom.id)
.from(useCan)
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
.fetch()
.size
}
fun getCalculateLive(
startDate: LocalDateTime,
endDate: LocalDateTime,
offset: Long,
limit: Long
): List<GetCalculateLiveQueryData> {
val formattedDate = getFormattedDate(liveRoom.beginDateTime)
return queryFactory
@@ -50,10 +76,50 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
)
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
.offset(offset)
.limit(limit)
.fetch()
}
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
fun getCalculateContentListTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
val orderFormattedDate = getFormattedDate(order.createdAt)
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
return queryFactory
.select(audioContent.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
.and(order.isActive.isTrue)
)
.groupBy(
audioContent.id,
order.type,
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.fetch()
.size
}
fun getCalculateContentList(
startDate: LocalDateTime,
endDate: LocalDateTime,
offset: Long,
limit: Long
): List<GetCalculateContentQueryData> {
val orderFormattedDate = getFormattedDate(order.createdAt)
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
@@ -96,6 +162,8 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
creatorSettlementRatio.contentSettlementRatio
)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
.offset(offset)
.limit(limit)
.fetch()
}
@@ -167,11 +235,33 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.fetch()
}
fun getCalculateContentDonationListTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
val donationFormattedDate = getFormattedDate(useCan.createdAt)
return queryFactory
.select(audioContent.id)
.from(useCan)
.innerJoin(useCan.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.DONATION))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.groupBy(donationFormattedDate, audioContent.id)
.fetch()
.size
}
fun getCalculateContentDonationList(
startDate: LocalDateTime,
endDate: LocalDateTime
endDate: LocalDateTime,
offset: Long,
limit: Long
): List<GetCalculateContentDonationQueryData> {
val donationFormattedDate = getFormattedDate(useCan.createdAt)
return queryFactory
.select(
QGetCalculateContentDonationQueryData(
@@ -195,6 +285,8 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
)
.groupBy(donationFormattedDate, audioContent.id)
.orderBy(member.id.asc(), donationFormattedDate.desc(), audioContent.id.desc())
.offset(offset)
.limit(limit)
.fetch()
}
@@ -361,7 +453,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.and(order.createdAt.loe(endDate))
.and(order.isActive.isTrue)
)
.groupBy(member.id)
.groupBy(member.id, creatorSettlementRatio.contentSettlementRatio)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)

View File

@@ -2,38 +2,56 @@ 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) {
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'calculateLive:' + " + "#startDateStr + ':' + #endDateStr"
key = "'calculateLive:' + " + "#startDateStr + ':' + #endDateStr + ':' + #offset + ':' + #limit"
)
fun getCalculateLive(startDateStr: String, endDateStr: String): List<GetCalculateLiveResponse> {
fun getCalculateLive(
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetCalculateLiveListResponse {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return repository
.getCalculateLive(startDate, endDate)
val totalCount = repository.getCalculateLiveTotalCount(startDate, endDate)
val items = repository
.getCalculateLive(startDate, endDate, offset, limit)
.map { it.toGetCalculateLiveResponse() }
return GetCalculateLiveListResponse(totalCount, items)
}
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'calculateContent:' + " + "#startDateStr + ':' + #endDateStr"
key = "'calculateContent:' + " + "#startDateStr + ':' + #endDateStr + ':' + #offset + ':' + #limit"
)
fun getCalculateContentList(startDateStr: String, endDateStr: String): List<GetCalculateContentResponse> {
fun getCalculateContentList(
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetCalculateContentListResponse {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return repository
.getCalculateContentList(startDate, endDate)
val totalCount = repository.getCalculateContentListTotalCount(startDate, endDate)
val items = repository
.getCalculateContentList(startDate, endDate, offset, limit)
.map { it.toGetCalculateContentResponse() }
return GetCalculateContentListResponse(totalCount, items)
}
@Transactional(readOnly = true)
@@ -53,18 +71,22 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'calculateContentDonationList2:' + " + "#startDateStr + ':' + #endDateStr"
key = "'calculateContentDonationList2:' + " + "#startDateStr + ':' + #endDateStr + ':' + #offset + ':' + #limit"
)
fun getCalculateContentDonationList(
startDateStr: String,
endDateStr: String
): List<GetCalculateContentDonationResponse> {
endDateStr: String,
offset: Long,
limit: Long
): GetCalculateContentDonationListResponse {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return repository
.getCalculateContentDonationList(startDate, endDate)
val totalCount = repository.getCalculateContentDonationListTotalCount(startDate, endDate)
val items = repository
.getCalculateContentDonationList(startDate, endDate, offset, limit)
.map { it.toGetCalculateContentDonationResponse() }
return GetCalculateContentDonationListResponse(totalCount, items)
}
@Transactional(readOnly = true)
@@ -139,4 +161,301 @@ 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 totalCount = repository.getCalculateLiveTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateLive(startDate, endDate, 0L, totalCount.toLong())
.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 totalCount = repository.getCalculateContentListTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateContentList(startDate, endDate, 0L, totalCount.toLong())
.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 totalCount = repository.getCalculateContentDonationListTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateContentDonationList(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateContentDonationResponse() }
}
return createExcelStream(
sheetName = "콘텐츠 후원 정산",
headers = listOf(
"크리에이터",
"콘텐츠 제목",
"유무료",
"등록일",
"후원일",
"후원건수",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.nickname)
row.createCell(1).setCellValue(item.title)
row.createCell(2).setCellValue(item.paidOrFree)
row.createCell(3).setCellValue(item.registrationDate)
row.createCell(4).setCellValue(item.donationDate)
row.createCell(5).setCellValue(item.numberOfDonation.toDouble())
row.createCell(6).setCellValue(item.totalCan.toDouble())
row.createCell(7).setCellValue(item.totalKrw.toDouble())
row.createCell(8).setCellValue(item.paymentFee.toDouble())
row.createCell(9).setCellValue(item.settlementAmount.toDouble())
row.createCell(10).setCellValue(item.tax.toDouble())
row.createCell(11).setCellValue(item.depositAmount.toDouble())
}
}
}
@Transactional(readOnly = true)
fun downloadCalculateCommunityPostExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateCommunityPostTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateCommunityPostList(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateCommunityPostResponse() }
}
return createExcelStream(
sheetName = "커뮤니티 정산",
headers = listOf(
"크리에이터",
"게시글",
"날짜",
"가격(캔)",
"구매건수",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.nickname)
row.createCell(1).setCellValue(item.title)
row.createCell(2).setCellValue(item.date)
row.createCell(3).setCellValue(item.can.toDouble())
row.createCell(4).setCellValue(item.numberOfPurchase.toDouble())
row.createCell(5).setCellValue(item.totalCan.toDouble())
row.createCell(6).setCellValue(item.totalKrw.toDouble())
row.createCell(7).setCellValue(item.paymentFee.toDouble())
row.createCell(8).setCellValue(item.settlementAmount.toDouble())
row.createCell(9).setCellValue(item.tax.toDouble())
row.createCell(10).setCellValue(item.depositAmount.toDouble())
}
}
}
@Transactional(readOnly = true)
fun downloadCalculateLiveByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateLiveByCreator(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateByCreator() }
}
return createCalculateByCreatorExcel("크리에이터별 라이브 정산", items)
}
@Transactional(readOnly = true)
fun downloadCalculateContentByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateContentByCreatorTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateContentByCreator(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateByCreator() }
}
return createCalculateByCreatorExcel("크리에이터별 콘텐츠 정산", items)
}
@Transactional(readOnly = true)
fun downloadCalculateCommunityByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate)
val items = if (totalCount == 0) {
emptyList()
} else {
repository
.getCalculateCommunityByCreator(startDate, endDate, 0L, totalCount.toLong())
.map { it.toGetCalculateByCreator() }
}
return createCalculateByCreatorExcel("크리에이터별 커뮤니티 정산", items)
}
private fun createCalculateByCreatorExcel(
sheetName: String,
items: List<GetCalculateByCreatorItem>
): StreamingResponseBody {
return createExcelStream(
sheetName = sheetName,
headers = listOf(
"이메일",
"닉네임",
"총 캔",
"원화",
"결제수수료",
"정산금액",
"원천세",
"입금액"
)
) { sheet ->
items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.email)
row.createCell(1).setCellValue(item.nickname)
row.createCell(2).setCellValue(item.totalCan.toDouble())
row.createCell(3).setCellValue(item.totalKrw.toDouble())
row.createCell(4).setCellValue(item.paymentFee.toDouble())
row.createCell(5).setCellValue(item.settlementAmount.toDouble())
row.createCell(6).setCellValue(item.tax.toDouble())
row.createCell(7).setCellValue(item.depositAmount.toDouble())
}
}
}
private fun createExcelStream(
sheetName: String,
headers: List<String>,
writeRows: (Sheet) -> Unit
): StreamingResponseBody {
return StreamingResponseBody { outputStream ->
val workbook = SXSSFWorkbook(100)
try {
val sheet = workbook.createSheet(sheetName)
val headerRow = sheet.createRow(0)
headers.forEachIndexed { index, value ->
headerRow.createCell(index).setCellValue(value)
}
writeRows(sheet)
workbook.write(outputStream)
outputStream.flush()
} finally {
workbook.dispose()
workbook.close()
}
}
}
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return startDate to endDate
}
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.calculate
data class GetCalculateContentDonationListResponse(
val totalCount: Int,
val items: List<GetCalculateContentDonationResponse>
)

View File

@@ -20,33 +20,32 @@ data class GetCalculateContentDonationQueryData @QueryProjection constructor(
// 합계
val totalCan: Int
) {
companion object {
private val KRW_PER_CAN = BigDecimal("100")
private val PAYMENT_FEE_RATE = BigDecimal("0.066")
private val SETTLEMENT_RATE = BigDecimal("0.7")
private val TAX_RATE = BigDecimal("0.033")
}
fun toGetCalculateContentDonationResponse(): GetCalculateContentDonationResponse {
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
val totalKrw = BigDecimal(totalCan).multiply(KRW_PER_CAN)
// 결제수수료 : 6.6%
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
val paymentFee = totalKrw.multiply(PAYMENT_FEE_RATE)
// 정산금액
// 유료콘텐츠 (원화 - 결제수수료) 의 90%
// 유료콘텐츠 (원화 - 결제수수료) 의 70%
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
val settlementAmount = if (price > 0) {
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.9))
} else {
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
}
val settlementAmount = totalKrw.subtract(paymentFee).multiply(SETTLEMENT_RATE)
// 원천세 = 정산금액의 3.3%
val tax = settlementAmount.multiply(BigDecimal(0.033))
val tax = settlementAmount.multiply(TAX_RATE)
// 입금액
val depositAmount = settlementAmount.subtract(tax)
val paidOrFree = if (price > 0) {
"유료"
} else {
"무료"
}
val paidOrFree = if (price > 0) "유료" else "무료"
return GetCalculateContentDonationResponse(
nickname = nickname,

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.calculate
data class GetCalculateContentListResponse(
val totalCount: Int,
val items: List<GetCalculateContentResponse>
)

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.calculate
data class GetCalculateLiveListResponse(
val totalCount: Int,
val items: List<GetCalculateLiveResponse>
)

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/charge")
class AdminChargeRefundController(private val service: AdminChargeRefundService) {
@PostMapping("/refund")
fun refund(@RequestBody request: AdminChargeRefundRequest) = ApiResponse.ok(service.refund(request))
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.admin.charge
data class AdminChargeRefundRequest(
val chargeId: Long
)

View File

@@ -0,0 +1,106 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
@Service
class AdminChargeRefundService(
private val chargeRepository: ChargeRepository,
private val messageSource: SodaMessageSource,
private val langContext: LangContext
) {
@Transactional
fun refund(request: AdminChargeRefundRequest) {
val charge = chargeRepository.findByIdOrNull(request.chargeId)
?: throw SodaException(messageKey = "common.error.invalid_request")
val payment = charge.payment
?: throw SodaException(messageKey = "common.error.invalid_request")
val member = charge.member
?: throw SodaException(messageKey = "common.error.invalid_request")
if (charge.status != ChargeStatus.CHARGE || payment.status != PaymentStatus.COMPLETE) {
throw SodaException(messageKey = "can.payment.refund.invalid_request")
}
validateRefundDate(charge)
validateUnusedCan(charge)
deductMemberCan(member, payment.paymentGateway, charge.chargeCan, charge.rewardCan)
charge.chargeCan = 0
charge.rewardCan = 0
charge.status = ChargeStatus.CANCEL
payment.status = PaymentStatus.RETURN
}
private fun validateUnusedCan(charge: Charge) {
val title = charge.title ?: throw SodaException(messageKey = "common.error.invalid_request")
val (originalChargeCan, originalRewardCan) = extractCanFromTitle(title)
if (charge.chargeCan != originalChargeCan || charge.rewardCan != originalRewardCan) {
throw SodaException(messageKey = "can.payment.refund.used_not_allowed")
}
}
private fun extractCanFromTitle(title: String): Pair<Int, Int> {
val parsedNumbers = TITLE_CAN_REGEX
.findAll(title)
.map { it.value.replace(",", "").toIntOrNull() }
.toList()
if (parsedNumbers.isEmpty() || parsedNumbers.first() == null) {
throw SodaException(messageKey = "common.error.invalid_request")
}
val chargeCanFromTitle = parsedNumbers.first()!!
val rewardCanFromTitle = parsedNumbers.getOrNull(1) ?: 0
return Pair(chargeCanFromTitle, rewardCanFromTitle)
}
private fun validateRefundDate(charge: Charge) {
val chargedAt = charge.createdAt
?: throw SodaException(messageKey = "common.error.invalid_request")
val now = LocalDateTime.now()
if (now.isAfter(chargedAt.plusDays(7))) {
val passedDays = ChronoUnit.DAYS.between(chargedAt.toLocalDate(), now.toLocalDate())
val messageTemplate = messageSource.getMessage("can.payment.refund.days_exceeded", langContext.lang)
?: "충천 후 %s일이 지나서 환불할 수 없습니다."
throw SodaException(message = String.format(messageTemplate, passedDays))
}
}
private fun deductMemberCan(member: Member, paymentGateway: PaymentGateway, chargeCan: Int, rewardCan: Int) {
when (paymentGateway) {
PaymentGateway.GOOGLE_IAP -> {
member.googleChargeCan -= chargeCan
member.googleRewardCan -= rewardCan
}
PaymentGateway.APPLE_IAP -> {
member.appleChargeCan -= chargeCan
member.appleRewardCan -= rewardCan
}
else -> {
member.pgChargeCan -= chargeCan
member.pgRewardCan -= rewardCan
}
}
}
companion object {
private val TITLE_CAN_REGEX = Regex("\\d[\\d,]*")
}
}

View File

@@ -88,7 +88,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
endDate: LocalDateTime,
paymentGateway: PaymentGateway,
currency: String? = null
): List<GetChargeStatusDetailQueryDto> {
): List<GetChargeStatusDetailResponse> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
@@ -117,11 +117,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
return queryFactory
.select(
QGetChargeStatusDetailQueryDto(
member.id,
QGetChargeStatusDetailResponse(
charge.id,
member.nickname,
payment.method.coalesce(""),
payment.price,
charge.chargeCan,
charge.rewardCan,
currencyExpr,
formattedDate
)

View File

@@ -44,15 +44,5 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.toLocalDateTime()
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
.map {
GetChargeStatusDetailResponse(
memberId = it.memberId,
nickname = it.nickname,
method = it.method,
amount = it.amount,
locale = it.locale,
datetime = it.datetime
)
}
}
}

View File

@@ -1,13 +0,0 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val method: String,
val amount: BigDecimal,
val locale: String,
val datetime: String
)

View File

@@ -1,12 +1,15 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetChargeStatusDetailResponse(
val memberId: Long,
data class GetChargeStatusDetailResponse @QueryProjection constructor(
val chargeId: Long,
val nickname: String,
val method: String,
val amount: BigDecimal,
val chargeCan: Int,
val rewardCan: Int,
val locale: String,
val datetime: String
)

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/member/block")
@PreAuthorize("hasRole('ADMIN')")
class AdminMemberBlockController(private val service: AdminMemberBlockService) {
@PostMapping
fun blockMember(@RequestBody request: AdminMemberBlockRequest) = ApiResponse.ok(
service.blockMember(request),
"차단되었습니다."
)
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.member
data class AdminMemberBlockRequest(
val memberId: Long,
val reason: String
)

View File

@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.SignOut
import kr.co.vividnext.sodalive.member.SignOutRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.auth.BlockAuth
import kr.co.vividnext.sodalive.member.auth.BlockAuthRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminMemberBlockService(
private val adminMemberRepository: AdminMemberRepository,
private val signOutRepository: SignOutRepository,
private val memberService: MemberService,
private val authRepository: AuthRepository,
private val blockAuthRepository: BlockAuthRepository
) {
@Transactional
fun blockMember(request: AdminMemberBlockRequest) {
if (request.reason.isBlank()) {
throw SodaException(messageKey = "member.validation.signout_reason_required")
}
val member = adminMemberRepository.findByIdAndActive(memberId = request.memberId)
?: throw SodaException(messageKey = "admin.member.not_found")
val auth = member.auth
val memberIdsToBlock = if (auth != null) {
authRepository.getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
name = auth.name,
birth = auth.birth,
di = auth.di,
uniqueCi = auth.uniqueCi
)
.ifEmpty { listOf(member.id!!) }
} else {
listOf(member.id!!)
}
memberIdsToBlock
.distinct()
.forEach { memberId ->
val targetMember = adminMemberRepository.findByIdAndActive(memberId = memberId)
?: return@forEach
targetMember.isActive = false
targetMember.nickname = "deleted_${targetMember.nickname}"
val signOut = SignOut(reason = request.reason)
signOut.member = targetMember
signOutRepository.save(signOut)
memberService.logoutAll(memberId = targetMember.id!!)
}
if (auth == null) return
val alreadyBlockedAuthId = blockAuthRepository.findByUniqueCiAndDi(auth.uniqueCi, auth.di)
if (alreadyBlockedAuthId == null || alreadyBlockedAuthId <= 0) {
blockAuthRepository.save(
BlockAuth(
name = auth.name,
birth = auth.birth,
uniqueCi = auth.uniqueCi,
di = auth.di,
gender = auth.gender
)
)
}
}
}

View File

@@ -614,6 +614,16 @@ class SodaMessageSource {
Lang.KO to "%s 캔이 부족합니다. 충전 후 이용해 주세요.",
Lang.EN to "You are short of %s cans. Please recharge and try again.",
Lang.JA to "%sCANが不足しています。チャージしてからご利用ください。"
),
"can.payment.refund.used_not_allowed" to mapOf(
Lang.KO to "사용한 캔은 환불할 수 없습니다.",
Lang.EN to "Used cans cannot be refunded.",
Lang.JA to "使用したCANは返金できません。"
),
"can.payment.refund.days_exceeded" to mapOf(
Lang.KO to "충천 후 %s일이 지나서 환불할 수 없습니다.",
Lang.EN to "Refund is not available because %s days have passed since charging.",
Lang.JA to "チャージ後%s日が経過しているため返金できません。"
)
)

View File

@@ -15,7 +15,7 @@ interface AuthQueryRepository {
fun getMemberIdsByDi(di: String): List<Long>
fun getMemberIdsByNameAndBirthAndDiAndGender(name: String, birth: String, di: String, gender: Int): List<Long>
fun getAuthIdByMemberId(memberId: Long): Long?
fun getActiveMemberIdsByDi(di: String): List<Long>
fun getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(name: String, birth: String, di: String, uniqueCi: String): List<Long>
}
class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQueryRepository {
@@ -60,13 +60,21 @@ class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQ
.fetchFirst()
}
override fun getActiveMemberIdsByDi(di: String): List<Long> {
override fun getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
name: String,
birth: String,
di: String,
uniqueCi: String
): List<Long> {
return queryFactory
.select(member.id)
.from(member)
.leftJoin(member.auth, auth)
.where(
auth.di.eq(di)
auth.name.eq(name)
.and(auth.birth.eq(birth))
.and(auth.di.eq(di))
.and(auth.uniqueCi.eq(uniqueCi))
.and(member.isActive.isTrue)
)
.fetch()

View File

@@ -81,7 +81,12 @@ class AuthService(
@Transactional
fun authenticate(certificate: AuthVerifyCertificate, memberId: Long): AuthResponse {
val memberIds = repository.getActiveMemberIdsByDi(di = certificate.di)
val memberIds = repository.getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
name = certificate.name,
birth = certificate.birth,
di = certificate.di,
uniqueCi = certificate.unique
)
if (memberIds.size >= 3) {
val message = messageSource.getMessage("member.auth.max_accounts", langContext.lang) ?: ""
throw SodaException(

View File

@@ -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",

View File

@@ -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(),

View File

@@ -0,0 +1,255 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
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.time.LocalDateTime
import java.util.Optional
@DisplayName("관리자 캔 환불 서비스 테스트")
class AdminChargeRefundServiceTest {
private lateinit var chargeRepository: ChargeRepository
private lateinit var messageSource: SodaMessageSource
private lateinit var langContext: LangContext
private lateinit var service: AdminChargeRefundService
@BeforeEach
fun setup() {
// given: 테스트 대상 의존성을 목 객체로 준비한다.
chargeRepository = Mockito.mock(ChargeRepository::class.java)
messageSource = Mockito.mock(SodaMessageSource::class.java)
langContext = Mockito.mock(LangContext::class.java)
// given: 메시지 포맷 테스트를 위해 기본 언어를 한국어로 고정한다.
Mockito.`when`(langContext.lang).thenReturn(Lang.KO)
// given: 환불 서비스 인스턴스를 생성한다.
service = AdminChargeRefundService(
chargeRepository = chargeRepository,
messageSource = messageSource,
langContext = langContext
)
}
@Test
@DisplayName("사용하지 않은 구글 인앱 캔을 7일 이내에 환불한다")
fun shouldRefundGoogleIapCanWhenUnusedWithinSevenDays() {
// given: 구글 인앱 충전 캔을 보유한 회원을 준비한다.
val member = Member(password = "password", nickname = "tester")
member.googleChargeCan = 50
member.googleRewardCan = 10
// given: 7일 이내 생성된 충전/결제 데이터를 준비한다.
val charge = Charge(50, 10, status = ChargeStatus.CHARGE)
charge.id = 1L
charge.title = "50 캔 + 10 캔"
charge.createdAt = LocalDateTime.now().minusDays(3)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.GOOGLE_IAP)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(1L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 1L))
// then: 충전/결제/회원 캔 상태가 환불 기준으로 변경된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.googleChargeCan)
assertEquals(0, member.googleRewardCan)
}
@Test
@DisplayName("사용하지 않은 PG 캔을 7일 이내에 환불한다 (콤마 포함 제목 파싱)")
fun shouldRefundPgCanWhenUnusedWithinSevenDays() {
// given: PG 충전 캔을 보유한 회원을 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 5000
member.pgRewardCan = 500
// given: 콤마가 포함된 제목과 7일 이내 생성된 충전/결제 데이터를 준비한다.
val charge = Charge(5000, 500, status = ChargeStatus.CHARGE)
charge.id = 2L
charge.title = "5,000 캔 + 500 캔"
charge.createdAt = LocalDateTime.now().minusDays(1)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(2L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 2L))
// then: 충전/결제/회원 캔 상태가 환불 기준으로 변경된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.pgChargeCan)
assertEquals(0, member.pgRewardCan)
}
@Test
@DisplayName("제목이 단일 숫자(500캔)인 경우 chargeCan만 비교해 환불한다")
fun shouldRefundWhenTitleContainsOnlyChargeCanWithoutSpace() {
// given: 단일 숫자 제목과 일치하는 PG 충전 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 500
member.pgRewardCan = 0
val charge = Charge(500, 0, status = ChargeStatus.CHARGE)
charge.id = 21L
charge.title = "500캔"
charge.createdAt = LocalDateTime.now().minusDays(2)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(21L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 21L))
// then: 단일 숫자 제목을 chargeCan으로 파싱해 정상 환불된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.pgChargeCan)
assertEquals(0, member.pgRewardCan)
}
@Test
@DisplayName("제목이 단일 숫자(4,000 캔)인 경우 콤마를 제거하고 chargeCan을 비교한다")
fun shouldRefundWhenTitleContainsOnlyChargeCanWithComma() {
// given: 콤마가 포함된 단일 숫자 제목과 일치하는 PG 충전 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 4000
member.pgRewardCan = 0
val charge = Charge(4000, 0, status = ChargeStatus.CHARGE)
charge.id = 22L
charge.title = "4,000 캔"
charge.createdAt = LocalDateTime.now().minusDays(2)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(22L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 22L))
// then: 콤마를 제거한 값(4000)으로 비교해 정상 환불된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.pgChargeCan)
assertEquals(0, member.pgRewardCan)
}
@Test
@DisplayName("이미 사용한 캔은 환불할 수 없다")
fun shouldThrowWhenCanWasUsed() {
// given: 제목 숫자(원본) 대비 현재 charge 수량이 감소한 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 80
member.pgRewardCan = 50
val charge = Charge(80, 50, status = ChargeStatus.CHARGE)
charge.id = 3L
charge.title = "100 캔 + 50 캔"
charge.createdAt = LocalDateTime.now().minusDays(2)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 사용 이력이 있는 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(3L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행하고 예외를 수집한다.
val exception = assertThrows(SodaException::class.java) {
service.refund(AdminChargeRefundRequest(chargeId = 3L))
}
// then: 사용 캔 환불 불가 메시지 키가 반환된다.
assertEquals("can.payment.refund.used_not_allowed", exception.messageKey)
}
@Test
@DisplayName("충전 후 7일이 지난 캔은 환불할 수 없다")
fun shouldThrowWhenChargedMoreThanSevenDaysAgo() {
// given: 7일을 초과한 충전 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 100
member.pgRewardCan = 20
val charge = Charge(100, 20, status = ChargeStatus.CHARGE)
charge.id = 4L
charge.title = "100 캔 + 20 캔"
charge.createdAt = LocalDateTime.now().minusDays(10)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 및 메시지 템플릿 반환 동작을 설정한다.
Mockito.`when`(chargeRepository.findById(4L)).thenReturn(Optional.of(charge))
Mockito.`when`(
messageSource.getMessage("can.payment.refund.days_exceeded", Lang.KO)
).thenReturn("충천 후 %s일이 지나서 환불할 수 없습니다.")
// when: 관리자 환불을 실행하고 예외를 수집한다.
val exception = assertThrows(SodaException::class.java) {
service.refund(AdminChargeRefundRequest(chargeId = 4L))
}
// then: 일수 포함 환불 불가 메시지가 반환된다.
assertTrue(exception.message!!.startsWith("충천 후 "))
assertTrue(exception.message!!.contains("환불할 수 없습니다."))
}
@Test
@DisplayName("존재하지 않는 충전 건은 환불할 수 없다")
fun shouldThrowWhenChargeNotFound() {
// given: 환불 대상 충전이 존재하지 않도록 설정한다.
Mockito.`when`(chargeRepository.findById(999L)).thenReturn(Optional.empty())
// when: 관리자 환불을 실행하고 예외를 수집한다.
val exception = assertThrows(SodaException::class.java) {
service.refund(AdminChargeRefundRequest(chargeId = 999L))
}
// then: 잘못된 요청 메시지 키가 반환된다.
assertEquals("common.error.invalid_request", exception.messageKey)
}
}

View File

@@ -0,0 +1,159 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.SignOut
import kr.co.vividnext.sodalive.member.SignOutRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.auth.BlockAuth
import kr.co.vividnext.sodalive.member.auth.BlockAuthRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
class AdminMemberBlockServiceTest {
private lateinit var adminMemberRepository: AdminMemberRepository
private lateinit var signOutRepository: SignOutRepository
private lateinit var memberService: MemberService
private lateinit var authRepository: AuthRepository
private lateinit var blockAuthRepository: BlockAuthRepository
private lateinit var service: AdminMemberBlockService
@BeforeEach
fun setup() {
adminMemberRepository = Mockito.mock(AdminMemberRepository::class.java)
signOutRepository = Mockito.mock(SignOutRepository::class.java)
memberService = Mockito.mock(MemberService::class.java)
authRepository = Mockito.mock(AuthRepository::class.java)
blockAuthRepository = Mockito.mock(BlockAuthRepository::class.java)
service = AdminMemberBlockService(
adminMemberRepository = adminMemberRepository,
signOutRepository = signOutRepository,
memberService = memberService,
authRepository = authRepository,
blockAuthRepository = blockAuthRepository
)
}
@Test
fun shouldBlockAllMembersWithSameAuthAndStoreBlockAuth() {
val member = Member(password = "password", nickname = "tester")
member.id = 101L
val linkedMember = Member(password = "password", nickname = "linked")
linkedMember.id = 202L
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-ci",
di = "di-value",
gender = 1
)
auth.member = member
val request = AdminMemberBlockRequest(memberId = 101L, reason = "운영정책 위반")
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = request.memberId)).thenReturn(member)
Mockito.`when`(
authRepository.getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
name = auth.name,
birth = auth.birth,
di = auth.di,
uniqueCi = auth.uniqueCi
)
).thenReturn(listOf(101L, 202L))
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = 202L)).thenReturn(linkedMember)
Mockito.`when`(blockAuthRepository.findByUniqueCiAndDi(auth.uniqueCi, auth.di)).thenReturn(null)
service.blockMember(request)
assertFalse(member.isActive)
assertEquals("deleted_tester", member.nickname)
assertFalse(linkedMember.isActive)
assertEquals("deleted_linked", linkedMember.nickname)
val signOutCaptor = ArgumentCaptor.forClass(SignOut::class.java)
Mockito.verify(signOutRepository, Mockito.times(2)).save(signOutCaptor.capture())
assertEquals(2, signOutCaptor.allValues.size)
assertTrue(signOutCaptor.allValues.all { it.reason == "운영정책 위반" })
assertEquals(setOf(101L, 202L), signOutCaptor.allValues.mapNotNull { it.member?.id }.toSet())
Mockito.verify(memberService).logoutAll(memberId = 101L)
Mockito.verify(memberService).logoutAll(memberId = 202L)
Mockito.verify(authRepository).getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
name = auth.name,
birth = auth.birth,
di = auth.di,
uniqueCi = auth.uniqueCi
)
Mockito.verify(blockAuthRepository).findByUniqueCiAndDi(auth.uniqueCi, auth.di)
val blockAuthCaptor = ArgumentCaptor.forClass(BlockAuth::class.java)
Mockito.verify(blockAuthRepository).save(blockAuthCaptor.capture())
assertEquals(auth.name, blockAuthCaptor.value.name)
assertEquals(auth.birth, blockAuthCaptor.value.birth)
assertEquals(auth.uniqueCi, blockAuthCaptor.value.uniqueCi)
assertEquals(auth.di, blockAuthCaptor.value.di)
assertEquals(auth.gender, blockAuthCaptor.value.gender)
}
@Test
fun shouldBlockMemberWithoutBlockAuthWhenMemberHasNoVerification() {
val member = Member(password = "password", nickname = "tester")
member.id = 202L
val request = AdminMemberBlockRequest(memberId = 202L, reason = "반복 신고")
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = request.memberId)).thenReturn(member)
service.blockMember(request)
assertFalse(member.isActive)
assertEquals("deleted_tester", member.nickname)
Mockito.verify(signOutRepository).save(Mockito.any(SignOut::class.java))
Mockito.verify(memberService).logoutAll(memberId = 202L)
Mockito.verifyNoInteractions(authRepository)
Mockito.verifyNoInteractions(blockAuthRepository)
}
@Test
fun shouldThrowWhenReasonIsBlank() {
val request = AdminMemberBlockRequest(memberId = 1L, reason = " ")
val exception = assertThrows(SodaException::class.java) {
service.blockMember(request)
}
assertEquals("member.validation.signout_reason_required", exception.messageKey)
Mockito.verifyNoInteractions(adminMemberRepository)
Mockito.verifyNoInteractions(signOutRepository)
Mockito.verifyNoInteractions(memberService)
Mockito.verifyNoInteractions(authRepository)
Mockito.verifyNoInteractions(blockAuthRepository)
}
@Test
fun shouldThrowWhenMemberNotFound() {
val request = AdminMemberBlockRequest(memberId = 303L, reason = "운영자 차단")
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = request.memberId)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.blockMember(request)
}
assertEquals("admin.member.not_found", exception.messageKey)
Mockito.verify(adminMemberRepository).findByIdAndActive(memberId = 303L)
Mockito.verifyNoInteractions(signOutRepository)
Mockito.verifyNoInteractions(memberService)
Mockito.verifyNoInteractions(authRepository)
Mockito.verifyNoInteractions(blockAuthRepository)
}
}