Merge pull request '에이전트 기능' (#416) from test into main

Reviewed-on: #416
This commit is contained in:
2026-04-14 06:29:22 +00:00
65 changed files with 11996 additions and 1 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ HELP.md
.gradle
.envrc
.omx/
.worktrees/
build/
!**/src/main/**/build/
!**/src/test/**/build/

115
.opencode/package-lock.json generated Normal file
View File

@@ -0,0 +1,115 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.4.0"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.0.tgz",
"integrity": "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.4.0",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.97",
"@opentui/solid": ">=0.1.97"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.0.tgz",
"integrity": "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,658 @@
# 에이전트 권한 및 정산 기능 추가 작업 계획
## 요구사항 상세 분석
### 1. 권한/역할 분석
- `MemberRole.AGENT`는 이미 `Member.kt`에 정의되어 있으므로 역할 enum 자체의 신규 추가는 필요하지 않다.
- 다만 현재 코드베이스에는 에이전트 전용 소속 관리/정산 조회 모듈이 없으므로, 실제 기능은 신규 구현이 필요하다.
- 크리에이터를 에이전트에 소속하거나 해제하는 작업은 `ADMIN`만 수행할 수 있어야 한다.
- 에이전트 전용 조회 API는 `AGENT` 권한 계정만 자신의 소속 크리에이터 데이터에 한해 접근할 수 있어야 한다.
- 관리자용 권한 경계는 기존 `@PreAuthorize("hasRole('ADMIN')")`, 에이전트용 권한 경계는 `@PreAuthorize("hasRole('AGENT')")` 패턴을 따른다.
### 2. 관계 모델 분석
- 요구사항은 `에이전트 1 : N 크리에이터`, `크리에이터 1 : 0..1 에이전트` 관계다.
- 이 관계는 `Member` 자체에 필드를 직접 늘리는 방식보다, `kr.co.vividnext.sodalive.partner.agent` 패키지 안에 전용 연관 엔티티를 두는 편이 패키지 경계와 제약 관리에 더 적합하다.
- 신규 연관 엔티티는 `creator_id` 유니크 제약으로 "한 크리에이터는 하나의 에이전트에만 소속"을 강제해야 한다.
- 서비스 계층에서는 다음을 모두 검증해야 한다.
- agent 회원이 실제 `MemberRole.AGENT`인지
- creator 회원이 실제 `MemberRole.CREATOR`인지
- creator가 이미 다른 agent에 소속되어 있는지
- 자기 자신을 agent/creator로 잘못 연결하는 요청이 아닌지
### 3. 정산 규칙 분석
- 에이전트 정산 비율은 관리자 설정값 1개만 있으면 된다.
- 기준 금액은 "크리에이터 세전 정산금액(settlementAmount)"이며, 라이브/콘텐츠/커뮤니티처럼 항목별 비율이 아니라 최종 정산금액에 대해 에이전트 비율을 적용한다.
- 에이전트 정산금은 크리에이터 정산금에서 차감하지 않고 별도로 계산한다.
- 에이전트 정산금 계산식은 다음으로 고정한다.
- `agentSettlementAmount = round(creatorSettlementAmount * agentSettlementRatio / 100)`
- 크리에이터 입금액 계산식은 기존 로직을 그대로 유지한다.
- 라이브/콘텐츠/커뮤니티는 기존 `CreatorSettlementRatio` 또는 콘텐츠별 정산 비율을 이용해 `settlementAmount`를 계산한 뒤, 그 결과에 에이전트 비율을 곱해야 한다.
- 채널후원/콘텐츠후원도 기존 정산 계산 결과의 `settlementAmount`를 기준으로 에이전트 금액을 별도 계산해야 한다.
### 4. 조회 요구사항 분석
- 에이전트는 소속 크리에이터 목록을 조회할 수 있어야 한다.
- 에이전트는 소속 크리에이터 기준으로 아래 5개 현황을 조회할 수 있어야 한다.
- 라이브
- 콘텐츠 판매
- 커뮤니티
- 채널후원
- 콘텐츠후원
- 각 조회는 `/admin/calculate/content-by-creator` 계열과 유사하게 크리에이터별 집계 응답을 제공해야 한다.
- 각 응답은 최소한 다음 정보를 포함해야 한다.
- 크리에이터 식별 정보
- 건수
- 총 캔 수
- 원화
- 수수료
- 정산금액
- 합계(total)
- 에이전트 정산금액(agentSettlementAmount)
- 기존 `GetCalculateByCreatorItem`은 건수가 없고 total 객체도 없으므로, 에이전트 전용 응답 DTO는 신규 정의가 필요하다.
## 구현 방향
### 1차 구현 범위 (2026-04-09)
- 이번 구현 슬라이스는 관리자 전용 기능만 포함한다.
- 포함 범위
- 에이전트-크리에이터 소속 지정 API
- 에이전트-크리에이터 소속 해제 API
- 에이전트 정산 비율 생성 API
- 에이전트 정산 비율 수정 API
- 에이전트 정산 비율 목록 API
- 위 기능에 필요한 엔티티/리포지토리/서비스/테스트/DDL 문서
- 제외 범위
- 에이전트 본인 소속 크리에이터 목록 조회 API
- 라이브/콘텐츠/커뮤니티/채널후원/콘텐츠후원 에이전트 정산 조회 API
### 권장 설계
- 공유 도메인 모델/리포지토리는 `kr.co.vividnext.sodalive.partner.agent` 하위 패키지에 둔다.
- `ADMIN` 전용 controller/service 진입점은 `kr.co.vividnext.sodalive.admin.partner.agent` 하위 패키지에 둔다.
- `AGENT` 전용 정산 조회 진입점은 `kr.co.vividnext.sodalive.partner.agent.calculate` 하위 패키지에 둔다.
- 기존 `admin.calculate`, `creator.admin.calculate`, `admin.member`의 구현 패턴은 재사용하되, DTO/쿼리/서비스는 에이전트 요구사항에 맞는 별도 모듈로 분리한다.
- 에이전트-크리에이터 소속 관계와 에이전트 정산 비율은 전용 엔티티로 분리해 기능 응집도를 유지한다.
### 3차 구현 범위 (이력형 소속/비율 + 확정 정산 스냅샷)
- 이번 구현 슬라이스는 기존 current-state 기반 에이전트 정산 구조를 historical model로 전환한다.
- 포함 범위
- `agent_creator_relation``assignedAt/unassignedAt` 기반 이력형 소속 모델로 전환
- `agent_settlement_ratio``effectiveFrom/effectiveTo` 기반 이력형 비율 모델로 전환
- 관리자 소속 지정/해제 API를 시간 경계 기반으로 수정
- 관리자 비율 생성/수정/조회 API를 이력형 비율 기준으로 수정
- AGENT 정산 조회 쿼리를 거래 시점(event time)의 소속/비율을 기준으로 계산하도록 수정
- 확정 정산 스냅샷 저장 모델 및 관리자 확정 API 추가
- finalized 기간 조회는 스냅샷 우선, 미확정 기간 조회는 live 계산 유지
- 위 구조에 필요한 테스트, DDL 문서, 계획 문서 갱신
- 제외 범위
- 기존 과거 데이터의 완전 복원 보장
- 이벤트소싱 도입
- 프론트엔드 화면 개편
### current-state 설계의 한계
- 현재 `AgentCreatorRelation``agent`, `creator`만 저장하고 remove 시 hard-delete 하므로 과거 소속 이력을 보존하지 못한다.
- 현재 `AgentSettlementRatio``deletedAt` 기반 현재 활성 행 조회에 의존하므로 특정 거래 시점의 비율을 안정적으로 재현하지 못한다.
- 현재 `AgentCalculateQueryRepository`는 거래 발생 시각(`useCan.createdAt`, `order.createdAt`)으로 기간을 자르면서도, 소속/비율은 현재 row를 기준으로 조인한다.
- 따라서 크리에이터가 에이전트에서 해제되거나 비율이 변경되면, 과거 정산 조회 결과도 바뀔 수 있다.
- 사용자가 요구한 "당시 적용 비율까지 고정된 완전한 과거 정산"은 current-state 조인만으로 충족할 수 없다.
### 변경 후 목표 모델
- 소속 관계는 append-only 이력 모델로 관리한다.
- `assignedAt`: 소속 시작 시각
- `unassignedAt`: 소속 종료 시각(nullable)
- 현재 소속은 `unassignedAt is null`로 정의한다.
- 정산 비율도 append-only 이력 모델로 관리한다.
- `effectiveFrom`: 비율 시작 시각
- `effectiveTo`: 비율 종료 시각(nullable)
- 현재 비율은 `effectiveTo is null`로 정의한다.
- 정산 확정 시점에는 별도 스냅샷 테이블에 결과를 저장한다.
- 소속/비율 foreign key만 저장하지 않고, 적용된 비율과 계산 결과를 숫자 값으로 함께 저장한다.
- 이후 소속/비율 변경이 발생해도 확정 정산은 변경되지 않는다.
### 확정 정산 스냅샷 설계 초안
- 신규 도메인 패키지 후보: `kr.co.vividnext.sodalive.partner.agent.settlement.snapshot`
- 신규 관리자 진입점 패키지 후보: `kr.co.vividnext.sodalive.admin.partner.agent.settlement`
- 신규 스냅샷 엔티티 초안 필드
- `periodStart`, `periodEnd`
- `settlementType` (`LIVE`, `CONTENT`, `COMMUNITY`, `CHANNEL_DONATION`, `CONTENT_DONATION`)
- `agentId`, `agentNickname`
- `creatorId`, `creatorNickname`
- `assignmentId`
- `agentSettlementRatioId`, `appliedAgentSettlementRatio`
- `count`, `totalCan`, `krw`, `fee`, `settlementAmount`, `agentSettlementAmount`
- 필요 시 `tax`, `depositAmount`
- `finalizedAt`, `finalizedByMemberId`
- 스냅샷은 append-only로 저장하고 동일 기간/타입/agent/creator 기준 재확정은 idempotent하게 막거나 재사용한다.
### 조회 전략 변경
- finalized 기간 조회
- 스냅샷 데이터가 있으면 스냅샷을 우선 조회한다.
- 스냅샷 데이터는 이후 소속 해제/비율 변경과 무관하게 그대로 반환한다.
- 미확정 기간 조회
- `AgentCalculateQueryRepository`에서 거래 시점 기준으로 소속/비율 이력 row를 찾아 계산한다.
- 시간 경계는 `start <= txTime < end` 형태의 반열린 구간으로 처리한다.
### API 변경 방향
- 관리자 소속 지정 API
- 기존 path 유지
- request에 `assignedAt` 추가
- 관리자 소속 해제 API
- 기존 path 유지
- request에 `unassignedAt` 추가
- delete 대신 종료 시각 기록
- 관리자 비율 생성/수정 API
- 기존 path 유지
- request에 `effectiveFrom` 추가
- update는 기존 행 수정이 아니라 이전 활성 행 종료 + 신규 행 추가로 동작
- 관리자 확정 정산 API
- 신규 path 후보: `POST /admin/partner/agent/settlement/finalize`
- 입력: 기간, 정산 타입, 대상 에이전트(`agentId` 기준 단일 에이전트 확정)
- 에이전트 조회 API
- 기존 path 유지
- finalized 기간은 스냅샷 우선, 그 외 기간은 live 계산
### 패키지/파일 초안
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelationRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/RemoveAgentCreatorRequest.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/response/*`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/**`
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/**`
- `docs/20260408_에이전트권한및정산기능추가.md`
### API 방향
- 관리자 전용
- 크리에이터 소속 지정 API
- 크리에이터 소속 해제 API
- 에이전트 정산 비율 생성/수정/조회 API
- 에이전트 전용
- 소속 크리에이터 목록 조회 API
- 라이브 크리에이터별 현황 조회 API
- 콘텐츠 크리에이터별 현황 조회 API
- 커뮤니티 크리에이터별 현황 조회 API
- 채널후원 크리에이터별 현황 조회 API
- 콘텐츠후원 크리에이터별 현황 조회 API
### 역할별 신규 엔드포인트 정리
#### ADMIN 전용 엔드포인트
| Method | Path | 권한 | 설명 |
|---|---|---|---|
| `POST` | `/admin/partner/agent/assignment` | `ADMIN` | 에이전트와 크리에이터 소속을 지정한다. |
| `POST` | `/admin/partner/agent/assignment/remove` | `ADMIN` | 지정된 크리에이터의 에이전트 소속을 해제한다. |
| `POST` | `/admin/partner/agent/ratio` | `ADMIN` | 에이전트 정산 비율을 생성한다. |
| `POST` | `/admin/partner/agent/ratio/update` | `ADMIN` | 기존 에이전트 정산 비율을 수정한다. |
| `GET` | `/admin/partner/agent/ratio` | `ADMIN` | 에이전트 정산 비율 목록을 페이지 단위로 조회한다. |
#### AGENT 전용 엔드포인트
| Method | Path | 권한 | 설명 |
|---|---|---|---|
| `GET` | `/agent/calculate/creator/list` | `AGENT` | 로그인한 에이전트에게 소속된 크리에이터 목록을 조회한다. |
| `GET` | `/agent/calculate/live-by-creator` | `AGENT` | 소속 크리에이터의 라이브 정산 현황을 크리에이터별로 조회한다. |
| `GET` | `/agent/calculate/content-by-creator` | `AGENT` | 소속 크리에이터의 콘텐츠 판매 정산 현황을 크리에이터별로 조회한다. |
| `GET` | `/agent/calculate/community-by-creator` | `AGENT` | 소속 크리에이터의 커뮤니티 정산 현황을 크리에이터별로 조회한다. |
| `GET` | `/agent/calculate/channel-donation-by-creator` | `AGENT` | 소속 크리에이터의 채널후원 정산 현황을 크리에이터별로 조회한다. |
| `GET` | `/agent/calculate/content-donation-by-creator` | `AGENT` | 소속 크리에이터의 콘텐츠후원 정산 현황을 크리에이터별로 조회한다. |
#### 공통 인증 메모
- 관리자 엔드포인트는 모두 `@PreAuthorize("hasRole('ADMIN')")` 기준으로 제한한다.
- 에이전트 엔드포인트는 모두 `@PreAuthorize("hasRole('AGENT')")` 기준으로 제한한다.
- 에이전트 조회 API는 `@AuthenticationPrincipal(... ) member: Member?`를 통해 로그인 사용자를 확인하고, `agent_creator_relation` 기준으로 본인 소속 크리에이터 데이터만 조회한다.
### 패키지/엔드포인트 배치에 대한 최종 판단
#### 1. ADMIN 전용 partner-agent API는 어디에 두는 것이 더 자연스러운가?
- **최종 판단: `ADMIN` 전용 controller/service 진입점은 `kr.co.vividnext.sodalive.admin.partner.agent.*` 아래로 옮기는 것이 더 자연스럽다.**
- 근거는 이 저장소의 기존 관례가 관리자 전용 기능을 `kr.co.vividnext.sodalive.admin.*` 아래에 배치하는 흐름이 더 강하기 때문이다.
- 예: `admin/calculate/AdminCalculateController.kt`
- 예: `admin/marketing/AdminAdMediaPartnerController.kt`
- 예: `admin/member/AdminMemberController.kt`
- `creator`처럼 역할이 강하게 분리된 영역도 `creator/admin/*` 패턴을 쓰므로, 현재 `partner.agent.assignment.AdminAgentCreatorController`, `partner.agent.ratio.AdminAgentSettlementRatioController`처럼 **도메인 패키지 안에 관리자 전용 진입점이 섞여 있는 구조는 이 저장소 기준으로는 예외에 가깝다.**
- 따라서 권장 구조는 아래와 같다.
- **공유 도메인 객체/리포지토리**: `kr.co.vividnext.sodalive.partner.agent.*`
- **ADMIN 전용 controller/service**: `kr.co.vividnext.sodalive.admin.partner.agent.*`
- 즉, `AgentCreatorRelation`, `AgentSettlementRatio`, repository까지 `admin.*`로 옮기는 것이 아니라, **관리자 진입점만 `admin.*`로 재배치**하는 것이 균형이 가장 좋다.
#### 2. 소속 크리에이터 목록 조회 API가 `calculate` 아래에 있는 것은 맞는 선택인가?
- **최종 판단: 현재 요구사항 범위에서는 유지해도 된다.**
- 이유는 이 API가 “에이전트 소속 관리용 일반 목록 API”라기보다, **에이전트 정산 화면에서 크리에이터별 현황 조회로 진입하기 위한 보조 목록 API**에 가깝기 때문이다.
- 현재 같은 컨트롤러에는 아래처럼 모두 정산/집계성 조회가 함께 모여 있다.
- `/agent/calculate/live-by-creator`
- `/agent/calculate/content-by-creator`
- `/agent/calculate/community-by-creator`
- `/agent/calculate/channel-donation-by-creator`
- `/agent/calculate/content-donation-by-creator`
- 따라서 **현재 맥락에서는 `/agent/calculate/creator/list`를 정산 조회의 진입용 목록으로 보는 해석이 가능하고, 응집도도 유지된다.**
- 다만 이 API가 앞으로 정산 외 목적(메시지, 운영, 소속 관리, 일반 대시보드)에도 재사용되기 시작하면, 그 시점에는 아래처럼 분리 재검토하는 것이 맞다.
- 패키지 후보: `kr.co.vividnext.sodalive.partner.agent.assignment` 또는 `kr.co.vividnext.sodalive.partner.agent.creator`
- 엔드포인트 후보: `/agent/creator/list`, `/agent/assignment/creator/list`
#### 3. 이번 기능에 대한 권장 정리 기준
- **ADMIN 전용 API**: `kr.co.vividnext.sodalive.admin.partner.agent.*`
- **AGENT 정산 조회 API**: `kr.co.vividnext.sodalive.partner.agent.calculate.*`
- **공유 도메인 모델/리포지토리**: `kr.co.vividnext.sodalive.partner.agent.*`
- **`/agent/calculate/creator/list`**: 현재는 유지, 단 정산 외 재사용이 커지면 별도 read/assignment 축으로 분리
## 작업 체크리스트
- [x] 기존 `MemberRole.AGENT` 사용 범위를 점검하고, 관리자 화면/API에서 에이전트 계정을 식별할 수 있는 조회 경로를 확정한다.
- [x] `kr.co.vividnext.sodalive.partner.agent` 패키지 아래에 에이전트-크리에이터 소속 전용 엔티티/리포지토리를 추가한다.
- [x] 소속 엔티티에 `creator_id` 유니크 제약과 agent/creator role 검증 로직을 추가해 "한 크리에이터는 하나의 에이전트에만 소속" 규칙을 보장한다.
- [x] 관리자 전용 크리에이터 소속 지정 API를 추가한다.
- [x] 관리자 전용 크리에이터 소속 해제 API를 추가한다.
- [x] 에이전트 정산 비율 전용 엔티티/리포지토리를 추가하고, 에이전트당 단일 비율만 유지되도록 한다.
- [x] 관리자 전용 에이전트 정산 비율 생성/수정/조회 API를 추가한다.
- [x] 1차 구현 범위의 assignment/ratio 컨트롤러/서비스 테스트를 추가해 role 검증, 중복 소속 방지, 누락 엔티티, pageable 위임을 검증한다.
- [x] 신규 assignment/ratio 테이블 생성을 위한 DDL 문서를 `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에 추가한다.
- [x] 에이전트 본인의 소속 크리에이터 목록 조회 API를 추가한다.
- [x] `/agent/calculate/creator/list`가 현재 시각 기준 `assignedAt <= now < unassignedAt` 활성 구간의 크리에이터만 노출하도록 보강한다.
- [x] 라이브 현황용 agent 전용 Query/DTO/응답을 추가하고, `settlementAmount` 기준 `agentSettlementAmount`를 계산한다.
- [x] 콘텐츠 판매 현황용 agent 전용 Query/DTO/응답을 추가하고, 기존 콘텐츠 정산 비율(`audioContent.settlementRatio` 또는 `CreatorSettlementRatio.contentSettlementRatio`)을 재사용한다.
- [x] 커뮤니티 현황용 agent 전용 Query/DTO/응답을 추가하고, `CreatorSettlementRatio.communitySettlementRatio` 기반 정산 후 agent 금액을 계산한다.
- [x] 채널후원 현황용 agent 전용 Query/DTO/응답을 추가하고, 기존 `ChannelDonationSettlementCalculator` 결과의 `settlementAmount` 기준으로 agent 금액을 계산한다.
- [x] 콘텐츠후원 현황용 agent 전용 Query/DTO/응답을 추가하고, 기존 콘텐츠후원 계산 결과의 `settlementAmount` 기준으로 agent 금액을 계산한다.
- [x] 5개 현황 응답 모두에 `totalCount + total + items` 구조를 맞추고, item/total 양쪽에 `agentSettlementAmount`를 포함한다.
- [x] AGENT 계정이 자신에게 소속된 크리에이터 데이터만 조회하도록 `@AuthenticationPrincipal` + relation 기반 필터링을 구현한다.
- [x] ADMIN/AGENT 권한 오류, 잘못된 role 요청, 중복 소속 요청, 비소속 데이터 조회 차단에 대한 예외 처리를 추가한다.
- [x] 컨트롤러/서비스/쿼리 리포지토리 테스트를 추가해 소속 제약, 권한 제약, 정산 계산식, total 합계를 검증한다.
- [x] `./gradlew test`, `./gradlew build`로 최종 검증한다.
- [x] `agent_creator_relation``assignedAt`, `unassignedAt`를 추가하고 current-state 단일 row 모델을 append-only 이력 모델로 전환한다.
- [x] 관리자 소속 지정 API가 `assignedAt`을 받아 활성 기간 중복을 검증하도록 수정한다.
- [x] 관리자 소속 해제 API가 hard-delete 대신 `unassignedAt` 종료 처리로 변경되도록 수정한다.
- [x] `agent_settlement_ratio``effectiveFrom`, `effectiveTo`를 추가하고 단일 현재 row 갱신 모델을 append-only 이력 모델로 전환한다.
- [x] 관리자 비율 생성/수정 API가 `effectiveFrom`을 받아 기존 활성 row 종료 + 신규 row 추가로 동작하도록 수정한다.
- [x] 관리자 비율 생성/수정 API가 `effectiveFrom` backdate, 동일 시각 입력, 기존 ratio history와 겹치는 시점을 거절하도록 검증을 보강한다.
- [x] 관리자 비율 생성/수정 API가 `settlementRatio`를 0..100 범위로 검증하도록 보강한다.
- [x] `agent_settlement_ratio` DDL에 MySQL 생성 컬럼 + UNIQUE 인덱스로 active row 단일성을 보장하고, `effective_from < effective_to` 기간 무결성 제약을 추가한다.
- [x] `agent_creator_relation` DDL에 MySQL 생성 컬럼 + UNIQUE 인덱스로 active row 단일성을 보장하고, `assigned_at < unassigned_at` 기간 무결성 제약을 추가한다.
- [x] 관리자 소속/비율 쓰기 경로가 `MemberRepository.findByIdForUpdate(...)` 기반 비관적 락과 unique violation 대응 패턴으로 직렬화되도록 보강한다.
- [x] AGENT 정산 조회가 거래 시점 기준의 소속 이력과 비율 이력을 조인하도록 `AgentCalculateQueryRepository`를 수정한다.
- [x] 기간 중 소속 변경 또는 비율 변경이 있는 경우 결과가 올바르게 분리/집계되는 테스트를 추가한다.
- [x] AGENT 정산 조회의 paged query가 사전 조회된 `creatorIds`가 빈 페이지일 때 전체 결과로 fallback하지 않고 빈 rows/items를 반환하도록 보강한다.
- [x] AGENT 정산 조회에서 `agent_settlement_ratio` 이력이 없으면 agent 정산금을 0% 대신 10% 기본값으로 계산하도록 수정한다.
- [x] 확정 정산 스냅샷 엔티티/리포지토리/관리자 API를 추가한다.
- [x] 확정 정산 스냅샷이 소속/비율 foreign key뿐 아니라 적용 비율과 계산 결과 숫자값을 함께 저장하도록 구현한다.
- 정정(2026-04-09): 현재 구현은 `appliedAgentSettlementRatio`와 계산 결과 숫자값은 저장하지만, 설계 초안에 명시한 `assignmentId`, `agentSettlementRatioId`는 아직 스냅샷/DDL에 포함하지 않았다. 따라서 이 항목은 엄밀히는 부분 충족 상태로 본다.
- [x] `AgentSettlementSnapshot``agent_settlement_snapshot` DDL에 `assignmentId`, `agentSettlementRatioId` 컬럼을 추가한다.
- [x] `AgentCalculateQueryRepository`와 snapshot 생성용 query DTO에 거래 시점 기준 `assignmentId`, `agentSettlementRatioId` projection을 추가한다.
- [x] `AdminAgentSettlementSnapshotService`와 관련 테스트를 갱신해 finalize 시점에 foreign key + 적용 비율 + 계산 숫자값이 함께 저장되도록 보완한다.
- 참고: 위 보완은 creator-period summary가 단일 소속/단일 비율 이력 row로 귀결되는 경우의 추적성은 복구하지만, 기간 중 복수 history row가 섞인 summary의 완전 provenance까지 보장하지는 않는다. 그 수준의 감사 추적이 필요하면 별도 snapshot source detail 테이블이 추가로 필요하다.
- [x] `agent_settlement_snapshot_source_detail`(가칭) DDL을 추가해 summary를 구성한 원천 source row별 provenance를 별도 저장한다.
- [x] finalize가 `raw source row`를 기준으로 source detail과 creator-period summary를 같은 트랜잭션 안에서 함께 저장하도록 보강한다.
- [x] source detail이 1건인 summary만 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`를 채우고, mixed-period summary는 `null`로 유지하는 규칙을 테스트로 고정한다.
- [x] finalized 기간 조회는 스냅샷 우선, 미확정 기간 조회는 live 계산을 사용하도록 분기한다.
- [x] 신규 이력/스냅샷 구조에 맞는 DDL 문서를 추가 또는 기존 DDL 문서를 확장한다.
- [x] 기존 계획 문서 하단 검증 기록에 이력형 전환과 스냅샷 도입 구현/검증 결과를 누적한다.
## 세부 구현 메모
### 1. 소속 관계 구현 기준
- 기존 `Member` 엔티티에 agent 필드를 직접 추가하지 않고, 전용 relation 테이블로 구현한다.
- 이유는 다음과 같다.
- 에이전트 전용 기능을 `partner.agent` 패키지에 응집시킬 수 있다.
- creator role 제약과 unique 제약을 명확히 걸 수 있다.
- 향후 소속 이력/상태 필드가 필요해져도 확장이 쉽다.
### 2. 정산 비율 구현 기준
- 기존 `CreatorSettlementRatio`가 도메인별 다중 비율을 관리하므로, 에이전트는 별도 `AgentSettlementRatio`로 분리하는 편이 자연스럽다.
- 필드는 단일 `settlementRatio`만 두고, 대상 회원은 `MemberRole.AGENT`로 제한한다.
### 3. 조회 응답 구현 기준
- 라이브/콘텐츠/커뮤니티의 기존 관리자 creator별 응답은 count/total 객체가 부족하므로 재사용보다 agent 전용 응답 신설이 적합하다.
- 채널후원 응답은 이미 `total + items` 구조가 있어 이를 가장 가까운 기준으로 삼는다.
- 에이전트 응답은 아래 공통 필드를 기준으로 통일한다.
- `creatorId`
- `creatorNickname`
- `count`
- `totalCan`
- `krw`
- `fee`
- `settlementAmount`
- `agentSettlementAmount`
- 필요 시 `tax`, `depositAmount`
### 4. 재사용 기준 파일
- 권한/인증 패턴: `AdminMemberController`, `CreatorAdminCalculateController`, `SecurityConfig`
- creator별 집계 패턴: `AdminCalculateQueryRepository`
- 채널후원 total 응답 패턴: `AdminChannelDonationCalculateService`, `GetAdminChannelDonationSettlementTotal`
- 정산 비율 검증 패턴: `CreatorSettlementRatioService`
### 5. 후속 보완 구현 순서 (2026-04-09 추가)
- 아래 보완 작업은 **ratio 입력 무결성 차단 → 관리자 쓰기 직렬화/DDL 보강 → snapshot traceability 연결 → 전체 검증** 순서로 진행한다.
#### 5-1. ratio 입력 무결성 차단부터 먼저 수정한다.
- [x] `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt`에 아래 RED 테스트를 추가한다.
- [x] active row보다 과거 `effectiveFrom`으로 create/update 요청 시 예외가 발생한다.
- [x] active row와 같은 `effectiveFrom`으로 create/update 요청 시 예외가 발생한다.
- [x] active row가 없더라도 기존 closed history와 겹치는 `effectiveFrom`이면 예외가 발생한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`에서 `effectiveFrom` backdate / same-time / history overlap을 거절하도록 검증을 추가한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt`에 history overlap 판별용 조회 메서드를 추가한다.
- [x] ratio 서비스 테스트를 재실행해 backdate 차단이 먼저 보장되는지 확인한다.
#### 5-2. 관리자 소속/비율 쓰기 경로를 직렬화한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`에서 agent 대상 `MemberRepository.findByIdForUpdate(...)`를 사용하도록 수정한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt`에서 creator 대상 `MemberRepository.findByIdForUpdate(...)`를 사용하도록 수정한다.
- [x] 두 서비스 모두 `saveAndFlush` + `DataIntegrityViolationException` 대응 패턴으로 unique violation을 사용자 예외로 변환하도록 보강한다.
- [x] `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt`에 락/unique violation 대응 케이스를 추가한다.
#### 5-3. active row 단일성과 기간 무결성을 DDL/엔티티에 맞춘다.
- [x] `docs/20260409_partner_agent_assignment_ratio_ddl.sql` 기준으로 `agent_creator_relation`, `agent_settlement_ratio`, `agent_settlement_snapshot` 최종 스키마가 구현 코드와 일치하는지 다시 점검한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt`의 매핑이 DDL의 최종 컬럼 구조와 충돌하지 않는지 확인한다.
- [x] generated column(`active_creator_key`, `active_ratio_key`)은 JPA 쓰기 대상에서 제외하고, 서비스/리포지토리 로직이 해당 컬럼 없이도 동작하는지 확인한다.
#### 5-4. snapshot traceability를 query → service → entity 순서로 연결한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt``assignmentId`, `agentSettlementRatioId` 필드를 추가한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`와 snapshot 생성용 query DTO에 거래 시점 기준 `assignmentId`, `agentSettlementRatioId` projection을 추가한다.
- [x] `docs/20260409_partner_agent_assignment_ratio_ddl.sql``agent_settlement_snapshot_source_detail`(가칭) 테이블을 추가하고, `snapshot_id`, `assignment_id`, `agent_settlement_ratio_id`, source subtotal 컬럼, 조회 인덱스/FK를 정의한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/**`에 source detail 엔티티/리포지토리 파일을 추가한다.
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt``raw source row` 기준의 source detail과 creator-period summary를 함께 저장하도록 바꾼다.
- [x] creator-period summary가 단일 source row로 귀결될 때만 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`를 summary에 채우고, mixed-period summary는 `null`로 저장하도록 매핑 규칙을 고정한다.
- [x] `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt`에 아래 검증을 추가한다.
- [x] 단일 source summary는 summary row와 detail row가 같은 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`를 가진다.
- [x] mixed-period summary는 summary의 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio``null`이고, detail row 여러 건으로 provenance를 복원할 수 있다.
- [x] detail 합계가 summary 숫자값과 일치한다.
#### 5-5. 최종 회귀 검증을 수행한다.
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*"`
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.*"`
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*"`
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"`
- [x] `./gradlew test`
- [x] `./gradlew build`
## 검증 계획
- 단위/서비스 테스트
- agent와 creator role 검증
- creator 중복 소속 방지
- 비소속 creator 조회 차단
- agentSettlementAmount 반올림 계산 검증
- category별 total 합계 검증
- 통합 성격 검증
- ADMIN assignment API 정상/실패 케이스
- AGENT 목록/정산 조회 API 정상/권한 실패 케이스
- 빌드 검증
- `./gradlew test`
- `./gradlew build`
---
## 검증 기록
### 계획 수립 1차
- 무엇을: 에이전트 권한, 소속 관계, 관리자 소속 관리 API, 에이전트 정산 비율, 에이전트 전용 크리에이터별 정산 조회 기능의 구현 범위를 분석하고 작업 계획 문서를 작성했다.
- 왜: 기존 코드베이스에 이미 존재하는 `AGENT` 역할, 관리자 정산 모듈, 크리에이터 정산 비율 모듈을 기준으로 중복 구현 없이 가장 자연스러운 확장 경로를 먼저 확정하기 위해서다.
- 어떻게:
- 권한/역할/정산 패턴을 코드 기준으로 확인했다.
- `docs` 폴더의 기존 작업 계획 문서 형식을 확인해 동일한 형식으로 문서를 작성했다.
- 실행/확인 결과:
- `explore` 백그라운드 탐색 2건(권한 패턴, 정산 패턴) → 완료
- `read`로 확인한 기준 파일: `Member.kt`, `AdminMemberController.kt`, `AdminMemberService.kt`, `SecurityConfig.kt`, `AdminCalculateController.kt`, `AdminCalculateService.kt`, `AdminCalculateQueryRepository.kt`, `CreatorAdminCalculateController.kt`, `CreatorAdminCalculateService.kt`, `AdminChannelDonationCalculateController.kt`, `AdminChannelDonationCalculateService.kt`, `ChannelDonationSettlementCalculator.kt`
- `./gradlew test` / `./gradlew build` → 문서 작성 단계이므로 미실행
### 1차 구현 1차
- 무엇을: 공유 도메인인 `partner.agent.assignment`, `partner.agent.ratio`와 관리자 진입점인 `admin.partner.agent.assignment`, `admin.partner.agent.ratio` 패키지에 관리자 전용 assignment/remove, ratio create/update/list 기능과 테스트, 신규 테이블 DDL 문서를 추가했다.
- 왜: 전체 에이전트 기능 중 첫 vertical slice로서 관리자 관점의 소속 관리와 정산 비율 관리부터 독립적으로 배포 가능한 최소 단위를 먼저 완성하기 위해서다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/**`에 relation 엔티티/리포지토리/요청 DTO를 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/**`에 관리자 전용 서비스/컨트롤러를 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/**`에 ratio 엔티티/리포지토리(querydsl)/요청·응답 DTO를 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/**`에 관리자 전용 서비스/컨트롤러를 추가했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`에 assignment/ratio 서비스·컨트롤러 테스트를 추가하고 TDD 순서로 RED→GREEN을 확인했다.
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql``agent_creator_relation`, `agent_settlement_ratio` 생성 스크립트를 추가했다.
- 실행/확인 결과:
- `lsp_diagnostics` on `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent` → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.assignment.*"` → 1차 실행 실패(신규 클래스 unresolved reference), 구현 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.ratio.*"` → 1차 실행 실패(신규 클래스 unresolved reference), 구현 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.*"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
### 2차 구현 1차
- 무엇을: `partner.agent.calculate` 패키지 아래에 AGENT 전용 소속 크리에이터 목록 조회 API와 라이브/콘텐츠/커뮤니티/채널후원/콘텐츠후원 creator-level summary API, QueryRepository, 응답 DTO, 테스트를 추가했다.
- 왜: 에이전트 기능의 두 번째 vertical slice로서 실제 에이전트 계정이 본인에게 배정된 크리에이터 범위 안에서만 정산 현황을 볼 수 있도록 기능을 완성하기 위해서다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/**``AgentCalculateController`, `AgentCalculateService`, `AgentCalculateQueryRepository`, assigned creator 응답 DTO, 일반 정산 summary DTO, 채널후원 summary DTO를 추가했다.
- AGENT 인증 패턴은 `@PreAuthorize("hasRole('AGENT')")``@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 가드절로 맞췄다.
- QueryRepository는 `agent_creator_relation` 조인으로 소속 크리에이터만 필터링하고, 콘텐츠 summary는 creator별 페이지를 유지하면서 콘텐츠별 정산 비율 버킷을 병합하도록 구현했다.
- `agentSettlementAmount`는 모든 응답에서 creator의 세전 `settlementAmount` 기준으로 `round(settlementAmount * agentRatio / 100)`를 적용했고, creator `settlementAmount`/`depositAmount` 자체는 차감하지 않았다.
- 스키마 변경은 없어서 추가 DDL 문서는 만들지 않았다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 1차 실행 실패(신규 클래스 unresolved reference), 구현 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.*"` → 성공
- `lsp_diagnostics` on `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate` → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
- `./gradlew test` → 성공
- `./gradlew build` → 최초 1회 `ktlintMainSourceSetCheck` 실패(신규 `AgentCalculateService.kt` 줄바꿈 규칙 위반), 포맷 수정 후 재실행 성공
### 3차 수정
- 무엇을: `partner.agent.assignment.*`, `partner.agent.ratio.*` 예외 키를 `SodaMessageSource`에 등록하고, 해당 메시지 조회를 보장하는 단위 테스트를 추가했다.
- 왜: 기능 자체는 동작하더라도 메시지 소스에 키가 없으면 런타임에서 사용자에게 의도한 다국어 문구 대신 키 문자열 또는 빈 메시지가 노출될 수 있기 때문이다.
- 어떻게:
- `src/test/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSourceTest.kt`를 추가해 `partner.agent.assignment.creator_already_assigned`, `partner.agent.ratio.invalid_agent`, `partner.agent.ratio.not_found` 메시지 조회를 RED→GREEN으로 검증했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt``partnerAgentMessages` 맵을 추가하고 `getMessage` 그룹 목록에 포함시켰다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.i18n.SodaMessageSourceTest"` → 1차 실행 실패(메시지 미등록), 수정 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.*"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:...kotlin-stdlib..."` 수동 확인 →
- `partner.agent.assignment.creator_already_assigned` 한국어 메시지 출력: `이미 다른 에이전트에 소속된 크리에이터입니다.`
- `GetAgentCreatorSettlementSummaryQueryData(21, creator-a, 2, 100, 70).toResponseItem(10)` 출력: `agentSettlementAmount=654`
- `GetAgentChannelDonationSettlementByCreatorQueryData(21, creator-a, 1, 50).toResponseItem(10)` 출력: `agentSettlementAmount=397`
### 4차 수정
- 무엇을: 관리자 전용 partner-agent controller/service 진입점을 `kr.co.vividnext.sodalive.admin.partner.agent.*` 아래로 재배치했다.
- 왜: 이 저장소의 기존 관례상 관리자 전용 진입점은 `admin.*` 계층에 두는 편이 더 일관적이고, 공유 도메인 객체와 관리자 API 진입점을 분리하는 것이 책임 경계를 더 명확하게 만들기 때문이다.
- 어떻게:
- `AdminAgentCreatorController`, `AdminAgentCreatorService``admin.partner.agent.assignment`로 이동하고, relation/request DTO/repository는 `partner.agent.assignment`에 유지했다.
- `AdminAgentSettlementRatioController`, `AdminAgentSettlementRatioService``admin.partner.agent.ratio`로 이동하고, ratio 엔티티/리포지토리/DTO는 `partner.agent.ratio`에 유지했다.
- assignment/ratio 테스트 패키지도 `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`로 이동했다.
- 계획 문서의 권장 설계/패키지 초안을 실제 구조에 맞게 갱신했다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*"` → 1차 실행 실패(새 패키지 unresolved reference), 이동 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.*"` → 1차 실행 실패(새 패키지 unresolved reference), 이동 후 재실행 성공
- `grep "kr.co.vividnext.sodalive.partner.agent.(assignment|ratio).AdminAgent" src/**/*.kt` 성격 확인 → 코드 기준 잔존 참조 없음
- `./gradlew test` → 성공
- `./gradlew build` → 성공
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:...kotlin-stdlib..."` 수동 확인 →
- `kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorController` 로딩 성공
- `kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioController` 로딩 성공
- 기존 `kr.co.vividnext.sodalive.partner.agent.assignment.AdminAgentCreatorController` / `...ratio.AdminAgentSettlementRatioController``ClassNotFoundException`으로 미존재 확인
### 5차 수정
- 무엇을: `agent_creator_relation``assignedAt/unassignedAt` 기반 append-only 이력 모델로 전환하고, 관리자 소속 지정/해제 API를 시간 경계 기반 계약으로 수정했다.
- 왜: 기존 current-state + hard-delete 구조로는 과거 소속 이력을 보존할 수 없고, 소속 해제 후 재배정 같은 운영 시나리오를 안전하게 표현할 수 없기 때문이다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt``assignedAt`, `unassignedAt`를 추가하고 `creator` 연관을 `ManyToOne`으로 변경해 동일 creator의 이력 row 누적을 허용했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt`, `RemoveAgentCreatorRequest.kt`에 명시적 시간 필드를 추가하고, `AdminAgentCreatorService.kt`에서 overlap 검증 및 hard-delete 대신 종료 시각 기록으로 동작을 바꿨다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`에는 현재 동작 유지용으로 `unassignedAt is null` 조건만 추가해 active assignment 조회가 계속 현재 row만 보도록 맞췄다.
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt`, `AdminAgentCreatorControllerTest.kt`를 TDD로 갱신해 RED에서 새 계약 부재를 확인한 뒤 GREEN으로 구현했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt`는 새 not-null `assignedAt` 계약에 맞게 relation fixture를 갱신했다.
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql``agent_creator_relation` 생성 스키마를 이력형 컬럼/인덱스로 바꾸고, 기존 테이블에 대한 `assigned_at`, `unassigned_at`, unique index 제거, backfill migration 블록을 추가했다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*"` → 1차 실행 실패(새 `assignedAt/unassignedAt`, repository 메서드, 엔티티 필드 부재), 구현 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.i18n.SodaMessageSourceTest"` → 성공
- `lsp_diagnostics` on modified Kotlin files/directories → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
- `./gradlew test``./gradlew build`를 병렬 실행 → 실패 (`build/test-results/test/*.xml` 동시 쓰기 충돌)
- `./gradlew test` 순차 재실행 → 성공
- `./gradlew build` 순차 재실행 → 성공
### 6차 수정
- 무엇을: `agent_settlement_ratio``effectiveFrom/effectiveTo` 기반 append-only 이력 모델로 전환하고, 관리자 비율 생성/수정/목록 API 계약을 유효 기간 노출 방식으로 갱신했다.
- 왜: 기존 `deletedAt` + 단일 current-row 갱신 방식으로는 과거 비율 이력을 보존할 수 없어서, 이후 거래 시점 기준 정산 조회나 운영 감사 시나리오에 필요한 근거 데이터를 남길 수 없기 때문이다.
- 어떻게:
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt`, `AdminAgentSettlementRatioControllerTest.kt`를 먼저 수정해 `effectiveFrom` 입력, `effectiveFrom/effectiveTo` 응답, 기존 활성 row 종료 + 신규 row 추가 동작을 RED로 만들었다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt``effectiveFrom/effectiveTo` 필드와 `close(...)` 메서드를 가진 이력 엔티티로 바꾸고, `member` 연관을 `ManyToOne`으로 변경해 동일 agent의 다중 이력 row를 허용했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/CreateAgentSettlementRatioRequest.kt`, `GetAgentSettlementRatioResponse.kt`, `AgentSettlementRatioRepository.kt`를 갱신해 요청/응답 계약과 active lookup 메서드 `findFirstByMemberIdAndEffectiveToIsNull(...)`를 도입했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`에서 create/update 모두 기존 활성 row를 `effectiveTo`로 닫은 뒤 새 row를 저장하도록 수정했고, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`는 현재 활성 비율 lookup만 새 repository 메서드로 맞췄다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt` fixture도 새 repository 메서드와 `effectiveFrom` 필수 생성자에 맞게 최소 호환 수정했다.
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에는 `agent_settlement_ratio` 생성 스키마를 `effective_from/effective_to` + history index 구조로 변경하고, 기존 테이블에 대한 컬럼 추가/backfill/unique index 제거/`deleted_at` 제거 migration 블록을 확장했다.
- 실행/확인 결과:
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → 1차 실행 실패(새 `effectiveFrom/effectiveTo` 계약, repository 메서드, 엔티티 필드 부재), 구현 후 재실행 성공
- `lsp_diagnostics` on modified Kotlin files/directories → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest` → 성공
- `./gradlew build` → 성공
### 7차 수정
- 무엇을: `AgentCalculateQueryRepository`, `AgentCalculateService`, agent calculate 응답용 query DTO와 테스트를 이벤트 시점 기준 소속/agent ratio 계산 방식으로 수정해, 기간 중 재배정과 agent 비율 변경이 있어도 다섯 개 정산 카테고리의 creator-level 결과가 당시 기준으로 집계되도록 바꿨다.
- 왜: 기존 구현은 거래 발생 시각으로 기간만 자르고, 소속은 현재 active relation, agent 비율은 현재 active ratio 한 개를 전체 기간에 적용하고 있어서 중간 재배정/비율 변경이 생기면 과거 조회 결과가 잘못 왜곡됐기 때문이다.
- 어떻게:
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt`에 기간 중 소속 변경, 기간 중 agent ratio 변경을 재현하는 통합 성격 테스트를 먼저 추가해 RED를 확인했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`에서 라이브/콘텐츠/커뮤니티/채널후원/콘텐츠후원 모두에 대해 거래 시각 기준 `assignedAt <= eventTime < unassignedAt`, `effectiveFrom <= eventTime < effectiveTo` 조건으로 이력 row를 조인하도록 수정했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt`, `GetAgentChannelDonationSettlementByCreatorResponse.kt`, `AgentCalculateService.kt`를 바꿔 row별 agent ratio를 응답 아이템 변환 시 적용하고, 채널후원 포함 전 카테고리에서 creator 기준 merge 후 total을 계산하도록 정리했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`도 row별 agent ratio 기대값으로 갱신해 서비스 레벨 병합 규칙을 검증했다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest"` → 1차 실행 실패(기간 중 소속 변경/agent ratio 변경 2건 assertion failure), 구현 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 성공
- `lsp_diagnostics` on modified Kotlin files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
- `./gradlew test` → 성공
- `./gradlew build` → 1차 `ktlintTestSourceSetCheck` 실패(신규 테스트 장문 라인), 2차 `ktlintMainSourceSetCheck` 실패(import 순서/장문 line), 포맷 수정 후 재실행 성공
### 8차 수정
- 무엇을: `partner.agent.settlement.snapshot` 패키지에 immutable creator-level snapshot 저장 모델과 repository/request-response mapper를 추가하고, `admin.partner.agent.settlement`에 확정 API를 만들었으며, `AgentCalculateService`가 finalized 기간이면 스냅샷을 우선 읽도록 다섯 카테고리 전체를 연결했다.
- 왜: 이력형 소속/비율 계산만으로는 확정 시점의 creator-level 결과를 별도 보존하거나 재사용할 수 없어서, 이후 읽기에서 동일 기간을 다시 계산하지 않고도 확정된 숫자값을 안정적으로 반환할 수 있어야 했기 때문이다.
- 어떻게:
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt`, `AdminAgentSettlementSnapshotControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`에 snapshot 생성/idempotency/finalized snapshot-first read RED 테스트를 먼저 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/**``AgentSettlementSnapshot`, `AgentSettlementSnapshotRepository`, `FinalizeAgentSettlementSnapshotRequest/Response`, snapshot-to-response mapper를 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt`에서 기존 `AgentCalculateQueryRepository` live 계산 결과를 creator-level 응답으로 병합한 뒤 숫자값을 그대로 스냅샷 row에 저장하고, 동일 기간/타입/agent 조합은 `exists...` 검사로 idempotent하게 막도록 구현했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt``POST /admin/partner/agent/settlement/finalize`를 추가하고, 인증 관리자 `member.id``finalizedByMemberId`로 전달하도록 맞췄다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`는 라이브 계산 전에 스냅샷 repository를 먼저 조회하고, 스냅샷이 있으면 generic 4종과 channel donation 1종 모두 동일 응답 DTO로 변환해 반환하도록 분기했다.
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql``agent_settlement_snapshot` 테이블과 lookup/unique 인덱스 DDL을 추가했다.
- 실행/확인 결과:
- `lsp_diagnostics` on `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate`, `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement` → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest"` → 1차 실행 실패(신규 snapshot 도메인/서비스/분기 미구현), 구현 및 테스트 fixture 수정 후 재실행 성공
- `./gradlew test` → 성공
- `./gradlew build` → 1차 `ktlintTestSourceSetCheck` 실패(import 순서/unused import), 2차 `ktlintMainSourceSetCheck` 실패(import 순서/장문 line), 포맷 수정 후 재실행 성공
### 9차 수정
- 무엇을: 최종 정리 과정에서 `AgentCalculateService`의 request-wide current ratio 의존성을 제거하고, 관련 테스트/빌드/수동 확인까지 다시 수행했다.
- 왜: 시점 기준 정산 조회와 finalized snapshot-first read로 전환된 뒤에는 `ratioRepository`가 더 이상 `AgentCalculateService`에서 직접 사용되지 않으므로, 잔존 의존성을 남기지 않는 것이 실제 설계와 코드 구조를 일치시키기 때문이다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`에서 사용되지 않는 `ratioRepository` 의존성을 제거했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`, `AgentCalculateQueryRepositoryTest.kt`의 생성자 호출부를 새 시그니처에 맞게 정리했다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*"` → 1차 실행 실패(`AgentCalculateQueryRepositoryTest`의 구 생성자 시그니처 참조), 수정 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*"` → 성공
- `./gradlew test && ./gradlew build` → 성공
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:...kotlin-stdlib..."` 수동 확인 →
- `FinalizeAgentSettlementSnapshotRequest(agentId=7, settlementType=LIVE, startDateStr=2026-02-20, endDateStr=2026-02-21).toDateRange()` 출력 확인
- `AgentSettlementSnapshot` 1건을 `toSettlementByCreatorItems()`로 변환했을 때 `agentSettlementAmount=654` 포함 응답 아이템 출력 확인
- `kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotController` 클래스 로딩 성공
### 10차 정정
- 무엇을: 체크리스트 279번의 충족 범위를 문서/코드 기준으로 다시 대조하고, 누락된 후속 구현 항목을 기존 계획 문서에 추가했다.
- 왜: 현재 스냅샷 구현은 `appliedAgentSettlementRatio`와 계산 숫자값은 저장하지만, 설계 초안과 체크리스트 문맥이 요구하는 `assignmentId`, `agentSettlementRatioId`는 저장하지 않아 문서 기준 완전 충족으로 보기 어려웠기 때문이다.
- 어떻게:
- 체크리스트 279 바로 아래에 정정 메모와 후속 체크박스 3개를 추가해 누락 범위를 `snapshot 컬럼`, `query projection`, `finalize 매핑/테스트`로 분리했다.
- 문서 설계 초안(`assignmentId`, `agentSettlementRatioId`)과 현재 구현(`AgentSettlementSnapshot`, `AdminAgentSettlementSnapshotService`, `agent_settlement_snapshot` DDL)을 대조해 차이를 명시했다.
- 실행/확인 결과:
- `docs/20260408_에이전트권한및정산기능추가.md:106-123, 278-282` 확인 → 스냅샷 설계 초안과 체크리스트가 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`, 계산 숫자값 저장을 함께 기대함을 재확인
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt` 확인 → 현재는 `appliedAgentSettlementRatio`와 계산 숫자값만 저장하고 `assignmentId`, `agentSettlementRatioId` 필드는 없음
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt` 확인 → finalize 매핑이 ratio 값과 숫자값만 채우고 FK 값은 생성하지 않음
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql` 확인 → `agent_settlement_snapshot` 테이블에도 `assignment_id`, `agent_settlement_ratio_id` 컬럼이 없음
### 11차 정정
- 무엇을: ratio backdate 금지, active row 단일성 보장, 관리자 쓰기 직렬화, snapshot traceability 범위 메모를 기존 체크리스트에 후속 작업으로 추가했다.
- 왜: 현재 `AdminAgentSettlementRatioService``effectiveFrom`의 시간순 검증 없이 활성 row를 닫고 새 row를 저장해 backdate 시 interval 무결성이 깨질 수 있고, `agent_settlement_ratio`/`agent_creator_relation` DDL은 active row 중복을 막는 DB 제약이 없어 동시 요청 시 조회 결과 왜곡 위험이 있기 때문이다. 또한 snapshot의 `assignmentId`/`agentSettlementRatioId` 보완은 문서 279의 의도는 충족하지만 mixed-period creator summary의 완전 provenance까지는 아님을 분명히 할 필요가 있었다.
- 어떻게:
- 체크리스트 ratio 구간에 `effectiveFrom` backdate/same-time/overlap 거절 검증, `agent_settlement_ratio`/`agent_creator_relation`의 MySQL 생성 컬럼 + UNIQUE 인덱스 + 기간 무결성 제약, `MemberRepository.findByIdForUpdate(...)` 기반 비관적 락 적용 항목을 추가했다.
- snapshot 279 보완 항목 아래에 `assignmentId`/`agentSettlementRatioId` 추가가 creator-period summary 기준 추적성은 복구하지만, mixed-period summary의 완전 provenance가 필요하면 별도 source detail 테이블이 필요하다는 참고 메모를 추가했다.
- 실행/확인 결과:
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt` 확인 → `effectiveFrom`의 시간순/겹침 검증 없이 active row close + insert 수행
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt` 확인 → overlap 검증은 있지만 비관적 락/DB active uniqueness 없이 read-then-write 수행
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`, `member/contentpreference/MemberContentPreferenceService.kt`, `member/contentpreference/MemberContentPreferenceRepository.kt` 확인 → `findByIdForUpdate`, `@Lock(PESSIMISTIC_WRITE)`, `saveAndFlush + DataIntegrityViolationException` 재조회 패턴이 저장소 기존 관례로 존재함
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql` 확인 → 현재 `agent_settlement_ratio`, `agent_creator_relation` 모두 active row 단일성 보장용 unique 제약과 기간 무결성 제약이 없음
- MySQL 제약 조사 결과 확인 → partial unique index 대신 생성 컬럼 + UNIQUE 인덱스가 가장 실용적이며, nullable unique를 직접 사용하는 방식은 active row 중복을 막지 못함
### 12차 정정
- 무엇을: 남은 후속 수정 범위를 실제 구현 순서대로 더 잘게 쪼갠 작업 계획으로 재배치했다.
- 왜: 현재 체크리스트는 해야 할 항목은 보이지만, 어떤 순서로 진행해야 리스크가 가장 적은지와 어떤 파일부터 수정해야 하는지가 한 번에 드러나지 않았기 때문이다.
- 어떻게:
- `세부 구현 메모` 아래에 `### 5. 후속 보완 구현 순서 (2026-04-09 추가)` 섹션을 새로 만들고, 작업을 `ratio 입력 무결성 차단 → 관리자 쓰기 직렬화 → DDL/엔티티 정합성 점검 → snapshot traceability 연결 → 최종 회귀 검증` 순서로 나눴다.
- 각 단계마다 실제 수정 대상 파일 경로와 테스트/검증 명령을 체크박스로 세분화해, 바로 실행 가능한 작업 순서 문서가 되도록 정리했다.
- 실행/확인 결과:
- `docs/20260408_에이전트권한및정산기능추가.md:326-361` 확인 → 후속 보완 구현 순서 섹션과 5개 단계 체크리스트가 문서에 반영됨
- `grep` 확인 → `### 5. 후속 보완 구현 순서`, `MemberRepository.findByIdForUpdate(...)`, `assignmentId`, `agentSettlementRatioId`, `./gradlew test`, `./gradlew build`가 새 순서형 계획에 포함됨
- 코드/빌드 실행 여부 → 문서 수정 단계이므로 미실행
### 13차 정정
- 무엇을: snapshot traceability 문제를 `summary FK 보완 + full audit provenance detail`까지 포함해 닫는 방향으로 체크리스트를 확장했다.
- 왜: 현재 creator-period summary snapshot은 `creatorId` 기준 병합 후 한 줄로 저장되므로, `assignmentId`/`agentSettlementRatioId`만 summary에 추가해도 mixed-period 구간의 원천 provenance는 완전히 복원되지 않기 때문이다. 완전 감사 추적이 필요하면 summary와 별도로 source detail row를 함께 저장해야 한다.
- 어떻게:
- snapshot 체크리스트 아래에 `agent_settlement_snapshot_source_detail`(가칭) DDL 추가, finalize의 `raw source row -> source detail 저장 -> summary 저장` 순서 보강, mixed-period summary null 규칙 테스트 고정 항목을 추가했다.
- `세부 구현 메모 > 5-4`를 확장해 DDL → detail entity/repository → finalize 저장 순서 → summary null 규칙 → detail-summary 합계 검증까지 실제 구현 순서대로 재배치했다.
- 실행/확인 결과:
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt` 확인 → 현재는 `rows.toMergedResponseItems()`로 creator-period summary만 저장하고 source detail 저장 단계는 없음
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt`, `GetAgentChannelDonationSettlementByCreatorResponse.kt` 확인 → `creatorId` 기준 병합 구조가 mixed-period provenance 소실의 직접 원인임
- `docs/20260408_에이전트권한및정산기능추가.md:106-123` 확인 → 문서 설계 초안은 summary FK/숫자 스냅샷까지는 기대하지만, full audit provenance는 별도 설계가 필요함
- Oracle/탐색 결과 확인 → `assignmentId + agentSettlementRatioId` summary 보완은 부분 해결이고, `source detail`까지 포함해야 mixed-period provenance 공백이 닫힘
### 14차 구현 및 정리
- 무엇을: 문서에서 미체크로 남아 있던 ratio/assignment 무결성, 쓰기 직렬화, snapshot traceability/source detail provenance, 최종 검증 항목을 실제 구현 결과에 맞게 모두 완료 처리하고, 검증 기록을 최신 상태로 갱신했다.
- 왜: 체크리스트와 실제 코드 상태가 어긋나 있으면 이후 작업자가 범위를 잘못 이해하거나, 이미 끝난 작업을 다시 추적해야 하는 비용이 생기기 때문이다. 또한 이번 변경은 정산 무결성과 확정 스냅샷 provenance를 함께 건드렸기 때문에, 최신 검증 결과를 문서에 남겨두는 것이 필수였다.
- 어떻게:
- `AdminAgentSettlementRatioService`, `AdminAgentCreatorService`, `AgentSettlementSnapshot`, `AdminAgentSettlementSnapshotService`, `AgentCalculateQueryRepository`, `docs/20260409_partner_agent_assignment_ratio_ddl.sql`을 기준으로 미체크 항목을 다시 대조하고, 실제 구현된 항목은 모두 `- [x]`로 갱신했다.
- ratio 쪽에는 `effectiveFrom` backdate/same-time/history overlap 차단과 concurrent unique violation 변환 테스트를 추가했고, assignment 쪽에는 `findByIdForUpdate(...)` + `saveAndFlush` + `DataIntegrityViolationException` 대응 패턴과 테스트를 맞췄다.
- snapshot 쪽에는 `assignmentId`, `agentSettlementRatioId`, `agent_settlement_snapshot_source_detail` provenance 저장, mixed-period summary null 규칙, detail 합계 검증까지 반영했다.
- 실행/확인 결과:
- `grep "^- \[ \]|^ - \[ \]" docs/20260408_에이전트권한및정산기능추가.md` → 미체크 항목 0건 확인
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest"` → 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest"` → 성공
- `./gradlew test` → 성공
- `./gradlew build` → 성공
- `Oracle` completion review → 현재 문서 기준 미체크 항목 판단을 검토한 뒤, 테스트/문서 정리를 마치면 완료 처리 가능하다는 방향 재확인
### 15차 수정
- 무엇을: `agent_settlement_ratio` 이력이 없는 AGENT 정산 조회에서 agent 정산금을 0%가 아니라 10% 기본값으로 계산하도록 수정하고, 해당 동작을 서비스 테스트와 수동 계산으로 검증했다.
- 왜: 현재 구현은 agent 비율 row가 없으면 agent 정산금이 0원으로 계산되는데, 이번 정책 결정은 미설정 상태를 10% 기본 비율로 간주하는 것이기 때문이다.
- 어떻게:
- `docs/20260408_에이전트권한및정산기능추가.md` 체크리스트에 기본 fallback 10% 변경 항목을 추가한 뒤 완료 처리했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt``agentSettlementRatio = null`일 때 일반 정산/채널후원 응답이 10%를 적용하는 RED 테스트 2건을 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentChannelDonationSettlementByCreatorResponse.kt`에서 null fallback을 `DEFAULT_AGENT_SETTLEMENT_RATIO = 10`으로 변경했다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest.shouldApplyDefaultAgentSettlementRatioWhenAgentRatioHistoryDoesNotExist" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest.shouldApplyDefaultAgentSettlementRatioToChannelDonationWhenAgentRatioHistoryDoesNotExist"` → 1차 실행 실패, 수정 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 성공
- `./gradlew build` → 성공
- `lsp_diagnostics` on Kotlin changed files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:..."` 수동 확인 → `genericAgentSettlementAmount=131`, `channelAgentSettlementAmount=397`
### 16차 수정
- 무엇을: 관리자 에이전트 정산 비율 생성/수정 API에 `settlementRatio` 0..100 범위 검증을 추가하고, 문서 체크리스트 미완료 항목을 실제 구현 상태로 정리했다.
- 왜: 계획 문서에는 범위 검증 항목이 남아 있었지만 실제 서비스에는 guard가 없어 음수나 100 초과 비율이 저장될 수 있었기 때문이다.
- 어떻게:
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt``settlementRatio = -1` 생성, `settlementRatio = 101` 수정 요청이 `common.error.invalid_request`를 던지고 `memberRepository`/`repository`가 호출되지 않는다는 RED 테스트 2건을 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt``validateSettlementRatio(settlementRatio: Int)`를 추가하고 `createAgentSettlementRatio`, `updateAgentSettlementRatio` 진입부에서 0..100 범위를 먼저 검증하도록 수정했다.
- `docs/20260408_에이전트권한및정산기능추가.md`의 체크리스트 277 항목을 완료 처리했다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest.shouldThrowWhenCreatingRatioWithSettlementRatioBelowZero" --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest.shouldThrowWhenUpdatingRatioWithSettlementRatioAboveHundred"` → 1차 실행 실패, 서비스 수정 후 재실행 성공
- `./gradlew build` → 성공
- `jshell --class-path "/Users/klaus/Develop/sodalive/Server/sodalive/build/classes/kotlin/main:/Users/klaus/Develop/sodalive/Server/sodalive/build/resources/main:..."` 수동 확인 → `messageKey=common.error.invalid_request`, `memberRepositoryCalls=0`, `repositoryCalls=0`
- `lsp_diagnostics` on Kotlin changed files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
### 17차 수정
- 무엇을: `creator/list` 현재 소속 판정을 현재 시각 활성 구간 기준으로 바로잡고, AGENT 정산 조회의 빈 페이지가 전체 결과로 새는 pagination 버그를 함께 수정했다.
- 왜: 기존 구현은 미래 `assignedAt` 소속을 너무 일찍 노출하고 미래 `unassignedAt` 소속을 너무 일찍 숨겼으며, paged query의 사전 조회 `creatorIds`가 빈 리스트일 때 2차 rows query가 전체 결과를 다시 읽을 수 있었기 때문이다.
- 어떻게:
- `docs/20260408_에이전트권한및정산기능추가.md` 체크리스트에 두 버그 수정 항목을 추가한 뒤 완료 처리했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt`에 현재 시각 활성 구간 creator list 회귀 테스트와, 5개 카테고리 paged query가 빈 페이지에서 빈 rows를 반환하는 회귀 테스트를 RED로 추가했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`에서 `getAssignedCreators()``currentTime`을 한 번만 잡아 count/items 조회에 공유하도록 수정했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`에서 creator list 쿼리를 `assignedAt <= now < unassignedAt` 반열린 구간으로 바꾸고, 5개 paged calculate 메서드가 사전 조회 `creatorIds`가 빈 리스트면 즉시 `emptyList()`를 반환하도록 보강했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`는 변경된 repository 시그니처에 맞춰 현재 시각 인자를 검증하도록 갱신했다.
- 실행/확인 결과:
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldGetAssignedCreatorsOnlyWithinCurrentAssignmentWindow" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldReturnEmptyRowsWhenPagedCreatorSelectionIsEmptyAcrossAllCategories"` → 1차 실행 실패, 수정 후 재실행 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest"` → 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 성공
- `./gradlew build` → 성공
- `lsp_diagnostics` on changed Kotlin files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)

View File

@@ -0,0 +1,658 @@
SET @schema_name := DATABASE();
SET @agent_creator_relation_table_exists := (
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
);
SET @create_agent_creator_relation_table_sql := IF(
@agent_creator_relation_table_exists = 0,
'CREATE TABLE agent_creator_relation (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
agent_id BIGINT NOT NULL COMMENT ''에이전트 회원 ID (member.id 참조)'',
creator_id BIGINT NOT NULL COMMENT ''크리에이터 회원 ID (member.id 참조)'',
assigned_at TIMESTAMP NOT NULL COMMENT ''소속 시작 시각'',
unassigned_at TIMESTAMP NULL DEFAULT NULL COMMENT ''소속 종료 시각(NULL이면 현재 활성 소속)'',
active_creator_key TINYINT GENERATED ALWAYS AS (
CASE
WHEN unassigned_at IS NULL THEN 1
ELSE NULL
END
) STORED,
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
PRIMARY KEY (id),
UNIQUE KEY uk_agent_creator_relation_creator_active (creator_id, active_creator_key),
KEY idx_agent_creator_relation_agent_id (agent_id),
KEY idx_agent_creator_relation_creator_id (creator_id),
KEY idx_agent_creator_relation_agent_unassigned_at (agent_id, unassigned_at),
KEY idx_agent_creator_relation_creator_assigned_at (creator_id, assigned_at),
KEY idx_agent_creator_relation_creator_unassigned_at (creator_id, unassigned_at),
CONSTRAINT fk_agent_creator_relation_agent_id FOREIGN KEY (agent_id) REFERENCES member (id),
CONSTRAINT fk_agent_creator_relation_creator_id FOREIGN KEY (creator_id) REFERENCES member (id),
CONSTRAINT chk_agent_creator_relation_period CHECK (unassigned_at IS NULL OR assigned_at < unassigned_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트-크리에이터 소속 관계''',
'SELECT ''agent_creator_relation already exists'' AS message'
);
PREPARE create_agent_creator_relation_table_stmt FROM @create_agent_creator_relation_table_sql;
EXECUTE create_agent_creator_relation_table_stmt;
DEALLOCATE PREPARE create_agent_creator_relation_table_stmt;
SET @agent_creator_relation_has_assigned_at := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND column_name = 'assigned_at'
);
SET @alter_agent_creator_relation_add_assigned_at_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_has_assigned_at = 0,
'ALTER TABLE agent_creator_relation ADD COLUMN assigned_at TIMESTAMP NOT NULL COMMENT ''소속 시작 시각'' AFTER creator_id',
'SELECT ''agent_creator_relation.assigned_at already exists'' AS message'
);
PREPARE alter_agent_creator_relation_add_assigned_at_stmt FROM @alter_agent_creator_relation_add_assigned_at_sql;
EXECUTE alter_agent_creator_relation_add_assigned_at_stmt;
DEALLOCATE PREPARE alter_agent_creator_relation_add_assigned_at_stmt;
SET @agent_creator_relation_has_unassigned_at := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND column_name = 'unassigned_at'
);
SET @alter_agent_creator_relation_add_unassigned_at_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_has_unassigned_at = 0,
'ALTER TABLE agent_creator_relation ADD COLUMN unassigned_at TIMESTAMP NULL DEFAULT NULL COMMENT ''소속 종료 시각(NULL이면 현재 활성 소속)'' AFTER assigned_at',
'SELECT ''agent_creator_relation.unassigned_at already exists'' AS message'
);
PREPARE alter_agent_creator_relation_add_unassigned_at_stmt FROM @alter_agent_creator_relation_add_unassigned_at_sql;
EXECUTE alter_agent_creator_relation_add_unassigned_at_stmt;
DEALLOCATE PREPARE alter_agent_creator_relation_add_unassigned_at_stmt;
SET @agent_creator_relation_has_active_creator_key := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND column_name = 'active_creator_key'
);
SET @alter_agent_creator_relation_add_active_creator_key_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_has_active_creator_key = 0,
'ALTER TABLE agent_creator_relation ADD COLUMN active_creator_key TINYINT GENERATED ALWAYS AS (CASE WHEN unassigned_at IS NULL THEN 1 ELSE NULL END) STORED AFTER unassigned_at',
'SELECT ''agent_creator_relation.active_creator_key already exists'' AS message'
);
PREPARE alter_agent_creator_relation_add_active_creator_key_stmt FROM @alter_agent_creator_relation_add_active_creator_key_sql;
EXECUTE alter_agent_creator_relation_add_active_creator_key_stmt;
DEALLOCATE PREPARE alter_agent_creator_relation_add_active_creator_key_stmt;
SET @agent_creator_relation_creator_unique_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND index_name = 'uk_agent_creator_relation_creator_id'
);
SET @drop_agent_creator_relation_creator_unique_sql := IF(
@agent_creator_relation_creator_unique_exists > 0,
'ALTER TABLE agent_creator_relation DROP INDEX uk_agent_creator_relation_creator_id',
'SELECT ''uk_agent_creator_relation_creator_id already dropped'' AS message'
);
PREPARE drop_agent_creator_relation_creator_unique_stmt FROM @drop_agent_creator_relation_creator_unique_sql;
EXECUTE drop_agent_creator_relation_creator_unique_stmt;
DEALLOCATE PREPARE drop_agent_creator_relation_creator_unique_stmt;
SET @agent_creator_relation_creator_active_unique_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND index_name = 'uk_agent_creator_relation_creator_active'
);
SET @add_agent_creator_relation_creator_active_unique_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_creator_active_unique_exists = 0,
'ALTER TABLE agent_creator_relation ADD UNIQUE INDEX uk_agent_creator_relation_creator_active (creator_id, active_creator_key)',
'SELECT ''uk_agent_creator_relation_creator_active already exists'' AS message'
);
PREPARE add_agent_creator_relation_creator_active_unique_stmt FROM @add_agent_creator_relation_creator_active_unique_sql;
EXECUTE add_agent_creator_relation_creator_active_unique_stmt;
DEALLOCATE PREPARE add_agent_creator_relation_creator_active_unique_stmt;
SET @agent_creator_relation_period_check_exists := (
SELECT COUNT(1)
FROM information_schema.table_constraints
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND constraint_name = 'chk_agent_creator_relation_period'
AND constraint_type = 'CHECK'
);
SET @add_agent_creator_relation_period_check_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_period_check_exists = 0,
'ALTER TABLE agent_creator_relation ADD CONSTRAINT chk_agent_creator_relation_period CHECK (unassigned_at IS NULL OR assigned_at < unassigned_at)',
'SELECT ''chk_agent_creator_relation_period already exists'' AS message'
);
PREPARE add_agent_creator_relation_period_check_stmt FROM @add_agent_creator_relation_period_check_sql;
EXECUTE add_agent_creator_relation_period_check_stmt;
DEALLOCATE PREPARE add_agent_creator_relation_period_check_stmt;
SET @agent_creator_relation_agent_unassigned_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND index_name = 'idx_agent_creator_relation_agent_unassigned_at'
);
SET @add_agent_creator_relation_agent_unassigned_index_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_agent_unassigned_index_exists = 0,
'ALTER TABLE agent_creator_relation ADD INDEX idx_agent_creator_relation_agent_unassigned_at (agent_id, unassigned_at)',
'SELECT ''idx_agent_creator_relation_agent_unassigned_at already exists'' AS message'
);
PREPARE add_agent_creator_relation_agent_unassigned_index_stmt FROM @add_agent_creator_relation_agent_unassigned_index_sql;
EXECUTE add_agent_creator_relation_agent_unassigned_index_stmt;
DEALLOCATE PREPARE add_agent_creator_relation_agent_unassigned_index_stmt;
SET @agent_creator_relation_creator_assigned_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND index_name = 'idx_agent_creator_relation_creator_assigned_at'
);
SET @add_agent_creator_relation_creator_assigned_index_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_creator_assigned_index_exists = 0,
'ALTER TABLE agent_creator_relation ADD INDEX idx_agent_creator_relation_creator_assigned_at (creator_id, assigned_at)',
'SELECT ''idx_agent_creator_relation_creator_assigned_at already exists'' AS message'
);
PREPARE add_agent_creator_relation_creator_assigned_index_stmt FROM @add_agent_creator_relation_creator_assigned_index_sql;
EXECUTE add_agent_creator_relation_creator_assigned_index_stmt;
DEALLOCATE PREPARE add_agent_creator_relation_creator_assigned_index_stmt;
SET @agent_creator_relation_creator_unassigned_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_creator_relation'
AND index_name = 'idx_agent_creator_relation_creator_unassigned_at'
);
SET @add_agent_creator_relation_creator_unassigned_index_sql := IF(
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_creator_unassigned_index_exists = 0,
'ALTER TABLE agent_creator_relation ADD INDEX idx_agent_creator_relation_creator_unassigned_at (creator_id, unassigned_at)',
'SELECT ''idx_agent_creator_relation_creator_unassigned_at already exists'' AS message'
);
PREPARE add_agent_creator_relation_creator_unassigned_index_stmt FROM @add_agent_creator_relation_creator_unassigned_index_sql;
EXECUTE add_agent_creator_relation_creator_unassigned_index_stmt;
DEALLOCATE PREPARE add_agent_creator_relation_creator_unassigned_index_stmt;
SET @agent_settlement_ratio_table_exists := (
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
);
SET @create_agent_settlement_ratio_table_sql := IF(
@agent_settlement_ratio_table_exists = 0,
'CREATE TABLE agent_settlement_ratio (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
member_id BIGINT NOT NULL COMMENT ''에이전트 회원 ID (member.id 참조)'',
settlement_ratio INT NOT NULL COMMENT ''에이전트 정산 비율(%)'',
effective_from TIMESTAMP NOT NULL COMMENT ''비율 시작 시각'',
effective_to TIMESTAMP NULL DEFAULT NULL COMMENT ''비율 종료 시각(NULL이면 현재 활성 비율)'',
active_ratio_key TINYINT GENERATED ALWAYS AS (
CASE
WHEN effective_to IS NULL THEN 1
ELSE NULL
END
) STORED,
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
PRIMARY KEY (id),
UNIQUE KEY uk_agent_settlement_ratio_member_active (member_id, active_ratio_key),
KEY idx_agent_settlement_ratio_member_effective_to (member_id, effective_to),
KEY idx_agent_settlement_ratio_member_effective_from (member_id, effective_from),
CONSTRAINT fk_agent_settlement_ratio_member_id FOREIGN KEY (member_id) REFERENCES member (id),
CONSTRAINT chk_agent_settlement_ratio_period CHECK (effective_to IS NULL OR effective_from < effective_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트 정산 비율''',
'SELECT ''agent_settlement_ratio already exists'' AS message'
);
PREPARE create_agent_settlement_ratio_table_stmt FROM @create_agent_settlement_ratio_table_sql;
EXECUTE create_agent_settlement_ratio_table_stmt;
DEALLOCATE PREPARE create_agent_settlement_ratio_table_stmt;
SET @agent_settlement_ratio_has_effective_from := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND column_name = 'effective_from'
);
SET @alter_agent_settlement_ratio_add_effective_from_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_effective_from = 0,
'ALTER TABLE agent_settlement_ratio ADD COLUMN effective_from TIMESTAMP NOT NULL COMMENT ''비율 시작 시각'' AFTER settlement_ratio',
'SELECT ''agent_settlement_ratio.effective_from already exists'' AS message'
);
PREPARE alter_agent_settlement_ratio_add_effective_from_stmt FROM @alter_agent_settlement_ratio_add_effective_from_sql;
EXECUTE alter_agent_settlement_ratio_add_effective_from_stmt;
DEALLOCATE PREPARE alter_agent_settlement_ratio_add_effective_from_stmt;
SET @agent_settlement_ratio_has_effective_to := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND column_name = 'effective_to'
);
SET @alter_agent_settlement_ratio_add_effective_to_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_effective_to = 0,
'ALTER TABLE agent_settlement_ratio ADD COLUMN effective_to TIMESTAMP NULL DEFAULT NULL COMMENT ''비율 종료 시각(NULL이면 현재 활성 비율)'' AFTER effective_from',
'SELECT ''agent_settlement_ratio.effective_to already exists'' AS message'
);
PREPARE alter_agent_settlement_ratio_add_effective_to_stmt FROM @alter_agent_settlement_ratio_add_effective_to_sql;
EXECUTE alter_agent_settlement_ratio_add_effective_to_stmt;
DEALLOCATE PREPARE alter_agent_settlement_ratio_add_effective_to_stmt;
SET @agent_settlement_ratio_has_active_ratio_key := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND column_name = 'active_ratio_key'
);
SET @alter_agent_settlement_ratio_add_active_ratio_key_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_active_ratio_key = 0,
'ALTER TABLE agent_settlement_ratio ADD COLUMN active_ratio_key TINYINT GENERATED ALWAYS AS (CASE WHEN effective_to IS NULL THEN 1 ELSE NULL END) STORED AFTER effective_to',
'SELECT ''agent_settlement_ratio.active_ratio_key already exists'' AS message'
);
PREPARE alter_agent_settlement_ratio_add_active_ratio_key_stmt FROM @alter_agent_settlement_ratio_add_active_ratio_key_sql;
EXECUTE alter_agent_settlement_ratio_add_active_ratio_key_stmt;
DEALLOCATE PREPARE alter_agent_settlement_ratio_add_active_ratio_key_stmt;
SET @agent_settlement_ratio_member_unique_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND index_name = 'uk_agent_settlement_ratio_member_id'
);
SET @drop_agent_settlement_ratio_member_unique_sql := IF(
@agent_settlement_ratio_member_unique_exists > 0,
'ALTER TABLE agent_settlement_ratio DROP INDEX uk_agent_settlement_ratio_member_id',
'SELECT ''uk_agent_settlement_ratio_member_id already dropped'' AS message'
);
PREPARE drop_agent_settlement_ratio_member_unique_stmt FROM @drop_agent_settlement_ratio_member_unique_sql;
EXECUTE drop_agent_settlement_ratio_member_unique_stmt;
DEALLOCATE PREPARE drop_agent_settlement_ratio_member_unique_stmt;
SET @agent_settlement_ratio_member_active_unique_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND index_name = 'uk_agent_settlement_ratio_member_active'
);
SET @add_agent_settlement_ratio_member_active_unique_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_member_active_unique_exists = 0,
'ALTER TABLE agent_settlement_ratio ADD UNIQUE INDEX uk_agent_settlement_ratio_member_active (member_id, active_ratio_key)',
'SELECT ''uk_agent_settlement_ratio_member_active already exists'' AS message'
);
PREPARE add_agent_settlement_ratio_member_active_unique_stmt FROM @add_agent_settlement_ratio_member_active_unique_sql;
EXECUTE add_agent_settlement_ratio_member_active_unique_stmt;
DEALLOCATE PREPARE add_agent_settlement_ratio_member_active_unique_stmt;
SET @agent_settlement_ratio_has_deleted_at := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND column_name = 'deleted_at'
);
SET @drop_agent_settlement_ratio_deleted_at_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_deleted_at > 0,
'ALTER TABLE agent_settlement_ratio DROP COLUMN deleted_at',
'SELECT ''agent_settlement_ratio.deleted_at already dropped'' AS message'
);
PREPARE drop_agent_settlement_ratio_deleted_at_stmt FROM @drop_agent_settlement_ratio_deleted_at_sql;
EXECUTE drop_agent_settlement_ratio_deleted_at_stmt;
DEALLOCATE PREPARE drop_agent_settlement_ratio_deleted_at_stmt;
SET @agent_settlement_ratio_period_check_exists := (
SELECT COUNT(1)
FROM information_schema.table_constraints
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND constraint_name = 'chk_agent_settlement_ratio_period'
AND constraint_type = 'CHECK'
);
SET @add_agent_settlement_ratio_period_check_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_period_check_exists = 0,
'ALTER TABLE agent_settlement_ratio ADD CONSTRAINT chk_agent_settlement_ratio_period CHECK (effective_to IS NULL OR effective_from < effective_to)',
'SELECT ''chk_agent_settlement_ratio_period already exists'' AS message'
);
PREPARE add_agent_settlement_ratio_period_check_stmt FROM @add_agent_settlement_ratio_period_check_sql;
EXECUTE add_agent_settlement_ratio_period_check_stmt;
DEALLOCATE PREPARE add_agent_settlement_ratio_period_check_stmt;
SET @agent_settlement_ratio_member_effective_to_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND index_name = 'idx_agent_settlement_ratio_member_effective_to'
);
SET @add_agent_settlement_ratio_member_effective_to_index_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_member_effective_to_index_exists = 0,
'ALTER TABLE agent_settlement_ratio ADD INDEX idx_agent_settlement_ratio_member_effective_to (member_id, effective_to)',
'SELECT ''idx_agent_settlement_ratio_member_effective_to already exists'' AS message'
);
PREPARE add_agent_settlement_ratio_member_effective_to_index_stmt FROM @add_agent_settlement_ratio_member_effective_to_index_sql;
EXECUTE add_agent_settlement_ratio_member_effective_to_index_stmt;
DEALLOCATE PREPARE add_agent_settlement_ratio_member_effective_to_index_stmt;
SET @agent_settlement_ratio_member_effective_from_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_ratio'
AND index_name = 'idx_agent_settlement_ratio_member_effective_from'
);
SET @add_agent_settlement_ratio_member_effective_from_index_sql := IF(
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_member_effective_from_index_exists = 0,
'ALTER TABLE agent_settlement_ratio ADD INDEX idx_agent_settlement_ratio_member_effective_from (member_id, effective_from)',
'SELECT ''idx_agent_settlement_ratio_member_effective_from already exists'' AS message'
);
PREPARE add_agent_settlement_ratio_member_effective_from_index_stmt FROM @add_agent_settlement_ratio_member_effective_from_index_sql;
EXECUTE add_agent_settlement_ratio_member_effective_from_index_stmt;
DEALLOCATE PREPARE add_agent_settlement_ratio_member_effective_from_index_stmt;
SET @agent_settlement_snapshot_table_exists := (
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
);
SET @create_agent_settlement_snapshot_table_sql := IF(
@agent_settlement_snapshot_table_exists = 0,
'CREATE TABLE agent_settlement_snapshot (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
period_start TIMESTAMP NOT NULL COMMENT ''정산 기간 시작 시각'',
period_end TIMESTAMP NOT NULL COMMENT ''정산 기간 종료 시각'',
settlement_type VARCHAR(50) NOT NULL COMMENT ''정산 유형(LIVE, CONTENT, COMMUNITY, CHANNEL_DONATION, CONTENT_DONATION)'',
agent_id BIGINT NOT NULL COMMENT ''에이전트 회원 ID 스냅샷'',
agent_nickname VARCHAR(255) NOT NULL COMMENT ''에이전트 닉네임 스냅샷'',
creator_id BIGINT NOT NULL COMMENT ''크리에이터 회원 ID 스냅샷'',
creator_nickname VARCHAR(255) NOT NULL COMMENT ''크리에이터 닉네임 스냅샷'',
assignment_id BIGINT NULL COMMENT ''적용된 소속 이력 row ID 스냅샷'',
agent_settlement_ratio_id BIGINT NULL COMMENT ''적용된 에이전트 정산 비율 이력 row ID 스냅샷'',
applied_agent_settlement_ratio INT NULL COMMENT ''적용된 에이전트 정산 비율 스냅샷(creator-level summary에 단일 값으로 귀결될 때만 저장)'',
count INT NOT NULL COMMENT ''건수 스냅샷'',
total_can INT NOT NULL COMMENT ''총 캔 수 스냅샷'',
krw INT NOT NULL COMMENT ''원화 금액 스냅샷'',
fee INT NOT NULL COMMENT ''수수료 스냅샷'',
settlement_amount INT NOT NULL COMMENT ''크리에이터 세전 정산금 스냅샷'',
tax INT NOT NULL COMMENT ''원천세/세금 스냅샷'',
deposit_amount INT NOT NULL COMMENT ''입금액 스냅샷'',
agent_settlement_amount INT NOT NULL COMMENT ''에이전트 정산금 스냅샷'',
finalized_at TIMESTAMP NOT NULL COMMENT ''확정 시각'',
finalized_by_member_id BIGINT NOT NULL COMMENT ''확정한 관리자 회원 ID 스냅샷'',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
PRIMARY KEY (id),
UNIQUE KEY uk_agent_settlement_snapshot_period_type_agent_creator (period_start, period_end, settlement_type, agent_id, creator_id),
KEY idx_agent_settlement_snapshot_lookup (agent_id, settlement_type, period_start, period_end),
KEY idx_agent_settlement_snapshot_creator_lookup (creator_id, settlement_type, period_start, period_end),
KEY idx_agent_settlement_snapshot_assignment_id (assignment_id),
KEY idx_agent_settlement_snapshot_ratio_id (agent_settlement_ratio_id),
CONSTRAINT fk_agent_settlement_snapshot_assignment_id FOREIGN KEY (assignment_id) REFERENCES agent_creator_relation (id),
CONSTRAINT fk_agent_settlement_snapshot_ratio_id FOREIGN KEY (agent_settlement_ratio_id) REFERENCES agent_settlement_ratio (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트 확정 정산 creator-level 스냅샷''',
'SELECT ''agent_settlement_snapshot already exists'' AS message'
);
PREPARE create_agent_settlement_snapshot_table_stmt FROM @create_agent_settlement_snapshot_table_sql;
EXECUTE create_agent_settlement_snapshot_table_stmt;
DEALLOCATE PREPARE create_agent_settlement_snapshot_table_stmt;
SET @agent_settlement_snapshot_has_assignment_id := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND column_name = 'assignment_id'
);
SET @alter_agent_settlement_snapshot_add_assignment_id_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_has_assignment_id = 0,
'ALTER TABLE agent_settlement_snapshot ADD COLUMN assignment_id BIGINT NULL COMMENT ''적용된 소속 이력 row ID 스냅샷'' AFTER creator_nickname',
'SELECT ''agent_settlement_snapshot.assignment_id already exists'' AS message'
);
PREPARE alter_agent_settlement_snapshot_add_assignment_id_stmt FROM @alter_agent_settlement_snapshot_add_assignment_id_sql;
EXECUTE alter_agent_settlement_snapshot_add_assignment_id_stmt;
DEALLOCATE PREPARE alter_agent_settlement_snapshot_add_assignment_id_stmt;
SET @agent_settlement_snapshot_has_ratio_id := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND column_name = 'agent_settlement_ratio_id'
);
SET @alter_agent_settlement_snapshot_add_ratio_id_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_has_ratio_id = 0,
'ALTER TABLE agent_settlement_snapshot ADD COLUMN agent_settlement_ratio_id BIGINT NULL COMMENT ''적용된 에이전트 정산 비율 이력 row ID 스냅샷'' AFTER assignment_id',
'SELECT ''agent_settlement_snapshot.agent_settlement_ratio_id already exists'' AS message'
);
PREPARE alter_agent_settlement_snapshot_add_ratio_id_stmt FROM @alter_agent_settlement_snapshot_add_ratio_id_sql;
EXECUTE alter_agent_settlement_snapshot_add_ratio_id_stmt;
DEALLOCATE PREPARE alter_agent_settlement_snapshot_add_ratio_id_stmt;
SET @agent_settlement_snapshot_unique_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND index_name = 'uk_agent_settlement_snapshot_period_type_agent_creator'
);
SET @add_agent_settlement_snapshot_unique_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_unique_exists = 0,
'ALTER TABLE agent_settlement_snapshot ADD UNIQUE INDEX uk_agent_settlement_snapshot_period_type_agent_creator (period_start, period_end, settlement_type, agent_id, creator_id)',
'SELECT ''uk_agent_settlement_snapshot_period_type_agent_creator already exists'' AS message'
);
PREPARE add_agent_settlement_snapshot_unique_stmt FROM @add_agent_settlement_snapshot_unique_sql;
EXECUTE add_agent_settlement_snapshot_unique_stmt;
DEALLOCATE PREPARE add_agent_settlement_snapshot_unique_stmt;
SET @agent_settlement_snapshot_lookup_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND index_name = 'idx_agent_settlement_snapshot_lookup'
);
SET @add_agent_settlement_snapshot_lookup_index_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_lookup_index_exists = 0,
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_lookup (agent_id, settlement_type, period_start, period_end)',
'SELECT ''idx_agent_settlement_snapshot_lookup already exists'' AS message'
);
PREPARE add_agent_settlement_snapshot_lookup_index_stmt FROM @add_agent_settlement_snapshot_lookup_index_sql;
EXECUTE add_agent_settlement_snapshot_lookup_index_stmt;
DEALLOCATE PREPARE add_agent_settlement_snapshot_lookup_index_stmt;
SET @agent_settlement_snapshot_creator_lookup_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND index_name = 'idx_agent_settlement_snapshot_creator_lookup'
);
SET @add_agent_settlement_snapshot_creator_lookup_index_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_creator_lookup_index_exists = 0,
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_creator_lookup (creator_id, settlement_type, period_start, period_end)',
'SELECT ''idx_agent_settlement_snapshot_creator_lookup already exists'' AS message'
);
PREPARE add_agent_settlement_snapshot_creator_lookup_index_stmt FROM @add_agent_settlement_snapshot_creator_lookup_index_sql;
EXECUTE add_agent_settlement_snapshot_creator_lookup_index_stmt;
DEALLOCATE PREPARE add_agent_settlement_snapshot_creator_lookup_index_stmt;
SET @agent_settlement_snapshot_assignment_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND index_name = 'idx_agent_settlement_snapshot_assignment_id'
);
SET @add_agent_settlement_snapshot_assignment_index_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_assignment_index_exists = 0,
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_assignment_id (assignment_id)',
'SELECT ''idx_agent_settlement_snapshot_assignment_id already exists'' AS message'
);
PREPARE add_agent_settlement_snapshot_assignment_index_stmt FROM @add_agent_settlement_snapshot_assignment_index_sql;
EXECUTE add_agent_settlement_snapshot_assignment_index_stmt;
DEALLOCATE PREPARE add_agent_settlement_snapshot_assignment_index_stmt;
SET @agent_settlement_snapshot_ratio_index_exists := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND index_name = 'idx_agent_settlement_snapshot_ratio_id'
);
SET @add_agent_settlement_snapshot_ratio_index_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_ratio_index_exists = 0,
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_ratio_id (agent_settlement_ratio_id)',
'SELECT ''idx_agent_settlement_snapshot_ratio_id already exists'' AS message'
);
PREPARE add_agent_settlement_snapshot_ratio_index_stmt FROM @add_agent_settlement_snapshot_ratio_index_sql;
EXECUTE add_agent_settlement_snapshot_ratio_index_stmt;
DEALLOCATE PREPARE add_agent_settlement_snapshot_ratio_index_stmt;
SET @agent_settlement_snapshot_assignment_fk_exists := (
SELECT COUNT(1)
FROM information_schema.table_constraints
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND constraint_name = 'fk_agent_settlement_snapshot_assignment_id'
AND constraint_type = 'FOREIGN KEY'
);
SET @add_agent_settlement_snapshot_assignment_fk_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_assignment_fk_exists = 0,
'ALTER TABLE agent_settlement_snapshot ADD CONSTRAINT fk_agent_settlement_snapshot_assignment_id FOREIGN KEY (assignment_id) REFERENCES agent_creator_relation (id)',
'SELECT ''fk_agent_settlement_snapshot_assignment_id already exists'' AS message'
);
PREPARE add_agent_settlement_snapshot_assignment_fk_stmt FROM @add_agent_settlement_snapshot_assignment_fk_sql;
EXECUTE add_agent_settlement_snapshot_assignment_fk_stmt;
DEALLOCATE PREPARE add_agent_settlement_snapshot_assignment_fk_stmt;
SET @agent_settlement_snapshot_ratio_fk_exists := (
SELECT COUNT(1)
FROM information_schema.table_constraints
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot'
AND constraint_name = 'fk_agent_settlement_snapshot_ratio_id'
AND constraint_type = 'FOREIGN KEY'
);
SET @add_agent_settlement_snapshot_ratio_fk_sql := IF(
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_ratio_fk_exists = 0,
'ALTER TABLE agent_settlement_snapshot ADD CONSTRAINT fk_agent_settlement_snapshot_ratio_id FOREIGN KEY (agent_settlement_ratio_id) REFERENCES agent_settlement_ratio (id)',
'SELECT ''fk_agent_settlement_snapshot_ratio_id already exists'' AS message'
);
PREPARE add_agent_settlement_snapshot_ratio_fk_stmt FROM @add_agent_settlement_snapshot_ratio_fk_sql;
EXECUTE add_agent_settlement_snapshot_ratio_fk_stmt;
DEALLOCATE PREPARE add_agent_settlement_snapshot_ratio_fk_stmt;
SET @agent_settlement_snapshot_source_detail_table_exists := (
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema = @schema_name
AND table_name = 'agent_settlement_snapshot_source_detail'
);
SET @create_agent_settlement_snapshot_source_detail_table_sql := IF(
@agent_settlement_snapshot_source_detail_table_exists = 0,
'CREATE TABLE agent_settlement_snapshot_source_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
snapshot_id BIGINT NOT NULL COMMENT ''summary snapshot FK'',
assignment_id BIGINT NULL COMMENT ''적용된 소속 이력 row ID'',
agent_settlement_ratio_id BIGINT NULL COMMENT ''적용된 에이전트 정산 비율 이력 row ID'',
applied_agent_settlement_ratio INT NULL COMMENT ''적용된 에이전트 정산 비율'',
count INT NOT NULL COMMENT ''source subtotal 건수'',
total_can INT NOT NULL COMMENT ''source subtotal 총 캔 수'',
krw INT NOT NULL COMMENT ''source subtotal 원화 금액'',
fee INT NOT NULL COMMENT ''source subtotal 수수료'',
settlement_amount INT NOT NULL COMMENT ''source subtotal 크리에이터 세전 정산금'',
tax INT NOT NULL COMMENT ''source subtotal 세금'',
deposit_amount INT NOT NULL COMMENT ''source subtotal 입금액'',
agent_settlement_amount INT NOT NULL COMMENT ''source subtotal 에이전트 정산금'',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
PRIMARY KEY (id),
KEY idx_agent_settlement_snapshot_source_detail_snapshot_id (snapshot_id),
KEY idx_agent_settlement_snapshot_source_detail_assignment_id (assignment_id),
KEY idx_agent_settlement_snapshot_source_detail_ratio_id (agent_settlement_ratio_id),
CONSTRAINT fk_agent_settlement_snapshot_source_detail_snapshot_id FOREIGN KEY (snapshot_id) REFERENCES agent_settlement_snapshot (id),
CONSTRAINT fk_agent_settlement_snapshot_source_detail_assignment_id FOREIGN KEY (assignment_id) REFERENCES agent_creator_relation (id),
CONSTRAINT fk_agent_settlement_snapshot_source_detail_ratio_id FOREIGN KEY (agent_settlement_ratio_id) REFERENCES agent_settlement_ratio (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트 확정 정산 source provenance detail''',
'SELECT ''agent_settlement_snapshot_source_detail already exists'' AS message'
);
PREPARE create_agent_settlement_snapshot_source_detail_table_stmt FROM @create_agent_settlement_snapshot_source_detail_table_sql;
EXECUTE create_agent_settlement_snapshot_source_detail_table_stmt;
DEALLOCATE PREPARE create_agent_settlement_snapshot_source_detail_table_stmt;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
# 관리자 에이전트 정산 상세 조회 설계
## 문서 목적
- 관리자 페이지에서 에이전트 목록을 보고, 특정 에이전트 상세 화면에서 소속 크리에이터와 에이전트별 정산 현황을 조회할 수 있도록 백엔드 read API 설계를 고정한다.
- 기존 `partner/agent/calculate` 계산 로직은 최대한 재사용하고, `ADMIN` 전용 조회 진입점만 별도로 추가한다.
## 요구사항 정리
- 관리자 화면에는 에이전트 리스트가 필요하다.
- 에이전트 닉네임
- 에이전트에 속한 크리에이터 수
- 관리자 화면에는 크리에이터 검색이 필요하다.
- 특정 에이전트에 크리에이터를 소속시키기 위한 검색
- 관리자 화면에는 특정 에이전트에 현재 소속된 크리에이터 목록이 필요하다.
- 에이전트 소속 해제를 위한 목록
- 관리자 화면에는 특정 에이전트 기준 정산 상세가 필요하다.
- 라이브 정산 현황
- 콘텐츠 판매 정산 현황
- 커뮤니티 정산 현황
- 채널 후원 정산 현황
- 콘텐츠 후원 정산 현황
## 범위와 해석
- 이번 설계는 **B안: 에이전트 목록 → 에이전트 상세** 흐름을 기준으로 한다.
- 에이전트 목록 화면에서는 요약 정보만 제공한다.
- 실제 정산 데이터는 에이전트 상세 화면에서 조회한다.
- 기존 `assignment`, `ratio`, `settlement/finalize` 쓰기 기능은 유지하고, 이번 범위에서는 관리자용 read API만 추가한다.
## 현재 코드 기준 확인 사항
- 관리자 전용 에이전트 관련 컨트롤러는 이미 존재한다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt`
- 에이전트 본인 전용 정산 조회 컨트롤러도 이미 존재한다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt`
- 따라서 현재 부족한 것은 정산 계산 로직이 아니라, `ADMIN`이 특정 `agentId`를 지정해 같은 데이터를 읽는 관리자 전용 read API 레이어다.
## 권장 아키텍처
### 1. 관리자 전용 read 진입점 추가
- 신규 패키지: `kr.co.vividnext.sodalive.admin.partner.agent.read`
- 신규 구성
- `AdminAgentReadController`
- `AdminAgentReadService`
- `AdminAgentReadQueryRepository`
- 역할
- 에이전트 목록 조회
- 크리에이터 검색 조회
- 특정 에이전트 소속 크리에이터 목록 조회
- 특정 에이전트 기준 5종 정산 조회
### 2. 기존 도메인 계산 로직 재사용
- 기존 계산/집계 로직은 계속 `kr.co.vividnext.sodalive.partner.agent.calculate` 아래에 둔다.
- 관리자용 read 서비스는 기존 `AgentCalculateService`, `AgentCalculateQueryRepository`, snapshot 조회 경로를 재사용한다.
- 현재 `AgentCalculateService`의 정산 조회 메서드는 이미 `agentId`를 직접 받으므로, 관리자 read 서비스는 이 메서드를 그대로 호출한다.
- 최종안은 **정산 계산 로직은 `partner.agent.calculate`에 유지하고, ADMIN은 별도 read 서비스에서 기존 agentId 기반 조회 메서드를 재사용하는 구조**다.
### 3. 쓰기와 읽기 축 분리
- 쓰기 축
- `admin.partner.agent.assignment`
- `admin.partner.agent.ratio`
- `admin.partner.agent.settlement`
- 읽기 축
- `admin.partner.agent.read`
- `partner.agent.calculate`
- 이렇게 나누면 “관리자 권한으로 읽는다”와 “정산 계산 규칙을 제공한다”의 책임이 섞이지 않는다.
## 화면 흐름 기준 API 설계
### 1. 에이전트 목록 API
- 목적: 관리자 화면의 첫 진입 리스트
- 권장 경로: `GET /admin/partner/agent/list`
- 권한: `ADMIN`
- 요청 파라미터
- `pageable`
- 응답 필드
- `agentId`
- `agentNickname`
- `assignedCreatorCount`
- `liveAgentSettlementAmount`
- `contentAgentSettlementAmount`
- `communityAgentSettlementAmount`
- `contentDonationAgentSettlementAmount`
- `channelDonationAgentSettlementAmount`
- `totalCount`
- 조회 기준
- `Member.role == AGENT`
- 현재 활성 소속 크리에이터 수만 집계
- 활성 소속 판정은 현재 코드의 assignment window 규칙과 동일하게 현재 시각 기준 `assignedAt <= now < unassignedAt(or null)`를 따른다.
- 정산 합계는 별도 날짜 입력 없이 **현재 월 기준**으로 계산한다.
- 기준 시간대: `Asia/Seoul`
- 시작 시각: 현재 월 1일 `00:00:00`
- 종료 시각: 다음 달 1일 `00:00:00` 직전까지 포함되는 배타 상한 방식
- 실제 조회 구간은 위 KST 월 경계를 UTC `LocalDateTime`으로 변환해 DB의 UTC 저장 시각과 비교한다.
- 각 합계 값의 의미는 해당 기간의 상세 조회 응답 `total.agentSettlementAmount`와 동일하다.
- 해당 월에 정산 내역이 없더라도 에이전트 목록에서는 제외하지 않고, 5종 합계를 모두 `0`으로 내려준다.
### 2. 크리에이터 검색 API
- 목적: 특정 에이전트에 크리에이터를 소속시키기 전 검색
- 권장 경로: `GET /admin/partner/agent/creator/search`
- 권한: `ADMIN`
- 요청 파라미터
- `search_word`
- `pageable`
- 응답 필드
- `creatorId`
- `creatorNickname`
- `currentAgentId` nullable
- `currentAgentNickname` nullable
- 동작 원칙
- 기존 `AdminMemberController.searchCreator()` 검색 관례를 따른다.
- 검색 결과에 현재 활성 소속 agent 정보를 붙여서, 이미 다른 agent 소속인지 운영자가 바로 판단할 수 있게 한다.
### 3. 특정 에이전트 소속 크리에이터 목록 API
- 목적: 상세 화면의 소속 크리에이터 탭
- 권장 경로: `GET /admin/partner/agent/{agentId}/creator/list`
- 권한: `ADMIN`
- 응답 필드
- `creatorId`
- `creatorNickname`
- `assignedAt`
- 참고
- 현재 `GetAgentAssignedCreatorResponse``creatorId`, `creatorNickname`만 제공한다.
- 관리자 상세 화면에서는 운영 판단을 위해 `assignedAt`이 같이 내려가는 편이 자연스럽다.
### 4. 특정 에이전트 정산 상세 API 5종
- 목적: 에이전트 상세 화면의 정산 탭
- 권한: `ADMIN`
- 권장 경로
- `GET /admin/partner/agent/{agentId}/calculate/live-by-creator`
- `GET /admin/partner/agent/{agentId}/calculate/content-by-creator`
- `GET /admin/partner/agent/{agentId}/calculate/community-by-creator`
- `GET /admin/partner/agent/{agentId}/calculate/channel-donation-by-creator`
- `GET /admin/partner/agent/{agentId}/calculate/content-donation-by-creator`
- 공통 요청 파라미터
- `startDateStr`
- `endDateStr`
- `pageable`
- 응답 원칙
- 기존 AGENT 전용 응답 계약을 가능한 그대로 유지한다.
- `totalCount`, `total`, `items` 구조 유지
- 각 item의 집계 필드 유지
- `count`
- `totalCan`
- `krw`
- `fee`
- `settlementAmount`
- `agentSettlementAmount`
- 차이점
- 기존 AGENT API는 로그인 principal에서 `agentId`를 얻는다.
- 관리자 API는 path variable `agentId`를 받는다.
## 데이터 모델/응답 설계
### 1. 에이전트 목록 응답 DTO
- 신규 DTO 필요
- DTO 명
- `GetAdminAgentListResponse`
- `GetAdminAgentListItem`
- 필수 필드
- response: `totalCount: Int`, `items: List<GetAdminAgentListItem>`
- item:
- `agentId: Long`
- `agentNickname: String`
- `assignedCreatorCount: Int`
- `liveAgentSettlementAmount: Int`
- `contentAgentSettlementAmount: Int`
- `communityAgentSettlementAmount: Int`
- `contentDonationAgentSettlementAmount: Int`
- `channelDonationAgentSettlementAmount: Int`
### 2. 크리에이터 검색 응답 DTO
- 신규 DTO 필요
- 기존 `admin/member` 검색 응답을 그대로 쓰기보다, 현재 활성 agent 정보를 함께 주는 전용 DTO가 필요하다.
- DTO 명
- `SearchAdminAgentAssignableCreatorResponse`
- `SearchAdminAgentAssignableCreatorItem`
- 필수 필드
- response: `totalCount: Int`, `items: List<SearchAdminAgentAssignableCreatorItem>`
- item: `creatorId: Long`, `creatorNickname: String`, `currentAgentId: Long?`, `currentAgentNickname: String?`
### 3. 관리자용 소속 크리에이터 목록 DTO
- 기존 `GetAgentAssignedCreatorResponse`를 그대로 재사용하기보다는 관리자용 항목에 `assignedAt`을 포함한 전용 DTO가 적합하다.
- DTO 명
- `GetAdminAgentAssignedCreatorResponse`
- `GetAdminAgentAssignedCreatorItem`
- 필수 필드
- response: `totalCount: Int`, `items: List<GetAdminAgentAssignedCreatorItem>`
- item: `creatorId: Long`, `creatorNickname: String`, `assignedAt: LocalDateTime`
### 4. 관리자용 정산 상세 DTO
- 가능하면 기존 아래 DTO를 그대로 재사용한다.
- `GetAgentSettlementByCreatorResponse`
- `GetAgentChannelDonationSettlementByCreatorResponse`
- 이유
- 화면 주체만 ADMIN으로 바뀌고 데이터 shape는 동일하기 때문이다.
- DTO까지 갈라지면 정산 계약이 중복될 가능성이 높다.
## 서비스 설계
### 1. `AdminAgentReadService`
- 책임
- 에이전트 목록 조회
- 크리에이터 검색 조회
- 에이전트 소속 크리에이터 목록 조회
- 에이전트 정산 상세 조회 진입
- 내부 동작
- 목록/검색/소속 목록은 전용 read query를 사용한다.
- 에이전트 목록 조회 시 현재 월 기준 시작/종료 시각을 서비스에서 계산해 전용 read query에 전달한다.
- 정산 상세는 기존 `AgentCalculateService`의 agentId 기반 public 조회 메서드를 사용한다.
### 2. 공통 정산 read 메서드 분리
- 현재 `AgentCalculateService`의 핵심 정산 조회 메서드는 이미 `agentId` 파라미터 중심 public 메서드다.
- 따라서 아래 방향으로 정리한다.
- controller는 AGENT/ADMIN 별도로 유지
- ADMIN read 서비스는 기존 `AgentCalculateService` public 메서드를 직접 호출한다.
- 기대 효과
- 정산 계산 규칙 중복 제거
- snapshot 우선 조회 / live 계산 fallback 규칙 재사용
## Repository / Query 방향
### 1. 에이전트 목록용 조회 추가
- 필요 기능
- AGENT role member 목록
- 각 agent의 현재 활성 creator count
- 각 agent의 현재 월 기준 5종 정산 합계
- 구현 방식
- `AdminAgentReadQueryRepository`에서 전용 projection query를 제공한다.
- 기본 에이전트 목록과 활성 creator count는 기존 Querydsl projection query로 조회한다.
- 현재 월 5종 정산 합계는 각 목록 item마다 기존 `AgentCalculateQueryRepository` total 조회 메서드를 재사용해 채운다.
- 정산 row가 없는 agent도 목록에 남겨야 하므로 기존 total 조회 응답의 `0` 기본값을 그대로 사용한다.
### 2. 크리에이터 검색용 조회 추가
- 필요 기능
- creator nickname 기준 검색
- 현재 활성 소속 agent nullable join
- 구현 방식
- `AdminAgentReadQueryRepository`에서 전용 검색 query를 제공한다.
### 3. 소속 크리에이터 목록 조회 추가
- 기존 `AgentCalculateQueryRepository.getAssignedCreators()`를 재사용할 수 있다.
- 다만 `assignedAt`을 응답에 내려야 하면 projection을 확장하거나 관리자 전용 projection을 추가해야 한다.
## 인증/예외 처리 원칙
- 새 관리자 read 엔드포인트는 모두 `@PreAuthorize("hasRole('ADMIN')")`를 사용한다.
- `agentId`가 존재하지 않거나 role이 `AGENT`가 아니면 `SodaException(messageKey = ...)`로 실패한다.
- `creator/search`는 기존 `admin/member/search` 관례처럼 최소 검색어 길이 검증을 적용한다.
- 정산 계산식, snapshot 우선 전략, assignment/ratio history 적용 규칙은 기존 `partner.agent.calculate` 동작을 그대로 사용한다.
## 테스트 설계
### 1. 컨트롤러 테스트
- `ADMIN` 접근 성공
- 익명 사용자 접근 실패
- `AGENT` 또는 일반 사용자 접근 실패
### 2. 서비스/리포지토리 테스트
- 에이전트 목록에서 닉네임과 현재 활성 creator count가 맞는지 검증
- 크리에이터 검색 결과에 현재 agent 소속 정보가 올바르게 붙는지 검증
- 특정 agent 소속 크리에이터 목록이 현재 활성 구간 기준으로만 내려오는지 검증
### 3. 정산 parity 테스트
- 동일 기간, 동일 `agentId`에 대해
- AGENT 전용 조회 응답
- ADMIN 전용 조회 응답
- 두 결과의 `totalCount`, `total`, `items`가 동일한지 검증
- 대상 5종
- live
- content
- community
- channel donation
- content donation
### 4. 설계 대비 구현 계획 누락 체크리스트
- [x] 관리자 컨트롤러 테스트에 `ADMIN` 접근 성공, 익명 접근 실패, `AGENT` 또는 일반 사용자 접근 실패 시나리오가 모두 포함되어 있는지 확인한다.
- [x] 동일 기간, 동일 `agentId` 기준으로 AGENT 전용 응답과 ADMIN 전용 응답의 `totalCount`, `total`, `items` parity를 5종 모두 검증하는 테스트가 구현 계획에 포함되어 있는지 확인한다.
- [x] 구현 계획의 검증 기록 단계에 `무엇을/왜/어떻게`, 실제 실행 명령과 결과, 후속 수정 시 누적 기록 및 `정정` 추가 원칙이 명시되어 있는지 확인한다.
- [x] `/admin/partner/agent/list`가 별도 날짜 입력 없이 현재 월 기준 5종 정산 합계를 계산하도록 구현 계획에 반영되어 있는지 확인한다.
- [x] 에이전트 목록 응답 DTO와 리스트 조회 테스트에 `live/content/community/contentDonation/channelDonation` 5종 `agentSettlementAmount` summary 필드가 모두 반영되어 있는지 확인한다.
- [x] 해당 월에 정산 내역이 없는 에이전트도 목록에서 제외하지 않고 5종 합계를 `0`으로 표기하는 쿼리/응답/테스트 계획이 포함되어 있는지 확인한다.
- [x] 리스트 summary 값이 같은 월 기준 상세 조회 응답의 `total.agentSettlementAmount`와 일치하는지 검증하는 회귀 테스트가 구현 계획에 포함되어 있는지 확인한다.
## 구현 시 유의사항
- 관리자용 정산 API를 새로 만든다고 해서 계산 query를 복제하지 않는다.
- 현재 assignment 활성 판정은 시간창 규칙을 반드시 동일하게 사용한다.
- 정산 응답 계약은 기존 agent 응답과 불필요하게 분기하지 않는다.
- 목록 화면은 요약 정보만 제공하고, 상세 정산은 상세 화면에서만 조회한다.
- 목록의 5종 합계는 상세 API의 기간 필터와 별개로, 현재 월 기준 요약값이라는 의미를 문서와 코드에서 동일하게 유지한다.
- 해당 월에 정산 내역이 없더라도 에이전트 행은 유지하고 금액은 `0`으로 표기한다.
## 최종 설계 결론
- 이번 기능은 “새 정산 엔진 추가”가 아니라 “기존 agent 정산 계산을 ADMIN에서 조회할 수 있게 read API를 보강”하는 작업으로 본다.
- 관리자 페이지 흐름은 아래로 고정한다.
1. `/admin/partner/agent/list`로 에이전트 목록과 현재 월 기준 5종 정산 합계 조회
2. 상세 화면에서 `/admin/partner/agent/{agentId}/creator/list`로 현재 소속 크리에이터 조회
3. 필요 시 `/admin/partner/agent/creator/search`로 크리에이터 검색 후 기존 assignment API로 소속 지정
4. 상세 화면에서 `/admin/partner/agent/{agentId}/calculate/*` 5종으로 정산 현황 조회
- 목록의 5종 합계는 상세 페이지 이동 포인트용 현재 월 summary이며, 상세 화면의 날짜 범위 조회와 역할을 구분한다.
- 현재 월에 정산 내역이 없는 에이전트도 목록에 포함되며, 합계는 0원으로 표시한다.
- 이 설계를 기준으로 다음 단계에서는 구현 계획 문서를 작성한다.
## 검증 기록
- 1차 설계
- 무엇을: 관리자용 에이전트 목록/상세 read API 구조와 기존 agent 계산 로직 재사용 범위를 문서로 고정했다.
- 왜: 현재 코드에는 관리자용 assignment/ratio/finalize는 있지만, 관리자용 agent 정산 상세 조회 API는 없어 read 레이어 설계가 먼저 필요했기 때문이다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt` 확인
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt` 확인
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt` 확인
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt` 확인
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt` 확인
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt` 확인
- 결과: 관리자용 read API 부재와 기존 계산 로직 재사용 가능성을 문서에 반영했다.

View File

@@ -0,0 +1,21 @@
- [x] `admin/partner/agent/**` 현재 구현과 테스트 범위 확인
- [x] `partner/agent/**` 현재 구현과 테스트 범위 확인
- [x] 관련 DDL/기획 문서에서 요구사항과 변경 배경 추적
- [x] git history와 GitHub 메타데이터에서 이전 결정/경고 사항 확인
- [x] 제외 대상 2건을 제외하고 남는 실질 이슈만 판정
## 검증 기록
### 1차 컨텍스트 리뷰
- 무엇을: 에이전트 권한 및 정산 기능의 최근 구현/수정 이력, 현재 코드, QA 문서, 관련 테스트를 교차 검토해 남아 있는 실질 결함이 있는지 확인했다.
- 왜: 최근 수정으로 일부 validation 이슈는 닫혔지만, event-time 이력 모델과 finalized snapshot 정책이 실제 조회 동작까지 일관되게 반영되는지 재확인이 필요했기 때문이다.
- 어떻게:
- `GIT_MASTER=1 git status`, `GIT_MASTER=1 git log -30 --oneline`, `GIT_MASTER=1 git log --oneline <merge-base>..HEAD`로 브랜치 상태와 최근 변경 맥락을 확인했다.
- `docs/20260408_에이전트권한및정산기능추가.md`, `docs/20260409_partner_agent_assignment_ratio_ddl.sql`, `docs/20260410_에이전트정산기능QA.md`를 읽어 요구사항/후속 수정/제외 대상 2건을 확인했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/**`, 관련 테스트 파일을 읽어 assignment, ratio, calculate, snapshot 로직을 대조했다.
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.*" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"`를 실행해 관련 테스트를 검증했다.
- 실행/확인 결과:
- 관련 코드/문서 검토 결과, `AgentCalculateQueryRepository.getAssignedCreatorTotalCount/getAssignedCreators``assignedAt` 현재 시점 조건 없이 `unassignedAt is null`만 사용함을 확인했다.
- `docs/20260410_에이전트정산기능QA.md:35`에 "미래 assignedAt만 가진 예약 소속은 현재 목록에 노출되지 않아야 한다" 요구사항이 명시되어 있음을 확인했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentAssignedCreatorFutureWindowQaTest.kt`의 첫 테스트가 실제로 실패해 위 요구사항 누락이 재현됨을 확인했다.
- `gh` 명령은 현재 환경에 설치되어 있지 않아 GitHub PR/이슈 메타데이터 조회는 불가했다 (`zsh:1: command not found: gh`).

View File

@@ -0,0 +1,200 @@
# 에이전트 정산 기능 QA 계획
## QA 목표
- 최근 구현된 agent authorization 및 settlement 기능에서 남아 있는 실제 결함을 백엔드 관점으로 점검한다.
- 범위는 `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/**`로 한정한다.
- 이미 수용된 두 가지 저우선순위 이슈(빈 finalized 기간 미생성, 중복 finalize 동시성 rough edge)는 제외한다.
## 작업 체크리스트
- [x] 관련 모듈/테스트/기존 작업 문서를 확인해 QA 범위를 정리한다.
- [x] 백엔드 QA 시나리오를 20개 이상 도출하고 P0/P1/P2로 분류한다.
- [x] P0/P1 시나리오를 중심으로 기존 테스트와 실행형 검증 명령을 수행한다.
- [x] 실패 시 재현 경로와 실제 증거를 확보해 결함 여부를 확정한다.
- [x] 최종 PASS/FAIL 판정을 정리하고 검증 기록을 남긴다.
## 구현 체크리스트
- [x] assignment/finalize 가드 미체크 항목 회귀 테스트를 추가한다.
- [x] calculate empty-result/snapshot pagination/finalized immutability 회귀 테스트를 추가한다.
- [x] ratio 목록 응답을 member 단위 current/history 구조로 확장한다.
- [x] generic 4종 calculate total 경로를 전용 total 계산 경로로 분리한다.
- [x] generic 4종 calculate total 경로를 DB total projection 전용 쿼리로 재리팩터링한다.
- [x] channel donation total 경로를 DB total projection 전용 쿼리로 재리팩터링한다.
- [x] finalize snapshot 생성 경로의 중복 groupBy/row 변환을 줄인다.
- [x] 관련 테스트, 진단, 수동 검증 결과를 문서에 반영한다.
## 검증 대상 축
- assignment create/remove 시간 경계 동작
- ratio create/update 검증과 history 동작
- calculate 5개 카테고리 조회
- snapshot finalize 및 finalized 조회
- provenance/source detail 무결성
- 신규 validation과 기존 동작의 상호작용
## 시나리오 목록
### P0
- [x] assignment 생성: 유효한 agent/creator/assignedAt이면 신규 이력 row가 생성된다.
- [x] assignment 생성: agentId와 creatorId가 같으면 거부된다.
- [x] assignment 생성: agent가 AGENT 역할이 아니면 거부된다.
- [x] assignment 생성: creator가 CREATOR 역할이 아니면 거부된다.
- [x] assignment 생성: 활성 소속과 시간이 겹치면 거부된다.
- [x] assignment 생성: 이전 소속의 `unassignedAt`과 동일한 `assignedAt`은 허용된다.
- [x] assignment 해제: 활성 소속이면 `unassignedAt`이 기록된다.
- [x] assignment 해제: `unassignedAt <= assignedAt`이면 거부된다.
- [x] assigned creator 목록 조회: 다른 agent 소속 creator는 제외된다.
- [x] assigned creator 목록 조회: 미래 `assignedAt`만 가진 예약 소속은 현재 목록에 노출되지 않아야 한다.
- [x] assigned creator 목록 조회: 미래 `unassignedAt`이 있는 현재 소속은 종료 전까지 유지되어야 한다.
- [x] ratio 생성: 유효한 `effectiveFrom`이면 활성 row 종료 후 신규 이력 row가 추가된다.
- [x] ratio 생성/수정: `settlementRatio`가 0..100 범위를 벗어나면 거부된다.
- [x] ratio 생성/수정: 과거 closed history 구간과 겹치는 `effectiveFrom`이면 거부된다.
- [x] calculate 조회: 5개 카테고리 모두 거래 시점 assignment를 기준으로 agent가 갈린다.
- [x] calculate 조회: 5개 카테고리 모두 거래 시점 ratio를 기준으로 agentSettlementAmount가 계산된다.
- [x] calculate 조회: content는 콘텐츠 개별 ratio와 creator 기본 ratio fallback을 모두 반영한다.
- [x] calculate 조회: 일반 정산/채널후원 모두 agent ratio 이력이 없으면 10% fallback이 적용된다.
- [x] snapshot finalize: LIVE finalize는 immutable snapshot과 source detail을 함께 저장한다.
- [x] snapshot finalize: 기간 내 source row가 여러 개면 summary FK는 비우고 provenance detail만 남긴다.
- [x] finalized read: 5개 카테고리 모두 동일 기간 finalized snapshot을 live 계산보다 우선 사용한다.
- [x] snapshot finalize: 동일 기간/타입/agent 재요청은 중복 저장 없이 alreadyFinalized=true를 반환한다.
### P1
- [x] admin finalize: 대상 member가 없으면 실패한다.
- [x] admin finalize: 대상 member가 AGENT 역할이 아니면 실패한다.
- [x] agent controller: 익명 사용자는 creator/list 조회에 실패한다.
- [x] admin finalize controller: 익명 사용자는 finalize 호출에 실패한다.
- [x] channel donation 조회: 분할 정산 레코드가 있어도 후원 건수는 distinct useCan 기준이다.
- [x] calculate 조회: 기간 내 결과가 없으면 total=0, items=[]로 일관되게 반환된다.
- [x] assigned creator 목록 조회: 정렬/페이지네이션이 creatorId desc 기준으로 유지된다.
### P2
- [x] snapshot read: snapshot pagination이 `creatorId desc` 기준으로 안정적이다.
- [x] ratio 목록 조회: current/history가 페이지 응답에서 누락 없이 노출된다.
- [x] provenance detail 합계는 snapshot summary 합계와 일치한다.
- [x] finalized 이후 ratio/assignment 변경이 생겨도 동일 finalized 기간 응답은 변하지 않는다.
## 미체크 항목 재확인 및 후속 방안
- `assignment 생성: agentId와 creatorId가 같으면 거부된다.`
- 현재 상태: `AdminAgentCreatorService.assignCreator()`가 동일 ID를 `partner.agent.assignment.invalid_relation`으로 즉시 거부한다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt:22-25`).
- 후속 방안: `AdminAgentCreatorServiceTest``agentId == creatorId` 요청을 추가해 동일 예외 키를 직접 검증한다.
- `admin finalize: 대상 member가 없으면 실패한다.`
- 현재 상태: `AdminAgentSettlementSnapshotService.getAgent()`가 member 미존재 시 `partner.agent.ratio.agent_not_found`를 던진다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:122-128`).
- 후속 방안: `AdminAgentSettlementSnapshotServiceTest``memberRepository.findById()`가 비어 있는 케이스를 추가해 finalize 진입 전 실패를 검증한다.
- `admin finalize: 대상 member가 AGENT 역할이 아니면 실패한다.`
- 현재 상태: 같은 `getAgent()`에서 role이 `AGENT`가 아니면 `partner.agent.ratio.invalid_agent`를 던진다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:122-128`).
- 후속 방안: `AdminAgentSettlementSnapshotServiceTest``USER` 또는 `CREATOR` role member를 주입해 예외 키를 직접 검증한다.
- `calculate 조회: 기간 내 결과가 없으면 total=0, items=[]로 일관되게 반환된다.`
- 현재 상태: 조회 repository는 total count를 `?: 0`으로 반환하고, 페이지 대상 creator가 없으면 빈 목록을 반환한다. 서비스도 이 값을 그대로 응답으로 조립한다 (`src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:272-326`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:200-211`).
- 후속 방안: in-range 데이터가 전혀 없는 fixture로 `AgentCalculateService` 또는 query integration test를 추가해 `total.count/total.totalCan/...`가 모두 0이고 `items=[]`인 응답 계약을 직접 고정한다.
- `snapshot read: snapshot pagination이 creatorId desc 기준으로 안정적이다.`
- 현재 상태: snapshot 조회는 `findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(...)`를 사용해 정렬 기준 자체는 명시되어 있다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotRepository.kt:14-19`). 다만 다건 snapshot에서 offset/limit slicing 순서를 직접 검증하는 테스트는 없다.
- 후속 방안: `AgentCalculateServiceTest`에 creatorId가 다른 snapshot 3건 이상을 주입하고, `offset/limit`별 응답 순서와 크기를 검증하는 회귀 테스트를 추가한다.
- `ratio 목록 조회: current/history가 페이지 응답에서 누락 없이 노출된다.`
- 현재 상태: 현재 응답 DTO는 `items: List<GetAgentSettlementRatioItem>` 단일 flat 구조이고 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt:6-17`), query도 ratio row를 flat list로만 조회한다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt:23-49`). 기존 서비스 테스트도 flat 목록 1건만 검증한다 (`src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt:344-367`).
- 구현 방안: `current/history`를 분리한 응답 DTO를 새로 정의하고, query/service 계층에서 member별 ratio row를 current 1건 + history n건으로 재구성하도록 응답 모델을 확장한다. 페이지 기준은 member 단위로 재정의하고, current/history 누락 회귀 테스트를 함께 추가한다.
- `finalized 이후 ratio/assignment 변경이 생겨도 동일 finalized 기간 응답은 변하지 않는다.`
- 현재 상태: finalize는 snapshot/source detail에 당시 값을 저장하고 (`src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt:44-202`), 읽기 경로는 finalized snapshot을 live 계산보다 우선 사용한다 (`src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt:458-598`). 다만 finalize 이후 assignment/ratio를 실제로 바꾼 뒤 같은 기간 재조회가 불변인지 직접 검증하는 테스트는 없다.
- 후속 방안: finalize 이후 assignment/ratio fixture를 변경한 뒤 동일 기간 read 응답이 snapshot 값 그대로 유지되는지를 검증하는 통합 회귀 테스트를 추가한다.
## 추가 구조/성능 메모
- `우선 반영 1순위: AgentCalculateService.buildSettlementByCreatorResponse()``totalRowsLoader` 기반 total 계산을 DB total query로 분리한다.
- 현재 경로: generic 4종(LIVE/CONTENT/COMMUNITY/CONTENT_DONATION) total은 `totalRowsLoader(startDate, endDate).toMergedResponseItems().toResponseTotal()`로 계산된다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:200-203`).
- 현재 비용: 기간 전체 `GetAgentCreatorSettlementSummaryQueryData`를 메모리에 적재한 뒤, 각 row마다 `toResponseItem()`에서 `BigDecimal` 기반 금액 계산을 수행하고 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt:17-40`), 다시 `groupBy { creatorId }`로 creator별 병합 후 (`.../GetAgentCreatorSettlementSummaryQueryData.kt:60-77`), 마지막에 `sumOf`로 grand total을 한 번 더 순회한다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt:33-43`).
- 왜 1순위인가: pagedRowsLoader는 페이지 범위만 읽지만 totalRowsLoader는 기간 전체 row를 읽는다. 따라서 메모리 사용량과 GC 비용, total 계산 지연을 동시에 줄이려면 total 경로를 먼저 제거하는 편이 효과가 가장 크다.
- row granularity 주의점: total을 단순 `sum(totalCan)`으로 바꾸면 안 된다. 현재 row는 creator 1건이 아니라 assignment/agent ratio/settlement ratio 기준으로 분할되어 있고 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt:392-596`), 같은 creator도 콘텐츠 개별 ratio 차이로 여러 row가 생긴다 (`src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:145-176`). 또한 거래 시점 assignment/agent ratio 이력 때문에 row가 갈라진다 (`.../AgentCalculateQueryRepositoryTest.kt:328-381`, `421-470`).
- 핵심 관찰: total 계산에서 creator별 병합 자체는 필수가 아니다. 현재 로직은 row → creator 병합 → grand total 순서지만, 최종 total 값은 row별 계산 결과를 그대로 모두 더한 값과 동일하다. 즉 total 전용 경로는 creator 응답 shape를 만들 필요 없이 “현재 row granularity의 정산 결과 총합”만 DB에서 반환하면 된다.
- 권장 구현 방안: `AgentCalculateQueryRepository`에 generic 4종용 `getCalculate*Total()` 전용 query를 추가하고, `GetAgentSettlementByCreatorTotal`에 대응하는 total projection을 `fetchOne()`으로 바로 반환한다. 이때 현재 반올림/비율 적용 semantics를 유지하려면, 기존 grouped row granularity를 유지한 뒤 그 row 단위 정산 금액을 다시 합산하는 형태가 필요하다. Spring Boot 2.7 + Querydsl JPA 제약을 고려하면, 1차 권장은 파생 테이블(native SQL 또는 Querydsl SQL/JPASQLQuery fallback) 기반 total query이고, 로컬 선례는 total을 목록 쿼리와 분리한 `AdminChannelDonationCalculateQueryRepository.getChannelDonationSettlementTotal()` / `CreatorAdminChannelDonationCalculateQueryRepository.getChannelDonationSettlementTotal()`이다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt:33-54`, `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt:18-38`).
- 차선책: DB query 분리가 바로 어렵다면, 최소한 total 계산에서 `.toMergedResponseItems()`를 제거하고 row를 단일 `fold`로 누적해 creator별 `groupBy`/중간 리스트 생성을 없앨 수 있다. 다만 이 방법은 기간 전체 row 적재 자체는 남기므로 임시 완화책으로만 본다.
- 구현 결과(1차): `List<GetAgentCreatorSettlementSummaryQueryData>.toResponseTotal()` 전용 경로를 추가해 generic 4종 total 계산에서 creator별 `groupBy`와 중간 `GetAgentSettlementByCreatorItem` 리스트 병합을 제거했다.
- 구현 결과(2차): `AgentCalculateQueryRepository`에 generic 4종용 `getCalculate*ByCreatorTotal()` native SQL derived-table query를 추가해 total을 DB에서 바로 계산하도록 바꿨다. 서비스는 더 이상 full row list를 total 계산용으로 읽지 않고, paged item 조회에만 기존 Querydsl row query를 사용한다.
- `AdminAgentSettlementSnapshotService.finalizeSnapshots()`는 DB가 assignment/ratio 단위로 이미 집계한 row를 creator별 snapshot 1건으로 다시 합산하는데, 이 합산 자체는 필요하지만 `rows.groupBy { creatorId }` 뒤에 `toMergedResponseItems()`가 다시 같은 key로 groupBy를 수행해 불필요한 재-groupBy가 한 번 더 발생한다. 반면 source detail은 query row 1건을 detail 1건으로 보존하므로 추가 집계는 없고, 동일 row의 `toResponseItem()` 변환만 summary/detail에서 각각 반복된다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:131-179`, `182-253`).
- 구현 방안: finalize 내부에서는 creator별 재-groupBy 없는 전용 merge 로직을 두고, row 변환 결과를 summary/detail 양쪽에서 재사용하도록 draft 구조를 조정한다.
- 구현 결과: `SnapshotAggregateDraft` 단일 패스 누적 구조로 summary/source detail 수치를 함께 합산하도록 바꿔 creator별 재-groupBy와 중복 `toResponseItem()` 변환을 제거했다.
## 검증 기록
### 1차 QA
- 무엇을:
- assignment/ratio/calculate/snapshot/controller 축의 기존 테스트를 집중 실행했다.
- `assigned creator list`의 시간창(window) 처리 누락 여부를 임시 DataJpa 재현 테스트로 검증했다.
- 왜:
- 기존 테스트가 보장하는 범위와, 목록 조회처럼 테스트 공백이 있는 시간 경계 시나리오를 분리해서 확인해야 실제 latent bug를 잡을 수 있기 때문이다.
- 어떻게:
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotControllerTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateControllerTest` → BUILD SUCCESSFUL
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorControllerTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → BUILD SUCCESSFUL
- 실패 재현 1: 임시 QA 테스트에서 미래 `assignedAt` 예약 소속 목록 노출 검증 → `TEST-kr.co.vividnext.sodalive.partner.agent.calculate.AgentAssignedCreatorFutureWindowQaTest.xml` 기준 `expected <0> but was <1>`
- 실패 재현 2: 임시 QA 테스트에서 미래 `unassignedAt` 이전 현재 소속 유지 검증 → `TEST-kr.co.vividnext.sodalive.partner.agent.calculate.AgentAssignedCreatorFutureWindowQaTest.xml` 기준 `expected <1> but was <0>`
### 2차 QA 문서 정리
- 무엇을:
- 이후 수정/테스트로 증거가 확보된 QA 항목만 문서 상태를 최신화했다.
- 왜:
- 현재 QA 문서는 일부 항목이 이미 수정/검증되었는데도 실패 메모나 미체크 상태로 남아 있어, 실제 상태와 문서가 어긋나 있었기 때문이다.
- 어떻게:
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:91-110` 확인 → 현재 시각 활성 구간 creator list 회귀 테스트가 미래 `assignedAt` 미노출 / 미래 `unassignedAt` 이전 유지 두 조건을 함께 검증함을 확인했다.
- `docs/20260408_에이전트권한및정산기능추가.md:644-658` 확인 → 위 creator list window 버그가 17차 수정에서 실제 수정되고 테스트/빌드 검증까지 완료되었음을 확인했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt:191-201` 확인 → provenance detail 각 수치 합계가 snapshot summary와 일치하는 검증이 이미 존재함을 확인했다.
- 위 세 근거에 따라 creator list 2개 항목의 실패 메모를 제거하고, `provenance detail 합계` 항목을 완료 처리했다.
### 3차 미체크 항목/구조 점검
- 무엇을:
- QA 문서에 남아 있던 미체크 항목을 구현/테스트 기준으로 다시 분류했다.
- `AgentCalculateService`의 creator 합산 경로와 `AdminAgentSettlementSnapshotService` finalize 경로의 중복 집계 여부를 코드 기준으로 재점검했다.
- 왜:
- 현재 문서에는 “구현은 이미 있으나 테스트가 빠진 항목”, “응답 모델 자체가 요구를 충족하지 못하는 항목”, “기능 결함은 아니지만 구조적으로 비효율적인 항목”이 섞여 있어 후속 작업 우선순위가 드러나지 않았기 때문이다.
- 어떻게:
- 명령 실행 없음: 이번 단계는 문서/코드 정합성 점검 목적이라 읽기 전용 파일 검토만 수행했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt:22-25` 확인 → 동일 agentId/creatorId 거부 로직은 구현돼 있으나 직접 테스트가 없음을 확인했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:69-89``src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateControllerTest.kt:37-62` 확인 → assigned creator 정렬/페이지네이션은 이미 검증돼 있어 완료 처리했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt:6-17``src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt:23-49` 확인 → ratio 목록은 flat 응답만 제공하므로 `current/history` 요구를 충족하려면 응답 모델과 조회 로직 확장이 필요함을 확인했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:141-168,172-211` 확인 → finalized snapshot 경로가 전체 snapshot row를 메모리에서 page/total 처리함을 확인했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:131-179,182-253` 확인 → finalize summary 생성 시 creator별 재-groupBy가 한 번 더 수행되고, source detail은 집계 대신 row 변환만 반복함을 확인했다.
### 4차 totalRowsLoader 개선 방향 구체화
- 무엇을:
- `buildSettlementByCreatorResponse()``totalRowsLoader` 경로만 따로 떼어 메모리/성능 병목과 개선 우선순위를 구체화했다.
- 왜:
- 기존 성능 메모는 “live 계산 경로 전반을 DB 쿼리로 옮기자” 수준이라 범위가 넓었고, 실제로 어떤 부분을 먼저 고쳐야 효과가 큰지 드러나지 않았기 때문이다.
- 어떻게:
- 명령 실행 없음: 이번 단계도 읽기 전용 코드/문서 검토만 수행했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:200-203` 확인 → total이 `totalRowsLoader(...).toMergedResponseItems().toResponseTotal()`로 계산됨을 다시 확인했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt:17-40,60-77``src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt:33-43` 확인 → row별 금액 계산, creator별 groupBy 병합, grand total 재합산이 모두 애플리케이션 메모리에서 수행됨을 확인했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt:392-596` 확인 → generic 4종 쿼리가 assignment/agent ratio/settlement ratio 기준의 grouped row를 반환함을 확인했다.
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:145-176,328-381,421-470` 확인 → 같은 creator도 settlement ratio/assignment/agent ratio 이력 때문에 여러 row로 분할될 수 있어 total 계산이 단순 `sum(totalCan)`으로 대체되면 안 됨을 확인했다.
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt:33-54``src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt:18-38` 확인 → 이 저장소 안에 total 전용 `fetchOne()` aggregate query 선례가 이미 있음을 확인했다.
### 5차 미체크 항목 구현 및 성능 완화
- 무엇을:
- assignment/finalize 가드, calculate empty-result, snapshot pagination, finalized immutability 회귀 테스트를 추가했다.
- ratio 목록 응답을 member 단위 `current/history` 구조로 확장하고, generic total/finalize 내부 계산 경로를 가볍게 정리했다.
- 왜:
- 문서에 남아 있던 미체크 항목을 실제 테스트와 응답 계약으로 고정해야 후속 회귀를 막을 수 있고, total/finalize 경로의 불필요한 groupBy/중간 객체 생성을 줄여야 현재 범위 안에서 안전하게 성능을 완화할 수 있기 때문이다.
- 어떻게:
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest` → BUILD SUCCESSFUL
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → BUILD SUCCESSFUL
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → BUILD SUCCESSFUL
- 참고: `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 사용할 수 없었고, 대신 위 Gradle 실행에서 `compileKotlin`/`compileTestKotlin`까지 함께 통과한 것으로 컴파일 진단을 대체했다.
### 6차 generic total DB projection 리팩터링
- 무엇을:
- generic 4종(LIVE/CONTENT/COMMUNITY/CONTENT_DONATION) total 계산을 full row load 기반 Kotlin 합산에서 DB total projection 전용 쿼리로 교체했다.
- 서비스 테스트와 DataJpa parity 테스트를 추가해 새 DB total이 기존 Kotlin total과 동일한지 고정했다.
- Oracle 리뷰에서 지적된 `CONTENT` total grouping drift(`explicit 70` vs `null -> fallback 70`)를 보정하고 전용 회귀 테스트를 추가했다.
- 왜:
- 1차 완화 이후에도 total 계산은 기간 전체 grouped row를 메모리로 읽어야 했고, 이 비용은 기간이 커질수록 그대로 남았다. 요청한 방향대로 total projection을 DB로 내리면서도 row-level rounding semantics를 유지하려면 parity 테스트와 함께 옮겨야 했다.
- 어떻게:
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest` → BUILD SUCCESSFUL
- 성공: `./gradlew build` → BUILD SUCCESSFUL
- 성공(수동 확인): `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionForContentRowsSplitByEffectiveSettlementRatio --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionAcrossAllGenericCategoriesWhenAgentRatioHistorySplitsRows` → BUILD SUCCESSFUL
- 성공(Oracle 후속 보정): `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionWhenExplicitAndFallbackSeventyMustStaySeparated --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest` → BUILD SUCCESSFUL
- 성공(Oracle 후속 보정): `./gradlew build` → BUILD SUCCESSFUL
- 참고: Kotlin LSP 부재로 `lsp_diagnostics`는 여전히 실행 불가했고, 이번 단계도 `compileKotlin`/`compileTestKotlin` 포함 Gradle 결과를 타입/컴파일 진단 근거로 사용했다.
### 7차 channel donation total DB projection 리팩터링
- 무엇을:
- `AgentCalculateService.getChannelDonationByCreator()`의 total 계산을 full row load 기반 Kotlin 합산에서 DB total projection 전용 쿼리로 교체했다.
- split `useCanCalculate`와 agent 비율 이력이 섞인 채널후원에서도 새 DB total이 기존 Kotlin total과 같은지 서비스/Repository 테스트로 고정했다.
- 왜:
- generic 4종 total만 DB projection으로 내려가고 채널후원 total은 여전히 전체 row를 읽고 있었기 때문에, 동일한 최적화 방향을 채널후원에도 적용해 total 계산용 메모리 적재를 제거할 필요가 있었다.
- 어떻게:
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest` → BUILD SUCCESSFUL
- 성공: `./gradlew build && ./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionForChannelDonationWithSplitCalculatesAndRatioHistory` → BUILD SUCCESSFUL
- 참고: Kotlin LSP 부재로 `lsp_diagnostics`는 실행 불가했고, 이번 단계도 `compileKotlin`/`compileTestKotlin` 포함 Gradle 결과를 타입/컴파일 진단 근거로 사용했다.

View File

@@ -0,0 +1,24 @@
# 에이전트 검색 기능 추가
## 작업 체크리스트
- [x] 관리자 에이전트 검색 관련 기존 controller/service/query/test 패턴을 확인한다.
- [x] 에이전트 검색 API 위치를 확정하고 요청/응답 형태를 정의한다.
- [x] 에이전트 닉네임 검색 동작을 검증하는 테스트를 먼저 추가한다.
- [x] 에이전트 닉네임으로 검색해 `memberId`를 식별할 수 있는 조회 기능을 구현한다.
- [x] 관련 진단/테스트/수동 QA를 수행하고 결과를 기록한다.
## 검증 기준
- 관리자 권한 진입점에서 검색 API가 노출되어야 한다.
- 검색어로 에이전트 닉네임을 조회했을 때 UI가 사용할 식별자(`memberId`)가 응답에 포함되어야 한다.
- 기존 관리자 에이전트 read 패키지의 응답/검색 패턴을 따른다.
## 검증 기록
- 1차 구현
- 무엇을: `admin.partner.agent.read` 패키지에 에이전트 닉네임 검색 API를 추가하고 관련 controller/service/query/test를 확장했다.
- 왜: 관리자 정산 비율 입력 시 memberId 직접 입력 대신 에이전트 검색 기반 선택을 지원하기 위해
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest"` → 성공
- `./gradlew build` → 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest.shouldAllowAdminRoleForAgentNicknameSearch" --info` → 성공
- 수동 QA 확인 응답: `{"success":true,"message":null,"data":[{"id":11,"nickname":"agent-a"}],"errorProperty":null}`
- 참고: Kotlin LSP가 환경에 구성되어 있지 않아 LSP 진단 대신 Gradle compile/test/build 결과로 검증했다.

View File

@@ -0,0 +1,19 @@
# 에이전트 정산 비율 수정 오류 대응 계획
- [x] 에이전트 정산 비율 수정 API/서비스/엔티티/리포지토리 흐름을 확인한다.
- [x] `Duplicate entry '2-1' for key 'agent_settlement_ratio.uk_agent_settlement_ratio_member_active'` 발생 조건과 현재 활성 비율 갱신 방식의 충돌 지점을 확인한다.
- [x] 실패를 재현하는 테스트를 먼저 추가하고, 테스트가 의도한 이유로 실패하는지 확인한다.
- [x] 기존 활성 비율을 안전하게 종료한 뒤 새 비율을 저장하도록 최소 범위로 수정한다.
- [x] 관련 테스트와 필요한 검증 명령을 실행하고 결과를 문서 하단에 누적 기록한다.
## 검증 기록
- 1차 구현
- 무엇을: 에이전트 정산 비율 수정 시 기존 활성 row 종료를 먼저 flush하도록 바꾸고, active unique 제약 충돌 시 같은 세션 재조회 없이 비즈니스 예외로 변환하도록 수정했다.
- 왜: 기존 활성 row가 DB에 닫히기 전에 새 row insert가 먼저 flush되면 `uk_agent_settlement_ratio_member_active` 충돌이 발생하고, 그 뒤 같은 세션 재조회가 이어지면 Hibernate `null id` assertion이 연쇄로 발생할 수 있기 때문이다.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest` → 실패, 수정 전 flush 순서 검증이 깨지고 예외 후 재조회 검증도 실패해 재현 확인.
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest` → 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → 성공.
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.*"` → 성공.
- `./gradlew ktlintCheck` → 성공.

View File

@@ -0,0 +1,20 @@
# 에이전트 소속 크리에이터 프로필 이미지 추가
- [x] `getAssignedCreators` 흐름과 기존 프로필 이미지 응답 패턴을 확인한다.
- [x] `GetAgentAssignedCreatorItem`에 프로필 이미지 URL 필드를 추가한다.
- [x] `AgentCalculateQueryRepository.getAssignedCreators` projection에 프로필 이미지 URL을 포함한다.
- [x] `AgentCalculateQueryRepositoryTest`에 프로필 이미지 URL 및 기본 이미지 fallback 검증을 추가한다.
- [x] `AgentCalculateServiceTest`, `AgentCalculateControllerTest` fixture와 검증을 갱신한다.
- [x] 정적 진단과 관련 테스트를 실행해 변경을 검증한다.
## 검증 기록
### 1차 구현
- 무엇을: 에이전트 소속 크리에이터 조회 응답에 프로필 이미지 URL 필드 추가 구현 및 검증
- 왜: 크리에이터 목록 응답에서 프로필 이미지를 함께 내려주기 위해
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest"` 실행 시 `profileImageUrl``cloudFrontHost` 관련 컴파일 실패를 확인해 red 단계 검증
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateControllerTest"` 실행 결과 `BUILD SUCCESSFUL`
- `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldGetAssignedCreatorsWithProfileImageUrl" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateControllerTest.shouldForwardAssignedCreatorsRequestToService"` 실행 결과 `BUILD SUCCESSFUL`
- Kotlin LSP 서버가 현재 환경에 없어 `lsp_diagnostics`는 수행 불가였고, 대신 compile/test/ktlint 결과로 정적 검증 대체

View File

@@ -0,0 +1,51 @@
## 작업 개요
- [x] `AdminAgentCreatorService`의 소속 등록 시 `assignedAt` 입력값을 한국 시간에서 UTC로 변환해 저장한다.
- [x] `AdminAgentCreatorService`의 소속 해제 시 `unassignedAt` 입력값을 한국 시간에서 UTC로 변환해 검증 후 저장한다.
- [x] `AdminAgentCreatorServiceTest`에 한국 시간 입력이 UTC로 저장되는 회귀 테스트를 추가한다.
- [x] 변경 파일 진단과 Gradle 검증(`test`, `ktlintCheck`)을 수행하고 결과를 기록한다.
- [x] `LocalDateTimeExtensions`에 타임존 입력 기반 UTC 변환 확장함수를 추가하고 기본 타임존을 KST로 둔다.
- [x] `AdminAgentCreatorService`의 시간 변환을 확장함수 호출로 리팩터링한다.
- [x] 확장함수 테스트와 관련 서비스 테스트를 다시 검증하고 기록한다.
- [x] `AdminAgentReadService.getAssignedCreators` 응답의 `assignedAt`을 UTC에서 KST로 변환해 내려오도록 수정한다.
- [x] `AdminAgentReadServiceTest``getAssignedCreators` 응답 시간이 KST로 변환되는 회귀 테스트를 추가한다.
- [x] 변경 파일 진단과 관련 Gradle 테스트를 수행하고 결과를 기록한다.
---
## 검증 기록
### 1차 구현
- 무엇을: 관리자 에이전트-크리에이터 소속 등록/해제 시각의 UTC 저장 정합성 보정.
- 왜: 요청 시각은 한국 시간 기준인데 현재 DB에는 변환 없이 그대로 저장되어 실제 저장 기준과 맞지 않음.
- 어떻게:
- `AdminAgentCreatorServiceTest`에 등록 저장값 UTC 변환, 해제 저장값 UTC 변환, 해제 시각 비교 UTC 기준 검증 테스트를 추가했다.
- `AdminAgentCreatorService`에서 `assignedAt`/`unassignedAt`를 저장 및 비교 전에 `Asia/Seoul -> UTC`로 변환하도록 수정했다.
- `lsp_diagnostics` 실행: `.kt` 확장자용 LSP 서버 미설정으로 도구 진단 불가.
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest"` 실행: 성공(BUILD SUCCESSFUL).
- `./gradlew ktlintCheck` 실행: 성공(BUILD SUCCESSFUL).
### 2차 수정
- 무엇을: `Asia/Seoul -> UTC` 변환 책임을 `LocalDateTime` 확장함수로 이동하고 서비스는 그 확장함수를 사용하도록 리팩터링.
- 왜: 서비스 레이어의 타임존 변환 중복을 줄이고, 동일한 변환 규칙을 재사용 가능한 확장함수로 통일하기 위함.
- 어떻게:
- `LocalDateTimeExtensionsTest`에 기본 KST 변환과 사용자 지정 타임존 변환 테스트를 먼저 추가했고, 확장함수 부재 상태에서 실패함을 확인했다.
- `LocalDateTimeExtensions``convertToUtc(timeZone: ZoneId = Asia/Seoul)` 확장함수를 추가했다.
- `AdminAgentCreatorService`는 내부 변환 함수를 제거하고 `request.assignedAt.convertToUtc()` / `request.unassignedAt.convertToUtc()`를 사용하도록 수정했다.
- `./gradlew test --tests "kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensionsTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest"` 실행: 성공(BUILD SUCCESSFUL).
- `./gradlew ktlintCheck` 실행: 성공(BUILD SUCCESSFUL).
### 3차 수정
- 무엇을: 관리자 에이전트 소속 크리에이터 목록 응답의 `assignedAt`을 UTC 기준 저장값에서 KST 기준 시각으로 변환해 반환하도록 수정.
- 왜: 현재 `getAssignedCreators`는 DB에서 조회한 UTC `assignedAt`을 그대로 내려주고 있어 관리자 화면에서 소속 시각이 9시간 늦게 보일 수 있음.
- 어떻게:
- 내부 패턴 조사 결과, 저장소에서는 UTC raw 값을 유지하고 서비스/DTO 경계에서 `UTC -> Asia/Seoul` 변환을 적용하는 방식이 가장 가까운 관례임을 확인했다.
- `AdminAgentReadServiceTest``assignedAt = 2026-04-10T03:00:00` UTC 조회값이 `2026-04-10T12:00:00` KST로 반환되는 회귀 테스트를 먼저 추가했고, 수정 전 해당 테스트가 실패함을 확인했다.
- `AdminAgentReadService.getAssignedCreators`에서 조회 결과를 매핑하며 `assignedAt.atZone(UTC).withZoneSameInstant(Asia/Seoul).toLocalDateTime()`으로 변환하도록 수정했다.
- `lsp_diagnostics` 실행: `.kt` 확장자용 LSP 서버 미설정으로 도구 진단 불가.
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest.shouldConvertAssignedAtToKstWhenGettingAssignedCreators"` 실행: 최초 실패 후 수정 뒤 성공(BUILD SUCCESSFUL).
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest"` 실행: 성공(BUILD SUCCESSFUL).
- `./gradlew ktlintCheck` 실행: 성공(BUILD SUCCESSFUL).

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.admin.partner.agent.assignment
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest
import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest
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/partner/agent/assignment")
class AdminAgentCreatorController(private val service: AdminAgentCreatorService) {
@PostMapping
fun assignCreator(@RequestBody request: AssignAgentCreatorRequest) = ApiResponse.ok(service.assignCreator(request))
@PostMapping("/remove")
fun removeCreator(@RequestBody request: RemoveAgentCreatorRequest) = ApiResponse.ok(service.removeCreator(request))
}

View File

@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.admin.partner.agent.assignment
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.convertToUtc
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository
import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest
import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class AdminAgentCreatorService(
private val relationRepository: AgentCreatorRelationRepository,
private val memberRepository: MemberRepository
) {
@Transactional
fun assignCreator(request: AssignAgentCreatorRequest) {
if (request.agentId == request.creatorId) {
throw SodaException(messageKey = "partner.agent.assignment.invalid_relation")
}
val agent = memberRepository.findByIdOrNull(request.agentId)
?: throw SodaException(messageKey = "partner.agent.assignment.agent_not_found")
if (agent.role != MemberRole.AGENT) {
throw SodaException(messageKey = "partner.agent.assignment.invalid_agent")
}
val creator = memberRepository.findByIdForUpdate(request.creatorId)
?: throw SodaException(messageKey = "partner.agent.assignment.creator_not_found")
if (creator.role != MemberRole.CREATOR) {
throw SodaException(messageKey = "partner.agent.assignment.invalid_creator")
}
val existingRelations = relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(request.creatorId)
val assignedAt = request.assignedAt.convertToUtc()
if (hasAssignmentOverlap(existingRelations, assignedAt)) {
throw SodaException(messageKey = "partner.agent.assignment.assignment_overlap")
}
val relation = AgentCreatorRelation()
relation.agent = agent
relation.creator = creator
relation.assignedAt = assignedAt
try {
relationRepository.saveAndFlush(relation)
} catch (e: DataIntegrityViolationException) {
relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(request.creatorId)
?: throw e
throw SodaException(messageKey = "partner.agent.assignment.assignment_overlap")
}
}
@Transactional
fun removeCreator(request: RemoveAgentCreatorRequest) {
val creator = memberRepository.findByIdForUpdate(request.creatorId)
?: throw SodaException(messageKey = "partner.agent.assignment.creator_not_found")
if (creator.role != MemberRole.CREATOR) {
throw SodaException(messageKey = "partner.agent.assignment.invalid_creator")
}
val relation = relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(request.creatorId)
?: throw SodaException(messageKey = "partner.agent.assignment.not_found")
val assignedAt = relation.assignedAt
?: throw SodaException(messageKey = "partner.agent.assignment.not_found")
val unassignedAt = request.unassignedAt.convertToUtc()
if (!unassignedAt.isAfter(assignedAt)) {
throw SodaException(messageKey = "partner.agent.assignment.invalid_unassigned_at")
}
relation.unassignedAt = unassignedAt
relationRepository.save(relation)
}
private fun hasAssignmentOverlap(
existingRelations: List<AgentCreatorRelation>,
assignedAt: LocalDateTime
): Boolean {
return existingRelations.any { relation ->
val existingUnassignedAt = relation.unassignedAt
existingUnassignedAt == null || assignedAt.isBefore(existingUnassignedAt)
}
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.admin.partner.agent.ratio
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.partner.agent.ratio.CreateAgentSettlementRatioRequest
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.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/partner/agent/ratio")
class AdminAgentSettlementRatioController(private val service: AdminAgentSettlementRatioService) {
@PostMapping
fun createAgentSettlementRatio(@RequestBody request: CreateAgentSettlementRatioRequest) =
ApiResponse.ok(service.createAgentSettlementRatio(request))
@PostMapping("/update")
fun updateAgentSettlementRatio(@RequestBody request: CreateAgentSettlementRatioRequest) =
ApiResponse.ok(service.updateAgentSettlementRatio(request))
@GetMapping
fun getAgentSettlementRatio(pageable: Pageable) = ApiResponse.ok(
service.getAgentSettlementRatio(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}

View File

@@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.admin.partner.agent.ratio
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatio
import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatioRepository
import kr.co.vividnext.sodalive.partner.agent.ratio.CreateAgentSettlementRatioRequest
import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioResponse
import kr.co.vividnext.sodalive.partner.agent.ratio.toGroupedResponseItems
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class AdminAgentSettlementRatioService(
private val repository: AgentSettlementRatioRepository,
private val memberRepository: MemberRepository
) {
@Transactional
fun createAgentSettlementRatio(request: CreateAgentSettlementRatioRequest) {
validateSettlementRatio(request.settlementRatio)
val agent = getAgent(request.memberId)
closeCurrentRatioIfExists(request)
validateClosedHistory(request)
val ratio = request.toEntity()
ratio.member = agent
saveRatioOrThrow(ratio)
}
@Transactional
fun updateAgentSettlementRatio(request: CreateAgentSettlementRatioRequest) {
validateSettlementRatio(request.settlementRatio)
val agent = getAgent(request.memberId)
val existing = repository.findFirstByMemberIdAndEffectiveToIsNull(request.memberId)
?: throw SodaException(messageKey = "partner.agent.ratio.not_found")
validateNewEffectiveFrom(existing.effectiveFrom, request.effectiveFrom)
existing.close(request.effectiveFrom)
repository.saveAndFlush(existing)
val ratio = request.toEntity()
ratio.member = agent
saveRatioOrThrow(ratio)
}
@Transactional(readOnly = true)
fun getAgentSettlementRatio(offset: Long, limit: Long): GetAgentSettlementRatioResponse {
val totalCount = repository.getAgentSettlementRatioTotalCount()
val items = repository.getAgentSettlementRatio(offset = offset, limit = limit)
.toGroupedResponseItems()
return GetAgentSettlementRatioResponse(totalCount = totalCount, items = items)
}
private fun getAgent(memberId: Long) = memberRepository.findByIdForUpdate(memberId)
?.also {
if (it.role != MemberRole.AGENT) {
throw SodaException(messageKey = "partner.agent.ratio.invalid_agent")
}
}
?: throw SodaException(messageKey = "partner.agent.ratio.agent_not_found")
private fun closeCurrentRatioIfExists(request: CreateAgentSettlementRatioRequest) {
val existing = repository.findFirstByMemberIdAndEffectiveToIsNull(request.memberId) ?: return
validateNewEffectiveFrom(existing.effectiveFrom, request.effectiveFrom)
existing.close(request.effectiveFrom)
repository.save(existing)
}
private fun validateNewEffectiveFrom(
currentEffectiveFrom: LocalDateTime,
newEffectiveFrom: LocalDateTime
) {
if (!newEffectiveFrom.isAfter(currentEffectiveFrom)) {
throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from")
}
}
private fun validateClosedHistory(request: CreateAgentSettlementRatioRequest) {
val hasOverlap = repository.findAllByMemberIdOrderByEffectiveFromAsc(request.memberId)
.any { history ->
val effectiveTo = history.effectiveTo ?: return@any false
!request.effectiveFrom.isBefore(history.effectiveFrom) && request.effectiveFrom.isBefore(effectiveTo)
}
if (hasOverlap) {
throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from")
}
}
private fun validateSettlementRatio(settlementRatio: Int) {
if (settlementRatio !in 0..100) {
throw SodaException(messageKey = "common.error.invalid_request")
}
}
private fun saveRatioOrThrow(ratio: AgentSettlementRatio) {
try {
repository.saveAndFlush(ratio)
} catch (e: DataIntegrityViolationException) {
if (isActiveRatioConflict(e)) {
throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from")
}
throw e
}
}
private fun isActiveRatioConflict(exception: Throwable): Boolean {
return generateSequence(exception) { it.cause }
.mapNotNull { it.message }
.any { it.contains("uk_agent_settlement_ratio_member_active") }
}
}

View File

@@ -0,0 +1,127 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
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.PathVariable
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/partner/agent")
class AdminAgentReadController(
private val service: AdminAgentReadService
) {
@GetMapping("/list")
fun getAgentList(pageable: Pageable) = ApiResponse.ok(
service.getAgentList(offset = pageable.offset, limit = pageable.pageSize.toLong())
)
@GetMapping("/creator/search")
fun searchAssignableCreators(
@RequestParam(value = "search_word") searchWord: String,
pageable: Pageable
) = ApiResponse.ok(
service.searchAssignableCreators(searchWord = searchWord, offset = pageable.offset, limit = pageable.pageSize.toLong())
)
@GetMapping("/search-by-nickname")
fun searchAgentByNickname(
@RequestParam(value = "search_word") searchWord: String,
@RequestParam(value = "size", required = false) size: Int?
): ApiResponse<List<AdminSimpleMemberResponse>> = ApiResponse.ok(
service.searchAgentByNickname(searchWord = searchWord, size = size ?: 20)
)
@GetMapping("/{agentId}/creator/list")
fun getAssignedCreators(
@PathVariable agentId: Long,
pageable: Pageable
) = ApiResponse.ok(
service.getAssignedCreators(agentId = agentId, offset = pageable.offset, limit = pageable.pageSize.toLong())
)
@GetMapping("/{agentId}/calculate/live-by-creator")
fun getCalculateLiveByCreator(
@PathVariable agentId: Long,
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getCalculateLiveByCreator(
agentId,
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
@GetMapping("/{agentId}/calculate/content-by-creator")
fun getCalculateContentByCreator(
@PathVariable agentId: Long,
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getCalculateContentByCreator(
agentId,
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
@GetMapping("/{agentId}/calculate/community-by-creator")
fun getCalculateCommunityByCreator(
@PathVariable agentId: Long,
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getCalculateCommunityByCreator(
agentId,
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
@GetMapping("/{agentId}/calculate/channel-donation-by-creator")
fun getChannelDonationByCreator(
@PathVariable agentId: Long,
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getChannelDonationByCreator(
agentId,
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
@GetMapping("/{agentId}/calculate/content-donation-by-creator")
fun getCalculateContentDonationByCreator(
@PathVariable agentId: Long,
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
pageable: Pageable
) = ApiResponse.ok(
service.getCalculateContentDonationByCreator(
agentId,
startDateStr,
endDateStr,
pageable.offset,
pageable.pageSize.toLong()
)
)
}

View File

@@ -0,0 +1,196 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.admin.member.QAdminSimpleMemberResponse
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.partner.agent.assignment.QAgentCreatorRelation.agentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
@Repository
class AdminAgentReadQueryRepository(
private val queryFactory: JPAQueryFactory,
private val calculateQueryRepository: AgentCalculateQueryRepository
) {
private val kstZoneId = ZoneId.of("Asia/Seoul")
private val utcZoneId = ZoneId.of("UTC")
fun getAgentListTotalCount(): Int {
return queryFactory
.select(member.id.count())
.from(member)
.where(member.role.eq(MemberRole.AGENT))
.fetchOne()
?.toInt()
?: 0
}
fun getAgentList(offset: Long, limit: Long, currentTime: ZonedDateTime): List<GetAdminAgentListItem> {
val kstCurrentTime = currentTime.withZoneSameInstant(kstZoneId)
val currentMonthStartKst = kstCurrentTime.toLocalDate().withDayOfMonth(1).atStartOfDay(kstZoneId)
val nextMonthStartKst = currentMonthStartKst.plusMonths(1)
val currentMonthStart = currentMonthStartKst.withZoneSameInstant(utcZoneId).toLocalDateTime()
val currentMonthEnd = nextMonthStartKst.withZoneSameInstant(utcZoneId).toLocalDateTime().minusNanos(1)
return queryFactory
.select(
QGetAdminAgentListItem(
member.id,
member.nickname,
agentCreatorRelation.id.countDistinct().intValue(),
Expressions.numberTemplate(Int::class.java, "0"),
Expressions.numberTemplate(Int::class.java, "0"),
Expressions.numberTemplate(Int::class.java, "0"),
Expressions.numberTemplate(Int::class.java, "0"),
Expressions.numberTemplate(Int::class.java, "0")
)
)
.from(member)
.leftJoin(agentCreatorRelation)
.on(
agentCreatorRelation.agent.id.eq(member.id)
.and(isActiveAssignment(kstCurrentTime.toLocalDateTime()))
)
.where(member.role.eq(MemberRole.AGENT))
.groupBy(member.id, member.nickname)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
.map { item ->
item.copy(
liveAgentSettlementAmount = calculateQueryRepository
.getCalculateLiveByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId)
.agentSettlementAmount,
contentAgentSettlementAmount = calculateQueryRepository
.getCalculateContentByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId)
.agentSettlementAmount,
communityAgentSettlementAmount = calculateQueryRepository
.getCalculateCommunityByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId)
.agentSettlementAmount,
contentDonationAgentSettlementAmount = calculateQueryRepository
.getCalculateContentDonationByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId)
.agentSettlementAmount,
channelDonationAgentSettlementAmount = calculateQueryRepository
.getChannelDonationByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId)
.agentSettlementAmount
)
}
}
fun searchAssignableCreatorsTotalCount(searchWord: String, currentTime: LocalDateTime): Int {
return queryFactory
.select(member.id.countDistinct())
.from(member)
.leftJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(isActiveAssignment(currentTime))
)
.where(
member.role.eq(MemberRole.CREATOR)
.and(member.nickname.contains(searchWord))
)
.fetchOne()
?.toInt()
?: 0
}
fun searchAssignableCreators(
searchWord: String,
offset: Long,
limit: Long,
currentTime: LocalDateTime
): List<SearchAdminAgentAssignableCreatorItem> {
val currentAgent = QMember("currentAgent")
return queryFactory
.select(
QSearchAdminAgentAssignableCreatorItem(
member.id,
member.nickname,
currentAgent.id,
currentAgent.nickname
)
)
.from(member)
.leftJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(isActiveAssignment(currentTime))
)
.leftJoin(agentCreatorRelation.agent, currentAgent)
.where(
member.role.eq(MemberRole.CREATOR)
.and(member.nickname.contains(searchWord))
)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
}
fun searchAgentByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
return queryFactory
.select(
QAdminSimpleMemberResponse(
member.id,
member.nickname
)
)
.from(member)
.where(
member.role.eq(MemberRole.AGENT)
.and(member.nickname.contains(searchWord))
.and(member.isActive.isTrue)
)
.orderBy(member.id.desc())
.limit(limit)
.fetch()
}
fun getAssignedCreatorTotalCount(agentId: Long, currentTime: LocalDateTime): Int {
return queryFactory
.select(agentCreatorRelation.id.count())
.from(agentCreatorRelation)
.where(agentCreatorRelation.agent.id.eq(agentId).and(isActiveAssignment(currentTime)))
.fetchOne()
?.toInt()
?: 0
}
fun getAssignedCreators(
agentId: Long,
offset: Long,
limit: Long,
currentTime: LocalDateTime
): List<GetAdminAgentAssignedCreatorItem> {
return queryFactory
.select(
QGetAdminAgentAssignedCreatorItem(
agentCreatorRelation.creator.id,
agentCreatorRelation.creator.nickname,
agentCreatorRelation.assignedAt
)
)
.from(agentCreatorRelation)
.where(agentCreatorRelation.agent.id.eq(agentId).and(isActiveAssignment(currentTime)))
.orderBy(agentCreatorRelation.creator.id.desc())
.offset(offset)
.limit(limit)
.fetch()
}
private fun isActiveAssignment(currentTime: LocalDateTime): BooleanExpression {
return agentCreatorRelation.assignedAt.loe(currentTime)
.and(agentCreatorRelation.unassignedAt.isNull.or(agentCreatorRelation.unassignedAt.gt(currentTime)))
}
}

View File

@@ -0,0 +1,144 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
@Service
class AdminAgentReadService(
private val queryRepository: AdminAgentReadQueryRepository,
private val memberRepository: MemberRepository,
private val calculateService: AgentCalculateService
) {
private val kstZoneId = ZoneId.of("Asia/Seoul")
@Transactional(readOnly = true)
fun getAgentList(offset: Long, limit: Long): GetAdminAgentListResponse {
val now = ZonedDateTime.now(kstZoneId)
return GetAdminAgentListResponse(
totalCount = queryRepository.getAgentListTotalCount(),
items = queryRepository.getAgentList(offset = offset, limit = limit, currentTime = now)
)
}
@Transactional(readOnly = true)
fun searchAssignableCreators(searchWord: String, offset: Long, limit: Long): SearchAdminAgentAssignableCreatorResponse {
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
val now = LocalDateTime.now()
return SearchAdminAgentAssignableCreatorResponse(
totalCount = queryRepository.searchAssignableCreatorsTotalCount(searchWord = searchWord, currentTime = now),
items = queryRepository.searchAssignableCreators(
searchWord = searchWord,
offset = offset,
limit = limit,
currentTime = now
)
)
}
@Transactional(readOnly = true)
fun searchAgentByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
val limit = if (size <= 0) 20 else size
return queryRepository.searchAgentByNickname(searchWord = searchWord, limit = limit.toLong())
}
@Transactional(readOnly = true)
fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAdminAgentAssignedCreatorResponse {
validateAgent(agentId)
val now = LocalDateTime.now()
val items = queryRepository.getAssignedCreators(agentId = agentId, offset = offset, limit = limit, currentTime = now)
.map {
it.copy(
assignedAt = it.assignedAt
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(kstZoneId)
.toLocalDateTime()
)
}
return GetAdminAgentAssignedCreatorResponse(
totalCount = queryRepository.getAssignedCreatorTotalCount(agentId = agentId, currentTime = now),
items = items
)
}
@Transactional(readOnly = true)
fun getCalculateLiveByCreator(
agentId: Long,
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
validateAgent(agentId)
return calculateService.getCalculateLiveByCreator(startDateStr, endDateStr, agentId, offset, limit)
}
@Transactional(readOnly = true)
fun getCalculateContentByCreator(
agentId: Long,
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
validateAgent(agentId)
return calculateService.getCalculateContentByCreator(startDateStr, endDateStr, agentId, offset, limit)
}
@Transactional(readOnly = true)
fun getCalculateCommunityByCreator(
agentId: Long,
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
validateAgent(agentId)
return calculateService.getCalculateCommunityByCreator(startDateStr, endDateStr, agentId, offset, limit)
}
@Transactional(readOnly = true)
fun getCalculateContentDonationByCreator(
agentId: Long,
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
validateAgent(agentId)
return calculateService.getCalculateContentDonationByCreator(startDateStr, endDateStr, agentId, offset, limit)
}
@Transactional(readOnly = true)
fun getChannelDonationByCreator(
agentId: Long,
startDateStr: String,
endDateStr: String,
offset: Long,
limit: Long
): GetAgentChannelDonationSettlementByCreatorResponse {
validateAgent(agentId)
return calculateService.getChannelDonationByCreator(startDateStr, endDateStr, agentId, offset, limit)
}
private fun validateAgent(agentId: Long) {
val member = memberRepository.findById(agentId).orElseThrow {
SodaException(messageKey = "partner.agent.ratio.agent_not_found")
}
if (member.role != MemberRole.AGENT) {
throw SodaException(messageKey = "partner.agent.ratio.invalid_agent")
}
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.core.annotations.QueryProjection
import java.time.LocalDateTime
data class GetAdminAgentAssignedCreatorResponse(
val totalCount: Int,
val items: List<GetAdminAgentAssignedCreatorItem>
)
data class GetAdminAgentAssignedCreatorItem @QueryProjection constructor(
val creatorId: Long,
val creatorNickname: String,
val assignedAt: LocalDateTime
)

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.core.annotations.QueryProjection
data class GetAdminAgentListResponse(
val totalCount: Int,
val items: List<GetAdminAgentListItem>
)
data class GetAdminAgentListItem @QueryProjection constructor(
val agentId: Long,
val agentNickname: String,
val assignedCreatorCount: Int,
val liveAgentSettlementAmount: Int = 0,
val contentAgentSettlementAmount: Int = 0,
val communityAgentSettlementAmount: Int = 0,
val contentDonationAgentSettlementAmount: Int = 0,
val channelDonationAgentSettlementAmount: Int = 0
)

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.core.annotations.QueryProjection
data class SearchAdminAgentAssignableCreatorResponse(
val totalCount: Int,
val items: List<SearchAdminAgentAssignableCreatorItem>
)
data class SearchAdminAgentAssignableCreatorItem @QueryProjection constructor(
val creatorId: Long,
val creatorNickname: String,
val currentAgentId: Long?,
val currentAgentNickname: String?
)

View File

@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.admin.partner.agent.settlement
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
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/partner/agent/settlement")
class AdminAgentSettlementSnapshotController(private val service: AdminAgentSettlementSnapshotService) {
@PostMapping("/finalize")
fun finalizeSettlement(
@RequestBody request: FinalizeAgentSettlementSnapshotRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val admin = member ?: throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.finalizeSnapshots(request, admin.id!!))
}
}

View File

@@ -0,0 +1,381 @@
package kr.co.vividnext.sodalive.admin.partner.agent.settlement
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorItem
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorQueryData
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentCreatorSettlementSummaryQueryData
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshot
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotSourceDetail
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotSourceDetailRepository
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotResponse
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class AdminAgentSettlementSnapshotService(
private val snapshotRepository: AgentSettlementSnapshotRepository,
private val sourceDetailRepository: AgentSettlementSnapshotSourceDetailRepository,
private val calculateRepository: AgentCalculateQueryRepository,
private val memberRepository: MemberRepository
) {
private data class SourceDetailDraft(
val creatorId: Long,
val assignmentId: Long?,
val agentSettlementRatioId: Long?,
val appliedAgentSettlementRatio: Int?,
val count: Int,
val totalCan: Int,
val krw: Int,
val fee: Int,
val settlementAmount: Int,
val tax: Int,
val depositAmount: Int,
val agentSettlementAmount: Int
)
private data class SnapshotFinalizeDraft(
val snapshots: List<AgentSettlementSnapshot>,
val sourceDetailsByCreatorId: Map<Long, List<SourceDetailDraft>>
)
private data class SnapshotAggregateDraft(
val creatorId: Long,
val creatorNickname: String,
var assignmentId: Long? = null,
var agentSettlementRatioId: Long? = null,
var appliedAgentSettlementRatio: Int? = null,
var sourceRowCount: Int = 0,
var count: Int = 0,
var totalCan: Int = 0,
var krw: Int = 0,
var fee: Int = 0,
var settlementAmount: Int = 0,
var tax: Int = 0,
var depositAmount: Int = 0,
var agentSettlementAmount: Int = 0,
val sourceDetails: MutableList<SourceDetailDraft> = mutableListOf()
) {
fun add(
assignmentId: Long?,
agentSettlementRatioId: Long?,
appliedAgentSettlementRatio: Int?,
count: Int,
totalCan: Int,
krw: Int,
fee: Int,
settlementAmount: Int,
tax: Int,
depositAmount: Int,
agentSettlementAmount: Int,
sourceDetail: SourceDetailDraft
) {
sourceRowCount += 1
this.assignmentId = if (sourceRowCount == 1) assignmentId else null
this.agentSettlementRatioId = if (sourceRowCount == 1) agentSettlementRatioId else null
this.appliedAgentSettlementRatio = if (sourceRowCount == 1) appliedAgentSettlementRatio else null
this.count += count
this.totalCan += totalCan
this.krw += krw
this.fee += fee
this.settlementAmount += settlementAmount
this.tax += tax
this.depositAmount += depositAmount
this.agentSettlementAmount += agentSettlementAmount
sourceDetails.add(sourceDetail)
}
}
@Transactional
fun finalizeSnapshots(
request: FinalizeAgentSettlementSnapshotRequest,
finalizedByMemberId: Long
): FinalizeAgentSettlementSnapshotResponse {
val (startDate, endDate) = request.toDateRange()
if (
snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
periodStart = startDate,
periodEnd = endDate,
settlementType = request.settlementType,
agentId = request.agentId
)
) {
return FinalizeAgentSettlementSnapshotResponse(finalizedCount = 0, alreadyFinalized = true)
}
val agent = getAgent(request.agentId)
val finalizedAt = LocalDateTime.now()
val draft = when (request.settlementType) {
AgentSettlementSnapshotType.LIVE -> buildGenericSnapshots(
request = request,
agent = agent,
finalizedAt = finalizedAt,
finalizedByMemberId = finalizedByMemberId,
rows = calculateRepository.getCalculateLiveByCreator(startDate, endDate, request.agentId)
)
AgentSettlementSnapshotType.CONTENT -> buildGenericSnapshots(
request = request,
agent = agent,
finalizedAt = finalizedAt,
finalizedByMemberId = finalizedByMemberId,
rows = calculateRepository.getCalculateContentByCreator(startDate, endDate, request.agentId)
)
AgentSettlementSnapshotType.COMMUNITY -> buildGenericSnapshots(
request = request,
agent = agent,
finalizedAt = finalizedAt,
finalizedByMemberId = finalizedByMemberId,
rows = calculateRepository.getCalculateCommunityByCreator(startDate, endDate, request.agentId)
)
AgentSettlementSnapshotType.CONTENT_DONATION -> buildGenericSnapshots(
request = request,
agent = agent,
finalizedAt = finalizedAt,
finalizedByMemberId = finalizedByMemberId,
rows = calculateRepository.getCalculateContentDonationByCreator(startDate, endDate, request.agentId)
)
AgentSettlementSnapshotType.CHANNEL_DONATION -> buildChannelSnapshots(
request = request,
agent = agent,
finalizedAt = finalizedAt,
finalizedByMemberId = finalizedByMemberId,
rows = calculateRepository.getChannelDonationByCreator(startDate, endDate, request.agentId)
)
}
if (draft.snapshots.isNotEmpty()) {
snapshotRepository.saveAll(draft.snapshots)
sourceDetailRepository.saveAll(buildSourceDetails(draft))
}
return FinalizeAgentSettlementSnapshotResponse(finalizedCount = draft.snapshots.size, alreadyFinalized = false)
}
private fun getAgent(agentId: Long): Member {
val agent = memberRepository.findByIdOrNull(agentId)
?: throw SodaException(messageKey = "partner.agent.ratio.agent_not_found")
if (agent.role != MemberRole.AGENT) {
throw SodaException(messageKey = "partner.agent.ratio.invalid_agent")
}
return agent
}
private fun buildGenericSnapshots(
request: FinalizeAgentSettlementSnapshotRequest,
agent: Member,
finalizedAt: LocalDateTime,
finalizedByMemberId: Long,
rows: List<GetAgentCreatorSettlementSummaryQueryData>
): SnapshotFinalizeDraft {
val (startDate, endDate) = request.toDateRange()
val aggregateDrafts = linkedMapOf<Long, SnapshotAggregateDraft>()
rows.forEach { row ->
val item = row.toResponseItem()
val sourceDetail = item.toSourceDetailDraft(
assignmentId = row.assignmentId,
agentSettlementRatioId = row.agentSettlementRatioId,
appliedAgentSettlementRatio = row.agentSettlementRatio
)
val aggregate = aggregateDrafts.getOrPut(row.creatorId) {
SnapshotAggregateDraft(
creatorId = row.creatorId,
creatorNickname = item.creatorNickname
)
}
aggregate.add(
assignmentId = row.assignmentId,
agentSettlementRatioId = row.agentSettlementRatioId,
appliedAgentSettlementRatio = row.agentSettlementRatio,
count = item.count,
totalCan = item.totalCan,
krw = item.krw,
fee = item.fee,
settlementAmount = item.settlementAmount,
tax = item.tax,
depositAmount = item.depositAmount,
agentSettlementAmount = item.agentSettlementAmount,
sourceDetail = sourceDetail
)
}
val snapshots = aggregateDrafts.values.map { aggregate ->
AgentSettlementSnapshot(
periodStart = startDate,
periodEnd = endDate,
settlementType = request.settlementType,
agentId = agent.id!!,
agentNickname = agent.nickname,
creatorId = aggregate.creatorId,
creatorNickname = aggregate.creatorNickname,
assignmentId = aggregate.assignmentId,
agentSettlementRatioId = aggregate.agentSettlementRatioId,
appliedAgentSettlementRatio = aggregate.appliedAgentSettlementRatio,
count = aggregate.count,
totalCan = aggregate.totalCan,
krw = aggregate.krw,
fee = aggregate.fee,
settlementAmount = aggregate.settlementAmount,
tax = aggregate.tax,
depositAmount = aggregate.depositAmount,
agentSettlementAmount = aggregate.agentSettlementAmount,
finalizedAt = finalizedAt,
finalizedByMemberId = finalizedByMemberId
)
}
return SnapshotFinalizeDraft(
snapshots = snapshots,
sourceDetailsByCreatorId = aggregateDrafts.mapValues { (_, aggregate) ->
aggregate.sourceDetails.toList()
}
)
}
private fun buildChannelSnapshots(
request: FinalizeAgentSettlementSnapshotRequest,
agent: Member,
finalizedAt: LocalDateTime,
finalizedByMemberId: Long,
rows: List<GetAgentChannelDonationSettlementByCreatorQueryData>
): SnapshotFinalizeDraft {
val (startDate, endDate) = request.toDateRange()
val aggregateDrafts = linkedMapOf<Long, SnapshotAggregateDraft>()
rows.forEach { row ->
val item = row.toResponseItem()
val sourceDetail = item.toSourceDetailDraft(
assignmentId = row.assignmentId,
agentSettlementRatioId = row.agentSettlementRatioId,
appliedAgentSettlementRatio = row.agentSettlementRatio
)
val aggregate = aggregateDrafts.getOrPut(row.creatorId) {
SnapshotAggregateDraft(
creatorId = row.creatorId,
creatorNickname = item.creatorNickname
)
}
aggregate.add(
assignmentId = row.assignmentId,
agentSettlementRatioId = row.agentSettlementRatioId,
appliedAgentSettlementRatio = row.agentSettlementRatio,
count = item.count,
totalCan = item.totalCan,
krw = item.krw,
fee = item.fee,
settlementAmount = item.settlementAmount,
tax = item.withholdingTax,
depositAmount = item.depositAmount,
agentSettlementAmount = item.agentSettlementAmount,
sourceDetail = sourceDetail
)
}
val snapshots = aggregateDrafts.values.map { aggregate ->
AgentSettlementSnapshot(
periodStart = startDate,
periodEnd = endDate,
settlementType = request.settlementType,
agentId = agent.id!!,
agentNickname = agent.nickname,
creatorId = aggregate.creatorId,
creatorNickname = aggregate.creatorNickname,
assignmentId = aggregate.assignmentId,
agentSettlementRatioId = aggregate.agentSettlementRatioId,
appliedAgentSettlementRatio = aggregate.appliedAgentSettlementRatio,
count = aggregate.count,
totalCan = aggregate.totalCan,
krw = aggregate.krw,
fee = aggregate.fee,
settlementAmount = aggregate.settlementAmount,
tax = aggregate.tax,
depositAmount = aggregate.depositAmount,
agentSettlementAmount = aggregate.agentSettlementAmount,
finalizedAt = finalizedAt,
finalizedByMemberId = finalizedByMemberId
)
}
return SnapshotFinalizeDraft(
snapshots = snapshots,
sourceDetailsByCreatorId = aggregateDrafts.mapValues { (_, aggregate) ->
aggregate.sourceDetails.toList()
}
)
}
private fun buildSourceDetails(draft: SnapshotFinalizeDraft): List<AgentSettlementSnapshotSourceDetail> {
val snapshotsByCreatorId = draft.snapshots.associateBy { it.creatorId }
return draft.sourceDetailsByCreatorId.flatMap { (creatorId, sourceDetails) ->
val snapshot = snapshotsByCreatorId.getValue(creatorId)
sourceDetails.map { detail ->
AgentSettlementSnapshotSourceDetail(
snapshot = snapshot,
assignmentId = detail.assignmentId,
agentSettlementRatioId = detail.agentSettlementRatioId,
appliedAgentSettlementRatio = detail.appliedAgentSettlementRatio,
count = detail.count,
totalCan = detail.totalCan,
krw = detail.krw,
fee = detail.fee,
settlementAmount = detail.settlementAmount,
tax = detail.tax,
depositAmount = detail.depositAmount,
agentSettlementAmount = detail.agentSettlementAmount
)
}
}
}
private fun GetAgentSettlementByCreatorItem.toSourceDetailDraft(
assignmentId: Long?,
agentSettlementRatioId: Long?,
appliedAgentSettlementRatio: Int?
) = SourceDetailDraft(
creatorId = creatorId,
assignmentId = assignmentId,
agentSettlementRatioId = agentSettlementRatioId,
appliedAgentSettlementRatio = appliedAgentSettlementRatio,
count = count,
totalCan = totalCan,
krw = krw,
fee = fee,
settlementAmount = settlementAmount,
tax = tax,
depositAmount = depositAmount,
agentSettlementAmount = agentSettlementAmount
)
private fun GetAgentChannelDonationSettlementByCreatorItem.toSourceDetailDraft(
assignmentId: Long?,
agentSettlementRatioId: Long?,
appliedAgentSettlementRatio: Int?
) = SourceDetailDraft(
creatorId = creatorId,
assignmentId = assignmentId,
agentSettlementRatioId = agentSettlementRatioId,
appliedAgentSettlementRatio = appliedAgentSettlementRatio,
count = count,
totalCan = totalCan,
krw = krw,
fee = fee,
settlementAmount = settlementAmount,
tax = withholdingTax,
depositAmount = depositAmount,
agentSettlementAmount = agentSettlementAmount
)
}

View File

@@ -70,7 +70,7 @@ class CreatorAdminMemberService(
throw SodaException(messageKey = "creator.admin.member.inactive_account")
}
if (member.role != MemberRole.CREATOR) {
if (member.role != MemberRole.CREATOR && member.role != MemberRole.AGENT) {
throw SodaException(messageKey = "common.error.bad_credentials")
}

View File

@@ -2,6 +2,10 @@ package kr.co.vividnext.sodalive.extensions
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC")
fun LocalDateTime.getTimeAgoString(): String {
val now = LocalDateTime.now()
@@ -16,3 +20,9 @@ fun LocalDateTime.getTimeAgoString(): String {
else -> "${duration.toDays() / 365}년전"
}
}
fun LocalDateTime.convertToUtc(timeZone: ZoneId = DEFAULT_KST_ZONE_ID): LocalDateTime {
return atZone(timeZone)
.withZoneSameInstant(UTC_ZONE_ID)
.toLocalDateTime()
}

View File

@@ -1047,6 +1047,74 @@ class SodaMessageSource {
)
)
private val partnerAgentMessages = mapOf(
"partner.agent.assignment.invalid_relation" to mapOf(
Lang.KO to "에이전트와 크리에이터는 같은 계정일 수 없습니다.",
Lang.EN to "Agent and creator cannot be the same account.",
Lang.JA to "エージェントとクリエイターは同じアカウントにできません。"
),
"partner.agent.assignment.agent_not_found" to mapOf(
Lang.KO to "해당 에이전트가 없습니다.",
Lang.EN to "Agent not found.",
Lang.JA to "該当するエージェントがいません。"
),
"partner.agent.assignment.invalid_agent" to mapOf(
Lang.KO to "올바른 에이전트를 선택해 주세요.",
Lang.EN to "Please select a valid agent.",
Lang.JA to "正しいエージェントを選択してください。"
),
"partner.agent.assignment.creator_not_found" to mapOf(
Lang.KO to "해당 크리에이터가 없습니다.",
Lang.EN to "Creator not found.",
Lang.JA to "該当するクリエイターがいません。"
),
"partner.agent.assignment.invalid_creator" to mapOf(
Lang.KO to "올바른 크리에이터를 선택해 주세요.",
Lang.EN to "Please select a valid creator.",
Lang.JA to "正しいクリエイターを選択してください。"
),
"partner.agent.assignment.creator_already_assigned" to mapOf(
Lang.KO to "이미 다른 에이전트에 소속된 크리에이터입니다.",
Lang.EN to "This creator is already assigned to another agent.",
Lang.JA to "すでに別のエージェントに所属しているクリエイターです。"
),
"partner.agent.assignment.assignment_overlap" to mapOf(
Lang.KO to "지정한 시각에 겹치는 에이전트 소속 이력이 이미 있습니다.",
Lang.EN to "An overlapping agent assignment already exists for the specified time.",
Lang.JA to "指定した時刻に重複するエージェント所属履歴が既に存在します。"
),
"partner.agent.assignment.invalid_unassigned_at" to mapOf(
Lang.KO to "소속 종료 시각은 시작 시각보다 늦어야 합니다.",
Lang.EN to "The unassigned time must be later than the assigned time.",
Lang.JA to "所属終了時刻は所属開始時刻より後でなければなりません。"
),
"partner.agent.assignment.not_found" to mapOf(
Lang.KO to "해당 소속 정보가 없습니다.",
Lang.EN to "Assignment not found.",
Lang.JA to "該当する所属情報がありません。"
),
"partner.agent.ratio.agent_not_found" to mapOf(
Lang.KO to "해당 에이전트가 없습니다.",
Lang.EN to "Agent not found.",
Lang.JA to "該当するエージェントがいません。"
),
"partner.agent.ratio.invalid_agent" to mapOf(
Lang.KO to "올바른 에이전트를 선택해 주세요.",
Lang.EN to "Please select a valid agent.",
Lang.JA to "正しいエージェントを選択してください。"
),
"partner.agent.ratio.not_found" to mapOf(
Lang.KO to "해당 에이전트 정산 비율이 없습니다.",
Lang.EN to "Agent settlement ratio not found.",
Lang.JA to "該当するエージェント精算率がありません。"
),
"partner.agent.ratio.invalid_effective_from" to mapOf(
Lang.KO to "비율 시작 시각이 기존 이력과 겹치거나 현재 활성 비율보다 늦어야 합니다.",
Lang.EN to "The effective-from time overlaps existing history or must be later than the current active ratio.",
Lang.JA to "比率開始時刻が既存履歴と重複しているか、現在の有効な比率より後である必要があります。"
)
)
private val adminMemberTagMessages = mapOf(
"admin.member.tag.already_registered" to mapOf(
Lang.KO to "이미 등록된 태그 입니다.",
@@ -2357,6 +2425,7 @@ class SodaMessageSource {
adminSignatureCanMessages,
adminAdMediaPartnerMessages,
adminMemberMessages,
partnerAgentMessages,
adminMemberTagMessages,
adminPointPolicyMessages,
adminMemberStatisticsMessages,

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.partner.agent.assignment
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class AgentCreatorRelation : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "agent_id", nullable = false)
var agent: Member? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "creator_id", nullable = false)
var creator: Member? = null
@Column(nullable = false)
var assignedAt: LocalDateTime? = null
@Column(nullable = true)
var unassignedAt: LocalDateTime? = null
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.partner.agent.assignment
import org.springframework.data.jpa.repository.JpaRepository
interface AgentCreatorRelationRepository : JpaRepository<AgentCreatorRelation, Long> {
fun findAllByCreatorIdOrderByAssignedAtAsc(creatorId: Long): List<AgentCreatorRelation>
fun findFirstByCreatorIdAndUnassignedAtIsNull(creatorId: Long): AgentCreatorRelation?
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.partner.agent.assignment
import java.time.LocalDateTime
data class AssignAgentCreatorRequest(
val agentId: Long,
val creatorId: Long,
val assignedAt: LocalDateTime
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.partner.agent.assignment
import java.time.LocalDateTime
data class RemoveAgentCreatorRequest(
val creatorId: Long,
val unassignedAt: LocalDateTime
)

View File

@@ -0,0 +1,127 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
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('AGENT')")
@RequestMapping("/agent/calculate")
class AgentCalculateController(private val service: AgentCalculateService) {
@GetMapping("/creator/list")
fun getAssignedCreators(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.getAssignedCreators(
agentId = agent.id!!,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
@GetMapping("/live-by-creator")
fun getCalculateLiveByCreator(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.getCalculateLiveByCreator(
startDateStr = startDateStr,
endDateStr = endDateStr,
agentId = agent.id!!,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
@GetMapping("/content-by-creator")
fun getCalculateContentByCreator(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.getCalculateContentByCreator(
startDateStr = startDateStr,
endDateStr = endDateStr,
agentId = agent.id!!,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
@GetMapping("/community-by-creator")
fun getCalculateCommunityByCreator(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.getCalculateCommunityByCreator(
startDateStr = startDateStr,
endDateStr = endDateStr,
agentId = agent.id!!,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
@GetMapping("/channel-donation-by-creator")
fun getChannelDonationByCreator(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.getChannelDonationByCreator(
startDateStr = startDateStr,
endDateStr = endDateStr,
agentId = agent.id!!,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
@GetMapping("/content-donation-by-creator")
fun getCalculateContentDonationByCreator(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
service.getCalculateContentDonationByCreator(
startDateStr = startDateStr,
endDateStr = endDateStr,
agentId = agent.id!!,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
}

View File

@@ -0,0 +1,939 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
import com.querydsl.core.types.dsl.DateTimeExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.calculate.ratio.QCreatorSettlementRatio.creatorSettlementRatio
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.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.QOrder.order
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.partner.agent.assignment.QAgentCreatorRelation.agentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.ratio.QAgentSettlementRatio.agentSettlementRatio
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import javax.persistence.EntityManager
@Repository
class AgentCalculateQueryRepository(
private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getAssignedCreatorTotalCount(agentId: Long, currentTime: LocalDateTime): Int {
return queryFactory
.select(agentCreatorRelation.id.count())
.from(agentCreatorRelation)
.where(
assignedToAgentAtCurrentTime(agentId = agentId, currentTime = currentTime)
)
.fetchOne()
?.toInt()
?: 0
}
fun getAssignedCreators(
agentId: Long,
offset: Long,
limit: Long,
currentTime: LocalDateTime
): List<GetAgentAssignedCreatorItem> {
return queryFactory
.select(
QGetAgentAssignedCreatorItem(
agentCreatorRelation.creator.id,
agentCreatorRelation.creator.nickname,
agentCreatorRelation.creator.profileImage
.coalesce("profile/default-profile.png")
.prepend("/")
.prepend(cloudFrontHost)
)
)
.from(agentCreatorRelation)
.where(
assignedToAgentAtCurrentTime(agentId = agentId, currentTime = currentTime)
)
.orderBy(agentCreatorRelation.creator.id.desc())
.offset(offset)
.limit(limit)
.fetch()
}
fun getCalculateLiveByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int {
return queryFactory
.select(member.id.countDistinct())
.from(useCan)
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.fetchOne()
?.toInt()
?: 0
}
fun getCalculateLiveByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
return getCalculateLiveByCreatorRows(startDate, endDate, agentId, creatorIds = null)
}
fun getCalculateLiveByCreatorTotal(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): GetAgentSettlementByCreatorTotal {
return getGenericSettlementTotal(
subQuery = buildLiveSettlementTotalSubQuery(),
startDate = startDate,
endDate = endDate,
agentId = agentId
)
}
fun getCalculateLiveByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
offset: Long,
limit: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
val creatorIds = queryFactory
.select(member.id)
.from(useCan)
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.groupBy(member.id)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
if (creatorIds.isEmpty()) {
return emptyList()
}
return getCalculateLiveByCreatorRows(startDate, endDate, agentId, creatorIds)
}
fun getCalculateContentByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int {
return queryFactory
.select(member.id.countDistinct())
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, order.createdAt))
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
.and(order.isActive.isTrue)
)
.fetchOne()
?.toInt()
?: 0
}
fun getCalculateContentByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
return getCalculateContentByCreatorRows(startDate, endDate, agentId, creatorIds = null)
}
fun getCalculateContentByCreatorTotal(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): GetAgentSettlementByCreatorTotal {
return getGenericSettlementTotal(
subQuery = buildContentSettlementTotalSubQuery(),
startDate = startDate,
endDate = endDate,
agentId = agentId
)
}
fun getCalculateContentByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
offset: Long,
limit: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
val creatorIds = queryFactory
.select(member.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, order.createdAt))
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
.and(order.isActive.isTrue)
)
.groupBy(member.id)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
if (creatorIds.isEmpty()) {
return emptyList()
}
return getCalculateContentByCreatorRows(startDate, endDate, agentId, creatorIds)
}
fun getCalculateCommunityByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int {
return queryFactory
.select(member.id.countDistinct())
.from(useCan)
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.fetchOne()
?.toInt()
?: 0
}
fun getCalculateCommunityByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
return getCalculateCommunityByCreatorRows(startDate, endDate, agentId, creatorIds = null)
}
fun getCalculateCommunityByCreatorTotal(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): GetAgentSettlementByCreatorTotal {
return getGenericSettlementTotal(
subQuery = buildCommunitySettlementTotalSubQuery(),
startDate = startDate,
endDate = endDate,
agentId = agentId
)
}
fun getCalculateCommunityByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
offset: Long,
limit: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
val creatorIds = queryFactory
.select(member.id)
.from(useCan)
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.groupBy(member.id)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
if (creatorIds.isEmpty()) {
return emptyList()
}
return getCalculateCommunityByCreatorRows(startDate, endDate, agentId, creatorIds)
}
fun getCalculateContentDonationByCreatorTotalCount(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): Int {
return queryFactory
.select(member.id.countDistinct())
.from(useCan)
.innerJoin(useCan.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.DONATION))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.fetchOne()
?.toInt()
?: 0
}
fun getCalculateContentDonationByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
return getCalculateContentDonationByCreatorRows(startDate, endDate, agentId, creatorIds = null)
}
fun getCalculateContentDonationByCreatorTotal(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): GetAgentSettlementByCreatorTotal {
return getGenericSettlementTotal(
subQuery = buildContentDonationSettlementTotalSubQuery(),
startDate = startDate,
endDate = endDate,
agentId = agentId
)
}
fun getCalculateContentDonationByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
offset: Long,
limit: Long
): List<GetAgentCreatorSettlementSummaryQueryData> {
val creatorIds = queryFactory
.select(member.id)
.from(useCan)
.innerJoin(useCan.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.DONATION))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.groupBy(member.id)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
if (creatorIds.isEmpty()) {
return emptyList()
}
return getCalculateContentDonationByCreatorRows(startDate, endDate, agentId, creatorIds)
}
fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int {
return queryFactory
.select(member.id.countDistinct())
.from(useCanCalculate)
.innerJoin(useCanCalculate.useCan, useCan)
.innerJoin(member)
.on(member.id.eq(useCanCalculate.recipientCreatorId))
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
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))
)
.fetchOne()
?.toInt()
?: 0
}
fun getChannelDonationByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): List<GetAgentChannelDonationSettlementByCreatorQueryData> {
return getChannelDonationByCreatorRows(startDate, endDate, agentId, creatorIds = null)
}
fun getChannelDonationByCreatorTotal(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): GetAgentChannelDonationSettlementTotal {
val query = entityManager.createNativeQuery(
"""
SELECT
COALESCE(SUM(grouped.count_value), 0) AS count,
COALESCE(SUM(grouped.total_can), 0) AS totalCan,
COALESCE(SUM(grouped.total_can * 100), 0) AS krw,
COALESCE(SUM(ROUND(grouped.total_can * 100 * 0.066, 0)), 0) AS fee,
COALESCE(SUM(ROUND(((grouped.total_can * 100) - ROUND(grouped.total_can * 100 * 0.066, 0)) * 0.85, 0)), 0) AS settlementAmount,
COALESCE(SUM(ROUND(ROUND(((grouped.total_can * 100) - ROUND(grouped.total_can * 100 * 0.066, 0)) * 0.85, 0) * 0.033, 0)), 0) AS withholdingTax,
COALESCE(SUM(ROUND(((grouped.total_can * 100) - ROUND(grouped.total_can * 100 * 0.066, 0)) * 0.85, 0) - ROUND(ROUND(((grouped.total_can * 100) - ROUND(grouped.total_can * 100 * 0.066, 0)) * 0.85, 0) * 0.033, 0)), 0) AS depositAmount,
COALESCE(SUM(ROUND(ROUND(((grouped.total_can * 100) - ROUND(grouped.total_can * 100 * 0.066, 0)) * 0.85, 0) * (grouped.agent_settlement_ratio / 100.0), 0)), 0) AS agentSettlementAmount
FROM (
SELECT
COUNT(DISTINCT uc.id) AS count_value,
SUM(ucc.can) AS total_can,
COALESCE(asr.settlement_ratio, 10) AS agent_settlement_ratio
FROM use_can_calculate ucc
INNER JOIN use_can uc ON ucc.use_can_id = uc.id
INNER JOIN member m ON m.id = ucc.recipient_creator_id
INNER JOIN agent_creator_relation acr ON acr.creator_id = m.id
AND acr.agent_id = :agentId
AND acr.assigned_at <= uc.created_at
AND (acr.unassigned_at IS NULL OR acr.unassigned_at > uc.created_at)
LEFT JOIN agent_settlement_ratio asr ON asr.member_id = :agentId
AND asr.effective_from <= uc.created_at
AND (asr.effective_to IS NULL OR asr.effective_to > uc.created_at)
WHERE uc.can_usage = 'CHANNEL_DONATION'
AND uc.is_refund = FALSE
AND ucc.status = 'RECEIVED'
AND uc.created_at >= :startDate
AND uc.created_at <= :endDate
GROUP BY acr.id, asr.id, COALESCE(asr.settlement_ratio, 10)
) grouped
""".trimIndent()
)
query.setParameter("startDate", startDate)
query.setParameter("endDate", endDate)
query.setParameter("agentId", agentId)
val result = query.singleResult as Array<*>
return GetAgentChannelDonationSettlementTotal(
count = result[0].toIntValue(),
totalCan = result[1].toIntValue(),
krw = result[2].toIntValue(),
fee = result[3].toIntValue(),
settlementAmount = result[4].toIntValue(),
withholdingTax = result[5].toIntValue(),
depositAmount = result[6].toIntValue(),
agentSettlementAmount = result[7].toIntValue()
)
}
fun getChannelDonationByCreator(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
offset: Long,
limit: Long
): List<GetAgentChannelDonationSettlementByCreatorQueryData> {
val creatorIds = queryFactory
.select(member.id)
.from(useCanCalculate)
.innerJoin(useCanCalculate.useCan, useCan)
.innerJoin(member)
.on(member.id.eq(useCanCalculate.recipientCreatorId))
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.where(
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))
)
.groupBy(member.id)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
if (creatorIds.isEmpty()) {
return emptyList()
}
return getChannelDonationByCreatorRows(startDate, endDate, agentId, creatorIds)
}
private fun getCalculateLiveByCreatorRows(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
creatorIds: List<Long>?
): List<GetAgentCreatorSettlementSummaryQueryData> {
return queryFactory
.select(
QGetAgentCreatorSettlementSummaryQueryData(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
useCan.id.count(),
useCan.can.add(useCan.rewardCan).sum(),
creatorSettlementRatio.liveSettlementRatio,
agentSettlementRatio.settlementRatio
)
)
.from(useCan)
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.leftJoin(agentSettlementRatio)
.on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt))
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
.and(if (!creatorIds.isNullOrEmpty()) member.id.`in`(creatorIds) else null)
)
.groupBy(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
creatorSettlementRatio.liveSettlementRatio,
agentSettlementRatio.settlementRatio
)
.orderBy(member.id.desc())
.fetch()
}
private fun getCalculateContentByCreatorRows(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
creatorIds: List<Long>?
): List<GetAgentCreatorSettlementSummaryQueryData> {
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
QGetAgentCreatorSettlementSummaryQueryData(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
order.id.count(),
order.can.sum(),
contentSettlementRatio,
agentSettlementRatio.settlementRatio
)
)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, order.createdAt))
)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.leftJoin(agentSettlementRatio)
.on(appliedAgentSettlementRatioAtEventTime(agentId, order.createdAt))
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
.and(order.isActive.isTrue)
.and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null)
)
.groupBy(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
contentSettlementRatio,
agentSettlementRatio.settlementRatio
)
.orderBy(member.id.desc())
.fetch()
}
private fun getCalculateCommunityByCreatorRows(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
creatorIds: List<Long>?
): List<GetAgentCreatorSettlementSummaryQueryData> {
return queryFactory
.select(
QGetAgentCreatorSettlementSummaryQueryData(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
useCan.id.count(),
useCan.can.add(useCan.rewardCan).sum(),
creatorSettlementRatio.communitySettlementRatio,
agentSettlementRatio.settlementRatio
)
)
.from(useCan)
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.leftJoin(agentSettlementRatio)
.on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt))
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
.and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null)
)
.groupBy(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
creatorSettlementRatio.communitySettlementRatio,
agentSettlementRatio.settlementRatio
)
.orderBy(member.id.desc())
.fetch()
}
private fun getCalculateContentDonationByCreatorRows(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
creatorIds: List<Long>?
): List<GetAgentCreatorSettlementSummaryQueryData> {
return queryFactory
.select(
QGetAgentCreatorSettlementSummaryQueryData(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
useCan.id.count(),
useCan.can.add(useCan.rewardCan).sum(),
Expressions.numberTemplate(Int::class.java, "70"),
agentSettlementRatio.settlementRatio
)
)
.from(useCan)
.innerJoin(useCan.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.leftJoin(agentSettlementRatio)
.on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt))
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.DONATION))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
.and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null)
)
.groupBy(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
agentSettlementRatio.settlementRatio
)
.orderBy(member.id.desc())
.fetch()
}
private fun getChannelDonationByCreatorRows(
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long,
creatorIds: List<Long>?
): List<GetAgentChannelDonationSettlementByCreatorQueryData> {
return queryFactory
.select(
QGetAgentChannelDonationSettlementByCreatorQueryData(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
useCan.id.countDistinct(),
useCanCalculate.can.sum(),
agentSettlementRatio.settlementRatio
)
)
.from(useCanCalculate)
.innerJoin(useCanCalculate.useCan, useCan)
.innerJoin(member)
.on(member.id.eq(useCanCalculate.recipientCreatorId))
.innerJoin(agentCreatorRelation)
.on(
agentCreatorRelation.creator.id.eq(member.id)
.and(assignedToAgentAtEventTime(agentId, useCan.createdAt))
)
.leftJoin(agentSettlementRatio)
.on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt))
.where(
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))
.and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null)
)
.groupBy(
member.id,
member.nickname,
agentCreatorRelation.id,
agentSettlementRatio.id,
agentSettlementRatio.settlementRatio
)
.orderBy(member.id.desc())
.fetch()
}
private fun getGenericSettlementTotal(
subQuery: String,
startDate: LocalDateTime,
endDate: LocalDateTime,
agentId: Long
): GetAgentSettlementByCreatorTotal {
val query = entityManager.createNativeQuery(
"""
SELECT
COALESCE(SUM(grouped.count_value), 0) AS count,
COALESCE(SUM(grouped.total_can), 0) AS totalCan,
COALESCE(SUM(grouped.total_can * 100), 0) AS krw,
COALESCE(SUM(ROUND(grouped.total_can * 100 * 0.066, 0)), 0) AS fee,
COALESCE(SUM(ROUND(((grouped.total_can * 100) - ((grouped.total_can * 100) * 0.066)) * (grouped.settlement_ratio / 100.0), 0)), 0) AS settlementAmount,
COALESCE(SUM(ROUND((((grouped.total_can * 100) - ((grouped.total_can * 100) * 0.066)) * (grouped.settlement_ratio / 100.0)) * 0.033, 0)), 0) AS tax,
COALESCE(SUM(ROUND((((grouped.total_can * 100) - ((grouped.total_can * 100) * 0.066)) * (grouped.settlement_ratio / 100.0)) - ((((grouped.total_can * 100) - ((grouped.total_can * 100) * 0.066)) * (grouped.settlement_ratio / 100.0)) * 0.033), 0)), 0) AS depositAmount,
COALESCE(SUM(ROUND(ROUND(((grouped.total_can * 100) - ((grouped.total_can * 100) * 0.066)) * (grouped.settlement_ratio / 100.0), 0) * (grouped.agent_settlement_ratio / 100.0), 0)), 0) AS agentSettlementAmount
FROM (
$subQuery
) grouped
""".trimIndent()
)
query.setParameter("startDate", startDate)
query.setParameter("endDate", endDate)
query.setParameter("agentId", agentId)
val result = query.singleResult as Array<*>
return GetAgentSettlementByCreatorTotal(
count = result[0].toIntValue(),
totalCan = result[1].toIntValue(),
krw = result[2].toIntValue(),
fee = result[3].toIntValue(),
settlementAmount = result[4].toIntValue(),
tax = result[5].toIntValue(),
depositAmount = result[6].toIntValue(),
agentSettlementAmount = result[7].toIntValue()
)
}
private fun buildLiveSettlementTotalSubQuery() = """
SELECT
COUNT(uc.id) AS count_value,
SUM(uc.can + uc.reward_can) AS total_can,
COALESCE(csr.live_settlement_ratio, 70) AS settlement_ratio,
COALESCE(asr.settlement_ratio, 10) AS agent_settlement_ratio
FROM use_can uc
INNER JOIN live_room lr ON uc.room_id = lr.id
INNER JOIN member m ON lr.member_id = m.id
INNER JOIN agent_creator_relation acr ON acr.creator_id = m.id
AND acr.agent_id = :agentId
AND acr.assigned_at <= uc.created_at
AND (acr.unassigned_at IS NULL OR acr.unassigned_at > uc.created_at)
LEFT JOIN creator_settlement_ratio csr ON csr.member_id = m.id
AND csr.deleted_at IS NULL
LEFT JOIN agent_settlement_ratio asr ON asr.member_id = :agentId
AND asr.effective_from <= uc.created_at
AND (asr.effective_to IS NULL OR asr.effective_to > uc.created_at)
WHERE uc.is_refund = FALSE
AND uc.created_at >= :startDate
AND uc.created_at <= :endDate
GROUP BY acr.id, asr.id, COALESCE(csr.live_settlement_ratio, 70), COALESCE(asr.settlement_ratio, 10)
""".trimIndent()
private fun buildContentSettlementTotalSubQuery() = """
SELECT
COUNT(o.id) AS count_value,
SUM(o.can) AS total_can,
COALESCE(COALESCE(c.settlement_ratio, csr.content_settlement_ratio), 70) AS settlement_ratio,
COALESCE(asr.settlement_ratio, 10) AS agent_settlement_ratio
FROM orders o
INNER JOIN content c ON o.content_id = c.id
INNER JOIN member m ON c.member_id = m.id
INNER JOIN agent_creator_relation acr ON acr.creator_id = m.id
AND acr.agent_id = :agentId
AND acr.assigned_at <= o.created_at
AND (acr.unassigned_at IS NULL OR acr.unassigned_at > o.created_at)
LEFT JOIN creator_settlement_ratio csr ON csr.member_id = m.id
AND csr.deleted_at IS NULL
LEFT JOIN agent_settlement_ratio asr ON asr.member_id = :agentId
AND asr.effective_from <= o.created_at
AND (asr.effective_to IS NULL OR asr.effective_to > o.created_at)
WHERE o.created_at >= :startDate
AND o.created_at <= :endDate
AND o.is_active = TRUE
GROUP BY
acr.id,
asr.id,
COALESCE(c.settlement_ratio, csr.content_settlement_ratio),
COALESCE(COALESCE(c.settlement_ratio, csr.content_settlement_ratio), 70),
COALESCE(asr.settlement_ratio, 10)
""".trimIndent()
private fun buildCommunitySettlementTotalSubQuery() = """
SELECT
COUNT(uc.id) AS count_value,
SUM(uc.can + uc.reward_can) AS total_can,
COALESCE(csr.community_settlement_ratio, 70) AS settlement_ratio,
COALESCE(asr.settlement_ratio, 10) AS agent_settlement_ratio
FROM use_can uc
INNER JOIN creator_community cc ON uc.creator_community_id = cc.id
INNER JOIN member m ON cc.member_id = m.id
INNER JOIN agent_creator_relation acr ON acr.creator_id = m.id
AND acr.agent_id = :agentId
AND acr.assigned_at <= uc.created_at
AND (acr.unassigned_at IS NULL OR acr.unassigned_at > uc.created_at)
LEFT JOIN creator_settlement_ratio csr ON csr.member_id = m.id
AND csr.deleted_at IS NULL
LEFT JOIN agent_settlement_ratio asr ON asr.member_id = :agentId
AND asr.effective_from <= uc.created_at
AND (asr.effective_to IS NULL OR asr.effective_to > uc.created_at)
WHERE uc.is_refund = FALSE
AND uc.can_usage = 'PAID_COMMUNITY_POST'
AND uc.created_at >= :startDate
AND uc.created_at <= :endDate
GROUP BY acr.id, asr.id, COALESCE(csr.community_settlement_ratio, 70), COALESCE(asr.settlement_ratio, 10)
""".trimIndent()
private fun buildContentDonationSettlementTotalSubQuery() = """
SELECT
COUNT(uc.id) AS count_value,
SUM(uc.can + uc.reward_can) AS total_can,
70 AS settlement_ratio,
COALESCE(asr.settlement_ratio, 10) AS agent_settlement_ratio
FROM use_can uc
INNER JOIN content c ON uc.content_id = c.id
INNER JOIN member m ON c.member_id = m.id
INNER JOIN agent_creator_relation acr ON acr.creator_id = m.id
AND acr.agent_id = :agentId
AND acr.assigned_at <= uc.created_at
AND (acr.unassigned_at IS NULL OR acr.unassigned_at > uc.created_at)
LEFT JOIN agent_settlement_ratio asr ON asr.member_id = :agentId
AND asr.effective_from <= uc.created_at
AND (asr.effective_to IS NULL OR asr.effective_to > uc.created_at)
WHERE uc.is_refund = FALSE
AND uc.can_usage = 'DONATION'
AND uc.created_at >= :startDate
AND uc.created_at <= :endDate
GROUP BY acr.id, asr.id, COALESCE(asr.settlement_ratio, 10)
""".trimIndent()
private fun Any?.toIntValue(): Int {
return when (this) {
null -> 0
is Number -> toInt()
else -> this.toString().toInt()
}
}
private fun assignedToAgentAtEventTime(
agentId: Long,
eventTime: DateTimeExpression<LocalDateTime>
) = agentCreatorRelation.agent.id.eq(agentId)
.and(agentCreatorRelation.assignedAt.loe(eventTime))
.and(agentCreatorRelation.unassignedAt.isNull.or(agentCreatorRelation.unassignedAt.gt(eventTime)))
private fun assignedToAgentAtCurrentTime(
agentId: Long,
currentTime: LocalDateTime
) = agentCreatorRelation.agent.id.eq(agentId)
.and(agentCreatorRelation.assignedAt.loe(currentTime))
.and(agentCreatorRelation.unassignedAt.isNull.or(agentCreatorRelation.unassignedAt.gt(currentTime)))
private fun appliedAgentSettlementRatioAtEventTime(
agentId: Long,
eventTime: DateTimeExpression<LocalDateTime>
) = agentSettlementRatio.member.id.eq(agentId)
.and(agentSettlementRatio.effectiveFrom.loe(eventTime))
.and(agentSettlementRatio.effectiveTo.isNull.or(agentSettlementRatio.effectiveTo.gt(eventTime)))
}

View File

@@ -0,0 +1,215 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.toChannelDonationSettlementByCreatorItems
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.toSettlementByCreatorItems
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class AgentCalculateService(
private val repository: AgentCalculateQueryRepository,
private val snapshotRepository: AgentSettlementSnapshotRepository
) {
@Transactional(readOnly = true)
fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAgentAssignedCreatorResponse {
val currentTime = LocalDateTime.now()
val totalCount = repository.getAssignedCreatorTotalCount(agentId = agentId, currentTime = currentTime)
val items = repository.getAssignedCreators(agentId = agentId, offset = offset, limit = limit, currentTime = currentTime)
return GetAgentAssignedCreatorResponse(totalCount = totalCount, items = items)
}
@Transactional(readOnly = true)
fun getCalculateLiveByCreator(
startDateStr: String,
endDateStr: String,
agentId: Long,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
return buildSettlementByCreatorResponse(
startDateStr = startDateStr,
endDateStr = endDateStr,
settlementType = AgentSettlementSnapshotType.LIVE,
agentId = agentId,
offset = offset,
limit = limit,
totalCountLoader = { startDate, endDate ->
repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, agentId)
},
totalLoader = { startDate, endDate ->
repository.getCalculateLiveByCreatorTotal(startDate, endDate, agentId)
},
pagedRowsLoader = { startDate, endDate ->
repository.getCalculateLiveByCreator(startDate, endDate, agentId, offset, limit)
}
)
}
@Transactional(readOnly = true)
fun getCalculateContentByCreator(
startDateStr: String,
endDateStr: String,
agentId: Long,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
return buildSettlementByCreatorResponse(
startDateStr = startDateStr,
endDateStr = endDateStr,
settlementType = AgentSettlementSnapshotType.CONTENT,
agentId = agentId,
offset = offset,
limit = limit,
totalCountLoader = { startDate, endDate ->
repository.getCalculateContentByCreatorTotalCount(startDate, endDate, agentId)
},
totalLoader = { startDate, endDate ->
repository.getCalculateContentByCreatorTotal(startDate, endDate, agentId)
},
pagedRowsLoader = { startDate, endDate ->
repository.getCalculateContentByCreator(startDate, endDate, agentId, offset, limit)
}
)
}
@Transactional(readOnly = true)
fun getCalculateCommunityByCreator(
startDateStr: String,
endDateStr: String,
agentId: Long,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
return buildSettlementByCreatorResponse(
startDateStr = startDateStr,
endDateStr = endDateStr,
settlementType = AgentSettlementSnapshotType.COMMUNITY,
agentId = agentId,
offset = offset,
limit = limit,
totalCountLoader = { startDate, endDate ->
repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate, agentId)
},
totalLoader = { startDate, endDate ->
repository.getCalculateCommunityByCreatorTotal(startDate, endDate, agentId)
},
pagedRowsLoader = { startDate, endDate ->
repository.getCalculateCommunityByCreator(startDate, endDate, agentId, offset, limit)
}
)
}
@Transactional(readOnly = true)
fun getCalculateContentDonationByCreator(
startDateStr: String,
endDateStr: String,
agentId: Long,
offset: Long,
limit: Long
): GetAgentSettlementByCreatorResponse {
return buildSettlementByCreatorResponse(
startDateStr = startDateStr,
endDateStr = endDateStr,
settlementType = AgentSettlementSnapshotType.CONTENT_DONATION,
agentId = agentId,
offset = offset,
limit = limit,
totalCountLoader = { startDate, endDate ->
repository.getCalculateContentDonationByCreatorTotalCount(startDate, endDate, agentId)
},
totalLoader = { startDate, endDate ->
repository.getCalculateContentDonationByCreatorTotal(startDate, endDate, agentId)
},
pagedRowsLoader = { startDate, endDate ->
repository.getCalculateContentDonationByCreator(startDate, endDate, agentId, offset, limit)
}
)
}
@Transactional(readOnly = true)
fun getChannelDonationByCreator(
startDateStr: String,
endDateStr: String,
agentId: Long,
offset: Long,
limit: Long
): GetAgentChannelDonationSettlementByCreatorResponse {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val snapshots = snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
periodStart = startDate,
periodEnd = endDate,
settlementType = AgentSettlementSnapshotType.CHANNEL_DONATION,
agentId = agentId
)
if (snapshots.isNotEmpty()) {
val total = snapshots.toChannelDonationSettlementByCreatorItems().toResponseTotal()
val items = snapshots.drop(offset.toInt()).take(limit.toInt()).toChannelDonationSettlementByCreatorItems()
return GetAgentChannelDonationSettlementByCreatorResponse(
totalCount = snapshots.size,
total = total,
items = items
)
}
val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate, agentId)
val total = repository.getChannelDonationByCreatorTotal(startDate, endDate, agentId)
val items = repository.getChannelDonationByCreator(startDate, endDate, agentId, offset, limit)
.toMergedResponseItems()
return GetAgentChannelDonationSettlementByCreatorResponse(
totalCount = totalCount,
total = total,
items = items
)
}
private fun buildSettlementByCreatorResponse(
startDateStr: String,
endDateStr: String,
settlementType: AgentSettlementSnapshotType,
agentId: Long,
offset: Long,
limit: Long,
totalCountLoader: (LocalDateTime, LocalDateTime) -> Int,
totalLoader: (LocalDateTime, LocalDateTime) -> GetAgentSettlementByCreatorTotal,
pagedRowsLoader: (LocalDateTime, LocalDateTime) -> List<GetAgentCreatorSettlementSummaryQueryData>
): GetAgentSettlementByCreatorResponse {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val snapshots = snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
periodStart = startDate,
periodEnd = endDate,
settlementType = settlementType,
agentId = agentId
)
if (snapshots.isNotEmpty()) {
val total = snapshots.toSettlementByCreatorItems().toResponseTotal()
val items = snapshots.drop(offset.toInt()).take(limit.toInt()).toSettlementByCreatorItems()
return GetAgentSettlementByCreatorResponse(
totalCount = snapshots.size,
total = total,
items = items
)
}
val totalCount = totalCountLoader(startDate, endDate)
val total = totalLoader(startDate, endDate)
val items = pagedRowsLoader(startDate, endDate)
.toMergedResponseItems()
return GetAgentSettlementByCreatorResponse(
totalCount = totalCount,
total = total,
items = items
)
}
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return startDate to endDate
}
}

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
import com.querydsl.core.annotations.QueryProjection
data class GetAgentAssignedCreatorResponse(
val totalCount: Int,
val items: List<GetAgentAssignedCreatorItem>
)
data class GetAgentAssignedCreatorItem @QueryProjection constructor(
val creatorId: Long,
val creatorNickname: String,
val profileImageUrl: String
)

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
import java.math.BigDecimal
import java.math.RoundingMode
data class GetAgentChannelDonationSettlementByCreatorResponse(
val totalCount: Int,
val total: GetAgentChannelDonationSettlementTotal,
val items: List<GetAgentChannelDonationSettlementByCreatorItem>
)
data class GetAgentChannelDonationSettlementTotal(
val count: Int,
val totalCan: Int,
val krw: Int,
val fee: Int,
val settlementAmount: Int,
val withholdingTax: Int,
val depositAmount: Int,
val agentSettlementAmount: Int
)
data class GetAgentChannelDonationSettlementByCreatorItem(
val creatorId: Long,
val creatorNickname: String,
val count: Int,
val totalCan: Int,
val krw: Int,
val fee: Int,
val settlementAmount: Int,
val withholdingTax: Int,
val depositAmount: Int,
val agentSettlementAmount: Int
)
data class GetAgentChannelDonationSettlementByCreatorQueryData @QueryProjection constructor(
val creatorId: Long,
val creatorNickname: String,
val assignmentId: Long? = null,
val agentSettlementRatioId: Long? = null,
val count: Long,
val totalCan: Int,
val agentSettlementRatio: Int? = null
) {
fun toResponseItem(): GetAgentChannelDonationSettlementByCreatorItem {
val amount = ChannelDonationSettlementCalculator.calculate(totalCan)
return GetAgentChannelDonationSettlementByCreatorItem(
creatorId = creatorId,
creatorNickname = creatorNickname,
count = count.toInt(),
totalCan = totalCan,
krw = amount.krw,
fee = amount.fee,
settlementAmount = amount.settlementAmount,
withholdingTax = amount.withholdingTax,
depositAmount = amount.depositAmount,
agentSettlementAmount = BigDecimal(amount.settlementAmount)
.multiply(BigDecimal(agentSettlementRatio ?: DEFAULT_AGENT_SETTLEMENT_RATIO).divide(BigDecimal("100")))
.setScale(0, RoundingMode.HALF_UP)
.toInt()
)
}
companion object {
private const val DEFAULT_AGENT_SETTLEMENT_RATIO = 10
}
}
fun List<GetAgentChannelDonationSettlementByCreatorQueryData>.toMergedResponseItems():
List<GetAgentChannelDonationSettlementByCreatorItem> {
return map { it.toResponseItem() }
.groupBy { it.creatorId }
.map { (_, items) ->
items.reduce { acc, item ->
acc.copy(
count = acc.count + item.count,
totalCan = acc.totalCan + item.totalCan,
krw = acc.krw + item.krw,
fee = acc.fee + item.fee,
settlementAmount = acc.settlementAmount + item.settlementAmount,
withholdingTax = acc.withholdingTax + item.withholdingTax,
depositAmount = acc.depositAmount + item.depositAmount,
agentSettlementAmount = acc.agentSettlementAmount + item.agentSettlementAmount
)
}
}
}
fun List<GetAgentChannelDonationSettlementByCreatorItem>.toResponseTotal(): GetAgentChannelDonationSettlementTotal {
return GetAgentChannelDonationSettlementTotal(
count = sumOf { it.count },
totalCan = sumOf { it.totalCan },
krw = sumOf { it.krw },
fee = sumOf { it.fee },
settlementAmount = sumOf { it.settlementAmount },
withholdingTax = sumOf { it.withholdingTax },
depositAmount = sumOf { it.depositAmount },
agentSettlementAmount = sumOf { it.agentSettlementAmount }
)
}

View File

@@ -0,0 +1,104 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
import java.math.RoundingMode
data class GetAgentCreatorSettlementSummaryQueryData @QueryProjection constructor(
val creatorId: Long,
val creatorNickname: String,
val assignmentId: Long? = null,
val agentSettlementRatioId: Long? = null,
val count: Long,
val totalCan: Int,
val settlementRatio: Int?,
val agentSettlementRatio: Int? = null
) {
fun toResponseItem(): GetAgentSettlementByCreatorItem {
val totalKrw = BigDecimal(totalCan).multiply(KRW_PER_CAN)
val fee = totalKrw.multiply(PAYMENT_FEE_RATE)
val settlementAmount = totalKrw.subtract(fee)
.multiply(BigDecimal(settlementRatio ?: DEFAULT_SETTLEMENT_RATIO).divide(PERCENT_DIVISOR))
val tax = settlementAmount.multiply(TAX_RATE)
val depositAmount = settlementAmount.subtract(tax)
val roundedSettlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt()
return GetAgentSettlementByCreatorItem(
creatorId = creatorId,
creatorNickname = creatorNickname,
count = count.toInt(),
totalCan = totalCan,
krw = totalKrw.toInt(),
fee = fee.setScale(0, RoundingMode.HALF_UP).toInt(),
settlementAmount = roundedSettlementAmount,
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
agentSettlementAmount = calculateAgentSettlementAmount(
settlementAmount = roundedSettlementAmount,
agentSettlementRatio = agentSettlementRatio ?: DEFAULT_AGENT_SETTLEMENT_RATIO
)
)
}
companion object {
private val KRW_PER_CAN = BigDecimal("100")
private val PAYMENT_FEE_RATE = BigDecimal("0.066")
private val TAX_RATE = BigDecimal("0.033")
private val PERCENT_DIVISOR = BigDecimal("100")
private const val DEFAULT_SETTLEMENT_RATIO = 70
private const val DEFAULT_AGENT_SETTLEMENT_RATIO = 10
private fun calculateAgentSettlementAmount(settlementAmount: Int, agentSettlementRatio: Int): Int {
return BigDecimal(settlementAmount)
.multiply(BigDecimal(agentSettlementRatio).divide(PERCENT_DIVISOR))
.setScale(0, RoundingMode.HALF_UP)
.toInt()
}
}
}
fun List<GetAgentCreatorSettlementSummaryQueryData>.toMergedResponseItems(): List<GetAgentSettlementByCreatorItem> {
return map { it.toResponseItem() }
.groupBy { it.creatorId }
.map { (_, items) ->
items.reduce { acc, item ->
acc.copy(
count = acc.count + item.count,
totalCan = acc.totalCan + item.totalCan,
krw = acc.krw + item.krw,
fee = acc.fee + item.fee,
settlementAmount = acc.settlementAmount + item.settlementAmount,
tax = acc.tax + item.tax,
depositAmount = acc.depositAmount + item.depositAmount,
agentSettlementAmount = acc.agentSettlementAmount + item.agentSettlementAmount
)
}
}
}
fun List<GetAgentCreatorSettlementSummaryQueryData>.toResponseTotal(): GetAgentSettlementByCreatorTotal {
return fold(
GetAgentSettlementByCreatorTotal(
count = 0,
totalCan = 0,
krw = 0,
fee = 0,
settlementAmount = 0,
tax = 0,
depositAmount = 0,
agentSettlementAmount = 0
)
) { total, row ->
val item = row.toResponseItem()
total.copy(
count = total.count + item.count,
totalCan = total.totalCan + item.totalCan,
krw = total.krw + item.krw,
fee = total.fee + item.fee,
settlementAmount = total.settlementAmount + item.settlementAmount,
tax = total.tax + item.tax,
depositAmount = total.depositAmount + item.depositAmount,
agentSettlementAmount = total.agentSettlementAmount + item.agentSettlementAmount
)
}
}

View File

@@ -0,0 +1,44 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
data class GetAgentSettlementByCreatorResponse(
val totalCount: Int,
val total: GetAgentSettlementByCreatorTotal,
val items: List<GetAgentSettlementByCreatorItem>
)
data class GetAgentSettlementByCreatorTotal(
val count: Int,
val totalCan: Int,
val krw: Int,
val fee: Int,
val settlementAmount: Int,
val tax: Int,
val depositAmount: Int,
val agentSettlementAmount: Int
)
data class GetAgentSettlementByCreatorItem(
val creatorId: Long,
val creatorNickname: String,
val count: Int,
val totalCan: Int,
val krw: Int,
val fee: Int,
val settlementAmount: Int,
val tax: Int,
val depositAmount: Int,
val agentSettlementAmount: Int
)
fun List<GetAgentSettlementByCreatorItem>.toResponseTotal(): GetAgentSettlementByCreatorTotal {
return GetAgentSettlementByCreatorTotal(
count = sumOf { it.count },
totalCan = sumOf { it.totalCan },
krw = sumOf { it.krw },
fee = sumOf { it.fee },
settlementAmount = sumOf { it.settlementAmount },
tax = sumOf { it.tax },
depositAmount = sumOf { it.depositAmount },
agentSettlementAmount = sumOf { it.agentSettlementAmount }
)
}

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.partner.agent.ratio
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class AgentSettlementRatio(
var settlementRatio: Int,
@Column(nullable = false)
var effectiveFrom: LocalDateTime
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
var effectiveTo: LocalDateTime? = null
fun close(effectiveTo: LocalDateTime) {
this.effectiveTo = effectiveTo
}
}

View File

@@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.partner.agent.ratio
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.partner.agent.ratio.QAgentSettlementRatio.agentSettlementRatio
import org.springframework.data.jpa.repository.JpaRepository
interface AgentSettlementRatioRepository :
JpaRepository<AgentSettlementRatio, Long>,
AgentSettlementRatioQueryRepository {
fun findFirstByMemberIdAndEffectiveToIsNull(memberId: Long): AgentSettlementRatio?
fun findAllByMemberIdOrderByEffectiveFromAsc(memberId: Long): List<AgentSettlementRatio>
}
interface AgentSettlementRatioQueryRepository {
fun getAgentSettlementRatio(offset: Long, limit: Long): List<GetAgentSettlementRatioRow>
fun getAgentSettlementRatioTotalCount(): Int
}
class AgentSettlementRatioQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AgentSettlementRatioQueryRepository {
override fun getAgentSettlementRatio(offset: Long, limit: Long): List<GetAgentSettlementRatioRow> {
val memberIds = queryFactory
.select(member.id)
.from(agentSettlementRatio)
.innerJoin(agentSettlementRatio.member, member)
.groupBy(member.id)
.orderBy(member.id.desc())
.offset(offset)
.limit(limit)
.fetch()
if (memberIds.isEmpty()) {
return emptyList()
}
return queryFactory
.select(
QGetAgentSettlementRatioRow(
member.id,
member.nickname,
agentSettlementRatio.settlementRatio,
agentSettlementRatio.effectiveFrom,
agentSettlementRatio.effectiveTo
)
)
.from(agentSettlementRatio)
.innerJoin(agentSettlementRatio.member, member)
.where(member.id.`in`(memberIds))
.orderBy(member.id.desc(), agentSettlementRatio.effectiveFrom.desc())
.fetch()
}
override fun getAgentSettlementRatioTotalCount(): Int {
return queryFactory
.select(member.id.countDistinct())
.from(agentSettlementRatio)
.innerJoin(agentSettlementRatio.member, member)
.fetchOne()
?.toInt()
?: 0
}
}

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.partner.agent.ratio
import java.time.LocalDateTime
data class CreateAgentSettlementRatioRequest(
val memberId: Long,
val settlementRatio: Int,
val effectiveFrom: LocalDateTime
) {
fun toEntity() = AgentSettlementRatio(
settlementRatio = settlementRatio,
effectiveFrom = effectiveFrom
)
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.partner.agent.ratio
import com.querydsl.core.annotations.QueryProjection
import java.time.LocalDateTime
data class GetAgentSettlementRatioResponse(
val totalCount: Int,
val items: List<GetAgentSettlementRatioItem>
)
data class GetAgentSettlementRatioItem(
val memberId: Long,
val nickname: String,
val current: GetAgentSettlementRatioHistoryItem?,
val history: List<GetAgentSettlementRatioHistoryItem>
)
data class GetAgentSettlementRatioHistoryItem(
val settlementRatio: Int,
val effectiveFrom: LocalDateTime,
val effectiveTo: LocalDateTime?
)
data class GetAgentSettlementRatioRow @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val settlementRatio: Int,
val effectiveFrom: LocalDateTime,
val effectiveTo: LocalDateTime?
)
fun List<GetAgentSettlementRatioRow>.toGroupedResponseItems(): List<GetAgentSettlementRatioItem> {
return groupBy { it.memberId to it.nickname }
.map { (memberInfo, rows) ->
GetAgentSettlementRatioItem(
memberId = memberInfo.first,
nickname = memberInfo.second,
current = rows.firstOrNull { it.effectiveTo == null }?.toHistoryItem(),
history = rows.filter { it.effectiveTo != null }.map { it.toHistoryItem() }
)
}
}
private fun GetAgentSettlementRatioRow.toHistoryItem() = GetAgentSettlementRatioHistoryItem(
settlementRatio = settlementRatio,
effectiveFrom = effectiveFrom,
effectiveTo = effectiveTo
)

View File

@@ -0,0 +1,80 @@
package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot
import kr.co.vividnext.sodalive.common.BaseEntity
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
class AgentSettlementSnapshot(
@Column(nullable = false, updatable = false)
var periodStart: LocalDateTime,
@Column(nullable = false, updatable = false)
var periodEnd: LocalDateTime,
@Column(nullable = false, updatable = false)
@Enumerated(EnumType.STRING)
var settlementType: AgentSettlementSnapshotType,
@Column(nullable = false, updatable = false)
var agentId: Long,
@Column(nullable = false, updatable = false)
var agentNickname: String,
@Column(nullable = false, updatable = false)
var creatorId: Long,
@Column(nullable = false, updatable = false)
var creatorNickname: String,
@Column(nullable = true, updatable = false)
var assignmentId: Long? = null,
@Column(nullable = true, updatable = false)
var agentSettlementRatioId: Long? = null,
@Column(nullable = true, updatable = false)
var appliedAgentSettlementRatio: Int? = null,
@Column(nullable = false, updatable = false)
var count: Int,
@Column(nullable = false, updatable = false)
var totalCan: Int,
@Column(nullable = false, updatable = false)
var krw: Int,
@Column(nullable = false, updatable = false)
var fee: Int,
@Column(nullable = false, updatable = false)
var settlementAmount: Int,
@Column(nullable = false, updatable = false)
var tax: Int,
@Column(nullable = false, updatable = false)
var depositAmount: Int,
@Column(nullable = false, updatable = false)
var agentSettlementAmount: Int,
@Column(nullable = false, updatable = false)
var finalizedAt: LocalDateTime,
@Column(nullable = false, updatable = false)
var finalizedByMemberId: Long
) : BaseEntity()
enum class AgentSettlementSnapshotType {
LIVE,
CONTENT,
COMMUNITY,
CHANNEL_DONATION,
CONTENT_DONATION
}

View File

@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface AgentSettlementSnapshotRepository : JpaRepository<AgentSettlementSnapshot, Long> {
fun existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
periodStart: LocalDateTime,
periodEnd: LocalDateTime,
settlementType: AgentSettlementSnapshotType,
agentId: Long
): Boolean
fun findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
periodStart: LocalDateTime,
periodEnd: LocalDateTime,
settlementType: AgentSettlementSnapshotType,
agentId: Long
): List<AgentSettlementSnapshot>
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorItem
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem
fun List<AgentSettlementSnapshot>.toSettlementByCreatorItems(): List<GetAgentSettlementByCreatorItem> {
return map {
GetAgentSettlementByCreatorItem(
creatorId = it.creatorId,
creatorNickname = it.creatorNickname,
count = it.count,
totalCan = it.totalCan,
krw = it.krw,
fee = it.fee,
settlementAmount = it.settlementAmount,
tax = it.tax,
depositAmount = it.depositAmount,
agentSettlementAmount = it.agentSettlementAmount
)
}
}
fun List<AgentSettlementSnapshot>.toChannelDonationSettlementByCreatorItems():
List<GetAgentChannelDonationSettlementByCreatorItem> {
return map {
GetAgentChannelDonationSettlementByCreatorItem(
creatorId = it.creatorId,
creatorNickname = it.creatorNickname,
count = it.count,
totalCan = it.totalCan,
krw = it.krw,
fee = it.fee,
settlementAmount = it.settlementAmount,
withholdingTax = it.tax,
depositAmount = it.depositAmount,
agentSettlementAmount = it.agentSettlementAmount
)
}
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class AgentSettlementSnapshotSourceDetail(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "snapshot_id", nullable = false)
var snapshot: AgentSettlementSnapshot,
@Column(nullable = true, updatable = false)
var assignmentId: Long? = null,
@Column(nullable = true, updatable = false)
var agentSettlementRatioId: Long? = null,
@Column(nullable = true, updatable = false)
var appliedAgentSettlementRatio: Int? = null,
@Column(nullable = false, updatable = false)
var count: Int,
@Column(nullable = false, updatable = false)
var totalCan: Int,
@Column(nullable = false, updatable = false)
var krw: Int,
@Column(nullable = false, updatable = false)
var fee: Int,
@Column(nullable = false, updatable = false)
var settlementAmount: Int,
@Column(nullable = false, updatable = false)
var tax: Int,
@Column(nullable = false, updatable = false)
var depositAmount: Int,
@Column(nullable = false, updatable = false)
var agentSettlementAmount: Int
) : BaseEntity()

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot
import org.springframework.data.jpa.repository.JpaRepository
interface AgentSettlementSnapshotSourceDetailRepository : JpaRepository<AgentSettlementSnapshotSourceDetail, Long>

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import java.time.LocalDateTime
data class FinalizeAgentSettlementSnapshotRequest(
val agentId: Long,
val settlementType: AgentSettlementSnapshotType,
val startDateStr: String,
val endDateStr: String
) {
fun toDateRange(): Pair<LocalDateTime, LocalDateTime> {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return startDate to endDate
}
}
data class FinalizeAgentSettlementSnapshotResponse(
val finalizedCount: Int,
val alreadyFinalized: Boolean
)

View File

@@ -0,0 +1,50 @@
package kr.co.vividnext.sodalive.admin.partner.agent.assignment
import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest
import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest
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 java.time.LocalDateTime
class AdminAgentCreatorControllerTest {
private lateinit var service: AdminAgentCreatorService
private lateinit var controller: AdminAgentCreatorController
@BeforeEach
fun setup() {
service = Mockito.mock(AdminAgentCreatorService::class.java)
controller = AdminAgentCreatorController(service)
}
@Test
@DisplayName("관리자 컨트롤러는 소속 지정 요청을 서비스로 전달한다")
fun shouldForwardAssignRequestToService() {
val request = AssignAgentCreatorRequest(
agentId = 11L,
creatorId = 22L,
assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
val response = controller.assignCreator(request)
assertEquals(true, response.success)
Mockito.verify(service).assignCreator(request)
}
@Test
@DisplayName("관리자 컨트롤러는 소속 해제 요청을 서비스로 전달한다")
fun shouldForwardRemoveRequestToService() {
val request = RemoveAgentCreatorRequest(
creatorId = 22L,
unassignedAt = LocalDateTime.of(2026, 4, 9, 11, 0)
)
val response = controller.removeCreator(request)
assertEquals(true, response.success)
Mockito.verify(service).removeCreator(request)
}
}

View File

@@ -0,0 +1,382 @@
package kr.co.vividnext.sodalive.admin.partner.agent.assignment
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository
import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest
import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest
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.ArgumentCaptor
import org.mockito.InOrder
import org.mockito.Mockito
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
import java.util.Optional
class AdminAgentCreatorServiceTest {
private lateinit var relationRepository: AgentCreatorRelationRepository
private lateinit var memberRepository: MemberRepository
private lateinit var service: AdminAgentCreatorService
@BeforeEach
fun setup() {
relationRepository = Mockito.mock(AgentCreatorRelationRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
service = AdminAgentCreatorService(
relationRepository = relationRepository,
memberRepository = memberRepository
)
}
@Test
@DisplayName("관리자는 에이전트와 크리에이터를 소속으로 연결할 수 있다")
fun shouldAssignCreatorToAgent() {
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
val expectedUtcAssignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 11L
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
creator.id = 22L
val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator)
Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(emptyList())
service.assignCreator(request)
val relationCaptor = ArgumentCaptor.forClass(AgentCreatorRelation::class.java)
Mockito.verify(relationRepository).saveAndFlush(relationCaptor.capture())
assertEquals(agent, relationCaptor.value.agent)
assertEquals(creator, relationCaptor.value.creator)
assertEquals(expectedUtcAssignedAt, relationCaptor.value.assignedAt)
assertEquals(null, relationCaptor.value.unassignedAt)
}
@Test
@DisplayName("동일한 회원을 에이전트와 크리에이터로 동시에 소속 지정할 수 없다")
fun shouldThrowWhenAgentAndCreatorAreSameMember() {
val request = AssignAgentCreatorRequest(
agentId = 11L,
creatorId = 11L,
assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
val exception = assertThrows(SodaException::class.java) {
service.assignCreator(request)
}
assertEquals("partner.agent.assignment.invalid_relation", exception.messageKey)
Mockito.verifyNoInteractions(memberRepository)
Mockito.verifyNoInteractions(relationRepository)
}
@Test
@DisplayName("관리자 소속 지정은 creator row를 잠근 뒤 저장한다")
fun shouldLockCreatorBeforeAssigning() {
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 11L
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
creator.id = 22L
val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator)
Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(emptyList())
service.assignCreator(request)
val inOrder: InOrder = Mockito.inOrder(memberRepository, relationRepository)
inOrder.verify(memberRepository).findById(11L)
inOrder.verify(memberRepository).findByIdForUpdate(22L)
inOrder.verify(relationRepository).findAllByCreatorIdOrderByAssignedAtAsc(22L)
inOrder.verify(relationRepository).saveAndFlush(Mockito.any(AgentCreatorRelation::class.java))
}
@Test
@DisplayName("동시 요청으로 소속 unique 제약이 충돌하면 중복 소속 예외로 변환한다")
fun shouldThrowAssignmentOverlapWhenConcurrentInsertViolatesUniqueConstraint() {
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 11L
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
creator.id = 22L
val existingRelation = AgentCreatorRelation()
existingRelation.agent = agent
existingRelation.creator = creator
existingRelation.assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator)
Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(emptyList())
Mockito.`when`(relationRepository.saveAndFlush(Mockito.any(AgentCreatorRelation::class.java)))
.thenThrow(DataIntegrityViolationException("duplicate"))
Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(existingRelation)
val exception = assertThrows(SodaException::class.java) {
service.assignCreator(request)
}
assertEquals("partner.agent.assignment.assignment_overlap", exception.messageKey)
}
@Test
@DisplayName("에이전트가 아니면 크리에이터를 소속으로 지정할 수 없다")
fun shouldThrowWhenMemberIsNotAgent() {
val invalidAgent = Member(password = "password", nickname = "user", role = MemberRole.USER)
invalidAgent.id = 11L
val request = AssignAgentCreatorRequest(
agentId = 11L,
creatorId = 22L,
assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(invalidAgent))
val exception = assertThrows(SodaException::class.java) {
service.assignCreator(request)
}
assertEquals("partner.agent.assignment.invalid_agent", exception.messageKey)
Mockito.verify(memberRepository).findById(11L)
Mockito.verifyNoInteractions(relationRepository)
}
@Test
@DisplayName("에이전트 회원이 없으면 소속 지정을 진행할 수 없다")
fun shouldThrowWhenAgentDoesNotExist() {
val request = AssignAgentCreatorRequest(
agentId = 11L,
creatorId = 22L,
assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.empty())
val exception = assertThrows(SodaException::class.java) {
service.assignCreator(request)
}
assertEquals("partner.agent.assignment.agent_not_found", exception.messageKey)
Mockito.verify(memberRepository).findById(11L)
Mockito.verifyNoInteractions(relationRepository)
}
@Test
@DisplayName("크리에이터가 아니면 에이전트에 소속시킬 수 없다")
fun shouldThrowWhenMemberIsNotCreator() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 11L
val invalidCreator = Member(password = "password", nickname = "user", role = MemberRole.USER)
invalidCreator.id = 22L
val request = AssignAgentCreatorRequest(
agentId = 11L,
creatorId = 22L,
assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(invalidCreator)
val exception = assertThrows(SodaException::class.java) {
service.assignCreator(request)
}
assertEquals("partner.agent.assignment.invalid_creator", exception.messageKey)
Mockito.verify(memberRepository).findById(11L)
Mockito.verify(memberRepository).findByIdForUpdate(22L)
Mockito.verifyNoInteractions(relationRepository)
}
@Test
@DisplayName("크리에이터 회원이 없으면 소속 지정을 진행할 수 없다")
fun shouldThrowWhenCreatorDoesNotExist() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 11L
val request = AssignAgentCreatorRequest(
agentId = 11L,
creatorId = 22L,
assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.assignCreator(request)
}
assertEquals("partner.agent.assignment.creator_not_found", exception.messageKey)
Mockito.verify(memberRepository).findById(11L)
Mockito.verify(memberRepository).findByIdForUpdate(22L)
Mockito.verifyNoInteractions(relationRepository)
}
@Test
@DisplayName("이미 다른 에이전트에 소속된 크리에이터는 중복 지정할 수 없다")
fun shouldThrowWhenCreatorIsAlreadyAssigned() {
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 11L
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
creator.id = 22L
val existingRelation = AgentCreatorRelation()
existingRelation.agent = agent
existingRelation.creator = creator
existingRelation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0)
existingRelation.unassignedAt = null
val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator)
Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(listOf(existingRelation))
val exception = assertThrows(SodaException::class.java) {
service.assignCreator(request)
}
assertEquals("partner.agent.assignment.assignment_overlap", exception.messageKey)
Mockito.verify(relationRepository).findAllByCreatorIdOrderByAssignedAtAsc(22L)
Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java))
Mockito.verify(relationRepository, Mockito.never()).saveAndFlush(Mockito.any(AgentCreatorRelation::class.java))
}
@Test
@DisplayName("종료된 이력이 있으면 이후 시각에 다시 소속 지정할 수 있다")
fun shouldAssignCreatorWhenPreviousAssignmentAlreadyEnded() {
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
val previousUnassignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 11L
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
creator.id = 22L
val existingRelation = AgentCreatorRelation()
existingRelation.agent = agent
existingRelation.creator = creator
existingRelation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0)
existingRelation.unassignedAt = previousUnassignedAt
val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator)
Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(listOf(existingRelation))
service.assignCreator(request)
Mockito.verify(relationRepository).saveAndFlush(Mockito.any(AgentCreatorRelation::class.java))
}
@Test
@DisplayName("관리자는 활성 소속의 종료 시각을 기록해 크리에이터 소속을 해제할 수 있다")
fun shouldCloseCreatorAssignmentWindow() {
val relation = AgentCreatorRelation()
relation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0)
val expectedUtcUnassignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
val request = RemoveAgentCreatorRequest(
creatorId = 22L,
unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(relation)
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(
Member(
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
).also { it.id = 22L }
)
service.removeCreator(request)
assertEquals(expectedUtcUnassignedAt, relation.unassignedAt)
Mockito.verify(relationRepository).save(relation)
}
@Test
@DisplayName("소속 종료 시각 비교는 한국 시간 입력을 UTC로 변환한 뒤 수행한다")
fun shouldValidateUnassignedAtAfterConvertingKstToUtc() {
val relation = AgentCreatorRelation()
relation.assignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
val request = RemoveAgentCreatorRequest(
creatorId = 22L,
unassignedAt = LocalDateTime.of(2026, 4, 9, 9, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(
Member(
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
).also { it.id = 22L }
)
Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(relation)
val exception = assertThrows(SodaException::class.java) {
service.removeCreator(request)
}
assertEquals("partner.agent.assignment.invalid_unassigned_at", exception.messageKey)
Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java))
}
@Test
@DisplayName("소속 정보가 없으면 해제할 수 없다")
fun shouldThrowWhenAssignmentDoesNotExist() {
val request = RemoveAgentCreatorRequest(
creatorId = 22L,
unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(
Member(
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
).also { it.id = 22L }
)
Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.removeCreator(request)
}
assertEquals("partner.agent.assignment.not_found", exception.messageKey)
Mockito.verify(relationRepository).findFirstByCreatorIdAndUnassignedAtIsNull(22L)
Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java))
}
@Test
@DisplayName("소속 종료 시각은 시작 시각보다 늦어야 한다")
fun shouldThrowWhenUnassignedAtIsNotAfterAssignedAt() {
val relation = AgentCreatorRelation()
relation.assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
val request = RemoveAgentCreatorRequest(
creatorId = 22L,
unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(
Member(
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
).also { it.id = 22L }
)
Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(relation)
val exception = assertThrows(SodaException::class.java) {
service.removeCreator(request)
}
assertEquals("partner.agent.assignment.invalid_unassigned_at", exception.messageKey)
Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java))
}
}

View File

@@ -0,0 +1,92 @@
package kr.co.vividnext.sodalive.admin.partner.agent.ratio
import kr.co.vividnext.sodalive.partner.agent.ratio.CreateAgentSettlementRatioRequest
import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioHistoryItem
import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioItem
import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioResponse
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
import java.time.LocalDateTime
class AdminAgentSettlementRatioControllerTest {
private lateinit var service: AdminAgentSettlementRatioService
private lateinit var controller: AdminAgentSettlementRatioController
@BeforeEach
fun setup() {
service = Mockito.mock(AdminAgentSettlementRatioService::class.java)
controller = AdminAgentSettlementRatioController(service)
}
@Test
@DisplayName("관리자 컨트롤러는 정산 비율 생성 요청을 서비스로 전달한다")
fun shouldForwardCreateRequestToService() {
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
val response = controller.createAgentSettlementRatio(request)
assertEquals(true, response.success)
Mockito.verify(service).createAgentSettlementRatio(request)
}
@Test
@DisplayName("관리자 컨트롤러는 정산 비율 수정 요청을 서비스로 전달한다")
fun shouldForwardUpdateRequestToService() {
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 18,
effectiveFrom = LocalDateTime.of(2026, 4, 10, 0, 0)
)
val response = controller.updateAgentSettlementRatio(request)
assertEquals(true, response.success)
Mockito.verify(service).updateAgentSettlementRatio(request)
}
@Test
@DisplayName("관리자 컨트롤러는 정산 비율 목록 조회 파라미터를 서비스로 전달한다")
fun shouldForwardPageableToService() {
val responseBody = GetAgentSettlementRatioResponse(
totalCount = 1,
items = listOf(
GetAgentSettlementRatioItem(
memberId = 31L,
nickname = "agent-a",
current = GetAgentSettlementRatioHistoryItem(
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0),
effectiveTo = null
),
history = listOf(
GetAgentSettlementRatioHistoryItem(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0),
effectiveTo = LocalDateTime.of(2026, 4, 9, 10, 0)
)
)
)
)
)
Mockito.`when`(service.getAgentSettlementRatio(offset = 20L, limit = 20L)).thenReturn(responseBody)
val response = controller.getAgentSettlementRatio(PageRequest.of(1, 20))
assertEquals(true, response.success)
assertEquals(1, response.data!!.totalCount)
assertEquals(15, response.data!!.items[0].current!!.settlementRatio)
assertEquals(LocalDateTime.of(2026, 4, 9, 10, 0), response.data!!.items[0].current!!.effectiveFrom)
assertEquals(null, response.data!!.items[0].current!!.effectiveTo)
assertEquals(1, response.data!!.items[0].history.size)
Mockito.verify(service).getAgentSettlementRatio(offset = 20L, limit = 20L)
}
}

View File

@@ -0,0 +1,452 @@
package kr.co.vividnext.sodalive.admin.partner.agent.ratio
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatio
import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatioRepository
import kr.co.vividnext.sodalive.partner.agent.ratio.CreateAgentSettlementRatioRequest
import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioHistoryItem
import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioItem
import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioRow
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.ArgumentCaptor
import org.mockito.InOrder
import org.mockito.Mockito
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
class AgentSettlementRatioServiceTest {
private lateinit var repository: AgentSettlementRatioRepository
private lateinit var memberRepository: MemberRepository
private lateinit var service: AdminAgentSettlementRatioService
@BeforeEach
fun setup() {
repository = Mockito.mock(AgentSettlementRatioRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
service = AdminAgentSettlementRatioService(
repository = repository,
memberRepository = memberRepository
)
}
@Test
@DisplayName("관리자는 에이전트 정산 비율을 생성할 수 있다")
fun shouldCreateAgentSettlementRatio() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
val request = CreateAgentSettlementRatioRequest(memberId = 31L, settlementRatio = 15, effectiveFrom = effectiveFrom)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null)
service.createAgentSettlementRatio(request)
val ratioCaptor = ArgumentCaptor.forClass(AgentSettlementRatio::class.java)
Mockito.verify(repository).saveAndFlush(ratioCaptor.capture())
assertEquals(agent, ratioCaptor.value.member)
assertEquals(15, ratioCaptor.value.settlementRatio)
assertEquals(effectiveFrom, ratioCaptor.value.effectiveFrom)
assertEquals(null, ratioCaptor.value.effectiveTo)
}
@Test
@DisplayName("기존 활성 비율이 있으면 생성 요청도 이전 row를 종료하고 새 이력 row를 추가한다")
fun shouldCloseCurrentRatioAndInsertNewHistoryWhenCreating() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val currentRatio = AgentSettlementRatio(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0)
)
currentRatio.member = agent
val newEffectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 15,
effectiveFrom = newEffectiveFrom
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(currentRatio)
service.createAgentSettlementRatio(request)
assertEquals(newEffectiveFrom, currentRatio.effectiveTo)
val ratioCaptor = ArgumentCaptor.forClass(AgentSettlementRatio::class.java)
val inOrder: InOrder = Mockito.inOrder(repository)
inOrder.verify(repository).save(currentRatio)
inOrder.verify(repository).saveAndFlush(ratioCaptor.capture())
assertEquals(agent, ratioCaptor.value.member)
assertEquals(15, ratioCaptor.value.settlementRatio)
assertEquals(newEffectiveFrom, ratioCaptor.value.effectiveFrom)
assertEquals(null, ratioCaptor.value.effectiveTo)
}
@Test
@DisplayName("기존 활성 비율 시작 시각보다 과거 effectiveFrom으로는 생성할 수 없다")
fun shouldThrowWhenCreatingRatioWithBackdatedEffectiveFrom() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val currentRatio = AgentSettlementRatio(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
currentRatio.member = agent
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 9, 59)
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(currentRatio)
val exception = assertThrows(SodaException::class.java) {
service.createAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey)
Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java))
Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(AgentSettlementRatio::class.java))
}
@Test
@DisplayName("settlementRatio가 0보다 작으면 정산 비율을 생성할 수 없다")
fun shouldThrowWhenCreatingRatioWithSettlementRatioBelowZero() {
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = -1,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
val exception = assertThrows(SodaException::class.java) {
service.createAgentSettlementRatio(request)
}
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verifyNoInteractions(memberRepository)
Mockito.verifyNoInteractions(repository)
}
@Test
@DisplayName("기존 활성 비율 시작 시각과 같은 effectiveFrom으로는 수정할 수 없다")
fun shouldThrowWhenUpdatingRatioWithSameEffectiveFrom() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val existingRatio = AgentSettlementRatio(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
existingRatio.member = agent
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 18,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(existingRatio)
val exception = assertThrows(SodaException::class.java) {
service.updateAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey)
Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java))
Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(AgentSettlementRatio::class.java))
}
@Test
@DisplayName("settlementRatio가 100보다 크면 정산 비율을 수정할 수 없다")
fun shouldThrowWhenUpdatingRatioWithSettlementRatioAboveHundred() {
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 101,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
val exception = assertThrows(SodaException::class.java) {
service.updateAgentSettlementRatio(request)
}
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verifyNoInteractions(memberRepository)
Mockito.verifyNoInteractions(repository)
}
@Test
@DisplayName("활성 비율이 없어도 기존 이력 구간과 겹치는 effectiveFrom으로는 생성할 수 없다")
fun shouldThrowWhenCreatingRatioOverlappingClosedHistory() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val historicalRatio = AgentSettlementRatio(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0)
)
historicalRatio.member = agent
historicalRatio.effectiveTo = LocalDateTime.of(2026, 4, 10, 0, 0)
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 18,
effectiveFrom = LocalDateTime.of(2026, 4, 5, 0, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null)
Mockito.`when`(repository.findAllByMemberIdOrderByEffectiveFromAsc(31L)).thenReturn(listOf(historicalRatio))
val exception = assertThrows(SodaException::class.java) {
service.createAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey)
Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java))
Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(AgentSettlementRatio::class.java))
}
@Test
@DisplayName("동시 요청으로 비율 unique 제약이 충돌하면 잘못된 effectiveFrom 예외로 변환한다")
fun shouldThrowWhenConcurrentInsertViolatesUniqueConstraint() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
val duplicateException = DataIntegrityViolationException(
"duplicate",
RuntimeException("Duplicate entry '31-1' for key 'agent_settlement_ratio.uk_agent_settlement_ratio_member_active'")
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(AgentSettlementRatio::class.java)))
.thenThrow(duplicateException)
val exception = assertThrows(SodaException::class.java) {
service.createAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey)
}
@Test
@DisplayName("에이전트 회원이 없으면 정산 비율을 생성할 수 없다")
fun shouldThrowWhenAgentDoesNotExist() {
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.createAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.agent_not_found", exception.messageKey)
Mockito.verify(memberRepository).findByIdForUpdate(31L)
Mockito.verifyNoInteractions(repository)
}
@Test
@DisplayName("에이전트가 아닌 회원에게는 정산 비율을 생성할 수 없다")
fun shouldThrowWhenMemberIsNotAgent() {
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
creator.id = 31L
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(creator)
val exception = assertThrows(SodaException::class.java) {
service.createAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.invalid_agent", exception.messageKey)
Mockito.verify(memberRepository).findByIdForUpdate(31L)
Mockito.verifyNoInteractions(repository)
}
@Test
@DisplayName("관리자는 기존 활성 비율 종료를 먼저 flush한 뒤 새 에이전트 정산 비율을 수정 저장한다")
fun shouldUpdateAgentSettlementRatio() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val existingRatio = AgentSettlementRatio(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0)
)
existingRatio.member = agent
val newEffectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 18,
effectiveFrom = newEffectiveFrom
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(existingRatio)
service.updateAgentSettlementRatio(request)
assertEquals(newEffectiveFrom, existingRatio.effectiveTo)
val ratioCaptor = ArgumentCaptor.forClass(AgentSettlementRatio::class.java)
val inOrder: InOrder = Mockito.inOrder(repository)
inOrder.verify(repository).saveAndFlush(existingRatio)
inOrder.verify(repository).saveAndFlush(ratioCaptor.capture())
assertEquals(agent, ratioCaptor.value.member)
assertEquals(18, ratioCaptor.value.settlementRatio)
assertEquals(newEffectiveFrom, ratioCaptor.value.effectiveFrom)
assertEquals(null, ratioCaptor.value.effectiveTo)
}
@Test
@DisplayName("정산 비율 수정 중 활성 unique 제약이 충돌하면 같은 세션 재조회 없이 잘못된 effectiveFrom 예외로 변환한다")
fun shouldThrowWithoutRequeryWhenUpdatingRatioViolatesActiveUniqueConstraint() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val existingRatio = AgentSettlementRatio(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0)
)
existingRatio.member = agent
val newEffectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 18,
effectiveFrom = newEffectiveFrom
)
val duplicateException = DataIntegrityViolationException(
"duplicate",
RuntimeException("Duplicate entry '31-1' for key 'agent_settlement_ratio.uk_agent_settlement_ratio_member_active'")
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(existingRatio)
Mockito.`when`(repository.saveAndFlush(Mockito.any(AgentSettlementRatio::class.java))).thenAnswer { invocation ->
val ratio = invocation.getArgument<AgentSettlementRatio>(0)
if (ratio === existingRatio) {
ratio
} else {
throw duplicateException
}
}
val exception = assertThrows(SodaException::class.java) {
service.updateAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey)
Mockito.verify(repository, Mockito.times(1)).findFirstByMemberIdAndEffectiveToIsNull(31L)
}
@Test
@DisplayName("기존 정산 비율이 없으면 수정할 수 없다")
fun shouldThrowWhenSettlementRatioDoesNotExist() {
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
agent.id = 31L
val request = CreateAgentSettlementRatioRequest(
memberId = 31L,
settlementRatio = 18,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0)
)
Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent)
Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.updateAgentSettlementRatio(request)
}
assertEquals("partner.agent.ratio.not_found", exception.messageKey)
Mockito.verify(repository).findFirstByMemberIdAndEffectiveToIsNull(31L)
Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java))
}
@Test
@DisplayName("관리자는 에이전트 정산 비율 목록을 조회할 수 있다")
fun shouldGetAgentSettlementRatioList() {
val items = listOf(
GetAgentSettlementRatioRow(
memberId = 31L,
nickname = "agent-a",
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0),
effectiveTo = null
),
GetAgentSettlementRatioRow(
memberId = 31L,
nickname = "agent-a",
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0),
effectiveTo = LocalDateTime.of(2026, 4, 9, 10, 0)
),
GetAgentSettlementRatioRow(
memberId = 21L,
nickname = "agent-b",
settlementRatio = 12,
effectiveFrom = LocalDateTime.of(2026, 3, 1, 0, 0),
effectiveTo = LocalDateTime.of(2026, 3, 15, 0, 0)
)
)
Mockito.`when`(repository.getAgentSettlementRatioTotalCount()).thenReturn(2)
Mockito.`when`(repository.getAgentSettlementRatio(offset = 20L, limit = 20L)).thenReturn(items)
val response = service.getAgentSettlementRatio(offset = 20L, limit = 20L)
assertEquals(2, response.totalCount)
assertEquals(2, response.items.size)
assertEquals(
GetAgentSettlementRatioItem(
memberId = 31L,
nickname = "agent-a",
current = GetAgentSettlementRatioHistoryItem(
settlementRatio = 15,
effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0),
effectiveTo = null
),
history = listOf(
GetAgentSettlementRatioHistoryItem(
settlementRatio = 10,
effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0),
effectiveTo = LocalDateTime.of(2026, 4, 9, 10, 0)
)
)
),
response.items[0]
)
assertEquals(21L, response.items[1].memberId)
assertEquals("agent-b", response.items[1].nickname)
assertEquals(null, response.items[1].current)
assertEquals(
listOf(
GetAgentSettlementRatioHistoryItem(
settlementRatio = 12,
effectiveFrom = LocalDateTime.of(2026, 3, 1, 0, 0),
effectiveTo = LocalDateTime.of(2026, 3, 15, 0, 0)
)
),
response.items[1].history
)
}
}

View File

@@ -0,0 +1,160 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import javax.servlet.http.HttpServletResponse
@WebMvcTest(AdminAgentReadController::class)
@Import(AdminAgentReadControllerSecurityTest.TestSecurityConfig::class)
class AdminAgentReadControllerSecurityTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var service: AdminAgentReadService
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@TestConfiguration
@EnableGlobalMethodSecurity(prePostEnabled = true)
class TestSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf().disable()
.authorizeRequests()
.antMatchers("/admin/partner/agent/**").hasRole("ADMIN")
.anyRequest().permitAll()
.and()
.exceptionHandling()
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
.and()
.build()
}
}
@Test
@DisplayName("관리자 권한이면 에이전트 목록 조회에 성공한다")
fun shouldAllowAdminRole() {
Mockito.`when`(service.getAgentList(offset = 0L, limit = 20L)).thenReturn(
GetAdminAgentListResponse(
totalCount = 1,
items = listOf(
GetAdminAgentListItem(
agentId = 11L,
agentNickname = "agent-a",
assignedCreatorCount = 2,
liveAgentSettlementAmount = 131,
contentAgentSettlementAmount = 80,
communityAgentSettlementAmount = 55,
contentDonationAgentSettlementAmount = 12,
channelDonationAgentSettlementAmount = 397
)
)
)
)
val result = mockMvc.perform(
get("/admin/partner/agent/list")
.param("page", "0")
.param("size", "20")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andReturn()
println(result.response.contentAsString)
}
@Test
@DisplayName("관리자 권한이면 에이전트 닉네임 검색에 성공한다")
fun shouldAllowAdminRoleForAgentNicknameSearch() {
Mockito.`when`(service.searchAgentByNickname(searchWord = "agent", size = 10)).thenReturn(
listOf(
AdminSimpleMemberResponse(
id = 11L,
nickname = "agent-a"
)
)
)
val result = mockMvc.perform(
get("/admin/partner/agent/search-by-nickname")
.param("search_word", "agent")
.param("size", "10")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data[0].id").value(11L))
.andReturn()
println(result.response.contentAsString)
}
@Test
@DisplayName("익명 사용자는 관리자 조회 API에 접근할 수 없다")
fun shouldRejectAnonymousUser() {
mockMvc.perform(
get("/admin/partner/agent/list")
.param("page", "0")
.param("size", "20")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("에이전트 권한 사용자는 관리자 조회 API에 접근할 수 없다")
fun shouldRejectAgentRole() {
mockMvc.perform(
get("/admin/partner/agent/list")
.param("page", "0")
.param("size", "20")
.with(user("agent").roles("AGENT"))
)
.andExpect(status().isForbidden)
}
@Test
@DisplayName("일반 사용자 권한 사용자는 관리자 조회 API에 접근할 수 없다")
fun shouldRejectUserRole() {
mockMvc.perform(
get("/admin/partner/agent/list")
.param("page", "0")
.param("size", "20")
.with(user("user").roles("USER"))
)
.andExpect(status().isForbidden)
}
}

View File

@@ -0,0 +1,192 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorItem
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementTotal
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorTotal
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
import java.time.LocalDateTime
class AdminAgentReadControllerTest {
private lateinit var service: AdminAgentReadService
private lateinit var controller: AdminAgentReadController
@BeforeEach
fun setup() {
service = Mockito.mock(AdminAgentReadService::class.java)
controller = AdminAgentReadController(service)
}
@Test
@DisplayName("관리자 컨트롤러는 에이전트 목록 조회 파라미터를 서비스로 전달한다")
fun shouldForwardAgentListRequest() {
val body = GetAdminAgentListResponse(
totalCount = 1,
items = listOf(
GetAdminAgentListItem(
agentId = 11L,
agentNickname = "agent-a",
assignedCreatorCount = 2,
liveAgentSettlementAmount = 131,
contentAgentSettlementAmount = 80,
communityAgentSettlementAmount = 55,
contentDonationAgentSettlementAmount = 12,
channelDonationAgentSettlementAmount = 397
)
)
)
Mockito.`when`(service.getAgentList(offset = 20L, limit = 20L)).thenReturn(body)
val response = controller.getAgentList(PageRequest.of(1, 20))
assertEquals(true, response.success)
assertEquals(2, response.data!!.items.first().assignedCreatorCount)
assertEquals(131, response.data!!.items.first().liveAgentSettlementAmount)
assertEquals(397, response.data!!.items.first().channelDonationAgentSettlementAmount)
Mockito.verify(service).getAgentList(offset = 20L, limit = 20L)
}
@Test
@DisplayName("관리자 컨트롤러는 크리에이터 검색 파라미터를 서비스로 전달한다")
fun shouldForwardCreatorSearchRequest() {
val body = SearchAdminAgentAssignableCreatorResponse(
totalCount = 1,
items = listOf(
SearchAdminAgentAssignableCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
currentAgentId = 11L,
currentAgentNickname = "agent-a"
)
)
)
Mockito.`when`(service.searchAssignableCreators(searchWord = "creator", offset = 0L, limit = 10L)).thenReturn(body)
val response = controller.searchAssignableCreators(searchWord = "creator", pageable = PageRequest.of(0, 10))
assertEquals(true, response.success)
assertEquals(11L, response.data!!.items.first().currentAgentId)
Mockito.verify(service).searchAssignableCreators(searchWord = "creator", offset = 0L, limit = 10L)
}
@Test
@DisplayName("관리자 컨트롤러는 에이전트 닉네임 검색 파라미터를 서비스로 전달한다")
fun shouldForwardAgentNicknameSearchRequest() {
val body = listOf(
AdminSimpleMemberResponse(
id = 11L,
nickname = "agent-a"
)
)
Mockito.`when`(service.searchAgentByNickname(searchWord = "agent", size = 10)).thenReturn(body)
val response = controller.searchAgentByNickname(searchWord = "agent", size = 10)
assertEquals(true, response.success)
assertEquals(11L, response.data!!.first().id)
Mockito.verify(service).searchAgentByNickname(searchWord = "agent", size = 10)
}
@Test
@DisplayName("관리자 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다")
fun shouldForwardAssignedCreatorListRequest() {
val body = GetAdminAgentAssignedCreatorResponse(
totalCount = 1,
items = listOf(
GetAdminAgentAssignedCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
assignedAt = LocalDateTime.of(2026, 4, 1, 10, 0)
)
)
)
Mockito.`when`(service.getAssignedCreators(agentId = 11L, offset = 10L, limit = 5L)).thenReturn(body)
val response = controller.getAssignedCreators(agentId = 11L, pageable = PageRequest.of(2, 5))
assertEquals(true, response.success)
assertEquals(21L, response.data!!.items.first().creatorId)
Mockito.verify(service).getAssignedCreators(agentId = 11L, offset = 10L, limit = 5L)
}
@Test
@DisplayName("관리자 컨트롤러는 라이브 정산 조회 파라미터를 서비스로 전달한다")
fun shouldForwardLiveSettlementRequest() {
val body = GetAgentSettlementByCreatorResponse(
totalCount = 1,
total = GetAgentSettlementByCreatorTotal(1, 50, 5000, 330, 3736, 123, 3613, 131),
items = listOf(GetAgentSettlementByCreatorItem(21L, "creator-a", 1, 50, 5000, 330, 3736, 123, 3613, 131))
)
Mockito.`when`(
service.getCalculateLiveByCreator(
agentId = 11L,
startDateStr = "2026-04-01",
endDateStr = "2026-04-30",
offset = 0L,
limit = 20L
)
).thenReturn(body)
val response = controller.getCalculateLiveByCreator(
agentId = 11L,
startDateStr = "2026-04-01",
endDateStr = "2026-04-30",
pageable = PageRequest.of(0, 20)
)
assertEquals(true, response.success)
assertEquals(131, response.data!!.items.first().agentSettlementAmount)
Mockito.verify(service).getCalculateLiveByCreator(11L, "2026-04-01", "2026-04-30", 0L, 20L)
}
@Test
@DisplayName("관리자 컨트롤러는 채널후원 정산 조회 파라미터를 서비스로 전달한다")
fun shouldForwardChannelDonationSettlementRequest() {
val body = GetAgentChannelDonationSettlementByCreatorResponse(
totalCount = 1,
total = GetAgentChannelDonationSettlementTotal(1, 50, 5000, 330, 3970, 131, 3839, 397),
items = listOf(
GetAgentChannelDonationSettlementByCreatorItem(
21L,
"creator-a",
1,
50,
5000,
330,
3970,
131,
3839,
397
)
)
)
Mockito.`when`(
service.getChannelDonationByCreator(
agentId = 11L,
startDateStr = "2026-04-01",
endDateStr = "2026-04-30",
offset = 0L,
limit = 20L
)
).thenReturn(body)
val response = controller.getChannelDonationByCreator(
agentId = 11L,
startDateStr = "2026-04-01",
endDateStr = "2026-04-30",
pageable = PageRequest.of(0, 20)
)
assertEquals(true, response.success)
assertEquals(397, response.data!!.items.first().agentSettlementAmount)
Mockito.verify(service).getChannelDonationByCreator(11L, "2026-04-01", "2026-04-30", 0L, 20L)
}
}

View File

@@ -0,0 +1,384 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatio
import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatioRepository
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.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository
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 java.time.Month
import java.time.ZoneId
import java.time.ZonedDateTime
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"])
@Import(QueryDslConfig::class)
class AdminAgentReadCurrentMonthListSummaryTest @Autowired constructor(
private val queryFactory: JPAQueryFactory,
private val memberRepository: MemberRepository,
private val relationRepository: AgentCreatorRelationRepository,
private val creatorSettlementRatioRepository: CreatorSettlementRatioRepository,
private val audioContentRepository: AudioContentRepository,
private val orderRepository: OrderRepository,
private val liveRoomRepository: LiveRoomRepository,
private val creatorCommunityRepository: CreatorCommunityRepository,
private val useCanRepository: UseCanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository,
private val snapshotRepository: AgentSettlementSnapshotRepository,
private val entityManager: EntityManager
) {
private val cloudFrontHost = "https://cdn.test"
private lateinit var repository: AdminAgentReadQueryRepository
private lateinit var calculateService: AgentCalculateService
@BeforeEach
fun setup() {
val calculateQueryRepository = AgentCalculateQueryRepository(queryFactory, entityManager, cloudFrontHost)
repository = AdminAgentReadQueryRepository(queryFactory, calculateQueryRepository)
calculateService = AgentCalculateService(calculateQueryRepository, snapshotRepository)
registerMysqlDateFunctions()
}
@Test
@DisplayName("에이전트 목록 조회는 현재 월 5종 정산 합계를 포함하고 내역이 없으면 0을 반환한다")
fun shouldReturnCurrentMonthSettlementSummariesAndZeroForEmptyAgents() {
val now = ZonedDateTime.of(LocalDateTime.of(2026, 4, 10, 12, 0), ZoneId.of("Asia/Seoul"))
val agentWithRows = saveMember("agent-with-rows", MemberRole.AGENT)
val emptyAgent = saveMember("agent-empty", MemberRole.AGENT)
seedCurrentMonthSettlementFixtures(agentWithRows, now.toLocalDateTime())
val items = repository.getAgentList(offset = 0, limit = 20, currentTime = now)
val emptyItem = items.first { it.agentId == emptyAgent.id }
val agentItem = items.first { it.agentId == agentWithRows.id }
assertEquals(0, emptyItem.liveAgentSettlementAmount)
assertEquals(0, emptyItem.contentAgentSettlementAmount)
assertEquals(0, emptyItem.communityAgentSettlementAmount)
assertEquals(0, emptyItem.contentDonationAgentSettlementAmount)
assertEquals(0, emptyItem.channelDonationAgentSettlementAmount)
assertEquals(
calculateService
.getCalculateLiveByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L)
.total.agentSettlementAmount,
agentItem.liveAgentSettlementAmount
)
assertEquals(
calculateService
.getCalculateContentByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L)
.total.agentSettlementAmount,
agentItem.contentAgentSettlementAmount
)
assertEquals(
calculateService
.getCalculateCommunityByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L)
.total.agentSettlementAmount,
agentItem.communityAgentSettlementAmount
)
assertEquals(
calculateService
.getCalculateContentDonationByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L)
.total.agentSettlementAmount,
agentItem.contentDonationAgentSettlementAmount
)
assertEquals(
calculateService
.getChannelDonationByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L)
.total.agentSettlementAmount,
agentItem.channelDonationAgentSettlementAmount
)
}
@Test
@DisplayName("에이전트 목록 현재 월 경계는 Asia/Seoul 기준으로 계산한다")
fun shouldCalculateCurrentMonthBoundaryInAsiaSeoul() {
val currentTime = ZonedDateTime.of(
LocalDateTime.of(2026, 3, 31, 15, 30),
ZoneId.of("UTC")
)
val agent = saveMember("agent-kst-boundary", MemberRole.AGENT)
seedUtcBoundaryFixtures(agent)
val items = repository.getAgentList(offset = 0, limit = 20, currentTime = currentTime)
val item = items.first { it.agentId == agent.id }
assertEquals(true, item.liveAgentSettlementAmount > 0)
assertEquals(true, item.contentAgentSettlementAmount > 0)
assertEquals(true, item.communityAgentSettlementAmount > 0)
assertEquals(true, item.contentDonationAgentSettlementAmount > 0)
assertEquals(true, item.channelDonationAgentSettlementAmount > 0)
}
@Test
@DisplayName("에이전트 목록 현재 월 경계는 UTC 조회 기준으로는 전월 말 15시부터 시작될 수 있다")
fun shouldIncludeUtcRowsThatBelongToKstMonthStart() {
val currentTime = ZonedDateTime.of(
LocalDateTime.of(2026, 3, 31, 15, 30),
ZoneId.of("UTC")
)
val agent = saveMember("agent-kst-utc-range", MemberRole.AGENT)
seedUtcBoundaryFixtures(agent)
val items = repository.getAgentList(offset = 0, limit = 20, currentTime = currentTime)
val item = items.first { it.agentId == agent.id }
assertEquals(Month.APRIL, currentTime.withZoneSameInstant(ZoneId.of("Asia/Seoul")).month)
assertEquals(true, item.liveAgentSettlementAmount > 0)
}
private fun seedCurrentMonthSettlementFixtures(agent: Member, now: LocalDateTime) {
val creator = saveMember("creator-${agent.nickname}", MemberRole.CREATOR)
val buyer = saveMember("buyer-${agent.nickname}", MemberRole.USER)
val sender = saveMember("sender-${agent.nickname}", MemberRole.USER)
saveRelation(agent, creator, assignedAt = now.withDayOfMonth(1).minusDays(1))
saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 65)
val liveRoom = saveLiveRoom(creator, now.withDayOfMonth(5).withHour(8))
saveLiveUseCan(sender, liveRoom, 10, now.withDayOfMonth(5).withHour(9))
saveLiveUseCan(sender, liveRoom, 20, now.withDayOfMonth(5).withHour(10))
val paidContent = saveAudioContent(creator, "content-${agent.nickname}", price = 50, settlementRatio = 80)
val donationContent = saveAudioContent(creator, "donation-${agent.nickname}", price = 0, settlementRatio = null)
saveOrder(buyer, creator, paidContent, now.withDayOfMonth(6).withHour(11))
val communityPost = saveCommunityPost(creator, 10)
saveCommunityUseCan(buyer, communityPost, 15, now.withDayOfMonth(7).withHour(12))
saveContentDonationUseCan(buyer, donationContent, 12, now.withDayOfMonth(8).withHour(13))
val channelDonation = saveChannelDonationUseCan(sender, 40, now.withDayOfMonth(9).withHour(14))
saveUseCanCalculate(channelDonation, creator.id!!, 15, PaymentGateway.PG)
saveUseCanCalculate(channelDonation, creator.id!!, 25, PaymentGateway.GOOGLE_IAP)
}
private fun seedUtcBoundaryFixtures(agent: Member) {
val creator = saveMember("creator-${agent.nickname}", MemberRole.CREATOR)
val buyer = saveMember("buyer-${agent.nickname}", MemberRole.USER)
val sender = saveMember("sender-${agent.nickname}", MemberRole.USER)
saveRelation(agent, creator, assignedAt = LocalDateTime.of(2026, 3, 1, 0, 0))
saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 65)
val liveRoom = saveLiveRoom(creator, LocalDateTime.of(2026, 3, 31, 15, 10))
saveLiveUseCan(sender, liveRoom, 10, LocalDateTime.of(2026, 3, 31, 15, 30))
saveLiveUseCan(sender, liveRoom, 20, LocalDateTime.of(2026, 3, 31, 15, 40))
val paidContent = saveAudioContent(creator, "content-${agent.nickname}", price = 50, settlementRatio = 80)
val donationContent = saveAudioContent(creator, "donation-${agent.nickname}", price = 0, settlementRatio = null)
saveOrder(buyer, creator, paidContent, LocalDateTime.of(2026, 3, 31, 15, 50))
val communityPost = saveCommunityPost(creator, 10)
saveCommunityUseCan(buyer, communityPost, 15, LocalDateTime.of(2026, 3, 31, 15, 55))
saveContentDonationUseCan(buyer, donationContent, 12, LocalDateTime.of(2026, 3, 31, 15, 58))
val channelDonation = saveChannelDonationUseCan(sender, 40, LocalDateTime.of(2026, 3, 31, 15, 59))
saveUseCanCalculate(channelDonation, creator.id!!, 15, PaymentGateway.PG)
saveUseCanCalculate(channelDonation, creator.id!!, 25, PaymentGateway.GOOGLE_IAP)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
)
}
private fun saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime) {
val relation = AgentCreatorRelation()
relation.agent = agent
relation.creator = creator
relation.assignedAt = assignedAt
relation.unassignedAt = null
relationRepository.saveAndFlush(relation)
}
private fun saveCreatorSettlementRatio(creator: Member, live: Int, content: Int, community: Int) {
val ratio = CreatorSettlementRatio(
subsidy = 0,
liveSettlementRatio = live,
contentSettlementRatio = content,
communitySettlementRatio = community
)
ratio.member = creator
creatorSettlementRatioRepository.saveAndFlush(ratio)
}
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime): LiveRoom {
val room = LiveRoom(
title = "live-room",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 10,
isAdult = false,
price = 10
)
room.member = creator
return liveRoomRepository.saveAndFlush(room)
}
private fun saveLiveUseCan(sender: Member, room: LiveRoom, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.LIVE,
can = can,
rewardCan = 0
)
useCan.member = sender
useCan.room = room
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveAudioContent(creator: Member, title: String, price: Int, settlementRatio: Int?): AudioContent {
val theme = AudioContentTheme(
theme = "theme-$title",
image = "image-$title.png"
)
entityManager.persist(theme)
val audioContent = AudioContent(
title = title,
detail = "detail-$title",
languageCode = "ko",
price = price,
settlementRatio = settlementRatio
)
audioContent.theme = theme
audioContent.member = creator
audioContent.isActive = true
return audioContentRepository.saveAndFlush(audioContent)
}
private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime): Order {
val order = Order(type = OrderType.KEEP)
order.member = buyer
order.creator = creator
order.audioContent = content
val saved = orderRepository.saveAndFlush(order)
updateOrderCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveCommunityPost(creator: Member, price: Int): CreatorCommunity {
val post = CreatorCommunity(
content = "community-content-$price",
price = price,
isCommentAvailable = true,
isAdult = false
)
post.member = creator
return creatorCommunityRepository.saveAndFlush(post)
}
private fun saveCommunityUseCan(buyer: Member, post: CreatorCommunity, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.PAID_COMMUNITY_POST,
can = can,
rewardCan = 0
)
useCan.member = buyer
useCan.communityPost = post
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveContentDonationUseCan(buyer: Member, content: AudioContent, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.DONATION,
can = can,
rewardCan = 0
)
useCan.member = buyer
useCan.audioContent = content
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveChannelDonationUseCan(sender: Member, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.CHANNEL_DONATION,
can = can,
rewardCan = 0
)
useCan.member = sender
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
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 updateOrderCreatedAt(orderId: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update Order o set o.createdAt = :createdAt where o.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", orderId)
.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()
}
}

View File

@@ -0,0 +1,325 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatio
import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatioRepository
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.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository
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 AdminAgentReadParityTest @Autowired constructor(
private val queryFactory: JPAQueryFactory,
private val memberRepository: MemberRepository,
private val relationRepository: AgentCreatorRelationRepository,
private val creatorSettlementRatioRepository: CreatorSettlementRatioRepository,
private val audioContentRepository: AudioContentRepository,
private val orderRepository: OrderRepository,
private val liveRoomRepository: LiveRoomRepository,
private val creatorCommunityRepository: CreatorCommunityRepository,
private val useCanRepository: UseCanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository,
private val snapshotRepository: AgentSettlementSnapshotRepository,
private val entityManager: EntityManager
) {
private val cloudFrontHost = "https://cdn.test"
private var seededAgentId: Long = 0L
private lateinit var agentService: AgentCalculateService
private lateinit var adminService: AdminAgentReadService
@BeforeEach
fun setup() {
registerMysqlDateFunctions()
val queryRepository = AgentCalculateQueryRepository(queryFactory, entityManager, cloudFrontHost)
val adminQueryRepository = AdminAgentReadQueryRepository(queryFactory, queryRepository)
agentService = AgentCalculateService(queryRepository, snapshotRepository)
adminService = AdminAgentReadService(adminQueryRepository, memberRepository, agentService)
seededAgentId = seedParityFixtures()
}
@Test
@DisplayName("관리자 라이브 정산 조회는 에이전트 조회와 동일한 응답을 반환한다")
fun shouldMatchLiveSettlementBetweenAdminAndAgent() {
val agentResponse = agentService.getCalculateLiveByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L)
val adminResponse = adminService.getCalculateLiveByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L)
assertEquals(agentResponse, adminResponse)
}
@Test
@DisplayName("관리자 콘텐츠 정산 조회는 에이전트 조회와 동일한 응답을 반환한다")
fun shouldMatchContentSettlementBetweenAdminAndAgent() {
val agentResponse = agentService.getCalculateContentByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L)
val adminResponse = adminService.getCalculateContentByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L)
assertEquals(agentResponse.totalCount, adminResponse.totalCount)
assertEquals(agentResponse.total, adminResponse.total)
assertEquals(agentResponse.items, adminResponse.items)
}
@Test
@DisplayName("관리자 커뮤니티 정산 조회는 에이전트 조회와 동일한 응답을 반환한다")
fun shouldMatchCommunitySettlementBetweenAdminAndAgent() {
val agentResponse = agentService.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L)
val adminResponse = adminService.getCalculateCommunityByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L)
assertEquals(agentResponse.totalCount, adminResponse.totalCount)
assertEquals(agentResponse.total, adminResponse.total)
assertEquals(agentResponse.items, adminResponse.items)
}
@Test
@DisplayName("관리자 채널후원 정산 조회는 에이전트 조회와 동일한 응답을 반환한다")
fun shouldMatchChannelDonationSettlementBetweenAdminAndAgent() {
val agentResponse = agentService.getChannelDonationByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L)
val adminResponse = adminService.getChannelDonationByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L)
assertEquals(agentResponse.totalCount, adminResponse.totalCount)
assertEquals(agentResponse.total, adminResponse.total)
assertEquals(agentResponse.items, adminResponse.items)
}
@Test
@DisplayName("관리자 콘텐츠후원 정산 조회는 에이전트 조회와 동일한 응답을 반환한다")
fun shouldMatchContentDonationSettlementBetweenAdminAndAgent() {
val agentResponse = agentService.getCalculateContentDonationByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L)
val adminResponse = adminService.getCalculateContentDonationByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L)
assertEquals(agentResponse.totalCount, adminResponse.totalCount)
assertEquals(agentResponse.total, adminResponse.total)
assertEquals(agentResponse.items, adminResponse.items)
}
private fun seedParityFixtures(): Long {
val agent = saveMember("agent-parity", MemberRole.AGENT)
val creator = saveMember("creator-parity", MemberRole.CREATOR)
val buyer = saveMember("buyer-parity", MemberRole.USER)
val sender = saveMember("sender-parity", MemberRole.USER)
saveRelation(agent, creator)
saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 65)
val liveRoom = saveLiveRoom(creator)
saveLiveUseCan(sender, liveRoom, 10, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveLiveUseCan(sender, liveRoom, 20, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
val paidContent = saveAudioContent(creator, "content-parity", price = 50, settlementRatio = 80)
val donationContent = saveAudioContent(creator, "content-donation-parity", price = 0, settlementRatio = null)
saveOrder(buyer, creator, paidContent, LocalDateTime.of(2026, 2, 20, 11, 0, 0))
val communityPost = saveCommunityPost(creator, 10)
saveCommunityUseCan(buyer, communityPost, 15, LocalDateTime.of(2026, 2, 20, 12, 0, 0))
saveContentDonationUseCan(buyer, donationContent, 12, LocalDateTime.of(2026, 2, 20, 13, 0, 0))
val channelDonation = saveChannelDonationUseCan(sender, 40, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
saveUseCanCalculate(channelDonation, creator.id!!, 15, PaymentGateway.PG)
saveUseCanCalculate(channelDonation, creator.id!!, 25, PaymentGateway.GOOGLE_IAP)
return agent.id!!
}
private fun saveMember(nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
)
}
private fun saveRelation(agent: Member, creator: Member) {
val relation = AgentCreatorRelation()
relation.agent = agent
relation.creator = creator
relation.assignedAt = LocalDateTime.of(2026, 2, 1, 0, 0, 0)
relation.unassignedAt = null
relationRepository.saveAndFlush(relation)
}
private fun saveCreatorSettlementRatio(creator: Member, live: Int, content: Int, community: Int) {
val ratio = CreatorSettlementRatio(
subsidy = 0,
liveSettlementRatio = live,
contentSettlementRatio = content,
communitySettlementRatio = community
)
ratio.member = creator
creatorSettlementRatioRepository.saveAndFlush(ratio)
}
private fun saveLiveRoom(creator: Member): LiveRoom {
val room = LiveRoom(
title = "live-room",
notice = "notice",
beginDateTime = LocalDateTime.of(2026, 2, 20, 8, 0, 0),
numberOfPeople = 10,
isAdult = false,
price = 10
)
room.member = creator
return liveRoomRepository.saveAndFlush(room)
}
private fun saveLiveUseCan(sender: Member, room: LiveRoom, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.LIVE,
can = can,
rewardCan = 0
)
useCan.member = sender
useCan.room = room
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveAudioContent(creator: Member, title: String, price: Int, settlementRatio: Int?): AudioContent {
val theme = AudioContentTheme(
theme = "theme-$title",
image = "image-$title.png"
)
entityManager.persist(theme)
val audioContent = AudioContent(
title = title,
detail = "detail-$title",
languageCode = "ko",
price = price,
settlementRatio = settlementRatio
)
audioContent.theme = theme
audioContent.member = creator
audioContent.isActive = true
return audioContentRepository.saveAndFlush(audioContent)
}
private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime): Order {
val order = Order(type = OrderType.KEEP)
order.member = buyer
order.creator = creator
order.audioContent = content
val saved = orderRepository.saveAndFlush(order)
updateOrderCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveCommunityPost(creator: Member, price: Int): CreatorCommunity {
val post = CreatorCommunity(
content = "community-content-$price",
price = price,
isCommentAvailable = true,
isAdult = false
)
post.member = creator
return creatorCommunityRepository.saveAndFlush(post)
}
private fun saveCommunityUseCan(buyer: Member, post: CreatorCommunity, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.PAID_COMMUNITY_POST,
can = can,
rewardCan = 0
)
useCan.member = buyer
useCan.communityPost = post
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveContentDonationUseCan(buyer: Member, content: AudioContent, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.DONATION,
can = can,
rewardCan = 0
)
useCan.member = buyer
useCan.audioContent = content
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveChannelDonationUseCan(sender: Member, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.CHANNEL_DONATION,
can = can,
rewardCan = 0
)
useCan.member = sender
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
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 updateOrderCreatedAt(orderId: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update Order o set o.createdAt = :createdAt where o.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", orderId)
.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()
}
}

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.jpa.impl.JPAQueryFactory
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 kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository
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 java.time.ZoneId
import java.time.ZonedDateTime
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"])
@Import(QueryDslConfig::class)
class AdminAgentReadQueryRepositoryTest @Autowired constructor(
private val queryFactory: JPAQueryFactory,
private val memberRepository: MemberRepository,
private val relationRepository: AgentCreatorRelationRepository,
private val entityManager: EntityManager
) {
private val cloudFrontHost = "https://cdn.test"
private lateinit var repository: AdminAgentReadQueryRepository
@BeforeEach
fun setup() {
repository = AdminAgentReadQueryRepository(
queryFactory,
AgentCalculateQueryRepository(queryFactory, entityManager, cloudFrontHost)
)
}
@Test
@DisplayName("에이전트 목록 조회는 활성 소속 크리에이터 수를 함께 반환한다")
fun shouldGetAgentListWithAssignedCreatorCount() {
val agentA = saveMember("agent-a", MemberRole.AGENT)
val agentB = saveMember("agent-b", MemberRole.AGENT)
val creatorA = saveMember("creator-a", MemberRole.CREATOR)
val creatorB = saveMember("creator-b", MemberRole.CREATOR)
val now = LocalDateTime.of(2026, 4, 10, 12, 0)
saveRelation(agentA, creatorA, assignedAt = now.minusDays(1), unassignedAt = null)
saveRelation(agentA, creatorB, assignedAt = now.minusDays(2), unassignedAt = now.plusDays(1))
val totalCount = repository.getAgentListTotalCount()
val items = repository.getAgentList(
offset = 0,
limit = 20,
currentTime = ZonedDateTime.of(now, ZoneId.of("Asia/Seoul"))
)
assertEquals(2, totalCount)
assertEquals(listOf(agentB.id, agentA.id), items.map { it.agentId })
assertEquals(listOf(0, 2), items.map { it.assignedCreatorCount })
}
@Test
@DisplayName("크리에이터 검색은 현재 활성 에이전트 소속 정보를 함께 반환한다")
fun shouldSearchAssignableCreatorsWithCurrentAgentInfo() {
val agent = saveMember("agent-search", MemberRole.AGENT)
val creatorAssigned = saveMember("creator-alpha", MemberRole.CREATOR)
val creatorFree = saveMember("creator-beta", MemberRole.CREATOR)
val now = LocalDateTime.of(2026, 4, 10, 12, 0)
saveRelation(agent, creatorAssigned, assignedAt = now.minusDays(1), unassignedAt = null)
val totalCount = repository.searchAssignableCreatorsTotalCount(searchWord = "creator", currentTime = now)
val items = repository.searchAssignableCreators(searchWord = "creator", offset = 0, limit = 20, currentTime = now)
assertEquals(2, totalCount)
assertEquals(listOf(creatorFree.id, creatorAssigned.id), items.map { it.creatorId })
assertEquals(listOf(null, agent.id), items.map { it.currentAgentId })
assertEquals(listOf(null, "agent-search"), items.map { it.currentAgentNickname })
}
@Test
@DisplayName("에이전트 닉네임 검색은 활성 AGENT만 id와 nickname으로 반환한다")
fun shouldSearchActiveAgentsByNickname() {
val matchedAgent = saveMember("agent-alpha", MemberRole.AGENT)
saveMember("agent-beta", MemberRole.AGENT, isActive = false)
saveMember("creator-agent", MemberRole.CREATOR)
val items = repository.searchAgentByNickname(searchWord = "agent", limit = 20)
assertEquals(1, items.size)
assertEquals(matchedAgent.id, items.first().id)
assertEquals("agent-alpha", items.first().nickname)
}
@Test
@DisplayName("특정 에이전트 소속 크리에이터 목록은 assignedAt을 포함해 현재 활성 구간만 반환한다")
fun shouldGetAssignedCreatorsForAdminDetail() {
val agent = saveMember("agent-detail", MemberRole.AGENT)
val activeCreator = saveMember("creator-active", MemberRole.CREATOR)
val futureCreator = saveMember("creator-future", MemberRole.CREATOR)
val now = LocalDateTime.of(2026, 4, 10, 12, 0)
saveRelation(agent, activeCreator, assignedAt = now.minusDays(3), unassignedAt = null)
saveRelation(agent, futureCreator, assignedAt = now.plusDays(1), unassignedAt = null)
val totalCount = repository.getAssignedCreatorTotalCount(agentId = agent.id!!, currentTime = now)
val items = repository.getAssignedCreators(agentId = agent.id!!, offset = 0, limit = 20, currentTime = now)
assertEquals(1, totalCount)
assertEquals(1, items.size)
assertEquals(activeCreator.id, items.first().creatorId)
assertEquals(now.minusDays(3), items.first().assignedAt)
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
member.isActive = isActive
return memberRepository.saveAndFlush(member)
}
private fun saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime, unassignedAt: LocalDateTime?) {
val relation = AgentCreatorRelation()
relation.agent = agent
relation.creator = creator
relation.assignedAt = assignedAt
relation.unassignedAt = unassignedAt
relationRepository.saveAndFlush(relation)
}
}

View File

@@ -0,0 +1,227 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorTotal
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 java.time.LocalDateTime
import java.time.ZonedDateTime
import java.util.Optional
class AdminAgentReadServiceTest {
private lateinit var queryRepository: AdminAgentReadQueryRepository
private lateinit var memberRepository: MemberRepository
private lateinit var calculateService: AgentCalculateService
private lateinit var service: AdminAgentReadService
@BeforeEach
fun setup() {
queryRepository = Mockito.mock(AdminAgentReadQueryRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
calculateService = Mockito.mock(AgentCalculateService::class.java)
service = AdminAgentReadService(queryRepository, memberRepository, calculateService)
}
@Test
@DisplayName("크리에이터 검색은 두 글자 미만 검색어를 거부한다")
fun shouldRejectTooShortSearchWord() {
val exception = assertThrows(SodaException::class.java) {
service.searchAssignableCreators(searchWord = "a", offset = 0, limit = 20)
}
assertEquals("admin.member.search_word_min_length", exception.messageKey)
}
@Test
@DisplayName("에이전트 닉네임 검색은 두 글자 미만 검색어를 거부한다")
fun shouldRejectTooShortAgentSearchWord() {
val exception = assertThrows(SodaException::class.java) {
service.searchAgentByNickname(searchWord = "a", size = 20)
}
assertEquals("admin.member.search_word_min_length", exception.messageKey)
}
@Test
@DisplayName("에이전트 닉네임 검색은 size가 0 이하이면 20으로 보정한다")
fun shouldDefaultAgentSearchSizeToTwenty() {
val expected = listOf(
AdminSimpleMemberResponse(
id = 11L,
nickname = "agent-a"
)
)
Mockito.`when`(queryRepository.searchAgentByNickname(searchWord = "agent", limit = 20L)).thenReturn(expected)
val actual = service.searchAgentByNickname(searchWord = "agent", size = 0)
assertEquals(expected, actual)
Mockito.verify(queryRepository).searchAgentByNickname(searchWord = "agent", limit = 20L)
}
@Test
@DisplayName("에이전트 목록 조회는 현재 월 summary 필드를 그대로 반환한다")
fun shouldReturnAgentListWithCurrentMonthSummaryFields() {
Mockito.`when`(queryRepository.getAgentListTotalCount()).thenReturn(1)
Mockito.doReturn(
listOf(
GetAdminAgentListItem(
agentId = 11L,
agentNickname = "agent-a",
assignedCreatorCount = 2,
liveAgentSettlementAmount = 131,
contentAgentSettlementAmount = 80,
communityAgentSettlementAmount = 55,
contentDonationAgentSettlementAmount = 12,
channelDonationAgentSettlementAmount = 397
)
)
).`when`(queryRepository).getAgentList(
Mockito.eq(0L),
Mockito.eq(20L),
Mockito.any(ZonedDateTime::class.java) ?: ZonedDateTime.now()
)
val actual = service.getAgentList(offset = 0L, limit = 20L)
assertEquals(397, actual.items.first().channelDonationAgentSettlementAmount)
}
@Test
@DisplayName("소속 크리에이터 목록은 AGENT 대상만 조회한다")
fun shouldThrowWhenTargetMemberIsNotAgent() {
val creator = Member(
email = "creator@test.com",
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
)
creator.id = 7L
Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(creator))
val exception = assertThrows(SodaException::class.java) {
service.getAssignedCreators(agentId = 7L, offset = 0, limit = 20)
}
assertEquals("partner.agent.ratio.invalid_agent", exception.messageKey)
}
@Test
@DisplayName("소속 크리에이터 목록 조회는 assignedAt을 UTC에서 KST로 변환해 반환한다")
fun shouldConvertAssignedAtToKstWhenGettingAssignedCreators() {
val agent = Member(
email = "agent@test.com",
password = "password",
nickname = "agent",
role = MemberRole.AGENT
)
agent.id = 11L
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(
queryRepository.getAssignedCreatorTotalCount(
Mockito.eq(11L),
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
).thenReturn(1)
Mockito.doReturn(
listOf(
GetAdminAgentAssignedCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
assignedAt = LocalDateTime.of(2026, 4, 10, 3, 0)
)
)
).`when`(queryRepository).getAssignedCreators(
Mockito.eq(11L),
Mockito.eq(0L),
Mockito.eq(20L),
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
val actual = service.getAssignedCreators(agentId = 11L, offset = 0L, limit = 20L)
assertEquals(1, actual.totalCount)
assertEquals(LocalDateTime.of(2026, 4, 10, 12, 0), actual.items.first().assignedAt)
}
@Test
@DisplayName("라이브 정산 상세 조회는 기존 AgentCalculateService로 위임한다")
fun shouldDelegateLiveSettlementToAgentCalculateService() {
val agent = Member(
email = "agent@test.com",
password = "password",
nickname = "agent",
role = MemberRole.AGENT
)
agent.id = 11L
val response = GetAgentSettlementByCreatorResponse(
totalCount = 1,
total = GetAgentSettlementByCreatorTotal(
count = 1,
totalCan = 50,
krw = 5000,
fee = 330,
settlementAmount = 3736,
tax = 123,
depositAmount = 3613,
agentSettlementAmount = 131
),
items = listOf(
GetAgentSettlementByCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
count = 1,
totalCan = 50,
krw = 5000,
fee = 330,
settlementAmount = 3736,
tax = 123,
depositAmount = 3613,
agentSettlementAmount = 131
)
)
)
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
Mockito.`when`(
calculateService.getCalculateLiveByCreator(
startDateStr = "2026-04-01",
endDateStr = "2026-04-30",
agentId = 11L,
offset = 0L,
limit = 20L
)
).thenReturn(response)
val actual = service.getCalculateLiveByCreator(
agentId = 11L,
startDateStr = "2026-04-01",
endDateStr = "2026-04-30",
offset = 0L,
limit = 20L
)
assertEquals(131, actual.items.first().agentSettlementAmount)
Mockito.verify(calculateService).getCalculateLiveByCreator(
startDateStr = "2026-04-01",
endDateStr = "2026-04-30",
agentId = 11L,
offset = 0L,
limit = 20L
)
}
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.admin.partner.agent.settlement
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotResponse
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
class AdminAgentSettlementSnapshotControllerTest {
private lateinit var service: AdminAgentSettlementSnapshotService
private lateinit var controller: AdminAgentSettlementSnapshotController
@BeforeEach
fun setup() {
service = Mockito.mock(AdminAgentSettlementSnapshotService::class.java)
controller = AdminAgentSettlementSnapshotController(service)
}
@Test
@DisplayName("인증 사용자 정보가 없으면 관리자 확정 정산 요청은 예외를 던진다")
fun shouldThrowWhenMemberIsNull() {
val request = FinalizeAgentSettlementSnapshotRequest(
agentId = 7L,
settlementType = AgentSettlementSnapshotType.LIVE,
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val exception = assertThrows(SodaException::class.java) {
controller.finalizeSettlement(request = request, member = null)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("관리자 컨트롤러는 확정 정산 요청과 인증 관리자 정보를 서비스로 전달한다")
fun shouldForwardFinalizeRequestToService() {
val request = FinalizeAgentSettlementSnapshotRequest(
agentId = 7L,
settlementType = AgentSettlementSnapshotType.LIVE,
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val member = Member(password = "password", nickname = "admin", role = MemberRole.ADMIN)
member.id = 99L
val body = FinalizeAgentSettlementSnapshotResponse(
finalizedCount = 1,
alreadyFinalized = false
)
Mockito.`when`(service.finalizeSnapshots(request, 99L)).thenReturn(body)
val response = controller.finalizeSettlement(request = request, member = member)
assertEquals(true, response.success)
assertEquals(1, response.data!!.finalizedCount)
assertEquals(false, response.data!!.alreadyFinalized)
Mockito.verify(service).finalizeSnapshots(request, 99L)
}
}

View File

@@ -0,0 +1,305 @@
package kr.co.vividnext.sodalive.admin.partner.agent.settlement
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentCreatorSettlementSummaryQueryData
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshot
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotSourceDetail
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotSourceDetailRepository
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
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.ArgumentCaptor
import org.mockito.Mockito
import java.util.Optional
class AdminAgentSettlementSnapshotServiceTest {
private lateinit var snapshotRepository: AgentSettlementSnapshotRepository
private lateinit var sourceDetailRepository: AgentSettlementSnapshotSourceDetailRepository
private lateinit var calculateRepository: AgentCalculateQueryRepository
private lateinit var memberRepository: MemberRepository
private lateinit var service: AdminAgentSettlementSnapshotService
@BeforeEach
fun setup() {
snapshotRepository = Mockito.mock(AgentSettlementSnapshotRepository::class.java)
sourceDetailRepository = Mockito.mock(AgentSettlementSnapshotSourceDetailRepository::class.java)
calculateRepository = Mockito.mock(AgentCalculateQueryRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
service = AdminAgentSettlementSnapshotService(
snapshotRepository = snapshotRepository,
sourceDetailRepository = sourceDetailRepository,
calculateRepository = calculateRepository,
memberRepository = memberRepository
)
}
@Test
@DisplayName("관리자는 live creator summary를 immutable 스냅샷으로 확정 저장한다")
fun shouldCreateImmutableSettlementSnapshots() {
val request = FinalizeAgentSettlementSnapshotRequest(
agentId = 7L,
settlementType = AgentSettlementSnapshotType.LIVE,
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val agent = Member(password = "password", nickname = "agent-a", role = MemberRole.AGENT)
agent.id = 7L
val (startDate, endDate) = request.toDateRange()
Mockito.`when`(
snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(false)
Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(agent))
Mockito.`when`(calculateRepository.getCalculateLiveByCreator(startDate, endDate, 7L)).thenReturn(
listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
assignmentId = 101L,
agentSettlementRatioId = 202L,
count = 2L,
totalCan = 100,
settlementRatio = 70,
agentSettlementRatio = 10
)
)
)
val response = service.finalizeSnapshots(request, finalizedByMemberId = 99L)
assertEquals(1, response.finalizedCount)
assertEquals(false, response.alreadyFinalized)
@Suppress("UNCHECKED_CAST")
val captor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<AgentSettlementSnapshot>>
Mockito.verify(snapshotRepository).saveAll(captor.capture())
val snapshot = captor.value.single()
assertEquals(startDate, snapshot.periodStart)
assertEquals(endDate, snapshot.periodEnd)
assertEquals(AgentSettlementSnapshotType.LIVE, snapshot.settlementType)
assertEquals(7L, snapshot.agentId)
assertEquals("agent-a", snapshot.agentNickname)
assertEquals(21L, snapshot.creatorId)
assertEquals("creator-a", snapshot.creatorNickname)
assertEquals(101L, snapshot.assignmentId)
assertEquals(202L, snapshot.agentSettlementRatioId)
assertEquals(10, snapshot.appliedAgentSettlementRatio)
assertEquals(2, snapshot.count)
assertEquals(100, snapshot.totalCan)
assertEquals(10_000, snapshot.krw)
assertEquals(660, snapshot.fee)
assertEquals(6_538, snapshot.settlementAmount)
assertEquals(216, snapshot.tax)
assertEquals(6_322, snapshot.depositAmount)
assertEquals(654, snapshot.agentSettlementAmount)
assertEquals(99L, snapshot.finalizedByMemberId)
assertNotNull(snapshot.finalizedAt)
@Suppress("UNCHECKED_CAST")
val detailCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<AgentSettlementSnapshotSourceDetail>>
Mockito.verify(sourceDetailRepository).saveAll(detailCaptor.capture())
val detail = detailCaptor.value.single()
assertEquals(101L, detail.assignmentId)
assertEquals(202L, detail.agentSettlementRatioId)
assertEquals(10, detail.appliedAgentSettlementRatio)
assertEquals(2, detail.count)
assertEquals(100, detail.totalCan)
assertEquals(10_000, detail.krw)
assertEquals(660, detail.fee)
assertEquals(6_538, detail.settlementAmount)
assertEquals(216, detail.tax)
assertEquals(6_322, detail.depositAmount)
assertEquals(654, detail.agentSettlementAmount)
}
@Test
@DisplayName("기간 중 source row가 여러 개면 summary FK는 비우고 source detail로 provenance를 남긴다")
fun shouldStoreMixedPeriodProvenanceInSourceDetails() {
val request = FinalizeAgentSettlementSnapshotRequest(
agentId = 7L,
settlementType = AgentSettlementSnapshotType.LIVE,
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val agent = Member(password = "password", nickname = "agent-a", role = MemberRole.AGENT)
agent.id = 7L
val (startDate, endDate) = request.toDateRange()
Mockito.`when`(
snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(false)
Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(agent))
Mockito.`when`(calculateRepository.getCalculateLiveByCreator(startDate, endDate, 7L)).thenReturn(
listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
assignmentId = 101L,
agentSettlementRatioId = 202L,
count = 1L,
totalCan = 40,
settlementRatio = 70,
agentSettlementRatio = 10
),
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
assignmentId = 102L,
agentSettlementRatioId = 203L,
count = 1L,
totalCan = 60,
settlementRatio = 70,
agentSettlementRatio = 20
)
)
)
val response = service.finalizeSnapshots(request, finalizedByMemberId = 99L)
assertEquals(1, response.finalizedCount)
@Suppress("UNCHECKED_CAST")
val snapshotCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<AgentSettlementSnapshot>>
Mockito.verify(snapshotRepository).saveAll(snapshotCaptor.capture())
val snapshot = snapshotCaptor.value.single()
assertEquals(null, snapshot.assignmentId)
assertEquals(null, snapshot.agentSettlementRatioId)
assertEquals(null, snapshot.appliedAgentSettlementRatio)
@Suppress("UNCHECKED_CAST")
val detailCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<AgentSettlementSnapshotSourceDetail>>
Mockito.verify(sourceDetailRepository).saveAll(detailCaptor.capture())
assertEquals(2, detailCaptor.value.size)
assertEquals(listOf(101L, 102L), detailCaptor.value.mapNotNull { it.assignmentId }.sorted())
assertEquals(listOf(202L, 203L), detailCaptor.value.mapNotNull { it.agentSettlementRatioId }.sorted())
assertEquals(snapshot.count, detailCaptor.value.sumOf { it.count })
assertEquals(snapshot.totalCan, detailCaptor.value.sumOf { it.totalCan })
assertEquals(snapshot.krw, detailCaptor.value.sumOf { it.krw })
assertEquals(snapshot.fee, detailCaptor.value.sumOf { it.fee })
assertEquals(snapshot.settlementAmount, detailCaptor.value.sumOf { it.settlementAmount })
assertEquals(snapshot.tax, detailCaptor.value.sumOf { it.tax })
assertEquals(snapshot.depositAmount, detailCaptor.value.sumOf { it.depositAmount })
assertEquals(snapshot.agentSettlementAmount, detailCaptor.value.sumOf { it.agentSettlementAmount })
}
@Test
@DisplayName("동일 기간과 타입이 이미 확정되었으면 스냅샷을 중복 저장하지 않는다")
fun shouldSkipSavingWhenSnapshotsAlreadyExist() {
val request = FinalizeAgentSettlementSnapshotRequest(
agentId = 7L,
settlementType = AgentSettlementSnapshotType.LIVE,
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val (startDate, endDate) = request.toDateRange()
Mockito.`when`(
snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(true)
val response = service.finalizeSnapshots(request, finalizedByMemberId = 99L)
assertEquals(0, response.finalizedCount)
assertEquals(true, response.alreadyFinalized)
Mockito.verify(snapshotRepository).existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
Mockito.verify(snapshotRepository, Mockito.never()).saveAll(Mockito.anyList())
Mockito.verifyNoInteractions(sourceDetailRepository)
Mockito.verifyNoInteractions(calculateRepository)
Mockito.verifyNoInteractions(memberRepository)
}
@Test
@DisplayName("관리자 finalize는 대상 에이전트 회원이 없으면 실패한다")
fun shouldThrowWhenFinalizingSnapshotsForMissingAgent() {
val request = FinalizeAgentSettlementSnapshotRequest(
agentId = 7L,
settlementType = AgentSettlementSnapshotType.LIVE,
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val (startDate, endDate) = request.toDateRange()
Mockito.`when`(
snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(false)
Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.empty())
val exception = assertThrows(SodaException::class.java) {
service.finalizeSnapshots(request, finalizedByMemberId = 99L)
}
assertEquals("partner.agent.ratio.agent_not_found", exception.messageKey)
Mockito.verifyNoInteractions(calculateRepository)
Mockito.verify(snapshotRepository, Mockito.never()).saveAll(Mockito.anyList())
Mockito.verifyNoInteractions(sourceDetailRepository)
}
@Test
@DisplayName("관리자 finalize는 대상 회원이 AGENT 역할이 아니면 실패한다")
fun shouldThrowWhenFinalizingSnapshotsForNonAgentMember() {
val request = FinalizeAgentSettlementSnapshotRequest(
agentId = 7L,
settlementType = AgentSettlementSnapshotType.LIVE,
startDateStr = "2026-02-20",
endDateStr = "2026-02-21"
)
val invalidAgent = Member(password = "password", nickname = "user-a", role = MemberRole.USER)
invalidAgent.id = 7L
val (startDate, endDate) = request.toDateRange()
Mockito.`when`(
snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(false)
Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(invalidAgent))
val exception = assertThrows(SodaException::class.java) {
service.finalizeSnapshots(request, finalizedByMemberId = 99L)
}
assertEquals("partner.agent.ratio.invalid_agent", exception.messageKey)
Mockito.verifyNoInteractions(calculateRepository)
Mockito.verify(snapshotRepository, Mockito.never()).saveAll(Mockito.anyList())
Mockito.verifyNoInteractions(sourceDetailRepository)
}
}

View File

@@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.extensions
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.time.ZoneId
class LocalDateTimeExtensionsTest {
@Test
fun shouldConvertToUtcUsingKstByDefault() {
val localDateTime = LocalDateTime.of(2026, 4, 9, 10, 0)
val utcDateTime = invokeConvertToUtc(localDateTime)
assertEquals(LocalDateTime.of(2026, 4, 9, 1, 0), utcDateTime)
}
@Test
fun shouldConvertToUtcUsingProvidedTimezone() {
val localDateTime = LocalDateTime.of(2026, 4, 9, 10, 0)
val utcDateTime = invokeConvertToUtc(localDateTime, ZoneId.of("Asia/Bangkok"))
assertEquals(LocalDateTime.of(2026, 4, 9, 3, 0), utcDateTime)
}
private fun invokeConvertToUtc(localDateTime: LocalDateTime, timeZone: ZoneId = ZoneId.of("Asia/Seoul")): LocalDateTime {
return try {
val method = Class.forName("kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensionsKt")
.getMethod("convertToUtc", LocalDateTime::class.java, ZoneId::class.java)
method.invoke(null, localDateTime, timeZone) as LocalDateTime
} catch (e: ReflectiveOperationException) {
fail("LocalDateTime.convertToUtc 확장함수를 찾을 수 없습니다.")
}
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SodaMessageSourceTest {
private val sodaMessageSource = SodaMessageSource()
@Test
fun shouldProvidePartnerAgentAssignmentAndRatioMessages() {
assertEquals(
"이미 다른 에이전트에 소속된 크리에이터입니다.",
sodaMessageSource.getMessage("partner.agent.assignment.creator_already_assigned", Lang.KO)
)
assertEquals(
"Please select a valid agent.",
sodaMessageSource.getMessage("partner.agent.ratio.invalid_agent", Lang.EN)
)
assertEquals(
"該当するエージェント精算率がありません。",
sodaMessageSource.getMessage("partner.agent.ratio.not_found", Lang.JA)
)
}
}

View File

@@ -0,0 +1,328 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
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 AgentCalculateControllerTest {
private lateinit var service: AgentCalculateService
private lateinit var controller: AgentCalculateController
@BeforeEach
fun setup() {
service = Mockito.mock(AgentCalculateService::class.java)
controller = AgentCalculateController(service)
}
@Test
@DisplayName("인증 사용자 정보가 없으면 소속 크리에이터 목록 조회는 예외를 던진다")
fun shouldThrowWhenMemberIsNullForAssignedCreators() {
val exception = assertThrows(SodaException::class.java) {
controller.getAssignedCreators(
member = null,
pageable = PageRequest.of(0, 10)
)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("에이전트 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다")
fun shouldForwardAssignedCreatorsRequestToService() {
val member = createAgentMember(7L)
val response = GetAgentAssignedCreatorResponse(
totalCount = 1,
items = listOf(
GetAgentAssignedCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
profileImageUrl = "https://cdn.test/profile/creator-a.png"
)
)
)
Mockito.`when`(service.getAssignedCreators(agentId = 7L, offset = 10L, limit = 5L)).thenReturn(response)
val apiResponse = controller.getAssignedCreators(
member = member,
pageable = PageRequest.of(2, 5)
)
assertEquals(true, apiResponse.success)
assertEquals(1, apiResponse.data!!.totalCount)
assertEquals(21L, apiResponse.data!!.items[0].creatorId)
assertEquals("https://cdn.test/profile/creator-a.png", apiResponse.data!!.items[0].profileImageUrl)
Mockito.verify(service).getAssignedCreators(agentId = 7L, offset = 10L, limit = 5L)
}
@Test
@DisplayName("에이전트 컨트롤러는 라이브 요약 조회 파라미터를 서비스로 전달한다")
fun shouldForwardLiveSummaryRequestToService() {
val member = createAgentMember(7L)
val response = GetAgentSettlementByCreatorResponse(
totalCount = 1,
total = GetAgentSettlementByCreatorTotal(
count = 2,
totalCan = 40,
krw = 4000,
fee = 264,
settlementAmount = 2615,
tax = 86,
depositAmount = 2529,
agentSettlementAmount = 262
),
items = listOf(
GetAgentSettlementByCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
count = 2,
totalCan = 40,
krw = 4000,
fee = 264,
settlementAmount = 2615,
tax = 86,
depositAmount = 2529,
agentSettlementAmount = 262
)
)
)
Mockito.`when`(
service.getCalculateLiveByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(response)
val apiResponse = controller.getCalculateLiveByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
member = member,
pageable = PageRequest.of(0, 20)
)
assertEquals(true, apiResponse.success)
assertEquals(262, apiResponse.data!!.items[0].agentSettlementAmount)
Mockito.verify(service).getCalculateLiveByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
}
@Test
@DisplayName("에이전트 컨트롤러는 콘텐츠 요약 조회 파라미터를 서비스로 전달한다")
fun shouldForwardContentSummaryRequestToService() {
val member = createAgentMember(7L)
val response = createGenericSummaryResponse(agentSettlementAmount = 374)
Mockito.`when`(
service.getCalculateContentByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 20L,
limit = 20L
)
).thenReturn(response)
val apiResponse = controller.getCalculateContentByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
member = member,
pageable = PageRequest.of(1, 20)
)
assertEquals(true, apiResponse.success)
assertEquals(374, apiResponse.data!!.items[0].agentSettlementAmount)
Mockito.verify(service).getCalculateContentByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 20L,
limit = 20L
)
}
@Test
@DisplayName("에이전트 컨트롤러는 커뮤니티 요약 조회 파라미터를 서비스로 전달한다")
fun shouldForwardCommunitySummaryRequestToService() {
val member = createAgentMember(7L)
val response = createGenericSummaryResponse(agentSettlementAmount = 168)
Mockito.`when`(
service.getCalculateCommunityByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 10L
)
).thenReturn(response)
val apiResponse = controller.getCalculateCommunityByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
member = member,
pageable = PageRequest.of(0, 10)
)
assertEquals(true, apiResponse.success)
assertEquals(168, apiResponse.data!!.items[0].agentSettlementAmount)
Mockito.verify(service).getCalculateCommunityByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 10L
)
}
@Test
@DisplayName("에이전트 컨트롤러는 채널후원 요약 조회 파라미터를 서비스로 전달한다")
fun shouldForwardChannelDonationSummaryRequestToService() {
val member = createAgentMember(7L)
val response = GetAgentChannelDonationSettlementByCreatorResponse(
totalCount = 1,
total = GetAgentChannelDonationSettlementTotal(
count = 1,
totalCan = 50,
krw = 5000,
fee = 330,
settlementAmount = 3970,
withholdingTax = 131,
depositAmount = 3839,
agentSettlementAmount = 397
),
items = listOf(
GetAgentChannelDonationSettlementByCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
count = 1,
totalCan = 50,
krw = 5000,
fee = 330,
settlementAmount = 3970,
withholdingTax = 131,
depositAmount = 3839,
agentSettlementAmount = 397
)
)
)
Mockito.`when`(
service.getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 10L
)
).thenReturn(response)
val apiResponse = controller.getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
member = member,
pageable = PageRequest.of(0, 10)
)
assertEquals(true, apiResponse.success)
assertEquals(397, apiResponse.data!!.items[0].agentSettlementAmount)
Mockito.verify(service).getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 10L
)
}
@Test
@DisplayName("에이전트 컨트롤러는 콘텐츠후원 요약 조회 파라미터를 서비스로 전달한다")
fun shouldForwardContentDonationSummaryRequestToService() {
val member = createAgentMember(7L)
val response = createGenericSummaryResponse(agentSettlementAmount = 131)
Mockito.`when`(
service.getCalculateContentDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 10L
)
).thenReturn(response)
val apiResponse = controller.getCalculateContentDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
member = member,
pageable = PageRequest.of(0, 10)
)
assertEquals(true, apiResponse.success)
assertEquals(131, apiResponse.data!!.items[0].agentSettlementAmount)
Mockito.verify(service).getCalculateContentDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 10L
)
}
private fun createAgentMember(id: Long): Member {
val member = Member(
email = "agent@test.com",
password = "password",
nickname = "agent",
role = MemberRole.AGENT
)
member.id = id
return member
}
private fun createGenericSummaryResponse(agentSettlementAmount: Int): GetAgentSettlementByCreatorResponse {
return GetAgentSettlementByCreatorResponse(
totalCount = 1,
total = GetAgentSettlementByCreatorTotal(
count = 1,
totalCan = 50,
krw = 5000,
fee = 330,
settlementAmount = 3736,
tax = 123,
depositAmount = 3613,
agentSettlementAmount = agentSettlementAmount
),
items = listOf(
GetAgentSettlementByCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
count = 1,
totalCan = 50,
krw = 5000,
fee = 330,
settlementAmount = 3736,
tax = 123,
depositAmount = 3613,
agentSettlementAmount = agentSettlementAmount
)
)
)
}
}

View File

@@ -0,0 +1,968 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatio
import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatioRepository
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.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository
import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatio
import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatioRepository
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository
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 AgentCalculateQueryRepositoryTest @Autowired constructor(
private val queryFactory: JPAQueryFactory,
private val memberRepository: MemberRepository,
private val relationRepository: AgentCreatorRelationRepository,
private val agentSettlementRatioRepository: AgentSettlementRatioRepository,
private val snapshotRepository: AgentSettlementSnapshotRepository,
private val creatorSettlementRatioRepository: CreatorSettlementRatioRepository,
private val audioContentRepository: AudioContentRepository,
private val orderRepository: OrderRepository,
private val liveRoomRepository: LiveRoomRepository,
private val creatorCommunityRepository: CreatorCommunityRepository,
private val useCanRepository: UseCanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository,
private val entityManager: EntityManager
) {
private val cloudFrontHost = "https://cdn.test"
private lateinit var repository: AgentCalculateQueryRepository
private lateinit var service: AgentCalculateService
@BeforeEach
fun setup() {
registerMysqlDateFunctions()
repository = AgentCalculateQueryRepository(queryFactory, entityManager, cloudFrontHost)
service = AgentCalculateService(repository, snapshotRepository)
}
@Test
@DisplayName("소속 크리에이터 목록 조회는 현재 에이전트에 연결된 크리에이터만 반환한다")
fun shouldGetAssignedCreatorsOnlyForCurrentAgent() {
val agent = saveMember("agent-a", MemberRole.AGENT)
val otherAgent = saveMember("agent-b", MemberRole.AGENT)
val creatorA = saveMember("creator-a", MemberRole.CREATOR)
val creatorB = saveMember("creator-b", MemberRole.CREATOR)
val creatorC = saveMember("creator-c", MemberRole.CREATOR)
val currentTime = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
saveRelation(agent, creatorA)
saveRelation(agent, creatorB)
saveRelation(otherAgent, creatorC)
val totalCount = repository.getAssignedCreatorTotalCount(agent.id!!, currentTime)
val items = repository.getAssignedCreators(agent.id!!, offset = 0, limit = 10, currentTime = currentTime)
assertEquals(2, totalCount)
assertEquals(2, items.size)
assertEquals(listOf(creatorB.id, creatorA.id), items.map { it.creatorId })
}
@Test
@DisplayName("소속 크리에이터 목록 조회는 현재 시각 활성 구간의 크리에이터만 반환한다")
fun shouldGetAssignedCreatorsOnlyWithinCurrentAssignmentWindow() {
val agent = saveMember("agent-window", MemberRole.AGENT)
val currentCreator = saveMember("creator-current", MemberRole.CREATOR)
val futureCreator = saveMember("creator-future", MemberRole.CREATOR)
val endingFutureCreator = saveMember("creator-ending-future", MemberRole.CREATOR)
val endedCreator = saveMember("creator-ended", MemberRole.CREATOR)
val currentTime = LocalDateTime.now()
saveRelation(agent, currentCreator, assignedAt = currentTime.minusDays(2), unassignedAt = null)
saveRelation(agent, futureCreator, assignedAt = currentTime.plusDays(1), unassignedAt = null)
saveRelation(agent, endingFutureCreator, assignedAt = currentTime.minusDays(2), unassignedAt = currentTime.plusDays(1))
saveRelation(agent, endedCreator, assignedAt = currentTime.minusDays(3), unassignedAt = currentTime.minusHours(1))
val totalCount = repository.getAssignedCreatorTotalCount(agent.id!!, currentTime)
val items = repository.getAssignedCreators(agent.id!!, offset = 0, limit = 10, currentTime = currentTime)
assertEquals(2, totalCount)
assertEquals(listOf(endingFutureCreator.id, currentCreator.id), items.map { it.creatorId })
}
@Test
@DisplayName("소속 크리에이터 목록 조회는 프로필 이미지 URL과 기본 이미지를 함께 반환한다")
fun shouldGetAssignedCreatorsWithProfileImageUrl() {
val agent = saveMember("agent-image", MemberRole.AGENT)
val creatorWithImage = saveMember("creator-image", MemberRole.CREATOR, profileImage = "profile/creator-image.png")
val creatorWithoutImage = saveMember("creator-default", MemberRole.CREATOR)
val currentTime = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
saveRelation(agent, creatorWithImage)
saveRelation(agent, creatorWithoutImage)
val items = repository.getAssignedCreators(agent.id!!, offset = 0, limit = 10, currentTime = currentTime)
assertEquals(
listOf(
"$cloudFrontHost/profile/default-profile.png",
"$cloudFrontHost/profile/creator-image.png"
),
items.map { it.profileImageUrl }
)
}
@Test
@DisplayName("라이브 크리에이터별 조회는 소속된 크리에이터만 집계한다")
fun shouldGetLiveSummaryRowsOnlyForAssignedCreators() {
val agent = saveMember("agent-live", MemberRole.AGENT)
val creator = saveMember("creator-live", MemberRole.CREATOR)
val otherCreator = saveMember("creator-other-live", MemberRole.CREATOR)
val sender = saveMember("sender-live", MemberRole.USER)
saveRelation(agent, creator)
saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70)
saveCreatorSettlementRatio(otherCreator, live = 80, content = 80, community = 80)
val room = saveLiveRoom(creator)
val otherRoom = saveLiveRoom(otherCreator)
saveLiveUseCan(sender, room, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveLiveUseCan(sender, room, 30, LocalDateTime.of(2026, 2, 20, 11, 0, 0))
saveLiveUseCan(sender, otherRoom, 50, LocalDateTime.of(2026, 2, 20, 12, 0, 0))
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val totalCount = repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, agent.id!!)
val rows = repository.getCalculateLiveByCreator(startDate, endDate, agent.id!!)
assertEquals(1, totalCount)
assertEquals(1, rows.size)
assertEquals(creator.id, rows[0].creatorId)
assertEquals(2L, rows[0].count)
assertEquals(40, rows[0].totalCan)
assertEquals(70, rows[0].settlementRatio)
}
@Test
@DisplayName("콘텐츠 크리에이터별 조회는 콘텐츠 개별 정산 비율과 기본 정산 비율을 모두 반영한다")
fun shouldGetContentSummaryRowsGroupedByEffectiveSettlementRatio() {
val agent = saveMember("agent-content", MemberRole.AGENT)
val creator = saveMember("creator-content", MemberRole.CREATOR)
val otherCreator = saveMember("creator-other-content", MemberRole.CREATOR)
val buyer = saveMember("buyer-content", MemberRole.USER)
saveRelation(agent, creator)
saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 70)
saveCreatorSettlementRatio(otherCreator, live = 70, content = 90, community = 70)
val paidContent = saveAudioContent(creator, "content-a", price = 50, settlementRatio = 80)
val fallbackContent = saveAudioContent(creator, "content-b", price = 30, settlementRatio = null)
val otherContent = saveAudioContent(otherCreator, "content-c", price = 90, settlementRatio = null)
saveOrder(buyer, creator, paidContent, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveOrder(buyer, creator, fallbackContent, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveOrder(buyer, otherCreator, otherContent, LocalDateTime.of(2026, 2, 20, 11, 0, 0))
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val totalCount = repository.getCalculateContentByCreatorTotalCount(startDate, endDate, agent.id!!)
val rows = repository.getCalculateContentByCreator(startDate, endDate, agent.id!!)
assertEquals(1, totalCount)
assertEquals(2, rows.size)
assertEquals(listOf(80, 60), rows.mapNotNull { it.settlementRatio }.sortedDescending())
assertEquals(listOf(50, 30), rows.map { it.totalCan }.sortedDescending())
assertEquals(listOf(creator.id!!, creator.id!!), rows.map { it.creatorId })
}
@Test
@DisplayName("콘텐츠 total projection은 콘텐츠별 비율과 fallback 비율이 섞여도 기존 Kotlin total과 같아야 한다")
fun shouldMatchDbTotalProjectionForContentRowsSplitByEffectiveSettlementRatio() {
val agent = saveMember("agent-content-total", MemberRole.AGENT)
val creator = saveMember("creator-content-total", MemberRole.CREATOR)
val buyer = saveMember("buyer-content-total", MemberRole.USER)
saveRelation(agent, creator)
saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 70)
val paidContent = saveAudioContent(creator, "content-total-a", price = 50, settlementRatio = 80)
val fallbackContent = saveAudioContent(creator, "content-total-b", price = 30, settlementRatio = null)
saveOrder(buyer, creator, paidContent, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveOrder(buyer, creator, fallbackContent, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val kotlinTotal = repository.getCalculateContentByCreator(startDate, endDate, agent.id!!).toResponseTotal()
val dbTotal = repository.getCalculateContentByCreatorTotal(startDate, endDate, agent.id!!)
assertEquals(kotlinTotal, dbTotal)
}
@Test
@DisplayName("콘텐츠 total projection은 explicit 70과 null fallback 70이 섞여도 기존 Kotlin total과 같아야 한다")
fun shouldMatchDbTotalProjectionWhenExplicitAndFallbackSeventyMustStaySeparated() {
val agent = saveMember("agent-content-fallback-total", MemberRole.AGENT)
val creator = saveMember("creator-content-fallback-total", MemberRole.CREATOR)
val buyer = saveMember("buyer-content-fallback-total", MemberRole.USER)
saveRelation(agent, creator)
val explicitRatioContent = saveAudioContent(creator, "content-explicit-seventy", price = 1, settlementRatio = 70)
val fallbackRatioContent = saveAudioContent(creator, "content-fallback-seventy", price = 1, settlementRatio = null)
saveOrder(buyer, creator, explicitRatioContent, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveOrder(buyer, creator, fallbackRatioContent, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val rows = repository.getCalculateContentByCreator(startDate, endDate, agent.id!!)
val kotlinTotal = rows.toResponseTotal()
val dbTotal = repository.getCalculateContentByCreatorTotal(startDate, endDate, agent.id!!)
assertEquals(2, rows.size)
assertEquals(kotlinTotal, dbTotal)
}
@Test
@DisplayName("커뮤니티 크리에이터별 조회는 소속된 크리에이터만 집계한다")
fun shouldGetCommunitySummaryRowsOnlyForAssignedCreators() {
val agent = saveMember("agent-community", MemberRole.AGENT)
val creator = saveMember("creator-community", MemberRole.CREATOR)
val otherCreator = saveMember("creator-other-community", MemberRole.CREATOR)
val buyer = saveMember("buyer-community", MemberRole.USER)
saveRelation(agent, creator)
saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 60)
saveCreatorSettlementRatio(otherCreator, live = 70, content = 70, community = 80)
val communityPost = saveCommunityPost(creator, 10)
val otherPost = saveCommunityPost(otherCreator, 20)
saveCommunityUseCan(buyer, communityPost, 20, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveCommunityUseCan(buyer, communityPost, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveCommunityUseCan(buyer, otherPost, 50, LocalDateTime.of(2026, 2, 20, 11, 0, 0))
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val totalCount = repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate, agent.id!!)
val rows = repository.getCalculateCommunityByCreator(startDate, endDate, agent.id!!)
assertEquals(1, totalCount)
assertEquals(1, rows.size)
assertEquals(creator.id, rows[0].creatorId)
assertEquals(2L, rows[0].count)
assertEquals(30, rows[0].totalCan)
assertEquals(60, rows[0].settlementRatio)
}
@Test
@DisplayName("콘텐츠후원 크리에이터별 조회는 소속된 크리에이터만 집계한다")
fun shouldGetContentDonationSummaryRowsOnlyForAssignedCreators() {
val agent = saveMember("agent-content-donation", MemberRole.AGENT)
val creator = saveMember("creator-content-donation", MemberRole.CREATOR)
val otherCreator = saveMember("creator-other-content-donation", MemberRole.CREATOR)
val buyer = saveMember("buyer-content-donation", MemberRole.USER)
saveRelation(agent, creator)
val content = saveAudioContent(creator, "content-donation-a", price = 0, settlementRatio = null)
val otherContent = saveAudioContent(otherCreator, "content-donation-b", price = 0, settlementRatio = null)
saveContentDonationUseCan(buyer, content, 7, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveContentDonationUseCan(buyer, content, 13, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveContentDonationUseCan(buyer, otherContent, 30, LocalDateTime.of(2026, 2, 20, 11, 0, 0))
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val totalCount = repository.getCalculateContentDonationByCreatorTotalCount(startDate, endDate, agent.id!!)
val rows = repository.getCalculateContentDonationByCreator(startDate, endDate, agent.id!!)
assertEquals(1, totalCount)
assertEquals(1, rows.size)
assertEquals(creator.id, rows[0].creatorId)
assertEquals(2L, rows[0].count)
assertEquals(20, rows[0].totalCan)
assertEquals(70, rows[0].settlementRatio)
}
@Test
@DisplayName("채널후원 크리에이터별 조회는 분할 정산 레코드가 있어도 후원 단위로 집계한다")
fun shouldCountDistinctUseCanForChannelDonationByCreator() {
val agent = saveMember("agent-channel", MemberRole.AGENT)
val creator = saveMember("creator-channel", MemberRole.CREATOR)
val otherCreator = saveMember("creator-other-channel", MemberRole.CREATOR)
val sender = saveMember("sender-channel", MemberRole.USER)
saveRelation(agent, creator)
val useCan = saveChannelDonationUseCan(sender, 50, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveUseCanCalculate(useCan, creator.id!!, 20, PaymentGateway.PG)
saveUseCanCalculate(useCan, creator.id!!, 30, PaymentGateway.GOOGLE_IAP)
val otherUseCan = saveChannelDonationUseCan(sender, 40, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveUseCanCalculate(otherUseCan, otherCreator.id!!, 40, PaymentGateway.PG)
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, agent.id!!)
val rows = repository.getChannelDonationByCreator(startDate, endDate, agent.id!!)
assertEquals(1, totalCount)
assertEquals(1, rows.size)
assertEquals(creator.id, rows[0].creatorId)
assertEquals(1L, rows[0].count)
assertEquals(50, rows[0].totalCan)
}
@Test
@DisplayName("채널후원 total projection은 분할 정산과 agent 비율 이력이 섞여도 기존 Kotlin total과 같아야 한다")
fun shouldMatchDbTotalProjectionForChannelDonationWithSplitCalculatesAndRatioHistory() {
val agent = saveMember("agent-channel-total", MemberRole.AGENT)
val creator = saveMember("creator-channel-total", MemberRole.CREATOR)
val sender = saveMember("sender-channel-total", MemberRole.USER)
saveRelation(agent, creator)
saveAgentSettlementRatio(agent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0))
saveAgentSettlementRatio(
agent,
settlementRatio = 20,
effectiveFrom = LocalDateTime.of(2026, 2, 20, 12, 0, 0),
effectiveTo = null,
previousEffectiveTo = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
)
val beforeRatioUseCan = saveChannelDonationUseCan(sender, 50, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
saveUseCanCalculate(beforeRatioUseCan, creator.id!!, 20, PaymentGateway.PG)
saveUseCanCalculate(beforeRatioUseCan, creator.id!!, 30, PaymentGateway.GOOGLE_IAP)
val afterRatioUseCan = saveChannelDonationUseCan(sender, 70, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
saveUseCanCalculate(afterRatioUseCan, creator.id!!, 40, PaymentGateway.PG)
saveUseCanCalculate(afterRatioUseCan, creator.id!!, 30, PaymentGateway.APPLE_IAP)
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val kotlinTotal = repository.getChannelDonationByCreator(startDate, endDate, agent.id!!)
.toMergedResponseItems()
.toResponseTotal()
val dbTotal = repository.getChannelDonationByCreatorTotal(startDate, endDate, agent.id!!)
assertEquals(kotlinTotal, dbTotal)
}
@Test
@DisplayName("페이지 대상 creator가 없으면 모든 카테고리 조회는 빈 rows를 반환한다")
fun shouldReturnEmptyRowsWhenPagedCreatorSelectionIsEmptyAcrossAllCategories() {
val agent = saveMember("agent-empty-page", MemberRole.AGENT)
val creator = saveMember("creator-empty-page", MemberRole.CREATOR)
val buyer = saveMember("buyer-empty-page", MemberRole.USER)
saveRelation(agent, creator)
saveAgentSettlementRatio(agent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0))
saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70)
val room = saveLiveRoom(creator)
saveLiveUseCan(buyer, room, 10, LocalDateTime.of(2026, 2, 20, 9, 0, 0))
val content = saveAudioContent(creator, "empty-page-content", price = 10, settlementRatio = null)
saveOrder(buyer, creator, content, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
val communityPost = saveCommunityPost(creator, 10)
saveCommunityUseCan(buyer, communityPost, 10, LocalDateTime.of(2026, 2, 20, 11, 0, 0))
saveContentDonationUseCan(buyer, content, 10, LocalDateTime.of(2026, 2, 20, 12, 0, 0))
val channelDonation = saveChannelDonationUseCan(buyer, 10, LocalDateTime.of(2026, 2, 20, 13, 0, 0))
saveUseCanCalculate(channelDonation, creator.id!!, 10, PaymentGateway.PG)
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
assertEquals(1, repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, agent.id!!))
assertEquals(1, repository.getCalculateContentByCreatorTotalCount(startDate, endDate, agent.id!!))
assertEquals(1, repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate, agent.id!!))
assertEquals(1, repository.getCalculateContentDonationByCreatorTotalCount(startDate, endDate, agent.id!!))
assertEquals(1, repository.getChannelDonationByCreatorTotalCount(startDate, endDate, agent.id!!))
assertEquals(
0,
repository.getCalculateLiveByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size
)
assertEquals(
0,
repository.getCalculateContentByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size
)
assertEquals(
0,
repository.getCalculateCommunityByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size
)
assertEquals(
0,
repository.getCalculateContentDonationByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size
)
assertEquals(
0,
repository.getChannelDonationByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size
)
}
@Test
@DisplayName("정산 조회는 기간 중 소속 변경이 있어도 거래 시점 기준 소속 에이전트에게만 반영한다")
fun shouldResolveAssignmentAtEventTimeAcrossAllSettlementCategories() {
val firstAgent = saveMember("agent-assignment-a", MemberRole.AGENT)
val secondAgent = saveMember("agent-assignment-b", MemberRole.AGENT)
val creator = saveMember("creator-assignment", MemberRole.CREATOR)
val buyer = saveMember("buyer-assignment", MemberRole.USER)
saveAgentSettlementRatio(firstAgent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0))
saveAgentSettlementRatio(secondAgent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0))
saveRelation(
agent = firstAgent,
creator = creator,
assignedAt = LocalDateTime.of(2026, 2, 1, 0, 0, 0),
unassignedAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
)
saveRelation(
agent = secondAgent,
creator = creator,
assignedAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0),
unassignedAt = null
)
saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70)
val room = saveLiveRoom(creator)
saveLiveUseCan(buyer, room, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveLiveUseCan(buyer, room, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val contentBefore = saveAudioContent(creator, "assignment-content-before", price = 10, settlementRatio = null)
val contentAfter = saveAudioContent(creator, "assignment-content-after", price = 20, settlementRatio = null)
saveOrder(buyer, creator, contentBefore, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveOrder(buyer, creator, contentAfter, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val communityBefore = saveCommunityPost(creator, 10)
val communityAfter = saveCommunityPost(creator, 20)
saveCommunityUseCan(buyer, communityBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveCommunityUseCan(buyer, communityAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
saveContentDonationUseCan(buyer, contentBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveContentDonationUseCan(buyer, contentAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val channelBefore = saveChannelDonationUseCan(buyer, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveUseCanCalculate(channelBefore, creator.id!!, 10, PaymentGateway.PG)
val channelAfter = saveChannelDonationUseCan(buyer, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
saveUseCanCalculate(channelAfter, creator.id!!, 20, PaymentGateway.PG)
val savedRelations = relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(creator.id!!)
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val firstAgentLiveRows = repository.getCalculateLiveByCreator(startDate, endDate, firstAgent.id!!)
val secondAgentLiveRows = repository.getCalculateLiveByCreator(startDate, endDate, secondAgent.id!!)
assertEquals(listOf(savedRelations[0].id), firstAgentLiveRows.map { it.assignmentId })
assertEquals(listOf(savedRelations[1].id), secondAgentLiveRows.map { it.assignmentId })
val firstAgentLive = service.getCalculateLiveByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10)
val secondAgentLive = service.getCalculateLiveByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10)
assertGenericSettlementResponse(firstAgentLive, expectedCount = 1, expectedTotalCan = 10)
assertGenericSettlementResponse(secondAgentLive, expectedCount = 1, expectedTotalCan = 20)
val firstAgentContent = service.getCalculateContentByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10)
val secondAgentContent = service.getCalculateContentByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10)
assertGenericSettlementResponse(firstAgentContent, expectedCount = 1, expectedTotalCan = 10)
assertGenericSettlementResponse(secondAgentContent, expectedCount = 1, expectedTotalCan = 20)
val firstAgentCommunity = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10)
val secondAgentCommunity = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10)
assertGenericSettlementResponse(firstAgentCommunity, expectedCount = 1, expectedTotalCan = 10)
assertGenericSettlementResponse(secondAgentCommunity, expectedCount = 1, expectedTotalCan = 20)
val firstAgentContentDonation = service.getCalculateContentDonationByCreator(
"2026-02-20",
"2026-02-20",
firstAgent.id!!,
0,
10
)
val secondAgentContentDonation = service.getCalculateContentDonationByCreator(
"2026-02-20",
"2026-02-20",
secondAgent.id!!,
0,
10
)
assertGenericSettlementResponse(firstAgentContentDonation, expectedCount = 1, expectedTotalCan = 10)
assertGenericSettlementResponse(secondAgentContentDonation, expectedCount = 1, expectedTotalCan = 20)
val firstAgentChannelDonation = service.getChannelDonationByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10)
val secondAgentChannelDonation = service.getChannelDonationByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10)
assertChannelDonationSettlementResponse(firstAgentChannelDonation, expectedCount = 1, expectedTotalCan = 10)
assertChannelDonationSettlementResponse(secondAgentChannelDonation, expectedCount = 1, expectedTotalCan = 20)
}
@Test
@DisplayName("정산 조회는 기간 중 agent 비율 변경이 있어도 거래 시점 기준 비율로 agent 정산금을 계산한다")
fun shouldResolveAgentSettlementRatioAtEventTimeAcrossAllSettlementCategories() {
val agent = saveMember("agent-ratio-history", MemberRole.AGENT)
val creator = saveMember("creator-ratio-history", MemberRole.CREATOR)
val buyer = saveMember("buyer-ratio-history", MemberRole.USER)
saveRelation(
agent = agent,
creator = creator,
assignedAt = LocalDateTime.of(2026, 2, 1, 0, 0, 0),
unassignedAt = null
)
saveAgentSettlementRatio(agent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0))
saveAgentSettlementRatio(
agent,
settlementRatio = 20,
effectiveFrom = LocalDateTime.of(2026, 2, 20, 12, 0, 0),
effectiveTo = null,
previousEffectiveTo = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
)
saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70)
val room = saveLiveRoom(creator)
saveLiveUseCan(buyer, room, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveLiveUseCan(buyer, room, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val contentBefore = saveAudioContent(creator, "ratio-content-before", price = 10, settlementRatio = null)
val contentAfter = saveAudioContent(creator, "ratio-content-after", price = 20, settlementRatio = null)
saveOrder(buyer, creator, contentBefore, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveOrder(buyer, creator, contentAfter, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val communityBefore = saveCommunityPost(creator, 10)
val communityAfter = saveCommunityPost(creator, 20)
saveCommunityUseCan(buyer, communityBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveCommunityUseCan(buyer, communityAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
saveContentDonationUseCan(buyer, contentBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveContentDonationUseCan(buyer, contentAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val channelBefore = saveChannelDonationUseCan(buyer, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveUseCanCalculate(channelBefore, creator.id!!, 10, PaymentGateway.PG)
val channelAfter = saveChannelDonationUseCan(buyer, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
saveUseCanCalculate(channelAfter, creator.id!!, 20, PaymentGateway.PG)
val savedRatios = agentSettlementRatioRepository.findAllByMemberIdOrderByEffectiveFromAsc(agent.id!!)
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val liveRows = repository.getCalculateLiveByCreator(startDate, endDate, agent.id!!)
assertEquals(savedRatios.mapNotNull { it.id }, liveRows.mapNotNull { it.agentSettlementRatioId }.sorted())
val expectedGenericAgentSettlementAmount =
calculateGenericAgentSettlementAmount(totalCan = 10, settlementRatio = 70, agentSettlementRatio = 10) +
calculateGenericAgentSettlementAmount(totalCan = 20, settlementRatio = 70, agentSettlementRatio = 20)
val expectedChannelAgentSettlementAmount =
calculateChannelAgentSettlementAmount(totalCan = 10, agentSettlementRatio = 10) +
calculateChannelAgentSettlementAmount(totalCan = 20, agentSettlementRatio = 20)
val liveResponse = service.getCalculateLiveByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10)
assertGenericSettlementResponse(
liveResponse,
expectedCount = 2,
expectedTotalCan = 30,
expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount
)
val contentResponse = service.getCalculateContentByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10)
assertGenericSettlementResponse(
contentResponse,
expectedCount = 2,
expectedTotalCan = 30,
expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount
)
val communityResponse = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10)
assertGenericSettlementResponse(
communityResponse,
expectedCount = 2,
expectedTotalCan = 30,
expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount
)
val contentDonationResponse = service.getCalculateContentDonationByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10)
assertGenericSettlementResponse(
contentDonationResponse,
expectedCount = 2,
expectedTotalCan = 30,
expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount
)
val channelDonationResponse = service.getChannelDonationByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10)
assertChannelDonationSettlementResponse(
channelDonationResponse,
expectedCount = 2,
expectedTotalCan = 30,
expectedAgentSettlementAmount = expectedChannelAgentSettlementAmount
)
}
@Test
@DisplayName("generic 4종 total projection은 agent 비율 이력으로 row가 갈려도 기존 Kotlin total과 같아야 한다")
fun shouldMatchDbTotalProjectionAcrossAllGenericCategoriesWhenAgentRatioHistorySplitsRows() {
val agent = saveMember("agent-total-ratio-history", MemberRole.AGENT)
val creator = saveMember("creator-total-ratio-history", MemberRole.CREATOR)
val buyer = saveMember("buyer-total-ratio-history", MemberRole.USER)
saveRelation(agent = agent, creator = creator, assignedAt = LocalDateTime.of(2026, 2, 1, 0, 0, 0), unassignedAt = null)
saveAgentSettlementRatio(agent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0))
saveAgentSettlementRatio(
agent,
settlementRatio = 20,
effectiveFrom = LocalDateTime.of(2026, 2, 20, 12, 0, 0),
effectiveTo = null,
previousEffectiveTo = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
)
saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70)
val room = saveLiveRoom(creator)
saveLiveUseCan(buyer, room, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveLiveUseCan(buyer, room, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val contentBefore = saveAudioContent(creator, "total-ratio-content-before", price = 10, settlementRatio = null)
val contentAfter = saveAudioContent(creator, "total-ratio-content-after", price = 20, settlementRatio = null)
saveOrder(buyer, creator, contentBefore, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveOrder(buyer, creator, contentAfter, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val communityBefore = saveCommunityPost(creator, 10)
val communityAfter = saveCommunityPost(creator, 20)
saveCommunityUseCan(buyer, communityBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveCommunityUseCan(buyer, communityAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
saveContentDonationUseCan(buyer, contentBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0))
saveContentDonationUseCan(buyer, contentAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0))
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
assertEquals(
repository.getCalculateLiveByCreator(startDate, endDate, agent.id!!).toResponseTotal(),
repository.getCalculateLiveByCreatorTotal(startDate, endDate, agent.id!!)
)
assertEquals(
repository.getCalculateContentByCreator(startDate, endDate, agent.id!!).toResponseTotal(),
repository.getCalculateContentByCreatorTotal(startDate, endDate, agent.id!!)
)
assertEquals(
repository.getCalculateCommunityByCreator(startDate, endDate, agent.id!!).toResponseTotal(),
repository.getCalculateCommunityByCreatorTotal(startDate, endDate, agent.id!!)
)
assertEquals(
repository.getCalculateContentDonationByCreator(startDate, endDate, agent.id!!).toResponseTotal(),
repository.getCalculateContentDonationByCreatorTotal(startDate, endDate, agent.id!!)
)
}
@Test
@DisplayName("generic 4종 total projection은 결과가 없으면 0 total을 반환한다")
fun shouldReturnZeroTotalsWhenNoGenericRowsExist() {
val agent = saveMember("agent-total-empty", MemberRole.AGENT)
val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59)
val zeroTotal = GetAgentSettlementByCreatorTotal(
count = 0,
totalCan = 0,
krw = 0,
fee = 0,
settlementAmount = 0,
tax = 0,
depositAmount = 0,
agentSettlementAmount = 0
)
assertEquals(zeroTotal, repository.getCalculateLiveByCreatorTotal(startDate, endDate, agent.id!!))
assertEquals(zeroTotal, repository.getCalculateContentByCreatorTotal(startDate, endDate, agent.id!!))
assertEquals(zeroTotal, repository.getCalculateCommunityByCreatorTotal(startDate, endDate, agent.id!!))
assertEquals(zeroTotal, repository.getCalculateContentDonationByCreatorTotal(startDate, endDate, agent.id!!))
}
private fun saveMember(nickname: String, role: MemberRole, profileImage: String? = null): Member {
return memberRepository.saveAndFlush(
Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role,
profileImage = profileImage
)
)
}
private fun saveRelation(
agent: Member,
creator: Member,
assignedAt: LocalDateTime = LocalDateTime.of(2026, 2, 1, 0, 0, 0),
unassignedAt: LocalDateTime? = null
) {
val relation = AgentCreatorRelation()
relation.agent = agent
relation.creator = creator
relation.assignedAt = assignedAt
relation.unassignedAt = unassignedAt
relationRepository.saveAndFlush(relation)
}
private fun saveAgentSettlementRatio(
agent: Member,
settlementRatio: Int,
effectiveFrom: LocalDateTime,
effectiveTo: LocalDateTime? = null,
previousEffectiveTo: LocalDateTime? = null
) {
if (previousEffectiveTo != null) {
val previous = agentSettlementRatioRepository.findFirstByMemberIdAndEffectiveToIsNull(agent.id!!)
previous?.effectiveTo = previousEffectiveTo
previous?.let { agentSettlementRatioRepository.saveAndFlush(it) }
}
val ratio = AgentSettlementRatio(
settlementRatio = settlementRatio,
effectiveFrom = effectiveFrom
)
ratio.member = agent
ratio.effectiveTo = effectiveTo
agentSettlementRatioRepository.saveAndFlush(ratio)
}
private fun saveCreatorSettlementRatio(creator: Member, live: Int, content: Int, community: Int) {
val ratio = CreatorSettlementRatio(
subsidy = 0,
liveSettlementRatio = live,
contentSettlementRatio = content,
communitySettlementRatio = community
)
ratio.member = creator
creatorSettlementRatioRepository.saveAndFlush(ratio)
}
private fun saveLiveRoom(creator: Member): LiveRoom {
val room = LiveRoom(
title = "live-room",
notice = "notice",
beginDateTime = LocalDateTime.of(2026, 2, 20, 8, 0, 0),
numberOfPeople = 10,
isAdult = false,
price = 10
)
room.member = creator
return liveRoomRepository.saveAndFlush(room)
}
private fun saveLiveUseCan(sender: Member, room: LiveRoom, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.LIVE,
can = can,
rewardCan = 0
)
useCan.member = sender
useCan.room = room
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveAudioContent(creator: Member, title: String, price: Int, settlementRatio: Int?): AudioContent {
val theme = AudioContentTheme(
theme = "theme-$title",
image = "image-$title.png"
)
entityManager.persist(theme)
val audioContent = AudioContent(
title = title,
detail = "detail-$title",
languageCode = "ko",
price = price,
settlementRatio = settlementRatio
)
audioContent.theme = theme
audioContent.member = creator
audioContent.isActive = true
return audioContentRepository.saveAndFlush(audioContent)
}
private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime): Order {
val order = Order(type = OrderType.KEEP)
order.member = buyer
order.creator = creator
order.audioContent = content
val saved = orderRepository.saveAndFlush(order)
updateOrderCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveCommunityPost(creator: Member, price: Int): CreatorCommunity {
val post = CreatorCommunity(
content = "community-content-$price",
price = price,
isCommentAvailable = true,
isAdult = false
)
post.member = creator
return creatorCommunityRepository.saveAndFlush(post)
}
private fun saveCommunityUseCan(buyer: Member, post: CreatorCommunity, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.PAID_COMMUNITY_POST,
can = can,
rewardCan = 0
)
useCan.member = buyer
useCan.communityPost = post
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveContentDonationUseCan(buyer: Member, content: AudioContent, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.DONATION,
can = can,
rewardCan = 0
)
useCan.member = buyer
useCan.audioContent = content
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
private fun saveChannelDonationUseCan(sender: Member, can: Int, createdAt: LocalDateTime): UseCan {
val useCan = UseCan(
canUsage = CanUsage.CHANNEL_DONATION,
can = can,
rewardCan = 0
)
useCan.member = sender
val saved = useCanRepository.saveAndFlush(useCan)
updateUseCanCreatedAt(saved.id!!, createdAt)
return saved
}
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 updateOrderCreatedAt(orderId: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update Order o set o.createdAt = :createdAt where o.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", orderId)
.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()
}
private fun assertGenericSettlementResponse(
response: GetAgentSettlementByCreatorResponse,
expectedCount: Int,
expectedTotalCan: Int,
expectedAgentSettlementAmount: Int? = null
) {
assertEquals(1, response.totalCount)
assertEquals(1, response.items.size)
assertEquals(expectedCount, response.total.count)
assertEquals(expectedCount, response.items[0].count)
assertEquals(expectedTotalCan, response.total.totalCan)
assertEquals(expectedTotalCan, response.items[0].totalCan)
expectedAgentSettlementAmount?.let {
assertEquals(it, response.total.agentSettlementAmount)
assertEquals(it, response.items[0].agentSettlementAmount)
}
}
private fun assertChannelDonationSettlementResponse(
response: GetAgentChannelDonationSettlementByCreatorResponse,
expectedCount: Int,
expectedTotalCan: Int,
expectedAgentSettlementAmount: Int? = null
) {
assertEquals(1, response.totalCount)
assertEquals(1, response.items.size)
assertEquals(expectedCount, response.total.count)
assertEquals(expectedCount, response.items[0].count)
assertEquals(expectedTotalCan, response.total.totalCan)
assertEquals(expectedTotalCan, response.items[0].totalCan)
expectedAgentSettlementAmount?.let {
assertEquals(it, response.total.agentSettlementAmount)
assertEquals(it, response.items[0].agentSettlementAmount)
}
}
private fun calculateGenericAgentSettlementAmount(totalCan: Int, settlementRatio: Int, agentSettlementRatio: Int): Int {
val totalKrw = java.math.BigDecimal(totalCan).multiply(java.math.BigDecimal("100"))
val fee = totalKrw.multiply(java.math.BigDecimal("0.066"))
val settlementAmount = totalKrw.subtract(fee)
.multiply(java.math.BigDecimal(settlementRatio).divide(java.math.BigDecimal("100")))
.setScale(0, java.math.RoundingMode.HALF_UP)
.toInt()
return java.math.BigDecimal(settlementAmount)
.multiply(java.math.BigDecimal(agentSettlementRatio).divide(java.math.BigDecimal("100")))
.setScale(0, java.math.RoundingMode.HALF_UP)
.toInt()
}
private fun calculateChannelAgentSettlementAmount(totalCan: Int, agentSettlementRatio: Int): Int {
val settlementAmount = kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
.calculate(totalCan)
.settlementAmount
return java.math.BigDecimal(settlementAmount)
.multiply(java.math.BigDecimal(agentSettlementRatio).divide(java.math.BigDecimal("100")))
.setScale(0, java.math.RoundingMode.HALF_UP)
.toInt()
}
}

View File

@@ -0,0 +1,921 @@
package kr.co.vividnext.sodalive.partner.agent.calculate
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshot
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository
import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType
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 java.time.LocalDateTime
class AgentCalculateServiceTest {
private lateinit var repository: AgentCalculateQueryRepository
private lateinit var snapshotRepository: AgentSettlementSnapshotRepository
private lateinit var service: AgentCalculateService
@BeforeEach
fun setup() {
repository = Mockito.mock(AgentCalculateQueryRepository::class.java)
snapshotRepository = Mockito.mock(
AgentSettlementSnapshotRepository::class.java,
Mockito.withSettings().defaultAnswer { invocation ->
if (invocation.method.name == "findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc") {
emptyList<AgentSettlementSnapshot>()
} else {
Mockito.RETURNS_DEFAULTS.answer(invocation)
}
}
)
service = AgentCalculateService(
repository = repository,
snapshotRepository = snapshotRepository
)
}
@Test
@DisplayName("에이전트 서비스는 소속 크리에이터 목록을 페이지 조건으로 조회한다")
fun shouldGetAssignedCreators() {
val items = listOf(
GetAgentAssignedCreatorItem(
creatorId = 21L,
creatorNickname = "creator-a",
profileImageUrl = "https://cdn.test/profile/creator-a.png"
)
)
Mockito.`when`(
repository.getAssignedCreatorTotalCount(
Mockito.eq(7L),
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
).thenReturn(1)
Mockito.`when`(
repository.getAssignedCreators(
Mockito.eq(7L),
Mockito.eq(10L),
Mockito.eq(5L),
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
).thenReturn(items)
val response = service.getAssignedCreators(agentId = 7L, offset = 10L, limit = 5L)
assertEquals(1, response.totalCount)
assertEquals(21L, response.items[0].creatorId)
assertEquals("https://cdn.test/profile/creator-a.png", response.items[0].profileImageUrl)
Mockito.verify(repository).getAssignedCreatorTotalCount(
Mockito.eq(7L),
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
Mockito.verify(
repository
).getAssignedCreators(
Mockito.eq(7L),
Mockito.eq(10L),
Mockito.eq(5L),
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
}
@Test
@DisplayName("에이전트 서비스는 라이브 크리에이터별 응답과 합계에 agent 정산금을 계산한다")
fun shouldBuildLiveSummaryResponse() {
val queryData = listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 2L,
totalCan = 100,
settlementRatio = 70,
agentSettlementRatio = 10
)
)
Mockito.`when`(
repository.getCalculateLiveByCreatorTotalCount(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(1)
Mockito.`when`(
repository.getCalculateLiveByCreatorTotal(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(
GetAgentSettlementByCreatorTotal(
count = 2,
totalCan = 100,
krw = 10_000,
fee = 660,
settlementAmount = 6_538,
tax = 216,
depositAmount = 6_322,
agentSettlementAmount = 654
)
)
Mockito.`when`(
repository.getCalculateLiveByCreator(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(queryData)
val response = service.getCalculateLiveByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
assertEquals(1, response.totalCount)
assertEquals(2, response.total.count)
assertEquals(10_000, response.total.krw)
assertEquals(6_538, response.total.settlementAmount)
assertEquals(654, response.total.agentSettlementAmount)
assertEquals(21L, response.items[0].creatorId)
assertEquals(654, response.items[0].agentSettlementAmount)
Mockito.verify(repository).getCalculateLiveByCreatorTotal(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
7L
)
Mockito.verify(repository, Mockito.never()).getCalculateLiveByCreator(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
7L
)
}
@Test
@DisplayName("에이전트 서비스는 콘텐츠 크리에이터별 응답을 크리에이터 기준으로 병합하고 agent 정산금을 계산한다")
fun shouldMergeContentSummaryRowsPerCreator() {
val totalRows = listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 1L,
totalCan = 50,
settlementRatio = 80,
agentSettlementRatio = 10
),
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 2L,
totalCan = 30,
settlementRatio = 60,
agentSettlementRatio = 20
)
)
Mockito.`when`(
repository.getCalculateContentByCreatorTotalCount(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(1)
Mockito.`when`(
repository.getCalculateContentByCreatorTotal(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(
GetAgentSettlementByCreatorTotal(
count = 3,
totalCan = 80,
krw = 8_000,
fee = 528,
settlementAmount = 5_417,
tax = 179,
depositAmount = 5_238,
agentSettlementAmount = 710
)
)
Mockito.`when`(
repository.getCalculateContentByCreator(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(totalRows)
val response = service.getCalculateContentByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
assertEquals(1, response.totalCount)
assertEquals(3, response.total.count)
assertEquals(80, response.total.totalCan)
assertEquals(5_417, response.total.settlementAmount)
assertEquals(710, response.total.agentSettlementAmount)
assertEquals(3, response.items[0].count)
assertEquals(80, response.items[0].totalCan)
assertEquals(710, response.items[0].agentSettlementAmount)
}
@Test
@DisplayName("에이전트 서비스는 커뮤니티 크리에이터별 응답과 합계에 agent 정산금을 계산한다")
fun shouldBuildCommunitySummaryResponse() {
val queryData = listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 2L,
totalCan = 30,
settlementRatio = 60,
agentSettlementRatio = 10
)
)
Mockito.`when`(
repository.getCalculateCommunityByCreatorTotalCount(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(1)
Mockito.`when`(
repository.getCalculateCommunityByCreatorTotal(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(
GetAgentSettlementByCreatorTotal(
count = 2,
totalCan = 30,
krw = 3_000,
fee = 198,
settlementAmount = 1_681,
tax = 55,
depositAmount = 1_626,
agentSettlementAmount = 168
)
)
Mockito.`when`(
repository.getCalculateCommunityByCreator(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(queryData)
val response = service.getCalculateCommunityByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
assertEquals(1, response.totalCount)
assertEquals(1_681, response.total.settlementAmount)
assertEquals(168, response.total.agentSettlementAmount)
assertEquals(168, response.items[0].agentSettlementAmount)
}
@Test
@DisplayName("에이전트 서비스는 콘텐츠후원 크리에이터별 응답과 합계에 agent 정산금을 계산한다")
fun shouldBuildContentDonationSummaryResponse() {
val queryData = listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 2L,
totalCan = 20,
settlementRatio = null,
agentSettlementRatio = 10
)
)
Mockito.`when`(
repository.getCalculateContentDonationByCreatorTotalCount(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(1)
Mockito.`when`(
repository.getCalculateContentDonationByCreatorTotal(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(
GetAgentSettlementByCreatorTotal(
count = 2,
totalCan = 20,
krw = 2_000,
fee = 132,
settlementAmount = 1_308,
tax = 43,
depositAmount = 1_265,
agentSettlementAmount = 131
)
)
Mockito.`when`(
repository.getCalculateContentDonationByCreator(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(queryData)
val response = service.getCalculateContentDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
assertEquals(1, response.totalCount)
assertEquals(1_308, response.total.settlementAmount)
assertEquals(131, response.total.agentSettlementAmount)
assertEquals(131, response.items[0].agentSettlementAmount)
}
@Test
@DisplayName("에이전트 비율 이력이 없으면 일반 정산 응답은 10퍼센트 기본값으로 agent 정산금을 계산한다")
fun shouldApplyDefaultAgentSettlementRatioWhenAgentRatioHistoryDoesNotExist() {
val queryData = listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 2L,
totalCan = 20,
settlementRatio = null,
agentSettlementRatio = null
)
)
Mockito.`when`(
repository.getCalculateContentDonationByCreatorTotalCount(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(1)
Mockito.`when`(
repository.getCalculateContentDonationByCreatorTotal(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(
GetAgentSettlementByCreatorTotal(
count = 2,
totalCan = 20,
krw = 2_000,
fee = 132,
settlementAmount = 1_308,
tax = 43,
depositAmount = 1_265,
agentSettlementAmount = 131
)
)
Mockito.`when`(
repository.getCalculateContentDonationByCreator(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(queryData)
val response = service.getCalculateContentDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
assertEquals(1, response.totalCount)
assertEquals(1_308, response.total.settlementAmount)
assertEquals(131, response.total.agentSettlementAmount)
assertEquals(131, response.items[0].agentSettlementAmount)
}
@Test
@DisplayName("에이전트 서비스는 채널후원 크리에이터별 응답과 합계에 agent 정산금을 계산한다")
fun shouldBuildChannelDonationSummaryResponse() {
val queryData = listOf(
GetAgentChannelDonationSettlementByCreatorQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 1L,
totalCan = 50,
agentSettlementRatio = 10
)
)
Mockito.`when`(
repository.getChannelDonationByCreatorTotalCount(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(1)
Mockito.`when`(
repository.getChannelDonationByCreatorTotal(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(
GetAgentChannelDonationSettlementTotal(
count = 1,
totalCan = 50,
krw = 5_000,
fee = 330,
settlementAmount = 3_970,
withholdingTax = 131,
depositAmount = 3_839,
agentSettlementAmount = 397
)
)
Mockito.`when`(
repository.getChannelDonationByCreator(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(queryData)
val response = service.getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
assertEquals(1, response.totalCount)
assertEquals(3_970, response.total.settlementAmount)
assertEquals(397, response.total.agentSettlementAmount)
assertEquals(397, response.items[0].agentSettlementAmount)
Mockito.verify(repository).getChannelDonationByCreatorTotal(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
7L
)
Mockito.verify(repository, Mockito.never()).getChannelDonationByCreator(
"2026-02-20".convertLocalDateTime(),
"2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
7L
)
}
@Test
@DisplayName("에이전트 비율 이력이 없으면 채널후원 응답은 10퍼센트 기본값으로 agent 정산금을 계산한다")
fun shouldApplyDefaultAgentSettlementRatioToChannelDonationWhenAgentRatioHistoryDoesNotExist() {
val queryData = listOf(
GetAgentChannelDonationSettlementByCreatorQueryData(
creatorId = 21L,
creatorNickname = "creator-a",
count = 1L,
totalCan = 50,
agentSettlementRatio = null
)
)
Mockito.`when`(
repository.getChannelDonationByCreatorTotalCount(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(1)
Mockito.`when`(
repository.getChannelDonationByCreatorTotal(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L
)
).thenReturn(
GetAgentChannelDonationSettlementTotal(
count = 1,
totalCan = 50,
krw = 5_000,
fee = 330,
settlementAmount = 3_970,
withholdingTax = 131,
depositAmount = 3_839,
agentSettlementAmount = 397
)
)
Mockito.`when`(
repository.getChannelDonationByCreator(
startDate = "2026-02-20".convertLocalDateTime(),
endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59),
agentId = 7L,
offset = 0L,
limit = 20L
)
).thenReturn(queryData)
val response = service.getChannelDonationByCreator(
startDateStr = "2026-02-20",
endDateStr = "2026-02-21",
agentId = 7L,
offset = 0L,
limit = 20L
)
assertEquals(1, response.totalCount)
assertEquals(3_970, response.total.settlementAmount)
assertEquals(397, response.total.agentSettlementAmount)
assertEquals(397, response.items[0].agentSettlementAmount)
}
@Test
@DisplayName("에이전트 서비스는 finalized 기간이면 다섯 카테고리 모두 스냅샷을 우선 사용한다")
fun shouldUseFinalizedSnapshotsFirstAcrossAllSettlementCategories() {
val startDate = "2026-02-20".convertLocalDateTime()
val endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
Mockito.`when`(
snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(
listOf(
createSnapshot(
settlementType = AgentSettlementSnapshotType.LIVE,
count = 2,
totalCan = 100,
krw = 10_000,
fee = 660,
settlementAmount = 6_538,
tax = 216,
depositAmount = 6_322,
agentSettlementAmount = 654,
appliedAgentSettlementRatio = 10
)
)
)
Mockito.`when`(
snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
startDate,
endDate,
AgentSettlementSnapshotType.CONTENT,
7L
)
).thenReturn(
listOf(
createSnapshot(
settlementType = AgentSettlementSnapshotType.CONTENT,
count = 3,
totalCan = 80,
krw = 8_000,
fee = 528,
settlementAmount = 5_417,
tax = 179,
depositAmount = 5_238,
agentSettlementAmount = 710,
appliedAgentSettlementRatio = null
)
)
)
Mockito.`when`(
snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
startDate,
endDate,
AgentSettlementSnapshotType.COMMUNITY,
7L
)
).thenReturn(
listOf(
createSnapshot(
settlementType = AgentSettlementSnapshotType.COMMUNITY,
count = 2,
totalCan = 30,
krw = 3_000,
fee = 198,
settlementAmount = 1_681,
tax = 55,
depositAmount = 1_626,
agentSettlementAmount = 168,
appliedAgentSettlementRatio = 10
)
)
)
Mockito.`when`(
snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
startDate,
endDate,
AgentSettlementSnapshotType.CONTENT_DONATION,
7L
)
).thenReturn(
listOf(
createSnapshot(
settlementType = AgentSettlementSnapshotType.CONTENT_DONATION,
count = 2,
totalCan = 20,
krw = 2_000,
fee = 132,
settlementAmount = 1_308,
tax = 43,
depositAmount = 1_265,
agentSettlementAmount = 131,
appliedAgentSettlementRatio = 10
)
)
)
Mockito.`when`(
snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
startDate,
endDate,
AgentSettlementSnapshotType.CHANNEL_DONATION,
7L
)
).thenReturn(
listOf(
createSnapshot(
settlementType = AgentSettlementSnapshotType.CHANNEL_DONATION,
count = 1,
totalCan = 50,
krw = 5_000,
fee = 330,
settlementAmount = 3_970,
tax = 131,
depositAmount = 3_839,
agentSettlementAmount = 397,
appliedAgentSettlementRatio = 10
)
)
)
val liveResponse = service.getCalculateLiveByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
val contentResponse = service.getCalculateContentByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
val communityResponse = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
val contentDonationResponse = service.getCalculateContentDonationByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
val channelDonationResponse = service.getChannelDonationByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
assertEquals(654, liveResponse.total.agentSettlementAmount)
assertEquals(710, contentResponse.total.agentSettlementAmount)
assertEquals(168, communityResponse.total.agentSettlementAmount)
assertEquals(131, contentDonationResponse.total.agentSettlementAmount)
assertEquals(397, channelDonationResponse.total.agentSettlementAmount)
assertEquals(6_538, liveResponse.items[0].settlementAmount)
assertEquals(5_417, contentResponse.items[0].settlementAmount)
assertEquals(1_681, communityResponse.items[0].settlementAmount)
assertEquals(1_308, contentDonationResponse.items[0].settlementAmount)
assertEquals(3_970, channelDonationResponse.items[0].settlementAmount)
assertEquals(131, channelDonationResponse.items[0].withholdingTax)
Mockito.verifyNoInteractions(repository)
}
@Test
@DisplayName("에이전트 서비스는 generic 정산 결과가 없으면 total 0과 빈 items를 반환한다")
fun shouldReturnEmptyGenericSettlementResponseWhenNoRowsExist() {
val startDate = "2026-02-20".convertLocalDateTime()
val endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
Mockito.`when`(repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, 7L)).thenReturn(0)
Mockito.`when`(
repository.getCalculateLiveByCreatorTotal(startDate, endDate, 7L)
).thenReturn(
GetAgentSettlementByCreatorTotal(
count = 0,
totalCan = 0,
krw = 0,
fee = 0,
settlementAmount = 0,
tax = 0,
depositAmount = 0,
agentSettlementAmount = 0
)
)
Mockito.`when`(repository.getCalculateLiveByCreator(startDate, endDate, 7L, 0L, 20L)).thenReturn(emptyList())
val response = service.getCalculateLiveByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
assertEquals(0, response.totalCount)
assertEquals(0, response.total.count)
assertEquals(0, response.total.totalCan)
assertEquals(0, response.total.krw)
assertEquals(0, response.total.fee)
assertEquals(0, response.total.settlementAmount)
assertEquals(0, response.total.tax)
assertEquals(0, response.total.depositAmount)
assertEquals(0, response.total.agentSettlementAmount)
assertEquals(emptyList<GetAgentSettlementByCreatorItem>(), response.items)
}
@Test
@DisplayName("에이전트 서비스는 채널후원 결과가 없으면 total 0과 빈 items를 반환한다")
fun shouldReturnEmptyChannelDonationSettlementResponseWhenNoRowsExist() {
val startDate = "2026-02-20".convertLocalDateTime()
val endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
Mockito.`when`(repository.getChannelDonationByCreatorTotalCount(startDate, endDate, 7L)).thenReturn(0)
Mockito.`when`(
repository.getChannelDonationByCreatorTotal(startDate, endDate, 7L)
).thenReturn(
GetAgentChannelDonationSettlementTotal(
count = 0,
totalCan = 0,
krw = 0,
fee = 0,
settlementAmount = 0,
withholdingTax = 0,
depositAmount = 0,
agentSettlementAmount = 0
)
)
Mockito.`when`(repository.getChannelDonationByCreator(startDate, endDate, 7L, 0L, 20L)).thenReturn(emptyList())
val response = service.getChannelDonationByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
assertEquals(0, response.totalCount)
assertEquals(0, response.total.count)
assertEquals(0, response.total.totalCan)
assertEquals(0, response.total.krw)
assertEquals(0, response.total.fee)
assertEquals(0, response.total.settlementAmount)
assertEquals(0, response.total.withholdingTax)
assertEquals(0, response.total.depositAmount)
assertEquals(0, response.total.agentSettlementAmount)
assertEquals(emptyList<GetAgentChannelDonationSettlementByCreatorItem>(), response.items)
}
@Test
@DisplayName("에이전트 서비스는 finalized snapshot을 creatorId desc 순서로 페이지 조회한다")
fun shouldPageFinalizedSnapshotsByCreatorIdDesc() {
val startDate = "2026-02-20".convertLocalDateTime()
val endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
Mockito.`when`(
snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(
listOf(
createSnapshot(
settlementType = AgentSettlementSnapshotType.LIVE,
count = 3,
totalCan = 300,
krw = 30_000,
fee = 1_980,
settlementAmount = 19_614,
tax = 647,
depositAmount = 18_967,
agentSettlementAmount = 1_961,
appliedAgentSettlementRatio = 10
).also { it.creatorId = 31L; it.creatorNickname = "creator-c" },
createSnapshot(
settlementType = AgentSettlementSnapshotType.LIVE,
count = 2,
totalCan = 200,
krw = 20_000,
fee = 1_320,
settlementAmount = 13_076,
tax = 431,
depositAmount = 12_645,
agentSettlementAmount = 1_308,
appliedAgentSettlementRatio = 10
).also { it.creatorId = 21L; it.creatorNickname = "creator-b" },
createSnapshot(
settlementType = AgentSettlementSnapshotType.LIVE,
count = 1,
totalCan = 100,
krw = 10_000,
fee = 660,
settlementAmount = 6_538,
tax = 216,
depositAmount = 6_322,
agentSettlementAmount = 654,
appliedAgentSettlementRatio = 10
).also { it.creatorId = 11L; it.creatorNickname = "creator-a" }
)
)
val response = service.getCalculateLiveByCreator("2026-02-20", "2026-02-21", 7L, 1L, 1L)
assertEquals(3, response.totalCount)
assertEquals(1, response.items.size)
assertEquals(21L, response.items[0].creatorId)
assertEquals("creator-b", response.items[0].creatorNickname)
Mockito.verifyNoInteractions(repository)
}
@Test
@DisplayName("finalized 이후 live 데이터가 바뀌어도 동일 기간 응답은 snapshot 값을 유지한다")
fun shouldKeepUsingFinalizedSnapshotAfterLiveDataChanges() {
val startDate = "2026-02-20".convertLocalDateTime()
val endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59)
Mockito.`when`(
snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
startDate,
endDate,
AgentSettlementSnapshotType.LIVE,
7L
)
).thenReturn(
listOf(
createSnapshot(
settlementType = AgentSettlementSnapshotType.LIVE,
count = 2,
totalCan = 100,
krw = 10_000,
fee = 660,
settlementAmount = 6_538,
tax = 216,
depositAmount = 6_322,
agentSettlementAmount = 654,
appliedAgentSettlementRatio = 10
)
)
)
Mockito.`when`(repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, 7L)).thenReturn(99)
Mockito.`when`(repository.getCalculateLiveByCreator(startDate, endDate, 7L)).thenReturn(
listOf(
GetAgentCreatorSettlementSummaryQueryData(
creatorId = 88L,
creatorNickname = "changed-live",
count = 10L,
totalCan = 999,
settlementRatio = 50,
agentSettlementRatio = 30
)
)
)
val response = service.getCalculateLiveByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L)
assertEquals(1, response.totalCount)
assertEquals(21L, response.items[0].creatorId)
assertEquals(100, response.total.totalCan)
assertEquals(654, response.total.agentSettlementAmount)
Mockito.verifyNoInteractions(repository)
}
private fun createSnapshot(
settlementType: AgentSettlementSnapshotType,
count: Int,
totalCan: Int,
krw: Int,
fee: Int,
settlementAmount: Int,
tax: Int,
depositAmount: Int,
agentSettlementAmount: Int,
appliedAgentSettlementRatio: Int?
): AgentSettlementSnapshot {
return AgentSettlementSnapshot(
periodStart = LocalDateTime.of(2026, 2, 20, 0, 0, 0),
periodEnd = LocalDateTime.of(2026, 2, 21, 23, 59, 59),
settlementType = settlementType,
agentId = 7L,
agentNickname = "agent-a",
creatorId = 21L,
creatorNickname = "creator-a",
appliedAgentSettlementRatio = appliedAgentSettlementRatio,
count = count,
totalCan = totalCan,
krw = krw,
fee = fee,
settlementAmount = settlementAmount,
tax = tax,
depositAmount = depositAmount,
agentSettlementAmount = agentSettlementAmount,
finalizedAt = LocalDateTime.of(2026, 2, 22, 0, 0, 0),
finalizedByMemberId = 1L
)
}
}