上一篇《测试体系与代码维护》收尾时立了个 flag——测试体系搭好了,"下周开始让它真正活起来"。这一篇就来兑现这句话。

接手的两个模块——后端 course-ai(Spring Boot 3.4 + MyBatis-Plus + Spring AI)和前端 eduagent-front(uniapp H5)——拿到时的状态其实不太体面:编译不过、依赖配置缺项、前后端 API 路径完全对不上,聊天 / 画像 / 错题这些关键链路全是 mock。所以"活起来"得从最底层做起:先让它能编译,再让前后端能真说上话,最后接上真实大模型、RAG 检索和多 Agent 调度。

本期一共落地 7 块新功能,并顺手清掉一批阻塞性 bug。先上个总览:

板块 交付物 状态
编译 / 启动 195 个 Java 文件全量编译通过 + docker-compose 起 4 个依赖 ✅ 已交付
登录流程 登录前置 + 注册 Tab + 画像感知跳转 ✅ 已交付
密码重置 邮箱链接 + 短信验证码 + 管理员,三选一 ✅ 已交付
智能对话 真接 DashScope Qwen,SSE 流式 ✅ 已交付
后端 endpoint 新增 14 个,废 9 个前端 stub ✅ 已交付
Agent 调度 10 意图分类 + 短路 + 路由到子 agent ✅ 已交付
RAG 检索 pgvector + DashScope 直连 embedding + 爬虫数据导入 ✅ 已交付

一、让 195 个 Java 文件先能编译

"活起来"的第一步是"能编译"。第一次 mvn compile 直接报出 130+ 个错误,散落在 service / config / controller / mapper / entity 各处。Windows CMD 下错误信息是乱码,但路径和行号还算清晰,顺着排就行。

1.1 根因分析(5 类)

把 130+ 个错误归类,其实只有 5 个根因:

类型 具体表现 根因
Lombok 失效 @Data / @Slf4j / @RequiredArgsConstructor 生成的 setter/getter/log/构造器全找不到 pom.xml 硬编码 Lombok 1.18.22,与 JDK 17 + Spring Boot 3.4 不兼容
缺失包 8 个 service 文件 import com.course.ai.exception.BusinessException; 不存在 该包从未创建
DTO 注释 SubmitAnswerRequest 全文件被 // 注释 历史遗留
Bean 重复 + 参数漂移 StudyPlanAIConfig 用 3 参构造 AiStudyPlanGenerator(实际 5 个 final 字段),同时与 @Service 重复注册 后期给 AiStudyPlanGenerator 加了依赖但没同步 Config
方法缺失 MinioService.uploadBytes(byte[], String, String) 不存在 同包内 MinioServicePatch.java 只是注释里的"待迁入"说明,没真迁

熟悉的 Lombok ↔ JDK 不匹配又来了——和上一篇 L1 编译期撞上的 JCTree$JCImport.qualid 是同一类问题,只是这次表现成"找不到符号"。

1.2 修复策略

先解决最值钱的那个:把 Lombok 的硬编码版本拿掉,让 spring-boot-parent 3.4 统一管理。

<!-- pom.xml 关键改动 -->
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <!-- 移除版本,由 spring-boot-parent 3.4 管理为 1.18.36 -->
</dependency>

移除版本固定后,Lombok 升到 SB 管理版,50+ 条"找不到符号"瞬间消失。剩下 5 处真错误再各个击破:新建 BusinessException、恢复 SubmitAnswerRequest(含 qId + userAnswer)、删 StudyPlanAIConfig 里两个 @Bean、按注释补上 uploadBytes 方法。

经验是:编译期问题一定先解决再谈别的。Lombok 失效让 50+ 个表面错误全是假阳性,先把版本升上去,剩下的真错误自然就剥离出来了。

1.3 验证

[INFO] Compiling 195 source files with javac [debug parameters release 17]
[INFO] BUILD SUCCESS
[INFO] Total time:  7.476 s

二、登录流程重构:先登录、再画像、再首页

编译过了,第一个改的业务流程是登录。原来 App 一启动就进 onboarding 问卷向导,这次把它改成"先登录",再根据画像是否建好决定去哪。

2.1 改造前 vs 改造后

改造前 改造后
App 启动落地页 onboarding(问卷向导) 登录页
注册 无独立入口 登录页顶部 Tab 切换
登录后跳转 固定跳首页 getProfile 判断画像 → 未建跳 onboarding,已建跳首页
记住 记住密码 记住手机号 / 邮箱

