MAP项目代码地图与方法说明
PROJECT CODE MAP更新时间 2026/10/05·8 条调用链逐段解释

英语学习平台:项目代码地图与方法说明#

目的:用于快速熟悉项目、面试前定位代码、现场按调用链讲解。
行号基准:本地主项目提交 48f175c90b4b8d2e63d0f93a497890ea788cf863,已按拆分后的 Agent 服务结构校对。
更新日期:2026-10-05。
注意:后续修改源码后,行号可能漂移;优先搜索本文列出的文件名、类名和方法名。

1. 阅读范围与最快阅读顺序#

本文覆盖项目的全部逻辑模块:Vue 前端、埋点 SDK、NestJS 业务服务、NestJS Agent 服务、共享基础设施、PostgreSQL 数据模型、FastAPI/Celery/Chroma 知识库服务、Docker 与反向代理。

以下内容不逐文件解释:node_modules、pnpm-lock.yaml、server-py/uv.lock、Prisma 自动生成客户端、OpenAPI 自动生成客户端、图片/CSS 等静态资源。它们不是面试时需要逐行讲解的业务代码。

建议按下面顺序阅读:

  1. README.md:1:先理解产品、组件和两条主调用链。
  2. server/prisma/schema.prisma:100:理解用户、学习、Agent、知识库的数据关系。
  3. apps/web/src/views/Chat/index.vue:308:理解前端如何驱动 Agent。
  4. server/apps/ai/src/agent/agent-runner.service.ts:63:理解 Agent 主编排;再顺着依赖读 agent-run-lifecycle.service.ts、agent-context.service.ts、agent-rag.service.ts、agent-tools.service.ts和 agent-usage.service.ts。
  5. server/apps/server/src/knowledge/knowledge.service.ts:99:理解知识库上传与业务状态。
  6. server-py/app/services/ingestion.py:82:理解异步解析与向量入库。
  7. server-py/app/vectorstore/chroma_store.py:432:理解私有向量检索。
  8. docker-compose.dev.yml:118、docker-compose.prod.yml:120:理解完整部署拓扑。

2. 一张图记住总体结构#

浏览器
  ├─ /api/v1  → NestJS Business :3000
  │              ├─ PostgreSQL:用户、课程、学习、知识库元数据
  │              ├─ MinIO:头像、课程资源、私人原始文档
  │              └─ FastAPI:创建/删除/核对索引
  │
  └─ /ai/v1   → NestJS Agent :3001
                 ├─ PostgreSQL:会话、消息、Run、Action、Checkpoint
                 ├─ 学习 Tool:读取/修改真实学习数据
                 └─ FastAPI:私人知识库检索

FastAPI RAG :8000
  ├─ Redis/Celery:异步任务、锁、nonce、Worker 心跳
  ├─ MinIO:读取原始 PDF/MD/TXT
  ├─ Embedding Provider:生成向量
  ├─ Chroma:保存与检索分片
  └─ HMAC 回调 Business:PROCESSING / READY / FAILED

3. 八条核心调用链(速查)#

3.1 登录与 Token 刷新#

  1. 前端表单:apps/web/src/components/Login/LoginForm.vue:72 的 handleLogin() 收集表单并调用登录 API。
  2. 前端 API:apps/web/src/apis/user/index.ts:15 的 login() 请求 /api/v1/user/login。
  3. 路由入口:server/apps/server/src/user/user.controller.ts:34 的 login() 转给 UserService。
  4. 登录逻辑:server/apps/server/src/user/user.service.ts:26 的 login() 查询用户、校验密码、更新最后登录时间并签发 Token。
  5. Token 生成:server/apps/server/src/auth/auth.service.ts:12 的 generateToken() 生成 access/refresh Token,并区分 tokenType。
  6. 前端持久化:apps/web/src/stores/user.ts:5 的 useUserStore 保存用户和 Token。
  7. 请求注入:apps/web/src/apis/index.ts:14 与 apps/web/src/apis/index.ts:67 的请求拦截器分别给 Business/Agent 请求加 Bearer Token。
  8. 自动刷新:apps/web/src/apis/token.ts:6 的 ensureAccessToken() 合并并发刷新请求;apps/web/src/apis/auth/index.ts:18 的 refreshTokenApi() 请求刷新接口。
  9. 服务端鉴权:server/libs/shared/src/auth/auth.guard.ts:14 的 canActivate() 验证 access Token,并把用户信息写入 request.user。

当前实现提醒:server/apps/server/src/user/user.service.ts:37 直接比较明文密码,server/prisma/schema.prisma:107 也直接保存 password。这是必须在公开演示或面试代码审查前修复的安全问题,应改为 Argon2/bcrypt 哈希和安全迁移。

3.2 普通课程与词汇学习#

  1. 课程列表页:apps/web/src/views/Course/index.vue:102 的 init() 加载全部课程和已购课程。
  2. 课程 API:apps/web/src/apis/course/index.ts:4 的 getCourseList()、:8 的 getMyCourseList() 请求 Business。
  3. 课程查询:server/apps/server/src/course/course.service.ts:13 的 findAll() 返回课程;:22 的 findMy() 返回当前用户已购课程。
  4. 进入学习页:apps/web/src/views/Course/Learn/index.vue:302 的 getWordListData() 获取课程词汇。
  5. 学习权限:server/apps/server/src/learn/learn.service.ts:100 的 getWordList() 先校验购买关系,再返回课程对应词汇。
  6. 保存掌握词:前端 apps/web/src/views/Course/Learn/index.vue:288 的 saveWordMaster() 调用 apps/web/src/apis/learn/index.ts:5。
  7. 服务端更新:server/apps/server/src/learn/learn.service.ts:12 的 saveWordMaster() 用事务和 Advisory Lock 并发安全地创建/更新 WordBookRecord,同时更新用户掌握词数。
  8. 词库浏览:apps/web/src/views/WordBook/index.vue:111 的 getList() 调用 server/apps/server/src/word-book/word-book.service.ts:15 的 findAll() 完成标签筛选、搜索和分页。

3.3 Agent 对话与 SSE#

  1. 页面初始化:apps/web/src/views/Chat/index.vue:308 的 loadConversations() 加载会话。
  2. 创建会话:apps/web/src/views/Chat/index.vue:323 的 newConversation() 调用 apps/web/src/apis/agent/index.ts:9 的 createConversation()。
  3. 发送消息:apps/web/src/views/Chat/index.vue:369 的 sendMessage() 创建本地占位消息,并调用 apps/web/src/apis/sse/index.ts:11 的 streamAgentMessage()。
  4. SSE 客户端:streamAgentMessage() 取得有效 Token、发起 POST 流式请求、解析事件,401 时只刷新一次 Token。
  5. Agent HTTP 入口:server/apps/ai/src/conversations/conversations.controller.ts:64 的 stream() 设置 SSE 头、转发事件、处理断开。
  6. 主编排器:server/apps/ai/src/agent/agent-runner.service.ts:63-451 的 AgentRunnerService.run() 校验请求、按顺序调用下面的专职服务,并消费 LangGraph 流输出 SSE。
  7. 运行生命周期:server/apps/ai/src/agent/agent-run-lifecycle.service.ts:46 的 createRun()、:108 的 persistUserMessage()、:133 的 completeRun()和 :173 的 failRun() 负责并发锁、Run 状态和消息落库。
  8. 前端事件处理:apps/web/src/views/Chat/index.vue:429 的 handleAgentEvent() 处理状态、工具、来源、增量文本、确认 Action、测验和完成事件。
  9. 停止生成:前端 apps/web/src/views/Chat/index.vue:465 的 stopGeneration() 调用 apps/web/src/apis/agent/index.ts:26;服务端 server/apps/ai/src/agent/agent-runner.service.ts:453-467 的 AgentRunnerService.cancel() 校验本机活动任务并 Abort,数据库条件取消由 agent-run-lifecycle.service.ts:262-292 的 cancelRun() 完成。
  10. 完成落库:server/apps/ai/src/agent/agent-run-lifecycle.service.ts:133-171 的 AgentRunLifecycleService.completeRun() 在事务中完成 Run、保存 Assistant 消息、工具审计、引用和 Token 用量。

3.4 Agent 写操作与人工确认#

  1. Agent 在 server/apps/ai/src/agent/agent-tools.service.ts:37-371 的 AgentToolsService.createTools() 中定义全部工具;agent-runner.service.ts:261-285 只负责按场景筛选可用工具。
  2. create_study_plan 和 complete_study_task 不直接修改最终业务状态,而是调用 AgentActionService 创建待确认 Action。
  3. 创建计划 Action:server/libs/shared/src/learning/agent-action.service.ts:23 的 prepareStudyPlan() 生成幂等键、加锁、创建 PENDING Action。
  4. 创建完成任务 Action:server/libs/shared/src/learning/agent-action.service.ts:73 的 AgentActionService.prepareCompleteTask() 校验任务归属并创建待确认 Action。
  5. 前端收到 action.required 后,由 apps/web/src/views/Chat/index.vue:481 的 confirmAction() 或 :495 的 rejectAction() 处理。
  6. 确认入口:server/apps/ai/src/conversations/actions.controller.ts:30 的 confirm() 调用 AgentActionService.confirm()。
  7. 确认与抢占:server/libs/shared/src/learning/agent-action.service.ts:116 的 AgentActionService.confirm() 通过会话锁、Action 锁、状态条件更新确保重复确认不会重复执行。
  8. 实际执行:server/libs/shared/src/learning/agent-action.service.ts:190 的 AgentActionService.executeConfirmed() 按 Action 类型调用学习领域服务,并处理重试、失败和补偿。
  9. 回执消息:server/libs/shared/src/learning/agent-action.service.ts:520 的 AgentActionService.ensureActionReceipt() 与 server/libs/shared/src/learning/agent-action.service.ts:536 的 persistActionReceipt() 把最终执行结果写回会话,页面刷新后仍可追溯。
  10. 后台恢复:server/apps/ai/src/agent/agent-action-recovery.service.ts:41 的 runRecovery() 定期过期 PENDING、重试 CONFIRMED、补偿终态计划、清理孤立 Action。

3.5 私人文档上传与异步索引#

  1. 知识库页面:apps/web/src/views/Knowledge/index.vue:200 的 upload() 调用 apps/web/src/apis/knowledge/index.ts:7。
  2. Business 入口:server/apps/server/src/knowledge/knowledge.controller.ts:33 的 upload() 接收 multipart 文件并应用鉴权、限流、大小限制。
  3. 业务上传:server/apps/server/src/knowledge/knowledge.service.ts:99 的 upload() 校验文件、配额和重复内容,在 PostgreSQL 建记录,并把原文件写入私有 MinIO Bucket。
  4. 文件识别:server/apps/server/src/knowledge/knowledge.service.ts:810 的 KnowledgeService.inspectFile() 校验扩展名、MIME 和文件签名;同文件的 sanitizeFilename()、decodeMultipartFilename()、buildTitle() 分别清洗文件名、处理 multipart 中文文件名、生成展示标题。
  5. 创建或重建索引:server/apps/server/src/knowledge/knowledge.service.ts:711 的 KnowledgeService.enqueue() 根据 reindex 分流:false 调 RagInternalClient.createIngestion() 创建入库任务,true 调 RagInternalClient.reindexDocument() 重建已有索引;两条路都会先把文档条件更新为 QUEUED。
  6. FastAPI 入口:server-py/app/api/rag.py:42-72 的 create_ingestion() 负责创建任务;同文件 :75-91 的 reindex_document() 先校验 URL 与 body 的 documentId 一致,再复用 create_ingestion() 的 Bucket、对象路径、版本校验和 Celery 投递流程。
  7. Celery 任务:server-py/app/tasks/celery_app.py:117 的 ingest_document() 获取文档级 Redis 锁,区分永久错误/瞬时错误,执行指数退避与失败清理。
  8. 入库主流程:server-py/app/services/ingestion.py:82 的 process_ingestion() 回调 PROCESSING、下载并校验文件、解析、分片、Embedding、写 Chroma、核对数量、回调 READY。
  9. 状态回调:server-py/app/services/callbacks.py:13 的 send_ingestion_callback() HMAC 调用 Business;server/apps/server/src/knowledge/internal-rag.controller.ts:18 的回调路由接收;server/apps/server/src/knowledge/knowledge.service.ts:610 的 KnowledgeService.applyRagStatus() 校验状态转换并更新 PostgreSQL。
  10. 前端轮询:apps/web/src/views/Knowledge/index.vue:179 的 schedulePolling() 在仍有处理中记录时继续刷新列表。

3.6 私人 RAG 检索与可信引用#

  1. 用户选择文档后发送问题,文档 ID 进入 SendAgentMessageRequest。
  2. server/apps/ai/src/agent/agent-runner.service.ts:498-529 的 AgentRunnerService.validateDocumentScope() 只接受当前用户拥有、状态 READY、未删除的文档。
  3. server/apps/ai/src/agent/agent-rag.service.ts:22-128 的 AgentRagService.prefetchKnowledge() 在模型运行前先检索;同文件 :241-263 的 contextualRetrievalQuery() 为短追问补充上下文。
  4. server/apps/ai/src/agent/agent-tools.service.ts:201-252 的 search_knowledge_base Tool 允许模型主动再次检索,其 userId/documentIds 由服务端上下文注入。
  5. server/libs/shared/src/rag/rag-internal.client.ts:77 的 searchKnowledge() 签名调用 FastAPI;:293 的 validateSearchResult() 再校验返回结构和越界结果。
  6. FastAPI 入口:server-py/app/api/rag.py:134 的 search_knowledge() 限制 topK、最低分和总超时。
  7. Chroma 检索:server-py/app/vectorstore/chroma_store.py:432 的 search_private() 用查询向量和 owner_user_id + PRIVATE + document allowlist 过滤,执行阈值过滤与去重。
  8. 来源登记:server/apps/ai/src/agent/agent-rag.service.ts:130-142 的 AgentRagService.captureSources() 保存真实召回来源并发送 source SSE 事件。
  9. 模型可见来源:server/apps/ai/src/agent/agent-rag.service.ts:144-164 的 AgentRagService.modelVisibleSources() 把内部 chunk 映射为临时 K1/K2 编号。
  10. 最终校验:server/apps/ai/src/agent/agent-rag.service.ts:192-239 的 AgentRagService.finalizeGroundedCitations() 只保留回答中实际引用且本轮真实召回的来源,过滤伪造引用。

3.7 学习计划、任务、测验与掌握度#

  1. 学习中心页面:apps/web/src/views/Study/index.vue:296 的 loadAll() 并行加载画像、计划、任务、测验等数据。
  2. Business 门面:server/apps/server/src/study/study.service.ts:17-115 把 HTTP 参数转换为领域服务调用。
  3. Agent 读取工具:server/libs/shared/src/learning/learning-tools.service.ts:8 的画像方法读取学习画像;同文件的统计、薄弱词、到期复习、近期测验方法继续补齐数据。
  4. 待确认计划:server/libs/shared/src/learning/study-domain.service.ts:49 的 StudyDomainService.createPendingPlan() 创建 PENDING_CONFIRMATION 计划;同文件 :70 的 activatePlan() 在确认后激活。
  5. 任务完成:server/libs/shared/src/learning/study-domain.service.ts:129 的 StudyDomainService.completeTask() 调用同文件 :692 的 completeTaskInTransaction(),并在后续判断是否完成整份计划。
  6. 生成测验:server/libs/shared/src/learning/study-domain.service.ts:168 的 StudyDomainService.generateQuiz() 从薄弱词/到期复习中选题,保存服务端题目快照,返回不含答案的公开题面。
  7. 防重复提交:server/libs/shared/src/learning/study-domain.service.ts:296 的 StudyDomainService.submitQuiz() 使用幂等键、Quiz Session 锁和词汇掌握度锁,只允许一次有效提交。
  8. 更新掌握度:server/libs/shared/src/learning/study-domain.service.ts:417 的 StudyDomainService.updateMastery() 按正误、间隔、easeFactor 更新统计和下次复习时间。
  9. 前端答题:apps/web/src/views/Study/index.vue:347 的 createQuiz() 生成;同文件 :360 的 gradeQuiz() 提交;同文件 :386 的 questionFeedback() 展示逐题反馈。

3.8 支付、WebSocket 与埋点#

  1. 购买课程:apps/web/src/views/Course/index.vue:118 的 handleBuy() 打开支付组件。
  2. 创建订单:apps/web/src/views/Course/components/Pay.vue:142 的 onConfirm() 调 apps/web/src/apis/pay/index.ts:5。
  3. 服务端下单:server/apps/server/src/pay/pay.service.ts:30 的 createOrder() 创建本地支付记录并调用支付宝 SDK。
  4. 支付回调:server/apps/server/src/pay/pay.service.ts:80 的 PayService.notify() 更新订单和课程购买关系,然后通知用户;当前代码未做支付宝回调验签、金额和商户号校验,这是明确的安全缺口。
  5. WebSocket:server/apps/server/src/socket/socket.gateway.ts:14 的 SocketGateway.handleConnection() 绑定用户 Socket;同文件 :21 的 emitPaymentSuccess() 推送支付成功。
  6. 前端埋点入口:apps/tracker/index.ts:9 的 Tracker 组合 UV、PV、性能、事件和错误采集。
  7. SDK 方法:apps/tracker/src/uv/index.ts:15 的 getFingerprint() 生成/获取指纹;apps/tracker/src/pv/index.ts:16 的 reportPv() 监听页面访问;apps/tracker/src/performance/index.ts:5 的 reportPerformance() 上报 Web 性能;apps/tracker/src/error/index.ts:4 的 reportError() 捕获错误;apps/tracker/src/event/index.ts:4 的 reportEvent() 上报按钮点击事件。
  8. Business 落库:server/apps/server/src/tracker/tracker.service.ts:20-88 分别写入访客关联、错误、事件、PV、性能和 UV 数据。

3A. 八条核心调用链(从前到后逐段详解)#

新的阅读顺序:每条链路先看浅色卡片里的“大白话主线”,弄清楚谁调用谁、为什么调用、返回后去哪;再看“代码位置速查”和后面的逐段说明。不要一开始就钻进所有判断分支。

定位格式统一说明:本章每个代码段都写成“从项目根目录开始的完整相对路径 + 行号范围 + 所属方法”。例如 server-py/app/tasks/celery_app.py:117-137 的所属方法是 ingest_document(),不再使用容易看不懂的 celery:117-137 之类简称。凡是代码里有 if、状态判断、条件更新或提前返回,说明中都会用大白话写清楚“为什么要判断、主要防止什么”。

3A.0 可选:主线看懂后,再查关键分支#

