☰
CRMEB商品标签功能开发实战:从数据模型到搜索优化全解析
2026/10/14 6:48:13 网站建设 项目流程

CRMEB标准版里做商品标签功能,这个需求我最近刚好在一个二次开发项目里完整走了一遍。从后台的标签管理、商品绑定标签,到前端的标签筛选搜索,再到搜索路径的优化,整套流程下来收获不少,也踩了几个坑。这篇东西就把整个开发过程拆开揉碎了讲清楚,涉及到的代码逻辑、表结构设计、模板改动、搜索优化思路,全部摊开来说,希望能给正在做类似功能的同学一个参考。

先说清楚目标:给标准版的商品模块增加一个“标签”维度,后台可以维护标签,商品可以关联一到多个标签,前台用户可以通过标签聚合页面浏览商品,同时标签能配合搜索权重和URL结构做基础优化。这套东西做完以后,商品不再只有分类这一条索引路径,等于多了一个内容聚合和引导入口。

1. 标签模块的整体设计与表结构规划

动手写代码之前,我得先把数据模型理清楚。商品标签本质上是一个“多对多”的关系:一个标签可以挂在多个商品上,一个商品也可以拥有多个标签。这个关系如果设计不好,后面查数据、更新关联、做筛选都会别扭。

1.1 标准版的数据表扩展方案

标准版本身就有一张存储分类的表,标签不能直接往商品表里塞字段,那样做扩展性太差,后期想加标签属性还得改商品表。所以我的方案是单独建两张表:

  • 标签表(store_product_label)
  • 商品标签关联表(store_product_label_relation)

标签表存储标签自身的信息,关联表只负责记录“哪个商品挂了哪个标签”。这个设计跟商品和分类的关系很像,但是比分类更轻量,因为标签不需要层级,它是一个扁平的属性集合。

1.2 标签表的字段设计

标签表我建议的字段大致是这样:

字段名类型说明
idint主键
label_namevarchar标签名称,比如“热销”“新品”“学生党”
label_typetinyint标签类型,1=商品标签,可预留其他类型
sortint排序值,越大越靠前
statustinyint状态,1启用 0禁用
create_timeint创建时间
update_timeint更新时间

有个细节值得注意:label_name 必须加唯一索引。为什么?因为标签本身是要给用户看的,如果后台可以重复创建同名标签,前台展示的时候会出现两个一模一样的入口,体验很怪。而且从数据清洗的角度讲,重复标签会稀释点击和搜索的权重。加了唯一索引以后,后台新增时做一次查重提示就行,UEditor或者表单验证层直接兜底。

1.3 关联表的字段设计

关联表简单很多,就三个核心字段,加一个主键:

字段名类型说明
idint主键
product_idint商品ID
label_idint标签ID
create_timeint绑定时间

这里要加一个唯一索引,联合索引(product_id, label_id),防止同一个商品重复绑定同一个标签。我不建议在关联表里冗余标签名称和商品名称,虽然查询的时候可以减少联表,但是一旦后台改了标签名称,关联表里的冗余数据就会不一致,到时候还得写一套同步逻辑,纯属给自己找事。

建表SQL给出来,标准版的数据库前缀一般是 eb_,我这里就按前缀写了,你们实际用的时候换成自己的前缀即可:

