给 EduMind 项目做生产级加固:从审计到落地的完整复盘
前言
前几天我干了一件事:对 EduMind做了一次多维度生产级审计。结果说实话不太好——六个 Critical、二十多个 High。
但审计本身不是目的,修才是。今天花了一天时间,挑了开发阶段真正值得修的问题逐一落地。这篇文章记录每个决策的考量:为什么选中它、思路是什么、做到什么程度停手。
一、什么该修,什么不该修
审计报告里的问题不是每一条都值得立刻动手。我的筛选标准就一条:
这个问题在当前开发阶段会不会实际造成麻烦?
按照这个标准:
- 立刻修:可观测性(出问题没法排查)、核心逻辑没测试(改了不知道坏了)、API 层不统一(排查问题多一个变量)
- 顺便修:DTO 校验(加几个注解的事)、安全头(也是几行配置)
- 上线前修:JWT 密钥轮换、HTTPS、Redis 密码——这些本地用不上,但上线不能缺
- 不着急:Dashboard 改 Composition API、前端 i18n——纯代码清洁度问题
好,开工。
二、可观测性:从”盲飞”到”看见”
为什么第一个修它
审计时最让我震惊的不是安全问题,而是整个项目只有一行监控配置:
1 | management.endpoints.web.exposure.include=health |
没有指标、没有结构化日志、没有 Request ID。这意味着线上出了任何问题,你唯一的排查手段是 docker compose logs 然后在海量纯文本里大海捞针。
可观测性不是锦上添花,它是你未来每一次排查的加速器。 先把它架好,后面加什么功能都有底气。
思路:三根支柱,逐步到位
可观测性不是装一个工具就完了。我把它拆成三层,从最容易到最难:
第一层:Metrics(指标)—— 看趋势
Spring Boot Actuator 本身就能暴露指标,缺的只是一个采集器和面板。选 Prometheus + Grafana 是因为:
- Prometheus 是云原生标配,一个依赖、三行配置就搞定
- Grafana Dashboard 是 JSON 文件,可以 Git 版本化管理
最关键的决策其实是哪些指标值得暴露。Spring Boot 默认给了一大堆 JVM/HTTP 指标,但业务指标要自己埋。我给 RAG 检索加了 @Timed——一行注解,Prometheus 里就会多出 rag_search_seconds_count 和 rag_search_seconds_sum。以后看到 Grafana 里 RAG 延迟曲线飙升,立刻知道是 Embedding 模型还是 pgvector 出了问题。
第二层:Logging(日志)—— 定位问题
纯文本日志在单机开发时够用,但稍微复杂一点的场景就废了。比如用户说”我刚提交的作业批改失败了”,你需要从几万行日志里找到属于那个请求的那几条。
解决办法是两个东西配合:
- 结构化日志(JSON 格式):日志变成机器可解析的键值对,
requestId、userId、level都是独立字段,接入 Loki 或 ELK 后一个查询还原完整链路 - Request ID:一个
OncePerRequestFilter,每个 HTTP 请求分配唯一 ID,注入 MDC,返回给前端。用户报 bug 时截图里的X-Request-Id就是你在日志系统里的搜索关键词
这里有个容易踩的坑:dev 和 prod 的日志策略要分开。dev 用彩色控制台输出,人眼看;prod 用 JSON 格式输出到 stdout(容器环境由 Docker 收集)同时异步写入滚动文件,机器读。不要用一种格式同时服务两个场景。
第三层:Tracing(追踪)—— 串联链路
真正完整的分布式追踪需要 OpenTelemetry,但那是大项目的事。对单体应用,Request ID + MDC 已经足够——每个请求从进来到出去,所有日志共享同一个 ID。Nginx 那边也自定义了 log_format,把 X-Request-Id 和 upstream_response_time 打到 access log 里。这样不管从入口查还是从应用查,都能串联起来。
什么没做
- Alertmanager:告警规则写了 6 条,但通知的 Webhook 没配。本地开发不需要收告警,上线前五分钟配好就行
- Loki/ELK:日志采集需要额外容器,本地资源宝贵,先靠
docker compose logs和文件凑合
三、RAG 管线测试:测什么、不测什么
为什么修这个
RAG 是 EduMind 最核心的功能——学生提问,系统检索课件,返回相关知识点。这条管线改了任何一段代码都可能影响检索质量,但之前完全没有测试。
写测试最大的敌人不是时间,是复杂度。RAG 管线牵扯到:
- ONNX 嵌入模型(几个 GB 的二进制文件,CI 跑不了)
- pgvector 向量检索(需要 PostgreSQL + pgvector 扩展)
- LLM 查询改写(外部服务,不稳定)
如果想着”把整个管线串起来测”,你永远写不出第一个测试。
思路:分层,每层只测纯逻辑
第一层:纯算法测试(最快,最有价值)
RrfFusionService 是 RRF 多路检索融合——输入两个排好序的列表,输出一个融合后的列表。纯数学公式,没有任何外部依赖。这类测试优先写,因为:
- 跑得快(毫秒级)
- 不会因为外部环境挂掉
- 算法逻辑最容易出 bug(排序、去重、边界条件)
第二层:编排逻辑测试(Mock 掉重依赖)
RagService 是管线的编排者——控制什么时候做向量检索、什么时候做关键词检索、什么时候触发查询改写、什么时候重新融合。它依赖 6 个 Service,但每个都可以 Mock。
这里的关键技巧是:Mock 不是”假装一切正常”,而是要模拟异常场景。 我写了三类测试:
- 正常路径:两路都有结果,走完 RRF + Reranker
- 降级路径:LLM 改写出错 → 静默回退,不中断检索
- 兜底路径:top-1 结果置信度太低 → 触发改写 → 追加检索 → 重新融合
异常路径的测试价值往往比正常路径高,因为那是线上真正会出问题的地方。
第三层:评测脚本(验证检索质量)
单元测试只能测”功能对不对”,不能测”搜得准不准”。我用 rag-eval-dataset.json 里的 15 条 C 语言查询,连本地数据库跑完整管线,算出 Keyword Recall、Content Coverage、MRR 三个指标。
评测脚本的设计有几个考量:
- 默认不跑(用
@EnabledIfEnvironmentVariable控制),因为需要本地有 ONNX 模型 + 索引好的文档 - 设 CI 门槛(如 Keyword Recall ≥ 30%),低于阈值直接构建失败
- 输出逐条查询的得分明细,一眼看出知识库的盲区
什么没测
EmbeddingService 和 RerankerService 没写测试——它们是 ONNX 模型的薄封装,测它等于测 ONNX Runtime,没意义。它们的正确性靠 Prometheus 的 rag_search_seconds 指标和评测脚本的召回率间接验证。
四、API 层统一:消灭裸 fetch
为什么修
项目里有一个封装好的 Axios 实例——自动拼 /api 前缀、自动带 Bearer Token、Token 过期自动跳登录页。但三个视图没用它,自己在用裸 fetch(),还硬编码了 http://localhost:8080/api。
这不是 bug——本地开发时 8080 端口当然通。但它是技术债:哪天换了端口或改了 Token 逻辑,这三个页面静默挂掉,排查时你完全想不到是因为它们走了另一套请求方式。
思路:按复杂度分类处理
最简单的(StudentSubmit.vue、Dashboard.vue)——直接全局替换 fetch → request.get/post/delete。Dashboard 有 14 个请求,但因为模式都一样(GET 传 params、POST 传 body),批量替换就行。
最复杂的(AIChat.vue)——聊天功能用了 SSE(Server-Sent Events)流式响应。Axios 不支持 SSE,所以流式部分只能保留 fetch。但即使这样,也可以把 URL 从 http://localhost:8080/api/chat/stream 改成 /api/chat/stream,利用 Vite 的代理转发,不再硬编码端口。其余的非流式请求(健康检查、历史加载、文件上传)全部切换。
这里有个比较容易漏的点:Dashboard 是 Options API(export default {}),不支持 TypeScript 类型注解。替换时我把新增的 const params: Record<string, string> 这种类型标注全删了,只保留纯 JS。
五、顺手修的小东西
Auth DTO 校验
UserRegisterDTO 没有任何校验注解——空用户名、一位数密码直接入库。加 @NotBlank 和 @Size 就是两分钟的事。同时补了 MethodArgumentNotValidException 的全局异常处理,否则校验失败返回 500 而不是 400。
安全头
安全头之前只在 Nginx 层有,应用层没有。加了一层防御纵深:Spring Security 的 headers() 配置里加了 CSP、HSTS、XSS Protection。Nginx 那边也补了 CSP 和 Permissions-Policy。
Actuator 端点放行
开启 Prometheus 端点后,/actuator/health 和 /actuator/prometheus 被 Spring Security 拦截返回 401。SecurityConfig 的放行列表和 JwtAuthenticationFilter 的跳过列表是两套独立逻辑,两处都要同步加上 /actuator/**。
六、收工
一天下来做的事:
1 | 可观测性: Prometheus 端点 + Grafana 仪表盘 + JSON 结构化日志 + RequestId 追踪 |
这些改动没有改任何业务逻辑,但项目从”能跑”变成了”能放心跑”。
有两点体会值得记下来:
审计是起点,筛选才是关键。 不是每个问题都值得修。同一个问题在开发阶段和上线阶段,优先级完全不一样。审计清单不是 TODO list,它是决策材料。
可观测性不是成本,是杠杆。 花半天架好 Prometheus + Grafana + 结构化日志,后续每一次优化、每一次排错都在享受这个杠杆。它是你最值得投入的第一笔时间。
如果你也在做一个项目的生产级加固,不用一口气修完。挑 3-5 个当前阶段最痛的问题,修完就停,下次再修下一批。生产级状态不是一次冲刺的结果,是持续打磨的过程。







