CRMEB Pro Dao 和 Model 搜索器实战:复杂列表筛选怎么写才不散?

摘要

后台列表二开最容易变乱的地方,就是筛选条件。今天加一个商品名称,明天加一个库存区间,后天加一个供应商,再过几天又要支持品牌、标签、分类、活动、上下架、售罄、库存预警、可见范围。

如果这些条件都写在 Controller 或 Services 里,列表接口很快就会变成一坨 if else。CRMEB Pro 的更稳写法,是让 Controller 只收参数,Services 做业务编排,Dao 统一查询形状,Model 搜索器解释筛选条件。

今天第二篇就专门讲 Dao 和 Model 搜索器,重点看两个真实模块:

text 复制代码
商品单位:小而清晰,适合学习标准写法
商品列表:复杂但典型,适合理解搜索器为什么必要

本文基于项目真实代码:

text 复制代码
crmeb_pro/route/admin.php
crmeb_pro/app/controller/admin/v1/product/StoreProduct.php
crmeb_pro/app/controller/admin/v1/product/StoreProductUnit.php
crmeb_pro/app/services/product/product/StoreProductServices.php
crmeb_pro/app/services/product/product/StoreProductUnitServices.php
crmeb_pro/app/dao/product/product/StoreProductDao.php
crmeb_pro/app/dao/product/product/StoreProductUnitDao.php
crmeb_pro/app/model/product/product/StoreProduct.php
crmeb_pro/app/model/product/product/StoreProductUnit.php

1. 后台列表最怕"筛选条件散落四处"

一个后台商品列表,可能同时支持这些条件:

text 复制代码
商品名称
商品 ID
关键字
条形码
SPU
分类
品牌
标签
保障服务
商品类型
供应商
配送方式
规格类型
销量区间
库存区间
价格区间
上下架状态
审核状态
库存预警
售罄状态
用户可见范围

如果写成这种方式,后期一定会散:

php 复制代码
public function index(Request $request)
{
    $query = Db::name('store_product')->where('is_del', 0);

    if ($request->param('keyword')) {
        $query->whereLike('store_name', '%' . $request->param('keyword') . '%');
    }
    if ($request->param('brand_id')) {
        $query->where('brand_id', $request->param('brand_id'));
    }
    if ($request->param('cate_id')) {
        $query->whereFindInSet('cate_id', $request->param('cate_id'));
    }

    return json($query->select());
}

这类代码的问题是:

text 复制代码
1. 查询条件和 HTTP 参数绑死
2. 不能复用到导出、弹窗选择器、批量处理
3. 复杂筛选越加越长
4. 软删除、审核状态、供应商隔离容易漏
5. 字段变更时很难知道影响哪些接口

CRMEB Pro 的写法不是这样。

2. 先看简单标准件:商品单位列表

商品单位列表 Controller:

php 复制代码
// crmeb_pro/app/controller/admin/v1/product/StoreProductUnit.php
public function index(Request $request)
{
    $where = $request->postMore([
        ['name', '']
    ]);
    $where['type'] = 0;
    $where['relation_id'] = 0;
    $where['status'] = 1;
    $where['is_del'] = 0;
    return $this->success($this->services->getUnitList($where));
}

Services:

php 复制代码
// crmeb_pro/app/services/product/product/StoreProductUnitServices.php
public function getUnitList(array $where, string $field = '*')
{
    [$page, $limit] = $this->getPageValue();
    $list = $this->dao->getList($where, $field, $page, $limit);
    $count = $this->dao->count($where);
    return compact('list', 'count');
}

Dao:

php 复制代码
// crmeb_pro/app/dao/product/product/StoreProductUnitDao.php
public function getList(array $where, string $field = '*', int $page = 0, int $limit = 0)
{
    return $this->search($where)->field($field)
        ->when($page && $limit, function ($query) use ($page, $limit) {
            $query->page($page, $limit);
        })->order('sort desc,id desc')->select()->toArray();
}

Model 搜索器:

php 复制代码
// crmeb_pro/app/model/product/product/StoreProductUnit.php
public function searchNameAttr($query, $value)
{
    if ($value !== '') {
        $query->whereLike('id|name', '%' . $value . '%');
    }
}

public function searchTypeAttr($query, $value)
{
    if (is_array($value)) {
        if ($value) $query->whereIn('type', $value);
    } else {
        if ($value !== '') $query->where('type', $value);
    }
}

public function searchRelationIdAttr($query, $value)
{
    if (is_array($value)) {
        if ($value) $query->whereIn('relation_id', $value);
    } else {
        if ($value !== '') $query->where('relation_id', $value);
    }
}

