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 (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -19,8 +20,7 @@ import (
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
// PayService jiu ↔ pay 收款中枢对接(下单/查单已切 pay v2 契约 /api/v2/orders,
|
||||
// webhook 回调仍为 v1 契约,见 HandleCallback)。
|
||||
// PayService jiu ↔ pay 收款中枢对接:下单/查单/webhook 均为 pay v2 契约。
|
||||
// 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。
|
||||
type PayService struct {
|
||||
db *gorm.DB
|
||||
@@ -28,6 +28,9 @@ type PayService struct {
|
||||
secret string
|
||||
retURL string
|
||||
client *http.Client
|
||||
|
||||
seenMu sync.Mutex // 保护 seen(nonce 防重放,单实例内存实现)
|
||||
seen map[string]time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -208,18 +211,21 @@ func (s *PayService) signedPost(path string, rawBody []byte) ([]byte, error) {
|
||||
|
||||
// ---------- ② webhook 入账 ----------
|
||||
|
||||
// payNotification pay v2 webhook payload(无 trade_no 字段,事件以 event_type 为准,
|
||||
// X-Pay-Event 头不参与签名不依赖)。
|
||||
type payNotification struct {
|
||||
EventType string `json:"event_type"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
BizSystem string `json:"biz_system"`
|
||||
BizRef string `json:"biz_ref"`
|
||||
ProductBizCode string `json:"product_biz_code"`
|
||||
Amount string `json:"amount"`
|
||||
TradeNo string `json:"trade_no"`
|
||||
AmountMinor int64 `json:"amount_minor"`
|
||||
Currency string `json:"currency"`
|
||||
Channel string `json:"channel"`
|
||||
PaidAt string `json:"paid_at"`
|
||||
}
|
||||
|
||||
// HandleCallback 验签 + 幂等 + 金额核对 + 续期。错误分两类:
|
||||
// HandleCallback 验签 + 时间窗 + nonce 防重放 + 按 event_type 分发。错误分两类:
|
||||
// ErrPaySignature(回 401,不重试也无效);其余(回非 SUCCESS,pay 会重试)。
|
||||
func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) error {
|
||||
if !s.Configured() {
|
||||
@@ -235,6 +241,9 @@ func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) erro
|
||||
if d := time.Since(time.Unix(tsInt, 0)); d > 5*time.Minute || d < -5*time.Minute {
|
||||
return ErrPaySignature
|
||||
}
|
||||
if s.replayed(nonce) {
|
||||
return ErrPaySignature
|
||||
}
|
||||
|
||||
var n payNotification
|
||||
if err := json.Unmarshal(rawBody, &n); err != nil || n.OutTradeNo == "" {
|
||||
@@ -244,12 +253,39 @@ func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) erro
|
||||
if t, err := time.Parse(time.RFC3339, n.PaidAt); err == nil {
|
||||
paidAt = t
|
||||
}
|
||||
return s.settle(n.OutTradeNo, n.ProductBizCode, n.Amount, n.TradeNo, n.Channel, paidAt)
|
||||
switch n.EventType {
|
||||
case "payment.succeeded":
|
||||
return s.settle(n.OutTradeNo, n.ProductBizCode, n.AmountMinor, n.Currency, n.Channel, paidAt)
|
||||
default: // refund.*/未来事件:本期不接(另起任务);ack 防 60s 永久重投,ALERT 留痕
|
||||
log.Printf("[pay] ALERT unhandled webhook event=%s out_trade_no=%s (acked)", n.EventType, n.OutTradeNo)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对 + 同事务续期。
|
||||
// replayed nonce 防重放:10 分钟窗口内重复即拒绝(pay 合法重投每次生成新 nonce 不受影响;
|
||||
// 单实例内存实现,重启丢失由 settle 幂等兜底)。
|
||||
func (s *PayService) replayed(nonce string) bool {
|
||||
s.seenMu.Lock()
|
||||
defer s.seenMu.Unlock()
|
||||
now := time.Now()
|
||||
for k, t := range s.seen {
|
||||
if now.Sub(t) > 10*time.Minute {
|
||||
delete(s.seen, k)
|
||||
}
|
||||
}
|
||||
if _, ok := s.seen[nonce]; ok {
|
||||
return true
|
||||
}
|
||||
if s.seen == nil {
|
||||
s.seen = map[string]time.Time{}
|
||||
}
|
||||
s.seen[nonce] = now
|
||||
return false
|
||||
}
|
||||
|
||||
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对(残单先兜底回填)+ 同事务续期。
|
||||
// webhook 与查单兜底共用此入口。
|
||||
func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string, paidAt time.Time) error {
|
||||
func (s *PayService) settle(outTradeNo, bizCode string, amountMinor int64, currency, channel string, paidAt time.Time) error {
|
||||
var shopID uint64
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var p model.LicensePurchase
|
||||
@@ -263,8 +299,17 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
|
||||
if p.Status == "paid" { // 幂等:pay 会重发
|
||||
return nil
|
||||
}
|
||||
if !amountEqual(p.Amount, amount) {
|
||||
log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.Amount, amount)
|
||||
if p.AmountMinor == 0 { // D1 残单兜底:下单后回填失败,入账前补查权威价
|
||||
if st, qerr := s.queryOrder(outTradeNo); qerr == nil && st.AmountMinor > 0 {
|
||||
p.AmountMinor, p.Currency = st.AmountMinor, st.Currency
|
||||
if err := tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).
|
||||
Updates(map[string]any{"amount_minor": p.AmountMinor, "currency": p.Currency}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if amountMinor != p.AmountMinor || !strings.EqualFold(currency, p.Currency) {
|
||||
log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%d/%s callback=%d/%s", outTradeNo, p.AmountMinor, p.Currency, amountMinor, currency)
|
||||
return ErrPayAmount
|
||||
}
|
||||
// 权益按建单时的套餐映射;回调 biz_code 仅一致性校验(不一致以本地为准并告警)
|
||||
@@ -291,18 +336,20 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
|
||||
log.Printf("[pay] ALERT promo double-claim out_trade_no=%s shop=%d:本店已享受过首月特惠,本单不叠加时长", outTradeNo, p.ShopID)
|
||||
}
|
||||
}
|
||||
updates := map[string]any{
|
||||
"status": "paid",
|
||||
"channel": channel,
|
||||
"paid_at": paidAt,
|
||||
}
|
||||
if entitleOK {
|
||||
if err := entitle(tx, p.ShopID, plan); err != nil {
|
||||
renewedTo, err := entitle(tx, p.ShopID, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["renewed_to"] = renewedTo
|
||||
}
|
||||
shopID = p.ShopID
|
||||
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
|
||||
"status": "paid",
|
||||
"trade_no": tradeNo,
|
||||
"channel": channel,
|
||||
"paid_at": paidAt,
|
||||
}).Error
|
||||
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(updates).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -316,8 +363,9 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
|
||||
}
|
||||
|
||||
// entitle 给门店续期:时长直接叠加(未过期从到期日叠,已过期从现在起算),
|
||||
// 并写入套餐权益。逻辑对齐 LicenseService.Redeem 的叠加段。
|
||||
func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error {
|
||||
// 并写入套餐权益。逻辑对齐 LicenseService.Redeem 的叠加段。返回续期后的授权到期日
|
||||
// (settle 落库 renewed_to 展示用)。
|
||||
func entitle(tx *gorm.DB, shopID uint64, plan payPlan) (time.Time, error) {
|
||||
now := time.Now()
|
||||
var lic model.License
|
||||
err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
@@ -325,7 +373,7 @@ func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error {
|
||||
Order("id DESC").First(&lic).Error
|
||||
creating := errors.Is(err, gorm.ErrRecordNotFound)
|
||||
if err != nil && !creating {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
base := now
|
||||
@@ -345,16 +393,22 @@ func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error {
|
||||
MaxDevices: plan.MaxDevices,
|
||||
Features: plan.Features,
|
||||
}
|
||||
return tx.Create(&lic).Error
|
||||
if err := tx.Create(&lic).Error; err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return expires, nil
|
||||
}
|
||||
return tx.Model(&lic).Updates(map[string]any{
|
||||
if err := tx.Model(&lic).Updates(map[string]any{
|
||||
"type": plan.Type,
|
||||
"tier": plan.Tier,
|
||||
"expires_at": expires,
|
||||
"is_active": true,
|
||||
"max_devices": plan.MaxDevices,
|
||||
"features": plan.Features,
|
||||
}).Error
|
||||
}).Error; err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return expires, nil
|
||||
}
|
||||
|
||||
// BackfillPurchaseAmountMinor 启动回填:v1 存量购买单 amount("2999.00") → amount_minor(299900)+CNY。
|
||||
@@ -372,13 +426,6 @@ func BackfillPurchaseAmountMinor(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// amountEqual 金额按分归一比较("2999.00" == "2999" == "2999.0")。
|
||||
func amountEqual(a, b string) bool {
|
||||
ca, ea := toCents(a)
|
||||
cb, eb := toCents(b)
|
||||
return ea == nil && eb == nil && ca == cb
|
||||
}
|
||||
|
||||
func toCents(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -493,7 +540,7 @@ func (s *PayService) reconcileOnce() {
|
||||
if st.PaidAt != nil {
|
||||
paidAt = *st.PaidAt
|
||||
}
|
||||
if err := s.settle(p.OutTradeNo, p.ProductBizCode, formatMinor(st.AmountMinor), "", "", paidAt); err != nil {
|
||||
if err := s.settle(p.OutTradeNo, p.ProductBizCode, st.AmountMinor, st.Currency, "", paidAt); err != nil {
|
||||
log.Printf("[pay] reconcile settle failed out_trade_no=%s: %v", p.OutTradeNo, err)
|
||||
} else {
|
||||
log.Printf("[pay] reconcile settled out_trade_no=%s (webhook missed)", p.OutTradeNo)
|
||||
|
||||
@@ -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