本表专门检查“条件不同会走向不同方法、查询、状态或返回结果”的代码。纯粹用于显示默认文字、拼接日志字段的三元表达式不会被误写成一条新业务链;它们仍在对应方法的逐段说明中解释。

链路 文件、方法与判断 条件成立时走哪条路 条件不成立时走哪条路 为什么要分开、主要防止什么
登录 apps/web/src/components/Login/LoginForm.vue:72-86
handleLogin() 判断表单是否通过校验
调 login(),成功后写 Pinia 并关闭弹窗。 不发请求,直接提示表单错误。 防止手机号、密码格式都不对时还请求后端。
登录 apps/web/src/apis/token.ts:28-50
ensureAccessToken() 判断 Token 是否临近过期、是否已有刷新 Promise
临近过期才调 refreshTokenApi();已有刷新任务就复用同一个 Promise。 Token 还有效就直接返回原 Token。 防止每个并发请求都各刷新一次,也防止没必要地频繁换 Token。
登录 apps/web/src/apis/index.ts:30-49,81-96
Business/Agent 响应拦截器判断是否为第一次 401
刷新 Token 后只重放原请求一次。 不是 401,或已经重试过,就直接抛错。 防止网络错误被误当成登录过期,也防止无穷 401 循环。
课程 apps/web/src/views/Course/index.vue:102-110
init() 判断当前标签
activeTab === "list" 调 getCourseList() 查全部课程。 其他标签调 getMyCourseList() 查已购课程。 两个页面语义和权限不同,不能拿“全部课程”冒充“我的课程”。
课程 apps/web/src/views/Course/index.vue:118-128
handleBuy() 判断是不是“我的课程”页
已购页直接进入学习页并提前返回。 非已购页先调用登录检查;登录成功才打开支付弹窗。 防止已购课程重复下单,也防止未登录用户直接创建订单。
背词 server/apps/server/src/learn/learn.service.ts:86-89
LearnService.saveWordMaster() 判断本次是否真的新增掌握词
masteredIncrement > 0 调 StudyCheckInService.syncDayNumber() 重算有效学习天数。 没有新增事实就保留 wordNumber.dayNumber。 防止重复提交同一批词也反复做昂贵统计,更防止把重复点击算成新学习日。
Agent server/apps/ai/src/agent/agent-runner.service.ts:279-285
AgentRunnerService.run() 判断会话场景
learning_coach 使用全部学习工具。 其他场景只留下 search_knowledge_base。 防止知识问答/写作场景误调用创建计划、完成任务等高影响学习工具。
Agent server/apps/ai/src/agent/agent-runner.service.ts:373-408
AgentRunnerService.run() 判断数据库终态、Abort、超时和递归上限
分别映射成 TIMED_OUT、CANCELLED、AGENT_RECURSION_LIMIT 及对应提示。 其他异常统一按普通 FAILED 处理。 防止所有错误都显示成一个模糊的“失败”,也防止取消被误记成服务故障。
Agent server/apps/ai/src/agent/agent-runner.service.ts:421-431
AgentRunnerService.run() 判断取消前是否已有回答片段
有片段就保留已经生成的内容,并重新校验引用。 没有可用片段就使用对应错误/取消提示并清空引用。 防止用户点停止后已经看到的内容突然丢失,也防止失败消息携带旧引用。
Agent server/apps/ai/src/agent/agent-context.service.ts:23-47
AgentContextService.prepareGraphMessages() 判断是否要重置 Checkpoint、旧 Checkpoint 是否存在
要重置就先删旧状态;若仍有有效 Checkpoint,只补本轮 USER 消息。 没有 Checkpoint 才从数据库重建完整历史。 防止失败 Run 的脏图状态继续污染下一轮,也防止已有 Checkpoint 时重复塞整段历史。
Action server/libs/shared/src/learning/agent-action.service.ts:232-249
AgentActionService.executeConfirmed() 判断 Action 类型
CREATE_STUDY_PLAN 调 StudyDomainService.activatePlan();COMPLETE_STUDY_TASK 调 StudyDomainService.completeTask()。 两种都不是就拒绝为“不支持的操作类型”。 防止未知/篡改的 Action 类型落到错误业务方法,更不能默认执行某一种写操作。
Action server/libs/shared/src/learning/agent-action.service.ts:263-298
AgentActionService.executeConfirmed() 判断错误是否永久、是否已重试 3 次
4xx 业务错误或到达上限就标 FAILED,并补偿计划。 短暂错误保留 CONFIRMED,标记 RETRYING,交给恢复器再试。 防止永久错误无限重试,也防止一次网络抖动就把可恢复操作永久判死。
文档入库 server/apps/server/src/knowledge/knowledge.service.ts:283-343
KnowledgeService.retry() 判断重试前状态
原状态是 READY 时令 reindex=true,表示已有完整索引,需要走重建。 原状态是 FAILED 时令 reindex=false,按创建入库任务重新处理。 防止“已有索引重建”和“失败任务重新创建”混成同一语义,也让 FastAPI 能分别做对应校验。
文档入库 server/apps/server/src/knowledge/knowledge.service.ts:748-751
KnowledgeService.enqueue() 判断 reindex
true 调 RagInternalClient.reindexDocument(),请求 /internal/v1/documents/{documentId}/reindex。 false 调 RagInternalClient.createIngestion(),请求 /internal/v1/ingestions。 这是你指出的两条真实分支:前者重建已有索引,后者创建首次/失败后的入库任务;不能只讲其中一条。
文档入库 server/apps/server/src/knowledge/knowledge.service.ts:752-795
KnowledgeService.enqueue() 判断 jobId 是否仍属于当前 trace、队列调用是否异常
保存成功只写当前 QUEUED+trace 的 jobId;异常时只把同 trace 的任务改 FAILED。 状态/trace 已变化就停止覆盖;若用户正在删除则抛冲突。 防止迟到响应覆盖新一轮重试,也防止删除中的文档被重新标成排队。
文档入库 server-py/app/tasks/celery_app.py:138-174
ingest_document() 判断永久错误、瞬时错误和重试次数
永久错误直接清向量并 FAILED;瞬时错误未到上限就指数退避重试。 瞬时错误达到上限才清理并 FAILED。 防止坏文件无意义重试,也防止网络短抖动立刻毁掉一个本来可成功的任务。
RAG server/apps/ai/src/agent/agent-runner.service.ts:508-529
AgentRunnerService.validateDocumentScope() 判断用户有没有显式选择文档
有 ID 就整批校验“归属当前用户、READY、未删除”,数量不符整批拒绝。 没有 ID 时由服务端查当前用户最新 100 份 READY 文档。 防止空数组被当成“搜全站”,也防止夹带一个别人的 ID 探测权限。
RAG server/apps/ai/src/agent/agent-rag.service.ts:45-77
AgentRagService.prefetchKnowledge() 判断短追问、扩展查询是否零结果
短追问先带最近上下文检索;扩展后零结果则用原问题再试一次。 原问题本身完整或扩展查询已有结果,就不做备用检索。 防止“这个呢?”失去语境,也防止补上下文反而把检索带偏。
RAG server/apps/ai/src/agent/agent-rag.service.ts:104-127
AgentRagService.prefetchKnowledge() 判断异常是否来自 Abort
用户取消就继续抛出,让整个 Run 进入取消流程。 普通 RAG 故障降级成 UNAVAILABLE,允许 Agent 明确说明资料服务不可用。 防止用户取消后后台仍继续花钱检索,也防止服务故障被谎称成“知识库没有资料”。
测验 server/libs/shared/src/learning/study-domain.service.ts:177-201
StudyDomainService.generateQuiz() 判断测验模式
DUE_REVIEW 查询已经到复习时间的词。 其他模式查询掌握分低于 80 的薄弱词。 两种模式解决的问题不同;不能只讲“查薄弱词”而漏掉“到期复习”。
测验 server/libs/shared/src/learning/study-domain.service.ts:423-447
StudyDomainService.updateMastery() 判断答对/答错和分数档位
答对提高 ease;95/90/80 分分别拉长到不同复习间隔。 答错降低 ease,并把间隔缩回 1 天。 防止一次答错后很久不复习,也防止一次答对就把并不稳定的单词推到很远。
测验 server/libs/shared/src/learning/study-domain.service.ts:324-351
StudyDomainService.submitQuiz() 判断幂等键是否已存在
userId+sessionId 都一致就返回第一次评分结果。 键相同但用户/Session 不同则拒绝;不存在才继续评分,并在加锁后再查一次。 防止双击重复评分,也防止拿别人的幂等键套取答题结果。
支付 apps/web/src/views/Course/components/Pay.vue:121-139
watch(modelValue) 判断弹窗开关和 Socket 是否存在
打开且有 Socket 才监听支付成功;关闭且有 Socket 才移除监听。 没有 Socket 就不调用 .on/.off。 防止空对象报错,也防止反复开弹窗累积多个监听、一次支付弹多次提示。
支付 apps/web/src/views/Course/components/Pay.vue:142-158
onConfirm() 判断下单返回码
200 才打开支付页、锁定按钮并启动倒计时。 失败就展示消息并恢复按钮。 防止订单根本没创建成功,页面却一直显示“支付中”。
埋点 apps/tracker/src/pv/index.ts:4-14
reportView() 判断是否为 Hash 路由
含 # 就把 hash 作为页面 path。 普通 History 路由使用 location.pathname。 防止 Vue Hash 路由每一页都被错误记成同一个 /。
词库 server/apps/server/src/word-book/word-book.service.ts:12-23
WordBookService.toBoolean() / findAll() 判断标签和搜索词
标签值严格等于字符串 true 才加入布尔过滤;搜索词非空才加入 contains。 未选标签、空搜索词都写成 undefined,让 Prisma 不加这一项条件。 防止“没选某标签”被误解成“专门查标签为 false”,也防止空字符串制造无意义搜索条件。
Agent server/apps/ai/src/agent/agent-runner.service.ts:90-93
AgentRunnerService.run() 判断外部 traceId 格式
满足 8~128 位安全字符才沿用,方便跨服务追踪。 不合法或没有就生成新的 UUID。 防止恶意超长/特殊字符污染日志,也保证每次 Run 总有可追踪 ID。
RAG server/libs/shared/src/rag/rag-internal.client.ts:107-166
RagInternalClient.request() 判断 retryable、外部 AbortSignal 和 HTTP 状态
只有显式可重试请求才使用多次 attempts;有外部 signal 时与内部超时 signal 合并;瞬时状态才退避再试。 写请求默认只发一次;没有外部 signal 只受内部超时控制;永久 4xx 立即失败。 防止写操作因自动重试被执行两遍,也防止用户取消或总超时后请求继续跑。
学习任务 server/libs/shared/src/learning/study-domain.service.ts:106-118
StudyDomainService.listTasks() 判断是否传了起止日期
有任一日期就给 scheduledFor 加 gte/lte 范围。 两个日期都没有就不加日期过滤,最多返回排序后的 200 条。 防止没传日期时生成一个错误的空区间,也限制一次查询量。
测验 server/apps/server/src/study/study.service.ts:67-79
StudyService.generateQuiz() 判断题数和模式参数
有限数字使用调用方题数;合法模式传给领域层。 题数无效时默认 10;模式缺省为 WEAK_WORDS,非法模式直接拒绝。 防止 NaN/非法枚举一路传进数据库查询,也让缺省调用有稳定行为。
课程学习页 apps/web/src/views/Course/Learn/index.vue:210-218
onKeyDown() 判断当前单词是否全部拼对
全部正确才调用 pageNext() 进入下一词。 仍有错误就停在当前词并提示“请先完成拼写”。 防止用户按回车直接跳过未完成的拼写练习。
课程学习页 apps/web/src/views/Course/Learn/index.vue:291-315
saveWordMaster() / getWordListData() 判断 Business 返回码
成功才清当前列表、拉下一批词并更新 Store 中的词数/学习天数。 失败只显示错误,不伪造本地学习进度。 防止后端保存失败但前端看起来已经学会,造成前后端事实不一致。
知识库页面 apps/web/src/views/Knowledge/index.vue:200-218
upload() 判断入队后的文档状态
状态 FAILED 表示原文件已保存但索引服务暂不可用,显示可重试警告。 其他状态显示“正在建立索引”,然后刷新列表。 防止把“文件保存成功、索引排队失败”误说成整次上传失败,也不能误说索引已经成功。
知识库页面 apps/web/src/views/Knowledge/index.vue:220-239
retry() 判断旧状态和新状态
旧状态 READY 先弹确认,因为这是全量重建;返回 FAILED 显示可重试警告。 FAILED 文档重试不弹“覆盖已有索引”确认;成功入队则显示成功。 防止用户无意重建一份正常索引,也让创建失败重试保持快捷。

3A.1 登录、Token 持久化、自动刷新与鉴权#

先分清楚:这里不是一条请求,而是三条会在不同时间发生的小流程

第一次登录负责拿到两张“通行证”;以后请求接口时带上短期通行证;短期通行证快过期或已经失效时,再拿长期通行证换新的。把这三件事分开看,就不会误以为这些方法会在一次登录里全部执行。

小流程一:用户第一次登录
handleLogin()→login() 请求→UserController.login()→UserService.login()→generateToken()→Pinia 保存
  1. 用户点击登录,页面进入 handleLogin()

    位置:LoginForm.vue:72-86。它先检查手机号和密码有没有填对,再调用前端的 login()。为什么调:页面自己不能查数据库,只能把登录信息交给后端。

    下一站:apps/web/src/apis/user/index.ts 把数据发到 /user/login。

  2. 请求到达 UserController.login()

    位置:user.controller.ts:34。Controller 只负责接住请求,然后调用 UserService.login()。这样入口和真正的登录规则不会混在一起。

    下一站:把登录参数原样交给 UserService.login()。

  3. UserService.login() 检查用户和密码

    位置:user.service.ts:26-60。先按手机号找用户;找不到或密码不对就结束。通过后更新最后登录时间,再调用 AuthService.generateToken()。

    为什么再调一个方法:生成通行证是公共能力,不应塞在用户查询代码里。

  4. generateToken() 返回短期和长期两张通行证

    位置:auth.service.ts:12-25。短期 Token 用来访问业务接口,长期 Token 只用来换新。返回后一路回到登录页面,页面把用户信息和两张 Token 写进 Pinia。

小流程二:以后访问需要登录的接口

Axios 请求拦截器 → ensureAccessToken() → 加上 Authorization → AuthGuard.canActivate() → 具体 Controller。这里的 AuthGuard 像门卫:验证通过后把可信的 userId 放进请求,后面的业务代码不再相信前端自己传来的 userId。

小流程三:Token 快过期或接口返回 401

ensureAccessToken() 调 refreshTokenApi(),后端 UserService.refreshToken() 检查长期 Token 后签发新 Token。多个请求同时发现过期时会共用同一个刷新任务,避免一瞬间换出多组 Token。若第一次请求已经返回 401,拦截器只重放一次,避免无限循环。

代码位置速查(看完上面的主线再查)#

步骤 文件与行号 方法 职责
1 apps/web/src/components/Login/LoginForm.vue:72-86 handleLogin() 表单校验、请求登录、写 Pinia、关闭弹窗。
2 apps/web/src/apis/user/index.ts:15-19 login() 把 DTO POST 到 /user/login。
3 server/apps/server/src/user/user.controller.ts:31-42 login() / refresh() 路由、登录限流、转交 Service。
4 server/apps/server/src/user/user.service.ts:26-60 login() 查用户、校验密码、更新登录时间、签发 Token。
5 server/apps/server/src/auth/auth.service.ts:12-25 generateToken() 生成 access/refresh JWT 并写入 tokenType。
6 apps/web/src/stores/user.ts:5-79 setUser() / updataToken() 持久化用户和 Token,暴露计算属性。
7 apps/web/src/apis/token.ts:6-50 ensureAccessToken() / isExpiring() 预判过期、合并并发刷新。
8 apps/web/src/apis/index.ts:10-97 Axios 拦截器 注入 Bearer;401 刷新后重放一次。
9 server/libs/shared/src/auth/auth.guard.ts:14-48 canActivate() 验签、限定 access Token、注入 request.user。

handleLogin():前端登录入口#

代码行 这段代码在做什么
apps/web/src/components/Login/LoginForm.vue:72
handleLogin()
定义异步提交方法,保证后续能等待表单和 HTTP 结果。
apps/web/src/components/Login/LoginForm.vue:73
handleLogin()
调用 Element Plus 表单校验;校验不通过会阻断提交。
apps/web/src/components/Login/LoginForm.vue:74
handleLogin()
读取当前表单响应式对象的原始值。
apps/web/src/components/Login/LoginForm.vue:75-78
handleLogin()
构造登录 DTO;手机号原样传递,密码先在浏览器做 MD5,再调 login()。
apps/web/src/components/Login/LoginForm.vue:79-83
handleLogin()
响应成功时把 user/accessToken/refreshToken 写入 Store,显示成功消息并关闭登录窗。
apps/web/src/components/Login/LoginForm.vue:84-85
handleLogin()
业务失败时显示服务端返回的 message,不改变原 Store。

UserService.login():业务端登录主逻辑#

代码行 作用
server/apps/server/src/user/user.service.ts:26-29
UserService.login()
以手机号查找用户;判断用户不存在就立即返回,防止后面拿空用户去比较密码或签发 Token,造成程序报错或错误登录。
server/apps/server/src/user/user.service.ts:30-38
UserService.login()
将请求中的密码值与数据库字段比较;不一致则返回密码错误。
server/apps/server/src/user/user.service.ts:39-48
UserService.login()
更新 lastLoginAt,并只 select 前端所需的非敏感用户字段。
server/apps/server/src/user/user.service.ts:49-55
UserService.login()
以用户 ID/phone 作为 JWT payload,调 generateToken() 一次生成两种 Token。
server/apps/server/src/user/user.service.ts:56-60
UserService.login()
用统一 ResponseService 包装用户信息和 Token。

安全理解:浏览器 MD5 只是把口令变成了另一个可重放的“口令”,不是安全存储。应该由服务端使用 Argon2/bcrypt + 随机 salt 校验,全程依靠 HTTPS。

generateToken()、ensureAccessToken() 与 Axios 重放#

