Skip to content

feat(voucher): 복권식 IAP — product_voucher_grant + tickets 발급/회수 (PLD-1468~1472) - #475

Closed
ipdae wants to merge 11 commits into
mainfrom
yang/voucher-lottery-refactor
Closed

feat(voucher): 복권식 IAP — product_voucher_grant + tickets 발급/회수 (PLD-1468~1472)#475
ipdae wants to merge 11 commits into
mainfrom
yang/voucher-lottery-refactor

Conversation

@ipdae

@ipdae ipdae commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

요약

NCG Voucher IAP측 전체(아웃박스+grant트리거+reconcile)를 복권식으로. 기존 환급식 스택(#471/#472/#474) 대체.

  • product_voucher_grant 테이블: 상품→(ticket_type,count) 매핑
  • grant 트리거(beat */2분): 적격 결제(매핑 있는 상품) → 포탈 grant에 tickets 전송(amountUsd 제거)
  • reconcile(beat */5분): 환불→포탈 revoke
  • 아웃박스 단일 조율점(멱등·발급선점·재시도), SAVEPOINT·row-lock·상태재검증·stall 알림

검증

IAP 유닛 43 + 크로스-서비스 e2e(tickets:[{STANDARD,2}]→포탈 STANDARD 2장 발급, enroll 필터 검증) 통과.

리뷰

엔벨로프는 #472/#474에서 리뷰·반영. 복권 변환분 별도 리뷰(merge, 🔴없음) — grant stall 알림·중복 인덱스 제거 반영.

후속(운영): ticket_type ↔ 포탈 prizeTables 키 사전 검증(설정 오타→FAILED 방지).
⚠️ 미배포. dev/staging은 마이그레이션(9c1e2f3a4b5c) 적용 필요.

ipdae and others added 10 commits August 3, 2026 21:36
- receipt_id UNIQUE 아웃박스: 포탈 바우처 grant/revoke 멱등·재시도 추적
- 지급 트리거 후 status=GRANTED, 리컨사일이 미완료분 재호출, 환불 시 REVOKE_PENDING→REVOKED
- 권위 있는 바우처 상태는 포탈 purchase_voucher, 여기는 IAP측 "포탈에 넘겼나" 마커
- 고아 voucher_request 재사용 대신 신규(옛 스키마 의미·스테일 회피)

⚠️ Alembic 마이그 미포함: 현재 versions에 head가 3개(미병합)라 down_revision 수동 지정 위험.
   `alembic revision --autogenerate -m add_voucher_grant_outbox`로 생성(head 자동해결, 필요시 alembic merge 선행).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- status: raw Text → EnumType(VoucherGrantStatus) (오타→중복발급 위험 차단, 죽어있던 EnumType 헬퍼 활용)
- VoucherGrantStatus(IntEnum) enums.py 추가 (PENDING/GRANTED/REVOKE_PENDING/REVOKED/FAILED)
- status/attempts에 server_default 병기 (bulk/upsert/raw insert NOT NULL 안전 — Receipt.mileage_change 패턴)
- status 인덱스(재시도 폴링)
- (정정) Alembic head는 1개(b1d5e1dc71ea)뿐 — merge 없이 바로 autogenerate 가능

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
down_revision=b1d5e1dc71ea (단일 head). status=Integer(EnumType 백엔드)+server_default,
attempts server_default, receipt_id UNIQUE, status 인덱스. downgrade 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
검증완료(VALID)+지급성공(tx SUCCESS) 결제를 폴링해 포탈 grant를 호출하는 beat(*/2분).
복잡한 send_product handle()을 건드리지 않고 아웃박스(voucher_grant_outbox)로 디커플링.

- enroll: cutoff 이후 적격 영수증 중 아웃박스 없는 건 PENDING 생성(SAVEPOINT로 레이스 흡수)
- dispatch: PENDING → platform/amountUsd 유도 → 포탈 grant → GRANTED/재시도/FAILED
- platform(PLD-1472): WEB=PC, APPLE/GOOGLE=MOBILE, TEST/REDEEM=대상아님
- amountUsd(PLD-1472): 상품 WEB/USD 가격(canonical)
- 응답 분류: success/already/too-small=종료, 'voucher disabled'/5xx=재시도, 4xx=FAILED
- 서버간 JWT(HS256, gameBackendApiHandler), cutoff로 과거 소급 방지, 마스터 스위치
- 멱등: 아웃박스 receipt_id UNIQUE + 포탈 grant iapUuid 멱등
- 테스트 22건(platform/planet/amount/응답분류/config게이팅)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 FAILED 영구종단이 회복가능 조건을 침묵 미지급으로 굳히는 문제:
- 인증(401/403)·레이트리밋(429)·타임아웃(408)을 transient(재시도)로 재분류
- amountUsd 미발견(가격 미활성 가능)도 FAILED 아닌 PENDING 유지
- FAILED 발생 시 알림(_alert → iap_alert_webhook_url) — 미지급 가시화

🟡:
- enroll 스토어 필터를 SQL로 이동(Receipt.store.in_) — skip대상(REDEEM) 침전/starvation 방지
- production에선 샌드박스 스토어(_TEST) 발급 제외(_grantable_stores, stage 게이트)
- dispatch 시점 Receipt 상태 재검증 — enroll 이후 환불/무효 전이 시 발급 금지(FAILED 종단)
- Price 조회 active=True + 최신 1건(다중행 임의선택 방지)
- dispatch 행별 try/except 격리 — poison 행이 배치 전체 롤백/중단 못하게
- PENDING 조회 with_for_update(skip_locked) — 동시 실행 중복 POST 방지
- 비-object 200 body 방어, jwt HS256 명시, tz-aware datetime

테스트 26건(+인증/레이트리밋 transient, 비-object body, stage 게이트)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
환불 감지 → 포탈 revoke를 아웃박스(voucher_grant_outbox)를 단일 조율점으로 처리.

- voucher_reconcile_task(beat */5분):
  (A) status 기반 enroll: 환불/무효(REFUNDED_*/INVALID) receipt의 미회수 아웃박스 → REVOKE_PENDING (admin 환불 등)
  (B) revoke dispatch: REVOKE_PENDING → 포탈 revoke → REVOKED. 행잠금(skip_locked)·행별 격리·재시도
- track_google_refund 훅: void 감지 시 order_id→receipt→enqueue_revoke (google buyer 환불은
  receipt.status 미갱신이라 이 훅이 유일 신호원). 알림 흐름과 독립·best-effort
- 아웃박스가 단일 조율점: REVOKE_PENDING/REVOKED는 grant enroll(notin_)·dispatch(PENDING)가 스킵
  → 환불이 grant보다 먼저 도착해도 발급 선점 차단
- revoke는 유실=미회수(환불 NCG 잔존)이므로 4xx도 드롭 않고 REVOKE_PENDING 유지+경보
- 테스트 14건(revoke 응답분류, enqueue 상태전이, 게이팅)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…470/1471)

