背景

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
2
3
4
5
6
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
String expected = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "未知";
return Result.error(ErrorCode.PARAM_ERROR.getCode(),
"参数「" + e.getName() + "」格式不正确,应为" + typeName(expected), requestId());
}

requestId 来自 RequestIdFilter 注入 MDC 的 UUID,每个请求从进入 Nginx 到返回响应全程携带。

为什么这样做

培训班项目运行在 localhost,只有你自己用。”系统错误”四个字够了——你知道自己刚做了什么操作。

一旦部署到服务器,用户不是你。他们无法用”我刚刚 JSON 少了个逗号”来描述问题。错误信息是用户反馈的唯一线索,含糊不得。

参考:Spring Boot Exception Handling Best Practices


二、统一权限校验 — 从手动 if 到声明式 @PreAuthorize

改之前

13 个 Controller 各自为战:

1
2
3
4
5
// 有的手动写了
if (!ci.getTeacherId().equals(userId)) return Result.error(403, "无权访问");

// 有的完全不写(DashboardController 的 10 个方法全部漏掉)
// 有的写了但写错了

后果:老师 A 把 URL 里的 classId 改成老师 B 的,就能看到老师 B 班级的所有数据——成绩、作业提交、学生列表。

改之后

统一使用 @PreAuthorize + 自定义 OwnershipGuard

1
2
3
4
5
6
// 一行注解替代手写 if
@PreAuthorize("@sec.isClassOwner(#classId)")
public Result<?> getDashboard(@RequestParam Long classId) { ... }

@PreAuthorize("@sec.isSubmissionOwner(#id)")
public Result<?> getContent(@PathVariable Long id) { ... }

OwnershipGuard 是一个 Spring Bean,从 SecurityContext 取当前用户,查数据库校验归属关系。支持 9 种资源类型:班级、课程、任务、文档、目录、知识库、提交记录、知识点、token。

1
2
3
4
5
6
7
8
@Component("sec")
public class OwnershipGuard {
public boolean isClassOwner(Long classId) {
Long userId = getCurrentUserId(); // 从 SecurityContext 取
ClassInfo ci = classInfoMapper.selectById(classId);
return userId.equals(ci.getTeacherId());
}
}

配合 @EnableMethodSecurityAccessDeniedException 全局处理器,越权访问统一返回 403。

为什么不用手写 if

  1. 人总是漏 —— DashboardController 的 10 个方法就是例子,写了两年没人发现没校验
  2. 编译器帮你记 —— 注解是声明式的,读代码的人一眼知道这里做了权限检查
  3. 统一的错误处理 —— 所有 @PreAuthorize 失败走同一个 403 handler

参考:Spring Security @PreAuthorize with resource ownership


改之前

1
2
3
// 登录 → 生成 24 小时 JWT
// 退出 → return Result.success(null); // 什么都不做
// Token 存 localStorage → XSS 直接偷

三个问题:

  1. 无刷新机制:token 过期必须重新输密码登录
  2. 无法吊销:token 发出去了,24 小时内改了密码也没用,攻击者照样能用
  3. localStorage 存 token:前端一旦被 XSS 注入,localStorage.getItem('token') 一行代码带走

改之后

刷新令牌体系:

1
2
3
登录 → access_token(JWT, 24h) + refresh_token(UUID, 7天, Redis存储)
access_token 过期 → POST /api/auth/refresh → 用 refresh_token 换新的
refresh_token 单次使用 → 换取成功后旧 token 立即删除,防重放

退出吊销:

1
2
3
4
POST /api/auth/logout
→ Token 加入 Redis 黑名单(SHA-256 hash,TTL = token 剩余有效期)
→ 清除 HttpOnly Cookie
→ JwtAuthenticationFilter 每次校验时检查黑名单

HttpOnly Cookie:

1
2
登录响应 → Set-Cookie: edumind_token=xxx; HttpOnly; SameSite=Lax; Path=/
JWT 过滤器 → 优先读 Authorization header,回退读 Cookie

Cookie 设置了 HttpOnly,JavaScript 完全无法访问,XSS 偷不走。

关键代码

1
2
3
4
5
6
7
8
9
10
11
12
// TokenService.java — Redis 刷新令牌 + 黑名单
public String createRefreshToken(Long userId) {
String token = UUID.randomUUID().toString().replace("-", "");
RBucket<Long> bucket = redisson.getBucket("auth:refresh:" + token);
bucket.set(userId, Duration.ofDays(7));
return token;
}

public void blacklist(String token, long remainingSeconds) {
String hash = sha256(token);
redisson.getBucket("auth:blacklist:" + hash).set("revoked", Duration.ofSeconds(remainingSeconds));
}

为什么需要这些

JWT 是无状态的——这是它的优点,也是它的致命弱点。无状态意味着服务器无法主动让它失效。刷新令牌 + 黑名单 本质是在无状态的 JWT 外面包了一层有状态的管理层,用 Redis 的 TTL 自动清理过期数据。