代码行 作用
server/apps/server/src/auth/auth.service.ts:12-18
AuthService.generateToken()
生成短效 access Token,并写入 tokenType: access。这个标记是给 Guard 判断“它能不能访问业务接口”用的,防止用 refresh Token 冒充 access Token。
server/apps/server/src/auth/auth.service.ts:19-24
AuthService.generateToken()
生成长效 refresh Token,并写入 tokenType: refresh,TTL 来自配置,默认 7 天。它只用于换新 Token,不应直接访问业务接口。
apps/web/src/apis/token.ts:6-13
ensureAccessToken()
判断 1:没有 refresh Token 就不能续期,直接报错,防止在未登录状态下反复请求刷新接口。判断 2:access Token 还有效就直接用,防止每个 API 都多发一次刷新请求。
apps/web/src/apis/token.ts:14-23
ensureAccessToken()
判断当前是否已经有 refreshPromise。有就共用,没有才新建;防止页面同时出现多个 401 时,并发换出多组 Token,导致新旧 Token 互相覆盖。
apps/web/src/apis/token.ts:24-26
ensureAccessToken()
finally 不管成功失败都清空共用 Promise,防止后续请求一直拿到上一次已结束或已失败的结果。
apps/web/src/apis/token.ts:28-50
isExpiring()
解码 JWT payload。判断距过期是否少于 15 秒,是就提前刷新,防止 Token 在请求飞行途中过期;解码异常也当作过期,防止把损坏 Token 继续发给后端。
apps/web/src/apis/index.ts:14-21,67-74
serverApi/agentApi 请求拦截器
Business 和 Agent 两个 Axios 实例在发送前都等待有效 access Token,然后注入 Authorization,防止各页面遗漏鉴权 Header。
apps/web/src/apis/index.ts:30-49,81-96
serverApi/agentApi 响应拦截器
判断是否是 401 且原请求还没有 _retry。只在这种情况下刷新并重放一次;_retry 防止刷新仍失败时无限重放。

AuthGuard.canActivate():服务端鉴权#

代码行 作用
server/libs/shared/src/auth/auth.guard.ts:14-21
AuthGuard.canActivate()
从 Nest ExecutionContext 取 Request,再从 Authorization: Bearer ... 提取 Token。
server/libs/shared/src/auth/auth.guard.ts:22-31
AuthGuard.canActivate()
缺 Token 或 JWT 验签失败均抛 401,不把底层错误暴露给客户端。
server/libs/shared/src/auth/auth.guard.ts:32-40
AuthGuard.canActivate()
额外检查 tokenType === access 和必需 payload,防止拿 refresh Token 访问业务接口。
server/libs/shared/src/auth/auth.guard.ts:41-48
AuthGuard.canActivate()
把解码结果写入 request.user,Controller 之后只信任此服务端身份。

3A.2 课程、购买权限、背词与掌握度#

这部分真正的主线:打开已购课程,取一批词,学完后保存,再取下一批

课程列表、背词和词库搜索原来写在同一个表里,看起来像一条链。实际最值得顺着讲的是“进入课程并完成一轮学习”;词库搜索只是另一个独立入口。

handleBuy()→进入学习页→getWordListData()→LearnController.getWordList()→LearnService.getWordList()→用户学完→saveWordMaster()
  1. 用户在课程页点击课程

    Course/index.vue 的 handleBuy() 先看这门课是否已经购买。已购买就进入学习页;没有购买才打开支付弹窗。为什么先判断:不能让未购买用户直接进入学习流程。

  2. 学习页调用 getWordListData() 要一批新单词

    页面通过 getWordList(courseId) 发请求。后端 Controller 从登录信息里取 userId,再调用 LearnService.getWordList()。userId 不由页面提交,避免冒充别人。

  3. getWordList() 先查购买记录,再挑选单词

    它确认用户真的买过课程,然后排除已经掌握的词,按词频取 10 个。返回后页面显示这一批词。

    接下来不是自动调用:要等用户完成拼写并主动保存。

  4. 用户学完后,页面调用 saveWordMaster()

    页面收集本轮 wordId,经 API 和 Controller 到达 LearnService.saveWordMaster()。服务端去重、检查单词存在,再在一次数据库操作中更新掌握记录、掌握词数和学习天数。

  5. 保存成功后重新调用 getWordListData()

    这就是返回链:后端返回本轮保存结果,前端清掉旧列表,再取下一批未掌握词;保存失败则留在当前页面,不假装用户已经学会。

不要混讲:词库页的 WordBookService.findAll() 是“搜索和筛选词典”的另一条读数据流程,不是背完一批词后自动调用的方法。

代码位置速查(主链和独立入口)#

步骤 文件与方法 职责
1 apps/web/src/views/Course/index.vue:102-128
init() / handleBuy()
切换全部/已购课程,决定进学习页还是打开支付。
2 apps/web/src/apis/course/index.ts:4-10
getCourseList() / getMyCourseList()
请求课程列表与我的课程。
3 server/apps/server/src/course/course.controller.ts:10-19
CourseController.findAll() / findMy()
公开全部课程,已购课程需 AuthGuard。
4 server/apps/server/src/course/course.service.ts:13-39
CourseService.findAll() / findMy()
Prisma 查课程与成功支付记录,格式化 Decimal 价格。
5 apps/web/src/views/Course/Learn/index.vue:291-318
saveWordMaster() / getWordListData()
加载待学词、上传已掌握词。
6 server/apps/server/src/learn/learn.controller.ts:18-33
LearnController.saveWordMaster() / getWordList()
从 JWT 取 userId,转交学习 Service。
7 server/apps/server/src/learn/learn.service.ts:13-97
LearnService.saveWordMaster()
事务、用户级锁、去重、upsert 掌握度与连续学习天数。
8 server/apps/server/src/learn/learn.service.ts:99-134
LearnService.getWordList()
校验购买权,排除已掌握词,按词频取 10 个。
9 apps/web/src/views/WordBook/index.vue:107-120 searchWord() / getList()
→ server/apps/server/src/word-book/word-book.service.ts:12-36 WordBookService.findAll()
搜索、标签过滤、分页和总数。

LearnService.saveWordMaster():保存一批已掌握词#

代码段 作用
server/apps/server/src/learn/learn.service.ts:13-24
LearnService.saveWordMaster()
验证输入必须是数组;清理、去重 wordId,限制一次 1~100 个。
server/apps/server/src/learn/learn.service.ts:25-29
LearnService.saveWordMaster()
开启 Prisma 事务,对 word-mastery:userId 加 PostgreSQL advisory transaction lock,串行化同一用户的掌握度更新。
server/apps/server/src/learn/learn.service.ts:30-46
LearnService.saveWordMaster()
查现有 WordBookRecord,算出缺失 ID;再去 WordBook 验证这些词真实存在。
server/apps/server/src/learn/learn.service.ts:47-57
LearnService.saveWordMaster()
生成本次复习时间和默认 3 天后下次复习时间。
server/apps/server/src/learn/learn.service.ts:58-70
LearnService.saveWordMaster()
批量创建新记录:掌握分 80、isMaster=true、复习次数 1。
server/apps/server/src/learn/learn.service.ts:71-80
LearnService.saveWordMaster()
对原有但未掌握的记录条件更新,避免重复请求反复增加用户掌握词数。
server/apps/server/src/learn/learn.service.ts:81-88
LearnService.saveWordMaster()
以真正从未掌握变为已掌握的数量更新 User.wordNumber。
server/apps/server/src/learn/learn.service.ts:89-94
LearnService.saveWordMaster()
有新学习事实时调 syncDayNumber() 从真实活动重算天数;否则保留现值。
server/apps/server/src/learn/learn.service.ts:95-97
LearnService.saveWordMaster()
返回学会的词数、累计词数与学习天数。

LearnService.getWordList():只让已购用户学对应课程#

代码段 作用
server/apps/server/src/learn/learn.service.ts:99-111
LearnService.getWordList()
查 CourseRecord,条件同时包含 userId、courseId、isPurchased=true;查不到就拒绝。
server/apps/server/src/learn/learn.service.ts:112-119
LearnService.getWordList()
从课程配置取对应词书标记,先查当前用户已掌握的 wordId。
server/apps/server/src/learn/learn.service.ts:120-131
LearnService.getWordList()
查 WordBook:课程标记为 true,ID 不在已掌握集合;按词频降序取 10 个。
server/apps/server/src/learn/learn.service.ts:132-134
LearnService.getWordList()
以统一响应结构返回,页面直接显示。

3A.3 Agent 会话、SSE 流式输出、取消与恢复#

一条消息是怎么从输入框走到逐字显示,再安全保存下来的

前端不会等完整答案一次性回来,而是保持一条长连接。后端每拿到一小段文字就立即发给页面,同时在数据库中记录这次任务的开始、完成、取消或失败。

sendMessage()→streamAgentMessage()→Controller.stream()→AgentRunner.run()→agent.stream()→message.delta→completeRun()→run.completed
  1. 用户发送消息,sendMessage() 先在页面放两个临时气泡

    一个是用户刚输入的内容,一个是空的 AI 气泡。为什么先放:不用等后端响应,用户马上能看到消息已经发出。随后调用 streamAgentMessage()。

  2. streamAgentMessage() 带上有效 Token 发起流式请求

    位置:apps/web/src/apis/sse/index.ts。它请求 /messages/stream,收到一条事件就交给页面的 handleAgentEvent()。如果第一次返回 401,只刷新一次 Token 后重试。

  3. ConversationsController.stream() 建立长连接

    Controller 先确认会话属于当前用户,再设置流式响应头,然后调用 AgentRunnerService.run()。它还监听浏览器断开:用户关页或断网时,会通知后端取消本次任务,避免模型在后台白跑。

  4. run() 先把这次任务登记为正在运行

    Runner 检查消息、会话和可用文档,再调用 lifecycle.createRun()。这个方法会挡住同一会话的第二个任务,也限制单个用户同时跑太多任务。成功后得到 runId,并设置取消开关和总超时。

  5. 保存用户消息并准备上下文

    Runner 调 persistUserMessage() 保存用户消息,再调 prepareGraphMessages() 读取历史对话。然后发送 run.started,页面因此知道真正的 runId。

  6. 准备资料和工具,再让 Agent 开始回答

    Runner 依次调用资料检索服务、工具服务和提示词服务。学习教练可以使用学习工具;资料辅导和写作场景只留下允许的工具。准备好后调用 agent.stream()。

  7. 模型每返回一小段文字,就发送一次 message.delta

    Runner 一边把小段文字拼成完整回答,一边发给前端。handleAgentEvent() 把 delta 追加到刚才的空气泡里,于是用户看到逐字生成效果。

  8. 完整回答先保存,再通知页面完成

    Runner 清理无效引用后调用 completeRun()。它在同一次数据库提交中把 Run 改为完成并保存最终 AI 消息。保存成功后才发送 action.required 和 run.completed,前端用真实数据库消息替换临时气泡。

取消是旁路,不是主流程的下一步:用户点“停止”时,前端先调用取消接口,再关浏览器连接;后端只把仍在运行的任务改成已取消,同时停止模型并清理这次任务留下的待确认操作。页面最后重读数据库,以数据库结果为准。

代码位置速查(看完上面的主线再查)#

步骤 文件与方法 职责
1 apps/web/src/views/Chat/index.vue:308-350
loadConversations() / newConversation() / selectConversation()
加载/新建/切换会话,恢复消息和待确认 Action。
2 apps/web/src/views/Chat/index.vue:369-427
sendMessage()
前端乐观插入用户消息和 Assistant 占位,启动 SSE。
3 apps/web/src/apis/sse/index.ts:11-72
streamAgentMessage()
POST SSE、解析事件、仅一次 401 刷新、支持 Abort。
4 server/apps/ai/src/conversations/conversations.controller.ts:61-113
ConversationsController.stream()
验权/限流/所有权,设 SSE Header,转发 Agent 事件,监听断开。
5 server/apps/ai/src/agent/agent-runner.service.ts:63-451
AgentRunnerService.run()
只保留总编排:串起 Lifecycle、Context、RAG、Tools、Prompt、Usage,消费模型流并发 SSE。
6 server/apps/ai/src/agent/agent-run-lifecycle.service.ts:46-171
createRun() / persistUserMessage() / completeRun()
用锁和事务建 Run,保存 User 消息,再原子完成 Run 并写 Assistant 消息。
7 server/apps/ai/src/agent/agent-runner.service.ts:453-467
AgentRunnerService.cancel()
agent-run-lifecycle.service.ts:262-304
cancelRun() / rejectPendingActions()
Runner 中止本实例 AbortController;Lifecycle 条件更新 Run 为 CANCELLED 并拒绝待确认 Action。
8 apps/web/src/views/Chat/index.vue:429-533
handleAgentEvent() / stopGeneration() / recoverSavedState()
消费事件、停止生成、从服务端重建真实状态。

sendMessage() 与 handleAgentEvent():页面如何不断更新#

代码段 作用
apps/web/src/views/Chat/index.vue:369-377
sendMessage()
先 trim 输入。判断空文本是为了防空消息;判断正在流式是为了防同一页面重复发送;没有会话时先新建,防消息无归属。
apps/web/src/views/Chat/index.vue:378-397
sendMessage()
清上轮临时状态,本地先插入 USER 消息和空 ASSISTANT 占位,让用户立即看到“已发送”,后续增量文本有固定位置可写。
apps/web/src/views/Chat/index.vue:398-420
sendMessage()
调 streamAgentMessage()。onEvent 交统一分发;onError/onClose 都清“生成中”标志并延时对账,防止 SSE 断线后本地内容与数据库最终内容不一致。
apps/web/src/views/Chat/index.vue:421-427
sendMessage()
如果 SSE 连接根本没启动成功,立即恢复 UI,防止按钮永久处于禁用/“生成中”。
apps/web/src/views/Chat/index.vue:429-444
handleAgentEvent()
根据 run.started/status/tool.* 更新 runId、状态文字和工具进度,使后续“取消”知道要取消哪个 Run。
apps/web/src/views/Chat/index.vue:445-454
handleAgentEvent()
message.delta 只追加到当前 Assistant 占位;Action/测验事件变成交互卡片,防止把“待用户确认”误当成已执行结果。
apps/web/src/views/Chat/index.vue:455-463
handleAgentEvent()
判断 run.completed 时用服务端终稿替换本地占位,防止漏字/重字;error 显示保底内容,然后滚到最新消息。

streamAgentMessage():POST 方式 SSE#

代码段 作用
apps/web/src/apis/sse/index.ts:11-18
streamAgentMessage()
建 AbortController,异步 start() 内最多两次尝试(原请求 + 401 刷新后一次)。
apps/web/src/apis/sse/index.ts:19-34
streamAgentMessage()
获取有效 Token,用 fetchEventSource POST JSON,带 Bearer 和 AbortSignal。
apps/web/src/apis/sse/index.ts:35-44
streamAgentMessage()
onopen 必须是 2xx 且 Content-Type 是 event-stream;401 抛特殊错误,其他异常直接拒绝。
apps/web/src/apis/sse/index.ts:45-54
streamAgentMessage()
onmessage 忽略空包,JSON.parse 后交给页面;单个坏包触发错误回调。
apps/web/src/apis/sse/index.ts:55-63
streamAgentMessage()
正常关闭时通知页面;异常则抛给外层判断是否属于可刷新 Token 的 401,防止把网络断开、服务端 500 等问题误当成登录过期而不停刷新 Token。
apps/web/src/apis/sse/index.ts:64-72
streamAgentMessage()
401 时强制刷新并 continue;其他错误通知 UI 后 abort;方法立即返回 Controller 供“停止”使用。

AgentRunnerService.run():Agent 主循环#

代码段 作用
server/apps/ai/src/agent/agent-runner.service.ts:63-101
AgentRunnerService.run()
校验消息和会话,用 validateDocumentScope() 生成服务端文档白名单;校验 traceId,再把 Provider、模型和并发上限交给 AgentRunLifecycleService.createRun()。
server/apps/ai/src/agent/agent-run-lifecycle.service.ts:46-106
AgentRunLifecycleService.createRun()
事务内先锁会话再锁用户;判断会话是否仍活跃、同会话是否已有 RUNNING、用户是否超并发限额。这是为了防同一会话被两个 Run 同时改乱,也防单用户占满资源。
server/apps/ai/src/agent/agent-runner.service.ts:102-168
AgentRunnerService.run()
建 AbortController 和 activeRuns;每 1.5 秒查数据库 Run,别的实例一旦取消就立即 Abort;安装全局超时,再初始化 SSE 序号、引用、Action、审计和 Token 容器。
server/apps/ai/src/agent/agent-runner.service.ts:170-182
run()
agent-run-lifecycle.service.ts:108-131 persistUserMessage()
agent-context.service.ts:23-85 prepareGraphMessages()
用 Lifecycle 保存 USER 消息、更新会话时间/默认标题;用 Context 决定是沿用 Checkpoint 还是从数据库重建历史,然后发 run.started。
server/apps/ai/src/agent/agent-runner.service.ts:184-245
AgentRunnerService.run()
两条快路:非学习教练却请求建计划时直接提示切换场景;知识导师的纯问候不调模型/工具。两条路都会调 completeRun() 落库,防止“前端看到了回答但数据库没记录”。
server/apps/ai/src/agent/agent-runner.service.ts:247-259
run()
agent-rag.service.ts:22-128 prefetchKnowledge()
调用拆出的 RAG 服务做模型前预检索。白名单为空就明确返回 NO_DOCUMENTS;检索服务故障则返回 UNAVAILABLE,防止把故障说成“用户没资料”。
server/apps/ai/src/agent/agent-runner.service.ts:261-296
run()
agent-tools.service.ts:37-371 createTools()
agent-context.service.ts:87-159 createMiddleware()
Tools 服务创建工具,Runner 再按场景筛选:learning_coach 可用全部,其他场景只留知识检索,防止知识问答误改学习状态。随后创建 Model/Agent/Prompt/Middleware/Checkpoint。
server/apps/ai/src/agent/agent-runner.service.ts:297-324
AgentRunnerService.run()
启动 LangGraph message stream,带 AbortSignal、递归上限、Usage Callback 和 thread/run ID;只接受 AIMessageChunk 且非空 delta,边累加答案边发 message.delta。
server/apps/ai/src/agent/agent-runner.service.ts:326-347
run()
agent-rag.service.ts:166-239 enforceKnowledgeState() / finalizeGroundedCitations()
拒绝空回答;修正与知识库真实状态矛盾的文本;清理 Prompt 泄漏;最后只保留本轮真实召回且答案实际使用的引用。
server/apps/ai/src/agent/agent-runner.service.ts:348-372
run()
agent-run-lifecycle.service.ts:133-171 completeRun()
Lifecycle 用 where id + RUNNING 原子抢到 COMPLETED,并在同事务写 Assistant 消息;成功后才发待确认 Action 和 run.completed,防止取消/完成竞态生成两个终态。
server/apps/ai/src/agent/agent-runner.service.ts:373-445
run()
agent-run-lifecycle.service.ts:173-260 failRun() / cleanupFailedCheckpoint()
catch 先判断 Run 是否已完成,再分 TIMED_OUT/CANCELLED/递归超限/普通 FAILED;失败后清 Checkpoint。取消时有文本就保留已生成部分,否则用错误提示并清引用;Lifecycle 再更新 Run、补写消息并拒绝 Action。
server/apps/ai/src/agent/agent-runner.service.ts:446-450
AgentRunnerService.run()
finally 清理全局超时器、跨实例状态轮询和 activeRuns,防止定时器/内存泄漏。

