项目:html5-examples | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
《03-接口设计》讲契约的设计意图,《15-接口全量参考》给端点查表,本篇补上第三块拼图:每个核心接口的完整报文实例——请求怎么发(curl)、成功响应长什么样、失败响应长什么样、字段值的来源依据。所有 JSON 均基于实体与 DTO 定义构造(实体:server/src/modules/*/entities/;DTO:server/src/modules/auth/dto/auth.dto.ts),凡属编造的运行值一律标注"示意值",凡随环境变化的字段(token、时间戳、id)标注"运行时值"。
四条贯穿性约定先交代,后文不再重复。其一,Base URL:开发期为 http://localhost:5555/api(经 Vite 代理转发到 3000,vite.config.ts 22-27 行),生产期取决于部署形态——《06-部署运行》形态 A 需网关反代 /api,形态 B 直接指向后端端口。本篇 curl 统一写 http://localhost:3000/api 直连后端,绕开代理层干扰。其二,错误形状统一:非 2xx 响应体是 NestJS 标准结构 {"statusCode": <int>, "message": <string 或 string[]>, "error": <类名>};其中 message 为数组是全局 ValidationPipe(whitelist + forbidNonWhitelisted,server/src/main.ts 12-18 行)的字段级校验错误,前端 getApiErrorMessage 会以「;」连接(《03-接口设计》§4)。其三,分页形状统一:{"data": [...], "pagination": {"total","page","limit","totalPages"}},参数经 server/src/common/utils/pagination.util.ts 钳制。其四,鉴权两通道:Authorization: Bearer <accessToken> 优先,auth-token httpOnly cookie 兜底;全局守卫为宽容模式——完全不带凭证时 req.user 为 undefined 继续放行,带无效凭证才 401(《08-安全与权限设计》§3)。凡标记 [Admin] 的端点另需 users.role === 'admin'。
GET /api/auth/captcha [公开](auth.controller.ts 57-61 行;captcha.service.ts 58-77 行)。挑战落 captcha_challenges 表,有效期后端常量控制(DEFAULT_EXPIRE_MS)。
curl -s http://localhost:3000/api/auth/captcha{
"captchaId": "3f9c2a7e-8b41-4d2f-9c6a-1e5b7d8a9f01", // 示意值(randomUUID)
"imageData": "data:image/svg+xml;base64,PHN2ZyB4bWxu...", // 运行时值(SVG 验证码图 base64)
"expiresAt": "2026-10-06T12:10:00.000Z" // 运行时值(生成时刻 + 有效期)
}无错误分支(公开只读)。契约要点:captchaId 与 captchaCode 在注册请求里必须成对回传,verifyCaptcha 按"id + 归一化大写 code + 未过期"三条件查库(captcha.service.ts 79-88 行)。
POST /api/auth/login [公开 + RateLimitGuard(5, 60_000)](auth.controller.ts 65-82 行)。account 三态归一(用户名/邮箱/手机号,auth.service.ts 的 normalizeAccount),成功后同时返回 body 并种下 auth-token cookie。
curl -s -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"account": "demo@example.com", "password": "passw0rd!"}'// 200 OK(AuthResult 结构,auth.service.ts 30-35 行)
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9.e30.签名", // 运行时值(JWT,payload {sub, username, role})
"refreshToken": "9f86d081884c7d659a2feaa0c55ad015...", // 运行时值(64 字节 randomBytes hex,已落 refresh_tokens 表)
"expiresIn": 604800, // 示意值:JWT_ACCESS_EXPIRES_IN 未配置时的代码兜底
"user": {
"id": "u-1001", // 示意值
"username": "demo@example.com",
"email": "demo@example.com",
"phone": null,
"displayName": "演示用户",
"avatar": null,
"role": "user",
"createdAt": "2026-06-15T07:25:34.110Z" // 运行时值
}
}失败分支三种。凭证错误(401):{"statusCode": 401, "message": "用户名或密码错误", "error": "Unauthorized"}。DTO 违约(400,数组 message):
{ "statusCode": 400, "message": ["password must be longer than or equal to 6 characters"], "error": "Bad Request" }限流触发(429):RateLimitGuard 超过 5 次/60 秒按 IP 计数后返回 429 并附 retryAfter(《08-安全与权限设计》§2.4;进程内实现,重启清零)。另注意响应头会带 Set-Cookie: auth-token=<accessToken>; HttpOnly; SameSite=Lax; Path=/(生产再加 Secure,auth.controller.ts 39-46 行)。
POST /api/auth/refresh [公开](auth.controller.ts 121-136 行;核心逻辑 auth.service.ts 336-364 行)。这是后端安全设计最讲究的端点:refresh token 一次性使用,旧 token 校验通过后立即打上 revokedAt;发现已吊销 token 再被使用即判定泄露,吊销该用户全部 refresh token。
curl -s -X POST http://localhost:3000/api/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refreshToken": "9f86d081884c7d659a2feaa0c55ad015..."}'成功:返回全新 AuthResult(结构与 §2 完全一致,refreshToken 字段为新令牌)。失败分支按服务端检查顺序:
// 缺参 401:{"statusCode":401,"message":"缺少 refresh token","error":"Unauthorized"}
// 未知 token 401:{"statusCode":401,"message":"无效的刷新令牌","error":"Unauthorized"}
// 已吊销(疑似泄露)401:{"statusCode":401,"message":"刷新令牌已被撤销,请重新登录","error":"Unauthorized"}
// 过期 401:{"statusCode":401,"message":"刷新令牌已过期,请重新登录","error":"Unauthorized"}前端消费方是 authStore.refreshAuth()(src/stores/authStore.ts 155-171 行):过期前 5 分钟静默调用,任何失败都清空本地会话并 clearFavorites()——刷新失败的语义被定义为"会话终结"而非"稍后重试",避免用已可能泄露的令牌反复尝试。
GET /api/auth/me [登录](auth.controller.ts 140-146 行)。前端 initialize() 的 Bearer 通道用它校验 token 并取完整用户信息(src/stores/authStore.ts 229 行)。
curl -s http://localhost:3000/api/auth/me -H 'Authorization: Bearer eyJhbGci...'// 200:AuthUser 结构(同 §2 的 user 字段)
// 401:{"statusCode":401,"message":"未登录或登录已过期","error":"Unauthorized"}对照端点 POST /api/auth/me [公开] 是 cookie 会话探测通道,恒返回 {"user": null}——因其 @Public() 使全局守卫短路、req.user 永远为 undefined(《08-安全与权限设计》§7 隐患 1、《15-接口全量参考》§1.2 的 ⚠️ 注)。本篇照录现状,修复决策见《13-技术债务》第 6 条(⚠️ 待确认 是否存在依赖该通道的存量用户)。
POST /api/auth/logout [公开](auth.controller.ts 159-169 行)。语义是"尽力而为 + 本地兜底":req.user 存在才吊销服务端全部 refresh token(宽容模式下匿名调用也不报错),随后无条件 clearCookie。
curl -s -X POST http://localhost:3000/api/auth/logout -H 'Authorization: Bearer eyJhbGci...'{ "success": true, "message": "已退出登录" }前端 authStore.logout() 在 API 失败时也继续清本地状态(try/catch 包裹,src/stores/authStore.ts 173-187 行)——登出永远成功,是"客户端状态清理不依赖网络"的典型处理。
控制器类级 @UseGuards(JwtAuthGuard)(favorites.controller.ts 25-27 行),身份完全由 Bearer/cookie 识别,无任何路径或参数携带用户 id——这是"越权面为零"的设计(《08-安全与权限设计》§7 收尾)。
查询:
curl -s http://localhost:3000/api/favorites -H 'Authorization: Bearer eyJhbGci...'// 200:完整 Example 实体数组(favorites.service.ts 19-31 行,innerJoin + 按收藏时间 DESC)
[
{
"id": "540",
"title": "Details/Summary 折叠面板",
"description": "使用 details 和 summary 元素创建原生折叠面板,无需 JavaScript。",
"category": "HTML基础",
"categorySlug": "html-basics",
"topicSlug": "html5",
"tags": ["details", "summary", "折叠", "手风琴", "原生组件"],
"language": "css",
"html": "<div class=\"faq\">…</div>", // 运行时值(全文)
"css": ".faq { max-width: 500px } …", // 运行时值(全文)
"js": null,
"sourceFile": "examples/html5-basics.md", // 示意值
"difficulty": "beginner",
"isInteractive": false,
"snippets": null,
"sourceGroup": "html5-basics",
"createdAt": "2026-06-15T07:25:34.110Z",
"updatedAt": "2026-10-03T12:40:38.787Z"
}
]新增与删除:
curl -s -X POST http://localhost:3000/api/favorites \
-H 'Content-Type: application/json' -H 'Authorization: Bearer eyJhbGci...' \
-d '{"exampleId": "540"}'
# 成功返回 Favorite 记录;示例不存在 404("示例 540 不存在");重复收藏由
# favorites 表 user_id+example_id 联合唯一索引兜底(《04-数据模型》§4.1)
curl -s -X DELETE http://localhost:3000/api/favorites/540 \
-H 'Authorization: Bearer eyJhbGci...'
# 成功:{"message": "已取消收藏 540"};记录不存在 404("收藏记录不存在")未登录(宽容模式下无凭证)调 GET 返回空数组而非 401(favorites.controller.ts 31-37 行注释"避免无凭证访问抛 500"),POST/DELETE 则显式 401("请先登录")——同控制器内两种未登录语义并存,读代码时容易当成不一致,实为刻意区分"读可空、写必登录"。前端乐观更新链路见《05-核心流程》流程四。
值得展开的是 GET 响应"返回完整示例数据"的取舍。收藏表里真正持久化的只有 (userId, exampleId, createdAt) 三个字段(《04-数据模型》§4.1),但接口返回的是整条 Example 实体(含 html/css/js 全文)——服务端用 QueryBuilder innerJoin(Favorite) 一次查询拼装(favorites.service.ts 19-31 行),前端收藏页因此不需要二次回查就能直接渲染卡片与预览。这个选择把复杂度留在了服务端一次性 JOIN,换来了消费端的零往返;代价是响应体随收藏数量线性增长(每条含数 KB 源码全文),对"几十条收藏"的量级完全无感,但若未来收藏规模上千,第一优化点应是给 GET 加 fields 裁剪参数或把全文改为按需单条拉取——契约形状(CodeExample[])可以保持不变,这也再次印证了"签名冻结、实现自由"的演进策略(见《16-决策记录ADR》§13 模式二)。另一处值得注意的语义是排序契约:服务端 orderBy favorite.created_at DESC, example.id ASC——前端无需再排序,"最近收藏在最前"是接口承诺而非页面行为;若发现收藏页顺序错乱,应先比对这条排序契约是否被前端二次处理破坏。
主题列表 GET /api/topics?page=1&limit=20 [公开](Topic 实体:modules/topics/entities/topic.entity.ts):
{
"data": [
{
"id": "topic-css", // 示意值(varchar 业务主键)
"name": "CSS",
"slug": "css", // unique
"icon": "ms:palette", // 示意值
"description": "层叠样式表示例合集",
"color": "#c0c1ff",
"order": 1,
"count": 485, // 示意值(topicOptions getter 过滤 count > 0)
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-10-01T00:00:00.000Z"
}
],
"pagination": { "total": 2, "page": 1, "limit": 20, "totalPages": 1 }
}分组分类树 GET /api/categories/grouped [公开] 返回 {"groups": [{"topicSlug": "css", "categories": [Category…]}]}(Category 含 name/slug/icon/topicSlug/count/description,结构见《04-数据模型》§2.2);二级分组分布 GET /api/categories/subcategories/:sourceGroup [公开] 是示例库侧边栏二级菜单的数据源:
curl -s http://localhost:3000/api/categories/subcategories/css-layout{ "sourceGroup": "css-layout", "data": [ { "name": "Flex 布局", "slug": "flex", "icon": "ms:view_column", "topicSlug": "css", "count": 96, "description": "Flexbox 各属性演示" } ] }
// ↑ data 元素为示意值;sourceGroup 回显路径参数目录域全部公开只读 + [Admin] 写(POST/PATCH/DELETE),与收藏一样共享统一分页形状。前端消费方 exampleStore.loadTopicsAndCategories() 在 App 挂载时 Promise.all 拉取一次并幂等缓存(《05-核心流程》流程一)。
以 PATCH /api/topics/:slug 为例([Admin],AdminGuard:未登录 401、非 admin 403)。除角色要求外,它最示范性的地方是DTO 即契约的 400 响应:
# 非 admin 角色 → 403:{"statusCode":403,"message":"Forbidden resource","error":"Forbidden"}
# 携带未声明字段 → 400(forbidNonWhitelisted 的直接产物)
curl -s -X PATCH http://localhost:3000/api/topics/css \
-H 'Content-Type: application/json' -H 'Authorization: Bearer <admin 的 token>' \
-d '{"name": "CSS", "role": "admin"}'{ "statusCode": 400, "message": ["property role should not exist"], "error": "Bad Request" }property role should not exist 这一条错误信息就是防 Mass Assignment 攻击的运行时证据——攻击者试图越权注入 role 字段被管道直接拒绝(《08-安全与权限设计》§4 第 1 条)。冻结模块 POST /api/examples [Admin](examples.controller.ts)的契约形状相同(Partial<Example> 入参、返回实体),但前端已零调用(《15-接口全量参考》§1.4),此处不再展开报文。
选这个端点做分页标本有一层特殊用意:它是同一份分页契约的三处实现之一(后端 QueryBuilder 版、前端本地 JSON 版、其他目录模块的复用版),三处行为被刻意保持逐位一致(《03-接口设计》§7 的镜像细节),所以读懂它的边界行为等于读懂全站列表接口。
curl -s 'http://localhost:3000/api/examples?topicSlug=html5&search=表单&page=1&limit=2'// 200(结构示意;该模块为冻结资产,前端已切本地 JSON,数据回填期间请勿作为内容源)
{
"data": [ /* CodeExample 实体数组,形状同 §10 的条目,至多 2 条 */ ],
"pagination": { "total": 77, "page": 1, "limit": 2, "totalPages": 39 }
}四个边界行为逐一说明。其一,参数钳制是静默的:page=-3 回退 1、limit=99999 被钳到 100、limit=abc 回退 20——全部不报错(pagination.util.ts 的 clampPage/clampLimit),这是"列表接口永远给出可用结果"的刻意设计,代价是调用方传错参数不会有任何提示,排查数据"看起来不对"时要先检查实际生效的分页值而非假设传参正确。其二,越界页返回空数据但保留正确 total:请求 page=999 得到 {"data": [], "pagination": {"total": 77, "page": 999, "totalPages": 39}}——page 原样回显、data 为空数组,前端据此渲染"没有更多"而非报错;前端 getExamples 的本地实现额外把 page 钳回 [1, totalPages](src/api/examples.ts 165 行),两版语义略有分叉但消费方无感。其三,ids 参数是逗号分隔字符串而非数组(?ids=540,541,542),后端拆分后走 IN 查询——这与"最近浏览/收藏回查"的前端调用习惯(数组参数)在 src/api/ 封装层转换,契约边界在封装层而非页面层。其四,搜索是子串包含且大小写不敏感,仅匹配 title 与 description 两个字段(本地版如此,src/api/examples.ts 153-160 行;后端版用 Like('%...%'))——tags 不参与 search,参与 search 的是 tags 的独立过滤参数,两者混淆会造成"搜不到标签"的误报(《12-FAQ》的排查入口)。
内容域不走 HTTP,但它是"契约"最密的地方——src/content/examples/*.json 的条目结构就是内容生产者与沙箱渲染框架之间的协议(《03-接口设计》§3.1 字段全表)。以下是真实条目(摘自 src/content/examples/html5-basics.json,实测首个条目 id=540;8 个分组合计实测 750 条——文档集初版基线为 1057 条,差额已定因:2026-10-06 内容治理提交 54429841 将 194 条源例并入合集条目与配方实验室后移除,各分组现为 css-animation 53 / css-basics 114 / css-layout 188 / css-visual 130 / html5-advanced 90 / html5-basics 58 / html5-forms-media 77 / html5-visual 40):
{
"id": "540",
"title": "Details/Summary 折叠面板",
"description": "使用 details 和 summary 元素创建原生折叠面板,无需 JavaScript。",
"category": "HTML基础",
"categorySlug": "html-basics",
"topicSlug": "html5",
"tags": ["details", "summary", "折叠", "手风琴", "原生组件"],
"language": "css",
"html": "<div class=\"faq\">\n <h3>❓ 常见问题</h3>\n <details open>\n <summary>什么是 HTML5?</summary>\n <p>…</p>\n </details>\n</div>",
"css": ".faq {\n max-width: 500px;\n}\n\ndetails {\n background: #f8f9fa;\n border-radius: 8px;\n}",
"js": null,
"difficulty": "beginner",
"isInteractive": false,
"snippets": null,
"sourceGroup": "html5-basics",
"createdAt": "2026-06-15T07:25:34.110Z",
"updatedAt": "2026-06-15T07:25:34.110Z"
}读这份契约的四个要点(依据见《03-接口设计》§3.2 与《04-数据模型》§3):html 是片段(无文档骨架,buildSrcDoc 补全);id 一经发布永不复用(收藏表/最近浏览/覆盖层三处引用);language 只影响高亮别名选择而非解析依据;snippets 为 null 或 [{label, css?, html?, js?}] 数组(变体模型)。该条目同时是 GET /api/favorites 响应元素的形状来源——收藏表存的是 exampleId 外键,返回时回查 examples 表拼出同构实体,所以 §6 与本节这组 JSON 看起来像同一个东西,这正是"类型同构三处同步"(《04-数据模型》§7 第 2 条)的运行时表现。
CSS 实验室侧没有 HTTP 契约,但有同等地位的注册契约:一个生成器要生效需要三处登记(组件 + kind 映射 + 目录条目),其类型核心是 generatorTypes.ts 的四接口。以下是与 RgbaColorGenerator.vue 同构的最小完整配置(示意值以注释标注):
// src/views/css-lab/css/generators/MyGenerator.vue 的核心配置(结构对齐 RgbaColorGenerator.vue)
import BaseGenerator from './BaseGenerator.vue'
import type { GeneratorSection, GeneratorPreset } from './generatorTypes'
const createDefaults = () => ({ color: '#2563EB', alpha: 0.35 }) // 示意值:默认参数
const config = reactive(createDefaults())
const codeOutput = computed(() =>
`background: rgba(37, 99, 235, ${config.alpha.toFixed(2)});` // 运行时值:参数推导
)
const sections: GeneratorSection[] = [
{
id: 'color',
label: '颜色设置',
params: [
{ key: 'color', label: '颜色', type: 'color' as const },
{ key: 'alpha', label: '透明度', type: 'slider' as const, min: 0, max: 1, step: 0.01, unit: '' },
],
},
]
const presets: GeneratorPreset[] = [
{ id: 'transparent-blue', label: '透明蓝', apply: () => { config.color = '#2563EB'; config.alpha = 0.3 } },
]配套的两行登记:generators/routes.ts 的 generatorComponents 加 my: defineAsyncComponent(() => import('./MyGenerator.vue'));src/config/css-lab/catalog.ts 的 cssTools 加 { id: 'css-gen-my', name: '我的生成器', icon: 'ms:brush', categoryId: 'css', link: toolPath('gen-my'), description: '…' }(link 必须经 toolPath 派生,缺键类型报错)。GeneratorParam.type 仅六值(slider/color/select/text/number/toggle),unit/options/min/max/step 为可选渲染参数——契约的封闭性(六控件白名单)是 63 个生成器界面一致性的来源。
把本篇 11 组报文压成一张心智表:行为域(captcha/login/refresh/me/logout/favorites)全部经 client.ts,错误统一 ApiError,401 触发全局登出跳转;目录域(topics/categories)公开分页只读,写操作 [Admin];内容域(JSON 条目 + 生成器配置)不经网络,契约由 TypeScript 类型与沙箱拼装逻辑承担。联调建议两条:其一,验证鉴权链路时按"captcha → register → login → me → refresh → logout"顺序走一遍完整生命周期,重点观察每次 Set-Cookie 与 expiresIn 的取值(对照《14-配置与环境详解》§2 的 TTL 语义澄清);其二,验证 DTO 白名单时故意多传一个字段——能收到 property xxx should not exist 的 400,说明全局管道在位,这是最便宜的全局健康探针。
再补三条联调中真正省时间的经验。第一,cookie 与 Bearer 别混用调试:curl 默认不存 cookie,浏览器里却可能残留旧 auth-token——同一端点在两种环境下的鉴权路径不同(前端走 Bearer 优先,curl 只带你给的头),出现"浏览器 200、curl 401"时先统一凭证通道再排查(《08-安全与权限设计》§2.1 的双通道设计)。第二,错误 message 是数组时前端会以「;」连接,联调时看到"字段 a 校验失败;字段 b 校验失败"这类拼接文案,应意识到后端报了两个错而前端只给了一行展示,直接看原始响应体定位更快。第三,时间戳字段一律 UTC ISO 字符串(2026-10-06T12:10:00.000Z),前端用 dayjs 格式化展示(《17-依赖全景》§1 的 dayjs 词条);若在联调中发现时间差 8 小时,先检查是否有人用了本地时区格式化再存库,而不是怀疑后端——实体层的时间列由 TypeORM 的 CreateDateColumn/UpdateDateColumn 自动维护(《04-数据模型》§4.3 的时间惯例),手写时间本身就是违反惯例的信号。
本篇完。下一篇:20-术语表与文档索引.md