feat(v2): 多币种 product(ProductPrice 分币价目 + v1 元回退)+ 结算币种由渠道能力驱动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 14:20:12 +08:00
parent abf6a43b8c
commit 585133532c
8 changed files with 128 additions and 54 deletions
+23 -10
View File
@@ -18,15 +18,18 @@ import (
)
var (
ErrProductNotFound = errors.New("gateway: product not found")
ErrNoAccount = errors.New("gateway: no enabled account for method/region")
ErrOrderNotPending = errors.New("gateway: order not pending")
ErrProductNotFound = errors.New("gateway: product not found")
ErrNoAccount = errors.New("gateway: no enabled account for method/region")
ErrOrderNotPending = errors.New("gateway: order not pending")
ErrNoSettleCurrency = errors.New("gateway: channel has no settle currency")
ErrCurrencyMismatch = errors.New("gateway: retry method settles a different currency")
)
// ProductResolver maps a client-facing SKU to the authoritative amount/currency.
// Amount authority lives in pay (设计 §3.1); the client never sends raw amounts.
// 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 string) (amountMinor int64, currency, subject, bizCode string, err error)
Resolve(sku, currency string) (amountMinor int64, subject, bizCode string, err error)
}
// WebhookEnqueuer receives a domain payload to deliver to the business system.
@@ -72,14 +75,19 @@ type OrderResult struct {
// account, persists a pending Order + Attempt (P1 OrderStore), and returns the
// payment session {render_type, payload}. 加渠道不改 client(设计 §4.2)。
func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderResult, error) {
amountMinor, currency, subject, bizCode, err := g.products.Resolve(in.SKU)
if err != nil {
return nil, err // ErrProductNotFound
}
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(含"该币种无价")
}
outNo := util.NewOutTradeNo("pay")
acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{
OutTradeNo: outNo, AmountMinor: amountMinor,
@@ -154,6 +162,11 @@ func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string) (*O
if err != nil {
return nil, err
}
caps := prov.Capabilities()
if len(caps.SettleCurrencies) == 0 || caps.SettleCurrencies[0] != o.Currency {
// 换到结算币种不同的渠道重试 = 需重定价,超出 P3 范围(P5 多币种路由)。
return nil, ErrCurrencyMismatch
}
tried, err := g.orders.AttemptAccountIDs(outTradeNo, method)
if err != nil {
return nil, err
+23 -3
View File
@@ -18,11 +18,19 @@ import (
type stubResolver struct{}
func (stubResolver) Resolve(sku string) (int64, string, string, string, error) {
func (stubResolver) Resolve(sku, currency string) (int64, string, string, error) {
if sku != "pro_year" {
return 0, "", "", "", gateway.ErrProductNotFound
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
}
return 29990000, "USDT", "Pro 年付", "pro_year", nil
}
type spyEnqueuer struct {
@@ -152,3 +160,15 @@ func TestRetrySwitchesAccount(t *testing.T) {
t.Fatalf("retry 应换到 fake-a2, got %+v", pend)
}
}
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)
}
}
+25 -20
View File
@@ -9,33 +9,38 @@ import (
"github.com/wangjia/pay/internal/money"
)
// DBProductResolver resolves a SKU (product biz_code) against the products table.
// v1 Product.Price is a "元" string; we parse it into int64 minor units for the
// given settlement currency. 加币种维度到 product 是 P3+ 的事;P2 用单一默认币种。
type DBProductResolver struct {
db *gorm.DB
currency string
}
// DBProductResolver 按 biz_code 解析套餐,金额取给定结算币种的权威价。
// 优先查 ProductPrice(分币种 int64 价);该币种无行且币种=CNY 时回退 Product.Price(v1 元 string)。
type DBProductResolver struct{ db *gorm.DB }
func NewDBProductResolver(db *gorm.DB, currency string) *DBProductResolver {
if currency == "" {
currency = "CNY"
}
return &DBProductResolver{db: db, currency: currency}
}
func NewDBProductResolver(db *gorm.DB) *DBProductResolver { return &DBProductResolver{db: db} }
func (r *DBProductResolver) Resolve(sku string) (int64, string, string, string, error) {
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
return 0, "", "", ErrProductNotFound
}
if err != nil {
return 0, "", "", "", err
return 0, "", "", err
}
minor, err := money.Parse(p.Price, r.currency)
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
}
return minor, r.currency, 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
}
+40 -17
View File
@@ -3,31 +3,54 @@ package gateway_test
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
)
func TestDBProductResolver(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:prodtest?mode=memory&cache=shared"),
&gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&model.Product{}); err != nil {
t.Fatalf("migrate: %v", err)
}
db := model.OpenTestDB(t)
db.Create(&model.Product{Name: "标准年付", Price: "299.00", BizCode: "annual_standard", Active: true})
r := gateway.NewDBProductResolver(db, "CNY")
minor, cur, subject, bizCode, err := r.Resolve("annual_standard")
if err != nil || minor != 29900 || cur != "CNY" || subject != "标准年付" || bizCode != "annual_standard" {
t.Fatalf("resolve = %d %s %s %s %v", minor, cur, subject, bizCode, err)
r := gateway.NewDBProductResolver(db)
minor, subject, bizCode, err := r.Resolve("annual_standard", "CNY")
if err != nil || minor != 29900 || subject != "标准年付" || bizCode != "annual_standard" {
t.Fatalf("resolve = %d %s %s %v", minor, subject, bizCode, err)
}
if _, _, _, _, err := r.Resolve("ghost"); err != gateway.ErrProductNotFound {
if _, _, _, err := r.Resolve("ghost", "CNY"); err != gateway.ErrProductNotFound {
t.Fatalf("缺套餐应 ErrProductNotFound, got %v", err)
}
}
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)
}
}
+2 -2
View File
@@ -25,8 +25,8 @@ func (nopEnqueuer) Enqueue(string, string, string, map[string]any) error { retur
type oneResolver struct{}
func (oneResolver) Resolve(sku string) (int64, string, string, string, error) {
return 29990000, "USDT", "Pro 年付", "pro_year", nil
func (oneResolver) Resolve(sku, currency string) (int64, string, string, error) {
return 29990000, "Pro 年付", "pro_year", nil
}
func buildEngine(t *testing.T) *gin.Engine {
+11
View File
@@ -0,0 +1,11 @@
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"`
}
+2 -1
View File
@@ -26,7 +26,8 @@ func OpenTestDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("open test db: %v", err)
}
if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}, &WebhookDelivery{}); err != nil {
if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}, &WebhookDelivery{},
&Product{}, &ProductPrice{}); err != nil {
t.Fatalf("migrate: %v", err)
}
sqlDB, _ := db.DB()
+2 -1
View File
@@ -50,7 +50,7 @@ func main() {
return o.Status == model.OrderPaidV2, nil
})
notifier.Start(60 * time.Second)
productResolver := gateway.NewDBProductResolver(db, "CNY") // 币种按部署区配(cn=CNY / global=USDT)
productResolver := gateway.NewDBProductResolver(db) // 币种由下单渠道结算能力驱动(设计 §4.1)
acctReg := accounts.New(config.C.Accounts)
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
@@ -103,6 +103,7 @@ func autoMigrate(db *gorm.DB) {
if err := db.AutoMigrate(
&model.Merchant{},
&model.Product{},
&model.ProductPrice{},
&model.Order{},
&model.NotifyLog{},
&model.BizNotifyLog{},