概述
黑名单功能的最后一块拼图:把误拉黑的人从 tb_black_list 里删掉。删除操作本身只有几行代码,但"用 DELETE 方法还是 POST""删不到记录算不算错""删完分页页码空洞怎么办"这几个问题值得掰开讲。
纲要
- 业务场景:解除拉黑后的可见性恢复
- 接口设计:
DELETE /users/blacklist/{uid}的语义与防越权 - 完整代码链路
- SettingsController:
@DeleteMapping+@PathVariable - SettingsService:
UserHolder.getUserId()取操作人 - BlackListApi / BlackListApiImpl:
QueryWrapper条件删除 - 为什么不能用
deleteById
- SettingsController:
- 删除不存在的记录:幂等性与影响行数盲区
- 删除的安全与数据一致性
- 物理删除 vs 软删除
@TableLogic对delete的改写行为- 并发删除导致的分页空洞
- 请求时序图
- 常见坑与规避
- 验证步骤
业务场景
用户在"通用设置 → 黑名单"页面看到拉黑列表,点某个条目的"取消黑名单",这条拉黑关系即被解除。解除后效果与拉黑时对称:对方对你的点赞、评论等互动恢复可见,推荐场景下对方也会重新出现在候选里。完整的影响范围(哪些功能被拉黑屏蔽、解除后恢复什么)在黑名单需求分析前文里列过表,这里不再重复。
一句话概括实现:从 tb_black_list 表中删除 user_id = 当前用户 AND black_user_id = 目标用户 的那一行。
接口设计
接口定义(YAPI 文档):
- 请求方式:
DELETE - 路径:
/users/blacklist/{uid} - 路径参数:
uid------要移除的黑名单用户 ID - 响应:无响应体,状态码 200
两个设计决策值得说清楚:
为什么用 DELETE 方法 + 路径参数。 删除的目标是"当前用户与 {uid} 之间的拉黑关系",这是一条明确的资源:/users/blacklist/{uid} 读出来就是"用户黑名单里的 uid 这一项"。DELETE + 路径定位资源、方法表达操作,符合 REST 语义;对比 POST /users/blacklist/delete + body 传 ID 的写法,后者把"操作"藏进了 URL,语义模糊,也无法被网关或中间件按 HTTP 方法做统一审计。
操作人从 token 取、目标从路径取。 Controller 参数里只有 uid(被取消拉黑的人),操作人 ID 由拦截器解析 token 后放进 UserHolder,Service 层再取。这是防越权的关键:请求里不带操作人 ID,前端无法伪造"替别人取消黑名单"的请求 。如果接口设计成接收 userId 参数,任何人改一下参数就能操作别人的黑名单------这类水平越权在社交产品里是高频漏洞。
完整代码链路
本节代码位于 mf-app-server(Controller、Service)和 mf-dubbo(Api 接口与实现)两个模块,涉及的文件:
tree
mf
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── SettingsController.java # DELETE 接口入口
│ └── service
│ └── SettingsService.java # 组装操作人与目标 ID
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── src/main/java/com/mf/dubbo/api
│ │ └── BlackListApi.java # Dubbo 服务契约
│ └── mf-dubbo-db
│ └── src/main/java/com/mf/dubbo
│ ├── api
│ │ └── BlackListApiImpl.java # 条件删除实现
│ └── mappers
│ └── BlackListMapper.java # MP 的 BaseMapper
└── mf-model
└── src/main/java/com/mf/model/domain
└── BlackList.java # 实体类
SettingsController
java
/**
* 取消黑名单
*/
@DeleteMapping("/blacklist/{uid}")
public ResponseEntity deleteBlackList(@PathVariable("uid") Long blackUserId) {
settingsService.deleteBlackList(blackUserId);
return ResponseEntity.ok(null);
}
@PathVariable("uid") 把路径里的 {uid} 绑定成 Long blackUserId。路径参数天生是字符串,Spring 会自动做类型转换;传非数字会直接 400,不用手动校验。
SettingsService
java
//取消黑名单
public void deleteBlackList(Long blackUserId) {
//1、获取当前用户id
Long userId = UserHolder.getUserId();
//2、调用api删除
blackListApi.delete(userId,blackUserId);
}
UserHolder.getUserId() 的值来自拦截器:请求进来时拦截器解析 token,把用户 ID 写入 ThreadLocal,Service 里随时可取。这条链路在"threadlocal 统一 token 处理"一文里展开过。
BlackListApi(Dubbo 契约)
java
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mf.model.domain.UserInfo;
public interface BlackListApi {
//分页查询黑名单列表
IPage<UserInfo> findByUserId(Long userId, int page, int size);
//取消黑名单用户
void delete(Long userId, Long blackUserId);
}
BlackListApiImpl(条件删除实现)
java
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mf.dubbo.mappers.BlackListMapper;
import com.mf.dubbo.mappers.UserInfoMapper;
import com.mf.model.domain.BlackList;
import com.mf.model.domain.UserInfo;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
@DubboService
public class BlackListApiImpl implements BlackListApi{
@Autowired
private UserInfoMapper userInfoMapper;
@Autowired
private BlackListMapper blackListMapper;
@Override
public IPage<UserInfo> findByUserId(Long userId, int page, int size) {
//1、构建分页参数对象Page
Page pages = new Page(page,size);
//2、调用方法分页(自定义编写 分页参数Page,sql条件参数)
return userInfoMapper.findBlackList(pages,userId);
}
@Override
public void delete(Long userId, Long blackUserId) {
QueryWrapper<BlackList> qw = new QueryWrapper<>();
qw.eq("user_id",userId);
qw.eq("black_user_id",blackUserId);
blackListMapper.delete(qw);
}
}
最终执行的 SQL 等价于:
sql
DELETE FROM tb_black_list WHERE user_id = ? AND black_user_id = ?
为什么不能用 deleteById。 deleteById(id) 按主键删除,而前端传过来的是 blackUserId(业务键),不是 tb_black_list.id。想用 deleteById 就得先 SELECT 查出主键再删,多一次往返还引入"查到删之间记录被并发删掉"的窗口;直接按业务键条件删除一步到位。同理,QueryWrapper 里 eq 的第一个参数是数据库列名 user_id、black_user_id,不是 Java 属性名------写成 eq("userId", ...) 运行时才报列不存在,这是 MyBatis-Plus 新手的经典手滑。想用属性名可以换 LambdaQueryWrapper<BlackList>() + eq(BlackList::getUserId, userId),编译期即可检查。
删除不存在的记录:幂等性与影响行数盲区
这是删除接口最容易被忽略的一个问题:取消一个根本没拉黑的人会发生什么?
答案是什么都不发生------条件不匹配,delete 返回影响行数 0,不抛异常。Controller 照样返回 200。这其实是件好事:
- 幂等性。用户双击"取消"按钮、请求超时后重试、消息队列重复投递,重复 DELETE 的结果与删一次完全相同。删除接口天然幂等,不需要额外处理。
- 要不要提示"该用户不在黑名单"? 两种取舍:
- 静默成功(项目现状):前端体验顺滑,重试无副作用。对 C 端用户,"移除一个已经不在列表里的人"本身就是不可能出现在 UI 上的操作(列表是从库里查的),提示意义不大。
- 明确反馈:适合 B 端管理后台或对账类场景,操作者需要知道每一步是否真实生效。做法是把影响行数透传出去,0 时返回业务码提示。
有个坑要指出:这条链路拿不到影响行数 。blackListMapper.delete(qw) 的返回值(int)在 BlackListApiImpl 里被丢弃,且 BlackListApi.delete 契约声明为 void------Service 层完全无法区分"删掉了"和"本来就没有"。排查线上问题时(比如用户反馈"取消了但列表里还在"),你不知道删除是否命中。改进方式是让契约返回数量:
java
//改进建议:契约返回影响行数,调用方可以区分两种情况
int delete(Long userId, Long blackUserId);
实现里只需把返回值接上:return blackListMapper.delete(qw);。这个"void 返回导致信息丢失"的问题与更新资料里 updateById 不返回更新结果的情况如出一辙------Dubbo 接口设计时,返回值宁可多给不能不给,下游要用的时候再加就得分两个版本。
删除的安全与数据一致性
物理删除即可,但要分清场景
黑名单记录是纯关系数据:拉黑了就是拉黑了,解除后产品上不需要"恢复历史拉黑记录"的能力,也没有审计需求,所以物理 DELETE 足够,还能让表保持干净。什么情况该软删除(加 is_deleted 列,删除改标位)?两个判断标准:
- 有审计或合规要求,"谁在什么时候拉黑/解除了谁"需要可追溯;
- 有恢复需求,比如"误操作恢复"或封禁记录作为风控证据。
黑名单两者都不占,别为了"通用"加软删除------每条软删除都让所有查询多一个 WHERE is_deleted = 0 条件,纯属自找麻烦。
如果实体标了 @TableLogic,delete 会变 update
MyBatis-Plus 的 @TableLogic 注解会让 BaseMapper 的 delete 自动改写成 UPDATE ... SET is_deleted = 1。本项目的 BlackList 实体没有这个注解,走的是真删除:
java
package com.mf.model.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
//黑名单
@Data
@NoArgsConstructor
@AllArgsConstructor
public class BlackList extends BasePojo {
private Long id;
private Long userId;
private Long blackUserId;
}
这里点一句即可:接手别人的 MyBatis-Plus 项目时,先看实体上有没有 @TableLogic,否则你以为的物理删除可能是条 update,日志里 SQL 对不上时会一脸懵。
并发:删除与分页查询叠加出"页码空洞"
用户停在黑名单第 2 页(每页 10 条,共 12 条),此时(另一端或另一操作)删除了一条,总数变 11:
- 第 2 页从 2 条变成 1 条,甚至如果删的是最后一页全部内容,请求第 2 页直接返回空列表------页码空洞。
- 更隐蔽的是 offset 偏移:
LIMIT 10, 10在总数变化后会"窜行",某条记录从第 2 页头部跳到第 1 页尾部,用户感觉"列表自己动了"。
处理方式按成本递增:
- 前端容忍(本项目采用):删除成功后前端刷新当前页,空洞只在并发窗口内出现,量级小到无感。
- 删除后重新拉总数并校正页码(总数 11、当前页 2 > 总页数 2 时回退到末页)。
- 强一致场景改游标分页(
WHERE id > last_id LIMIT 10),彻底避开 offset 漂移,代价是不能跳页。
社交黑名单列表这种低频操作场景,第 1 种足够。
请求时序图
tb_black_list BlackListApi (Dubbo) SettingsService SettingsController 拦截器 客户端 tb_black_list BlackListApi (Dubbo) SettingsService SettingsController 拦截器 客户端 #mermaid-svg-HyD8WBA30Exvp6mp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HyD8WBA30Exvp6mp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HyD8WBA30Exvp6mp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HyD8WBA30Exvp6mp .error-icon{fill:#552222;}#mermaid-svg-HyD8WBA30Exvp6mp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HyD8WBA30Exvp6mp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HyD8WBA30Exvp6mp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HyD8WBA30Exvp6mp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HyD8WBA30Exvp6mp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HyD8WBA30Exvp6mp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HyD8WBA30Exvp6mp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HyD8WBA30Exvp6mp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HyD8WBA30Exvp6mp .marker.cross{stroke:#333333;}#mermaid-svg-HyD8WBA30Exvp6mp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HyD8WBA30Exvp6mp p{margin:0;}#mermaid-svg-HyD8WBA30Exvp6mp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HyD8WBA30Exvp6mp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-HyD8WBA30Exvp6mp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HyD8WBA30Exvp6mp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-HyD8WBA30Exvp6mp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-HyD8WBA30Exvp6mp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-HyD8WBA30Exvp6mp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-HyD8WBA30Exvp6mp .sequenceNumber{fill:white;}#mermaid-svg-HyD8WBA30Exvp6mp #sequencenumber{fill:#333;}#mermaid-svg-HyD8WBA30Exvp6mp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-HyD8WBA30Exvp6mp .messageText{fill:#333;stroke:none;}#mermaid-svg-HyD8WBA30Exvp6mp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HyD8WBA30Exvp6mp .labelText,#mermaid-svg-HyD8WBA30Exvp6mp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-HyD8WBA30Exvp6mp .loopText,#mermaid-svg-HyD8WBA30Exvp6mp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-HyD8WBA30Exvp6mp .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HyD8WBA30Exvp6mp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-HyD8WBA30Exvp6mp .noteText,#mermaid-svg-HyD8WBA30Exvp6mp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-HyD8WBA30Exvp6mp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HyD8WBA30Exvp6mp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HyD8WBA30Exvp6mp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HyD8WBA30Exvp6mp .actorPopupMenu{position:absolute;}#mermaid-svg-HyD8WBA30Exvp6mp .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-HyD8WBA30Exvp6mp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HyD8WBA30Exvp6mp .actor-man circle,#mermaid-svg-HyD8WBA30Exvp6mp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-HyD8WBA30Exvp6mp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DELETE /users/blacklist/{uid}解析 token,UserHolder 写入 userId放行请求@PathVariable 取 blackUserIddeleteBlackList(blackUserId)UserHolder.getUserId() 取操作人delete(userId, blackUserId)DELETE WHERE user_id=? AND black_user_id=?影响行数(0 或 1)void(返回值被丢弃)返回200 OK(无响应体)
常见坑与规避
| 坑 | 后果 | 规避 |
|---|---|---|
| 接口接收 userId 参数作为操作人 | 水平越权,可替任何用户取消黑名单 | 操作人一律从 token 取(UserHolder.getUserId()),请求里不传 |
用 deleteById(blackUserId) 删除 |
blackUserId 不是主键,删错行或删不到 | 按业务键 QueryWrapper.eq 条件删除 |
eq("userId", ...) 传属性名 |
运行时报列不存在 | QueryWrapper 第一参数是列名,或改用 LambdaQueryWrapper |
| 契约返回 void,影响行数丢弃 | 无法区分"删了"和"本来没有",排查盲区 | 契约返回 int,需要反馈场景透传业务码 |
| 删除后前端不处理分页 | 页码空洞、offset 窜行 | 删除后刷新当前页;强一致场景用游标分页 |
user_id、black_user_id 两列 DEFAULT NULL 且无联合唯一索引 |
INSERT 两条相同关系都成功,黑名单列表出现重复;NULL 行不参与唯一约束 |
补索引:ALTER TABLE tb_black_list MODIFY user_id bigint NOT NULL, MODIFY black_user_id bigint NOT NULL, ADD UNIQUE KEY uk_user_black(user_id, black_user_id); |
最后一条是老问题:tb_black_list 的建表语句里两列都是 DEFAULT NULL,即使加了 UNIQUE KEY,MySQL 对含 NULL 的行不做唯一校验,同一对关系仍可插入多行。先把列改成 NOT NULL 再建唯一索引,约束才真正生效------这是本项目第三次遇到"缺唯一索引 + NULL 列"的组合了。
API 速览
| 项 | 值 |
|---|---|
| 接口 | 取消黑名单 |
| 请求方式 | DELETE |
| 路径 | /users/blacklist/{uid} |
| 路径参数 | uid:被取消拉黑的用户 ID |
| 操作人来源 | token(拦截器 → UserHolder) |
| 响应 | 200,无响应体 |
| 幂等性 | 是,重复调用无副作用 |
验证步骤
按下面的顺序可以完整复现删除链路,包括幂等性验证。
先插一条测试数据(假设当前登录用户 ID 为 1):
sql
INSERT INTO tb_black_list (user_id, black_user_id, created, updated)
VALUES (1, 100, NOW(), NOW());
SELECT * FROM tb_black_list WHERE user_id = 1 AND black_user_id = 100;
然后带 token 调用删除接口:
bash
curl -X DELETE "http://localhost:18080/users/blacklist/100" \
-H "Authorization: Bearer <token>"
预期返回 200。回库确认记录消失:
sql
SELECT * FROM tb_black_list WHERE user_id = 1 AND black_user_id = 100;
-- Empty set
关键一步------再执行一次同样的 DELETE:仍然返回 200、不报错,库里也不会多出异常数据,这就是条件删除的天然幂等。最后调分页接口确认列表:
bash
curl "http://localhost:18080/users/blacklist?page=1&size=10" \
-H "Authorization: Bearer <token>"
返回的 counts 比删除前少 1,items 里不再包含用户 100。
常见误解澄清
| 误解 | 事实 |
|---|---|
| 删除不存在的记录应该报 404 | 条件删除影响行数 0 不报错,这是删除接口幂等的基础;要不要反馈"不存在"是产品决策,不是技术必然 |
| RESTful 就必须删除接口返回被删的数据 | 返回 200 + 空体即可,本项目正是这么做的;返回被删实体通常只在需要"撤销"能力时有意义 |
QueryWrapper 的 eq 传实体属性名 |
传的是数据库列名;属性名匹配要用 LambdaQueryWrapper |
| 软删除比物理删除更安全 | 对有审计/恢复需求的数据成立;黑名单这类纯关系数据物理删除更简单,也不会污染查询 |
| 取消黑名单后对方立刻能刷到我的动态 | 恢复的是"可见性资格",推荐列表有缓存与刷新周期,感知上会有延迟 |
| 删除接口天然可重复点击,不需要防抖 | 接口幂等不等于没有重复请求成本,前端仍应防抖,只是后端兜底后重复请求无害 |
总结
取消黑名单的实现核心是按业务键的条件删除:DELETE WHERE user_id = ? AND black_user_id = ?。几个要点收束一下:
- 操作人从 token 取、目标从路径取,接口结构上杜绝水平越权。
deleteById按主键工作,业务键删除必须走QueryWrapper(或 LambdaQueryWrapper)条件删除。- 条件删除影响行数 0 不报错,天然幂等;但
void契约丢掉了这个信息,建议返回int。 - 黑名单无审计需求,物理删除即可;接手项目先确认实体有没有
@TableLogic,它会把 delete 偷偷换成 update。 - 删除与分页叠加会有页码空洞和 offset 窜行,低频场景前端刷新当前页即可兜住。
至此黑名单(需求分析、分页查询、取消黑名单)收官,"通用设置"功能也随之完整:设置查询、陌生人问题、通知开关、黑名单管理四块全部落地。后续离开 MySQL------项目的海量社交数据(圈子、推荐、小视频)将迁移到 MongoDB,从《MongoDB:介绍》开始。