a996593b1c
按 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
371 lines
14 KiB
Go
371 lines
14 KiB
Go
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, "跨店不可见")
|
||
}
|