feat(channel-donation-calculate): 채널 후원 정산 조회 기능을 추가한다
This commit is contained in:
49
docs/20260226_channel_donation_settlement_index_ddl.sql
Normal file
49
docs/20260226_channel_donation_settlement_index_ddl.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
SET @schema_name := DATABASE();
|
||||||
|
|
||||||
|
SET @use_can_index_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @schema_name
|
||||||
|
AND table_name = 'use_can'
|
||||||
|
AND index_name = 'idx_use_can_channel_donation_filter'
|
||||||
|
);
|
||||||
|
SET @use_can_index_sql := IF(
|
||||||
|
@use_can_index_exists = 0,
|
||||||
|
'ALTER TABLE use_can ADD INDEX idx_use_can_channel_donation_filter (can_usage, is_refund, created_at, id)',
|
||||||
|
'SELECT "idx_use_can_channel_donation_filter already exists"'
|
||||||
|
);
|
||||||
|
PREPARE use_can_index_stmt FROM @use_can_index_sql;
|
||||||
|
EXECUTE use_can_index_stmt;
|
||||||
|
DEALLOCATE PREPARE use_can_index_stmt;
|
||||||
|
|
||||||
|
SET @use_can_calculate_join_index_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @schema_name
|
||||||
|
AND table_name = 'use_can_calculate'
|
||||||
|
AND index_name = 'idx_use_can_calculate_settlement_join'
|
||||||
|
);
|
||||||
|
SET @use_can_calculate_join_index_sql := IF(
|
||||||
|
@use_can_calculate_join_index_exists = 0,
|
||||||
|
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_settlement_join (use_can_id, status, recipient_creator_id)',
|
||||||
|
'SELECT "idx_use_can_calculate_settlement_join already exists"'
|
||||||
|
);
|
||||||
|
PREPARE use_can_calculate_join_index_stmt FROM @use_can_calculate_join_index_sql;
|
||||||
|
EXECUTE use_can_calculate_join_index_stmt;
|
||||||
|
DEALLOCATE PREPARE use_can_calculate_join_index_stmt;
|
||||||
|
|
||||||
|
SET @use_can_calculate_creator_index_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @schema_name
|
||||||
|
AND table_name = 'use_can_calculate'
|
||||||
|
AND index_name = 'idx_use_can_calculate_creator_settlement'
|
||||||
|
);
|
||||||
|
SET @use_can_calculate_creator_index_sql := IF(
|
||||||
|
@use_can_calculate_creator_index_exists = 0,
|
||||||
|
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_creator_settlement (recipient_creator_id, status, use_can_id)',
|
||||||
|
'SELECT "idx_use_can_calculate_creator_settlement already exists"'
|
||||||
|
);
|
||||||
|
PREPARE use_can_calculate_creator_index_stmt FROM @use_can_calculate_creator_index_sql;
|
||||||
|
EXECUTE use_can_calculate_creator_index_stmt;
|
||||||
|
DEALLOCATE PREPARE use_can_calculate_creator_index_stmt;
|
||||||
191
docs/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md
Normal file
191
docs/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 작업 계획
|
||||||
|
|
||||||
|
- [x] 기존 정산 API 패턴(`admin.calculate`, `creator.admin.calculate`)과 채널 후원 데이터 소스(`ChannelDonationMessage`, `CanUsage.CHANNEL_DONATION`)를 확인한다.
|
||||||
|
- [x] 기존 패키지에 직접 누적하지 않도록 신규 하위 패키지를 설계한다.
|
||||||
|
- 관리자: `kr.co.vividnext.sodalive.admin.calculate.channelDonation`
|
||||||
|
- 크리에이터 관리자: `kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation`
|
||||||
|
- [x] 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)로 전체 데이터를 조회한 뒤 응답을 크리에이터별로 그룹화해 반환하도록 설계한다.
|
||||||
|
- [x] 크리에이터 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)만 입력받아 인증 사용자 본인 데이터만 조회한다.
|
||||||
|
- [x] 서비스 계층에서 날짜 문자열을 `convertLocalDateTime()`으로 변환하고 종료일은 `23:59:59`로 보정해 조회 구간을 통일한다.
|
||||||
|
- [x] 저장소(QueryRepository) 계층에 날짜 범위 조건(`createdAt >= startDate`, `createdAt <= endDate`)과 크리에이터 기준 그룹화(`groupBy(member.id)` 등)를 반영한 집계 조회를 추가한다.
|
||||||
|
- [x] API URL을 기존 정산 URL 규칙에 맞춰 확정하고 문서화한다.
|
||||||
|
- 관리자: `GET /admin/calculate/channel-donation-by-creator`
|
||||||
|
- 크리에이터 관리자: `GET /creator-admin/calculate/channel-donation`
|
||||||
|
- [x] 정산 계산 공식을 공통 로직으로 구현하고, 사람이 이해하기 쉬운 한글 주석을 추가한다.
|
||||||
|
- 원화 = 캔 * 100
|
||||||
|
- 수수료 = 원화 * 6.6%
|
||||||
|
- 정산금액 = (원화 - 수수료) * 85%
|
||||||
|
- 원천세 = 정산금액 * 3.3%
|
||||||
|
- 입금액 = 정산금액 - 원천세
|
||||||
|
- [x] 계산 정밀도 정책을 정의한다(`BigDecimal`, `RoundingMode.HALF_UP`, 반올림 시점 고정).
|
||||||
|
- [x] 성능/효율 개선 항목을 반영한다(집계 쿼리 중심 처리, 불필요한 애플리케이션 후처리 최소화, count 조회 최적화 검토).
|
||||||
|
- [x] 응답 DTO 스펙을 아래 필드로 고정하고 권한 정책(관리자=전체, 크리에이터 관리자=본인)을 함께 검증한다.
|
||||||
|
- 날짜(`yyyy-MM-dd`)
|
||||||
|
- 크리에이터
|
||||||
|
- 건수(`count`)
|
||||||
|
- 총 받은 캔 수(`totalCan`)
|
||||||
|
- 원화
|
||||||
|
- 수수료
|
||||||
|
- 정산금액
|
||||||
|
- 원천세
|
||||||
|
- 입금액
|
||||||
|
- [x] 테스트를 추가한다(관리자 날짜 필터 + 크리에이터별 그룹화 응답, 크리에이터 관리자 날짜 필터/본인 범위, 계산식 정확성, 경계값).
|
||||||
|
- [x] 검증을 수행한다(`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`).
|
||||||
|
|
||||||
|
## API URL 선정 근거
|
||||||
|
|
||||||
|
- 기본 경로는 권한 범위별 정산 컨트롤러 관례를 따른다.
|
||||||
|
- 관리자: `@RequestMapping("/admin/calculate")`
|
||||||
|
- 크리에이터 관리자: `@RequestMapping("/creator-admin/calculate")`
|
||||||
|
- 하위 경로는 기존 정산 API와 동일하게 소문자 하이픈(`kebab-case`) 명사 조합을 사용한다.
|
||||||
|
- 예: `content-donation-list`, `cumulative-sales-by-content`, `community-by-creator`
|
||||||
|
- `channel-donation` 토큰은 기존 채널 후원 API 경로(`@RequestMapping("/explorer/profile/channel-donation")`)와 용어를 맞춰 도메인 표현을 통일한다.
|
||||||
|
- 관리자 정산은 조회 결과가 크리에이터별 그룹화 응답이므로 기존 `*-by-creator` 패턴을 적용해 `channel-donation-by-creator`로 정한다.
|
||||||
|
- 크리에이터 관리자 정산은 인증 사용자 본인 범위로 고정되므로 `-by-creator` 접미사를 제외하고 `channel-donation`으로 정한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 계획 수립
|
||||||
|
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 구현을 위한 작업 계획 문서를 작성했다.
|
||||||
|
- 왜: 구현 전에 패키지 구조, 날짜 범위 조회, 정산 계산식, 성능 검증 기준을 명확히 하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `docs`의 기존 작업 계획 문서 형식(체크박스 + 검증 기록)을 기준으로 템플릿을 맞췄다.
|
||||||
|
- `admin.calculate`, `creator.admin.calculate`, `explorer.profile.channelDonation` 경로를 탐색해 반영했다.
|
||||||
|
- 사용자 요청에 따라 실제 코드 구현/테스트는 수행하지 않고 계획 문서만 작성했다.
|
||||||
|
|
||||||
|
### 2차 계획 수정
|
||||||
|
- 무엇을: 조회 조건을 `관리자=날짜+크리에이터 구분`, `크리에이터 관리자=날짜만`으로 명확히 분리했고, 응답 필드를 `날짜(yyyy-MM-dd), 크리에이터, 원화, 수수료, 정산금액, 원천세, 입금액`으로 고정했다.
|
||||||
|
- 왜: 추가 요구사항(조회 조건 분리, Response 필드 고정)을 계획 단계에서 누락 없이 반영하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `admin.calculate`/`creator.admin.calculate`의 기존 날짜 파라미터 및 인증 기반 필터링 패턴을 재탐색해 계획 항목을 수정했다.
|
||||||
|
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
||||||
|
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 문서만 작성해야 하는 요청 범위를 유지하기 위해 코드 구현/테스트 변경은 수행하지 않았다.
|
||||||
|
|
||||||
|
### 3차 계획 수정
|
||||||
|
- 무엇을: 관리자 조회 요구사항을 `크리에이터 식별값으로 필터`가 아닌 `조회 결과를 크리에이터별로 그룹화하여 반환`으로 정정했다.
|
||||||
|
- 왜: 사용자 의도가 “조회 조건 추가”가 아니라 “응답 결과 구성 방식(크리에이터별 그룹화)”이었기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- `AdminCalculateController`의 `*-by-creator` 엔드포인트가 날짜/페이지 파라미터만 받고(`creatorId/memberId` 미입력), 서비스/리포지토리에서 `GetCalculateByCreatorResponse`와 `groupBy(member.id)` 기반으로 결과를 구성하는 패턴을 확인했다.
|
||||||
|
- 위 근거를 바탕으로 체크리스트를 `관리자=날짜 필터 + 크리에이터별 그룹화 응답` 기준으로 수정했다.
|
||||||
|
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
||||||
|
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 4차 계획 수정
|
||||||
|
- 무엇을: 작업 계획 문서에 API URL을 어떤 기준으로 정했는지(경로 규칙, 용어 선택, 최종 URL) 근거를 추가했다.
|
||||||
|
- 왜: 구현 전에 URL 명명 기준을 명확히 남겨, 이후 개발 시 경로 해석 차이와 재작업을 방지하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `AdminCalculateController`, `CreatorAdminCalculateController`, `ChannelDonationController`의 `@RequestMapping`/`@GetMapping` 패턴을 비교해 기준 경로와 하위 경로 규칙을 도출했다.
|
||||||
|
- 관리자 URL은 `*-by-creator` 관례를 적용해 `/admin/calculate/channel-donation-by-creator`, 크리에이터 관리자 URL은 본인 범위 고정 특성에 맞춰 `/creator-admin/calculate/channel-donation`으로 문서화했다.
|
||||||
|
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
||||||
|
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 5차 구현
|
||||||
|
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 API를 신규 하위 패키지로 구현하고, 날짜 범위 조회/크리에이터별 그룹화/정산 공식 공통 계산 로직을 적용했다.
|
||||||
|
- 왜: 기존 정산 코드에 얽히지 않고 유지보수 가능한 구조로 요구사항(관리자=크리에이터별 그룹 응답, 크리에이터 관리자=본인 범위 조회)을 정확히 반영하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- 신규 패키지 생성: `admin.calculate.channelDonation`, `creator.admin.calculate.channelDonation`, 공통 계산기 `calculate.channelDonation`.
|
||||||
|
- API 구현: `GET /admin/calculate/channel-donation-by-creator`, `GET /creator-admin/calculate/channel-donation`.
|
||||||
|
- QueryDSL 집계: `UseCan` + `UseCanCalculate`를 사용해 `CanUsage.CHANNEL_DONATION`, 날짜 범위, 환불 제외 조건을 적용하고 관리자 응답은 날짜+크리에이터 기준 그룹화, 크리에이터 관리자 응답은 날짜 기준 그룹화로 구현.
|
||||||
|
- 정산 계산식 공통화: `ChannelDonationSettlementCalculator`에서 `BigDecimal("0.066")`, `BigDecimal("0.85")`, `BigDecimal("0.033")`, `RoundingMode.HALF_UP` 정책으로 계산하고 공식 설명 한글 주석을 추가.
|
||||||
|
- 테스트 추가: 계산식/반올림 단위 테스트 및 관리자·크리에이터 관리자 컨트롤러/서비스 경로 테스트를 추가.
|
||||||
|
- 검증 실행:
|
||||||
|
- `./gradlew test --tests "kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculatorTest"` → 성공
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew test` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
- 참고: Kotlin LSP 서버 미설정 환경이라 `.kt` 파일에 대한 `lsp_diagnostics`는 실행 시 서버 미설정 오류를 반환했다.
|
||||||
|
|
||||||
|
### 6차 수정
|
||||||
|
- 무엇을: 정산 계산식을 단계별 반올림 후 다음 단계 계산하는 방식으로 수정하고, 크리에이터 관리자 조회 쿼리/카운트에서 불필요한 `member` 조인을 제거했다.
|
||||||
|
- 왜: 정산 항목 간 관계(`입금액 = 정산금액 - 원천세`)를 정수 기준으로 일관되게 맞추고, 조회 성능 최적화를 위해 불필요 조인을 줄이기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `ChannelDonationSettlementCalculator`를 단계별 반올림 파이프라인으로 변경했다.
|
||||||
|
- `수수료 = round(원화 * 6.6%)`
|
||||||
|
- `정산금액 = round((원화 - 수수료) * 85%)`
|
||||||
|
- `원천세 = round(정산금액 * 3.3%)`
|
||||||
|
- `입금액 = 정산금액 - 원천세`
|
||||||
|
- 크리에이터 관리자 경로는 인증 사용자 닉네임을 서비스 인자로 전달해 응답 `creator`를 구성하고, QueryRepository의 `member` 조인/닉네임 select를 제거했다.
|
||||||
|
- 관리자 totalCount는 `member` 조인 없이 `recipientCreatorId` 기반 distinct 키로 계산하도록 변경했다.
|
||||||
|
- 검증 실행:
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew test` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
|
||||||
|
### 7차 수정
|
||||||
|
- 무엇을: 요청한 2번/3번 최적화를 반영해 QueryDSL `@QueryProjection` 기반 매핑으로 전환하고, 날짜 그룹 조회 경로 인덱스 전략 DDL을 추가했다. 또한 테스트 가독성을 위해 `@DisplayName`을 추가했다.
|
||||||
|
- 왜: `Projections.constructor` 대비 타입 안전성과 유지보수성을 높이고, 채널 후원 정산 조회의 날짜 범위/조인 필터 성능 개선 근거를 DDL로 명확히 남기기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- Query DTO 전환:
|
||||||
|
- `GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`에 `@QueryProjection`을 적용했다.
|
||||||
|
- 각 QueryRepository의 `Projections.constructor`를 `QGet*QueryData(...)` 호출로 교체했다.
|
||||||
|
- 인덱스 전략 반영:
|
||||||
|
- `docs/20260226_channel_donation_settlement_index_ddl.sql` 파일을 추가해 `use_can`, `use_can_calculate` 인덱스 DDL을 정의했다.
|
||||||
|
- 테스트 가독성 개선:
|
||||||
|
- 채널 후원 정산 관련 신규 테스트에 `@DisplayName`(한글)을 추가해 테스트 의도를 명확히 했다.
|
||||||
|
- 검증 실행:
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew test` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
- 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행하면 테스트 결과 XML 파일 쓰기 충돌이 재발할 수 있어, 순차 실행 기준으로 최종 검증했다.
|
||||||
|
|
||||||
|
### 8차 수정
|
||||||
|
- 무엇을: 채널 후원 정산 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `count` 필드를 추가하고, QueryData/Repository/Test를 함께 갱신했다.
|
||||||
|
- 왜: 사용자 요청에 따라 정산 응답에서 그룹별 건수를 직접 확인할 수 있도록 하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- Item DTO에 `@JsonProperty("count") val count: Int`를 추가했다.
|
||||||
|
- QueryDTO(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)에 `count: Long`을 추가하고 `toResponseItem()`에서 `count.toInt()`로 매핑했다.
|
||||||
|
- Repository projection에 `useCan.id.count()`를 추가해 count 값을 조회하도록 반영했다.
|
||||||
|
- 컨트롤러/서비스 테스트 fixture 및 assertion에 `count` 검증을 추가했다.
|
||||||
|
- 검증 실행:
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew test` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
|
||||||
|
### 9차 수정
|
||||||
|
- 무엇을: 채널 후원 정산 `count`가 분할 정산 레코드 수로 과집계되던 문제를 수정하고, 동일 후원(`UseCan` 1건) + 분할 정산(`UseCanCalculate` 2건) 회귀 테스트를 관리자/크리에이터 관리자 경로에 추가했다.
|
||||||
|
- 왜: 결제 게이트웨이별 분할 정산이 발생하면 기존 `useCan.id.count()`가 실제 후원 건수보다 크게 집계되어 정산 화면 `count`가 잘못 표시되기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- `AdminChannelDonationCalculateQueryRepository`, `CreatorAdminChannelDonationCalculateQueryRepository`의 집계 `count`를 `useCan.id.countDistinct()`로 변경했다.
|
||||||
|
- QueryRepository 통합 테스트(`AdminChannelDonationCalculateQueryRepositoryTest`, `CreatorAdminChannelDonationCalculateQueryRepositoryTest`)를 추가해 분할 정산 시 `count=1`, `totalCan` 합산(50) 동작을 검증했다.
|
||||||
|
- H2 환경에서 MySQL 함수(`DATE_FORMAT`, `CONVERT_TZ`)를 테스트 가능하게 하기 위해 `H2MySqlFunctionDialect`, `H2MysqlDateFunctions` 테스트 지원 코드를 추가하고 각 테스트에서 alias를 등록했다.
|
||||||
|
- 검증 실행:
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
- 참고: Kotlin LSP 미설정 환경이라 `.kt` 대상 `lsp_diagnostics`는 실행 시 서버 미설정 오류가 발생했다.
|
||||||
|
|
||||||
|
### 10차 수정
|
||||||
|
- 무엇을: 관리자 채널 후원 정산의 `totalCount` 쿼리에 `member` `innerJoin`을 추가해 목록 조회와 동일한 조인 조건으로 집계하도록 정렬했다.
|
||||||
|
- 왜: 기존에는 `totalCount`는 `member` 조인 없이 계산하고 목록은 `member` `innerJoin`을 사용해, 데이터 정합성 이슈(고아 `recipientCreatorId`)가 있을 때 `totalCount`와 `items`가 불일치할 수 있었다.
|
||||||
|
- 어떻게:
|
||||||
|
- `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotalCount(...)`에 `member` 조인(`member.id = useCanCalculate.recipientCreatorId`)을 추가했다.
|
||||||
|
- distinct 그룹 키를 `recipientCreatorId` 문자열 대신 `member.id` 문자열 기준으로 변경해 목록 쿼리의 그룹 축(날짜+멤버)과 맞췄다.
|
||||||
|
- 검증 실행:
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
- 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행했을 때 test result XML 쓰기 충돌이 1회 발생해, 이후 순차 실행으로 재검증했다.
|
||||||
|
|
||||||
|
### 10차 수정
|
||||||
|
- 무엇을: 정산 페이지 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `totalCan` 필드를 추가했다.
|
||||||
|
- 왜: 사용자 요청대로 화면에서 건수 다음에 총 받은 캔 수를 함께 노출하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- Item DTO에 `@JsonProperty("totalCan") val totalCan: Int`를 `count` 다음 위치로 추가했다.
|
||||||
|
- QueryData(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)의 `toResponseItem()`에서 `totalCan ?: 0`을 응답 Item의 `totalCan`으로 매핑했다.
|
||||||
|
- 컨트롤러/서비스 테스트 fixture와 assertion에 `totalCan` 검증을 추가했다.
|
||||||
|
- 검증 실행:
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew test` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
|
||||||
|
### 11차 수정
|
||||||
|
- 무엇을: 채널 후원 정산 인덱스 DDL(`docs/20260226_channel_donation_settlement_index_ddl.sql`)을 재실행 가능한 멱등 스크립트로 수정했다.
|
||||||
|
- 왜: 동일 DB에 DDL을 재적용할 때 기존 `ADD INDEX`가 `Duplicate key name`으로 실패할 수 있어, 운영 재적용/롤백 후 재적용 시 안정성을 확보해야 했기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- `information_schema.statistics`에서 `table_schema = DATABASE()` 기준으로 인덱스 존재 여부를 조회하도록 변경했다.
|
||||||
|
- 인덱스가 없을 때만 `ALTER TABLE ... ADD INDEX`를 실행하고, 이미 존재하면 안내 `SELECT`를 실행하는 동적 SQL(`PREPARE`/`EXECUTE`) 패턴을 적용했다.
|
||||||
|
- 대상 인덱스 3개(`idx_use_can_channel_donation_filter`, `idx_use_can_calculate_settlement_join`, `idx_use_can_calculate_creator_settlement`) 모두 동일 규칙으로 반영했다.
|
||||||
|
- 검증 실행:
|
||||||
|
- `lsp_diagnostics`(대상: `docs/20260226_channel_donation_settlement_index_ddl.sql`) → `.sql` LSP 서버 미설정으로 진단 불가(환경 제약)
|
||||||
|
- `lsp_diagnostics`(대상: 본 문서) → `No diagnostics found`
|
||||||
|
- `./gradlew test --tests "*channelDonation*"` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
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
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/calculate")
|
||||||
|
class AdminChannelDonationCalculateController(
|
||||||
|
private val service: AdminChannelDonationCalculateService
|
||||||
|
) {
|
||||||
|
@GetMapping("/channel-donation-by-creator")
|
||||||
|
fun getChannelDonationByCreator(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getChannelDonationByCreator(
|
||||||
|
startDateStr = startDateStr,
|
||||||
|
endDateStr = endDateStr,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminChannelDonationCalculateQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
val distinctGroupKey = Expressions.stringTemplate(
|
||||||
|
"CONCAT({0}, '-', {1})",
|
||||||
|
formattedDate,
|
||||||
|
member.id.stringValue()
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(distinctGroupKey.countDistinct())
|
||||||
|
.from(useCanCalculate)
|
||||||
|
.innerJoin(useCanCalculate.useCan, useCan)
|
||||||
|
.innerJoin(member)
|
||||||
|
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
||||||
|
.where(baseWhereCondition(startDate, endDate))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChannelDonationByCreator(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<GetAdminChannelDonationSettlementQueryData> {
|
||||||
|
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAdminChannelDonationSettlementQueryData(
|
||||||
|
formattedDate,
|
||||||
|
member.nickname,
|
||||||
|
useCan.id.countDistinct(),
|
||||||
|
useCanCalculate.can.sum()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCanCalculate)
|
||||||
|
.innerJoin(useCanCalculate.useCan, useCan)
|
||||||
|
.innerJoin(member)
|
||||||
|
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
||||||
|
.where(baseWhereCondition(startDate, endDate))
|
||||||
|
.groupBy(formattedDate, member.id)
|
||||||
|
.orderBy(formattedDate.desc(), member.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun baseWhereCondition(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime
|
||||||
|
) = useCan.canUsage.eq(CanUsage.CHANNEL_DONATION)
|
||||||
|
.and(useCan.isRefund.isFalse)
|
||||||
|
.and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED))
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
|
||||||
|
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||||
|
return Expressions.stringTemplate(
|
||||||
|
"DATE_FORMAT({0}, {1})",
|
||||||
|
Expressions.dateTimeTemplate(
|
||||||
|
LocalDateTime::class.java,
|
||||||
|
"CONVERT_TZ({0},{1},{2})",
|
||||||
|
dateTimePath,
|
||||||
|
"UTC",
|
||||||
|
"Asia/Seoul"
|
||||||
|
),
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminChannelDonationCalculateService(
|
||||||
|
private val repository: AdminChannelDonationCalculateQueryRepository
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getChannelDonationByCreator(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): GetAdminChannelDonationSettlementResponse {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate)
|
||||||
|
val items = repository
|
||||||
|
.getChannelDonationByCreator(startDate, endDate, offset, limit)
|
||||||
|
.map { it.toResponseItem() }
|
||||||
|
|
||||||
|
return GetAdminChannelDonationSettlementResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetAdminChannelDonationSettlementItem(
|
||||||
|
@JsonProperty("date") val date: String,
|
||||||
|
@JsonProperty("creator") val creator: String,
|
||||||
|
@JsonProperty("count") val count: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("krw") val krw: Int,
|
||||||
|
@JsonProperty("fee") val fee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("withholdingTax") val withholdingTax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
|
||||||
|
|
||||||
|
data class GetAdminChannelDonationSettlementQueryData @QueryProjection constructor(
|
||||||
|
val date: String,
|
||||||
|
val creator: String,
|
||||||
|
val count: Long,
|
||||||
|
val totalCan: Int?
|
||||||
|
) {
|
||||||
|
fun toResponseItem(): GetAdminChannelDonationSettlementItem {
|
||||||
|
val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0)
|
||||||
|
|
||||||
|
return GetAdminChannelDonationSettlementItem(
|
||||||
|
date = date,
|
||||||
|
creator = creator,
|
||||||
|
count = count.toInt(),
|
||||||
|
totalCan = totalCan ?: 0,
|
||||||
|
krw = settlement.krw,
|
||||||
|
fee = settlement.fee,
|
||||||
|
settlementAmount = settlement.settlementAmount,
|
||||||
|
withholdingTax = settlement.withholdingTax,
|
||||||
|
depositAmount = settlement.depositAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
data class GetAdminChannelDonationSettlementResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetAdminChannelDonationSettlementItem>
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kr.co.vividnext.sodalive.calculate.channelDonation
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
data class ChannelDonationSettlementAmount(
|
||||||
|
val krw: Int,
|
||||||
|
val fee: Int,
|
||||||
|
val settlementAmount: Int,
|
||||||
|
val withholdingTax: Int,
|
||||||
|
val depositAmount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
object ChannelDonationSettlementCalculator {
|
||||||
|
private val KRW_PER_CAN = BigDecimal("100")
|
||||||
|
private val FEE_RATE = BigDecimal("0.066")
|
||||||
|
private val SETTLEMENT_RATE = BigDecimal("0.85")
|
||||||
|
private val WITHHOLDING_TAX_RATE = BigDecimal("0.033")
|
||||||
|
|
||||||
|
fun calculate(totalCan: Int): ChannelDonationSettlementAmount {
|
||||||
|
// 원화 = 캔 * 100
|
||||||
|
val krw = BigDecimal(totalCan).multiply(KRW_PER_CAN).setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
|
||||||
|
// 수수료 = 원화 * 6.6%
|
||||||
|
val fee = BigDecimal(krw).multiply(FEE_RATE).setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
|
||||||
|
// 정산금액 = (원화 - 수수료) * 85%
|
||||||
|
val settlementAmount = BigDecimal(krw - fee)
|
||||||
|
.multiply(SETTLEMENT_RATE)
|
||||||
|
.setScale(0, RoundingMode.HALF_UP)
|
||||||
|
.toInt()
|
||||||
|
|
||||||
|
// 원천세 = 정산금액 * 3.3%
|
||||||
|
val withholdingTax = BigDecimal(settlementAmount)
|
||||||
|
.multiply(WITHHOLDING_TAX_RATE)
|
||||||
|
.setScale(0, RoundingMode.HALF_UP)
|
||||||
|
.toInt()
|
||||||
|
|
||||||
|
// 입금액 = 정산금액 - 원천세
|
||||||
|
val depositAmount = settlementAmount - withholdingTax
|
||||||
|
|
||||||
|
return ChannelDonationSettlementAmount(
|
||||||
|
krw = krw,
|
||||||
|
fee = fee,
|
||||||
|
settlementAmount = settlementAmount,
|
||||||
|
withholdingTax = withholdingTax,
|
||||||
|
depositAmount = depositAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
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
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('CREATOR')")
|
||||||
|
@RequestMapping("/creator-admin/calculate")
|
||||||
|
class CreatorAdminChannelDonationCalculateController(
|
||||||
|
private val service: CreatorAdminChannelDonationCalculateService
|
||||||
|
) {
|
||||||
|
@GetMapping("/channel-donation")
|
||||||
|
fun getChannelDonation(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
pageable: Pageable
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getChannelDonation(
|
||||||
|
startDateStr = startDateStr,
|
||||||
|
endDateStr = endDateStr,
|
||||||
|
memberId = member.id!!,
|
||||||
|
creatorNickname = member.nickname,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class CreatorAdminChannelDonationCalculateQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
fun getChannelDonationTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, memberId: Long): Int {
|
||||||
|
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(formattedDate.countDistinct())
|
||||||
|
.from(useCanCalculate)
|
||||||
|
.innerJoin(useCanCalculate.useCan, useCan)
|
||||||
|
.where(baseWhereCondition(startDate, endDate, memberId))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChannelDonation(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
memberId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<GetCreatorChannelDonationSettlementQueryData> {
|
||||||
|
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCreatorChannelDonationSettlementQueryData(
|
||||||
|
formattedDate,
|
||||||
|
useCan.id.countDistinct(),
|
||||||
|
useCanCalculate.can.sum()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCanCalculate)
|
||||||
|
.innerJoin(useCanCalculate.useCan, useCan)
|
||||||
|
.where(baseWhereCondition(startDate, endDate, memberId))
|
||||||
|
.groupBy(formattedDate)
|
||||||
|
.orderBy(formattedDate.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun baseWhereCondition(startDate: LocalDateTime, endDate: LocalDateTime, memberId: Long) = useCan.canUsage.eq(
|
||||||
|
CanUsage.CHANNEL_DONATION
|
||||||
|
)
|
||||||
|
.and(useCan.isRefund.isFalse)
|
||||||
|
.and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED))
|
||||||
|
.and(useCanCalculate.recipientCreatorId.eq(memberId))
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
|
||||||
|
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||||
|
return Expressions.stringTemplate(
|
||||||
|
"DATE_FORMAT({0}, {1})",
|
||||||
|
Expressions.dateTimeTemplate(
|
||||||
|
LocalDateTime::class.java,
|
||||||
|
"CONVERT_TZ({0},{1},{2})",
|
||||||
|
dateTimePath,
|
||||||
|
"UTC",
|
||||||
|
"Asia/Seoul"
|
||||||
|
),
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CreatorAdminChannelDonationCalculateService(
|
||||||
|
private val repository: CreatorAdminChannelDonationCalculateQueryRepository
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getChannelDonation(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
memberId: Long,
|
||||||
|
creatorNickname: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): GetCreatorChannelDonationSettlementResponse {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getChannelDonationTotalCount(startDate, endDate, memberId)
|
||||||
|
val items = repository
|
||||||
|
.getChannelDonation(startDate, endDate, memberId, offset, limit)
|
||||||
|
.map { it.toResponseItem(creatorNickname) }
|
||||||
|
|
||||||
|
return GetCreatorChannelDonationSettlementResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetCreatorChannelDonationSettlementItem(
|
||||||
|
@JsonProperty("date") val date: String,
|
||||||
|
@JsonProperty("creator") val creator: String,
|
||||||
|
@JsonProperty("count") val count: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("krw") val krw: Int,
|
||||||
|
@JsonProperty("fee") val fee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("withholdingTax") val withholdingTax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
|
||||||
|
|
||||||
|
data class GetCreatorChannelDonationSettlementQueryData @QueryProjection constructor(
|
||||||
|
val date: String,
|
||||||
|
val count: Long,
|
||||||
|
val totalCan: Int?
|
||||||
|
) {
|
||||||
|
fun toResponseItem(creatorNickname: String): GetCreatorChannelDonationSettlementItem {
|
||||||
|
val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0)
|
||||||
|
|
||||||
|
return GetCreatorChannelDonationSettlementItem(
|
||||||
|
date = date,
|
||||||
|
creator = creatorNickname,
|
||||||
|
count = count.toInt(),
|
||||||
|
totalCan = totalCan ?: 0,
|
||||||
|
krw = settlement.krw,
|
||||||
|
fee = settlement.fee,
|
||||||
|
settlementAmount = settlement.settlementAmount,
|
||||||
|
withholdingTax = settlement.withholdingTax,
|
||||||
|
depositAmount = settlement.depositAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
data class GetCreatorChannelDonationSettlementResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetCreatorChannelDonationSettlementItem>
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
|
||||||
|
class AdminChannelDonationCalculateControllerTest {
|
||||||
|
private lateinit var service: AdminChannelDonationCalculateService
|
||||||
|
private lateinit var controller: AdminChannelDonationCalculateController
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
service = Mockito.mock(AdminChannelDonationCalculateService::class.java)
|
||||||
|
controller = AdminChannelDonationCalculateController(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 컨트롤러는 날짜/페이지 파라미터를 서비스로 전달한다")
|
||||||
|
fun shouldForwardDateRangeAndPageableToService() {
|
||||||
|
val response = GetAdminChannelDonationSettlementResponse(
|
||||||
|
totalCount = 1,
|
||||||
|
items = listOf(
|
||||||
|
GetAdminChannelDonationSettlementItem(
|
||||||
|
date = "2026-02-26",
|
||||||
|
creator = "creator-a",
|
||||||
|
count = 2,
|
||||||
|
totalCan = 20,
|
||||||
|
krw = 2000,
|
||||||
|
fee = 132,
|
||||||
|
settlementAmount = 1588,
|
||||||
|
withholdingTax = 52,
|
||||||
|
depositAmount = 1536
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.`when`(
|
||||||
|
service.getChannelDonationByCreator(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
offset = 15L,
|
||||||
|
limit = 15L
|
||||||
|
)
|
||||||
|
).thenReturn(response)
|
||||||
|
|
||||||
|
val apiResponse = controller.getChannelDonationByCreator(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
pageable = PageRequest.of(1, 15)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, apiResponse.success)
|
||||||
|
assertEquals(1, apiResponse.data!!.totalCount)
|
||||||
|
assertEquals("creator-a", apiResponse.data!!.items[0].creator)
|
||||||
|
assertEquals(2, apiResponse.data!!.items[0].count)
|
||||||
|
assertEquals(20, apiResponse.data!!.items[0].totalCan)
|
||||||
|
|
||||||
|
Mockito.verify(service).getChannelDonationByCreator(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
offset = 15L,
|
||||||
|
limit = 15L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"])
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val useCanRepository: UseCanRepository,
|
||||||
|
private val useCanCalculateRepository: UseCanCalculateRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
private lateinit var repository: AdminChannelDonationCalculateQueryRepository
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
registerMysqlDateFunctions()
|
||||||
|
repository = AdminChannelDonationCalculateQueryRepository(queryFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("동일 후원의 분할 정산 레코드는 건수를 중복 집계하지 않는다")
|
||||||
|
fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculations() {
|
||||||
|
val creator = saveMember(nickname = "creator-admin", role = MemberRole.CREATOR)
|
||||||
|
val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER)
|
||||||
|
val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0)
|
||||||
|
|
||||||
|
saveUseCanCalculate(
|
||||||
|
useCan = useCan,
|
||||||
|
recipientCreatorId = creator.id!!,
|
||||||
|
can = 20,
|
||||||
|
paymentGateway = PaymentGateway.PG
|
||||||
|
)
|
||||||
|
saveUseCanCalculate(
|
||||||
|
useCan = useCan,
|
||||||
|
recipientCreatorId = creator.id!!,
|
||||||
|
can = 30,
|
||||||
|
paymentGateway = PaymentGateway.GOOGLE_IAP
|
||||||
|
)
|
||||||
|
updateUseCanCreatedAt(useCan.id!!, LocalDateTime.of(2026, 2, 20, 12, 0, 0))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
|
||||||
|
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate)
|
||||||
|
val items = repository.getChannelDonationByCreator(startDate, endDate, offset = 0, limit = 20)
|
||||||
|
|
||||||
|
assertEquals(1, totalCount)
|
||||||
|
assertEquals(1, items.size)
|
||||||
|
assertEquals("2026-02-20", items[0].date)
|
||||||
|
assertEquals("creator-admin", items[0].creator)
|
||||||
|
assertEquals(1L, items[0].count)
|
||||||
|
assertEquals(50, items[0].totalCan)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(nickname: String, role: MemberRole): Member {
|
||||||
|
return memberRepository.saveAndFlush(
|
||||||
|
Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
role = role
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveUseCan(member: Member, can: Int, rewardCan: Int): UseCan {
|
||||||
|
val useCan = UseCan(
|
||||||
|
canUsage = CanUsage.CHANNEL_DONATION,
|
||||||
|
can = can,
|
||||||
|
rewardCan = rewardCan
|
||||||
|
)
|
||||||
|
useCan.member = member
|
||||||
|
return useCanRepository.saveAndFlush(useCan)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) {
|
||||||
|
val useCanCalculate = UseCanCalculate(
|
||||||
|
can = can,
|
||||||
|
paymentGateway = paymentGateway,
|
||||||
|
status = UseCanCalculateStatus.RECEIVED
|
||||||
|
)
|
||||||
|
useCanCalculate.useCan = useCan
|
||||||
|
useCanCalculate.recipientCreatorId = recipientCreatorId
|
||||||
|
useCanCalculateRepository.saveAndFlush(useCanCalculate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", useCanId)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerMysqlDateFunctions() {
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
|
||||||
|
).executeUpdate()
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||||
|
).executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
|
||||||
|
class AdminChannelDonationCalculateServiceTest {
|
||||||
|
private lateinit var repository: AdminChannelDonationCalculateQueryRepository
|
||||||
|
private lateinit var service: AdminChannelDonationCalculateService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
repository = Mockito.mock(AdminChannelDonationCalculateQueryRepository::class.java)
|
||||||
|
service = AdminChannelDonationCalculateService(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 정산 조회는 날짜 범위를 변환하고 크리에이터 그룹 응답을 반환한다")
|
||||||
|
fun shouldConvertDateRangeAndReturnCreatorGroupedItems() {
|
||||||
|
val queryData = GetAdminChannelDonationSettlementQueryData(
|
||||||
|
date = "2026-02-26",
|
||||||
|
creator = "creator-a",
|
||||||
|
count = 3L,
|
||||||
|
totalCan = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getChannelDonationByCreatorTotalCount(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
)
|
||||||
|
).thenReturn(1)
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getChannelDonationByCreator(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
0L,
|
||||||
|
20L
|
||||||
|
)
|
||||||
|
).thenReturn(listOf(queryData))
|
||||||
|
|
||||||
|
val result = service.getChannelDonationByCreator(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, result.totalCount)
|
||||||
|
assertEquals(1, result.items.size)
|
||||||
|
assertEquals("2026-02-26", result.items[0].date)
|
||||||
|
assertEquals("creator-a", result.items[0].creator)
|
||||||
|
assertEquals(3, result.items[0].count)
|
||||||
|
assertEquals(100, result.items[0].totalCan)
|
||||||
|
assertEquals(10_000, result.items[0].krw)
|
||||||
|
assertEquals(660, result.items[0].fee)
|
||||||
|
|
||||||
|
Mockito.verify(repository).getChannelDonationByCreatorTotalCount(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
)
|
||||||
|
Mockito.verify(repository).getChannelDonationByCreator(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
0L,
|
||||||
|
20L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package kr.co.vividnext.sodalive.calculate.channelDonation
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ChannelDonationSettlementCalculatorTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("정산 공식에 따라 금액을 계산한다")
|
||||||
|
fun shouldCalculateSettlementAmountsWithExpectedFormula() {
|
||||||
|
val result = ChannelDonationSettlementCalculator.calculate(totalCan = 100)
|
||||||
|
|
||||||
|
assertEquals(10_000, result.krw)
|
||||||
|
assertEquals(660, result.fee)
|
||||||
|
assertEquals(7_939, result.settlementAmount)
|
||||||
|
assertEquals(262, result.withholdingTax)
|
||||||
|
assertEquals(7_677, result.depositAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("소수 계산은 단계별 반올림 규칙을 따른다")
|
||||||
|
fun shouldRoundHalfUpOnDecimalResults() {
|
||||||
|
val result = ChannelDonationSettlementCalculator.calculate(totalCan = 1)
|
||||||
|
|
||||||
|
assertEquals(100, result.krw)
|
||||||
|
assertEquals(7, result.fee)
|
||||||
|
assertEquals(79, result.settlementAmount)
|
||||||
|
assertEquals(3, result.withholdingTax)
|
||||||
|
assertEquals(76, result.depositAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("총 캔 수가 0이면 모든 금액은 0이다")
|
||||||
|
fun shouldReturnZeroWhenTotalCanIsZero() {
|
||||||
|
val result = ChannelDonationSettlementCalculator.calculate(totalCan = 0)
|
||||||
|
|
||||||
|
assertEquals(0, result.krw)
|
||||||
|
assertEquals(0, result.fee)
|
||||||
|
assertEquals(0, result.settlementAmount)
|
||||||
|
assertEquals(0, result.withholdingTax)
|
||||||
|
assertEquals(0, result.depositAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
|
||||||
|
class CreatorAdminChannelDonationCalculateControllerTest {
|
||||||
|
private lateinit var service: CreatorAdminChannelDonationCalculateService
|
||||||
|
private lateinit var controller: CreatorAdminChannelDonationCalculateController
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
service = Mockito.mock(CreatorAdminChannelDonationCalculateService::class.java)
|
||||||
|
controller = CreatorAdminChannelDonationCalculateController(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인증 사용자 정보가 없으면 예외를 던진다")
|
||||||
|
fun shouldThrowWhenMemberIsNull() {
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
controller.getChannelDonation(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
member = null,
|
||||||
|
pageable = PageRequest.of(0, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 컨트롤러는 본인 ID/닉네임과 페이지 정보를 서비스로 전달한다")
|
||||||
|
fun shouldForwardMemberAndPageableToService() {
|
||||||
|
val member = createMember(7L)
|
||||||
|
val response = GetCreatorChannelDonationSettlementResponse(
|
||||||
|
totalCount = 1,
|
||||||
|
items = listOf(
|
||||||
|
GetCreatorChannelDonationSettlementItem(
|
||||||
|
date = "2026-02-26",
|
||||||
|
creator = "creator-self",
|
||||||
|
count = 4,
|
||||||
|
totalCan = 10,
|
||||||
|
krw = 1000,
|
||||||
|
fee = 66,
|
||||||
|
settlementAmount = 794,
|
||||||
|
withholdingTax = 26,
|
||||||
|
depositAmount = 768
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.`when`(
|
||||||
|
service.getChannelDonation(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
memberId = 7L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
offset = 10L,
|
||||||
|
limit = 5L
|
||||||
|
)
|
||||||
|
).thenReturn(response)
|
||||||
|
|
||||||
|
val apiResponse = controller.getChannelDonation(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
member = member,
|
||||||
|
pageable = PageRequest.of(2, 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, apiResponse.success)
|
||||||
|
assertEquals(1, apiResponse.data!!.totalCount)
|
||||||
|
assertEquals("creator-self", apiResponse.data!!.items[0].creator)
|
||||||
|
assertEquals(4, apiResponse.data!!.items[0].count)
|
||||||
|
assertEquals(10, apiResponse.data!!.items[0].totalCan)
|
||||||
|
|
||||||
|
Mockito.verify(service).getChannelDonation(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
memberId = 7L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
offset = 10L,
|
||||||
|
limit = 5L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "creator@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "creator",
|
||||||
|
role = MemberRole.CREATOR
|
||||||
|
)
|
||||||
|
member.id = id
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"])
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class CreatorAdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val useCanRepository: UseCanRepository,
|
||||||
|
private val useCanCalculateRepository: UseCanCalculateRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
private lateinit var repository: CreatorAdminChannelDonationCalculateQueryRepository
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
registerMysqlDateFunctions()
|
||||||
|
repository = CreatorAdminChannelDonationCalculateQueryRepository(queryFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("분할 정산 레코드가 있어도 크리에이터 정산 건수는 후원 단위로 집계한다")
|
||||||
|
fun shouldCountDistinctUseCanForCreatorWhenDonationIsSplitAcrossCalculations() {
|
||||||
|
val creator = saveMember(nickname = "creator-self", role = MemberRole.CREATOR)
|
||||||
|
val sender = saveMember(nickname = "sender-self", role = MemberRole.USER)
|
||||||
|
val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0)
|
||||||
|
|
||||||
|
saveUseCanCalculate(
|
||||||
|
useCan = useCan,
|
||||||
|
recipientCreatorId = creator.id!!,
|
||||||
|
can = 20,
|
||||||
|
paymentGateway = PaymentGateway.PG
|
||||||
|
)
|
||||||
|
saveUseCanCalculate(
|
||||||
|
useCan = useCan,
|
||||||
|
recipientCreatorId = creator.id!!,
|
||||||
|
can = 30,
|
||||||
|
paymentGateway = PaymentGateway.GOOGLE_IAP
|
||||||
|
)
|
||||||
|
updateUseCanCreatedAt(useCan.id!!, LocalDateTime.of(2026, 2, 20, 12, 0, 0))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
|
||||||
|
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getChannelDonationTotalCount(startDate, endDate, creator.id!!)
|
||||||
|
val items = repository.getChannelDonation(startDate, endDate, creator.id!!, offset = 0, limit = 20)
|
||||||
|
|
||||||
|
assertEquals(1, totalCount)
|
||||||
|
assertEquals(1, items.size)
|
||||||
|
assertEquals("2026-02-20", items[0].date)
|
||||||
|
assertEquals(1L, items[0].count)
|
||||||
|
assertEquals(50, items[0].totalCan)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(nickname: String, role: MemberRole): Member {
|
||||||
|
return memberRepository.saveAndFlush(
|
||||||
|
Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
role = role
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveUseCan(member: Member, can: Int, rewardCan: Int): UseCan {
|
||||||
|
val useCan = UseCan(
|
||||||
|
canUsage = CanUsage.CHANNEL_DONATION,
|
||||||
|
can = can,
|
||||||
|
rewardCan = rewardCan
|
||||||
|
)
|
||||||
|
useCan.member = member
|
||||||
|
return useCanRepository.saveAndFlush(useCan)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) {
|
||||||
|
val useCanCalculate = UseCanCalculate(
|
||||||
|
can = can,
|
||||||
|
paymentGateway = paymentGateway,
|
||||||
|
status = UseCanCalculateStatus.RECEIVED
|
||||||
|
)
|
||||||
|
useCanCalculate.useCan = useCan
|
||||||
|
useCanCalculate.recipientCreatorId = recipientCreatorId
|
||||||
|
useCanCalculateRepository.saveAndFlush(useCanCalculate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", useCanId)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerMysqlDateFunctions() {
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
|
||||||
|
).executeUpdate()
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||||
|
).executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
|
||||||
|
class CreatorAdminChannelDonationCalculateServiceTest {
|
||||||
|
private lateinit var repository: CreatorAdminChannelDonationCalculateQueryRepository
|
||||||
|
private lateinit var service: CreatorAdminChannelDonationCalculateService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
repository = Mockito.mock(CreatorAdminChannelDonationCalculateQueryRepository::class.java)
|
||||||
|
service = CreatorAdminChannelDonationCalculateService(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 관리자 정산 조회는 본인 범위와 날짜 범위를 적용한다")
|
||||||
|
fun shouldApplyMemberScopeAndDateRange() {
|
||||||
|
val queryData = GetCreatorChannelDonationSettlementQueryData(
|
||||||
|
date = "2026-02-26",
|
||||||
|
count = 2L,
|
||||||
|
totalCan = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getChannelDonationTotalCount(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
7L
|
||||||
|
)
|
||||||
|
).thenReturn(1)
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getChannelDonation(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
7L,
|
||||||
|
0L,
|
||||||
|
20L
|
||||||
|
)
|
||||||
|
).thenReturn(listOf(queryData))
|
||||||
|
|
||||||
|
val result = service.getChannelDonation(
|
||||||
|
startDateStr = "2026-02-20",
|
||||||
|
endDateStr = "2026-02-21",
|
||||||
|
memberId = 7L,
|
||||||
|
creatorNickname = "creator-self",
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, result.totalCount)
|
||||||
|
assertEquals(1, result.items.size)
|
||||||
|
assertEquals("creator-self", result.items[0].creator)
|
||||||
|
assertEquals(2, result.items[0].count)
|
||||||
|
assertEquals(50, result.items[0].totalCan)
|
||||||
|
assertEquals(5_000, result.items[0].krw)
|
||||||
|
|
||||||
|
Mockito.verify(repository).getChannelDonationTotalCount(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
7L
|
||||||
|
)
|
||||||
|
Mockito.verify(repository).getChannelDonation(
|
||||||
|
"2026-02-20".convertLocalDateTime(),
|
||||||
|
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||||
|
7L,
|
||||||
|
0L,
|
||||||
|
20L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package kr.co.vividnext.sodalive.support
|
||||||
|
|
||||||
|
import org.hibernate.dialect.H2Dialect
|
||||||
|
import org.hibernate.dialect.function.StandardSQLFunction
|
||||||
|
import org.hibernate.type.StandardBasicTypes
|
||||||
|
|
||||||
|
class H2MySqlFunctionDialect : H2Dialect() {
|
||||||
|
init {
|
||||||
|
registerFunction("date_format", StandardSQLFunction("DATE_FORMAT", StandardBasicTypes.STRING))
|
||||||
|
registerFunction("DATE_FORMAT", StandardSQLFunction("DATE_FORMAT", StandardBasicTypes.STRING))
|
||||||
|
registerFunction("convert_tz", StandardSQLFunction("CONVERT_TZ", StandardBasicTypes.TIMESTAMP))
|
||||||
|
registerFunction("CONVERT_TZ", StandardSQLFunction("CONVERT_TZ", StandardBasicTypes.TIMESTAMP))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.co.vividnext.sodalive.support
|
||||||
|
|
||||||
|
import java.sql.Timestamp
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class H2MysqlDateFunctions {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun convertTz(value: Timestamp?, fromTz: String?, toTz: String?): Timestamp? {
|
||||||
|
if (value == null || fromTz == null || toTz == null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromZoneId = ZoneId.of(fromTz)
|
||||||
|
val toZoneId = ZoneId.of(toTz)
|
||||||
|
val converted = value.toLocalDateTime().atZone(fromZoneId).withZoneSameInstant(toZoneId).toLocalDateTime()
|
||||||
|
|
||||||
|
return Timestamp.valueOf(converted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun dateFormat(value: Timestamp?, pattern: String?): String? {
|
||||||
|
if (value == null || pattern == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val javaPattern = pattern
|
||||||
|
.replace("%Y", "yyyy")
|
||||||
|
.replace("%m", "MM")
|
||||||
|
.replace("%d", "dd")
|
||||||
|
|
||||||
|
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user