完成、取消与前端恢复#

方法/代码段 作用
server/apps/ai/src/agent/agent-run-lifecycle.service.ts:133-171
AgentRunLifecycleService.completeRun()
事务中判断 Run 是否仍为 RUNNING,只有抢到 RUNNING -> COMPLETED 的请求才写 Assistant 消息。这是为了防止“正常完成”和“取消/超时”同时落库,生成两个终态或重复消息。
server/apps/ai/src/agent/agent-runner.service.ts:453-467
AgentRunnerService.cancel()
agent-run-lifecycle.service.ts:262-304 cancelRun() / rejectPendingActions()
Runner 先判断本机 active Run 的用户/会话是否匹配,防止取消别人任务;Lifecycle 只把 RUNNING 改 CANCELLED,防覆盖已完成结果;若在当前进程再 Abort 模型,最后拒绝待确认 Action。
apps/web/src/views/Chat/index.vue:465-479
stopGeneration()
先告诉服务端取消,再中止浏览器 SSE,最后重读持久化内容。这个顺序防止只关网页连接、后端模型仍继续跑。
apps/web/src/views/Chat/index.vue:512-533
recoverSavedState()
并行读消息和 Action。判断服务端是否已有 Assistant 终稿,或本地已无占位;只在其中一个条件成立时替换,防止数据库短暂还没写完时用空列表覆盖屏幕上的增量文本。

3A.4 Agent 写操作:用户确认、避免重复执行与故障恢复#

核心原则:AI 只能提出“想做什么”,用户点确认后,后端才真正改数据

下面以“帮我制定一份学习计划”为例。最关键的不是模型会写计划,而是它不能绕过用户直接让计划生效;即使用户连点两次、两个服务同时处理或执行中途重启,也只能得到一份正确结果。

模型选择 create_study_plan→prepareStudyPlan()→返回待确认 Action→SSE action.required→confirmAction()→confirm()→executeConfirmed()→activatePlan()→结果回写会话
  1. Runner 把 create_study_plan 工具交给学习教练

    调用发生在上一条 Agent 主链里:AgentRunner.run() 调 AgentToolsService.createTools(),再把工具交给 Agent。只有模型判断用户确实想制定计划时,才会进入这个工具。

  2. 工具先调用 buildPersonalizedStudyPlan() 组装计划草稿

    位置:agent-tools.service.ts:253-293。它读取当前用户的学习统计、薄弱词和到期复习词。模型只提供目标、天数和侧重点,真正的日期、任务和单词 ID 由后端生成,避免模型随便编数据。

    计划草稿准备好后,工具调用 AgentActionService.prepareStudyPlan()。

  3. prepareStudyPlan() 保存“草稿计划 + 待确认操作”

    它给本次操作算一个防重复标记;先检查这次 Run 和会话确实属于当前用户,再创建状态为“等待确认”的计划和 Action。这两条记录一起成功或一起失败。

    返回值是一条 Action,不是已经生效的学习计划。

  4. Action 返回工具,再回到 Runner

    工具把 Action 放进 pendingActions,并告诉模型“正在等待用户确认”。Runner 必须先把本次 AI 回答保存成功,之后才发送 action.required。这样失败的 AI 任务不会留下一个还能执行的按钮。

  5. 前端收到 action.required,展示确认和拒绝按钮

    handleAgentEvent() 把 Action 放到页面列表。用户点确认时,confirmAction() 调用 /actions/{id}/confirm;点拒绝则走另一条拒绝接口。

  6. AgentActionService.confirm() 再做一次完整检查

    它确认 Action 属于当前用户、没过期、会话还开着,而且创建它的 AI 任务已经成功结束。随后把状态从“等待确认”改成“已经确认”。如果用户连点两次,只有第一次能改成功。

    只有需要真正执行时,才继续调用 executeConfirmed()。

  7. executeConfirmed() 抢到执行权后调用 activatePlan()

    它先留下“我正在处理”的时间,其他服务看到后不会重复执行。然后在同一次数据库提交里创建真实学习任务、激活新计划、结束旧计划,并把 Action 改成“执行成功”。任一步失败,整组修改都不会只完成一半。

  8. 执行结果写回原会话,页面刷新也能看见

    成功或拒绝都会生成一条结果消息。前端确认请求结束后重新读取消息和 Action,所以即使中间断网,最终页面仍以数据库里的真实结果为准。

服务中途重启怎么办:恢复任务会定期寻找“已确认但没有执行完”的 Action,再试一次;也会处理过期、孤立和补偿没完成的记录。这里的“补偿”用大白话说,就是把提前创建但最终不能生效的计划取消掉,避免留下垃圾数据。

代码位置速查(看完上面的主线再查)#

步骤 文件与方法 职责
1 server/apps/ai/src/agent/agent-tools.service.ts:253-293
AgentToolsService.createTools() 内的 create_study_plan Tool
从真实学习数据组计划,只准备 Action,不直接生效。
2 server/libs/shared/src/learning/agent-action.service.ts:23-71
AgentActionService.prepareStudyPlan()
建 PENDING_CONFIRMATION 计划和 PENDING Action,幂等防重。
3 apps/web/src/views/Chat/index.vue:481-507
confirmAction() / rejectAction()
用户点确认/拒绝,然后重读会话真实状态。
4 server/apps/ai/src/conversations/actions.controller.ts:27-55
ActionsController.confirm() / list() / reject()
JWT + 限流,确认/查询/拒绝 Action。
5 server/libs/shared/src/learning/agent-action.service.ts:116-188
AgentActionService.confirm()
锁会话/Action,验 Run 已完成,条件确认。
6 server/libs/shared/src/learning/agent-action.service.ts:190-302
AgentActionService.executeConfirmed()
抢执行租约,分派业务方法,写 EXECUTED/可重试/永久失败。
7 server/libs/shared/src/learning/study-domain.service.ts:560-622
StudyDomainService.activatePlanInTransaction()
创建真实任务并激活计划。
8 server/apps/ai/src/agent/agent-action-recovery.service.ts:23-58
onModuleInit() / runRecovery()
定时过期、重试、补偿和清孤儿 Action。

prepareStudyPlan():每一段在做什么#

代码段 作用
server/libs/shared/src/learning/agent-action.service.ts:23-28
AgentActionService.prepareStudyPlan()
把计划 JSON 做稳定哈希,组成 runId + planHash 幂等键:同一 Run 产生同一计划时只有一个 Action。
server/libs/shared/src/learning/agent-action.service.ts:29-34
AgentActionService.prepareStudyPlan()
开启事务,锁会话,并用 assertActionContext() 校验 user/conversation/run 的归属与运行状态。
server/libs/shared/src/learning/agent-action.service.ts:35-41
AgentActionService.prepareStudyPlan()
对幂等键加 advisory lock;先查已有 Action,找到就返回 DTO,不再创建计划。
server/libs/shared/src/learning/agent-action.service.ts:42-45
AgentActionService.prepareStudyPlan()
执行用户级 Action 配额检查;再锁 study-plan-user:userId,避免并发计划冲突。
server/libs/shared/src/learning/agent-action.service.ts:46-50
AgentActionService.prepareStudyPlan()
调 createPendingPlan():计划先写为 PENDING_CONFIRMATION,任务只存 JSON 草稿,未创建可执行 StudyTask。
server/libs/shared/src/learning/agent-action.service.ts:51-66
AgentActionService.prepareStudyPlan()
创建 AgentAction:写类型、计划 ID/payload、面向用户的 summary、幂等键,并设 24 小时过期。
server/libs/shared/src/learning/agent-action.service.ts:67-71
AgentActionService.prepareStudyPlan()
把 Prisma 记录转成前端 Action DTO;事务一起提交计划和 Action。

confirm():确认不等于盲目执行#

代码段 作用
server/libs/shared/src/learning/agent-action.service.ts:116-123
AgentActionService.confirm()
先做一次归属查询取 conversationId,找不到直接 404。
server/libs/shared/src/learning/agent-action.service.ts:124-136
AgentActionService.confirm()
事务内按固定顺序锁会话和 Action,再重读当前记录,防止 TOCTOU。
server/libs/shared/src/learning/agent-action.service.ts:137-149
AgentActionService.confirm()
已 EXECUTED 或已 CONFIRMED 直接按幂等返回;过期则改 EXPIRED 并准备补偿。
server/libs/shared/src/learning/agent-action.service.ts:150-164
AgentActionService.confirm()
只允许 PENDING;确认会话仍 ACTIVE;且创建该 Action 的 Run 必须已 COMPLETED,防止模型未结束就执行。
server/libs/shared/src/learning/agent-action.service.ts:165-176
AgentActionService.confirm()
用 updateMany where status=PENDING 做原子 PENDING -> CONFIRMED;抢不到则重读最新状态。
server/libs/shared/src/learning/agent-action.service.ts:177-188
AgentActionService.confirm()
事务外处理过期补偿;已执行直接返回;真正需执行的才进 executeConfirmed()。

executeConfirmed() 与恢复器#

代码段 作用
server/libs/shared/src/learning/agent-action.service.ts:190-215
AgentActionService.executeConfirmed()
设 60 秒执行租约;条件抢占 CONFIRMED/RETRYING 的 Action,记录执行时间与 attempt 次数。
server/libs/shared/src/learning/agent-action.service.ts:216-231
AgentActionService.executeConfirmed()
没抢到时重读;EXECUTED 直接返回,其他实例正在执行则不重复执行。
server/libs/shared/src/learning/agent-action.service.ts:232-257
AgentActionService.executeConfirmed()
事务内锁 Action,按类型调 activatePlan() 或 completeTask();两者都支持传入同一事务。
server/libs/shared/src/learning/agent-action.service.ts:258-273
AgentActionService.executeConfirmed()
业务成功后更新 Action 为 EXECUTED、保存结果,再写回执消息,页面刷新仍能看到结果。
server/libs/shared/src/learning/agent-action.service.ts:274-302
AgentActionService.executeConfirmed()
异常时区分永久 4xx/超过重试上限与瞬时错误;前者补偿并 FAILED,后者 RETRYING 等恢复器续跑。
server/apps/ai/src/agent/agent-action-recovery.service.ts:23-35
AgentActionRecoveryService.onModuleInit()
判断配置周期是否至少 30 秒,防止配错后高频扫数据库;启动时先立即恢复一次,防止服务重启后还要等完整周期。
server/apps/ai/src/agent/agent-action-recovery.service.ts:41-58
AgentActionRecoveryService.runRecovery()
判断本实例上一轮是否还没结束,是就跳过,防止定时器重入;四类恢复并行执行且分别记错,防止一个坏 Action 拖住所有恢复。

3A.5 私人文档上传、MinIO、Celery 和 Chroma 异步入库#

一份文件为什么不是“上传成功”就立刻能提问

上传请求只负责把原文件安全保存并安排后台处理。解析 PDF、切成小段、计算向量可能很慢,所以交给后台任务;页面通过状态变化知道它什么时候真正可检索。

Knowledge.upload()→KnowledgeService.upload()→PostgreSQL + MinIO→enqueue()→FastAPI→Celery Worker→process_ingestion()→Chroma→状态回调→页面轮询
  1. 用户选中文件,页面调用 upload()

    前端先挡住明显超过 20MB 的文件,再用 uploadKnowledgeDocument() 把文件和标题发给 Business 服务。页面此时只能说“正在处理”,不能说“已经能搜索”。

  2. KnowledgeController.upload() 接住文件并调用 KnowledgeService.upload()

    Controller 负责登录检查、请求次数限制和接收文件;Service 才负责真正的业务规则。userId 来自登录信息,不从表单里取。

  3. upload() 检查文件并创建文档记录

    它检查扩展名、PDF 文件头、文本编码、大小、重复内容和用户额度。通过后先在 PostgreSQL 建一条 UPLOADED 记录,再把原文件写进 MinIO。

    为什么两个地方都存:数据库记录“这是谁的文档、现在到哪一步”,MinIO 保存真正的原文件。

  4. 原文件保存成功后调用 enqueue()

    enqueue() 把文档状态改成排队中,整理对象地址、文件哈希和处理版本,然后调用 RagInternalClient.createIngestion()。重建旧索引时则调用 reindexDocument(),这是另一条入口,但后面会汇入同一套处理流程。

  5. RAG Client 给内部请求签名,再发给 FastAPI

    签名的作用可以理解为内部服务之间的防伪章:Python 服务确认请求确实来自 NestJS,而且内容没有被改。FastAPI 校验通过后只负责把任务放进 Celery 队列,并立即返回 jobId。

  6. Celery Worker 在后台调用 process_ingestion()

    Worker 先锁住这份文档,避免两个人同时处理同一份文件。然后从 MinIO 下载原文,再核对大小和哈希,确保拿到的就是用户上传的那一份。

  7. process_ingestion() 解析、切段、计算向量并写入 Chroma

    PDF 保留页码,Markdown/TXT 按文本解析;长内容切成小段后批量计算向量。全部写入后还会重新核对分片数量和 ID,只有完整一致才算成功。

  8. Worker 回调 Business,页面轮询看到最终状态

    Worker 把 PROCESSING、READY 或 FAILED 回传给 NestJS。applyRagStatus() 检查这是不是当前这次任务的回调,再更新数据库。页面每 3 秒刷新一次;看到 READY 后停止轮询并显示“可检索”。

返回方向要记住:上传接口返回“文件已保存、任务已排队”,不是等待 Worker 全部做完。真正完成的消息走的是“Worker 回调 NestJS → 页面轮询 NestJS”这条路。

代码位置速查(看完上面的主线再查)#

步骤 文件与方法 职责
1 apps/web/src/views/Knowledge/index.vue:167-218
loadDocuments() / upload()
选文件、上传、刷新列表并开启状态轮询。
2 apps/web/src/apis/knowledge/index.ts:7-33
uploadKnowledgeDocument() / getKnowledgeDocuments() / retryKnowledgeDocument() / deleteKnowledgeDocument()
构造 FormData,上传/列表/重试/删除 API。
3 server/apps/server/src/knowledge/knowledge.controller.ts:20-80
KnowledgeController.upload() / list() / get() / retry() / remove()
JWT、Redis 限流、Multer 20MB 限制、转 Service。
4 server/apps/server/src/knowledge/knowledge.service.ts:99-252
KnowledgeService.upload()
文件检查、去重、配额、PostgreSQL 元数据、MinIO 原文件。
5 server/apps/server/src/knowledge/knowledge.service.ts:711-797
KnowledgeService.enqueue()
抢 QUEUED,组内部合约,调 FastAPI,保存 jobId 或降级 FAILED。
6 server/libs/shared/src/rag/rag-internal.client.ts:42-60,107-167
createIngestion() / reindexDocument() / request()
HMAC 签名内部 HTTP、超时、有限重试。
7 server-py/app/api/rag.py:29-91
create_ingestion() / reindex_document()
创建端点直接校验并投递;重建端点先校验路径 ID 与 body ID 一致,再汇入同一套安全校验和确定性 Celery Job。
8 server-py/app/tasks/celery_app.py:117-182
ingest_document()
Redis 文档锁、永久/瞬时错误、指数退避、失败清理。
9 server-py/app/services/ingestion.py:82-218
process_ingestion()
下载、校验、解析、分片、Embedding、Chroma upsert、READY 回调。
10 server/apps/server/src/knowledge/knowledge.service.ts:610-709
KnowledgeService.applyRagStatus()
校验 trace/状态机/真实索引信息,更新业务事实。
11 apps/web/src/views/Knowledge/index.vue:179-185
schedulePolling()
只要还有中间态,3 秒后静默刷新。

前端 upload() 与轮询#

代码段 作用
apps/web/src/views/Knowledge/index.vue:167-177
loadDocuments()
读当前页并替换 list/total。判断 quiet 是为了区分“用户主动打开”和“后台轮询”,防止每 3 秒整页 loading 闪一次。
apps/web/src/views/Knowledge/index.vue:179-185
schedulePolling()
先清旧 timer,防止重复调用后同时存在多个轮询。再判断是否有 UPLOADED/QUEUED/PROCESSING/DELETING;只有任务还没结束才 3 秒后继续查,防止 READY/FAILED 后仍无限请求。
apps/web/src/views/Knowledge/index.vue:188-198
onFileChange()
取第一个文件。判断是否超过 20MB,超过就清空选择,防止明知后端会拒绝还上传大文件、浪费网络。
apps/web/src/views/Knowledge/index.vue:200-209
upload()
判断没选文件就直接返回,防空请求。上传后判断状态是否 FAILED;FAILED 说明文件可能已保存但索引没排上,因此显示“可重试”警告,防止误导用户以为已可检索。
apps/web/src/views/Knowledge/index.vue:210-218
upload()
服务端受理后清文件/标题/原生 input,回第 1 页并重读;finally 恢复按钮,防止异常后上传按钮永久 loading。
apps/web/src/apis/knowledge/index.ts:7-17
uploadKnowledgeDocument()
把 file 和非空 title 放入 FormData,将超时放宽到 120 秒,防止较大文件在正常上传中被通用短超时误杀。

KnowledgeService.upload():业务事实与原文件#

代码段 作用
server/apps/server/src/knowledge/knowledge.service.ts:99-112
KnowledgeService.upload()
判断 Multer 是否真的收到文件和非空 buffer,防止空请求继续入库;服务端再做一次大小限制,防止只相信前端或代理层限制而被超大文件拖垮。然后用 inspectFile() 检查真实内容并计算 SHA-256。
server/apps/server/src/knowledge/knowledge.service.ts:113-119
KnowledgeService.upload()
以 userId + contentHash + deletedAt=null 预查重复,给用户可理解的冲突。
server/apps/server/src/knowledge/knowledge.service.ts:121-125
KnowledgeService.upload()
生成不可猜文档 ID,清理标题,构造包含 userId/documentId 的私有 MinIO objectKey。
server/apps/server/src/knowledge/knowledge.service.ts:126-168
KnowledgeService.upload()
事务内对用户上传加锁;再查重、文档数配额和总字节配额;插入 KnowledgeDocument(status=UPLOADED)。
server/apps/server/src/knowledge/knowledge.service.ts:169-181
KnowledgeService.upload()
捕获唯一约束竞态;如果另一个并发请求已抢先插入,转成“已上传”而不是 500。
server/apps/server/src/knowledge/knowledge.service.ts:183-190
KnowledgeService.upload()
将原始 buffer 写入私有 Bucket,带 MIME 和 contentHash 元数据。
server/apps/server/src/knowledge/knowledge.service.ts:191-228
KnowledgeService.upload()
写对象抛错时先 statObject 对账,处理“已写成但客户端超时”;确实未持久化才条件改 FAILED。
server/apps/server/src/knowledge/knowledge.service.ts:230-243
KnowledgeService.upload()
入队前再确认文档仍是 UPLOADED/未删除;如用户并发删除,清 MinIO 并终止。
server/apps/server/src/knowledge/knowledge.service.ts:245-251
KnowledgeService.upload()
调 enqueue();即使 RAG 队列暂时失败,仍返回已保存文档和可重试提示。

