# 实战：Fresh 2 + PostgreSQL + Drizzle 博客

本教程把 [项目蓝图 1](/docs/projects) 展开为可执行步骤。基础路由、Island 与中间件语法见 [Fresh 2 全栈开发](/docs/web/fresh)，本文只写博客特有的部分。

## 目标与最终形态

```text
fresh-blog/
├── routes/posts/[slug].tsx      # 文章详情（服务端渲染）
├── routes/index.tsx             # 文章列表
├── routes/admin/posts.tsx       # 受保护的创建页 + POST
├── routes/admin/_middleware.ts  # admin 区域访问控制
├── routes/admin/login.tsx       # 登录表单（写入 cookie）
├── islands/PostEditor.tsx       # 唯一需要 hydration 的组件
├── components/
├── src/db/{client,schema}.ts    # Drizzle 连接与表结构
├── drizzle.config.ts
└── deno.json
```

阶段一交付：列表、详情、受保护的创建。阶段二再加 Markdown 渲染、草稿、图片与搜索。创建表单用原生 `<form method="post">` 提交，无 JavaScript 时全站可读可写。

## 初始化

```bash
deno run -Ar jsr:@fresh/init
cd fresh-blog
deno install npm:drizzle-orm npm:drizzle-kit npm:pg npm:@types/pg
```

本地 PostgreSQL 用 Docker 起一个即可，连接串写入 `.env`：

```bash
docker run --name blog-pg -e POSTGRES_PASSWORD=dev-only -p 5432:5432 -d postgres
```

```bash title=".env"
DATABASE_URL=postgresql://postgres:dev-only@localhost:5432/postgres
```

<Callout type="warn" title="Drizzle 需要 node_modules">
drizzle-kit 在 Deno 下运行时要加 `--node-modules-dir`（官方 Deno + Drizzle 教程的做法），生成和迁移命令见下面的 task 定义。
</Callout>

## 里程碑一：列表、详情、受保护创建

### 表结构与迁移

```ts title="src/db/schema.ts"
import { boolean, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";

export const posts = pgTable("posts", {
  id: serial().primaryKey(),
  slug: text().notNull().unique(),
  title: text().notNull(),
  body: text().notNull(),
  published: boolean().notNull().default(false),
  createdAt: timestamp().notNull().defaultNow(),
});
```

`slug` 上的 `unique()` 是验收点之一：重复 slug 必须在数据库层被拒绝，而不是靠应用层查重。

```ts title="src/db/client.ts"
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({ connectionString: Deno.env.get("DATABASE_URL") });
export const db = drizzle(pool);
```

```ts title="drizzle.config.ts"
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  out: "./drizzle",
  schema: "./src/db/schema.ts",
  dialect: "postgresql",
  dbCredentials: { url: Deno.env.get("DATABASE_URL")! },
});
```

在 `deno.json` 的 `tasks` 中追加（模板自带的 `dev`、`check` 保持不变）：

```json title="deno.json（节选）"
{
  "tasks": {
    "db:generate": "deno run -A --node-modules-dir npm:drizzle-kit generate",
    "db:migrate": "deno run -A --env --node-modules-dir npm:drizzle-kit migrate"
  }
}
```

`-A` 只授给本地受信的迁移工具（drizzle-kit 需要读配置、连数据库、写 SQL 文件），不是应用运行时权限；线上进程的权限单独按最小集合配置。

`--env` 让 Deno 加载 `.env`。先 `deno task db:generate` 产出 `drizzle/` 下的 SQL，审查后再 `deno task db:migrate` 应用。

### 数据访问函数

把查询写成普通函数，路由和测试共用：

```ts title="src/db/posts.ts"
import { desc, eq } from "drizzle-orm";
import { db } from "./client.ts";
import { posts } from "./schema.ts";

export function listPublishedPosts() {
  return db.select().from(posts)
    .where(eq(posts.published, true))
    .orderBy(desc(posts.createdAt));
}

export function getPostBySlug(slug: string) {
  return db.select().from(posts).where(eq(posts.slug, slug)).limit(1);
}

export async function createPost(input: { slug: string; title: string; body: string }) {
  // 阶段一提交即发布；草稿流程在里程碑二引入
  await db.insert(posts).values({ ...input, published: true });
}
```