public function searchStatusAttr($query, $value)
{
    if ($value !== '') {
        $query->where('status', $value);
    }
}

public function searchIsDelAttr($query, $value)
{
    if ($value !== '') {
        $query->where('is_del', $value);
    }
}

这一组代码把职责拆得很干净:

text 复制代码
Controller:拿 name,补 type/relation_id/status/is_del
Services:分页、列表、总数
Dao:字段、分页、排序
Model:name/type/relation_id/status/is_del 怎么变成 SQL 条件

这就是标准答案。

3. 搜索器的命名规则

Model 搜索器的方法名有规律:

text 复制代码
search + 字段名驼峰 + Attr

例如:

text 复制代码
name        -> searchNameAttr
type        -> searchTypeAttr
relation_id -> searchRelationIdAttr
is_del      -> searchIsDelAttr
brand_id    -> searchBrandIdAttr
cate_id     -> searchCateIdAttr

Dao 调用:

php 复制代码
$this->search($where)

$where 里有:

php 复制代码
[
    'name' => '件',
    'type' => 0,
    'is_del' => 0
]

对应搜索器就会参与构建查询。

所以二开加筛选条件时,第一反应不应该是"Controller 加 where",而应该问:

text 复制代码
这个筛选条件有没有对应搜索器?
如果没有,是否应该加到 Model?
Dao 是否需要调整字段、排序、关联?
Services 是否要处理业务默认值?

4. 商品列表为什么更需要搜索器

商品列表路由在:

php 复制代码
// crmeb_pro/route/admin.php
Route::group('product', function () {
    Route::get('product', 'v1.product.StoreProduct/index')
        ->option(['real_name' => '商品列表']);

    Route::get('product/list', 'v1.product.StoreProduct/search_list')
        ->option(['real_name' => '获取所有商品列表']);

    Route::get('product/type_header', 'v1.product.StoreProduct/type_header')
        ->option(['real_name' => '商品列表头部数据']);
});

商品列表不是一个简单的 select *。它要支持后台商品管理、弹窗选择商品、活动选品、导出、批量操作等多个场景。

Controller 会收很多参数,例如商品保存接口有大量字段:

php 复制代码
protected function getProductSaveFields(): array
{
    return [
        ['product_type', 0],
        ['supplier_id', 0],
        ['cate_id', []],
        ['store_name', ''],
        ['store_info', ''],
        ['keyword', ''],
        ['unit_name', '件'],
        ['brand_id', []],
        ['bar_code', ''],
        ['is_support_refund', 1],
        ['is_presale_product', 0],
        ['product_clear', []],
        ['store_label_id', []],
        ['ensure_id', []],
        ['level_type', 1]
    ];
}

列表筛选也同样复杂。复杂度一上来,必须把"查询条件解释"集中起来。

5. StoreProductDao 重写 search,是为了处理复杂组合条件

商品 Dao 在:

text 复制代码
crmeb_pro/app/dao/product/product/StoreProductDao.php

它绑定商品模型:

php 复制代码
protected function setModel(): string
{
    return StoreProduct::class;
}

并重写了 search()

php 复制代码
public function search(array $where = [], bool $search = false)
{
    return parent::search($where, $search)
        ->when(isset($where['store_name']) && $where['store_name'] && is_string($where['store_name']), function ($query) use ($where) {
            if (isset($where['field_key']) && in_array($where['field_key'], ['product_id', 'bar_code', 'store_name', 'keyword', 'code'])) {
                switch ($where['field_key']) {
                    case 'product_id':
                        $query->where('id', trim($where['store_name']));
                        break;
                    case 'store_name':
                        $query->where('store_name', 'like', '%' . trim($where['store_name']) . '%');
                        break;
                    case 'keyword':
                        $query->where('keyword', 'like', '%' . trim($where['store_name']) . '%');
                        break;
                    case 'code':
                        $query->where('code', trim($where['store_name']));
                        break;
                    case 'bar_code':
                        $query->where(function ($query) use ($where) {
                            $query->where('bar_code', trim($where['store_name']))
                                ->whereOr('id', 'IN', function ($q) use ($where) {
                                    $q->name('store_product_attr_value')
                                        ->field('product_id')
                                        ->where('bar_code', trim($where['store_name']))
                                        ->select();
                                });
                        });
                        break;
                }
            } else {
                $query->where(function ($q) use ($where) {
                    $q->where('id|keyword|store_name|store_info|bar_code|spu', 'LIKE', '%' . trim($where['store_name']) . '%')
                        ->whereOr('id', 'IN', function ($q) use ($where) {
                            $q->name('store_product_attr_value')
                                ->field('product_id')
                                ->where('bar_code', trim($where['store_name']))
                                ->select();
                        });
                });
            }
        });
}

