Skip to content

feat(voucher): NCG Voucher(복권) IAP 측 — 아웃박스 발급·회수·상품매핑 admin·CSV (PLD-1468~1472, P1-IAP) - #476

Merged
ipdae merged 17 commits into
mainfrom
yang/pld-voucher-admin-iap
Aug 14, 2026
Merged

feat(voucher): NCG Voucher(복권) IAP 측 — 아웃박스 발급·회수·상품매핑 admin·CSV (PLD-1468~1472, P1-IAP)#476
ipdae merged 17 commits into
mainfrom
yang/pld-voucher-admin-iap

Conversation

@ipdae

@ipdae ipdae commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

요약

NCG Voucher(복권) 기능의 IAP 측 전체. IAP 결제 → 복권 티켓 발급(아웃박스), 회수/리컨사일(환불 방어), 상품→티켓 매핑 admin CRUD + CSV 일괄, 발급 컷오프 타임스탬프화까지. 상금표·확률 정책과 실제 발급 서명은 포탈 소관이고, IAP는 "어떤 상품이 어떤 티켓을 몇 개 주는가"와 발급 트리거/아웃박스를 담당한다.

PLD-1468~1472 + P1-IAP. 슬라이스별 코드리뷰 반영 완료, 인터널에 test 이미지로 배포·검증 완료(생성→REPLACE→'-'→거부 원자롤백, 발급 beat).

포함 범위

아웃박스 / 발급 (PLD-1468/1469/1472)

  • VoucherGrantOutbox 모델 + 마이그레이션 (status EnumType + server_default)
  • grant 트리거 beat 태스크 — 컷오프 이후 결제 enroll → 포탈 grant 위임(iss=iap JWT). FAILED 종단 완화·starvation 방지·상태 재검증
  • 발급 컷오프를 receipt id → 타임스탬프 기준으로 전환

회수 / 리컨사일 (PLD-1470/1471)

  • 환불 방어 회수 + reconcile. 리뷰 반영: 회수 유실 구멍 2건(🔴) + 스톨/starvation

상품 매핑 admin + CSV (P1-IAP, C1b)

  • product_voucher_grant + product-voucher-grants admin CRUD (GET/PUT/DELETE)
  • R2: grant retryable — 포탈 retryable:true(정책 전파 지연)면 종단 실패 아닌 재시도(self-heal)
  • 상품 CSV에 voucher 컬럼 확장 → (type,count) 고정 3쌍 슬롯으로 리팩터

문서

  • apps/api/VOUCHER_ADMIN_API.md (README 링크). 운영/직접호출은 9c-backoffice .claude/skills/voucher-ops/.

설정시점 가드 / 안전장치

  • C1 ticket_type ∈ 포탈 라이브 상금표 (fail-closed: url미설정/temporary/도달불가면 저장 거부)
  • C3-lite cap 설정 시 count×최대상금 ≤ cap (환율 무관 머니펌프 방어; prod은 cap 필수)
  • C5 매핑 낙관적 동시성(with_for_update, stale→409)
  • R2 발급 재시도 분류(body.retryable)
  • CSV 위반 행 → import 전체 롤백(원자적). REPLACE는 hard-delete 아닌 active=false(복구가능)

