feat(charge): 첫 충전 보너스 신규 생성을 중단한다
This commit is contained in:
@@ -0,0 +1,219 @@
|
|||||||
|
# 첫 충전 보너스 신규 지급 중단 구현 계획
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 완료, 검증·독립 리뷰 통과, 미배포 |
|
||||||
|
| 작성일 | 2026-10-19 |
|
||||||
|
| 요구사항 기준 | [prd.md](prd.md) |
|
||||||
|
| 현재 Phase | Phase 1, 신규 생성 분기 변경 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
| 다음 Goal | 없음, 구현 범위 완료 |
|
||||||
|
| 작업 위치 | 현재 `test` 브랜치, 별도 worktree·브랜치 생성 없음 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
신규 충전에서 첫 충전 보너스 작업만 만들지 않고, 기존 활성 이벤트 지급과 이미 저장된 모든 작업 계약을 보존한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | 1/1 | 없음 | 운영 배포는 별도 범위 |
|
||||||
|
|
||||||
|
사용자의 구현 승인을 받아 `P1-T1`과 독립 리뷰를 포함한 `P1-GATE`를 완료했다. 아래 문서 작성 단계의 미실행 기록은 당시 이력으로 유지한다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- `ChargeEventJobService.buildSnapshot(charge, member)`에서 첫 충전 분기를 없애고 활성 이벤트 스냅샷 경로로 통일한다.
|
||||||
|
- 삭제된 분기 때문에 사용되지 않는 생성자 의존성, 함수, import와 상수를 해당 파일에서만 정리한다.
|
||||||
|
- 신규 생성 차단과 기존 활성 이벤트/저장 작업의 회귀 테스트를 추가 또는 보강한다.
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 구매·결제 완료 경로, 유료 캔, 회원 정보 응답의 `chargeCount`, `isFirstCharged`, API, DB schema, 이벤트 enum과 기존 지급 처리 변경.
|
||||||
|
- `PENDING`/`FAILED`/`PROCESSING`/`DONE` 상태 정책, worker/admin 서비스, `publishPaidNotification`의 `FIRST_CHARGE` 분기 변경.
|
||||||
|
- `ChargeEventService.applyChargeEvent` 및 그 공유 메서드의 정리. 현재 live caller가 없지만 별도 레거시 경계다.
|
||||||
|
- migration, 과거 작업 수정/삭제/환수, feature flag, cutoff 값, dependency 추가, 별도 운영 실행.
|
||||||
|
|
||||||
|
## 구현 제약
|
||||||
|
|
||||||
|
- 기술 스택은 Kotlin, Spring Boot, JUnit 5이며 기존 서비스 패턴을 따른다.
|
||||||
|
- gateway 누락 예외 검증은 snapshot 선택보다 먼저 일어나도록 유지한다.
|
||||||
|
- 인증 여부/charge count를 조회해 첫 충전 자격을 판정하지 않는다. 매 충전에서 기존 활성 이벤트와 회원별 횟수 한도를 평가한다.
|
||||||
|
- `processJob(jobId)`는 저장 스냅샷으로 작업을 수행하므로 해당 경로와 retry/idempotency를 수정하지 않는다.
|
||||||
|
- 기존 `FIRST_CHARGE` 데이터는 배포 시점과 관계없이 보존하고 처리한다.
|
||||||
|
|
||||||
|
## Phase 1: 신규 생성 분기 변경
|
||||||
|
|
||||||
|
**Phase 결과:** 신규 `FIRST_CHARGE` 작업 생성은 중단되고 활성 이벤트 및 기존 저장 작업의 지급은 기존대로 동작한다.
|
||||||
|
|
||||||
|
**선행조건:** 별도의 구현 승인. 이 문서 작성은 구현 승인이 아니다.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1` 완료, `P1-GATE` 통과 및 실제 결과 기록.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 1.1 신규 생성 차단과 회귀 보존
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`**
|
||||||
|
|
||||||
|
**Objective:** 새 결제 완료 충전의 작업 스냅샷을 활성 이벤트로만 제한하면서 기존 이벤트·저장 작업의 계약을 테스트로 고정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** PRD `STOP-001`~`STOP-004` 확정, 별도 구현 승인, 작업 트리에서 대상 파일 재확인.
|
||||||
|
- **완료 증거:** 모든 실행 체크박스 완료, focused test와 ktlint 결과, task Progress에 실제 명령/결과 기록.
|
||||||
|
- **범위 밖:** worker/admin 동작 수정, 결제 데이터 조작, 문서 밖 범위 확장.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `ChargeEventJobService.createProcessingJob(sourceChargeId: Long, memberId: Long): ChargeEventJob?`의 기존 gateway 검증, charge/member 조회, 멱등 키 조회 및 저장 계약.
|
||||||
|
- Produces: 외부 시그니처 변경 없음. `buildSnapshot(charge: Charge, member: Member): ChargeEventJob?`는 활성 이벤트 스냅샷 결과 또는 `null`만 반환한다.
|
||||||
|
|
||||||
|
- [x] **RED:** `ChargeEventJobServiceTest`에 인증 회원 첫 충전에서 신규 `FIRST_CHARGE`가 생기지 않으며, 인증 이력 조회를 하지 않고 활성 이벤트 스냅샷만 생기는 테스트를 추가한다. 15% 계산 대신 활성 이벤트 설정의 `addPercent`/제목/ID/횟수 제한으로 저장 내용을 검증한다.
|
||||||
|
- [x] **RED 확인:** 아래 focused test 명령을 실행해 새 회귀 assertion이 `FIRST_CHARGE` 분기 때문에 실패하는지 확인한다. 테스트 설정 오류나 fixture 오류는 RED로 인정하지 않는다.
|
||||||
|
- [x] **기존 동작 고정:** 활성 이벤트 테스트 픽스처로 `PG`, `PAYVERSE`, `APPLE_IAP`, `GOOGLE_IAP` 각각 gateway, container(`pg`/`ios`/`aos`), `additionalCan`, `methodSnapshot`, `ACTIVE_CHARGE_EVENT`와 event ID가 기존 규칙 그대로 저장되는지 확인한다. 프로세스 후 지급 알림의 제목/수신자/추가 캔 내용이 기존 규칙을 따르는지 검증한다. 기존 계약을 고정하는 테스트는 변경 전부터 통과해야 한다.
|
||||||
|
- [x] **RED:** 활성 이벤트 없음과 회원별 횟수 한도 소진에서 새 작업이 없고, 새로 첫 결제한 인증 회원도 일반 활성 이벤트를 적용받는 테스트를 추가한다.
|
||||||
|
- [x] **기존 동작 고정:** 과거 `FIRST_CHARGE` snapshot fixture로 활성 이벤트가 없어도 저장된 `PENDING`/`PROCESSING` 작업을 `processJob`가 저장 금액·gateway·container·method대로 한 번 처리하고 인증/자격 조회나 이벤트 조회를 다시 하지 않는지 확인한다. 네 gateway의 보너스 잔액과 첫 충전 알림을 검증하며, 별도 상태 자동 복구 동작은 기대하지 않는다.
|
||||||
|
- [x] **기존 동작 고정:** `FAILED`는 관리자 수동 retry가 `PENDING`으로 되돌린 뒤 worker에 처리되고, `DONE`은 재호출해도 재지급되지 않는 기존 동작을 검증한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest'`를 실행한다. 신규 중단 assertion은 미구현 동작으로 실패하고, 저장된 작업/일반 이벤트 보존 테스트는 통과하는지 구분한다.
|
||||||
|
- [x] **GREEN:** `buildSnapshot`에서 gateway 선검증을 유지하고 `buildActiveEventSnapshot(charge, member, paymentGateway)`을 반환하도록 최소 변경한다. 미사용 `AuthRepository` 생성자 의존성/import, `buildFirstChargeSnapshot`, `ceil` import 및 더 이상 참조되지 않는 `FIRST_CHARGE_METHOD`만 제거한다.
|
||||||
|
- [x] **GREEN 확인:** 동일 focused test 명령으로 신규 차단 및 활성 이벤트/기존 작업 회귀가 통과하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리한다. 서비스·worker·admin 테스트와 `./gradlew ktlintCheck`를 다시 실행하고 실제 결과를 Progress에 기록한다. 필요한 경우에만 테스트 fixture 중복을 정리한다.
|
||||||
|
|
||||||
|
**Task 검증 기록 (2026-10-19):** 원본 focused 11개 PASS 후 테스트를 추가했다. RED에서는 35개 중 신규 생성 assertion 9개만 실패하고 보존 테스트 26개는 통과했다. `AuthRepository`를 실제 주입한 fixture에 DI·충전 1건 stub을 설정해 기존 `FIRST_CHARGE`가 생성되는 실패를 확인했다. 최소 변경 후 서비스 25개·worker 6개·관리자 4개, 총 35개가 통과했다. 기존 테스트 삭제·완화는 없다. RED의 의존성 주입·stub은 production 의존성 제거에 맞춰 정리하되 결과 assertion은 유지했다.
|
||||||
|
|
||||||
|
#### 필수 테스트와 판정
|
||||||
|
|
||||||
|
| 테스트 이름 | 핵심 assertion |
|
||||||
|
|---|---|
|
||||||
|
| `shouldNotCreateFirstChargeJobForNewAuthenticatedCharge` | 첫 충전 대상 회원도 신규 `FIRST_CHARGE`를 만들지 않는다. 활성 이벤트가 없으면 저장 없이 `null`이다. |
|
||||||
|
| `shouldApplyActiveEventToNewChargeAcrossGateways` | 네 gateway에서 일반 이벤트의 ID·제목·비율·container를 사용한다. 예: 100캔, 일반 이벤트 10%라면 15캔이 아닌 10캔이다. |
|
||||||
|
| `shouldNotCreateJobWhenActiveEventLimitIsExhausted` | 일반 이벤트 한도가 소진되면 첫 충전 보너스로 우회하지 않고 저장 없이 `null`이다. |
|
||||||
|
| `shouldPayExistingFirstChargeSnapshotWithoutActiveEvent` | 기존 스냅샷 금액이 15이면 현재 이벤트/인증과 무관하게 15를 유지하고, 네 gateway의 해당 보너스 잔액·기록·첫 충전 알림을 검증한다. |
|
||||||
|
| `shouldProcessPendingFirstChargeJobAndSkipDoneJob` | worker의 기존 `PENDING` 처리와 `DONE` 재호출에서 지급 1회·잔액 불변을 검증한다. |
|
||||||
|
| `shouldRetryFailedFirstChargeJobWithoutChangingSnapshot` | 관리자 재시도 후 `PENDING`, retryCount 0, 원본 스냅샷 보존과 후속 지급을 검증한다. |
|
||||||
|
|
||||||
|
문서 작성의 **TDD 예외 사유:** 실행 코드 동작이 없는 문서 전용 작업이다. **대체 검증 방법:** PRD와 계획의 요구사항·파일·불변조건 대조, Markdown 참조 확인 및 `./gradlew tasks --all` 실행이다. 구현 Task `P1-T1`은 TDD 예외가 아니다.
|
||||||
|
|
||||||
|
### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`**
|
||||||
|
|
||||||
|
**Objective:** 첫 충전 신규 생성 차단과 기존 활성 이벤트/저장 작업 계약 보존을 자동 테스트 및 배포 경계 체크리스트로 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1`의 모든 체크박스 완료, 해당 Progress 기록 존재.
|
||||||
|
- **완료 증거:** focused test 및 ktlint 통과, 아래 불변조건 확인, 미실행 배포 체크를 운영 검증 완료로 오인하지 않도록 기록.
|
||||||
|
- **범위 밖:** 운영 DB/결제/SQL 조작, 배포 실행, 테스트 삭제·완화, 새 복구 정책.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJob.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorker.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobService.kt`
|
||||||
|
|
||||||
|
- [x] `./gradlew test --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest'` 통과 확인.
|
||||||
|
- [x] `./gradlew ktlintCheck` 통과 확인.
|
||||||
|
- [x] 코드 diff가 PRD 포함 파일과만 일치하며 enum, `processJob`, worker, admin service, 결제 API 변경이 없는지 확인.
|
||||||
|
- [x] 읽기 전용 배포 체크리스트에 cutoff 이후 신규 `FIRST_CHARGE` 부재 및 기존 snapshot 처리 지속을 남긴다. 이는 이 계획에서 실행하지 않는다.
|
||||||
|
- [x] 전체 회귀는 공통 경계/여러 domain 영향, targeted 결과로 영향 판단 불가, release/final 전체 증거 요구 또는 사용자 요청일 때 실행한다. 생략 시 근거와 focused 명령을 Progress에 기록한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | `P1-T1` | 별도 구현 승인 | 아니요 | 승인이 없으면 문서 상태 유지 |
|
||||||
|
| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패 원인 소유 테스트/변경 확인 후 별도 회귀 goal 결정 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
P1-T1 → P1-GATE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- `ChargeEventJobType.FIRST_CHARGE`와 기존 저장 snapshot 처리 코드를 삭제하지 않는다.
|
||||||
|
- `publishPaidNotification`의 `FIRST_CHARGE` 처리, 지급 알림/현지화, retry, idempotency 또는 `DONE` 중복 방지를 바꾸지 않는다.
|
||||||
|
- 원본 충전, 광고/클라이언트 추적, 공개 API, DB, 구매 완료 경로를 바꾸지 않는다.
|
||||||
|
- `AuthRepository`/`ChargeRepository` 공유 메서드와 레거시 `ChargeEventService`를 삭제하지 않는다.
|
||||||
|
- cutoff 설정, migration, 운영용 SQL, 새 dependency와 관계없는 정리를 추가하지 않는다.
|
||||||
|
- RED 확인 전에 구현하지 않으며 테스트를 삭제/skip/완화하지 않는다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### 문서 작성 및 검증, 2026-10-19
|
||||||
|
|
||||||
|
- 상태: 문서 완료, 구현 미승인·미실행
|
||||||
|
- 무엇을: 사용자 확정 정책과 기술 범위로 PRD 및 실행 계획을 작성했다.
|
||||||
|
- 왜: 구현 전에 신규 생성 차단 범위와 기존 작업 보존 계약을 분명히 하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- 가이드/샘플 및 소스·테스트 읽기 — 완료.
|
||||||
|
- `./gradlew tasks --all` — 문서 생성 후 실행, exit 0 / `BUILD SUCCESSFUL`. 계획에서 참조한 `test`, `ktlintCheck` task 등록 확인.
|
||||||
|
- 현재 `test` 브랜치 확인 및 Git 변경 범위 확인 — 신규 문서 디렉터리만 추가됐으며 소스·테스트·설정 변경 없음.
|
||||||
|
- 테스트/결제/운영 확인 — 이번 문서 작업에서는 미실행. 미래 구현 항목이며 코드 변경이나 지급 중단 효과가 발생하지 않았다.
|
||||||
|
- 남은 항목: `P1-T1` 별도 승인, 이후 `P1-GATE`.
|
||||||
|
- 다음 행동: 별도 승인 이후 `P1-T1`만 goal로 활성화한다.
|
||||||
|
|
||||||
|
## 문서 검증 기록
|
||||||
|
|
||||||
|
- [x] `./gradlew tasks --all` 실행으로 명령 유효성과 task 등록을 확인했다.
|
||||||
|
- Markdown LSP 진단은 `.md` 서버 미설정으로 불가했다. 문서 직접 읽기와 소스 대조로 보완하며, 서버를 설치하거나 설정을 바꾸지 않는다.
|
||||||
|
- 문서 전용 변경이므로 focused/전체 테스트와 ktlint를 재실행하지 않았다. 대신 문서 검토와 Gradle task 등록을 확인했다. 위 구현 체크박스는 모두 미실행 상태다.
|
||||||
|
- 앞선 동작 조사에서 관련 기존 테스트 11건이 통과했으나, 확인된 픽스처는 `ACTIVE_CHARGE_EVENT`다. 따라서 이는 신규 중단이나 `FIRST_CHARGE` 회귀 커버리지 증거가 아니며 별도 테스트가 필요하다.
|
||||||
|
|
||||||
|
### P1-T1 시작, 2026-10-19
|
||||||
|
|
||||||
|
- 사용자 승인: "승인 문서대로 구현 진행". 현재 `test` 브랜치에서 수행하며 worktree/브랜치를 생성하지 않는다.
|
||||||
|
- 사전 대조: `P1-T1`의 신규 생성 경로 변경과 `P1-GATE`의 기존 저장 작업 보존 검증은 일치한다. 공용 enum/worker/admin/원 결제 API는 변경하지 않는다.
|
||||||
|
- 기존 테스트 읽기: 일반 이벤트 지급/중복 방지/재시도를 다루며 첫 충전 신규 중단과 저장된 첫 충전 스냅샷의 회귀는 추가해야 한다.
|
||||||
|
- 승인된 범위 외 운영 DB/결제 호출/배포는 수행하지 않는다. 실제 서비스 메서드를 실행하는 격리 QA로 변경 동작을 검증한다.
|
||||||
|
|
||||||
|
### P1-T1 완료 및 P1-GATE 검증, 2026-10-19
|
||||||
|
|
||||||
|
- 운영 코드 변경은 `ChargeEventJobService.kt` 한 파일, 회귀 테스트는 기존 세 파일에 추가했다.
|
||||||
|
- 기준선: `./gradlew --no-daemon test --rerun --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest'` — 기존 11개 PASS.
|
||||||
|
- RED: 같은 focused 명령 — 35개 중 9개 의도한 실패. `expected ACTIVE_CHARGE_EVENT but was FIRST_CHARGE` 및 `expected null but was ChargeEventJob` 확인.
|
||||||
|
- GREEN/정리: `./gradlew --no-daemon test --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest' ktlintCheck` — 35개 PASS, ktlint PASS. 신규 테스트 줄바꿈 lint를 수정 후 재검증했다.
|
||||||
|
- 최종 실행: `./gradlew --no-daemon -I "$RUN_TMP/qa.init.gradle" firstBonusManualQa build ktlintCheck` — exit 0, `BUILD SUCCESSFUL in 9m 11s`. `build`가 전체 test를 실제 실행했고 322 suites / 2,291 tests / failures 0 / errors 0 / skipped 0이다.
|
||||||
|
- 빌드 산출물: `build/libs/sodalive-0.0.1-SNAPSHOT.jar`. 테스트 결과: `build/test-results/test/`, `build/reports/tests/test/index.html`.
|
||||||
|
- Kotlin LSP는 서버 미설치로 네 파일 모두 진단 불가다. 설치하지 않고 Gradle 컴파일·전체 테스트·ktlint로 보완했다. Markdown LSP도 미설정이다.
|
||||||
|
- `git diff --check` PASS. 소스 변경은 승인된 한 파일이며 enum/worker/admin/원본 결제와 `isFirstCharged`는 변경하지 않았다.
|
||||||
|
- 사용자 ULW 요청으로 전체 suite/build를 실행했다. 운영 DB/결제/푸시 호출 및 배포는 하지 않았다.
|
||||||
|
|
||||||
|
#### 독립 실행 QA 증거
|
||||||
|
|
||||||
|
임시 `BonusQa.java`와 Gradle init task가 JUnit 밖에서 실제 `createAndProcessImmediate`, `processJob`, `AdminChargeEventJobService.retry`, `ChargeEventJobWorker.runPendingJobs`를 호출했다. repository/트랜잭션/알림 전송 경계는 격리 fixture로 대체했다. 이는 실제 서비스 로직 실행 증거이며, DB 커밋·운영 결제·푸시 수신의 검증은 아니다.
|
||||||
|
|
||||||
|
| 시나리오 | 이진 판정 | 관찰 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 신규 생성/일반 이벤트 | 활성 이벤트 없음은 작업·지급 0, 100캔·일반 이벤트 10%는 FIRST가 아닌 ACTIVE 10캔 한 번 | 네 gateway 모두 PASS |
|
||||||
|
| S2 한도/입력 경계 | 한도 소진은 작업 0, 결제 정보 누락은 기존 예외 | 네 gateway 모두 PASS |
|
||||||
|
| S3 저장된 첫 충전 | PENDING/PROCESSING의 저장 15캔을 정확한 지갑에 1회 지급, 원본 유료 잔액 유지, 기록·알림 일치 | 네 gateway 모두 PASS |
|
||||||
|
| S4 재시도/중복 | FAILED 관리자 재시도 후 worker 지급, DONE 재호출은 잔액·기록·알림 증가 없음 | 네 gateway 모두 PASS |
|
||||||
|
|
||||||
|
- 실행 출력: `QA PASS: 28 isolated real-service scenarios; no external payment, DB or push calls.`
|
||||||
|
- 임시 로그의 핵심 결과를 이 문서에 보존한다. QA 스크립트·init task·task 전용 캐시와 로그는 최종 리뷰 후 삭제하며 저장소에 추가하지 않는다.
|
||||||
|
|
||||||
|
#### 배포 후 별도 운영 체크리스트 (이번 실행 범위 밖)
|
||||||
|
|
||||||
|
- 모든 구버전 생성 인스턴스 drain 이후 새 `FIRST_CHARGE` 작업이 생기지 않는지 확인한다. 문서 작성일/구매 시작일을 cutoff로 사용하지 않는다.
|
||||||
|
- 이전에 생성된 `FIRST_CHARGE`의 저장 지급액·gateway·수신자·지급 기록을 대조하고 기존 재시도가 유지되는지 확인한다. 롤링 배포 중 구버전이 생성한 작업도 보존한다.
|
||||||
|
- `PROCESSING`에 새 자동 복구를 추가하지 않았고, `FAILED`는 기존 관리자 재시도가 필요하다.
|
||||||
|
|
||||||
|
### P1-GATE 최종 판정, 2026-10-19
|
||||||
|
|
||||||
|
- 상태: 완료. 별도 구현자의 결과를 부모 세션에서 직접 읽고 실행 검증한 뒤 독립 리뷰를 수행했다.
|
||||||
|
- 독립 리뷰: `APPROVED`, spec compliance와 코드 품질 모두 차단 결함 없음. S1~S4, 변경 diff, 저장 작업 enum/지급/알림/worker/admin 계약 및 RED/GREEN·전체 XML·QA 로그를 대조했다.
|
||||||
|
- 검증 수치: focused 35개 PASS, 전체 2,291개 PASS, 실패·오류·skip 0, build·ktlint PASS, Java 드라이버 28개 시나리오 PASS.
|
||||||
|
- 원본 결제/API/광고 추적·공용 enum/레거시 서비스 변경 없음. 배포 또는 데이터 migration을 수행하지 않았다.
|
||||||
|
- 사전 판단: 추가 SDD 작업 디렉터리 대신 이 계획의 Progress에 결과를 누적하고 현재 `test` 브랜치를 유지했다. 제품 정책의 추가 변경은 없다.
|
||||||
|
- 문서 최종 확인: `./gradlew --no-daemon tasks --all` 성공. Markdown/Kotlin LSP 미설정·미설치 한계는 그대로 명시한다.
|
||||||
|
- 임시 QA 자원은 task 소유 경로에만 생성했다. 검증·리뷰 결과를 이 문서에 남겼으며 최종 커밋 후 스크립트/init task/로그/캐시/메모를 제거하고 부재를 확인한다. 기존 공유 Gradle 캐시와 다른 작업의 프로세스는 건드리지 않는다.
|
||||||
|
- 남은 운영 확인: 실제 DB 커밋·다중 인스턴스 부하·PG/스토어 결제·푸시 수신 및 배포 후 신규 생성 중단은 이번 검증에 포함하지 않았다. 위 배포 체크리스트에 따라 별도로 확인한다.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 첫 충전 보너스 신규 지급 중단 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현·검증·독립 리뷰 완료, 미배포 |
|
||||||
|
| 작성일 | 2026-10-19 |
|
||||||
|
| 최종 수정일 | 2026-10-19 |
|
||||||
|
| 대상 제품 | 충전 이벤트 보너스 지급 |
|
||||||
|
| 작성자·결정권자 | 사용자 요청 |
|
||||||
|
| 관련 구현 계획 | [plan-task.md](plan-task.md) |
|
||||||
|
| 관련 review | [plan-task.md 최종 Gate 기록](plan-task.md#p1-gate-최종-판정-2026-10-19) |
|
||||||
|
|
||||||
|
## 1. 개요와 문제
|
||||||
|
|
||||||
|
변경 전에는 결제 완료 뒤 `ChargeEventJobService`가 인증 회원의 첫 충전을 판정해 원본 충전량의 15%를 올림 계산한 `FIRST_CHARGE` 작업을 만들었다. 이번 변경은 첫 충전 보너스 신규 생성을 중단하되, 이미 저장된 작업과 일반 활성 충전 이벤트의 지급 계약을 유지한다.
|
||||||
|
|
||||||
|
사용자 확인 정책: 기존 지급은 정상 동작 중이며, “이미 생성된 것은 기존대로 지급한다.” 이 문서는 정책과 구현 범위를 기록할 뿐이며, 문서 변경 자체는 실제 지급을 중단하지 않는다.
|
||||||
|
|
||||||
|
## 2. 목표와 제외 범위
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
|
||||||
|
- 이후 코드가 만드는 새 보너스 작업은 기존 `ACTIVE_CHARGE_EVENT` 조건을 만족할 때만 생성한다.
|
||||||
|
- 신규 `FIRST_CHARGE` 작업을 만들지 않는다.
|
||||||
|
- 저장된 작업은 현재 worker와 관리자의 기존 계약대로 처리한다.
|
||||||
|
- 현재 `test` 브랜치에서 작업하며 별도 worktree나 브랜치를 생성하지 않는다. 문서 작성 후 사용자 승인에 따라 아래 범위만 구현한다.
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 원본 유료 충전, 회원 정보 응답의 `chargeCount`, `isFirstCharged` 광고 추적, 클라이언트 API 변경.
|
||||||
|
- `FIRST_CHARGE` enum, 기존 작업 스냅샷, 지급·알림·현지화·재시도·멱등성 처리 제거 또는 변경.
|
||||||
|
- 과거 작업·보너스 데이터 수정/삭제, clawback, DB migration, feature flag, cutoff-date 설정.
|
||||||
|
- 새 의존성, 광범위한 정리, 사용처가 없는 레거시 서비스 제거.
|
||||||
|
|
||||||
|
## 3. 확정 요구사항과 수용 기준
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| STOP-001 | 확정 | 새 `FIRST_CHARGE` 작업을 생성하지 않는다. | 각 결제 게이트웨이의 검증을 통과한 신규 충전에서 생성되는 작업이 없거나 `ACTIVE_CHARGE_EVENT`뿐이다. 15% 계산 및 첫 충전 사유 스냅샷을 신규 생성 경로에서 사용하지 않는다. |
|
||||||
|
| STOP-002 | 확정 | 일반 활성 이벤트를 기존 기준으로 유지한다. | 인증 회원의 첫 결제도 같은 `buildActiveEventSnapshot` 경로를 따른다. 활성 이벤트 부재 또는 회원별 횟수 한도 소진이면 작업을 만들지 않는다. 결제 및 일반 충전 처리는 성공한다. |
|
||||||
|
| STOP-003 | 확정 | 이미 생성된 작업을 기존대로 처리한다. | 저장된 `PENDING`은 worker 대상이다. `FAILED`는 관리자 재시도 후 worker 대상이다. `PROCESSING`에는 자동 복구를 새로 추가하지 않는다. `DONE`은 재지급하지 않는다. 처리 과정은 인증/이벤트 자격을 재계산하지 않고 저장된 gateway, container, amount, method snapshot을 사용한다. |
|
||||||
|
| STOP-004 | 확정 | 원본 충전 기록과 외부 계약을 보존한다. | 유료 충전, 회원 충전 이력/광고 추적, 공개 API에는 변경이 없다. |
|
||||||
|
|
||||||
|
## 4. 기술 범위와 보존 경계
|
||||||
|
|
||||||
|
변경 지점은 [`ChargeEventJobService.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt)의 `buildSnapshot(charge, member): ChargeEventJob?`이다. `buildActiveEventSnapshot`을 직접 반환하도록 하며, 결제 gateway 검증은 그 호출 전에 유지한다. 이후 인증회원 첫 결제 여부와 무관하게 활성 이벤트 조회 및 횟수 제한을 적용한다.
|
||||||
|
|
||||||
|
이 변경으로 더 이상 사용하지 않는 `AuthRepository` 생성자 의존성/import, `buildFirstChargeSnapshot`, `ceil` import를 제거한다. `FIRST_CHARGE_METHOD`는 이 파일에서만 참조되며 제거 대상이다. 단, `FIRST_CHARGE` enum과 `methodSnapshot`을 이용하는 기존 작업 처리 및 `publishPaidNotification` 분기는 보존한다.
|
||||||
|
|
||||||
|
[`ChargeEventJob.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJob.kt), [`ChargeEventJobRepository.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobRepository.kt), [`ChargeEventJobWorker.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorker.kt), [`AdminChargeEventJobService.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobService.kt)와 결제 완료 경로는 수정 대상이 아니다. `ChargeEventService.applyChargeEvent`에는 현재 호출자가 없지만 이를 포함한 레거시 서비스는 이 작업에서 제거하지 않는다. 레거시가 참조하는 `AuthRepository` 및 `ChargeRepository` 공용 메서드도 유지한다.
|
||||||
|
|
||||||
|
## 5. 배포와 운영 경계
|
||||||
|
|
||||||
|
- 적용 기준은 구매 시작일이나 별도 cutoff 설정이 아니라, 새 작업을 만들지 않도록 갱신된 코드가 실제 실행되는 시점이다.
|
||||||
|
- 구버전 인스턴스가 남아 있는 rolling 배포 중에는 `FIRST_CHARGE` 작업이 더 생길 수 있다. 모든 구버전 생성 인스턴스가 drain된 뒤에야 신규 생성 중단이 완결된다.
|
||||||
|
- 배포 시점/일정은 이 문서에서 정하지 않는다.
|
||||||
|
- 배포 후 운영 확인 항목은 cutoff 이후 신규 `FIRST_CHARGE` 작업 부재와 기존 스냅샷 작업의 정상 지속 처리다. 이는 향후 확인 체크리스트이며, 실행된 운영 검증이 아니다.
|
||||||
|
|
||||||
|
## 6. 추적과 의사결정
|
||||||
|
|
||||||
|
| 요구사항 | 계획 Goal | 검증 |
|
||||||
|
|---|---|---|
|
||||||
|
| STOP-001, STOP-002 | `P1-T1` | `ChargeEventJobServiceTest` 신규 생성/활성 이벤트 회귀 |
|
||||||
|
| STOP-003, STOP-004 | `P1-T1`, `P1-GATE` | worker/admin 회귀, 충전 불변조건 테스트 |
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2026-10-19 | DEC-001 | 확정, 사용자 정책 | 첫 충전 보너스 신규 지급 중단, 생성된 작업은 기존대로 지급한다. |
|
||||||
|
| 2026-10-19 | DEC-002 | 기술 접근 | 신규 스냅샷 선택을 `buildActiveEventSnapshot`으로 단순화하고 기존 저장 작업 처리 경로는 유지한다. |
|
||||||
|
| 2026-10-19 | DEC-003 | 확정, 사용자 작업 조건 | worktree를 사용하지 않고 현재 `test` 브랜치에서 문서만 작성한다. |
|
||||||
|
| 2026-10-19 | DEC-004 | 정정, 사용자 구현 승인 | 문서대로 구현 진행 승인. `test` 브랜치/worktree 미사용 조건은 유지하며 신규 생성 분기와 관련 테스트만 변경한다. |
|
||||||
|
|
||||||
|
## 7. 성공 기준
|
||||||
|
|
||||||
|
- [x] 신규 작업 생성 테스트에서 `FIRST_CHARGE`가 생성되지 않는다.
|
||||||
|
- [x] 활성 이벤트가 있으면 신규 일반 이벤트 스냅샷만 생성되고, 이벤트 부재/한도 소진이면 작업 없이 종료한다.
|
||||||
|
- [x] PG, PAYVERSE, APPLE_IAP, GOOGLE_IAP의 기존 활성 이벤트 snapshot과 잔액/알림 계약이 유지된다.
|
||||||
|
- [x] 저장된 첫 충전 `PENDING`/`PROCESSING` 작업은 현재 방식대로 한 번 처리되고, `FAILED` 수동 재시도와 `DONE` 중복 방지가 유지된다.
|
||||||
|
- [x] 사용자 승인 이후 구현하고 focused 35개·전체 2,291개 테스트, build·ktlint와 격리 실행 QA 28개 시나리오를 통과했다. 운영 배포·실지급 확인은 별도다.
|
||||||
+1
-34
@@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.fcm.FcmEventType
|
|||||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -24,7 +23,6 @@ import org.springframework.transaction.support.TransactionSynchronization
|
|||||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
import org.springframework.transaction.support.TransactionTemplate
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -34,7 +32,6 @@ class ChargeEventJobService(
|
|||||||
private val chargeRepository: ChargeRepository,
|
private val chargeRepository: ChargeRepository,
|
||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val chargeEventRepository: ChargeEventRepository? = null,
|
private val chargeEventRepository: ChargeEventRepository? = null,
|
||||||
private val authRepository: AuthRepository? = null,
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher? = null,
|
private val applicationEventPublisher: ApplicationEventPublisher? = null,
|
||||||
transactionManager: PlatformTransactionManager? = null
|
transactionManager: PlatformTransactionManager? = null
|
||||||
) {
|
) {
|
||||||
@@ -121,36 +118,7 @@ class ChargeEventJobService(
|
|||||||
private fun buildSnapshot(charge: Charge, member: Member): ChargeEventJob? {
|
private fun buildSnapshot(charge: Charge, member: Member): ChargeEventJob? {
|
||||||
val paymentGateway = charge.payment?.paymentGateway
|
val paymentGateway = charge.payment?.paymentGateway
|
||||||
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||||
return if (member.auth != null) {
|
return buildActiveEventSnapshot(charge, member, paymentGateway)
|
||||||
val authRepository = authRepository ?: return null
|
|
||||||
val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di)
|
|
||||||
val chargeCount = authRepository.getMemberIdsByDi(member.auth!!.di)
|
|
||||||
.sumOf { id -> chargeRepository.getChargeCountAfterDate(memberId = id, authDate) }
|
|
||||||
if (chargeCount > 1) {
|
|
||||||
buildActiveEventSnapshot(charge, member, paymentGateway)
|
|
||||||
} else {
|
|
||||||
buildFirstChargeSnapshot(charge, member, paymentGateway)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
buildActiveEventSnapshot(charge, member, paymentGateway)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildFirstChargeSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob {
|
|
||||||
val additionalCan = ceil(charge.chargeCan * 0.15).toInt()
|
|
||||||
return ChargeEventJob(
|
|
||||||
sourceChargeId = charge.id!!,
|
|
||||||
memberId = member.id!!,
|
|
||||||
chargeEventId = null,
|
|
||||||
jobType = ChargeEventJobType.FIRST_CHARGE,
|
|
||||||
idempotencyKey = idempotencyKey(charge.id!!, ChargeEventJobType.FIRST_CHARGE, null),
|
|
||||||
additionalCan = additionalCan,
|
|
||||||
paymentGateway = paymentGateway,
|
|
||||||
container = container(paymentGateway),
|
|
||||||
methodSnapshot = FIRST_CHARGE_METHOD,
|
|
||||||
status = ChargeEventJobStatus.PROCESSING,
|
|
||||||
processingStartedAt = LocalDateTime.now()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildActiveEventSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob? {
|
private fun buildActiveEventSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob? {
|
||||||
@@ -222,7 +190,6 @@ class ChargeEventJobService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val FIRST_CHARGE_METHOD = "첫 충전 이벤트"
|
|
||||||
private const val MAX_ERROR_LENGTH = 1000
|
private const val MAX_ERROR_LENGTH = 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
@@ -1,14 +1,32 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.admin.event.charge
|
package kr.co.vividnext.sodalive.v2.admin.event.charge
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJob
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJob
|
||||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService
|
||||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus
|
||||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobType
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobType
|
||||||
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobWorker
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.transaction.TransactionDefinition
|
||||||
|
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||||
|
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
class AdminChargeEventJobServiceTest {
|
class AdminChargeEventJobServiceTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -54,6 +72,111 @@ class AdminChargeEventJobServiceTest {
|
|||||||
assertEquals(2, pending.retryCount)
|
assertEquals(2, pending.retryCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실패한 첫 충전 작업은 관리자 재시도 후 저장 스냅샷 그대로 워커에서 한 번 지급한다")
|
||||||
|
fun shouldRetryFailedFirstChargeJobWithoutChangingSnapshot() {
|
||||||
|
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
|
||||||
|
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||||
|
val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||||
|
val service = AdminChargeEventJobService(repository)
|
||||||
|
val notifications = mutableListOf<FcmEvent>()
|
||||||
|
val payoutService = ChargeEventJobService(
|
||||||
|
repository,
|
||||||
|
chargeRepository,
|
||||||
|
memberRepository,
|
||||||
|
applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) }
|
||||||
|
)
|
||||||
|
val transactionManager = object : AbstractPlatformTransactionManager() {
|
||||||
|
override fun doGetTransaction(): Any = Any()
|
||||||
|
override fun doBegin(transaction: Any, definition: TransactionDefinition) {}
|
||||||
|
override fun doCommit(status: DefaultTransactionStatus) {}
|
||||||
|
override fun doRollback(status: DefaultTransactionStatus) {}
|
||||||
|
}
|
||||||
|
val worker = ChargeEventJobWorker(repository, payoutService, transactionManager)
|
||||||
|
val member = Member(password = "pw", nickname = "tester").also {
|
||||||
|
it.id = 10L
|
||||||
|
it.appleChargeCan = 100
|
||||||
|
}
|
||||||
|
val failed = ChargeEventJob(
|
||||||
|
sourceChargeId = 100L,
|
||||||
|
memberId = 10L,
|
||||||
|
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||||
|
idempotencyKey = "charge-event:100:FIRST_CHARGE:none",
|
||||||
|
additionalCan = 15,
|
||||||
|
paymentGateway = PaymentGateway.APPLE_IAP,
|
||||||
|
container = "ios",
|
||||||
|
methodSnapshot = "첫 충전 이벤트",
|
||||||
|
status = ChargeEventJobStatus.FAILED,
|
||||||
|
retryCount = 3,
|
||||||
|
nextRetryAt = LocalDateTime.now().minusDays(1),
|
||||||
|
lastError = "bonus down"
|
||||||
|
).also { it.id = 1L }
|
||||||
|
val savedCharges = mutableListOf<Charge>()
|
||||||
|
Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(failed)
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(failed))
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.findNextPendingJobIdsForUpdate(
|
||||||
|
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now(),
|
||||||
|
Mockito.eq(30)
|
||||||
|
)
|
||||||
|
).thenAnswer {
|
||||||
|
if (failed.status == ChargeEventJobStatus.PENDING) listOf(1L) else emptyList<Long>()
|
||||||
|
}
|
||||||
|
Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member)
|
||||||
|
Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer {
|
||||||
|
assertEquals(ChargeEventJobStatus.PROCESSING, failed.status)
|
||||||
|
(it.arguments[0] as Charge).also { charge ->
|
||||||
|
charge.id = 200L
|
||||||
|
savedCharges.add(charge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val beforeRetry = LocalDateTime.now()
|
||||||
|
|
||||||
|
worker.runPendingJobs()
|
||||||
|
assertEquals(ChargeEventJobStatus.FAILED, failed.status)
|
||||||
|
assertTrue(savedCharges.isEmpty())
|
||||||
|
service.retry(1L)
|
||||||
|
|
||||||
|
assertEquals(ChargeEventJobStatus.PENDING, failed.status)
|
||||||
|
assertEquals(0, failed.retryCount)
|
||||||
|
assertNull(failed.lastError)
|
||||||
|
assertTrue(requireNotNull(failed.nextRetryAt) >= beforeRetry)
|
||||||
|
assertEquals(100L, failed.sourceChargeId)
|
||||||
|
assertEquals(10L, failed.memberId)
|
||||||
|
assertNull(failed.chargeEventId)
|
||||||
|
assertEquals(ChargeEventJobType.FIRST_CHARGE, failed.jobType)
|
||||||
|
assertEquals("charge-event:100:FIRST_CHARGE:none", failed.idempotencyKey)
|
||||||
|
assertEquals(15, failed.additionalCan)
|
||||||
|
assertEquals(PaymentGateway.APPLE_IAP, failed.paymentGateway)
|
||||||
|
assertEquals("ios", failed.container)
|
||||||
|
assertEquals("첫 충전 이벤트", failed.methodSnapshot)
|
||||||
|
|
||||||
|
worker.runPendingJobs()
|
||||||
|
worker.runPendingJobs()
|
||||||
|
payoutService.processJob(1L)
|
||||||
|
|
||||||
|
assertEquals(ChargeEventJobStatus.DONE, failed.status)
|
||||||
|
assertEquals(200L, failed.resultChargeId)
|
||||||
|
assertNotNull(failed.processingStartedAt)
|
||||||
|
assertNotNull(failed.processedAt)
|
||||||
|
assertNull(failed.nextRetryAt)
|
||||||
|
assertEquals(listOf(0, 15, 0), listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan))
|
||||||
|
assertEquals(listOf(0, 100, 0), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan))
|
||||||
|
val paidCharge = savedCharges.single()
|
||||||
|
assertEquals(0, paidCharge.chargeCan)
|
||||||
|
assertEquals(15, paidCharge.rewardCan)
|
||||||
|
assertEquals(ChargeStatus.EVENT, paidCharge.status)
|
||||||
|
assertEquals(member, paidCharge.member)
|
||||||
|
assertEquals(PaymentGateway.APPLE_IAP, paidCharge.payment?.paymentGateway)
|
||||||
|
assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status)
|
||||||
|
assertEquals("첫 충전 이벤트", paidCharge.payment?.method)
|
||||||
|
val notification = notifications.single()
|
||||||
|
assertEquals("can.charge.event.first_title", notification.titleKey)
|
||||||
|
assertEquals("can.charge.event.additional_can_paid", notification.messageKey)
|
||||||
|
assertEquals(listOf(15), notification.args)
|
||||||
|
assertEquals(listOf(10L), notification.recipients)
|
||||||
|
}
|
||||||
|
|
||||||
private fun job(id: Long, status: ChargeEventJobStatus): ChargeEventJob {
|
private fun job(id: Long, status: ChargeEventJobStatus): ChargeEventJob {
|
||||||
return ChargeEventJob(
|
return ChargeEventJob(
|
||||||
sourceChargeId = 100L,
|
sourceChargeId = 100L,
|
||||||
|
|||||||
+274
@@ -1,15 +1,31 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.can.charge.event
|
package kr.co.vividnext.sodalive.v2.can.charge.event
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.event.charge.ChargeEvent
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.transaction.TransactionDefinition
|
import org.springframework.transaction.TransactionDefinition
|
||||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||||
@@ -78,6 +94,264 @@ class ChargeEventJobServiceTest {
|
|||||||
assertEquals(TransactionDefinition.PROPAGATION_REQUIRES_NEW, transactionTemplate.propagationBehavior)
|
assertEquals(TransactionDefinition.PROPAGATION_REQUIRES_NEW, transactionTemplate.propagationBehavior)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(PaymentGateway::class, names = ["PG", "PAYVERSE", "APPLE_IAP", "GOOGLE_IAP"])
|
||||||
|
@DisplayName("인증 회원의 첫 충전도 활성 이벤트가 없으면 보너스 작업을 만들지 않는다")
|
||||||
|
fun shouldNotCreateFirstChargeJobForNewAuthenticatedCharge(gateway: PaymentGateway) {
|
||||||
|
val fixture = SnapshotFixture(gateway)
|
||||||
|
|
||||||
|
val result = fixture.service.createAndProcessImmediate(100L, 10L)
|
||||||
|
|
||||||
|
assertNull(result)
|
||||||
|
assertEquals(emptyList<ChargeEventJob>(), fixture.savedJobs)
|
||||||
|
assertEquals(emptyList<Charge>(), fixture.savedCharges)
|
||||||
|
assertEquals(emptyList<FcmEvent>(), fixture.notifications)
|
||||||
|
fixture.assertWallet(1, 2, 3)
|
||||||
|
fixture.assertNoEligibilityLookup()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"PG, pg, false, 11, 2, 3",
|
||||||
|
"PAYVERSE, pg, false, 11, 2, 3",
|
||||||
|
"APPLE_IAP, ios, false, 1, 12, 3",
|
||||||
|
"GOOGLE_IAP, aos, false, 1, 2, 13",
|
||||||
|
"PG, pg, true, 11, 2, 3",
|
||||||
|
"PAYVERSE, pg, true, 11, 2, 3",
|
||||||
|
"APPLE_IAP, ios, true, 1, 12, 3",
|
||||||
|
"GOOGLE_IAP, aos, true, 1, 2, 13"
|
||||||
|
)
|
||||||
|
@DisplayName("모든 결제 수단과 인증 여부에서 첫 충전 대신 일반 이벤트의 10퍼센트를 지급한다")
|
||||||
|
fun shouldApplyActiveEventToNewChargeAcrossGateways(
|
||||||
|
gateway: PaymentGateway,
|
||||||
|
container: String,
|
||||||
|
authenticated: Boolean,
|
||||||
|
pgReward: Int,
|
||||||
|
appleReward: Int,
|
||||||
|
googleReward: Int
|
||||||
|
) {
|
||||||
|
val fixture = SnapshotFixture(gateway, authenticated)
|
||||||
|
fixture.activeEvent(paymentCount = 1)
|
||||||
|
|
||||||
|
val result = requireNotNull(fixture.service.createAndProcessImmediate(100L, 10L))
|
||||||
|
|
||||||
|
assertEquals(ChargeEventJobType.ACTIVE_CHARGE_EVENT, result.jobType)
|
||||||
|
assertEquals(20L, result.chargeEventId)
|
||||||
|
assertEquals(100L, result.sourceChargeId)
|
||||||
|
assertEquals(10L, result.memberId)
|
||||||
|
assertEquals("charge-event:100:ACTIVE_CHARGE_EVENT:20", result.idempotencyKey)
|
||||||
|
assertEquals(10, result.additionalCan)
|
||||||
|
assertEquals(gateway, result.paymentGateway)
|
||||||
|
assertEquals(container, result.container)
|
||||||
|
assertEquals("봄 이벤트", result.methodSnapshot)
|
||||||
|
assertEquals(listOf(result), fixture.savedJobs)
|
||||||
|
assertEquals(ChargeEventJobStatus.DONE, result.status)
|
||||||
|
fixture.assertPayout(10, gateway, "봄 이벤트")
|
||||||
|
fixture.assertWallet(pgReward, appleReward, googleReward)
|
||||||
|
fixture.assertNotification(10, title = "봄 이벤트")
|
||||||
|
fixture.assertNoEligibilityLookup()
|
||||||
|
Mockito.verify(fixture.eventRepository).getPaymentCount(
|
||||||
|
fixture.member,
|
||||||
|
"봄 이벤트",
|
||||||
|
fixture.event.startDate,
|
||||||
|
fixture.event.endDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 이벤트 한도를 소진한 인증 회원은 첫 충전 보너스로 우회하지 않는다")
|
||||||
|
fun shouldNotCreateJobWhenActiveEventLimitIsExhausted() {
|
||||||
|
val fixture = SnapshotFixture(PaymentGateway.PG)
|
||||||
|
fixture.activeEvent(paymentCount = 2)
|
||||||
|
|
||||||
|
val result = fixture.service.createAndProcessImmediate(100L, 10L)
|
||||||
|
|
||||||
|
assertNull(result)
|
||||||
|
assertEquals(emptyList<ChargeEventJob>(), fixture.savedJobs)
|
||||||
|
assertEquals(emptyList<Charge>(), fixture.savedCharges)
|
||||||
|
assertEquals(emptyList<FcmEvent>(), fixture.notifications)
|
||||||
|
fixture.assertWallet(1, 2, 3)
|
||||||
|
fixture.assertNoEligibilityLookup()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("결제 정보가 없으면 활성 이벤트 조회보다 먼저 기존 예외를 유지한다")
|
||||||
|
fun shouldRejectMissingGatewayBeforeLookingUpActiveEvent() {
|
||||||
|
val fixture = SnapshotFixture(PaymentGateway.PG)
|
||||||
|
fixture.sourceCharge.payment = null
|
||||||
|
|
||||||
|
assertThrows(SodaException::class.java) { fixture.service.createProcessingJob(100L, 10L) }
|
||||||
|
|
||||||
|
Mockito.verifyNoInteractions(fixture.eventRepository, fixture.jobRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"PG, pg, PENDING, 16, 2, 3",
|
||||||
|
"PAYVERSE, pg, PENDING, 16, 2, 3",
|
||||||
|
"APPLE_IAP, ios, PENDING, 1, 17, 3",
|
||||||
|
"GOOGLE_IAP, aos, PENDING, 1, 2, 18",
|
||||||
|
"PG, pg, PROCESSING, 16, 2, 3",
|
||||||
|
"PAYVERSE, pg, PROCESSING, 16, 2, 3",
|
||||||
|
"APPLE_IAP, ios, PROCESSING, 1, 17, 3",
|
||||||
|
"GOOGLE_IAP, aos, PROCESSING, 1, 2, 18"
|
||||||
|
)
|
||||||
|
@DisplayName("기존 첫 충전 작업은 현재 이벤트나 인증 재계산 없이 저장된 15캔을 한 번 지급한다")
|
||||||
|
fun shouldPayExistingFirstChargeSnapshotWithoutActiveEvent(
|
||||||
|
gateway: PaymentGateway,
|
||||||
|
container: String,
|
||||||
|
status: ChargeEventJobStatus,
|
||||||
|
pgReward: Int,
|
||||||
|
appleReward: Int,
|
||||||
|
googleReward: Int
|
||||||
|
) {
|
||||||
|
val fixture = SnapshotFixture(gateway)
|
||||||
|
val storedJob = ChargeEventJob(
|
||||||
|
sourceChargeId = 100L,
|
||||||
|
memberId = 10L,
|
||||||
|
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||||
|
idempotencyKey = "charge-event:100:FIRST_CHARGE:none",
|
||||||
|
additionalCan = 15,
|
||||||
|
paymentGateway = gateway,
|
||||||
|
container = container,
|
||||||
|
methodSnapshot = "첫 충전 이벤트",
|
||||||
|
status = status,
|
||||||
|
nextRetryAt = LocalDateTime.now(),
|
||||||
|
lastError = "previous failure"
|
||||||
|
).also { it.id = 1L }
|
||||||
|
Mockito.`when`(fixture.jobRepository.findByIdForUpdate(1L)).thenReturn(storedJob)
|
||||||
|
|
||||||
|
fixture.service.processJob(1L)
|
||||||
|
fixture.service.processJob(1L)
|
||||||
|
|
||||||
|
assertEquals(ChargeEventJobStatus.DONE, storedJob.status)
|
||||||
|
assertEquals(15, storedJob.additionalCan)
|
||||||
|
assertEquals(gateway, storedJob.paymentGateway)
|
||||||
|
assertEquals(container, storedJob.container)
|
||||||
|
assertEquals("첫 충전 이벤트", storedJob.methodSnapshot)
|
||||||
|
assertEquals(200L, storedJob.resultChargeId)
|
||||||
|
assertNotNull(storedJob.processedAt)
|
||||||
|
assertNull(storedJob.nextRetryAt)
|
||||||
|
assertNull(storedJob.lastError)
|
||||||
|
assertEquals(emptyList<ChargeEventJob>(), fixture.savedJobs)
|
||||||
|
fixture.assertPayout(15, gateway, "첫 충전 이벤트")
|
||||||
|
fixture.assertWallet(pgReward, appleReward, googleReward)
|
||||||
|
fixture.assertNotification(15, titleKey = "can.charge.event.first_title")
|
||||||
|
fixture.assertNoEligibilityLookup()
|
||||||
|
Mockito.verifyNoInteractions(fixture.eventRepository)
|
||||||
|
Mockito.verify(fixture.chargeRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SnapshotFixture(gateway: PaymentGateway, authenticated: Boolean = true) {
|
||||||
|
val jobRepository = Mockito.mock(ChargeEventJobRepository::class.java)
|
||||||
|
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||||
|
private val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||||
|
val eventRepository = Mockito.mock(ChargeEventRepository::class.java)
|
||||||
|
val savedJobs = mutableListOf<ChargeEventJob>()
|
||||||
|
val savedCharges = mutableListOf<Charge>()
|
||||||
|
val notifications = mutableListOf<FcmEvent>()
|
||||||
|
val member = Member(password = "pw", nickname = "tester").also {
|
||||||
|
it.id = 10L
|
||||||
|
it.pgChargeCan = 100
|
||||||
|
it.appleChargeCan = 200
|
||||||
|
it.googleChargeCan = 300
|
||||||
|
it.pgRewardCan = 1
|
||||||
|
it.appleRewardCan = 2
|
||||||
|
it.googleRewardCan = 3
|
||||||
|
if (authenticated) {
|
||||||
|
Auth("tester", "19900101", "test-ci", "test-di", 1).member = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sourceCharge = Charge(100, 5).also {
|
||||||
|
it.id = 100L
|
||||||
|
it.member = member
|
||||||
|
it.payment = Payment(PaymentStatus.COMPLETE, gateway).also { payment -> payment.method = "카드" }
|
||||||
|
}
|
||||||
|
val event = ChargeEvent(
|
||||||
|
"봄 이벤트",
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 12, 31, 0, 0),
|
||||||
|
2,
|
||||||
|
0.10f
|
||||||
|
).also { it.id = 20L }
|
||||||
|
val service = ChargeEventJobService(
|
||||||
|
jobRepository,
|
||||||
|
chargeRepository,
|
||||||
|
memberRepository,
|
||||||
|
chargeEventRepository = eventRepository,
|
||||||
|
applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) }
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Mockito.`when`(chargeRepository.findByIdForUpdate(100L)).thenReturn(sourceCharge)
|
||||||
|
Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member)
|
||||||
|
Mockito.`when`(jobRepository.saveAndFlush(Mockito.any(ChargeEventJob::class.java))).thenAnswer {
|
||||||
|
(it.arguments[0] as ChargeEventJob).also { job ->
|
||||||
|
job.id = 1L
|
||||||
|
savedJobs.add(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mockito.`when`(jobRepository.findByIdForUpdate(1L)).thenAnswer { savedJobs.singleOrNull() }
|
||||||
|
Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer {
|
||||||
|
(it.arguments[0] as Charge).also { charge ->
|
||||||
|
charge.id = 200L
|
||||||
|
savedCharges.add(charge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun activeEvent(paymentCount: Int) {
|
||||||
|
Mockito.`when`(eventRepository.getChargeEvent()).thenReturn(event)
|
||||||
|
Mockito.`when`(
|
||||||
|
eventRepository.getPaymentCount(member, "봄 이벤트", event.startDate, event.endDate)
|
||||||
|
).thenReturn(paymentCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNoEligibilityLookup() {
|
||||||
|
Mockito.verify(chargeRepository, Mockito.never()).getChargeCountAfterDate(
|
||||||
|
Mockito.anyLong(),
|
||||||
|
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertWallet(pgReward: Int, appleReward: Int, googleReward: Int) {
|
||||||
|
assertEquals(
|
||||||
|
listOf(pgReward, appleReward, googleReward),
|
||||||
|
listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan)
|
||||||
|
)
|
||||||
|
assertEquals(listOf(100, 200, 300), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan))
|
||||||
|
assertEquals(100, sourceCharge.chargeCan)
|
||||||
|
assertEquals(5, sourceCharge.rewardCan)
|
||||||
|
assertEquals(ChargeStatus.CHARGE, sourceCharge.status)
|
||||||
|
assertEquals(PaymentStatus.COMPLETE, sourceCharge.payment?.status)
|
||||||
|
assertEquals("카드", sourceCharge.payment?.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertPayout(amount: Int, gateway: PaymentGateway, method: String) {
|
||||||
|
val paidCharge = savedCharges.single()
|
||||||
|
assertEquals(0, paidCharge.chargeCan)
|
||||||
|
assertEquals(amount, paidCharge.rewardCan)
|
||||||
|
assertEquals(ChargeStatus.EVENT, paidCharge.status)
|
||||||
|
assertEquals("$amount 캔", paidCharge.title)
|
||||||
|
assertEquals(member, paidCharge.member)
|
||||||
|
assertEquals(gateway, paidCharge.payment?.paymentGateway)
|
||||||
|
assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status)
|
||||||
|
assertEquals(method, paidCharge.payment?.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNotification(amount: Int, title: String = "", titleKey: String? = null) {
|
||||||
|
val notification = notifications.single()
|
||||||
|
assertEquals(FcmEventType.INDIVIDUAL, notification.type)
|
||||||
|
assertEquals(PushNotificationCategory.SYSTEM, notification.category)
|
||||||
|
assertEquals(title, notification.title)
|
||||||
|
assertEquals(titleKey, notification.titleKey)
|
||||||
|
assertEquals("can.charge.event.additional_can_paid", notification.messageKey)
|
||||||
|
assertEquals(listOf(amount), notification.args)
|
||||||
|
assertEquals(listOf(10L), notification.recipients)
|
||||||
|
assertNull(notification.isAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun job(
|
private fun job(
|
||||||
id: Long,
|
id: Long,
|
||||||
memberId: Long = 10L,
|
memberId: Long = 10L,
|
||||||
|
|||||||
+79
@@ -1,10 +1,20 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.can.charge.event
|
package kr.co.vividnext.sodalive.v2.can.charge.event
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||||
@@ -92,6 +102,75 @@ class ChargeEventJobWorkerTest {
|
|||||||
assertTrue(retryJob.nextRetryAt!!.isBefore(LocalDateTime.now().plusMinutes(6)))
|
assertTrue(retryJob.nextRetryAt!!.isBefore(LocalDateTime.now().plusMinutes(6)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("워커는 저장된 첫 충전 대기 작업을 지급하고 완료 작업 재호출에는 중복 지급하지 않는다")
|
||||||
|
fun shouldProcessPendingFirstChargeJobAndSkipDoneJob() {
|
||||||
|
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
|
||||||
|
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||||
|
val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||||
|
val notifications = mutableListOf<FcmEvent>()
|
||||||
|
val service = ChargeEventJobService(
|
||||||
|
repository,
|
||||||
|
chargeRepository,
|
||||||
|
memberRepository,
|
||||||
|
applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) }
|
||||||
|
)
|
||||||
|
val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager())
|
||||||
|
val member = Member(password = "pw", nickname = "tester").also {
|
||||||
|
it.id = 10L
|
||||||
|
it.pgChargeCan = 100
|
||||||
|
}
|
||||||
|
val pending = ChargeEventJob(
|
||||||
|
sourceChargeId = 100L,
|
||||||
|
memberId = 10L,
|
||||||
|
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||||
|
idempotencyKey = "charge-event:100:FIRST_CHARGE:none",
|
||||||
|
additionalCan = 15,
|
||||||
|
paymentGateway = PaymentGateway.PAYVERSE,
|
||||||
|
container = "pg",
|
||||||
|
methodSnapshot = "첫 충전 이벤트",
|
||||||
|
nextRetryAt = LocalDateTime.now().minusMinutes(1)
|
||||||
|
).also { it.id = 1L }
|
||||||
|
val savedCharges = mutableListOf<Charge>()
|
||||||
|
Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenAnswer {
|
||||||
|
if (pending.status == ChargeEventJobStatus.PENDING) listOf(1L) else emptyList<Long>()
|
||||||
|
}
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(pending))
|
||||||
|
Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(pending)
|
||||||
|
Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member)
|
||||||
|
Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer {
|
||||||
|
assertEquals(ChargeEventJobStatus.PROCESSING, pending.status)
|
||||||
|
(it.arguments[0] as Charge).also { charge ->
|
||||||
|
charge.id = 200L
|
||||||
|
savedCharges.add(charge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.runPendingJobs()
|
||||||
|
worker.runPendingJobs()
|
||||||
|
service.processJob(1L)
|
||||||
|
|
||||||
|
assertEquals(ChargeEventJobStatus.DONE, pending.status)
|
||||||
|
assertNotNull(pending.processingStartedAt)
|
||||||
|
assertNotNull(pending.processedAt)
|
||||||
|
assertEquals(200L, pending.resultChargeId)
|
||||||
|
assertEquals(listOf(15, 0, 0), listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan))
|
||||||
|
assertEquals(listOf(100, 0, 0), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan))
|
||||||
|
val paidCharge = savedCharges.single()
|
||||||
|
assertEquals(0, paidCharge.chargeCan)
|
||||||
|
assertEquals(15, paidCharge.rewardCan)
|
||||||
|
assertEquals(ChargeStatus.EVENT, paidCharge.status)
|
||||||
|
assertEquals(member, paidCharge.member)
|
||||||
|
assertEquals(PaymentGateway.PAYVERSE, paidCharge.payment?.paymentGateway)
|
||||||
|
assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status)
|
||||||
|
assertEquals("첫 충전 이벤트", paidCharge.payment?.method)
|
||||||
|
val notification = notifications.single()
|
||||||
|
assertEquals("can.charge.event.first_title", notification.titleKey)
|
||||||
|
assertEquals("can.charge.event.additional_can_paid", notification.messageKey)
|
||||||
|
assertEquals(listOf(15), notification.args)
|
||||||
|
assertEquals(listOf(10L), notification.recipients)
|
||||||
|
}
|
||||||
|
|
||||||
private fun job(id: Long, retryCount: Int = 0): ChargeEventJob {
|
private fun job(id: Long, retryCount: Int = 0): ChargeEventJob {
|
||||||
return ChargeEventJob(
|
return ChargeEventJob(
|
||||||
sourceChargeId = 100L,
|
sourceChargeId = 100L,
|
||||||
|
|||||||
Reference in New Issue
Block a user