CREATE TABLE `eb_store_product_label` ( `id` int(11) NOT NULL AUTO_INCREMENT, `label_name` varchar(100) NOT NULL DEFAULT '' COMMENT '标签名称', `label_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '标签类型 1=商品', `sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1=显示 0=隐藏', `create_time` int(11) NOT NULL DEFAULT '0', `update_time` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `label_name` (`label_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签表'; CREATE TABLE `eb_store_product_label_relation` ( `id` int(11) NOT NULL AUTO_INCREMENT, `product_id` int(11) NOT NULL DEFAULT '0' COMMENT '商品ID', `label_id` int(11) NOT NULL DEFAULT '0' COMMENT '标签ID', `create_time` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `product_label` (`product_id`,`label_id`), KEY `label_id` (`label_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签关联表';

两张表合起来就是完整的标签数据模型。为什么关联表不直接冗余商品缩略图和价格?我在这块吃过亏,曾经觉得查询的时候少联一张表性能好,结果运营在后台改商品名称、换缩略图以后,标签页展示的数据全成了旧数据。后来果断把冗余字段删了,查询一律走JOIN,性能并没有慢到不能接受,反而数据一致性有了保障。

2. 后台标签管理的完整实现

表结构定了,接下来是后台功能。标准版后台是典型的 ThinkPHP 6 分层结构,控制器、模型、验证器、服务层各干各的。标签管理这块,核心就是一套标准的增删改查,但是有几句代码细节值得单独拎出来讲。

2.1 路由与控制器构建

我先注册菜单,后台菜单表需要插入两条记录,一条是“商品标签”的列表页,一条可以挂在商品菜单下面。控制器命名我建议用StoreProductLabel,类的路径是app/admin/controller/store/StoreProductLabel.php。

控制器里最核心的几个方法:

  • index():列表页,支持分页、搜索、状态筛选
  • save():新增标签
  • update():编辑标签
  • delete():删除标签

有一个点必须注意:删除标签不只是删掉标签表里的那行记录,还要同步删除关联表里跟这个标签相关的所有绑定关系。不然会出现“关联表里存了一个不存在的标签ID”这种脏数据,前台查询的时候要么报错,要么查出空数据。

删除逻辑直接用模型事件来处理,比在控制器里手动写干净:

public static function onBeforeDelete($label) { // 同步删除关联表数据 LabelRelation::where('label_id', $label->id)->delete(); }

这样不管是控制器调用,还是未来其他地方通过模型删除标签,都能保证关联数据被清理掉,不会漏。

2.2 列表页的排序与状态切换

后台列表一定要支持排序。排序的作用不只是后台管理方便,它直接决定前台标签展示的顺序。我的经验是排序值大的排前面,新增的标签默认给个中间值比如50,运营手动调整。

状态切换我建议用标准的 Ajax 提交,点击开关以后直接改数据库。这里有一个小细节:状态禁用以后,前台标签页应当立即停止展示这个标签,但是已经绑定的商品不受影响。也就是说,禁用的标签不是删除,只是暂时下线。这样运营可以随时下架一个标签,不用重新关联商品。

列表查询代码大概长这样:

public function index() { $where = []; $keyword = input('keyword', ''); $status = input('status', ''); if ($keyword) { $where[] = ['label_name', 'like', "%{$keyword}%"]; } if ($status !== '') { $where[] = ['status', '=', $status]; } $list = LabelModel::where(...$where) ->order('sort desc, id desc') ->paginate(20); return json(compact('list')); }

列表页的模板字段就按标准的列表渲染,表格右侧操作列放“编辑”和“删除”按钮。新增和编辑共用一个弹窗表单,这是我比较推荐的做法,少维护一套页面,提交地址根据是否有ID来决定是走 save 还是 update。

2.3 表单验证的细节处理

验证器这块,只需用一个规则:

protected $rule = [ 'label_name' => 'require|unique:store_product_label', ];

unique会自动排除当前ID,编辑的时候不会误判。这个规则加上数据库唯一索引,双保险,基本杜绝了重名标签。

另外,sort 字段我建议用数字验证,不然有人填个“abc”进去,前台排序会出问题。验证规则加一层number就行,错误提示信息写清楚一点,运营侧的人不一定懂技术术语。

后台做完以后,整体感觉就是“基本功”。这块没有太花哨的技巧,重点是别漏掉关联表的清理、别让重复标签落库、排序状态这些基础交互做顺手。真正有意思的是商品端怎么把标签用起来。

3. 商品与标签的多对多关联操作

后台标签管理搞定了,接下来是商品端。商品关联标签这个场景,我选择的是在商品新增/编辑页加一个标签选择器,商品保存的时候同步保存关联关系。这里涉及的改动比较多,我拆成几步讲。

3.1 商品表单的标签选择器

商品编辑页在标准版里是比较复杂的表单,右侧一般有各种属性设置区。标签选择器我选择放在商品基本信息下方。界面用弹出式多选,或者直接用一组可以勾选的标签盒子,看你个人项目习惯。

我的做法是在商品表单里加一个隐藏域label_ids,用逗号拼接的ID字符串,比如“12,23,5”。前端在页面加载的时候,请求后台接口获取当前商品已有的标签ID数组,把对应的标签盒子设为选中状态。用户勾选和取消勾选的时候,动态更新隐藏域的值。

前端核心代码简化一下大概是这个思路:

// 初始化选中状态 let selectedLabels = []; function toggleLabel(id, checked) { if (checked) { selectedLabels.push(id); } else { selectedLabels = selectedLabels.filter(item => item !== id); } document.getElementById('label_ids').value = selectedLabels.join(','); }

标签选择器不建议做成下拉多选,因为商品可能关联很多标签,下拉框一多就滚动到怀疑人生。可视化的标签盒子,一个标签一个小方块,点击选中高亮,这种交互在后台用起来最直观。

3.2 保存商品时的关联更新策略

商品保存的后端逻辑,标准版是在服务层(如StoreProductServices)里做的。找到保存商品的逻辑方法,在商品主数据写库之后,加一段处理标签关联的代码。

这里有个非常关键的设计点:保存关联关系的时候,不能用“先清空再插入”这种粗暴方式吗?我建议看场景。如果商品编辑频率不高,标签数量也不大,完全可以:

  1. 先删除该商品所有旧关联
  2. 再批量插入新关联

这样代码最简单,逻辑也最清晰。但是有一个副作用:商品的标签关联ID会变。如果有人接了第三方的标签统计系统,用关联ID做增量同步,那每次编辑商品都会产生一堆“新增绑定”的假数据。

如果在意这个,就改成求差集:

  • 查出旧的关联标签ID数组
  • 算出新增的和删除的
  • 只做增量插入和增量删除

但是说实话,一个电商后台的标签绑定,没必要做得那么精细。我自己实践下来,直接先删后插是最稳的,生产环境跑了半年没出过问题。下面是我采用的代码片段:

// 商品保存完成后调用 public function saveProductLabels($productId, $labelIds) { LabelRelation::where('product_id', $productId)->delete(); if (!empty($labelIds)) { $data = []; foreach ($labelIds as $labelId) { $data[] = [ 'product_id' => $productId, 'label_id' => $labelId, 'create_time' => time(), ]; } LabelRelation::insertAll($data); } }

insertAll批量插入性能很好,哪怕一个商品挂十个标签,也就一条SQL的事。这里要注意,插入前一定要过滤一遍 labelIds,确保都是启用状态的合法标签ID,防止传进来一个禁用标签的ID。

3.3 商品列表展示标签信息

商品列表页最好也展示一下这个商品挂了哪些标签,方便运营快速了解商品情况。实现方式是在商品列表查询的时候,关联查询每个商品对应的标签名称,拼成一个字符串展示。

标准版商品列表用的查询可能有多处,有商品列表、回收站列表、选择商品弹窗等。我建议在模型层加一个访问器:

public function getLabelNamesAttr($value, $data) { // 通过关联查询获取标签名 return $this->labels()->column('label_name'); }

然后商品列表模板里直接{$item.label_names|implode=',',###}输出就行。不过要注意,如果商品列表是分页查询,在模型里加了访问器以后,每一条商品都会额外执行一次关联查询,商品多的时候性能会有点压力。

解决思路有两个:

  • 列表页不展示标签名,只有编辑弹窗里才查
  • 用with(['labels'])预载入关联,把查询合并成两条SQL

我建议用预载入方案,ThinkPHP 6 里写法很简单:

$list = ProductModel::with(['labels'])->paginate(20);

关联模型定义:

public function labels() { return $this->belongsToMany(LabelModel::class, 'eb_store_product_label_relation', 'product_id', 'label_id'); }

这样列表页在访问$item->labels的时候,不会再产生N+1查询,性能可控。

4. 前台标签筛选与搜索优化的完整落地

后台和商品的关联都通了,重头戏在前台。标签功能最终价值就在前台的展示和搜索路径上。这块我分三部分:标签聚合页、URL结构与搜索匹配、标签页的SEO细节。

4.1 标签聚合页的部署方案

前台标签聚合页,我采用的是标准做法:一个标签列表页移动端 /label/list和一个标签商品列表页移动端 /label/{id}。

标签列表页很简单,展示所有启用状态的标签,按 sort 倒序排列。每个标签可以显示名称和关联商品数量。关联商品数量如果每次都 count 一下,标签多了会有压力,我建议后台定期统计并缓存,或者更新关联的时候同步更新一个计数字段到标签表。

标签详情页是重点。这个页面展示某个标签下的所有商品,分页加载,排序规则可以和普通商品列表保持一致,比如综合排序、销量排序、价格排序。

查询逻辑:

$labelId = (int) input('id'); $productIds = LabelRelation::where('label_id', $labelId) ->column('product_id'); $list = ProductModel::whereIn('id', $productIds) ->where('is_show', 1) ->where('is_del', 0) ->order('sort desc, id desc') ->paginate(10);

这段代码有个隐藏问题,如果某个标签下关联了上万件商品,whereIn拼接的SQL会非常长。实际项目里标签的商品量一般不会到万级,但为了稳妥,我建议加一个上限,最多取最近上架的500个商品ID。如果这个标签的商品实在太多,说明标签划分太粗了,运营层面可以再拆细一点。

4.2 URL结构与搜索路径优化

标签页的URL,标准版默认可能是/label/index?id=1这种带参数的。说实话,这种URL对搜索引擎不够友好,看起来也不美观。我建议用伪静态的方式改成/label/1.html或者/label/热销.html。

伪静态规则,Apache 环境加一条:

RewriteRule ^label/([0-9]+)\.html$ /index.php/label/index?id=$1 [L]

Nginx 环境对应加:

rewrite ^/label/([0-9]+)\.html$ /index.php/label/index?id=$1 last;

URL 只是第一步。标签页想获得搜索流量的核心,在于“标签名称”本身要跟用户的搜索词相关。举个例子,“热销”这个标签,和“夏季连衣裙”这个标签,后者明显更有搜索价值。我建议运营在创建标签的时候,多想想用户搜索时可能使用的词,而不是凭感觉拍脑袋。技术层面能做的,是把标签名称自动映射到页面标题、关键词、描述上。

页面TDK的生成逻辑:

$title = $labelInfo['label_name'] . '_商品推荐_' . config('site_name'); $keywords = $labelInfo['label_name'] . ',商品,购物'; $description = '为您精选' . $labelInfo['label_name'] . '商品列表,品质保障,价格实惠。';

模板里 title、keywords、description 三个变量替换成这些动态值就行。

4.3 搜索匹配逻辑的增强

商品搜索这块,标准版一般默认是搜商品名称、关键词等。标签可以作为一个新的搜索维度加进去。用户搜索“热销”这个词,我们希望能把标签名为“热销”的商品也搜出来。

我的方案是这样的:在搜索接口里,除了常规的商品表搜索,再加一步 —— 如果关键词命中了某个标签名,就把这个标签下关联的商品ID合并进搜索结果。

搜索实现逻辑:

// 关键词命中标签 $labelIds = LabelModel::where('label_name', 'like', "%{$keyword}%") ->where('status', 1) ->column('id'); if (!empty($labelIds)) { $labelProductIds = LabelRelation::whereIn('label_id', $labelIds) ->column('product_id'); // 跟关键词搜索的结果合并去重 $allProductIds = array_merge($keywordProductIds, $labelProductIds); $allProductIds = array_unique($allProductIds); }

注意,标签搜索命中的商品,跟关键词搜出来的商品可能是同一批,也可能是一大半不同的,合并去重以后分页返回。这样处理不会打乱原来的搜索结果,只是扩充了召回范围。召回多了以后,排序还是走原有的排序规则,不会因为标签匹配而改变商品的天然排序位置。

搜索这一步做完,标签的“搜索优化”价值就体现出来了:它让一部分商品多了一个被动曝光的路径,用户搜标签词也能找到它们。

4.4 前台商品详情页的标签展示

最后一步,前台商品详情页展示标签。在商品详情的标题下方加一个标签位,把当前商品关联的标签循环输出,每个标签带上链接,点击可以跳转到对应标签聚合页。这就是一个天然的站内推荐入口,用户看完一个商品,如果对标签感兴趣,点击进去就能看到一批同类商品。

商品详情页标签展示的模板片段:

<div class="product-labels"> {volist name="$product.labels" id="label"} <a href="/label/{$label.id}.html" class="label-item">{$label.label_name}</a> {/volist} </div>

这里需要确保商品详情查询的时候用了with(['labels']),不然模板里会N+1查询,详情页每次打开多出好几条SQL。

5. 常见问题与排查技巧实录

功能做完上线,问题就一个接一个来。这里把我实际开发过程中遇到过的坑整理一下,每条都是真实踩过的,不是理论推测。

5.1 标签删不掉或者删了商品还显示

删除一个标签后,商品详情页还看得到这个标签。排查后发现是删除的时候只删了标签表,没删关联表,商品查询的时候还是能关联到这条标签记录。后来我在模型删除事件里加了同步清理关联表的逻辑,这个问题就再没出现过。

还有一种情况是缓存。标准版有些查询结果会做缓存,删完标签后如果缓存没刷新,前台还会展示一段时间。处理办法是在删除操作之后调用一次缓存清理,把商品详情相关的缓存key清掉。

5.2 保存商品后标签顺带被更新了

如果商品编辑保存的逻辑里,恰好有一段更新关联表数据的操作,又没有做好条件区分,很容易出现改一个商品信息,标签绑定被重置成空的情况。我排查过一次,发现是公共方法里写死了“如果提交了 label_ids 就更新关联”,但是编辑场景没传 label_ids,默认空数组就把关联清了。

解决办法就是在更新关联之前判断一下请求里是否真的带了 label_ids 字段:

if (request()->has('label_ids')) { // 才执行更新关联逻辑 }

而不是判断值是否为空。因为空数组是合法状态,表示“这个商品一个标签都不挂”,但没传字段表示“这次编辑不涉及标签”。

5.3 前台标签聚合页分页异常

标签聚合页分页的时候,发现第二页的商品跟第一页重复或者错乱。原因是分页查询用whereIn传入的ID顺序不稳定,MySQL 不会保证whereIn的结果按传入顺序返回。解决办法是查询的时候显式加order('field(id, ...)')或者干脆按 id 排序。

我实际开发中更推荐按 sort、id 排序,稳定可靠。如果确实需要按关联的先后顺序返回,就加上:

->orderRaw('field(id,' . implode(',', $productIds) . ')')

但是这种方式只适合ID列表不长的情况,ID多了SQL会很长,性能反而差。

5.4 标签名带空格或者符号导致链接异常

标签名称是运营手动录入的,有可能带空格、添加特殊符号。如果直接拼到URL里,会出现链接打不开或者路径错乱的问题。所以我前台生成标签链接的时候,统一是用标签ID拼URL,标签名只做展示文本。这样不管运营起什么名字,链接都是“死的”,不会因为名称里有特殊字符而出问题。

5.5 搜索“标签词”结果为空

有一种场景:用户搜索“热销”,但是搜索结果为空。原因是搜索逻辑搜索的是商品表里的 name 字段或者 keyword 字段,压根没去标签表里查。加了标签匹配逻辑以后就解决了。但是要注意,如果你的搜索是基于搜索引擎(比如 Elasticsearch)做的,那还得考虑索引同步的时候把标签名称冗余到商品文档里,否则标签词仍然搜不到。

5.6 标签列表性能优化

如果标签和商品关系量很大,挂载聚合页时每页展示10个商品,每个商品都要判断有没有标签,后台还要统计标签商品数量,查询压力不小。我的优化策略是:

  • 标签商品数量,用定时任务每晚统计一次更新到标签表冗余字段
  • 商品列表查询,统一使用 with 预载入
  • 标签聚合页的查询加一层缓存,缓存时间设5分钟

这里的缓存不是指框架的页面静态化,而是查询结果的cache()方法,标签数据变化不频繁,5分钟缓存完全可以接受。

6. 标签功能上线以后的经验总结

最后分享一点个人经验。标签功能做完以后,我发现它对运营的帮助比预想中大。分类是固定的、单一的,但是标签是灵活的、多角度的。同一个商品可以既挂“大码”又挂“显瘦”又挂“夏季新款”,每个标签对应一类搜索意图,相当于给商品同时开辟了多条流量入口。

但是标签不是越多越好。我在实际运营数据里看到的情况是:标签数量控制在20个以内、每个标签关联商品数量在30到200之间的时候,点进标签页的转化率最高。标签太少,聚合页内容单薄;标签太宽泛,商品质量参差不齐,反而拉低整体转化。

另外,标签命名要站在用户搜索习惯的角度考虑,别用内部黑话。我之前见过运营把标签起成“SKU-01”这种,对内部管理是清晰了,但用户根本不会搜这个词,标签页做得再好看也拿不到搜索流量。标签的命名应该遵循“用户会搜索的长尾词”这个逻辑。

最后再提醒一件事:标签功能上线后,记得在后台顺手加一个标签点击统计埋点。我在项目里加了简单的统计逻辑,记录每个标签页的PV和UV,跑了两个月以后,根据这些数据把不常用的标签下架、把高热标签扩充商品,整个标签模块的流量提升了将近一倍。数据驱动的优化,在这个功能上体现得特别明显。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询