Files
jiu/backend/internal/service/pay_test.go
T
wangjia a996593b1c fix(backend): 特惠限次 webhook 兜底——第二笔已支付特惠单只标 paid 不叠加时长
按 pay-contract 看板要求:续期入账前事务内再查本店是否已有另一笔 paid 的
promo_first_month(防绕过前端/并发双买);命中则跳过 entitle、记 ALERT 告警,
回调仍回 SUCCESS(钱已收,退款人工处理)。补对应测试(双买不叠加/正常套餐不受影响)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 22:28:21 +08:00

371 lines
14 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 服务:/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), "回调须回 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, "跨店不可见")
}