Files
jiu/backend/internal/service/pay_test.go
T

966 lines
42 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/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_UnknownEventAckedrefund.* 等本期不接的事件应 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_ZeroAmountInputFailsClosedsettle 入参 amountMinor==0(真实流不应出现,
// 防御性场景)时必须 fail-closedguard 的 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 验签后返回 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, 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), "回调须回 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", 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_WithinWindowSkippedcreated_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_PayAlreadyPaidKeepsPendingpay 回 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_ShopIsolation3 店共 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=2order-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_StatusFilterstatus 筛选生效;非法 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_SummaryStableAcrossFilterAndPaginationsummary 四值只看店铺全量,
// 不随 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_PayURLOnlyOnPendingpending 单带 pay_urlpaid 单抹空。
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_UserNameJoinuser_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_UserNameFallbackToUsernamereal_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")
}