Files
app-261004/README.md
T
2026-10-08 11:09:37 +08:00

482 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 个人博客 · 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 # 根布局:<html> / <body> + 全局字体变量
├── 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
<Callout type="tip" title="提示">
支持 info / tip / warning / danger 四种类型。
</Callout>
```
---
## 架构要点
### 字体:构建期自托管
中文 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/<id>"
# 3. 测量指定视口下的横向溢出
node scripts/measure-overflow.mjs http://localhost:3000/zh 390 900
# 4. 检查代码块是否在内部滚动而非撑破布局
node scripts/check-code-blocks.mjs http://localhost:3000/zh/posts/<slug> 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. **`<html lang>` 固定为 `zh-CN`。** 根布局 `app/layout.tsx` 渲染 `<html>`,
而语言信息要到 `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 状态码,
全局骨架屏被移除(原因见上文「草稿机制」)。如需加载态,
请在页面内部用 `<Suspense>` 包裹具体的数据区块。
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` 已包含类型检查,构建失败会直接阻断部署。
构建过程**不需要**数据库可用(页面为动态渲染,不在构建期取数)。