React + Hono + Better Auth 全栈技术方案:构建 Cloudflare 原生 SaaS 应用

深入讲解基于 React、Hono、Better Auth、Drizzle ORM 和 Cloudflare D1 的全栈 SaaS 技术架构,涵盖认证授权、数据库设计、类型安全 API、Workers 部署、安全防护、测试与分阶段实施方案。

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

这套方案的核心原则是:

  1. Better Auth 是唯一认证所有者。
  2. D1 是认证和业务数据真相源。
  3. Hono 是唯一服务端 HTTP 入口。
  4. React 只负责 UI,不承载安全边界。
  5. Drizzle 是唯一数据库访问层。
  6. 同域优先,避免跨域认证复杂度。
  7. 先使用单 Worker,达到明确瓶颈后再拆分。
  8. 点击分析与业务主数据分离。
  9. 数据库唯一约束替代应用层并发假设。
  10. 每个受保护查询都必须检查租户或用户归属。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页