feat(backend): pay webhook 升 v2 事件模型——event_type 分发、amount_minor 核对、nonce 防重放

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 20:47:55 +08:00
parent f5a70d08cc
commit 7f713c4c96
2 changed files with 192 additions and 74 deletions
+114 -43
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
@@ -24,31 +25,39 @@ func newTestPaySvc(db *gorm.DB, baseURL string) *PayService {
return NewPayService(db, baseURL, testPaySecret, "https://jiu.example.com/license/result/")
}
// signedCallbackArgs 按契约给回调体生成签名参数(模拟 pay 侧签名)。
// signedCallbackArgs 按契约给回调体生成签名参数(模拟 pay 侧签名)。每次调用生成新 nonce
// 模拟 pay 合法重投(每次重投重新生成 nonce/sign);原样重放请复用同一次的返回值。
func signedCallbackArgs(body []byte) (ts, nonce, sign string) {
ts = strconv.FormatInt(time.Now().Unix(), 10)
nonce = "test-nonce"
nonce = uuid.New().String()
sign = util.PaySign(testPaySecret, "jiu", ts, nonce, string(body))
return
}
func callbackBody(outTradeNo, bizCode, amount string) []byte {
// eventBody 构造 pay v2 webhook payload(无 trade_no 字段)。
func eventBody(eventType, outTradeNo, bizCode string, amountMinor int64, currency string) []byte {
b, _ := json.Marshal(map[string]any{
"event_type": eventType,
"out_trade_no": outTradeNo,
"biz_system": "jiu",
"biz_ref": "1",
"product_biz_code": bizCode,
"amount": amount,
"trade_no": "2026070322001",
"amount_minor": amountMinor,
"currency": currency,
"channel": "alipay",
"paid_at": time.Now().Format(time.RFC3339),
})
return b
}
func createPendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode, amount, otn string) *model.LicensePurchase {
// callbackBody 便捷封装:event_type 固定 payment.succeeded、currency 固定 CNY。
func callbackBody(outTradeNo, bizCode string, amountMinor int64) []byte {
return eventBody("payment.succeeded", outTradeNo, bizCode, amountMinor, "CNY")
}
func createPendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode string, amountMinor int64, currency, otn string) *model.LicensePurchase {
t.Helper()
p := &model.LicensePurchase{ShopID: shopID, UserID: 1, ProductBizCode: bizCode, Amount: amount, OutTradeNo: otn, Status: "pending"}
p := &model.LicensePurchase{ShopID: shopID, UserID: 1, ProductBizCode: bizCode, AmountMinor: amountMinor, Currency: currency, OutTradeNo: otn, Status: "pending"}
require.NoError(t, db.Create(p).Error)
return p
}
@@ -64,21 +73,12 @@ func TestPaySign_Vector(t *testing.T) {
assert.False(t, util.PaySignVerify("other", got, "jiu", "1751520000", "nonce", `{"a":1}`))
}
func TestAmountEqual(t *testing.T) {
assert.True(t, amountEqual("2999.00", "2999"))
assert.True(t, amountEqual("2999.0", "2999.00"))
assert.True(t, amountEqual("0.01", "0.01"))
assert.False(t, amountEqual("2999.00", "2999.01"))
assert.False(t, amountEqual("", "2999"))
assert.False(t, amountEqual("abc", "2999"))
}
// ---------- 回调:验签门 ----------
func TestHandleCallback_BadSignature(t *testing.T) {
db := testutil.SetupTestDB()
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-1", "annual_standard", "2999.00")
body := callbackBody("yanmei-1", "annual_standard", 299900)
ts, nonce, _ := signedCallbackArgs(body)
err := svc.HandleCallback(body, ts, nonce, "forged-signature")
@@ -88,7 +88,7 @@ func TestHandleCallback_BadSignature(t *testing.T) {
func TestHandleCallback_ExpiredTimestamp(t *testing.T) {
db := testutil.SetupTestDB()
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-1", "annual_standard", "2999.00")
body := callbackBody("yanmei-1", "annual_standard", 299900)
ts := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)
sign := util.PaySign(testPaySecret, "jiu", ts, "n", string(body))
@@ -99,20 +99,80 @@ func TestHandleCallback_ExpiredTimestamp(t *testing.T) {
func TestHandleCallback_NotConfigured(t *testing.T) {
db := testutil.SetupTestDB()
svc := NewPayService(db, "http://pay.invalid", "", "")
body := callbackBody("yanmei-1", "annual_standard", "2999.00")
body := callbackBody("yanmei-1", "annual_standard", 299900)
ts, nonce, sign := signedCallbackArgs(body)
assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPaySignature)
}
// TestHandleCallback_NonceReplay:同一 ts/nonce/sign 原样重发(非 pay 合法重投,
// 合法重投每次生成新 nonce)→ 拒签。
func TestHandleCallback_NonceReplay(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY013")
createPendingPurchase(t, db, shop.ID, "annual_standard", 299900, "CNY", "yanmei-otn-replay")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-replay", "annual_standard", 299900)
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
err := svc.HandleCallback(body, ts, nonce, sign)
assert.ErrorIs(t, err, ErrPaySignature, "原样重放应拒签")
}
// TestHandleCallback_UnknownEventAckedrefund.* 等本期不接的事件应 ack(返回 nil)防
// pay 60s 永久重投,且不改变购买单状态。
func TestHandleCallback_UnknownEventAcked(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY014")
createPendingPurchase(t, db, shop.ID, "annual_standard", 299900, "CNY", "yanmei-otn-refund")
svc := newTestPaySvc(db, "http://pay.invalid")
body := eventBody("refund.succeeded", "yanmei-otn-refund", "annual_standard", 299900, "CNY")
ts, nonce, sign := signedCallbackArgs(body)
assert.NoError(t, svc.HandleCallback(body, ts, nonce, sign), "未知事件应 ack")
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-refund").First(&p).Error)
assert.Equal(t, "pending", p.Status, "未知事件不改变购买单状态")
}
// TestHandleCallback_ResidualAmountBackfill:残单兜底——建单时金额回填失败(amount_minor=0),
// 入账前补查权威价成功 → 核对通过并回填。
func TestHandleCallback_ResidualAmountBackfill(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY015")
createPendingPurchase(t, db, shop.ID, "annual_standard", 0, "", "yanmei-otn-residual")
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v2/orders/yanmei-otn-residual", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"order_no":"yanmei-otn-residual","status":"paid","amount_minor":299900,"currency":"CNY"}}`)
})
payServer := httptest.NewServer(mux)
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
body := callbackBody("yanmei-otn-residual", "annual_standard", 299900)
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-residual").First(&p).Error)
assert.Equal(t, "paid", p.Status)
assert.Equal(t, int64(299900), p.AmountMinor, "残单兜底应回填金额")
assert.Equal(t, "CNY", p.Currency)
assert.NotNil(t, p.RenewedTo, "入账应落库续期后到期日")
}
// ---------- 回调:入账 ----------
func TestHandleCallback_SettleAndRenew(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY001")
createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-1")
createPendingPurchase(t, db, shop.ID, "annual_standard", 299900, "CNY", "yanmei-otn-1")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-1", "annual_standard", "2999.00")
body := callbackBody("yanmei-otn-1", "annual_standard", 299900)
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
@@ -121,6 +181,7 @@ func TestHandleCallback_SettleAndRenew(t *testing.T) {
assert.Equal(t, "paid", p.Status)
assert.Equal(t, "alipay", p.Channel)
assert.NotNil(t, p.PaidAt)
assert.NotNil(t, p.RenewedTo, "入账应落库续期后到期日")
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
@@ -129,15 +190,16 @@ func TestHandleCallback_SettleAndRenew(t *testing.T) {
assert.Equal(t, 2, lic.MaxDevices)
assert.InDelta(t, 365, daysFromNow(lic.ExpiresAt), 1)
assert.Equal(t, float64(1000), lic.Features["image_quota"]) // JSON 数字解出 float64
assert.True(t, p.RenewedTo.Equal(*lic.ExpiresAt), "renewed_to 应等于续期后的授权到期日")
}
func TestHandleCallback_Idempotent(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY002")
createPendingPurchase(t, db, shop.ID, "monthly_pro", "599.00", "yanmei-otn-2")
createPendingPurchase(t, db, shop.ID, "monthly_pro", 59900, "CNY", "yanmei-otn-2")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-2", "monthly_pro", "599.00")
body := callbackBody("yanmei-otn-2", "monthly_pro", 59900)
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
@@ -148,7 +210,7 @@ func TestHandleCallback_Idempotent(t *testing.T) {
expires1 = *lic.ExpiresAt
}
// pay 重发同一单:不得重复续期
// pay 重发同一单:新 nonce/sign(合法重投特征),不得重复续期
ts2, nonce2, sign2 := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts2, nonce2, sign2))
var lic model.License
@@ -159,10 +221,10 @@ func TestHandleCallback_Idempotent(t *testing.T) {
func TestHandleCallback_AmountMismatch(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY003")
createPendingPurchase(t, db, shop.ID, "annual_pro", "5999.00", "yanmei-otn-3")
createPendingPurchase(t, db, shop.ID, "annual_pro", 599900, "CNY", "yanmei-otn-3")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-3", "annual_pro", "0.01") // 篡改金额
body := callbackBody("yanmei-otn-3", "annual_pro", 599899) // 差 1 分
ts, nonce, sign := signedCallbackArgs(body)
assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPayAmount)
@@ -174,7 +236,7 @@ func TestHandleCallback_AmountMismatch(t *testing.T) {
func TestHandleCallback_UnknownOrder(t *testing.T) {
db := testutil.SetupTestDB()
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-not-exist", "annual_standard", "2999.00")
body := callbackBody("yanmei-not-exist", "annual_standard", 299900)
ts, nonce, sign := signedCallbackArgs(body)
assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPurchaseNotFound)
}
@@ -190,10 +252,10 @@ func TestEntitle_StackOnActiveLicense(t *testing.T) {
ShopID: shop.ID, LicenseKey: "SEED-1", Type: "trial", Tier: "standard",
ExpiresAt: &future, IsActive: true, MaxDevices: 3,
}).Error)
createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-4")
createPendingPurchase(t, db, shop.ID, "annual_standard", 299900, "CNY", "yanmei-otn-4")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-4", "annual_standard", "2999.00")
body := callbackBody("yanmei-otn-4", "annual_standard", 299900)
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
@@ -211,10 +273,10 @@ func TestEntitle_ExpiredStartsFromNow(t *testing.T) {
ShopID: shop.ID, LicenseKey: "SEED-2", Type: "trial", Tier: "standard",
ExpiresAt: &past, IsActive: true, MaxDevices: 3,
}).Error)
createPendingPurchase(t, db, shop.ID, "monthly_standard", "299.00", "yanmei-otn-5")
createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-5")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-5", "monthly_standard", "299.00")
body := callbackBody("yanmei-otn-5", "monthly_standard", 29900)
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
@@ -334,13 +396,13 @@ func TestCreatePurchase_PromoOncePerShop(t *testing.T) {
assert.NotErrorIs(t, err, ErrPromoUsed)
// pending 单不算已享用(可能弃单),仍可重新下单
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-0")
createPendingPurchase(t, db, shop.ID, PromoBizCode, 100, "CNY", "yanmei-promo-0")
used, err = svc.PromoUsed(shop.ID)
require.NoError(t, err)
assert.False(t, used, "pending 不算已享用")
// 已支付的特惠单存在 → 已享用,再购直接拒绝
p := createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-1")
p := createPendingPurchase(t, db, shop.ID, PromoBizCode, 100, "CNY", "yanmei-promo-1")
require.NoError(t, db.Model(p).Update("status", "paid").Error)
used, err = svc.PromoUsed(shop.ID)
require.NoError(t, err)
@@ -362,10 +424,10 @@ func TestHandleCallback_PromoDoubleClaimNoEntitle(t *testing.T) {
svc := newTestPaySvc(db, "http://pay.invalid")
// 并发双买:两笔 pending 特惠单同时存在,先后收到回调
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-a")
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-b")
createPendingPurchase(t, db, shop.ID, PromoBizCode, 100, "CNY", "yanmei-promo-a")
createPendingPurchase(t, db, shop.ID, PromoBizCode, 100, "CNY", "yanmei-promo-b")
body := callbackBody("yanmei-promo-a", PromoBizCode, "1.00")
body := callbackBody("yanmei-promo-a", PromoBizCode, 100)
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
@@ -375,7 +437,7 @@ func TestHandleCallback_PromoDoubleClaimNoEntitle(t *testing.T) {
assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1, "第一笔正常续期 30 天")
// 第二笔回调:标 paid 但不再叠加
body2 := callbackBody("yanmei-promo-b", PromoBizCode, "1.00")
body2 := callbackBody("yanmei-promo-b", PromoBizCode, 100)
ts2, nonce2, sign2 := signedCallbackArgs(body2)
require.NoError(t, svc.HandleCallback(body2, ts2, nonce2, sign2), "回调须回 SUCCESSpay 停止重试")
@@ -387,8 +449,8 @@ func TestHandleCallback_PromoDoubleClaimNoEntitle(t *testing.T) {
assert.True(t, lic.ExpiresAt.Equal(expires1), "第二笔特惠单不得叠加时长")
// 兜底只限特惠:同店正常套餐单不受影响
createPendingPurchase(t, db, shop.ID, "monthly_standard", "299.00", "yanmei-normal-c")
body3 := callbackBody("yanmei-normal-c", "monthly_standard", "299.00")
createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-normal-c")
body3 := callbackBody("yanmei-normal-c", "monthly_standard", 29900)
ts3, nonce3, sign3 := signedCallbackArgs(body3)
require.NoError(t, svc.HandleCallback(body3, ts3, nonce3, sign3))
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
@@ -401,7 +463,7 @@ func TestStatus_ScopedToShop(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY007")
other := testutil.CreateTestShop(db, "PAY008")
createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-7")
createPendingPurchase(t, db, shop.ID, "annual_standard", 299900, "CNY", "yanmei-otn-7")
svc := newTestPaySvc(db, "http://pay.invalid")
st, err := svc.Status(shop.ID, "yanmei-otn-7")
@@ -414,14 +476,23 @@ func TestStatus_ScopedToShop(t *testing.T) {
// ---------- v2 金额回填 ----------
// createLegacyAmountPurchase 建一条只有 v1 元字符串 amount、amount_minor=0 的存量购买单,
// 供 BackfillPurchaseAmountMinor 测试其从旧列回填。
func createLegacyAmountPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode, amount, otn string) *model.LicensePurchase {
t.Helper()
p := &model.LicensePurchase{ShopID: shopID, UserID: 1, ProductBizCode: bizCode, Amount: amount, OutTradeNo: otn, Status: "pending"}
require.NoError(t, db.Create(p).Error)
return p
}
func TestBackfillPurchaseAmountMinor(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY012")
p1 := createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-backfill-1")
p2 := createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-backfill-2")
p1 := createLegacyAmountPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-backfill-1")
p2 := createLegacyAmountPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-backfill-2")
// 已有 amount_minor>0 的行不应被覆盖
p3 := createPendingPurchase(t, db, shop.ID, "monthly_pro", "599.00", "yanmei-backfill-3")
p3 := createLegacyAmountPurchase(t, db, shop.ID, "monthly_pro", "599.00", "yanmei-backfill-3")
require.NoError(t, db.Model(p3).Updates(map[string]any{"amount_minor": 123, "currency": "USD"}).Error)
BackfillPurchaseAmountMinor(db)