From 7e99a7a7ee5cf158c13686e788f969cad27b7893 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 23:21:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E6=8E=88=E6=9D=83=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=88=97=E8=A1=A8=E6=8E=A5=E5=8F=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=88=86=E9=A1=B5/=E7=AD=9B=E9=80=89/=E6=B1=87=E6=80=BB?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=92=91=E8=AE=A2=E5=8D=95=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/internal/handler/pay.go | 34 +++++ backend/internal/handler/pay_test.go | 119 ++++++++++++++++ backend/internal/router/router.go | 1 + backend/internal/service/pay.go | 138 ++++++++++++++++++ backend/internal/service/pay_test.go | 205 +++++++++++++++++++++++++++ 5 files changed, 497 insertions(+) create mode 100644 backend/internal/handler/pay_test.go diff --git a/backend/internal/handler/pay.go b/backend/internal/handler/pay.go index 3521c45..3575acb 100644 --- a/backend/internal/handler/pay.go +++ b/backend/internal/handler/pay.go @@ -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)) diff --git a/backend/internal/handler/pay_test.go b/backend/internal/handler/pay_test.go new file mode 100644 index 0000000..c1307c8 --- /dev/null +++ b/backend/internal/handler/pay_test.go @@ -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) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e2f7e9c..51f07c3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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(过期只读/锁定拦截写操作) diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index a9ecf15..82fda24 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -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 之一(否则 ErrInvalidStatus,handler 回 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 +} diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index e13c8ce..0e7f69e 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -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_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, "查不到用户留空") +}