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

1675 lines
73 KiB
Markdown
Raw Blame History

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