fix(admin): 商户筛选下拉改用不分页的精简接口

自查新做的下游订单页时发现的问题:商户筛选下拉调用
/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 反映真实总数」这一事实,即上面接口存在的理由
This commit is contained in:
yml2213
2026-10-06 20:44:48 +08:00
parent 04c3369e6d
commit 663d7e1e9f
7 changed files with 142 additions and 4 deletions
@@ -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"`
+2
View File
@@ -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)
+21
View File
@@ -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 {
@@ -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)
}
}