2.2 关键判定逻辑

判断"画像是否建好"的逻辑放在前端,只看两个信号——目标分数或在学课程,有任一即视为已建:

// login.vue
isProfileBuilt(profile) {
  if (!profile) return false
  const hasTarget  = profile.targetScore != null
  const hasCourses = Array.isArray(profile.currentCourses) && profile.currentCourses.length > 0
  return hasTarget || hasCourses
}

2.3 期间踩中两个隐藏 bug

坑一 · OPTIONS 预检 401 把后续请求挡掉

浏览器跨域且带 authorization 头时,会先发一个 OPTIONS 预检请求。LoginInterceptor 没排除 OPTIONS,预检直接返回 401,浏览器视为预检失败,真正的 GET / PUT 根本不发出。结果是后端日志一片空白,前端只见一个 toast "提交失败",极难定位。

// LoginInterceptor.preHandle 头部加上
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
    return true;
}

坑二 · /users/profile 永远返回登录时的旧快照

原实现直接 UserHolder.getUser() 返回 ThreadLocal 里的会话 DTO,导致 PUT 更新画像后再 GET,拿到的仍是 null 字段,前端 isProfileBuilt 永远判 false。改成按 userId 重新查 DB,并把 user 表字段(username / grade / major)补回去。

2.4 E2E 验证

# 新用户路径
GET /users/profile → {targetScore: null, currentCourses: []}     → 跳 onboarding ✓
# 已建画像路径
GET /users/profile → {targetScore: 90, currentCourses: [1,2,3]} → 跳 index ✓

三、密码重置三件套:管理员 / 邮箱 / 短信

原来的"忘记密码"是个 toast 占位。这次落地 3 条互不影响的独立通路,由前端 ActionSheet 让用户自己选。

3.1 后端架构

设计上把"发"这件事抽成接口,默认实现先走零外部依赖的日志版,将来换真实通道只需加一个 @Primary 实现热替换。

service/
├── EmailService           // 接口
│   └── impl/LogEmailService   // 默认实现:邮件正文打到后端日志,零外部依赖
├── SmsService             // 接口
│   └── impl/LogSmsService     // 默认实现:同上
└── impl/PasswordResetServiceImpl  // 核心:token / code 生成 + Redis 存 + 改密

controller/UserController(新增 4 端点)
  POST /users/password/forgot/email     // 邮箱发链接
  POST /users/password/forgot/sms       // 短信发码
  POST /users/password/reset/token      // token 改密
  POST /users/password/reset/sms        // 验证码改密

3.2 安全约束

密码重置最容易踩安全的坑,几条约束一开始就钉死:

约束 实现
不暴露账号是否存在 不论邮箱 / 手机号是否注册过,一律返回"若已注册,已发送"
一次性消费 Redis 验证成功立即 delete key
防短信轰炸 同手机号 60s 内只能发 1 次(setIfAbsent + TTL)
时效 邮件 token 15min;短信 code 5min
不在登录拦截器内 4 个 reset 路径加入 excludePathPatterns

3.3 邮件 / 短信怎么"发"

当前是零外部依赖的占位:把内容打到后端日志([MOCK EMAIL] / [MOCK SMS] 块),联调时人工去日志里复制链接 / code。接真实 SMTP 时,新建 SmtpEmailService implements EmailService 标上 @Primary 即可热替换,不用动其它任何代码

3.4 E2E 验证(11 个场景全过)

# 场景 结果
1–2 邮箱注册新用户 + 手机号 / 邮箱两种方式登录 都拿到 token
3–6 邮箱发链接 → token 改密 → 旧密码失效 → 新密码登录
7–8 短信发码 + 60s 内再发 第 2 次被冷却拦截
9–10 验证码改密 + 新密码登录
11 同验证码二次重放 提示"验证码已过期"

四、智能对话真接通 Qwen(SSE 流式)

后端 /api/ai/chat 早就用 Spring AI 接 DashScope 跑通了,但前端 chat.vue 的 callAI 还是 setTimeout 模拟逐字渲染一段 mock 字符串——两端从没真接上。这次把它们接通。

4.1 前端改造

前端用 fetch + ReadableStream 解 SSE,按 \n\n 切事件、按 event: / data: 解析,逐 token 推进 chatStore:

