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 @@
+
← 返回文档索引
+
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);Store 的 CreateBatch/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"归属维度留给各产品"的落地 |
+
+
+
+
范围之外(有意排除)
+
+- admin 批次列表/CSV 导出(pangolin 已有
admin_support.go/export.go,宿主可自行在 Store 基础方法上拼)
+- pangolin/jiu 迁移到 import 本库(独立后续任务,brain todo)
+- 独立服务化方案 B、reseller 门户、优惠券变体(设计文档标注 later)
+
+
+
相关: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-