18b97d5d35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
966 lines
42 KiB
Go
966 lines
42 KiB
Go
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),
|
||
// webhook 自带权威金额(真实流恒非零)→ settle 事务内直接用入参回填并核对通过、入账续期。
|
||
// settle 不再外呼 queryOrder,此处特意不配置 pay mock server(传 http://pay.invalid),
|
||
// 证明回填全程无需外部查单。
|
||
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")
|
||
|
||
svc := newTestPaySvc(db, "http://pay.invalid")
|
||
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, "残单兜底应用 webhook 权威金额回填")
|
||
assert.Equal(t, "CNY", p.Currency)
|
||
assert.NotNil(t, p.RenewedTo, "入账应落库续期后到期日")
|
||
}
|
||
|
||
// TestSettle_ZeroAmountInputFailsClosed:settle 入参 amountMinor==0(真实流不应出现,
|
||
// 防御性场景)时必须 fail-closed:guard 的 p.AmountMinor==0 分支兜底命中 ErrPayAmount,
|
||
// 不入账、不续期、购买单维持 pending。settle 不再外呼 queryOrder 兜底补查,
|
||
// 用调用计数断言零调用,防止该死代码路径回归。
|
||
func TestSettle_ZeroAmountInputFailsClosed(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PAY016")
|
||
createPendingPurchase(t, db, shop.ID, "annual_standard", 0, "", "yanmei-otn-residual-fail")
|
||
|
||
var queryCalls int
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("GET /api/v2/orders/yanmei-otn-residual-fail", func(w http.ResponseWriter, r *http.Request) {
|
||
queryCalls++
|
||
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, "入参金额为 0 应 fail-closed")
|
||
assert.Equal(t, 0, queryCalls, "settle 不应再外呼查单")
|
||
|
||
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, "入参金额为 0 不得入账")
|
||
assert.Nil(t, p.RenewedTo, "入参金额为 0 不得续期")
|
||
|
||
var licCount int64
|
||
require.NoError(t, db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&licCount).Error)
|
||
assert.Equal(t, int64(0), licCount, "入参金额为 0 不得续期")
|
||
}
|
||
|
||
// ---------- 回调:入账 ----------
|
||
|
||
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")
|
||
}
|