# 个人博客 · v0.1 一个使用 Next.js + PostgreSQL 构建的个人博客,支持 Markdown / MDX 写作、代码高亮、 多语言路由,并带有一套完整的文章 CRUD REST 接口。 > 设计规范见 [`DESIGN.md`](./DESIGN.md),第三方库文档见 [`文档地址.md`](./文档地址.md), > 功能范围见 [`v0.1功能文档.md`](./v0.1功能文档.md)。 --- ## 快速开始 ```bash pnpm install # 1. 配置数据库连接(见下方「环境变量」) # 2. 建表 pnpm db:migrate # 3. 导入示例文章(把 content/posts/ 下的 Markdown 写入数据库) pnpm seed pnpm dev # 开发服务器 http://localhost:3000 ``` 生产构建与本地预览: ```bash pnpm build # 构建(含类型检查) pnpm start # 启动生产服务器 pnpm lint # ESLint pnpm typecheck # 仅类型检查 ``` > 首次访问 `/` 会 307 重定向到默认语言(`/zh`),这是 next-intl 的正常行为。 --- ## 环境变量 在 `.env.local` 中配置: | 变量 | 必填 | 说明 | | --- | --- | --- | | `DATABASE_URL` | 是 | PostgreSQL 连接串。本地通常为 `postgresql://user:pass@localhost:5432/blog?sslmode=disable`;托管库(Neon / Supabase 等)用其提供的连接串(一般带 `?sslmode=require`)。 | | `NEXT_PUBLIC_SITE_URL` | 是 | 站点公开地址,用于 `sitemap.xml`、`robots.txt`、canonical 与 Open Graph 的绝对 URL。部署后改成正式域名即可,无需改代码。 | | `DATABASE_POOL_MAX` | 否 | 连接池上限,默认 `10`。Serverless 环境建议调小。 | | `DATABASE_STATEMENT_TIMEOUT_MS` | 否 | 单条 SQL 超时,默认 `15000`。 | > **SSL 说明**:本地 Postgres 默认不启用 SSL,若连接串写了 `sslmode=require` > 会报 `The server does not support SSL connections`。用 `pnpm db:diagnose` > 可以自动判断该用哪种配置。 `.env.local` 已加入 `.gitignore`,不会被提交。 --- ## 数据库 ### 为什么用 `pg` 而不是 `@neondatabase/serverless` 脚手架原本使用 `drizzle-orm/neon-http` + `@neondatabase/serverless`。 该驱动走的是 **Neon 的 HTTP 代理端点**,不是 PostgreSQL 的 TCP 协议, 因此**无法连接本地或自建的普通 Postgres**(实测报 `fetch failed`)。 本项目改用 `pg`(node-postgres)连接池,既能连本地 Postgres, 也能连 Neon / Supabase / Railway 等任何标准 Postgres—— 部署到 Neon 时只需使用其 TCP 连接串即可,代码无需改动。 ### 数据模型 单表 `posts`: | 列 | 类型 | 说明 | | --- | --- | --- | | `id` | serial | 主键 | | `slug` | varchar(200) | URL 标识,**唯一**,格式 `^[a-z0-9]+(-[a-z0-9]+)*$` | | `title` | text | 标题 | | `summary` | text | 摘要(卡片与 SEO description) | | `content` | text | Markdown / MDX 正文 | | `tags` | text[] | 标签数组,默认空数组 | | `status` | varchar(20) | `draft` \| `published` | | `reading_minutes` | integer | 阅读时长,写入时计算并缓存 | | `cover` | text | 封面图路径 | | `description` | text | 覆盖默认 SEO 描述 | | `published_at` | timestamptz | 发布时间;草稿为 `null` | | `created_at` / `updated_at` | timestamptz | 时间戳 | **索引** | 索引 | 用途 | | --- | --- | | `posts_slug_unique_idx` | slug 唯一性 + 按 slug 查询 | | `posts_status_published_at_idx` | 列表页主查询:按状态过滤 + 按发布时间倒序 | | `posts_tags_gin_idx` | 标签筛选(GIN 支持 `tags @> ARRAY['x']`) | **约束**(写在迁移 SQL 中,Drizzle 的 schema 回调不生成 CHECK 约束) - `posts_status_check`:`status IN ('draft', 'published')` - `posts_slug_format_check`:slug 必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$` > 约束同时存在于数据库与应用层(Zod)是有意为之:应用层给出友好的字段级报错, > 数据库层保证任何写入路径(脚本、手工 SQL)都无法绕过。 ### 常用命令 ```bash pnpm db:check # 探测连通性并列出表与行数 pnpm db:diagnose # 诊断驱动 / SSL 配置是否正确 pnpm db:generate # 由 schema.ts 生成迁移 SQL pnpm db:migrate # 执行迁移 pnpm db:verify # 校验约束与索引是否真的生效(含"坏写入"测试) pnpm db:studio # Drizzle Studio 可视化查看数据 pnpm seed # 把 content/posts/*.mdx 导入数据库(幂等 upsert) ``` --- ## API 所有接口遵循统一响应格式: ```jsonc // 成功 { "data": ..., "meta": { ... } } // 失败 { "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [{ "field": "slug", "message": "..." }] } } ``` 错误码:`VALIDATION_ERROR`(400) · `BAD_REQUEST`(400) · `NOT_FOUND`(404) · `CONFLICT`(409) · `METHOD_NOT_ALLOWED`(405) · `INTERNAL_ERROR`(500) · `SERVICE_UNAVAILABLE`(503) ### `GET /api/posts` — 文章列表 | 参数 | 默认 | 说明 | | --- | --- | --- | | `limit` | 20 | 1–100 | | `offset` | 0 | 偏移量 | | `tag` | — | 按标签过滤 | | `status` | — | `draft` \| `published` | | `includeDrafts` | `false` | 是否包含草稿 | ```bash curl "http://localhost:3000/api/posts?limit=5" curl "http://localhost:3000/api/posts?tag=Next.js" ``` 返回 `meta: { total, limit, offset, hasMore }`。**默认只返回已发布文章。** ### `POST /api/posts` — 新建文章 ```bash curl -X POST http://localhost:3000/api/posts \ -H "content-type: application/json" \ -d '{ "slug": "hello-world", "title": "你好世界", "summary": "第一篇文章", "content": "## 正文\n\n支持 **Markdown**。", "tags": ["随笔"], "status": "published" }' ``` 必填:`slug` / `title` / `summary` / `content`;其余可选。 `status` 默认 `draft`。返回 **201**。 ### `GET /api/posts/[slug]` — 文章详情 含正文。草稿与不存在的 slug 返回 **404**。传 `?includeDrafts=true` 可读草稿。 ### `PATCH /api/posts/[slug]` — 更新文章 部分更新,只传要改的字段。也支持 `PUT`(同义)。 - 改 `slug` 时会校验新 slug 未被占用 - 改 `content` 会自动重算 `reading_minutes` - `draft → published` 自动补 `published_at`;`published → draft` 清空它 - 空 body `{}` 返回 **400** ### `DELETE /api/posts/[slug]` — 删除文章 成功返回 **204**,不存在返回 **404**。 ### `GET /api/tags` — 标签聚合 返回全部已发布文章的标签及数量,按数量倒序。 ### `GET /api/health` — 健康检查 返回数据库连通性与延迟;数据库不可用时返回 **503**,便于监控直接判定不健康。 --- ## 项目结构 ``` app/ ├── layout.tsx # 根布局: /
+ 全局字体变量 ├── globals.css # 设计令牌(@theme)+ 基础层 + .prose-blog 排版 ├── not-found.tsx # 全局 404(无语言前缀时兜底) ├── robots.ts # 动态生成 robots.txt ├── sitemap.ts # 动态生成 sitemap.xml ├── api/ │ ├── health/route.ts # GET 健康检查 │ ├── posts/route.ts # GET 列表 / POST 新建 │ ├── posts/[slug]/route.ts # GET 详情 / PATCH·PUT 更新 / DELETE 删除 │ └── tags/route.ts # GET 标签聚合 └── [locale]/ # 语言段:en / zh / ja ├── layout.tsx # i18n Provider + 页头页脚外壳 ├── page.tsx # 首页:Hero + 文章列表 ├── error.tsx # 错误边界 ├── not-found.tsx # 语言段内 404(带站点外壳) ├── about/page.tsx # 关于页 └── posts/[slug]/page.tsx # 文章详情页 components/ ├── ui/ # 基础组件:Button / Card / Badge / Container / Section ├── layout/ # 站点壳:SiteHeader / SiteFooter / LocaleSwitcher ├── home/hero.tsx # 首页 Hero(氛围渐变) ├── post/ # 文章卡片、目录 ├── mdx/ # MDX 元素映射:CodeBlock / Callout └── motion/ # 动画:Reveal / MotionItem / ReadingProgress lib/ ├── api/response.ts # 统一响应格式、错误码、错误包装器 ├── db/ │ ├── index.ts # pg 连接池 + 健康检查(server-only) │ └── schema.ts # Drizzle 表定义 ├── validation/post.ts # Zod 校验契约(API 与脚本共用) ├── posts/ │ ├── repository.ts # 仓储层:分页 / 筛选 / 排序 / 草稿过滤 │ ├── index.ts # 页面使用的门面(server-only) │ └── types.ts # 领域类型 ├── fonts.ts # 字体配置(next/font 自托管) ├── site-config.ts # 站点配置(单一事实来源) ├── mdx.ts # MDX 编译管线(remark + rehype + Shiki) ├── toc.ts # 从 MDX 提取目录 └── utils.ts # cn / 日期格式化 / URL 拼接 content/posts/ # 文章导入源(.mdx,通过 pnpm seed 写入数据库) drizzle/migrations/ # 数据库迁移 SQL messages/ # i18n 文案(en / zh / ja) scripts/ # 开发期验证脚本(见下文) proxy.ts # 语言路由中间件(Next.js 16 新约定) ``` --- ## 写作方式 内容已迁移到 **PostgreSQL**,有两种写入方式: ### 方式一:调用 API(推荐) 见上文 [API](#api) 一节的 `POST /api/posts` / `PATCH /api/posts/[slug]`。 ### 方式二:写 Markdown 文件后导入 在 `content/posts/` 下新增 `.mdx` 文件(**文件名即 slug**),然后运行 `pnpm seed` 导入数据库。该命令是**幂等 upsert**:已存在的 slug 会被更新, 不会产生重复数据。 ```mdx --- title: "文章标题" date: "2025-03-08" summary: "一句话摘要,用于卡片与 SEO description。" tags: ["Next.js", "性能"] draft: false --- 正文使用 Markdown 编写,支持 GFM 表格、任务列表与删除线。 ``` ### frontmatter 字段 | 字段 | 必填 | 类型 | 对应数据库列 | | --- | --- | --- | --- | | `title` | 是 | string | `title` | | `date` | 是 | string(`YYYY-MM-DD`) | `published_at` | | `summary` | 是 | string | `summary` | | `tags` | 否 | string[] | `tags` | | `draft` | 否 | boolean | `status`(`true` → `draft`) | | `cover` | 否 | string | `cover` | | `description` | 否 | string | `description` | **校验分两层**:`pnpm seed` 导入前会校验 frontmatter 并指出具体文件与字段; 数据库层还有 CHECK 约束兜底。API 写入则由 Zod 校验。 > `content/posts/` 现在只是**导入源**,不再是运行时内容源。 > 页面的数据全部来自数据库。 ### 草稿机制 `status = 'draft'` 的文章: - 不出现在首页列表与 `GET /api/posts`(除非显式传 `includeDrafts=true`) - 不出现在 `sitemap.xml` - 直接访问其 URL 返回**真正的 HTTP 404** - `GET /api/posts/[slug]` 同样返回 404;需传 `?includeDrafts=true` 才能读到 > **实现要点**:详情页必须**不使用路由级 `loading.tsx`**。 > 路由级 `loading.tsx` 会创建 Suspense 边界,Next.js 会立即以 **200** 状态 > 流式输出骨架屏,之后页面内的 `notFound()` 只能替换内容、无法再改写状态码, > 结果就是「草稿/不存在的文章返回 200、内容却是 404 页」—— > 搜索引擎会把这些 URL 当作有效页面收录。 > 详见 `components/post/post-skeleton.tsx` 的注释。 ### 在正文中使用组件 MDX 中可直接使用内置组件(正文存在数据库里,同样会被编译): ```mdx