这里做的不是简单搜索,而是把后台搜索框拆成多种模式:

text 复制代码
按商品 ID 精确查
按商品名称模糊查
按关键字模糊查
按商品编码查
按条形码查主商品或 SKU
无指定字段时做多字段兜底搜索

这种逻辑如果放 Controller,接口会非常臃肿;放 Dao 里,所有需要商品搜索的地方都能复用。

商品 Dao 的 search() 还处理区间条件:

php 复制代码
->when(isset($where['sales_range']) && $where['sales_range'], function ($query) use ($where) {
    $sales_range = explode('-', $where['sales_range']);
    if (count($sales_range) == 1) {
        $query->where('sales', '>=', $sales_range[0]);
    }
    if (count($sales_range) == 2 && ($sales_range[0] !== '' || $sales_range[1] !== '')) {
        if ($sales_range[0] === '') {
            $query->where('sales', '<=', $sales_range[1]);
        } elseif ($sales_range[1] === '') {
            $query->where('sales', '>=', $sales_range[0]);
        } else {
            $query->whereBetween('sales', $sales_range);
        }
    }
})
->when(isset($where['stock_range']) && $where['stock_range'], function ($query) use ($where) {
    $stock_range = explode('-', $where['stock_range']);
    if (count($stock_range) == 1) {
        $query->where('stock', '>=', $stock_range[0]);
    }
    if (count($stock_range) == 2 && ($stock_range[0] !== '' || $stock_range[1] !== '')) {
        if ($stock_range[0] === '') {
            $query->where('stock', '<=', $stock_range[1]);
        } elseif ($stock_range[1] === '') {
            $query->where('stock', '>=', $stock_range[0]);
        } else {
            $query->whereBetween('stock', $stock_range);
        }
    }
});

区间筛选有很多边界:

text 复制代码
100-200:between
100-:大于等于
-200:小于等于
100:大于等于
空值:不筛选

如果每个列表接口都自己写一遍,早晚会有一个接口漏掉边界。集中在 Dao 里,反而更容易统一维护。

7. StoreProduct Model 搜索器处理字段含义

商品 Model 在:

text 复制代码
crmeb_pro/app/model/product/product/StoreProduct.php

比如关键词搜索器:

php 复制代码
public function searchKeywordAttr($query, $value, $data)
{
    if ($value != '' && !isset($data['store_id'])) {
        $field = 'id|keyword|store_name|store_info|bar_code';
        if (is_string($value)) {
            $query->whereLike($field, htmlspecialchars("%" . trim($value) . "%"));
        } elseif (is_array($value) && count($value) > 0) {
            $query->where(function ($q) use ($value, $field) {
                $data = [];
                foreach ($value as $k) {
                    $data[] = [$field, 'like', "%" . trim($k) . "%"];
                }
                $q->whereOr($data);
            });
        }
    }
}

分类搜索器:

php 复制代码
public function searchCateIdAttr($query, $value)
{
    if ($value) {
        if (is_array($value)) {
            $query->whereIn('id', function ($query) use ($value) {
                $query->name('store_product_relation')
                    ->where('type', 1)
                    ->where('relation_id', 'IN', $value)
                    ->field('product_id')
                    ->select();
            });
        } else {
            $query->whereFindInSet('cate_id', $value);
        }
    }
}

品牌搜索器:

php 复制代码
public function searchBrandIdAttr($query, $value)
{
    if ($value) {
        if (is_array($value)) {
            $query->whereIn('brand_id', $value);
        } else {
            $query->where('brand_id', $value);
        }
    }
}

标签和保障服务搜索器:

php 复制代码
public function searchLabelIdAttr($query, $value)
{
    if ($value !== '') {
        $query->whereFindInSet('label_id', $value);
    }
}

public function searchEnsureIdAttr($query, $value)
{
    if ($value !== '') {
        $query->whereFindInSet('ensure_id', $value);
    }
}

这说明 Model 搜索器不只是简单 where,它负责解释字段背后的数据结构:

text 复制代码
cate_id 可能是逗号字符串,也可能走关联表
label_id 是 FIND_IN_SET
ensure_id 是 FIND_IN_SET
brand_id 支持单值和数组
keyword 支持字符串和数组

这些知识不应该散落在 Controller。

8. 状态筛选最适合放搜索器

商品状态非常典型:

