项目:html5-examples | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
项目的"接口"分三个层面,阅读源码时务必区分:
server/src/modules/*/**.controller.ts 暴露的 /api/* 端点(NestJS),前端经 src/api/client.ts 调用,开发期走 Vite 代理 /api → localhost:3000。CodeExample 等数据结构与 src/utils/sandbox.ts、src/api/examples.ts 之间的格式约定——示例正文虽已本地化,契约不变。src/api/*.ts 暴露给页面的函数签名(与后端结构同构)、composable 的 props/事件契约。鉴权模型:全局 JwtAuthGuard(app.module.ts 的 APP_GUARD)默认拦截一切请求,控制器用 @Public() 装饰器(modules/auth/decorators/public.decorator.ts)显式放行公开端点;写操作叠加 AdminGuard(modules/auth/guards/admin.guard.ts)。访问令牌以 Authorization: Bearer <accessToken> 携带(client.ts 的 authTokenProvider 注入),cookie 会话作为无 token 时的回退通道(credentials: 'include' + CORS credentials: true)。
这套鉴权设计有一个值得称道的取向:默认拒绝、显式放行。与其让每个接口自己声明"要不要鉴权"(容易漏),不如全局先拦死,再用 @Public() 一个装饰器把公开接口白纸黑字地列出来——审查一个控制器的安全性时,只需要数一数 @Public() 的个数。同理,管理类写操作在"已登录"之上再叠一层 AdminGuard 角色检查,形成"公开 < 登录 < 管理员"的三级访问漏斗。前端与之配套的是 Bearer 优先、cookie 兜底的双通道:正常用户走无感刷新的 token 对,残存的 cookie 会话保证老版本前端不至于突然全员掉线。理解这个漏斗后再看端点表,每个接口该挂在哪一级就不再需要记忆,而是可以推导。
以下逐条来自对控制器源码的 grep 实证。标注 [公开] 的为 @Public() 放行端点;标注 [Admin] 的挂 AdminGuard;未标注的需登录。
阅读这些端点表时建议带着三个视角。视角一:消费方是谁——topics/categories/examples 的 GET 全部公开,因为它们支撑的是无登录的内容浏览;favorites 与 auth 未标注的端点全部要登录,因为它们操作的就是"当前用户";写操作几乎清一色 [Admin],对应"内容由维护者经管理后台或脚本治理"的运营模式。视角二:参数形状——列表端点统一接受 page/limit 分页对加若干业务过滤字段,详情端点统一以路径参数收 id 或 slug,写端点统一收 DTO(class-validator 校验);三个"统一"让新端点的接入几乎没有学习成本。视角三:活跃度——标有"历史板块"的端点(visual-demos、chart-examples)与 examples 的部分端点属于冻结资产(见 01 篇 §3.4),调用它们之前先确认前端是否还有消费方。
api/auth(modules/auth/auth.controller.ts)| 方法 | 路径 | 用途 | 入参 | 响应 |
|---|---|---|---|---|
| GET | /api/auth/captcha | 获取图形验证码 | — | 验证码 challenge(captcha.service 生成,落 CaptchaChallenge 实体) |
| POST | /api/auth/login | 账号密码登录 | AccountLoginDto:username?(3-120)、password(6-128),可选 remember | AuthResult{ user, accessToken, refreshToken?, expiresIn } + cookie |
| POST | /api/auth/register | 注册 | RegisterDto:username(3-120)、password(6-128)、nickname?(2-30)、captchaToken(8-64)、captchaCode(4-8) | AuthResult |
| POST | /api/auth/phone-auth | 手机号登录/注册 | PhoneAuthDto:phone(6-20)、password(8-64)、验证码字段 | AuthResult |
| POST | /api/auth/refresh | 刷新令牌 | RefreshDto{ refreshToken } | 新 AuthResult |
| GET | /api/auth/me | 校验 Bearer token 并取当前用户 | —(Bearer) | AuthUser |
| POST | /api/auth/me | 更新当前用户资料 | 资料 DTO | AuthUser |
| POST | /api/auth/logout | 登出(吊销 refreshToken、清 cookie) | — | 200 |
api/topics、api/categories| 方法 | 路径 | 用途 | 入参 | 响应 |
|---|---|---|---|---|
| GET | /api/topics | 主题列表 [公开] | page?, limit? | PaginatedResponse<Topic> |
| GET | /api/topics/:slug | 单主题 [公开] | slug | Topic |
| POST/PATCH/DELETE | /api/topics[/:slug] | 主题写操作 [Admin] | Topic 字段 | Topic |
| GET | /api/categories | 分类列表 [公开] | topicSlug?, page?, limit? | PaginatedResponse<Category> |
| GET | /api/categories/grouped | 按 topic 分组的分类树 | — | { groups: CategoryGroup[] } |
| GET | /api/categories/subcategories/:sourceGroup | 某分组下真实 category 分布(侧边栏二级菜单数据源) | sourceGroup | { sourceGroup, data: Category[] } |
| GET | /api/categories/:slug | 单分类 [公开] | slug | Category |
| POST/PATCH/DELETE | /api/categories[/:slug] | 分类写操作 [Admin] | Category 字段 | Category |
api/examples(modules/examples/examples.controller.ts)⚠️ 此模块后端仍在线但前端已不再调用(2026-10-05 起
src/api/examples.ts切换为本地 JSON)。端点保留用于管理后台与历史兼容。
| 方法 | 路径 | 权限 | 入参(query/body) | 响应 |
|---|---|---|---|---|
| GET | /api/examples | [公开] | topicSlug? categorySlug? search? isInteractive?('true'/'false') sourceFilePrefix? sourceGroup? ids?(逗号分隔)page? limit?(默认 20,钳制上限 100) | PaginatedResponse<Example> |
| GET | /api/examples/:id | [公开] | id | Example(404 当不存在) |
| POST | /api/examples | [Admin] | Partial<Example> body | Example |
| PATCH | /api/examples/:id | [Admin] | Partial<Example> body | Example |
| DELETE | /api/examples/:id | [Admin] | id | 200 |
分页参数经 server/src/common/utils/pagination.util.ts 归一化:clampPage()(非法回退 1)、clampLimit()(非法回退 20,默认上限 100,全量端点可放宽到 1000)。
api/favorites(modules/favorites/favorites.controller.ts,前端 src/api/favorites.ts 消费)| 方法 | 路径 | 用途 | 入参 | 响应 |
|---|---|---|---|---|
| GET | /api/favorites | 当前用户收藏列表(返回完整示例数据,身份由 Bearer 识别) | — | CodeExample[] |
| POST | /api/favorites | 收藏 | { exampleId: string } | 200 |
| DELETE | /api/favorites/:exampleId | 取消收藏 | exampleId | 200 |
api/static-sites(static-site-nav 模块)| 方法 | 路径 | 用途 | 入参 | 响应 |
|---|---|---|---|---|
| GET | /api/static-sites | 静态网站模板列表(默认 limit=1000、createdAt DESC) | page?, limit? | { data, pagination } |
| GET | /api/static-sites/:id | 单站详情(pages/external_deps 等 jsonb) | id | StaticSiteNav |
| POST/PATCH/DELETE | /api/static-sites[/:id] | 写操作 | 实体字段 | 实体 |
api/ui-elements 与 Canvas 示例 api/canvas-examples| 方法 | 路径 | 用途 |
|---|---|---|
| GET | /api/ui-elements/categories | UI 组件分类 |
| GET | /api/ui-elements | 组件列表(分页/筛选) |
| GET/PATCH/DELETE | /api/ui-elements/:id | 详情/更新/删除 |
| GET | /api/canvas-examples/libraries | 9 个图形库清单 |
| GET | /api/canvas-examples/categories | 各库分类统计 |
| GET | /api/canvas-examples | 示例列表(按 library/category/分页过滤,前端 src/api/canvas-examples.ts 消费,返回 CanvasExample[]/CategoryStat[]/LibraryStat[]) |
| GET/POST/PATCH | /api/canvas-examples/:id | 详情/创建/更新 |
| 方法 | 路径 | 模块 | 说明 |
|---|---|---|---|
| GET | `/api/visual-demos/libraries | engines | groups` |
| GET | /api/visual-demos、/:id | visual-demos | 同上 |
| GET | /api/chart-examples/categories、列表、:id | chart-examples | 同上 |
| GET | /api/knowledge/oss/signature | oss | OSS 直传签名 |
| POST | /api/knowledge/oss/upload | oss | 上传 |
| POST | /api/run/code/execute、/api/run/sql/query、/api/run/mysql/query | run | 代码/SQL 执行(admin-token 守卫) |
| * | /api/knowledge/* | knowledge | 知识库/文档/片段/评论/通知/画板全家桶(已核实 2026-10-06:SPA 前端、三姊妹项目与 /admin 静态页均未消费,见《13-技术债务》第 2 条) |
| * | /admin/* | ServeStaticModule | 管理后台静态页(排除 /api/(.*)) |
这是本项目最重要的"内部接口":内容生产者(JSON/脚本/人工)与消费框架(沙箱、预览、列表)之间的稳定协议。
src/types/index.ts)| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识;数字 id(如 "540")排前,合集条目按标题排后 |
title / description | string | 是 | 标题与描述(搜索命中的两个字段) |
category / categorySlug | string | 是 | 分类显示名 / slug |
topicSlug | string | 是 | 所属主题(`'html5' |
tags | string[] | 是 | 标签(getExamples 支持任一命中过滤) |
language | `'html' | 'css' | 'js' |
html | string | 是 | HTML 正文(不含 <!DOCTYPE>/<html>/<head> 文档结构,由沙箱补全) |
css? / js? | string | 否 | 样式与脚本,沙箱按序拼接 |
sourceFile / sourceLine? | string/number | 是/否 | 原始来源追溯字段 |
difficulty | `'beginner' | 'intermediate' | 'advanced'` |
isInteractive? / snippets? | boolean / DemoSnippet[] | 否 | 交互式演示标记与变体列表 |
sourceGroup? | string | 否 | 二级分组 slug(侧边栏下钻键) |
createdAt? / updatedAt? | string(ISO) | 否 | 时间戳 |
示例(摘自 src/content/examples/html5-basics.json 首条 id=540;原标本 id=536「语义化布局标签」已随 2026-10-06 内容治理提交 54429841 移除):
{
"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\">…<details open>…</details>…</div>",
"css": ".faq { max-width: 500px; … }",
"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"
}src/utils/sandbox.ts)buildSrcDoc(example, snippet?) 对示例源码的隐含约定:
html 是片段:框架负责包上 <!DOCTYPE html>、charset、viewport、<base href="{origin}/">(使 /shared-assets/、/static-sites/ 绝对路径可用)与统一 reset CSS;topicSlug === 'html5' 时自动包 .example-stage 舞台容器(宽高 100vh、flex 居中),补偿导入时被清洗掉的 body 布局样式——html5 主题示例的 css 中的 body 选择器应在导入期改写为 .example-stage(AGENTS.md 8.7 第 6 步);snippet 参数({ label, css?, html?, js? }):css/js 追加到示例对应段之后,html 整体替换——这是详情页「变体」切换的实现基础(DemoSnippet.label 为变体名);js 中的 </script 会被转义为 <\/script 防止提前闭合;重复拼接的两段完全一致 JS 会被 deduplicateRepeatedJs() 去重;needsSameOrigin() 正则检出,sandbox 属性追加 allow-same-origin。这份契约的价值在于它同时服务了三方而没有任何一方需要妥协。内容生产者只需要产出"干净的片段"——不用关心文档结构、编码声明、样式重置;预览框架因此获得完全的渲染控制权——统一字体基线、统一 reset、按主题注入舞台容器,还能在拼装层做安全处理(script 标签闭合转义);而示例本身保持了"复制出去也能跑"的完整性——源码面板展示的仍是片段原文,用户复制三段到本地 HTML 里稍加包装即可独立运行。变更这份契约前必须同时评估三个方向的影响面:新增"框架自动注入的全局能力"(如默认字体)会不会改变存量示例的渲染?收紧"源码清洗规则"会不会让旧示例在下次加载时行为漂移?放宽"变体合并语义"会不会让 snippets 的追加顺序产生覆盖歧义?
src/api/examples.ts 暴露给页面的签名)| 函数 | 签名 | 语义 |
|---|---|---|
getExamples | (params?: { topicSlug?, categorySlug?, search?, sourceFilePrefix?, sourceGroup?, ids?, tags?, page?, limit? }) => Promise<PaginatedResponse<CodeExample>> | 内存过滤 + 分页,结构与后端一致 |
getExampleById | (id: string) => Promise<CodeExample> | 不存在抛 ApiError(404) |
updateExample | (id, data: Partial<CodeExample>) => Promise<CodeExample> | 只接受 html/css/js 三字段,写 localStorage 覆盖层 |
PaginatedResponse<T> | { data: T[], pagination: { total, page, limit, totalPages } } | 与 GET /api/examples 响应同构 |
| 配置文件 | 导出 | 契约 |
|---|---|---|
src/config/css-lab/catalog.ts | cssTools: ToolItem[] | ToolItem{ id, name, description?, icon, categoryId, link, accent? }(src/types/tool.ts);link 必须经 toolPath() 派生 |
src/config/effects/index.ts | TECH_META、ALL_CATEGORIES、ALL_EFFECTS、categoryById、totalEffects | EffectItem 必含 tech/categoryId/name/nameCn/desc/tags;页面只消费本入口 |
src/config/tailwindTemplates.ts | 模板清单 + TAILWIND_CATEGORY_META | 本地分类(components/sections/pages/dashboards/navsites);sites 分类走后端 |
src/config/svgLessons*.ts | 课程章节数据 | 章节组件在 views/svg-lessons/ |
src/api/client.ts,397 行)所有后端请求的统一行为:
先说这个文件在架构中的位置。client.ts 是全站唯一允许出现裸 fetch 的地方,397 行代码完成了通常由 axios 之类库承担的全部职责——正因如此,它没有引入任何运行时依赖,行为完全透明可测(配套 client.test.ts 对重试与错误归一有专门用例)。它对调用方暴露的是五个谓词函数(get/post/put/patch/del)加两个注入点(token provider 与 401 处理器),对下则把传输细节(超时、重试、缓存穿透、卸载期 keepalive)全部封装。阅读它的推荐顺序是:先看 request() 主循环(重试编排),再看 doRequest()(单次请求与错误折叠),最后看 toApiError()(异常归一),三个函数吃透,全站的网络行为就尽在掌握。
| 特性 | 约定 | 源码位置 |
|---|---|---|
| baseURL | import.meta.env.VITE_API_BASE_URL ?? '/api' | defaultConfig |
| 超时 | 10s(AbortController + linkSignals 合并外部 signal,兼容无 AbortSignal.any 环境) | request() |
| 重试 | 仅 GET 且网络错误(0)/5xx,默认 2 次,指数退避 min(500*2^(n-1), 2000)ms | shouldRetry()/backoffDelay() |
| 401 | 触发 authFailureHandler(authStore 注册:清会话 → /login?redirect=);403 不触发 | request() catch 分支 |
| 鉴权头 | authTokenProvider() 返回值非空则附 Authorization: Bearer | doRequest() |
| 空响应 | 2xx 且空 body 返回 null as T(调用方应声明 T | null) | doRequest() |
| 缓存 | disableCache=true 时 GET 附 _t 时间戳 | get() |
| 卸载期请求 | fireAndForgetKeepalive():keepalive:true、不重试不抛错、不读响应(64KB body 上限),用于 beforeunload 快照保存 | 独立导出 |
| 查询串 | buildQueryString 过滤 undefined、全值字符串化 | 私有函数 |
错误处理约定:错误统一为 ApiError(status/statusText/url/body);getApiErrorMessage(error, fallback) 是全站唯一的错误文案提取器——优先取后端 body.message(NestJS string | string[],数组以「;」连接),否则回退 error.message 或 fallback 文案。AbortError 归一为 ApiError(408);网络异常归一为 ApiError(0)。
后端侧约定(server/src/main.ts):全局 ValidationPipe{ whitelist, forbidNonWhitelisted, transform }——DTO 未声明字段直接 400 并返回字段级 message 数组,与前端 getApiErrorMessage 的数组拼接逻辑互为配套。
错误处理的整体哲学是"语义在异常对象上,展示在最后一公里"。client.ts 不弹 toast、不跳页面,它只负责把五花八门的失败(网络断、超时、4xx、5xx、JSON 解析失败)折叠成携带状态码与后端原文的 ApiError;真正决定用户体验的展示逻辑放在两层——全局层处理"会话失效"这种横切事件(401 → 清会话跳登录),页面层处理业务性失败(useResourceFetch 的 error 分支渲染 EmptyState 重试按钮,或 toast.error(getApiErrorMessage(e)) 轻提示)。这个分工避免了两类常见事故:错误提示在 client 与页面各弹一次;以及页面各自为政地解释 401 导致行为不一致。编写新的 api 模块时,请继续保持"只抛不弹"的纪律。
若要给后端新增资源模块(对照既有模块的固定四件套):
modules/<name>/entities/<name>.entity.ts:TypeORM 实体(可参考 bigint.transformer.ts、id.util.ts);modules/<name>/<name>.service.ts:业务逻辑,分页用 clampPage/clampLimit 归一化;modules/<name>/<name>.controller.ts:@Controller('api/<name>') + 公开端点标 @Public() + 写端点挂 AdminGuard;modules/<name>/<name>.module.ts:TypeOrmModule.forFeature([Entity]) 并注册进 app.module.ts(实体同步登记 entities 数组);src/api/<name>.ts 用 get/post/patch/del 封装同名签名,经 src/api/index.ts 再导出。结构变更需生成迁移:
cd server && pnpm migration:generate && pnpm migration:run(data-source.ts提供连接配置)。
src/api/ 下每个资源文件的结构高度一致,新增模块时照抄惯例即可通过 review:
./client 导入需要的谓词函数(get/post/patch/del),从 @/types 导入领域类型,必要时从 ./examples 复用 PaginatedResponse(它是全站分页响应的规范类型,即便该模块数据来自后端也复用同一形状)。getTopics、getTopicBySlug、createTopic),参数用可选对象展开为 query,路径参数经 encodeURIComponent 包裹。api/examples.ts 的特例(内容本地化),一般后端资源模块保持"薄封装"。src/api/index.ts 再导出,页面统一从 @/api 引入,便于未来调整文件组织而不动调用方。src/types/** 的实体保持同构;不便进全局类型文件的场景(如分页包装、分组结构)在模块内就近声明并导出(如 categories.ts 的 CategoryGroup/GroupedCategoriesResponse)。对照 src/api/topics.ts(43 行)可以五分钟掌握全部惯例——它是所有模块里最短的一个,却完整覆盖了 CRUD 五个端点的标准写法。
全站列表数据遵循同一套分页契约,前后端两侧的实现互为镜像:
响应形状:{ data: T[], pagination: { total, page, limit, totalPages } }。total 是过滤后的总数,totalPages 至少为 1(空结果也返回一页),前端据此渲染分页器与"共 N 个"文案。
参数钳制:后端 pagination.util.ts 把 page 钳到 ≥1 的整数、limit 钳到 [1, 100](默认 20),非法输入静默回退而非报错——列表接口"永远给出可用结果"是刻意选择,避免一个越界参数让整页白屏。全量拉取场景(分类目录、静态站点)用放宽上限的 clampLimit(value, 1000)。
前端镜像:api/examples.ts 的本地实现逐条复刻了这些语义——Math.max(1, limit)、Math.ceil(total/limit)、页码钳制到 [1, totalPages]——这是"接口签名向后兼容"承诺的落地细节:调用方无论拿到的是后端响应还是本地响应,边界行为完全一致。
越界语义:请求页超出范围时服务端返回空 data 但保留正确 total;前端 ExamplesLibraryPage 通过"筛选变化即重置 page=1"的 watcher 顺序约定(见 02 篇 §2.2)保证用户几乎不会触达该状态。
接口层面的历史包袱处理,项目沉淀了三条可复用的策略:
策略一:签名兼容,实现替换。示例数据从 PostgreSQL 迁到本地 JSON 是一次伤筋动骨的实现替换,但 getExamples/getExampleById 的函数签名、分页响应结构、404 异常语义原样保留, eleven 个页面消费方零改动。这说明该项目的数据访问层设计成功地隔离了"数据在哪"与"数据长什么样"两件事,日后若需反向迁移(内容重新入库)或接入 CMS,同样只需替换 api/examples.ts 内部实现。
策略二:旧路径重定向,而非断链。接口与路由的每一次迁移都伴随 redirect 或保留端点:路由层有九条兼容重定向,后端保留了已无人调用的 examples CRUD。代价是代码库存在"死代码",收益是外部书签、搜索引擎收录、用户肌肉记忆全部无损。清理死代码应当是一个显式的、带着消费方审计的任务(见 01 篇 §3.4),而不是顺手行为。
策略三:双通道过渡。认证的 Bearer/cookie 双通道是典型的灰度手法:新前端优先 Bearer,检测不到时回退 cookie。任何"传输凭证方式升级""响应包裹结构调整"类的改动,都可以借鉴这种"新通道先行、旧通道兜底、观察期后收口"的三步节奏。
本篇完。下一篇:04-数据模型.md