From a4ae5fcb64fb7340b85e183877981a8112aaa248 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 09:26:32 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20pay=20v2=20P2(=E6=94=B6=E6=AC=BE?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF)+=20P7(codes=20=E5=85=B1=E4=BA=AB=E5=BA=93)?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2(pay 仓,7 任务):Provider 抽象/render_type + 收款管线 + 统一入账 + webhook v2。 P7(新仓 ~/code/codes,8 任务):零依赖 codes 内核 + Redeem[T] 泛型事务骨架。 并行起草(均只依赖已完成 P1),登记 index。 Co-Authored-By: Claude Opus 4.8 --- docs/index.html | 10 + docs/pay-v2-p7-codes-lib.html | 91 + .../plans/2026-07-10-pay-v2-p2-pipeline.md | 2176 +++++++++++++ .../plans/2026-07-10-pay-v2-p7-codes-lib.md | 2841 +++++++++++++++++ 4 files changed, 5118 insertions(+) create mode 100644 docs/pay-v2-p7-codes-lib.html create mode 100644 docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md create mode 100644 docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md diff --git a/docs/index.html b/docs/index.html index 0b7f1f4..cfbae9d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -57,11 +57,21 @@
阅读版;执行真相源 docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md(含 checkbox)。pay v2 设计的首个落地阶段(8 阶段之 P1)。4 个 TDD 任务:money 包(int64 最小单位+币种)→ Order/Attempt/Account/Refund 模型+状态机+:memory: 测试约定 → OrderStore(幂等标付/取消/列表,条件UPDATE+RowsAffected)→ 账户配置注册表(凭证走 env)。沿用 GORM AutoMigrate/glebarez sqlite 惯例;金额从 string 元改 int64。P2-P8(Provider/收款管线/渠道 adapter/退款/路由/对账/codes 库/订阅)各自成计划、落地前细化。
P1 · 2026-07-10 · Go(Gin+GORM+sqlite) · 待执行
+ +
pay v2 · P2 收款管线 + Provider 抽象 + webhook v2(计划)
+
7 个 TDD 任务:Provider 接口(create/verify_callback/query)+6 render_type+PaidEvent+注册表 → fake provider → OrderStore 扩展 → 一次性收款管线 gateway → 统一入账/开通(复用 P1 MarkAttemptPaid 幂等)→ webhook v2 outbox+Notifier(event_type+HMAC)→ HTTP /v1 接线。复用 P1 全部类型、免 docker。/v1 与 v1 /api/v1 并存。
+
P2 · 2026-07-10 · 待执行 · 真相源 .md
+
pay v2 · P2-P8 依赖 DAG 与并行执行
P1 已完成。P2-P8 的依赖关系图(SVG DAG)+ 关键路径 P2→P3→P4/P6 + 波次(P7 全程并行/P5∥P3/P4∥P6)+ 同仓并行的真实边界(先计划后实现、worktree 隔离防冲突)。
roadmap · 2026-07-10 · 8 阶段依赖分析
+ +
pay v2 · P7 codes 共享库(激活码兑换内核)(阅读版)
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md(含 checkbox)。把 pangolin 现有码逻辑抽成独立仓库 github.com/wangjia/codes(与 pay 不同部署,方案 A:共享库嵌入各产品,不改 pay/pangolin/jiu 现有代码)。8 个 TDD 任务:通用权益描述符 Entitlement(duration/quota,不硬编码 plan+天数)→ Crockford Base32 生成器(移植 pangolin idgen)→ Dialect+内嵌 mysql/sqlite migrations+Store CRUD → Mint 批次生成 → Redeem[T] 兑换事务骨架(宿主本地事务+锁+CAS+幂等+grant 回调)→ RateLimiter/NonceChecker 接口+GuardedRedeem → redisx 可选子包(唯一 import Redis 处)→ webhook 灌码(pay-contract 式通用 HMAC 签名)。核心包零第三方依赖。
+
P7 · 2026-07-10 · Go(stdlib database/sql,可选 redis) · 待执行
+

运行与联调步骤见仓库根 README.md

🔧 排障 Runbook

diff --git a/docs/pay-v2-p7-codes-lib.html b/docs/pay-v2-p7-codes-lib.html new file mode 100644 index 0000000..b878043 --- /dev/null +++ b/docs/pay-v2-p7-codes-lib.html @@ -0,0 +1,91 @@ + + + + + +pay v2 · P7 codes 共享库(阅读版) + + + +
+← 返回文档索引 +

pay v2 · P7 codes 共享库(激活码兑换内核) (阅读版)

+

执行真相源 docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md(含 - [ ] checkbox)。设计见 pay v2 统一支付网关设计 §9/§9.1/§9.2/§12。参考实现:pangolin/server/internal/codes/(只读,移植 canonical 部分)。

+ +
+P7 目标:把 pangolin 现有码逻辑(哈希存储/状态机/生成器/兑换事务/webhook 灌码)抽成一个与 pay 不同部署、不同仓库的独立 Go 共享库,供 pangolin(订阅)/jiu(门店 license)/未来 dudu(额度)等产品各自 import 嵌入。库 entitlement-agnostic——码携带通用「权益描述符」而非硬编码 plan+天数;兑换的"最终开通落库"通过宿主注入的回调函数完成,兑换是宿主本地事务(码表与宿主业务表同库)。本计划只搭这个新仓库,不改 pay/pangolin/jiu 现有代码。 +
+ +

部署与模块选型(已决策)

+ + + + + + + +
选项结论
pay 仓内 pkg/codes不采用 —— pay 依赖很重(gin/GORM/alipay/wechatpay-go),codes 与支付管线正交,放进 pay 逻辑/依赖两维度都错
pangolin 仓内保留、jiu 抄一份不采用 —— 违反"共享库"目标,退化两份漂移代码
独立仓库 + 独立 module已定 —— 新仓 ~/code/codes,module github.com/wangjia/codes;核心包零第三方依赖(仅 stdlib),可选 Redis 能力隔离进 codes/redisx 子包
+

落地约定:源码 ~/code/codes,remote ssh://git@git.51yanmei.com:2222/wangjia/codes.git(需先在 Gitea 建仓)。pangolin/jiu 迁移到 import 本库是后续独立工作,不在本计划内。

+ +

8 个 TDD 任务

+

Task 1 · 模块脚手架 + Entitlement + 状态机

Entitlement{Kind,Payload} 通用权益描述符(duration={plan,days} / quota={resource,amount}),替代硬编码 plan_id+duration_days;Status 三态 unused/redeemed/void;哨兵错误。
+

Task 2 · Crockford Base32 生成器

移植 pangolin internal/idgen 的 Crockford 部分(15 数据字符+1 mod-37 校验字符,crypto/rand 防偏抽样),根包薄封装 GenerateCode/Canonicalize/Hash
+

Task 3 · Dialect + 内嵌 migrations + Store CRUD

mysql/sqlite 双 migration(embed.FS + 零依赖 ApplyMigrations,也可接 golang-migrate iofs);StoreCreateBatch/CreateCode/FindByHash(ForUpdate)/MarkRedeemed/Void/WriteAudit,哈希唯一约束防重码。
+

Task 4 · Mint 批次生成

碰撞重试(移植 pangolin CreateBatch 逻辑),明文码只在返回值出现一次,从不落库/落日志。
+

Task 5 · Redeem[T] 兑换事务骨架(核心)

Redeem[T any](ctx, store, tx, codeHash, redeemerRef, grant GrantFunc[T])——宿主开事务传入、锁(FindByHashForUpdate+dialect)+CAS(MarkRedeemed 条件 UPDATE)+幂等(同 redeemerRef 短路)+审计,grant 回调在同一 tx 内执行宿主的权益写入,失败整体回滚——落地"码表与宿主表同库=本地事务"的方案 A 前提。另有 VoidCode
+

Task 6 · RateLimiter/NonceChecker 接口 + GuardedRedeem

零依赖默认实现(NoopRateLimiter/InMemoryNonceChecker);GuardedRedeem[T] 包一层失败锁定,失败计数、成功清零。
+

Task 7 · redisx 子包(可选)

唯一 import Redis 的地方——不引用就不产生依赖。移植 pangolin 的失败计数器+TTL 锁定、SET NX 原子去重,miniredis 测试免 docker。
+

Task 8 · webhook 灌码

通用 HMAC 签名(改用 pay-contract 既有 system+timestamp+nonce+body 一并入 MAC 的双向签名惯例,而非 pangolin 原版 body-only HMAC)+ 去重 + 通用权益负载 MintPayload → 调 Mint
+ +

关键设计决策

+ + + + + + + + + +
决策说明
同库本地事务Redeem[T] 接收宿主已开的 *sql.Tx——码状态翻转与宿主 grant 回调写同一事务,要求宿主权益表与 codes 表在同一个 *sql.DB 下(设计文档 §9.1 方案 A 前提)
通用权益描述符Entitlement{Kind,Payload} 库只做信封校验,从不解释业务字段——宿主 GrantFunc 才解释,对应设计文档 §12 可扩展性验证
Redis 可选核心包零 Redis 依赖;codes/redisx 子包才 import go-redis,不 import 就不产生依赖
webhook 签名升级从 pangolin 的 body-only HMAC 改为 pay-contract 的 system+timestamp+nonce+body 一并入 MAC,防头部篡改,统一多产品 webhook 验签心智模型
redeemerRef 不透明字符串("user:123"/"shop:9"),库不关心归属维度——设计文档 §9.1"归属维度留给各产品"的落地
+ +

范围之外(有意排除)

+ + +

相关:pay v2 设计 · P1 核心数据模型 · P2-P8 依赖 DAG · 真相源 docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md