inspectFile() 和 enqueue()#

代码段 作用
server/apps/server/src/knowledge/knowledge.service.ts:810-824
KnowledgeService.inspectFile()
判断扩展名是否为 pdf/md/txt,防止把未支持的文件交给解析器。PDF 还判断前 5 字节是否为 %PDF-,防止只改后缀名就伪装成 PDF。
server/apps/server/src/knowledge/knowledge.service.ts:826-838
KnowledgeService.inspectFile()
判断文本能否严格按 UTF-8 解码、是否含 NUL 二进制字节,防止乱码或二进制文件进入文本分片流程。
server/apps/server/src/knowledge/knowledge.service.ts:716-734
KnowledgeService.enqueue()
用条件更新判断文档是否仍为 UPLOADED;只有是才改 QUEUED。如果更新数不是 1,说明另一个请求已改状态,立即停止,防止重复入队或把删除中文档重新入队。
server/apps/server/src/knowledge/knowledge.service.ts:735-747
KnowledgeService.enqueue()
组装跨服务请求。不在 HTTP 再传大文件,只传 MinIO 定位信息和哈希,防止文件在服务间重复拷贝并便于 Worker 校验。
server/apps/server/src/knowledge/knowledge.service.ts:283-343
KnowledgeService.retry()
只允许 FAILED/READY 重试。原状态是 READY 时生成 reindex=true,表示已经有完整旧索引;原状态是 FAILED 时生成 reindex=false,表示重新创建一次入库任务。然后把这个布尔值原样交给 enqueue(),所以分支来源不是随便猜的。
server/apps/server/src/knowledge/knowledge.service.ts:748-751
KnowledgeService.enqueue()
reindex === true 明确调用 RagInternalClient.reindexDocument(request, traceId);它会请求文档专用的 /internal/v1/documents/{documentId}/reindex 端点,负责重建已经 READY 的旧索引。
server/apps/server/src/knowledge/knowledge.service.ts:748-751
KnowledgeService.enqueue()
reindex === false 明确调用 RagInternalClient.createIngestion(request, traceId);它会请求 /internal/v1/ingestions,用于新上传文档或 FAILED 文档重新创建入库任务。
server/apps/server/src/knowledge/knowledge.service.ts:752-769
KnowledgeService.enqueue()
不论上面走创建还是重建,FastAPI 都必须返回通过校验的 jobId。保存 jobId 时再判断 status/trace 仍是本任务,防止上一次慢响应把新一次重试的 jobId 覆盖掉。
server/apps/server/src/knowledge/knowledge.service.ts:770-796
KnowledgeService.enqueue()
如果 FastAPI/队列暂时不可用,只把“同 trace 且仍 QUEUED”的记录改 FAILED,防止覆盖用户已发起的新重试;原文件保留,不让短暂队列故障造成数据丢失。

RagInternalClient:创建端点和重建端点分别做什么#

代码段 作用
server/libs/shared/src/rag/rag-internal.client.ts:42-50
RagInternalClient.createIngestion()
POST /internal/v1/ingestions 创建新的入库任务,然后用 validateIngestionAccepted() 检查响应里的 documentId/jobId,防止下游回了别的文档任务。
server/libs/shared/src/rag/rag-internal.client.ts:52-60
RagInternalClient.reindexDocument()
POST /internal/v1/documents/{documentId}/reindex 重建指定文档;documentId 先 URL 编码,响应同样必须通过 validateIngestionAccepted()。它不是 createIngestion() 的别名,而是另一条明确的 FastAPI 路由。

这段三元表达式要从“问号”和“冒号”两边一起读:

const accepted = reindex
  ? await this.rag.reindexDocument(request, traceId)
  : await this.rag.createIngestion(request, traceId);
  • const accepted = ...:不管走哪条路,都把 FastAPI 返回的 { jobId, documentId } 存到 accepted,供后面保存 jobId。
  • reindex ? ...:reindex 为 true,执行问号后面的 reindexDocument()。
  • : ...:reindex 为 false,执行冒号后面的 createIngestion();这条就是之前遗漏解释的分支。
  • 它不是两个方法都执行,每次只会执行其中一个。
reindex 来源与完整去向 从前到后的完整调用链
新上传固定传 false;FAILED 文档重试也得到 false KnowledgeService.upload()/retry() → KnowledgeService.enqueue(..., false, traceId) → RagInternalClient.createIngestion() → FastAPI create_ingestion() → Celery ingest_document() → process_ingestion()
READY 文档重建得到 true KnowledgeService.retry() → KnowledgeService.enqueue(..., true, traceId) → RagInternalClient.reindexDocument() → FastAPI reindex_document() → 校验 ID 后调用 create_ingestion() → Celery ingest_document() → process_ingestion()

FastAPI create_ingestion()、reindex_document() 与 Celery ingest_document()#

代码段 作用
server-py/app/api/rag.py:29-34
_job_id()
用 documentId/contentHash/indexVersion/traceId 生成稳定 Celery task ID,让相同的一次入库请求有相同标识,减少因 HTTP 重试产生两个无关任务。
server-py/app/api/rag.py:42-65
create_ingestion()
HMAC 先确认是可信 NestJS 在调。再判断 Bucket 是否是指定私有桶、objectKey 是否真在该用户/文档目录、版本是否一致,防止 Worker 被诱导去读别人文件或写错向量 Collection。
server-py/app/api/rag.py:66-72
create_ingestion()
把 Celery 的同步 apply_async 放进线程池,防止 Redis/Broker 卡顿把 FastAPI 整个事件循环堵住;最后返回真实 jobId。
server-py/app/api/rag.py:75-91
reindex_document()
接收 /documents/{document_id}/reindex。先判断 URL 里的 document_id 是否与 body.documentId 相同,不同就拒绝,防止路径说“重建 A”、请求体却偷偷让 Worker 处理 B。相同后明确调用 create_ingestion(body, _principal),所以后续仍会经过同样的 Bucket、对象归属、索引版本检查和 Celery 投递,不是少一套安全校验。
server-py/app/tasks/celery_app.py:117-137
ingest_document()
先用 Pydantic 重新检查队列 payload,防止坏消息直接进主流程。然后尝试获取这份 documentId 的 Redis 锁;拿不到就说明另一个 Worker 正在处理同一文档,于是按锁 TTL 延后重试,防止重复解析、重复 Embedding 和向量互相覆盖。
server-py/app/tasks/celery_app.py:138-153
ingest_document()
调 process_ingestion()。判断错误是否为 PermanentIngestionError;如果文件格式、哈希、权限等永久不会自愈,就不做无意义重试,而是清向量、回调 FAILED,防止残留半套索引。
server-py/app/tasks/celery_app.py:154-174
ingest_document()
其他错误先判断已重试几次。未达上限就指数退避,给网络/Embedding/Chroma 短暂故障恢复时间,也防止立即重试形成请求风暴;到上限才清理并标 FAILED。
server-py/app/tasks/celery_app.py:175-182
ingest_document()
finally 一定释放 Redis 锁,防止下次永久处理不了这份文档。如果锁已因超时自动失效,只记警告,防止“释放一把已没有的锁”覆盖真正业务结果。

process_ingestion():解析到 READY 的每个阶段#

代码段 作用
server-py/app/services/ingestion.py:82-96
process_ingestion()
取配置和 Chroma;先判断删除 tombstone,防止用户已经删文档后,迟到的 Worker 又把向量写回来。再用 document/owner/hash/trace 判断是否已经完整入库,给重复任务走幂等快路。
server-py/app/services/ingestion.py:97-116
process_ingestion()
已完整入库就清旧 Collection,重发 READY 回调并返回,这是任务级幂等快路。
server-py/app/services/ingestion.py:117-123
process_ingestion()
先回调 PROCESSING,让 PostgreSQL/前端看到 Worker 已真正开始。
server-py/app/services/ingestion.py:124-142
process_ingestion()
在线程池读 MinIO;区分原对象不存在;重新校验大小、请求 size 和 SHA-256,防止中途替换/损坏。
server-py/app/services/ingestion.py:143-152
process_ingestion()
parse_document() 按 PDF/TXT/MD 解析为带页码 Section,split_sections() 按边界和 overlap 分片;输入问题转永久错误。
server-py/app/services/ingestion.py:154-159
process_ingestion()
按 embedding_batch_size 分批生成向量,避免一次把过多文本打给 Provider。
server-py/app/services/ingestion.py:160-177
process_ingestion()
写向量前再查删除;清当前文档旧向量并验 remaining=0;将 chunks/vectors/所有者/标题/哈希/trace upsert 到 Chroma。
server-py/app/services/ingestion.py:178-191
process_ingestion()
写入后第三次判断删除竞态;如果用户刚好在写向量期间删除了文档,就把所有 Collection 中的向量清干净,防止“页面上已删除、知识库里还能搜到”。然后再删除旧 Collection 副本。
server-py/app/services/ingestion.py:192-205
process_ingestion()
组 READY 回调,包含真实 chunkCount/Provider/Model/indexVersion;如 Business 以 4xx 拒绝 READY,立即清向量防止孤儿。
server-py/app/services/ingestion.py:206-218
process_ingestion()
记录分片数、Embedding 批次和总耗时,返回回调对象。

applyRagStatus():为什么迟到回调不能乱改状态#

代码段 作用
server/apps/server/src/knowledge/knowledge.service.ts:610-637
KnowledgeService.applyRagStatus()
手工检查 callback 结构和字段类型,路径 documentId 必须与 body 一致,状态只允许 PROCESSING/READY/FAILED。
server/apps/server/src/knowledge/knowledge.service.ts:638-651
KnowledgeService.applyRagStatus()
文档必须未删且非 DELETING;traceId 格式正确且与当前 ingestionTraceId 完全一致;再验状态转移。
server/apps/server/src/knowledge/knowledge.service.ts:653-679
KnowledgeService.applyRagStatus()
PROCESSING 清错误;READY 必须带正整数 chunkCount 与 Provider/Model/Version,版本必须和服务端一致。
server/apps/server/src/knowledge/knowledge.service.ts:680-692
KnowledgeService.applyRagStatus()
FAILED 清索引信息,对错误码/文案做清洗和长度限制。
server/apps/server/src/knowledge/knowledge.service.ts:693-708
KnowledgeService.applyRagStatus()
updateMany where 同时带 id/trace/旧 status,实现 CAS;受影响非 1 说明并发变化,拒绝覆盖。

3A.6 私人 RAG 检索、多重权限与可信引用#

用户提问以后,系统怎样只搜索他的资料,并证明回答引用了哪一段

这条链不是“把所有 PDF 塞给模型”。系统先从数据库得到用户真正有权使用的文档名单,再到向量库里只搜索这些文档;结果返回后还要复查一次,最后只展示回答真正用到的来源。

validateDocumentScope()→prefetchKnowledge()→searchKnowledge()→FastAPI search→Chroma.search_private()→validateSearchResult()→K1/K2 给模型→finalizeGroundedCitations()→前端来源卡片
  1. Runner 先调用 validateDocumentScope() 生成允许搜索的文档名单

    用户没选文档时,只取他自己的、已经处理完成且未删除的文档;用户指定了 ID 时,数量必须和数据库查到的合法文档完全一致。为什么先做:不能把前端传来的 documentId 当成权限证明。

  2. Runner 调用 AgentRagService.prefetchKnowledge()

    这个方法在模型回答前先搜索一次。如果问题只是“这个呢?”之类短句,它会补上最近的提问再搜索;补完反而没结果时,再用原句试一次。

    真正跨服务搜索由 RagInternalClient.searchKnowledge() 完成。

  3. searchKnowledge() 把问题、userId 和文档名单发给 FastAPI

    请求带内部签名和总超时。Python 返回后,NestJS 不会直接相信结果,而是调用 validateSearchResult() 检查条数、版本、分数以及每条结果是否仍在刚才的文档名单内。

  4. FastAPI 把问题变成向量,再调用 search_private()

    Chroma 搜索时同时带上“当前用户、私人资料、允许的文档 ID”三个限制。它会多取一些候选,再删掉低分和重复内容,最后只保留最相关的几段。

  5. 搜索结果原路返回,NestJS 登记真实来源

    captureSources() 对重复分片去重,并通过 SSE 把来源发给前端。随后 modelVisibleSources() 把内部 ID 换成 K1、K2 这种临时编号,模型不需要看到真实存储标识。

  6. 模型根据资料生成回答,并写出类似 [[source:K1]] 的标记

    如果模型觉得还需要搜索,也可以调用只读的 search_knowledge_base 工具;但 userId 和文档名单仍由服务端放进去,模型不能自己扩大搜索范围。

  7. finalizeGroundedCitations() 最后检查引用真假

    它只接受本轮确实搜索到的编号,模型编出来的文件或页码会被删除。最后把合法标记换成“资料 1”,并只把答案实际使用的来源返回给前端。

  8. 前端展示回答和可点击来源卡片

    用户看到文件名、页码、相关度和原文片段。这样回答不是只有一句“根据资料”,而是可以追到具体证据。

搜索失败和没有结果是两回事:没有结果会明确显示“没有找到相关内容”;RAG 服务故障会标成“暂时不可用”。代码不会把系统故障假装成用户没有资料,更不会在没有真实来源时伪造引用。

代码位置速查(看完上面的主线再查)#

步骤 文件与方法 职责
1 server/apps/ai/src/agent/agent-runner.service.ts:498-529
AgentRunnerService.validateDocumentScope()
从 PostgreSQL 构造当前用户 READY 文档白名单。
2 server/apps/ai/src/agent/agent-rag.service.ts:22-128
AgentRagService.prefetchKnowledge()
运行模型前检索,追问时扩展 query,失败明确降级。
3 server/apps/ai/src/agent/agent-tools.service.ts:201-252
AgentToolsService.createTools() 内的 search_knowledge_base Tool
模型可主动调的只读 Tool,但 userId/documentIds 由服务端闭包注入。
4 server/libs/shared/src/rag/rag-internal.client.ts:77-90,293-360
RagInternalClient.searchKnowledge() / validateSearchResult()
HMAC 检索,回包后再验 query/版本/条数/文档白名单/分数。
5 server-py/app/api/rag.py:134-174 search_knowledge() 限 topK/minScore/总超时,调 Chroma。
6 server-py/app/vectorstore/chroma_store.py:432-508
ChromaStore.search_private()
owner + PRIVATE + document allowlist 向量检索、阈值和去重。
7 server/apps/ai/src/agent/agent-rag.service.ts:130-142
AgentRagService.captureSources()
对 chunkId 去重,登记真实来源并发 SSE source。
8 server/apps/ai/src/agent/agent-rag.service.ts:144-164,192-239
modelVisibleSources() / finalizeGroundedCitations()
映射 K1/K2 给模型,最后只保留真实使用的引用。

validateDocumentScope() 和 prefetchKnowledge()#

代码段 作用
server/apps/ai/src/agent/agent-runner.service.ts:498-507
AgentRunnerService.validateDocumentScope()
判断 documentIds 是否真的是字符串数组,然后 trim、去重、限制最多 100 份/每个 ID 64 字符。这是为了防止恶意大数组拖垮数据库,也防止非字符串让后续 .trim() 直接崩溃。
server/apps/ai/src/agent/agent-runner.service.ts:508-516
AgentRunnerService.validateDocumentScope()
判断用户是否没指定文档。没指定时由服务端只查“这个用户、READY、未删除”的最新 100 份,防止空列表被理解成“允许搜全站文档”。
server/apps/ai/src/agent/agent-runner.service.ts:517-529
AgentRunnerService.validateDocumentScope()
有指定 ID 时,查这些 ID 中同时属于当前 user、READY、未删的数量。数量对不上就整批拒绝,防止越权搜别人文档,也不分别告诉客户端哪个 ID 属于别人。
server/apps/ai/src/agent/agent-rag.service.ts:35-43
AgentRagService.prefetchKnowledge()
判断白名单是否为空。为空就返 NO_DOCUMENTS,防止发一个没有权限范围的向量检索;不为空才告诉前端开始检索。
server/apps/ai/src/agent/agent-rag.service.ts:44-60,241-263
AgentRagService.prefetchKnowledge() / contextualRetrievalQuery()
先判断这句是否像“这个呢?”这种短追问;是就补上一条 USER 消息,防止检索词过短、向量召回失去上下文。调 RAG 时的 userId 和文档白名单都是服务端注入,不信前端。
server/apps/ai/src/agent/agent-rag.service.ts:61-77
AgentRagService.prefetchKnowledge()
判断“扩展后的追问”是否零结果且和原句不同;是就用原句补试一次,防止补上下文反而把召回带偏。判断 Abort 是为了防止用户已取消还继续浪费 Embedding/检索资源。
server/apps/ai/src/agent/agent-rag.service.ts:78-103
AgentRagService.prefetchKnowledge()
把真实结果登记成本轮可用来源,写审计并通知前端。结果数是 0 时明确说“没找到相关内容”,防止错说成“用户没有知识库”。
server/apps/ai/src/agent/agent-rag.service.ts:104-127
AgentRagService.prefetchKnowledge()
判断错误是否由用户 Abort 引起;是就继续抛出让整个 Run 取消。其他 RAG 故障降级为 UNAVAILABLE,防止知识库暂时不可用就把整个对话伪装成“没有资料”或编造引用。

RagInternalClient.request() 与 validateSearchResult()#