### 路由

```tsx title="routes/posts/[slug].tsx"
import { define } from "@/utils.ts";
import { HttpError } from "fresh";
import { getPostBySlug } from "@/src/db/posts.ts";

export default define.page(async (ctx) => {
  const [post] = await getPostBySlug(ctx.params.slug);
  if (!post || !post.published) {
    // 交给 Fresh 的错误处理，响应状态是真正的 404，而不是 200 的“不存在”页面
    throw new HttpError(404);
  }
  return (
    <main>
      <h1>{post.title}</h1>
      <article>{post.body}</article>
    </main>
  );
});
```

列表页 `routes/index.tsx` 同样用 `define.page` 调 `listPublishedPosts()` 渲染 `<ul>`。两者都是服务端组件，浏览器不收到对应 JavaScript。

创建页用原生表单加 POST handler，提交成功后 303 跳转：

```tsx title="routes/admin/posts.tsx"
import { define } from "@/utils.ts";
import { createPost } from "@/src/db/posts.ts";

export const handlers = define.handlers({
  async POST(ctx) {
    const form = await ctx.req.formData();
    const slug = form.get("slug")?.toString() ?? "";
    const title = form.get("title")?.toString() ?? "";
    const body = form.get("body")?.toString() ?? "";
    if (!/^[a-z0-9-]+$/.test(slug) || !title || !body) {
      return new Response("invalid input", { status: 400 });
    }
    try {
      await createPost({ slug, title, body });
    } catch (err) {
      // 只把唯一约束冲突（SQLSTATE 23505）映射为 409，连接失败等按 500 处理
      if (err && typeof err === "object" && "code" in err && err.code === "23505") {
        return new Response("slug already exists", { status: 409 });
      }
      console.error("createPost failed", err);
      return new Response("internal error", { status: 500 });
    }
    return new Response(null, {
      status: 303,
      headers: { location: `/posts/${slug}` },
    });
  },
});

export default define.page<typeof handlers>(function NewPost() {
  return (
    <main>
      <h1>新文章</h1>
      <form method="post">
        <input name="slug" required pattern="[a-z0-9-]+" />
        <input name="title" required />
        <textarea name="body" required />
        <button type="submit">发布</button>
      </form>
    </main>
  );
});
```

### admin 区域保护

子目录的 `_middleware.ts` 只作用于该子目录的路由：

浏览器表单无法附加 `Authorization` 头，所以中间件同时接受 cookie（浏览器登录后）和 Bearer（API/curl）：

```ts title="routes/admin/_middleware.ts"
import { define } from "@/utils.ts";
import { getCookies } from "jsr:@std/http/cookie";

const token = Deno.env.get("ADMIN_TOKEN");

export default define.middleware((ctx) => {
  if (new URL(ctx.req.url).pathname === "/admin/login") return ctx.next();
  const bearer = !!token && ctx.req.headers.get("authorization") === `Bearer ${token}`;
  const cookie = !!token && getCookies(ctx.req.headers)["admin_token"] === token;
  if (!bearer && !cookie) {
    return new Response("Unauthorized", { status: 401 });
  }
  return ctx.next();
});
```

登录页校验口令并写入 `HttpOnly; SameSite=Lax` cookie——SameSite=Lax 下跨站 POST 不携带 cookie，是这个占位方案的 CSRF 底线：

```tsx title="routes/admin/login.tsx"
import { define } from "@/utils.ts";

export const handlers = define.handlers({
  async POST(ctx) {
    const form = await ctx.req.formData();
    const token = Deno.env.get("ADMIN_TOKEN");
    if (!token || form.get("token") !== token) {
      return new Response("Unauthorized", { status: 401 });
    }
    return new Response(null, {
      status: 303,
      headers: {
        location: "/admin/posts",
        "set-cookie": `admin_token=${token}; Path=/admin; HttpOnly; SameSite=Lax`,
      },
    });
  },
});

export default define.page<typeof handlers>(function Login() {
  return (
    <main>
      <h1>管理员登录</h1>
      <form method="post">
        <input name="token" type="password" required />
        <button type="submit">登录</button>
      </form>
    </main>
  );
});
```

