做后端这么多年,如果让我选一个“看着简单、坑起来要命”的模块,标签管理绝对排前三。这一章正好轮到它,因为前面几章我们把用户、权限、内容主流程都过了一遍,第五章结尾提到要给文章、商品这类业务数据加一套可配置的标签体系,所以第六章就是把标签管理模块从零到一落完整。标签模块本质上就是增删改查,但它牵扯到唯一性约束、关联表设计、逻辑删除和唯一索引的冲突、前后端联调跨域、统一返回结构等一系列非常具体的问题。这篇文章适合正在做前后端分离项目的后端同学,尤其是用 Java Spring Boot 写管理后台的,看完可以直接照着做。
老实说,标签管理这个模块放在任何一个系统里都不起眼,产品经理通常也就一句话:“后台加个标签管理,文章那边可以打标签。”但等你真正动手设计表结构、写接口、跟前端联调的时候,才会发现这一句话背后全是隐藏的分歧。下文我会按实际推进顺序来写:业务边界确认、表结构设计、接口实现、列表查询性能、联调坑点、以及我实测过程中踩过的真实问题复盘。
1. 真正开工前,先确认标签属于哪种业务形态
1.1 先分清“打标”和“标签库”是两回事
我在第一眼看到“标签管理模块”这个需求时,第一反应是直接建一张 tag 表,然后写五个接口:增删改查和分页。但做了几个项目之后发现,标签这个需求至少有三类截然不同的业务形态,后端设计的重心完全不一样。
第一类是“标签作为独立资源”。后台维护一个标签库,每个标签有自己的名称、颜色、排序、状态,可以被多个业务模块引用。这类模块的重点是标签本身的 CRUD 和列表管理,也就是本章标题“标签管理模块”最直接对应的场景。
第二类是“标签作为附属打标能力”。真正的主数据是文章、商品、用户,标签只是挂在这些业务数据上的一个标记,后端核心工作其实是设计好中间关联表,以及在写入业务数据时同步维护标签关系。这种形态下,标签管理后端通常只需要提供“查询所有标签”和“按名字创建或获取标签”这两个能力就够了。
第三类是“标签有层级或分组”。比如标签可以分为“品类”“场景”“人群”等组,或者标签之间还有父子关系。这种需求一般会用 tag_group 表加 tag 表的 parent_id 字段,或者干脆引入树形结构。
我在设计第六章这个模块的时候,是按“第一类为主、预留第二类”的思路处理的:主表足够完整,支持独立管理;同时提供关联中间表的通用设计,后面文章、商品要打标签时,不用再回头大改。这个决策建议每个人都先跟产品经理聊清楚,因为你按哪种形态建表,直接决定了后续所有接口的复杂程度。
1.2 命名规则、去重口径、删除策略,需求阶段就要定死
标签模块的返工重灾区,不是代码,而是产品口径没对齐。我举几个真实例子。
标签名是否允许重名?很多人觉得“标签肯定不允许重名”,但实际业务里经常出现“Java”和“java”这种大小写不同的标签,你说它们是不是同一个?如果产品没定义,后端就只能按数据库排序规则来,MySQL 的 utf8mb4_general_ci 排序规则下,Java和java会被判定为重复,插不进去。这个时候前端再给你传一个JAVA,你就会收到一个莫名其妙的唯一键冲突。
标签名要不要自动 trim 首尾空格?如果一个标签“ Vip”和“Vip”算两个标签,那后面的打标统计就全乱了。现实情况是用户录入或者前端下拉搜索时,很容易多一个空格。我的做法是后端统一在入参 DTO 里处理,不指望前端。
删除标签时,已经被引用的数据怎么处理?在真实业务里,文章上已经打了“热门”标签,如果后台把这个标签删了,文章那边是原样保留一个无效标签,还是把这个标签从文章上解除关联,还是干脆禁止删除?这三种策略结果差异极大。我们这一章的方案是:由调用方通过参数决定是否级联解绑,但默认走逻辑删除加“禁止删除被引用标签”的逻辑,后面我会具体说原因。
1.3 标签和分类混用,是整个需求里最隐蔽的雷
还有一种常见情况:产品经理嘴上说“标签”,心里想的是“分类”。比如后台要“管理标签”,列表页有“电视”“冰箱”“手机”,点进去还要能维护子类。这种带层级的树形结构,就不适合用平铺的标签表来做。分类一般是一棵固定的树,删除父级时下面所有子级都要跟着处理;标签是扁平的,同一个打标对象可以拥有多个标签,两者语义完全不同。
我在这个模块里坚持把标签做平铺,不带 parent_id,也不引入分组。如果以后真有分组需求,我宁可再建一张分组表和一张分组标签关联表,也不直接在 tag 表上加 parent_id,不然所有列表筛选、计数、去重逻辑都会多一个维度,复杂度翻倍。
2. 表结构设计:一张主表加中间表,冗余字段要克制
2.1 标签主表字段逐个说清楚
标签主表我建议叫tag,不要叫label,也不要叫tags。label在不少业务系统里已经有别的含义,tags看起来像数组或者复数集合,tag作为单数表名最不产生歧义。核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,雪花 ID 或自增 ID 均可 |
| name | varchar(50) | 标签名称,必须加唯一索引 |
| color | varchar(20) | 标签颜色值,用于前端展示,如 #ff6600 |
| sort | int | 手动排序值,数值越小越靠前 |
| status | tinyint | 0 禁用,1 启用 |
| remark | varchar(255) | 备注说明,非必填 |
| create_by | bigint | 创建人 ID |
| create_time | datetime | 创建时间 |
| update_by | bigint | 更新人 ID |
| update_time | datetime | 更新时间 |
| deleted | bigint | 逻辑删除标识,默认 0,删除后写入主键 ID |
name字段的宽度我一般控制在 30 到 50 个字符,中文标签三五个字很正常,但有些系统允许英文长短语,太短会卡业务,太长会浪费索引空间。varchar(50)在 utf8mb4 字符集下最多能存 50 个字符,足够绝大多数场景。
color字段很多人觉得没用,但标签模块只要一接前端,就会发现他们几乎一定会要颜色。不同标签用不同颜色展示,信息辨识度会高很多。这个字段不需要做过多的校验,后端只存字符串,前端传什么用什么,但我会在创建和修改接口里限制正则格式,只允许#hex格式,防止脏数据入库。
deleted字段这里我用的是bigint而不是tinyint,这是基于一个非常实际的问题:如果使用逻辑删除字段和唯一索引,单纯的 0/1 会导致“删掉的标签占住唯一索引,导致同名新标签无法创建”。这个问题我在最后一部分的实测复盘里会详细讲解决方案,这里先记住,deleted不能简单用 0/1。
2.2 标签关联中间表:联合唯一约束是必须的
标签主表只是基础,真正让标签产生价值的是它和业务数据的关联关系。假如现在要给文章打标,我会建一张article_tag中间表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| article_id | bigint | 文章 ID |
| tag_id | bigint | 标签 ID |
| create_time | datetime | 创建时间 |
这张表必须加上一个联合唯一约束:UNIQUE KEY uk_article_tag (article_id, tag_id)。少了这个约束,同一个文章重复插入同一标签,会导致列表里出现重复的标签。很多初级开发喜欢在代码里“先查一遍再插入”,但并发环境下两条请求同时打过来,照样会插入重复数据,唯一约束才是最后一道闸门。
中间表要不要冗余一个tag_name字段?我见过一些系统为了查列表时少 JOIN 一次,把标签名称直接冗余到关联表里。这样做的代价是标签一改名,所有关联表里的冗余 name 都要同步更新,很可能漏掉。我的习惯是只存tag_id,查询时 JOIN 标签主表拿名称,标签管理模块规模不大,一次 JOIN 成本很低,没必要用一致性换性能。
2.3 usage_count 到底该不该冗余
标签列表页经常会有一个“使用次数”列,显示这个标签被多少篇文章引用了。实现方式有两种:一是每次都实时JOIN中间表COUNT,二是直接在tag表冗余一个usage_count字段。
如果标签总量在几千以内、关联表数据量也不大,我推荐直接实时统计,查询语句就是:
SELECT t.id, t.name, t.color, t.sort, t.status, COUNT(at.article_id) AS usage_count FROM tag t LEFT JOIN article_tag at ON at.tag_id = t.id GROUP BY t.id ORDER BY t.sort ASC;这种写法在数据量小的时候非常方便,完全不用担心一致性问题。但等标签数量到几万、中间表到几百万行之后,这种实时COUNT会拖慢列表页。这个时候才值得引入冗余字段。
引入冗余字段的方案是:tag表加usage_count字段,在创建关联关系时+1,在解除关联时-1。注意这个增减动作必须和关联表的插入、删除放在同一个事务里,否则一旦中间表写入成功但计数没更新,数据就对不上了。引入冗余字段之后,原来那个GROUP BY查询就变成了普通的分页查询,性能提升立竿见影。我的建议就一句话:先实时统计,等真慢了再加冗余,别一上来就把写路径搞复杂。
3. 后端接口实现:七类接口逐一落地
3.1 接口清单和 URL 设计
标签管理模块的后端接口,我最终落地的是下面这一组:
| 接口 | 方法 | URL | 说明 |
|---|---|---|---|
| 分页列表 | GET | /api/admin/tags | 支持名称模糊搜索、状态筛选、排序 |
| 标签详情 | GET | /api/admin/tags/{id} | 返回单个标签完整信息 |
| 创建标签 | POST | /api/admin/tags | 新增标签 |
| 修改标签 | PUT | /api/admin/tags/{id} | 更新名称、颜色、排序、状态 |
| 删除标签 | DELETE | /api/admin/tags/{id} | 逻辑删除,默认禁止删除已引用标签 |
| 批量删除 | DELETE | /api/admin/tags/batch-delete | 批量逻辑删除 |
| 下拉选项 | GET | /api/admin/tags/options | 只返回启用中的 id 和 name,供前端下拉使用 |
URL 里我统一带了/api/admin前缀,表示这是后台管理端接口。这样做的好处是后续可以在网关或者拦截器上针对/api/admin/**单独做权限校验,也不会和 C 端接口混在一起。
options这个接口很容易被忽略,但前端在文章编辑页要选择标签时,其实只想要一个扁平列表,不需要分页和多余字段。如果复用分页接口,前端每次都得传pageNum=1&pageSize=999,很难看。单独提供一个options接口,把返回字段压到最小,是成本最低的优化。
3.2 创建标签:先查后插不够,必须靠唯一索引兜底
创建标签的接口看起来非常直白:接收 name、color、sort、status,做参数校验,插入数据库。但代码写起来有几个细节值得留意。
第一,名称一定要做归一化处理。我在 Service 层拿到 DTO 之后,第一件事是name = name.trim(),如果为空直接抛参数异常。这里不能用前端判断来代替,因为只要有一个人直接调 API,空字符串就能进到数据库。
第二,唯一性校验不能只靠“先查再插”。如果同一个标签名同时有两个请求进来,先查都查不到,然后两个都执行插入,最后只有一个能成功。所以我在插入之前会查一次,但真正兜底的是数据库里uk_name唯一索引。插入时捕获重复键异常,转成业务异常提示“标签名称已存在”,而不是把 500 错误直接抛给前端。
第三,创建标签时要对color做一次格式化。前端玩得嗨了可能传#ff6600、ff6600、#FF6600各种格式,我会统一转成小写,并在入库前用正则校验。
核心代码大概是这样的:
@Override @Transactional(rollbackFor = Exception.class) public TagVO createTag(TagCreateDTO dto) { String name = dto.getName().trim(); if (name.isEmpty()) { throw new BizException("标签名称不能为空"); } if (tagMapper.selectCount(new LambdaQueryWrapper<Tag>() .eq(Tag::getName, name) .eq(Tag::getDeleted, 0)) > 0) { throw new BizException("标签名称已存在"); } Tag tag = new Tag(); tag.setName(name); tag.setColor(formatColor(dto.getColor())); tag.setSort(dto.getSort() == null ? 0 : dto.getSort()); tag.setStatus(dto.getStatus() == null ? 1 : dto.getStatus()); tag.setDeleted(0L); tagMapper.insert(tag); return toVO(tag); }注意由于插入了唯一索引,上面这次selectCount其实可以省掉,直接捕获异常。但保留一次查询可以让错误提示更友好,也能避免大部分正常场景下直接依赖异常流转。正确理解是:selectCount用于提前拦截,索引用于最终兜底,两者都保留。
3.3 修改标签:接收 DTO 手动赋值,别拿整个实体怼上去
修改接口最容易踩的坑,是前端把整个标签对象原样传回来,后端直接updateById(entity)一把梭。这种写法的隐患是:如果前端只改了 name,但它的对象里color字段因为某种原因没传,反序列化后就变成 null,updateById会把color也更新成 null。我之前就遇到过一次线上标签颜色大面积丢失,全都变成黑色,最后查下来就是前端某个版本漏了字段,而我的修改接口没有做空值保护。
所以在修改标签时,我坚持用专门的更新 DTO,并且只接收允许被修改的字段。代码示例如下:
@Override @Transactional(rollbackFor = Exception.class) public void updateTag(Long id, TagUpdateDTO dto) { Tag current = tagMapper.selectById(id); if (current == null || current.getDeleted() != 0L) { throw new BizException("标签不存在"); } if (dto.getName() != null) { String name = dto.getName().trim(); if (name.isEmpty()) { throw new BizException("标签名称不能为空"); } // 查询是否存在同名但不同 ID 的标签 Long duplicateId = tagMapper.selectIdByNameExcludeId(name, id); if (duplicateId != null) { throw new BizException("标签名称已存在"); } current.setName(name); } // 只有明确传了值才更新,避免 null 覆盖 if (dto.getColor() != null) { current.setColor(formatColor(dto.getColor())); } if (dto.getSort() != null) { current.setSort(dto.getSort()); } if (dto.getStatus() != null) { current.setStatus(dto.getStatus()); } tagMapper.updateById(current); }手动赋值看起来很啰嗦,但每个字段是否被更新都由后端代码明确控制,前端传什么都不会搞出“字段被自动置空”这种灵异事件。这种写法带来的额外收益是:接口文档可以直接写明“仅传需要修改的字段”,前端不需要每次把整个对象带上,接口语义也更清晰。
修改名称时还有一个隐藏点:如果一个标签已经被许多文章引用,改名相当于全局批量更新展示名称。因为关联表里存的是tag_id,查询时实时 JOIN 标签主表,所以改名后所有关联数据自动生效,不需要做额外同步。这也是我在中间表不冗余tag_name的核心原因。
3.4 删除标签:三种策略和选择建议
删除标签的接口是我在需求阶段就要产品经理确认的关键点。根据标签是否已经被关联,我有三个策略:
策略一,强制解绑。标签删除后,把所有关联表里的记录一并删除,文章、商品上的这个标签就消失了。优点是实现简单,缺点是如果删错了标签,关联数据找不回来。
策略二,禁止删除。删除标签前先检查 midder 表有没有关联数据,有就抛业务异常:“该标签已被 xx 个内容引用,无法删除”。优点是不会误删关联数据,缺点是无法清理无用标签。
策略三,逻辑删除并保留关联。标签被删除后,主表的deleted置为非 0,但关联表的记录还在,查询文章详情时如果发现关联的标签已被删除,就过滤掉或者展示“已删除标签”占位。这个策略最灵活,但需要前端配合处理。
我的默认建议是策略二,加上一个可选的“强制删除”参数。后台列表给两个删除按钮:普通删除按策略二拦截,高级操作里可以勾选强制删除,后端确认后先解除所有关联再删除标签。这套方案在真实项目中基本不会引起客诉,因为误删成本高,禁止删除反而是最安全的兜底。
删除接口还有一个必做动作:如果列表是按标签名称排序或者按使用量排序,删除后要及时清理相应缓存,不然前端列表会出现明明已删除但还在展示的脏数据。
3.5 列表与下拉选项:动态条件注意空值
分页列表接口的 Service 层逻辑,通常是这么组织的:
public PageResult<TagVO> pageTags(TagQueryDTO query) { LambdaQueryWrapper<Tag> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Tag::getDeleted, 0L); if (StringUtils.hasText(query.getName())) { wrapper.like(Tag::getName, query.getName().trim()); } if (query.getStatus() != null) { wrapper.eq(Tag::getStatus, query.getStatus()); } wrapper.orderByAsc(Tag::getSort).orderByDesc(Tag::getId); Page<Tag> page = tagMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); // 转换为 VO 并返回 }这里有几个容易出问题的细节。getName()如果是空字符串,前端可能不知道该传什么,干脆传了'',StringUtils.hasText能同时过滤 null 和空字符串,比!= null严谨。status是 Integer,前端如果不筛选用 0 表示,但0在!= null判断里是合法的,后端必须允许传 0。很多初学者写if (query.getStatus() != null && query.getStatus() != 0)导致禁用状态的标签永远筛不出来,这就是踩了“用 0 表示空”的坑。我这里统一约定,状态筛选不传就是 null,传 0 就筛禁用,传 1 就筛启用,前后端文档写清楚。
下拉选项接口只返回启用中的标签,SQL 就是SELECT id, name FROM tag WHERE deleted = 0 AND status = 1 ORDER BY sort ASC,不需要分页。注意前端下拉框通常会做一个“可输入可搜索”的效果,所以标签名称长度尽量不要限制太死,不然用户想输入一个新的不存在的标签名时会被后端截断,体验很差。
4. 列表页的性能与排序:别让“小模块”拖垮主流程
4.1 分页排序与动态 SQL 的写法
标签管理模块虽然小,但列表页的查询设计依然要按照生产标准来。首先分页接口一定要用物理分页,不要查出全量数据在内存里分页。我用的是 MyBatis-Plus 的分页插件,配置好PaginationInnerInterceptor之后,selectPage会自动生成 limit 语句和 count 语句。
排序字段建议固定两点:sort升序,id降序。sort用于让运营可以手动置顶某些标签,id降序保证两种标签的sort相同也能稳定输出顺序,不然翻页会看到标签顺序反复跳动,前端会很困惑。
还有个影响性能的小细节:列表查询只需要展示用字段,不要SELECT *。标签表字段不多,但中间涉及 JOIN 和 COUNT 的时候,多查一列就多一分开销。我通常会在 Mapper 里定义一个TagVO对应的 resultMap,只查必要字段。
4.2 按使用率排序的方案对比
回到前面提到的usage_count问题,很多标签列表默认排序是“使用次数最多”。如果不做冗余字段,就会在上一节那个GROUP BY查询上再加一层排序:
ORDER BY usage_count DESC这个查询在标签数量上千、关联数据上万时就已经开始变慢,尤其是LEFT JOIN还要GROUP BY id,MySQL 会拿 tag 表全量去关联中间表,索引再好也要扫不少行。
所以一旦遇到性能瓶颈,就切换成冗余字段方案。更新usage_count的时机只有一个:关联表插入成功时 +1,删除成功时 -1,且必须在同一个事务里执行。这个方案唯一要留意的是事务边界,完成关联表插入和计数更新之间不能有太长的耗时操作,更不能用异步任务去计数,否则计数会有延迟,用户在界面上看到的使用次数和实际数量不一致。
我的明确建议是:列表页排序如果需要“使用量”,直接用ORDER BY usage_count DESC,冗余字段方案优先于实时统计。不要以为几千条数据无所谓,标签模块是高频访问模块,每个列表页都要 COUNT,后台运营反复点,压力会不断累积。
4.3 无主标签的定期清理方案
运营后台经常会出现一种情况:有人通过文章编辑页的“快速创建标签”功能,创建了一堆标签,但后来没用上,也没在标签管理后台里删除。这些标签本身并不算脏数据,但当量越来越多之后,下拉选项和标签云里全是垃圾词。
我做了两个层面的清理策略。第一层是接口层:快速创建标签的接口会判断“如果当前标签没有被任何内容引用,且创建时间超过 90 天,可以在管理后台的待清理列表里展示”。第二层是任务层:每天凌晨跑一个定时任务,扫描超过 180 天未被引用且状态为禁用的标签,做逻辑删除。这两个策略配合,不会误删刚刚建立关联的标签,也能把长期无用的标签慢慢消化掉。
这里特别提醒:清理任务一定要限流和分批。假设关联表很大,一次性删除几万条中间表记录可能锁表,影响线上正常写入。我的做法是每次只取 500 个标签,逐批处理,每批之间间隔 5 秒。稳定为主,快慢反而是其次。
5. 前后端联调:跨域、统一返回结构、参数校验一个都不能少
5.1 跨域配置:allowCredentials 与 “*” 不能共存
前后端分离项目必然碰到跨域。前端跑在http://localhost:5173,后端跑在http://localhost:8080,直接请求会被浏览器拦下来。Spring Boot 里解决方式很多,最简单的是实现WebMvcConfigurer重写addCorsMappings:
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); }这里有个非常经典的坑:如果你用了.allowedOrigins("*"),同时又把allowCredentials(true)打开,浏览器会直接拒绝响应,因为Access-Control-Allow-Origin不能是*的前提下带上凭证。正确做法是用allowedOriginPatterns("*")。另外预检请求OPTIONS一定要放行,否则前端 POST 请求会在预检阶段就被拦掉。
我在项目里还会区分环境和路径:生产环境不会把allowedOriginPatterns("*")开到全部路径,限制为后台域名和前端域名,避免任何网站都能跨域调用。
5.2 Result 统一返回结构
标签管理模块的接口返回结构必须统一。我用的结构是:
{ "code": 0, "message": "success", "data": {} }为了做到这一点,Controller 的每个方法返回类型都是Result<T>,Service 层抛出BizException时,全局异常处理器统一转成Result.fail(e.getMessage())。这里有个细节:像参数校验失败这类错误,返回码最好不要用 500,我一般用 400;业务冲突用 409;未登录用 401;无权限用 403。前端拿到 code 之后进入不同分支,而不是一律弹错误窗口。
统一返回结构的意义在联调时会体现得非常明显。前端只要封装一个request.ts,拦截 code 不为 0 的情况统一提示,后端接口所有返回结构一致,整个联调效率会高很多。如果标签管理接口里有个别方法直接返回实体,前端就得单独处理,容易出问题。
5.3 参数校验错误信息可读
标签模块的参数校验我比较推荐用@Validated+@NotBlank、@Size这类注解,但在全局异常里必须捕获MethodArgumentNotValidException,否则前端拿到的是一大段英文错误信息,很难看懂。
我一般在 DTO 上写清楚 message:
public class TagCreateDTO { @NotBlank(message = "标签名称不能为空") @Size(max = 30, message = "标签名称不能超过30个字符") private String name; @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "标签颜色格式不正确") private String color; }然后在@RestControllerAdvice里对校验异常统一处理,把每条字段错误拼成友好的中文提示返回。前端弹窗直接展示这个 message 就行。这个细节看似简单,但绝对能减少大量因为“后端报错信息看不懂”而反复截图沟通的时间。
5.4 时间字段的序列化坑
标签表有create_time、update_time两个时间字段,如果后端默认用 Jackson 序列化LocalDateTime,前端拿到的可能是"2025-01-15T10:30:00"这种 ISO 格式,甚至更早的版本会拿到一个数组。不同浏览器、不同前端组件对它的解析不一致,经常出现列表页时间显示成一串数字或显示 NaT。
我的方案是全局统一配置时间格式:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8并且在对应 VO 的字段上直接用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")做兜底。宁可后端先格式化好,也别让前端去猜时区。标签管理列表页的用户是运营人员,他们更习惯看到2025-01-15 10:30:00这个格式,而不是 ISO 时间。
6. 实测复盘:四个真实踩坑记录,附最终写法
6.1 逻辑删除字段与唯一索引的碰撞
这是我做标签管理模块时遇到的最隐蔽的坑,单独拎出来复盘。
我先按常规建表方式加了deleted tinyint default 0,然后把name字段设为唯一索引。结果测试删除功能时发现:删掉一个叫“测试”的标签,再创建“测试”标签,插入直接报唯一键冲突。因为被删掉的记录还在表里,它的name仍然是“测试”,唯一索引锁死了这个名字。
我把deleted从tinyint改成bigint,逻辑删除的时候不是置 1,而是把这条记录的主键 ID 写进去。这样每条被删除的记录都有一个不同的deleted值,唯一索引由单列(name)改成复合索引(name, deleted),新的“测试”标签deleted=0,老记录deleted=10086,互不冲突。
ALTER TABLE `tag` DROP INDEX `uk_name`, ADD UNIQUE KEY `uk_name_deleted` (`name`, `deleted`);代码层逻辑删除时也要对应修改:tagMapper.deleteById(id)在 MyBatis-Plus 里默认会把deleted更新为 1,所以这里必须自定义 SQL:
UPDATE tag SET deleted = id WHERE id = #{id}这个方案唯一的注意点:复合唯一索引会让“相同 name + deleted 0”仍然唯一,满足业务要求;而被删除的记录因为deleted各不同,不会阻塞新建。这个坑如果你不在标签模块里遇到,大概率也会在用户表、分类表里遇到,属于逻辑删除和唯一约束的组合经典问题。
6.2 空字符串和 null 的校验差异
前端表单里如果用户什么都没填,受控组件的值往往是""而不是null。我在创建标签接口里用@NotBlank校验 name,但下拉选项接口里如果前端传了一个category=空字符串,由于没有加校验注解,空字符串进入查询条件后会查出一个空集合。
表面上影响不大,但我遇到过一次:前端传status=空字符串,后端用 Integer 接收,Spring 在反序列化时会直接把空字符串转成 null,所以查询没影响;但如果接收字段是 String 类型,空字符串就会参与查询。解决方式有两种:一是前端统一把空字符串转成 null,二是后端所有查询条件统一用StringUtils.hasText判断。我推荐后端自己做好防御,不要把责任推给前端,毕竟接口可能会被第三方直接调用。
6.3 并发编辑覆盖:前端返回旧数据
标签编辑页如果停留时间太长,另一个运营同事把标签名从“新人”改成了“新用户”,此时第一个运营还在编辑页上,没刷新,看到的是“新人”,提交时把整个对象带回来,后端updateById就把“新用户”覆盖回“新人”。这个问题在标签管理这种小模块里很常见。
我采用的方案比较轻量:在标签表增加version字段,更新 SQL 带上WHERE version = #{oldVersion},受影响行数为 0 就提示“数据已被他人修改,请刷新后再试”。没有用复杂的乐观锁框架,只是一个@Version注解的事情。标签管理模块做了乐观锁,后面凡是要做同步编辑的模块都可以复用这套模式。
6.4 JSON 序列化循环引用
标签详情接口一开始返回的是Tag实体,实体里有一个List<Article>字段,Article 里又引用了List<Tag>,Jackson 序列化时直接抛出了could not write JSON: Infinite recursion。
这个问题的根因是实体设计里双向关联。实际上,标签详情接口根本不需要返回关联的文章列表,需要的话前端会单独调文章列表接口。我的最终方案是:Controller 全部返回 VO,TagVO 只包含 id、name、color、sort、status、usage_count、create_time、update_time,实体里的关联字段全部用@JsonIgnore或者直接去掉。这样既避免循环引用,也让接口返回结构干净。类似的坑在用户角色模块、部门模块里都会出现,我现在的习惯是“数据库实体不和前端 API 契约绑定”,中间永远隔一层 VO。
这次第六章做完,我最大的体会是:标签管理看似是一个标准 CRUD,实际上真正花时间的不是写接口,而是把表结构、逻辑删除和唯一索引的关系想清楚,把删除策略、重名策略这些业务口径和产品对齐。很多项目做到后面出现数据问题,都源于开发阶段没有把这些“小边界”钉死。下一章我们打算做内容审核流,标签作为附属元数据还会再次出现,到时候这套设计的价值会更明显。如果你正在做类似的管理模块,建议把我上面踩过的坑对照你的库表自查一遍,尤其是逻辑删除加唯一索引那一条,在项目早期改掉成本最低。