ai-content-lab
项目概述
AI Content Lab 是一个部署在 Cloudflare Pages + Workers + D1 上的轻量级在线内容创作与分享平台。无需邮箱或手机号注册,系统自动生成 6 位字母数字账号和密码,用户可创建 HTML / Markdown / JSON / 图表 / 代码 五种类型的项目,发布到社区供他人浏览和 fork。
技术栈:
| 层 | 技术 |
|---|---|
| 前端框架 | React 19 + TypeScript 6 (strict) |
| 构建工具 | Vite 8 |
| UI 框架 | shadcn/ui + Tailwind CSS 4 |
| 代码编辑器 | CodeMirror 6 (@uiw/react-codemirror) |
| 后端运行时 | Cloudflare Workers |
| 后端框架 | Hono 4 |
| 数据库 | Cloudflare D1 (SQLite) |
| 部署 | Cloudflare Pages + Wrangler |
架构设计
整体分层
核心路由与数据流
Pages Functions 桥接模式
这个项目在 Cloudflare 部署上有一个精巧的设计:通过一个薄薄的 Pages Functions 适配层,将 Hono Worker 应用嵌入到 Cloudflare Pages 中:
ts
// functions/api/[[route]].ts
import app from "../../worker/index";
export const onRequest = async (context) => {
return app.fetch(context.request, context.env);
};这样做的好处是:Hono 应用可以独立开发和测试(作为标准 Worker),而部署时通过这个三行桥接器,无缝运行在 Pages Functions 之上。Pages 负责静态资源托管 + SPA fallback,Functions 处理 /api/* 的动态请求。
Share 页面的路由隔离
在 App.tsx:56-72 中,/share/:id 路由被特殊处理——它不挂载 MainLayout,避免加载导航栏、auth hooks 和主题逻辑:
tsx
const AppContent = () => {
const location = useLocation();
const isSharePage = location.pathname.startsWith("/share/");
if (isSharePage) {
return (
<>
<Routes>
<Route path="/share/:id" element={<SharePage />} />
</Routes>
<Toaster />
</>
);
}
return <MainLayout />;
};分享页是面向非登录用户的独立展示页面,每个项目类型有专属的渲染模式——HTML 以全屏 iframe 展示,Markdown 提供阅读视图+主题切换,代码项目保留源码查看和运行功能。
统一内容列设计
这是整个系统最有意思的设计决策——不为每种项目类型拆分内容字段,而是用一个
projects.content TEXT列存储所有类型的内容。
Schema
sql
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
project_type TEXT NOT NULL DEFAULT 'html',
is_community INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);project_type 作为区分器(discriminator),决定 content 字段的解释方式和渲染方式:
| project_type | content 格式 | 渲染方式 |
|---|---|---|
html | 原始 HTML 字符串 | iframe sandbox="allow-scripts" Blob URL |
markdown | GFM 文本 | react-markdown + remark-gfm + rehype-sanitize |
json | JSON 字符串 | 结构化树视图 + 原始 JSON 导出 |
diagram | JSON envelope {format, version, source} | Mermaid / draw.io / Excalidraw |
code | JSON {version, language, code, ...} | iframe 沙箱执行 + 日志控制台 |
为什么这样做
- Schema 简洁:不需要为每个新类型加列、做 migration
- 新增类型成本低:只需在
PROJECT_TYPES常量中加一项,前后端各自注册渲染/校验逻辑 - 查询无需 join:所有项目在同一张表,按
user_id列表查询无需跨表
代价是前端和后端都需要对 content 做类型感知的解析和校验。以 Code 类型为例,content 实际存储的是如下 JSON 结构:
json
{
"version": 1,
"language": "javascript",
"code": "console.log('hello');",
"stdin": "",
"auto_run": false,
"layout_mode": "split",
"preview_mode": "desktop",
"show_line_numbers": true
}前端 src/lib/code-project.ts:96-113 和后端 worker/middleware/validation.ts:51-85 各自维护了一套完全一致的校验逻辑。这是该设计的主要权衡——需要在两端保持类型校验的同步。
无会话认证设计
一个刻意的简洁选择:整个系统没有服务端 session、没有 token、没有 cookie。认证完全依赖
localStorage。
认证流程
- 前端
use-user-id.tshook 在挂载时从localStorage读取userId和password - 每次 API 调用(如
POST /api/v1/projects)在请求体中附带user_id - Worker 端通过对比请求中的
user_id与数据库中的projects.user_id做所有权校验
ts
// worker/routes/projects.ts:168-175
const project = await getProjectById(c.env.DB, id);
if (!project) {
return c.json({ error: "Project not found" }, 404);
}
if (project.user_id !== user_id) {
return c.json({ error: "Not authorized to update this project" }, 403);
}密码处理
密码使用 Web Crypto API 做 SHA-256 哈希后存入 D1,不在数据库中存储明文:
ts
// 在 worker/routes/auth.ts 中
const encoder = new TextEncoder();
const hashBuffer = await crypto.subtle.digest(
"SHA-256",
encoder.encode(password)
);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const passwordHash = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");账号生成
src/lib/utils.ts 中使用 crypto.randomUUID() 截取前 6 位字母数字作为账号 ID,密码同样随机生成 6 位。前端在注册成功后提供 .txt 文件下载,提示用户妥善保存。
这种设计的代价是:任何知道 user_id 的人都可以冒充该用户操作(如果同时也知道密码则可通过登录验证),但在低风险的内容创作场景下,这个简洁性换取的安全妥协是可接受的。
多类型项目系统
API 设计
面向资源的 RESTful API,以 Project 为核心实体:
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /projects?user_id= | 获取用户项目列表 |
POST | /projects | 创建项目 |
PUT | /projects/:id | 更新项目 |
DELETE | /projects/:id?user_id= | 删除项目 |
PATCH | /projects/:id/community | 切换社区公开状态 |
POST | /projects/:id/fork | fork 公开项目 |
统一响应格式:
json
// 成功
{ "data": { "id": "xxx", "title": "...", ... } }
// 失败
{ "error": "描述信息", "code": "ERROR_CODE" }ApiClient 封装
src/lib/api.ts 使用 class ApiClient 封装所有后端调用,核心是一个泛型 request<T> 私有方法:
ts
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
const json = (await response.json()) as ApiResponse<T> | ApiError;
if (!response.ok) {
const errorBody = json as ApiError;
throw new Error(
errorBody.error || `Request failed with status ${response.status}`,
);
}
return (json as ApiResponse<T>).data;
}数据访问层
worker/db/queries.ts 集中封装所有 D1 查询,统一使用参数化 SQL。典型查询如创建项目:
ts
export const createProject = async (
db: D1Database,
project: Pick<Project, "id" | "user_id" | "title" | "content" | "project_type">,
): Promise<Project> => {
const now = new Date().toISOString();
await db
.prepare(
"INSERT INTO projects (id, user_id, title, content, project_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(project.id, project.user_id, project.title, project.content, project.project_type, now, now)
.run();
return { ...project, is_community: 0, created_at: now, updated_at: now };
};所有查询都通过 .prepare().bind() 模式防止 SQL 注入。
代码项目沙箱执行
代码项目(JavaScript / TypeScript)可以在浏览器沙箱中运行,这是整个系统中最复杂的预览功能。
结构化存储
代码项目的 content 不是纯文本代码,而是一个包含运行配置的 JSON 结构:
ts
export const createCodeProjectContent = (
language: CodeLanguage = "javascript",
): CodeProjectContent => ({
version: CODE_PROJECT_CONTENT_VERSION,
language,
code: getDefaultCode(language),
stdin: "",
auto_run: false,
layout_mode: "split", // split | stacked
preview_mode: "desktop", // desktop | mobile | auto
show_line_numbers: true,
});layout_mode 控制编辑器和预览的分屏方式(左右分屏或上下堆叠),preview_mode 控制预览区域的视口模拟。
沙箱执行架构
代码运行在一个独立的 iframe 中,通过以下机制保证安全:
- CSP(Content Security Policy):iframe 内禁止外部资源加载
- 超时保护:5 秒执行超时,防止死循环
- 日志桥接:iframe 内的
console.log通过postMessage回传到宿主页面的日志面板 - stdin 模拟:通过
playgroundRuntime.input()向前端获取用户输入
提供的模板代码展示了 DOM 操作的完整能力——你可以在沙箱中创建元素、设置样式、操作文档结构。
类型安全校验
前后端各自维护了一套对 CodeProjectContent 的校验,确保 format 一致性。前端版本在 code-project.ts:96-113,后端版本在 validation.ts:51-85:
ts
// worker/middleware/validation.ts - 后端校验
export const isValidCodeProjectContent = (content: unknown): content is string => {
if (typeof content !== "string") return false;
try {
const parsed = JSON.parse(content) as unknown;
if (!isObjectRecord(parsed)) return false;
return (
typeof parsed.version === "number" &&
typeof parsed.code === "string" &&
typeof parsed.language === "string" &&
CODE_LANGUAGES.includes(parsed.language as (typeof CODE_LANGUAGES)[number]) &&
(parsed.stdin === undefined || typeof parsed.stdin === "string") &&
(parsed.auto_run === undefined || typeof parsed.auto_run === "boolean") &&
(parsed.layout_mode === undefined || (...)) &&
(parsed.preview_mode === undefined || (...)) &&
(parsed.show_line_numbers === undefined || typeof parsed.show_line_numbers === "boolean")
);
} catch {
return false;
}
};图表多格式支持
图表项目是另一个设计亮点——用一个统一的 JSON envelope 同时兼容 Mermaid、draw.io、Excalidraw 三种格式。
JSON Envelope 格式
json
{
"format": "mermaid",
"version": "1",
"source": "flowchart TD\n A([Start]) --> B[Process]\n B --> C([End])"
}format 可以是 mermaid、drawio 或 excalidraw,source 包含实际的图表描述文本。
格式检测
src/lib/diagram-utils.ts:86-103 实现了粘贴内容的启发式格式检测,用户粘贴图表文本时无需手动选择格式:
ts
export const detectDiagramFormat = (text: string): DiagramFormat => {
const trimmed = text.trim();
// draw.io: 包含 mxfile/mxGraphModel XML 标签
if (/<mxfile/i.test(trimmed) || /<mxGraphModel/i.test(trimmed)) {
return "drawio";
}
// Mermaid: 以已知的图表关键字开头
const mermaidKeywords =
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap|timeline|gitGraph|block-beta|architecture-beta|xychart-beta)\b/i;
if (mermaidKeywords.test(trimmed)) {
return "mermaid";
}
return "mermaid"; // 默认回退
};向后兼容
对于旧版项目(content 直接是 Mermaid 纯文本而非 JSON envelope),parseDiagramContent 会将其 fallback 为 { format: "mermaid", version: "1", source: raw } 处理:
ts
export const parseDiagramContent = (raw: string): DiagramContent => {
try {
const parsed: unknown = JSON.parse(raw);
if (parsed !== null && typeof parsed === "object" &&
"format" in parsed && "source" in parsed) {
// ... 返回结构化数据
}
} catch {
// Not JSON – 作为旧版 Mermaid 纯文本处理
}
return { format: "mermaid", version: "1", source: raw };
};draw.io 集成
draw.io 通过 embed.diagrams.net 的 iframe 嵌入,使用双向 postMessage 协议实现保存/加载——用户在 draw.io 编辑器中的改动可以回写到项目的 content 字段。
社区与 Fork 机制
社区发布
用户可以随时切换项目的 is_community 状态(1 = 公开, 0 = 私有),社区页展示所有公开项目的分页列表(支持 10/20/50/100 条每页)。
ts
// PATCH /api/v1/projects/:id/community
projectRoutes.patch("/:id/community", async (c) => {
const { user_id, is_community } = body;
// 校验 user_id 格式和 is_community 类型
const project = await getProjectById(c.env.DB, id);
if (project.user_id !== user_id) {
return c.json({ error: "Not authorized" }, 403);
}
await setProjectCommunity(c.env.DB, id, is_community);
return c.json({ data: { message: "Community status updated" } });
});Fork 机制
Fork 会复制源项目的标题(添加 (Fork) 后缀)、content 和 project_type 到当前用户名下:
ts
// POST /api/v1/projects/:id/fork
projectRoutes.post("/:id/fork", async (c) => {
const source = await getProjectById(c.env.DB, id);
if (source.is_community !== 1) {
return c.json({ error: "Project is not public" }, 403);
}
if (source.user_id === user_id) {
return c.json({ error: "Cannot fork your own project" }, 400);
}
const newId = crypto.randomUUID();
const forked = await createProject(c.env.DB, {
id: newId,
user_id,
title: `${source.title} (Fork)`,
content: source.content,
project_type: source.project_type,
});
return c.json({ data: forked }, 201);
});Fork 链路包含:限制检查(能否 fork 自己的项目)、公开状态校验(只能 fork 已公开项目)、每日创建限流检查,然后创建一份完整的项目副本。
限流与安全
限流策略
全局和用户维度的并发创建限制,防止滥用:
| 维度 | 限制 | 错误码 |
|---|---|---|
| 全局用户注册 | 100 用户/天 | dailyUserCreationLimitReached |
| 每用户项目创建 | 10 项目/天 | dailyProjectCreationLimitReached |
实现方式是在创建前查询当天的 UTC 时间窗口内的计数:
ts
// worker/db/queries.ts:47-57
export const countUsersCreatedToday = async (db: D1Database): Promise<number> => {
const { dayStart, dayEnd } = getCurrentUtcDayWindow();
const result = await db
.prepare(
"SELECT COUNT(*) AS user_count FROM users WHERE datetime(created_at) >= datetime(?) AND datetime(created_at) < datetime(?)",
)
.bind(dayStart, dayEnd)
.first<{ user_count: number }>();
return result?.user_count ?? 0;
};超限时返回 HTTP 429 和对应的 code,前端在 toast 中展示错误信息。
输入校验
所有 API 端点都有严格的输入校验:
ts
// worker/middleware/validation.ts
const USER_ID_REGEX = /^[a-zA-Z0-9]{6}$/;
const PASSWORD_REGEX = /^[a-zA-Z0-9]{6}$/;
export const isValidUserId = (userId: unknown): userId is string => {
return typeof userId === "string" && USER_ID_REGEX.test(userId);
};
export const isValidTitle = (title: unknown): title is string => {
return typeof title === "string" && title.length >= 1 && title.length <= 100;
};
export const isValidProjectType = (projectType: unknown): projectType is ProjectType => {
return (
typeof projectType === "string" &&
PROJECT_TYPES.includes(projectType as ProjectType)
);
};isValidJsonContent 和 isValidCodeProjectContent 在创建类型特定项目时进一步校验 content 的结构合法性。
HTML / Markdown 安全
- HTML 预览:内容通过
sandbox="allow-scripts"iframe + Blob URL 渲染,隔离执行环境 - Markdown 渲染:使用
rehype-sanitize过滤危险 HTML 标签,防止 XSS
总结
AI Content Lab 是一个「在约束中做设计」的典型案例:选择 Cloudflare 全栈(Pages + Workers + D1),用两表 schema 支撑五种内容类型,用无会话认证换取零配置的注册体验,用统一 content 列简化 schema 但增加前后端校验同步的复杂度。
几个值得借鉴的设计点:
- Pages Functions 桥接模式:三行代码将 Hono Worker 嵌入 Pages,开发和生产环境统一
- content + project_type 区分器:用应用层类型系统替代数据库 schema 的字段拆分,新类型扩展成本低
- 沙箱执行架构:CodeMirror 编辑器 + iframe CSP 隔离 + postMessage 日志桥接,实现安全的浏览器端代码预览
- JSON envelope 向后兼容:图表项目的 format/source 结构兼顾了 Mermaid / draw.io / Excalidraw 三种格式,以及旧版纯文本的 fallback
- API 响应约定:统一
{ data }/{ error, code }格式,ApiClient 泛型封装,类型安全从前端贯穿到数据库
站点:https://ai-content-lab.tangzixuan.cc/
源码:github.com/tangzixuan/ai-content-lab