摘要
后台列表二开最容易变乱的地方,就是筛选条件。今天加一个商品名称,明天加一个库存区间,后天加一个供应商,再过几天又要支持品牌、标签、分类、活动、上下架、售罄、库存预警、可见范围。
如果这些条件都写在 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 里,所有需要商品搜索的地方都能复用。
6. 区间筛选适合放 Dao search 里集中处理
商品 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. 搜索器里最容易踩的坑
- 空字符串判断不能乱写。
php
if ($value !== '') {
$query->where('status', $value);
}
如果写成:
php
if ($value) {
$query->where('status', $value);
}
那 0 这种有效状态就会被跳过。
- 数组条件要处理空数组。
php
if (is_array($value)) {
if ($value) $query->whereIn('brand_id', $value);
}
否则空数组可能生成无意义查询。
- 搜索值要处理转义。
商品关键词搜索器里用了:
php
htmlspecialchars("%" . trim($value) . "%")
至少能避免一部分异常输入直接进入模糊搜索。
- 业务状态不要简单等于字段。
status = 1 在商品列表里不是 where status = 1,而是:
text
is_show = 1
is_del = 0
is_verify = 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. 注意事项
- 列表筛选不要直接写在 Controller。
- 新增筛选条件前,先全局搜索是否已有同名搜索器。
- 字段值可能为
0时,不要用简单if ($value)判断。 - 复杂区间、排序、统计字段要集中在 Dao,避免多处复制。
- Model 搜索器只解释查询条件,不做跨模块业务编排。
- Services 可以补系统配置、缓存、展示字段和分页逻辑。
- 导出、弹窗选择器、批量处理尽量复用同一套 Dao 查询。
- 涉及供应商、门店、渠道隔离时,必须检查
type、relation_id、supplier_id。 - 商品状态是组合条件,不是单字段条件。
- 复杂列表上线前要用多个筛选组合测试,特别是库存区间、分类、品牌、状态一起筛时。
标签建议
text
CRMEB Pro
CRMEB二开
ThinkPHP
Dao
Model
搜索器
商品列表
后台筛选
商城系统
源码解析