php 复制代码
public function searchStatusAttr($query, $value, $data)
{
    if ($value !== '') {
        switch ((int)$value) {
            case -2:
                $query->where(['is_verify' => -2, 'is_del' => 0]);
                break;
            case -1:
                $query->where(['is_verify' => -1, 'is_del' => 0]);
                break;
            case 0:
                $query->where(['is_verify' => 0, 'is_del' => 0]);
                break;
            case 1:
                $query->where(['is_show' => 1, 'is_del' => 0, 'is_verify' => 1]);
                break;
            case 2:
                $query->where(['is_show' => 0, 'is_del' => 0, 'is_verify' => 1]);
                break;
            case 3:
                $query->where(['is_del' => 0, 'is_verify' => 1]);
                break;
            case 4:
                $query->where(['is_del' => 0, 'is_verify' => 1])
                    ->where(function ($query) {
                        $query->where('is_sold', 1)->whereOr('stock', 0);
                    });
                break;
            case 5:
                break;
        };
    }
}

这里 status 不是数据库里的一个简单字段,而是后台业务状态:

text 复制代码
-2:强制下架
-1:审核未通过
0:待审核
1:出售中
2:仓库中
3:全部正常商品
4:售罄/库存为 0
5:特殊兜底

如果把这段逻辑写在 Controller,以后导出列表、商品弹窗、统计头部也要跟着复制。放在 Model 搜索器里,$where['status'] 的含义就统一了。

9. Dao 的 getList 和 getSearchList 不只是 select

商品 Dao 的 getList() 会补充统计字段:

php 复制代码
public function getList(array $where, int $page = 0, int $limit = 0, string $order = '', array $with = [])
{
    $prefix = Config::get('database.connections.' . Config::get('database.default') . '.prefix');
    return $this->search($where)
        ->order(($order ? $order . ' ,' : '') . 'sort desc,id desc')
        ->when(count($with), function ($query) use ($with) {
            $query->with($with);
        })
        ->when($page != 0 && $limit != 0, function ($query) use ($page, $limit) {
            $query->page($page, $limit);
        })
        ->field([
            '*',
            '(SELECT count(*) FROM `' . $prefix . 'user_relation` WHERE `relation_id` = `' . $prefix . 'store_product`.`id` AND `category` = \'product\' AND `type` = \'collect\') as collect',
            '(SELECT count(*) FROM `' . $prefix . 'user_relation` WHERE `relation_id` = `' . $prefix . 'store_product`.`id` AND `category` = \'product\' AND `type` = \'like\') as likes',
            '(SELECT SUM(stock) FROM `' . $prefix . 'store_product_attr_value` WHERE `product_id` = `' . $prefix . 'store_product`.`id` AND `type` = 0) as stock',
            '(SELECT count(*) FROM `' . $prefix . 'store_visit` WHERE `product_id` = `' . $prefix . 'store_product`.`id` AND `product_type` = \'product\') as visitor',
        ])
        ->select()
        ->toArray();
}

这也是为什么查询不该写在 Controller:

text 复制代码
商品列表展示库存不是直接用主表 stock
收藏数、点赞数、访问数来自子查询
SKU 库存要从 store_product_attr_value 汇总
排序要结合前端传参和默认 sort/id

这些属于"查询形状",应该放 Dao。

10. Services 负责补业务数据,不负责堆 SQL

商品 Services 的 getList() 会先处理业务默认值:

php 复制代码
public function getList(array $where, $is_move = false)
{
    $store_stock = sys_config('store_stock', 0);
    $where['store_stock'] = $store_stock > 0 ? $store_stock : 2;
    [$page, $limit] = $this->getPageValue();

    $order_string = '';
    $order_arr = ['asc', 'desc'];
    if (isset($where['sales']) && in_array($where['sales'], $order_arr)) {
        $order_string = 'sales ' . $where['sales'];
    }

    $count = $this->dao->getCount($where);
    if ($count <= $limit && $page !== 1) {
        $page = 1;
    }

    $list = $this->dao->getList($where, $page, $limit, $order_string);
}

它还会补充分类名、供应商名、门店名等展示字段。

这就是 Services 的定位:

text 复制代码
把系统配置转成查询条件
处理分页回退
调用 Dao 查列表和总数
补充展示所需的跨模块数据
处理缓存和业务默认值

它不应该变成第二个 Dao,更不应该到处写裸 SQL。

11. 新增筛选条件时,推荐这样拆

假设你要给商品列表新增一个筛选条件:

text 复制代码
只看支持送礼的商品 is_send_gift

不要这样写:

