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