参考:OAuth 2.0 Refresh Token Pattern


四、PII 加密 — 数据库泄露时最后一道防线

改之前

1
2
SELECT username, phone, email FROM sys_user;
-- teacher1 | 13812345678 | teacher@qq.com

全是明文。一旦数据库被拖库,所有用户的手机号、邮箱、真实姓名全部暴露。

改之后

存储层:MyBatis-Plus TypeHandler 自动加解密

1
2
3
4
5
6
7
8
@TableName(value = "sys_user", autoResultMap = true)  // ⚠️ 必须开启
public class User {
@TableField(typeHandler = AESEncryptHandler.class) // 写入自动加密、读出自动解密
private String phone;

@TableField(typeHandler = AESEncryptHandler.class)
private String email;
}

存入:13812345678 → 数据库变成 a8Gk2xR...Base64密文
读出:自动解密回 13812345678,业务代码完全无感

加密算法:AES-256-GCM(认证加密模式,自带防篡改校验),密钥从环境变量注入。

日志层:logback %replace 掩码

1
2
3
<!-- 手机号:138****5678,邮箱:te***@qq.com -->
%replace(%replace(%msg){'(1[3-9]\d)\d{4}(\d{4})','$1****$2'})
{'([\w\-.]{2})[\w\-.]*(@[\w]+\.[\w]+)','$1***$2'}

兼容性:已有数据库里的明文数据,解密失败时直接返回原值不崩溃,灰度迁移。

为什么用 TypeHandler 而不是 Service 层手动加密

  1. 对业务透明:Service 层代码一行不用改,像以前一样 user.getPhone() 拿到的就是明文
  2. 不会遗漏:所有 MyBatis-Plus 自动生成的 CRUD 都走 TypeHandler
  3. 缺点:自定义 SQL(@Select、XML mapper)不走 TypeHandler,需要手动加密查询条件

参考:MyBatis-Plus TypeHandler 字段加密最佳实践


五、@Timed 业务指标 — 从盲人变明眼

改之前

只有 1 个指标——RAG 检索耗时。AI 对话、批改、文件上传、向量化全是盲区。

学生反馈”批改很慢” → 你看不到批改的平均耗时、p99、失败率,只能猜。

改之后

6 个业务指标,覆盖所有核心路径:

1
2
3
4
5
@Timed(value = "openclaw.chat", histogram = true)    // AI 对话
@Timed(value = "openclaw.stream", histogram = true) // AI 流式对话
@Timed(value = "embedding.document", histogram = true) // Embedding 推理
@Timed(value = "file.upload", histogram = true) // 文件上传
@Timed(value = "rag.search", histogram = true) // RAG 检索(已有)

@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 个测试,覆盖到之前完全没测的 SubmissionControllerCourseServiceImplSharedKbServiceImpl

1
2
3
4
SubmissionControllerTest      3 个 — 正常读取、404、文件读取失败
CourseServiceImplTest 10 个 — 创建、缓存命中/未命中、更新/删除权限校验
SharedKbServiceImplTest 12 个 — 创建/删除/加入/退出/邀请过期/次数上限/角色变更
AuthIntegrationTest 5 个 — 注册→登录全链路、刷新令牌、黑名单(集成测试)

集成测试继承 BaseIntegrationTest,自动适配 CI/Docker/外部数据库三种环境,默认跳过(@Tag("integration")),CI 中通过 -Dgroups=integration 激活。

关键测试示例

1
2
3
4
5
6
7
8
9
10
@Test
@DisplayName("非创建者删除知识库 → 抛异常,且不执行 delete")
void shouldThrowWhenNotOwner() {
when(sharedKbMapper.selectById(1L)).thenReturn(sample);

assertThatThrownBy(() -> service.delete(999L, 1L))
.isInstanceOf(IllegalArgumentException.class);
verify(sharedKbMapper, never()).deleteById(anyLong());
verify(memberMapper, never()).delete(any());
}

这个测试验证了 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 一键部署

改了什么

  1. nginx.confnginx.conf.template:支持 ${DOMAIN} 变量,新增 443 端口 SSL 配置、HTTP→HTTPS 重定向、OCSP Stapling
  2. docker-compose.yml:Nginx 开 443 端口,新增 certbot 容器(自动续期),envsubst 命令启动时替换域名模板
  3. scripts/setup-ssl.sh:一键申请 Let’s Encrypt 证书
  4. SecurityConfig.java:CORS 来源改为从配置读取,生产环境填 HTTPS 域名

使用方式

1
2
3
4
5
6
7
8
# 1. .env 里配置域名和邮箱
DOMAIN=edumind.example.com
CERTBOT_EMAIL=admin@example.com

# 2. DNS 指向服务器后,跑一次
./scripts/setup-ssl.sh

# 3. 以后全自动 — certbot 每 12 小时检查续期

本地开发不受影响——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 对明文数据做降级处理,不需要一次性迁移全部数据。


参考链接