Files
pay/docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md
T

73 KiB
Raw Blame History

pay v2 · P3 首批渠道 adaptercrypto / 支付宝 / 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.gointernal/storeinternal/moneyinternal/accounts)。
  • docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md(P2:internal/providerinternal/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/gatewayinternal/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 任何 adapterinternal/provider 核心包不 import 任何 adapter(adapter 反向依赖核心包)。
  • 凭证 env only:所有密钥经 accounts.Registry.Credential(accountID, key) 或装配期注入,严禁写进 config.yaml / 落库明文 / 提交 git。测试用 t.Setenv / 生成临时密钥。
  • 入账/幂等/金额核对不变:入账仍走 P2 gateway.Settle(先幂等入队 webhook、再 MarkAttemptPaid 条件 UPDATE 翻转;PaidCurrency==attempt.CurrencyPaidAmountMinor>=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(OrderV2BizCode 列)
  • 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 追加:

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: 编译/断言失败 —— OrderV2BizCode 字段 / 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 —— enqueuePaymentSucceededdata 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(DBProductResolverProductPrice,缺行回退 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.CreateOrderproviders.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(新增/追加):

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 通过)。autoMigratedb.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(IncomingTransfers TronGrid 查询形态),适配到 provider.Provider + AccountConfigpay 不 import pangolin,逻辑移植不是代码依赖。

  1. 账户 = 单一固定收款地址(canonical:钱包 A 的地址 0,m/44'/195'/0'/0/0;xpub 派生/selfcheck 属 pangolin-pay 钱包侧离线工具,pay 只拿最终地址):AccountConfig 只存 CredentialEnvPrefix,真值从 env 取——地址 <PREFIX>_ADDRESS、TronGrid key <PREFIX>_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-<OutTradeNo>-<tail>",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=<USDT>&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:

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/v3provider.*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 语义)。当面付二维码(PreCreateqr)是次要形态,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:

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/FinishedIsFailure() 与 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));常量 CheckoutSessionModePaymentCheckoutSessionPaymentStatusPaidwebhook.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」;autoMigrateProductPrice;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 + 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 落地登记。