1fd86bc1b3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
1675 lines
73 KiB
Markdown
1675 lines
73 KiB
Markdown
# pay v2 · P3 首批渠道 adapter(crypto / 支付宝 / Stripe)+ P2 延后项收口 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 每个 Task 自包含,给完整 Go 代码 + 测试 + 命令,可交给全新 implementer subagent。
|
||
|
||
> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`(重点 §4 Provider 接口 / render_type、§5 webhook v2 event_type/product_biz_code、§3 Order/Attempt/Account/多币种)。
|
||
> **前置计划:**
|
||
> - `docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md`(P1:`internal/model/v2.go`、`internal/store`、`internal/money`、`internal/accounts`)。
|
||
> - `docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md`(P2:`internal/provider`、`internal/gateway`、webhook v2 outbox;**本计划直接复用其接口,不重造**)。本计划收口 P2 Self-Review 记录的三处「已知 scope 取舍」:order 补 `biz_code`、多币种 product、webhook payload 补 `product_biz_code`。
|
||
|
||
**Goal:** 在 P2 的 Provider 抽象 + 收款/入账管线上,落地**首批 3 个真实渠道 adapter**——crypto 自托管(USDT-TRC20 单地址收款,链上扫描查单)、支付宝(迁移 v1 `internal/channel/alipay.go` 的验签/查单逻辑成 Provider)、Stripe(Checkout Session 跳转 + webhook 验签)——各自实现 `provider.Provider` 并按配置 `Register` 进注册表;同时收口 P2 有意延后的三项:`OrderV2.BizCode` 列 + webhook payload `product_biz_code`、多币种 product 模型 + `ProductResolver` 升级。真实密钥全走 env,测试全程 `httptest` 假渠道 + 免 docker,**不打真网**。
|
||
|
||
**Architecture:** 渠道差异封死在各 adapter 的 `Create/VerifyCallback/Query`(设计 §4.1),`internal/gateway` 与 `internal/provider` 保持中性,不 import 任何具体渠道。三个 adapter 落在 `internal/provider/{crypto,alipay,stripe}`,与 `fake` 平级。凭证一律经 `accounts.Registry.Credential(accountID, key)` 从 env 读(`<CredentialEnvPrefix>_<KEY>`),config.yaml 只存 env 前缀。多账户回调验签靠**装配期把已解析的渠道客户端注入 adapter**(P2 Self-Review D4 记录的方案:接口不变,adapter 持凭证/客户端)。多币种:新增 `model.ProductPrice`(product_id×currency→amount_minor)子表,`Product.Price`(v1 元 string)保留为 CNY 默认;`ProductResolver.Resolve(sku, currency)` 由**下单时选中的渠道结算币种**驱动(`provider.Capabilities().SettleCurrencies[0]`)。
|
||
|
||
**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite · `github.com/smartwalle/alipay/v3 v3.2.29`(已在 go.mod,v1 复用)· **新增** `github.com/stripe/stripe-go/v79`(pin,见 Task 5)· crypto 仅用标准库(`net/http`/`crypto/rand`)扫 TronGrid REST(逻辑移植自 pangolin 仓 ref `origin/worktree-macos-killswitch:pay/`,不 import pangolin)。
|
||
|
||
## Global Constraints(继承 P2)
|
||
|
||
- **复用 P1/P2,不重造**:金额 `AmountMinor int64 + Currency` 码(`internal/money`,`Parse(s,cur)`/`Format(minor,cur)`,CNY/USD=2 位、USDT=6 位);模型 `model.OrderV2`/`model.Attempt` + `V2` 后缀状态;数据访问 `store.OrderStore`;账户 `accounts.Registry.EnabledFor`/`Credential` + `config.AccountConfig`;管线 `gateway.Gateway`(`CreateOrder`/`Settle`/`HandleCallback`/`SyncPendingAttempts`);Provider 抽象 `provider.Provider`/`Session`/`PaidEvent`/`CreateRequest`/`CallbackInput`/`QueryRequest`/`Capabilities`/`Registry`。
|
||
- **金额权威在 pay**:客户端传 `sku`(product biz_code),金额由 `ProductResolver` 服务端按结算币种解析,**绝不接受裸传金额**。金额一律 int64 最小单位 + 币种码,禁 float。
|
||
- **Provider 中性**:adapter 只依赖 `internal/provider`(+ 自己的渠道 SDK / 标准库 + `internal/money` + `internal/accounts`);**`internal/gateway` 绝不 import 任何 adapter**。`internal/provider` 核心包不 import 任何 adapter(adapter 反向依赖核心包)。
|
||
- **凭证 env only**:所有密钥经 `accounts.Registry.Credential(accountID, key)` 或装配期注入,严禁写进 config.yaml / 落库明文 / 提交 git。测试用 `t.Setenv` / 生成临时密钥。
|
||
- **入账/幂等/金额核对不变**:入账仍走 P2 `gateway.Settle`(先幂等入队 webhook、再 `MarkAttemptPaid` 条件 UPDATE 翻转;`PaidCurrency==attempt.Currency` 且 `PaidAmountMinor>=attempt.AmountMinor`)。adapter 只负责产出归一化 `PaidEvent`,不碰入账。
|
||
- **GORM AutoMigrate 惯例**:新增列(`OrderV2.BizCode`)/新表(`ProductPrice`)由 `db.AutoMigrate` 增量建,**AutoMigrate 只加不删**,对存量库安全;模型内嵌 `model.Base`。
|
||
- 每步 `go build ./...` 通过;测试 `go test ./...`(全部 `:memory:`/临时 sqlite + `httptest`,**免 docker、不打真网**)。每任务严格 bite-sized TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。禁占位。
|
||
|
||
---
|
||
|
||
### Task 1: 收口延后项 A —— OrderV2 加 biz_code + webhook payload 带 product_biz_code
|
||
|
||
**Files:**
|
||
- Modify: `internal/model/v2.go`(`OrderV2` 加 `BizCode` 列)
|
||
- Modify: `internal/gateway/gateway.go`(`CreateOrder` 捕获 resolver 的 bizCode 并写入 order)
|
||
- Modify: `internal/gateway/settle.go`(`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>
|