⚠️ 머지 전 게이트 (참고)

  • 실제 발급은 포탈 KMS 바우처 키 + active=true 매핑에서 발동 — prod 활성 전 C3-lite cap 설정 필수.
  • 런칭 게이트(기획/법무): 상금표 수치·확률 공시·사행성. 이 PR은 IAP 인프라/설정면이며, prod 활성화는 별도 게이트 통과 후.
  • 포탈 형제 작업(복권 리팩터 통합 PR #1276 계열)과 머지 순서 조율 필요.

테스트

  • 순수 검증 로직(voucher_validation.py) 유닛테스트 green.
  • 인터널 라이브: CSV 3쌍 경로 end-to-end(생성/REPLACE/'-'/거부 원자롤백) + admin CRUD 실호출 검증 완료.

운영 DB는 이미 `c1a7f0d3b9e4`에 올라가 있는데(2026-08-13 수동 적용) 그 리비전
파일이 main에 없어서, main 체크아웃으로 `alembic upgrade`를 돌리면
"Can't locate revision identified by 'c1a7f0d3b9e4'"로 실패한다. 즉 지금은
어떤 마이그레이션도 운영에 올릴 수 없는 상태다. 이 커밋이 그 간극을 메운다.

기능 코드는 포함하지 않는다. `/api/purchase/log`에서 신호를 기록하는 변경은
인증 없는 쓰기 경로가 되므로 SKU 가드를 붙인 뒤 #478에서 따로 간다.
여기 담긴 건 스키마와 그 모델/enum 뿐이고, 어디서도 참조되지 않아 동작
변화가 없다.

바우처(#476)의 마이그레이션도 같은 부모(b1d5e1dc71ea)에서 갈라져 있어,
이 파일이 main에 있어야 이후 merge revision으로 정합하게 봉합할 수 있다.

검증: alembic heads = c1a7f0d3b9e4 단일, b1d5e1dc71ea→head 실행계획이
purchase_signal 생성 한 단계뿐, 모델 import 정상.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ipdae and others added 16 commits August 14, 2026 14:52
- 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>
- voucher_grant_task R2: 포탈 409 body.retryable=true(ERR-TICKET-TYPE-UNKNOWN)를 transient로
  재분류 → 영구 FAILED 제거, 정책 일관 시 self-heal(`is True` 방어 판정). stall alert(≥5) 보존.
- admin.py product-voucher-grant CRUD(GET/PUT/DELETE): C1(ticket_type ∈ 라이브 정책, 포탈
  prize-tables 크로스read·fail-closed) + C3-lite(count×최대상금 ≤ cap, NCG-only·환율 무관)
  + C5(with_for_update + base_updated_at 409). prod에서 cap 미설정이면 활성화 거부(fail-open 방지).
- voucher_validation.py: config-free 순수 검증 모듈(테스트 용이). config에 portal_prize_tables_url,
  voucher_grant_max_ncg_per_grant(out-of-band cap) 추가.
- tests: voucher_validation 15 + _post_grant R2 10 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- voucher_validation.parse_voucher_columns(pure): voucher_ticket_type/voucher_count 파싱
  (빈칸=유지·'-'=전체비활성·';' 다중), 빈세그먼트/중복/길이불일치 거부 + C1/C3-lite 강제.
- import_utils: import_products_from_csv에 voucher_tables/cap 파라미터 + _apply_voucher_row
  (REPLACE=미나열은 active=False[hard-delete 아님·복구가능], 같은 트랜잭션 원자적, FK flush, blank-id 거부).
- admin.import_products_endpoint: **실제 voucher 값 있는 행**이 있을 때만 라이브 정책 fetch
  (헤더 substring 아닌 DictReader 데이터 스캔), prod C3-lite cap 게이트, HTTPException 상태코드 보존.
- 리뷰 반영: 세미콜론 정렬 버그, 중복/빈세그먼트, blank-id FK, 포탈 상시결합, hard-delete→deactivate, black.
- tests: parse 12케이스 포함 27 green.

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

- 세미콜론 병렬(voucher_ticket_type=A;B / voucher_count=1;2) → voucher_ticket_type_1..3 / voucher_count_1..3
  쌍 슬롯. 개수 정렬 불필요(리뷰 지적 세미콜론 오정렬 버그 제거).
- parse_voucher_columns(pairs, tables, cap): 전슬롯빈칸=유지, '-'단독=전체비활성, 값=REPLACE, count빈칸=1.
- import_utils VOUCHER_SLOTS=3, admin 헤더감지 voucher_ticket_type_1..3. tests 25 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
product-voucher-grants CRUD + products/import CSV 3쌍 슬롯, 인증(verify_token
aud=iap), 설정시점 가드(C1/C3-lite/C5), 발급 워커 흐름(포탈 grant·R2),
상태코드 문서화. README에서 링크. 운영은 9c-backoffice voucher-ops 스킬.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
두 체인이 같은 부모(b1d5e1dc71ea)에서 갈라져 각각 다른 환경에 이미 적용된
상태다. 운영은 c1a7f0d3b9e4(purchase_signal 있음, 바우처 없음), 인터널은
9c1e2f3a4b5c(바우처 있음, purchase_signal 없음). 둘 다 main에 들어오면
head가 2개가 되어 `alembic upgrade head`가 거부한다.

스키마 변경 없는 merge revision으로 봉합한다. 각 환경의 upgrade head가
자기에게 없는 쪽만 채우고 같은 지점으로 수렴한다(오프라인 계획으로 확인):

  운영   → voucher_grant_outbox, product_voucher_grant 생성 + merge
  인터널 → purchase_signal 생성 + merge

re-parenting(한쪽 체인의 down_revision 변경)은 쓰지 않았다. 인터널이 이미
9c1e2f3a4b5c 라서 c1a7f0d3b9e4 를 조상으로 간주해 영구히 건너뛰고,
purchase_signal 없이 "적용됨"으로 남기 때문이다.

이 커밋을 담으려면 부모 두 개가 한 트리에 있어야 해서, 브랜치를
#479(yang/purchase-signal-schema) 위로 리베이스했다. #479가 main에 머지되면
이 PR의 diff에서 해당 파일들은 자동으로 빠진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ipdae
ipdae force-pushed the yang/pld-voucher-admin-iap branch from 74ceee3 to 59891c6 Compare August 14, 2026 06:01
@ipdae
ipdae merged commit e03c293 into main Aug 14, 2026
3 checks passed
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