package service import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "time" "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 侧签名)。 func signedCallbackArgs(body []byte) (ts, nonce, sign string) { ts = strconv.FormatInt(time.Now().Unix(), 10) nonce = "test-nonce" sign = util.PaySign(testPaySecret, "jiu", ts, nonce, string(body)) return } func callbackBody(outTradeNo, bizCode, amount string) []byte { b, _ := json.Marshal(map[string]any{ "out_trade_no": outTradeNo, "biz_system": "jiu", "biz_ref": "1", "product_biz_code": bizCode, "amount": amount, "trade_no": "2026070322001", "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 { 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 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 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") 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", "2999.00") 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", "2999.00") ts, nonce, sign := signedCallbackArgs(body) assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPaySignature) } // ---------- 回调:入账 ---------- 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") svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-otn-1", "annual_standard", "2999.00") 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) 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 } 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") svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-otn-2", "monthly_pro", "599.00") 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 重发同一单:不得重复续期 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", "5999.00", "yanmei-otn-3") svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-otn-3", "annual_pro", "0.01") // 篡改金额 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", "2999.00") 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", "2999.00", "yanmei-otn-4") svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-otn-4", "annual_standard", "2999.00") 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", "299.00", "yanmei-otn-5") svc := newTestPaySvc(db, "http://pay.invalid") body := callbackBody("yanmei-otn-5", "monthly_standard", "299.00") 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 服务:/products 列表 + /orders 验签后返回 pay_url mux := http.NewServeMux() mux.HandleFunc("GET /api/v1/products", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"data":[{"id":3,"biz_code":"annual_standard"},{"id":4,"biz_code":"monthly_pro"}]}`) }) var gotBizRef string mux.HandleFunc("POST /api/v1/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) gotBizRef, _ = req["biz_ref"].(string) fmt.Fprint(w, `{"data":{"pay_url":"https://openapi.alipay.com/gateway","out_trade_no":"yanmei-new-1","amount":"2999.00","subject":"年付标准"}}`) }) payServer := httptest.NewServer(mux) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard") require.NoError(t, err) assert.Equal(t, "https://openapi.alipay.com/gateway", res.PayURL) assert.Equal(t, "yanmei-new-1", res.OutTradeNo) var p model.LicensePurchase require.NoError(t, db.Where("out_trade_no = ?", "yanmei-new-1").First(&p).Error) assert.Equal(t, "pending", p.Status) assert.Equal(t, "2999.00", p.Amount) assert.Equal(t, strconv.FormatUint(p.ID, 10), gotBizRef, "biz_ref 应为购买记录 id") } func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") _, err := svc.CreatePurchase(1, 1, "no_such_plan") assert.ErrorIs(t, err, ErrUnknownPlan) unconfigured := NewPayService(db, "http://pay.invalid", "", "") _, err = unconfigured.CreatePurchase(1, 1, "annual_standard") 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) assert.NotErrorIs(t, err, ErrPromoUsed) // pending 单不算已享用(可能弃单),仍可重新下单 createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "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") 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) 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, "1.00", "yanmei-promo-a") createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-b") body := callbackBody("yanmei-promo-a", PromoBizCode, "1.00") 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, "1.00") 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", "299.00", "yanmei-normal-c") body3 := callbackBody("yanmei-normal-c", "monthly_standard", "299.00") 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", "2999.00", "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, "跨店不可见") }