代码段 作用
server/libs/shared/src/rag/rag-internal.client.ts:115-121
RagInternalClient.request()
计算一个“整个请求最晚到什么时候必须结束”的 deadline。只有明确标记 retryable 的读/幂等请求才重试,防止写操作被无意重复执行。
server/libs/shared/src/rag/rag-internal.client.ts:123-143
RagInternalClient.request()
每次请求前判断用户是否已取消、是否已超总时限,防止无意继续重试。HMAC 覆盖精确 method+path+body,防止签名被拿去调另一个接口或篡改请求体。
server/libs/shared/src/rag/rag-internal.client.ts:144-166
RagInternalClient.request()
判断 HTTP 是否成功;非瞬时 4xx 立即结束,因为参数/权限错误重试也不会变好。只对 408/425/429/5xx 退避重试,防止短暂故障直接失败,但总 deadline 又防止无限等待。
server/libs/shared/src/rag/rag-internal.client.ts:297-311
RagInternalClient.validateSearchResult()
判断回包 query 是否和请求一样、结果是否超 topK、Embedding 模型/索引版本是否正确。这是为了防止下游错服务、旧版索引或异常回包被当成可信资料。
server/libs/shared/src/rag/rag-internal.client.ts:312-354
RagInternalClient.validateSearchResult()
逐条判断 chunkId 是否唯一、documentId 是否在白名单,并检查标题/页码/内容/分数的类型和边界。最重要的是防止 Python/Chroma 因 Bug 把其他用户或未选文档的分片送进 Agent。
server/libs/shared/src/rag/rag-internal.client.ts:355-360
RagInternalClient.validateSearchResult()
只组装通过上述全部判断的字段,防止把下游多返的未知/敏感字段一起透传给模型和前端。

FastAPI/Chroma 检索和服务端引用闭环#

代码段 作用
server-py/app/api/rag.py:138-151
search_knowledge()
判断 topK 是否超过服务端上限,超了就压低,防止一次拉太多分片消耗内存和模型 Token。asyncio.timeout 防止 Chroma/Embedding 卡住后永久占着 FastAPI 请求。
server-py/app/api/rag.py:152-174
search_knowledge()
判断是否超时;超时统一转 504,防止上游只看到混乱底层异常。日志只记 user hash,防止真实用户 ID 进日志。
server-py/app/vectorstore/chroma_store.py:441-455
ChromaStore.search_private()
判断 document allowlist 是否为空;空则拒绝,防止“没有过滤条件”意外变成搜全库。向量维度不一致也拒绝,防止用错 Embedding 模型得到假相似度。
server-py/app/vectorstore/chroma_store.py:456-470
ChromaStore.search_private()
先多取 topK 的 3 倍,是因为后面还要删低分和重复内容;上限 100 又防止为了去重而无限扩大查询。
server-py/app/vectorstore/chroma_store.py:471-507
ChromaStore.search_private()
Chroma 已过滤,回包后仍再判断 owner/visibility/kind/document,防止向量库过滤 Bug 导致越权。低分、同哈希、近似文本都跳过,防止低质量/重复资料浪费上下文。
server/apps/ai/src/agent/agent-rag.service.ts:130-142
AgentRagService.captureSources()
判断 chunkId 是否已登记;已有就跳过,防止预检索和 Tool 检索召回同一分片后出现重复引用。
server/apps/ai/src/agent/agent-rag.service.ts:144-164
AgentRagService.modelVisibleSources()
判断来源是否在服务端权威引用集;不在就报错,防止未登记/伪造来源进模型。模型只看 K1/K2,不需要知道内部 ID。
server/apps/ai/src/agent/agent-rag.service.ts:192-222
AgentRagService.finalizeGroundedCitations()
对模型输出里的每个 [[source:...]] 判断是否在本轮 K 编号/chunkId 白名单。不在就删掉,防止模型自己编文件或页码;真实引用转成用户可读的 [资料 1]。
server/apps/ai/src/agent/agent-rag.service.ts:223-239
AgentRagService.finalizeGroundedCitations()
兼容模型直接输出 K1/chunkId 的情况;只有真匹配才替换。最后删除 documentId 类内部标识,并只返回答案实际用到的 citations,防止展示“检索了但没使用”的假依据。

3A.7 学习中心、计划/任务、测验与复习算法#

这部分也要拆开看:打开学习中心是一条读数据流程,做测验是一条完整写数据流程

学习计划如何创建和激活已经在上一条 Action 链讲过。这里重点顺着“生成测验 → 用户答题 → 服务端评分 → 更新掌握度 → 刷新页面”走一遍。

打开页面:只读取当前学习状态

Study.loadAll() 同时请求画像、计划、任务、薄弱词、到期复习和历史成绩。它们互不依赖,所以一起发比一个一个等更快。页面发现有未提交的测验时直接恢复,不会偷偷再生成一份。

createQuiz()→StudyController.generateQuiz()→StudyService.generateQuiz()→StudyDomain.generateQuiz()→公开题面→gradeQuiz()→submitQuiz()→updateMastery()→loadAll()
  1. 用户选择“薄弱词”或“到期复习”,页面调用 createQuiz()

    页面通过 generateQuiz(10, mode) 请求 10 道题。Controller 做登录检查和请求次数限制,Service 检查题数和模式,再调用领域层的 generateQuiz()。

  2. generateQuiz() 根据模式找候选词

    “薄弱词”查掌握分低于 80 的词;“到期复习”只查已经到复习时间的词。没有释义的词不能做选择题,会被跳过;候选太少时返回明确提示。

  3. 服务端生成题目并保存带答案的快照

    每题放一个正确选项和几个干扰项,然后打乱。完整答案只保存在数据库;返回前调用 publicQuestion() 删除正确答案和解释,所以浏览器看不到答案。

  4. 用户答完后,页面调用 gradeQuiz()

    页面先检查每题是否都选了答案,然后调用 submitQuiz() API,并带上一个本次提交专用的防重复标记。这个标记让双击按钮或网络重试不会产生两份成绩。

  5. 后端 submitQuiz() 只用服务端保存的答案评分

    它检查测验属于当前用户、没有过期、没有提交过;还检查每个选项确实属于对应题目。评分时不相信前端提供的正确答案,只读取生成测验时保存的快照。

  6. 每道题调用 updateMastery() 更新单词掌握情况

    答对会提高掌握分并逐步拉长下次复习间隔;答错会降低掌握分并尽快安排复习。新分同时参考历史表现和本次结果,避免一次答对或答错让长期水平剧烈跳变。

  7. 成绩、单词状态和学习天数一起保存

    这些修改放在同一次数据库提交中,避免出现“成绩已经有了,但只更新了一半单词”。成功后返回评分结果。

  8. 前端显示成绩,再调用 loadAll() 刷新页面

    重新读取后,画像、薄弱词、下次复习时间和历史成绩都会变成最新值,页面不会继续显示考试前的旧数据。

代码位置速查(页面读取、计划和测验)#

步骤 文件与方法 职责
1 apps/web/src/views/Study/index.vue:298-331
loadAll()
并行加载画像、计划、任务、薄弱词、复习、测验,同步打卡天数。
2 apps/web/src/apis/study/index.ts:20-66
getStudyProfile() / generateQuiz() / submitQuiz() 等
学习中心全部 Business API 薄封装。
3 server/apps/server/src/study/study.controller.ts:17-107
StudyController 各路由方法
统一 AuthGuard,生成/提交测验加 Redis 限流。
4 server/apps/server/src/study/study.service.ts:17-137
StudyService 各门面方法
HTTP 门面:校验参数,组合 LearningTools/StudyDomain,包统一响应。
5 server/libs/shared/src/learning/study-domain.service.ts:49-165
createPendingPlan() / activatePlan() / cancelPlan() / listTasks()
创建/激活/取消计划,列任务。
6 server/libs/shared/src/learning/study-domain.service.ts:168-260
StudyDomainService.generateQuiz()
从真实薄弱/到期词选题,保存带答案快照,只返公开题面。
7 server/libs/shared/src/learning/study-domain.service.ts:296-415
StudyDomainService.submitQuiz()
幂等提交、会话锁、评分、写 Attempt、更新掌握度。
8 server/libs/shared/src/learning/study-domain.service.ts:417-488
StudyDomainService.updateMastery()
按正误计算平滑分、easeFactor、复习间隔和掌握词总数。
9 server/libs/shared/src/learning/study-check-in.service.ts:11-37
StudyCheckInService.syncDayNumber()
按上海日历日从真实学习事实重算天数。

页面 loadAll()、createQuiz() 和 gradeQuiz()#

代码段 作用
apps/web/src/views/Study/index.vue:298-316
loadAll()
用 Promise.all 并行发 7 个互不依赖的读请求,防止串行等待让页面加载时间变成 7 个请求耗时的总和。
apps/web/src/views/Study/index.vue:317-330
loadAll()
画像里的 dayNumber 是数字才同步 Store,防止异常值污染页头统计。判断当前是否已有未提交 Quiz;有就恢复它,防止刷新页面后同一测验丢失或又生成一份。
apps/web/src/views/Study/index.vue:353-364
createQuiz()
开始时置 loading,请求 10 道指定模式题并清上次答案/结果。finally 一定恢复按钮,防止生成失败后界面永久禁用。
apps/web/src/views/Study/index.vue:366-377
gradeQuiz()
判断是否每题都已选答案,没答完就不提交,防止服务端收到不完整答卷。幂等键绑定 sessionId + 本次 UUID,用于识别用户双击/网络重试是否同一次提交。
apps/web/src/views/Study/index.vue:378-383
gradeQuiz()
保存服务端评分后重读画像/复习/历史,防止界面继续显示评分前的旧掌握度和复习时间。

generateQuiz():答案为什么不会发给前端#

代码段 作用
server/libs/shared/src/learning/study-domain.service.ts:168-175
StudyDomainService.generateQuiz()
把题数强制压到 1~20,防止请求负数/过多题拖垮查询并产生巨大 JSON。有 conversationId 时校验归属,防止用别人会话关联测验。
server/libs/shared/src/learning/study-domain.service.ts:177-190
StudyDomainService.generateQuiz()
根据模式判断是查“到复习时间”还是“掌握分低于 80”。多取 3 倍候选是为后续过滤无释义词留余量,防止最后题数无故不足。
server/libs/shared/src/learning/study-domain.service.ts:191-201
StudyDomainService.generateQuiz()
判断词条是否有可作答案的释义,没有就过滤,防止出一道没正确答案的题。如果最后一个候选都没有,直接返明确业务提示。
server/libs/shared/src/learning/study-domain.service.ts:202-212
StudyDomainService.generateQuiz()
选项池先去重;判断少于 2 个不出题,因为只有一种释义时没法生成有意义的选择题。
server/libs/shared/src/learning/study-domain.service.ts:214-236
StudyDomainService.generateQuiz()
给每题放 1 个正确项 + 最多 3 个干扰项并打乱;不可猜 ID 防止前端通过固定规则猜答案。正确答案只保留在服务端快照。
server/libs/shared/src/learning/study-domain.service.ts:237-253
StudyDomainService.generateQuiz()
新测验 30 分钟过期。对用户加锁后先过期其他未提交会话,防止连点两次同时产生两份“当前测验”。
server/libs/shared/src/learning/study-domain.service.ts:254-260
StudyDomainService.generateQuiz()
返回前调 publicQuestion() 删除 correctOptionId/explanation,防止用户打开浏览器开发者工具就看到正确答案。

submitQuiz() 与 updateMastery()#

代码段 作用
server/libs/shared/src/learning/study-domain.service.ts:302-323
StudyDomainService.submitQuiz()
检查 sessionId/幂等键格式和答案数量/字段长度,防止脏数据、超大请求或恶意长字符串进入锁与事务。
server/libs/shared/src/learning/study-domain.service.ts:324-335
StudyDomainService.submitQuiz()
事务外先查幂等键。找到后判断 userId 和 sessionId 是否都一样;一样说明是同一次重复提交,直接返原结果,否则拒绝,防止别人复用这个键取结果。
server/libs/shared/src/learning/study-domain.service.ts:337-351
StudyDomainService.submitQuiz()
事务内按固定顺序锁测验和掌握度,防止多个提交互相死锁/交叉更新。加锁后再查一次幂等键,关闭“事务外查完到进锁之间”的并发窗口。
server/libs/shared/src/learning/study-domain.service.ts:352-370
StudyDomainService.submitQuiz()
只查当前用户的 Session,防越权。已有 Attempt 直返,防重复评分;过期拒绝,防答卷长期可用;Map 拒绝同题重复答案,题数对不上则拒绝,防漏题。
server/libs/shared/src/learning/study-domain.service.ts:372-385
StudyDomainService.submitQuiz()
判断每个 optionId 真属于该题,防止用户手工传入别题/伪造选项。然后只用服务端快照里的正确答案评分,不信前端。
server/libs/shared/src/learning/study-domain.service.ts:386-399
StudyDomainService.submitQuiz()
写不可变的 QuizAttempt 快照和唯一幂等键,既便于日后审计当时题目,也由数据库唯一约束兜底防重复提交。
server/libs/shared/src/learning/study-domain.service.ts:400-414
StudyDomainService.submitQuiz()
更新每个词的掌握度、标记 Session 已提交、重算 dayNumber,所有操作放同一事务,防止出现“测验已有分数,但只更新了一半单词”。
server/libs/shared/src/learning/study-domain.service.ts:423-447
StudyDomainService.updateMastery()
判断旧记录是否存在,不存在就用中性默认分起步。新分用 70% 历史 + 30% 本次,防止一次答对/答错就让长期掌握度剧烈跳变;答错缩短复习间隔,高分才逐步拉长。
server/libs/shared/src/learning/study-domain.service.ts:448-476
StudyDomainService.updateMastery()
upsert 判断有记录就更新、没有就创建,防止并行先查后插造成重复的“用户×单词”记录。
server/libs/shared/src/learning/study-domain.service.ts:477-487
StudyDomainService.updateMastery()
判断是否真正跨过 80 分掌握线;只在跨线时增/减 User.wordNumber,防止每次复习都重复加减。减少时要求 wordNumber > 0,防负数。
server/libs/shared/src/learning/study-check-in.service.ts:15-37
StudyCheckInService.syncDayNumber()
把三种真实学习事实转成上海日历日,用 UNION 去重后计数,防止同一天学 10 次被算成 10 天,也防止 UTC 跨日导致中国用户打卡日期错位。

3A.8 课程支付、支付回调、WebSocket 通知与埋点#

支付和埋点不是前后相接的同一条链,必须分成两条讲

支付链解决“下单后如何以支付平台的回调为准,并及时通知页面”;埋点链解决“页面行为如何被采集并写入数据库”。它们只是都出现在 App 和课程页面附近,不应该被误解为支付成功后才开始埋点。

支付主线
handleBuy()→Pay.onConfirm()→PayController.createOrder()→PayService.createOrder()→支付宝页面→notify()→CourseRecord→WebSocket→前端提示成功
  1. 用户点击购买,handleBuy() 决定是否打开支付弹窗

    已购买就直接进入课程;未登录先登录;只有登录且未购买才打开 Pay 组件。弹窗打开时开始监听当前用户的 paymentSuccess 消息。

  2. 用户确认付款,Pay.onConfirm() 调用 createOrder()

    请求经过 Controller 到达 PayService.createOrder()。服务端先检查用户是否已经买过,再创建本地订单,最后调用支付宝 SDK 生成支付地址并返回给前端。

  3. 前端打开支付宝页面,但不会自己把课程标成已购买

    这是因为“用户打开付款页”不等于“已经付款”。本地页面只进入等待状态,并展示订单倒计时。

  4. 付款后,支付宝服务器调用 PayController.notify()

    这个请求不是浏览器发出的。Controller 把通知交给 PayService.notify(),后者找到本地订单,更新支付状态并创建课程购买记录。

  5. 服务端调用 emitPaymentSuccess() 通知当前用户

    Socket Gateway 按 userId 找到对应房间并发送成功消息。Pay 组件收到后关闭等待状态、提示成功并刷新课程信息。这样页面不需要一直轮询订单。

当前必须诚实说明的缺口:现有支付回调还没有完整校验支付宝签名、金额和商户号,因此这条链只能作为流程展示,不能宣称已经达到真实支付上线标准。
埋点主线(与支付独立)
App 初始化 Tracker→取得访客 ID→监听页面/点击/错误/性能→Beacon 或 fetch 上报→TrackerController→TrackerService 落库

应用启动时就创建 Tracker:先取得匿名访客 ID,再安装页面访问、点击、错误和性能监听。事件发生后才调用相应的上报方法;后端按事件类型写入不同数据表。用户登录后只是把匿名访客和 userId 关联起来,不会重新创建一套访客记录。

代码位置速查(支付与埋点是两条独立流程)#

步骤 文件与方法 职责
1 apps/web/src/views/Course/index.vue:118-128
handleBuy()
已购直接学习,未登录唤起登录,其他打开 Pay。
2 apps/web/src/views/Course/components/Pay.vue:121-170
watch(modelValue) / onConfirm() / close() / tips()
监听支付 WebSocket,创建订单,打开支付页,管理超时。
3 apps/web/src/apis/pay/index.ts:5-8
createOrder()
POST /pay/create-order。
4 server/apps/server/src/pay/pay.controller.ts:11-20
PayController.createOrder() / notify()
下单需 JWT;支付平台 notify 为外部回调入口。
5 server/apps/server/src/pay/pay.service.ts:25-78
PayService.createOrderNo() / createOrder()
防已购、建 PaymentRecord、生成支付宝链接和过期时间。
6 server/apps/server/src/pay/pay.service.ts:80-107
PayService.notify()
更新支付成功、创建 CourseRecord、推送成功事件。
7 server/apps/server/src/socket/socket.gateway.ts:14-24
SocketGateway.handleConnection() / emitPaymentSuccess()
连接加入用户房间,向房间发 paymentSuccess。
8 apps/web/src/App.vue:12-46 watch()
→ apps/tracker/index.ts:9-40 Tracker.init() / setUserId()
初始化埋点,登录后关联 userId,同时管理 Socket 连接。
9 apps/tracker/src/uv/index.ts 等
getFingerprint() / reportPv() / reportEvent() / reportError() / reportPerformance()
UV/PV/点击/错误/Web Vitals 采集,Beacon 上报。
10 server/apps/server/src/tracker/tracker.controller.ts:12-50 TrackerController 各路由
→ server/apps/server/src/tracker/tracker.service.ts:20-110 TrackerService 各落库方法
将各类埋点落到 Prisma 表。

Pay 组件与 Socket Hook#

