34
docs/20260305_관리자사용자차단기능추가.md
Normal file
34
docs/20260305_관리자사용자차단기능추가.md
Normal 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` 확인.
|
||||||
32
docs/20260305_관리자정산엑셀다운로드추가.md
Normal file
32
docs/20260305_관리자정산엑셀다운로드추가.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 관리자 정산 엑셀 다운로드 추가 작업 계획
|
||||||
|
|
||||||
|
- [x] 기존 정산 API 구조와 엑셀 다운로드 응답 패턴(`ResponseEntity<InputStreamResource>`)을 기준으로 구현 범위를 확정한다.
|
||||||
|
- [x] 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live/excel`)를 추가한다.
|
||||||
|
- [x] 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-list/excel`)를 추가한다.
|
||||||
|
- [x] 콘텐츠 후원 정산 엑셀 다운로드 API(`GET /admin/calculate/content-donation-list/excel`)를 추가한다.
|
||||||
|
- [x] 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-post/excel`)를 추가한다.
|
||||||
|
- [x] 크리에이터별 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live-by-creator/excel`)를 추가한다.
|
||||||
|
- [x] 크리에이터별 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-by-creator/excel`)를 추가한다.
|
||||||
|
- [x] 크리에이터별 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-by-creator/excel`)를 추가한다.
|
||||||
|
- [x] 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-date/excel`)를 추가한다.
|
||||||
|
- [x] 각 엑셀 API가 시작/끝 날짜를 받아 전체 데이터를 내려주도록 서비스/리포지토리를 확장한다.
|
||||||
|
- [x] `lsp_diagnostics`, 테스트, 빌드로 변경사항을 검증하고 결과를 문서 하단에 기록한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 1차 구현
|
||||||
|
- 무엇을: 관리자 정산 API 8종(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별)에 `/excel` 다운로드 엔드포인트를 추가하고, 전체 데이터 엑셀 생성 서비스를 구현했다.
|
||||||
|
- 왜: 페이지네이션 기반 조회 API와 별도로 시작일/종료일 기준의 전체 정산 데이터를 한 번에 내려받을 수 있어야 한다는 요구사항을 충족하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `AdminCalculateController`에 7개 엔드포인트(`.../excel`)를 추가하고 공통 다운로드 헤더(`Content-Disposition`, xlsx content type)를 적용했다.
|
||||||
|
- `AdminCalculateService`에 7개 엑셀 생성 메서드를 추가해 기간 변환 후 전체 데이터 조회 및 `XSSFWorkbook` 기반 시트/헤더/행 작성을 구현했다.
|
||||||
|
- 페이지네이션 대상(커뮤니티 정산, 크리에이터별 정산 3종)은 `totalCount`를 조회해 `offset=0`, `limit=totalCount`로 전체 행을 조회하도록 처리했다.
|
||||||
|
- `AdminChannelDonationCalculateController`에 `GET /admin/calculate/channel-donation-by-date/excel`를 추가하고 기존 크리에이터별 엑셀 응답 로직과 동일한 규칙을 적용했다.
|
||||||
|
- `AdminChannelDonationCalculateService`에 날짜별 엑셀 다운로드 메서드를 추가해 전체 데이터 기준 시트를 생성했다.
|
||||||
|
- 테스트를 보강했다.
|
||||||
|
- `AdminChannelDonationCalculateControllerTest`: 날짜별 엑셀 다운로드 테스트 추가
|
||||||
|
- `AdminChannelDonationCalculateServiceTest`: 날짜별 엑셀 바이트 생성 테스트 추가
|
||||||
|
- 실행 결과:
|
||||||
|
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
|
||||||
|
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
20
docs/20260305_관리자정산콘텐츠크리에이터별조회SQL오류수정.md
Normal file
20
docs/20260305_관리자정산콘텐츠크리에이터별조회SQL오류수정.md
Normal 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` -> 성공
|
||||||
14
docs/20260305_관리자정산페이징추가.md
Normal file
14
docs/20260305_관리자정산페이징추가.md
Normal 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` 확인.
|
||||||
12
docs/20260305_관리자충전상세응답필드수정.md
Normal file
12
docs/20260305_관리자충전상세응답필드수정.md
Normal 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`을 확인했다.
|
||||||
12
docs/20260305_관리자충전상세캔개수추가.md
Normal file
12
docs/20260305_관리자충전상세캔개수추가.md
Normal 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`을 확인했다.
|
||||||
13
docs/20260305_관리자충전상세쿼리프로젝션리팩토링.md
Normal file
13
docs/20260305_관리자충전상세쿼리프로젝션리팩토링.md
Normal 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`)을 확인했다.
|
||||||
27
docs/20260305_정산엑셀스트리밍전환.md
Normal file
27
docs/20260305_정산엑셀스트리밍전환.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 관리자 정산 엑셀 스트리밍 전환 작업 계획
|
||||||
|
|
||||||
|
- [x] 기존 정산 엑셀 다운로드 API의 요청/응답 계약(엔드포인트, 쿼리 파라미터, 헤더)을 유지한다.
|
||||||
|
- [x] `AdminCalculateController`의 엑셀 응답 타입을 `StreamingResponseBody` 기반으로 전환한다.
|
||||||
|
- [x] `AdminCalculateService`의 엑셀 생성 방식을 `XSSFWorkbook + ByteArrayOutputStream`에서 `SXSSFWorkbook + 스트리밍 write`로 전환한다.
|
||||||
|
- [x] `AdminChannelDonationCalculateController`의 날짜별/크리에이터별 엑셀 응답을 `StreamingResponseBody` 기반으로 전환한다.
|
||||||
|
- [x] `AdminChannelDonationCalculateService`의 날짜별/크리에이터별 엑셀 생성을 `SXSSFWorkbook` 스트리밍 방식으로 전환한다.
|
||||||
|
- [x] 관련 테스트를 스트리밍 응답 기준으로 수정한다.
|
||||||
|
- [x] `lsp_diagnostics`, 테스트, 빌드를 실행하고 결과를 검증 기록에 남긴다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 1차 구현
|
||||||
|
- 무엇을: 관리자 정산 엑셀 다운로드 API 전체(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별/채널후원 크리에이터별)의 서버 내부 생성/전송 방식을 스트리밍으로 전환했다.
|
||||||
|
- 왜: 기존 `XSSFWorkbook + ByteArrayOutputStream + InputStreamResource` 방식은 전체 워크북과 바이트 배열을 메모리에 유지해 대용량 다운로드 시 피크 메모리 사용량이 커지기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 컨트롤러 응답 타입을 `ResponseEntity<StreamingResponseBody>`로 변경하고, 기존 파일명 인코딩/`Content-Disposition`/xlsx MIME 타입은 유지했다.
|
||||||
|
- 서비스 반환 타입을 `StreamingResponseBody`로 변경하고 `SXSSFWorkbook(100)`로 row window 기반 생성 후 `outputStream`에 직접 `write`하도록 변경했다.
|
||||||
|
- 스트리밍 완료 시 `workbook.dispose()`와 `workbook.close()`를 호출해 임시 파일/리소스 해제를 보장했다.
|
||||||
|
- 채널후원 컨트롤러/서비스(날짜별, 크리에이터별)에도 동일 패턴을 적용했다.
|
||||||
|
- 테스트를 스트리밍 응답 기준으로 수정했다.
|
||||||
|
- 컨트롤러 테스트: `InputStreamResource` 검증 -> `StreamingResponseBody` 검증
|
||||||
|
- 서비스 테스트: `readAllBytes()` -> `StreamingResponseBody.writeTo(ByteArrayOutputStream)` 검증
|
||||||
|
- 실행 결과:
|
||||||
|
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
|
||||||
|
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
38
docs/20260305_캔환불API생성.md
Normal file
38
docs/20260305_캔환불API생성.md
Normal 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` 실행 → 성공
|
||||||
16
docs/20260305_콘텐츠후원정산70퍼센트검증및최적화.md
Normal file
16
docs/20260305_콘텐츠후원정산70퍼센트검증및최적화.md
Normal 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` 포함)
|
||||||
@@ -2,11 +2,17 @@ package kr.co.vividnext.sodalive.admin.calculate
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@@ -14,15 +20,49 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
class AdminCalculateController(private val service: AdminCalculateService) {
|
class AdminCalculateController(private val service: AdminCalculateService) {
|
||||||
@GetMapping("/live")
|
@GetMapping("/live")
|
||||||
fun getCalculateLive(
|
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 startDateStr: String,
|
||||||
@RequestParam endDateStr: String
|
@RequestParam endDateStr: String
|
||||||
) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr))
|
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
||||||
|
fileName = "live.xlsx",
|
||||||
|
response = service.downloadCalculateLiveExcel(startDateStr, endDateStr)
|
||||||
|
)
|
||||||
|
|
||||||
@GetMapping("/content-list")
|
@GetMapping("/content-list")
|
||||||
fun getCalculateContentList(
|
fun getCalculateContentList(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam 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 startDateStr: String,
|
||||||
@RequestParam endDateStr: 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")
|
@GetMapping("/cumulative-sales-by-content")
|
||||||
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
|
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
|
||||||
@@ -31,9 +71,26 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
|||||||
|
|
||||||
@GetMapping("/content-donation-list")
|
@GetMapping("/content-donation-list")
|
||||||
fun getCalculateContentDonationList(
|
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 startDateStr: String,
|
||||||
@RequestParam endDateStr: 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")
|
@GetMapping("/community-post")
|
||||||
fun getCalculateCommunityPost(
|
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")
|
@GetMapping("/live-by-creator")
|
||||||
fun getCalculateLiveByCreator(
|
fun getCalculateLiveByCreator(
|
||||||
@RequestParam startDateStr: String,
|
@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")
|
@GetMapping("/content-by-creator")
|
||||||
fun getCalculateContentByCreator(
|
fun getCalculateContentByCreator(
|
||||||
@RequestParam startDateStr: String,
|
@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")
|
@GetMapping("/community-by-creator")
|
||||||
fun getCalculateCommunityByCreator(
|
fun getCalculateCommunityByCreator(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@@ -90,4 +174,28 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
|||||||
pageable.pageSize.toLong()
|
pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@GetMapping("/community-by-creator/excel")
|
||||||
|
fun downloadCalculateCommunityByCreatorExcel(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String
|
||||||
|
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
||||||
|
fileName = "community-by-creator.xlsx",
|
||||||
|
response = service.downloadCalculateCommunityByCreatorExcel(startDateStr, endDateStr)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createExcelResponse(
|
||||||
|
fileName: String,
|
||||||
|
response: StreamingResponseBody
|
||||||
|
): ResponseEntity<StreamingResponseBody> {
|
||||||
|
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.headers(headers)
|
||||||
|
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||||
|
.body(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,33 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
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)
|
val formattedDate = getFormattedDate(liveRoom.beginDateTime)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -50,10 +76,50 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
)
|
)
|
||||||
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
|
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
|
||||||
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
|
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
.fetch()
|
.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 orderFormattedDate = getFormattedDate(order.createdAt)
|
||||||
val pointGroup = CaseBuilder()
|
val pointGroup = CaseBuilder()
|
||||||
.`when`(order.point.loe(0)).then(0)
|
.`when`(order.point.loe(0)).then(0)
|
||||||
@@ -96,6 +162,8 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
)
|
)
|
||||||
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,11 +235,33 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.fetch()
|
.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(
|
fun getCalculateContentDonationList(
|
||||||
startDate: LocalDateTime,
|
startDate: LocalDateTime,
|
||||||
endDate: LocalDateTime
|
endDate: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
): List<GetCalculateContentDonationQueryData> {
|
): List<GetCalculateContentDonationQueryData> {
|
||||||
val donationFormattedDate = getFormattedDate(useCan.createdAt)
|
val donationFormattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateContentDonationQueryData(
|
QGetCalculateContentDonationQueryData(
|
||||||
@@ -195,6 +285,8 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
)
|
)
|
||||||
.groupBy(donationFormattedDate, audioContent.id)
|
.groupBy(donationFormattedDate, audioContent.id)
|
||||||
.orderBy(member.id.asc(), donationFormattedDate.desc(), audioContent.id.desc())
|
.orderBy(member.id.asc(), donationFormattedDate.desc(), audioContent.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +453,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
.and(order.isActive.isTrue)
|
.and(order.isActive.isTrue)
|
||||||
)
|
)
|
||||||
.groupBy(member.id)
|
.groupBy(member.id, creatorSettlementRatio.contentSettlementRatio)
|
||||||
.orderBy(member.id.desc())
|
.orderBy(member.id.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
@@ -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.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook
|
||||||
import org.springframework.cache.annotation.Cacheable
|
import org.springframework.cache.annotation.Cacheable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
|
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
cacheNames = ["cache_ttl_3_hours"],
|
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 startDate = startDateStr.convertLocalDateTime()
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
val totalCount = repository.getCalculateLiveTotalCount(startDate, endDate)
|
||||||
return repository
|
val items = repository
|
||||||
.getCalculateLive(startDate, endDate)
|
.getCalculateLive(startDate, endDate, offset, limit)
|
||||||
.map { it.toGetCalculateLiveResponse() }
|
.map { it.toGetCalculateLiveResponse() }
|
||||||
|
|
||||||
|
return GetCalculateLiveListResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
cacheNames = ["cache_ttl_3_hours"],
|
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 startDate = startDateStr.convertLocalDateTime()
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
val totalCount = repository.getCalculateContentListTotalCount(startDate, endDate)
|
||||||
return repository
|
val items = repository
|
||||||
.getCalculateContentList(startDate, endDate)
|
.getCalculateContentList(startDate, endDate, offset, limit)
|
||||||
.map { it.toGetCalculateContentResponse() }
|
.map { it.toGetCalculateContentResponse() }
|
||||||
|
|
||||||
|
return GetCalculateContentListResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -53,18 +71,22 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
cacheNames = ["cache_ttl_3_hours"],
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
key = "'calculateContentDonationList2:' + " + "#startDateStr + ':' + #endDateStr"
|
key = "'calculateContentDonationList2:' + " + "#startDateStr + ':' + #endDateStr + ':' + #offset + ':' + #limit"
|
||||||
)
|
)
|
||||||
fun getCalculateContentDonationList(
|
fun getCalculateContentDonationList(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
endDateStr: String
|
endDateStr: String,
|
||||||
): List<GetCalculateContentDonationResponse> {
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): GetCalculateContentDonationListResponse {
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
val totalCount = repository.getCalculateContentDonationListTotalCount(startDate, endDate)
|
||||||
return repository
|
val items = repository
|
||||||
.getCalculateContentDonationList(startDate, endDate)
|
.getCalculateContentDonationList(startDate, endDate, offset, limit)
|
||||||
.map { it.toGetCalculateContentDonationResponse() }
|
.map { it.toGetCalculateContentDonationResponse() }
|
||||||
|
|
||||||
|
return GetCalculateContentDonationListResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -139,4 +161,301 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor
|
|||||||
|
|
||||||
GetCalculateByCreatorResponse(totalCount, items)
|
GetCalculateByCreatorResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun downloadCalculateLiveExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
||||||
|
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
||||||
|
val 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
data class GetCalculateContentDonationListResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetCalculateContentDonationResponse>
|
||||||
|
)
|
||||||
@@ -20,33 +20,32 @@ data class GetCalculateContentDonationQueryData @QueryProjection constructor(
|
|||||||
// 합계
|
// 합계
|
||||||
val totalCan: Int
|
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 {
|
fun toGetCalculateContentDonationResponse(): GetCalculateContentDonationResponse {
|
||||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
val totalKrw = BigDecimal(totalCan).multiply(KRW_PER_CAN)
|
||||||
|
|
||||||
// 결제수수료 : 6.6%
|
// 결제수수료 : 6.6%
|
||||||
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
val paymentFee = totalKrw.multiply(PAYMENT_FEE_RATE)
|
||||||
|
|
||||||
// 정산금액
|
// 정산금액
|
||||||
// 유료콘텐츠 (원화 - 결제수수료) 의 90%
|
// 유료콘텐츠 (원화 - 결제수수료) 의 70%
|
||||||
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
|
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
|
||||||
val settlementAmount = if (price > 0) {
|
val settlementAmount = totalKrw.subtract(paymentFee).multiply(SETTLEMENT_RATE)
|
||||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.9))
|
|
||||||
} else {
|
|
||||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 원천세 = 정산금액의 3.3%
|
// 원천세 = 정산금액의 3.3%
|
||||||
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
val tax = settlementAmount.multiply(TAX_RATE)
|
||||||
|
|
||||||
// 입금액
|
// 입금액
|
||||||
val depositAmount = settlementAmount.subtract(tax)
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
val paidOrFree = if (price > 0) {
|
val paidOrFree = if (price > 0) "유료" else "무료"
|
||||||
"유료"
|
|
||||||
} else {
|
|
||||||
"무료"
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetCalculateContentDonationResponse(
|
return GetCalculateContentDonationResponse(
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
data class GetCalculateContentListResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetCalculateContentResponse>
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
data class GetCalculateLiveListResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetCalculateLiveResponse>
|
||||||
|
)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.core.io.InputStreamResource
|
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
@@ -11,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
@@ -34,6 +34,15 @@ class AdminChannelDonationCalculateController(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@GetMapping("/channel-donation-by-date/excel")
|
||||||
|
fun downloadChannelDonationByDateExcel(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String
|
||||||
|
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
||||||
|
fileName = "channel-donation-by-date.xlsx",
|
||||||
|
response = service.downloadChannelDonationByDateExcel(startDateStr, endDateStr)
|
||||||
|
)
|
||||||
|
|
||||||
@GetMapping("/channel-donation-by-creator")
|
@GetMapping("/channel-donation-by-creator")
|
||||||
fun getChannelDonationByCreator(
|
fun getChannelDonationByCreator(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@@ -52,23 +61,23 @@ class AdminChannelDonationCalculateController(
|
|||||||
fun downloadChannelDonationByCreatorExcel(
|
fun downloadChannelDonationByCreatorExcel(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam endDateStr: String
|
@RequestParam endDateStr: String
|
||||||
): ResponseEntity<InputStreamResource> {
|
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
||||||
val encodedFileName = URLEncoder.encode(
|
fileName = "channel-donation-by-creator.xlsx",
|
||||||
"channel-donation-by-creator.xlsx",
|
response = service.downloadChannelDonationByCreatorExcel(startDateStr, endDateStr)
|
||||||
StandardCharsets.UTF_8.toString()
|
)
|
||||||
).replace("+", "%20")
|
|
||||||
|
private fun createExcelResponse(
|
||||||
|
fileName: String,
|
||||||
|
response: StreamingResponseBody
|
||||||
|
): ResponseEntity<StreamingResponseBody> {
|
||||||
|
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
|
||||||
val headers = HttpHeaders().apply {
|
val headers = HttpHeaders().apply {
|
||||||
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
|
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = service.downloadChannelDonationByCreatorExcel(
|
|
||||||
startDateStr = startDateStr,
|
|
||||||
endDateStr = endDateStr
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||||
.body(InputStreamResource(response))
|
.body(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
import org.apache.poi.ss.usermodel.Sheet
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.io.ByteArrayInputStream
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
import java.io.ByteArrayOutputStream
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdminChannelDonationCalculateService(
|
class AdminChannelDonationCalculateService(
|
||||||
@@ -50,17 +51,21 @@ class AdminChannelDonationCalculateService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun downloadChannelDonationByCreatorExcel(startDateStr: String, endDateStr: String): ByteArrayInputStream {
|
fun downloadChannelDonationByDateExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate)
|
||||||
val items = repository
|
val items = if (totalCount == 0) {
|
||||||
.getChannelDonationByCreatorForExcel(startDate, endDate)
|
emptyList()
|
||||||
.map { it.toResponseItem() }
|
} else {
|
||||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
repository
|
||||||
|
.getChannelDonationByDate(startDate, endDate, 0L, totalCount.toLong())
|
||||||
|
.map { it.toResponseItem() }
|
||||||
|
}
|
||||||
|
|
||||||
XSSFWorkbook().use { workbook ->
|
return createExcelStream(
|
||||||
val sheet = workbook.createSheet("크리에이터별 채널후원 정산")
|
sheetName = "채널후원 정산",
|
||||||
val header = listOf(
|
headers = listOf(
|
||||||
|
"날짜",
|
||||||
"크리에이터",
|
"크리에이터",
|
||||||
"건수",
|
"건수",
|
||||||
"총 받은 캔 수",
|
"총 받은 캔 수",
|
||||||
@@ -70,11 +75,42 @@ class AdminChannelDonationCalculateService(
|
|||||||
"원천세",
|
"원천세",
|
||||||
"입금액"
|
"입금액"
|
||||||
)
|
)
|
||||||
val headerRow = sheet.createRow(0)
|
) { sheet ->
|
||||||
header.forEachIndexed { index, value ->
|
items.forEachIndexed { index, item ->
|
||||||
headerRow.createCell(index).setCellValue(value)
|
val row = sheet.createRow(index + 1)
|
||||||
|
row.createCell(0).setCellValue(item.date)
|
||||||
|
row.createCell(1).setCellValue(item.creator)
|
||||||
|
row.createCell(2).setCellValue(item.count.toDouble())
|
||||||
|
row.createCell(3).setCellValue(item.totalCan.toDouble())
|
||||||
|
row.createCell(4).setCellValue(item.krw.toDouble())
|
||||||
|
row.createCell(5).setCellValue(item.fee.toDouble())
|
||||||
|
row.createCell(6).setCellValue(item.settlementAmount.toDouble())
|
||||||
|
row.createCell(7).setCellValue(item.withholdingTax.toDouble())
|
||||||
|
row.createCell(8).setCellValue(item.depositAmount.toDouble())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun downloadChannelDonationByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
||||||
|
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
||||||
|
val items = repository
|
||||||
|
.getChannelDonationByCreatorForExcel(startDate, endDate)
|
||||||
|
.map { it.toResponseItem() }
|
||||||
|
|
||||||
|
return createExcelStream(
|
||||||
|
sheetName = "크리에이터별 채널후원 정산",
|
||||||
|
headers = listOf(
|
||||||
|
"크리에이터",
|
||||||
|
"건수",
|
||||||
|
"총 받은 캔 수",
|
||||||
|
"원화",
|
||||||
|
"수수료",
|
||||||
|
"정산금액",
|
||||||
|
"원천세",
|
||||||
|
"입금액"
|
||||||
|
)
|
||||||
|
) { sheet ->
|
||||||
items.forEachIndexed { index, item ->
|
items.forEachIndexed { index, item ->
|
||||||
val row = sheet.createRow(index + 1)
|
val row = sheet.createRow(index + 1)
|
||||||
row.createCell(0).setCellValue(item.creator)
|
row.createCell(0).setCellValue(item.creator)
|
||||||
@@ -86,10 +122,37 @@ class AdminChannelDonationCalculateService(
|
|||||||
row.createCell(6).setCellValue(item.withholdingTax.toDouble())
|
row.createCell(6).setCellValue(item.withholdingTax.toDouble())
|
||||||
row.createCell(7).setCellValue(item.depositAmount.toDouble())
|
row.createCell(7).setCellValue(item.depositAmount.toDouble())
|
||||||
}
|
}
|
||||||
|
|
||||||
workbook.write(byteArrayOutputStream)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ByteArrayInputStream(byteArrayOutputStream.toByteArray())
|
private fun createExcelStream(
|
||||||
|
sheetName: String,
|
||||||
|
headers: List<String>,
|
||||||
|
writeRows: (Sheet) -> Unit
|
||||||
|
): StreamingResponseBody {
|
||||||
|
return StreamingResponseBody { outputStream ->
|
||||||
|
val workbook = SXSSFWorkbook(100)
|
||||||
|
try {
|
||||||
|
val sheet = workbook.createSheet(sheetName)
|
||||||
|
val headerRow = sheet.createRow(0)
|
||||||
|
headers.forEachIndexed { index, value ->
|
||||||
|
headerRow.createCell(index).setCellValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRows(sheet)
|
||||||
|
workbook.write(outputStream)
|
||||||
|
outputStream.flush()
|
||||||
|
} finally {
|
||||||
|
workbook.dispose()
|
||||||
|
workbook.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
return startDate to endDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
data class AdminChargeRefundRequest(
|
||||||
|
val chargeId: Long
|
||||||
|
)
|
||||||
@@ -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,]*")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
endDate: LocalDateTime,
|
endDate: LocalDateTime,
|
||||||
paymentGateway: PaymentGateway,
|
paymentGateway: PaymentGateway,
|
||||||
currency: String? = null
|
currency: String? = null
|
||||||
): List<GetChargeStatusDetailQueryDto> {
|
): List<GetChargeStatusDetailResponse> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
Expressions.dateTimeTemplate(
|
Expressions.dateTimeTemplate(
|
||||||
@@ -117,11 +117,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetChargeStatusDetailQueryDto(
|
QGetChargeStatusDetailResponse(
|
||||||
member.id,
|
charge.id,
|
||||||
member.nickname,
|
member.nickname,
|
||||||
payment.method.coalesce(""),
|
payment.method.coalesce(""),
|
||||||
payment.price,
|
payment.price,
|
||||||
|
charge.chargeCan,
|
||||||
|
charge.rewardCan,
|
||||||
currencyExpr,
|
currencyExpr,
|
||||||
formattedDate
|
formattedDate
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,15 +44,5 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class GetChargeStatusDetailResponse(
|
data class GetChargeStatusDetailResponse @QueryProjection constructor(
|
||||||
val memberId: Long,
|
val chargeId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val amount: BigDecimal,
|
val amount: BigDecimal,
|
||||||
|
val chargeCan: Int,
|
||||||
|
val rewardCan: Int,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
"차단되었습니다."
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
|
data class AdminMemberBlockRequest(
|
||||||
|
val memberId: Long,
|
||||||
|
val reason: String
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -614,6 +614,16 @@ class SodaMessageSource {
|
|||||||
Lang.KO to "%s 캔이 부족합니다. 충전 후 이용해 주세요.",
|
Lang.KO to "%s 캔이 부족합니다. 충전 후 이용해 주세요.",
|
||||||
Lang.EN to "You are short of %s cans. Please recharge and try again.",
|
Lang.EN to "You are short of %s cans. Please recharge and try again.",
|
||||||
Lang.JA to "%sCANが不足しています。チャージしてからご利用ください。"
|
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日が経過しているため返金できません。"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface AuthQueryRepository {
|
|||||||
fun getMemberIdsByDi(di: String): List<Long>
|
fun getMemberIdsByDi(di: String): List<Long>
|
||||||
fun getMemberIdsByNameAndBirthAndDiAndGender(name: String, birth: String, di: String, gender: Int): List<Long>
|
fun getMemberIdsByNameAndBirthAndDiAndGender(name: String, birth: String, di: String, gender: Int): List<Long>
|
||||||
fun getAuthIdByMemberId(memberId: Long): 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 {
|
class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQueryRepository {
|
||||||
@@ -60,13 +60,21 @@ class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQ
|
|||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActiveMemberIdsByDi(di: String): List<Long> {
|
override fun getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
|
||||||
|
name: String,
|
||||||
|
birth: String,
|
||||||
|
di: String,
|
||||||
|
uniqueCi: String
|
||||||
|
): List<Long> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(member.id)
|
.select(member.id)
|
||||||
.from(member)
|
.from(member)
|
||||||
.leftJoin(member.auth, auth)
|
.leftJoin(member.auth, auth)
|
||||||
.where(
|
.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)
|
.and(member.isActive.isTrue)
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
|
|||||||
@@ -81,7 +81,12 @@ class AuthService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun authenticate(certificate: AuthVerifyCertificate, memberId: Long): AuthResponse {
|
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) {
|
if (memberIds.size >= 3) {
|
||||||
val message = messageSource.getMessage("member.auth.max_accounts", langContext.lang) ?: ""
|
val message = messageSource.getMessage("member.auth.max_accounts", langContext.lang) ?: ""
|
||||||
throw SodaException(
|
throw SodaException(
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import org.junit.jupiter.api.BeforeEach
|
|||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.core.io.InputStreamResource
|
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import java.io.ByteArrayInputStream
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
|
|
||||||
class AdminChannelDonationCalculateControllerTest {
|
class AdminChannelDonationCalculateControllerTest {
|
||||||
private lateinit var service: AdminChannelDonationCalculateService
|
private lateinit var service: AdminChannelDonationCalculateService
|
||||||
@@ -135,6 +134,38 @@ class AdminChannelDonationCalculateControllerTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 컨트롤러는 날짜별 정산 엑셀을 다운로드한다")
|
||||||
|
fun shouldDownloadDateSettlementExcel() {
|
||||||
|
Mockito.`when`(
|
||||||
|
service.downloadChannelDonationByDateExcel(
|
||||||
|
startDateStr = "2026-02-01",
|
||||||
|
endDateStr = "2026-02-29"
|
||||||
|
)
|
||||||
|
).thenReturn(StreamingResponseBody { outputStream -> outputStream.write(byteArrayOf(1, 2, 3)) })
|
||||||
|
|
||||||
|
val response = controller.downloadChannelDonationByDateExcel(
|
||||||
|
startDateStr = "2026-02-01",
|
||||||
|
endDateStr = "2026-02-29"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(200, response.statusCode.value())
|
||||||
|
val contentDispositionHeader = response.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION)
|
||||||
|
assertNotNull(contentDispositionHeader)
|
||||||
|
assertEquals(true, contentDispositionHeader?.contains("attachment; filename*="))
|
||||||
|
assertEquals(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
response.headers.contentType.toString()
|
||||||
|
)
|
||||||
|
assertNotNull(response.body)
|
||||||
|
assertEquals(true, response.body is StreamingResponseBody)
|
||||||
|
|
||||||
|
Mockito.verify(service).downloadChannelDonationByDateExcel(
|
||||||
|
startDateStr = "2026-02-01",
|
||||||
|
endDateStr = "2026-02-29"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("관리자 컨트롤러는 크리에이터별 정산 엑셀을 다운로드한다")
|
@DisplayName("관리자 컨트롤러는 크리에이터별 정산 엑셀을 다운로드한다")
|
||||||
fun shouldDownloadCreatorSettlementExcel() {
|
fun shouldDownloadCreatorSettlementExcel() {
|
||||||
@@ -143,7 +174,7 @@ class AdminChannelDonationCalculateControllerTest {
|
|||||||
startDateStr = "2026-02-01",
|
startDateStr = "2026-02-01",
|
||||||
endDateStr = "2026-02-29"
|
endDateStr = "2026-02-29"
|
||||||
)
|
)
|
||||||
).thenReturn(ByteArrayInputStream(byteArrayOf(1, 2, 3)))
|
).thenReturn(StreamingResponseBody { outputStream -> outputStream.write(byteArrayOf(1, 2, 3)) })
|
||||||
|
|
||||||
val response = controller.downloadChannelDonationByCreatorExcel(
|
val response = controller.downloadChannelDonationByCreatorExcel(
|
||||||
startDateStr = "2026-02-01",
|
startDateStr = "2026-02-01",
|
||||||
@@ -159,7 +190,7 @@ class AdminChannelDonationCalculateControllerTest {
|
|||||||
response.headers.contentType.toString()
|
response.headers.contentType.toString()
|
||||||
)
|
)
|
||||||
assertNotNull(response.body)
|
assertNotNull(response.body)
|
||||||
assertEquals(true, response.body is InputStreamResource)
|
assertEquals(true, response.body is StreamingResponseBody)
|
||||||
|
|
||||||
Mockito.verify(service).downloadChannelDonationByCreatorExcel(
|
Mockito.verify(service).downloadChannelDonationByCreatorExcel(
|
||||||
startDateStr = "2026-02-01",
|
startDateStr = "2026-02-01",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeEach
|
|||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
class AdminChannelDonationCalculateServiceTest {
|
class AdminChannelDonationCalculateServiceTest {
|
||||||
private lateinit var repository: AdminChannelDonationCalculateQueryRepository
|
private lateinit var repository: AdminChannelDonationCalculateQueryRepository
|
||||||
@@ -153,6 +154,54 @@ class AdminChannelDonationCalculateServiceTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 날짜별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다")
|
||||||
|
fun shouldGenerateDateSettlementExcelBytes() {
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getChannelDonationByDateTotalCount(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
)
|
||||||
|
).thenReturn(1)
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getChannelDonationByDate(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
0L,
|
||||||
|
1L
|
||||||
|
)
|
||||||
|
).thenReturn(
|
||||||
|
listOf(
|
||||||
|
GetAdminChannelDonationSettlementQueryData(
|
||||||
|
date = "2026-02-20",
|
||||||
|
creator = "creator-a",
|
||||||
|
count = 3L,
|
||||||
|
totalCan = 100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = service.downloadChannelDonationByDateExcel(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21"
|
||||||
|
)
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
response.writeTo(outputStream)
|
||||||
|
|
||||||
|
assertTrue(outputStream.toByteArray().isNotEmpty())
|
||||||
|
|
||||||
|
Mockito.verify(repository).getChannelDonationByDateTotalCount(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
)
|
||||||
|
Mockito.verify(repository).getChannelDonationByDate(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
0L,
|
||||||
|
1L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("관리자 크리에이터별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다")
|
@DisplayName("관리자 크리에이터별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다")
|
||||||
fun shouldGenerateCreatorSettlementExcelBytes() {
|
fun shouldGenerateCreatorSettlementExcelBytes() {
|
||||||
@@ -175,8 +224,10 @@ class AdminChannelDonationCalculateServiceTest {
|
|||||||
startDateStr = "2026-02-20",
|
startDateStr = "2026-02-20",
|
||||||
endDateStr = "2026-02-21"
|
endDateStr = "2026-02-21"
|
||||||
)
|
)
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
response.writeTo(outputStream)
|
||||||
|
|
||||||
assertTrue(response.readAllBytes().isNotEmpty())
|
assertTrue(outputStream.toByteArray().isNotEmpty())
|
||||||
|
|
||||||
Mockito.verify(repository).getChannelDonationByCreatorForExcel(
|
Mockito.verify(repository).getChannelDonationByCreatorForExcel(
|
||||||
"2026-02-20".convertLocalDateTime(),
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user