# 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 落地登记。