React + Hono + Better Auth + Drizzle + D1 完整技术方案
1. 方案定位
1.1 技术目标
构建一套具备以下能力的全栈 Web 应用基础设施:
- React 管理前端界面。
- Hono 提供业务 API 和短链接跳转。
- Better Auth 负责注册、登录、Session、OAuth 和账号安全。
- Drizzle 管理数据库 Schema、查询和迁移。
- Cloudflare D1 存储认证和业务主数据。
- Cloudflare Workers 同时运行 API、认证服务并托管前端静态资源。
- 前后端通过 Hono RPC 共享 TypeScript 类型。
- 默认使用同域部署,避免复杂的 CORS 和跨域 Cookie。
- 后续可以平滑加入 KV、R2、Queues、Durable Objects 和 Analytics Engine。
Cloudflare 官方目前提供 React SPA、Worker API 与 Cloudflare Vite Plugin 的一体化项目模板;前端静态资源和 Worker 可以在同一个项目中构建和部署,Worker 在本地开发时运行于 workerd 环境。
2. 核心架构决策
2.1 推荐部署拓扑
第一阶段采用单仓库、单 Worker、同域部署:
https://example.com
│
├── / React SPA
├── /login React SPA
├── /dashboard/* React SPA
│
├── /api/auth/* Better Auth
├── /api/v1/* Hono 业务 API
├── /r/:slug 短链接跳转
└── /health 健康检查
│
├── D1:认证及业务主数据
├── Turnstile:注册/登录防滥用
└── 可选 KV:短链接跳转缓存
选择同域的主要原因:
- 前端请求 API 不需要配置 CORS。
- Better Auth 使用第一方 Cookie。
- 不需要配置跨子域 Cookie。
- 本地开发和生产行为更一致。
- 单次部署即可更新前端和后端。
- 对个人开发者维护成本最低。
Cloudflare 的 React 模板支持 SPA 路由回退,同时可将 /api/* 请求交给 Worker。静态 SPA 路由可以由资产层直接处理,不必每次进入 Worker。
2.2 暂不采用的设计
P0 阶段不采用:
- Next.js。
- SSR 或 React Server Components。
- 独立认证微服务。
- GraphQL。
- tRPC。
- Redis。
- KV 作为 Session 真相源。
- 多区域写数据库。
- 复杂事件溯源架构。
- Kubernetes 或常驻容器。
这些能力对普通 SaaS 管理后台和短网址系统不是必要条件。
3. 技术栈
3.1 前端
React
TypeScript
Vite
TanStack Router
TanStack Query
React Hook Form
Zod
Tailwind CSS
shadcn/ui
Better Auth React Client
职责:
- TanStack Router:页面路由和 URL 查询参数。
- TanStack Query:服务端数据缓存、分页、Mutation。
- React Hook Form:表单状态。
- Zod:前端表单校验和共享业务 Schema。
- Better Auth React Client:登录、注册、退出、Session。
- shadcn/ui:基础 UI 组件。
3.2 Worker 后端
Cloudflare Workers
Hono
Better Auth
Drizzle ORM
Cloudflare D1
Zod Validator
Hono RPC
Hono 基于标准 Request/Response,Cloudflare Bindings 可以从 c.env 读取;Hono RPC 可以根据服务端路由类型生成类型安全客户端。
3.3 安全及外围能力
Cloudflare Turnstile
Cloudflare WAF
Workers Secrets
可选 Durable Objects
可选 Cloudflare Queues
可选 Analytics Engine
4. 项目目录结构
推荐使用单应用结构,不必一开始使用 Monorepo:
project/
├── src/
│ ├── client/
│ │ ├── app/
│ │ │ ├── router.tsx
│ │ │ ├── query-client.ts
│ │ │ └── providers.tsx
│ │ ├── routes/
│ │ │ ├── __root.tsx
│ │ │ ├── index.tsx
│ │ │ ├── login.tsx
│ │ │ ├── register.tsx
│ │ │ └── dashboard/
│ │ │ ├── route.tsx
│ │ │ └── links.tsx
│ │ ├── features/
│ │ │ ├── auth/
│ │ │ ├── links/
│ │ │ └── account/
│ │ ├── components/
│ │ │ ├── ui/
│ │ │ └── layout/
│ │ ├── lib/
│ │ │ ├── auth-client.ts
│ │ │ ├── api-client.ts
│ │ │ └── errors.ts
│ │ ├── main.tsx
│ │ └── styles.css
│ │
│ ├── worker/
│ │ ├── index.ts
│ │ ├── app.ts
│ │ ├── env.ts
│ │ ├── auth/
│ │ │ ├── create-auth.ts
│ │ │ └── middleware.ts
│ │ ├── routes/
│ │ │ ├── health.ts
│ │ │ ├── links.ts
│ │ │ ├── account.ts
│ │ │ └── redirect.ts
│ │ ├── services/
│ │ │ └── link-service.ts
│ │ ├── repositories/
│ │ │ └── link-repository.ts
│ │ └── middleware/
│ │ ├── error-handler.ts
│ │ ├── request-id.ts
│ │ └── auth.ts
│ │
│ ├── db/
│ │ ├── client.ts
│ │ ├── schema/
│ │ │ ├── auth.ts
│ │ │ ├── links.ts
│ │ │ └── index.ts
│ │ └── relations.ts
│ │
│ └── shared/
│ ├── schemas/
│ ├── contracts/
│ ├── constants/
│ └── types/
│
├── drizzle/
│ ├── migrations/
│ └── meta/
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── public/
├── index.html
├── vite.config.ts
├── drizzle.config.ts
├── wrangler.jsonc
├── package.json
└── tsconfig.json
分层规则
Route
↓
Service
↓
Repository
↓
Drizzle / D1
禁止:
- React 组件直接拼接 API URL。
- Route 文件直接散落复杂 SQL。
- Repository 中编写权限判断。
- 前端通过隐藏按钮代替服务端权限检查。
- 业务代码直接访问 Better Auth 内部表。
5. Cloudflare 配置
5.1 wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "birdor-app",
"main": "./src/worker/index.ts",
"compatibility_date": "2026-07-13",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"not_found_handling": "single-page-application"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "birdor-app-production",
"database_id": "REPLACE_WITH_DATABASE_ID",
"migrations_dir": "drizzle/migrations"
}
],
"vars": {
"APP_ENV": "production",
"APP_URL": "https://example.com",
"BETTER_AUTH_URL": "https://example.com"
},
"observability": {
"enabled": true
}
}
Cloudflare Vite Plugin 会读取 Wrangler 配置,并在本地模拟 D1、KV、R2 等 Binding;默认本地数据保存在项目的 .wrangler/state 目录。
5.2 Secret
不得把以下内容写入 Git:
BETTER_AUTH_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
GITHUB_CLIENT_ID
GITHUB_CLIENT_SECRET
TURNSTILE_SECRET_KEY
生产环境设置:
npx wrangler secret put BETTER_AUTH_SECRET
npx wrangler secret put TURNSTILE_SECRET_KEY
本地开发放在:
.dev.vars
BETTER_AUTH_SECRET=local-development-secret
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
6. Vite 配置
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { cloudflare } from "@cloudflare/vite-plugin";
import path from "node:path";
export default defineConfig({
plugins: [react(), cloudflare()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
sourcemap: true,
},
});
Cloudflare Vite Plugin 支持构建 SPA、静态站、API Worker 和完整的全栈应用,并提供 vite preview 在 Workers Runtime 中验证构建产物。
7. 环境类型
// src/worker/env.ts
export interface Env {
DB: D1Database;
APP_ENV: "development" | "staging" | "production";
APP_URL: string;
BETTER_AUTH_URL: string;
BETTER_AUTH_SECRET: string;
TURNSTILE_SECRET_KEY: string;
GOOGLE_CLIENT_ID?: string;
GOOGLE_CLIENT_SECRET?: string;
}
生成 Cloudflare 类型:
npx wrangler types
应用代码不得散落使用 as unknown as Env。
8. Drizzle 与 D1
8.1 数据库客户端
// src/db/client.ts
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function createDb(database: D1Database) {
return drizzle(database, {
schema,
casing: "snake_case",
});
}
export type Database = ReturnType<typeof createDb>;
Drizzle 官方支持 Cloudflare D1 和 Workers Runtime,并使用 SQLite 方言。
8.2 Drizzle 配置
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema/index.ts",
out: "./drizzle/migrations",
dialect: "sqlite",
driver: "d1-http",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
strict: true,
verbose: true,
});
Drizzle Kit 可以生成迁移,也可以通过 D1 HTTP API 将迁移应用到远程数据库。生产环境应使用可审查的 migration 文件,而不是直接使用 push。
9. 数据库设计
9.1 Better Auth 表
Better Auth Schema 由 CLI 生成,不建议手写复制:
npx auth@latest generate
使用 Drizzle Adapter 时,Better Auth CLI 负责生成 Schema,实际迁移继续由 Drizzle 管理;auth migrate 主要用于内置 Kysely Adapter。
核心认证表通常包含:
user
session
account
verification
原则:
- 不直接在业务代码中操作
session。 - 不在业务表中复制用户邮箱。
- 所有用户外键引用
user.id。 - Better Auth 升级时先生成 Schema Diff,再检查 migration。
9.2 短网址业务表
// src/db/schema/links.ts
import {
index,
integer,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
export const links = sqliteTable(
"links",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
slug: text("slug").notNull(),
targetUrl: text("target_url").notNull(),
title: text("title"),
description: text("description"),
status: text("status", {
enum: ["active", "disabled", "expired"],
})
.notNull()
.default("active"),
redirectType: integer("redirect_type")
.notNull()
.default(302),
expiresAt: integer("expires_at", {
mode: "timestamp_ms",
}),
createdAt: integer("created_at", {
mode: "timestamp_ms",
})
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", {
mode: "timestamp_ms",
})
.notNull()
.$defaultFn(() => new Date()),
},
(table) => [
uniqueIndex("links_slug_unique").on(table.slug),
index("links_user_created_idx").on(
table.userId,
table.createdAt,
),
index("links_status_expires_idx").on(
table.status,
table.expiresAt,
),
],
);
9.3 点击统计
不要让每次短链接跳转同步更新 links.click_count:
访问请求
→ UPDATE links SET click_count = click_count + 1
这种方式会将高频访问变成数据库写热点。
P0 可以只记录轻量汇总;P1 建议:
跳转 Worker
├── 立即返回 Redirect
└── ctx.waitUntil()
└── Analytics Engine / Queue
业务主表保存配置和状态,点击事件交给分析型存储。
9.4 D1 使用约束
所有高频过滤字段必须建立索引,例如:
slug
user_id + created_at
status + expires_at
D1 的费用和额度会受到读取、写入行数影响;索引会增加一定写入,但通常能够显著减少查询扫描行数。
D1 还提供 Time Travel,可以恢复最近 30 天内任意分钟的数据状态,但重要业务仍建议定期导出备份。
10. Better Auth 配置
10.1 Auth Factory
D1 Binding 在请求时从 env 注入,因此创建 Factory:
// src/worker/auth/create-auth.ts
import { betterAuth } from "better-auth/minimal";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { captcha } from "better-auth/plugins";
import { createDb } from "@/db/client";
import * as schema from "@/db/schema";
import type { Env } from "../env";
export function createAuth(env: Env) {
const db = createDb(env.DB);
return betterAuth({
appName: "Birdor",
baseURL: env.BETTER_AUTH_URL,
basePath: "/api/auth",
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: "sqlite",
schema,
}),
trustedOrigins: [env.APP_URL],
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
freshAge: 60 * 10,
cookieCache: {
enabled: true,
maxAge: 60 * 3,
},
},
account: {
encryptOAuthTokens: true,
accountLinking: {
enabled: true,
trustedProviders: [
"google",
"github",
"email-password",
],
allowDifferentEmails: false,
},
},
socialProviders: {
...(env.GOOGLE_CLIENT_ID &&
env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
},
rateLimit: {
enabled: true,
storage: "database",
},
plugins: [
captcha({
provider: "cloudflare-turnstile",
secretKey: env.TURNSTILE_SECRET_KEY,
}),
],
});
}
export type Auth = ReturnType<typeof createAuth>;
Better Auth 官方提供独立 Drizzle Adapter,并建议使用 better-auth/minimal 减小使用 ORM Adapter 时的 Bundle。
Better Auth 的 trustedOrigins 用于声明受信任来源;Session Cookie Cache 可以短时间减少数据库 Session 查询。
10.2 为什么 Session 保存在 D1
推荐:
Session 真相源:D1
短期查询优化:签名 Cookie Cache
不推荐:
Session 真相源:Workers KV
原因是认证系统需要可靠的撤销、设备管理和账号禁用能力。P0 使用 D1 存储 Session,配合 3 分钟左右 Cookie Cache,能够平衡查询成本和撤销延迟。
11. Hono 应用
11.1 主应用
// src/worker/app.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { secureHeaders } from "hono/secure-headers";
import type { Env } from "./env";
import { createAuth } from "./auth/create-auth";
import { requestId } from "./middleware/request-id";
import { errorHandler } from "./middleware/error-handler";
import { linksRoute } from "./routes/links";
import { redirectRoute } from "./routes/redirect";
export type AppVariables = {
requestId: string;
};
export const app = new Hono<{
Bindings: Env;
Variables: AppVariables;
}>();
app.use("*", requestId);
app.use("*", logger());
app.use("*", secureHeaders());
app.on(["GET", "POST"], "/api/auth/*", (c) => {
const auth = createAuth(c.env);
return auth.handler(c.req.raw);
});
app.get("/health", (c) => {
return c.json({
status: "ok",
requestId: c.get("requestId"),
timestamp: new Date().toISOString(),
});
});
app.route("/api/v1/links", linksRoute);
app.route("/r", redirectRoute);
app.notFound((c) => {
return c.json(
{
error: {
code: "NOT_FOUND",
message: "Resource not found",
requestId: c.get("requestId"),
},
},
404,
);
});
app.onError(errorHandler);
export type AppType = typeof app;
Better Auth 在 Hono 中通过把原始请求传入 auth.handler(c.req.raw) 挂载。
11.2 Worker 入口
// src/worker/index.ts
import { app } from "./app";
export default app;
12. 鉴权中间件
// src/worker/auth/middleware.ts
import { createMiddleware } from "hono/factory";
import { createAuth, type Auth } from "./create-auth";
import type { Env } from "../env";
type Session = Auth["$Infer"]["Session"];
export type AuthVariables = {
user: Session["user"];
session: Session["session"];
};
export const requireAuth = createMiddleware<{
Bindings: Env;
Variables: AuthVariables;
}>(async (c, next) => {
const auth = createAuth(c.env);
const result = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!result) {
return c.json(
{
error: {
code: "UNAUTHORIZED",
message: "Authentication required",
},
},
401,
);
}
c.set("user", result.user);
c.set("session", result.session);
await next();
});
权限原则:
认证 Authentication
→ 确认用户身份
授权 Authorization
→ 确认用户能否操作某个资源
资源归属 Resource Ownership
→ 确认该 link.user_id 等于当前 user.id
不能只检查用户是否已登录。
13. API 设计规范
13.1 路径
GET /api/v1/links
POST /api/v1/links
GET /api/v1/links/:id
PATCH /api/v1/links/:id
DELETE /api/v1/links/:id
GET /r/:slug
Better Auth 保持独立命名空间:
/api/auth/*
13.2 成功响应
单对象:
{
"data": {
"id": "link_123",
"slug": "docs",
"targetUrl": "https://example.com/docs"
}
}
分页:
{
"data": [],
"page": {
"cursor": "next_cursor",
"hasMore": true
}
}
13.3 错误响应
{
"error": {
"code": "SLUG_ALREADY_EXISTS",
"message": "The requested slug is already in use",
"requestId": "req_123",
"details": {
"field": "slug"
}
}
}
错误码使用稳定的机器可读值:
VALIDATION_ERROR
UNAUTHORIZED
FORBIDDEN
NOT_FOUND
SLUG_ALREADY_EXISTS
RATE_LIMITED
INTERNAL_ERROR
不要让前端依赖英文错误文本判断业务逻辑。
14. Hono Route 示例
// src/worker/routes/links.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { requireAuth } from "../auth/middleware";
import { createDb } from "@/db/client";
import { links } from "@/db/schema";
import { and, desc, eq } from "drizzle-orm";
const createLinkSchema = z.object({
slug: z
.string()
.min(3)
.max(64)
.regex(/^[a-zA-Z0-9_-]+$/),
targetUrl: z.string().url(),
redirectType: z.union([
z.literal(301),
z.literal(302),
]).default(302),
});
export const linksRoute = new Hono()
.use("*", requireAuth)
.get("/", async (c) => {
const user = c.get("user");
const db = createDb(c.env.DB);
const result = await db
.select()
.from(links)
.where(eq(links.userId, user.id))
.orderBy(desc(links.createdAt))
.limit(50);
return c.json({
data: result,
});
})
.post(
"/",
zValidator("json", createLinkSchema),
async (c) => {
const user = c.get("user");
const input = c.req.valid("json");
const db = createDb(c.env.DB);
const id = crypto.randomUUID();
const now = new Date();
await db.insert(links).values({
id,
userId: user.id,
slug: input.slug,
targetUrl: input.targetUrl,
redirectType: input.redirectType,
createdAt: now,
updatedAt: now,
});
return c.json(
{
data: {
id,
...input,
},
},
201,
);
},
)
.delete("/:id", async (c) => {
const user = c.get("user");
const id = c.req.param("id");
const db = createDb(c.env.DB);
const result = await db
.delete(links)
.where(
and(
eq(links.id, id),
eq(links.userId, user.id),
),
)
.returning({ id: links.id });
if (result.length === 0) {
return c.json(
{
error: {
code: "NOT_FOUND",
message: "Link not found",
},
},
404,
);
}
return c.body(null, 204);
});
必须依赖数据库唯一索引保证 Slug 唯一性,不能只执行:
SELECT 是否存在
→ 如果不存在再 INSERT
因为并发请求可能同时通过检查。
15. 短链接跳转
// src/worker/routes/redirect.ts
import { Hono } from "hono";
import { and, eq, or, gt, isNull } from "drizzle-orm";
import { createDb } from "@/db/client";
import { links } from "@/db/schema";
export const redirectRoute = new Hono().get(
"/:slug",
async (c) => {
const slug = c.req.param("slug");
const db = createDb(c.env.DB);
const now = new Date();
const link = await db.query.links.findFirst({
where: and(
eq(links.slug, slug),
eq(links.status, "active"),
or(
isNull(links.expiresAt),
gt(links.expiresAt, now),
),
),
columns: {
targetUrl: true,
redirectType: true,
},
});
if (!link) {
return c.redirect("/link-not-found", 302);
}
return c.redirect(
link.targetUrl,
link.redirectType === 301 ? 301 : 302,
);
},
);
默认使用 302,只有目标地址确认长期固定时才使用 301。浏览器和 CDN 会积极缓存永久重定向,错误配置后更难快速修正。
16. React Auth Client
// src/client/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
同域部署时不需要指定 baseURL。Better Auth 官方客户端允许同域情况下省略该配置。
登录:
const result = await authClient.signIn.email({
email,
password,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
});
if (result.error) {
setError(result.error.message ?? "登录失败");
return;
}
Better Auth Captcha Plugin 支持 Cloudflare Turnstile,并通过 x-captcha-response 传递挑战 Token。
17. Hono RPC
17.1 导出 API 类型
// src/worker/app.ts
export type AppType = typeof app;
17.2 前端客户端
// src/client/lib/api-client.ts
import { hc } from "hono/client";
import type { AppType } from "@/worker/app";
export const api = hc<AppType>("/");
17.3 与 TanStack Query 集成
export function linksQueryOptions() {
return queryOptions({
queryKey: ["links"],
queryFn: async () => {
const response =
await api.api.v1.links.$get();
if (!response.ok) {
throw await toApiError(response);
}
return response.json();
},
});
}
注意:
- Hono RPC 适合同一 TypeScript 仓库。
- 对外公共 API 应额外维护 OpenAPI。
- 不要让前端引用 Worker 的运行时代码,只导入类型。
- Route 链式定义有助于保持准确的类型推导。
18. Turnstile 防滥用
Turnstile 应保护:
注册
登录失败次数较高后的登录
忘记密码
发送验证邮件
Magic Link
高风险匿名表单
客户端生成 Token 后,必须在服务端验证。Turnstile Token 有效期为 5 分钟且只能使用一次;只放置前端 Widget 而不执行服务端 Siteverify 不构成有效保护。
自动化测试使用 Cloudflare 提供的测试 Sitekey 和 Secret,不应在 E2E 中使用真实生产 Key。
19. 限流设计
P0
Better Auth:
rateLimit: {
enabled: true,
storage: "database",
}
业务 API 使用简单数据库窗口限流,只保护低频敏感接口:
创建链接
修改邮箱
发送验证邮件
密码重置
API Key 创建
Better Auth 支持使用数据库或 Secondary Storage 保存限流记录;使用 Drizzle 时应生成 Schema,再通过 ORM migration 应用。
P1
高流量时迁移到 Durable Objects:
key = userId + endpoint
value = 当前窗口计数
适用于需要强一致计数的接口。
不建议使用普通 KV 实现严格限流,因为限流需要可靠的原子更新语义。
20. 前端权限控制
React 路由可根据 Session 控制页面展示:
const session = authClient.useSession();
if (session.isPending) {
return <PageSkeleton />;
}
if (!session.data) {
return <Navigate to="/login" />;
}
但这只是用户体验层。
真正的权限检查必须发生在 Hono:
React 路由保护:防止错误页面展示
Hono requireAuth:阻止未登录请求
Service 权限检查:阻止越权操作
SQL user_id 条件:阻止跨用户数据访问
21. 邮件系统
Better Auth 负责:
- 生成验证 Token。
- 生成密码重置 Token。
- 验证 Token。
- 更新用户状态。
实际邮件发送接入:
Resend / Postmark / Amazon SES
邮件发送建议通过 Cloudflare Queue 异步执行:
注册请求
→ 创建用户
→ 写入邮件任务
→ 返回响应
→ Queue Consumer 发送邮件
需要避免在数据库事务已经失败时发送验证邮件。
22. 日志与可观测性
每个请求生成 Request ID:
crypto.randomUUID()
结构化日志字段:
{
"level": "info",
"event": "link.created",
"requestId": "req_xxx",
"userId": "user_xxx",
"linkId": "link_xxx",
"durationMs": 12
}
禁止记录:
用户密码
Session Token
Authorization Header
OAuth Access Token
Reset Token
完整 Cookie
Turnstile Token
关键指标:
请求数量
P50/P95/P99 延迟
5xx 比例
认证失败率
注册成功率
D1 查询失败率
Slug 冲突率
跳转成功率
邮件发送失败率
23. 数据库迁移流程
本地修改
npx auth@latest generate
npx drizzle-kit generate
检查生成的 SQL:
删除列?
重建表?
唯一索引是否可能失败?
是否存在历史脏数据?
Better Auth 插件是否新增表?
应用本地迁移:
npx wrangler d1 migrations apply DB --local
应用 Staging:
npx wrangler d1 migrations apply DB \
--remote \
--env staging
应用生产:
npx wrangler d1 migrations apply DB \
--remote
部署顺序:
1. 备份或确认 Time Travel 状态
2. 应用向后兼容 Migration
3. 部署 Worker
4. 执行冒烟测试
5. 后续版本再删除废弃字段
不要在同一次部署中直接执行破坏性字段重命名。使用 Expand–Migrate–Contract:
新增字段
→ 双写
→ 回填历史数据
→ 切换读取
→ 停止旧字段写入
→ 删除旧字段
24. 本地开发
安装
pnpm install
初始化本地数据库
pnpm db:migrate:local
启动
pnpm dev
推荐脚本
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"deploy": "pnpm build && wrangler deploy",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"test:e2e": "playwright test",
"auth:generate": "auth generate",
"db:generate": "drizzle-kit generate",
"db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:remote": "wrangler d1 migrations apply DB --remote",
"cf:typegen": "wrangler types"
}
}
25. 测试策略
25.1 单元测试
覆盖:
Slug 校验
URL 校验
权限规则
过期时间判断
错误映射
Service 层逻辑
25.2 Worker 集成测试
使用:
Vitest
@cloudflare/vitest-pool-workers
本地 D1 Binding
覆盖:
POST /api/v1/links
未登录返回 401
跨用户资源返回 404 或 403
重复 Slug 返回 409
过期链接不跳转
禁用链接不跳转
Cloudflare 的测试工具支持 Workers Runtime 和 Binding 环境。
25.3 E2E
使用 Playwright:
注册
邮箱验证模拟
登录
创建短链接
访问短链接
修改短链接
退出
Session 失效
Turnstile 使用官方测试 Key,确保测试可预测。
26. CI/CD
Pull Request
pnpm install --frozen-lockfile
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm test:e2e
Staging
应用 Staging Migration
部署 Staging Worker
执行冒烟测试
Production
人工批准
检查 Migration SQL
应用生产 Migration
部署 Worker
检查 /health
检查登录
检查创建链接
检查跳转
回滚原则
应用代码可以回滚 Worker Deployment。
数据库不能依赖简单的“反向 Migration”:
- 优先使用向前修复。
- 保留字段兼容。
- 严重数据问题使用 D1 Time Travel。
- 恢复前先确认恢复点和影响范围。
27. 安全基线
必须实现:
- 全站 HTTPS。
- Better Auth Secret 使用高强度随机值。
- Secret 只保存在 Workers Secrets。
- Email Verification。
- Turnstile 服务端验证。
- 登录和密码重置限流。
- OAuth Token 加密存储。
- Session Cookie 使用 HttpOnly、Secure。
- 严格配置
trustedOrigins。 - 每个业务查询包含资源归属条件。
- 对外 URL 只允许
http:和https:。 - 禁止
javascript:、data:和危险 Scheme。 - 管理接口单独检查角色。
- 不在日志记录 Token 和 Cookie。
- 数据导出与账号删除流程。
- 依赖版本固定,不使用
*。 - Better Auth 升级必须同时检查 Schema Migration。
建议增加:
Content-Security-Policy
X-Content-Type-Options
Referrer-Policy
Permissions-Policy
28. 可扩展架构
P1:KV 跳转缓存
slug
→ KV 查询
→ 未命中查 D1
→ 回填 KV
→ Redirect
D1 仍然是主数据源。
更新链接时:
更新 D1
→ 删除或覆盖 KV
缓存内容只保存跳转需要的最小字段:
{
"url": "https://example.com",
"type": 302,
"expiresAt": null,
"status": "active"
}
P1:Analytics Engine
将点击事件从 D1 中分离:
timestamp
slug
country
referer
device
browser
campaign
P2:Organization
需要团队 SaaS 时启用 Better Auth Organization Plugin:
Organization
├── Owner
├── Admin
└── Member
Better Auth Organization Plugin 支持组织、成员、角色和团队管理。
此时业务表改为:
links.organization_id
domains.organization_id
api_keys.organization_id
不要同时保留含义冲突的:
user_id
organization_id
workspace_id
team_id
应选定统一租户边界。
P2:API Key
开放公共 API 时启用 Better Auth API Key Plugin。该插件支持用户级或组织级 API Key,以及过期、前缀和权限配置。
P3:拆分 Worker
流量增长后:
Web/API Worker
├── React Assets
├── Better Auth
└── 管理 API
Redirect Worker
├── /:slug
├── KV
└── Analytics Engine
两个 Worker 通过:
- Service Binding。
- 共享 D1。
- 共享 KV。
进行内部通信。
29. PostgreSQL 迁移预留
D1 适合项目初期和中等规模 SaaS,但业务模型应避免过度绑定 SQLite 特有行为。
设计要求:
- 主键使用字符串 ID。
- 时间统一使用 UTC。
- 不依赖隐式类型转换。
- 不使用 SQLite 特有的复杂触发器作为核心业务。
- Repository 隔离数据库查询。
- Migration 文件进入版本控制。
- API 层不暴露数据库字段命名。
- JSON 字段只用于低查询需求数据。
未来可以替换为:
Cloudflare Hyperdrive
+
PostgreSQL
+
Drizzle PostgreSQL Driver
前端、Hono Route、Better Auth Client 和多数 Service 层无需变化。
30. 实施阶段
P0:项目骨架
交付:
- React + Vite。
- Cloudflare Vite Plugin。
- Hono Worker。
- D1 Binding。
- Drizzle Schema。
/health。- CI 基础检查。
验收:
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm preview
P1:认证系统
交付:
- Better Auth。
- 注册。
- 邮箱密码登录。
- Session。
- 退出。
- Email Verification。
- Turnstile。
- 登录保护中间件。
验收:
- 未登录访问私有 API 返回 401。
- 注册表单需要有效 Turnstile。
- 登录后可以读取 Session。
- 退出后 Session 不再可用。
- 其他用户不能访问当前用户数据。
P2:核心业务
交付:
- 创建链接。
- 查询列表。
- 修改链接。
- 删除链接。
- Slug 唯一约束。
- 跳转。
- 过期和禁用状态。
验收:
- 重复 Slug 返回 409。
- 过期链接不跳转。
- 被禁用链接不跳转。
- 删除操作包含
user_id条件。 - 非 HTTP/HTTPS URL 被拒绝。
P3:生产化
交付:
- OAuth。
- 邮件队列。
- 结构化日志。
- Staging 环境。
- E2E。
- Migration 审查流程。
- 安全 Headers。
- 错误监控。
P4:增长能力
交付:
- KV 跳转缓存。
- Analytics Engine。
- Organization。
- API Key。
- 自定义域名。
- 额度和套餐。
31. 最终推荐架构
Browser
│
├── React + TanStack Router
├── TanStack Query
├── Better Auth React Client
└── Hono RPC Client
│
▼
Cloudflare Worker
│
├── Static Assets
├── Hono
│ ├── /api/auth/* → Better Auth
│ ├── /api/v1/* → Business API
│ └── /r/:slug → Redirect
│
├── Drizzle
│ └── D1
│ ├── Better Auth tables
│ └── Business tables
│
├── Turnstile
└── Optional
├── KV
├── Queues
├── Durable Objects
└── Analytics Engine
这套方案的核心原则是:
- Better Auth 是唯一认证所有者。
- D1 是认证和业务数据真相源。
- Hono 是唯一服务端 HTTP 入口。
- React 只负责 UI,不承载安全边界。
- Drizzle 是唯一数据库访问层。
- 同域优先,避免跨域认证复杂度。
- 先使用单 Worker,达到明确瓶颈后再拆分。
- 点击分析与业务主数据分离。
- 数据库唯一约束替代应用层并发假设。
- 每个受保护查询都必须检查租户或用户归属。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。