From 663d7e1e9ff4dc1e3909b4b71ec171e1ddc30123 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 20:44:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=95=86=E6=88=B7=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E4=B8=8B=E6=8B=89=E6=94=B9=E7=94=A8=E4=B8=8D=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E7=9A=84=E7=B2=BE=E7=AE=80=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自查新做的下游订单页时发现的问题:商户筛选下拉调用 /api/platform/merchants?size=200,而分页查询会把 >100 的 size 静默降级为 20 (service.normalizePage),因此商户超过 20 家时下拉里会少一批商户,界面不报任何错。 - 新增 service.ListMerchantOptions:不分页返回全部启用商户的精简信息 (id/code/name),独立于分页的 ListMerchants - 新增 GET /api/platform/merchant-options - 前端改用该接口;types 增加 MerchantOption,api 增加 merchantOptions() 测试: - TestListMerchantOptionsReturnsAllActiveMerchants:造 25 家启用 + 1 家禁用, 断言返回数量与库中启用数一致、必须超过分页上限、且不含已禁用商户 - TestListMerchantsClampsOversizedPageSize:钉住「size=200 实际只返回 20 条, 而 total 反映真实总数」这一事实,即上面接口存在的理由 --- backend/internal/handler/merchant_platform.go | 13 +++ backend/internal/router/router.go | 2 + backend/internal/service/merchant.go | 21 +++++ .../internal/service/merchant_options_test.go | 89 +++++++++++++++++++ frontend/src/api/index.ts | 4 + frontend/src/pages/PlatformOrders.tsx | 10 ++- frontend/src/types/index.ts | 7 ++ 7 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/merchant_options_test.go diff --git a/backend/internal/handler/merchant_platform.go b/backend/internal/handler/merchant_platform.go index 634495d..4ce4650 100644 --- a/backend/internal/handler/merchant_platform.go +++ b/backend/internal/handler/merchant_platform.go @@ -20,6 +20,19 @@ func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) { response.Page(c, list, total, page, size) } +// ListMerchantOptions GET /api/platform/merchant-options +// +// 返回全部启用商户的精简列表,供筛选下拉使用。分页接口的 size 上限是 100, +// 下拉需要「全部」,因此单开一个不分页的轻量接口。 +func (h *MerchantHandler) ListMerchantOptions(c *gin.Context) { + options, err := h.merchantSvc.ListMerchantOptions() + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, options) +} + type createMerchantReq struct { Code string `json:"code" binding:"required"` Name string `json:"name" binding:"required"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 264e275..507a38a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -236,6 +236,8 @@ func Setup(h *Handlers) *gin.Engine { platform.PATCH("/platform/admins/:id", h.User.UpdatePlatformAdmin) platform.DELETE("/platform/admins/:id", h.User.DeletePlatformAdmin) platform.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) + // 不分页的精简商户列表,供筛选下拉使用(分页接口的 size 上限是 100) + platform.GET("/platform/merchant-options", h.Merchant.ListMerchantOptions) // 跨商户订单视图:商户后台的 /api/merchant/orders 强制单商户隔离, // 超管需要一条平台视角入口来查看全部下游订单。 platform.GET("/platform/orders", h.Merchant.ListPlatformOrders) diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go index b2829ab..e7a157d 100644 --- a/backend/internal/service/merchant.go +++ b/backend/internal/service/merchant.go @@ -240,6 +240,27 @@ func (s *MerchantService) ListMerchants(page, size int) ([]model.Merchant, int64 return merchants, total, err } +// MerchantOption 是筛选下拉使用的精简商户信息。 +type MerchantOption struct { + ID uint `json:"id"` + Code string `json:"code"` + Name string `json:"name"` +} + +// ListMerchantOptions 返回全部启用商户的精简列表。 +// +// 刻意独立于分页的 ListMerchants:下拉需要「全部」,而分页接口的 size 上限是 100, +// 直接调大 size 只会被静默降级成 20,表现为下拉里少了一批商户。 +func (s *MerchantService) ListMerchantOptions() ([]MerchantOption, error) { + options := make([]MerchantOption, 0) + err := s.db.Model(&model.Merchant{}). + Select("id", "code", "name"). + Where("status = ?", model.MerchantStatusActive). + Order("id ASC"). + Find(&options).Error + return options, err +} + func (s *MerchantService) GetMerchant(merchantID uint) (*model.Merchant, error) { var merchant model.Merchant if err := s.db.First(&merchant, merchantID).Error; err != nil { diff --git a/backend/internal/service/merchant_options_test.go b/backend/internal/service/merchant_options_test.go new file mode 100644 index 0000000..951528b --- /dev/null +++ b/backend/internal/service/merchant_options_test.go @@ -0,0 +1,89 @@ +package service + +import ( + "fmt" + "testing" + + "affiliate_dash/internal/model" +) + +// TestListMerchantOptionsReturnsAllActiveMerchants 卡住两个点: +// 1. 下拉需要"全部商户",不能被分页的 size 上限(100,且传大值会被降级为 20)截断; +// 2. 已禁用商户不应出现在可筛选列表里。 +func TestListMerchantOptionsReturnsAllActiveMerchants(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + + const activeCount = 25 + for i := 0; i < activeCount; i++ { + merchant := model.Merchant{ + Code: fmt.Sprintf("opt-merchant-%02d", i), + Name: fmt.Sprintf("下拉商户 %02d", i), + Status: model.MerchantStatusActive, + } + if err := db.Create(&merchant).Error; err != nil { + t.Fatalf("create merchant: %v", err) + } + } + disabled := model.Merchant{Code: "opt-merchant-disabled", Name: "已禁用商户", Status: model.MerchantStatusDisabled} + if err := db.Create(&disabled).Error; err != nil { + t.Fatalf("create disabled merchant: %v", err) + } + + options, err := svc.ListMerchantOptions() + if err != nil { + t.Fatalf("ListMerchantOptions: %v", err) + } + + // 期望值与库里实际的启用商户数一致(迁移会 seed 一个自营商户,不能写死数字)。 + var activeInDB int64 + if err := db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&activeInDB).Error; err != nil { + t.Fatalf("count active merchants: %v", err) + } + if int64(len(options)) != activeInDB { + t.Fatalf("期望返回全部 %d 个启用商户,实际 %d", activeInDB, len(options)) + } + // 分页默认上限是 20、最大 100;这里必须远超它,否则说明又被分页截断了。 + if len(options) <= 20 { + t.Fatalf("下拉必须返回全部商户,不能被分页上限截断,实际只有 %d 个", len(options)) + } + for _, option := range options { + if option.ID == 0 || option.Code == "" || option.Name == "" { + t.Fatalf("选项字段不应为空:%+v", option) + } + if option.ID == disabled.ID { + t.Fatal("已禁用商户不应出现在筛选下拉里") + } + } +} + +// TestListMerchantsClampsOversizedPageSize 记录上面那个接口存在的真实原因: +// 分页查询会把 >100 的 size 静默降级为 20,所以"下拉要全部商户"不能靠它。 +// 顺带钉住一个更隐蔽的事实:total 反映真实总数,而 list 已被截断。 +func TestListMerchantsClampsOversizedPageSize(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + + const created = 25 + for i := 0; i < created; i++ { + merchant := model.Merchant{ + Code: fmt.Sprintf("clamp-merchant-%02d", i), + Name: fmt.Sprintf("截断商户 %02d", i), + Status: model.MerchantStatusActive, + } + if err := db.Create(&merchant).Error; err != nil { + t.Fatalf("create merchant: %v", err) + } + } + + list, total, err := svc.ListMerchants(1, 200) + if err != nil { + t.Fatalf("ListMerchants: %v", err) + } + if len(list) != 20 { + t.Fatalf("size=200 应被降级为 20,实际返回 %d 条", len(list)) + } + if total <= 20 { + t.Fatalf("total 应反映真实总数(会被截断),实际 %d", total) + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4e4f39a..a434068 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -19,6 +19,7 @@ import type { Merchant, MerchantMember, MerchantMemberGroup, + MerchantOption, MerchantOwnerAccount, MerchantRole, MerchantProduct, @@ -207,6 +208,9 @@ export const platformApi = { request.delete(`/platform/merchant-accounts/${userId}`).then((r) => r.data.data), merchants: (params?: Record) => request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult), + // 不分页的精简商户列表,供筛选下拉使用(/platform/merchants 的 size 上限是 100) + merchantOptions: () => + request.get('/platform/merchant-options').then((r) => r.data.data as MerchantOption[]), // 平台侧跨商户订单视图:不传 merchant_id 即全部商户 orders: (params?: Record) => request.get('/platform/orders', { params }).then((r) => r.data.data as PageResult), diff --git a/frontend/src/pages/PlatformOrders.tsx b/frontend/src/pages/PlatformOrders.tsx index 1b5e05d..d7682bb 100644 --- a/frontend/src/pages/PlatformOrders.tsx +++ b/frontend/src/pages/PlatformOrders.tsx @@ -4,7 +4,7 @@ import type { ColumnsType } from 'antd/es/table' import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' import { platformApi } from '../api' import { PageHeader } from '../components/PageHeader' -import type { FulfillmentOrder, Merchant, PageResult } from '../types' +import type { FulfillmentOrder, MerchantOption, PageResult } from '../types' import { formatDateTime } from '../utils/time' import { money, orderStatusMap, orderStatusOptions, pageConfig } from './merchantCenterUtils' @@ -47,7 +47,7 @@ function orderStatusTag(value: string) { */ export default function PlatformOrders() { const [data, setData] = useState>(emptyPage) - const [merchants, setMerchants] = useState([]) + const [merchants, setMerchants] = useState([]) const [loading, setLoading] = useState(false) const [page, setPage] = useState(1) @@ -79,9 +79,11 @@ export default function PlatformOrders() { }, [load]) useEffect(() => { + // 用不分页的精简接口:/platform/merchants 的 size 上限是 100, + // 传更大的值会被静默降级,下拉里会少一批商户。 platformApi - .merchants({ page: 1, size: 200 }) - .then((res) => setMerchants(res.list || [])) + .merchantOptions() + .then((options) => setMerchants(options || [])) .catch(() => setMerchants([])) }, []) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 301a75d..715d59f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -24,6 +24,13 @@ export interface Merchant { // 平台侧跨商户订单列表里标识订单归属所需的商户信息(后端 Preload 的 Merchant 子集) export type MerchantSummary = Pick +// 筛选下拉用的精简商户(来自 /platform/merchant-options,不分页) +export interface MerchantOption { + id: number + code: string + name: string +} + export interface MerchantMember { id: number merchant_id: number