🔴#1 훅 배치 UNIQUE 충돌 통째 롤백 → google 환불 회수 영구 유실:
  enqueue_revoke_for_receipt 생성 경로를 begin_nested(SAVEPOINT)로 행별 격리,
  충돌(grant 선점) 시 재조회 후 REVOKE_PENDING 전이

🔴#2 크래시창에 발급 후 FAILED 찍힌 건이 enroll scope 밖 → 회수 누락:
  status 기반 enroll scope에 FAILED 포함(revoke는 미발급 receipt엔 멱등 no-op이라 무해)

🟡:
- transient(5xx/인증/레이트리밋) 스톨은 failed에 안 잡혀 무알림 → attempts>=5 REVOKE_PENDING
  백로그 집계해 경보(회수 유실 진행중 가시화)
- dispatch order_by(attempts, receipt_id) → 영구 4xx 고-attempts 행이 신규 회수 starve 방지
- enqueue_revoke_by_order_id에 google 스토어 필터 + 다중매치 전건 큐잉(크로스-스토어 오회수 방지)
- reconcile 엔진을 grant와 공유(프로세스당 커넥션 풀 이중생성 방지)
- Apple buyer 환불 미커버 known-gap 명시(ASSN 처리기 도입 시 연결)

테스트 17건(+FAILED 전이, order_id 다중매치/무매치)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…1472)

환급식(amountUsd/WEB가격) → 복권식(상품별 티켓 매핑).
- product_voucher_grant 모델(product_id·ticket_type·count·active, UNIQUE) + alembic
- tickets_for_product(active 매핑 조회) → usd_amount_for_product 대체
- enroll: 바우처 대상 상품(active 티켓 매핑 존재)만 필터 — 미대상 상품 윈도우 침전 방지
- dispatch payload: amountUsd → tickets[{ticketType,count}], platform은 통계용 유지
- 티켓 미설정/비활성 시 PENDING 유지(재시도). 나머지 엔벨로프(아웃박스/멱등/상태재검증/락) 그대로

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
usd_amount_for_product 테스트 → tickets_for_product(티켓 리스트/빈/0카운트 스킵).
platform/planet/_post_grant/게이팅/reconcile 테스트는 모델 무관이라 유지. IAP 43건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- grant 태스크에 stall 경보 추가(attempts>=5 PENDING) — transient/티켓매핑 미설정으로
  무한 재시도 중인 미지급이 침묵 침전하지 않게(reconcile과 대칭)
- product_voucher_grant 마이그레이션의 단일 인덱스 제거 — UNIQUE(product_id,ticket_type)
  복합 btree가 product_id 선두 조회 커버(중복+autogenerate 드리프트 제거)

후속(운영): ticket_type ↔ 포탈 prizeTables 키 사전 검증 절차(설정 오타→종단 FAILED 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
voucher_grant_cutoff_receipt_id(int) → voucher_grant_cutoff(datetime, ISO env).
enroll에서 created_at >= cutoff(설정 시)로 과거 소급 방지. id보다 운영상 명확("이 시각 이후 결제부터").
미설정(None)=컷오프 없음. 최대 id 조회 불필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ipdae

ipdae commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

#476 에 통째로 포함되어 머지됐습니다(e03c293).

#476#479(purchase_signal 스키마) 위로 리베이스하면서 커밋 SHA 가 바뀌어 GitHub 이 자동으로 닫지 못했을 뿐, 내용은 전부 main 에 있습니다. 확인:

마이그레이션 체인도 0eb7c95c9f30 단일 head 로 봉합돼 있습니다.

@ipdae ipdae closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant