概述
之前每个环节拆开讲过了:OSS 上传、百度人脸校验、资料保存与查询更新、统一 token、统一异常。本篇不重复细节,只做三件事:把这五个板块串成一条线、沉淀可复用的套路、给出一份可以直接勾选的自检清单。后续写圈子功能之前,建议先拿它过一遍工程。
纲要
- 产出与知识地图(五个板块的依赖关系)
- 可复用套路清单:第三方接入五步法 / VO 设计 / 拦截器 + ThreadLocal / 统一异常 / 增量更新
- 关键坑回顾:10 个实测踩过或埋着的坑
- 自检清单:8 项可验证的完成度检查
- 练习建议:4 个可落地的改造任务(改哪里、怎么验)
- 常见误解澄清与后续预告
本次产出与知识地图
一句话概括:接入三套第三方能力组件(OSS 存储、百度人脸,加上之前的短信),跑通资料完整链路(完善 → 上传头像 → 查询 → 更新),并完成两项横切优化(统一鉴权、统一异常处理)。
五个板块不是并列关系,画成依赖图更清楚:
#mermaid-svg-Ma7HiZJ9gJcL2VO7{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-Ma7HiZJ9gJcL2VO7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .error-icon{fill:#552222;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .marker.cross{stroke:#333333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 p{margin:0;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .cluster-label text{fill:#333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .cluster-label span{color:#333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .cluster-label span p{background-color:transparent;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .label text,#mermaid-svg-Ma7HiZJ9gJcL2VO7 span{fill:#333;color:#333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node rect,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node circle,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node ellipse,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node polygon,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .rough-node .label text,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node .label text,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .image-shape .label,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .rough-node .label,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node .label,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .image-shape .label,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .icon-shape .label{text-align:center;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node.clickable{cursor:pointer;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .arrowheadPath{fill:#333333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .cluster text{fill:#333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .cluster span{color:#333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .icon-shape,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .icon-shape p,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .icon-shape .label rect,#mermaid-svg-Ma7HiZJ9gJcL2VO7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ma7HiZJ9gJcL2VO7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 复用
复用
第三方组件接入
OSS + 百度人脸
完善用户信息
保存资料 / 上传头像
用户信息管理
查询 / 更新
统一 token 处理
拦截器 + ThreadLocal
统一异常处理
BusinessException + Advice
组件是资料链路的前提------没有 OssTemplate.upload() 拿不到图片 URL,人脸检测连输入都没有;没有 AipFaceTemplate,头像上传的业务规则无法落地。两项优化放最后,不是它们不重要,而是必须先有写好的接口才有东西可改造 :拦截器改造的对象正是前面每个 Controller 方法里重复的 verifyToken 三行代码,异常处理器接管的是 updateHead 里抛出的 BusinessException。
工程到目前结束时的形态:
tree
mf
├── mf-autoconfig # 第三方组件封装(本次新增 Oss / AipFace 两套)
│ └── src/main/java/com/mf/autoconfig
│ ├── mfAutoConfiguration.java
│ ├── properties
│ │ ├── SmsProperties.java
│ │ ├── OssProperties.java
│ │ └── AipFaceProperties.java
│ └── template
│ ├── SmsTemplate.java
│ ├── OssTemplate.java
│ └── AipFaceTemplate.java
├── mf-model # 实体与 VO(本次新增 UserInfo / UserInfoVo / ErrorResult)
│ └── src/main/java/com/mf/model
│ ├── domain
│ │ ├── User.java
│ │ ├── UserInfo.java # 注意:直接 implements Serializable,没继承 BasePojo
│ │ └── BasePojo.java
│ └── vo
│ ├── UserInfoVo.java
│ └── ErrorResult.java
├── mf-dubbo
│ ├── mf-dubbo-interface # UserInfoApi(findById / update / save)
│ └── mf-dubbo-db # UserInfoApiImpl + UserInfoMapper
└── mf-app-server
└── src/main/java/com/mf/server
├── controller
│ ├── UserController.java # loginReginfo / loginReginfo/head
│ └── UsersControler.java # 查询 / 更新(类名拼写就是这么写的)
├── service
│ └── UserInfoService.java
├── interceptor # 本次新增
│ ├── TokenInterceptor.java
│ ├── UserHolder.java
│ └── WebConfig.java
└── exception # 本次新增
├── BusinessException.java
└── ExceptionAdvice.java
可复用套路清单
这是真正值得带走的五条套路,每一条都至少会再用一次。
| 套路 | 本次怎么用 | 后续哪里还会用 |
|---|---|---|
| 第三方接入五步法:申请配置 → 先调通再封装 → 四件套(Properties / Template / AutoConfiguration / 注册文件)→ 业务只依赖 Template → 配置外置 + 最小权限 | 用了两次:OssProperties + OssTemplate、AipFaceProperties + AipFaceTemplate,都挂在 mfAutoConfiguration 上,靠 META-INF/spring.factories 注册 |
后续内容审核(图片文本鉴黄)完全照抄,还是这四件套 |
VO 设计 :接口只暴露该暴露的字段;实体过 Dubbo 必须 Serializable,VO 不过 Dubbo 就不需要序列化 |
UserInfo 过 Dubbo 所以实现 Serializable;UserInfoVo 只给前端,字段和类型跟接口文档对齐 |
圈子、小视频、消息所有对外接口都要配 VO |
拦截器 + ThreadLocal 统一鉴权 :preHandle 校验并 set,afterCompletion 必须 remove |
TokenInterceptor 解析 token 存 UserHolder,Controller 里一行 UserHolder.getUserId() 替代原来三行重复代码 |
整个项目所有需要登录态的接口;换 Spring Security 前一直是主力方案 |
统一异常处理 :BusinessException 携带 ErrorResult + @RestControllerAdvice 按类型匹配;业务异常 warn、系统异常 error 且绝不返回堆栈 |
updateHead 检测不通过时 throw new BusinessException(ErrorResult.faceError()),ExceptionAdvice 兜底 |
后续每个业务校验失败都只写一行 throw |
增量更新 :MyBatis-Plus updateById 动态 SQL,只更新非 null 字段 |
更新资料时只传要改的字段,null 字段不会把数据库值冲掉 | 用户资料、动态设置等所有局部更新场景 |
第三条里有个容易忽略的细节:afterCompletion 里的 remove 不是可选项。Tomcat 是线程池,线程处理完请求会复用,如果不清理,下一个落到同一线程的请求会拿到上一个用户的 id------串号比报错严重得多。
第四条的日志级别也是有讲究的:业务异常(用户传了张风景照)走 warn,系统异常(NPE)走 error 并且只返回 ErrorResult.error() 这种固定文案,堆栈绝不响应给前端。
关键坑回顾表
按「坑 → 症状 → 解法」列出来,复习时重点看第 6 行,那是工程里真实存在的安全洞。
| 坑 | 症状 | 解法 |
|---|---|---|
| SpringMVC multipart 默认上限 1MB | 上传正常照片报 MaxUploadSizeExceededException |
application.yml 配 spring.servlet.multipart.max-file-size |
| 类型校验只看扩展名 | 改后缀的 a.jpg(实为 html)照样过校验 |
看 contentType / 魔数,扩展名只做参考 |
人脸检测 error_code == 0 ≠ 合规头像 |
双人合照也返回 0,检测通过 | 判定要用 result.face_num 等业务字段,不能只看错误码 |
| OSS URL 双斜杠拼接 | url 配置以 / 结尾,Template 又拼了一个 /,地址变成 ...com//2026/... |
工程代码里 properties.getUrl() + "/" + filename 实测有此问题,二选一:配置去掉尾斜杠或拼接时去重 |
updateById 改不动 null |
想把某字段清空,传 null 后数据库纹丝不动 | 动态 SQL 的特性不是 bug;清空要么用 UpdateWrapper set null,要么业务上不允许清空 |
| 更新接口白名单只在文档没在代码 | PUT /users 直接收 UserInfo 实体,传 avatar 字段可绕过人脸校验直接改头像 |
改用 DTO 只收允许修改的字段(见下方练习一) |
| ThreadLocal 忘 remove | 压测后偶现「用户 A 看到用户 B 的资料」 | afterCompletion 里 UserHolder.remove(),并写自检项 |
@ControllerAdvice 覆盖不到 Controller 之前 |
拦截器里抛的异常不进 Advice,直接 500 白页 | 拦截器里自己 try-catch 或写 ErrorController;鉴权失败用 response.setStatus(401) 直接返回 |
| JWT claims 数字类型强转 | (Long) claims.get("id") 抛 ClassCastException |
jjw 库反序列化整型默认是 Integer,先 Number 再 longValue() |
BeanUtils.copyProperties 静默不拷贝 |
UserInfo.age 是 Integer、VO 里是 String,age 字段一直是 null,不报错 |
同名不同类型不拷贝也不告警;类型对不上的字段手动 set(本次 findById 就是这么补的) |
自检清单
对照勾选,全过才算真的完成:
| 检查项 | 怎么验 |
|---|---|
| OSS 能上传并拿到可访问 URL | 跑 OssTest,把输出的 URL 贴进浏览器能打开图片 |
| 人脸检测能区分自拍 / 风景 / 合照 | 三张图各调一次 detect(),风景返回 false,自拍返回 true;合照如果也返回 true,说明你踩了上表第 3 行 |
| 资料能保存且重复提交不炸 | 同一 token 连续 POST 两次 /user/loginReginfo,第二次观察主键冲突的处理方式 |
| 查询返回 VO 字段 | GET /users 响应里没有 created / updated / tags,age 是字符串 |
| 更新只改允许改的字段 | PUT /users 只传 nickname,查库确认其他字段没被冲掉 |
| 不带 token 返回 401 | 去掉 Authorization 请求头请求任意接口,状态码 401 且不进 Controller |
| 业务异常返回统一结构 | 传一张风景照做头像,响应体是 {"errCode":"000003","errMessage":"图片非人像,请重新上传!"} 而不是堆栈 |
| ThreadLocal 无残留 | 在 afterCompletion 打日志确认每次请求结束都执行了 remove |
练习建议
四个可以直接动手的改造任务,每个都给验收标准。
任务一:修掉更新接口的白名单漏洞。 UsersControler.updateUserInfo() 现在 @RequestBody UserInfo 直接收实体,实测请求体里带上 avatar 字段就能绕过人脸校验改头像。改法:新建 UserInfoUpdateDto,只声明 nickname、birthday、gender、city、income、education、profession、marriage 这些接口文档允许的字段,Controller 收 DTO 再转实体。验证:用 Postman 传 {"avatar":"https://evil.com/x.jpg"},查库确认 avatar 没变。
任务二:补上自动填充。 工程 UserInfo 是直接 implements Serializable,没继承 BasePojo,所以 created / updated 全靠数据库默认值,updated 在更新时不会自动刷新。改法:让 UserInfo extends BasePojo,配合 MyBatis-Plus 的 MetaObjectHandler 做插入/更新自动填充。验证:改一次昵称,查库确认 updated 变了。注意权衡:继承后这两个字段参与序列化,确认 Dubbo 传输没受影响。
任务三:异常状态码分流。 ExceptionAdvice 现在把业务异常也映射成 500,前端没法区分「用户的错」和「服务器的错」。改法:业务异常返回 200 携带 errCode 或 400 段状态码,系统异常保持 500------具体跟前端约定好即可,关键是别让两类异常共用一个状态码。验证:风景照头像返回 4xx/200+错误码,人为制造一个 NPE 返回 500。
任务四:错误码常量化。 ErrorResult 里 "999999"、"000003" 全是魔法数字字符串散落在静态方法里。改法:定义 ErrorCode 枚举,收编 code 和默认 message,ErrorResult.error() 等静态方法改从枚举构造。验证:全局搜索确认不再有裸的错误码字符串。
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 「ThreadLocal 存的数据会随请求结束自动清理」 | 不会。清理靠你在 afterCompletion 手动 remove,Tomcat 线程池复用线程,不清理就串号 |
「error_code == 0 表示图片合规」 |
只表示「接口调用成功、检测到了人脸」,双人合照也返回 0;合规判定要看 face_num 等业务字段 |
| 「实体直接当请求参数最方便」 | 实体字段集是数据库视角,不是接口契约。直收实体的接口,攻击者多传一个字段就多一个攻击面 |
「Serializable 加在哪都行」 |
只有真正过 Dubbo 网络传输的对象(实体)必须要;VO 只在本进程内序列化给前端,加不加都不影响功能 |
「@RestControllerAdvice 能接住所有异常」 |
只能接住 DispatcherServlet 分发之后(Controller 及视图渲染阶段)的异常,拦截器、过滤器阶段的不归它管 |
「updateById 传 null 会把字段清空」 |
恰好相反,动态 SQL 会跳过 null 字段,「改不动 null」;真要清空得用 UpdateWrapper |
| 「封装 Template 是过度设计」 | Template 隔离的是第三方 SDK 的初始化和协议细节。直接在 Service 里 new AipFace,换厂商时你要改所有业务类;用 Template 只改一处 |
后续预告
接下来是 MongoDB 基础与圈子功能。本节的产物会直接被复用:第三方接入五步法照搬到 MongoDB 的配置管理上(届时用 @ConfigurationProperties 封装连接参数的思路一致),圈子的所有对外接口继续走 VO + UserHolder.getUserId() 取当前用户,发布动态的校验失败继续 throw new BusinessException(...)。把本节的自检清单过完再往前走,成本最低。
总结
- 我们的主线是「组件 → 链路 → 横切」:OSS 和百度人脸是砖,资料四接口是墙,统一鉴权和统一异常是水电改造。
- 五步法是含金量最高的套路,OSS、百度人脸验证了两次,内容审核会是第三次。
- 坑清单里最值得动手的是更新接口的 avatar 绕过漏洞------白名单必须落在代码里,文档防不了攻击者。
- 四个练习任务都来自工程里真实存在的代码,改完拿自检清单回归一遍。