// chat.vue 用 fetch + ReadableStream 解 SSE
const resp = await fetch('http://localhost:8080/api/ai/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', authorization: token,
             'Accept': 'text/event-stream' },
  body: JSON.stringify({ prompt: message, sessionId, useRag: false })
})
const reader = resp.body.getReader()
const decoder = new TextDecoder('utf-8')
// 按 \n\n 切事件,按 event:/data: 解析,逐 token 推 chatStore

坑 · SSE 规范要剥离前导空格,但 Spring 的输出不能剥

接通后第一版输出变成了 HelloHello!Howcan...——词间空格全没了。用 od -c 直接看原始字节才确认:Spring 把 token 原样拼在 data: 后面,比如 data: there 里这个空格属于 token 本身(词间分隔符)。如果按 SSE 规范"strip one leading SPACE"去处理,就会把所有词间空格吃掉。

这条经验和上一篇排 SSE buffering 是一脉相承的:不要按规范读,要按字节读——Spring 这里就没按规范写。

// 对策:不剥离,原样取
if (raw.startsWith('data:')) dataLines.push(raw.substring(5))

4.2 验证

$ curl -sN -X POST http://localhost:8080/api/ai/chat ...
event:session
data:b92ceb69-7975-4cc2-a0e7-1626c201bc9f

event:delta
data:红
event:delta
data:黑
event:delta
data:树
event:delta
data:是一种
...
event:delta
data:度为 $O
event:delta
data:(\log n)$

五、补齐缺失的后端 endpoint(14 个)

前端有一堆 notImplemented 占位,对应的后端路径压根不存在。这次按模块一次性补齐 14 个真实 endpoint。

5.1 全景

模块 新增方法 + 路径
错题 PUT /api/mistakes/{id}/status(标记已掌握 / 待复习)<br>GET /api/mistakes/statistics
练习 POST /api/exercises/next(占位 → 真实)<br>GET /api/exercises/subjects · /subjects/{id}/topics · /records
课程 GET /api/courses
对话辅助 GET /api/ai/chat/recommend(基于薄弱点 + CS 通用题兜底)<br>POST /api/ai/chat/{sessionId}/cancel<br>DELETE /api/ai/chat/{sessionId}/history
首页 / 报告 / 个人 GET /api/home · /api/home/heatmap · /api/home/growth<br>GET /api/report?period= · /api/report/growth?days=<br>GET /users/statistics
通知(含新表) GET /api/notifications · /unread-count<br>PUT /api/notifications/{id}/read · /read-all<br>内部:NotificationService.push() 供其它服务塞通知

5.2 顺带修的两个老 bug

文件 问题 修复
QuestionMapper.selectExercises ORDER BY RAND() 是 MySQL 写法,PostgreSQL 报 "function rand() does not exist" RANDOM()
ExerciseServiceImpl.determineTargetDifficulty 返回 "BASIC/INTERMEDIATE/ADVANCED" 字符串,但 question.difficulty 是 Integer,永远查不到题 改成返回 "1"/"2"/"3",并兼容旧字符串输入

第二条和上一篇 L4 闭环里抓出的"key 用 kpName 还是 kpId"是同一类毛病——契约两端类型对不上,接口看着 200,数据却永远落不到点。

5.3 前端联动

同步把 api/index.js 里 9 个 notImplemented 占位换成真实路径。剩 3 个保留:refreshToken(后端用长 token,无需刷新)、generateProfile(注册时已建画像,无独立动作)、submitExercise(空数组守卫)。

六、Agent 智能调度系统

项目里已有多个独立 agent / service(TeachingAgentAiChatServiceMultimodalChatServiceMistakeDiagnosisAgentAiStudyPlanGenerator…),但全靠手动直接调用。这次在它们上面搭一层调度 agent,对用户原始输入做意图判别,再路由到正确的子 agent。

6.1 架构

POST /api/agent/dispatch
    |
    v
DispatcherAgent.classify()  // 1) 强信号短路 → 2) LLM 分类 → 3) 兜底
    |
    v
AgentOrchestrator.dispatch()
    |
    v
┌──── TeachingAgent.{generateExercises | gradeAnswer | recommendResources
│                  | getLearningProgress | generateLearningSuggestion
│                  | generateTargetedPractice}
├──── AiChatService            // CHAT(同步聚合 + 单次返回)
├──── MultimodalChatService    // MULTIMODAL_RECOGNITION
├──── MistakeDiagnosisAgent    // MISTAKE_DIAGNOSIS
└──── AiStudyPlanGenerator     // STUDY_PLAN_GENERATE