php 复制代码
// 不建议:Controller 直接追加查询
if ($request->param('is_send_gift') !== '') {
    Db::name('store_product')->where('is_send_gift', $request->param('is_send_gift'));
}

推荐这样做:

Controller 只收参数:

php 复制代码
$where = $this->request->getMore([
    ['is_send_gift', ''],
    ['store_name', ''],
    ['status', '']
]);

Model 增加搜索器:

php 复制代码
public function searchIsSendGiftAttr($query, $value)
{
    if ($value !== '') {
        $query->where('is_send_gift', (int)$value);
    }
}

Dao 不一定需要改,因为已有:

php 复制代码
$this->search($where)

如果前端还要按送礼商品排序,或者要额外关联礼品配置表,那再考虑改 Dao 的 getList()getSearchList()

12. 新增复杂筛选时,先判断放 Dao 还是 Model

不是所有筛选都必须放 Model 搜索器。可以按这个规则判断:

text 复制代码
单字段条件:优先 Model 搜索器
字段有业务含义:优先 Model 搜索器
需要区间解析:Dao search 或 Model 搜索器都可,项目已有商品区间放 Dao search
需要关联多表并影响字段:优先 Dao
需要跨模块业务默认值:Services
需要登录态/权限身份:Controller 或 AuthController 入口,业务判断再交给 Services

举例:

text 复制代码
brand_id:Model 搜索器
cate_id:Model 搜索器
label_id:Model 搜索器
status:Model 搜索器
sales_range:StoreProductDao::search
stock_range:StoreProductDao::search
collect/likes/visitor 字段:StoreProductDao::getList
store_stock 系统配置:StoreProductServices

这样拆以后,列表条件再多,也不会把一个文件写炸。

13. 搜索器里最容易踩的坑

  1. 空字符串判断不能乱写。
php 复制代码
if ($value !== '') {
    $query->where('status', $value);
}

如果写成:

php 复制代码
if ($value) {
    $query->where('status', $value);
}

0 这种有效状态就会被跳过。

  1. 数组条件要处理空数组。
php 复制代码
if (is_array($value)) {
    if ($value) $query->whereIn('brand_id', $value);
}

否则空数组可能生成无意义查询。

  1. 搜索值要处理转义。

商品关键词搜索器里用了:

php 复制代码
htmlspecialchars("%" . trim($value) . "%")

至少能避免一部分异常输入直接进入模糊搜索。

  1. 业务状态不要简单等于字段。

status = 1 在商品列表里不是 where status = 1,而是:

text 复制代码
is_show = 1
is_del = 0
is_verify = 1
  1. 不要在搜索器里写太重的业务编排。

搜索器是查询解释器,不是 Services。需要调用多个服务、写缓存、事务更新时,应回到 Services。

14. 二开排查列表筛选不生效,看这几个点

text 复制代码
1. Controller 有没有把参数放进 $where
2. 参数名是否和搜索器名对应
3. Model 是否存在 searchXxxAttr
4. 空值判断是否误伤 0
5. Dao 是否调用了 $this->search($where)
6. Services 是否覆盖或重置了 where
7. 分页是否因为 count 逻辑回到第一页
8. with 关联是否影响字段读取
9. 前端字段名是否和后端参数名一致
10. 是否还有 type/relation_id/supplier_id 等隔离条件

尤其是商品列表,排查时不要只看 Controller。很多真正影响查询的逻辑在:

text 复制代码
StoreProductServices::getList
StoreProductDao::search
StoreProductDao::getList
StoreProductDao::getSearchList
StoreProduct Model 的 searchXxxAttr

15. 注意事项

  1. 列表筛选不要直接写在 Controller。
  2. 新增筛选条件前,先全局搜索是否已有同名搜索器。
  3. 字段值可能为 0 时,不要用简单 if ($value) 判断。
  4. 复杂区间、排序、统计字段要集中在 Dao,避免多处复制。
  5. Model 搜索器只解释查询条件,不做跨模块业务编排。
  6. Services 可以补系统配置、缓存、展示字段和分页逻辑。
  7. 导出、弹窗选择器、批量处理尽量复用同一套 Dao 查询。
  8. 涉及供应商、门店、渠道隔离时,必须检查 typerelation_idsupplier_id
  9. 商品状态是组合条件,不是单字段条件。
  10. 复杂列表上线前要用多个筛选组合测试,特别是库存区间、分类、品牌、状态一起筛时。

标签建议

text 复制代码
CRMEB Pro
CRMEB二开
ThinkPHP
Dao
Model
搜索器
商品列表
后台筛选
商城系统
源码解析