Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
73 KiB
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 读(<CredentialEnvPrefix>_<KEY>),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(enqueuePaymentSucceededpayload 加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 追加:
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 字段。新增独立用例更省事:
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 之后加一列:
ProductID uint64 `gorm:"index"`
BizCode string `gorm:"index;size:64"` // 套餐码副本(下单时从 product 复制),回调带回业务方按码映射权益(设计 §5)
Subject string `gorm:"size:128"`
internal/gateway/gateway.go —— CreateOrder 捕获 bizCode 并写入(改 2 处):
amountMinor, currency, subject, bizCode, err := g.products.Resolve(in.SKU)
if err != nil {
return nil, err // ErrProductNotFound
}
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 加一行:
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
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):
- 新表
ProductPrice{ProductID, Currency, AmountMinor int64},uniqueIndex(product_id, currency)。这是权威分币价目表(int64 最小单位,与 P2 money 口径一致,不用元 string,免二次解析/浮点)。 Product.Price(v1 元 string)保留不动,语义收敛为「CNY 默认价」——当某币种在ProductPrice无显式行且币种为 CNY 时,回退money.Parse(Product.Price, "CNY")。存量 v1 数据零改动即得 CNY 价;新增 USD/USDT 价只往ProductPrice插行。- 币种由下单选中的渠道决定,不由 client 传:
gateway.CreateOrder先providers.Get(method)→Capabilities().SettleCurrencies[0]得结算币种 →products.Resolve(sku, currency)。渠道自述能力(crypto=USDT / alipay=CNY / stripe=USD)是唯一真相源。 ProductResolver.Resolve改签名:Resolve(sku, currency string) (amountMinor int64, subject, bizCode string, err error)——currency 变入参(调用方按渠道给定),不再是出参;某 sku 无该币种价 →ErrProductNotFound(下发「该套餐不支持此支付方式的结算币种」)。
- Step 1: 写失败测试
internal/gateway/product_test.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:
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:
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(整文件替换):
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:
// 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 等并列):
ErrNoSettleCurrency = errors.New("gateway: channel has no settle currency")
ErrCurrencyMismatch = errors.New("gateway: retry method settles a different currency")
CreateOrder 改为先取 provider、由其结算币种解析(替换开头到建单段):
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 之前):
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
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/<path>)。本 Task 移植其三块核心逻辑:internal/pay/service.go(allocateAmount唯一金额分配+冷却预留)、internal/watcher/watcher.go(Tick精确金额+块时匹配)、internal/tron/client.go(IncomingTransfersTronGrid 查询形态),适配到provider.Provider+AccountConfig。pay 不 import pangolin,逻辑移植不是代码依赖。
- 账户 = 单一固定收款地址(canonical:钱包 A 的地址 0,
m/44'/195'/0'/0/0;xpub 派生/selfcheck 属 pangolin-pay 钱包侧离线工具,pay 只拿最终地址):AccountConfig只存CredentialEnvPrefix,真值从 env 取——地址<PREFIX>_ADDRESS、TronGrid key<PREFIX>_TRONGRID_KEY(缺则回退全局 envTRONGRID_API_KEY)。经accounts.Registry.Credential(accountID, "ADDRESS"/"TRONGRID_KEY")。 - 并发单区分 = 唯一金额(base + 随机尾数),canonical
allocateAmount原样移植:尾数crypto/rand均匀取[1, 9999]微 USDT(≤0.01 USDT),金额冷却预留 30min(须 > 支付窗 15min,盖住迟到付款窗,旧款绝不可能匹配到复用金额的新单),同(地址,金额)在冷却窗内不复用,64 次重试分配。canonical 用自库AmountRecentlyUsed查重;adapter 移植为进程内预留表(map[(accountID,amount)]→到期时刻+ 互斥锁,pay 单实例部署等价)——重启丢预留窗的残余碰撞概率 ≈ 并发同价单数/9999,记硬化项(P6 可改查 attempts 表)。 - 尾数经 ProviderRef 往返(不改 attempt 金额):P2 冻结契约里
attempt.AmountMinor=product 基准价,adapter 无法回写;故ProviderRef = "CRYPTO-<OutTradeNo>-<tail>",Create 把分配到的尾数编进 ref 落库(attempt.provider_ref),Query 从req.ProviderRef解析尾数、期望金额 =req.AmountMinor + tail。因 tail≥1,PaidAmountMinor(=期望)>attempt.AmountMinor(=base),Settle的>=金额校验天然通过。 - 查单(
Query)= canonical watcher 匹配:GET {base}/v1/accounts/{address}/transactions/trc20?only_confirmed=true&contract_address=<USDT>&limit=50,TRON-PRO-API-KEY头带 key。确认标准 = TronGridonly_confirmed=true(已固化区块),不自算确认数/成熟度(canonical 原样)。命中条件(canonicalTick):to==本地址∧type=="Transfer"∧value == 期望金额(精确相等,微 USDT=链上原始整数)∧block_timestamp/1000 > CreatedAt.Unix()(块时必须晚于建单,拒绝迟到旧款误配)。命中→PaidEvent{succeeded, PaidAmountMinor=value, PaidCurrency="USDT", PaidAt=块时, Raw=tx_id};否则pending。 VerifyCallback不适用:自托管无渠道异步回调(canonical 就是 watcher 轮询,对应 pay 的SyncPendingAttempts)。返回provider.ErrNotSupported——/api/v2/callback/crypto即便被打也安全拒。- orphan 到账不归 adapter:canonical 把匹配不到活跃单的已确认到账记
orphan_payments人工对账;pay v2 里Query是单尝试视角,无此全局职责——orphan 落表归 P6 对账 job(记入 Self-Review 硬化项)。 - 支付窗:
Session.ExpiresAt = now + 15min(canonicalOrderTTL)。USDT 合约:主网 TRC20 =TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t(6 位小数,最小单位与 money USDT 一致)。
- Step 1: 写失败测试
internal/provider/crypto/crypto_test.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:
// 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-<OutTradeNo>-<tail>") 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 // "<accountID>/<amount>" → 预留到期(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 <PREFIX>_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-<OutTradeNo>-<tail>" 的尾数(最后一个 '-' 之后)。
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
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"区分——迁移 v1IsMobile语义)。当面付二维码(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 给 alipayTotalAmount;回调/查单的元 string →money.Parse(...,"CNY")回 minor。 -
v1
internal/channel/alipay.go与/api/v1端点不动(存量当面付部署仍用);本 adapter 是 v2 侧独立实现,逻辑迁移不删旧码。 -
Step 1: 写失败测试
internal/provider/alipay/alipay_test.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:
// 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
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: 引入依赖(先让测试能编译)
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:
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=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
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:
// 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
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 读
<PREFIX>_APP_ID/_APP_PRIVATE_KEY/_ALIPAY_PUBLIC_KEY/_PRODUCTION,建*alipay.Client注入alipay.New(client)。 -
stripe:取首个 enabled stripe 账户,从 env 读
<PREFIX>_SECRET_KEY/_WEBHOOK_SECRET,client.New(key, nil)注入stripe.New(sc, whSecret)。 -
凭证缺失 → 记
log跳过该渠道(不 fatal:允许只上线部分渠道)。 -
Step 1: 写失败测试(端到端,crypto 全链)
internal/gateway/e2e_crypto_test.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:
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 迁移):
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
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+ webhookproduct_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+随机尾数)区分并发单;查单=TronGridonly_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 落地登记。