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