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 (
|
var (
|
||||||
ErrProductNotFound = errors.New("gateway: product not found")
|
ErrProductNotFound = errors.New("gateway: product not found")
|
||||||
ErrNoAccount = errors.New("gateway: no enabled account for method/region")
|
ErrNoAccount = errors.New("gateway: no enabled account for method/region")
|
||||||
ErrOrderNotPending = errors.New("gateway: order not pending")
|
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.
|
// ProductResolver maps a client SKU + settlement currency to authoritative amount.
|
||||||
// Amount authority lives in pay (设计 §3.1); the client never sends raw amounts.
|
// Currency is chosen by the selected channel's SettleCurrencies (设计 §3.1/§4.1),
|
||||||
|
// never sent by the client.
|
||||||
type ProductResolver interface {
|
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.
|
// 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
|
// account, persists a pending Order + Attempt (P1 OrderStore), and returns the
|
||||||
// payment session {render_type, payload}. 加渠道不改 client(设计 §4.2)。
|
// payment session {render_type, payload}. 加渠道不改 client(设计 §4.2)。
|
||||||
func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderResult, error) {
|
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)
|
prov, err := g.providers.Get(in.Method)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err // ErrUnknownMethod
|
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")
|
outNo := util.NewOutTradeNo("pay")
|
||||||
acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{
|
acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{
|
||||||
OutTradeNo: outNo, AmountMinor: amountMinor,
|
OutTradeNo: outNo, AmountMinor: amountMinor,
|
||||||
@@ -154,6 +162,11 @@ func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string) (*O
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
tried, err := g.orders.AttemptAccountIDs(outTradeNo, method)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -18,11 +18,19 @@ import (
|
|||||||
|
|
||||||
type stubResolver struct{}
|
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" {
|
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 {
|
type spyEnqueuer struct {
|
||||||
@@ -152,3 +160,15 @@ func TestRetrySwitchesAccount(t *testing.T) {
|
|||||||
t.Fatalf("retry 应换到 fake-a2, got %+v", pend)
|
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"
|
"github.com/wangjia/pay/internal/money"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DBProductResolver resolves a SKU (product biz_code) against the products table.
|
// DBProductResolver 按 biz_code 解析套餐,金额取给定结算币种的权威价。
|
||||||
// v1 Product.Price is a "元" string; we parse it into int64 minor units for the
|
// 优先查 ProductPrice(分币种 int64 价);该币种无行且币种=CNY 时回退 Product.Price(v1 元 string)。
|
||||||
// given settlement currency. 加币种维度到 product 是 P3+ 的事;P2 用单一默认币种。
|
type DBProductResolver struct{ db *gorm.DB }
|
||||||
type DBProductResolver struct {
|
|
||||||
db *gorm.DB
|
|
||||||
currency string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDBProductResolver(db *gorm.DB, currency string) *DBProductResolver {
|
func NewDBProductResolver(db *gorm.DB) *DBProductResolver { return &DBProductResolver{db: db} }
|
||||||
if currency == "" {
|
|
||||||
currency = "CNY"
|
|
||||||
}
|
|
||||||
return &DBProductResolver{db: db, currency: currency}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
var p model.Product
|
||||||
err := r.db.Where("biz_code = ? AND active = ?", sku, true).First(&p).Error
|
err := r.db.Where("biz_code = ? AND active = ?", sku, true).First(&p).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return 0, "", "", "", ErrProductNotFound
|
return 0, "", "", ErrProductNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", "", err
|
return 0, "", "", err
|
||||||
}
|
}
|
||||||
minor, err := money.Parse(p.Price, r.currency)
|
// 1) 分币种权威价
|
||||||
if err != nil {
|
var pp model.ProductPrice
|
||||||
return 0, "", "", "", err
|
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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
|
|
||||||
"github.com/wangjia/pay/internal/gateway"
|
"github.com/wangjia/pay/internal/gateway"
|
||||||
"github.com/wangjia/pay/internal/model"
|
"github.com/wangjia/pay/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDBProductResolver(t *testing.T) {
|
func TestDBProductResolver(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open("file:prodtest?mode=memory&cache=shared"),
|
db := model.OpenTestDB(t)
|
||||||
&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.Create(&model.Product{Name: "标准年付", Price: "299.00", BizCode: "annual_standard", Active: true})
|
db.Create(&model.Product{Name: "标准年付", Price: "299.00", BizCode: "annual_standard", Active: true})
|
||||||
|
|
||||||
r := gateway.NewDBProductResolver(db, "CNY")
|
r := gateway.NewDBProductResolver(db)
|
||||||
minor, cur, subject, bizCode, err := r.Resolve("annual_standard")
|
minor, subject, bizCode, err := r.Resolve("annual_standard", "CNY")
|
||||||
if err != nil || minor != 29900 || cur != "CNY" || subject != "标准年付" || bizCode != "annual_standard" {
|
if err != nil || minor != 29900 || subject != "标准年付" || bizCode != "annual_standard" {
|
||||||
t.Fatalf("resolve = %d %s %s %s %v", minor, cur, subject, bizCode, err)
|
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)
|
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{}
|
type oneResolver struct{}
|
||||||
|
|
||||||
func (oneResolver) Resolve(sku string) (int64, string, string, string, error) {
|
func (oneResolver) Resolve(sku, currency string) (int64, string, string, error) {
|
||||||
return 29990000, "USDT", "Pro 年付", "pro_year", nil
|
return 29990000, "Pro 年付", "pro_year", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildEngine(t *testing.T) *gin.Engine {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("open test db: %v", err)
|
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)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
sqlDB, _ := db.DB()
|
sqlDB, _ := db.DB()
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func main() {
|
|||||||
return o.Status == model.OrderPaidV2, nil
|
return o.Status == model.OrderPaidV2, nil
|
||||||
})
|
})
|
||||||
notifier.Start(60 * time.Second)
|
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)
|
acctReg := accounts.New(config.C.Accounts)
|
||||||
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
|
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
|
||||||
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
|
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
|
||||||
@@ -103,6 +103,7 @@ func autoMigrate(db *gorm.DB) {
|
|||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&model.Merchant{},
|
&model.Merchant{},
|
||||||
&model.Product{},
|
&model.Product{},
|
||||||
|
&model.ProductPrice{},
|
||||||
&model.Order{},
|
&model.Order{},
|
||||||
&model.NotifyLog{},
|
&model.NotifyLog{},
|
||||||
&model.BizNotifyLog{},
|
&model.BizNotifyLog{},
|
||||||
|
|||||||
Reference in New Issue
Block a user