# 个人博客 · 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 支持 info / tip / warning / danger 四种类型。 ``` --- ## 架构要点 ### 字体:构建期自托管 中文 Noto Sans SC、英文 Inter、代码 IBM Plex Mono,全部通过 `next/font/google` 在**构建期下载并自托管**(输出到 `.next/static/media`),运行时不会向 Google 发请求, 因此没有第三方请求、没有 FOUT,也符合隐私要求。 每个字体都显式配置了 `fallback` 回退栈与 `adjustFontFallback`: | 字体 | 角色 | 回退栈 | | --- | --- | --- | | Inter | 英文 / UI | `-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Arial, sans-serif` | | Noto Sans SC | 中文 | `PingFang SC, Hiragino Sans GB, Microsoft YaHei, Source Han Sans SC, WenQuanYi Micro Hei, sans-serif` | | IBM Plex Mono | 代码 | `SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, Courier New, monospace` | > Noto Sans SC 在 Google Fonts 上按权重分片(非可变字体),因此显式声明 4 个权重, > 由 next/font 生成 `unicode-range` 分片,浏览器只下载实际用到的子集。 > 因其体积较大,设置了 `preload: false`,避免抢占首屏关键资源。 ### 设计令牌:单一事实来源 `DESIGN.md` 中的 `{colors.*}` / `{rounded.*}` / `{spacing.*}` 全部映射为 `app/globals.css` 里 `@theme` 声明的 CSS 变量,因此 Tailwind 工具类 (`bg-canvas`、`rounded-lg`、`p-xl`)与手写 CSS 读取的是**同一份变量**。 ### 渲染策略 | 页面 | 渲染方式 | | --- | --- | | 首页 / 关于页 | SSG(`generateStaticParams` 预生成 3 种语言) | | 文章详情页 | SSG(构建期编译 MDX + Shiki 高亮 + 提取目录) | | sitemap / robots | 构建期静态生成 | | `/[locale]/posts/[slug]` | `dynamicParams = false`,未预生成的 slug 返回 404 | 文章正文与代码高亮结果都是静态 HTML,运行时零额外渲染开销。 ### 动画的渐进增强 `components/motion/` 下的动画**没有**使用 motion 的 `whileInView`。 它在服务端会输出 `opacity: 0`,只有 JS 执行且元素滚入视口后才显示—— 一旦 JS 加载失败,文章列表会**永久不可见**,搜索引擎抓到的也是空内容。 改为渐进增强策略:服务端输出完全可见的 HTML,客户端挂载后, 仅当元素位于视口下方时才叠加一次过渡动画。**可读性是默认状态,动画是叠加效果。** --- ## 验证脚本 ### 一次性运行全部测试 ```bash pnpm test # 校验单测 + 404 状态码 + API 端到端(需先 pnpm start) ``` 或分别运行: ```bash pnpm test:validation # Zod 校验规则单测(无需服务器/数据库) pnpm test:404 # 检查草稿与不存在的文章是否返回真正的 404 pnpm test:api # API 端到端(CRUD + 分页 + 筛选 + 错误分支) pnpm db:verify # 数据库约束与索引是否真的生效(含"坏写入"测试) ``` `test:api` / `test:404` 需要先启动服务器: ```bash pnpm start --port 3000 node scripts/test-api.mjs http://localhost:3000 ``` > 这些脚本的测试数据统一使用 `zz-` 前缀,跑完会自动清理,不影响真实内容。 ### 视觉问题的排查脚本 横向溢出、代码块滚动行为这类问题,单看截图难以定位到具体元素, 因此通过 Chrome DevTools Protocol 直接向浏览器查询元素的 `boundingRect`: ```bash # 1. 启动带调试端口的 headless Chrome chrome --headless=new --remote-debugging-port=9222 --window-size=390,900 about:blank # 2. 从 http://127.0.0.1:9222/json 取 webSocketDebuggerUrl,设为环境变量 export CDP_WS="ws://127.0.0.1:9222/devtools/page/" # 3. 测量指定视口下的横向溢出 node scripts/measure-overflow.mjs http://localhost:3000/zh 390 900 # 4. 检查代码块是否在内部滚动而非撑破布局 node scripts/check-code-blocks.mjs http://localhost:3000/zh/posts/ 390 # 5. 在精确视口下截图(含整页) node scripts/screenshot.mjs http://localhost:3000/zh 390 844 out.png --full # 6. 诊断 notFound() 的状态码行为 node scripts/diagnose-404.mjs http://localhost:3000 ``` > **为什么需要 `screenshot.mjs`**:`chrome --headless --screenshot --window-size=390,900` > 的输出位图宽度与 CSS 视口宽度可能不一致,会让移动端截图看起来"内容被截断", > 而实际测量 `document.scrollWidth` 却是正常的。用 CDP 的 > `Emulation.setDeviceMetricsOverride` 才能得到与真实设备一致的截图。 --- ## 已知限制 1. **API 尚无鉴权。** `POST` / `PATCH` / `DELETE` 目前任何人都能调用, 仅适合本地开发或内网使用。上线前必须加鉴权(Session + HttpOnly Cookie 或 API Key),并给写操作加上速率限制。 2. **`` 固定为 `zh-CN`。** 根布局 `app/layout.tsx` 渲染 ``, 而语言信息要到 `app/[locale]/layout.tsx` 才能确定,因此无法直接改写该属性。 实际语言已通过 canonical、`og:locale`、`hreflang` 正确告知搜索引擎。 3. **文章内容不随语言切换。** 语言切换只影响 UI 文案,文章本身是单一版本。 如需多语言文章,可给 `posts` 表加 `locale` 列并调整唯一索引为 `(slug, locale)`。 4. **列表接口用 offset 分页。** 数据量增大后 offset 会变慢 (需扫描并丢弃前 N 行),届时应改为基于 `published_at + id` 的游标分页, 接口形状可以保持不变(`meta.hasMore` 已预留)。 5. **详情页未启用 ISR 缓存。** 为保证返回真正的 404,详情页使用 `dynamic = "force-dynamic"`。查询走 slug 唯一索引,开销很小; 若要进一步提速,可在数据层加 Redis 缓存。 6. **没有路由级 `loading.tsx`。** 为保证 `notFound()` 能正确设置 404 状态码, 全局骨架屏被移除(原因见上文「草稿机制」)。如需加载态, 请在页面内部用 `` 包裹具体的数据区块。 7. **标签目前仅作展示,没有标签聚合页。** `GET /api/tags` 与 `listTags()` 已实现, 加标签页时可直接复用。 8. **无内容版本历史。** 更新会直接覆盖原记录(`updated_at` 会变化), 没有 revision 表。如需回溯,应增加 `post_revisions` 表。 9. **暗色模式未实现。** `DESIGN.md` 明确说明上游品牌尚未发布暗色令牌, 因此 `globals.css` 中固定 `color-scheme: light`,未做自动反转。 --- ## 部署到 Vercel 1. 将仓库推送到 GitHub / GitLab 2. 在 Vercel 导入项目(会自动识别 Next.js) 3. 配置环境变量: - `DATABASE_URL`:生产数据库连接串(Neon / Supabase 等) - `NEXT_PUBLIC_SITE_URL`:正式域名,如 `https://blog.example.com` 4. 首次部署后,在本地对生产库执行一次迁移:`pnpm db:migrate` 5. 在 Vercel 的 Domains 中添加自定义域名,HTTPS 证书自动签发 > **Serverless 注意**:Vercel 的每个函数实例都会建立自己的连接池。 > 建议使用 Neon 的 pooled 连接串(`-pooler` 主机名), > 并把 `DATABASE_POOL_MAX` 调小(如 `2`)以避免耗尽数据库连接数。 `pnpm build` 已包含类型检查,构建失败会直接阻断部署。 构建过程**不需要**数据库可用(页面为动态渲染,不在构建期取数)。