6.2 意图分类策略

为了不让每次请求都白白多花一次 LLM 调用,分类做成三段式——强信号先短路,命不中再交给 LLM,再不行兜底走 CHAT:

阶段 逻辑 延迟
① 短路 请求里带 hintIntent / attachmentUrls / extras.mistakeId → 直接命中对应意图,跳过 LLM ~0ms
② LLM 分类 把 10 种意图描述塞进 prompt,让 Qwen 输出 {intent, confidence, reasoning, extractedParams} ~800ms
③ 兜底 LLM 解析失败或意图未识别 → 默认 CHAT

6.3 实测分类(中英文混合,8 条 case 全过)

输入 命中意图 抽出的参数
"give me 3 medium dynamic programming questions" EXERCISE_GENERATE {count:3, difficulty:"2", knowledgePoint:"dynamic programming"}
"我下一步该学什么?" LEARNING_SUGGESTION {}
"帮我分析下我学得怎么样了" LEARNING_PROGRESS {}
"给我推荐些关于二叉树的学习资料" RESOURCE_RECOMMEND {knowledgePoint:"二叉树"}
"用一句话解释什么是哈希表" CHAT {}
{extras:{mistakeId:12}}(无 prompt) MISTAKE_DIAGNOSIS(短路命中) {mistakeId:12}
{attachmentUrls:[".png"]} MULTIMODAL_RECOGNITION(短路命中) {}
{hintIntent:EXERCISE_GENERATE, extras:{kpId:1,…}} EXERCISE_GENERATE(短路命中) 原样透传

6.4 端到端

/api/agent/dispatch 能直接拿到子 agent 的真实返回。比如 CHAT 路径,Qwen 同步返回"哈希表是一种通过哈希函数将键映射到值的数据结构…";EXERCISE_GENERATE 路径,真从题库里抽出 2 道 Java 语法基础题。

七、RAG 检索体系

最后一块,也是最重的一块——把爬来的学习资料接成可检索的知识库,让 AI 回答时能"引经据典"。

7.1 数据源

data_crawler/crawl/cleaned/ 下有 13 个 JSON 文件,已切好 chunks,合计约 18,908 条:

学科 chunks
数据结构(csdn + github) 6,042
算法(csdn + github) 2,832
数据库(csdn + github) 2,147
计算机网络(csdn + github) 1,897
操作系统(csdn + github) 2,402
高等数学 / 线性代数 3,393
面试题 195

7.2 技术栈

  • 向量库:PostgreSQL + pgvector 扩展(pgvector/pgvector:pg16 镜像)
  • 距离:欧氏距离 <-> 运算符,ORDER BY embedding <-> #{q}::vector
  • Embedding:DashScope text-embedding-v3,1024 维
  • 相似度筛选:先取 topK × 3 召回,再按 subject → course_id → kp.course_id 过滤

7.3 三个非显然的坑

坑一 · 维度错位

表原本是 vector(768),但配置的 v3 模型实际输出 1024 维。直接 ALTER TYPE pgvector 不支持,只能趁表里 0 行的窗口期 DROP COLUMN + ADD COLUMN 重建。

坑二 · Spring AI 解不开 DashScope 的响应

OpenAiApi$EmbeddingList 没标 @JsonIgnoreProperties(ignoreUnknown=true),而 DashScope 的返回多了一个顶层 id 字段,直接触发 UnrecognizedPropertyException。改不了 Spring AI 内部的反序列化器,干脆自己写一个 HTTP 客户端绕开:DashScopeEmbeddingClient

// DashScopeEmbeddingClient:100 行内的自包含直连
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class EmbeddingApiResponse {
    public String object, model, id;       // 多塞的 id 留着
    public List<EmbeddingItem> data;
    public Map<String, Object> usage;
}

这条的经验是:三方 SDK 的兼容性问题,绕过比对抗便宜。自己写 100 行 HTTP 客户端,比去改 Spring AI 内部转换器经济得多。

坑三 · batch 上限不是 25 而是 10

text-embedding-v3 的文档没明确写批量上限,实测超过 10 条就返回 InvalidParameter: batch size is invalid, it should not be larger than 10。把 BATCH_SIZE 从 25 改成 10 后稳定通过。

