feat(v2): order 补 biz_code + webhook payload 带 product_biz_code(收口 P2 延后项)

This commit is contained in:
wangjia
2026-07-10 14:12:44 +08:00
parent c06f6d3a3c
commit 7a27a6223a
5 changed files with 53 additions and 11 deletions
+2 -2
View File
@@ -72,7 +72,7 @@ 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, _, err := g.products.Resolve(in.SKU)
amountMinor, currency, subject, bizCode, err := g.products.Resolve(in.SKU)
if err != nil {
return nil, err // ErrProductNotFound
}
@@ -93,7 +93,7 @@ func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderR
if err := g.orders.CreateOrder(&model.OrderV2{
OutTradeNo: outNo, BizSystem: in.BizSystem, BizRef: in.BizRef,
Subject: subject, AmountMinor: amountMinor, Currency: currency,
BizCode: bizCode, Subject: subject, AmountMinor: amountMinor, Currency: currency,
Status: model.OrderPendingV2,
}); err != nil {
return nil, err
+17
View File
@@ -81,6 +81,23 @@ func TestCreateOrderPipeline(t *testing.T) {
}
}
func TestCreateOrderPersistsBizCode(t *testing.T) {
g, _, _, orders := newGateway(t)
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("create: %v", err)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil {
t.Fatalf("get order: %v", err)
}
if o.BizCode != "pro_year" { // stubResolver 的 bizCode
t.Fatalf("order.BizCode = %q, want pro_year", o.BizCode)
}
}
func TestCreateOrderErrors(t *testing.T) {
g, _, _, _ := newGateway(t)
ctx := context.Background()
+9 -8
View File
@@ -82,14 +82,15 @@ func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt, paidAt time.Time)
return nil // 独立收款无业务方回调;或已翻转/已取消
}
data := map[string]any{
"event_type": "payment.succeeded",
"out_trade_no": o.OutTradeNo,
"biz_system": o.BizSystem,
"biz_ref": o.BizRef,
"amount_minor": o.AmountMinor,
"currency": o.Currency,
"channel": att.Channel,
"paid_at": paidAt.Format(time.RFC3339),
"event_type": "payment.succeeded",
"out_trade_no": o.OutTradeNo,
"biz_system": o.BizSystem,
"biz_ref": o.BizRef,
"product_biz_code": o.BizCode, // 设计 §5:业务方按套餐码映射权益(时长/档位),不硬编码 product_id
"amount_minor": o.AmountMinor,
"currency": o.Currency,
"channel": att.Channel,
"paid_at": paidAt.Format(time.RFC3339),
}
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", data)
}
+23
View File
@@ -53,6 +53,29 @@ func TestSettleHappyIdempotentAndWebhook(t *testing.T) {
}
}
func TestSettleWebhookCarriesProductBizCode(t *testing.T) {
g, fp, spy, orders := newGateway(t)
ctx := context.Background()
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-9",
})
ref := attemptRef(t, orders)
fp.SetQueryResult(ref, provider.PaidEvent{
ProviderRef: ref, Status: provider.PaidSucceeded,
PaidAmountMinor: 29990000, PaidCurrency: "USDT",
})
if _, err := g.SyncPendingAttempts(ctx, 10); err != nil {
t.Fatalf("sync: %v", err)
}
if len(spy.calls) != 1 {
t.Fatalf("want 1 webhook, got %d", len(spy.calls))
}
if spy.calls[0]["product_biz_code"] != "pro_year" {
t.Fatalf("payload product_biz_code = %v, want pro_year", spy.calls[0]["product_biz_code"])
}
_ = res
}
func TestSettleGuards(t *testing.T) {
g, _, spy, orders := newGateway(t)
ctx := context.Background()
+2 -1
View File
@@ -47,6 +47,7 @@ type OrderV2 struct {
BizSystem string `gorm:"index;size:32"` // 接入方(pangolin/jiu),空=独立收款
BizRef string `gorm:"size:128"` // 接入方业务单号,回调原样带回
ProductID uint64 `gorm:"index"`
BizCode string `gorm:"index;size:64"` // 套餐码副本(下单时从 product 复制),回调带回业务方按码映射权益(设计 §5)
Subject string `gorm:"size:128"`
// 金额:int64 最小单位 + 币种(presentment/charge/settlement 三态在 charge 落地此处)
AmountMinor int64 `gorm:"not null"`
@@ -74,7 +75,7 @@ type Attempt struct {
AmountMinor int64 `gorm:"not null"`
Currency string `gorm:"size:16;not null"`
Status AttemptStatus `gorm:"index;size:16;not null"`
ExpiresAt *time.Time // 本次尝试超时
ExpiresAt *time.Time // 本次尝试超时
PaidAt *time.Time
}