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:
+23
-10
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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{},
|
||||
|
||||
Reference in New Issue
Block a user