前言

前几天我干了一件事:对 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_countrag_search_seconds_sum。以后看到 Grafana 里 RAG 延迟曲线飙升,立刻知道是 Embedding 模型还是 pgvector 出了问题。

第二层:Logging(日志)—— 定位问题

纯文本日志在单机开发时够用,但稍微复杂一点的场景就废了。比如用户说”我刚提交的作业批改失败了”,你需要从几万行日志里找到属于那个请求的那几条。

解决办法是两个东西配合:

  • 结构化日志(JSON 格式):日志变成机器可解析的键值对,requestIduserIdlevel 都是独立字段,接入 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-Idupstream_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%),低于阈值直接构建失败
  • 输出逐条查询的得分明细,一眼看出知识库的盲区

什么没测

EmbeddingServiceRerankerService 没写测试——它们是 ONNX 模型的薄封装,测它等于测 ONNX Runtime,没意义。它们的正确性靠 Prometheus 的 rag_search_seconds 指标和评测脚本的召回率间接验证。


四、API 层统一:消灭裸 fetch

为什么修

项目里有一个封装好的 Axios 实例——自动拼 /api 前缀、自动带 Bearer Token、Token 过期自动跳登录页。但三个视图没用它,自己在用裸 fetch(),还硬编码了 http://localhost:8080/api

这不是 bug——本地开发时 8080 端口当然通。但它是技术债:哪天换了端口或改了 Token 逻辑,这三个页面静默挂掉,排查时你完全想不到是因为它们走了另一套请求方式。

思路:按复杂度分类处理

最简单的StudentSubmit.vueDashboard.vue)——直接全局替换 fetchrequest.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
2
3
4
5
可观测性:  Prometheus 端点 + Grafana 仪表盘 + JSON 结构化日志 + RequestId 追踪
测试: 23 个 RAG 管线测试 + 真实数据库评测脚本
API 层: 3 个视图统一 Axios,消灭硬编码 localhost
安全: DTO 校验 + 应用层安全头 + Actuator 放行
修复: Reranker 模型路径

这些改动没有改任何业务逻辑,但项目从”能跑”变成了”能放心跑”。

有两点体会值得记下来:

  1. 审计是起点,筛选才是关键。 不是每个问题都值得修。同一个问题在开发阶段和上线阶段,优先级完全不一样。审计清单不是 TODO list,它是决策材料。

  2. 可观测性不是成本,是杠杆。 花半天架好 Prometheus + Grafana + 结构化日志,后续每一次优化、每一次排错都在享受这个杠杆。它是你最值得投入的第一笔时间。


如果你也在做一个项目的生产级加固,不用一口气修完。挑 3-5 个当前阶段最痛的问题,修完就停,下次再修下一批。生产级状态不是一次冲刺的结果,是持续打磨的过程。