feat(backend): 授权订单列表接口——分页/筛选/汇总,支撑订单管理 tab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 23:21:24 +08:00
parent 7e5a697f28
commit 7e99a7a7ee
5 changed files with 497 additions and 0 deletions
+34
View File
@@ -4,6 +4,7 @@ import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -112,6 +113,39 @@ func (h *PayHandler) Cancel(c *gin.Context) {
util.RespondSuccess(c, gin.H{"canceled": canceled})
}
// Purchases GET /api/v1/license/purchases — 订单管理 tab 数据源:分页/status 筛选/汇总。
// 仅管理员可查看(handler 内判权,同 Purchase)。
func (h *PayHandler) Purchases(c *gin.Context) {
role := middleware.GetRole(c)
if role != "admin" && role != "superadmin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可查看订单"})
return
}
page, _ := strconv.Atoi(c.Query("page"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
status := c.Query("status")
list, err := h.svc.ListPurchases(middleware.GetShopID(c), page, pageSize, status)
if err != nil {
if errors.Is(err, service.ErrInvalidStatus) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, list)
}
// PromoStatus GET /api/v1/license/promo-status — 本店首月特惠是否已享用(前端据此置灰特惠档)。
func (h *PayHandler) PromoStatus(c *gin.Context) {
used, err := h.svc.PromoUsed(middleware.GetShopID(c))
+119
View File
@@ -0,0 +1,119 @@
package handler
import (
"fmt"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/testutil"
)
// setupPayRouter 独立路由:仅挂 payH.Purchases(仓里暂无 pay handler 测试先例,
// 不复用 setupProtectedRouter 以免污染其他测试的路由表;判权写法与 Purchase/Cancel 同构)。
func setupPayRouter(db *gorm.DB) *gin.Engine {
payH := NewPayHandler(service.NewPayService(db, "http://pay.invalid", "test-secret", ""))
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT(db))
api.GET("/license/purchases", payH.Purchases)
return r
}
func seedPurchase(t *testing.T, db *gorm.DB, shopID, userID uint64, otn, status string, amountMinor int64) {
t.Helper()
require.NoError(t, db.Create(&model.LicensePurchase{
ShopID: shopID, UserID: userID, ProductBizCode: "monthly_standard",
AmountMinor: amountMinor, Currency: "CNY", OutTradeNo: otn, Status: status,
}).Error)
}
func TestPayHandler_Purchases_OperatorForbidden(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PHDL01")
op := testutil.CreateTestUser(db, shop.ID, "op1", "pass", "operator")
token := getAuthToken(op.ID, shop.ID, "operator")
r := setupPayRouter(db)
w := makeRequest(r, "GET", "/api/v1/license/purchases", token, nil)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestPayHandler_Purchases_ReadonlyForbidden(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PHDL02")
ro := testutil.CreateTestUser(db, shop.ID, "ro1", "pass", "readonly")
token := getAuthToken(ro.ID, shop.ID, "readonly")
r := setupPayRouter(db)
w := makeRequest(r, "GET", "/api/v1/license/purchases", token, nil)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestPayHandler_Purchases_AdminOK(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PHDL03")
admin := testutil.CreateTestUser(db, shop.ID, "admin1", "pass", "admin")
token := getAuthToken(admin.ID, shop.ID, "admin")
seedPurchase(t, db, shop.ID, admin.ID, "phdl-1", "paid", 29900)
seedPurchase(t, db, shop.ID, admin.ID, "phdl-2", "pending", 29900)
r := setupPayRouter(db)
w := makeRequest(r, "GET", "/api/v1/license/purchases", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
data, ok := resp["data"].(map[string]interface{})
require.True(t, ok, "响应应含 data")
assert.EqualValues(t, 2, data["total"])
summary, ok := data["summary"].(map[string]interface{})
require.True(t, ok)
assert.EqualValues(t, 1, summary["paid_count"])
assert.EqualValues(t, 1, summary["pending_count"])
assert.EqualValues(t, 2, summary["total_count"])
}
func TestPayHandler_Purchases_SuperadminOK(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PHDL04")
sa := testutil.CreateTestUser(db, shop.ID, "sa1", "pass", "superadmin")
token := getAuthToken(sa.ID, shop.ID, "superadmin")
r := setupPayRouter(db)
w := makeRequest(r, "GET", "/api/v1/license/purchases", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestPayHandler_Purchases_InvalidStatusBadRequest(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PHDL05")
admin := testutil.CreateTestUser(db, shop.ID, "admin2", "pass", "admin")
token := getAuthToken(admin.ID, shop.ID, "admin")
r := setupPayRouter(db)
w := makeRequest(r, "GET", "/api/v1/license/purchases?status=bogus", token, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestPayHandler_Purchases_PageSizeClamped(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PHDL06")
admin := testutil.CreateTestUser(db, shop.ID, "admin3", "pass", "admin")
token := getAuthToken(admin.ID, shop.ID, "admin")
for i := 0; i < 3; i++ {
seedPurchase(t, db, shop.ID, admin.ID, fmt.Sprintf("phdl6-%d", i), "paid", 100)
}
r := setupPayRouter(db)
// page_size 请求 500,服务端应上限 clamp 到 100(不报错,正常 200)
w := makeRequest(r, "GET", "/api/v1/license/purchases?page_size=500", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
}
+1
View File
@@ -124,6 +124,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
license.POST("/purchase/:out_trade_no/cancel", payH.Cancel)
license.GET("/promo-status", payH.PromoStatus)
license.GET("/purchases", payH.Purchases)
}
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
+138
View File
@@ -40,6 +40,7 @@ var (
ErrPayAmount = errors.New("回调金额与订单不符")
ErrPurchaseNotFound = errors.New("购买记录不存在")
ErrPromoUsed = errors.New("首月特惠每个门店限购一次,本店已享受过")
ErrInvalidStatus = errors.New("无效的订单状态")
)
// PromoBizCode 新店首月特惠(¥1/30 天标准版),每个门店仅可购买一次。
@@ -657,3 +658,140 @@ func (s *PayService) cancelOrder(orderNo string) (bool, error) {
}
return r.Data.Canceled, nil
}
// ---------- ⑥ 订单列表(授权管理·订单管理 tab 数据源)----------
// PurchaseListItem 订单管理 tab 一行。pay_url 仅 pending 单输出(继续支付用),
// paid/failed 抹空;amount 是 Deprecated 兼容串(同 formatMinor 口径)。
type PurchaseListItem struct {
OutTradeNo string `json:"out_trade_no"`
BizCode string `json:"product_biz_code"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
Amount string `json:"amount"` // Deprecated: formatMinor(AmountMinor) 分转元字符串
Status string `json:"status"`
PayURL string `json:"pay_url"`
UserName string `json:"user_name"` // 下单人显示名(LEFT JOIN users.real_name),查不到留空
CreatedAt time.Time `json:"created_at"`
PaidAt *time.Time `json:"paid_at"`
RenewedTo *time.Time `json:"renewed_to"`
}
// PurchaseSummary 汇总统计:同店全量(不受分页/status 筛选影响)。
type PurchaseSummary struct {
PaidTotalMinor int64 `json:"paid_total_minor"`
PaidCount int64 `json:"paid_count"`
PendingCount int64 `json:"pending_count"`
TotalCount int64 `json:"total_count"`
}
// PurchaseList ListPurchases 响应体。
type PurchaseList struct {
Items []PurchaseListItem `json:"items"`
Total int64 `json:"total"`
Summary PurchaseSummary `json:"summary"`
}
// ListPurchases 本店订单列表:分页 + status 筛选 + 全量汇总。status 空=全部,
// 非空须为 pending/paid/failed 之一(否则 ErrInvalidStatushandler 回 400)。
func (s *PayService) ListPurchases(shopID uint64, page, pageSize int, status string) (*PurchaseList, error) {
if status != "" && status != "pending" && status != "paid" && status != "failed" {
return nil, ErrInvalidStatus
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
// 每次从 s.db 重新起 query,避免 *gorm.DB 复用累加条件的坑。
scope := func() *gorm.DB {
q := s.db.Model(&model.LicensePurchase{}).Where("shop_id = ?", shopID)
if status != "" {
q = q.Where("status = ?", status)
}
return q
}
var total int64
if err := scope().Count(&total).Error; err != nil {
return nil, err
}
var rows []model.LicensePurchase
if err := scope().Order("created_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
return nil, err
}
// 下单人显示名:批量查 users,查不到留空。
userIDs := make([]uint64, 0, len(rows))
seen := map[uint64]bool{}
for _, p := range rows {
if !seen[p.UserID] {
seen[p.UserID] = true
userIDs = append(userIDs, p.UserID)
}
}
names := map[uint64]string{}
if len(userIDs) > 0 {
var users []model.User
if err := s.db.Where("id IN ?", userIDs).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
names[u.ID] = u.RealName
}
}
items := make([]PurchaseListItem, 0, len(rows))
for _, p := range rows {
amount := formatMinor(p.AmountMinor)
if amount == "" {
amount = p.Amount
}
payURL := ""
if p.Status == "pending" {
payURL = p.PayURL
}
items = append(items, PurchaseListItem{
OutTradeNo: p.OutTradeNo,
BizCode: p.ProductBizCode,
AmountMinor: p.AmountMinor,
Currency: p.Currency,
Amount: amount,
Status: p.Status,
PayURL: payURL,
UserName: names[p.UserID],
CreatedAt: p.CreatedAt,
PaidAt: p.PaidAt,
RenewedTo: p.RenewedTo,
})
}
// 汇总:同店全量,一次 GROUP BY 扫出后内存汇总,不受分页/status 筛选影响。
var groups []struct {
Status string
Cnt int64
Amt int64
}
if err := s.db.Model(&model.LicensePurchase{}).
Select("status, COUNT(*) as cnt, COALESCE(SUM(amount_minor),0) as amt").
Where("shop_id = ?", shopID).Group("status").Scan(&groups).Error; err != nil {
return nil, err
}
summary := PurchaseSummary{}
for _, g := range groups {
summary.TotalCount += g.Cnt
switch g.Status {
case "paid":
summary.PaidCount = g.Cnt
summary.PaidTotalMinor = g.Amt
case "pending":
summary.PendingCount = g.Cnt
}
}
return &PurchaseList{Items: items, Total: total, Summary: summary}, nil
}
+205
View File
@@ -62,6 +62,25 @@ func createPendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode str
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 {
@@ -737,3 +756,189 @@ func TestCancelPurchase_NonPendingNoOpNoExternalCall(t *testing.T) {
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, "查不到用户留空")
}