项目:interview-platform | 版本:v1.2 | 生成日期:2026-10-06 | 修订:2026-10-06 一致性回刷 | 依据:源码分析
前十四篇文档讲的是「系统是什么」;本篇做一次反向练习——逐段带着真实代码读三个核心文件,把《05-核心流程》的时序图、《07-模块详解》的机制综述还原到行级。三个走读对象的选择标准是「业务价值最高 + 设计浓度最高」:其一是 AI 模拟面试的流式链路(src/app/api/mock-interview/route.ts 与 src/app/api/mock-interview/[id]/turn/route.ts,辅以 src/lib/deepseek.ts 与 src/lib/sse.ts 两个支撑件),它是全项目延迟最敏感、状态机最完整的一条链路;其二是 src/lib/review-scheduler.ts,艾宾浩斯间隔复习的领域核心,体量仅 232 行却是全站「数据模型极简主义」的代表作;其三是 src/app/ai/mock-interview/page.tsx(1715 行),全站最大的单文件客户端组件,代表本项目「重客户端组件」的前端形态上限。走读过程中发现的疑点与潜在缺陷,以「走读发现」标记就地记录,整改方案仍统一收口在《13-技术债务与演进建议》。为控制篇幅,代码片段只摘录承重的行,省略号代表无关细节;完整文件请直接打开源码对照阅读。
先给四个文件定位。src/lib/deepseek.ts 是 AI 调用的唯一公共封装(116 行,三个导出函数),src/lib/sse.ts 是前端侧的 SSE 解析器(48 行),两者是「基础设施」;src/app/api/mock-interview/route.ts(创建面试并出第一题,244 行)与 [id]/turn/route.ts(提交回答并获评估,359 行)是「业务编排」,各自内联了一份 SSE 帧构造函数与方向标签表。值得先指出的一个结构事实:两个路由文件没有复用 deepseek.ts,而是各自内联了 fetch 直连——route.ts 第 136 行与 turn/route.ts 第 204 行都是裸的 fetch("https://api.deepseek.com/chat/completions", ...),连同 Key 的占位值校验(第 69 行、第 66 行)也是各自复制的一份。这与《11-扩展开发指南》SOP 三「组装,不要自建 HTTP 调用」的纪律形成对照,原因推断是这两个路由需要流式中间态(边转发边累积),而 callDeepSeekStream 只返回上游原始 body,封装收益确实有限——但 Key 校验与上游错误处理本可以复用,这是链路上第一处可收敛点。
POST /api/mock-interview 的进入段是本项目「守卫 → 限流 → 业务护栏」管线的标准样板(requireAuth 之后第 33 行按用户限流 10 次/分),真正值得逐行读的是第 41-55 行的每日费用护栏——它手工推算了上海时区的自然日零点:
const nowShanghai = new Date(Date.now() + 8 * 60 * 60 * 1000);
const dayStartUtc = new Date(
Date.UTC(nowShanghai.getUTCFullYear(), nowShanghai.getUTCMonth(), nowShanghai.getUTCDate()) -
8 * 60 * 60 * 1000
);
const todayCount = await prisma.mockInterview.count({
where: { userId: user!.id, createdAt: { gte: dayStartUtc } },
});这七行的算法值得拆开讲:先把当前时刻整体平移 +8 小时得到「上海挂钟视角的时间戳」,再从这个视角提取年月日拼出 UTC 的当日零点,最后减回 8 小时还原成真实的 UTC 瞬时。这样得到的 dayStartUtc 在任何时区的服务器上都是同一个物理时刻——「上海时间今天零点」。它与 src/lib/streak.ts 里封装好的 getShanghaiMidnight 是同一件事的两种实现(一个内联在路由里,一个抽成了公共函数),这又是一处可收敛点:注释明确写着「每日费用护栏:单用户每天最多 20 场面试(上海时区自然日)」,语义与 streak 域完全同构。护栏的计数源是 mockInterview.count(按 createdAt 过滤),意味着删除面试记录会释放当日配额——这是可接受的宽松语义,但运维侧统计「真实消耗」时应改看轮次数而非场次数。
创建接口在建立 MockInterview 记录后按方向分叉,这个分叉是全项目 AI 成本哲学的缩影。行为面试分支(第 85-125 行)完全不碰 DeepSeek:getRandomBehavioralQuestions(1) 从本地题库取题写入 Turn#1,然后用 setTimeout 编排了 50ms/100ms 的两级延迟把首帧、题目帧、结束帧依次 enqueue——这就是「模拟流式输出」的全部实现。前端对此毫无感知差异,因为它消费的是同一套 SSE 协议。技术方向分支(第 136 行起)才是真流式,核心循环如下:
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") continue;
const parsed = JSON.parse(payload);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
fullQuestion += content;
controller.enqueue(enc.encode(sse({ event: "chunk", content })));
}
}
}
// 流结束后才把完整问题落库
await prisma.mockInterviewTurn.create({ data: { ...aiQuestion: fullQuestion, turnOrder: 1 } });这段循环是流式代理的教科书形态:按行拆上游 SSE、残行留在 buffer、解析出 delta 增量后立即转发并累积,全链路零额外缓冲。两个细节需要点破。第一,落库被刻意推迟到流结束之后——若中途客户端断开,流的 catch 分支发出 error 帧并 close,Turn#1 不会被写入,留下一条「无轮次」的面试记录(见 1.5 的走读发现);第二,JSON.parse 包在 try/catch 里静默跳过畸形行(第 201-203 行的空 catch),这是对上游协议的宽容处理,风险是上游格式变更时问题会表现为「内容静默缺失」而非显式报错,排障时需要知道这一点。对照第 156-162 行的上游错误分支:非 2xx 时直接把 errorText 拼进响应返回——这是《13-技术债务与演进建议》1.5 点名的内部信息泄露面(turn 接口的同位置已经是转译过的「AI 服务暂时不可用」),两个文件的同构分支一严一松,正是「老代码先写、纪律后立」的时间层积。
POST /api/mock-interview/[id]/turn 是整条链路状态机的核心。进入段(鉴权、限流 20 次/分、JSON 错误辅助函数)之后,第 70-86 行完成五连校验:面试存在(404)→ 未结束(400)→ 归属(403,注释强调「userId 为 null 的历史记录同样拒绝」)→ 存在当前轮(400)→ 当前轮未回答(400)。这五连里「当前轮已回答」的校验与第 99-100 行的注释互为因果:
// 注意:用户回答在 AI 评估完成后与本轮反馈一起原子落库。
// 此前先存答案再调 AI,一旦 AI 失败该轮即被 userAnswer 占用,永远无法重试。这行注释是全项目最珍贵的一段「决策化石」——它记录了一次真实事故与修复:答案落库被推迟到 AI 成功之后,与反馈在同一个 $transaction 内原子写入(第 285-311 行)。事务里三分支:更新本轮(userAnswer/aiFeedback/score);若 isLastTurn || parsed.isComplete 则同时完结面试(status/endedAt/overallScore/summary);否则创建下一轮(turnOrder + 1,追问取 parsed.nextQuestion ?? "请继续。")。评估 prompt 的组装在第 102-201 行,按 isBehavioral × isLastTurn 展开成四段模板:行为面试走 STAR 法则并内置评分权重(完整性 40%/具体性 25%/逻辑性 20%/表达力 15%),最后一轮强制 isComplete: true 并要求返回 overallScore(0-100)与 summary。第 197-201 行的消息拼装有个容易看漏的细节:...conversationHistory.slice(0, -1) 把历史消息去掉最后一条——因为最后一条是当前轮的提问,它已经被 evaluationPrompt 以引号包裹的方式带上了,不裁掉会造成问题重复。AI 返回的是 JSON 包在 Markdown 代码围栏里的混合文本,第 26-30 行的 parseDeepSeekJSON 用正则提取围栏内 JSON、失败回退纯文本解析;第 274-282 行解析失败时向流内发 error 事件后直接 return——此时 HTTP 已是 200,客户端只能靠事件判断成败,这是 SSE 协议「先上车后补票」特性的必然代价(详见《19-数据契约与示例集》的 SSE 帧示例)。
src/lib/deepseek.ts 的价值在三个细节:validateDeepSeekApiKey() 把占位串与空值一并拦截(第 21-30 行),callDeepSeekStream 非 2xx 时读出 errorText 打日志但抛出的错误只带状态码(第 97-101 行,「不透传响应体」),sseFormat 与路由内联的 sse() 是同一个函数的两种存在。src/lib/sse.ts 的 parseSSEStream 是前端消费端:按双换行切帧、残帧留 buffer、跳过非法 JSON,finally 里 releaseLock() 释放读取器。它的测试(sse.test.ts)专门覆盖了跨 chunk 断裂场景——流式解析器最容易错的地方被测试钉住了。走读发现两条:其一,turn 接口第 52 行 await request.json() 与创建接口的 body 解析都没有包在错误处理里(创建接口有外层 try/catch 兜底,turn 的 json 解析失败会走 ReadableStream 之外的未捕获路径,由 Next 返回 500),健壮性略不对称 ⚠️ 待确认(实际触发需构造非法 JSON 且已过限流的请求)。其二,创建接口若在流中途失败,会留下一条 status: "in-progress" 且无任何轮次的「空壳面试」,它计入每日 20 场配额且出现在历史列表里——前端 history 视图对这类记录的呈现是空轮次列表,建议服务端在 error 帧分支补一条面试清理,或历史查询过滤零轮次记录。
src/lib/review-scheduler.ts 共 232 行、两个导出常量级接口(DueReviewItem、ReviewSchedule)、三个函数,是全项目「领域逻辑应长什么样」的范本。第 4 行的一行常量就是全部调度规则:
export const EBINGHAUS_INTERVALS = [1, 2, 4, 7, 15, 30];第 32-41 行的 calculateNextReview(lastReviewDate, reviewCount) 是唯一的日期演算纯函数:stage = Math.min(reviewCount, 5) 把阶段封顶在数组末位(第 30 天后不再拉长间隔),下次复习时间 = 最后练习日 + 当前阶段间隔。它的测试(review-scheduler.test.ts)锁了四件事:次数 0 对应 1 天、按 1→2→4→7→15→30 递进、超阶段封顶 30 天、不修改传入的 Date(纯函数性——函数内部 new Date(lastReviewDate) 复制后才 setDate,避免调用方的日期被原地污染)。这种「测试即规格」的写法与《09-测试与质量保障》第 3 节的评述一致。
核心函数 getDueReviewQuestions(userId, limit, stageFilter)(第 47-134 行)分三步。第一步是整个调度的数据基础——一次 groupBy 拿到个人练习历史的浓缩视图:
const practiceRecords = await prisma.practiceHistory.groupBy({
by: ["questionId"],
_max: { attemptedAt: true },
_count: { _all: true },
where: userId ? { userId } : {},
orderBy: { _max: { attemptedAt: "desc" } },
});第二步批量取题目元信息(findMany + in + Map),避免循环内单查——这个函数自己没有犯 /api/reminders 那样的 N+1(对照《13-技术债务与演进建议》2.5)。第三步是纯内存的状态推导循环(第 97-128 行):reviewCount = record._count._all - 1(注释:第一次练习不算复习)、daysOverdue 以整天数向下取整、只保留 daysOverdue >= 0 的到期题、stageFilter 在循环内过滤(注意源码里 stage 是 0 基的内部值,返回前做了 stage + 1 转为 1 基的展示值,第 101 行与第 124 行的注释各标了一次),最后按逾期天数降序、slice(0, limit) 截断。两个设计要点值得展开。其一,这里没有「复习计划表」,到期判定完全由 PracticeHistory 的实时推导产生——好处是删掉任何快照都能重算、间隔规则改了立即生效;代价是 groupBy 扫描个人全量练习记录,这在《10-性能设计与优化》第 3 节被评估为「个人量级可接受、万条以上需预过滤」。其二,第 102-103 行与第 196-197 行各有一个被 ESLint 禁用规则标注的 _intervalDays / _prevInterval 未使用变量——它们是中间版本的残留(作者曾用局部变量缓存间隔值,改成直接调 calculateNextReview 后忘了删),不影响正确性但属于可清理的噪声。与另一套间隔表的关系必须在此点明:/api/reminders/route.ts 内部还有一份 SPACED_INTERVALS = [1,3,7,15,30],与本文件的艾宾浩斯表口径不一致——两套间隔表的发现、影响与收敛方案已在《13-技术债务与演进建议》2.4 完整记录,本篇只强调走读层面的结论:复习语义的唯一合法来源是本文件的 EBINGHAUS_INTERVALS,阅读任何消费「第几天该复习」的代码时都应先核对它用的是哪张表。
第二个导出函数(第 139-231 行)服务 /api/review/schedule 的单题计划视图。它把每个阶段的 scheduledAt 沿艾宾浩斯表逐级累加(第 199-204 行的内层 for 循环把前序间隔逐一加到首练日期上),再把练习记录按时间升序与阶段对位——practices[idx] 存在即认为该阶段已完成(completedAt 取实际练习时间),未完成且已过 scheduledAt 即 isDue: true。这里有一个值得注意的简化假设:完成对位是按「第 idx 次练习」而非「该次练习是否发生在窗口内」——用户若一天连练三次,后两次会被对位到后续阶段,导致计划显示「提前完成」。对激励型复习场景这是可接受的宽松语义(多练不算错),但它解释了为什么这个函数的 isDue 判定不能与 getDueReviewQuestions 的逐题推导完全互推;两个函数共享常量但不共享中间状态,各自独立成立的语义边界,读代码时需要分开理解。无练习记录时(第 167-179 行)返回全阶段 scheduledAt: null 的空计划,保证前端拿到的是同构结构而非空指针。
这个文件在 1715 行里装了四个视图(select 选方向 / chat 对话 / history 历史 / video 视频,ViewType 联合类型第 21 行)、六个方向卡片(第 46-187 行的 DIRECTIONS 数组,每个含内联 SVG 图标;随后第 189 行的 DIRECTION_LABELS 与第 198-240 行的 MODES 数组补齐方向与模式的静态配置)、会话类型映射层与主组件。它的组织方式是「单文件内分层」:类型区(第 12-42 行)→ 静态数据区 → 子组件区(各视图的展示组件,含第 568 行起的反馈展开逻辑等)→ 映射函数区 → 主组件(第 1407 行起)。主组件的状态集(第 1408-1424 行)共 15 个 useState,可归为四组:视图与数据(currentView/currentInterview/historyInterviews)、请求态(creatingInterview/submittingAnswer/loadingHistory)、流式态(streamingQuestion/streamingFeedback)、视频态(videoEnabled/videoDirection/videoMode)。没有引入任何状态库,全部靠本地 state + 回调传递——这是《02-页面设计》第 5 节「UI 状态 = 组件本地 useState」策略在最大页面上的落地形态。
第 1360-1403 行是整份文件里复用价值最高的一段——RawTurn/RawInterview 接口声明后端原始字段,mapTurn/mapInterview 把它们翻译成前端语义名:
function mapTurn(raw: RawTurn): InterviewTurn {
return {
id: raw.id,
question: raw.aiQuestion, // Prisma: aiQuestion → 前端: question
answer: raw.userAnswer ?? null, // userAnswer → answer
feedback: raw.aiFeedback ?? null, // aiFeedback → feedback
score: raw.score ?? null,
createdAt: raw.createdAt,
};
}注意 mapInterview 还把后端的 summary 映射为前端的 overallFeedback——这个第四组映射在 AGENTS.md 开发约定第 4 条(只点名了前三组)之外,是走读补充确认的事实。映射函数的存在让「后端字段改名」的爆炸半径收敛在两个函数里,代价是任何一边单独改名都会静默断链(《12-FAQ与故障排查》第 9 节「改了 schema 但接口字段没变」条目说的就是这个坑)。
主组件的两个核心异步函数把 lib/sse 的解析器接进了 React 状态机。handleSelectDirection(第 1494-1579 行)先处理视频/语音短路(第 1497-1502 行:直接切 video 视图,不建文本面试记录),随后 POST /api/mock-interview 并在 parseSSEStream 回调里分发四类事件:interview 事件用 event.id 立即构造一条带占位轮次(id 为 "streaming-turn")的本地面试并切到 chat 视图;chunk 事件把增量追加进 streamingQuestion 并同步改写本地面试最后一轮的 question 字段(第 1546-1556 行的 setCurrentInterview 展开更新)——这就是「打字机效果」在数据层的实现;done 事件则回源拉取完整面试替换本地状态(第 1559-1565 行);error 事件只 console.error 并清理流式态。handleAnswerSubmit(第 1582-1633 行)同构:先定位「待回答轮」(find((t) => !t.answer))用于挂流式反馈,feedback 事件累积进 streamingFeedback,done 后同样回源替换。两个函数共同透露了本项目流式 UI 的一个重要架构决策:SSE 只负责「过程的即时呈现」,最终状态一律以 done 后的回源为准——流式期间写入的本地状态都是可丢弃的过渡态,真正的事实源是数据库经 GET 接口吐回的完整对象。这个模式让前端无需在流式路径里维护任何「半完成」的业务不变量,代价是每次 done 都多一次请求。
这是本次走读最重要的静态发现。handleSelectDirection 与 handleAnswerSubmit 的 done 分支都执行 fetch("/api/mock-interview/" + interviewId)(默认 GET),但服务端 src/app/api/mock-interview/[id]/route.ts 只导出了 DELETE 一个方法(全文件 52 行,export async function DELETE 是唯一导出;2026-10-06 回刷勘误:初稿误记 53 行)。按 Next.js App Router 的路由语义,未导出的方法返回 405——也就是说,done 之后的前端回源请求会得到 405 响应,r.json() 对非 JSON 响应体解析抛错,且这条 promise 链没有 .catch,错误以未处理拒绝的形式静默消失,setStreamingQuestion(null) 与 setSubmittingAnswer(false) 不会执行 ⚠️ 待确认(运行时 UI 表现:流式光标态可能残留,fetchHistory 的数据最终仍会覆盖列表视图,故问题易被掩盖)。对照《15-接口全量参考》第 4 节的端点表(同样只登记了 DELETE)可以确认这不是文档遗漏而是源码现状。修复方向二选一:服务端为 [id]/route.ts 补一个 GET handler(带归属校验,返回面试 + turns),或前端把 done 后的回源改为复用已有的 GET /api/mock-interviews 列表并在本地定位当前 id。补充(2026-10-06 回源核对):第 1653 行 handleSelectHistoryInterview 也调用同一端点(GET /api/mock-interview/${interview.id}),但有 res.ok 判断与「回退到列表项数据」的降级分支——405 时静默退化为概要视图,不属未处理拒绝,恰是本问题「易被掩盖」的又一佐证。此外文件里还有两处小额观察:第 1443-1444 行历史拉取失败被静默吞掉(catch 空体,历史视图只显示空态而无错误提示);第 1427-1431 行的视频可用性探测用 HEAD 请求且失败降级为隐藏入口,与《07-模块详解》第 4.2 节的描述完全吻合,属正确实现。
三个对象走完,把可迁移的读码经验收束成三句话。读流式链路时,先找「状态的写入点」再读转发循环——本链路里所有关键设计(答案延迟落库、问题流后落库、done 后回源)都发生在「流结束」这个时刻点上,转发循环本身反而是最没有业务语义的部分;同理,评估这条链路的正确性时问三个问题即可:谁在什么条件下能写入 Turn、事务覆盖了哪几行写入、客户端以什么为准。读领域服务时,先读常量与纯函数再读编排——review-scheduler 的全部智慧浓缩在 6 个数字与一个 Math.min 里,编排层只是给它们接上数据库;识别「这个文件有没有隐藏状态」的最佳办法是看测试是否断言了纯函数性(本文件有,说明作者有意识)。读大型客户端组件时,先画状态分组再读事件流——15 个 useState 按「视图/请求态/流式态/外设态」四组理解后,1715 行就塌缩成两个 SSE 分发函数与四个视图的渲染分支;而本次发现的 405 问题也提醒我们:前端「看起来在正常工作」的路径未必真的在按声明的协议通信,跨端契约(谁调谁的哪个方法)值得用接口对照表逐条核对,这正是《19-数据契约与示例集》要做的事。