From 45b97088ca7c74b5a23f4ef41f8047a237166626 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 19 Oct 2026 18:33:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(charge):=20=EC=B2=AB=20=EC=B6=A9=EC=A0=84?= =?UTF-8?q?=20=EB=B3=B4=EB=84=88=EC=8A=A4=20=EC=8B=A0=EA=B7=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=A4=91=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 219 ++++++++++++++ docs/20261019_첫충전보너스신규지급중단/prd.md | 81 ++++++ .../can/charge/event/ChargeEventJobService.kt | 35 +-- .../charge/AdminChargeEventJobServiceTest.kt | 123 ++++++++ .../charge/event/ChargeEventJobServiceTest.kt | 274 ++++++++++++++++++ .../charge/event/ChargeEventJobWorkerTest.kt | 79 +++++ 6 files changed, 777 insertions(+), 34 deletions(-) create mode 100644 docs/20261019_첫충전보너스신규지급중단/plan-task.md create mode 100644 docs/20261019_첫충전보너스신규지급중단/prd.md diff --git a/docs/20261019_첫충전보너스신규지급중단/plan-task.md b/docs/20261019_첫충전보너스신규지급중단/plan-task.md new file mode 100644 index 00000000..5dfd547d --- /dev/null +++ b/docs/20261019_첫충전보너스신규지급중단/plan-task.md @@ -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/스토어 결제·푸시 수신 및 배포 후 신규 생성 중단은 이번 검증에 포함하지 않았다. 위 배포 체크리스트에 따라 별도로 확인한다. diff --git a/docs/20261019_첫충전보너스신규지급중단/prd.md b/docs/20261019_첫충전보너스신규지급중단/prd.md new file mode 100644 index 00000000..0a36410e --- /dev/null +++ b/docs/20261019_첫충전보너스신규지급중단/prd.md @@ -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개 시나리오를 통과했다. 운영 배포·실지급 확인은 별도다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt index 60da8940..77b357cb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt @@ -13,7 +13,6 @@ 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.MemberRepository -import kr.co.vividnext.sodalive.member.auth.AuthRepository import org.springframework.context.ApplicationEventPublisher import org.springframework.dao.DataIntegrityViolationException 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.TransactionTemplate import java.time.LocalDateTime -import kotlin.math.ceil import kotlin.math.round @Service @@ -34,7 +32,6 @@ class ChargeEventJobService( private val chargeRepository: ChargeRepository, private val memberRepository: MemberRepository, private val chargeEventRepository: ChargeEventRepository? = null, - private val authRepository: AuthRepository? = null, private val applicationEventPublisher: ApplicationEventPublisher? = null, transactionManager: PlatformTransactionManager? = null ) { @@ -121,36 +118,7 @@ class ChargeEventJobService( private fun buildSnapshot(charge: Charge, member: Member): ChargeEventJob? { val paymentGateway = charge.payment?.paymentGateway ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") - return if (member.auth != null) { - 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() - ) + return buildActiveEventSnapshot(charge, member, paymentGateway) } private fun buildActiveEventSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob? { @@ -222,7 +190,6 @@ class ChargeEventJobService( } companion object { - private const val FIRST_CHARGE_METHOD = "첫 충전 이벤트" private const val MAX_ERROR_LENGTH = 1000 } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt index fa0f3fd9..a2eb801d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt @@ -1,14 +1,32 @@ 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.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.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.ChargeEventJobType +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobWorker 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.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.util.Optional class AdminChargeEventJobServiceTest { @Test @@ -54,6 +72,111 @@ class AdminChargeEventJobServiceTest { 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() + 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() + 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() + } + 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 { return ChargeEventJob( sourceChargeId = 100L, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt index 8f581f6a..374fafa3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt @@ -1,15 +1,31 @@ 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.ChargeRepository 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.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.MemberRepository +import kr.co.vividnext.sodalive.member.auth.Auth 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.assertThrows +import org.junit.jupiter.api.DisplayName 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.springframework.context.ApplicationEventPublisher import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.support.AbstractPlatformTransactionManager import org.springframework.transaction.support.DefaultTransactionStatus @@ -78,6 +94,264 @@ class ChargeEventJobServiceTest { 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(), fixture.savedJobs) + assertEquals(emptyList(), fixture.savedCharges) + assertEquals(emptyList(), 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(), fixture.savedJobs) + assertEquals(emptyList(), fixture.savedCharges) + assertEquals(emptyList(), 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(), 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() + val savedCharges = mutableListOf() + val notifications = mutableListOf() + 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( id: Long, memberId: Long = 10L, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt index 3fa5cef9..5ef0eef8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt @@ -1,10 +1,20 @@ 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.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.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher import org.springframework.scheduling.annotation.Scheduled import org.springframework.transaction.support.AbstractPlatformTransactionManager import org.springframework.transaction.support.DefaultTransactionStatus @@ -92,6 +102,75 @@ class ChargeEventJobWorkerTest { 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() + 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() + Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenAnswer { + if (pending.status == ChargeEventJobStatus.PENDING) listOf(1L) else emptyList() + } + 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 { return ChargeEventJob( sourceChargeId = 100L,