<Callout type="warn" title="这只是最小占位认证">
共享口令 + cookie 只适合单人项目起步。上线前换成真正的 session 方案（官方文档 Examples 一节有 Session management 示例）与正式 CSRF 防护——Fresh 官方插件列表里有 csrf 插件。
</Callout>

## 里程碑二：Markdown、草稿、图片、搜索

- **Markdown**：渲染逻辑放在服务端组件里做（官方 Examples 有 Rendering Markdown 示例），不要为渲染建 island。
- **草稿**：`published` 字段已在 schema 中；列表和详情用 `where(eq(posts.published, true))` 过滤，admin 增加发布/下线操作。
- **图片**：小站先把静态图放 `static/`；用户上传走单独的受保护 endpoint，校验 MIME 与大小（参考 [图片处理 API 教程](/docs/projects/image-api) 的校验思路）。
- **搜索**：`routes/search.tsx` 读取 `ctx.url` 查询参数，用 Drizzle 的 `ilike` 做标题/正文匹配即可起步；量大后再换 PostgreSQL 全文索引。
- **编辑器**：只有 `islands/PostEditor.tsx`（预览、快捷键等）需要 hydration。文章正文、导航、列表全部保持服务端组件。

## 测试策略

- **数据访问层**：对独立的测试数据库跑 `deno test`，覆盖 slug 唯一约束冲突、草稿过滤。
- **handler**：Fresh 官方测试方式是把路由 handler 挂到内存 `App` 上，用 Web `Request` 驱动：

```ts title="routes/admin/posts_test.ts"
import { App } from "fresh";
import { handlers } from "./posts.tsx";

Deno.test("POST rejects invalid slug", async () => {
  const handler = new App().post("/admin/posts", handlers.POST).handler();
  const form = new FormData();
  form.set("slug", "Bad Slug!");
  form.set("title", "t");
  form.set("body", "b");
  const res = await handler(
    new Request("http://localhost/admin/posts", { method: "POST", body: form }),
  );
  if (res.status !== 400) throw new Error(`expected 400, got ${res.status}`);
});
```

- **无 JS 验收**：禁用浏览器 JavaScript（或直接 `curl`）走一遍列表 → 详情 → 创建流程。

## 部署

Fresh 可部署到新 Deno Deploy：关联 PostgreSQL 后，平台会注入 `DATABASE_URL`、`PGHOST` 等变量，迁移命令可配置为 pre-deploy command，在 revision 接流量前运行——这正是"迁移是独立发布步骤"的落点。细节见 [数据库、Cron 与 Timelines](/docs/deploy/data-and-cron)。部署前运行 `deno task check` 与全部测试。

## 验收清单

<Checklist id="fresh-blog-acceptance" items={[
  "禁用 JavaScript 后文章列表与详情可读、创建表单可提交",
  "重复 slug 被数据库唯一约束拒绝并返回 409",
  "除 PostEditor island 外没有组件向浏览器发送 JavaScript",
  "drizzle-kit generate 的 SQL 经过审查，migrate 在独立发布步骤运行",
  "admin 路由未授权访问返回 401",
  "deno fmt --check、deno lint、deno task check、deno test 全部通过"
]} />

官方参考：[Fresh Getting started](https://usefresh.dev/docs/getting-started)、[Fresh Forms](https://usefresh.dev/docs/advanced/forms)、[Fresh Testing](https://usefresh.dev/docs/testing)、[Deno + Drizzle 教程](https://docs.deno.com/examples/tutorials/drizzle/)、[Drizzle PostgreSQL](https://orm.drizzle.team/docs/get-started/postgresql-new)。
