feat(backend): pay webhook 升 v2 事件模型——event_type 分发、amount_minor 核对、nonce 防重放
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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_UnknownEventAcked:refund.* 等本期不接的事件应 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), "回调须回 SUCCESS,pay 停止重试")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user