diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 218c622..9d4effd 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -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) diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index e2ea7f8..992211e 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -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)