项目:interview-platform | 版本:v1.2 | 生成日期:2026-10-06 | 修订:2026-10-06 一致性回刷 | 依据:源码分析
《15-接口全量参考》把 101 个端点压缩成了七要素速查表;本篇反向操作——选取九组核心接口,把每个契约展开成可执行的完整请求/响应示例,包括 curl 命令、基于 Prisma 模型构造的响应 JSON、逐个错误分支的响应体,以及 AI 流式接口的 SSE 帧序列。选样覆盖五条业务主线:认证(注册 + 登录)、题库练习(列表 / 状态 / 判题提交)、AI 模拟面试(创建 / 答题 / 结束)、竞赛(创建与提交)、看板(到期复习 / 打卡)。逐端点全量清单仍以《15-接口全量参考》为准,本篇不做覆盖性承诺。
三条通读约定必须先建立。第一,示意值标注:下文所有 JSON 中的 id、时间戳、token、评分等运行期值均为示意值——凡形如 7f3a... 的 id 是按 UUID 格式虚构的,时间戳取 2026-10-06 前后,它们只用于展示结构与字段名,不代表任何真实记录;示例结构逐一对照 prisma/schema.prisma 的模型字段与对应 route.ts 的响应构造代码编写,字段名(含 Prisma 的 @map 下划线映射)以源码为准。第二,认证方式:除标注 Pub 的端点外,示例 curl 都需要携带会话。NextAuth v4 的会话凭证是 next-auth.session-token HttpOnly cookie,浏览器内由库自动管理;curl 联调时最简单的做法是先在浏览器登录后从开发者工具复制 cookie 值,再以 -H "Cookie: next-auth.session-token=<会话令牌>" 方式携带(下文以 $SESSION_COOKIE 变量代称)。第三,错误响应公约:全部端点共用统一错误形态——顶层 JSON 的 error 字段承载中文消息({"error": "..."}),状态码语义为 400 参数/校验、401 未登录、403 无权限或归属不符、404 不存在、409 冲突、429 限流(带 X-RateLimit-Limit/Remaining/Reset 三个响应头)、502 上游故障(不透传内部细节)、503 依赖未配置、500 兜底;每个接口小节只列出该端点特有的错误分支,公约不再重复。另有一个跨切面事实需要先交代:竞赛没有独立的「报名」端点——本项目的竞赛参与模型是「时间窗内直接提交即参与」,ContestSubmission 表就是参与记录,下文第 7 节将按此事实给出竞赛侧的完整契约。
请求与响应(Pub,IP 限流 5 次/分):
curl -X POST https://<host>/api/auth/send-code \
-H "Content-Type: application/json" \
-d '{"email": "dev@example.com"}'{ "success": true }SMTP 未配置时该接口仍然返回成功——验证码降级打印到服务端控制台(日志前缀 [mailer],本地联调直接从日志取码),这是《12-FAQ与故障排查》第 8 条所述降级语义在契约层的体现。错误分支:邮箱非法 400;60 秒重发窗口内重复请求由 src/lib/verify-code.ts 的 isRateLimited 拦截,表现为 429(提示「发送频繁」);IP 超限 429 且带 X-RateLimit-* 头。
请求与响应(Pub,IP 限流 10 次/时):
curl -X POST https://<host>/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "dev@example.com", "password": "secret123", "name": "示例用户", "code": "483920"}'{
"id": "7f3a2b1c-4d5e-4f60-8a90-b1c2d3e4f5a6",
"email": "dev@example.com",
"name": "示例用户"
}响应体按 src/app/api/auth/register/route.ts 第 80-83 行的字段构造(id/name 为示意值);name 缺省时服务端取邮箱前缀。错误分支按校验顺序:缺 email/password 或密码不足 6 位 → 400 {"error": "密码至少6位"};邮箱格式不合法 → 400;密码超 128 位 → 400;邮箱已注册 → 409 {"error": "该邮箱已被注册"}(此分支不消耗验证码,源码注释「先查重,避免已注册邮箱消费验证码」);验证码缺失 → 400;验证码错误或过期 → 400 {"error": "验证码错误或已过期"}(校验成功即删除该邮箱全部历史码,一次性语义);IP 超限 → 429;服务端异常 → 500 {"error": "注册失败"}。注册成功后 emailVerified 即刻置位,无需再走验证流程。
登录不走自建端点,而是 next-auth 库的标准三步协议。第一步取 CSRF 令牌,第二步提交凭证换取会话 cookie,第三步用会话 cookie 访问任意受保护接口:
# 1) 获取 csrfToken(响应 {"csrfToken": "<令牌-示意>"})
curl -c cookies.txt https://<host>/api/auth/csrf
# 2) 提交凭证(表单编码;成功响应 {"url": "..."} 并在 Set-Cookie 下发会话)
curl -b cookies.txt -c cookies.txt -X POST https://<host>/api/auth/callback/credentials \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "csrfToken=<上一步返回的令牌>" \
--data-urlencode "email=dev@example.com" \
--data-urlencode "password=secret123" \
--data-urlencode "json=true"
# 3) 携带会话 cookie 访问受保护接口
curl -b cookies.txt https://<host>/api/user前端实际由 src/components/auth/LoginForm.tsx 调用 signIn("credentials", {...}) 完成同一流程。错误在协议层的表现需要特别注意:凭证错误(密码不对)返回 401 的重定向式响应;两类业务错误以异常 code 传递——ACCOUNT_BANNED(封禁)与 EMAIL_NOT_VERIFIED(邮箱未验证),前端 LoginForm 识别 code 后展示对应提示。会话建立后,session 回调每次拉取都回查数据库刷新 image/name/role 并按 banned 拒签(《16-决策记录ADR》ADR-007),因此「封禁即时踢出」在契约层的表现是:被封禁用户的后续请求全部 401 {"error": "未登录,请先登录"},前端 request.ts 自动登出跳转登录页。
请求(Pub,游客可带个人化参数但被忽略):
curl -H "Cookie: $SESSION_COOKIE" \
"https://<host>/api/questions?difficulty=中等&questionType=code&jobRole=backend&orderBy=createdAt&orderDir=desc&take=20&skip=0"响应体结构(登录态,示意值;字段对照 Question 模型及其关联):
{
"questions": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"title": "手写 Promise.all",
"content": "实现一个 Promise.all,要求……(Markdown 长文)",
"solution": "参考解析……",
"answer": null,
"codeTemplate": { "javascript": "function promiseAll(promises) {\n // 在此实现\n}" },
"questionType": "code",
"difficulty": "中等",
"company": "字节跳动",
"jobRole": "backend",
"viewCount": 1234,
"encounterCount": 56,
"categoryId": "domain-前端",
"createdAt": "2026-09-20T08:00:00.000Z",
"updatedAt": "2026-09-24T10:00:00.000Z",
"tags": [{ "questionId": "a1b2c3d4-0000-4000-8000-000000000001", "tag": "Promise" }],
"category": { "id": "domain-前端", "name": "前端", "parentId": null },
"mastery": "learning",
"isBookmarked": true
}
],
"total": 327,
"take": 20,
"skip": 0
}三个契约要点:mastery/isBookmarked 是响应构造时合并进去的个人状态(默认 unsolved/false,游客恒为默认值,前端不得据此给游客渲染徽章);tags 与 category 因 include: { tags: true, category: true } 而内联;排序字段走白名单(createdAt/viewCount/encounterCount),白名单外的 orderBy 静默回退 createdAt。错误分支仅 500 兜底。
请求(User,zod 校验):
curl -X PATCH -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"mastery": "mastered", "isBookmarked": false}' \
"https://<host>/api/questions/a1b2c3d4-0000-4000-8000-000000000001/state"响应体按 UserQuestionState 模型构造(upsert 幂等,示意值):
{
"id": "c9d8e7f6-1122-4334-8556-a7b8c9d0e1f2",
"userId": "7f3a2b1c-4d5e-4f60-8a90-b1c2d3e4f5a6",
"questionId": "a1b2c3d4-0000-4000-8000-000000000001",
"mastery": "mastered",
"isBookmarked": false,
"createdAt": "2026-09-24T10:05:00.000Z",
"updatedAt": "2026-10-06T02:30:00.000Z"
}错误分支:未登录 401;题目不存在 404;两字段都缺 → 400(zod refine「至少提供一个要更新的字段」);mastery 取值超出三枚举 → 400。GET 方法同路径返回同样的状态对象(无记录时为默认态)。
请求(Pub——未登录也可判题,登录才写练习记录):
curl -X POST -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"code": "function promiseAll(promises) {\n return new Promise((resolve, reject) => {\n /* 实现 */\n });\n}", "language": "javascript"}' \
"https://<host>/api/questions/a1b2c3d4-0000-4000-8000-000000000001/submit"响应体按 JudgeResult 接口(src/lib/judge.ts 第 6-21 行)构造,results 逐用例(示意值):
{
"status": "wrong_answer",
"passedCount": 3,
"totalCount": 4,
"duration": 1823,
"results": [
{ "testCaseId": "t1...", "input": "[[Promise.resolve(1)]]", "expected": "[1]", "actual": "[1]", "passed": true, "duration": 402 },
{ "testCaseId": "t2...", "input": "[[], Promise.reject(new Error('x'))]", "expected": "\"Error: x\"", "actual": "null", "passed": false, "duration": 388 }
]
}错误分支:缺 code/language → 400;题目不存在 → 404;该题无可见测试用例 → 400 {"error": "该题暂无测试用例"};Piston 上游故障 → 500(消息含「代码执行服务错误」);单用例执行超 15 秒 → 不报错而是判为 time_limit_exceeded 并中止后续用例(正常 200 响应)。登录态下的副作用契约:写入一条 PracticeHistory(accepted → completed、其余 → failed),并把个人掌握状态从 unsolved 推进为 learning(单向,不回退已 mastered 的状态)——消费打卡与复习数据的集成方依赖这条映射规则保持口径一致。
请求(User,限流 10 次/分 + 每日 20 场):
curl -N -X POST -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"direction": "backend"}' \
"https://<host>/api/mock-interview"-N(--no-buffer)是 curl 消费 SSE 的关键参数。响应为 text/event-stream,帧序列如下(对照 src/app/api/mock-interview/route.ts 的 sse() 构造;每帧以 data: 前缀、双换行结尾;内容为示意):
data: {"event":"interview","id":"e5f6a7b8-9999-4000-8000-00000000abcd"}
data: {"event":"chunk","content":"请你"}
data: {"event":"chunk","content":"介绍一下 MySQL"}
data: {"event":"chunk","content":"索引的底层结构。"}
data: {"event":"done"}三个事件构成创建阶段的完整协议:interview 携带面试 id(前端以此立即进入对话视图)、chunk 携带首题的增量文本、done 结束流(此时服务端才把完整问题落库为 Turn#1)。behavioral 方向的帧序列同构,但内容来自本地题库且以 setTimeout 模拟流式(《18-代码走读实录》第 1.3 节)。错误分支在流开始之前返回 JSON:400 缺 direction、429 创建频繁或 {"error": "今日模拟面试次数已达上限(20 场),请明天再试"}、503 Key 未配置、502 上游错误(此分支把上游 errorText 拼入响应,为已知不一致,《13-技术债务与演进建议》1.5)。
请求(User,限流 20 次/分;answer 为示意文本):
curl -N -X POST -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"answer": "InnoDB 的索引是 B+ 树,叶子节点存数据……(候选人的完整回答)"}' \
"https://<host>/api/mock-interview/e5f6a7b8-9999-4000-8000-00000000abcd/turn"非末轮的帧序列:feedback 事件流式输出 AI 评估(注意:流里传的是 JSON 全文的增量——AI 按要求返回包含 feedback/score/nextQuestion 的 JSON,前端把整段当 Markdown 渲染,解析出结构化字段是服务端在流结束后做的),done 事件携带结构化结果:
data: {"event":"feedback","content":"{"}
data: {"event":"feedback","content":"\"feedback\": \"回答覆盖了 B+ 树结构,","score":null}
data: {"event":"done","score":7,"status":"in-progress","nextQuestion":"那什么情况下索引会失效?"}说明两点:帧内 JSON 字符串在 SSE 载荷中被转义(data: 后是一个合法 JSON 值,其 content 字段本身是字符串,示例中为排版清晰省略了转义反斜杠,实际帧为标准 JSON 编码);feedback 帧只有 content 一个业务字段(增量文本)。末轮(第 5 轮或 AI 判定完成)的 done 帧形态不同:{"event":"done","score":8,"status":"completed","overallScore":76,"summary":"本场面试总结……"}。错误分支:404 面试不存在、403 非本人(含历史匿名记录)、400 面试已结束/当前轮已回答/轮次异常、502 AI 服务暂时不可用(转译文案,不透传上游);流开始后的错误以事件帧下发且 HTTP 已 200——{"event":"error","message":"AI 返回了无法解析的内容"},消费方必须实现 error 事件处理而不能只看状态码。答题的副作用契约(与《18-代码走读实录》第 1.4 节呼应):答案与反馈在一个事务内原子落库,AI 失败则本轮不写答案、可重试。
请求(User):
curl -X PATCH -H "Cookie: $SESSION_COOKIE" \
"https://<host>/api/mock-interview/e5f6a7b8-9999-4000-8000-00000000abcd/complete"响应为完结后的面试对象(本地按已评分轮均值计算 overallScore,summary 由服务端模板生成「面试已结束,共 N 轮问答,综合评分 X 分。」;示意值):
{
"id": "e5f6a7b8-9999-4000-8000-00000000abcd",
"direction": "backend",
"status": "completed",
"overallScore": 7,
"summary": "面试已结束,共 3 轮问答,综合评分 7 分。",
"startedAt": "2026-10-06T02:00:00.000Z",
"endedAt": "2026-10-06T02:18:00.000Z",
"createdAt": "2026-10-06T02:00:00.000Z",
"updatedAt": "2026-10-06T02:18:00.000Z",
"userId": "7f3a2b1c-4d5e-4f60-8a90-b1c2d3e4f5a6",
"turns": []
}错误分支:404 / 403(归属)/ 400 {"error": "面试已结束"}。前端完成本动作后应重新拉取历史(GET /api/mock-interviews,响应 {"interviews": [...]} 含 turns,按 startedAt 降序)。走读层面的已知偏差在此提醒:前端 done 后的回源请求指向了本组 GET /api/mock-interview/[id],而该路由文件目前只导出 DELETE(《18-代码走读实录》第 3.4 节),集成方不应依赖该 GET 端点,直至其被补齐。
请求(User——登录即可创建,非 RBAC 权限点,源码注释说明前台表单共用):
curl -X POST -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"title": "第 42 期周赛", "description": "每周例行练习赛", "startTime": "2026-10-10T14:00:00.000Z", "duration": 90, "type": "weekly", "problemIds": ["q-uuid-1-示意", "q-uuid-2-示意", "q-uuid-3-示意"]}' \
"https://<host>/api/contests"响应 201 为竞赛对象(含按 ContestProblem 建立的 problems 关联,score 默认 100、orderBy 按数组顺序;示意值)。错误分支:缺必填字段 400;problemIds 中存在不存在的题目 ID 时整体 400(部分题目不存在,请检查题目ID)——源码按 ID(problemIds)批量查题并校验命中数与请求数一致,创建不会因部分题目缺失而静默跳过(2026-10-06 回刷核销:初稿「按 title 批量查题、静默跳过」的 ⚠️ 待确认 经回源证实为误记,见 src/app/api/contests/route.ts POST 的 findMany({ where: { id: { in: problemIds } } }) 与数量校验)。
请求(User;contests/[id]/submit/route.ts):
curl -X POST -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"questionId": "q-uuid-1-示意", "code": "class Solution { /* ... */ }", "language": "python"}' \
"https://<host>/api/contests/contest-uuid-示意/submit"响应体(示意值;submission 为嵌套对象,字段对照 ContestSubmission 模型):
{
"submission": {
"id": "s1u2v3w4-5566-4778-8990-abcdef012345",
"status": "accepted",
"score": 100,
"passedCount": 5,
"totalCount": 5,
"duration": 954,
"submittedAt": "2026-10-10T14:32:11.000Z"
}
}得分契约:accepted 得该题满分(ContestProblem.score),否则 round(passedCount / totalCount × score) 按比例折算。错误分支是本端点的重点:未登录 401 {"error": "请先登录"};竞赛不存在 404;竞赛尚未开始 / 竞赛已结束 / 该题目不属于此竞赛 / 无测试用例——四个业务拒绝全部是 400 但消息不同(前端应透传消息);判题服务异常 500。判题包含隐藏用例(judgeCode(..., true)),与练习判题的可见用例形成防作弊差异;代码原文随 ContestSubmission.code 入库支持赛后复盘。
请求(User;limit 默认 20,stage 可选阶段过滤):
curl -H "Cookie: $SESSION_COOKIE" "https://<host>/api/review/due?limit=5"响应(字段对照 review-scheduler.ts 的 DueReviewItem 接口,日期已被 toISOString() 序列化;示意值):
{
"dueCount": 12,
"reviews": [
{
"questionId": "a1b2c3d4-0000-4000-8000-000000000001",
"title": "手写 Promise.all",
"difficulty": "中等",
"categoryName": "前端",
"lastPracticedAt": "2026-09-28T12:00:00.000Z",
"nextReviewAt": "2026-10-05T12:00:00.000Z",
"daysOverdue": 1,
"stage": 3
}
]
}契约要点:dueCount 是不带 limit 的全量到期数(服务端为此跑了两遍推导,due/route.ts 第 18-21 行),reviews 按 daysOverdue 降序;stage 为 1 基展示值;间隔口径是艾宾浩斯表 [1,2,4,7,15,30]——与 /api/reminders 的 [1,3,7,15,30] 不同源(见《13-技术债务与演进建议》2.4),跨端集成二选一时建议以本端点为准。错误仅 500 兜底({"error": "获取待复习列表失败"})。
请求(User):
curl -H "Cookie: $SESSION_COOKIE" "https://<host>/api/stats/streak"响应(示意值;字段对照 streak.ts 与 UserStreak 模型的当日快照):
{
"currentStreak": 6,
"longestStreak": 21,
"todayPracticed": true,
"todayCount": 4,
"dailyGoal": 5,
"goalMet": false
}本端点有两条特殊契约必须写进集成方的肌肉记忆。其一,GET 有写副作用:每次调用都会重算并 upsert 当日 UserStreak 快照(读时物化),幂等无害但意味着「只读」的监控拨测也会产生写流量,不建议对其做高频探活。其二,日界是上海时区:currentStreak 从「今天练过则今天、否则昨天」起向过去逐日回查,跨时区消费方核对数据必须以 UTC+8 日历日为准。POST 同路径修改 dailyGoal(body {"dailyGoal": 8},响应为更新后的同构快照);修改后 goalMet 按 todayCount >= dailyGoal 重算。
把全项目四条 SSE 链路的帧协议收拢成一张心智图,便于对照实现消费端。自建事件协议(服务端构造 {"event": "...", ...} JSON 帧):mock-interview 创建(interview → chunk* → done)、mock-interview 答题(feedback* → done,或 error)、/api/ai(透传 DeepSeek 原始 OpenAI 格式帧——{"choices":[{"delta":{"content":"..."}}]},服务端仅在末尾补发 data: [DONE],是唯一不改帧形态的透传实现);hints 生成不流式(非流式 JSON)。消费端的实现契约统一是:用 fetch POST(不能用 EventSource,它只支持 GET)、按双换行切帧、残帧留 buffer(src/lib/sse.ts 已实现并有跨 chunk 断裂测试)、必须处理 error 事件(流开始后 HTTP 恒 200,成败只能看事件)。生产部署的前置检查是反代不得缓冲该路径响应(nginx proxy_buffering off;应用已带 Cache-Control: no-cache, no-transform 但未带 X-Accel-Buffering: no,《12-FAQ与故障排查》第 9 节)。
错误响应的形态在《03-接口设计》第 4 节有语义清单,本篇补一份「带响应头的完整示例」作为收尾——限流 429 的真实形态(响应头值为示意):
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1780742400
Content-Type: application/json
{"error":"创建过于频繁,请稍后再试"}X-RateLimit-Reset 是窗口重置时刻的 Unix 秒级时间戳(rate-limit.ts 第 110 行 Math.ceil(resetTime / 1000)),客户端应据此退避而非立即重试。最后把九组契约的联调顺序建议留给接手者:先跑认证三步(1.1-1.3)拿到 $SESSION_COOKIE,再按「列表 → 状态 → 判题」验证题库域(2-4 节),然后开一个低成本方向(general)的面试走完创建/答题/结束三连(5 节,注意每日 20 场护栏),竞赛与看板两组(6-7 节)依赖预置数据(竞赛需先创建,复习与打卡需先有练习记录——可用第 4 节的判题提交制造)。全部示例的响应 JSON 均已按 Prisma 模型与源码构造核对;运行期字段的实际取值以真实环境为准,示意值不可直接作为测试断言的期望值。