+
+ + diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md b/docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md new file mode 100644 index 0000000..b671ec3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md @@ -0,0 +1,2176 @@ +# pay v2 · P2 Provider 抽象 + 一次性收款管线 + webhook v2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`(重点 §4 Provider 接口 / render_type、§5 webhook v2 event_type、§3 Order/Attempt/Account)。 +> **前置计划:** `docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md`(P1 已落地:`internal/model/v2.go`、`internal/store/order.go`、`internal/money`、`internal/accounts`)。本计划是 P2,直接复用 P1 接口,不重造。 + +**Goal:** 在 P1 数据地基上,建立 **Provider 渠道抽象 + 注册表**、一个 **fake provider**(测试用)、**一次性收款管线**(下单→选 provider→建单+尝试→返回 render_type/payload;查单/重试/取消)、**统一开通/入账管线**(webhook 与 query 都归一化成 `PaidEvent` → 定位订单 → 幂等 → 金额/币种核对 → 复用 `MarkAttemptPaid` → 触发对业务方 webhook)、以及 **webhook v2**(pay→业务方,payload 带 `event_type`,HMAC 双向签名,重试兜底 outbox)。真实渠道 adapter 在 P3。 + +**Architecture:** 干净 v2:Provider 接口把渠道差异封死在 `create/verify_callback/query`(设计 §4.1),客户端只认 6 个 `render_type`(设计 §4.2)。管线层 `internal/gateway` 面向 `provider.Registry` + `store.OrderStore`(P1)+ `accounts.Registry`(P1),不 import 具体渠道。入账用 P1 的 `MarkAttemptPaid`(条件 UPDATE + RowsAffected 幂等)。对业务方 webhook 走 **outbox 表 + 后台重试**(unique(out_trade_no,event_type) 幂等入队),沿用 v1 `notifyBizByOutTradeNo` 的 HMAC 签名头范式(`util.HMACSign`)。 + +**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite · `internal/util`(HMACSign/HMACVerify/RespondError/RespondSuccess/NewOutTradeNo)。 + +## Global Constraints + +- **复用 P1,不重造**:金额 `AmountMinor int64 + Currency`(`internal/money`);订单/尝试模型 `model.OrderV2`/`model.Attempt` + `V2` 后缀状态常量(`model.OrderPendingV2`/`OrderPaidV2`/`OrderCanceledV2`、`model.AttemptPending`/`AttemptPaid`/`AttemptExpired`);数据访问 `store.OrderStore`(`CreateOrder`/`CreateAttempt`/`MarkAttemptPaid`/`CancelOrder`/`ListOrders`);账户 `accounts.Registry.EnabledFor` + `config.AccountConfig`。 +- **金额权威在 pay**:客户端传 `sku`(product biz_code),金额由 `ProductResolver` 服务端解析,**绝不接受裸传金额**(设计 §3.1)。金额一律 int64 最小单位 + 币种码,禁 float。 +- **幂等**:入账用 P1 `MarkAttemptPaid` 的条件 UPDATE + `RowsAffected`(order=pending 原子守卫,"一单 N 渠道只一个成功")。webhook 入队用 `INSERT ... ON CONFLICT DO NOTHING`(unique(out_trade_no,event_type))。 +- **金额/币种核对**:入账前校验 `PaidEvent.PaidCurrency == attempt.Currency` 且 `PaidAmountMinor >= attempt.AmountMinor`(允许 crypto 多付,拒绝少付/错币种)。 +- **Provider 中性**:`internal/gateway` 与 `internal/provider` 不 import 任何具体渠道;真实渠道在 P3 各自实现 `provider.Provider` 并 `Register`。 +- **签名沿用双向 HMAC**:`util.HMACSign(secret, system, ts, nonce, rawBody)`,±5min 窗口 + nonce(设计 §5),与 v1 `notifyBizByOutTradeNo` / `verifyBizSign` 一致。 +- 每步 `go build ./...` 通过;测试 `go test ./...`(全部 `:memory:` sqlite + `httptest`,**免 docker**)。GORM `AutoMigrate`,模型内嵌 `model.Base`。 +- 每任务严格 bite-sized TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。禁占位。 + +--- + +### Task 1: Provider 接口 + 核心类型 + 注册表 + +**Files:** +- Create: `internal/provider/provider.go`(接口 + Session/PaidEvent/Capabilities/CreateRequest/CallbackInput + render_type/status 枚举 + Registry) +- Test: `internal/provider/provider_test.go` + +**Interfaces:** +- Consumes: `config.AccountConfig`(P1)。 +- Produces: + - `type RenderType string` + 6 常量 `RenderRedirect/RenderQR/RenderCryptoAddress/RenderNativePay/RenderSDKHandoff/RenderIAPReceipt`(设计 §4.2)。 + - `type PaidStatus string` + `PaidPending/PaidSucceeded/PaidFailed`。 + - `type Capabilities struct{ RenderTypes []RenderType; SupportsRefund/SupportsRecurring bool; RecurringKind string; SettleCurrencies/Regions []string }`。 + - `type CreateRequest struct{ OutTradeNo, Subject string; AmountMinor int64; Currency string; Account config.AccountConfig; ReturnURL string; Metadata map[string]string }`。 + - `type Session struct{ ProviderRef string; RenderType RenderType; Payload map[string]any; ExpiresAt *time.Time }`。 + - `type CallbackInput struct{ Raw []byte; Headers, Query map[string]string }`。 + - `type PaidEvent struct{ ProviderRef string; Status PaidStatus; PaidAmountMinor int64; PaidCurrency string; Raw string }`。 + - `type Provider interface{ Method() string; Capabilities() Capabilities; Create(ctx,CreateRequest)(*Session,error); VerifyCallback(ctx,CallbackInput)(*PaidEvent,error); Query(ctx,providerRef string)(*PaidEvent,error) }`。 + - 可选扩展接口 `RefundingProvider`(P4)、`RecurringProvider`(P8),供类型断言探测。 + - `type Registry struct{...}` · `NewRegistry()` · `Register(Provider)` · `Get(method)(Provider,error)` · `Methods()[]string`;`ErrUnknownMethod`。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/provider_test.go`: +```go +package provider_test + +import ( + "context" + "errors" + "testing" + + "github.com/wangjia/pay/internal/provider" +) + +// stubProvider 最小实现,驱动 Provider 接口 + Registry 成型。 +type stubProvider struct{ method string } + +func (s stubProvider) Method() string { return s.method } +func (s stubProvider) Capabilities() provider.Capabilities { return provider.Capabilities{RenderTypes: []provider.RenderType{provider.RenderQR}} } +func (s stubProvider) Create(context.Context, provider.CreateRequest) (*provider.Session, error) { + return &provider.Session{ProviderRef: "R-1", RenderType: provider.RenderQR}, nil +} +func (s stubProvider) VerifyCallback(context.Context, provider.CallbackInput) (*provider.PaidEvent, error) { + return &provider.PaidEvent{ProviderRef: "R-1", Status: provider.PaidSucceeded}, nil +} +func (s stubProvider) Query(context.Context, string) (*provider.PaidEvent, error) { + return &provider.PaidEvent{ProviderRef: "R-1", Status: provider.PaidPending}, nil +} + +func TestRegistryRegisterGet(t *testing.T) { + r := provider.NewRegistry() + r.Register(stubProvider{method: "alipay"}) + r.Register(stubProvider{method: "crypto"}) + + p, err := r.Get("crypto") + if err != nil || p.Method() != "crypto" { + t.Fatalf("Get crypto = %v, %v", p, err) + } + if _, err := r.Get("nope"); !errors.Is(err, provider.ErrUnknownMethod) { + t.Fatalf("未知 method 应返回 ErrUnknownMethod, got %v", err) + } + if got := r.Methods(); len(got) != 2 || got[0] != "alipay" || got[1] != "crypto" { + t.Fatalf("Methods 应按字典序返回 [alipay crypto], got %v", got) + } +} + +func TestSessionAndCaps(t *testing.T) { + var _ provider.Provider = stubProvider{} // 编译期断言 stub 满足接口 + caps := stubProvider{}.Capabilities() + if len(caps.RenderTypes) != 1 || caps.RenderTypes[0] != provider.RenderQR { + t.Fatalf("caps = %+v", caps) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/ -v` +Expected: 编译失败 —— `provider` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/provider/provider.go`: +```go +// Package provider abstracts a payment channel behind a uniform interface: +// create → {render_type, payload}, verify_callback / query → normalized PaidEvent. +// Channel-specific quirks (RSA/cert/on-chain confirmations/receipt checks) are +// sealed inside each Provider's VerifyCallback; the pipeline stays channel-neutral. +package provider + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/wangjia/pay/config" +) + +// RenderType — 客户端只认的 6 种付款意图形态(设计 §4.2),不含任何 UI。 +type RenderType string + +const ( + RenderRedirect RenderType = "redirect" + RenderQR RenderType = "qr" + RenderCryptoAddress RenderType = "crypto_address" + RenderNativePay RenderType = "native_pay" + RenderSDKHandoff RenderType = "sdk_handoff" + RenderIAPReceipt RenderType = "iap_receipt" +) + +// PaidStatus — verify_callback / query 归一化后的支付状态。 +type PaidStatus string + +const ( + PaidPending PaidStatus = "pending" + PaidSucceeded PaidStatus = "succeeded" + PaidFailed PaidStatus = "failed" +) + +// Capabilities — 渠道能力自述(设计 §4.1 capabilities())。 +type Capabilities struct { + RenderTypes []RenderType + SupportsRefund bool + SupportsRecurring bool + RecurringKind string // token_offsession/gateway_scheduled/store_managed/none + SettleCurrencies []string + Regions []string +} + +// CreateRequest — Provider.Create 入参:pay 已定金额/币种/账户,Provider 拿去渠道建单。 +type CreateRequest struct { + OutTradeNo string + Subject string + AmountMinor int64 + Currency string + Account config.AccountConfig // 选中的收款账户(含 CredentialEnvPrefix) + ReturnURL string + Metadata map[string]string +} + +// Session — Provider.Create 产出:渠道单号 + 付款意图数据(render_type + payload)。 +type Session struct { + ProviderRef string + RenderType RenderType + Payload map[string]any + ExpiresAt *time.Time +} + +// CallbackInput — 渠道原始回调(HTTP body/头/query),由各 Provider 自行解析验签。 +type CallbackInput struct { + Raw []byte + Headers map[string]string + Query map[string]string +} + +// PaidEvent — verify_callback / query 的统一产出(设计 §4.1 → {order_ref,status,paid_amount})。 +type PaidEvent struct { + ProviderRef string + Status PaidStatus + PaidAmountMinor int64 + PaidCurrency string + Raw string +} + +var ( + ErrUnknownMethod = errors.New("provider: unknown method") + ErrNotSupported = errors.New("provider: capability not supported") +) + +// Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。 +type Provider interface { + Method() string + Capabilities() Capabilities + Create(ctx context.Context, req CreateRequest) (*Session, error) + VerifyCallback(ctx context.Context, in CallbackInput) (*PaidEvent, error) + Query(ctx context.Context, providerRef string) (*PaidEvent, error) +} + +// RefundingProvider — 可选:支持渠道退款的 Provider 额外实现(P4;不支持则 capabilities=false)。 +type RefundingProvider interface { + Provider + Refund(ctx context.Context, providerRef string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error) +} + +// RecurringProvider — 可选:支持自动续订(P8,设计 §5.1 4 类 kind)。 +type RecurringProvider interface { + Provider + CreateAgreement(ctx context.Context, req CreateRequest) (agreementRef string, err error) + Charge(ctx context.Context, agreementRef string, amountMinor int64, currency string) (*PaidEvent, error) + CancelAgreement(ctx context.Context, agreementRef string) error +} + +// Registry — 方法名 → Provider(设计 §2 Provider adapter 注册表)。启动期注册,运行期只读。 +type Registry struct{ providers map[string]Provider } + +func NewRegistry() *Registry { return &Registry{providers: map[string]Provider{}} } + +func (r *Registry) Register(p Provider) { r.providers[p.Method()] = p } + +func (r *Registry) Get(method string) (Provider, error) { + p, ok := r.providers[method] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, method) + } + return p, nil +} + +// Methods 返回已注册方法名(字典序,供 GET /methods 下发已启用渠道)。 +func (r *Registry) Methods() []string { + out := make([]string, 0, len(r.providers)) + for m := range r.providers { + out = append(out, m) + } + sort.Strings(out) + return out +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/ -v` +Expected: `TestRegistryRegisterGet` 与 `TestSessionAndCaps` PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/provider.go internal/provider/provider_test.go +git commit -m "feat(v2): Provider 渠道抽象接口 + 6 render_type + PaidEvent + 注册表" +``` + +--- + +### Task 2: fake provider(测试用,验证管线;真渠道在 P3) + +**Files:** +- Create: `internal/provider/fake/fake.go`(**可构建包**,非 `_test`,供管线测试 import) +- Test: `internal/provider/fake/fake_test.go` + +**Interfaces:** +- Consumes: `provider.*`(Task 1)。 +- Produces: + - `type Provider struct{...}` · `func New() *Provider`(实现 `provider.Provider`,`Method()=="fake"`,render_type=`crypto_address`)。 + - `Create` 确定性产出 `provider_ref = "FAKE-"+OutTradeNo`,payload 含 address/amount_minor/currency。 + - `VerifyCallback` 解析 JSON `{provider_ref,status,amount_minor,currency}` → `PaidEvent`。 + - `Query` 返回预置结果(默认 pending);`SetQueryResult(providerRef, PaidEvent)` 测试 seam(模拟查单命中)。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/fake/fake_test.go`: +```go +package fake_test + +import ( + "context" + "testing" + + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/fake" +) + +func TestFakeCreateAndVerify(t *testing.T) { + var _ provider.Provider = fake.New() // 编译期断言满足接口 + + p := fake.New() + ctx := context.Background() + + sess, err := p.Create(ctx, provider.CreateRequest{OutTradeNo: "PAY-1", AmountMinor: 29990000, Currency: "USDT"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.ProviderRef != "FAKE-PAY-1" || sess.RenderType != provider.RenderCryptoAddress { + t.Fatalf("session = %+v", sess) + } + + ev, err := p.VerifyCallback(ctx, provider.CallbackInput{ + Raw: []byte(`{"provider_ref":"FAKE-PAY-1","status":"succeeded","amount_minor":29990000,"currency":"USDT"}`), + }) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.ProviderRef != "FAKE-PAY-1" || ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 29990000 { + t.Fatalf("event = %+v", ev) + } +} + +func TestFakeQueryPreset(t *testing.T) { + p := fake.New() + ctx := context.Background() + + ev, _ := p.Query(ctx, "FAKE-PAY-2") + if ev.Status != provider.PaidPending { + t.Fatalf("默认应 pending, got %v", ev.Status) + } + + p.SetQueryResult("FAKE-PAY-2", provider.PaidEvent{ + ProviderRef: "FAKE-PAY-2", Status: provider.PaidSucceeded, PaidAmountMinor: 100, PaidCurrency: "USDT", + }) + ev2, _ := p.Query(ctx, "FAKE-PAY-2") + if ev2.Status != provider.PaidSucceeded || ev2.PaidAmountMinor != 100 { + t.Fatalf("预置后应 succeeded, got %+v", ev2) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/fake/ -v` +Expected: 编译失败 —— `fake` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/provider/fake/fake.go`: +```go +// Package fake is an in-process Provider used to exercise the pay v2 pipeline +// end-to-end without any real channel. Real adapters (crypto/alipay/stripe) land +// in P3. Deterministic: provider_ref = "FAKE-"+OutTradeNo; render_type = crypto_address. +package fake + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/wangjia/pay/internal/provider" +) + +type Provider struct { + mu sync.Mutex + queryResults map[string]provider.PaidEvent +} + +func New() *Provider { return &Provider{queryResults: map[string]provider.PaidEvent{}} } + +func (p *Provider) Method() string { return "fake" } + +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderCryptoAddress}, + SupportsRefund: false, + SettleCurrencies: []string{"USDT"}, + Regions: []string{"global"}, + } +} + +func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + exp := time.Now().Add(15 * time.Minute) + return &provider.Session{ + ProviderRef: "FAKE-" + req.OutTradeNo, + RenderType: provider.RenderCryptoAddress, + Payload: map[string]any{ + "address": "TFake" + req.Account.AccountID + req.OutTradeNo, + "amount_minor": req.AmountMinor, + "currency": req.Currency, + }, + ExpiresAt: &exp, + }, nil +} + +// VerifyCallback 解析测试注入的 JSON 回调体成归一化 PaidEvent。 +func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { + var b struct { + ProviderRef string `json:"provider_ref"` + Status string `json:"status"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + } + if err := json.Unmarshal(in.Raw, &b); err != nil { + return nil, err + } + if b.ProviderRef == "" { + return nil, errors.New("fake: missing provider_ref") + } + return &provider.PaidEvent{ + ProviderRef: b.ProviderRef, + Status: provider.PaidStatus(b.Status), + PaidAmountMinor: b.AmountMinor, + PaidCurrency: b.Currency, + Raw: string(in.Raw), + }, nil +} + +func (p *Provider) Query(_ context.Context, providerRef string) (*provider.PaidEvent, error) { + p.mu.Lock() + defer p.mu.Unlock() + if ev, ok := p.queryResults[providerRef]; ok { + e := ev + return &e, nil + } + return &provider.PaidEvent{ProviderRef: providerRef, Status: provider.PaidPending}, nil +} + +// SetQueryResult primes Query to report a specific event (test seam). +func (p *Provider) SetQueryResult(providerRef string, ev provider.PaidEvent) { + p.mu.Lock() + defer p.mu.Unlock() + p.queryResults[providerRef] = ev +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/... -v` +Expected: `provider` 与 `provider/fake` 两包 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/fake/ +git commit -m "feat(v2): fake provider(确定性,验证收款管线;真渠道 P3)" +``` + +--- + +### Task 3: OrderStore 扩展 — 按 provider_ref 定位尝试 / 取单 / 列待处理 / 弃过期尝试 + +**Files:** +- Create: `internal/store/order_query.go`(扩展查询/状态方法 + 哨兵错误) +- Test: `internal/store/order_query_test.go` + +**Interfaces:** +- Consumes: `model.OrderV2`/`model.Attempt` + 状态枚举(P1);`model.OpenTestDB`。 +- Produces(挂 `*OrderStore`): + - `var ErrOrderNotFound, ErrAttemptNotFound = errors.New(...)` + - `GetOrder(outTradeNo string) (*model.OrderV2, error)` — 未命中返回 `ErrOrderNotFound`。 + - `AttemptByProviderRef(providerRef string) (*model.Attempt, error)` — 由裸 provider_ref 反查出 attempt(含 OutTradeNo+Channel),未命中 `ErrAttemptNotFound`。 + - `ListAttemptsByStatus(status model.AttemptStatus, limit int) ([]model.Attempt, error)` — 供查单兜底扫 pending。 + - `ExpirePendingAttempts(outTradeNo string) (int64, error)` — retry 前把该单 pending 尝试置 expired,返回受影响数。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/order_query_test.go`: +```go +package store_test + +import ( + "errors" + "testing" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" +) + +func TestGetOrderAndAttemptByRef(t *testing.T) { + s := store.NewOrderStore(model.OpenTestDB(t)) + seedOrder(t, s, "PAY-Q1") // 复用 order_test.go 的 seedOrder(建 order + attempt provider_ref="P-PAY-Q1") + + o, err := s.GetOrder("PAY-Q1") + if err != nil || o.OutTradeNo != "PAY-Q1" { + t.Fatalf("GetOrder = %+v, %v", o, err) + } + if _, err := s.GetOrder("NOPE"); !errors.Is(err, store.ErrOrderNotFound) { + t.Fatalf("缺单应 ErrOrderNotFound, got %v", err) + } + + att, err := s.AttemptByProviderRef("P-PAY-Q1") + if err != nil || att.OutTradeNo != "PAY-Q1" || att.Channel != "crypto" { + t.Fatalf("AttemptByProviderRef = %+v, %v", att, err) + } + if _, err := s.AttemptByProviderRef("P-UNKNOWN"); !errors.Is(err, store.ErrAttemptNotFound) { + t.Fatalf("缺尝试应 ErrAttemptNotFound, got %v", err) + } +} + +func TestListPendingAndExpire(t *testing.T) { + s := store.NewOrderStore(model.OpenTestDB(t)) + seedOrder(t, s, "PAY-Q2") + + pending, err := s.ListAttemptsByStatus(model.AttemptPending, 10) + if err != nil || len(pending) != 1 { + t.Fatalf("ListAttemptsByStatus pending = %d, %v", len(pending), err) + } + + n, err := s.ExpirePendingAttempts("PAY-Q2") + if err != nil || n != 1 { + t.Fatalf("ExpirePendingAttempts = %d, %v", n, err) + } + pending2, _ := s.ListAttemptsByStatus(model.AttemptPending, 10) + if len(pending2) != 0 { + t.Fatalf("弃过期后应无 pending, got %d", len(pending2)) + } + _ = time.Now +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ -run 'GetOrderAndAttempt|ListPendingAndExpire' -v` +Expected: 编译失败 —— 方法/哨兵未定义。 + +- [ ] **Step 3: 写实现** + +`internal/store/order_query.go`: +```go +package store + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" +) + +var ( + ErrOrderNotFound = errors.New("store: order not found") + ErrAttemptNotFound = errors.New("store: attempt not found") +) + +// GetOrder returns an order by out_trade_no. +func (s *OrderStore) GetOrder(outTradeNo string) (*model.OrderV2, error) { + var o model.OrderV2 + if err := s.db.Where("out_trade_no = ?", outTradeNo).First(&o).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrOrderNotFound + } + return nil, fmt.Errorf("store.GetOrder: %w", err) + } + return &o, nil +} + +// AttemptByProviderRef resolves an attempt from a bare provider_ref, so settlement +// can recover out_trade_no + channel from a callback/query that only carries the ref. +func (s *OrderStore) AttemptByProviderRef(providerRef string) (*model.Attempt, error) { + var a model.Attempt + if err := s.db.Where("provider_ref = ?", providerRef).First(&a).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAttemptNotFound + } + return nil, fmt.Errorf("store.AttemptByProviderRef: %w", err) + } + return &a, nil +} + +// ListAttemptsByStatus lists attempts in a status (for query-sync fallback). +func (s *OrderStore) ListAttemptsByStatus(status model.AttemptStatus, limit int) ([]model.Attempt, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + var out []model.Attempt + if err := s.db.Where("status = ?", status).Order("id ASC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListAttemptsByStatus: %w", err) + } + return out, nil +} + +// ExpirePendingAttempts marks all pending attempts of an order as expired +// (used before a retry spawns a fresh attempt). Order status is untouched. +func (s *OrderStore) ExpirePendingAttempts(outTradeNo string) (int64, error) { + res := s.db.Model(&model.Attempt{}). + Where("out_trade_no = ? AND status = ?", outTradeNo, model.AttemptPending). + Update("status", model.AttemptExpired) + if res.Error != nil { + return 0, fmt.Errorf("store.ExpirePendingAttempts: %w", res.Error) + } + return res.RowsAffected, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ -v` +Expected: 新增两测试 + P1 既有 `TestMarkPaidIdempotentAndCancel`/`TestMarkAttemptPaidProviderRefMismatchRollsBack` 全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/store/order_query.go internal/store/order_query_test.go +git commit -m "feat(v2): OrderStore 扩展 GetOrder/AttemptByProviderRef/ListByStatus/ExpirePending" +``` + +--- + +### Task 4: 一次性收款管线 — 下单 / 取单 / 重试 / 取消 + +**Files:** +- Create: `internal/gateway/gateway.go`(Gateway + 接口 ProductResolver/WebhookEnqueuer + CreateOrder/GetOrder/RetryOrder/CancelOrder) +- Create: `internal/gateway/product.go`(`DBProductResolver` 默认实现:model.Product 价 string 元 → money int64) +- Test: `internal/gateway/gateway_test.go` +- Test: `internal/gateway/product_test.go` + +**Interfaces:** +- Consumes: `provider.Registry`(T1)、`store.OrderStore`(P1+T3)、`accounts.Registry`(P1)、`money`(P1)、`util.NewOutTradeNo`。 +- Produces: + - `type ProductResolver interface{ Resolve(sku string) (amountMinor int64, currency, subject, bizCode string, err error) }` + - `type WebhookEnqueuer interface{ Enqueue(outTradeNo, bizSystem, eventType string, data map[string]any) error }` + - `type Gateway struct{...}` · `func New(orders *store.OrderStore, providers *provider.Registry, accts *accounts.Registry, products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway` + - `type CreateOrderInput struct{ SKU, Method, BizSystem, BizRef, ReturnURL string }` + - `type SessionView struct{ RenderType string; Payload map[string]any; ExpiresAt *time.Time }` + - `type OrderResult struct{ OrderNo string; Session SessionView }` + - `CreateOrder(ctx, CreateOrderInput) (*OrderResult, error)` + - `type OrderStatusView struct{ OrderNo, Status, Subject, Currency string; AmountMinor int64; PaidAt *time.Time }` + - `GetOrder(outTradeNo string) (*OrderStatusView, error)` + - `RetryOrder(ctx, outTradeNo, method string) (*OrderResult, error)`(order 须 pending;弃旧 pending 尝试 → 新建尝试) + - `CancelOrder(outTradeNo string) (bool, error)` + - 错误:`ErrProductNotFound`、`ErrNoAccount`、`ErrOrderNotPending`。 + - `DBProductResolver`(默认币种 CNY;按 biz_code 查 active product,`money.Parse(price,cur)`)。 + +- [ ] **Step 1: 写失败测试** + +`internal/gateway/gateway_test.go`: +```go +package gateway_test + +import ( + "context" + "testing" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/fake" + "github.com/wangjia/pay/internal/store" +) + +// --- 测试替身 --- + +type stubResolver struct{} + +func (stubResolver) Resolve(sku string) (int64, string, string, string, error) { + if sku != "pro_year" { + return 0, "", "", "", gateway.ErrProductNotFound + } + return 29990000, "USDT", "Pro 年付", "pro_year", nil +} + +type spyEnqueuer struct { + calls []map[string]any +} + +func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType string, data map[string]any) error { + s.calls = append(s.calls, data) + return nil +} + +func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *store.OrderStore) { + t.Helper() + orders := store.NewOrderStore(model.OpenTestDB(t)) + preg := provider.NewRegistry() + fp := fake.New() + preg.Register(fp) + // fake 注册在 method "fake";账户按 channel="fake" region="global" 配。 + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + }) + spy := &spyEnqueuer{} + g := gateway.New(orders, preg, areg, stubResolver{}, spy, "global") + return g, fp, spy, orders +} + +func TestCreateOrderPipeline(t *testing.T) { + g, _, _, orders := newGateway(t) + res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{ + SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1", + }) + if err != nil { + t.Fatalf("CreateOrder: %v", err) + } + if res.OrderNo == "" || res.Session.RenderType != string(provider.RenderCryptoAddress) { + t.Fatalf("result = %+v", res) + } + if res.Session.Payload["currency"] != "USDT" { + t.Fatalf("payload = %+v", res.Session.Payload) + } + // 落库:order pending + attempt pending 带 provider_ref。 + o, err := orders.GetOrder(res.OrderNo) + if err != nil || o.Status != model.OrderPendingV2 || o.AmountMinor != 29990000 { + t.Fatalf("order = %+v, %v", o, err) + } + att, err := orders.AttemptByProviderRef("FAKE-" + res.OrderNo) + if err != nil || att.Channel != "fake" || att.AccountID != "fake-a1" || att.Status != model.AttemptPending { + t.Fatalf("attempt = %+v, %v", att, err) + } +} + +func TestCreateOrderErrors(t *testing.T) { + g, _, _, _ := newGateway(t) + ctx := context.Background() + if _, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "nope", Method: "fake"}); err != gateway.ErrProductNotFound { + t.Fatalf("未知 sku 应 ErrProductNotFound, got %v", err) + } + if _, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "ghost"}); err == nil { + t.Fatalf("未知 method 应报错") + } +} + +func TestRetryAndCancel(t *testing.T) { + g, _, _, orders := newGateway(t) + ctx := context.Background() + res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) + + // retry:弃旧尝试 + 建新尝试(order 仍 pending)。 + r2, err := g.RetryOrder(ctx, res.OrderNo, "fake") + if err != nil || r2.OrderNo != res.OrderNo { + t.Fatalf("retry = %+v, %v", r2, err) + } + pend, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + if len(pend) != 1 { + t.Fatalf("retry 后应恰 1 个 pending 尝试, got %d", len(pend)) + } + + // cancel pending → true;再 cancel → false。 + ok, err := g.CancelOrder(res.OrderNo) + if err != nil || !ok { + t.Fatalf("cancel = %v, %v", ok, err) + } + if ok2, _ := g.CancelOrder(res.OrderNo); ok2 { + t.Fatalf("已取消单再取消应 false") + } + // canceled 单不可 retry。 + if _, err := g.RetryOrder(ctx, res.OrderNo, "fake"); err != gateway.ErrOrderNotPending { + t.Fatalf("canceled 单 retry 应 ErrOrderNotPending, got %v", err) + } +} +``` + +`internal/gateway/product_test.go`: +```go +package gateway_test + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" +) + +func TestDBProductResolver(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:prodtest?mode=memory&cache=shared"), + &gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&model.Product{}); err != nil { + t.Fatalf("migrate: %v", err) + } + db.Create(&model.Product{Name: "标准年付", Price: "299.00", BizCode: "annual_standard", Active: true}) + + r := gateway.NewDBProductResolver(db, "CNY") + minor, cur, subject, bizCode, err := r.Resolve("annual_standard") + if err != nil || minor != 29900 || cur != "CNY" || subject != "标准年付" || bizCode != "annual_standard" { + t.Fatalf("resolve = %d %s %s %s %v", minor, cur, subject, bizCode, err) + } + if _, _, _, _, err := r.Resolve("ghost"); err != gateway.ErrProductNotFound { + t.Fatalf("缺套餐应 ErrProductNotFound, got %v", err) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -v` +Expected: 编译失败 —— `gateway` 包不存在。 + +- [ ] **Step 3: 写 gateway 实现** + +`internal/gateway/gateway.go`: +```go +// Package gateway is the channel-neutral payment pipeline: it turns a client +// {sku, method} into an authoritative Order + a payment Session (render_type + +// payload), and settles callbacks/queries into paid + a business webhook. It +// depends only on provider.Registry, store.OrderStore (P1), accounts.Registry +// (P1) — never on a concrete channel. +package gateway + +import ( + "context" + "errors" + "time" + + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +var ( + ErrProductNotFound = errors.New("gateway: product not found") + ErrNoAccount = errors.New("gateway: no enabled account for method/region") + ErrOrderNotPending = errors.New("gateway: order not pending") +) + +// ProductResolver maps a client-facing SKU to the authoritative amount/currency. +// Amount authority lives in pay (设计 §3.1); the client never sends raw amounts. +type ProductResolver interface { + Resolve(sku string) (amountMinor int64, currency, subject, bizCode string, err error) +} + +// WebhookEnqueuer receives a domain payload to deliver to the business system. +type WebhookEnqueuer interface { + Enqueue(outTradeNo, bizSystem, eventType string, data map[string]any) error +} + +type Gateway struct { + orders *store.OrderStore + providers *provider.Registry + accounts *accounts.Registry + products ProductResolver + webhook WebhookEnqueuer + region string +} + +func New(orders *store.OrderStore, providers *provider.Registry, accts *accounts.Registry, + products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway { + return &Gateway{orders: orders, providers: providers, accounts: accts, + products: products, webhook: webhook, region: region} +} + +type CreateOrderInput struct { + SKU string + Method string + BizSystem string + BizRef string + ReturnURL string +} + +type SessionView struct { + RenderType string `json:"render_type"` + Payload map[string]any `json:"payload"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +type OrderResult struct { + OrderNo string `json:"order_no"` + Session SessionView `json:"session"` +} + +// CreateOrder resolves the product (authoritative amount), picks a provider + +// account, persists a pending Order + Attempt (P1 OrderStore), and returns the +// payment session {render_type, payload}. 加渠道不改 client(设计 §4.2)。 +func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderResult, error) { + amountMinor, currency, subject, _, err := g.products.Resolve(in.SKU) + if err != nil { + return nil, err // ErrProductNotFound + } + prov, err := g.providers.Get(in.Method) + if err != nil { + return nil, err // ErrUnknownMethod + } + accts := g.accounts.EnabledFor(in.Method, g.region) + if len(accts) == 0 { + return nil, ErrNoAccount + } + acct := accts[0] // 路由策略(round_robin/weighted/…)在 P5;P2 取首个 enabled。 + + outNo := util.NewOutTradeNo("pay") + if err := g.orders.CreateOrder(&model.OrderV2{ + OutTradeNo: outNo, BizSystem: in.BizSystem, BizRef: in.BizRef, + Subject: subject, AmountMinor: amountMinor, Currency: currency, + Status: model.OrderPendingV2, + }); err != nil { + return nil, err + } + + sess, err := prov.Create(ctx, provider.CreateRequest{ + OutTradeNo: outNo, Subject: subject, AmountMinor: amountMinor, + Currency: currency, Account: acct, ReturnURL: in.ReturnURL, + }) + if err != nil { + return nil, err + } + if err := g.orders.CreateAttempt(&model.Attempt{ + OutTradeNo: outNo, Channel: in.Method, AccountID: acct.AccountID, + Provider: prov.Method(), ProviderRef: sess.ProviderRef, + RenderType: string(sess.RenderType), AmountMinor: amountMinor, Currency: currency, + Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt, + }); err != nil { + return nil, err + } + + return &OrderResult{OrderNo: outNo, Session: SessionView{ + RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt, + }}, nil +} + +type OrderStatusView struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + Subject string `json:"subject"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + PaidAt *time.Time `json:"paid_at,omitempty"` +} + +func (g *Gateway) GetOrder(outTradeNo string) (*OrderStatusView, error) { + o, err := g.orders.GetOrder(outTradeNo) + if err != nil { + return nil, err // ErrOrderNotFound + } + return &OrderStatusView{ + OrderNo: o.OutTradeNo, Status: string(o.Status), Subject: o.Subject, + AmountMinor: o.AmountMinor, Currency: o.Currency, PaidAt: o.PaidAt, + }, nil +} + +// RetryOrder spawns a fresh attempt (possibly a different method) on a still-pending +// order; old pending attempts are expired. attempt 超时 ≠ order 关闭(设计 §3.2)。 +func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string) (*OrderResult, error) { + o, err := g.orders.GetOrder(outTradeNo) + if err != nil { + return nil, err + } + if o.Status != model.OrderPendingV2 { + return nil, ErrOrderNotPending + } + prov, err := g.providers.Get(method) + if err != nil { + return nil, err + } + accts := g.accounts.EnabledFor(method, g.region) + if len(accts) == 0 { + return nil, ErrNoAccount + } + acct := accts[0] + + if _, err := g.orders.ExpirePendingAttempts(outTradeNo); err != nil { + return nil, err + } + sess, err := prov.Create(ctx, provider.CreateRequest{ + OutTradeNo: outTradeNo, Subject: o.Subject, AmountMinor: o.AmountMinor, + Currency: o.Currency, Account: acct, + }) + if err != nil { + return nil, err + } + if err := g.orders.CreateAttempt(&model.Attempt{ + OutTradeNo: outTradeNo, Channel: method, AccountID: acct.AccountID, + Provider: prov.Method(), ProviderRef: sess.ProviderRef, + RenderType: string(sess.RenderType), AmountMinor: o.AmountMinor, Currency: o.Currency, + Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt, + }); err != nil { + return nil, err + } + return &OrderResult{OrderNo: outTradeNo, Session: SessionView{ + RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt, + }}, nil +} + +func (g *Gateway) CancelOrder(outTradeNo string) (bool, error) { + return g.orders.CancelOrder(outTradeNo) +} +``` + +> ⚠️ retry 会为同一 `provider_ref`(fake 恒为 `FAKE-`)再次 `CreateAttempt`,而 `Attempt` 有 `uniqueIndex(channel,provider_ref)`(P1)。真实渠道每次 create 返回不同 provider_ref,不冲突;**fake provider 的 retry 测试**须换 method 或让 fake 每次产不同 ref。上面 `TestRetryAndCancel` 里 retry 用同 method `fake` → 同 ref 会撞唯一键。**实现 Step 修正**:在 `fake.Create` 的 provider_ref 追加纳秒糊避免自撞:`"FAKE-" + req.OutTradeNo`(建单)与 retry 冲突。为让测试稳定,fake `Create` 改为 `provider_ref = "FAKE-" + req.OutTradeNo + "-" + strconv.FormatInt(time.Now().UnixNano(),36)`,并在 Task 2 测试里改断言为 `strings.HasPrefix(sess.ProviderRef, "FAKE-PAY-1")`。见下方 Step 3a。 + +- [ ] **Step 3a: 回改 fake provider 的 provider_ref 唯一化(避免 retry 撞唯一键)** + +改 `internal/provider/fake/fake.go` 的 `Create`: +```go +import ( + // ... 追加 + "strconv" + "strings" +) + +func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + exp := time.Now().Add(15 * time.Minute) + ref := "FAKE-" + req.OutTradeNo + "-" + strconv.FormatInt(time.Now().UnixNano(), 36) + return &provider.Session{ + ProviderRef: ref, + RenderType: provider.RenderCryptoAddress, + Payload: map[string]any{ + "address": "TFake" + req.Account.AccountID, + "amount_minor": req.AmountMinor, + "currency": req.Currency, + }, + ExpiresAt: &exp, + }, nil +} +``` +同步把 Task 2 `fake_test.go` 的断言改为前缀匹配: +```go + if !strings.HasPrefix(sess.ProviderRef, "FAKE-PAY-1") || sess.RenderType != provider.RenderCryptoAddress { + t.Fatalf("session = %+v", sess) + } +``` +(测试文件加 `import "strings"`。)并把本 Task `gateway_test.go` 里 `AttemptByProviderRef("FAKE-" + res.OrderNo)` 改为按 order 反查尝试: +```go + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + if len(atts) != 1 || atts[0].Channel != "fake" || atts[0].AccountID != "fake-a1" { + t.Fatalf("attempt = %+v", atts) + } +``` + +> 说明:此唯一化是 fake 专属(真实渠道天然每单不同 ref);记入 Self-Review。 + +- [ ] **Step 4: 写 DBProductResolver 实现** + +`internal/gateway/product.go`: +```go +package gateway + +import ( + "errors" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/money" +) + +// DBProductResolver resolves a SKU (product biz_code) against the products table. +// v1 Product.Price is a "元" string; we parse it into int64 minor units for the +// given settlement currency. 加币种维度到 product 是 P3+ 的事;P2 用单一默认币种。 +type DBProductResolver struct { + db *gorm.DB + currency string +} + +func NewDBProductResolver(db *gorm.DB, currency string) *DBProductResolver { + if currency == "" { + currency = "CNY" + } + return &DBProductResolver{db: db, currency: currency} +} + +func (r *DBProductResolver) Resolve(sku string) (int64, string, string, string, error) { + var p model.Product + err := r.db.Where("biz_code = ? AND active = ?", sku, true).First(&p).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, "", "", "", ErrProductNotFound + } + if err != nil { + return 0, "", "", "", err + } + minor, err := money.Parse(p.Price, r.currency) + if err != nil { + return 0, "", "", "", err + } + return minor, r.currency, p.Name, p.BizCode, nil +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ ./internal/provider/... -v` +Expected: gateway 4 测试 + provider/fake 测试全 PASS(fake 断言已改前缀)。 + +- [ ] **Step 6: 全量编译 + 测试** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/...` +Expected: 编译通过,全 `ok`。 + +- [ ] **Step 7: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/gateway/gateway.go internal/gateway/product.go internal/gateway/gateway_test.go internal/gateway/product_test.go internal/provider/fake/fake.go internal/provider/fake/fake_test.go +git commit -m "feat(v2): 一次性收款管线 CreateOrder/GetOrder/Retry/Cancel + DBProductResolver" +``` + +--- + +### Task 5: 统一开通/入账管线 — PaidEvent 归一 → 定位 → 幂等 → 金额核对 → 标付 → 入队 webhook + +**Files:** +- Create: `internal/gateway/settle.go`(Settle / HandleCallback / SyncPendingAttempts) +- Test: `internal/gateway/settle_test.go` + +**Interfaces:** +- Consumes: `provider.PaidEvent`、`store.OrderStore.AttemptByProviderRef/MarkAttemptPaid/GetOrder`(P1+T3)、`WebhookEnqueuer`。 +- Produces(挂 `*Gateway`): + - `type SettleResult string` + `SettleIgnored/SettleNotFound/SettleAmountMismatch/SettleDuplicate/SettleProcessed`。 + - `var ErrAmountMismatch = errors.New(...)` + - `Settle(ctx, ev *provider.PaidEvent) (SettleResult, error)` — 定位 attempt→order,核对币种/金额,幂等 `MarkAttemptPaid`,仅真正翻转时入队 `payment.succeeded`。 + - `HandleCallback(ctx, method string, in provider.CallbackInput) (SettleResult, error)` — provider.VerifyCallback → Settle。 + - `SyncPendingAttempts(ctx, limit int) (int, error)` — 扫 pending 尝试 → 各自 provider.Query → Settle(查单兜底,设计 §8「对账优先于实时」)。 + +- [ ] **Step 1: 写失败测试** + +`internal/gateway/settle_test.go`: +```go +package gateway_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" +) + +func attemptRef(t *testing.T, orders interface { + ListAttemptsByStatus(model.AttemptStatus, int) ([]model.Attempt, error) +}) string { + t.Helper() + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + if len(atts) == 0 { + t.Fatalf("无 pending 尝试") + } + return atts[0].ProviderRef +} + +func TestSettleHappyIdempotentAndWebhook(t *testing.T) { + g, _, spy, orders := newGateway(t) + ctx := context.Background() + res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) + ref := attemptRef(t, orders) + + ev := &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"} + got, err := g.Settle(ctx, ev) + if err != nil || got != gateway.SettleProcessed { + t.Fatalf("settle#1 = %v, %v", got, err) + } + // 订单已 paid + o, _ := orders.GetOrder(res.OrderNo) + if o.Status != model.OrderPaidV2 { + t.Fatalf("order 应 paid, got %v", o.Status) + } + // webhook 入队一次,payload 带 event_type + if len(spy.calls) != 1 || spy.calls[0]["event_type"] != "payment.succeeded" || spy.calls[0]["out_trade_no"] != res.OrderNo { + t.Fatalf("webhook calls = %+v", spy.calls) + } + + // 幂等:再 settle → duplicate,不重复入队 + got2, _ := g.Settle(ctx, ev) + if got2 != gateway.SettleDuplicate || len(spy.calls) != 1 { + t.Fatalf("settle#2 = %v, calls=%d", got2, len(spy.calls)) + } +} + +func TestSettleGuards(t *testing.T) { + g, _, spy, orders := newGateway(t) + ctx := context.Background() + g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) + ref := attemptRef(t, orders) + + // 未 succeeded → ignored + if got, _ := g.Settle(ctx, &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidPending}); got != gateway.SettleIgnored { + t.Fatalf("pending 应 ignored, got %v", got) + } + // 未知 ref → not_found + if got, _ := g.Settle(ctx, &provider.PaidEvent{ProviderRef: "GHOST", Status: provider.PaidSucceeded, PaidCurrency: "USDT", PaidAmountMinor: 1}); got != gateway.SettleNotFound { + t.Fatalf("未知 ref 应 not_found, got %v", got) + } + // 少付 → amount_mismatch + if got, err := g.Settle(ctx, &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidCurrency: "USDT", PaidAmountMinor: 1}); got != gateway.SettleAmountMismatch || err == nil { + t.Fatalf("少付应 amount_mismatch, got %v %v", got, err) + } + // 错币种 → amount_mismatch + if got, _ := g.Settle(ctx, &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidCurrency: "CNY", PaidAmountMinor: 29990000}); got != gateway.SettleAmountMismatch { + t.Fatalf("错币种应 amount_mismatch, got %v", got) + } + if len(spy.calls) != 0 { + t.Fatalf("守卫失败路径不应入队 webhook, got %d", len(spy.calls)) + } +} + +func TestHandleCallbackAndSync(t *testing.T) { + g, fp, _, orders := newGateway(t) + ctx := context.Background() + g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) + ref := attemptRef(t, orders) + + // 回调路径:fake.VerifyCallback 解析 JSON → Settle + body, _ := json.Marshal(map[string]any{"provider_ref": ref, "status": "succeeded", "amount_minor": 29990000, "currency": "USDT"}) + got, err := g.HandleCallback(ctx, "fake", provider.CallbackInput{Raw: body}) + if err != nil || got != gateway.SettleProcessed { + t.Fatalf("HandleCallback = %v, %v", got, err) + } + + // 查单兜底:另起一单,预置 query 命中 → SyncPendingAttempts 收敛 + res2, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-2"}) + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + ref2 := atts[0].ProviderRef + fp.SetQueryResult(ref2, provider.PaidEvent{ProviderRef: ref2, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}) + n, err := g.SyncPendingAttempts(ctx, 10) + if err != nil || n < 1 { + t.Fatalf("SyncPendingAttempts = %d, %v", n, err) + } + o2, _ := orders.GetOrder(res2.OrderNo) + if o2.Status != model.OrderPaidV2 { + t.Fatalf("查单兜底后 order 应 paid, got %v", o2.Status) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run 'Settle|HandleCallback' -v` +Expected: 编译失败 —— `Settle`/`SettleResult` 等未定义。 + +- [ ] **Step 3: 写实现** + +`internal/gateway/settle.go`: +```go +package gateway + +import ( + "context" + "errors" + "log" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/store" +) + +type SettleResult string + +const ( + SettleIgnored SettleResult = "ignored" // 非成功状态(pending/failed) + SettleNotFound SettleResult = "not_found" // provider_ref 无对应 attempt + SettleAmountMismatch SettleResult = "amount_mismatch" // 币种不符 / 少付 + SettleDuplicate SettleResult = "duplicate" // 订单已非 pending(幂等 no-op) + SettleProcessed SettleResult = "processed" // 本次真正翻转为 paid +) + +var ErrAmountMismatch = errors.New("gateway: paid amount/currency mismatch") + +// Settle normalizes a PaidEvent into activation: locate order via provider_ref → +// attempt → order (设计 §4「归一化 PaidEvent」), reconcile currency+amount, then +// idempotently mark paid via P1 MarkAttemptPaid (order=pending 原子守卫). Enqueues +// the payment.succeeded webhook only on a real flip (avoids duplicate delivery). +func (g *Gateway) Settle(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) { + if ev.Status != provider.PaidSucceeded { + return SettleIgnored, nil // 非成功状态:确认收到即可 + } + att, err := g.orders.AttemptByProviderRef(ev.ProviderRef) + if err != nil { + if errors.Is(err, store.ErrAttemptNotFound) { + return SettleNotFound, nil + } + return SettleNotFound, err + } + // 金额/币种核对:币种须一致,实付须 ≥ 应收(允许 crypto 多付,拒少付)。 + if ev.PaidCurrency != att.Currency || ev.PaidAmountMinor < att.AmountMinor { + return SettleAmountMismatch, ErrAmountMismatch + } + + flipped, err := g.orders.MarkAttemptPaid(att.OutTradeNo, att.Channel, ev.ProviderRef, time.Now()) + if err != nil { + return SettleNotFound, err + } + if !flipped { + return SettleDuplicate, nil // 已处理过 / 已取消 / 已过期 → 幂等 no-op + } + + g.enqueuePaymentSucceeded(att) + return SettleProcessed, nil +} + +// enqueuePaymentSucceeded 组 webhook 领域 payload 并入队(失败只记日志,不回滚入账)。 +func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt) { + o, err := g.orders.GetOrder(att.OutTradeNo) + if err != nil { + log.Printf("[settle] 入队 webhook 取单失败 %s: %v", att.OutTradeNo, err) + return + } + if o.BizSystem == "" { + return // 独立收款,无业务方回调 + } + paidAt := "" + if o.PaidAt != nil { + paidAt = o.PaidAt.Format(time.RFC3339) + } + data := map[string]any{ + "event_type": "payment.succeeded", + "out_trade_no": o.OutTradeNo, + "biz_system": o.BizSystem, + "biz_ref": o.BizRef, + "amount_minor": o.AmountMinor, + "currency": o.Currency, + "channel": att.Channel, + "paid_at": paidAt, + } + if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", data); err != nil { + log.Printf("[settle] 入队 webhook 失败 %s: %v", o.OutTradeNo, err) + } +} + +// HandleCallback runs a channel's raw callback through its Provider.VerifyCallback +// (验签/解析封死在渠道内) then settles the normalized event. +func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider.CallbackInput) (SettleResult, error) { + prov, err := g.providers.Get(method) + if err != nil { + return SettleNotFound, err + } + ev, err := prov.VerifyCallback(ctx, in) + if err != nil { + return SettleNotFound, err + } + return g.Settle(ctx, ev) +} + +// SyncPendingAttempts polls every pending attempt via its Provider.Query and +// settles hits — the query-based backstop for lost webhooks (设计 §8 对账优先). +// Returns how many attempts were newly settled to paid. +func (g *Gateway) SyncPendingAttempts(ctx context.Context, limit int) (int, error) { + atts, err := g.orders.ListAttemptsByStatus(model.AttemptPending, limit) + if err != nil { + return 0, err + } + settled := 0 + for i := range atts { + att := &atts[i] + prov, err := g.providers.Get(att.Channel) + if err != nil { + continue + } + ev, err := prov.Query(ctx, att.ProviderRef) + if err != nil || ev == nil { + continue + } + if res, _ := g.Settle(ctx, ev); res == SettleProcessed { + settled++ + } + } + return settled, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -v` +Expected: Settle/HandleCallback/Sync 三测试 + Task 4 四测试全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/gateway/settle.go internal/gateway/settle_test.go +git commit -m "feat(v2): 入账管线 Settle/HandleCallback/SyncPending(归一→定位→幂等→核对→标付→入队)" +``` + +--- + +### Task 6: webhook v2 — outbox 模型 + 幂等入队 store + Notifier(event_type + HMAC + 重试) + +**Files:** +- Create: `internal/model/webhook_delivery.go`(outbox 表 + unique(out_trade_no,event_type)) +- Modify: `internal/model/testdb.go`(`OpenTestDB` 追加 `&WebhookDelivery{}`) +- Create: `internal/store/webhook.go`(WebhookStore:EnqueueDelivery/ListUndelivered/MarkDelivered/MarkFailed) +- Create: `internal/webhook/notifier.go`(实现 `gateway.WebhookEnqueuer` + Deliver/DeliverPending/Start) +- Test: `internal/store/webhook_test.go` +- Test: `internal/webhook/notifier_test.go` + +**Interfaces:** +- Produces: + - `model.WebhookDelivery{ Base; OutTradeNo/EventType(uniqueIndex uq_delivery); BizSystem; Payload; Delivered bool; Attempts int; LastError string }`。 + - `store.WebhookStore` · `NewWebhookStore(db)` · `EnqueueDelivery(outTradeNo,bizSystem,eventType,payload string) error`(ON CONFLICT DO NOTHING 幂等)· `ListUndelivered(limit) ([]WebhookDelivery,error)` · `MarkDelivered(id uint64) error` · `MarkFailed(id uint64, errMsg string) error`。 + - `webhook.Notifier` · `NewNotifier(ws *store.WebhookStore, bizConfig func(string)(config.BizSystemConfig,bool)) *Notifier`。实现 `Enqueue(outTradeNo,bizSystem,eventType string, data map[string]any) error`(JSON 序列化 → EnqueueDelivery)· `DeliverPending(limit int) (int,error)` · `Start(interval time.Duration)`。 +- Consumes: `util.HMACSign`、`config.BizSystemConfig`。 + +- [ ] **Step 1: 写失败测试** + +`internal/store/webhook_test.go`: +```go +package store_test + +import ( + "testing" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" +) + +func TestWebhookOutboxEnqueueIdempotent(t *testing.T) { + ws := store.NewWebhookStore(model.OpenTestDB(t)) + + if err := ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", `{"a":1}`); err != nil { + t.Fatalf("enqueue#1: %v", err) + } + // 幂等:同 (out_trade_no,event_type) 再入队不新增行、不报错。 + if err := ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", `{"a":1}`); err != nil { + t.Fatalf("enqueue#2: %v", err) + } + list, _ := ws.ListUndelivered(10) + if len(list) != 1 { + t.Fatalf("应恰 1 行待投递, got %d", len(list)) + } + + if err := ws.MarkDelivered(list[0].ID); err != nil { + t.Fatalf("markDelivered: %v", err) + } + if again, _ := ws.ListUndelivered(10); len(again) != 0 { + t.Fatalf("投递后应 0 待投递, got %d", len(again)) + } +} + +func TestWebhookMarkFailed(t *testing.T) { + ws := store.NewWebhookStore(model.OpenTestDB(t)) + _ = ws.EnqueueDelivery("PAY-2", "jiu", "payment.succeeded", `{}`) + list, _ := ws.ListUndelivered(10) + if err := ws.MarkFailed(list[0].ID, "boom"); err != nil { + t.Fatalf("markFailed: %v", err) + } + again, _ := ws.ListUndelivered(10) + if len(again) != 1 || again[0].Attempts != 1 || again[0].LastError != "boom" { + t.Fatalf("失败后应仍待投递且 attempts=1, got %+v", again) + } +} +``` + +`internal/webhook/notifier_test.go`: +```go +package webhook_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" + "github.com/wangjia/pay/internal/webhook" +) + +func TestNotifierDeliversSignedEvent(t *testing.T) { + const secret = "shh-secret" + var gotBody []byte + var gotHeaders http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("SUCCESS")) + })) + defer srv.Close() + + ws := store.NewWebhookStore(model.OpenTestDB(t)) + bizCfg := func(system string) (config.BizSystemConfig, bool) { + if system == "pangolin" { + return config.BizSystemConfig{CallbackURL: srv.URL, Secret: secret}, true + } + return config.BizSystemConfig{}, false + } + n := webhook.NewNotifier(ws, bizCfg) + + // 经 Enqueuer 接口入队(gateway 就是这么调的)。 + err := n.Enqueue("PAY-1", "pangolin", "payment.succeeded", map[string]any{ + "event_type": "payment.succeeded", "out_trade_no": "PAY-1", "amount_minor": 29990000, "currency": "USDT", + }) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + sent, err := n.DeliverPending(10) + if err != nil || sent != 1 { + t.Fatalf("DeliverPending = %d, %v", sent, err) + } + + // 校验签名头(pay→业务方,双向 HMAC,业务方可同法验签)。 + sys := gotHeaders.Get("X-Pay-System") + ts := gotHeaders.Get("X-Pay-Timestamp") + nonce := gotHeaders.Get("X-Pay-Nonce") + sign := gotHeaders.Get("X-Pay-Sign") + if gotHeaders.Get("X-Pay-Event") != "payment.succeeded" { + t.Fatalf("缺 X-Pay-Event 头") + } + if !util.HMACVerify(secret, sign, sys, ts, nonce, string(gotBody)) { + t.Fatalf("签名校验失败") + } + // body 带 event_type + var m map[string]any + _ = json.Unmarshal(gotBody, &m) + if m["event_type"] != "payment.succeeded" || m["out_trade_no"] != "PAY-1" { + t.Fatalf("body = %s", gotBody) + } + // 已标投递:再投不重发 + if again, _ := n.DeliverPending(10); again != 0 { + t.Fatalf("已投递不应重发, got %d", again) + } +} + +func TestNotifierRetriesOnFailure(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusInternalServerError) // 业务方暂时挂 + })) + defer srv.Close() + + ws := store.NewWebhookStore(model.OpenTestDB(t)) + n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) { + return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x"}, true + }) + _ = n.Enqueue("PAY-3", "pangolin", "payment.succeeded", map[string]any{"event_type": "payment.succeeded"}) + + if sent, _ := n.DeliverPending(10); sent != 0 { + t.Fatalf("失败不应算投递成功, got %d", sent) + } + // 仍待投递,可被下一轮重试兜底 + pend, _ := ws.ListUndelivered(10) + if len(pend) != 1 || pend[0].Attempts != 1 { + t.Fatalf("失败后应留队重试, got %+v", pend) + } + if _, _ = n.DeliverPending(10); hits < 2 { + t.Fatalf("应重试第二次, hits=%d", hits) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/webhook/ -run Webhook -v; go test ./internal/webhook/ -v` +Expected: 编译失败 —— `WebhookDelivery`/`WebhookStore`/`webhook` 包未定义。 + +- [ ] **Step 3: 写 outbox 模型 + 迁移** + +`internal/model/webhook_delivery.go`: +```go +package model + +// WebhookDelivery 是 pay→业务方 webhook 的 outbox(v2)。unique(out_trade_no,event_type) +// 保证同一订单同一事件只入队一次(幂等);后台 Notifier 扫 Delivered=false 重试兜底。 +type WebhookDelivery struct { + Base + OutTradeNo string `gorm:"size:64;not null;uniqueIndex:uq_delivery" json:"out_trade_no"` + EventType string `gorm:"size:32;not null;uniqueIndex:uq_delivery" json:"event_type"` + BizSystem string `gorm:"index;size:32" json:"biz_system"` + Payload string `gorm:"type:text" json:"payload"` // 已序列化的领域 JSON(含 event_type) + Delivered bool `gorm:"index;default:false" json:"delivered"` + Attempts int `json:"attempts"` + LastError string `gorm:"size:255" json:"last_error,omitempty"` +} +``` + +改 `internal/model/testdb.go` 的 `AutoMigrate` 调用,追加 `&WebhookDelivery{}`: +```go + if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}, &WebhookDelivery{}); err != nil { +``` + +- [ ] **Step 4: 写 WebhookStore** + +`internal/store/webhook.go`: +```go +package store + +import ( + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/wangjia/pay/internal/model" +) + +type WebhookStore struct{ db *gorm.DB } + +func NewWebhookStore(db *gorm.DB) *WebhookStore { return &WebhookStore{db: db} } + +// EnqueueDelivery inserts an outbox row; a duplicate (out_trade_no,event_type) +// is a no-op (idempotent enqueue) via ON CONFLICT DO NOTHING. +func (s *WebhookStore) EnqueueDelivery(outTradeNo, bizSystem, eventType, payload string) error { + row := model.WebhookDelivery{ + OutTradeNo: outTradeNo, BizSystem: bizSystem, EventType: eventType, Payload: payload, + } + err := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "out_trade_no"}, {Name: "event_type"}}, + DoNothing: true, + }).Create(&row).Error + if err != nil { + return fmt.Errorf("store.EnqueueDelivery: %w", err) + } + return nil +} + +func (s *WebhookStore) ListUndelivered(limit int) ([]model.WebhookDelivery, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var out []model.WebhookDelivery + if err := s.db.Where("delivered = ?", false).Order("id ASC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListUndelivered: %w", err) + } + return out, nil +} + +func (s *WebhookStore) MarkDelivered(id uint64) error { + if err := s.db.Model(&model.WebhookDelivery{}).Where("id = ?", id). + Update("delivered", true).Error; err != nil { + return fmt.Errorf("store.MarkDelivered: %w", err) + } + return nil +} + +// MarkFailed increments attempts and records the last error, leaving the row +// undelivered for the next retry sweep. +func (s *WebhookStore) MarkFailed(id uint64, errMsg string) error { + if len(errMsg) > 255 { + errMsg = errMsg[:255] + } + if err := s.db.Model(&model.WebhookDelivery{}).Where("id = ?", id). + Updates(map[string]any{ + "attempts": gorm.Expr("attempts + 1"), + "last_error": errMsg, + }).Error; err != nil { + return fmt.Errorf("store.MarkFailed: %w", err) + } + return nil +} +``` + +- [ ] **Step 5: 写 Notifier** + +`internal/webhook/notifier.go`: +```go +// Package webhook delivers pay→business webhooks (v2, with event_type) from an +// outbox, signed with the shared HMAC scheme (双向验签,与 v1 notifyBiz 一致), +// with a background retry sweep as the backstop for lost/failed deliveries. +package webhook + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +// BizConfigFunc resolves a business system's callback URL + HMAC secret. +type BizConfigFunc func(system string) (config.BizSystemConfig, bool) + +type Notifier struct { + deliveries *store.WebhookStore + bizConfig BizConfigFunc + client *http.Client +} + +func NewNotifier(ws *store.WebhookStore, bizConfig BizConfigFunc) *Notifier { + return &Notifier{deliveries: ws, bizConfig: bizConfig, client: &http.Client{Timeout: 10 * time.Second}} +} + +// Enqueue implements gateway.WebhookEnqueuer: serialize the domain payload and +// idempotently persist it to the outbox (delivery happens async). +func (n *Notifier) Enqueue(outTradeNo, bizSystem, eventType string, data map[string]any) error { + body, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("webhook.Enqueue marshal: %w", err) + } + return n.deliveries.EnqueueDelivery(outTradeNo, bizSystem, eventType, string(body)) +} + +// DeliverPending flushes undelivered rows; returns how many succeeded this pass. +func (n *Notifier) DeliverPending(limit int) (int, error) { + rows, err := n.deliveries.ListUndelivered(limit) + if err != nil { + return 0, err + } + ok := 0 + for i := range rows { + if n.deliverOne(&rows[i]) { + ok++ + } + } + return ok, nil +} + +func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool { + cfg, found := n.bizConfig(d.BizSystem) + if !found || cfg.CallbackURL == "" { + _ = n.deliveries.MarkFailed(d.ID, "biz system not configured") + return false + } + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := uuid.NewString() + sign := util.HMACSign(cfg.Secret, d.BizSystem, ts, nonce, d.Payload) + + req, err := http.NewRequest(http.MethodPost, cfg.CallbackURL, bytes.NewReader([]byte(d.Payload))) + if err != nil { + _ = n.deliveries.MarkFailed(d.ID, err.Error()) + return false + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Pay-System", d.BizSystem) + req.Header.Set("X-Pay-Event", d.EventType) + req.Header.Set("X-Pay-Timestamp", ts) + req.Header.Set("X-Pay-Nonce", nonce) + req.Header.Set("X-Pay-Sign", sign) + + resp, err := n.client.Do(req) + if err != nil { + _ = n.deliveries.MarkFailed(d.ID, err.Error()) + return false + } + rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + // 约定:业务方返回 HTTP 200 且响应含 SUCCESS 视为受理(与 v1 一致)。 + if resp.StatusCode == http.StatusOK && strings.Contains(strings.ToUpper(string(rb)), "SUCCESS") { + _ = n.deliveries.MarkDelivered(d.ID) + return true + } + _ = n.deliveries.MarkFailed(d.ID, fmt.Sprintf("http %d: %s", resp.StatusCode, truncate(string(rb), 120))) + return false +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +// Start runs a background retry sweep (backstop for lost/failed webhooks). +func (n *Notifier) Start(interval time.Duration) { + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[webhook] retry sweep panic recovered: %v", r) + } + }() + t := time.NewTicker(interval) + defer t.Stop() + for range t.C { + if _, err := n.DeliverPending(50); err != nil { + log.Printf("[webhook] DeliverPending: %v", err) + } + } + }() +} +``` + +> `deliverOne` 形参类型是 `*store.WebhookDeliveryRow` —— 为不让 `webhook` 包直接依赖 `model`,在 `store` 暴露别名 `type WebhookDeliveryRow = model.WebhookDelivery`。在 `internal/store/webhook.go` 顶部加: +> ```go +> // WebhookDeliveryRow is the row type returned by WebhookStore (alias to the model). +> type WebhookDeliveryRow = model.WebhookDelivery +> ``` +> 并把 `ListUndelivered` 返回类型改为 `[]WebhookDeliveryRow`(与 `[]model.WebhookDelivery` 同一类型,测试里 `store.WebhookStore` 用法不变)。 + +- [ ] **Step 6: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/webhook/ ./internal/model/ -v` +Expected: outbox 幂等 / markFailed / Notifier 签名投递 / 重试 四测试 + 既有全 PASS。 + +- [ ] **Step 7: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/model/webhook_delivery.go internal/model/testdb.go internal/store/webhook.go internal/store/webhook_test.go internal/webhook/notifier.go internal/webhook/notifier_test.go +git commit -m "feat(v2): webhook v2 outbox + Notifier(event_type + HMAC 双向 + 重试兜底)" +``` + +--- + +### Task 7: HTTP 接线 — /v1 路由 + handler + 端到端联通 + main 装配 + +**Files:** +- Create: `internal/handler/gateway.go`(GatewayHandler:CreateOrder/GetStatus/Retry/Cancel/Callback) +- Modify: `internal/router/router.go`(加 `SetupV2`,挂 `/v1` 组) +- Modify: `main.go`(装配 provider 注册表 + gateway + notifier;`autoMigrate` 加 `&model.WebhookDelivery{}`) +- Test: `internal/handler/gateway_test.go`(gin `httptest` 端到端) + +**Interfaces:** +- Produces: + - `type GatewayHandler struct{...}` · `NewGatewayHandler(g *gateway.Gateway) *GatewayHandler`。 + - `router.SetupV2(r *gin.Engine, g *gateway.Gateway)` 挂:`POST /v1/orders`、`GET /v1/orders/:order_no`、`POST /v1/orders/:order_no/retry`、`POST /v1/orders/:order_no/cancel`、`POST /v1/callback/:method`。 +- Consumes: `gateway.Gateway`(T4/T5)、`util.RespondError/RespondSuccess`。业务方下单签名沿用 v1 `verifyBizSign` 范式(`biz_system` 非空即校验)。 + +- [ ] **Step 1: 写失败测试(端到端:下单→查→回调→已付)** + +`internal/handler/gateway_test.go`: +```go +package handler_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/fake" + "github.com/wangjia/pay/internal/router" + "github.com/wangjia/pay/internal/store" +) + +type nopEnqueuer struct{} + +func (nopEnqueuer) Enqueue(string, string, string, map[string]any) error { return nil } + +type oneResolver struct{} + +func (oneResolver) Resolve(sku string) (int64, string, string, string, error) { + return 29990000, "USDT", "Pro 年付", "pro_year", nil +} + +func buildEngine(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + orders := store.NewOrderStore(model.OpenTestDB(t)) + preg := provider.NewRegistry() + preg.Register(fake.New()) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + }) + g := gateway.New(orders, preg, areg, oneResolver{}, nopEnqueuer{}, "global") + r := gin.New() + router.SetupV2(r, g) + return r +} + +func do(t *testing.T, r *gin.Engine, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w, out +} + +func TestV2OrderLifecycle(t *testing.T) { + r := buildEngine(t) + + // 下单(独立收款,无 biz_system → 无需签名) + w, out := do(t, r, http.MethodPost, "/v1/orders", map[string]any{"sku": "pro_year", "method": "fake"}) + if w.Code != http.StatusOK { + t.Fatalf("create code=%d body=%v", w.Code, out) + } + data := out["data"].(map[string]any) + orderNo := data["order_no"].(string) + sess := data["session"].(map[string]any) + if sess["render_type"] != "crypto_address" { + t.Fatalf("session = %v", sess) + } + + // 查单:pending + _, out2 := do(t, r, http.MethodGet, "/v1/orders/"+orderNo, nil) + if out2["data"].(map[string]any)["status"] != "pending" { + t.Fatalf("status = %v", out2["data"]) + } + + // 取 provider_ref:直接构造 fake 回调体(provider_ref 由 payload 无法拿,需查尝试) + // 这里用 callback 端点 + fake JSON:先取尝试 ref。测试通过再次下单太绕, + // 改为:回调体里 provider_ref 用 order_no 反查——fake ref 前缀 FAKE-。 + // 直接命中:构造 verify 输入需真实 ref,故经 /v1/callback 前先查库拿 ref。 + // 简化:暴露一个内部查询——本测试用 status 已足够验证下单/查单闭环; + // 回调闭环在 gateway settle_test 已覆盖。此处验证 callback 路由存在且 404 语义: + wc, _ := do(t, r, http.MethodPost, "/v1/callback/fake", map[string]any{ + "provider_ref": "GHOST", "status": "succeeded", "amount_minor": 1, "currency": "USDT", + }) + if wc.Code != http.StatusOK { // not_found 也回 200(渠道无需重投未知单) + t.Fatalf("callback code=%d", wc.Code) + } + + // 取消 + wCancel, outCancel := do(t, r, http.MethodPost, "/v1/orders/"+orderNo+"/cancel", nil) + if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true { + t.Fatalf("cancel = %d %v", wCancel.Code, outCancel) + } +} +``` + +> 说明:回调"成功入账"闭环已在 `settle_test.go`(Task 5)用真实 provider_ref 覆盖;handler 测试聚焦**路由 + 请求/响应形状 + 下单/查/取消**闭环,回调端点只验路由存在与 200 语义(未知单回 200 不重投)。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/handler/ -run TestV2OrderLifecycle -v` +Expected: 编译失败 —— `router.SetupV2` / `GatewayHandler` 未定义。 + +- [ ] **Step 3: 写 handler** + +`internal/handler/gateway.go`: +```go +package handler + +import ( + "encoding/json" + "errors" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +type GatewayHandler struct { + g *gateway.Gateway +} + +func NewGatewayHandler(g *gateway.Gateway) *GatewayHandler { return &GatewayHandler{g: g} } + +type createV2Request struct { + SKU string `json:"sku"` + Method string `json:"method"` + BizSystem string `json:"biz_system,omitempty"` + BizRef string `json:"biz_ref,omitempty"` + ReturnURL string `json:"return_url,omitempty"` +} + +// CreateOrder POST /v1/orders —— 下单,返回 {order_no, session:{render_type, payload}}。 +// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign)。 +func (h *GatewayHandler) CreateOrder(c *gin.Context) { + raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes)) + if err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败") + return + } + var req createV2Request + if err := json.Unmarshal(raw, &req); err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误") + return + } + if req.SKU == "" || req.Method == "" { + util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 sku 或 method") + return + } + if req.BizSystem != "" { + if err := verifyBizSign(c, req.BizSystem, raw); err != nil { + util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + } + res, err := h.g.CreateOrder(c.Request.Context(), gateway.CreateOrderInput{ + SKU: req.SKU, Method: req.Method, BizSystem: req.BizSystem, BizRef: req.BizRef, ReturnURL: req.ReturnURL, + }) + if err != nil { + h.writeCreateErr(c, "下单", req.Method, err) + return + } + util.RespondSuccess(c, res) +} + +// GetStatus GET /v1/orders/:order_no +func (h *GatewayHandler) GetStatus(c *gin.Context) { + v, err := h.g.GetOrder(c.Param("order_no")) + if err != nil { + util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在") + return + } + util.RespondSuccess(c, v) +} + +type retryRequest struct { + Method string `json:"method"` +} + +// Retry POST /v1/orders/:order_no/retry +func (h *GatewayHandler) Retry(c *gin.Context) { + var req retryRequest + _ = c.ShouldBindJSON(&req) + if req.Method == "" { + util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 method") + return + } + res, err := h.g.RetryOrder(c.Request.Context(), c.Param("order_no"), req.Method) + if err != nil { + if errors.Is(err, gateway.ErrOrderNotPending) { + util.RespondError(c, http.StatusConflict, "order_not_pending", "订单非待支付态,不可重试") + return + } + if errors.Is(err, store.ErrOrderNotFound) { + util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在") + return + } + h.writeCreateErr(c, "重试", req.Method, err) + return + } + util.RespondSuccess(c, res) +} + +// Cancel POST /v1/orders/:order_no/cancel +func (h *GatewayHandler) Cancel(c *gin.Context) { + ok, err := h.g.CancelOrder(c.Param("order_no")) + if err != nil { + util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败") + return + } + util.RespondSuccess(c, gin.H{"canceled": ok}) +} + +// Callback POST /v1/callback/:method —— 渠道异步回调;经 provider.VerifyCallback → Settle。 +// 已受理(含未知单/幂等/金额不符,都不需要渠道重投)一律回 200。 +func (h *GatewayHandler) Callback(c *gin.Context) { + method := c.Param("method") + raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes)) + if err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大") + return + } + headers := map[string]string{} + for k := range c.Request.Header { + headers[k] = c.GetHeader(k) + } + res, err := h.g.HandleCallback(c.Request.Context(), method, provider.CallbackInput{ + Raw: raw, Headers: headers, + }) + if err != nil { + // 验签失败等:回 400 让渠道按策略重投(或人工排障)。 + log.Printf("[v2 callback] method=%s result=%s err=%v", method, res, err) + util.RespondError(c, http.StatusBadRequest, "callback_failed", "回调处理失败") + return + } + c.JSON(http.StatusOK, gin.H{"result": string(res)}) +} + +func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, err error) { + switch { + case errors.Is(err, gateway.ErrProductNotFound): + util.RespondError(c, http.StatusNotFound, "product_not_found", "套餐不存在或已下架") + case errors.Is(err, provider.ErrUnknownMethod): + util.RespondError(c, http.StatusBadRequest, "unknown_method", "不支持的支付方式") + case errors.Is(err, gateway.ErrNoAccount): + util.RespondError(c, http.StatusServiceUnavailable, "no_account", "该支付方式暂不可用") + default: + log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err) + util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试") + } +} +``` + +- [ ] **Step 4: 写路由 SetupV2** + +`internal/router/router.go` 追加(不动既有 `Setup`): +```go +import ( + // ... 既有 import 保留,追加: + "github.com/wangjia/pay/internal/gateway" +) + +// SetupV2 装配 pay v2 统一网关路由(/v1)。与 v1 /api/v1 并存,逐步收口。 +func SetupV2(r *gin.Engine, g *gateway.Gateway) { + h := handler.NewGatewayHandler(g) + v1 := r.Group("/v1") + { + v1.POST("/orders", h.CreateOrder) + v1.GET("/orders/:order_no", h.GetStatus) + v1.POST("/orders/:order_no/retry", h.Retry) + v1.POST("/orders/:order_no/cancel", h.Cancel) + v1.POST("/callback/:method", h.Callback) + } +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/handler/ -run TestV2OrderLifecycle -v` +Expected: PASS。 + +- [ ] **Step 6: main 装配(编译期接线,不新增测试)** + +`main.go`:①`autoMigrate` 追加 `&model.WebhookDelivery{}`;②`main()` 里装配 v2(在 `router.Setup(...)` 之后): +```go +// v2 统一网关装配(P2):provider 注册表 + gateway + webhook notifier。 +pReg := provider.NewRegistry() +// P3 起在此 Register 真实渠道:crypto / alipay / stripe …(fake 仅测试用,不注册进生产)。 +webhookStore := store.NewWebhookStore(db) +notifier := webhook.NewNotifier(webhookStore, config.C.BizByName) +notifier.Start(60 * time.Second) +orderStore := store.NewOrderStore(db) +productResolver := gateway.NewDBProductResolver(db, "CNY") // 币种按部署区配(cn=CNY / global=USDT) +acctReg := accounts.New(config.C.Accounts) +gw := gateway.New(orderStore, pReg, acctReg, productResolver, notifier, "cn") +router.SetupV2(r, gw) +``` +追加 import:`internal/provider`、`internal/store`、`internal/webhook`、`internal/gateway`、`internal/accounts`。 + +> `config.C.BizByName` 签名 `func(string)(config.BizSystemConfig,bool)` 恰配 `webhook.BizConfigFunc`,直接传方法值。 + +- [ ] **Step 7: 全量编译 + 测试** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./...` +Expected: 编译通过,全部包 `ok`(含 handler/gateway/provider/store/webhook/model/money/accounts)。 + +- [ ] **Step 8: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/handler/gateway.go internal/router/router.go main.go +git commit -m "feat(v2): /v1 网关路由 + handler(下单/查/重试/取消/回调)+ main 装配 gateway/notifier" +``` + +--- + +## Self-Review + +**Spec coverage(P2 范围,对照设计 §3/§4/§5):** +- Provider 接口 `capabilities/create/verify_callback/query` + 可选 refund/recurring 留空 + 注册表 `map[method]Provider`(Task 1)= 设计 §4.1 ✓。 +- fake provider 验证管线,真渠道 P3(Task 2)✓。 +- 一次性收款管线 `POST /v1/orders → 选 provider → create → 落 Order+Attempt(复用 P1 OrderStore)→ 返回 {order_no, session:{render_type, payload}}`;`GET /v1/orders/{no}`;retry/cancel(Task 4/7)= 设计 §3.1/3.2/4.2 ✓。 +- 统一开通/入账:webhook 与 query 都产出归一化 `PaidEvent` → 定位(provider_ref→attempt→order)→ 幂等 → 金额/币种核对 → `MarkAttemptPaid`(复用 P1)→ 触发业务方 webhook(Task 5)= 设计 §4 ✓。 +- webhook v2:pay→业务方,payload 带 `event_type`(先 `payment.succeeded`),HMAC 双向签名(复用 `util.HMACSign`),outbox + 重试兜底(Task 6)= 设计 §5 ✓。 +- 幂等/金额核对贯穿:入账走 P1 条件 UPDATE(order=pending 守卫);webhook 入队走 unique 键 ON CONFLICT;核对币种一致 + 实付 ≥ 应收(Task 5/6)✓。 + +**复用 P1(不重造):** 金额 `money.Parse/Format`、模型 `model.OrderV2`/`Attempt` + `V2` 后缀状态、`store.OrderStore.CreateOrder/CreateAttempt/MarkAttemptPaid/CancelOrder/ListOrders`、`accounts.Registry.EnabledFor` + `config.AccountConfig`、`util.HMACSign/NewOutTradeNo/RespondError/RespondSuccess`、`model.OpenTestDB` 全部直接复用。仅新增:provider 抽象、gateway 管线、store 查询扩展(GetOrder/AttemptByProviderRef/ListByStatus/ExpirePending)、webhook outbox。 + +**Placeholder scan:** 无 TODO/占位;每任务给完整 Go 代码 + 测试 + 命令。唯一"回改"是 Task 4 Step 3a 把 fake `provider_ref` 唯一化(纳秒后缀),原因:`Attempt` 有 `uniqueIndex(channel,provider_ref)`(P1),retry 同 method 会撞键;真实渠道每单天然不同 ref,此唯一化是 fake 专属测试保真手段,已在计划内闭合(同步改 Task 2 断言为前缀匹配 + gateway 测试按 ListAttemptsByStatus 取 ref)。 + +**Type consistency:** `provider.Provider`/`Session`/`PaidEvent`/`CreateRequest`/`CallbackInput` 在 Task 1 定义,Task 2(fake)、Task 4/5(gateway)、Task 7(handler)一致消费。`gateway.WebhookEnqueuer.Enqueue(outTradeNo,bizSystem,eventType,data)` 由 Task 5 消费、Task 6 `webhook.Notifier` 实现。`store.WebhookDeliveryRow = model.WebhookDelivery`(别名)让 `webhook` 包不直接依赖 `model`。`config.C.BizByName` 签名恰配 `webhook.BizConfigFunc`。金额一律 `int64 minor + Currency`。 + +**已知 scope 取舍(记录,不阻塞 P2):** +- webhook payload 未含 `product_biz_code`(设计 §5 列了):`model.OrderV2` 无 biz_code 列(P1 已定),不改 P1 schema;P2 业务方用 `biz_ref` 映射自身订单足够,`product_biz_code` 待 P3 给 order 补 biz_code 列或经 product 关联时补齐。 +- 路由策略取"首个 enabled 账户"(设计 §3.3 的 round_robin/weighted/limit_aware 在 **P5**)。 +- 币种维度未进 product(`DBProductResolver` 用单一部署默认币种):多币种 product 在 P3+。 +- `/v1`(v2)与 `/api/v1`(v1)并存,收口在 pay 定稿后(设计 §1 / brain todo #5)。 + +--- + +## 后续阶段(各自独立成计划,落地前逐一细化) + +- **P3** 首批渠道 adapter 实现 `provider.Provider`:crypto 自托管(包裹 pangolin-pay 单地址/地址池收款 + 链上确认 query)/ 支付宝(迁移 v1 `internal/channel/alipay.go` 到 provider,复用验签)/ Stripe(sdk_handoff + webhook 验签)。各自 `Register` 进注册表;补 order 的 biz_code / 多币种。 +- **P4** 退款:`POST /v1/refunds` + `provider.RefundingProvider` + 三向(业务发起/crypto 人工 sweep/平台通知)+ `refund.succeeded`/`refund.failed` 事件 + order 退款态机(refunding/partially_refunded/refunded)。 +- **P5** 多账户路由策略(round_robin/weighted/limit_aware/by_region/crypto 地址池)替换"首个 enabled"。 +- **P6** 对账 job:周期 `SyncPendingAttempts` + 每 provider `query` 拉渠道流水 vs 本地订单,防掉单/防重复授权。 +- **P7** codes 共享库(A 嵌入各产品)。 +- **P8(later)** 订阅/recurring(`provider.RecurringProvider` 4 类 kind)、拒付 chargeback 事件。 + +> 契约同步(pay-contract):webhook `event_type` 断代 v2.0.0、`render_type`/多币种向后兼容加字段,随 P2/P3 落地登记。 + + diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md b/docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md new file mode 100644 index 0000000..0fd01d7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md @@ -0,0 +1,2841 @@ +# pay v2 · P7 codes 共享库(激活码兑换内核)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现本计划。步骤用 `- [ ]` checkbox 追踪。 + +> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html` §9(codes 兑换内核)/ §9.1(部署选型 A,已定)/ §9.2(履约动作多态)/ §12(通用权益描述符,可扩展性验证)。本计划是 pay v2 八阶段中的 **P7**,承接 `2026-07-10-pay-v2-p1-core-model.md` 末尾"后续阶段"列出的 P7 条目。 + +**Goal:** 把 pangolin 现有 `server/internal/codes/`(码模型/状态机/生成器/兑换事务/webhook 灌码)抽成一个与 pay **不同部署、不同仓库**的独立 Go 共享库,供 pangolin(订阅)、jiu(门店 license)、未来 dudu(额度)等产品各自 `import` 嵌入。库本身**entitlement-agnostic**——码携带通用「权益描述符」而非硬编码 plan+天数,兑换的"最终开通落库"通过宿主注入的回调函数完成,兑换是**宿主本地事务**(码表与宿主业务表同库)。 + +**Architecture:** 移植 pangolin `internal/codes` 的 canonical 部分(哈希存储 / 状态机 unused-redeemed-void / Crockford Base32 生成器 / 锁+CAS+幂等兑换骨架 / 批次 / 审计),去掉 pangolin 专属的 `plan_id`+`duration_days`+`subscriptions` 硬编码,替换成通用 `Entitlement{Kind, Payload}` 信封;webhook 签名从 pangolin 的「仅对 body 做 HMAC」升级为 pay-contract 既有的「system+timestamp+nonce+body 一并入 MAC」多产品既定惯例。库只依赖 `database/sql`(host 传入已打开的 `*sql.DB`/`*sql.Tx`,mysql 或 sqlite 均可,库不 import 具体驱动),Redis 相关能力(限流/去重)隔离进可选子包,避免强加给不跑 Redis 的宿主。 + +## 部署与模块选型(已决策,依据设计文档 §9.1 方案 A) + +| 选项 | 说明 | 结论 | +|---|---|---| +| pay 仓内 `pkg/codes` 子包 | 复用 pay 的 go.mod | ❌ 不采用——pay 依赖很重(gin/GORM/alipay SDK/wechatpay-go),codes 的宿主(pangolin/jiu)只想要码逻辑,不想被迫拉进整个支付网关的依赖树;codes 与 pay 收款管线正交(§9 已定"不并入 pay"),放进 pay 仓在逻辑和依赖两个维度都是错误信号。 | +| pangolin 仓内保留、jiu 抄一份 | 零迁移成本 | ❌ 不采用——违反"共享库"目标,retire 到两份漂移代码,与设计文档 §9.1"方案 A = 共享库嵌入各产品"的决策矛盾。 | +| **独立仓库 + 独立 Go module(方案 A 落地形态)** | 新仓 `~/code/codes`,module `github.com/wangjia/codes`,各产品 `go get`/`replace` 引入 | ✅ **已定**——依赖最小(核心包零第三方依赖,仅 stdlib;可选 Redis 能力隔离进 `codes/redisx` 子包,不 import 就不产生依赖);可被 pangolin/jiu/dudu 平等 import;版本可独立打 tag,不与任何单一产品的发布节奏绑定。 | + +**落地约定**(照用户全局仓库管理惯例):新仓源码放 `~/code/codes`,`git init` 后 remote 指向 `ssh://git@git.51yanmei.com:2222/wangjia/codes.git`(需先在 Gitea web 建仓才能 push;若尚未建仓,先本地 commit,push 留到 Gitea 仓建好后)。**本计划只搭这个新仓库,不改 pangolin/jiu 现有代码**——pangolin 现有 `server/internal/codes/` 迁移到 import 本库是后续独立工作(brain todo,pay 定稿 + codes 库稳定后再动),本计划不做。 + +## Global Constraints + +- **Module**:`github.com/wangjia/codes`,`go 1.23`(取 pangolin `1.25.10` / pay `1.26.1` 的下界,保证两者都能正常 `require` 本库)。核心包(根目录 `codes`)编译期**零第三方依赖**——只有 `internal/idgen` 用到的 stdlib(`crypto/rand`/`crypto/sha256`)和测试期用到 `modernc.org/sqlite`(纯 Go 免 CGO,仅测试文件 import,不进宿主生产依赖图)。`codes/redisx` 子包才 import `github.com/redis/go-redis/v9`,宿主不 import 这个子包就不会被拉进 Redis 依赖。 +- **同库本地事务是唯一原子性保证**:`Redeem[T]` 接收宿主已开启的 `*sql.Tx`,码状态翻转与宿主的 `grant` 回调写同一个事务——这要求宿主的权益表与 codes 表**在同一个数据库连接/同一个 `*sql.DB` 之下**(设计文档 §9.1 方案 A 的前提)。若宿主权益数据在另一个物理库,本库不提供分布式事务,那是方案 B(独立服务 + saga)的范畴,不在本计划内。 +- **明文码永不落库/永不进日志**:数据库与审计日志只存 `SHA-256(canonical_plaintext)`;明文只在 `Mint` 的返回值里出现一次(调用方——通常是 webhook 处理器或运营 CLI——自行负责安全投递)。 +- **状态机三态**:`unused → redeemed`(经 `Redeem`)、`unused → void`(经 `VoidCode`),**没有其他合法转移**;`redeemed`/`void` 都是终态。 +- **通用权益描述符**:`Entitlement{Kind, Payload}` 替代硬编码 `plan_id`+`duration_days`;`Kind=duration` 承载时长型(`{plan,days}`),`Kind=quota` 承载额度型(`{resource,amount}`,对应 dudu 秒数/未来流量包)。库只做信封校验(`Validate()`),**从不解释 `Payload` 内的业务字段**——那是宿主 `GrantFunc` 的职责。 +- **幂等**:同一 `redeemerRef` 重复兑换同一码 → `Idempotent:true`、不重复执行 `grant`;不同 `redeemerRef` 兑换已兑换码 → `ErrCodeRedeemed`。`redeemerRef` 是不透明字符串(`"user:123"` / `"shop:9"`),库不关心其归属维度——这正是设计文档 §9.1 强调"归属维度(user/shop)…留给各产品"的落地方式。 +- 每步 `go build ./...` 通过;测试 `go test ./...`(sqlite `:memory:` 免 docker;`redisx` 用 `github.com/alicebob/miniredis/v2` 免 docker——与 pangolin `server/go.mod` 现有测试依赖一致)。 +- **本计划范围之外**(明确排除,避免范围蔓延):admin 批次列表/CSV 导出(pangolin `admin_support.go`/`export.go` 已有实现,宿主若需要可直接在自己的 admin 层调用 `Store` 的基础方法拼,不进本库);pangolin/jiu 迁移到 import 本库(单独任务);独立服务化方案 B、reseller 门户、优惠券变体(设计文档 §7/§9.1 标注的 later)。 + +--- + +### Task 1: 模块脚手架 + Entitlement 描述符 + 状态机 + 哨兵错误 + +**Files:** +- Create: `go.mod`(新仓根目录) +- Create: `entitlement.go` +- Create: `status.go` +- Create: `errors.go` +- Test: `entitlement_test.go` +- Test: `status_test.go` + +**Interfaces:** +- Produces: + - `type EntitlementKind string` + 常量 `EntitlementDuration` / `EntitlementQuota` + - `type Entitlement struct{ Kind EntitlementKind; Payload json.RawMessage }` + - `type DurationPayload struct{ Plan string; Days int }` / `type QuotaPayload struct{ Resource string; Amount int64 }` + - `func NewDurationEntitlement(plan string, days int) (Entitlement, error)` / `func NewQuotaEntitlement(resource string, amount int64) (Entitlement, error)` + - `func (e Entitlement) DecodeDuration() (DurationPayload, error)` / `func (e Entitlement) DecodeQuota() (QuotaPayload, error)` + - `func (e Entitlement) Validate() error` + - `type Status string` + 常量 `StatusUnused` / `StatusRedeemed` / `StatusVoid`;`func (s Status) Redeemable() bool` / `func (s Status) Voidable() bool` + - 哨兵错误:`ErrDuplicate` / `ErrCodeNotFound` / `ErrCodeRedeemed` / `ErrCodeVoid` / `ErrInvalidCode` / `ErrLocked` / `ErrNotVoidable` + +- [ ] **Step 0: 建仓** + +```bash +mkdir -p ~/code/codes && cd ~/code/codes +git init +go mod init github.com/wangjia/codes +git remote add origin ssh://git@git.51yanmei.com:2222/wangjia/codes.git +``` +(Gitea 仓需先在 web 建好才能 push;未建好先只本地 commit。) + +- [ ] **Step 1: 写失败测试** + +`entitlement_test.go`: +```go +package codes_test + +import ( + "encoding/json" + "testing" + + "github.com/wangjia/codes" +) + +func TestDurationEntitlementRoundTrip(t *testing.T) { + e, err := codes.NewDurationEntitlement("pro", 30) + if err != nil { + t.Fatalf("NewDurationEntitlement: %v", err) + } + if e.Kind != codes.EntitlementDuration { + t.Fatalf("kind = %q, want duration", e.Kind) + } + if err := e.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + p, err := e.DecodeDuration() + if err != nil { + t.Fatalf("DecodeDuration: %v", err) + } + if p.Plan != "pro" || p.Days != 30 { + t.Fatalf("got %+v", p) + } + if _, err := e.DecodeQuota(); err == nil { + t.Fatal("DecodeQuota on a duration entitlement should error") + } + + // 整个 Entitlement 可安全过 JSON(webhook payload 的编码方式)。 + raw, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back codes.Entitlement + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + p2, err := back.DecodeDuration() + if err != nil || p2 != p { + t.Fatalf("round-trip mismatch: %+v vs %+v (err=%v)", p2, p, err) + } +} + +func TestQuotaEntitlementRoundTrip(t *testing.T) { + e, err := codes.NewQuotaEntitlement("data_gb", 100) + if err != nil { + t.Fatalf("NewQuotaEntitlement: %v", err) + } + q, err := e.DecodeQuota() + if err != nil { + t.Fatalf("DecodeQuota: %v", err) + } + if q.Resource != "data_gb" || q.Amount != 100 { + t.Fatalf("got %+v", q) + } +} + +func TestEntitlementValidateRejectsEmpty(t *testing.T) { + if err := (codes.Entitlement{}).Validate(); err == nil { + t.Fatal("empty entitlement should fail Validate") + } + if err := (codes.Entitlement{Kind: codes.EntitlementDuration}).Validate(); err == nil { + t.Fatal("duration entitlement with empty payload should fail Validate") + } +} +``` + +`status_test.go`: +```go +package codes_test + +import ( + "testing" + + "github.com/wangjia/codes" +) + +func TestStatusTransitions(t *testing.T) { + cases := []struct { + s codes.Status + redeemable, voidable bool + }{ + {codes.StatusUnused, true, true}, + {codes.StatusRedeemed, false, false}, + {codes.StatusVoid, false, false}, + } + for _, c := range cases { + if got := c.s.Redeemable(); got != c.redeemable { + t.Errorf("%s.Redeemable() = %v, want %v", c.s, got, c.redeemable) + } + if got := c.s.Voidable(); got != c.voidable { + t.Errorf("%s.Voidable() = %v, want %v", c.s, got, c.voidable) + } + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go test ./... -v` +Expected: 编译失败——`codes` 包内容不存在。 + +- [ ] **Step 3: 写实现** + +`entitlement.go`: +```go +// Package codes implements a product-agnostic activation-code lifecycle: +// generation, hashed storage, a local-transaction redeem skeleton with a +// host-supplied grant callback, batches, and audit. It is deliberately not +// entitlement-aware — see Entitlement — and not payment-aware (see +// pay-v2-unified-gateway-design.html §9 for the boundary with pay). +// +// Security invariant: plaintext codes are NEVER written to storage or logs. +// Only SHA-256(canonical_plaintext) is persisted; plaintext appears exactly +// once, in Mint's return value. +package codes + +import ( + "encoding/json" + "fmt" +) + +// EntitlementKind selects how Entitlement.Payload should be interpreted by +// the host's GrantFunc. codes treats Payload as opaque bytes beyond envelope +// validation — it never inspects business fields. +type EntitlementKind string + +const ( + EntitlementDuration EntitlementKind = "duration" + EntitlementQuota EntitlementKind = "quota" +) + +// Entitlement is the generic "what does this code grant" descriptor. It +// replaces a hardcoded plan_id+duration_days pair so one library carries a +// subscription extension (pangolin/jiu) or a quota top-up (dudu seconds, +// future traffic packs) without a schema change (design doc §12). +type Entitlement struct { + Kind EntitlementKind `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +// DurationPayload is the Kind=duration payload shape. +type DurationPayload struct { + Plan string `json:"plan"` + Days int `json:"days"` +} + +// QuotaPayload is the Kind=quota payload shape. +type QuotaPayload struct { + Resource string `json:"resource"` + Amount int64 `json:"amount"` +} + +func NewDurationEntitlement(plan string, days int) (Entitlement, error) { + b, err := json.Marshal(DurationPayload{Plan: plan, Days: days}) + if err != nil { + return Entitlement{}, fmt.Errorf("codes: marshal duration payload: %w", err) + } + return Entitlement{Kind: EntitlementDuration, Payload: b}, nil +} + +func NewQuotaEntitlement(resource string, amount int64) (Entitlement, error) { + b, err := json.Marshal(QuotaPayload{Resource: resource, Amount: amount}) + if err != nil { + return Entitlement{}, fmt.Errorf("codes: marshal quota payload: %w", err) + } + return Entitlement{Kind: EntitlementQuota, Payload: b}, nil +} + +func (e Entitlement) DecodeDuration() (DurationPayload, error) { + if e.Kind != EntitlementDuration { + return DurationPayload{}, fmt.Errorf("codes: entitlement kind %q is not duration", e.Kind) + } + var p DurationPayload + if err := json.Unmarshal(e.Payload, &p); err != nil { + return DurationPayload{}, fmt.Errorf("codes: decode duration payload: %w", err) + } + return p, nil +} + +func (e Entitlement) DecodeQuota() (QuotaPayload, error) { + if e.Kind != EntitlementQuota { + return QuotaPayload{}, fmt.Errorf("codes: entitlement kind %q is not quota", e.Kind) + } + var p QuotaPayload + if err := json.Unmarshal(e.Payload, &p); err != nil { + return QuotaPayload{}, fmt.Errorf("codes: decode quota payload: %w", err) + } + return p, nil +} + +// Validate checks the envelope only: Kind/Payload are non-empty, and for the +// two known kinds the payload decodes. Unknown kinds pass through opaquely +// so new entitlement shapes don't require a codes library release first. +func (e Entitlement) Validate() error { + if e.Kind == "" { + return fmt.Errorf("codes: entitlement kind is empty") + } + if len(e.Payload) == 0 { + return fmt.Errorf("codes: entitlement payload is empty") + } + switch e.Kind { + case EntitlementDuration: + _, err := e.DecodeDuration() + return err + case EntitlementQuota: + _, err := e.DecodeQuota() + return err + default: + return nil + } +} +``` + +`status.go`: +```go +package codes + +// Status is the code lifecycle state. The only legal transitions are +// unused→redeemed (via Redeem) and unused→void (via VoidCode); both other +// states are terminal. +type Status string + +const ( + StatusUnused Status = "unused" + StatusRedeemed Status = "redeemed" + StatusVoid Status = "void" +) + +func (s Status) Redeemable() bool { return s == StatusUnused } +func (s Status) Voidable() bool { return s == StatusUnused } +``` + +`errors.go`: +```go +package codes + +import "errors" + +var ( + ErrDuplicate = errors.New("codes: duplicate code hash") + ErrCodeNotFound = errors.New("codes: code not found") + ErrCodeRedeemed = errors.New("codes: code already redeemed") + ErrCodeVoid = errors.New("codes: code has been voided") + ErrInvalidCode = errors.New("codes: invalid code format") + ErrLocked = errors.New("codes: too many failed attempts, temporarily locked") + ErrNotVoidable = errors.New("codes: code is not in a voidable state") +) +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd ~/code/codes && go test ./... -v` +Expected: `TestDurationEntitlementRoundTrip` / `TestQuotaEntitlementRoundTrip` / `TestEntitlementValidateRejectsEmpty` / `TestStatusTransitions` 全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd ~/code/codes +git add go.mod entitlement.go status.go errors.go entitlement_test.go status_test.go +git commit -m "feat: module scaffold + 通用权益描述符 Entitlement + 状态机 + 哨兵错误" +``` + +--- + +### Task 2: Crockford Base32 生成器(移植 pangolin idgen)+ 根包薄封装 + +**Files:** +- Create: `internal/idgen/idgen.go` +- Test: `internal/idgen/idgen_test.go` +- Create: `codegen.go`(根包薄封装,对外 API) +- Test: `codegen_test.go` + +**Interfaces:** +- Produces(`internal/idgen`,包内私有,不对宿主暴露): + - `func GenerateCode() (string, error)` — 15 随机 Crockford Base32 数据字符 + 1 位 mod-37 校验字符(16 字符),`crypto/rand`。 + - `func CanonicalizeCode(code string) (string, error)` — 归一化(大写、I/L→1、O→0、去连字符/空格)+ 校验位验证。 + - `func HashCode(canonical string) string` — SHA-256 hex。 +- Produces(根包 `codes`,对外 API): + - `func GenerateCode() (string, error)` / `func Canonicalize(code string) (string, error)` / `func Hash(canonical string) string` — 薄封装,委托给 `internal/idgen`。 + +此任务**原样移植** `pangolin/server/internal/codes` 依赖的 `internal/idgen` 的 Crockford Base32 部分(去掉与本库无关的 UUID v7 生成),含其完整的校验位/防偏抽样算法与既有测试集,是激活码格式与安全性的 canonical 来源,不重新发明。 + +- [ ] **Step 1: 写失败测试** + +`internal/idgen/idgen_test.go`: +```go +package idgen_test + +import ( + "strings" + "testing" + + "github.com/wangjia/codes/internal/idgen" +) + +func TestGenerateCodeFormat(t *testing.T) { + for i := 0; i < 1000; i++ { + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + if len(code) != 16 { + t.Errorf("code %q: length = %d, want 16", code, len(code)) + } + canonical, err := idgen.CanonicalizeCode(code) + if err != nil { + t.Errorf("CanonicalizeCode(%q): %v", code, err) + } + if canonical != code { + t.Errorf("canonical form mismatch: got %q, want %q", canonical, code) + } + } +} + +func TestGenerateCodeUniqueness(t *testing.T) { + const n = 5_000 + seen := make(map[string]struct{}, n) + for i := 0; i < n; i++ { + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + h := idgen.HashCode(code) + if _, dup := seen[h]; dup { + t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h) + } + seen[h] = struct{}{} + } +} + +func TestCanonicalizeCodeNormalization(t *testing.T) { + base, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + lower := strings.ToLower(base) + canonical, err := idgen.CanonicalizeCode(lower) + if err != nil { + t.Errorf("CanonicalizeCode(lower) error: %v", err) + } + if canonical != base { + t.Errorf("CanonicalizeCode(lower) = %q, want %q", canonical, base) + } + + if idx := strings.IndexByte(base, '1'); idx >= 0 && idx < 15 { + for _, sub := range []string{"I", "L", "i", "l"} { + variant := base[:idx] + sub + base[idx+1:] + c, err := idgen.CanonicalizeCode(variant) + if err != nil { + t.Errorf("CanonicalizeCode(%q) error: %v", variant, err) + continue + } + if c != base { + t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base) + } + } + } + if idx := strings.IndexByte(base, '0'); idx >= 0 && idx < 15 { + variant := base[:idx] + "O" + base[idx+1:] + c, err := idgen.CanonicalizeCode(variant) + if err != nil { + t.Errorf("CanonicalizeCode(%q) error: %v", variant, err) + } else if c != base { + t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base) + } + } +} + +func TestCheckCharDetectsSingleErrors(t *testing.T) { + const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + for pos := 0; pos < 15; pos++ { + original := rune(code[pos]) + for _, replacement := range alpha { + if replacement == original { + continue + } + mutated := code[:pos] + string(replacement) + code[pos+1:] + if _, err := idgen.CanonicalizeCode(mutated); err == nil { + t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q", + pos, original, replacement, code, mutated) + } + } + } +} + +func TestHashCodeConsistency(t *testing.T) { + code1, _ := idgen.GenerateCode() + code2, _ := idgen.GenerateCode() + for code1 == code2 { + code2, _ = idgen.GenerateCode() + } + h1a := idgen.HashCode(code1) + h1b := idgen.HashCode(code1) + h2 := idgen.HashCode(code2) + if h1a != h1b { + t.Error("HashCode is not deterministic") + } + if h1a == h2 { + t.Error("different codes produced the same digest") + } + if len(h1a) != 64 { + t.Errorf("HashCode length = %d, want 64 (hex SHA-256)", len(h1a)) + } +} + +func TestCanonicalizeCodeRejectsInvalidLength(t *testing.T) { + for _, c := range []string{"", "ABCDE", "ABCDEFGH12345678X"} { + if _, err := idgen.CanonicalizeCode(c); err == nil { + t.Errorf("CanonicalizeCode(%q) should fail for length %d", c, len(c)) + } + } +} + +func TestCanonicalizeCodeRejectsInvalidChars(t *testing.T) { + base, _ := idgen.GenerateCode() + invalid := "!" + base[1:] + if _, err := idgen.CanonicalizeCode(invalid); err == nil { + t.Errorf("CanonicalizeCode(%q) should fail for invalid character", invalid) + } +} + +func TestHyphenStripping(t *testing.T) { + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:] + canonical, err := idgen.CanonicalizeCode(hyphenated) + if err != nil { + t.Errorf("CanonicalizeCode(hyphenated) error: %v", err) + } + if canonical != code { + t.Errorf("CanonicalizeCode(hyphenated) = %q, want %q", canonical, code) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go test ./internal/idgen/ -v` +Expected: 编译失败——`internal/idgen` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/idgen/idgen.go`(移植自 `pangolin/server/internal/idgen/idgen.go`,去掉 UUID v7 部分——本库不需要): +```go +// Package idgen generates 16-character Crockford Base32 activation codes +// with a mod-37 check character. Ported from pangolin's +// server/internal/idgen (Crockford portion only — UUID v7 generation is not +// needed by this library). +package idgen + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U +// to prevent visual confusion with 1, 1, 0, and V respectively). +const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// crockfordCheck is the extended 37-symbol check-character alphabet used for +// the Crockford mod-37 check symbol. Symbols 0–31 match crockfordAlphabet; +// symbols 32–36 are *, ~, $, =, U. +const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U" + +// crockfordDecode maps every printable ASCII character to its Crockford +// numeric value (0–31), or -1 if invalid. Normalisation (I/i/l/L→1, O/o→0) +// is baked in at init time. +var crockfordDecode [128]int8 + +func init() { + for i := range crockfordDecode { + crockfordDecode[i] = -1 + } + for i, ch := range crockfordAlphabet { + crockfordDecode[ch] = int8(i) + if ch >= 'A' && ch <= 'Z' { + crockfordDecode[ch-'A'+'a'] = int8(i) + } + } + crockfordDecode['I'] = crockfordDecode['1'] + crockfordDecode['i'] = crockfordDecode['1'] + crockfordDecode['l'] = crockfordDecode['1'] + crockfordDecode['L'] = crockfordDecode['1'] + crockfordDecode['O'] = crockfordDecode['0'] + crockfordDecode['o'] = crockfordDecode['0'] +} + +// computeCheckValue computes the Crockford mod-37 check value (Horner's +// method) of the first 15 characters of s (already canonical). Returns -1 +// on any invalid character. +func computeCheckValue(s string) int { + result := 0 + for i := 0; i < 15; i++ { + ch := s[i] + if ch >= 128 { + return -1 + } + v := int(crockfordDecode[ch]) + if v < 0 { + return -1 + } + result = (result*32 + v) % 37 + } + return result +} + +func validateCheckChar(canonical string) error { + if len(canonical) != 16 { + return errors.New("idgen: invalid length for check validation") + } + expected := computeCheckValue(canonical) + if expected < 0 { + return errors.New("idgen: invalid data characters in code") + } + want := rune(crockfordCheck[expected]) + got := rune(canonical[15]) + if got != want { + return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got) + } + return nil +} + +// CanonicalizeCode converts an activation-code string into canonical form: +// uppercase, I/L→1 and O→0 substituted, hyphens/spaces stripped, check +// character validated. Returns an error on invalid characters, wrong length +// (after stripping), or a bad check character. +func CanonicalizeCode(code string) (string, error) { + code = strings.TrimSpace(code) + code = strings.ReplaceAll(code, "-", "") + code = strings.ReplaceAll(code, " ", "") + + if len(code) != 16 { + return "", fmt.Errorf("idgen: code must be exactly 16 characters, got %d", len(code)) + } + + var buf [16]byte + for i := 0; i < 16; i++ { + ch := code[i] + if ch >= 128 { + return "", fmt.Errorf("idgen: non-ASCII character at position %d", i) + } + v := crockfordDecode[ch] + if v < 0 { + if i < 15 { + return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i) + } + buf[i] = []byte(strings.ToUpper(string(ch)))[0] + continue + } + buf[i] = crockfordAlphabet[v] + } + + canonical := string(buf[:]) + if err := validateCheckChar(canonical); err != nil { + return "", err + } + return canonical, nil +} + +// HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext +// code — the value that gets persisted; the plaintext itself never is. +func HashCode(canonical string) string { + sum := sha256.Sum256([]byte(canonical)) + return hex.EncodeToString(sum[:]) +} + +// GenerateCode generates one random activation code in canonical Crockford +// Base32 form: 15 random data characters + 1 mod-37 check character. +// crypto/rand with rejection sampling (accept range [0,224), 224=7×32) to +// avoid modular bias. +func GenerateCode() (string, error) { + const dataLen = 15 + var buf [dataLen]byte + i := 0 + for i < dataLen { + var tmp [dataLen * 2]byte + if _, err := rand.Read(tmp[:]); err != nil { + return "", fmt.Errorf("idgen: crypto/rand: %w", err) + } + for _, b := range tmp { + if b < 224 { + buf[i] = crockfordAlphabet[b%32] + i++ + if i == dataLen { + break + } + } + } + } + data := string(buf[:]) + checkVal := computeCheckValue(data + "0") + if checkVal < 0 { + return "", errors.New("idgen: internal check computation error") + } + return data + string(crockfordCheck[checkVal]), nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd ~/code/codes && go test ./internal/idgen/ -v` +Expected: 全 PASS。 + +- [ ] **Step 5: 根包薄封装 + 测试** + +`codegen_test.go`: +```go +package codes_test + +import ( + "testing" + + "github.com/wangjia/codes" +) + +func TestGenerateCanonicalizeHashRoundTrip(t *testing.T) { + code, err := codes.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + canonical, err := codes.Canonicalize(code) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + if canonical != code { + t.Fatalf("canonical mismatch: %q vs %q", canonical, code) + } + h := codes.Hash(canonical) + if len(h) != 64 { + t.Fatalf("Hash length = %d, want 64", len(h)) + } +} +``` + +`codegen.go`: +```go +package codes + +import "github.com/wangjia/codes/internal/idgen" + +// GenerateCode generates one random 16-char Crockford Base32 activation code. +func GenerateCode() (string, error) { return idgen.GenerateCode() } + +// Canonicalize normalises and validates a user-entered code string. +func Canonicalize(code string) (string, error) { return idgen.CanonicalizeCode(code) } + +// Hash returns the hex SHA-256 digest of a canonical code — the only form +// ever persisted. +func Hash(canonical string) string { return idgen.HashCode(canonical) } +``` + +- [ ] **Step 6: 跑测试确认通过 + 全量编译** + +Run: `cd ~/code/codes && go build ./... && go test ./...` +Expected: 编译通过,`internal/idgen` 与根包均 `ok`。 + +- [ ] **Step 7: Commit** + +```bash +cd ~/code/codes +git add internal/idgen/ codegen.go codegen_test.go +git commit -m "feat: Crockford Base32 生成器(移植 pangolin idgen)+ 根包薄封装" +``` + +--- + +### Task 3: Dialect + 内嵌 migrations(mysql+sqlite)+ Store CRUD + +**Files:** +- Create: `dialect.go` +- Test: `dialect_test.go` +- Create: `migrations/mysql/000001_codes.up.sql` / `000001_codes.down.sql` +- Create: `migrations/sqlite/000001_codes.up.sql` / `000001_codes.down.sql` +- Create: `migrations.go` +- Test: `migrations_test.go` +- Create: `store.go` +- Create: `testdb_test.go`(包内测试共用 helper) +- Test: `store_test.go` + +**Interfaces:** +- Produces: + - `type Dialect string` + 常量 `DialectMySQL`/`DialectSQLite`;`func (d Dialect) LockForUpdate() string` + - `var migrationsFS embed.FS`(私有)+ `func ApplyMigrations(ctx context.Context, db *sql.DB, dialect Dialect) error` + - `type Code struct{ ID int64; CodeHash string; BatchID int64; Entitlement Entitlement; Status Status; RedeemedBy string; RedeemedAt *time.Time; VoidReason string; CreatedAt time.Time }` + - `type Batch struct{ ID int64; Channel string; Entitlement Entitlement; CreatedBy, Note string; CreatedAt time.Time }` + - `type Store struct{...}` · `func NewStore(db *sql.DB, dialect Dialect) *Store` + - `(*Store) CreateBatch(ctx, channel string, ent Entitlement, createdBy, note string) (int64, error)` + - `(*Store) CreateCode(ctx, codeHash string, batchID int64, ent Entitlement) error`(重复 hash → `ErrDuplicate`) + - `(*Store) FindByHash(ctx, codeHash string) (*Code, error)` + - `(*Store) FindByHashForUpdate(ctx, tx *sql.Tx, codeHash string) (*Code, error)` + - `(*Store) MarkRedeemed(ctx, tx *sql.Tx, id int64, redeemerRef string, at time.Time) error` + - `(*Store) Void(ctx, id int64, reason string) (bool, error)` + - `(*Store) WriteAudit(ctx, tx *sql.Tx, actor, action, target string, meta map[string]any) error`(`tx==nil` 时直接用 `s.db`) + +- [ ] **Step 1: 写失败测试(Dialect,不需要 DB)** + +`dialect_test.go`: +```go +package codes_test + +import ( + "testing" + + "github.com/wangjia/codes" +) + +func TestDialectLockForUpdate(t *testing.T) { + if got := codes.DialectMySQL.LockForUpdate(); got != "FOR UPDATE" { + t.Errorf("mysql LockForUpdate = %q", got) + } + if got := codes.DialectSQLite.LockForUpdate(); got != "" { + t.Errorf("sqlite LockForUpdate = %q, want empty (single-writer engine)", got) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go test ./... -run TestDialectLockForUpdate -v` +Expected: 编译失败——`Dialect`/`DialectMySQL`/`DialectSQLite` 未定义。 + +- [ ] **Step 3: 写 dialect.go** + +```go +package codes + +// Dialect captures the one SQL construct this library needs that differs +// between engines: pessimistic row locking inside a transaction. SQLite is a +// single-writer engine — a BEGIN'd write transaction already serializes +// concurrent writers, so no explicit lock clause is needed there. +type Dialect string + +const ( + DialectMySQL Dialect = "mysql" + DialectSQLite Dialect = "sqlite" +) + +func (d Dialect) LockForUpdate() string { + if d == DialectMySQL { + return "FOR UPDATE" + } + return "" +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd ~/code/codes && go test ./... -run TestDialectLockForUpdate -v` → PASS。 + +- [ ] **Step 5: 写 migration SQL(mysql + sqlite)** + +`migrations/mysql/000001_codes.up.sql`: +```sql +CREATE TABLE codes_batches ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + channel VARCHAR(32) NOT NULL, + entitlement_kind VARCHAR(32) NOT NULL, + entitlement_payload JSON NOT NULL, + created_by VARCHAR(64) NOT NULL, + note VARCHAR(255) NULL, + created_at DATETIME NOT NULL +); + +CREATE TABLE codes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code_hash CHAR(64) NOT NULL, + batch_id BIGINT UNSIGNED NOT NULL, + entitlement_kind VARCHAR(32) NOT NULL, + entitlement_payload JSON NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'unused', + redeemed_by VARCHAR(128) NULL, + redeemed_at DATETIME NULL, + void_reason VARCHAR(255) NULL, + created_at DATETIME NOT NULL, + UNIQUE KEY uq_codes_code_hash (code_hash), + KEY idx_codes_status (status), + KEY idx_codes_batch (batch_id) +); + +CREATE TABLE codes_audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + actor VARCHAR(128) NOT NULL, + action VARCHAR(32) NOT NULL, + target VARCHAR(128) NOT NULL, + meta JSON NULL, + at DATETIME NOT NULL +); +``` + +`migrations/mysql/000001_codes.down.sql`: +```sql +DROP TABLE codes_audit_log; +DROP TABLE codes; +DROP TABLE codes_batches; +``` + +`migrations/sqlite/000001_codes.up.sql`: +```sql +CREATE TABLE codes_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, + entitlement_kind TEXT NOT NULL, + entitlement_payload TEXT NOT NULL, + created_by TEXT NOT NULL, + note TEXT NULL, + created_at DATETIME NOT NULL +); + +CREATE TABLE codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code_hash TEXT NOT NULL UNIQUE, + batch_id INTEGER NOT NULL, + entitlement_kind TEXT NOT NULL, + entitlement_payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unused' CHECK (status IN ('unused','redeemed','void')), + redeemed_by TEXT NULL, + redeemed_at DATETIME NULL, + void_reason TEXT NULL, + created_at DATETIME NOT NULL +); +CREATE INDEX idx_codes_status ON codes (status); +CREATE INDEX idx_codes_batch ON codes (batch_id); + +CREATE TABLE codes_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + meta TEXT NULL, + at DATETIME NOT NULL +); +``` + +`migrations/sqlite/000001_codes.down.sql`: +```sql +DROP TABLE codes_audit_log; +DROP TABLE codes; +DROP TABLE codes_batches; +``` + +- [ ] **Step 6: 写失败测试(ApplyMigrations)** + +`migrations_test.go`: +```go +package codes_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/wangjia/codes" + _ "modernc.org/sqlite" +) + +func TestApplyMigrationsSQLite(t *testing.T) { + db, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatalf("open: %v", err) + } + db.SetMaxOpenConns(1) // :memory:+shared cache: one connection = one schema + defer db.Close() + + if err := codes.ApplyMigrations(context.Background(), db, codes.DialectSQLite); err != nil { + t.Fatalf("ApplyMigrations: %v", err) + } + + for _, table := range []string{"codes_batches", "codes", "codes_audit_log"} { + var name string + err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name) + if err != nil { + t.Errorf("table %s missing: %v", table, err) + } + } +} +``` + +- [ ] **Step 7: 跑测试确认失败** + +Run: `cd ~/code/codes && go get modernc.org/sqlite@latest && go test ./... -run TestApplyMigrationsSQLite -v` +Expected: 编译失败——`ApplyMigrations` 未定义。 + +- [ ] **Step 8: 写 migrations.go** + +```go +package codes + +import ( + "context" + "database/sql" + "embed" + "fmt" + "sort" + "strings" +) + +//go:embed migrations/mysql/*.sql migrations/sqlite/*.sql +var migrationsFS embed.FS + +// ApplyMigrations executes every *.up.sql file for dialect, in lexical +// order, inside one transaction. It's a zero-dependency convenience for +// hosts that don't already run golang-migrate; hosts that do (e.g. +// pangolin) can instead point their own migrate runner at the embedded +// files under "migrations//" via golang-migrate's iofs source. +func ApplyMigrations(ctx context.Context, db *sql.DB, dialect Dialect) error { + dir := "migrations/" + string(dialect) + entries, err := migrationsFS.ReadDir(dir) + if err != nil { + return fmt.Errorf("codes.ApplyMigrations: read %s: %w", dir, err) + } + var files []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".up.sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("codes.ApplyMigrations: begin: %w", err) + } + defer tx.Rollback() + + for _, f := range files { + raw, err := migrationsFS.ReadFile(dir + "/" + f) + if err != nil { + return fmt.Errorf("codes.ApplyMigrations: read %s: %w", f, err) + } + for _, stmt := range splitStatements(string(raw)) { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("codes.ApplyMigrations: exec %s: %w", f, err) + } + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("codes.ApplyMigrations: commit: %w", err) + } + return nil +} + +// splitStatements splits a .sql file's content into individual statements on +// ";" terminators — sufficient for this package's DDL (no semicolons inside +// string literals). +func splitStatements(sqlText string) []string { + parts := strings.Split(sqlText, ";") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} +``` + +- [ ] **Step 9: 跑测试确认通过** + +Run: `cd ~/code/codes && go test ./... -run TestApplyMigrationsSQLite -v` → PASS。 + +- [ ] **Step 10: 写失败测试(Store CRUD)** + +`testdb_test.go`(供本任务及后续任务的包内测试共用): +```go +package codes + +import ( + "context" + "database/sql" + "testing" + + _ "modernc.org/sqlite" +) + +// openTestDB opens an in-memory SQLite DB with the codes schema migrated. +// Shared by every *_test.go in this package (all live in `package codes`, +// not `codes_test`, so this helper is visible package-wide). +func openTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) // :memory:+shared cache: one connection = one schema + t.Cleanup(func() { _ = db.Close() }) + if err := ApplyMigrations(context.Background(), db, DialectSQLite); err != nil { + t.Fatalf("apply migrations: %v", err) + } + return db +} +``` + +`store_test.go`: +```go +package codes + +import ( + "context" + "testing" +) + +func mustDuration(t *testing.T, plan string, days int) Entitlement { + t.Helper() + e, err := NewDurationEntitlement(plan, days) + if err != nil { + t.Fatalf("NewDurationEntitlement: %v", err) + } + return e +} + +func TestStoreCreateBatchAndCode(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + ctx := context.Background() + ent := mustDuration(t, "pro", 30) + + batchID, err := s.CreateBatch(ctx, "manual", ent, "cli", "test batch") + if err != nil { + t.Fatalf("CreateBatch: %v", err) + } + if batchID == 0 { + t.Fatal("batchID should be non-zero") + } + + hash := Hash("ABCDEFGHJKMNPQR0") // any 16-char string is fine here; Store doesn't validate format + if err := s.CreateCode(ctx, hash, batchID, ent); err != nil { + t.Fatalf("CreateCode: %v", err) + } + + got, err := s.FindByHash(ctx, hash) + if err != nil { + t.Fatalf("FindByHash: %v", err) + } + if got == nil { + t.Fatal("FindByHash: not found") + } + if got.Status != StatusUnused || got.BatchID != batchID { + t.Fatalf("got %+v", got) + } + p, err := got.Entitlement.DecodeDuration() + if err != nil || p.Plan != "pro" || p.Days != 30 { + t.Fatalf("entitlement round-trip failed: %+v (err=%v)", p, err) + } + + // duplicate hash -> ErrDuplicate + if err := s.CreateCode(ctx, hash, batchID, ent); err != ErrDuplicate { + t.Fatalf("duplicate CreateCode err = %v, want ErrDuplicate", err) + } + + // FindByHash on unknown hash -> nil, no error + miss, err := s.FindByHash(ctx, "no-such-hash") + if err != nil || miss != nil { + t.Fatalf("FindByHash(unknown) = %+v, %v", miss, err) + } +} + +func TestStoreVoid(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + ctx := context.Background() + ent := mustDuration(t, "pro", 30) + + batchID, _ := s.CreateBatch(ctx, "manual", ent, "cli", "") + hash := Hash("VOIDTESTHASH0001") + if err := s.CreateCode(ctx, hash, batchID, ent); err != nil { + t.Fatalf("CreateCode: %v", err) + } + code, _ := s.FindByHash(ctx, hash) + + ok, err := s.Void(ctx, code.ID, "printing error") + if err != nil || !ok { + t.Fatalf("Void ok=%v err=%v", ok, err) + } + after, _ := s.FindByHash(ctx, hash) + if after.Status != StatusVoid || after.VoidReason != "printing error" { + t.Fatalf("got %+v", after) + } + + // voiding an already-void code is a no-op false, not an error + ok2, err := s.Void(ctx, code.ID, "again") + if err != nil || ok2 { + t.Fatalf("second Void ok=%v err=%v, want false,nil", ok2, err) + } +} + +func TestStoreWriteAudit(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + ctx := context.Background() + + if err := s.WriteAudit(ctx, nil, "cli", "mint", "batch:1", map[string]any{"count": 5}); err != nil { + t.Fatalf("WriteAudit: %v", err) + } + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM codes_audit_log`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Fatalf("audit rows = %d, want 1", n) + } +} +``` + +- [ ] **Step 11: 跑测试确认失败** + +Run: `cd ~/code/codes && go test . -run 'TestStoreCreateBatchAndCode|TestStoreVoid|TestStoreWriteAudit' -v` +Expected: 编译失败——`Store`/`NewStore`/`Code`/`Batch` 未定义。 + +- [ ] **Step 12: 写 store.go** + +```go +package codes + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" +) + +// Code mirrors one `codes` row. Entitlement is decoded from the stored JSON +// envelope; the caller interprets Payload via DecodeDuration/DecodeQuota. +type Code struct { + ID int64 + CodeHash string + BatchID int64 + Entitlement Entitlement + Status Status + RedeemedBy string + RedeemedAt *time.Time + VoidReason string + CreatedAt time.Time +} + +// Batch mirrors one `codes_batches` row. +type Batch struct { + ID int64 + Channel string + Entitlement Entitlement + CreatedBy string + Note string + CreatedAt time.Time +} + +// Store wraps a *sql.DB (host-owned connection pool — mysql or sqlite) and +// exposes every database operation this library needs. Methods taking a +// *sql.Tx run inside that host-managed transaction; others use the pool +// directly. +type Store struct { + db *sql.DB + dialect Dialect +} + +func NewStore(db *sql.DB, dialect Dialect) *Store { return &Store{db: db, dialect: dialect} } + +func (s *Store) CreateBatch(ctx context.Context, channel string, ent Entitlement, createdBy, note string) (int64, error) { + if err := ent.Validate(); err != nil { + return 0, err + } + res, err := s.db.ExecContext(ctx, + `INSERT INTO codes_batches (channel, entitlement_kind, entitlement_payload, created_by, note, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + channel, string(ent.Kind), string(ent.Payload), createdBy, nullableString(note), time.Now().UTC()) + if err != nil { + return 0, fmt.Errorf("codes.Store.CreateBatch: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("codes.Store.CreateBatch last id: %w", err) + } + return id, nil +} + +// CreateCode inserts one codes row. Returns ErrDuplicate on a code_hash +// unique-constraint violation (mysql or sqlite) — the caller should retry +// with a freshly generated code (see Mint). +func (s *Store) CreateCode(ctx context.Context, codeHash string, batchID int64, ent Entitlement) error { + if err := ent.Validate(); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO codes (code_hash, batch_id, entitlement_kind, entitlement_payload, status, created_at) + VALUES (?, ?, ?, ?, 'unused', ?)`, + codeHash, batchID, string(ent.Kind), string(ent.Payload), time.Now().UTC()) + if err != nil { + if isDuplicateKey(err) { + return ErrDuplicate + } + return fmt.Errorf("codes.Store.CreateCode: %w", err) + } + return nil +} + +const selectCodeSQL = `SELECT id, code_hash, batch_id, entitlement_kind, entitlement_payload, status, redeemed_by, redeemed_at, void_reason, created_at FROM codes` + +func (s *Store) FindByHash(ctx context.Context, codeHash string) (*Code, error) { + row := s.db.QueryRowContext(ctx, selectCodeSQL+" WHERE code_hash = ?", codeHash) + return scanCode(row) +} + +// FindByHashForUpdate locks the row for tx's duration (mysql: SELECT…FOR +// UPDATE; sqlite: no clause needed — the write transaction already +// serializes). Call inside the tx passed to Redeem. +func (s *Store) FindByHashForUpdate(ctx context.Context, tx *sql.Tx, codeHash string) (*Code, error) { + q := selectCodeSQL + " WHERE code_hash = ? " + s.dialect.LockForUpdate() + row := tx.QueryRowContext(ctx, q, codeHash) + return scanCode(row) +} + +func scanCode(row *sql.Row) (*Code, error) { + var c Code + var kind, payload string + var redeemedBy, voidReason sql.NullString + var redeemedAt sql.NullTime + err := row.Scan(&c.ID, &c.CodeHash, &c.BatchID, &kind, &payload, &c.Status, + &redeemedBy, &redeemedAt, &voidReason, &c.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("codes.Store: scan code: %w", err) + } + c.Entitlement = Entitlement{Kind: EntitlementKind(kind), Payload: json.RawMessage(payload)} + c.RedeemedBy = redeemedBy.String + if redeemedAt.Valid { + t := redeemedAt.Time + c.RedeemedAt = &t + } + c.VoidReason = voidReason.String + return &c, nil +} + +// MarkRedeemed flips a code to redeemed inside tx. Guarded by +// "AND status='unused'" so a concurrent winner's write can't be clobbered — +// belt-and-suspenders alongside the row lock taken by FindByHashForUpdate. +func (s *Store) MarkRedeemed(ctx context.Context, tx *sql.Tx, id int64, redeemerRef string, at time.Time) error { + _, err := tx.ExecContext(ctx, + `UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=? WHERE id=? AND status='unused'`, + redeemerRef, at, id) + if err != nil { + return fmt.Errorf("codes.Store.MarkRedeemed: %w", err) + } + return nil +} + +// Void marks an unused code void outside any host transaction (an +// independent admin action, not part of a redeem flow). Returns false (no +// error) if the code was not unused. +func (s *Store) Void(ctx context.Context, id int64, reason string) (bool, error) { + res, err := s.db.ExecContext(ctx, + `UPDATE codes SET status='void', void_reason=? WHERE id=? AND status='unused'`, + reason, id) + if err != nil { + return false, fmt.Errorf("codes.Store.Void: %w", err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// WriteAudit inserts an audit row. Pass tx to keep it inside a caller's +// transaction (e.g. Redeem); pass nil to write standalone (e.g. Mint, Void). +func (s *Store) WriteAudit(ctx context.Context, tx *sql.Tx, actor, action, target string, meta map[string]any) error { + metaJSON := "null" + if len(meta) > 0 { + b, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("codes.Store.WriteAudit: marshal meta: %w", err) + } + metaJSON = string(b) + } + type execer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + } + var ex execer = s.db + if tx != nil { + ex = tx + } + if _, err := ex.ExecContext(ctx, + `INSERT INTO codes_audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`, + actor, action, target, metaJSON, time.Now().UTC()); err != nil { + return fmt.Errorf("codes.Store.WriteAudit: %w", err) + } + return nil +} + +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} + +// isDuplicateKey recognises unique-constraint violations across the two +// supported engines without importing either driver package. +func isDuplicateKey(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "Duplicate entry") || + strings.Contains(msg, "1062") || + strings.Contains(msg, "UNIQUE constraint failed") +} +``` + +- [ ] **Step 13: 跑测试确认通过** + +Run: `cd ~/code/codes && go test . -run 'TestStoreCreateBatchAndCode|TestStoreVoid|TestStoreWriteAudit' -v` → PASS。 + +- [ ] **Step 14: 全量编译 + 测试 + go mod tidy** + +Run: `cd ~/code/codes && go mod tidy && go build ./... && go test ./...` +Expected: 编译通过,全部测试 `ok`;`go.mod`/`go.sum` 新增 `modernc.org/sqlite`(仅测试用到)。 + +- [ ] **Step 15: Commit** + +```bash +cd ~/code/codes +git add dialect.go dialect_test.go migrations/ migrations.go migrations_test.go store.go testdb_test.go store_test.go go.mod go.sum +git commit -m "feat: Dialect + 内嵌 mysql/sqlite migrations + Store CRUD(哈希存储/状态机/审计)" +``` + +--- + +### Task 4: Mint — 批次生成(碰撞重试) + +**Files:** +- Create: `mint.go` +- Test: `mint_test.go` + +**Interfaces:** +- Consumes: `Store`(Task3)、`GenerateCode`/`Hash`(Task2)、`Entitlement`(Task1)。 +- Produces: + - `type MintRequest struct{ Channel string; Entitlement Entitlement; Count int; CreatedBy, Note string }` + - `type MintResult struct{ BatchID int64; Codes []string; Channel string; Entitlement Entitlement }` + - `func Mint(ctx context.Context, store *Store, req MintRequest) (*MintResult, error)` + +- [ ] **Step 1: 写失败测试** + +`mint_test.go`: +```go +package codes + +import ( + "context" + "testing" +) + +func TestMintGeneratesRequestedCount(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + ent := mustDuration(t, "team", 90) + + res, err := Mint(context.Background(), s, MintRequest{ + Channel: "store", Entitlement: ent, Count: 5, CreatedBy: "webhook:card-store", Note: "batch A", + }) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if res.BatchID == 0 { + t.Fatal("BatchID should be non-zero") + } + if len(res.Codes) != 5 { + t.Fatalf("len(Codes) = %d, want 5", len(res.Codes)) + } + + seen := make(map[string]struct{}) + for _, plain := range res.Codes { + canonical, err := Canonicalize(plain) + if err != nil || canonical != plain { + t.Fatalf("code %q not canonical: %v", plain, err) + } + if _, dup := seen[plain]; dup { + t.Fatalf("duplicate plaintext %q", plain) + } + seen[plain] = struct{}{} + + row, err := s.FindByHash(context.Background(), Hash(plain)) + if err != nil || row == nil { + t.Fatalf("FindByHash(%q): row=%v err=%v", plain, row, err) + } + if row.Status != StatusUnused || row.BatchID != res.BatchID { + t.Fatalf("row = %+v", row) + } + p, err := row.Entitlement.DecodeDuration() + if err != nil || p.Plan != "team" || p.Days != 90 { + t.Fatalf("entitlement = %+v (err=%v)", p, err) + } + } +} + +func TestMintRejectsInvalidRequest(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + ent := mustDuration(t, "pro", 30) + + if _, err := Mint(context.Background(), s, MintRequest{Channel: "store", Entitlement: ent, Count: 0}); err == nil { + t.Fatal("Count=0 should error") + } + if _, err := Mint(context.Background(), s, MintRequest{Channel: "store", Count: 1}); err == nil { + t.Fatal("empty Entitlement should error") + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go test . -run TestMint -v` +Expected: 编译失败——`Mint`/`MintRequest`/`MintResult` 未定义。 + +- [ ] **Step 3: 写实现** + +`mint.go`: +```go +package codes + +import ( + "context" + "fmt" +) + +// MintRequest describes a batch of Count codes to generate, all sharing one +// Entitlement (design doc §9.2: "购买激活码" = pay 收款 + codes mint 发码). +type MintRequest struct { + Channel string // open string (e.g. "store"/"tg"/"line"/"manual") — host may extend + Entitlement Entitlement + Count int + CreatedBy string + Note string +} + +// MintResult carries the plaintext codes — the ONLY time they ever appear. +// The caller (webhook handler, admin CLI, purchase-fulfillment handler) is +// responsible for delivering them securely; codes never logs or persists +// plaintext. +type MintResult struct { + BatchID int64 + Codes []string + Channel string + Entitlement Entitlement +} + +// Mint generates Count codes for one Entitlement, writes the batch + code +// hashes via store, and returns the plaintext codes. Duplicate-hash retries +// (birthday collision, astronomically unlikely for 75-bit codes) are handled +// automatically up to maxRetries per slot. +func Mint(ctx context.Context, store *Store, req MintRequest) (*MintResult, error) { + const maxRetries = 10 + if req.Count <= 0 { + return nil, fmt.Errorf("codes.Mint: count must be > 0, got %d", req.Count) + } + if err := req.Entitlement.Validate(); err != nil { + return nil, fmt.Errorf("codes.Mint: %w", err) + } + + batchID, err := store.CreateBatch(ctx, req.Channel, req.Entitlement, req.CreatedBy, req.Note) + if err != nil { + return nil, fmt.Errorf("codes.Mint: %w", err) + } + + plaintexts := make([]string, 0, req.Count) + for i := 0; i < req.Count; i++ { + var code string + ok := false + for attempt := 0; attempt < maxRetries; attempt++ { + c, err := GenerateCode() + if err != nil { + return nil, fmt.Errorf("codes.Mint: generate: %w", err) + } + err = store.CreateCode(ctx, Hash(c), batchID, req.Entitlement) + if err == ErrDuplicate { + continue + } + if err != nil { + return nil, fmt.Errorf("codes.Mint: %w", err) + } + code, ok = c, true + break + } + if !ok { + return nil, fmt.Errorf("codes.Mint: exceeded %d retries for slot %d", maxRetries, i) + } + plaintexts = append(plaintexts, code) + } + + return &MintResult{BatchID: batchID, Codes: plaintexts, Channel: req.Channel, Entitlement: req.Entitlement}, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过 + 全量** + +Run: `cd ~/code/codes && go build ./... && go test ./...` +Expected: 全 `ok`。 + +- [ ] **Step 5: Commit** + +```bash +cd ~/code/codes +git add mint.go mint_test.go +git commit -m "feat: Mint 批次生成(碰撞重试,明文只出现一次)" +``` + +--- + +### Task 5: Redeem[T] 兑换事务骨架 + VoidCode(本计划核心) + +**Files:** +- Create: `redeem.go` +- Test: `redeem_test.go` + +**Interfaces:** +- Consumes: `Store`(Task3)、`Entitlement`/`Status`(Task1)。 +- Produces: + - `type GrantFunc[T any] func(ctx context.Context, tx *sql.Tx, code Code) (T, error)` + - `type RedeemResult[T any] struct{ Idempotent bool; Code Code; Grant T }` + - `func Redeem[T any](ctx context.Context, store *Store, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error)` + - `func VoidCode(ctx context.Context, store *Store, codeHash, actor, reason string) error` + +**设计要点**(对应用户给的目标签名 `Redeem(ctx, tx, codeHash, grantFn)`):`tx` 由**宿主**开启并最终 `Commit`/`Rollback`——`Redeem` 本身不管理事务生命周期。这样宿主可以把自己的权益写入(`grant` 回调)和 `Redeem` 内部的码状态翻转放进**同一个事务**,只要两者在同一个 `*sql.DB` 下,提交/回滚就是原子的(设计文档 §9.1 方案 A 的落地)。`grant` 返回任意类型 `T`(Go 泛型),让宿主直接拿到强类型的开通结果(如 `pangolin.SubscriptionID` 或 `jiu.LicenseExpiry`),不用 `any` 断言。 + +- [ ] **Step 1: 写失败测试** + +`redeem_test.go`: +```go +package codes + +import ( + "context" + "database/sql" + "testing" + "time" +) + +// setupHostSubs creates a minimal stand-in for a host's own entitlement +// table (e.g. pangolin's `subscriptions`), living in the SAME db as codes — +// exactly the deployment-A premise this test exercises. +func setupHostSubs(t *testing.T, db *sql.DB) { + t.Helper() + _, err := db.Exec(`CREATE TABLE test_subs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_ref TEXT NOT NULL, expires_at DATETIME NOT NULL)`) + if err != nil { + t.Fatalf("create test_subs: %v", err) + } +} + +// grantDuration is a stand-in GrantFunc: apply "extend by days" against +// test_subs, returning the new expiry. +func grantDuration(days int) GrantFunc[time.Time] { + return func(ctx context.Context, tx *sql.Tx, code Code) (time.Time, error) { + p, err := code.Entitlement.DecodeDuration() + if err != nil { + return time.Time{}, err + } + newExpiry := time.Now().UTC().AddDate(0, 0, p.Days) + if _, err := tx.ExecContext(ctx, + `INSERT INTO test_subs (user_ref, expires_at) VALUES (?, ?)`, + "placeholder", newExpiry); err != nil { + return time.Time{}, err + } + _ = days + return newExpiry, nil + } +} + +func mintOne(t *testing.T, s *Store, ent Entitlement) string { + t.Helper() + res, err := Mint(context.Background(), s, MintRequest{Channel: "manual", Entitlement: ent, Count: 1, CreatedBy: "test"}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + return res.Codes[0] +} + +func TestRedeemSuccessAndIdempotent(t *testing.T) { + db := openTestDB(t) + setupHostSubs(t, db) + s := NewStore(db, DialectSQLite) + ent := mustDuration(t, "pro", 30) + plain := mintOne(t, s, ent) + hash := Hash(plain) + ctx := context.Background() + + // First redeem: succeeds, grant runs once. + tx, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin: %v", err) + } + res, err := Redeem(ctx, s, tx, hash, "user:1", grantDuration(30)) + if err != nil { + t.Fatalf("Redeem: %v", err) + } + if res.Idempotent { + t.Fatal("first redeem should not be idempotent") + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + var subCount int + db.QueryRow(`SELECT COUNT(*) FROM test_subs`).Scan(&subCount) + if subCount != 1 { + t.Fatalf("test_subs rows = %d, want 1", subCount) + } + + // Second redeem, same redeemerRef: idempotent, grant NOT re-invoked. + tx2, _ := db.BeginTx(ctx, nil) + res2, err := Redeem(ctx, s, tx2, hash, "user:1", grantDuration(30)) + if err != nil { + t.Fatalf("Redeem (idempotent): %v", err) + } + if !res2.Idempotent { + t.Fatal("second redeem by same user should be idempotent") + } + tx2.Commit() + + db.QueryRow(`SELECT COUNT(*) FROM test_subs`).Scan(&subCount) + if subCount != 1 { + t.Fatalf("test_subs rows after idempotent redeem = %d, want still 1 (grant must not re-run)", subCount) + } +} + +func TestRedeemDifferentUserFails(t *testing.T) { + db := openTestDB(t) + setupHostSubs(t, db) + s := NewStore(db, DialectSQLite) + ent := mustDuration(t, "pro", 30) + plain := mintOne(t, s, ent) + hash := Hash(plain) + ctx := context.Background() + + tx, _ := db.BeginTx(ctx, nil) + if _, err := Redeem(ctx, s, tx, hash, "user:1", grantDuration(30)); err != nil { + t.Fatalf("first redeem: %v", err) + } + tx.Commit() + + tx2, _ := db.BeginTx(ctx, nil) + defer tx2.Rollback() + if _, err := Redeem(ctx, s, tx2, hash, "user:2", grantDuration(30)); err != ErrCodeRedeemed { + t.Fatalf("err = %v, want ErrCodeRedeemed", err) + } +} + +func TestRedeemGrantFailureRollsBackWholeTx(t *testing.T) { + db := openTestDB(t) + setupHostSubs(t, db) + s := NewStore(db, DialectSQLite) + ent := mustDuration(t, "pro", 30) + plain := mintOne(t, s, ent) + hash := Hash(plain) + ctx := context.Background() + + failingGrant := func(ctx context.Context, tx *sql.Tx, code Code) (time.Time, error) { + return time.Time{}, sql.ErrTxDone // any error stands in for "host's grant failed" + } + + tx, _ := db.BeginTx(ctx, nil) + _, err := Redeem(ctx, s, tx, hash, "user:1", failingGrant) + if err == nil { + t.Fatal("expected grant error to propagate") + } + if rbErr := tx.Rollback(); rbErr != nil { + t.Fatalf("rollback: %v", rbErr) + } + + // Code must still be unused — the whole tx (code state + grant) rolled back atomically. + row, _ := s.FindByHash(ctx, hash) + if row.Status != StatusUnused { + t.Fatalf("code status after rollback = %s, want unused", row.Status) + } + var subCount int + db.QueryRow(`SELECT COUNT(*) FROM test_subs`).Scan(&subCount) + if subCount != 0 { + t.Fatalf("test_subs rows after rollback = %d, want 0", subCount) + } +} + +func TestRedeemNotFoundAndVoid(t *testing.T) { + db := openTestDB(t) + setupHostSubs(t, db) + s := NewStore(db, DialectSQLite) + ent := mustDuration(t, "pro", 30) + ctx := context.Background() + + tx, _ := db.BeginTx(ctx, nil) + defer tx.Rollback() + if _, err := Redeem(ctx, s, tx, "no-such-hash", "user:1", grantDuration(30)); err != ErrCodeNotFound { + t.Fatalf("err = %v, want ErrCodeNotFound", err) + } + + plain := mintOne(t, s, ent) + hash := Hash(plain) + if err := VoidCode(ctx, s, hash, "admin:1", "compromised batch"); err != nil { + t.Fatalf("VoidCode: %v", err) + } + tx2, _ := db.BeginTx(ctx, nil) + defer tx2.Rollback() + if _, err := Redeem(ctx, s, tx2, hash, "user:1", grantDuration(30)); err != ErrCodeVoid { + t.Fatalf("err = %v, want ErrCodeVoid", err) + } + + // voiding an already-redeemed code is rejected + plain2 := mintOne(t, s, ent) + hash2 := Hash(plain2) + tx3, _ := db.BeginTx(ctx, nil) + if _, err := Redeem(ctx, s, tx3, hash2, "user:9", grantDuration(30)); err != nil { + t.Fatalf("redeem plain2: %v", err) + } + tx3.Commit() + if err := VoidCode(ctx, s, hash2, "admin:1", "too late"); err != ErrNotVoidable { + t.Fatalf("err = %v, want ErrNotVoidable", err) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go test . -run TestRedeem -v` +Expected: 编译失败——`Redeem`/`GrantFunc`/`VoidCode` 未定义。 + +- [ ] **Step 3: 写实现** + +`redeem.go`: +```go +package codes + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// GrantFunc performs the host's local entitlement write (extend a pangolin +// subscription row, bump a jiu license, …) inside the SAME *sql.Tx as the +// code-state flip, so grant + redeem commit atomically as long as the +// host's entitlement table lives in the same database as the codes tables +// (design doc §9.1 — the entire premise of deployment option A). +type GrantFunc[T any] func(ctx context.Context, tx *sql.Tx, code Code) (T, error) + +// RedeemResult is Redeem's outcome. +type RedeemResult[T any] struct { + // Idempotent is true when redeemerRef already redeemed this code; Grant + // is the zero value in that case — grant is intentionally NOT re-run, so + // the host must not assume Grant reflects current state on a replay. + Idempotent bool + Code Code + Grant T +} + +// Redeem executes one redemption attempt inside the caller-owned transaction +// tx. The caller owns tx's lifecycle (BeginTx/Commit/Rollback) — this lets +// the host fold its own entitlement writes (via grant) and any other +// business writes into the exact same local transaction. +// +// Flow: SELECT…FOR UPDATE (locks the row for tx's duration) → state-machine +// check → idempotency short-circuit → grant(ctx, tx, code) → MarkRedeemed → +// audit log. Any returned error means the caller must roll back tx; Redeem +// itself never commits or rolls back. +func Redeem[T any](ctx context.Context, store *Store, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error) { + var zero T + + code, err := store.FindByHashForUpdate(ctx, tx, codeHash) + if err != nil { + return nil, err + } + if code == nil { + return nil, ErrCodeNotFound + } + + switch code.Status { + case StatusRedeemed: + if code.RedeemedBy == redeemerRef { + return &RedeemResult[T]{Idempotent: true, Code: *code, Grant: zero}, nil + } + return nil, ErrCodeRedeemed + case StatusVoid: + return nil, ErrCodeVoid + } + + grantResult, err := grant(ctx, tx, *code) + if err != nil { + return nil, fmt.Errorf("codes.Redeem: grant: %w", err) + } + + now := time.Now().UTC() + if err := store.MarkRedeemed(ctx, tx, code.ID, redeemerRef, now); err != nil { + return nil, err + } + if err := store.WriteAudit(ctx, tx, redeemerRef, "redeem", fmt.Sprintf("code:%d", code.ID), map[string]any{ + "batch_id": code.BatchID, + "kind": string(code.Entitlement.Kind), + }); err != nil { + return nil, err + } + + code.Status = StatusRedeemed + code.RedeemedBy = redeemerRef + code.RedeemedAt = &now + return &RedeemResult[T]{Idempotent: false, Code: *code, Grant: grantResult}, nil +} + +// VoidCode marks an unused code void (printing error, compromised batch) so +// it can never be redeemed. Standalone admin action — not part of a Redeem +// flow, so it manages its own (non-transactional) writes. Returns +// ErrCodeNotFound / ErrNotVoidable as appropriate. +func VoidCode(ctx context.Context, store *Store, codeHash, actor, reason string) error { + code, err := store.FindByHash(ctx, codeHash) + if err != nil { + return err + } + if code == nil { + return ErrCodeNotFound + } + if !code.Status.Voidable() { + return ErrNotVoidable + } + ok, err := store.Void(ctx, code.ID, reason) + if err != nil { + return err + } + if !ok { + return ErrNotVoidable + } + return store.WriteAudit(ctx, nil, actor, "void", fmt.Sprintf("code:%d", code.ID), map[string]any{"reason": reason}) +} +``` + +- [ ] **Step 4: 跑测试确认通过 + 全量** + +Run: `cd ~/code/codes && go build ./... && go test ./... -v` +Expected:`TestRedeemSuccessAndIdempotent` / `TestRedeemDifferentUserFails` / `TestRedeemGrantFailureRollsBackWholeTx` / `TestRedeemNotFoundAndVoid` 全 PASS,其余任务测试保持绿。 + +- [ ] **Step 5: Commit** + +```bash +cd ~/code/codes +git add redeem.go redeem_test.go +git commit -m "feat: Redeem[T] 兑换事务骨架(锁+CAS+幂等+宿主grant回调)+ VoidCode" +``` + +--- + +### Task 6: RateLimiter + NonceChecker 接口(零依赖默认实现)+ GuardedRedeem + +**Files:** +- Create: `ratelimit.go` +- Create: `noncecheck.go` +- Create: `guard.go` +- Test: `guard_test.go` + +**Interfaces:** +- Produces: + - `type RateLimiter interface{ Allowed(ctx, key string) (bool, error); RecordFailure(ctx, key string) error; Reset(ctx, key string) error }` + - `type NoopRateLimiter struct{}`(始终放行,零依赖默认值) + - `type NonceChecker interface{ SeenOrStore(ctx, nonce string, ttl time.Duration) (bool, error) }` + - `type InMemoryNonceChecker struct{...}` · `func NewInMemoryNonceChecker() *InMemoryNonceChecker`(单进程默认值,供 webhook/测试用) + - `func GuardedRedeem[T any](ctx, store *Store, limiter RateLimiter, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error)` + +这两个接口**只在这一步定义**,是为了让 Task 7 的 `redisx` 子包(Redis 限流 + 去重)和 Task 8 的 webhook 处理器都能实现/依赖同一份契约,而不必反向依赖子包。 + +- [ ] **Step 1: 写失败测试** + +`guard_test.go`: +```go +package codes + +import ( + "context" + "database/sql" + "errors" + "sync" + "testing" + "time" +) + +// fakeLimiter is an in-memory RateLimiter test double counting failures. +type fakeLimiter struct { + mu sync.Mutex + fails map[string]int + failMax int + resets int +} + +func newFakeLimiter(failMax int) *fakeLimiter { + return &fakeLimiter{fails: make(map[string]int), failMax: failMax} +} +func (f *fakeLimiter) Allowed(_ context.Context, key string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.fails[key] < f.failMax, nil +} +func (f *fakeLimiter) RecordFailure(_ context.Context, key string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.fails[key]++ + return nil +} +func (f *fakeLimiter) Reset(_ context.Context, key string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.fails[key] = 0 + f.resets++ + return nil +} + +func TestGuardedRedeemLocksOutAfterFailures(t *testing.T) { + db := openTestDB(t) + setupHostSubs(t, db) + s := NewStore(db, DialectSQLite) + limiter := newFakeLimiter(2) + ctx := context.Background() + + grantCalls := 0 + grant := GrantFunc[int](func(ctx context.Context, tx *sql.Tx, code Code) (int, error) { + grantCalls++ + return 0, nil + }) + + // two failed attempts against a nonexistent code hash + for i := 0; i < 2; i++ { + tx, _ := db.BeginTx(ctx, nil) + _, err := GuardedRedeem(ctx, s, limiter, tx, "missing", "user:1", grant) + tx.Rollback() + if err != ErrCodeNotFound { + t.Fatalf("attempt %d: err = %v, want ErrCodeNotFound", i, err) + } + } + + // third attempt: locked out before ever touching the DB/grant + tx, _ := db.BeginTx(ctx, nil) + defer tx.Rollback() + _, err := GuardedRedeem(ctx, s, limiter, tx, "missing", "user:1", grant) + if err != ErrLocked { + t.Fatalf("err = %v, want ErrLocked", err) + } + if grantCalls != 0 { + t.Fatalf("grant should never have been called, got %d calls", grantCalls) + } +} + +func TestGuardedRedeemResetsOnSuccess(t *testing.T) { + db := openTestDB(t) + setupHostSubs(t, db) + s := NewStore(db, DialectSQLite) + limiter := newFakeLimiter(5) + ctx := context.Background() + ent := mustDuration(t, "pro", 30) + plain := mintOne(t, s, ent) + hash := Hash(plain) + + tx, _ := db.BeginTx(ctx, nil) + _, err := GuardedRedeem(ctx, s, limiter, tx, hash, "user:1", grantDuration(30)) + if err != nil { + t.Fatalf("GuardedRedeem: %v", err) + } + tx.Commit() + + if limiter.resets != 1 { + t.Fatalf("resets = %d, want 1", limiter.resets) + } +} + +func TestNoopRateLimiterNeverBlocks(t *testing.T) { + var l RateLimiter = NoopRateLimiter{} + ok, err := l.Allowed(context.Background(), "anyone") + if err != nil || !ok { + t.Fatalf("NoopRateLimiter.Allowed = %v, %v", ok, err) + } + if err := l.RecordFailure(context.Background(), "anyone"); err != nil { + t.Fatalf("RecordFailure: %v", err) + } +} + +func TestInMemoryNonceCheckerDedup(t *testing.T) { + c := NewInMemoryNonceChecker() + ctx := context.Background() + dup1, err := c.SeenOrStore(ctx, "n1", time.Minute) + if err != nil || dup1 { + t.Fatalf("first SeenOrStore: dup=%v err=%v", dup1, err) + } + dup2, err := c.SeenOrStore(ctx, "n1", time.Minute) + if err != nil || !dup2 { + t.Fatalf("second SeenOrStore: dup=%v err=%v, want dup=true", dup2, err) + } + dup3, err := c.SeenOrStore(ctx, "n2", time.Minute) + if err != nil || dup3 { + t.Fatalf("different nonce: dup=%v err=%v", dup3, err) + } + _ = errors.New // silence unused import if trimmed later +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go test . -run 'TestGuardedRedeem|TestNoopRateLimiter|TestInMemoryNonceChecker' -v` +Expected: 编译失败——`RateLimiter`/`GuardedRedeem`/`NonceChecker` 未定义。 + +- [ ] **Step 3: 写实现** + +`ratelimit.go`: +```go +package codes + +import "context" + +// RateLimiter guards redemption attempts against brute-force guessing. +// NoopRateLimiter (the zero-dependency default) never blocks; production +// hosts running Redis should use codes/redisx.RateLimiter instead — kept in +// a separate subpackage so importing the core codes package never pulls in +// a Redis client. +type RateLimiter interface { + // Allowed reports whether key (typically redeemerRef) may attempt a + // redemption right now. + Allowed(ctx context.Context, key string) (bool, error) + // RecordFailure registers one failed attempt for key. + RecordFailure(ctx context.Context, key string) error + // Reset clears key's failure count (called after a successful redeem). + Reset(ctx context.Context, key string) error +} + +// NoopRateLimiter never blocks and never errors — the default when a host +// doesn't want (or doesn't yet have) rate limiting. +type NoopRateLimiter struct{} + +func (NoopRateLimiter) Allowed(context.Context, string) (bool, error) { return true, nil } +func (NoopRateLimiter) RecordFailure(context.Context, string) error { return nil } +func (NoopRateLimiter) Reset(context.Context, string) error { return nil } +``` + +`noncecheck.go`: +```go +package codes + +import ( + "context" + "sync" + "time" +) + +// NonceChecker deduplicates webhook deliveries. InMemoryNonceChecker is a +// single-process default (fine for one webhook instance, or tests); +// multi-instance deployments should use codes/redisx.NonceChecker instead. +type NonceChecker interface { + // SeenOrStore returns true if nonce was already seen (and does NOT + // store it again); otherwise stores it with ttl and returns false. + SeenOrStore(ctx context.Context, nonce string, ttl time.Duration) (bool, error) +} + +// InMemoryNonceChecker is a sync.Map-backed NonceChecker with lazy TTL +// sweep. Not safe across multiple process instances behind a load balancer +// — use codes/redisx.NonceChecker there. +type InMemoryNonceChecker struct { + mu sync.Mutex + seen map[string]time.Time +} + +func NewInMemoryNonceChecker() *InMemoryNonceChecker { + return &InMemoryNonceChecker{seen: make(map[string]time.Time)} +} + +func (c *InMemoryNonceChecker) SeenOrStore(_ context.Context, nonce string, ttl time.Duration) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + for n, exp := range c.seen { + if now.After(exp) { + delete(c.seen, n) + } + } + if exp, ok := c.seen[nonce]; ok && now.Before(exp) { + return true, nil + } + c.seen[nonce] = now.Add(ttl) + return false, nil +} +``` + +`guard.go`: +```go +package codes + +import ( + "context" + "database/sql" +) + +// GuardedRedeem wraps Redeem with a RateLimiter fail-lock, mirroring +// pangolin's per-user redeem lockout: ErrLocked is returned without +// touching the DB (grant is never invoked) if key has exceeded its failure +// budget; any Redeem error records one failure; success or an idempotent +// replay resets the counter. +func GuardedRedeem[T any](ctx context.Context, store *Store, limiter RateLimiter, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error) { + if limiter == nil { + limiter = NoopRateLimiter{} + } + allowed, err := limiter.Allowed(ctx, redeemerRef) + if err != nil { + return nil, err + } + if !allowed { + return nil, ErrLocked + } + + result, err := Redeem(ctx, store, tx, codeHash, redeemerRef, grant) + if err != nil { + if recErr := limiter.RecordFailure(ctx, redeemerRef); recErr != nil { + return nil, recErr + } + return nil, err + } + if resetErr := limiter.Reset(ctx, redeemerRef); resetErr != nil { + return nil, resetErr + } + return result, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过 + 全量** + +Run: `cd ~/code/codes && go build ./... && go test ./... -v` +Expected: 全 PASS(注意 `guard_test.go` 里未用到的 `errors` import 若报错,直接删掉那行 `_ = errors.New` 占位与对应 import——写实现时不要引入不必要的 import)。 + +- [ ] **Step 5: Commit** + +```bash +cd ~/code/codes +git add ratelimit.go noncecheck.go guard.go guard_test.go +git commit -m "feat: RateLimiter/NonceChecker 接口(零依赖默认实现)+ GuardedRedeem 失败锁定" +``` + +--- + +### Task 7: redisx 子包(可选)— Redis 限流 + 去重(miniredis 测试,免 docker) + +**Files:** +- Create: `redisx/ratelimit.go` +- Create: `redisx/nonce.go` +- Test: `redisx/ratelimit_test.go` +- Test: `redisx/nonce_test.go` + +**Interfaces:** +- Consumes: `codes.RateLimiter` / `codes.NonceChecker` 接口(Task6)。 +- Produces: + - `type redisx.RateLimiter struct{...}` · `func redisx.NewRateLimiter(rdb *redis.Client, prefix string, failMax int, lockDur time.Duration) *RateLimiter`(实现 `codes.RateLimiter`) + - `type redisx.NonceChecker struct{...}` · `func redisx.NewNonceChecker(rdb *redis.Client, prefix string) *NonceChecker`(实现 `codes.NonceChecker`) + +**这是唯一 import Redis 客户端的地方**——不 import `codes/redisx` 的宿主完全不产生 Redis 依赖。逻辑移植自 pangolin `internal/codes/service.go` 的 `redisKeyFail`/`isLocked`/`recordFail`/`clearFail`(失败计数器 + TTL)与 `webhook.go` 的 `checkAndStoreNonce`(`SET NX` 原子去重)。 + +- [ ] **Step 1: 写失败测试(RateLimiter)** + +`redisx/ratelimit_test.go`: +```go +package redisx_test + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/wangjia/codes/redisx" +) + +func newTestRedis(t *testing.T) *redis.Client { + t.Helper() + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis.Run: %v", err) + } + t.Cleanup(mr.Close) + return redis.NewClient(&redis.Options{Addr: mr.Addr()}) +} + +func TestRateLimiterLocksAfterFailMax(t *testing.T) { + rdb := newTestRedis(t) + rl := redisx.NewRateLimiter(rdb, "test:fail:", 3, time.Hour) + ctx := context.Background() + + for i := 0; i < 3; i++ { + allowed, err := rl.Allowed(ctx, "user:1") + if err != nil || !allowed { + t.Fatalf("attempt %d: allowed=%v err=%v", i, allowed, err) + } + if err := rl.RecordFailure(ctx, "user:1"); err != nil { + t.Fatalf("RecordFailure: %v", err) + } + } + allowed, err := rl.Allowed(ctx, "user:1") + if err != nil || allowed { + t.Fatalf("after 3 failures: allowed=%v err=%v, want false", allowed, err) + } + + // a different key is unaffected + allowed2, _ := rl.Allowed(ctx, "user:2") + if !allowed2 { + t.Fatal("user:2 should be unaffected by user:1's failures") + } +} + +func TestRateLimiterReset(t *testing.T) { + rdb := newTestRedis(t) + rl := redisx.NewRateLimiter(rdb, "test:fail:", 2, time.Hour) + ctx := context.Background() + + rl.RecordFailure(ctx, "user:1") + rl.RecordFailure(ctx, "user:1") + if allowed, _ := rl.Allowed(ctx, "user:1"); allowed { + t.Fatal("should be locked before reset") + } + if err := rl.Reset(ctx, "user:1"); err != nil { + t.Fatalf("Reset: %v", err) + } + if allowed, err := rl.Allowed(ctx, "user:1"); err != nil || !allowed { + t.Fatalf("after reset: allowed=%v err=%v", allowed, err) + } +} +``` + +`redisx/nonce_test.go`: +```go +package redisx_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/codes/redisx" +) + +func TestNonceCheckerDedup(t *testing.T) { + rdb := newTestRedis(t) + nc := redisx.NewNonceChecker(rdb, "test:nonce:") + ctx := context.Background() + + dup1, err := nc.SeenOrStore(ctx, "n1", time.Minute) + if err != nil || dup1 { + t.Fatalf("first: dup=%v err=%v", dup1, err) + } + dup2, err := nc.SeenOrStore(ctx, "n1", time.Minute) + if err != nil || !dup2 { + t.Fatalf("second (replay): dup=%v err=%v, want true", dup2, err) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go get github.com/redis/go-redis/v9@latest github.com/alicebob/miniredis/v2@latest && go test ./redisx/... -v` +Expected: 编译失败——`redisx` 包不存在。 + +- [ ] **Step 3: 写实现** + +`redisx/ratelimit.go`: +```go +// Package redisx provides Redis-backed implementations of codes.RateLimiter +// and codes.NonceChecker. This is the ONLY package in the module that +// imports a Redis client — hosts that don't import redisx never pull Redis +// into their dependency graph. +package redisx + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// RateLimiter implements codes.RateLimiter with a per-key failure counter + +// TTL, ported from pangolin's redeem lockout +// (internal/codes/service.go: redisKeyFail/isLocked/recordFail/clearFail). +type RateLimiter struct { + rdb *redis.Client + prefix string + failMax int + lockDur time.Duration +} + +func NewRateLimiter(rdb *redis.Client, prefix string, failMax int, lockDur time.Duration) *RateLimiter { + if failMax <= 0 { + failMax = 5 + } + if lockDur <= 0 { + lockDur = time.Hour + } + if prefix == "" { + prefix = "codes:redeem:fail:" + } + return &RateLimiter{rdb: rdb, prefix: prefix, failMax: failMax, lockDur: lockDur} +} + +func (r *RateLimiter) key(k string) string { return r.prefix + k } + +func (r *RateLimiter) Allowed(ctx context.Context, key string) (bool, error) { + val, err := r.rdb.Get(ctx, r.key(key)).Int() + if err == redis.Nil { + return true, nil + } + if err != nil { + return false, fmt.Errorf("redisx.RateLimiter.Allowed: %w", err) + } + return val < r.failMax, nil +} + +func (r *RateLimiter) RecordFailure(ctx context.Context, key string) error { + k := r.key(key) + pipe := r.rdb.Pipeline() + pipe.Incr(ctx, k) + pipe.Expire(ctx, k, r.lockDur) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("redisx.RateLimiter.RecordFailure: %w", err) + } + return nil +} + +func (r *RateLimiter) Reset(ctx context.Context, key string) error { + if err := r.rdb.Del(ctx, r.key(key)).Err(); err != nil { + return fmt.Errorf("redisx.RateLimiter.Reset: %w", err) + } + return nil +} +``` + +`redisx/nonce.go`: +```go +package redisx + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// NonceChecker implements codes.NonceChecker via Redis SET NX — atomic +// check-and-store, safe across multiple webhook-handler instances. Ported +// from pangolin's webhook.go: checkAndStoreNonce. +type NonceChecker struct { + rdb *redis.Client + prefix string +} + +func NewNonceChecker(rdb *redis.Client, prefix string) *NonceChecker { + if prefix == "" { + prefix = "codes:webhook:nonce:" + } + return &NonceChecker{rdb: rdb, prefix: prefix} +} + +func (c *NonceChecker) SeenOrStore(ctx context.Context, nonce string, ttl time.Duration) (bool, error) { + set, err := c.rdb.SetNX(ctx, c.prefix+nonce, "1", ttl).Result() + if err != nil { + return false, fmt.Errorf("redisx.NonceChecker.SeenOrStore: %w", err) + } + return !set, nil // SetNX returns true when newly set (not a duplicate) +} +``` + +- [ ] **Step 4: 跑测试确认通过 + 全量** + +Run: `cd ~/code/codes && go mod tidy && go build ./... && go test ./...` +Expected: 全 `ok`;根包(不 import `redisx`)编译产物不含 Redis 客户端符号。 + +- [ ] **Step 5: Commit** + +```bash +cd ~/code/codes +git add redisx/ go.mod go.sum +git commit -m "feat(redisx): 可选 Redis 限流+去重实现(miniredis 测试,免 docker;根包零 Redis 依赖)" +``` + +--- + +### Task 8: webhook 灌码 — 通用 HMAC 签名 + 去重 + 通用权益负载 → Mint + +**Files:** +- Create: `webhook.go` +- Test: `webhook_test.go` + +**Interfaces:** +- Consumes: `Store`/`Mint`(Task3/4)、`Canonicalize`/`Hash`(Task2)、`NonceChecker`/`InMemoryNonceChecker`(Task6)。 +- Produces: + - `func VerifyWebhookSignature(secret []byte, system, timestamp, nonce string, rawBody []byte, gotSig string) error` + - `type MintPayload struct{ Code, Channel string; Entitlement Entitlement; Note string }` + - `type WebhookHandler struct{...}` · `func NewWebhookHandler(store *Store, nonces NonceChecker, system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler`(实现 `http.Handler`) + +**签名选型说明**:pangolin 现有 webhook(`internal/codes/webhook.go`)只对 body 做 HMAC,timestamp/nonce 通过独立请求头校验、不进 MAC。本库改用 **pay-contract 既有的双向签名惯例**(`sign = base64(HMAC_SHA256(secret, system+"\n"+timestamp+"\n"+nonce+"\n"+rawBody))`,见 `~/code/pay-contract/README.md` §3)——把 system/timestamp/nonce 也纳入 MAC,防止头部被篡改而签名仍验证通过;同时让"codes 灌码"与"pay 回调"这两类跨产品 webhook 用同一套验签心智模型,降低多产品接入的认知负担。这是本任务对 pangolin 原实现的一处**有意偏离**,已在 Self-Review 中记录。 + +- [ ] **Step 1: 写失败测试** + +`webhook_test.go`: +```go +package codes + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func signRequest(t *testing.T, secret, system, ts, nonce string, body []byte) string { + t.Helper() + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(system + "\n" + ts + "\n" + nonce + "\n")) + mac.Write(body) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func newSignedRequest(t *testing.T, secret, system, nonce string, body []byte) *http.Request { + t.Helper() + ts := strconv.FormatInt(time.Now().Unix(), 10) + sig := signRequest(t, secret, system, ts, nonce, body) + req := httptest.NewRequest(http.MethodPost, "/webhook/codes", strings.NewReader(string(body))) + req.Header.Set("X-Pay-Timestamp", ts) + req.Header.Set("X-Pay-Nonce", nonce) + req.Header.Set("X-Pay-Sign", sig) + return req +} + +func TestWebhookMintsCodeOnValidRequest(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) + + ent := mustDuration(t, "pro", 30) + payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) + req := newSignedRequest(t, "s3cret", "card-store", "nonce-1", payload) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + canonical, _ := Canonicalize("ABCDEFGHJKMNPQR0") + row, err := s.FindByHash(context.Background(), Hash(canonical)) + if err != nil || row == nil { + t.Fatalf("code not stored: row=%v err=%v", row, err) + } +} + +func TestWebhookReplayedNonceIsIgnored(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) + ent := mustDuration(t, "pro", 30) + payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) + + req1 := newSignedRequest(t, "s3cret", "card-store", "nonce-dup", payload) + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusCreated { + t.Fatalf("first request status = %d", rec1.Code) + } + + req2 := newSignedRequest(t, "s3cret", "card-store", "nonce-dup", payload) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("replayed nonce status = %d, want 200", rec2.Code) + } + if !strings.Contains(rec2.Body.String(), "duplicate_ignored") { + t.Fatalf("body = %s", rec2.Body.String()) + } +} + +func TestWebhookRejectsBadSignature(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) + ent := mustDuration(t, "pro", 30) + payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) + + req := newSignedRequest(t, "WRONG-secret", "card-store", "nonce-2", payload) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestWebhookRejectsStaleTimestamp(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) + ent := mustDuration(t, "pro", 30) + payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) + + staleTS := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10) + sig := signRequest(t, "s3cret", "card-store", staleTS, "nonce-3", payload) + req := httptest.NewRequest(http.MethodPost, "/webhook/codes", strings.NewReader(string(payload))) + req.Header.Set("X-Pay-Timestamp", staleTS) + req.Header.Set("X-Pay-Nonce", "nonce-3") + req.Header.Set("X-Pay-Sign", sig) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestWebhookRejectsInvalidEntitlement(t *testing.T) { + db := openTestDB(t) + s := NewStore(db, DialectSQLite) + h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) + + payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store"}) // empty Entitlement + req := newSignedRequest(t, "s3cret", "card-store", "nonce-4", payload) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd ~/code/codes && go test . -run TestWebhook -v` +Expected: 编译失败——`WebhookHandler`/`MintPayload`/`VerifyWebhookSignature` 未定义。 + +- [ ] **Step 3: 写实现** + +`webhook.go`: +```go +package codes + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// VerifyWebhookSignature checks the pay-contract HMAC convention: +// +// sign = base64(HMAC_SHA256(secret, system+"\n"+timestamp+"\n"+nonce+"\n"+rawBody)) +// +// shared with pay's own callback signing (~/code/pay-contract §3), so every +// cross-product inbound webhook is verified the same way. Binding +// system/timestamp/nonce into the MAC (unlike pangolin's original body-only +// HMAC) means a tampered header invalidates the signature too. +func VerifyWebhookSignature(secret []byte, system, timestamp, nonce string, rawBody []byte, gotSig string) error { + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(system + "\n" + timestamp + "\n" + nonce + "\n")) + mac.Write(rawBody) + want := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(gotSig)) { + return fmt.Errorf("codes: webhook signature mismatch") + } + return nil +} + +func verifyTimestamp(tsStr string, tolerance time.Duration) error { + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + return fmt.Errorf("codes: invalid webhook timestamp: %w", err) + } + diff := time.Since(time.Unix(ts, 0)) + if diff < 0 { + diff = -diff + } + if diff > tolerance { + return fmt.Errorf("codes: webhook timestamp outside tolerance") + } + return nil +} + +// MintPayload is the JSON body an external card store posts to mint one +// code. Entitlement is the generic descriptor (entitlement.go) — the +// webhook never hardcodes plan/days, so the same endpoint serves duration +// and quota products alike. +type MintPayload struct { + Code string `json:"code"` + Channel string `json:"channel"` + Entitlement Entitlement `json:"entitlement"` + Note string `json:"note,omitempty"` +} + +// WebhookHandler implements POST /webhook/codes ingestion from a card +// store. Headers follow pay-contract naming: X-Pay-Timestamp / X-Pay-Nonce / +// X-Pay-Sign. +type WebhookHandler struct { + store *Store + nonces NonceChecker + secret []byte + system string + timestampTolerance time.Duration + nonceTTL time.Duration + createdBy string +} + +// NewWebhookHandler creates a handler. Pass nonces=nil to use the +// single-process InMemoryNonceChecker default; multi-instance deployments +// should pass a codes/redisx.NonceChecker instead. +func NewWebhookHandler(store *Store, nonces NonceChecker, system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler { + if nonces == nil { + nonces = NewInMemoryNonceChecker() + } + return &WebhookHandler{ + store: store, nonces: nonces, system: system, secret: []byte(secret), + timestampTolerance: tolerance, nonceTTL: nonceTTL, createdBy: "webhook:" + system, + } +} + +func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + ts := r.Header.Get("X-Pay-Timestamp") + nonce := r.Header.Get("X-Pay-Nonce") + sig := r.Header.Get("X-Pay-Sign") + if ts == "" || nonce == "" || sig == "" { + http.Error(w, "missing signature headers", http.StatusBadRequest) + return + } + if err := VerifyWebhookSignature(h.secret, h.system, ts, nonce, body, sig); err != nil { + http.Error(w, "signature mismatch", http.StatusUnauthorized) + return + } + if err := verifyTimestamp(ts, h.timestampTolerance); err != nil { + http.Error(w, "stale timestamp", http.StatusUnauthorized) + return + } + + ctx := r.Context() + dup, err := h.nonces.SeenOrStore(ctx, nonce, h.nonceTTL) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if dup { + writeJSON(w, http.StatusOK, map[string]string{"status": "duplicate_ignored"}) + return + } + + var payload MintPayload + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, "bad payload", http.StatusBadRequest) + return + } + if payload.Code == "" || payload.Channel == "" { + http.Error(w, "missing code/channel", http.StatusBadRequest) + return + } + if err := payload.Entitlement.Validate(); err != nil { + http.Error(w, "invalid entitlement: "+err.Error(), http.StatusBadRequest) + return + } + + canonical, err := Canonicalize(payload.Code) + if err != nil { + http.Error(w, "invalid code format", http.StatusBadRequest) + return + } + + h.mintOrAck(ctx, w, canonical, payload) +} + +// mintOrAck writes one code (one batch per webhook call, mirroring +// pangolin's original behavior) and responds; ErrDuplicate is treated as an +// idempotent no-op ack, not an error. +func (h *WebhookHandler) mintOrAck(ctx context.Context, w http.ResponseWriter, canonical string, payload MintPayload) { + batchID, err := h.store.CreateBatch(ctx, payload.Channel, payload.Entitlement, h.createdBy, payload.Note) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + err = h.store.CreateCode(ctx, Hash(canonical), batchID, payload.Entitlement) + if err == ErrDuplicate { + writeJSON(w, http.StatusOK, map[string]string{"status": "already_exists"}) + return + } + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "created"}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} +``` + +- [ ] **Step 4: 跑测试确认通过 + 全量** + +Run: `cd ~/code/codes && go build ./... && go test ./... -v` +Expected: 全部任务的测试(Task1–8)一并跑绿。 + +- [ ] **Step 5: Commit** + +```bash +cd ~/code/codes +git add webhook.go webhook_test.go +git commit -m "feat: webhook 灌码(pay-contract 式通用 HMAC 签名 + 去重 + 通用权益负载 → Mint)" +``` + +--- + +## Self-Review + +**Spec coverage(用户 P7 范围逐条核对):** +- 独立 Go module/包 + 选型理由 → 「部署与模块选型」章节 ✓(独立仓 `github.com/wangjia/codes`,非 pay 子包/非 pangolin 内嵌,理由:依赖隔离 + 设计文档 §9.1 方案 A)。 +- 码模型 + 状态机 + 生成器(Crockford Base32+校验位,crypto/rand)+ 哈希存储(SHA-256,明文不落库) → Task1(状态机/错误)+ Task2(生成器,移植 pangolin idgen)+ Task3(Store,`code_hash` 唯一约束,明文只在 `Mint`/webhook 请求体经过内存,从不写库/写日志)✓。 +- 兑换事务骨架 `Redeem(ctx, tx, codeHash, grantFn)` → Task5,签名对齐用户给出的形态(`Redeem[T any](ctx, store, tx, codeHash, redeemerRef, grant)`——多出的 `store`/`redeemerRef` 是必要的接收者与幂等键,`grant` 即 `grantFn`),锁(`FindByHashForUpdate`+dialect)、CAS(`MarkRedeemed` 的 `AND status='unused'`)、幂等(同 `redeemerRef` 短路)三者齐全,`TestRedeemGrantFailureRollsBackWholeTx` 专门验证"宿主本地事务"的原子性承诺 ✓。 +- 批次 + 审计 → Task3(`CreateBatch`/`WriteAudit`)+ Task4(`Mint` 内的批次生成)✓。 +- 发卡店 webhook 灌码(HMAC) → Task8,选用 pay-contract 既有多产品签名惯例(而非原样照抄 pangolin 的 body-only HMAC),偏离已在 Task8 开头注明理由 ✓。 +- 通用权益描述符(不硬编码 plan+days,支持时长/额度) → Task1 `Entitlement{Kind,Payload}` + `DurationPayload`/`QuotaPayload`,对应设计文档 §12 的两种 entitlement 形态 ✓。 +- Redis 限流(可选,标注) → Task6(接口 + 零依赖默认值)+ Task7(`redisx` 子包,唯一 import Redis 的地方,不引用就不产生依赖)✓,明确标注"可选"。 + +**未覆盖 / 有意排除(见 Global Constraints 末尾"本计划范围之外"):** admin 批次列表/CSV 导出(pangolin 已有实现,宿主可自行在 `Store` 基础方法上拼,不进本库以保持库精简);pangolin/jiu 迁移到 import 本库(独立后续任务,本计划只交付库本身);独立服务化方案 B、reseller 门户、优惠券变体(设计文档标注 later)。这些都不是用户列出的 P7 范围条目。 + +**Placeholder scan:** 全部 8 个任务均给出完整 Go 实现 + 完整测试,无 TODO/占位;唯一需要人工确认的是 Task1 Step 0 的 Gitea 建仓(网络操作,不属于代码)。 + +**Type consistency 关键点:** +- `Entitlement`(Task1)→ 贯穿 `Code.Entitlement`(Task3)、`MintRequest.Entitlement`/`MintResult.Entitlement`(Task4)、`GrantFunc[T]` 的 `code Code` 参数(Task5)、`MintPayload.Entitlement`(Task8),全程同一类型,无中途转换丢信息。 +- `Status`(Task1)三态与 `store.go`/`redeem.go` 的 SQL `status` 列字符串值(`'unused'`/`'redeemed'`/`'void'`)逐字一致。 +- `Redeem`/`GuardedRedeem` 的泛型签名 `[T any]` 在 Task5→Task6(`GuardedRedeem` 包一层)→ 测试(`grantDuration(days int) GrantFunc[time.Time]`)全程一致,未出现 `any` 断言噩梦。 +- `Dialect`(Task3)贯穿 `Store`/`FindByHashForUpdate`/`ApplyMigrations`,mysql/sqlite 两分支的 SQL 均已给出(mysql 分支未在 CI 用真实 MySQL 验证——本计划测试全跑 sqlite `:memory:`,mysql 语法只做静态审阅;这与 pangolin 现有 `run_mysql_test.sh` 需要 docker 的取舍一致,MySQL 集成验证留给宿主接入时用真实 MySQL 跑一次 `ApplyMigrations`)。 + +**一处需要宿主在真正 import 时注意(非本库缺陷,写进这里防止遗忘)**:`Redeem`/`GuardedRedeem` 要求 `tx` 与 `store` 底层 `*sql.DB` 同源,且宿主的 `grant` 回调里的写操作必须使用**传入的同一个 `tx`**(而不是另开一个 `store.db.Exec(...)`)——否则会静默丢失"本地事务"的原子性保证,退化成两个独立事务。这一点在 `GrantFunc` 的类型签名(强制传入 `*sql.Tx` 而非允许拿到 `*sql.DB`)里已经做了预防,但仍建议后续给宿主的接入文档里用大写加粗强调一次。 + +--- + +## 后续阶段(接续 P1 末尾"后续阶段"列表) + +- P2–P6:见 `2026-07-10-pay-v2-p1-core-model.md` 末尾(Provider 抽象/首批渠道/退款/多账户路由/对账 job)。 +- **P7(本计划)**:codes 共享库,独立仓 `github.com/wangjia/codes`,不改动 pay/pangolin/jiu 任何现有代码。 +- **P8(later)**:订阅/recurring(4 类 kind)、拒付 chargeback(见设计文档 §5.1)。 +- **未编号后续(brain todo,不在 pay 的 P1–P8 序列里)**:pangolin `server/internal/codes/` 迁移为 import `github.com/wangjia/codes`(替换 `plan_id+duration_days` 为 `Entitlement`,`grantFn` 实现 pangolin 的订阅叠加算法);jiu 门店 license 接入同一个库;codes 优惠券变体(设计文档 §7)。均待本计划落地验证后再排期。