代码段 作用
apps/web/src/views/Course/components/Pay.vue:121-139
watch(modelValue)
判断弹窗是打开还是关闭。打开时,先判断 Socket 是否存在,存在才监听 paymentSuccess;这样能防止对空对象调用方法导致页面报错。关闭时再判断 Socket 是否存在,然后移除监听,防止每次打开弹窗都多绑一次,最后一次付款弹出多次“支付成功”。
apps/web/src/views/Course/components/Pay.vue:142-158
onConfirm()
把当前课程拼成下单参数并调用 createOrder()。判断返回码是不是 200:成功才打开支付页并启动倒计时;失败则展示后端消息并恢复按钮,防止下单失败却让页面一直显示“支付中”。
apps/web/src/views/Course/components/Pay.vue:160-170
close() / tips()
close() 关闭弹窗并清空支付中、过期时间;tips() 在倒计时结束时提醒重新下单并重置状态,防止用户继续拿已经过期的支付链接操作。
apps/web/src/hooks/useSocket.ts:7-21
useSocket().connect()
先判断有没有登录用户,没有就不连接,防止匿名连接占资源;再判断全局 Socket 是否已经存在,存在就不重复创建,防止一个页面同时保留多条连接、收到重复消息。随后只用 WebSocket,并设置最多 5 次退避重连。
apps/web/src/hooks/useSocket.ts:24-40
useSocket().disconnect() / getSocket()
退出登录时,先判断 Socket 是否存在,再断开、清掉所有事件监听并置空,防止旧账号的监听残留到新账号;getSocket() 只负责把当前连接交给组件使用。

PayService.createOrder() 和 notify()#

代码段 作用
server/apps/server/src/pay/pay.service.ts:25-28
PayService.createOrderNo()
用 ORD- + 12 位 nanoid 生成本地商户订单号,让每笔支付都能用一个较难撞车的编号追踪。
server/apps/server/src/pay/pay.service.ts:30-40
PayService.createOrder()
先按 userId+courseId 查询是否已经购买。判断到已购就直接返回错误,防止用户重复买同一课程、生成无意义订单。注意:这只是业务层先查,并不能完全代替数据库唯一约束。
server/apps/server/src/pay/pay.service.ts:42-53
PayService.createOrder()
在事务里创建 PaymentRecord,保存用户、商户单号、标题、描述和金额。事务保证这一段失败时整体回滚,防止留下半条支付记录。
server/apps/server/src/pay/pay.service.ts:54-71
PayService.createOrder()
设置 2 分钟过期时间,调用支付宝 pageExecute() 生成支付链接;把订单、金额、标题和 userId/courseId 放进请求,并指定服务端回调地址。超时限制是为了防止旧订单长期有效。
server/apps/server/src/pay/pay.service.ts:72-78
PayService.createOrder()
返回支付链接和毫秒级过期时间。前端只负责跳转,不会自己把课程标成已购,防止用户伪造前端状态绕过真实支付结果。
server/apps/server/src/pay/pay.service.ts:80-91
PayService.notify()
收到支付平台回调后,在事务里按商户订单号找到记录并写入付款时间、成功状态和支付宝交易号。这里当前没有先判断验签、金额、商户号和旧状态,无法防止伪造回调或重复回调,是需要补强的地方。
server/apps/server/src/pay/pay.service.ts:93-102
PayService.notify()
解析回调里的 userId/courseId,创建已购 CourseRecord,再通过 paymentRecordId 关联付款事实。当前使用 create() 而不是幂等 upsert(),重复通知可能重复创建或触发唯一键错误。
server/apps/server/src/pay/pay.service.ts:103-107
PayService.notify()
向该用户的 Socket 房间推送支付成功,事务完成后给支付平台返回 true。用途是让前端不用不停轮询就能马上更新;但推送发生在事务回调内部,严格做法应在事务真正提交后再通知,防止极端情况下先通知、后回滚。

支付链审查重点:当前 notify() 代码中未看到支付宝回调验签、金额/商户号校验和幂等 upsert;Socket 也直接信任 handshake query 的 userId。这两点是演示项目的高优先级安全改造项,面试时应主动说明。

Tracker SDK:从浏览器到数据库#

代码段 作用
apps/web/src/App.vue:12-30
new Tracker()
配置统一 baseUrl 和 UV、PV、事件、错误、性能接口路径;创建 Tracker 后立刻启动匿名采集。
apps/web/src/App.vue:31-46
watch(() => userStore.user?.id)
监听登录用户 ID。判断有 ID 时,把匿名访客关联到用户并连接 Socket;没有 ID 时断开 Socket,防止退出后仍以旧用户身份接收支付通知。immediate: true 是为了页面首次加载就立即同步一次。
apps/tracker/index.ts:9-16
Tracker.constructor()
保存配置、访客 ID 和初始化 Promise,并调用 init();这是 SDK 的统一启动入口。
apps/tracker/index.ts:18-30
Tracker.init()
先判断 initPromise 是否已经存在,存在就复用,防止同时初始化多次、重复安装全局监听。首次初始化先取指纹并让服务端 upsert Visitor,拿到 visitorId 后才安装事件、错误、PV 和性能监听,防止上报的数据没有访客归属。
apps/tracker/index.ts:32-40
Tracker.setUserId()
登录可能发生在指纹初始化完成前,所以先等待 init();再上报 visitorId+userId,把匿名访问和真实账号关联起来,防止关联请求带着空 visitorId。
apps/tracker/src/uv/index.ts:15-27
getFingerprint()
UAParser 取得浏览器、系统、设备,FingerprintJS 生成 anonymousId,再用 fetch 等服务端返回 Visitor 主键。服务端使用 upsert,所以同一匿名指纹再次访问时更新旧记录,而不是每次新增一名访客。
apps/tracker/src/pv/index.ts:4-40
reportView() / reportPv()
首次进入就上报 URL、来源页和路径;判断 URL 是否含 #,是为了正确记录 Hash 路由。再监听 hashchange、前进后退,并包装 pushState/replaceState,防止 SPA 切页不刷新浏览器而漏记 PV。
apps/tracker/src/event/index.ts:4-38
reportEvent()
监听全局点击。判断目标是不是 BUTTON,或是不是 BUTTON 里面的 SPAN,只上报按钮点击,防止页面任意区域的点击都变成大量无意义数据;随后记录位置、大小和文字并通过 Beacon 上报。
apps/tracker/src/error/index.ts:4-29
reportError()
分别捕获普通 JS 错误和未处理 Promise 拒绝。对 Promise 原因判断是不是 Error:是就读取 message/stack,不是就转 JSON,防止直接访问不存在的属性再次报错。
apps/tracker/src/performance/index.ts:5-65
reportPerformance()
读取 FP/FCP,判断指标条目存在后才取时间,防止浏览器没提供该指标时访问空值;用 PerformanceObserver 取 LCP、web-vitals 取 INP/CLS。只有页面变成 hidden 时才统一 Beacon 上报,尽量拿到完整会话指标,也防止过程里频繁请求。
apps/tracker/src/report/index.ts:1-16
report() / reportFetch()
普通埋点用 sendBeacon(),减少关闭页面时请求被浏览器取消;需要服务端返回 visitorId 的 UV/关联请求用 fetch(..., keepalive: true)。两种方法用途不同,不能一律用 Beacon。
server/apps/server/src/tracker/tracker.service.ts:20-110
TrackerService.updateUv() / error() / event() / pv() / performance() / uv()
updateUv() 关联登录用户;其余方法分别写错误、点击、PV、性能。uv() 按 anonymousId 做 upsert:存在就更新,不存在才创建,防止同一匿名访客每刷新一次就产生一条重复 Visitor;最后返回内部 visitorId 给后续埋点使用。

3A.9 八条链路如何快速记忆#

链路 一句话记忆 核心数据表/外部存储
登录 表单 → JWT → Pinia → 拦截器刷新 → Guard User、JWT
课程背词 课程购买权 → 待学词 → 用户锁 → 掌握度 CourseRecord、WordBookRecord、User
Agent SSE 前端占位 → POST SSE → Run 抢占 → LangGraph 增量 → 事务落库 AiConversation、AiMessage、AiRun
Action Tool 只准备 → 用户确认 → 租约抢执行 → 回执/恢复 AgentAction、StudyPlan、StudyTask
文档入库 PostgreSQL+MinIO → HMAC FastAPI → Celery → 解析/Embedding → Chroma → 回调 KnowledgeDocument、MinIO、Redis、Chroma
RAG READY 白名单 → HMAC → owner+document 过滤 → 召回 → K 编号 → 最终引用校验 KnowledgeDocument、Chroma、AiMessage.citations
学习闭环 画像 → 出题快照 → 幂等提交 → 更新掌握度/复习时间 QuizSession、QuizAttempt、WordBookRecord
支付埋点 本地订单 → 支付平台 → notify → 购买记录 → Socket;浏览器事件 → Beacon → Prisma PaymentRecord、CourseRecord、Visitor/PV/Event/Error/Performance

4. Vue 前端代码地图#

4.1 启动、路由、状态与网络层#

文件与行号 方法/代码 用途
apps/web/src/main.ts:11 createPinia/createApp 注册 Pinia 持久化、Element Plus、Router、自定义 focus 指令并挂载应用。
apps/web/src/router/index.ts:10 createRouter() 汇总首页、词库、设置、聊天、课程、知识库、学习中心路由。
apps/web/src/stores/user.ts:5 useUserStore 保存用户资料和 Token,提供登录态 getter、更新与退出。
apps/web/src/apis/index.ts:10 serverApi Business Axios 实例,统一 Bearer Token、刷新、错误提示。
apps/web/src/apis/index.ts:62 aiApi Agent Axios 实例,访问 /ai/v1。
apps/web/src/apis/token.ts:6 ensureAccessToken() 判断 JWT 是否即将过期,并用单例 Promise 避免并发刷新风暴。
apps/web/src/apis/sse/index.ts:11 streamAgentMessage() 建立 Agent SSE POST 流,处理 401 刷新、消息解析、错误和取消。
packages/common/ai/index.ts:75 AgentSseEvent 前后端共享的 SSE 判别联合类型。

4.2 页面与主要方法#

页面 关键方法 用途
views/Home/index.vue:171 initProject() 初始化首页 Three.js/滚动等展示逻辑;:165 打开登录。
views/Course/index.vue:102 init() 读取课程和购买状态;:118 发起购买。
views/Course/Learn/index.vue:302 getWordListData() 加载课程词汇;:288 保存掌握词;:273/:280 翻页。
views/WordBook/index.vue:107 searchWord() 执行词库搜索;:111 请求分页列表。
views/Knowledge/index.vue:167 loadDocuments() 加载文档;:200 上传;:220 重试;:242 删除;:179 状态轮询。
views/Chat/index.vue:308 loadConversations() 加载 Agent 会话。
views/Chat/index.vue:323 newConversation() 按场景创建会话。
views/Chat/index.vue:338 selectConversation() 切换会话并恢复消息/Action。
views/Chat/index.vue:369 sendMessage() 发消息、建立 SSE、管理本地占位消息。
views/Chat/index.vue:429 handleAgentEvent() 消费全部 Agent SSE 事件。
views/Chat/index.vue:465 stopGeneration() 取消前端流和服务端 Run。
views/Chat/index.vue:481/:495 confirmAction()/rejectAction() 确认或拒绝高影响操作。
views/Chat/index.vue:512 recoverSavedState() 页面刷新后恢复消息、未完成状态和待确认 Action。
views/Chat/index.vue:562 renderMarkdown() 渲染回答 Markdown。
views/Study/index.vue:296 loadAll() 加载画像、薄弱词、复习、计划、任务和测验。
views/Study/index.vue:327/:336 cancelPlan()/finishTask() 取消计划、直接完成任务。
views/Study/index.vue:347/:360 createQuiz()/gradeQuiz() 生成和提交个性化测验。
views/Setting/index.vue:199 onSave() 保存用户设置;:211 上传头像;:223 退出登录。

4.3 通用组件与 Hooks#

文件与行号 方法 用途
components/Login/LoginForm.vue:72 handleLogin() 登录表单提交。
components/Login/RegisterForm.vue:104 handleRegister() 注册表单提交。
components/Search/index.vue:59 getWordList() 搜索词条;:94 复制单词。
views/Chat/components/Bubble.vue:131 parseMarkdown() 旧聊天气泡 Markdown;:148 发送消息。
hooks/useVoiceToText.ts:34 useVoiceToText() 封装浏览器 SpeechRecognition。
hooks/useAudio.ts:22 useAudio() 封装浏览器语音朗读。
hooks/useSocket.ts:6 useSocket() 管理支付成功等 Socket.IO 事件。
hooks/useAvatar.ts:5 useAvatar() 统一头像 URL 处理。

5. NestJS Business 代码地图#

5.1 启动与公共基础设施#

文件与行号 方法 用途
server/apps/server/src/main.ts:7 bootstrap() 创建 Nest、配置 trust proxy、全局拦截器/异常过滤器、/api/v1 前缀和端口。
server/apps/server/src/app.module.ts:18 AppModule 注册用户、词库、课程、支付、Socket、学习、埋点、知识库、Study、Health 模块。
server/libs/shared/src/interceptor/interceptor.ts:34 intercept() 生成/透传 Trace ID、统一成功响应、转换 bigint、记录耗时。
server/libs/shared/src/interceptor/exceptionFilter.ts:14 catch() 统一异常响应;内部错误只返回通用提示并记录类型。
server/libs/shared/src/prisma/prisma.service.ts:11 constructor() 配置 pg 连接池、连接/空闲/查询/statement 超时。
server/libs/shared/src/rate-limit/redis-rate-limit.guard.ts:56 canActivate() 用 Redis Lua 原子计数限流,设置 RateLimit 响应头;Redis 故障时 fail closed。
server/libs/shared/src/minio/minio.service.ts:64 onModuleInit() 初始化公共与私有 Bucket 及策略。
server/libs/shared/src/minio/minio.service.ts:107 putPrivateObject() 写入带 SHA-256 元数据的私人对象。

5.2 业务模块与方法#

模块 Controller Service 关键方法
用户 user.controller.ts:28-62 user.service.ts:26 login、:62 register、:108 refreshToken、:144 uploadAvatar、:196 updateUser。
认证 auth.guard.ts:14 auth.service.ts:12 generateToken 生成 access/refresh;Guard 校验 access 并注入用户。
词库 word-book.controller.ts:10 word-book.service.ts:15 findAll 处理标签、模糊搜索、分页和排序。
课程 course.controller.ts:11/:17 course.service.ts:13 findAll、:22 findMy。
课程学习 learn.controller.ts:20/:30 learn.service.ts:12 saveWordMaster、:100 getWordList。
Study study.controller.ts:23-104 study.service.ts:17-115 提供画像、薄弱词、复习、计划、任务和测验 HTTP 门面。
支付 pay.controller.ts:13/:19 pay.service.ts:30 createOrder、:80 notify。
Socket socket.gateway.ts:14/:21 绑定客户端并推送支付结果。
埋点 tracker.controller.ts:18-48 tracker.service.ts:20-88 落库 UV、PV、性能、事件、错误。
健康检查 health.controller.ts:9/:14 liveness 只确认进程;readiness 调共享基础设施健康检查。

6. NestJS Agent 代码地图#

6.1 启动、会话与 SSE#

文件与行号 方法 用途
server/apps/ai/src/main.ts:7 bootstrap() 启动 /ai/v1 服务,配置 Trace/异常、trust proxy 和端口。
server/apps/ai/src/ai.module.ts:12 AiModule 注册 Chat、Prompt、共享服务、Digest、Provider、Health、Conversation。
conversations/conversations.controller.ts:36 create() 创建会话。
conversations/conversations.controller.ts:64 stream() Agent SSE 入口,监听断开并转发标准事件。
conversations/conversations.controller.ts:137 cancel() 取消指定 Run。
conversations/conversations.service.ts:33 create() 使用用户级 Advisory Lock 限制活动会话数量。
conversations/conversations.service.ts:69 messages() 校验归属并返回历史消息与 Action 回执。
conversations/conversations.service.ts:96 archive() 归档会话、清理 Action、删除 Checkpoint;失败时留给恢复任务。

6.2 Agent 拆分后的编排服务方法总览#

原来集中在 AgentRunnerService 的职责已拆成 7 个服务。Runner 只保留总编排;要找某段真正逻辑,先按下表找文件。

文件与行号 方法 用途
agent-runner.service.ts:49-61 onModuleInit() 调用 Config 校验配置,预创建 Context Middleware,启动 Lifecycle stale Run 恢复;销毁时停定时器并 Abort 活动 Run。
agent-runner.service.ts:63-451 run() 总编排:权限、调用 Lifecycle/Context/RAG/Tools/Prompt/Usage、模型流、SSE、终态分派。
agent-runner.service.ts:453-467 cancel() 校验本机 active Run,委托 Lifecycle 更新数据库终态/拒绝 Action,并 Abort 本机模型。
agent-runner.service.ts:498-540 validateDocumentScope() / validateMessage() 校验文档归属/READY/未删除和消息 1-4000 字符。
agent-run-lifecycle.service.ts:30-44,306-361 startRecovery() / recoverStaleRuns() 定期把长时间 RUNNING 的中断任务标为失败,清 Checkpoint 和待确认 Action。
agent-run-lifecycle.service.ts:46-304 createRun() / persistUserMessage() / completeRun() / failRun() / cancelRun() 运行并发锁、User/Assistant 消息落库、成功/失败/取消终态、Checkpoint 和 Action 清理。
agent-context.service.ts:23-159 prepareGraphMessages() / createMiddleware() 决定沿用 Checkpoint 还是从库重建历史;清理旧 Tool 结果,并在消息/Token 到阈值时做摘要。
agent-rag.service.ts:22-280 prefetchKnowledge() / captureSources() / modelVisibleSources() / enforceKnowledgeState() / finalizeGroundedCitations() 预检索、追问扩展、真实来源登记、知识状态防幻觉和引用闭环。
agent-tools.service.ts:37-371 createTools() 定义 10 个 Agent Tool,统一处理次数上限、参数重复、单例工具、超时、审计和 SSE。
agent-tools.service.ts:373-507 buildPersonalizedStudyPlan() / normalizePlanFocus() 基于真实统计、弱词和到期词构造 3-30 天计划草稿,并规范化侧重点。
agent-usage.service.ts:10-76 createCallback() / hasUsage() / calculateCost() 兼容不同 Provider 的 Token 字段,累加用量并按可选单价计算成本。
agent-config.service.ts:8-51 validate() / integer() / optionalNumber() 集中校验超时大小关系、RAG topK、价格成对配置,并提供有范围的数值读取。
server/apps/ai/src/prompt/agent-prompt.service.ts systemPrompt() / sanitizeResponse() 按 learning/knowledge/writing 场景生成系统提示词和工具约束,并清理不应向用户暴露的内部标记。

AgentToolsService.createTools() 中的十个工具位于 agent-tools.service.ts:37-371:学习画像、已购课程、学习统计、薄弱词、到期复习、近期测验、知识库检索、创建计划、生成测验、完成任务。

6.3 Action、Checkpoint 与 Provider#

