diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md b/docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md new file mode 100644 index 0000000..51fe802 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md @@ -0,0 +1,1674 @@ +# pay v2 · P3 首批渠道 adapter(crypto / 支付宝 / Stripe)+ P2 延后项收口 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. 每个 Task 自包含,给完整 Go 代码 + 测试 + 命令,可交给全新 implementer subagent。 + +> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`(重点 §4 Provider 接口 / render_type、§5 webhook v2 event_type/product_biz_code、§3 Order/Attempt/Account/多币种)。 +> **前置计划:** +> - `docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md`(P1:`internal/model/v2.go`、`internal/store`、`internal/money`、`internal/accounts`)。 +> - `docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md`(P2:`internal/provider`、`internal/gateway`、webhook v2 outbox;**本计划直接复用其接口,不重造**)。本计划收口 P2 Self-Review 记录的三处「已知 scope 取舍」:order 补 `biz_code`、多币种 product、webhook payload 补 `product_biz_code`。 + +**Goal:** 在 P2 的 Provider 抽象 + 收款/入账管线上,落地**首批 3 个真实渠道 adapter**——crypto 自托管(USDT-TRC20 单地址收款,链上扫描查单)、支付宝(迁移 v1 `internal/channel/alipay.go` 的验签/查单逻辑成 Provider)、Stripe(Checkout Session 跳转 + webhook 验签)——各自实现 `provider.Provider` 并按配置 `Register` 进注册表;同时收口 P2 有意延后的三项:`OrderV2.BizCode` 列 + webhook payload `product_biz_code`、多币种 product 模型 + `ProductResolver` 升级。真实密钥全走 env,测试全程 `httptest` 假渠道 + 免 docker,**不打真网**。 + +**Architecture:** 渠道差异封死在各 adapter 的 `Create/VerifyCallback/Query`(设计 §4.1),`internal/gateway` 与 `internal/provider` 保持中性,不 import 任何具体渠道。三个 adapter 落在 `internal/provider/{crypto,alipay,stripe}`,与 `fake` 平级。凭证一律经 `accounts.Registry.Credential(accountID, key)` 从 env 读(`_`),config.yaml 只存 env 前缀。多账户回调验签靠**装配期把已解析的渠道客户端注入 adapter**(P2 Self-Review D4 记录的方案:接口不变,adapter 持凭证/客户端)。多币种:新增 `model.ProductPrice`(product_id×currency→amount_minor)子表,`Product.Price`(v1 元 string)保留为 CNY 默认;`ProductResolver.Resolve(sku, currency)` 由**下单时选中的渠道结算币种**驱动(`provider.Capabilities().SettleCurrencies[0]`)。 + +**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite · `github.com/smartwalle/alipay/v3 v3.2.29`(已在 go.mod,v1 复用)· **新增** `github.com/stripe/stripe-go/v79`(pin,见 Task 5)· crypto 仅用标准库(`net/http`/`crypto/rand`)扫 TronGrid REST(逻辑移植自 pangolin 仓 ref `origin/worktree-macos-killswitch:pay/`,不 import pangolin)。 + +## Global Constraints(继承 P2) + +- **复用 P1/P2,不重造**:金额 `AmountMinor int64 + Currency` 码(`internal/money`,`Parse(s,cur)`/`Format(minor,cur)`,CNY/USD=2 位、USDT=6 位);模型 `model.OrderV2`/`model.Attempt` + `V2` 后缀状态;数据访问 `store.OrderStore`;账户 `accounts.Registry.EnabledFor`/`Credential` + `config.AccountConfig`;管线 `gateway.Gateway`(`CreateOrder`/`Settle`/`HandleCallback`/`SyncPendingAttempts`);Provider 抽象 `provider.Provider`/`Session`/`PaidEvent`/`CreateRequest`/`CallbackInput`/`QueryRequest`/`Capabilities`/`Registry`。 +- **金额权威在 pay**:客户端传 `sku`(product biz_code),金额由 `ProductResolver` 服务端按结算币种解析,**绝不接受裸传金额**。金额一律 int64 最小单位 + 币种码,禁 float。 +- **Provider 中性**:adapter 只依赖 `internal/provider`(+ 自己的渠道 SDK / 标准库 + `internal/money` + `internal/accounts`);**`internal/gateway` 绝不 import 任何 adapter**。`internal/provider` 核心包不 import 任何 adapter(adapter 反向依赖核心包)。 +- **凭证 env only**:所有密钥经 `accounts.Registry.Credential(accountID, key)` 或装配期注入,严禁写进 config.yaml / 落库明文 / 提交 git。测试用 `t.Setenv` / 生成临时密钥。 +- **入账/幂等/金额核对不变**:入账仍走 P2 `gateway.Settle`(先幂等入队 webhook、再 `MarkAttemptPaid` 条件 UPDATE 翻转;`PaidCurrency==attempt.Currency` 且 `PaidAmountMinor>=attempt.AmountMinor`)。adapter 只负责产出归一化 `PaidEvent`,不碰入账。 +- **GORM AutoMigrate 惯例**:新增列(`OrderV2.BizCode`)/新表(`ProductPrice`)由 `db.AutoMigrate` 增量建,**AutoMigrate 只加不删**,对存量库安全;模型内嵌 `model.Base`。 +- 每步 `go build ./...` 通过;测试 `go test ./...`(全部 `:memory:`/临时 sqlite + `httptest`,**免 docker、不打真网**)。每任务严格 bite-sized TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。禁占位。 + +--- + +### Task 1: 收口延后项 A —— OrderV2 加 biz_code + webhook payload 带 product_biz_code + +**Files:** +- Modify: `internal/model/v2.go`(`OrderV2` 加 `BizCode` 列) +- Modify: `internal/gateway/gateway.go`(`CreateOrder` 捕获 resolver 的 bizCode 并写入 order) +- Modify: `internal/gateway/settle.go`(`enqueuePaymentSucceeded` payload 加 `product_biz_code`) +- Test: `internal/gateway/settle_test.go`(断言 payload 含 `product_biz_code`)、`internal/gateway/gateway_test.go`(断言 order.BizCode 落库) + +**Interfaces:** +- `ProductResolver.Resolve` 签名此 Task **不变**(仍 `Resolve(sku string) (amountMinor int64, currency, subject, bizCode string, err error)`,第 4 返回值 bizCode 已存在,P2 gateway 用 `_` 丢弃)。本 Task 只把它接起来。多币种改签名在 Task 2。 + +- [ ] **Step 1: 写失败测试** + +在 `internal/gateway/gateway_test.go` 追加: +```go +func TestCreateOrderPersistsBizCode(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("create: %v", err) + } + o, err := orders.GetOrder(res.OrderNo) + if err != nil { + t.Fatalf("get order: %v", err) + } + if o.BizCode != "pro_year" { // stubResolver 的 bizCode + t.Fatalf("order.BizCode = %q, want pro_year", o.BizCode) + } +} +``` + +在 `internal/gateway/settle_test.go` 里,找到断言 webhook payload 的成功用例(`payment.succeeded` 那条),追加对 `product_biz_code` 的断言。若现有用例的 order 没有 BizCode,先在其建单处补 `BizCode` 字段。新增独立用例更省事: +```go +func TestSettleWebhookCarriesProductBizCode(t *testing.T) { + g, fp, spy, orders := newGateway(t) + res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{ + SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-9", + }) + att := firstPendingAttempt(t, orders) // 复用 P2 gateway_test 里取待处理尝试的 helper + fp.SetQueryResult(att.ProviderRef, provider.PaidEvent{ + ProviderRef: att.ProviderRef, Status: provider.PaidSucceeded, + PaidAmountMinor: 29990000, PaidCurrency: "USDT", + }) + if _, err := g.SyncPendingAttempts(context.Background(), 10); err != nil { + t.Fatalf("sync: %v", err) + } + if len(spy.calls) != 1 { + t.Fatalf("want 1 webhook, got %d", len(spy.calls)) + } + if spy.calls[0]["product_biz_code"] != "pro_year" { + t.Fatalf("payload product_biz_code = %v, want pro_year", spy.calls[0]["product_biz_code"]) + } + _ = res +} +``` +> 注:`firstPendingAttempt` 若 P2 gateway_test 未提供,用 `orders.ListAttemptsByStatus(model.AttemptPending, 10)` 取第一条;stubResolver 返回 `("pro_year")` 时 subject/bizCode 均为 `pro_year`(见 P2 gateway_test 里的 `stubResolver.Resolve`)。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run 'BizCode|ProductBizCode' -v` +Expected: 编译/断言失败 —— `OrderV2` 无 `BizCode` 字段 / payload 无 `product_biz_code`。 + +- [ ] **Step 3: 写实现** + +`internal/model/v2.go` —— `OrderV2` 结构体 `ProductID` 之后加一列: +```go + ProductID uint64 `gorm:"index"` + BizCode string `gorm:"index;size:64"` // 套餐码副本(下单时从 product 复制),回调带回业务方按码映射权益(设计 §5) + Subject string `gorm:"size:128"` +``` + +`internal/gateway/gateway.go` —— `CreateOrder` 捕获 bizCode 并写入(改 2 处): +```go + amountMinor, currency, subject, bizCode, err := g.products.Resolve(in.SKU) + if err != nil { + return nil, err // ErrProductNotFound + } +``` +```go + if err := g.orders.CreateOrder(&model.OrderV2{ + OutTradeNo: outNo, BizSystem: in.BizSystem, BizRef: in.BizRef, + BizCode: bizCode, Subject: subject, AmountMinor: amountMinor, Currency: currency, + Status: model.OrderPendingV2, + }); err != nil { + return nil, err + } +``` + +`internal/gateway/settle.go` —— `enqueuePaymentSucceeded` 的 `data` map 加一行: +```go + data := map[string]any{ + "event_type": "payment.succeeded", + "out_trade_no": o.OutTradeNo, + "biz_system": o.BizSystem, + "biz_ref": o.BizRef, + "product_biz_code": o.BizCode, // 设计 §5:业务方按套餐码映射权益(时长/档位),不硬编码 product_id + "amount_minor": o.AmountMinor, + "currency": o.Currency, + "channel": att.Channel, + "paid_at": paidAt.Format(time.RFC3339), + } +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/gateway/... -v` +Expected: 全 PASS(含 P2 既有用例)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/model/v2.go internal/gateway/gateway.go internal/gateway/settle.go internal/gateway/gateway_test.go internal/gateway/settle_test.go +git commit -m "feat(v2): order 补 biz_code + webhook payload 带 product_biz_code(收口 P2 延后项)" +``` + +--- + +### Task 2: 收口延后项 B —— 多币种 product 模型 + ProductResolver 按结算币种解析 + +**Files:** +- Create: `internal/model/product_price.go`(`ProductPrice` 子表) +- Modify: `internal/gateway/gateway.go`(`ProductResolver` 接口改签名 `Resolve(sku, currency)`;`CreateOrder` 币种由渠道结算币种驱动;`RetryOrder` 跨币种守卫) +- Modify: `internal/gateway/product.go`(`DBProductResolver` 查 `ProductPrice`,缺行回退 `Product.Price` 元 string 作 CNY 默认) +- Modify: `internal/gateway/gateway_test.go`(stubResolver 跟签名) +- Test: `internal/gateway/product_test.go`(多币种解析 + v1 回退)、`internal/gateway/gateway_test.go`(渠道结算币种驱动) + +**决策(多币种模型,兼容 v1 元 string):** +1. **新表 `ProductPrice{ProductID, Currency, AmountMinor int64}`**,`uniqueIndex(product_id, currency)`。这是权威分币价目表(int64 最小单位,与 P2 money 口径一致,不用元 string,免二次解析/浮点)。 +2. **`Product.Price`(v1 元 string)保留不动**,语义收敛为「**CNY 默认价**」——当某币种在 `ProductPrice` 无显式行且币种为 CNY 时,回退 `money.Parse(Product.Price, "CNY")`。存量 v1 数据零改动即得 CNY 价;新增 USD/USDT 价只往 `ProductPrice` 插行。 +3. **币种由下单选中的渠道决定**,不由 client 传:`gateway.CreateOrder` 先 `providers.Get(method)` → `Capabilities().SettleCurrencies[0]` 得结算币种 → `products.Resolve(sku, currency)`。渠道自述能力(crypto=USDT / alipay=CNY / stripe=USD)是唯一真相源。 +4. **`ProductResolver.Resolve` 改签名**:`Resolve(sku, currency string) (amountMinor int64, subject, bizCode string, err error)`——currency 变**入参**(调用方按渠道给定),不再是出参;某 sku 无该币种价 → `ErrProductNotFound`(下发「该套餐不支持此支付方式的结算币种」)。 + +- [ ] **Step 1: 写失败测试** + +`internal/gateway/product_test.go`(新增/追加): +```go +package gateway_test + +import ( + "testing" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" +) + +func TestDBProductResolverMultiCurrency(t *testing.T) { + db := model.OpenTestDB(t) + // 一个套餐:CNY 走 v1 Price 元 string 回退;USD/USDT 走 ProductPrice。 + p := model.Product{Name: "Pro 年付", Price: "199.00", BizCode: "pro_year", Active: true, MerchantID: 1} + if err := db.Create(&p).Error; err != nil { + t.Fatal(err) + } + rows := []model.ProductPrice{ + {ProductID: p.ID, Currency: "USD", AmountMinor: 2999}, + {ProductID: p.ID, Currency: "USDT", AmountMinor: 29990000}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + r := gateway.NewDBProductResolver(db) + + // CNY 回退 Product.Price("199.00" 元 → 19900 分) + if amt, _, code, err := r.Resolve("pro_year", "CNY"); err != nil || amt != 19900 || code != "pro_year" { + t.Fatalf("CNY resolve = %d,%q,%v want 19900,pro_year,nil", amt, code, err) + } + // USD 走 ProductPrice + if amt, _, _, err := r.Resolve("pro_year", "USD"); err != nil || amt != 2999 { + t.Fatalf("USD resolve = %d,%v want 2999", amt, err) + } + // USDT 走 ProductPrice + if amt, _, _, err := r.Resolve("pro_year", "USDT"); err != nil || amt != 29990000 { + t.Fatalf("USDT resolve = %d,%v want 29990000", amt, err) + } + // 不支持的币种 → ErrProductNotFound + if _, _, _, err := r.Resolve("pro_year", "JPY"); err != gateway.ErrProductNotFound { + t.Fatalf("JPY resolve err = %v want ErrProductNotFound", err) + } +} +``` + +在 `internal/gateway/gateway_test.go`:把 `stubResolver` 改跟新签名,并加渠道结算币种驱动用例。fake 的 `Capabilities().SettleCurrencies=["USDT"]`,故下单币种应为 USDT: +```go +type stubResolver struct{} + +func (stubResolver) Resolve(sku, currency string) (int64, string, string, error) { + if sku != "pro_year" { + return 0, "", "", gateway.ErrProductNotFound + } + // 结算币种驱动金额:USDT 6 位, 其余按分。测试只用 fake(USDT)。 + switch currency { + case "USDT": + return 29990000, "Pro 年付", "pro_year", nil + case "CNY": + return 19900, "Pro 年付", "pro_year", nil + default: + return 0, "", "", gateway.ErrProductNotFound + } +} + +func TestCreateOrderCurrencyFromChannelCapability(t *testing.T) { + g, _, _, orders := newGateway(t) // fake provider, SettleCurrencies=["USDT"] + res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake"}) + if err != nil { + t.Fatalf("create: %v", err) + } + o, _ := orders.GetOrder(res.OrderNo) + if o.Currency != "USDT" || o.AmountMinor != 29990000 { + t.Fatalf("order = %s/%d want USDT/29990000", o.Currency, o.AmountMinor) + } +} +``` +> P2 gateway_test 里其它调用 `stubResolver.Resolve(...)` 或断言 currency 的用例,一并跟签名/期望值(fake=USDT)修正。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run 'MultiCurrency|CurrencyFromChannel' -v` +Expected: 编译失败(签名不符 / `ProductPrice` 不存在 / `NewDBProductResolver` 参数变化)。 + +- [ ] **Step 3: 写实现** + +`internal/model/product_price.go`: +```go +package model + +// ProductPrice 是套餐的分币种权威价目(int64 最小单位,与 money 口径一致)。 +// v1 Product.Price(元 string)保留为 CNY 默认价:某币种无此表行且币种=CNY 时回退解析 Price。 +// 新增 USD/USDT 等结算币种只需往本表插行,不动 Product。 +type ProductPrice struct { + Base + ProductID uint64 `gorm:"uniqueIndex:uq_product_currency;not null" json:"product_id"` + Currency string `gorm:"uniqueIndex:uq_product_currency;size:16;not null" json:"currency"` + AmountMinor int64 `gorm:"not null" json:"amount_minor"` +} +``` + +`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 按 biz_code 解析套餐,金额取给定结算币种的权威价。 +// 优先查 ProductPrice(分币种 int64 价);该币种无行且币种=CNY 时回退 Product.Price(v1 元 string)。 +type DBProductResolver struct{ db *gorm.DB } + +func NewDBProductResolver(db *gorm.DB) *DBProductResolver { return &DBProductResolver{db: db} } + +func (r *DBProductResolver) Resolve(sku, currency string) (int64, 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 + } + // 1) 分币种权威价 + var pp model.ProductPrice + err = r.db.Where("product_id = ? AND currency = ?", p.ID, currency).First(&pp).Error + if err == nil { + return pp.AmountMinor, p.Name, p.BizCode, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, "", "", err + } + // 2) 回退:仅 CNY 用 v1 Product.Price(元 string) + if currency == "CNY" && p.Price != "" { + minor, perr := money.Parse(p.Price, "CNY") + if perr != nil { + return 0, "", "", perr + } + return minor, p.Name, p.BizCode, nil + } + // 该套餐不支持此结算币种 + return 0, "", "", ErrProductNotFound +} +``` + +`internal/gateway/gateway.go` —— 接口 + `CreateOrder` + `RetryOrder`: +```go +// ProductResolver maps a client SKU + settlement currency to authoritative amount. +// Currency is chosen by the selected channel's SettleCurrencies (设计 §3.1/§4.1), +// never sent by the client. +type ProductResolver interface { + Resolve(sku, currency string) (amountMinor int64, subject, bizCode string, err error) +} +``` +加一个错误值(与既有 `ErrProductNotFound` 等并列): +```go + ErrNoSettleCurrency = errors.New("gateway: channel has no settle currency") + ErrCurrencyMismatch = errors.New("gateway: retry method settles a different currency") +``` +`CreateOrder` 改为**先取 provider、由其结算币种解析**(替换开头到建单段): +```go +func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderResult, error) { + prov, err := g.providers.Get(in.Method) + if err != nil { + return nil, err // ErrUnknownMethod + } + caps := prov.Capabilities() + if len(caps.SettleCurrencies) == 0 { + return nil, ErrNoSettleCurrency + } + currency := caps.SettleCurrencies[0] // 结算币种由渠道自述能力驱动(设计 §4.1) + amountMinor, subject, bizCode, err := g.products.Resolve(in.SKU, currency) + if err != nil { + return nil, err // ErrProductNotFound(含"该币种无价") + } + accts := g.accounts.EnabledFor(in.Method, g.region) + if len(accts) == 0 { + return nil, ErrNoAccount + } + acct := accts[0] // 路由策略在 P5;P3 取首个 enabled。 + + outNo := util.NewOutTradeNo("pay") + if err := g.orders.CreateOrder(&model.OrderV2{ + OutTradeNo: outNo, BizSystem: in.BizSystem, BizRef: in.BizRef, + BizCode: bizCode, Subject: subject, AmountMinor: amountMinor, Currency: currency, + Status: model.OrderPendingV2, + }); err != nil { + return nil, err + } + // ...(prov.Create + CreateAttempt 段落不变,复用 P2 代码)... +``` +> 下方 `prov.Create(...)`/`CreateAttempt(...)`/`return` 段落**保持 P2 原样**(已用 `prov`/`subject`/`amountMinor`/`currency`/`acct` 局部变量,变量名全部对齐,无需再改)。 + +`RetryOrder` 加跨币种守卫(在取到 `prov` 之后、`ExpirePendingAttempts` 之前): +```go + prov, err := g.providers.Get(method) + if err != nil { + return nil, err + } + caps := prov.Capabilities() + if len(caps.SettleCurrencies) == 0 || caps.SettleCurrencies[0] != o.Currency { + // 换到结算币种不同的渠道重试 = 需重定价,超出 P3 范围(P5 多币种路由)。 + return nil, ErrCurrencyMismatch + } +``` + +`main.go` 装配处 `gateway.NewDBProductResolver(db, "CNY")` 改为 `gateway.NewDBProductResolver(db)`(去掉币种参数;真正装配在 Task 6 整体重写,此处先跟签名保证 `go build` 通过)。`autoMigrate` 的 `db.AutoMigrate(...)` 参数表加 `&model.ProductPrice{}`。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/gateway/... -v` +Expected: 全 PASS。`main.go` 编译通过(handler 层不受影响:currency 不在 handler 出参)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/model/product_price.go internal/gateway/ main.go +git commit -m "feat(v2): 多币种 product(ProductPrice 分币价目 + v1 元回退)+ 结算币种由渠道能力驱动" +``` + +--- + +### Task 3: crypto adapter —— USDT-TRC20 自托管收款(单地址 + 唯一金额,移植 pangolin-pay) + +**Files:** +- Create: `internal/provider/crypto/crypto.go`(`provider.Provider` 实现 + TronGrid 客户端) +- Test: `internal/provider/crypto/crypto_test.go`(`httptest` 假 TronGrid + 边界) + +**Interfaces:** +- Consumes: `provider.*`、`accounts.Registry`(按 AccountID 取地址/TronGrid key)、`internal/money`。 +- Produces:`func New(accts *accounts.Registry, opts ...Option) *Provider`(`Method()=="crypto"`,render_type=`crypto_address`);`Option`:`WithBaseURL(string)`/`WithHTTPClient(*http.Client)`。 + +**决策(移植 pangolin-pay「单地址 + 唯一金额」收款模型):** +> **源码溯源**:canonical 实现在 pangolin 仓 git ref `origin/worktree-macos-killswitch:pay/`(工作树里没有;读法 `git -C /Users/wangjia/code/pangolin show origin/worktree-macos-killswitch:pay/`)。本 Task 移植其三块核心逻辑:`internal/pay/service.go`(`allocateAmount` 唯一金额分配+冷却预留)、`internal/watcher/watcher.go`(`Tick` 精确金额+块时匹配)、`internal/tron/client.go`(`IncomingTransfers` TronGrid 查询形态),适配到 `provider.Provider` + `AccountConfig`。**pay 不 import pangolin**,逻辑移植不是代码依赖。 + +1. **账户 = 单一固定收款地址**(canonical:钱包 A 的地址 0,`m/44'/195'/0'/0/0`;xpub 派生/selfcheck 属 pangolin-pay 钱包侧离线工具,pay 只拿最终地址):`AccountConfig` 只存 `CredentialEnvPrefix`,真值从 env 取——地址 `_ADDRESS`、TronGrid key `_TRONGRID_KEY`(缺则回退全局 env `TRONGRID_API_KEY`)。经 `accounts.Registry.Credential(accountID, "ADDRESS"/"TRONGRID_KEY")`。 +2. **并发单区分 = 唯一金额(base + 随机尾数)**,canonical `allocateAmount` 原样移植:尾数 `crypto/rand` 均匀取 `[1, 9999]` 微 USDT(≤0.01 USDT),**金额冷却预留** 30min(须 > 支付窗 15min,盖住迟到付款窗,旧款绝不可能匹配到复用金额的新单),同(地址,金额)在冷却窗内不复用,64 次重试分配。canonical 用自库 `AmountRecentlyUsed` 查重;adapter 移植为**进程内预留表**(`map[(accountID,amount)]→到期时刻` + 互斥锁,pay 单实例部署等价)——重启丢预留窗的残余碰撞概率 ≈ 并发同价单数/9999,记硬化项(P6 可改查 attempts 表)。 +3. **尾数经 ProviderRef 往返(不改 attempt 金额)**:P2 冻结契约里 `attempt.AmountMinor`=product 基准价,adapter 无法回写;故 `ProviderRef = "CRYPTO--"`,Create 把分配到的尾数编进 ref 落库(attempt.provider_ref),Query 从 `req.ProviderRef` 解析尾数、期望金额 = `req.AmountMinor + tail`。因 tail≥1,`PaidAmountMinor(=期望)>attempt.AmountMinor(=base)`,`Settle` 的 `>=` 金额校验天然通过。 +4. **查单(`Query`)= canonical watcher 匹配**:`GET {base}/v1/accounts/{address}/transactions/trc20?only_confirmed=true&contract_address=&limit=50`,`TRON-PRO-API-KEY` 头带 key。**确认标准 = TronGrid `only_confirmed=true`(已固化区块)**,不自算确认数/成熟度(canonical 原样)。命中条件(canonical `Tick`):`to==本地址` ∧ `type=="Transfer"` ∧ `value == 期望金额(精确相等,微 USDT=链上原始整数)` ∧ `block_timestamp/1000 > CreatedAt.Unix()`(**块时必须晚于建单**,拒绝迟到旧款误配)。命中→`PaidEvent{succeeded, PaidAmountMinor=value, PaidCurrency="USDT", PaidAt=块时, Raw=tx_id}`;否则 `pending`。 +5. **`VerifyCallback` 不适用**:自托管无渠道异步回调(canonical 就是 watcher 轮询,对应 pay 的 `SyncPendingAttempts`)。返回 `provider.ErrNotSupported`——`/api/v2/callback/crypto` 即便被打也安全拒。 +6. **orphan 到账不归 adapter**:canonical 把匹配不到活跃单的已确认到账记 `orphan_payments` 人工对账;pay v2 里 `Query` 是单尝试视角,无此全局职责——orphan 落表归 **P6 对账 job**(记入 Self-Review 硬化项)。 +7. **支付窗**:`Session.ExpiresAt = now + 15min`(canonical `OrderTTL`)。**USDT 合约**:主网 TRC20 = `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`(6 位小数,最小单位与 money USDT 一致)。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/crypto/crypto_test.go`: +```go +package crypto_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/crypto" +) + +const addr = "TWreceiveADDRESS0000000000000000000" + +func newProv(t *testing.T, ts *httptest.Server) *crypto.Provider { + t.Setenv("CRY_ADDRESS", addr) + t.Setenv("CRY_TRONGRID_KEY", "test-key") + reg := accounts.New([]config.AccountConfig{ + {AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}, + }) + return crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client())) +} + +// 唯一金额分配(canonical allocateAmount 语义):同价并发单分到不同尾数金额, +// 尾数 ∈ [1,9999] 微USDT,且经 ProviderRef 往返。 +func TestCreateAllocatesUniqueTailedAmount(t *testing.T) { + ts := httptest.NewServer(http.NotFoundHandler()) // Create 不打网 + defer ts.Close() + p := newProv(t, ts) + + mk := func(outNo string) *provider.Session { + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: outNo, AmountMinor: 29990000, Currency: "USDT", + Account: config.AccountConfig{AccountID: "cry-1", CredentialEnvPrefix: "cry"}, + }) + if err != nil { + t.Fatalf("create %s: %v", outNo, err) + } + return sess + } + s1, s2 := mk("PAY-A"), mk("PAY-B") + + if s1.RenderType != provider.RenderCryptoAddress { + t.Fatalf("render_type = %s", s1.RenderType) + } + if s1.Payload["address"] != addr { + t.Fatalf("address = %v want %s", s1.Payload["address"], addr) + } + a1, _ := s1.Payload["amount_minor"].(int64) + a2, _ := s2.Payload["amount_minor"].(int64) + for _, a := range []int64{a1, a2} { + if a <= 29990000 || a > 29990000+9999 { + t.Fatalf("amount_minor = %d 不在 (base, base+9999]", a) + } + } + if a1 == a2 { + t.Fatalf("冷却窗内两单分到同一唯一金额: %d(预留失效)", a1) + } + // 尾数编进 provider_ref,Query 端可复原期望金额 + if want := fmt.Sprintf("CRYPTO-PAY-A-%d", a1-29990000); s1.ProviderRef != want { + t.Fatalf("provider_ref = %s want %s", s1.ProviderRef, want) + } + if s1.ExpiresAt == nil { + t.Fatal("应带 15min 支付窗 ExpiresAt") + } +} + +// canonical watcher 匹配:only_confirmed + 精确金额 + 块时晚于建单。 +func TestQueryMatchesConfirmedExactAmount(t *testing.T) { + const base, tail = int64(29990000), int64(777) + expected := base + tail + created := time.Now().Add(-10 * time.Minute) + req := provider.QueryRequest{ + ProviderRef: "CRYPTO-PAY-A-777", OutTradeNo: "PAY-A", AccountID: "cry-1", + AmountMinor: base, Currency: "USDT", CreatedAt: created, + } + + run := func(value, blockMs int64) (*provider.PaidEvent, url.Values) { + var seen url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.URL.Query() + if r.Header.Get("TRON-PRO-API-KEY") == "" { + http.Error(w, "no key", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "data": []map[string]any{{ + "transaction_id": "tx1", + "to": addr, + "type": "Transfer", + "value": strconv.FormatInt(value, 10), + "block_timestamp": blockMs, // 毫秒(TronGrid 口径),adapter 内 /1000 成秒 + }}, + }) + })) + defer ts.Close() + p := newProv(t, ts) + ev, err := p.Query(context.Background(), req) + if err != nil { + t.Fatalf("query: %v", err) + } + return ev, seen + } + + // A. 已确认 + 金额精确 + 块时晚于建单 → succeeded + ev, seen := run(expected, time.Now().Add(-2*time.Minute).UnixMilli()) + if ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != expected || ev.PaidCurrency != "USDT" { + t.Fatalf("A: event = %+v", ev) + } + if ev.PaidAt == nil || ev.Raw != "tx1" { + t.Fatalf("A: PaidAt/Raw = %+v", ev) + } + if seen.Get("only_confirmed") != "true" { + t.Fatalf("必须带 only_confirmed=true(canonical 确认标准), query = %v", seen) + } + + // B. 金额差 1 微USDT → pending(唯一金额精确匹配,不误判) + if ev, _ := run(expected-1, time.Now().Add(-2*time.Minute).UnixMilli()); ev.Status != provider.PaidPending { + t.Fatalf("B: 金额不符应 pending, got %+v", ev) + } + + // C. 块时早于建单(迟到旧款/金额复用场景)→ pending + if ev, _ := run(expected, created.Add(-time.Hour).UnixMilli()); ev.Status != provider.PaidPending { + t.Fatalf("C: 块时早于建单应 pending, got %+v", ev) + } +} + +func TestVerifyCallbackNotSupported(t *testing.T) { + ts := httptest.NewServer(http.NotFoundHandler()) + defer ts.Close() + p := newProv(t, ts) + if _, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte("{}")}); err == nil { + t.Fatal("crypto 无异步回调,VerifyCallback 应返回 ErrNotSupported") + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/crypto/ -v` +Expected: 编译失败 —— `crypto` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/provider/crypto/crypto.go`: +```go +// Package crypto ports pangolin-pay's self-hosted USDT-TRC20 receiving model to +// provider.Provider: a single fixed receiving address per account + a unique +// amount per order (base price + a random micro tail in [1,9999], reserved +// against reuse for a cooldown window longer than the payment TTL). Settlement +// is query-only: poll TronGrid (only_confirmed) and match by exact amount + +// block time after order creation. No keys are ever held here; sweeping to cold +// storage is a separate offline step. +// +// Canonical source (logic ported, no import): pangolin repo ref +// origin/worktree-macos-killswitch:pay/ — internal/pay/service.go (allocateAmount), +// internal/watcher/watcher.go (Tick matching), internal/tron/client.go (IncomingTransfers). +// The tail rides in ProviderRef ("CRYPTO--") so Query can +// recompute the expected amount without touching the frozen attempt.AmountMinor. +package crypto + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/money" + "github.com/wangjia/pay/internal/provider" +) + +// USDTContract 主网 TRC20 USDT 合约地址(6 位小数,最小单位=money USDT minor)。 +const USDTContract = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + +const ( + defaultBaseURL = "https://api.trongrid.io" + refPrefix = "CRYPTO-" + orderTTL = 15 * time.Minute // canonical OrderTTL:支付窗 + amountCooldown = 30 * time.Minute // canonical AmountCooldown:金额预留窗,须 > orderTTL(迟到旧款不可能匹配复用金额的新单) + tailMax = 9999 // 唯一金额尾数 ∈ [1,9999] 微USDT,≤0.01 USDT +) + +type Provider struct { + accts *accounts.Registry + baseURL string + http *http.Client + now func() time.Time + + mu sync.Mutex + reserved map[string]time.Time // "/" → 预留到期(canonical AmountRecentlyUsed 的进程内等价) +} + +type Option func(*Provider) + +func WithBaseURL(u string) Option { return func(p *Provider) { p.baseURL = u } } +func WithHTTPClient(c *http.Client) Option { return func(p *Provider) { p.http = c } } + +func New(accts *accounts.Registry, opts ...Option) *Provider { + p := &Provider{ + accts: accts, + baseURL: defaultBaseURL, + http: &http.Client{Timeout: 15 * time.Second}, + now: time.Now, + reserved: map[string]time.Time{}, + } + for _, o := range opts { + o(p) + } + return p +} + +func (p *Provider) Method() string { return "crypto" } + +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) address(accountID string) (string, error) { + a := p.accts.Credential(accountID, "ADDRESS") + if a == "" { + return "", fmt.Errorf("crypto: 账户 %s 未配置收款地址(env _ADDRESS)", accountID) + } + return a, nil +} + +func (p *Provider) apiKey(accountID string) string { + if k := p.accts.Credential(accountID, "TRONGRID_KEY"); k != "" { + return k + } + return os.Getenv("TRONGRID_API_KEY") +} + +// allocateAmount 移植 canonical pay/service.go:随机尾数 [1,tailMax] + 冷却预留, +// 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。 +func (p *Provider) allocateAmount(accountID string, base int64) (amount, tail int64, err error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.now() + for k, until := range p.reserved { // 顺手清理过期预留,map 不长胖 + if now.After(until) { + delete(p.reserved, k) + } + } + for attempt := 0; attempt < 64; attempt++ { + t, rerr := randInt(tailMax) // [1, tailMax] + if rerr != nil { + return 0, 0, rerr + } + amt := base + t + key := accountID + "/" + strconv.FormatInt(amt, 10) + if _, used := p.reserved[key]; used { + continue + } + p.reserved[key] = now.Add(amountCooldown) + return amt, t, nil + } + return 0, 0, fmt.Errorf("crypto: 无法分配唯一金额(同价并发单过多?)") +} + +// randInt returns a uniform integer in [1, max](canonical 同名函数原样)。 +func randInt(max int64) (int64, error) { + n, err := rand.Int(rand.Reader, big.NewInt(max)) + if err != nil { + return 0, err + } + return n.Int64() + 1, nil +} + +// tailFromRef 解析 "CRYPTO--" 的尾数(最后一个 '-' 之后)。 +func tailFromRef(ref string) (int64, error) { + i := strings.LastIndex(ref, "-") + if i < 0 || i == len(ref)-1 { + return 0, fmt.Errorf("crypto: provider_ref 无尾数: %q", ref) + } + return strconv.ParseInt(ref[i+1:], 10, 64) +} + +func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + if req.Currency != "USDT" { + return nil, fmt.Errorf("crypto: 仅支持 USDT, got %s", req.Currency) + } + addr, err := p.address(req.Account.AccountID) + if err != nil { + return nil, err + } + expected, tail, err := p.allocateAmount(req.Account.AccountID, req.AmountMinor) + if err != nil { + return nil, err + } + display, err := money.Format(expected, "USDT") + if err != nil { + return nil, err + } + exp := p.now().Add(orderTTL) + return &provider.Session{ + ProviderRef: refPrefix + req.OutTradeNo + "-" + strconv.FormatInt(tail, 10), + RenderType: provider.RenderCryptoAddress, + Payload: map[string]any{ + "address": addr, + "amount": display, // 如 "29.997263":用户须付此精确额,唯一金额即订单身份 + "amount_minor": expected, + "currency": "USDT", + "network": "TRC20", + "contract": USDTContract, + }, + ExpiresAt: &exp, + }, nil +} + +// VerifyCallback: 自托管无渠道异步回调(canonical 即 watcher 轮询),入账只走查单兜底。 +func (p *Provider) VerifyCallback(_ context.Context, _ provider.CallbackInput) (*provider.PaidEvent, error) { + return nil, provider.ErrNotSupported +} + +// trc20Resp 对应 TronGrid /v1/accounts/{addr}/transactions/trc20 响应 +// (canonical tron/client.go 同构;contract_address 查询参数已在服务端过滤合约)。 +type trc20Resp struct { + Data []struct { + TxID string `json:"transaction_id"` + To string `json:"to"` + Type string `json:"type"` + Value string `json:"value"` + BlockMs int64 `json:"block_timestamp"` // 毫秒 + } `json:"data"` +} + +// Query 移植 canonical watcher.Tick 的匹配:已确认(only_confirmed)到账中, +// 精确等于期望金额且块时晚于建单的一笔 → succeeded;否则 pending。 +func (p *Provider) Query(ctx context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + pending := &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending} + if req.Currency != "USDT" { + return pending, nil + } + tail, err := tailFromRef(req.ProviderRef) + if err != nil { + return nil, err + } + expected := req.AmountMinor + tail + addr, err := p.address(req.AccountID) + if err != nil { + return nil, err + } + + endpoint := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50", + p.baseURL, url.PathEscape(addr), url.QueryEscape(USDTContract)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + if k := p.apiKey(req.AccountID); k != "" { + httpReq.Header.Set("TRON-PRO-API-KEY", k) + } + resp, err := p.http.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("crypto: TronGrid HTTP %d: %s", resp.StatusCode, body) + } + var tr trc20Resp + if err := json.Unmarshal(body, &tr); err != nil { + return nil, err + } + + createdUnix := req.CreatedAt.Unix() + for _, d := range tr.Data { + if d.To != addr || d.Type != "Transfer" { + continue + } + val, perr := strconv.ParseInt(d.Value, 10, 64) + if perr != nil || val != expected { // 唯一金额精确匹配 + continue + } + blockTs := d.BlockMs / 1000 + if blockTs <= createdUnix { // 块时必须晚于建单:拒迟到旧款(canonical t.BlockTs > o.CreatedAt) + continue + } + paidAt := time.Unix(blockTs, 0) + return &provider.PaidEvent{ + ProviderRef: req.ProviderRef, + Status: provider.PaidSucceeded, + PaidAmountMinor: val, + PaidCurrency: "USDT", + Raw: d.TxID, + PaidAt: &paidAt, + }, nil + } + return pending, nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/crypto/ -v` +Expected: `TestCreateAllocatesUniqueTailedAmount` / `TestQueryMatchesConfirmedExactAmount`(A succeeded / B pending / C pending)/ `TestVerifyCallbackNotSupported` 全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/crypto/ +git commit -m "feat(v2): crypto adapter — 移植 pangolin-pay 单地址+唯一金额模型(only_confirmed 扫链,VerifyCallback 不适用)" +``` + +--- + +### Task 4: 支付宝 adapter —— 迁移 v1 channel 验签/查单成 Provider + +**Files:** +- Create: `internal/provider/alipay/alipay.go`(`provider.Provider` 实现,包裹 `*alipay.Client`) +- Test: `internal/provider/alipay/alipay_test.go`(生成临时 RSA 密钥,离线 Create + 签名/验签 round-trip) + +**Interfaces:** +- Consumes: `github.com/smartwalle/alipay/v3`、`provider.*`、`internal/money`。 +- Produces:`func New(client *alipay.Client) *Provider`(`Method()=="alipay"`)。客户端在**装配期**由 env 凭证构建后注入(Task 6),adapter 只持已就绪 client——满足「多账户回调验签靠 adapter 持凭证/客户端」(P2 D4)。 + +**决策:** +- **render_type = `redirect`**:支付宝网站支付返回收银台跳转 URL(PC=`TradePagePay`,H5=`TradeWapPay`,由 `req.Metadata["is_mobile"]=="1"` 区分——迁移 v1 `IsMobile` 语义)。当面付二维码(`PreCreate`→`qr`)是次要形态,P3 不接(v1 `/api/v1` 存量端点保留,设计 §1 收口后再议);记为可选后续。 +- **provider_ref = OutTradeNo**:支付宝下单(page/wap)不在建单时返回 trade_no,`out_trade_no` 是我方对账/查单/回调匹配的唯一键。`Create` 返回 `ProviderRef=req.OutTradeNo`,`VerifyCallback`/`Query` 均以 `out_trade_no` 归位。(alipay 同 method retry 会撞 `uniqueIndex(channel,provider_ref)`——需换新 out_trade_no,归 P5,happy path 不受影响。) +- **VerifyCallback = RSA 验签**:复用 v1 `client.DecodeNotification`(内部用已加载支付宝公钥验签)。回调体是 form-urlencoded(在 `CallbackInput.Raw`),`url.ParseQuery` 还原 `url.Values` 再解码。 +- **Query = 查单**:复用 v1 `client.TradeQuery`(打网络;单测不覆盖,靠装配/手工联调,标注)。 +- **金额**:CNY,`money.Format(minor,"CNY")` → 元 string 给 alipay `TotalAmount`;回调/查单的元 string → `money.Parse(...,"CNY")` 回 minor。 +- **v1 `internal/channel/alipay.go` 与 `/api/v1` 端点不动**(存量当面付部署仍用);本 adapter 是 v2 侧独立实现,逻辑迁移不删旧码。 + +- [ ] **Step 1: 写失败测试** + +`internal/provider/alipay/alipay_test.go`: +```go +package alipay_test + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/url" + "sort" + "strings" + "testing" + + sw "github.com/smartwalle/alipay/v3" + + "github.com/wangjia/pay/internal/provider" + ali "github.com/wangjia/pay/internal/provider/alipay" +) + +// 生成一对 RSA 密钥:appPriv 供 client 下单签名;aliPriv/aliPub 冒充"支付宝侧"给异步通知签名/验签。 +func genKeys(t *testing.T) (appPrivPKCS1B64, aliPrivPKCS1B64, aliPubPKCS1B64 string) { + t.Helper() + app, _ := rsa.GenerateKey(rand.Reader, 2048) + aliK, _ := rsa.GenerateKey(rand.Reader, 2048) + b64 := func(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + appPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(app)) + aliPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(aliK)) + aliPubPKCS1B64 = b64(x509.MarshalPKCS1PublicKey(&aliK.PublicKey)) + return +} + +func buildClient(t *testing.T, appPriv, aliPub string) *sw.Client { + c, err := sw.New("2021000000000000", appPriv, false) // 沙箱 + if err != nil { + t.Fatalf("new client: %v", err) + } + if err := c.LoadAliPayPublicKey(aliPub); err != nil { + t.Fatalf("load pub: %v", err) + } + return c +} + +func TestCreateRedirect(t *testing.T) { + appPriv, _, aliPub := genKeys(t) + p := ali.New(buildClient(t, appPriv, aliPub)) + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY", + ReturnURL: "https://x/return", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "PAY-1" { + t.Fatalf("session = %+v", sess) + } + u, _ := sess.Payload["url"].(string) + if !strings.Contains(u, "alipay") { + t.Fatalf("url = %q 不像收银台跳转", u) + } +} + +// 用"支付宝侧"私钥给一份通知表单签名,adapter 用装的公钥验签 → 归一化 PaidEvent。 +func TestVerifyCallbackRSA(t *testing.T) { + appPriv, aliPriv, aliPub := genKeys(t) + p := ali.New(buildClient(t, appPriv, aliPub)) + + form := url.Values{} + form.Set("out_trade_no", "PAY-1") + form.Set("trade_no", "2021AAA") + form.Set("trade_status", "TRADE_SUCCESS") + form.Set("total_amount", "199.00") + form.Set("sign_type", "RSA2") + form.Set("sign", signRSA2(t, aliPriv, form)) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.ProviderRef != "PAY-1" || ev.Status != provider.PaidSucceeded || + ev.PaidAmountMinor != 19900 || ev.PaidCurrency != "CNY" { + t.Fatalf("event = %+v", ev) + } +} + +// signRSA2 复刻支付宝签名:排序非空参数(排除 sign/sign_type),k=v&拼接,RSA-SHA256,base64。 +func signRSA2(t *testing.T, aliPrivB64 string, form url.Values) string { + t.Helper() + der, _ := base64.StdEncoding.DecodeString(aliPrivB64) + priv, err := x509.ParsePKCS1PrivateKey(der) + if err != nil { + t.Fatalf("parse ali priv: %v", err) + } + keys := make([]string, 0, len(form)) + for k := range form { + if k == "sign" || k == "sign_type" || form.Get(k) == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var parts []string + for _, k := range keys { + parts = append(parts, k+"="+form.Get(k)) + } + h := sha256.Sum256([]byte(strings.Join(parts, "&"))) + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return base64.StdEncoding.EncodeToString(sig) +} + +var _ = pem.Encode // 避免 import 未用(若不需要 pem 可删该 import 与本行) +``` +> ⚠️ **验签兼容性风险(执行时先验证)**:`DecodeNotification` 的待签串构造须与 `signRSA2` 完全一致(smartwalle v3 的排序/编码细节)。若 round-trip 失败,以 `go test -run VerifyCallbackRSA -v` 的报错为准微调 `signRSA2`(通常是参数过滤/URL 解码差异),或改用 smartwalle 提供的签名 helper(若该版本导出)。这是本 Task 最脆的一环。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/alipay/ -v` +Expected: 编译失败 —— `internal/provider/alipay` 包不存在。 + +- [ ] **Step 3: 写实现** + +`internal/provider/alipay/alipay.go`: +```go +// Package alipay adapts Alipay web payment (page/wap) to provider.Provider, +// porting v1 internal/channel/alipay.go's verify/query logic. The *alipay.Client +// (with app private key + Alipay public key) is built from env credentials at +// assembly time and injected, so verify_callback/query hold their own credentials. +package alipay + +import ( + "context" + "fmt" + "net/url" + + sw "github.com/smartwalle/alipay/v3" + + "github.com/wangjia/pay/internal/money" + "github.com/wangjia/pay/internal/provider" +) + +type Provider struct{ client *sw.Client } + +func New(client *sw.Client) *Provider { return &Provider{client: client} } + +func (p *Provider) Method() string { return "alipay" } + +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderRedirect}, + SupportsRefund: false, // 退款 P4 + SettleCurrencies: []string{"CNY"}, + Regions: []string{"cn"}, + } +} + +func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + if req.Currency != "CNY" { + return nil, fmt.Errorf("alipay: 仅支持 CNY, got %s", req.Currency) + } + amount, err := money.Format(req.AmountMinor, "CNY") + if err != nil { + return nil, err + } + var payURL *url.URL + if req.Metadata["is_mobile"] == "1" { + wp := sw.TradeWapPay{} + wp.OutTradeNo = req.OutTradeNo + wp.Subject = req.Subject + wp.TotalAmount = amount + wp.ProductCode = "QUICK_WAP_WAY" + wp.ReturnURL = req.ReturnURL + payURL, err = p.client.TradeWapPay(wp) + } else { + pp := sw.TradePagePay{} + pp.OutTradeNo = req.OutTradeNo + pp.Subject = req.Subject + pp.TotalAmount = amount + pp.ProductCode = "FAST_INSTANT_TRADE_PAY" + pp.ReturnURL = req.ReturnURL + pp.QRPayMode = "2" // 跳转到完整扫码收银台(迁移 v1 语义) + payURL, err = p.client.TradePagePay(pp) + } + if err != nil { + return nil, fmt.Errorf("alipay: 下单失败: %w", err) + } + return &provider.Session{ + ProviderRef: req.OutTradeNo, // 支付宝以 out_trade_no 归位 + RenderType: provider.RenderRedirect, + Payload: map[string]any{"url": payURL.String()}, + }, nil +} + +func (p *Provider) VerifyCallback(ctx context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { + form, err := url.ParseQuery(string(in.Raw)) + if err != nil { + return nil, fmt.Errorf("alipay: 解析回调表单失败: %w", err) + } + noti, err := p.client.DecodeNotification(form) // 内部用已加载支付宝公钥验签 + if err != nil { + return nil, fmt.Errorf("alipay: 回调验签失败: %w", err) + } + return notifyToEvent(noti.OutTradeNo, string(noti.TradeStatus), noti.TotalAmount, in.Raw) +} + +func (p *Provider) Query(ctx context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + rsp, err := p.client.TradeQuery(ctx, sw.TradeQuery{OutTradeNo: req.OutTradeNo}) + if err != nil { + return nil, fmt.Errorf("alipay: 查单失败: %w", err) + } + if rsp.IsFailure() { + // 交易不存在等:视为未命中(pending),交管线继续轮询。 + return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil + } + ev, err := notifyToEvent(rsp.OutTradeNo, string(rsp.TradeStatus), rsp.TotalAmount, nil) + if err != nil { + return nil, err + } + ev.ProviderRef = req.ProviderRef + return ev, nil +} + +func notifyToEvent(outTradeNo, tradeStatus, totalAmount string, raw []byte) (*provider.PaidEvent, error) { + status := provider.PaidPending + if tradeStatus == string(sw.TradeStatusSuccess) || tradeStatus == string(sw.TradeStatusFinished) { + status = provider.PaidSucceeded + } + var minor int64 + if totalAmount != "" { + m, err := money.Parse(totalAmount, "CNY") + if err != nil { + return nil, fmt.Errorf("alipay: 金额解析失败 %q: %w", totalAmount, err) + } + minor = m + } + return &provider.PaidEvent{ + ProviderRef: outTradeNo, + Status: status, + PaidAmountMinor: minor, + PaidCurrency: "CNY", + Raw: string(raw), + }, nil +} +``` +> ⚠️ **API 面核对**:smartwalle/alipay v3.2.29 里 `DecodeNotification` 在 v1 代码中签名为 `DecodeNotification(ctx, form)`(见 `internal/channel/alipay.go`),而此处写 `DecodeNotification(form)`——**执行时以 `go build` 报错为准对齐**(该库不同小版本有 `(form)` 与 `(ctx, form)` 两种)。`TradeWapPay`/`TradePagePay` 返回 `(*url.URL, error)`,`TradeQuery(ctx, TradeQuery)`、`TradeStatusSuccess/Finished`、`IsFailure()` 与 v1 一致。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/alipay/ -v` +Expected: `TestCreateRedirect` / `TestVerifyCallbackRSA` PASS(Query 打网络,不在单测覆盖)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/alipay/ +git commit -m "feat(v2): alipay adapter — 迁移 v1 验签/查单,render_type=redirect(page/wap)" +``` + +--- + +### Task 5: Stripe adapter —— Checkout Session 跳转 + webhook 验签 + +**Files:** +- Modify: `go.mod`/`go.sum`(新增 `github.com/stripe/stripe-go/v79`,pin 版本) +- Create: `internal/provider/stripe/stripe.go`(`provider.Provider` 实现) +- Test: `internal/provider/stripe/stripe_test.go`(`httptest` 假 Stripe API + 本地构造 webhook 签名,全离线) + +**Interfaces:** +- Consumes: `github.com/stripe/stripe-go/v79`(+ `.../client`、`.../checkout/session`、`.../webhook`)、`provider.*`。 +- Produces:`func New(sc *client.API, webhookSecret string) *Provider`(`Method()=="stripe"`)。`*client.API` 装配期建(生产走默认 backend,测试注入指向 httptest 的 backend);webhook secret 来自 env。 + +**决策:** +- **依赖 pin**:`go get github.com/stripe/stripe-go/v79@v79.12.0`(pin 到具体 tag,不用 latest;major=v79 稳定)。若该 tag 拉取失败,取 `go list -m -versions github.com/stripe/stripe-go/v79` 最新可用 v79.x 并在 commit message 记录实际版本。 +- **render_type = `redirect`**:Checkout Session 的 `url` 跳转 Stripe 托管收银台。`provider_ref = session.ID`(`cs_...`)。 +- **金额/币种**:USD,Stripe 用最小单位(cent)= money USD minor,直接 `req.AmountMinor`;`currency="usd"`。 +- **VerifyCallback = webhook 签名**:`webhook.ConstructEvent(raw, sigHeader, secret)`(HMAC-SHA256,含时间容差),取 `checkout.session.completed`,反序列化 `stripe.CheckoutSession` → PaidEvent。sig header 从 `CallbackInput.Headers["Stripe-Signature"]` 取。 +- **Query = session 查询**:`sc.CheckoutSessions.Get(providerRef, nil)`,`payment_status=="paid"` → succeeded。 +- **测试全离线**:Create/Query 打 httptest 假 Stripe(注入 backend);webhook 本地按 Stripe 方案算 `t=...,v1=hmac(secret, "t.payload")` 头,`ConstructEvent` 验签通过。**不打真网**。 + +- [ ] **Step 1: 引入依赖(先让测试能编译)** + +```bash +cd /Users/wangjia/code/pay +go get github.com/stripe/stripe-go/v79@v79.12.0 +go mod tidy +``` + +- [ ] **Step 2: 写失败测试** + +`internal/provider/stripe/stripe_test.go`: +```go +package stripe_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + gostripe "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/client" + + "github.com/wangjia/pay/internal/provider" + st "github.com/wangjia/pay/internal/provider/stripe" +) + +const whSecret = "whsec_test_secret" + +func fakeStripeAPI(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"): + // 创建 session + fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"): + // 查询 session — 已付 + fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`) + default: + http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound) + } + })) +} + +func newStripe(t *testing.T, ts *httptest.Server) *st.Provider { + backends := &gostripe.Backends{ + API: gostripe.GetBackendWithConfig(gostripe.APIBackend, &gostripe.BackendConfig{ + URL: gostripe.String(ts.URL), + }), + } + sc := client.New("sk_test_x", backends) + return st.New(sc, whSecret) +} + +func TestCreateCheckoutRedirect(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-1", Subject: "Pro Year", AmountMinor: 2999, Currency: "USD", + ReturnURL: "https://x/return", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_test_123" { + t.Fatalf("session = %+v", sess) + } + if !strings.Contains(sess.Payload["url"].(string), "cs_test_123") { + t.Fatalf("url = %v", sess.Payload["url"]) + } +} + +func TestQueryPaid(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + ev, err := p.Query(context.Background(), provider.QueryRequest{ProviderRef: "cs_test_123", Currency: "USD"}) + if err != nil { + t.Fatalf("query: %v", err) + } + if ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 2999 || ev.PaidCurrency != "USD" { + t.Fatalf("event = %+v", ev) + } +} + +func TestVerifyWebhook(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + payload := `{"id":"evt_1","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}}}` + sig := signStripe(payload, whSecret, time.Now().Unix()) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{ + Raw: []byte(payload), + Headers: map[string]string{"Stripe-Signature": sig}, + }) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.ProviderRef != "cs_test_123" || ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 2999 { + t.Fatalf("event = %+v", ev) + } +} + +// signStripe 复刻 Stripe webhook 签名头: t=,v1=hex(HMAC-SHA256(secret, ".")) +func signStripe(payload, secret string, ts int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + fmt.Fprintf(mac, "%d.%s", ts, payload) + return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil))) +} +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/stripe/ -v` +Expected: 编译失败 —— `internal/provider/stripe` 包不存在。 + +- [ ] **Step 4: 写实现** + +`internal/provider/stripe/stripe.go`: +```go +// Package stripe adapts Stripe Checkout to provider.Provider: Create → hosted +// Checkout Session (redirect), VerifyCallback → webhook signature verify, Query → +// session lookup. The *client.API + webhook secret are injected at assembly (default +// backend in prod; httptest backend in tests) so nothing hits the real network in CI. +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + gostripe "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/client" + "github.com/stripe/stripe-go/v79/webhook" + + "github.com/wangjia/pay/internal/provider" +) + +type Provider struct { + sc *client.API + webhookSecret string +} + +func New(sc *client.API, webhookSecret string) *Provider { + return &Provider{sc: sc, webhookSecret: webhookSecret} +} + +func (p *Provider) Method() string { return "stripe" } + +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderRedirect}, + SupportsRefund: false, // P4 + SettleCurrencies: []string{"USD"}, + Regions: []string{"global"}, + } +} + +func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + if req.Currency != "USD" { + return nil, fmt.Errorf("stripe: 仅支持 USD, got %s", req.Currency) + } + params := &gostripe.CheckoutSessionParams{ + Mode: gostripe.String(string(gostripe.CheckoutSessionModePayment)), + SuccessURL: gostripe.String(req.ReturnURL), + ClientReferenceID: gostripe.String(req.OutTradeNo), + LineItems: []*gostripe.CheckoutSessionLineItemParams{{ + Quantity: gostripe.Int64(1), + PriceData: &gostripe.CheckoutSessionLineItemPriceDataParams{ + Currency: gostripe.String("usd"), + UnitAmount: gostripe.Int64(req.AmountMinor), // cent = USD minor + ProductData: &gostripe.CheckoutSessionLineItemPriceDataProductDataParams{ + Name: gostripe.String(req.Subject), + }, + }, + }}, + } + sess, err := p.sc.CheckoutSessions.New(params) + if err != nil { + return nil, fmt.Errorf("stripe: 创建 Checkout Session 失败: %w", err) + } + return &provider.Session{ + ProviderRef: sess.ID, + RenderType: provider.RenderRedirect, + Payload: map[string]any{"url": sess.URL}, + }, nil +} + +func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { + sig := in.Headers["Stripe-Signature"] + event, err := webhook.ConstructEvent(in.Raw, sig, p.webhookSecret) + if err != nil { + return nil, fmt.Errorf("stripe: webhook 验签失败: %w", err) + } + if event.Type != "checkout.session.completed" { + // 其它事件此阶段不处理:回归一化 pending(管线 Settle 视为 ignored)。 + return &provider.PaidEvent{Status: provider.PaidPending, Raw: string(in.Raw)}, nil + } + var sess gostripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { + return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err) + } + return sessionToEvent(&sess, in.Raw), nil +} + +func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + sess, err := p.sc.CheckoutSessions.Get(req.ProviderRef, nil) + if err != nil { + return nil, fmt.Errorf("stripe: 查询 session 失败: %w", err) + } + ev := sessionToEvent(sess, nil) + ev.ProviderRef = req.ProviderRef + return ev, nil +} + +func sessionToEvent(sess *gostripe.CheckoutSession, raw []byte) *provider.PaidEvent { + status := provider.PaidPending + if sess.PaymentStatus == gostripe.CheckoutSessionPaymentStatusPaid { + status = provider.PaidSucceeded + } + return &provider.PaidEvent{ + ProviderRef: sess.ID, + Status: status, + PaidAmountMinor: sess.AmountTotal, // cent + PaidCurrency: strings.ToUpper(string(sess.Currency)), + Raw: string(raw), + } +} +``` +> ⚠️ **stripe-go API 面**:v79 里 `client.API` 的 checkout 服务字段名为 `CheckoutSessions`(`.New(params)`/`.Get(id, params)`);常量 `CheckoutSessionModePayment`、`CheckoutSessionPaymentStatusPaid`、`webhook.ConstructEvent(payload []byte, header, secret string)`。执行时以 `go build` / godoc 为准;若字段/常量名随版本微调,按编译错误对齐(逻辑不变)。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/stripe/ -v` +Expected: `TestCreateCheckoutRedirect` / `TestQueryPaid` / `TestVerifyWebhook` 全 PASS,无网络访问。 + +- [ ] **Step 6: Commit** + +```bash +cd /Users/wangjia/code/pay +git add go.mod go.sum internal/provider/stripe/ +git commit -m "feat(v2): stripe adapter — Checkout Session 跳转 + webhook 验签(stripe-go/v79 pin)" +``` + +--- + +### Task 6: main 配置驱动注册 + crypto 真 adapter httptest 全链端到端 + +**Files:** +- Create: `internal/provider/registry_build.go`(据 config accounts + env 装配注册表) +- Modify: `main.go`(用装配函数替换「留空的 pReg」;`autoMigrate` 加 `ProductPrice`;resolver 去币种参数——Task 2 已改) +- Test: `internal/gateway/e2e_crypto_test.go`(crypto 真 adapter + 假 TronGrid,下单→查单兜底→入账→webhook 全链) + +**Interfaces:** +- Produces:`func BuildRegistry(accts *accounts.Registry, cfg BuildConfig) *provider.Registry` —— 有 enabled 账户才 `Register` 对应 adapter;凭证经 `accts.Credential` 从 env 取。`BuildConfig` 携带非密钥装配项(stripe backend 覆盖等,测试可注入)。 +- Consumes: 三个 adapter 包 + `accounts`/`config`。**放在 `internal/provider` 下的独立文件**,让 `main` 只调一个装配函数;`gateway` 仍不 import adapter。 + +**决策(有 enabled 账户才 Register):** +- 逐 channel 判断 `accts.EnabledFor(channel, "")` 非空才建 adapter 并注册,避免给未配置渠道下发 `method`。 +- crypto:整个渠道一个 adapter(`crypto.New(accts)`),多地址=多 account,查单按 AccountID 取地址(P5 地址池路由前,首个 enabled 即可)。 +- alipay:取首个 enabled alipay 账户,从 env 读 `_APP_ID`/`_APP_PRIVATE_KEY`/`_ALIPAY_PUBLIC_KEY`/`_PRODUCTION`,建 `*alipay.Client` 注入 `alipay.New(client)`。 +- stripe:取首个 enabled stripe 账户,从 env 读 `_SECRET_KEY`/`_WEBHOOK_SECRET`,`client.New(key, nil)` 注入 `stripe.New(sc, whSecret)`。 +- 凭证缺失 → 记 `log` 跳过该渠道(不 fatal:允许只上线部分渠道)。 + +- [ ] **Step 1: 写失败测试(端到端,crypto 全链)** + +`internal/gateway/e2e_crypto_test.go`: +```go +package gateway_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "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/crypto" + "github.com/wangjia/pay/internal/store" +) + +func TestE2ECryptoQuerySettles(t *testing.T) { + const addr = "TWe2eADDRESS00000000000000000000000" + t.Setenv("E2E_ADDRESS", addr) + t.Setenv("E2E_TRONGRID_KEY", "k") + + // 假 TronGrid:已确认、金额精确匹配、块时晚于建单的转账(期望金额在下单后从 payload 取)。 + var expected int64 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{"success": true, "data": []map[string]any{{ + "transaction_id": "tx-e2e", + "to": addr, + "type": "Transfer", + "value": strconv.FormatInt(expected, 10), + "block_timestamp": time.Now().Add(5 * time.Second).UnixMilli(), // 晚于建单(查单在下单后发生) + }}} + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + acctReg := accounts.New([]config.AccountConfig{ + {AccountID: "e2e-1", Channel: "crypto", Enabled: true, Region: "global", CredentialEnvPrefix: "e2e"}, + }) + preg := provider.NewRegistry() + preg.Register(crypto.New(acctReg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client()))) + + spy := &spyEnqueuer{} + g := gateway.New(orders, preg, acctReg, cryptoResolver{}, spy, "global") + + // 下单 → 从 session payload 拿到期望链上金额(base+唯一尾数),喂给假 TronGrid。 + res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{ + SKU: "pro_year", Method: "crypto", BizSystem: "pangolin", BizRef: "u-e2e", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + if len(atts) != 1 { + t.Fatalf("want 1 pending attempt, got %d", len(atts)) + } + expected = res.Session.Payload["amount_minor"].(int64) // 唯一金额(随机尾数)只有 payload/provider_ref 知道 + + // 查单兜底 → 命中 → 入账 → webhook + n, err := g.SyncPendingAttempts(context.Background(), 10) + if err != nil || n != 1 { + t.Fatalf("sync settled=%d err=%v want 1", n, err) + } + o, _ := orders.GetOrder(res.OrderNo) + if o.Status != model.OrderPaidV2 { + t.Fatalf("order status = %s want paid", o.Status) + } + if len(spy.calls) != 1 || spy.calls[0]["product_biz_code"] != "pro_year" { + t.Fatalf("webhook = %+v", spy.calls) + } +} + +type cryptoResolver struct{} + +func (cryptoResolver) Resolve(sku, currency string) (int64, string, string, error) { + if sku == "pro_year" && currency == "USDT" { + return 29990000, "Pro 年付", "pro_year", nil + } + return 0, "", "", gateway.ErrProductNotFound +} +``` +> 期望金额从 `res.Session.Payload["amount_minor"]` 取——唯一金额含随机尾数,只有 Create 产出的 payload / provider_ref 知道,e2e 不自行推算(这正是单地址+唯一金额模型的契约:客户端展示的精确金额即订单身份)。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run E2ECrypto -v` +Expected: 失败(尚未装配/或若已可跑则直接验证真 adapter 全链;若失败原因是装配缺失以外的逻辑问题,按报错修)。 +> 说明:此 e2e 直接用 `crypto.New` 组 gateway,不依赖 `BuildRegistry`,故 Step 1 即可先绿——它验证的是**真 crypto adapter 跑通 P2 管线**。`BuildRegistry` 在 Step 3 落地供 `main` 用。 + +- [ ] **Step 3: 写实现(装配函数 + main 接线)** + +`internal/provider/registry_build.go`: +```go +package provider + +// 注:本文件依赖具体 adapter 包,但只在装配期被 main 调用;gateway/provider 核心逻辑 +// 仍与 adapter 解耦(核心接口在 provider.go,adapter 反向依赖本包)。 + +import ( + "log" + + sw "github.com/smartwalle/alipay/v3" + gostripe "github.com/stripe/stripe-go/v79" + stripeclient "github.com/stripe/stripe-go/v79/client" + + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/provider/alipay" + "github.com/wangjia/pay/internal/provider/crypto" + "github.com/wangjia/pay/internal/provider/stripe" +) + +// BuildRegistry 据 enabled 账户装配注册表:有 enabled 账户且凭证齐备的渠道才 Register。 +func BuildRegistry(accts *accounts.Registry) *Registry { + reg := NewRegistry() + + // crypto:整渠道一个 adapter,多地址=多账户(P5 地址池路由前取首个 enabled)。 + if len(accts.EnabledFor("crypto", "")) > 0 { + reg.Register(crypto.New(accts)) + log.Println("[providers] crypto 已注册") + } + + // alipay:首个 enabled 账户的 env 凭证 → *alipay.Client。 + if as := accts.EnabledFor("alipay", ""); len(as) > 0 { + a := as[0] + appID := accts.Credential(a.AccountID, "APP_ID") + appPriv := accts.Credential(a.AccountID, "APP_PRIVATE_KEY") + aliPub := accts.Credential(a.AccountID, "ALIPAY_PUBLIC_KEY") + prod := accts.Credential(a.AccountID, "PRODUCTION") == "1" + if appID == "" || appPriv == "" || aliPub == "" { + log.Printf("[providers] alipay 账户 %s 凭证不全,跳过", a.AccountID) + } else if c, err := sw.New(appID, appPriv, prod); err != nil { + log.Printf("[providers] alipay client 构建失败: %v", err) + } else if err := c.LoadAliPayPublicKey(aliPub); err != nil { + log.Printf("[providers] alipay 加载公钥失败: %v", err) + } else { + reg.Register(alipay.New(c)) + log.Println("[providers] alipay 已注册") + } + } + + // stripe:首个 enabled 账户的 env 凭证 → *client.API。 + if ss := accts.EnabledFor("stripe", ""); len(ss) > 0 { + s := ss[0] + key := accts.Credential(s.AccountID, "SECRET_KEY") + wh := accts.Credential(s.AccountID, "WEBHOOK_SECRET") + if key == "" || wh == "" { + log.Printf("[providers] stripe 账户 %s 凭证不全,跳过", s.AccountID) + } else { + sc := stripeclient.New(key, nil) + _ = gostripe.String // 保持 import 稳定(如无其它用途可删该行与 gostripe import) + reg.Register(stripe.New(sc, wh)) + log.Println("[providers] stripe 已注册") + } + } + + return reg +} +``` +> 若 `gostripe` 未被其它引用导致 unused import,直接删掉 `gostripe` 这行 import 与 `_ = gostripe.String`。 + +`main.go` 装配段替换(把 P2 的「留空 pReg」改成装配 + 加 ProductPrice 迁移): +```go + orderStore := store.NewOrderStore(db) + webhookStore := store.NewWebhookStore(db) + notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) { + o, err := orderStore.GetOrder(no) + if err != nil { + return false, err + } + return o.Status == model.OrderPaidV2, nil + }) + notifier.Start(60 * time.Second) + productResolver := gateway.NewDBProductResolver(db) // 多币种:按渠道结算币种解析(Task 2) + acctReg := accounts.New(config.C.Accounts) + pReg := provider.BuildRegistry(acctReg) // 配置驱动:有 enabled 账户才注册对应渠道 + gw := gateway.New(orderStore, pReg, acctReg, productResolver, notifier, "cn") + router.SetupV2(r, gw) +``` +`autoMigrate` 参数表补 `&model.ProductPrice{}`(Task 2 若未加则此处加)。删掉 P2 遗留的 `pReg := provider.NewRegistry()` 空注册表行与其注释。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./... 2>&1 | tail -30` +Expected: 全仓 PASS(含新 e2e);`go build` 通过(main 装配用 `BuildRegistry`)。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wangjia/code/pay +git add internal/provider/registry_build.go internal/gateway/e2e_crypto_test.go main.go +git commit -m "feat(v2): 配置驱动注册三渠道 adapter + crypto 真 adapter 全链 e2e(下单→查单→入账→webhook)" +``` + +--- + +## Self-Review + +**Spec coverage(P3 范围,对照设计 §3/§4/§5 + P2 Self-Review 延后项):** +- **延后项 A** order 补 `biz_code` + webhook `product_biz_code`(Task 1)= 设计 §5 ✓。 +- **延后项 B** 多币种 product(`ProductPrice` 分币价目 + v1 元回退)+ `ProductResolver.Resolve(sku,currency)` 按渠道结算币种解析(Task 2)= 设计 §3.1 ✓。 +- **crypto adapter**:移植 pangolin-pay(git ref `origin/worktree-macos-killswitch:pay/`)「单地址+唯一金额」模型:render_type=`crypto_address`;账户=单一固定收款地址(env);唯一金额(base+随机尾数)区分并发单;查单=TronGrid `only_confirmed` 扫地址+精确金额+块时晚于建单;`VerifyCallback` 不适用(query-only)(Task 3)= 设计 §4.1/4.2 ✓。 +- **alipay adapter**:迁移 v1 验签/查单,render_type=`redirect`(page/wap),`VerifyCallback`=RSA 验签,`Query`=查单(Task 4)= 设计 §4.1 ✓;v1 端点不动 ✓。 +- **stripe adapter**:Checkout Session 跳转(redirect),`VerifyCallback`=webhook 签名,`Query`=session 查询,stripe-go/v79 pin,全离线测(Task 5)✓。 +- **配置驱动注册** + crypto 真 adapter 全链 e2e(Task 6)✓。 + +**关键决策:** +- **多币种模型**:新表 `ProductPrice(product_id,currency,amount_minor int64)` 权威分币价;`Product.Price`(v1 元 string)收敛为 CNY 默认回退;币种由 `Capabilities().SettleCurrencies[0]` 驱动,client 不传币种。 +- **crypto 并发区分(canonical pangolin-pay)**:单地址多单靠**唯一金额** = base + `crypto/rand` 随机尾数 `[1,9999]` 微 USDT(≤0.01 USDT),配 30min 金额冷却预留(> 15min 支付窗,迟到旧款不可能匹配复用金额的新单);尾数编进 `ProviderRef`("CRYPTO--")往返 Create/Query,**不改 P2 冻结的 attempt 金额**;tail≥1 使 `Settle` 的 `>=` 校验天然通过。 +- **crypto 确认(canonical)**:TronGrid `only_confirmed=true`(已固化区块)即确认,匹配加「块时晚于建单」守卫;不自算确认数。 +- **crypto 无回调(canonical 即 watcher 轮询)**:`VerifyCallback` 返回 `ErrNotSupported`,入账只走 `SyncPendingAttempts` 查单兜底。 +- **alipay render_type=redirect**(page/wap),provider_ref=out_trade_no;当面付 qr 形态 P3 不接(存量 /api/v1 覆盖)。 +- **stripe 依赖**:`github.com/stripe/stripe-go/v79`(pin v79.12.0),webhook 验签用 `webhook.ConstructEvent`,测试本地算签名头,全离线。 +- **多账户回调验签**:adapter 装配期持已解析客户端(alipay `*Client`/stripe `*client.API`/crypto 经 `accounts.Registry`),兑现 P2 D4「接口不变、adapter 持凭证」。 + +**Provider 中性守恒:** `internal/gateway` 仍零 import adapter;唯一「知道所有 adapter」的是 `internal/provider/registry_build.go`(装配文件,只被 main 调),核心 `provider.go` 不反向依赖。gateway 单测继续用 `fake`;e2e 用真 crypto adapter + 假 TronGrid。 + +**Placeholder scan:** 无 TODO/占位;每任务给完整 Go 代码 + 测试 + 命令。三处 ⚠️ 标注是**外部 SDK API 面/签名细节的执行期核对点**(smartwalle `DecodeNotification` 的 `(form)` vs `(ctx,form)`、stripe-go v79 字段/常量名、alipay 待签串构造),非占位——逻辑完整,仅需按 `go build`/`go test` 报错对齐命名。 + +**已知取舍 / 硬化项(记录,不阻塞 P3):** +- **crypto 金额预留在进程内存**:canonical 用自库 `AmountRecentlyUsed` 查重,adapter 移植为内存预留表(pay 单实例等价);**重启丢预留窗**,残余碰撞概率 ≈ 冷却窗内同价并发单数/9999。硬化:P6 改为查 attempts 表(按 provider_ref 尾数)做持久查重。**约束:同一收款地址只能由单个 pay 实例服务**(多实例需共享预留存储)。 +- **crypto orphan 到账未落表**:canonical 把匹配不到活跃单的已确认到账记 `orphan_payments` 人工对账;pay v2 的 `Query` 是单尝试视角无此全局职责——orphan 落表 + 迟到付款对账归 P6 对账 job。 +- **单地址+唯一金额 vs P5 多账户路由**:两者共存——唯一金额的作用域是**每个地址**(预留键=(accountID,amount)),多地址(多 account)让容量倍增而非冲突;约束是**每地址、每 base 价、冷却窗(30min)内并发单上限 ≈ 9999**,以及上一条的「单实例服务一个地址」。 +- **alipay retry 同 method** 复用 out_trade_no 会撞 `uniqueIndex(channel,provider_ref)`——需换新 out_trade_no,归 P5 路由。 +- **alipay/stripe Query 单测未覆盖**(打网络):靠 e2e/手工联调;crypto Query 已被 e2e + 单测充分覆盖。 +- **单渠道单账户**:P3 每渠道取首个 enabled 账户;多账户路由(round_robin/weighted/地址池)在 P5。 +- **回调原始报文落表**(callback_logs)仍延后(P6);P3 靠 `PaidEvent.Raw` + 日志。 + +**排除项(各自独立成计划):** 退款 P4 · 多账户路由 P5 · 对账 job + 订单级过期清理 P6 · codes 共享库 P7 · 订阅/recurring P8。 + +> 契约同步(pay-contract):webhook payload 新增 `product_biz_code`(向后兼容加字段);多币种 `ProductPrice`、三渠道 `render_type`(redirect/crypto_address)随 P3 落地登记。 + +