Files
jiu/backend/internal/service/pay_test.go
T
2026-07-10 20:38:28 +08:00

441 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 v2 服务:POST /api/v2/orders 验签后返回 sessionredirect+ 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 失败时,不阻断下单,金额留 0PayURL/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, "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, "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, "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), "回调须回 SUCCESSpay 停止重试")
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, "跨店不可见")
}
// ---------- v2 金额回填 ----------
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")
// 已有 amount_minor>0 的行不应被覆盖
p3 := createPendingPurchase(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 的行不被覆盖")
}