EduMind 生产级加固实录 — 从培训班项目到可上线水平
背景
EduMind 是一个 AI 驱动的智能教学助手,技术栈是 Spring Boot 4 + Vue 3 + PostgreSQL + Redis。经过多轮功能迭代后,代码规模达到 170 个 Java 源文件、15 个 Vue 组件。
功能是完善的——但离”能正经上线”还有一段距离。最近做了一次全面的生产就绪度审计,从 6 个维度逐项定位差距,然后一次性补齐。
审计的 6 个维度:安全(C+)、可靠性(B)、CI/CD(C+)、可观测性(B-)、数据(D)、测试(D+)。总体评分 C+。
以下按改动的重要程度逐一记录。
一、全局异常处理 — 从用户看不懂到一眼定位
改之前
只有 5 个异常处理器。大部分异常落到兜底 catch(Exception) 返回:
1 | {"code": 500, "message": "系统错误"} |
实际效果:
- JSON 少个引号 → 500 “系统错误”
- 缺少必填参数 → 500 “系统错误”
- 文件超过 50MB → 500 “系统错误”
?classId=abc(应为数字) → 500 “系统错误”- NPE → 500 “系统错误”,无法反馈
用户反馈问题时只能说”报错了”,你问他什么操作、什么参数、请求 ID——全不知道。
改之后
13 个异常处理器,每种异常返回用户能看懂的中文消息 + requestId:
| 异常 | 返回码 | 消息示例 |
|---|---|---|
| JSON 格式错误 | 400 | 请求体格式错误 — 请检查 JSON 格式(逗号、引号、括号是否配对) |
| 缺少参数 | 400 | 缺少必填参数「classId」 |
| 类型转换失败 | 400 | 参数「classId」格式不正确,应为整数 |
| 校验失败 | 400 | 参数校验失败 — username 长度需要在2和50之间 |
| 文件超大 | 413 | 文件大小超出限制,最大支持 50 MB |
| NPE/500 | 500 | 服务器内部错误,请联系管理员并提供以下请求ID |
每个错误响应携带 requestId(UUID),前端无须解析 JSON 就能从响应头拿到。运维用 grep requestId /root/logs/edumind.log 秒定位。
关键代码
1 |
|
requestId 来自 RequestIdFilter 注入 MDC 的 UUID,每个请求从进入 Nginx 到返回响应全程携带。
为什么这样做
培训班项目运行在 localhost,只有你自己用。”系统错误”四个字够了——你知道自己刚做了什么操作。
一旦部署到服务器,用户不是你。他们无法用”我刚刚 JSON 少了个逗号”来描述问题。错误信息是用户反馈的唯一线索,含糊不得。
参考:Spring Boot Exception Handling Best Practices
二、统一权限校验 — 从手动 if 到声明式 @PreAuthorize
改之前
13 个 Controller 各自为战:
1 | // 有的手动写了 |
后果:老师 A 把 URL 里的 classId 改成老师 B 的,就能看到老师 B 班级的所有数据——成绩、作业提交、学生列表。
改之后
统一使用 @PreAuthorize + 自定义 OwnershipGuard:
1 | // 一行注解替代手写 if |
OwnershipGuard 是一个 Spring Bean,从 SecurityContext 取当前用户,查数据库校验归属关系。支持 9 种资源类型:班级、课程、任务、文档、目录、知识库、提交记录、知识点、token。
1 |
|
配合 @EnableMethodSecurity 和 AccessDeniedException 全局处理器,越权访问统一返回 403。
为什么不用手写 if
- 人总是漏 —— DashboardController 的 10 个方法就是例子,写了两年没人发现没校验
- 编译器帮你记 —— 注解是声明式的,读代码的人一眼知道这里做了权限检查
- 统一的错误处理 —— 所有
@PreAuthorize失败走同一个 403 handler
参考:Spring Security @PreAuthorize with resource ownership
三、JWT 三板斧 — 刷新、吊销、HttpOnly Cookie
改之前
1 | // 登录 → 生成 24 小时 JWT |
三个问题:
- 无刷新机制:token 过期必须重新输密码登录
- 无法吊销:token 发出去了,24 小时内改了密码也没用,攻击者照样能用
- localStorage 存 token:前端一旦被 XSS 注入,
localStorage.getItem('token')一行代码带走
改之后
刷新令牌体系:
1 | 登录 → access_token(JWT, 24h) + refresh_token(UUID, 7天, Redis存储) |
退出吊销:
1 | POST /api/auth/logout |
HttpOnly Cookie:
1 | 登录响应 → Set-Cookie: edumind_token=xxx; HttpOnly; SameSite=Lax; Path=/ |
Cookie 设置了 HttpOnly,JavaScript 完全无法访问,XSS 偷不走。
关键代码
1 | // TokenService.java — Redis 刷新令牌 + 黑名单 |
为什么需要这些
JWT 是无状态的——这是它的优点,也是它的致命弱点。无状态意味着服务器无法主动让它失效。刷新令牌 + 黑名单 本质是在无状态的 JWT 外面包了一层有状态的管理层,用 Redis 的 TTL 自动清理过期数据。
参考:OAuth 2.0 Refresh Token Pattern
四、PII 加密 — 数据库泄露时最后一道防线
改之前
1 | SELECT username, phone, email FROM sys_user; |
全是明文。一旦数据库被拖库,所有用户的手机号、邮箱、真实姓名全部暴露。
改之后
存储层:MyBatis-Plus TypeHandler 自动加解密
1 | // ⚠️ 必须开启 |
存入:13812345678 → 数据库变成 a8Gk2xR...Base64密文
读出:自动解密回 13812345678,业务代码完全无感
加密算法:AES-256-GCM(认证加密模式,自带防篡改校验),密钥从环境变量注入。
日志层:logback %replace 掩码
1 | <!-- 手机号:138****5678,邮箱:te***@qq.com --> |
兼容性:已有数据库里的明文数据,解密失败时直接返回原值不崩溃,灰度迁移。
为什么用 TypeHandler 而不是 Service 层手动加密
- 对业务透明:Service 层代码一行不用改,像以前一样
user.getPhone()拿到的就是明文 - 不会遗漏:所有 MyBatis-Plus 自动生成的 CRUD 都走 TypeHandler
- 缺点:自定义 SQL(
@Select、XML mapper)不走 TypeHandler,需要手动加密查询条件
参考:MyBatis-Plus TypeHandler 字段加密最佳实践
五、@Timed 业务指标 — 从盲人变明眼
改之前
只有 1 个指标——RAG 检索耗时。AI 对话、批改、文件上传、向量化全是盲区。
学生反馈”批改很慢” → 你看不到批改的平均耗时、p99、失败率,只能猜。
改之后
6 个业务指标,覆盖所有核心路径:
1 | // AI 对话 |
@Timed 通过 Spring AOP 自动记录方法耗时,暴露 _seconds_count / _seconds_sum / _seconds_max 到 Micrometer → Prometheus → Grafana。
配合 8 条 Prometheus 告警规则:应用宕机、高错误率、高延迟、RAG 慢、AI 对话慢、Embedding 慢、文件上传慢、JVM 堆高水位。
为什么只用注解不用手动编程
@Timed 的前提是注册 TimedAspect Bean,项目在 ObservabilityConfig 里已经注册好了。每加一个指标只需一行注解——比每次手动 Timer.Sample + sample.stop() 干净一个量级。
注意:@Timed 走 Spring AOP 代理,同一类内部自调用会绕过代理。如果 A 方法调 B 方法,B 的 @Timed 不生效。解决方案是把 @Timed 方法放到独立 Bean 中。
参考:Micrometer @Timed Best Practices
六、测试补全 — 从 12 个到 100 个
改之前
- 13 个 Controller:只测了 2 个
- 13 个 ServiceImpl:只测了 2 个
- 15 个 Vue 组件:0 个
- 0 个集成测试(
BaseIntegrationTest写好了但没人继承)
改之后
新增 25 个测试,覆盖到之前完全没测的 SubmissionController、CourseServiceImpl、SharedKbServiceImpl:
1 | SubmissionControllerTest 3 个 — 正常读取、404、文件读取失败 |
集成测试继承 BaseIntegrationTest,自动适配 CI/Docker/外部数据库三种环境,默认跳过(@Tag("integration")),CI 中通过 -Dgroups=integration 激活。
关键测试示例
1 |
|
这个测试验证了 rollbackFor = Exception.class 的核心价值:权限校验失败时,不仅抛异常,而且不执行任何数据操作。
七、质量门禁 — 让 CI 真正干活
JaCoCo 0% → 50%
之前 minimum=0.00,意味着你可以写 0 个测试,构建照样通过。改成 0.50 后,Service 和 Controller 层覆盖率低于 50% 直接构建失败。
OWASP Dependency-Check
扫描所有 Maven 依赖的已知 CVE 漏洞。CVSS ≥ 7.0(高危/严重)直接构建失败,阻止带着漏洞上线。
Dependabot
之前只有 GitHub Actions 做 CI/CD,没有自动依赖更新。创建 .github/dependabot.yml 后,每周一自动检查 Maven/npm/GitHub Actions 的依赖更新,有新版就开 PR。
移除 continue-on-error
前端 CI 的 TypeScript 类型检查和 ESLint 之前有 continue-on-error: true,意味着 54 个预存 lint 错误不会让 CI 变红。删除后,CI 真正成为质量门禁。
八、HTTPS 就绪 — Let’s Encrypt 一键部署
改了什么
nginx.conf→nginx.conf.template:支持${DOMAIN}变量,新增 443 端口 SSL 配置、HTTP→HTTPS 重定向、OCSP Staplingdocker-compose.yml:Nginx 开 443 端口,新增 certbot 容器(自动续期),envsubst命令启动时替换域名模板scripts/setup-ssl.sh:一键申请 Let’s Encrypt 证书SecurityConfig.java:CORS 来源改为从配置读取,生产环境填 HTTPS 域名
使用方式
1 | # 1. .env 里配置域名和邮箱 |
本地开发不受影响——DOMAIN=localhost 时 443 端口没有证书文件,Nginx 还是走 HTTP。
九、其他小改动
| 改动 | 说明 |
|---|---|
事务统一 rollbackFor = Exception.class |
31 个方法全部补齐,受检异常也回滚 |
| 注册限流 | @RateLimit 同 IP 60s 最多 3 次 |
| DTO 校验补全 | 6 个 DTO 补 @NotBlank/@NotNull/@Email |
| 提交幂等性 | Redis SET NX EX 10 + 前端 submitting 锁,防双击重复提交 |
| S3/Redis/DB 超时 | RAG 检索、Stream Consumer、分布式锁各层超时已配置 |
| Graceful Shutdown | 30 秒优雅停机 + @PreDestroy 清理消费者 |
| Gradle/Spotless 等 Java 格式化 | 未加(仍在待办) |
评分变化
| 维度 | 改之前 | 改之后 |
|---|---|---|
| 安全 | C+ | A- |
| 可靠性 | B | B+ |
| 可观测性 | B- | B+ |
| CI/CD | C+ | B |
| 数据 | D | B- |
| 测试 | D+ | B- |
| 总体 | C+ | B |
核心经验
1. 审计先行,数据驱动。 不是凭感觉改代码,而是 6 个维度、55 个检查点逐项审计,每个缺口都有文件路径和代码行号。
2. 改动最小化。 每个修改都遵循”让现有代码更安全/可观测/可测试”,不重构业务逻辑。55 个文件、2314 行净增代码,零功能回归。
3. 工具链闭环。 Security + Metrics + Testing + CI 四条线同时加固,形成”写代码 → 测试通过 → 扫描安全 → 指标可视 → 部署有底”的完整链条。
4. 兼容旧数据。 PII 加密的 TypeHandler 对明文数据做降级处理,不需要一次性迁移全部数据。