7.4 导入器设计

导入器最重要的素养是幂等——重跑不能产生副作用:

能力 实现
幂等 chunk_idvector_embedding.source_id 并建唯一索引;重跑会 skip 已导入
自动 anchor 按文件名解析学科 → 自动建 course + 对应 anchor knowledge_point
批量 embed 10 条 / 批,单批失败不影响整体
统计 每文件返回 {total, ok, skipped, failed}

7.5 触发方式

# 单文件
POST /api/admin/rag/import-file?path=F:/main/data_crawler/crawl/cleaned/github_database.json

# 目录(filter 可选)
POST /api/admin/rag/import-dir?dir=F:/main/data_crawler/crawl/cleaned&filter=github

7.6 验证:从导入到回答

# 1) 导入 github_database.json(150 chunks)
{"file":".../github_database.json","subject":"数据库","source":"github",
 "anchorKpId":21,"total":150,"skipped":0,"ok":150,"failed":0}

# 2) 提问
POST /api/ai/chat {"prompt":"数据库事务的 ACID 分别指什么?","subject":"数据库","useRag":true}

# 3) SSE 响应里看到
event:references
data:[{"vecId":127,"content":"## 3.事务\n\n**事务**是...具有4个特性:**原子性**、**一致性**、**隔离性**、**持续性**。简称为**ACID**特性","score":1.0},
     {"vecId":3,"content":"## 概念\n\n事务指的是满足 ACID 特性...","score":0.67},
     {"vecId":5,"content":"### 4. 持久性(Durability)\n\n一旦事务提交...","score":0.33}]

event:delta
data:好的!以下是关于数据库事务的 ACID 特性的详细解释,结合了参考资料内容...

AI 开头明说"结合了参考资料",说明 PromptBuilder 已经把召回片段注入了 Prompt——RAG 真闭环了

八、几条复盘经验

  • 编译期问题先解决,再谈别的。Lombok 失效让 50+ 个表面错误全是假阳,先升版本,剩下的真错误自然剥离出来。
  • 不要按规范读,要按字节读。SSE 的 data: 行规范要剥前导空格,但 Spring 不按规范写——od -c 直接看字节才确认空格的真实归属。
  • 三方 SDK 兼容性问题,绕过比对抗便宜。Spring AI 解不开 DashScope 响应,自己写 100 行 HTTP 客户端,比改它内部转换器划算。
  • 幂等是导入器的基本素养source_id + 唯一索引,让重跑变成无副作用操作。
  • OPTIONS 预检 401 的陷阱在浏览器跨域里相当常见,写拦截器时第一时间放过 OPTIONS。

九、阶段成果数据自检

指标 目标 本期实际
L1 编译通过 100% 100% ✅(升级 Lombok 后)
编译错误清零 130+ → 0 0 ✅
新增 / 改写后端文件 30+
新增 endpoint 14 14 ✅
Agent 意图分类 case 全过 8 / 8 ✅
密码重置 E2E 全过 11 / 11 ✅
RAG 闭环 跑通 ✅ 已闭环
RAG 已导入向量 试水 150 / 18,908(0.8%)
E2E 验证场景 40+ 条 curl,全 PASS ✅

十、下一步

系统"活起来"这件事本期已经办到——能编译、能登录、能对话、能检索、能调度。下一阶段把几处临时方案做成工程化:

当前 下一步
密码重置邮件 / 短信 打到后端日志 接 SMTP(QQ / Gmail)+ 阿里云 SMS
Agent cancel 占位 ok 维护 Map<sessionId, Disposable>,真断流
Schema 变更 手动 docker exec psql 整理成 V2__add_email_and_rag.sql,重新启用 Flyway
API key 硬编码 yml ${OPENAI_API_KEY} 环境变量
RAG 全量 已导入 150 / ~18,908 分批:先 csdn_data_structure(4940)+ github_offer(195)覆盖最高频科目
RAG 召回质量 纯向量召回 加入 BM25 关键词召回 + RRF 融合
权限 AdminController 任何登录用户可触发 @PreAuthorize("hasRole('ADMIN')")

至此,"计科智伴"从"编译都过不去"走到了"接通真实大模型 + RAG + 多 Agent 调度"。系统真的活起来了,下一篇就让它跑得更稳、更全。

本文聚焦本期新增功能与关键技术决策,详细 commit / 修复列表可参见仓库 git log。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