Skip to content

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_typecontent 格式渲染方式
html原始 HTML 字符串iframe sandbox="allow-scripts" Blob URL
markdownGFM 文本react-markdown + remark-gfm + rehype-sanitize
jsonJSON 字符串结构化树视图 + 原始 JSON 导出
diagramJSON envelope {format, version, source}Mermaid / draw.io / Excalidraw
codeJSON {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

认证流程

  1. 前端 use-user-id.ts hook 在挂载时从 localStorage 读取 userIdpassword
  2. 每次 API 调用(如 POST /api/v1/projects)在请求体中附带 user_id
  3. 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/forkfork 公开项目

统一响应格式:

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 可以是 mermaiddrawioexcalidrawsource 包含实际的图表描述文本。

格式检测

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)
  );
};

isValidJsonContentisValidCodeProjectContent 在创建类型特定项目时进一步校验 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