package service import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/util" "github.com/wangjia/jiu/backend/testutil" ) const testPaySecret = "test-shared-secret" func newTestPaySvc(db *gorm.DB, baseURL string) *PayService { return NewPayService(db, baseURL, testPaySecret, "https://jiu.example.com/license/result/") } // signedCallbackArgs 按契约给回调体生成签名参数(模拟 pay 侧签名)。每次调用生成新 nonce, // 模拟 pay 合法重投(每次重投重新生成 nonce/sign);原样重放请复用同一次的返回值。 func signedCallbackArgs(body []byte) (ts, nonce, sign string) { ts = strconv.FormatInt(time.Now().Unix(), 10) nonce = uuid.New().String() sign = util.PaySign(testPaySecret, "jiu", ts, nonce, string(body)) return } // 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_minor": amountMinor, "currency": currency, "channel": "alipay", "paid_at": time.Now().Format(time.RFC3339), }) return b } // 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, AmountMinor: amountMinor, Currency: currency, OutTradeNo: otn, Status: "pending"} require.NoError(t, db.Create(p).Error) return p } // createPurchaseFull ListPurchases 测试专用:可控 userID/status/pay_url/created_at/paid_at/renewed_to。 func createPurchaseFull(t *testing.T, db *gorm.DB, shopID, userID uint64, bizCode string, amountMinor int64, otn, status, payURL string, createdAt time.Time) *model.LicensePurchase { t.Helper() p := &model.LicensePurchase{ ShopID: shopID, UserID: userID, ProductBizCode: bizCode, AmountMinor: amountMinor, Currency: "CNY", OutTradeNo: otn, Status: status, PayURL: payURL, } require.NoError(t, db.Create(p).Error) require.NoError(t, db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Update("created_at", createdAt).Error) if status == "paid" { paidAt := createdAt.Add(time.Minute) renewedTo := createdAt.AddDate(0, 0, 30) require.NoError(t, db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID). Updates(map[string]any{"paid_at": paidAt, "renewed_to": renewedTo}).Error) } require.NoError(t, db.First(p, p.ID).Error) return p } // createStalePendingPurchase 建一条 created_at 在 5 分钟对账窗口之外的 pending 购买单, // 供 reconcileOnce 测试(该函数只捞 created_at < now-5min 的 pending 单)。 func createStalePendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode string, amountMinor int64, currency, otn string) *model.LicensePurchase { t.Helper() p := createPendingPurchase(t, db, shopID, bizCode, amountMinor, currency, otn) stale := time.Now().Add(-10 * time.Minute) require.NoError(t, db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Update("created_at", stale).Error) return p } // ---------- 签名 ---------- func TestPaySign_Vector(t *testing.T) { // 与契约参考实现一致:base64(HMAC_SHA256(secret, join("\n", parts))) got := util.PaySign("secret", "jiu", "1751520000", "nonce", `{"a":1}`) assert.NotEmpty(t, got) assert.True(t, util.PaySignVerify("secret", got, "jiu", "1751520000", "nonce", `{"a":1}`)) assert.False(t, util.PaySignVerify("secret", got, "jiu", "1751520001", "nonce", `{"a":1}`)) assert.False(t, util.PaySignVerify("other", got, "jiu", "1751520000", "nonce", `{"a":1}`)) } // ---------- 回调:验签门 ---------- func TestHandleCallback_BadSignature(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-1", "annual_standard", 299900) ts, nonce, _ := signedCallbackArgs(body) err := svc.HandleCallback(body, ts, nonce, "forged-signature") assert.ErrorIs(t, err, ErrPaySignature) } func TestHandleCallback_ExpiredTimestamp(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") 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)) err := svc.HandleCallback(body, ts, "n", sign) assert.ErrorIs(t, err, ErrPaySignature) } func TestHandleCallback_NotConfigured(t *testing.T) { db := testutil.SetupTestDB() svc := NewPayService(db, "http://pay.invalid", "", "") 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, "入账应落库续期后到期日") } // TestSettle_ResidualQueryFailFailsClosed:残单(amount_minor=0)且调用方自己也不知道金额 // (amountMinor==0,如 reconcile 遇到 pay 侧字段异常)时,settle 内补查权威价若也失败 // (pay 查单 500),必须 fail-closed:不入账、不续期、购买单维持 pending。 func TestSettle_ResidualQueryFailFailsClosed(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY016") createPendingPurchase(t, db, shop.ID, "annual_standard", 0, "", "yanmei-otn-residual-fail") mux := http.NewServeMux() mux.HandleFunc("GET /api/v2/orders/yanmei-otn-residual-fail", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) payServer := httptest.NewServer(mux) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) err := svc.settle("yanmei-otn-residual-fail", "annual_standard", 0, "", "alipay", time.Now()) assert.ErrorIs(t, err, ErrPayAmount, "补查也失败应 fail-closed") var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-residual-fail").First(&p).Error) assert.Equal(t, "pending", p.Status, "补查失败不得入账") assert.Nil(t, p.RenewedTo, "补查失败不得续期") var licCount int64 require.NoError(t, db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&licCount).Error) assert.Equal(t, int64(0), licCount, "补查失败不得续期") } // ---------- 回调:入账 ---------- func TestHandleCallback_SettleAndRenew(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY001") 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", 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-1").First(&p).Error) 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) assert.Equal(t, "standard", lic.Tier) assert.Equal(t, "annual", lic.Type) 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", 59900, "CNY", "yanmei-otn-2") svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-otn-2", "monthly_pro", 59900) ts, nonce, sign := signedCallbackArgs(body) require.NoError(t, svc.HandleCallback(body, ts, nonce, sign)) var expires1 time.Time { var lic model.License require.NoError(t, db.Where("shop_id = ?", shop.ID).First(&lic).Error) expires1 = *lic.ExpiresAt } // pay 重发同一单:新 nonce/sign(合法重投特征),不得重复续期 ts2, nonce2, sign2 := signedCallbackArgs(body) require.NoError(t, svc.HandleCallback(body, ts2, nonce2, sign2)) var lic model.License require.NoError(t, db.Where("shop_id = ?", shop.ID).First(&lic).Error) assert.True(t, lic.ExpiresAt.Equal(expires1), "重发不得二次叠加") } func TestHandleCallback_AmountMismatch(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY003") 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", 599899) // 差 1 分 ts, nonce, sign := signedCallbackArgs(body) assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPayAmount) var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-3").First(&p).Error) assert.Equal(t, "pending", p.Status, "金额不符不得入账") } func TestHandleCallback_UnknownOrder(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-not-exist", "annual_standard", 299900) ts, nonce, sign := signedCallbackArgs(body) assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPurchaseNotFound) } // ---------- 续期叠加 ---------- func TestEntitle_StackOnActiveLicense(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY004") // 现有授权还剩 100 天(如试用/兑换券),购买年付应从到期日往后叠 future := time.Now().Add(100 * 24 * time.Hour) require.NoError(t, db.Create(&model.License{ ShopID: shop.ID, LicenseKey: "SEED-1", Type: "trial", Tier: "standard", ExpiresAt: &future, IsActive: true, MaxDevices: 3, }).Error) 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", 299900) ts, nonce, sign := signedCallbackArgs(body) require.NoError(t, svc.HandleCallback(body, ts, nonce, sign)) var lic model.License require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error) assert.InDelta(t, 100+365, daysFromNow(lic.ExpiresAt), 1, "未过期应从到期日叠加") assert.Equal(t, "annual", lic.Type) } func TestEntitle_ExpiredStartsFromNow(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY005") past := time.Now().Add(-30 * 24 * time.Hour) require.NoError(t, db.Create(&model.License{ ShopID: shop.ID, LicenseKey: "SEED-2", Type: "trial", Tier: "standard", ExpiresAt: &past, IsActive: true, MaxDevices: 3, }).Error) 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", 29900) ts, nonce, sign := signedCallbackArgs(body) require.NoError(t, svc.HandleCallback(body, ts, nonce, sign)) var lic model.License require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error) assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1, "已过期应从现在起算") } // ---------- 下单 ---------- func TestCreatePurchase_HappyPath(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY006") // 假 pay v2 服务:POST /api/v2/orders 验签后返回 session(redirect)+ GET /api/v2/orders/:no 回填金额 mux := http.NewServeMux() var gotSku, gotMethod, gotBizSystem, gotBizRef string mux.HandleFunc("POST /api/v2/orders", func(w http.ResponseWriter, r *http.Request) { body := make([]byte, r.ContentLength) _, _ = r.Body.Read(body) if !util.PaySignVerify(testPaySecret, r.Header.Get("X-Pay-Sign"), r.Header.Get("X-Pay-System"), r.Header.Get("X-Pay-Timestamp"), r.Header.Get("X-Pay-Nonce"), string(body)) { w.WriteHeader(http.StatusUnauthorized) return } var req map[string]any _ = json.Unmarshal(body, &req) gotSku, _ = req["sku"].(string) gotMethod, _ = req["method"].(string) gotBizSystem, _ = req["biz_system"].(string) gotBizRef, _ = req["biz_ref"].(string) fmt.Fprint(w, `{"data":{"order_no":"pay-x1","session":{"render_type":"redirect","payload":{"url":"https://pay.test/cashier"},"expires_at":"2026-07-10T12:00:00Z"}}}`) }) mux.HandleFunc("GET /api/v2/orders/pay-x1", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"data":{"order_no":"pay-x1","status":"pending","subject":"岩美酒库·标准版年付","amount_minor":299900,"currency":"CNY"}}`) }) payServer := httptest.NewServer(mux) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard", "mobile") require.NoError(t, err) assert.Equal(t, "pay-x1", res.OutTradeNo) assert.Equal(t, "redirect", res.RenderType) assert.Equal(t, "https://pay.test/cashier", res.Payload["url"]) assert.Equal(t, int64(299900), res.AmountMinor) assert.Equal(t, "CNY", res.Currency) assert.Equal(t, "https://pay.test/cashier", res.PayURL, "兼容字段:redirect 时 = payload.url") assert.Equal(t, "2999.00", res.Amount, "兼容字段:分转元字符串") assert.Equal(t, "annual_standard", gotSku, "sku 应直用 biz_code") assert.Equal(t, "alipay", gotMethod) assert.Equal(t, "jiu", gotBizSystem) var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "pay-x1").First(&p).Error) assert.Equal(t, "pending", p.Status) assert.Equal(t, int64(299900), p.AmountMinor) assert.Equal(t, "CNY", p.Currency) assert.Equal(t, "https://pay.test/cashier", p.PayURL) assert.Equal(t, strconv.FormatUint(p.ID, 10), gotBizRef, "biz_ref 应为购买记录 id") } // TestCreatePurchase_QueryOrderFailKeepsZeroAmount 覆盖 D1 金额兜底:下单 POST 成功但 // best-effort 查单 GET 失败时,不阻断下单,金额留 0(PayURL/OutTradeNo 等仍正常落库)。 func TestCreatePurchase_QueryOrderFailKeepsZeroAmount(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY007") mux := http.NewServeMux() mux.HandleFunc("POST /api/v2/orders", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"data":{"order_no":"pay-test-qf","session":{"render_type":"redirect","payload":{"url":"https://pay.test/cashier"}}}}`) }) mux.HandleFunc("GET /api/v2/orders/pay-test-qf", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) payServer := httptest.NewServer(mux) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard", "mobile") require.NoError(t, err, "查单失败不应阻断下单") assert.Equal(t, int64(0), res.AmountMinor, "查单失败金额留 0") assert.Equal(t, "", res.Amount, "查单失败金额留 0") assert.Equal(t, "https://pay.test/cashier", res.PayURL) var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "pay-test-qf").First(&p).Error) assert.Equal(t, "https://pay.test/cashier", p.PayURL) assert.Equal(t, int64(0), p.AmountMinor) } func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") _, err := svc.CreatePurchase(1, 1, "no_such_plan", "pc") assert.ErrorIs(t, err, ErrUnknownPlan) unconfigured := NewPayService(db, "http://pay.invalid", "", "") _, err = unconfigured.CreatePurchase(1, 1, "annual_standard", "pc") assert.ErrorIs(t, err, ErrPayNotConfigured) } // ---------- 首月特惠限购 ---------- func TestCreatePurchase_PromoOncePerShop(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY009") other := testutil.CreateTestShop(db, "PAY010") svc := newTestPaySvc(db, "http://pay.invalid") // 未买过:不触发限购(pay 地址无效会走到下单失败,但不是 ErrPromoUsed) used, err := svc.PromoUsed(shop.ID) require.NoError(t, err) assert.False(t, used) _, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode, "pc") assert.NotErrorIs(t, err, ErrPromoUsed) // pending 单不算已享用(可能弃单),仍可重新下单 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, 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) assert.True(t, used) _, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode, "pc") assert.ErrorIs(t, err, ErrPromoUsed) // 多租户隔离:别家买过不影响本店 used, err = svc.PromoUsed(other.ID) require.NoError(t, err) assert.False(t, used) } // webhook 兜底(契约 INTEGRATION-BOARD):并发/绕过前端产生第二笔特惠单时, // 回调仍回 SUCCESS 并标 paid(钱已收),但不叠加时长,防止绕过购买接口的限购。 func TestHandleCallback_PromoDoubleClaimNoEntitle(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY011") svc := newTestPaySvc(db, "http://pay.invalid") // 并发双买:两笔 pending 特惠单同时存在,先后收到回调 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, 100) ts, nonce, sign := signedCallbackArgs(body) require.NoError(t, svc.HandleCallback(body, ts, nonce, sign)) var lic model.License require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error) expires1 := *lic.ExpiresAt assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1, "第一笔正常续期 30 天") // 第二笔回调:标 paid 但不再叠加 body2 := callbackBody("yanmei-promo-b", PromoBizCode, 100) ts2, nonce2, sign2 := signedCallbackArgs(body2) require.NoError(t, svc.HandleCallback(body2, ts2, nonce2, sign2), "回调须回 SUCCESS,pay 停止重试") var p2 model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-promo-b").First(&p2).Error) assert.Equal(t, "paid", p2.Status, "第二笔仍标 paid(钱已收,退款人工处理)") require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error) assert.True(t, lic.ExpiresAt.Equal(expires1), "第二笔特惠单不得叠加时长") // 兜底只限特惠:同店正常套餐单不受影响 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) assert.InDelta(t, 60, daysFromNow(lic.ExpiresAt), 1, "正常套餐照常叠加") } // ---------- Status ---------- 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", 299900, "CNY", "yanmei-otn-7") svc := newTestPaySvc(db, "http://pay.invalid") st, err := svc.Status(shop.ID, "yanmei-otn-7") require.NoError(t, err) assert.Equal(t, "pending", st.Status) _, err = svc.Status(other.ID, "yanmei-otn-7") assert.ErrorIs(t, err, ErrPurchaseNotFound, "跨店不可见") } // ---------- 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 := 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 := 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) var got1, got2, got3 model.LicensePurchase require.NoError(t, db.First(&got1, p1.ID).Error) require.NoError(t, db.First(&got2, p2.ID).Error) require.NoError(t, db.First(&got3, p3.ID).Error) assert.Equal(t, int64(299900), got1.AmountMinor) assert.Equal(t, "CNY", got1.Currency) assert.Equal(t, int64(100), got2.AmountMinor) assert.Equal(t, "CNY", got2.Currency) assert.Equal(t, int64(123), got3.AmountMinor, "已有 amount_minor>0 的行不被覆盖") assert.Equal(t, "USD", got3.Currency, "已有 amount_minor>0 的行不被覆盖") } // ---------- 查单兜底:reconcileOnce v2 八态映射 ---------- // reconcileMux 建一个只服务 GET /api/v2/orders/:no 的假 pay,固定返回给定状态/金额。 func reconcileMux(t *testing.T, otn, status string, amountMinor int64, currency string) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("GET /api/v2/orders/"+otn, func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"data":{"order_no":%q,"status":%q,"amount_minor":%d,"currency":%q}}`, otn, status, amountMinor, currency) }) return httptest.NewServer(mux) } func TestReconcileOnce_PaidSettlesAndRenews(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY017") createStalePendingPurchase(t, db, shop.ID, "annual_standard", 299900, "CNY", "yanmei-otn-reconcile-paid") payServer := reconcileMux(t, "yanmei-otn-reconcile-paid", "paid", 299900, "CNY") defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) svc.reconcileOnce() var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-paid").First(&p).Error) assert.Equal(t, "paid", p.Status, "查单发现已支付应入账") assert.NotNil(t, p.RenewedTo, "入账应同事务续期") var lic model.License require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error) assert.InDelta(t, 365, daysFromNow(lic.ExpiresAt), 1, "年付套餐应续期 365 天") } func TestReconcileOnce_CanceledMarksFailed(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY018") createStalePendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-canceled") payServer := reconcileMux(t, "yanmei-otn-reconcile-canceled", "canceled", 29900, "CNY") defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) svc.reconcileOnce() var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-canceled").First(&p).Error) assert.Equal(t, "failed", p.Status, "canceled 应标 failed") } func TestReconcileOnce_ExpiredMarksFailed(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY019") createStalePendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-expired") payServer := reconcileMux(t, "yanmei-otn-reconcile-expired", "expired", 29900, "CNY") defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) svc.reconcileOnce() var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-expired").First(&p).Error) assert.Equal(t, "failed", p.Status, "expired 应标 failed") } func TestReconcileOnce_RefundedNoOp(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY020") createStalePendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-refunded") payServer := reconcileMux(t, "yanmei-otn-reconcile-refunded", "refunded", 29900, "CNY") defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) svc.reconcileOnce() var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-refunded").First(&p).Error) assert.Equal(t, "pending", p.Status, "refunded 本轮 no-op,仅记录,状态不变") } // TestReconcileOnce_WithinWindowSkipped:created_at 未超 5 分钟对账窗口的 pending 单不应被捞到。 func TestReconcileOnce_WithinWindowSkipped(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY021") createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-fresh") payServer := reconcileMux(t, "yanmei-otn-reconcile-fresh", "paid", 29900, "CNY") defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) svc.reconcileOnce() var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-fresh").First(&p).Error) assert.Equal(t, "pending", p.Status, "未超窗口的单本轮不应被对账") } // ---------- 取消透传(Task 5:防 pending 堆积挤占 reconcile 每轮 50 条限额)---------- // cancelMux 建一个只服务 POST /api/v2/orders/:no/cancel 的假 pay,固定回给定 canceled 值, // 并统计外呼次数(供"非 pending 单不外呼"断言)。 func cancelMux(t *testing.T, otn string, canceled bool, callCount *int) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("POST /api/v2/orders/"+otn+"/cancel", func(w http.ResponseWriter, r *http.Request) { if callCount != nil { *callCount++ } fmt.Fprintf(w, `{"data":{"canceled":%v}}`, canceled) }) return httptest.NewServer(mux) } func TestCancelPurchase_PayCanceledMarksFailed(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY022") createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-1") var calls int payServer := cancelMux(t, "yanmei-otn-cancel-1", true, &calls) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) canceled, err := svc.CancelPurchase(shop.ID, "yanmei-otn-cancel-1") require.NoError(t, err) assert.True(t, canceled) assert.Equal(t, 1, calls, "应外呼 pay 一次") var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-cancel-1").First(&p).Error) assert.Equal(t, "failed", p.Status, "pay 回 canceled=true 应本地标 failed") } // TestCancelPurchase_PayAlreadyPaidKeepsPending:pay 回 canceled=false(已支付竞态: // 取消请求到达 pay 时单已被支付)→ 本地必须保持 pending,等 webhook/reconcile 正常入账, // 不得被误标 failed(否则钱已收但门店权益丢失)。 func TestCancelPurchase_PayAlreadyPaidKeepsPending(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY023") createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-2") payServer := cancelMux(t, "yanmei-otn-cancel-2", false, nil) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) canceled, err := svc.CancelPurchase(shop.ID, "yanmei-otn-cancel-2") require.NoError(t, err) assert.False(t, canceled) var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-cancel-2").First(&p).Error) assert.Equal(t, "pending", p.Status, "已支付竞态本地应保持 pending,等 webhook/reconcile 入账") } func TestCancelPurchase_OtherShopNotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY024") other := testutil.CreateTestShop(db, "PAY025") createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-3") svc := newTestPaySvc(db, "http://pay.invalid") _, err := svc.CancelPurchase(other.ID, "yanmei-otn-cancel-3") assert.ErrorIs(t, err, ErrPurchaseNotFound, "跨店不可见") } // TestCancelPurchase_NonPendingNoOpNoExternalCall:非 pending 单(已 paid)直接返回 false, // 且不外呼 pay(避免对已终态单做无意义/有风险的取消请求)。 func TestCancelPurchase_NonPendingNoOpNoExternalCall(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY026") p := createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-4") require.NoError(t, db.Model(p).Update("status", "paid").Error) var calls int payServer := cancelMux(t, "yanmei-otn-cancel-4", true, &calls) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) canceled, err := svc.CancelPurchase(shop.ID, "yanmei-otn-cancel-4") require.NoError(t, err) assert.False(t, canceled) assert.Equal(t, 0, calls, "非 pending 单不应外呼 pay") var got model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-cancel-4").First(&got).Error) assert.Equal(t, "paid", got.Status, "非 pending 单状态不变") } // ---------- ⑥ 订单列表(订单管理 tab)---------- // TestListPurchases_ShopIsolation:3 店共 7 单,A 店列表绝不含 B/C 店的单。 func TestListPurchases_ShopIsolation(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") shopA := testutil.CreateTestShop(db, "PAY100") shopB := testutil.CreateTestShop(db, "PAY101") shopC := testutil.CreateTestShop(db, "PAY102") base := time.Now().Add(-time.Hour) createPurchaseFull(t, db, shopA.ID, 1, "annual_pro", 599900, "iso-a-1", "paid", "", base) createPurchaseFull(t, db, shopA.ID, 1, "monthly_standard", 29900, "iso-a-2", "pending", "https://pay.example.com/a2", base.Add(time.Minute)) createPurchaseFull(t, db, shopA.ID, 1, "monthly_standard", 29900, "iso-a-3", "failed", "", base.Add(2*time.Minute)) createPurchaseFull(t, db, shopB.ID, 1, "annual_standard", 199900, "iso-b-1", "paid", "", base) createPurchaseFull(t, db, shopB.ID, 1, "monthly_pro", 99900, "iso-b-2", "pending", "https://pay.example.com/b2", base.Add(time.Minute)) createPurchaseFull(t, db, shopC.ID, 1, "monthly_standard", 29900, "iso-c-1", "paid", "", base) createPurchaseFull(t, db, shopC.ID, 1, "monthly_standard", 29900, "iso-c-2", "pending", "https://pay.example.com/c2", base.Add(time.Minute)) list, err := svc.ListPurchases(shopA.ID, 1, 20, "") require.NoError(t, err) assert.EqualValues(t, 3, list.Total) assert.Len(t, list.Items, 3) for _, item := range list.Items { assert.Contains(t, []string{"iso-a-1", "iso-a-2", "iso-a-3"}, item.OutTradeNo, "A 店列表不得混入 B/C 店订单") } assert.EqualValues(t, 3, list.Summary.TotalCount) } // TestListPurchases_OrderAndPagination:按 created_at DESC 排序,分页边界正确。 func TestListPurchases_OrderAndPagination(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") shop := testutil.CreateTestShop(db, "PAY103") base := time.Now().Add(-time.Hour) // otn-1 最早,otn-5 最晚 for i := 1; i <= 5; i++ { createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, fmt.Sprintf("order-%d", i), "paid", "", base.Add(time.Duration(i)*time.Minute)) } // page=1 size=2:最新两条 order-5, order-4 p1, err := svc.ListPurchases(shop.ID, 1, 2, "") require.NoError(t, err) assert.EqualValues(t, 5, p1.Total) require.Len(t, p1.Items, 2) assert.Equal(t, "order-5", p1.Items[0].OutTradeNo) assert.Equal(t, "order-4", p1.Items[1].OutTradeNo) // page=2 size=2:order-3, order-2 p2, err := svc.ListPurchases(shop.ID, 2, 2, "") require.NoError(t, err) require.Len(t, p2.Items, 2) assert.Equal(t, "order-3", p2.Items[0].OutTradeNo) assert.Equal(t, "order-2", p2.Items[1].OutTradeNo) // page=3 size=2:仅剩 order-1(边界:末页不满) p3, err := svc.ListPurchases(shop.ID, 3, 2, "") require.NoError(t, err) require.Len(t, p3.Items, 1) assert.Equal(t, "order-1", p3.Items[0].OutTradeNo) // page=4 size=2:越界返回空 p4, err := svc.ListPurchases(shop.ID, 4, 2, "") require.NoError(t, err) assert.Empty(t, p4.Items) assert.EqualValues(t, 5, p4.Total) } // TestListPurchases_StatusFilter:status 筛选生效;非法 status 报 ErrInvalidStatus。 func TestListPurchases_StatusFilter(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") shop := testutil.CreateTestShop(db, "PAY104") base := time.Now().Add(-time.Hour) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "flt-1", "paid", "", base) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "flt-2", "paid", "", base.Add(time.Minute)) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "flt-3", "pending", "https://pay.example.com/flt-3", base.Add(2*time.Minute)) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "flt-4", "failed", "", base.Add(3*time.Minute)) paid, err := svc.ListPurchases(shop.ID, 1, 20, "paid") require.NoError(t, err) assert.EqualValues(t, 2, paid.Total) for _, item := range paid.Items { assert.Equal(t, "paid", item.Status) } pending, err := svc.ListPurchases(shop.ID, 1, 20, "pending") require.NoError(t, err) assert.EqualValues(t, 1, pending.Total) assert.Equal(t, "flt-3", pending.Items[0].OutTradeNo) failed, err := svc.ListPurchases(shop.ID, 1, 20, "failed") require.NoError(t, err) assert.EqualValues(t, 1, failed.Total) all, err := svc.ListPurchases(shop.ID, 1, 20, "") require.NoError(t, err) assert.EqualValues(t, 4, all.Total) _, err = svc.ListPurchases(shop.ID, 1, 20, "bogus") assert.ErrorIs(t, err, ErrInvalidStatus) } // TestListPurchases_SummaryStableAcrossFilterAndPagination:summary 四值只看店铺全量, // 不随 status 筛选/分页变化。 func TestListPurchases_SummaryStableAcrossFilterAndPagination(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") shop := testutil.CreateTestShop(db, "PAY105") base := time.Now().Add(-time.Hour) createPurchaseFull(t, db, shop.ID, 1, "annual_pro", 599900, "sum-1", "paid", "", base) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "sum-2", "paid", "", base.Add(time.Minute)) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "sum-3", "pending", "https://pay.example.com/sum-3", base.Add(2*time.Minute)) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "sum-4", "failed", "", base.Add(3*time.Minute)) wantPaidTotal := int64(599900 + 29900) wantPaidCount := int64(2) wantPendingCount := int64(1) wantTotalCount := int64(4) all, err := svc.ListPurchases(shop.ID, 1, 20, "") require.NoError(t, err) assert.Equal(t, wantPaidTotal, all.Summary.PaidTotalMinor) assert.Equal(t, wantPaidCount, all.Summary.PaidCount) assert.Equal(t, wantPendingCount, all.Summary.PendingCount) assert.Equal(t, wantTotalCount, all.Summary.TotalCount) // status=paid 筛选 + 分页只影响 items/total,不影响 summary filtered, err := svc.ListPurchases(shop.ID, 1, 1, "paid") require.NoError(t, err) assert.EqualValues(t, 2, filtered.Total, "total 是筛选后的计数") assert.Len(t, filtered.Items, 1, "分页只影响 items 条数") assert.Equal(t, wantPaidTotal, filtered.Summary.PaidTotalMinor) assert.Equal(t, wantPaidCount, filtered.Summary.PaidCount) assert.Equal(t, wantPendingCount, filtered.Summary.PendingCount) assert.Equal(t, wantTotalCount, filtered.Summary.TotalCount) } // TestListPurchases_PayURLOnlyOnPending:pending 单带 pay_url,paid 单抹空。 func TestListPurchases_PayURLOnlyOnPending(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") shop := testutil.CreateTestShop(db, "PAY106") base := time.Now().Add(-time.Hour) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "url-pending", "pending", "https://pay.example.com/url-pending", base) createPurchaseFull(t, db, shop.ID, 1, "monthly_standard", 29900, "url-paid", "paid", "https://pay.example.com/url-paid-stale", base.Add(time.Minute)) list, err := svc.ListPurchases(shop.ID, 1, 20, "") require.NoError(t, err) require.Len(t, list.Items, 2) byOtn := map[string]PurchaseListItem{} for _, item := range list.Items { byOtn[item.OutTradeNo] = item } assert.Equal(t, "https://pay.example.com/url-pending", byOtn["url-pending"].PayURL) assert.Empty(t, byOtn["url-paid"].PayURL, "paid 单 pay_url 必须抹空") } // TestListPurchases_UserNameJoin:user_name 取下单人 real_name,查不到留空。 func TestListPurchases_UserNameJoin(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") shop := testutil.CreateTestShop(db, "PAY107") user := testutil.CreateTestUser(db, shop.ID, "boss", "pass", "admin") require.NoError(t, db.Model(user).Update("real_name", "王老板").Error) base := time.Now().Add(-time.Hour) createPurchaseFull(t, db, shop.ID, user.ID, "annual_pro", 599900, "un-known", "paid", "", base) createPurchaseFull(t, db, shop.ID, 999999, "annual_pro", 599900, "un-missing", "paid", "", base.Add(time.Minute)) list, err := svc.ListPurchases(shop.ID, 1, 20, "") require.NoError(t, err) byOtn := map[string]PurchaseListItem{} for _, item := range list.Items { byOtn[item.OutTradeNo] = item } assert.Equal(t, "王老板", byOtn["un-known"].UserName) assert.Empty(t, byOtn["un-missing"].UserName, "查不到用户留空") } // TestListPurchases_UserNameFallbackToUsername:real_name 为空(用户创建接口该字段可选)时, // user_name 必须回退 username,不能显示空白。 func TestListPurchases_UserNameFallbackToUsername(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") shop := testutil.CreateTestShop(db, "PAY108") user := testutil.CreateTestUser(db, shop.ID, "noreal", "pass", "admin") // CreateTestUser 默认会填 real_name,显式置空才能命中回退分支。 require.NoError(t, db.Model(user).Update("real_name", "").Error) base := time.Now().Add(-time.Hour) createPurchaseFull(t, db, shop.ID, user.ID, "annual_pro", 599900, "un-noreal", "paid", "", base) list, err := svc.ListPurchases(shop.ID, 1, 20, "") require.NoError(t, err) byOtn := map[string]PurchaseListItem{} for _, item := range list.Items { byOtn[item.OutTradeNo] = item } assert.Equal(t, "noreal", byOtn["un-noreal"].UserName, "real_name 为空时应回退 username") }