diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 2ea83f7..33a0a7a 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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 diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 4ec78c7..b7e49e0 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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) + } +} diff --git a/internal/gateway/product.go b/internal/gateway/product.go index 2ee0ec7..dce3e4b 100644 --- a/internal/gateway/product.go +++ b/internal/gateway/product.go @@ -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 } diff --git a/internal/gateway/product_test.go b/internal/gateway/product_test.go index d30c053..02801f5 100644 --- a/internal/gateway/product_test.go +++ b/internal/gateway/product_test.go @@ -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) + } +} diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go index 55aaf36..d23576f 100644 --- a/internal/handler/gateway_test.go +++ b/internal/handler/gateway_test.go @@ -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 { diff --git a/internal/model/product_price.go b/internal/model/product_price.go new file mode 100644 index 0000000..8d9ab20 --- /dev/null +++ b/internal/model/product_price.go @@ -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"` +} diff --git a/internal/model/testdb.go b/internal/model/testdb.go index 7be7bdc..6fa61c5 100644 --- a/internal/model/testdb.go +++ b/internal/model/testdb.go @@ -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() diff --git a/main.go b/main.go index 9556690..e46f71f 100644 --- a/main.go +++ b/main.go @@ -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. 选策略(缺省 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{},