feat(charge): 첫 충전 보너스 신규 생성을 중단한다

This commit is contained in:
2026-10-19 18:33:34 +09:00
parent 2aef413adf
commit 45b97088ca
6 changed files with 777 additions and 34 deletions
@@ -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개 시나리오를 통과했다. 운영 배포·실지급 확인은 별도다.
@@ -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
} }
} }
@@ -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,
@@ -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,
@@ -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,