英语学习平台:项目代码地图与方法说明#
目的:用于快速熟悉项目、面试前定位代码、现场按调用链讲解。
行号基准:本地主项目提交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 等静态资源。它们不是面试时需要逐行讲解的业务代码。
建议按下面顺序阅读:
README.md:1:先理解产品、组件和两条主调用链。server/prisma/schema.prisma:100:理解用户、学习、Agent、知识库的数据关系。apps/web/src/views/Chat/index.vue:308:理解前端如何驱动 Agent。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。server/apps/server/src/knowledge/knowledge.service.ts:99:理解知识库上传与业务状态。server-py/app/services/ingestion.py:82:理解异步解析与向量入库。server-py/app/vectorstore/chroma_store.py:432:理解私有向量检索。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 刷新#
- 前端表单:
apps/web/src/components/Login/LoginForm.vue:72的handleLogin()收集表单并调用登录 API。 - 前端 API:
apps/web/src/apis/user/index.ts:15的login()请求/api/v1/user/login。 - 路由入口:
server/apps/server/src/user/user.controller.ts:34的login()转给UserService。 - 登录逻辑:
server/apps/server/src/user/user.service.ts:26的login()查询用户、校验密码、更新最后登录时间并签发 Token。 - Token 生成:
server/apps/server/src/auth/auth.service.ts:12的generateToken()生成 access/refresh Token,并区分tokenType。 - 前端持久化:
apps/web/src/stores/user.ts:5的useUserStore保存用户和 Token。 - 请求注入:
apps/web/src/apis/index.ts:14与apps/web/src/apis/index.ts:67的请求拦截器分别给 Business/Agent 请求加 Bearer Token。 - 自动刷新:
apps/web/src/apis/token.ts:6的ensureAccessToken()合并并发刷新请求;apps/web/src/apis/auth/index.ts:18的refreshTokenApi()请求刷新接口。 - 服务端鉴权:
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 普通课程与词汇学习#
- 课程列表页:
apps/web/src/views/Course/index.vue:102的init()加载全部课程和已购课程。 - 课程 API:
apps/web/src/apis/course/index.ts:4的getCourseList()、:8的getMyCourseList()请求 Business。 - 课程查询:
server/apps/server/src/course/course.service.ts:13的findAll()返回课程;:22的findMy()返回当前用户已购课程。 - 进入学习页:
apps/web/src/views/Course/Learn/index.vue:302的getWordListData()获取课程词汇。 - 学习权限:
server/apps/server/src/learn/learn.service.ts:100的getWordList()先校验购买关系,再返回课程对应词汇。 - 保存掌握词:前端
apps/web/src/views/Course/Learn/index.vue:288的saveWordMaster()调用apps/web/src/apis/learn/index.ts:5。 - 服务端更新:
server/apps/server/src/learn/learn.service.ts:12的saveWordMaster()用事务和 Advisory Lock 并发安全地创建/更新WordBookRecord,同时更新用户掌握词数。 - 词库浏览:
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#
- 页面初始化:
apps/web/src/views/Chat/index.vue:308的loadConversations()加载会话。 - 创建会话:
apps/web/src/views/Chat/index.vue:323的newConversation()调用apps/web/src/apis/agent/index.ts:9的createConversation()。 - 发送消息:
apps/web/src/views/Chat/index.vue:369的sendMessage()创建本地占位消息,并调用apps/web/src/apis/sse/index.ts:11的streamAgentMessage()。 - SSE 客户端:
streamAgentMessage()取得有效 Token、发起 POST 流式请求、解析事件,401 时只刷新一次 Token。 - Agent HTTP 入口:
server/apps/ai/src/conversations/conversations.controller.ts:64的stream()设置 SSE 头、转发事件、处理断开。 - 主编排器:
server/apps/ai/src/agent/agent-runner.service.ts:63-451的AgentRunnerService.run()校验请求、按顺序调用下面的专职服务,并消费 LangGraph 流输出 SSE。 - 运行生命周期:
server/apps/ai/src/agent/agent-run-lifecycle.service.ts:46的createRun()、:108的persistUserMessage()、:133的completeRun()和:173的failRun()负责并发锁、Run 状态和消息落库。 - 前端事件处理:
apps/web/src/views/Chat/index.vue:429的handleAgentEvent()处理状态、工具、来源、增量文本、确认 Action、测验和完成事件。 - 停止生成:前端
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()完成。 - 完成落库:
server/apps/ai/src/agent/agent-run-lifecycle.service.ts:133-171的AgentRunLifecycleService.completeRun()在事务中完成 Run、保存 Assistant 消息、工具审计、引用和 Token 用量。
3.4 Agent 写操作与人工确认#
- Agent 在
server/apps/ai/src/agent/agent-tools.service.ts:37-371的AgentToolsService.createTools()中定义全部工具;agent-runner.service.ts:261-285只负责按场景筛选可用工具。 create_study_plan和complete_study_task不直接修改最终业务状态,而是调用AgentActionService创建待确认 Action。- 创建计划 Action:
server/libs/shared/src/learning/agent-action.service.ts:23的prepareStudyPlan()生成幂等键、加锁、创建 PENDING Action。 - 创建完成任务 Action:
server/libs/shared/src/learning/agent-action.service.ts:73的AgentActionService.prepareCompleteTask()校验任务归属并创建待确认 Action。 - 前端收到
action.required后,由apps/web/src/views/Chat/index.vue:481的confirmAction()或:495的rejectAction()处理。 - 确认入口:
server/apps/ai/src/conversations/actions.controller.ts:30的confirm()调用AgentActionService.confirm()。 - 确认与抢占:
server/libs/shared/src/learning/agent-action.service.ts:116的AgentActionService.confirm()通过会话锁、Action 锁、状态条件更新确保重复确认不会重复执行。 - 实际执行:
server/libs/shared/src/learning/agent-action.service.ts:190的AgentActionService.executeConfirmed()按 Action 类型调用学习领域服务,并处理重试、失败和补偿。 - 回执消息:
server/libs/shared/src/learning/agent-action.service.ts:520的AgentActionService.ensureActionReceipt()与server/libs/shared/src/learning/agent-action.service.ts:536的persistActionReceipt()把最终执行结果写回会话,页面刷新后仍可追溯。 - 后台恢复:
server/apps/ai/src/agent/agent-action-recovery.service.ts:41的runRecovery()定期过期 PENDING、重试 CONFIRMED、补偿终态计划、清理孤立 Action。
3.5 私人文档上传与异步索引#
- 知识库页面:
apps/web/src/views/Knowledge/index.vue:200的upload()调用apps/web/src/apis/knowledge/index.ts:7。 - Business 入口:
server/apps/server/src/knowledge/knowledge.controller.ts:33的upload()接收 multipart 文件并应用鉴权、限流、大小限制。 - 业务上传:
server/apps/server/src/knowledge/knowledge.service.ts:99的upload()校验文件、配额和重复内容,在 PostgreSQL 建记录,并把原文件写入私有 MinIO Bucket。 - 文件识别:
server/apps/server/src/knowledge/knowledge.service.ts:810的KnowledgeService.inspectFile()校验扩展名、MIME 和文件签名;同文件的sanitizeFilename()、decodeMultipartFilename()、buildTitle()分别清洗文件名、处理 multipart 中文文件名、生成展示标题。 - 创建或重建索引:
server/apps/server/src/knowledge/knowledge.service.ts:711的KnowledgeService.enqueue()根据reindex分流:false调RagInternalClient.createIngestion()创建入库任务,true调RagInternalClient.reindexDocument()重建已有索引;两条路都会先把文档条件更新为 QUEUED。 - FastAPI 入口:
server-py/app/api/rag.py:42-72的create_ingestion()负责创建任务;同文件:75-91的reindex_document()先校验 URL 与 body 的 documentId 一致,再复用create_ingestion()的 Bucket、对象路径、版本校验和 Celery 投递流程。 - Celery 任务:
server-py/app/tasks/celery_app.py:117的ingest_document()获取文档级 Redis 锁,区分永久错误/瞬时错误,执行指数退避与失败清理。 - 入库主流程:
server-py/app/services/ingestion.py:82的process_ingestion()回调 PROCESSING、下载并校验文件、解析、分片、Embedding、写 Chroma、核对数量、回调 READY。 - 状态回调:
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。 - 前端轮询:
apps/web/src/views/Knowledge/index.vue:179的schedulePolling()在仍有处理中记录时继续刷新列表。
3.6 私人 RAG 检索与可信引用#
- 用户选择文档后发送问题,文档 ID 进入
SendAgentMessageRequest。 server/apps/ai/src/agent/agent-runner.service.ts:498-529的AgentRunnerService.validateDocumentScope()只接受当前用户拥有、状态 READY、未删除的文档。server/apps/ai/src/agent/agent-rag.service.ts:22-128的AgentRagService.prefetchKnowledge()在模型运行前先检索;同文件:241-263的contextualRetrievalQuery()为短追问补充上下文。server/apps/ai/src/agent/agent-tools.service.ts:201-252的search_knowledge_baseTool 允许模型主动再次检索,其userId/documentIds由服务端上下文注入。server/libs/shared/src/rag/rag-internal.client.ts:77的searchKnowledge()签名调用 FastAPI;:293的validateSearchResult()再校验返回结构和越界结果。- FastAPI 入口:
server-py/app/api/rag.py:134的search_knowledge()限制 topK、最低分和总超时。 - Chroma 检索:
server-py/app/vectorstore/chroma_store.py:432的search_private()用查询向量和owner_user_id + PRIVATE + document allowlist过滤,执行阈值过滤与去重。 - 来源登记:
server/apps/ai/src/agent/agent-rag.service.ts:130-142的AgentRagService.captureSources()保存真实召回来源并发送sourceSSE 事件。 - 模型可见来源:
server/apps/ai/src/agent/agent-rag.service.ts:144-164的AgentRagService.modelVisibleSources()把内部 chunk 映射为临时K1/K2编号。 - 最终校验:
server/apps/ai/src/agent/agent-rag.service.ts:192-239的AgentRagService.finalizeGroundedCitations()只保留回答中实际引用且本轮真实召回的来源,过滤伪造引用。
3.7 学习计划、任务、测验与掌握度#
- 学习中心页面:
apps/web/src/views/Study/index.vue:296的loadAll()并行加载画像、计划、任务、测验等数据。 - Business 门面:
server/apps/server/src/study/study.service.ts:17-115把 HTTP 参数转换为领域服务调用。 - Agent 读取工具:
server/libs/shared/src/learning/learning-tools.service.ts:8的画像方法读取学习画像;同文件的统计、薄弱词、到期复习、近期测验方法继续补齐数据。 - 待确认计划:
server/libs/shared/src/learning/study-domain.service.ts:49的StudyDomainService.createPendingPlan()创建 PENDING_CONFIRMATION 计划;同文件:70的activatePlan()在确认后激活。 - 任务完成:
server/libs/shared/src/learning/study-domain.service.ts:129的StudyDomainService.completeTask()调用同文件:692的completeTaskInTransaction(),并在后续判断是否完成整份计划。 - 生成测验:
server/libs/shared/src/learning/study-domain.service.ts:168的StudyDomainService.generateQuiz()从薄弱词/到期复习中选题,保存服务端题目快照,返回不含答案的公开题面。 - 防重复提交:
server/libs/shared/src/learning/study-domain.service.ts:296的StudyDomainService.submitQuiz()使用幂等键、Quiz Session 锁和词汇掌握度锁,只允许一次有效提交。 - 更新掌握度:
server/libs/shared/src/learning/study-domain.service.ts:417的StudyDomainService.updateMastery()按正误、间隔、easeFactor 更新统计和下次复习时间。 - 前端答题:
apps/web/src/views/Study/index.vue:347的createQuiz()生成;同文件:360的gradeQuiz()提交;同文件:386的questionFeedback()展示逐题反馈。
3.8 支付、WebSocket 与埋点#
- 购买课程:
apps/web/src/views/Course/index.vue:118的handleBuy()打开支付组件。 - 创建订单:
apps/web/src/views/Course/components/Pay.vue:142的onConfirm()调apps/web/src/apis/pay/index.ts:5。 - 服务端下单:
server/apps/server/src/pay/pay.service.ts:30的createOrder()创建本地支付记录并调用支付宝 SDK。 - 支付回调:
server/apps/server/src/pay/pay.service.ts:80的PayService.notify()更新订单和课程购买关系,然后通知用户;当前代码未做支付宝回调验签、金额和商户号校验,这是明确的安全缺口。 - WebSocket:
server/apps/server/src/socket/socket.gateway.ts:14的SocketGateway.handleConnection()绑定用户 Socket;同文件:21的emitPaymentSuccess()推送支付成功。 - 前端埋点入口:
apps/tracker/index.ts:9的Tracker组合 UV、PV、性能、事件和错误采集。 - 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()上报按钮点击事件。 - 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-86handleLogin() 判断表单是否通过校验 |
调 login(),成功后写 Pinia 并关闭弹窗。 |
不发请求,直接提示表单错误。 | 防止手机号、密码格式都不对时还请求后端。 |
| 登录 | apps/web/src/apis/token.ts:28-50ensureAccessToken() 判断 Token 是否临近过期、是否已有刷新 Promise |
临近过期才调 refreshTokenApi();已有刷新任务就复用同一个 Promise。 |
Token 还有效就直接返回原 Token。 | 防止每个并发请求都各刷新一次,也防止没必要地频繁换 Token。 |
| 登录 | apps/web/src/apis/index.ts:30-49,81-96Business/Agent 响应拦截器判断是否为第一次 401 |
刷新 Token 后只重放原请求一次。 | 不是 401,或已经重试过,就直接抛错。 | 防止网络错误被误当成登录过期,也防止无穷 401 循环。 |
| 课程 | apps/web/src/views/Course/index.vue:102-110init() 判断当前标签 |
activeTab === "list" 调 getCourseList() 查全部课程。 |
其他标签调 getMyCourseList() 查已购课程。 |
两个页面语义和权限不同,不能拿“全部课程”冒充“我的课程”。 |
| 课程 | apps/web/src/views/Course/index.vue:118-128handleBuy() 判断是不是“我的课程”页 |
已购页直接进入学习页并提前返回。 | 非已购页先调用登录检查;登录成功才打开支付弹窗。 | 防止已购课程重复下单,也防止未登录用户直接创建订单。 |
| 背词 | server/apps/server/src/learn/learn.service.ts:86-89LearnService.saveWordMaster() 判断本次是否真的新增掌握词 |
masteredIncrement > 0 调 StudyCheckInService.syncDayNumber() 重算有效学习天数。 |
没有新增事实就保留 wordNumber.dayNumber。 |
防止重复提交同一批词也反复做昂贵统计,更防止把重复点击算成新学习日。 |
| Agent | server/apps/ai/src/agent/agent-runner.service.ts:279-285AgentRunnerService.run() 判断会话场景 |
learning_coach 使用全部学习工具。 |
其他场景只留下 search_knowledge_base。 |
防止知识问答/写作场景误调用创建计划、完成任务等高影响学习工具。 |
| Agent | server/apps/ai/src/agent/agent-runner.service.ts:373-408AgentRunnerService.run() 判断数据库终态、Abort、超时和递归上限 |
分别映射成 TIMED_OUT、CANCELLED、AGENT_RECURSION_LIMIT 及对应提示。 |
其他异常统一按普通 FAILED 处理。 |
防止所有错误都显示成一个模糊的“失败”,也防止取消被误记成服务故障。 |
| Agent | server/apps/ai/src/agent/agent-runner.service.ts:421-431AgentRunnerService.run() 判断取消前是否已有回答片段 |
有片段就保留已经生成的内容,并重新校验引用。 | 没有可用片段就使用对应错误/取消提示并清空引用。 | 防止用户点停止后已经看到的内容突然丢失,也防止失败消息携带旧引用。 |
| Agent | server/apps/ai/src/agent/agent-context.service.ts:23-47AgentContextService.prepareGraphMessages() 判断是否要重置 Checkpoint、旧 Checkpoint 是否存在 |
要重置就先删旧状态;若仍有有效 Checkpoint,只补本轮 USER 消息。 | 没有 Checkpoint 才从数据库重建完整历史。 | 防止失败 Run 的脏图状态继续污染下一轮,也防止已有 Checkpoint 时重复塞整段历史。 |
| Action | server/libs/shared/src/learning/agent-action.service.ts:232-249AgentActionService.executeConfirmed() 判断 Action 类型 |
CREATE_STUDY_PLAN 调 StudyDomainService.activatePlan();COMPLETE_STUDY_TASK 调 StudyDomainService.completeTask()。 |
两种都不是就拒绝为“不支持的操作类型”。 | 防止未知/篡改的 Action 类型落到错误业务方法,更不能默认执行某一种写操作。 |
| Action | server/libs/shared/src/learning/agent-action.service.ts:263-298AgentActionService.executeConfirmed() 判断错误是否永久、是否已重试 3 次 |
4xx 业务错误或到达上限就标 FAILED,并补偿计划。 | 短暂错误保留 CONFIRMED,标记 RETRYING,交给恢复器再试。 | 防止永久错误无限重试,也防止一次网络抖动就把可恢复操作永久判死。 |
| 文档入库 | server/apps/server/src/knowledge/knowledge.service.ts:283-343KnowledgeService.retry() 判断重试前状态 |
原状态是 READY 时令 reindex=true,表示已有完整索引,需要走重建。 |
原状态是 FAILED 时令 reindex=false,按创建入库任务重新处理。 |
防止“已有索引重建”和“失败任务重新创建”混成同一语义,也让 FastAPI 能分别做对应校验。 |
| 文档入库 | server/apps/server/src/knowledge/knowledge.service.ts:748-751KnowledgeService.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-795KnowledgeService.enqueue() 判断 jobId 是否仍属于当前 trace、队列调用是否异常 |
保存成功只写当前 QUEUED+trace 的 jobId;异常时只把同 trace 的任务改 FAILED。 | 状态/trace 已变化就停止覆盖;若用户正在删除则抛冲突。 | 防止迟到响应覆盖新一轮重试,也防止删除中的文档被重新标成排队。 |
| 文档入库 | server-py/app/tasks/celery_app.py:138-174ingest_document() 判断永久错误、瞬时错误和重试次数 |
永久错误直接清向量并 FAILED;瞬时错误未到上限就指数退避重试。 | 瞬时错误达到上限才清理并 FAILED。 | 防止坏文件无意义重试,也防止网络短抖动立刻毁掉一个本来可成功的任务。 |
| RAG | server/apps/ai/src/agent/agent-runner.service.ts:508-529AgentRunnerService.validateDocumentScope() 判断用户有没有显式选择文档 |
有 ID 就整批校验“归属当前用户、READY、未删除”,数量不符整批拒绝。 | 没有 ID 时由服务端查当前用户最新 100 份 READY 文档。 | 防止空数组被当成“搜全站”,也防止夹带一个别人的 ID 探测权限。 |
| RAG | server/apps/ai/src/agent/agent-rag.service.ts:45-77AgentRagService.prefetchKnowledge() 判断短追问、扩展查询是否零结果 |
短追问先带最近上下文检索;扩展后零结果则用原问题再试一次。 | 原问题本身完整或扩展查询已有结果,就不做备用检索。 | 防止“这个呢?”失去语境,也防止补上下文反而把检索带偏。 |
| RAG | server/apps/ai/src/agent/agent-rag.service.ts:104-127AgentRagService.prefetchKnowledge() 判断异常是否来自 Abort |
用户取消就继续抛出,让整个 Run 进入取消流程。 | 普通 RAG 故障降级成 UNAVAILABLE,允许 Agent 明确说明资料服务不可用。 |
防止用户取消后后台仍继续花钱检索,也防止服务故障被谎称成“知识库没有资料”。 |
| 测验 | server/libs/shared/src/learning/study-domain.service.ts:177-201StudyDomainService.generateQuiz() 判断测验模式 |
DUE_REVIEW 查询已经到复习时间的词。 |
其他模式查询掌握分低于 80 的薄弱词。 | 两种模式解决的问题不同;不能只讲“查薄弱词”而漏掉“到期复习”。 |
| 测验 | server/libs/shared/src/learning/study-domain.service.ts:423-447StudyDomainService.updateMastery() 判断答对/答错和分数档位 |
答对提高 ease;95/90/80 分分别拉长到不同复习间隔。 | 答错降低 ease,并把间隔缩回 1 天。 | 防止一次答错后很久不复习,也防止一次答对就把并不稳定的单词推到很远。 |
| 测验 | server/libs/shared/src/learning/study-domain.service.ts:324-351StudyDomainService.submitQuiz() 判断幂等键是否已存在 |
userId+sessionId 都一致就返回第一次评分结果。 | 键相同但用户/Session 不同则拒绝;不存在才继续评分,并在加锁后再查一次。 | 防止双击重复评分,也防止拿别人的幂等键套取答题结果。 |
| 支付 | apps/web/src/views/Course/components/Pay.vue:121-139watch(modelValue) 判断弹窗开关和 Socket 是否存在 |
打开且有 Socket 才监听支付成功;关闭且有 Socket 才移除监听。 | 没有 Socket 就不调用 .on/.off。 |
防止空对象报错,也防止反复开弹窗累积多个监听、一次支付弹多次提示。 |
| 支付 | apps/web/src/views/Course/components/Pay.vue:142-158onConfirm() 判断下单返回码 |
200 才打开支付页、锁定按钮并启动倒计时。 | 失败就展示消息并恢复按钮。 | 防止订单根本没创建成功,页面却一直显示“支付中”。 |
| 埋点 | apps/tracker/src/pv/index.ts:4-14reportView() 判断是否为 Hash 路由 |
含 # 就把 hash 作为页面 path。 |
普通 History 路由使用 location.pathname。 |
防止 Vue Hash 路由每一页都被错误记成同一个 /。 |
| 词库 | server/apps/server/src/word-book/word-book.service.ts:12-23WordBookService.toBoolean() / findAll() 判断标签和搜索词 |
标签值严格等于字符串 true 才加入布尔过滤;搜索词非空才加入 contains。 |
未选标签、空搜索词都写成 undefined,让 Prisma 不加这一项条件。 |
防止“没选某标签”被误解成“专门查标签为 false”,也防止空字符串制造无意义搜索条件。 |
| Agent | server/apps/ai/src/agent/agent-runner.service.ts:90-93AgentRunnerService.run() 判断外部 traceId 格式 |
满足 8~128 位安全字符才沿用,方便跨服务追踪。 | 不合法或没有就生成新的 UUID。 | 防止恶意超长/特殊字符污染日志,也保证每次 Run 总有可追踪 ID。 |
| RAG | server/libs/shared/src/rag/rag-internal.client.ts:107-166RagInternalClient.request() 判断 retryable、外部 AbortSignal 和 HTTP 状态 |
只有显式可重试请求才使用多次 attempts;有外部 signal 时与内部超时 signal 合并;瞬时状态才退避再试。 | 写请求默认只发一次;没有外部 signal 只受内部超时控制;永久 4xx 立即失败。 | 防止写操作因自动重试被执行两遍,也防止用户取消或总超时后请求继续跑。 |
| 学习任务 | server/libs/shared/src/learning/study-domain.service.ts:106-118StudyDomainService.listTasks() 判断是否传了起止日期 |
有任一日期就给 scheduledFor 加 gte/lte 范围。 |
两个日期都没有就不加日期过滤,最多返回排序后的 200 条。 | 防止没传日期时生成一个错误的空区间,也限制一次查询量。 |
| 测验 | server/apps/server/src/study/study.service.ts:67-79StudyService.generateQuiz() 判断题数和模式参数 |
有限数字使用调用方题数;合法模式传给领域层。 | 题数无效时默认 10;模式缺省为 WEAK_WORDS,非法模式直接拒绝。 |
防止 NaN/非法枚举一路传进数据库查询,也让缺省调用有稳定行为。 |
| 课程学习页 | apps/web/src/views/Course/Learn/index.vue:210-218onKeyDown() 判断当前单词是否全部拼对 |
全部正确才调用 pageNext() 进入下一词。 |
仍有错误就停在当前词并提示“请先完成拼写”。 | 防止用户按回车直接跳过未完成的拼写练习。 |
| 课程学习页 | apps/web/src/views/Course/Learn/index.vue:291-315saveWordMaster() / getWordListData() 判断 Business 返回码 |
成功才清当前列表、拉下一批词并更新 Store 中的词数/学习天数。 | 失败只显示错误,不伪造本地学习进度。 | 防止后端保存失败但前端看起来已经学会,造成前后端事实不一致。 |
| 知识库页面 | apps/web/src/views/Knowledge/index.vue:200-218upload() 判断入队后的文档状态 |
状态 FAILED 表示原文件已保存但索引服务暂不可用,显示可重试警告。 | 其他状态显示“正在建立索引”,然后刷新列表。 | 防止把“文件保存成功、索引排队失败”误说成整次上传失败,也不能误说索引已经成功。 |
| 知识库页面 | apps/web/src/views/Knowledge/index.vue:220-239retry() 判断旧状态和新状态 |
旧状态 READY 先弹确认,因为这是全量重建;返回 FAILED 显示可重试警告。 | FAILED 文档重试不弹“覆盖已有索引”确认;成功入队则显示成功。 | 防止用户无意重建一份正常索引,也让创建失败重试保持快捷。 |
3A.1 登录、Token 持久化、自动刷新与鉴权#
第一次登录负责拿到两张“通行证”;以后请求接口时带上短期通行证;短期通行证快过期或已经失效时,再拿长期通行证换新的。把这三件事分开看,就不会误以为这些方法会在一次登录里全部执行。
- 用户点击登录,页面进入
handleLogin()位置:
LoginForm.vue:72-86。它先检查手机号和密码有没有填对,再调用前端的login()。为什么调:页面自己不能查数据库,只能把登录信息交给后端。 - 请求到达
UserController.login()位置:
user.controller.ts:34。Controller 只负责接住请求,然后调用UserService.login()。这样入口和真正的登录规则不会混在一起。 UserService.login()检查用户和密码位置:
user.service.ts:26-60。先按手机号找用户;找不到或密码不对就结束。通过后更新最后登录时间,再调用AuthService.generateToken()。generateToken()返回短期和长期两张通行证位置:
auth.service.ts:12-25。短期 Token 用来访问业务接口,长期 Token 只用来换新。返回后一路回到登录页面,页面把用户信息和两张 Token 写进 Pinia。
Axios 请求拦截器 → ensureAccessToken() → 加上 Authorization → AuthGuard.canActivate() → 具体 Controller。这里的 AuthGuard 像门卫:验证通过后把可信的 userId 放进请求,后面的业务代码不再相信前端自己传来的 userId。
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:72handleLogin() |
定义异步提交方法,保证后续能等待表单和 HTTP 结果。 |
apps/web/src/components/Login/LoginForm.vue:73handleLogin() |
调用 Element Plus 表单校验;校验不通过会阻断提交。 |
apps/web/src/components/Login/LoginForm.vue:74handleLogin() |
读取当前表单响应式对象的原始值。 |
apps/web/src/components/Login/LoginForm.vue:75-78handleLogin() |
构造登录 DTO;手机号原样传递,密码先在浏览器做 MD5,再调 login()。 |
apps/web/src/components/Login/LoginForm.vue:79-83handleLogin() |
响应成功时把 user/accessToken/refreshToken 写入 Store,显示成功消息并关闭登录窗。 |
apps/web/src/components/Login/LoginForm.vue:84-85handleLogin() |
业务失败时显示服务端返回的 message,不改变原 Store。 |
UserService.login():业务端登录主逻辑#
| 代码行 | 作用 |
|---|---|
server/apps/server/src/user/user.service.ts:26-29UserService.login() |
以手机号查找用户;判断用户不存在就立即返回,防止后面拿空用户去比较密码或签发 Token,造成程序报错或错误登录。 |
server/apps/server/src/user/user.service.ts:30-38UserService.login() |
将请求中的密码值与数据库字段比较;不一致则返回密码错误。 |
server/apps/server/src/user/user.service.ts:39-48UserService.login() |
更新 lastLoginAt,并只 select 前端所需的非敏感用户字段。 |
server/apps/server/src/user/user.service.ts:49-55UserService.login() |
以用户 ID/phone 作为 JWT payload,调 generateToken() 一次生成两种 Token。 |
server/apps/server/src/user/user.service.ts:56-60UserService.login() |
用统一 ResponseService 包装用户信息和 Token。 |
安全理解:浏览器 MD5 只是把口令变成了另一个可重放的“口令”,不是安全存储。应该由服务端使用 Argon2/bcrypt + 随机 salt 校验,全程依靠 HTTPS。
generateToken()、ensureAccessToken() 与 Axios 重放#
| 代码行 | 作用 |
|---|---|
server/apps/server/src/auth/auth.service.ts:12-18AuthService.generateToken() |
生成短效 access Token,并写入 tokenType: access。这个标记是给 Guard 判断“它能不能访问业务接口”用的,防止用 refresh Token 冒充 access Token。 |
server/apps/server/src/auth/auth.service.ts:19-24AuthService.generateToken() |
生成长效 refresh Token,并写入 tokenType: refresh,TTL 来自配置,默认 7 天。它只用于换新 Token,不应直接访问业务接口。 |
apps/web/src/apis/token.ts:6-13ensureAccessToken() |
判断 1:没有 refresh Token 就不能续期,直接报错,防止在未登录状态下反复请求刷新接口。判断 2:access Token 还有效就直接用,防止每个 API 都多发一次刷新请求。 |
apps/web/src/apis/token.ts:14-23ensureAccessToken() |
判断当前是否已经有 refreshPromise。有就共用,没有才新建;防止页面同时出现多个 401 时,并发换出多组 Token,导致新旧 Token 互相覆盖。 |
apps/web/src/apis/token.ts:24-26ensureAccessToken() |
finally 不管成功失败都清空共用 Promise,防止后续请求一直拿到上一次已结束或已失败的结果。 |
apps/web/src/apis/token.ts:28-50isExpiring() |
解码 JWT payload。判断距过期是否少于 15 秒,是就提前刷新,防止 Token 在请求飞行途中过期;解码异常也当作过期,防止把损坏 Token 继续发给后端。 |
apps/web/src/apis/index.ts:14-21,67-74serverApi/agentApi 请求拦截器 |
Business 和 Agent 两个 Axios 实例在发送前都等待有效 access Token,然后注入 Authorization,防止各页面遗漏鉴权 Header。 |
apps/web/src/apis/index.ts:30-49,81-96serverApi/agentApi 响应拦截器 |
判断是否是 401 且原请求还没有 _retry。只在这种情况下刷新并重放一次;_retry 防止刷新仍失败时无限重放。 |
AuthGuard.canActivate():服务端鉴权#
| 代码行 | 作用 |
|---|---|
server/libs/shared/src/auth/auth.guard.ts:14-21AuthGuard.canActivate() |
从 Nest ExecutionContext 取 Request,再从 Authorization: Bearer ... 提取 Token。 |
server/libs/shared/src/auth/auth.guard.ts:22-31AuthGuard.canActivate() |
缺 Token 或 JWT 验签失败均抛 401,不把底层错误暴露给客户端。 |
server/libs/shared/src/auth/auth.guard.ts:32-40AuthGuard.canActivate() |
额外检查 tokenType === access 和必需 payload,防止拿 refresh Token 访问业务接口。 |
server/libs/shared/src/auth/auth.guard.ts:41-48AuthGuard.canActivate() |
把解码结果写入 request.user,Controller 之后只信任此服务端身份。 |
3A.2 课程、购买权限、背词与掌握度#
课程列表、背词和词库搜索原来写在同一个表里,看起来像一条链。实际最值得顺着讲的是“进入课程并完成一轮学习”;词库搜索只是另一个独立入口。
- 用户在课程页点击课程
Course/index.vue的handleBuy()先看这门课是否已经购买。已购买就进入学习页;没有购买才打开支付弹窗。为什么先判断:不能让未购买用户直接进入学习流程。 - 学习页调用
getWordListData()要一批新单词页面通过
getWordList(courseId)发请求。后端 Controller 从登录信息里取 userId,再调用LearnService.getWordList()。userId 不由页面提交,避免冒充别人。 getWordList()先查购买记录,再挑选单词它确认用户真的买过课程,然后排除已经掌握的词,按词频取 10 个。返回后页面显示这一批词。
- 用户学完后,页面调用
saveWordMaster()页面收集本轮 wordId,经 API 和 Controller 到达
LearnService.saveWordMaster()。服务端去重、检查单词存在,再在一次数据库操作中更新掌握记录、掌握词数和学习天数。 - 保存成功后重新调用
getWordListData()这就是返回链:后端返回本轮保存结果,前端清掉旧列表,再取下一批未掌握词;保存失败则留在当前页面,不假装用户已经学会。
WordBookService.findAll() 是“搜索和筛选词典”的另一条读数据流程,不是背完一批词后自动调用的方法。代码位置速查(主链和独立入口)#
| 步骤 | 文件与方法 | 职责 |
|---|---|---|
| 1 | apps/web/src/views/Course/index.vue:102-128init() / handleBuy() |
切换全部/已购课程,决定进学习页还是打开支付。 |
| 2 | apps/web/src/apis/course/index.ts:4-10getCourseList() / getMyCourseList() |
请求课程列表与我的课程。 |
| 3 | server/apps/server/src/course/course.controller.ts:10-19CourseController.findAll() / findMy() |
公开全部课程,已购课程需 AuthGuard。 |
| 4 | server/apps/server/src/course/course.service.ts:13-39CourseService.findAll() / findMy() |
Prisma 查课程与成功支付记录,格式化 Decimal 价格。 |
| 5 | apps/web/src/views/Course/Learn/index.vue:291-318saveWordMaster() / getWordListData() |
加载待学词、上传已掌握词。 |
| 6 | server/apps/server/src/learn/learn.controller.ts:18-33LearnController.saveWordMaster() / getWordList() |
从 JWT 取 userId,转交学习 Service。 |
| 7 | server/apps/server/src/learn/learn.service.ts:13-97LearnService.saveWordMaster() |
事务、用户级锁、去重、upsert 掌握度与连续学习天数。 |
| 8 | server/apps/server/src/learn/learn.service.ts:99-134LearnService.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-24LearnService.saveWordMaster() |
验证输入必须是数组;清理、去重 wordId,限制一次 1~100 个。 |
server/apps/server/src/learn/learn.service.ts:25-29LearnService.saveWordMaster() |
开启 Prisma 事务,对 word-mastery:userId 加 PostgreSQL advisory transaction lock,串行化同一用户的掌握度更新。 |
server/apps/server/src/learn/learn.service.ts:30-46LearnService.saveWordMaster() |
查现有 WordBookRecord,算出缺失 ID;再去 WordBook 验证这些词真实存在。 |
server/apps/server/src/learn/learn.service.ts:47-57LearnService.saveWordMaster() |
生成本次复习时间和默认 3 天后下次复习时间。 |
server/apps/server/src/learn/learn.service.ts:58-70LearnService.saveWordMaster() |
批量创建新记录:掌握分 80、isMaster=true、复习次数 1。 |
server/apps/server/src/learn/learn.service.ts:71-80LearnService.saveWordMaster() |
对原有但未掌握的记录条件更新,避免重复请求反复增加用户掌握词数。 |
server/apps/server/src/learn/learn.service.ts:81-88LearnService.saveWordMaster() |
以真正从未掌握变为已掌握的数量更新 User.wordNumber。 |
server/apps/server/src/learn/learn.service.ts:89-94LearnService.saveWordMaster() |
有新学习事实时调 syncDayNumber() 从真实活动重算天数;否则保留现值。 |
server/apps/server/src/learn/learn.service.ts:95-97LearnService.saveWordMaster() |
返回学会的词数、累计词数与学习天数。 |
LearnService.getWordList():只让已购用户学对应课程#
| 代码段 | 作用 |
|---|---|
server/apps/server/src/learn/learn.service.ts:99-111LearnService.getWordList() |
查 CourseRecord,条件同时包含 userId、courseId、isPurchased=true;查不到就拒绝。 |
server/apps/server/src/learn/learn.service.ts:112-119LearnService.getWordList() |
从课程配置取对应词书标记,先查当前用户已掌握的 wordId。 |
server/apps/server/src/learn/learn.service.ts:120-131LearnService.getWordList() |
查 WordBook:课程标记为 true,ID 不在已掌握集合;按词频降序取 10 个。 |
server/apps/server/src/learn/learn.service.ts:132-134LearnService.getWordList() |
以统一响应结构返回,页面直接显示。 |
3A.3 Agent 会话、SSE 流式输出、取消与恢复#
前端不会等完整答案一次性回来,而是保持一条长连接。后端每拿到一小段文字就立即发给页面,同时在数据库中记录这次任务的开始、完成、取消或失败。
- 用户发送消息,
sendMessage()先在页面放两个临时气泡一个是用户刚输入的内容,一个是空的 AI 气泡。为什么先放:不用等后端响应,用户马上能看到消息已经发出。随后调用
streamAgentMessage()。 streamAgentMessage()带上有效 Token 发起流式请求位置:
apps/web/src/apis/sse/index.ts。它请求/messages/stream,收到一条事件就交给页面的handleAgentEvent()。如果第一次返回 401,只刷新一次 Token 后重试。ConversationsController.stream()建立长连接Controller 先确认会话属于当前用户,再设置流式响应头,然后调用
AgentRunnerService.run()。它还监听浏览器断开:用户关页或断网时,会通知后端取消本次任务,避免模型在后台白跑。run()先把这次任务登记为正在运行Runner 检查消息、会话和可用文档,再调用
lifecycle.createRun()。这个方法会挡住同一会话的第二个任务,也限制单个用户同时跑太多任务。成功后得到 runId,并设置取消开关和总超时。- 保存用户消息并准备上下文
Runner 调
persistUserMessage()保存用户消息,再调prepareGraphMessages()读取历史对话。然后发送run.started,页面因此知道真正的 runId。 - 准备资料和工具,再让 Agent 开始回答
Runner 依次调用资料检索服务、工具服务和提示词服务。学习教练可以使用学习工具;资料辅导和写作场景只留下允许的工具。准备好后调用
agent.stream()。 - 模型每返回一小段文字,就发送一次
message.deltaRunner 一边把小段文字拼成完整回答,一边发给前端。
handleAgentEvent()把 delta 追加到刚才的空气泡里,于是用户看到逐字生成效果。 - 完整回答先保存,再通知页面完成
Runner 清理无效引用后调用
completeRun()。它在同一次数据库提交中把 Run 改为完成并保存最终 AI 消息。保存成功后才发送action.required和run.completed,前端用真实数据库消息替换临时气泡。
代码位置速查(看完上面的主线再查)#
| 步骤 | 文件与方法 | 职责 |
|---|---|---|
| 1 | apps/web/src/views/Chat/index.vue:308-350loadConversations() / newConversation() / selectConversation() |
加载/新建/切换会话,恢复消息和待确认 Action。 |
| 2 | apps/web/src/views/Chat/index.vue:369-427sendMessage() |
前端乐观插入用户消息和 Assistant 占位,启动 SSE。 |
| 3 | apps/web/src/apis/sse/index.ts:11-72streamAgentMessage() |
POST SSE、解析事件、仅一次 401 刷新、支持 Abort。 |
| 4 | server/apps/ai/src/conversations/conversations.controller.ts:61-113ConversationsController.stream() |
验权/限流/所有权,设 SSE Header,转发 Agent 事件,监听断开。 |
| 5 | server/apps/ai/src/agent/agent-runner.service.ts:63-451AgentRunnerService.run() |
只保留总编排:串起 Lifecycle、Context、RAG、Tools、Prompt、Usage,消费模型流并发 SSE。 |
| 6 | server/apps/ai/src/agent/agent-run-lifecycle.service.ts:46-171createRun() / persistUserMessage() / completeRun() |
用锁和事务建 Run,保存 User 消息,再原子完成 Run 并写 Assistant 消息。 |
| 7 | server/apps/ai/src/agent/agent-runner.service.ts:453-467AgentRunnerService.cancel()agent-run-lifecycle.service.ts:262-304cancelRun() / rejectPendingActions() |
Runner 中止本实例 AbortController;Lifecycle 条件更新 Run 为 CANCELLED 并拒绝待确认 Action。 |
| 8 | apps/web/src/views/Chat/index.vue:429-533handleAgentEvent() / stopGeneration() / recoverSavedState() |
消费事件、停止生成、从服务端重建真实状态。 |
sendMessage() 与 handleAgentEvent():页面如何不断更新#
| 代码段 | 作用 |
|---|---|
apps/web/src/views/Chat/index.vue:369-377sendMessage() |
先 trim 输入。判断空文本是为了防空消息;判断正在流式是为了防同一页面重复发送;没有会话时先新建,防消息无归属。 |
apps/web/src/views/Chat/index.vue:378-397sendMessage() |
清上轮临时状态,本地先插入 USER 消息和空 ASSISTANT 占位,让用户立即看到“已发送”,后续增量文本有固定位置可写。 |
apps/web/src/views/Chat/index.vue:398-420sendMessage() |
调 streamAgentMessage()。onEvent 交统一分发;onError/onClose 都清“生成中”标志并延时对账,防止 SSE 断线后本地内容与数据库最终内容不一致。 |
apps/web/src/views/Chat/index.vue:421-427sendMessage() |
如果 SSE 连接根本没启动成功,立即恢复 UI,防止按钮永久处于禁用/“生成中”。 |
apps/web/src/views/Chat/index.vue:429-444handleAgentEvent() |
根据 run.started/status/tool.* 更新 runId、状态文字和工具进度,使后续“取消”知道要取消哪个 Run。 |
apps/web/src/views/Chat/index.vue:445-454handleAgentEvent() |
message.delta 只追加到当前 Assistant 占位;Action/测验事件变成交互卡片,防止把“待用户确认”误当成已执行结果。 |
apps/web/src/views/Chat/index.vue:455-463handleAgentEvent() |
判断 run.completed 时用服务端终稿替换本地占位,防止漏字/重字;error 显示保底内容,然后滚到最新消息。 |
streamAgentMessage():POST 方式 SSE#
| 代码段 | 作用 |
|---|---|
apps/web/src/apis/sse/index.ts:11-18streamAgentMessage() |
建 AbortController,异步 start() 内最多两次尝试(原请求 + 401 刷新后一次)。 |
apps/web/src/apis/sse/index.ts:19-34streamAgentMessage() |
获取有效 Token,用 fetchEventSource POST JSON,带 Bearer 和 AbortSignal。 |
apps/web/src/apis/sse/index.ts:35-44streamAgentMessage() |
onopen 必须是 2xx 且 Content-Type 是 event-stream;401 抛特殊错误,其他异常直接拒绝。 |
apps/web/src/apis/sse/index.ts:45-54streamAgentMessage() |
onmessage 忽略空包,JSON.parse 后交给页面;单个坏包触发错误回调。 |
apps/web/src/apis/sse/index.ts:55-63streamAgentMessage() |
正常关闭时通知页面;异常则抛给外层判断是否属于可刷新 Token 的 401,防止把网络断开、服务端 500 等问题误当成登录过期而不停刷新 Token。 |
apps/web/src/apis/sse/index.ts:64-72streamAgentMessage() |
401 时强制刷新并 continue;其他错误通知 UI 后 abort;方法立即返回 Controller 供“停止”使用。 |
AgentRunnerService.run():Agent 主循环#
| 代码段 | 作用 |
|---|---|
server/apps/ai/src/agent/agent-runner.service.ts:63-101AgentRunnerService.run() |
校验消息和会话,用 validateDocumentScope() 生成服务端文档白名单;校验 traceId,再把 Provider、模型和并发上限交给 AgentRunLifecycleService.createRun()。 |
server/apps/ai/src/agent/agent-run-lifecycle.service.ts:46-106AgentRunLifecycleService.createRun() |
事务内先锁会话再锁用户;判断会话是否仍活跃、同会话是否已有 RUNNING、用户是否超并发限额。这是为了防同一会话被两个 Run 同时改乱,也防单用户占满资源。 |
server/apps/ai/src/agent/agent-runner.service.ts:102-168AgentRunnerService.run() |
建 AbortController 和 activeRuns;每 1.5 秒查数据库 Run,别的实例一旦取消就立即 Abort;安装全局超时,再初始化 SSE 序号、引用、Action、审计和 Token 容器。 |
server/apps/ai/src/agent/agent-runner.service.ts:170-182run()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-245AgentRunnerService.run() |
两条快路:非学习教练却请求建计划时直接提示切换场景;知识导师的纯问候不调模型/工具。两条路都会调 completeRun() 落库,防止“前端看到了回答但数据库没记录”。 |
server/apps/ai/src/agent/agent-runner.service.ts:247-259run()agent-rag.service.ts:22-128 prefetchKnowledge() |
调用拆出的 RAG 服务做模型前预检索。白名单为空就明确返回 NO_DOCUMENTS;检索服务故障则返回 UNAVAILABLE,防止把故障说成“用户没资料”。 |
server/apps/ai/src/agent/agent-runner.service.ts:261-296run()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-324AgentRunnerService.run() |
启动 LangGraph message stream,带 AbortSignal、递归上限、Usage Callback 和 thread/run ID;只接受 AIMessageChunk 且非空 delta,边累加答案边发 message.delta。 |
server/apps/ai/src/agent/agent-runner.service.ts:326-347run()agent-rag.service.ts:166-239 enforceKnowledgeState() / finalizeGroundedCitations() |
拒绝空回答;修正与知识库真实状态矛盾的文本;清理 Prompt 泄漏;最后只保留本轮真实召回且答案实际使用的引用。 |
server/apps/ai/src/agent/agent-runner.service.ts:348-372run()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-445run()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-450AgentRunnerService.run() |
finally 清理全局超时器、跨实例状态轮询和 activeRuns,防止定时器/内存泄漏。 |
完成、取消与前端恢复#
| 方法/代码段 | 作用 |
|---|---|
server/apps/ai/src/agent/agent-run-lifecycle.service.ts:133-171AgentRunLifecycleService.completeRun() |
事务中判断 Run 是否仍为 RUNNING,只有抢到 RUNNING -> COMPLETED 的请求才写 Assistant 消息。这是为了防止“正常完成”和“取消/超时”同时落库,生成两个终态或重复消息。 |
server/apps/ai/src/agent/agent-runner.service.ts:453-467AgentRunnerService.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-479stopGeneration() |
先告诉服务端取消,再中止浏览器 SSE,最后重读持久化内容。这个顺序防止只关网页连接、后端模型仍继续跑。 |
apps/web/src/views/Chat/index.vue:512-533recoverSavedState() |
并行读消息和 Action。判断服务端是否已有 Assistant 终稿,或本地已无占位;只在其中一个条件成立时替换,防止数据库短暂还没写完时用空列表覆盖屏幕上的增量文本。 |
3A.4 Agent 写操作:用户确认、避免重复执行与故障恢复#
下面以“帮我制定一份学习计划”为例。最关键的不是模型会写计划,而是它不能绕过用户直接让计划生效;即使用户连点两次、两个服务同时处理或执行中途重启,也只能得到一份正确结果。
- Runner 把
create_study_plan工具交给学习教练调用发生在上一条 Agent 主链里:
AgentRunner.run()调AgentToolsService.createTools(),再把工具交给 Agent。只有模型判断用户确实想制定计划时,才会进入这个工具。 - 工具先调用
buildPersonalizedStudyPlan()组装计划草稿位置:
agent-tools.service.ts:253-293。它读取当前用户的学习统计、薄弱词和到期复习词。模型只提供目标、天数和侧重点,真正的日期、任务和单词 ID 由后端生成,避免模型随便编数据。 prepareStudyPlan()保存“草稿计划 + 待确认操作”它给本次操作算一个防重复标记;先检查这次 Run 和会话确实属于当前用户,再创建状态为“等待确认”的计划和 Action。这两条记录一起成功或一起失败。
- Action 返回工具,再回到 Runner
工具把 Action 放进
pendingActions,并告诉模型“正在等待用户确认”。Runner 必须先把本次 AI 回答保存成功,之后才发送action.required。这样失败的 AI 任务不会留下一个还能执行的按钮。 - 前端收到
action.required,展示确认和拒绝按钮handleAgentEvent()把 Action 放到页面列表。用户点确认时,confirmAction()调用/actions/{id}/confirm;点拒绝则走另一条拒绝接口。 AgentActionService.confirm()再做一次完整检查它确认 Action 属于当前用户、没过期、会话还开着,而且创建它的 AI 任务已经成功结束。随后把状态从“等待确认”改成“已经确认”。如果用户连点两次,只有第一次能改成功。
executeConfirmed()抢到执行权后调用activatePlan()它先留下“我正在处理”的时间,其他服务看到后不会重复执行。然后在同一次数据库提交里创建真实学习任务、激活新计划、结束旧计划,并把 Action 改成“执行成功”。任一步失败,整组修改都不会只完成一半。
- 执行结果写回原会话,页面刷新也能看见
成功或拒绝都会生成一条结果消息。前端确认请求结束后重新读取消息和 Action,所以即使中间断网,最终页面仍以数据库里的真实结果为准。
代码位置速查(看完上面的主线再查)#
| 步骤 | 文件与方法 | 职责 |
|---|---|---|
| 1 | server/apps/ai/src/agent/agent-tools.service.ts:253-293AgentToolsService.createTools() 内的 create_study_plan Tool |
从真实学习数据组计划,只准备 Action,不直接生效。 |
| 2 | server/libs/shared/src/learning/agent-action.service.ts:23-71AgentActionService.prepareStudyPlan() |
建 PENDING_CONFIRMATION 计划和 PENDING Action,幂等防重。 |
| 3 | apps/web/src/views/Chat/index.vue:481-507confirmAction() / rejectAction() |
用户点确认/拒绝,然后重读会话真实状态。 |
| 4 | server/apps/ai/src/conversations/actions.controller.ts:27-55ActionsController.confirm() / list() / reject() |
JWT + 限流,确认/查询/拒绝 Action。 |
| 5 | server/libs/shared/src/learning/agent-action.service.ts:116-188AgentActionService.confirm() |
锁会话/Action,验 Run 已完成,条件确认。 |
| 6 | server/libs/shared/src/learning/agent-action.service.ts:190-302AgentActionService.executeConfirmed() |
抢执行租约,分派业务方法,写 EXECUTED/可重试/永久失败。 |
| 7 | server/libs/shared/src/learning/study-domain.service.ts:560-622StudyDomainService.activatePlanInTransaction() |
创建真实任务并激活计划。 |
| 8 | server/apps/ai/src/agent/agent-action-recovery.service.ts:23-58onModuleInit() / runRecovery() |
定时过期、重试、补偿和清孤儿 Action。 |
prepareStudyPlan():每一段在做什么#
| 代码段 | 作用 |
|---|---|
server/libs/shared/src/learning/agent-action.service.ts:23-28AgentActionService.prepareStudyPlan() |
把计划 JSON 做稳定哈希,组成 runId + planHash 幂等键:同一 Run 产生同一计划时只有一个 Action。 |
server/libs/shared/src/learning/agent-action.service.ts:29-34AgentActionService.prepareStudyPlan() |
开启事务,锁会话,并用 assertActionContext() 校验 user/conversation/run 的归属与运行状态。 |
server/libs/shared/src/learning/agent-action.service.ts:35-41AgentActionService.prepareStudyPlan() |
对幂等键加 advisory lock;先查已有 Action,找到就返回 DTO,不再创建计划。 |
server/libs/shared/src/learning/agent-action.service.ts:42-45AgentActionService.prepareStudyPlan() |
执行用户级 Action 配额检查;再锁 study-plan-user:userId,避免并发计划冲突。 |
server/libs/shared/src/learning/agent-action.service.ts:46-50AgentActionService.prepareStudyPlan() |
调 createPendingPlan():计划先写为 PENDING_CONFIRMATION,任务只存 JSON 草稿,未创建可执行 StudyTask。 |
server/libs/shared/src/learning/agent-action.service.ts:51-66AgentActionService.prepareStudyPlan() |
创建 AgentAction:写类型、计划 ID/payload、面向用户的 summary、幂等键,并设 24 小时过期。 |
server/libs/shared/src/learning/agent-action.service.ts:67-71AgentActionService.prepareStudyPlan() |
把 Prisma 记录转成前端 Action DTO;事务一起提交计划和 Action。 |
confirm():确认不等于盲目执行#
| 代码段 | 作用 |
|---|---|
server/libs/shared/src/learning/agent-action.service.ts:116-123AgentActionService.confirm() |
先做一次归属查询取 conversationId,找不到直接 404。 |
server/libs/shared/src/learning/agent-action.service.ts:124-136AgentActionService.confirm() |
事务内按固定顺序锁会话和 Action,再重读当前记录,防止 TOCTOU。 |
server/libs/shared/src/learning/agent-action.service.ts:137-149AgentActionService.confirm() |
已 EXECUTED 或已 CONFIRMED 直接按幂等返回;过期则改 EXPIRED 并准备补偿。 |
server/libs/shared/src/learning/agent-action.service.ts:150-164AgentActionService.confirm() |
只允许 PENDING;确认会话仍 ACTIVE;且创建该 Action 的 Run 必须已 COMPLETED,防止模型未结束就执行。 |
server/libs/shared/src/learning/agent-action.service.ts:165-176AgentActionService.confirm() |
用 updateMany where status=PENDING 做原子 PENDING -> CONFIRMED;抢不到则重读最新状态。 |
server/libs/shared/src/learning/agent-action.service.ts:177-188AgentActionService.confirm() |
事务外处理过期补偿;已执行直接返回;真正需执行的才进 executeConfirmed()。 |
executeConfirmed() 与恢复器#
| 代码段 | 作用 |
|---|---|
server/libs/shared/src/learning/agent-action.service.ts:190-215AgentActionService.executeConfirmed() |
设 60 秒执行租约;条件抢占 CONFIRMED/RETRYING 的 Action,记录执行时间与 attempt 次数。 |
server/libs/shared/src/learning/agent-action.service.ts:216-231AgentActionService.executeConfirmed() |
没抢到时重读;EXECUTED 直接返回,其他实例正在执行则不重复执行。 |
server/libs/shared/src/learning/agent-action.service.ts:232-257AgentActionService.executeConfirmed() |
事务内锁 Action,按类型调 activatePlan() 或 completeTask();两者都支持传入同一事务。 |
server/libs/shared/src/learning/agent-action.service.ts:258-273AgentActionService.executeConfirmed() |
业务成功后更新 Action 为 EXECUTED、保存结果,再写回执消息,页面刷新仍能看到结果。 |
server/libs/shared/src/learning/agent-action.service.ts:274-302AgentActionService.executeConfirmed() |
异常时区分永久 4xx/超过重试上限与瞬时错误;前者补偿并 FAILED,后者 RETRYING 等恢复器续跑。 |
server/apps/ai/src/agent/agent-action-recovery.service.ts:23-35AgentActionRecoveryService.onModuleInit() |
判断配置周期是否至少 30 秒,防止配错后高频扫数据库;启动时先立即恢复一次,防止服务重启后还要等完整周期。 |
server/apps/ai/src/agent/agent-action-recovery.service.ts:41-58AgentActionRecoveryService.runRecovery() |
判断本实例上一轮是否还没结束,是就跳过,防止定时器重入;四类恢复并行执行且分别记错,防止一个坏 Action 拖住所有恢复。 |
3A.5 私人文档上传、MinIO、Celery 和 Chroma 异步入库#
上传请求只负责把原文件安全保存并安排后台处理。解析 PDF、切成小段、计算向量可能很慢,所以交给后台任务;页面通过状态变化知道它什么时候真正可检索。
- 用户选中文件,页面调用
upload()前端先挡住明显超过 20MB 的文件,再用
uploadKnowledgeDocument()把文件和标题发给 Business 服务。页面此时只能说“正在处理”,不能说“已经能搜索”。 KnowledgeController.upload()接住文件并调用KnowledgeService.upload()Controller 负责登录检查、请求次数限制和接收文件;Service 才负责真正的业务规则。userId 来自登录信息,不从表单里取。
upload()检查文件并创建文档记录它检查扩展名、PDF 文件头、文本编码、大小、重复内容和用户额度。通过后先在 PostgreSQL 建一条
UPLOADED记录,再把原文件写进 MinIO。- 原文件保存成功后调用
enqueue()enqueue()把文档状态改成排队中,整理对象地址、文件哈希和处理版本,然后调用RagInternalClient.createIngestion()。重建旧索引时则调用reindexDocument(),这是另一条入口,但后面会汇入同一套处理流程。 - RAG Client 给内部请求签名,再发给 FastAPI
签名的作用可以理解为内部服务之间的防伪章:Python 服务确认请求确实来自 NestJS,而且内容没有被改。FastAPI 校验通过后只负责把任务放进 Celery 队列,并立即返回 jobId。
- Celery Worker 在后台调用
process_ingestion()Worker 先锁住这份文档,避免两个人同时处理同一份文件。然后从 MinIO 下载原文,再核对大小和哈希,确保拿到的就是用户上传的那一份。
process_ingestion()解析、切段、计算向量并写入 ChromaPDF 保留页码,Markdown/TXT 按文本解析;长内容切成小段后批量计算向量。全部写入后还会重新核对分片数量和 ID,只有完整一致才算成功。
- Worker 回调 Business,页面轮询看到最终状态
Worker 把
PROCESSING、READY或FAILED回传给 NestJS。applyRagStatus()检查这是不是当前这次任务的回调,再更新数据库。页面每 3 秒刷新一次;看到READY后停止轮询并显示“可检索”。
代码位置速查(看完上面的主线再查)#
| 步骤 | 文件与方法 | 职责 |
|---|---|---|
| 1 | apps/web/src/views/Knowledge/index.vue:167-218loadDocuments() / upload() |
选文件、上传、刷新列表并开启状态轮询。 |
| 2 | apps/web/src/apis/knowledge/index.ts:7-33uploadKnowledgeDocument() / getKnowledgeDocuments() / retryKnowledgeDocument() / deleteKnowledgeDocument() |
构造 FormData,上传/列表/重试/删除 API。 |
| 3 | server/apps/server/src/knowledge/knowledge.controller.ts:20-80KnowledgeController.upload() / list() / get() / retry() / remove() |
JWT、Redis 限流、Multer 20MB 限制、转 Service。 |
| 4 | server/apps/server/src/knowledge/knowledge.service.ts:99-252KnowledgeService.upload() |
文件检查、去重、配额、PostgreSQL 元数据、MinIO 原文件。 |
| 5 | server/apps/server/src/knowledge/knowledge.service.ts:711-797KnowledgeService.enqueue() |
抢 QUEUED,组内部合约,调 FastAPI,保存 jobId 或降级 FAILED。 |
| 6 | server/libs/shared/src/rag/rag-internal.client.ts:42-60,107-167createIngestion() / reindexDocument() / request() |
HMAC 签名内部 HTTP、超时、有限重试。 |
| 7 | server-py/app/api/rag.py:29-91create_ingestion() / reindex_document() |
创建端点直接校验并投递;重建端点先校验路径 ID 与 body ID 一致,再汇入同一套安全校验和确定性 Celery Job。 |
| 8 | server-py/app/tasks/celery_app.py:117-182ingest_document() |
Redis 文档锁、永久/瞬时错误、指数退避、失败清理。 |
| 9 | server-py/app/services/ingestion.py:82-218process_ingestion() |
下载、校验、解析、分片、Embedding、Chroma upsert、READY 回调。 |
| 10 | server/apps/server/src/knowledge/knowledge.service.ts:610-709KnowledgeService.applyRagStatus() |
校验 trace/状态机/真实索引信息,更新业务事实。 |
| 11 | apps/web/src/views/Knowledge/index.vue:179-185schedulePolling() |
只要还有中间态,3 秒后静默刷新。 |
前端 upload() 与轮询#
| 代码段 | 作用 |
|---|---|
apps/web/src/views/Knowledge/index.vue:167-177loadDocuments() |
读当前页并替换 list/total。判断 quiet 是为了区分“用户主动打开”和“后台轮询”,防止每 3 秒整页 loading 闪一次。 |
apps/web/src/views/Knowledge/index.vue:179-185schedulePolling() |
先清旧 timer,防止重复调用后同时存在多个轮询。再判断是否有 UPLOADED/QUEUED/PROCESSING/DELETING;只有任务还没结束才 3 秒后继续查,防止 READY/FAILED 后仍无限请求。 |
apps/web/src/views/Knowledge/index.vue:188-198onFileChange() |
取第一个文件。判断是否超过 20MB,超过就清空选择,防止明知后端会拒绝还上传大文件、浪费网络。 |
apps/web/src/views/Knowledge/index.vue:200-209upload() |
判断没选文件就直接返回,防空请求。上传后判断状态是否 FAILED;FAILED 说明文件可能已保存但索引没排上,因此显示“可重试”警告,防止误导用户以为已可检索。 |
apps/web/src/views/Knowledge/index.vue:210-218upload() |
服务端受理后清文件/标题/原生 input,回第 1 页并重读;finally 恢复按钮,防止异常后上传按钮永久 loading。 |
apps/web/src/apis/knowledge/index.ts:7-17uploadKnowledgeDocument() |
把 file 和非空 title 放入 FormData,将超时放宽到 120 秒,防止较大文件在正常上传中被通用短超时误杀。 |
KnowledgeService.upload():业务事实与原文件#
| 代码段 | 作用 |
|---|---|
server/apps/server/src/knowledge/knowledge.service.ts:99-112KnowledgeService.upload() |
判断 Multer 是否真的收到文件和非空 buffer,防止空请求继续入库;服务端再做一次大小限制,防止只相信前端或代理层限制而被超大文件拖垮。然后用 inspectFile() 检查真实内容并计算 SHA-256。 |
server/apps/server/src/knowledge/knowledge.service.ts:113-119KnowledgeService.upload() |
以 userId + contentHash + deletedAt=null 预查重复,给用户可理解的冲突。 |
server/apps/server/src/knowledge/knowledge.service.ts:121-125KnowledgeService.upload() |
生成不可猜文档 ID,清理标题,构造包含 userId/documentId 的私有 MinIO objectKey。 |
server/apps/server/src/knowledge/knowledge.service.ts:126-168KnowledgeService.upload() |
事务内对用户上传加锁;再查重、文档数配额和总字节配额;插入 KnowledgeDocument(status=UPLOADED)。 |
server/apps/server/src/knowledge/knowledge.service.ts:169-181KnowledgeService.upload() |
捕获唯一约束竞态;如果另一个并发请求已抢先插入,转成“已上传”而不是 500。 |
server/apps/server/src/knowledge/knowledge.service.ts:183-190KnowledgeService.upload() |
将原始 buffer 写入私有 Bucket,带 MIME 和 contentHash 元数据。 |
server/apps/server/src/knowledge/knowledge.service.ts:191-228KnowledgeService.upload() |
写对象抛错时先 statObject 对账,处理“已写成但客户端超时”;确实未持久化才条件改 FAILED。 |
server/apps/server/src/knowledge/knowledge.service.ts:230-243KnowledgeService.upload() |
入队前再确认文档仍是 UPLOADED/未删除;如用户并发删除,清 MinIO 并终止。 |
server/apps/server/src/knowledge/knowledge.service.ts:245-251KnowledgeService.upload() |
调 enqueue();即使 RAG 队列暂时失败,仍返回已保存文档和可重试提示。 |
inspectFile() 和 enqueue()#
| 代码段 | 作用 |
|---|---|
server/apps/server/src/knowledge/knowledge.service.ts:810-824KnowledgeService.inspectFile() |
判断扩展名是否为 pdf/md/txt,防止把未支持的文件交给解析器。PDF 还判断前 5 字节是否为 %PDF-,防止只改后缀名就伪装成 PDF。 |
server/apps/server/src/knowledge/knowledge.service.ts:826-838KnowledgeService.inspectFile() |
判断文本能否严格按 UTF-8 解码、是否含 NUL 二进制字节,防止乱码或二进制文件进入文本分片流程。 |
server/apps/server/src/knowledge/knowledge.service.ts:716-734KnowledgeService.enqueue() |
用条件更新判断文档是否仍为 UPLOADED;只有是才改 QUEUED。如果更新数不是 1,说明另一个请求已改状态,立即停止,防止重复入队或把删除中文档重新入队。 |
server/apps/server/src/knowledge/knowledge.service.ts:735-747KnowledgeService.enqueue() |
组装跨服务请求。不在 HTTP 再传大文件,只传 MinIO 定位信息和哈希,防止文件在服务间重复拷贝并便于 Worker 校验。 |
server/apps/server/src/knowledge/knowledge.service.ts:283-343KnowledgeService.retry() |
只允许 FAILED/READY 重试。原状态是 READY 时生成 reindex=true,表示已经有完整旧索引;原状态是 FAILED 时生成 reindex=false,表示重新创建一次入库任务。然后把这个布尔值原样交给 enqueue(),所以分支来源不是随便猜的。 |
server/apps/server/src/knowledge/knowledge.service.ts:748-751KnowledgeService.enqueue() |
reindex === true 明确调用 RagInternalClient.reindexDocument(request, traceId);它会请求文档专用的 /internal/v1/documents/{documentId}/reindex 端点,负责重建已经 READY 的旧索引。 |
server/apps/server/src/knowledge/knowledge.service.ts:748-751KnowledgeService.enqueue() |
reindex === false 明确调用 RagInternalClient.createIngestion(request, traceId);它会请求 /internal/v1/ingestions,用于新上传文档或 FAILED 文档重新创建入库任务。 |
server/apps/server/src/knowledge/knowledge.service.ts:752-769KnowledgeService.enqueue() |
不论上面走创建还是重建,FastAPI 都必须返回通过校验的 jobId。保存 jobId 时再判断 status/trace 仍是本任务,防止上一次慢响应把新一次重试的 jobId 覆盖掉。 |
server/apps/server/src/knowledge/knowledge.service.ts:770-796KnowledgeService.enqueue() |
如果 FastAPI/队列暂时不可用,只把“同 trace 且仍 QUEUED”的记录改 FAILED,防止覆盖用户已发起的新重试;原文件保留,不让短暂队列故障造成数据丢失。 |
RagInternalClient:创建端点和重建端点分别做什么#
| 代码段 | 作用 |
|---|---|
server/libs/shared/src/rag/rag-internal.client.ts:42-50RagInternalClient.createIngestion() |
POST /internal/v1/ingestions 创建新的入库任务,然后用 validateIngestionAccepted() 检查响应里的 documentId/jobId,防止下游回了别的文档任务。 |
server/libs/shared/src/rag/rag-internal.client.ts:52-60RagInternalClient.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-65create_ingestion() |
HMAC 先确认是可信 NestJS 在调。再判断 Bucket 是否是指定私有桶、objectKey 是否真在该用户/文档目录、版本是否一致,防止 Worker 被诱导去读别人文件或写错向量 Collection。 |
server-py/app/api/rag.py:66-72create_ingestion() |
把 Celery 的同步 apply_async 放进线程池,防止 Redis/Broker 卡顿把 FastAPI 整个事件循环堵住;最后返回真实 jobId。 |
server-py/app/api/rag.py:75-91reindex_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-137ingest_document() |
先用 Pydantic 重新检查队列 payload,防止坏消息直接进主流程。然后尝试获取这份 documentId 的 Redis 锁;拿不到就说明另一个 Worker 正在处理同一文档,于是按锁 TTL 延后重试,防止重复解析、重复 Embedding 和向量互相覆盖。 |
server-py/app/tasks/celery_app.py:138-153ingest_document() |
调 process_ingestion()。判断错误是否为 PermanentIngestionError;如果文件格式、哈希、权限等永久不会自愈,就不做无意义重试,而是清向量、回调 FAILED,防止残留半套索引。 |
server-py/app/tasks/celery_app.py:154-174ingest_document() |
其他错误先判断已重试几次。未达上限就指数退避,给网络/Embedding/Chroma 短暂故障恢复时间,也防止立即重试形成请求风暴;到上限才清理并标 FAILED。 |
server-py/app/tasks/celery_app.py:175-182ingest_document() |
finally 一定释放 Redis 锁,防止下次永久处理不了这份文档。如果锁已因超时自动失效,只记警告,防止“释放一把已没有的锁”覆盖真正业务结果。 |
process_ingestion():解析到 READY 的每个阶段#
| 代码段 | 作用 |
|---|---|
server-py/app/services/ingestion.py:82-96process_ingestion() |
取配置和 Chroma;先判断删除 tombstone,防止用户已经删文档后,迟到的 Worker 又把向量写回来。再用 document/owner/hash/trace 判断是否已经完整入库,给重复任务走幂等快路。 |
server-py/app/services/ingestion.py:97-116process_ingestion() |
已完整入库就清旧 Collection,重发 READY 回调并返回,这是任务级幂等快路。 |
server-py/app/services/ingestion.py:117-123process_ingestion() |
先回调 PROCESSING,让 PostgreSQL/前端看到 Worker 已真正开始。 |
server-py/app/services/ingestion.py:124-142process_ingestion() |
在线程池读 MinIO;区分原对象不存在;重新校验大小、请求 size 和 SHA-256,防止中途替换/损坏。 |
server-py/app/services/ingestion.py:143-152process_ingestion() |
parse_document() 按 PDF/TXT/MD 解析为带页码 Section,split_sections() 按边界和 overlap 分片;输入问题转永久错误。 |
server-py/app/services/ingestion.py:154-159process_ingestion() |
按 embedding_batch_size 分批生成向量,避免一次把过多文本打给 Provider。 |
server-py/app/services/ingestion.py:160-177process_ingestion() |
写向量前再查删除;清当前文档旧向量并验 remaining=0;将 chunks/vectors/所有者/标题/哈希/trace upsert 到 Chroma。 |
server-py/app/services/ingestion.py:178-191process_ingestion() |
写入后第三次判断删除竞态;如果用户刚好在写向量期间删除了文档,就把所有 Collection 中的向量清干净,防止“页面上已删除、知识库里还能搜到”。然后再删除旧 Collection 副本。 |
server-py/app/services/ingestion.py:192-205process_ingestion() |
组 READY 回调,包含真实 chunkCount/Provider/Model/indexVersion;如 Business 以 4xx 拒绝 READY,立即清向量防止孤儿。 |
server-py/app/services/ingestion.py:206-218process_ingestion() |
记录分片数、Embedding 批次和总耗时,返回回调对象。 |
applyRagStatus():为什么迟到回调不能乱改状态#
| 代码段 | 作用 |
|---|---|
server/apps/server/src/knowledge/knowledge.service.ts:610-637KnowledgeService.applyRagStatus() |
手工检查 callback 结构和字段类型,路径 documentId 必须与 body 一致,状态只允许 PROCESSING/READY/FAILED。 |
server/apps/server/src/knowledge/knowledge.service.ts:638-651KnowledgeService.applyRagStatus() |
文档必须未删且非 DELETING;traceId 格式正确且与当前 ingestionTraceId 完全一致;再验状态转移。 |
server/apps/server/src/knowledge/knowledge.service.ts:653-679KnowledgeService.applyRagStatus() |
PROCESSING 清错误;READY 必须带正整数 chunkCount 与 Provider/Model/Version,版本必须和服务端一致。 |
server/apps/server/src/knowledge/knowledge.service.ts:680-692KnowledgeService.applyRagStatus() |
FAILED 清索引信息,对错误码/文案做清洗和长度限制。 |
server/apps/server/src/knowledge/knowledge.service.ts:693-708KnowledgeService.applyRagStatus() |
updateMany where 同时带 id/trace/旧 status,实现 CAS;受影响非 1 说明并发变化,拒绝覆盖。 |
3A.6 私人 RAG 检索、多重权限与可信引用#
这条链不是“把所有 PDF 塞给模型”。系统先从数据库得到用户真正有权使用的文档名单,再到向量库里只搜索这些文档;结果返回后还要复查一次,最后只展示回答真正用到的来源。
- Runner 先调用
validateDocumentScope()生成允许搜索的文档名单用户没选文档时,只取他自己的、已经处理完成且未删除的文档;用户指定了 ID 时,数量必须和数据库查到的合法文档完全一致。为什么先做:不能把前端传来的 documentId 当成权限证明。
- Runner 调用
AgentRagService.prefetchKnowledge()这个方法在模型回答前先搜索一次。如果问题只是“这个呢?”之类短句,它会补上最近的提问再搜索;补完反而没结果时,再用原句试一次。
searchKnowledge()把问题、userId 和文档名单发给 FastAPI请求带内部签名和总超时。Python 返回后,NestJS 不会直接相信结果,而是调用
validateSearchResult()检查条数、版本、分数以及每条结果是否仍在刚才的文档名单内。- FastAPI 把问题变成向量,再调用
search_private()Chroma 搜索时同时带上“当前用户、私人资料、允许的文档 ID”三个限制。它会多取一些候选,再删掉低分和重复内容,最后只保留最相关的几段。
- 搜索结果原路返回,NestJS 登记真实来源
captureSources()对重复分片去重,并通过 SSE 把来源发给前端。随后modelVisibleSources()把内部 ID 换成 K1、K2 这种临时编号,模型不需要看到真实存储标识。 - 模型根据资料生成回答,并写出类似
[[source:K1]]的标记如果模型觉得还需要搜索,也可以调用只读的
search_knowledge_base工具;但 userId 和文档名单仍由服务端放进去,模型不能自己扩大搜索范围。 finalizeGroundedCitations()最后检查引用真假它只接受本轮确实搜索到的编号,模型编出来的文件或页码会被删除。最后把合法标记换成“资料 1”,并只把答案实际使用的来源返回给前端。
- 前端展示回答和可点击来源卡片
用户看到文件名、页码、相关度和原文片段。这样回答不是只有一句“根据资料”,而是可以追到具体证据。
代码位置速查(看完上面的主线再查)#
| 步骤 | 文件与方法 | 职责 |
|---|---|---|
| 1 | server/apps/ai/src/agent/agent-runner.service.ts:498-529AgentRunnerService.validateDocumentScope() |
从 PostgreSQL 构造当前用户 READY 文档白名单。 |
| 2 | server/apps/ai/src/agent/agent-rag.service.ts:22-128AgentRagService.prefetchKnowledge() |
运行模型前检索,追问时扩展 query,失败明确降级。 |
| 3 | server/apps/ai/src/agent/agent-tools.service.ts:201-252AgentToolsService.createTools() 内的 search_knowledge_base Tool |
模型可主动调的只读 Tool,但 userId/documentIds 由服务端闭包注入。 |
| 4 | server/libs/shared/src/rag/rag-internal.client.ts:77-90,293-360RagInternalClient.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-508ChromaStore.search_private() |
owner + PRIVATE + document allowlist 向量检索、阈值和去重。 |
| 7 | server/apps/ai/src/agent/agent-rag.service.ts:130-142AgentRagService.captureSources() |
对 chunkId 去重,登记真实来源并发 SSE source。 |
| 8 | server/apps/ai/src/agent/agent-rag.service.ts:144-164,192-239modelVisibleSources() / finalizeGroundedCitations() |
映射 K1/K2 给模型,最后只保留真实使用的引用。 |
validateDocumentScope() 和 prefetchKnowledge()#
| 代码段 | 作用 |
|---|---|
server/apps/ai/src/agent/agent-runner.service.ts:498-507AgentRunnerService.validateDocumentScope() |
判断 documentIds 是否真的是字符串数组,然后 trim、去重、限制最多 100 份/每个 ID 64 字符。这是为了防止恶意大数组拖垮数据库,也防止非字符串让后续 .trim() 直接崩溃。 |
server/apps/ai/src/agent/agent-runner.service.ts:508-516AgentRunnerService.validateDocumentScope() |
判断用户是否没指定文档。没指定时由服务端只查“这个用户、READY、未删除”的最新 100 份,防止空列表被理解成“允许搜全站文档”。 |
server/apps/ai/src/agent/agent-runner.service.ts:517-529AgentRunnerService.validateDocumentScope() |
有指定 ID 时,查这些 ID 中同时属于当前 user、READY、未删的数量。数量对不上就整批拒绝,防止越权搜别人文档,也不分别告诉客户端哪个 ID 属于别人。 |
server/apps/ai/src/agent/agent-rag.service.ts:35-43AgentRagService.prefetchKnowledge() |
判断白名单是否为空。为空就返 NO_DOCUMENTS,防止发一个没有权限范围的向量检索;不为空才告诉前端开始检索。 |
server/apps/ai/src/agent/agent-rag.service.ts:44-60,241-263AgentRagService.prefetchKnowledge() / contextualRetrievalQuery() |
先判断这句是否像“这个呢?”这种短追问;是就补上一条 USER 消息,防止检索词过短、向量召回失去上下文。调 RAG 时的 userId 和文档白名单都是服务端注入,不信前端。 |
server/apps/ai/src/agent/agent-rag.service.ts:61-77AgentRagService.prefetchKnowledge() |
判断“扩展后的追问”是否零结果且和原句不同;是就用原句补试一次,防止补上下文反而把召回带偏。判断 Abort 是为了防止用户已取消还继续浪费 Embedding/检索资源。 |
server/apps/ai/src/agent/agent-rag.service.ts:78-103AgentRagService.prefetchKnowledge() |
把真实结果登记成本轮可用来源,写审计并通知前端。结果数是 0 时明确说“没找到相关内容”,防止错说成“用户没有知识库”。 |
server/apps/ai/src/agent/agent-rag.service.ts:104-127AgentRagService.prefetchKnowledge() |
判断错误是否由用户 Abort 引起;是就继续抛出让整个 Run 取消。其他 RAG 故障降级为 UNAVAILABLE,防止知识库暂时不可用就把整个对话伪装成“没有资料”或编造引用。 |
RagInternalClient.request() 与 validateSearchResult()#
| 代码段 | 作用 |
|---|---|
server/libs/shared/src/rag/rag-internal.client.ts:115-121RagInternalClient.request() |
计算一个“整个请求最晚到什么时候必须结束”的 deadline。只有明确标记 retryable 的读/幂等请求才重试,防止写操作被无意重复执行。 |
server/libs/shared/src/rag/rag-internal.client.ts:123-143RagInternalClient.request() |
每次请求前判断用户是否已取消、是否已超总时限,防止无意继续重试。HMAC 覆盖精确 method+path+body,防止签名被拿去调另一个接口或篡改请求体。 |
server/libs/shared/src/rag/rag-internal.client.ts:144-166RagInternalClient.request() |
判断 HTTP 是否成功;非瞬时 4xx 立即结束,因为参数/权限错误重试也不会变好。只对 408/425/429/5xx 退避重试,防止短暂故障直接失败,但总 deadline 又防止无限等待。 |
server/libs/shared/src/rag/rag-internal.client.ts:297-311RagInternalClient.validateSearchResult() |
判断回包 query 是否和请求一样、结果是否超 topK、Embedding 模型/索引版本是否正确。这是为了防止下游错服务、旧版索引或异常回包被当成可信资料。 |
server/libs/shared/src/rag/rag-internal.client.ts:312-354RagInternalClient.validateSearchResult() |
逐条判断 chunkId 是否唯一、documentId 是否在白名单,并检查标题/页码/内容/分数的类型和边界。最重要的是防止 Python/Chroma 因 Bug 把其他用户或未选文档的分片送进 Agent。 |
server/libs/shared/src/rag/rag-internal.client.ts:355-360RagInternalClient.validateSearchResult() |
只组装通过上述全部判断的字段,防止把下游多返的未知/敏感字段一起透传给模型和前端。 |
FastAPI/Chroma 检索和服务端引用闭环#
| 代码段 | 作用 |
|---|---|
server-py/app/api/rag.py:138-151search_knowledge() |
判断 topK 是否超过服务端上限,超了就压低,防止一次拉太多分片消耗内存和模型 Token。asyncio.timeout 防止 Chroma/Embedding 卡住后永久占着 FastAPI 请求。 |
server-py/app/api/rag.py:152-174search_knowledge() |
判断是否超时;超时统一转 504,防止上游只看到混乱底层异常。日志只记 user hash,防止真实用户 ID 进日志。 |
server-py/app/vectorstore/chroma_store.py:441-455ChromaStore.search_private() |
判断 document allowlist 是否为空;空则拒绝,防止“没有过滤条件”意外变成搜全库。向量维度不一致也拒绝,防止用错 Embedding 模型得到假相似度。 |
server-py/app/vectorstore/chroma_store.py:456-470ChromaStore.search_private() |
先多取 topK 的 3 倍,是因为后面还要删低分和重复内容;上限 100 又防止为了去重而无限扩大查询。 |
server-py/app/vectorstore/chroma_store.py:471-507ChromaStore.search_private() |
Chroma 已过滤,回包后仍再判断 owner/visibility/kind/document,防止向量库过滤 Bug 导致越权。低分、同哈希、近似文本都跳过,防止低质量/重复资料浪费上下文。 |
server/apps/ai/src/agent/agent-rag.service.ts:130-142AgentRagService.captureSources() |
判断 chunkId 是否已登记;已有就跳过,防止预检索和 Tool 检索召回同一分片后出现重复引用。 |
server/apps/ai/src/agent/agent-rag.service.ts:144-164AgentRagService.modelVisibleSources() |
判断来源是否在服务端权威引用集;不在就报错,防止未登记/伪造来源进模型。模型只看 K1/K2,不需要知道内部 ID。 |
server/apps/ai/src/agent/agent-rag.service.ts:192-222AgentRagService.finalizeGroundedCitations() |
对模型输出里的每个 [[source:...]] 判断是否在本轮 K 编号/chunkId 白名单。不在就删掉,防止模型自己编文件或页码;真实引用转成用户可读的 [资料 1]。 |
server/apps/ai/src/agent/agent-rag.service.ts:223-239AgentRagService.finalizeGroundedCitations() |
兼容模型直接输出 K1/chunkId 的情况;只有真匹配才替换。最后删除 documentId 类内部标识,并只返回答案实际用到的 citations,防止展示“检索了但没使用”的假依据。 |
3A.7 学习中心、计划/任务、测验与复习算法#
学习计划如何创建和激活已经在上一条 Action 链讲过。这里重点顺着“生成测验 → 用户答题 → 服务端评分 → 更新掌握度 → 刷新页面”走一遍。
Study.loadAll() 同时请求画像、计划、任务、薄弱词、到期复习和历史成绩。它们互不依赖,所以一起发比一个一个等更快。页面发现有未提交的测验时直接恢复,不会偷偷再生成一份。
- 用户选择“薄弱词”或“到期复习”,页面调用
createQuiz()页面通过
generateQuiz(10, mode)请求 10 道题。Controller 做登录检查和请求次数限制,Service 检查题数和模式,再调用领域层的generateQuiz()。 generateQuiz()根据模式找候选词“薄弱词”查掌握分低于 80 的词;“到期复习”只查已经到复习时间的词。没有释义的词不能做选择题,会被跳过;候选太少时返回明确提示。
- 服务端生成题目并保存带答案的快照
每题放一个正确选项和几个干扰项,然后打乱。完整答案只保存在数据库;返回前调用
publicQuestion()删除正确答案和解释,所以浏览器看不到答案。 - 用户答完后,页面调用
gradeQuiz()页面先检查每题是否都选了答案,然后调用
submitQuiz()API,并带上一个本次提交专用的防重复标记。这个标记让双击按钮或网络重试不会产生两份成绩。 - 后端
submitQuiz()只用服务端保存的答案评分它检查测验属于当前用户、没有过期、没有提交过;还检查每个选项确实属于对应题目。评分时不相信前端提供的正确答案,只读取生成测验时保存的快照。
- 每道题调用
updateMastery()更新单词掌握情况答对会提高掌握分并逐步拉长下次复习间隔;答错会降低掌握分并尽快安排复习。新分同时参考历史表现和本次结果,避免一次答对或答错让长期水平剧烈跳变。
- 成绩、单词状态和学习天数一起保存
这些修改放在同一次数据库提交中,避免出现“成绩已经有了,但只更新了一半单词”。成功后返回评分结果。
- 前端显示成绩,再调用
loadAll()刷新页面重新读取后,画像、薄弱词、下次复习时间和历史成绩都会变成最新值,页面不会继续显示考试前的旧数据。
代码位置速查(页面读取、计划和测验)#
| 步骤 | 文件与方法 | 职责 |
|---|---|---|
| 1 | apps/web/src/views/Study/index.vue:298-331loadAll() |
并行加载画像、计划、任务、薄弱词、复习、测验,同步打卡天数。 |
| 2 | apps/web/src/apis/study/index.ts:20-66getStudyProfile() / generateQuiz() / submitQuiz() 等 |
学习中心全部 Business API 薄封装。 |
| 3 | server/apps/server/src/study/study.controller.ts:17-107StudyController 各路由方法 |
统一 AuthGuard,生成/提交测验加 Redis 限流。 |
| 4 | server/apps/server/src/study/study.service.ts:17-137StudyService 各门面方法 |
HTTP 门面:校验参数,组合 LearningTools/StudyDomain,包统一响应。 |
| 5 | server/libs/shared/src/learning/study-domain.service.ts:49-165createPendingPlan() / activatePlan() / cancelPlan() / listTasks() |
创建/激活/取消计划,列任务。 |
| 6 | server/libs/shared/src/learning/study-domain.service.ts:168-260StudyDomainService.generateQuiz() |
从真实薄弱/到期词选题,保存带答案快照,只返公开题面。 |
| 7 | server/libs/shared/src/learning/study-domain.service.ts:296-415StudyDomainService.submitQuiz() |
幂等提交、会话锁、评分、写 Attempt、更新掌握度。 |
| 8 | server/libs/shared/src/learning/study-domain.service.ts:417-488StudyDomainService.updateMastery() |
按正误计算平滑分、easeFactor、复习间隔和掌握词总数。 |
| 9 | server/libs/shared/src/learning/study-check-in.service.ts:11-37StudyCheckInService.syncDayNumber() |
按上海日历日从真实学习事实重算天数。 |
页面 loadAll()、createQuiz() 和 gradeQuiz()#
| 代码段 | 作用 |
|---|---|
apps/web/src/views/Study/index.vue:298-316loadAll() |
用 Promise.all 并行发 7 个互不依赖的读请求,防止串行等待让页面加载时间变成 7 个请求耗时的总和。 |
apps/web/src/views/Study/index.vue:317-330loadAll() |
画像里的 dayNumber 是数字才同步 Store,防止异常值污染页头统计。判断当前是否已有未提交 Quiz;有就恢复它,防止刷新页面后同一测验丢失或又生成一份。 |
apps/web/src/views/Study/index.vue:353-364createQuiz() |
开始时置 loading,请求 10 道指定模式题并清上次答案/结果。finally 一定恢复按钮,防止生成失败后界面永久禁用。 |
apps/web/src/views/Study/index.vue:366-377gradeQuiz() |
判断是否每题都已选答案,没答完就不提交,防止服务端收到不完整答卷。幂等键绑定 sessionId + 本次 UUID,用于识别用户双击/网络重试是否同一次提交。 |
apps/web/src/views/Study/index.vue:378-383gradeQuiz() |
保存服务端评分后重读画像/复习/历史,防止界面继续显示评分前的旧掌握度和复习时间。 |
generateQuiz():答案为什么不会发给前端#
| 代码段 | 作用 |
|---|---|
server/libs/shared/src/learning/study-domain.service.ts:168-175StudyDomainService.generateQuiz() |
把题数强制压到 1~20,防止请求负数/过多题拖垮查询并产生巨大 JSON。有 conversationId 时校验归属,防止用别人会话关联测验。 |
server/libs/shared/src/learning/study-domain.service.ts:177-190StudyDomainService.generateQuiz() |
根据模式判断是查“到复习时间”还是“掌握分低于 80”。多取 3 倍候选是为后续过滤无释义词留余量,防止最后题数无故不足。 |
server/libs/shared/src/learning/study-domain.service.ts:191-201StudyDomainService.generateQuiz() |
判断词条是否有可作答案的释义,没有就过滤,防止出一道没正确答案的题。如果最后一个候选都没有,直接返明确业务提示。 |
server/libs/shared/src/learning/study-domain.service.ts:202-212StudyDomainService.generateQuiz() |
选项池先去重;判断少于 2 个不出题,因为只有一种释义时没法生成有意义的选择题。 |
server/libs/shared/src/learning/study-domain.service.ts:214-236StudyDomainService.generateQuiz() |
给每题放 1 个正确项 + 最多 3 个干扰项并打乱;不可猜 ID 防止前端通过固定规则猜答案。正确答案只保留在服务端快照。 |
server/libs/shared/src/learning/study-domain.service.ts:237-253StudyDomainService.generateQuiz() |
新测验 30 分钟过期。对用户加锁后先过期其他未提交会话,防止连点两次同时产生两份“当前测验”。 |
server/libs/shared/src/learning/study-domain.service.ts:254-260StudyDomainService.generateQuiz() |
返回前调 publicQuestion() 删除 correctOptionId/explanation,防止用户打开浏览器开发者工具就看到正确答案。 |
submitQuiz() 与 updateMastery()#
| 代码段 | 作用 |
|---|---|
server/libs/shared/src/learning/study-domain.service.ts:302-323StudyDomainService.submitQuiz() |
检查 sessionId/幂等键格式和答案数量/字段长度,防止脏数据、超大请求或恶意长字符串进入锁与事务。 |
server/libs/shared/src/learning/study-domain.service.ts:324-335StudyDomainService.submitQuiz() |
事务外先查幂等键。找到后判断 userId 和 sessionId 是否都一样;一样说明是同一次重复提交,直接返原结果,否则拒绝,防止别人复用这个键取结果。 |
server/libs/shared/src/learning/study-domain.service.ts:337-351StudyDomainService.submitQuiz() |
事务内按固定顺序锁测验和掌握度,防止多个提交互相死锁/交叉更新。加锁后再查一次幂等键,关闭“事务外查完到进锁之间”的并发窗口。 |
server/libs/shared/src/learning/study-domain.service.ts:352-370StudyDomainService.submitQuiz() |
只查当前用户的 Session,防越权。已有 Attempt 直返,防重复评分;过期拒绝,防答卷长期可用;Map 拒绝同题重复答案,题数对不上则拒绝,防漏题。 |
server/libs/shared/src/learning/study-domain.service.ts:372-385StudyDomainService.submitQuiz() |
判断每个 optionId 真属于该题,防止用户手工传入别题/伪造选项。然后只用服务端快照里的正确答案评分,不信前端。 |
server/libs/shared/src/learning/study-domain.service.ts:386-399StudyDomainService.submitQuiz() |
写不可变的 QuizAttempt 快照和唯一幂等键,既便于日后审计当时题目,也由数据库唯一约束兜底防重复提交。 |
server/libs/shared/src/learning/study-domain.service.ts:400-414StudyDomainService.submitQuiz() |
更新每个词的掌握度、标记 Session 已提交、重算 dayNumber,所有操作放同一事务,防止出现“测验已有分数,但只更新了一半单词”。 |
server/libs/shared/src/learning/study-domain.service.ts:423-447StudyDomainService.updateMastery() |
判断旧记录是否存在,不存在就用中性默认分起步。新分用 70% 历史 + 30% 本次,防止一次答对/答错就让长期掌握度剧烈跳变;答错缩短复习间隔,高分才逐步拉长。 |
server/libs/shared/src/learning/study-domain.service.ts:448-476StudyDomainService.updateMastery() |
upsert 判断有记录就更新、没有就创建,防止并行先查后插造成重复的“用户×单词”记录。 |
server/libs/shared/src/learning/study-domain.service.ts:477-487StudyDomainService.updateMastery() |
判断是否真正跨过 80 分掌握线;只在跨线时增/减 User.wordNumber,防止每次复习都重复加减。减少时要求 wordNumber > 0,防负数。 |
server/libs/shared/src/learning/study-check-in.service.ts:15-37StudyCheckInService.syncDayNumber() |
把三种真实学习事实转成上海日历日,用 UNION 去重后计数,防止同一天学 10 次被算成 10 天,也防止 UTC 跨日导致中国用户打卡日期错位。 |
3A.8 课程支付、支付回调、WebSocket 通知与埋点#
支付链解决“下单后如何以支付平台的回调为准,并及时通知页面”;埋点链解决“页面行为如何被采集并写入数据库”。它们只是都出现在 App 和课程页面附近,不应该被误解为支付成功后才开始埋点。
- 用户点击购买,
handleBuy()决定是否打开支付弹窗已购买就直接进入课程;未登录先登录;只有登录且未购买才打开 Pay 组件。弹窗打开时开始监听当前用户的
paymentSuccess消息。 - 用户确认付款,
Pay.onConfirm()调用createOrder()请求经过 Controller 到达
PayService.createOrder()。服务端先检查用户是否已经买过,再创建本地订单,最后调用支付宝 SDK 生成支付地址并返回给前端。 - 前端打开支付宝页面,但不会自己把课程标成已购买
这是因为“用户打开付款页”不等于“已经付款”。本地页面只进入等待状态,并展示订单倒计时。
- 付款后,支付宝服务器调用
PayController.notify()这个请求不是浏览器发出的。Controller 把通知交给
PayService.notify(),后者找到本地订单,更新支付状态并创建课程购买记录。 - 服务端调用
emitPaymentSuccess()通知当前用户Socket Gateway 按 userId 找到对应房间并发送成功消息。Pay 组件收到后关闭等待状态、提示成功并刷新课程信息。这样页面不需要一直轮询订单。
应用启动时就创建 Tracker:先取得匿名访客 ID,再安装页面访问、点击、错误和性能监听。事件发生后才调用相应的上报方法;后端按事件类型写入不同数据表。用户登录后只是把匿名访客和 userId 关联起来,不会重新创建一套访客记录。
代码位置速查(支付与埋点是两条独立流程)#
| 步骤 | 文件与方法 | 职责 |
|---|---|---|
| 1 | apps/web/src/views/Course/index.vue:118-128handleBuy() |
已购直接学习,未登录唤起登录,其他打开 Pay。 |
| 2 | apps/web/src/views/Course/components/Pay.vue:121-170watch(modelValue) / onConfirm() / close() / tips() |
监听支付 WebSocket,创建订单,打开支付页,管理超时。 |
| 3 | apps/web/src/apis/pay/index.ts:5-8createOrder() |
POST /pay/create-order。 |
| 4 | server/apps/server/src/pay/pay.controller.ts:11-20PayController.createOrder() / notify() |
下单需 JWT;支付平台 notify 为外部回调入口。 |
| 5 | server/apps/server/src/pay/pay.service.ts:25-78PayService.createOrderNo() / createOrder() |
防已购、建 PaymentRecord、生成支付宝链接和过期时间。 |
| 6 | server/apps/server/src/pay/pay.service.ts:80-107PayService.notify() |
更新支付成功、创建 CourseRecord、推送成功事件。 |
| 7 | server/apps/server/src/socket/socket.gateway.ts:14-24SocketGateway.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-139watch(modelValue) |
判断弹窗是打开还是关闭。打开时,先判断 Socket 是否存在,存在才监听 paymentSuccess;这样能防止对空对象调用方法导致页面报错。关闭时再判断 Socket 是否存在,然后移除监听,防止每次打开弹窗都多绑一次,最后一次付款弹出多次“支付成功”。 |
apps/web/src/views/Course/components/Pay.vue:142-158onConfirm() |
把当前课程拼成下单参数并调用 createOrder()。判断返回码是不是 200:成功才打开支付页并启动倒计时;失败则展示后端消息并恢复按钮,防止下单失败却让页面一直显示“支付中”。 |
apps/web/src/views/Course/components/Pay.vue:160-170close() / tips() |
close() 关闭弹窗并清空支付中、过期时间;tips() 在倒计时结束时提醒重新下单并重置状态,防止用户继续拿已经过期的支付链接操作。 |
apps/web/src/hooks/useSocket.ts:7-21useSocket().connect() |
先判断有没有登录用户,没有就不连接,防止匿名连接占资源;再判断全局 Socket 是否已经存在,存在就不重复创建,防止一个页面同时保留多条连接、收到重复消息。随后只用 WebSocket,并设置最多 5 次退避重连。 |
apps/web/src/hooks/useSocket.ts:24-40useSocket().disconnect() / getSocket() |
退出登录时,先判断 Socket 是否存在,再断开、清掉所有事件监听并置空,防止旧账号的监听残留到新账号;getSocket() 只负责把当前连接交给组件使用。 |
PayService.createOrder() 和 notify()#
| 代码段 | 作用 |
|---|---|
server/apps/server/src/pay/pay.service.ts:25-28PayService.createOrderNo() |
用 ORD- + 12 位 nanoid 生成本地商户订单号,让每笔支付都能用一个较难撞车的编号追踪。 |
server/apps/server/src/pay/pay.service.ts:30-40PayService.createOrder() |
先按 userId+courseId 查询是否已经购买。判断到已购就直接返回错误,防止用户重复买同一课程、生成无意义订单。注意:这只是业务层先查,并不能完全代替数据库唯一约束。 |
server/apps/server/src/pay/pay.service.ts:42-53PayService.createOrder() |
在事务里创建 PaymentRecord,保存用户、商户单号、标题、描述和金额。事务保证这一段失败时整体回滚,防止留下半条支付记录。 |
server/apps/server/src/pay/pay.service.ts:54-71PayService.createOrder() |
设置 2 分钟过期时间,调用支付宝 pageExecute() 生成支付链接;把订单、金额、标题和 userId/courseId 放进请求,并指定服务端回调地址。超时限制是为了防止旧订单长期有效。 |
server/apps/server/src/pay/pay.service.ts:72-78PayService.createOrder() |
返回支付链接和毫秒级过期时间。前端只负责跳转,不会自己把课程标成已购,防止用户伪造前端状态绕过真实支付结果。 |
server/apps/server/src/pay/pay.service.ts:80-91PayService.notify() |
收到支付平台回调后,在事务里按商户订单号找到记录并写入付款时间、成功状态和支付宝交易号。这里当前没有先判断验签、金额、商户号和旧状态,无法防止伪造回调或重复回调,是需要补强的地方。 |
server/apps/server/src/pay/pay.service.ts:93-102PayService.notify() |
解析回调里的 userId/courseId,创建已购 CourseRecord,再通过 paymentRecordId 关联付款事实。当前使用 create() 而不是幂等 upsert(),重复通知可能重复创建或触发唯一键错误。 |
server/apps/server/src/pay/pay.service.ts:103-107PayService.notify() |
向该用户的 Socket 房间推送支付成功,事务完成后给支付平台返回 true。用途是让前端不用不停轮询就能马上更新;但推送发生在事务回调内部,严格做法应在事务真正提交后再通知,防止极端情况下先通知、后回滚。 |
支付链审查重点:当前
notify()代码中未看到支付宝回调验签、金额/商户号校验和幂等 upsert;Socket 也直接信任 handshake query 的 userId。这两点是演示项目的高优先级安全改造项,面试时应主动说明。
Tracker SDK:从浏览器到数据库#
| 代码段 | 作用 |
|---|---|
apps/web/src/App.vue:12-30new Tracker() |
配置统一 baseUrl 和 UV、PV、事件、错误、性能接口路径;创建 Tracker 后立刻启动匿名采集。 |
apps/web/src/App.vue:31-46watch(() => userStore.user?.id) |
监听登录用户 ID。判断有 ID 时,把匿名访客关联到用户并连接 Socket;没有 ID 时断开 Socket,防止退出后仍以旧用户身份接收支付通知。immediate: true 是为了页面首次加载就立即同步一次。 |
apps/tracker/index.ts:9-16Tracker.constructor() |
保存配置、访客 ID 和初始化 Promise,并调用 init();这是 SDK 的统一启动入口。 |
apps/tracker/index.ts:18-30Tracker.init() |
先判断 initPromise 是否已经存在,存在就复用,防止同时初始化多次、重复安装全局监听。首次初始化先取指纹并让服务端 upsert Visitor,拿到 visitorId 后才安装事件、错误、PV 和性能监听,防止上报的数据没有访客归属。 |
apps/tracker/index.ts:32-40Tracker.setUserId() |
登录可能发生在指纹初始化完成前,所以先等待 init();再上报 visitorId+userId,把匿名访问和真实账号关联起来,防止关联请求带着空 visitorId。 |
apps/tracker/src/uv/index.ts:15-27getFingerprint() |
UAParser 取得浏览器、系统、设备,FingerprintJS 生成 anonymousId,再用 fetch 等服务端返回 Visitor 主键。服务端使用 upsert,所以同一匿名指纹再次访问时更新旧记录,而不是每次新增一名访客。 |
apps/tracker/src/pv/index.ts:4-40reportView() / reportPv() |
首次进入就上报 URL、来源页和路径;判断 URL 是否含 #,是为了正确记录 Hash 路由。再监听 hashchange、前进后退,并包装 pushState/replaceState,防止 SPA 切页不刷新浏览器而漏记 PV。 |
apps/tracker/src/event/index.ts:4-38reportEvent() |
监听全局点击。判断目标是不是 BUTTON,或是不是 BUTTON 里面的 SPAN,只上报按钮点击,防止页面任意区域的点击都变成大量无意义数据;随后记录位置、大小和文字并通过 Beacon 上报。 |
apps/tracker/src/error/index.ts:4-29reportError() |
分别捕获普通 JS 错误和未处理 Promise 拒绝。对 Promise 原因判断是不是 Error:是就读取 message/stack,不是就转 JSON,防止直接访问不存在的属性再次报错。 |
apps/tracker/src/performance/index.ts:5-65reportPerformance() |
读取 FP/FCP,判断指标条目存在后才取时间,防止浏览器没提供该指标时访问空值;用 PerformanceObserver 取 LCP、web-vitals 取 INP/CLS。只有页面变成 hidden 时才统一 Beacon 上报,尽量拿到完整会话指标,也防止过程里频繁请求。 |
apps/tracker/src/report/index.ts:1-16report() / reportFetch() |
普通埋点用 sendBeacon(),减少关闭页面时请求被浏览器取消;需要服务端返回 visitorId 的 UV/关联请求用 fetch(..., keepalive: true)。两种方法用途不同,不能一律用 Beacon。 |
server/apps/server/src/tracker/tracker.service.ts:20-110TrackerService.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,非 rootrag用户运行 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. 当前实现边界与面试前风险#
- 密码安全:
user.service.ts:37是明文比较,必须改为密码哈希。 - 并发配置需讲清参数语义:
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。 - Agent 大类拆分已落地:
AgentRunnerService已从约 1900 行缩到 540 行,运行生命周期、上下文、RAG/引用、Tools、Usage 和 Config 都已拆出。面试时应讲“Runner 仅编排,专职服务承载细节”以及如何用事务、状态条件更新和事件协议保持拆分前后行为一致。 - 核心测试不足:当前测试主要覆盖配置与 HMAC,Agent、Action 并发、知识状态机、入库补偿和 RAG 权限需补单元/集成/E2E。
- PDF 能力边界:只有文本提取,没有 OCR、表格和复杂布局恢复。
- 检索能力边界:当前是 Dense Retrieval + 去重,没有 BM25 混合检索和 Reranker。
- MCP/爬虫不在本仓库:简历中的 MCP、Multi-Agent、Playwright/Scrapy 能力需要用其他真实项目说明。
- 旧链路并存:
server/apps/ai/src/chat是旧聊天实现,建议明确迁移计划,避免面试官误认为存在两套相互冲突的 Agent 系统。 - 行号会漂移:修复以上问题后,用类名/方法名搜索,并更新本文的提交基准。
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、心跳,不是长期事实来源。