文件与行号 方法 用途
learning/agent-action.service.ts:23 prepareStudyPlan() 创建带幂等键的计划确认 Action。
learning/agent-action.service.ts:73 prepareCompleteTask() 创建完成任务 Action。
learning/agent-action.service.ts:116 confirm() 并发安全地确认并抢占执行权。
learning/agent-action.service.ts:190 executeConfirmed() 执行业务写入,记录尝试、结果和失败。
learning/agent-action.service.ts:338-393 四个 recovery 方法 过期、重试、补偿、清理孤立 Action。
llm/checkpoint.service.ts:16 onModuleInit() 创建 PostgreSQL LangGraph Saver。
llm/checkpoint.service.ts:29 deleteThread() 删除会话 checkpoint 数据。
llm/llm.config.ts:31 createCheckpoint() 解析数据库 URL,把 checkpoint 放入独立 langgraph schema。
providers/chat-model.provider.ts:29 createModel() 根据环境创建 Ollama/OpenAI-compatible 模型。
providers/chat-model.provider.ts:78 probe() 发真实最小请求验证模型可用。
health/health.service.ts:29 readiness() 聚合基础设施、模型、RAG 和 checkpoint 健康状态。

server/apps/ai/src/chat/chat.service.ts:17 是旧版角色聊天链路;新版面试重点应讲 Conversations + AgentRunnerService 编排 + Lifecycle/Context/RAG/Tools/Usage 专职服务。旧链路可用于解释系统演进,但应说明后续需要迁移下线。

7. 知识库 Business 代码地图#

文件:server/apps/server/src/knowledge/knowledge.service.ts。

行号 方法 用途
99 upload() 校验配额/文件/重复内容,创建元数据,写 MinIO,触发异步索引;失败时清理。
254 list() 按用户分页返回未删除文档。
278 get() 返回用户拥有的单份文档。
283 retry() 对 FAILED/异常状态文档重置并重新入队。
352 remove() 标记 DELETING,删向量和对象,最后软删除;保留可恢复状态。
436 recoverStaleDocuments() 扫描长期 QUEUED/PROCESSING 文档,根据真实状态恢复或重新入队。
503 reconcileReadyDocuments() 对 READY 文档核对 MinIO SHA-256 和 Chroma 分片数量,异常时重建。
610 applyRagStatus() 处理 Worker 回调,校验 trace/job/状态转换并写 READY/FAILED。
711 enqueue() 组装跨服务请求,调用 FastAPI create/reindex 并保存 jobId。
799 findOwned() 用户所有权查询。
810 inspectFile() 文件类型、扩展名、MIME、签名和空文件校验。
879 assertCallbackTransition() 限制 RAG 回调状态机。

后台调度位于 knowledge-recovery.service.ts:23:模块启动后定时调用 recoverStaleDocuments() 和 reconcileReadyDocuments(),并用进程内布尔值避免同实例重入。

8. FastAPI / Celery / Chroma 代码地图#

8.1 启动、配置、资源与安全#

文件与行号 方法/类 用途
server-py/app/main.py:25 lifespan() 服务启动日志与关闭 Redis/MinIO 等资源。
server-py/app/main.py:55/:69 两个异常处理器 输出统一错误结构并携带 traceId。
server-py/app/config.py:13 Settings 所有 RAG、Redis、MinIO、Embedding、Chroma、切片、超时配置。
server-py/app/config.py:86 validate_production_secrets() 生产环境拒绝弱密钥、占位值、不安全 URL/探针。
server-py/app/core/resources.py:14/:29 get_redis()/get_minio() 缓存创建基础设施客户端。
server-py/app/middleware.py:16 dispatch() 建立/透传 Trace ID 并记录请求耗时。
server-py/app/auth/hmac.py:22 canonical_request() 生成 timestamp、nonce、method、path、body hash 组成的签名原文。
server-py/app/auth/hmac.py:32 create_internal_headers() 生成发往 NestJS 的 HMAC 请求头。
server-py/app/auth/hmac.py:65 HmacVerifier.verify() 验 Key ID、时间窗、签名和 Redis nonce 防重放。
server-py/app/auth/hmac.py:136 require_internal_auth() FastAPI Dependency,保护全部内部接口。

8.2 文档解析、分片与 Embedding#

文件与行号 方法 用途
ingestion/parser.py:38 parse_document() PDF 用 pypdf 逐页提取;MD/TXT 按 UTF-8 解码;限制页数与字符数。
ingestion/parser.py:22 _normalize_text() 统一换行、空白并去掉 NUL。
ingestion/splitter.py:18 _find_boundary() 在目标长度附近优先找段落/句子边界。
ingestion/splitter.py:29 split_sections() 按配置长度和重叠切片,保留页码、索引和内容哈希。
embeddings/provider.py:37 LangChainEmbeddingProvider 统一 Ollama/OpenAI-compatible Embedding 接口。
embeddings/provider.py:67/:73 embed_query()/embed_documents() 分别生成查询向量和批量文档向量。
embeddings/provider.py:86 probe() 获取真实向量维度并验证 Provider。

当前解析器不支持 OCR、表格结构恢复和复杂多栏版式;扫描 PDF 会因无可提取文本失败。这是当前能力边界,不应在面试中说成“支持任意 PDF”。

8.3 入库服务与 Celery#

文件与行号 方法 用途
api/rag.py:29 _job_id() 由文档、哈希、版本、trace 计算 Celery task ID。
server-py/app/api/rag.py:42-72 create_ingestion() 创建入库入口:校验 Bucket、对象归属路径和索引版本,再投递 Celery。
server-py/app/api/rag.py:75-91 reindex_document() 重建入口:先校验路径 documentId 与 body 一致,再调用 create_ingestion() 汇入同一套校验和投递流程。
tasks/celery_app.py:63 record_worker_heartbeat() Celery 心跳时刷新 Redis Worker TTL。
tasks/celery_app.py:117 ingest_document() 文档级锁、任务执行、分类重试、失败清理和失败回调。
services/ingestion.py:82 process_ingestion() 整条入库主链。
services/ingestion.py:221 report_ingestion_failure() 回调脱敏失败信息。
services/ingestion.py:255 cleanup_failed_ingestion() 删除本次文档在所有受管 Collection 中的残留向量。
services/callbacks.py:13 send_ingestion_callback() HMAC 回调 Business,有限次数重试瞬时错误。
services/deletion.py:12/:20 tombstone 方法 Redis 记录删除标记,防止晚到 Worker 重新写入已删除文档。

8.4 Chroma 方法#

文件:server-py/app/vectorstore/chroma_store.py。

行号 方法 用途
52/55 heartbeat()/version() 基础连通性与版本探测。
58 validate_collection() 校验 Collection 的 Provider、模型、维度和索引版本。
62 _collection() 获取或创建当前版本 Collection。
110 probe() 只读或读写探针。
169 upsert_document() 批量写确定性 chunk ID、文档、向量和私有元数据。
235 completed_ingestion_count() 核对本次入库真实分片数量和完整标记。
294 delete_document() 删除当前 Collection 中指定用户文档。
328 delete_document_everywhere() 删除所有受管 Collection 的该文档。
417 count_document() 为 Business 一致性核对提供真实向量数。
432 search_private() 显式文档白名单检索、分数过滤、哈希去重、近重复过滤。

当前检索是稠密向量检索,并非 BM25 + 向量的混合检索,也没有 Cross-Encoder Reranker;不要把其他工作项目中的混合检索能力说成这个仓库已经实现。

9. 跨服务通信与 HMAC#

9.1 NestJS 调 FastAPI#

文件:server/libs/shared/src/rag/rag-internal.client.ts。

行号 方法 用途
24 probeEmbedding() 真实测试 Embedding。
35 readiness() 请求 RAG readiness。
42 createIngestion() 创建索引任务。
52 reindexDocument() 重建索引。
62 deleteDocumentVectors() 删除文档向量。
77 searchKnowledge() 私有知识检索。
92 countDocumentVectors() 获取真实分片数量。
293 validateSearchResult() 验证返回文档、用户白名单、页码、分数、重复 chunk、版本和模型。

server/libs/shared/src/internal-auth/internal-hmac.service.ts:31 的 sign() 创建签名;:66 的 verify() 验签。internal-hmac.guard.ts:46 的 canActivate() 再加 Redis nonce 防重放,用于 Python 回调 NestJS。

9.2 API 合约#

  • Python Pydantic 请求/响应:server-py/app/schemas/rag.py:8-96。
  • 导出的 OpenAPI:server-py/openapi.json。
  • 生成的 TypeScript Client:server/libs/shared/src/generated/rag,不要手改。
  • 根脚本 package.json 中 rag:contract 重新导出并生成;rag:contract:check 检查代码与合约是否漂移。

10. PostgreSQL 数据模型#

文件:server/prisma/schema.prisma。

行号 模型 作用与主要关系
100 User 用户主表;关联词汇、订单、课程、知识文档、会话、Run、Action、计划、任务、测验。
131 WordBookRecord 用户-单词掌握记录,保存分数、正误、复习间隔和下次复习时间。
156 WordBook 词库主数据和考试标签。
187 PaymentRecord 支付宝订单状态。
204 CourseRecord 用户课程购买关系。
219 Course 课程信息。
233 Visitor 匿名/登录访客,与 PV、事件、性能、错误关联。
253/268/283/303 四个埋点模型 PageView、TrackEvent、PerformanceEntry、ErrorEntry。
318 KnowledgeDocument 原文件元数据、对象 Key、状态、任务、哈希、分片数、模型和版本。
348 AiConversation 用户会话、场景、归档和 Checkpoint 清理状态。
369 AiMessage 用户/助手/工具消息、引用和工具调用审计。
385 AiRun 一次 Agent 执行的状态、Trace、模型、耗时、Token、成本和错误。
416 AgentAction 高影响写操作的 Payload、幂等键、状态、重试、补偿和结果。
447 StudyPlan 待确认/活动/完成/取消的计划及 JSON 计划体。
465 StudyTask 计划内每日任务,planId + scheduledFor + order 唯一。
486 QuizAttempt 一次已提交测验,包含题目快照、回答、分数、反馈和幂等键。
509 QuizSession 未提交测验会话和过期时间,避免客户端伪造题目。

迁移重点:20260722122000_add_langgraph_schema 建独立 Checkpoint Schema;20260722190000_agent_rag_learning 增加 Agent/RAG/学习模型;后续三个迁移修正掌握度回填与 QuizMode。

11. 共享类型、配置与埋点 SDK#

11.1 packages/common#

  • packages/common/ai/index.ts:4-104:会话、消息、Run、Action、SSE、Usage 类型。
  • packages/common/knowledge/index.ts:1-55:文档状态、列表、Citation 和检索结果。
  • packages/common/study/index.ts:1-119:计划、任务、测验、弱词、复习 DTO。
  • packages/common/user/index.ts:1-68:用户、登录、注册、Token Payload。
  • packages/common/course/index.ts:4、word/index.ts:1、pay/index.ts:1、tracker/index.ts:1:各业务共享 DTO。

共享 DTO 只解决 TypeScript 前后端一致性;Python RAG 通过 OpenAPI 合约同步,不直接引用 TypeScript 源码。

11.2 apps/tracker#

  • src/report/index.ts:1/:6:Beacon/fetch 两种上报方式。
  • src/uv/index.ts:6/:15:本地 UV 标识与 FingerprintJS 指纹。
  • src/pv/index.ts:4/:16:首屏和 History 路由变化的 PV。
  • src/performance/index.ts:5:PerformanceObserver/Web Vitals 数据。
  • src/event/index.ts:4:点击等自定义事件。
  • src/error/index.ts:4:JS Error 和 Promise rejection。

12. Docker、代理、健康检查与备份#

12.1 Compose 服务定位#

开发环境服务从 docker-compose.dev.yml:118 开始:PostgreSQL 119、Redis 137、Ollama 153、MinIO 191、Chroma 220、存储初始化 235、RAG API 244、Worker 272、Migration 295、Business 304、Agent 326、Web 369。

生产环境从 docker-compose.prod.yml:120 开始:PostgreSQL 121、Redis 137、MinIO 152、Chroma 168、RAG API 188、Worker 204、Migration 215、Business 224、Agent 240、Backup 286、Web 326、Caddy 346。

12.2 镜像与代理#

  • Dockerfile.server:1-46:Node 多阶段构建,一次生成 Prisma Client 并构建 Business/Agent,运行时用 node 用户。
  • server-py/Dockerfile:1-28:uv 锁定依赖,Python 3.12,非 root rag 用户运行 Uvicorn。
  • Dockerfile.web:1-30:构建 Tracker + Vue,产物进入 Nginx。
  • deploy/nginx/default.conf:35:/api/ 代理 Business。
  • deploy/nginx/default.conf:47:/ai/ 代理 Agent,并关闭缓冲以支持 SSE。
  • deploy/nginx/default.conf:64:/socket.io/ 代理 WebSocket。
  • deploy/nginx/default.conf:76/:87:只公开 avatar/course 对象;:98 拒绝其他 /media/,因此私人知识文件不会公开。
  • deploy/caddy/Caddyfile:生产 TLS/外层反向代理入口。

12.3 备份#

  • deploy/backup/backup-once.sh:60:pg_dump 生成压缩 PostgreSQL 备份。
  • :69:生成 SHA-256 校验文件。
  • :73-81:上传数据库备份,并把指定 MinIO Bucket mirror 到备份 S3。
  • Chroma 是可从 PostgreSQL 元数据与 MinIO 原文件重建的派生数据,因此备份主链以 PostgreSQL + MinIO 为准。

13. 健康检查和故障定位#

13.1 NestJS#

  • server/libs/shared/src/health/infrastructure-health.service.ts:62 的 check() 并行检查 PostgreSQL、Redis、MinIO。
  • Business:server/apps/server/src/health/health.controller.ts:9/:14。
  • Agent:server/apps/ai/src/health/health.service.ts:29 额外检查模型、RAG 与 Checkpoint。

13.2 Python#

  • server-py/app/services/readiness.py:26 的 check() 并行检查 Redis、MinIO、Worker、Chroma、Embedding。
  • :64 的 _run_check() 为每个组件统一超时和错误结构。
  • server-py/app/api/health.py:12/:22 暴露 /health/live、/health/ready。

13.3 常见故障从哪里查#

现象 首先检查
登录 401 前端 apis/token.ts:6、Business auth.guard.ts:14、Token TTL 配置。
SSE 没有增量 Nginx default.conf:47-61、前端 apis/sse/index.ts:11、Controller conversations.controller.ts:64。
文档一直 QUEUED Celery 队列/Worker 心跳,celery_app.py:117,knowledge.service.ts:436。
文档一直 PROCESSING Worker 日志、Embedding、Chroma、回调 HMAC,ingestion.py:82。
READY 但搜不到 PostgreSQL白名单、Collection 模型/版本、Chroma owner/document filter、最低分。
重复学习计划 AgentAction.idempotencyKey、confirm():116、Advisory Lock、唯一索引。
取消后模型还在跑 当前实例 AbortController、Run 数据库状态轮询、底层 Provider 是否支持 AbortSignal。

14. 当前实现边界与面试前风险#

  1. 密码安全:user.service.ts:37 是明文比较,必须改为密码哈希。
  2. 并发配置需讲清参数语义:agent-runner.service.ts:85-89 调用 AgentConfigService.integer('AGENT_MAX_CONCURRENT_RUNS_PER_USER', 2, 1),参数是“默认值 2、下限 1”,第四个参数才是上限;真正的并发抢占在 agent-run-lifecycle.service.ts:46-105。
  3. Agent 大类拆分已落地:AgentRunnerService 已从约 1900 行缩到 540 行,运行生命周期、上下文、RAG/引用、Tools、Usage 和 Config 都已拆出。面试时应讲“Runner 仅编排,专职服务承载细节”以及如何用事务、状态条件更新和事件协议保持拆分前后行为一致。
  4. 核心测试不足:当前测试主要覆盖配置与 HMAC,Agent、Action 并发、知识状态机、入库补偿和 RAG 权限需补单元/集成/E2E。
  5. PDF 能力边界:只有文本提取,没有 OCR、表格和复杂布局恢复。
  6. 检索能力边界:当前是 Dense Retrieval + 去重,没有 BM25 混合检索和 Reranker。
  7. MCP/爬虫不在本仓库:简历中的 MCP、Multi-Agent、Playwright/Scrapy 能力需要用其他真实项目说明。
  8. 旧链路并存:server/apps/ai/src/chat 是旧聊天实现,建议明确迁移计划,避免面试官误认为存在两套相互冲突的 Agent 系统。
  9. 行号会漂移:修复以上问题后,用类名/方法名搜索,并更新本文的提交基准。

15. 面试现场快速定位表#

面试官问什么 直接打开哪里
整体架构 README.md:31、docker-compose.prod.yml:120
Agent 主循环 agent-runner.service.ts:63、agent-run-lifecycle.service.ts:46
Tool Calling agent-tools.service.ts:37
Human-in-the-loop agent-action.service.ts:116/:190
幂等与并发 agent-action.service.ts:23/:116、study-domain.service.ts:555/:688
LangGraph Checkpoint checkpoint.service.ts:16、llm.config.ts:31
SSE conversations.controller.ts:64、apis/sse/index.ts:11
RAG 权限 agent-runner.service.ts:498、agent-rag.service.ts:22、chroma_store.py:432
可信引用 agent-rag.service.ts:130/:144/:192
文档入库 knowledge.service.ts:99、ingestion.py:82、celery_app.py:117
分片 parser.py:38、splitter.py:29
数据一致性 knowledge.service.ts:436/:503/:610
HMAC internal-hmac.service.ts:31/:66、hmac.py:65
数据库设计 schema.prisma:318-528
学习闭环 study-domain.service.ts:164/:292/:412
Docker 部署 docker-compose.prod.yml:120、三个 Dockerfile、Nginx 配置

16. 一句话总结每个核心服务#

  • Vue Web:负责学习、知识库、Agent 对话与确认交互,不决定权限和业务事实。
  • NestJS Business:负责用户身份、课程、支付、学习、文档元数据和所有业务写入,是主要事实边界。
  • NestJS Agent:负责会话、工具选择、模型编排、SSE、引用校验、Run/Action 生命周期,不直接让模型改数据库。
  • FastAPI RAG:只负责文档基础设施和检索,不维护用户/课程/计划等业务真相。
  • Celery Worker:把耗时解析、分片、Embedding、Chroma 写入从上传请求中解耦。
  • PostgreSQL:业务事实、Agent 状态和 Checkpoint。
  • MinIO:可重新索引的原始文件与公开媒体对象。
  • Chroma:可重建的向量和分片派生数据。
  • Redis:短期协调状态:任务队列、锁、限流、nonce、心跳,不是长期事实来源。
文档目录