19 KiB
个人博客 · v0.1
一个使用 Next.js + PostgreSQL 构建的个人博客,支持 Markdown / MDX 写作、代码高亮、 多语言路由,并带有一套完整的文章 CRUD REST 接口。
设计规范见
DESIGN.md,第三方库文档见文档地址.md, 功能范围见v0.1功能文档.md。
快速开始
pnpm install
# 1. 配置数据库连接(见下方「环境变量」)
# 2. 建表
pnpm db:migrate
# 3. 导入示例文章(把 content/posts/ 下的 Markdown 写入数据库)
pnpm seed
pnpm dev # 开发服务器 http://localhost:3000
生产构建与本地预览:
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)都无法绕过。
常用命令
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
所有接口遵循统一响应格式:
// 成功
{ "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 |
是否包含草稿 |
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 — 新建文章
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 一节的 POST /api/posts / PATCH /api/posts/[slug]。
方式二:写 Markdown 文件后导入
在 content/posts/ 下新增 .mdx 文件(文件名即 slug),然后运行
pnpm seed 导入数据库。该命令是幂等 upsert:已存在的 slug 会被更新,
不会产生重复数据。
---
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 中可直接使用内置组件(正文存在数据库里,同样会被编译):
<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,客户端挂载后, 仅当元素位于视口下方时才叠加一次过渡动画。可读性是默认状态,动画是叠加效果。
验证脚本
一次性运行全部测试
pnpm test # 校验单测 + 404 状态码 + API 端到端(需先 pnpm start)
或分别运行:
pnpm test:validation # Zod 校验规则单测(无需服务器/数据库)
pnpm test:404 # 检查草稿与不存在的文章是否返回真正的 404
pnpm test:api # API 端到端(CRUD + 分页 + 筛选 + 错误分支)
pnpm db:verify # 数据库约束与索引是否真的生效(含"坏写入"测试)
test:api / test:404 需要先启动服务器:
pnpm start --port 3000
node scripts/test-api.mjs http://localhost:3000
这些脚本的测试数据统一使用
zz-前缀,跑完会自动清理,不影响真实内容。
视觉问题的排查脚本
横向溢出、代码块滚动行为这类问题,单看截图难以定位到具体元素,
因此通过 Chrome DevTools Protocol 直接向浏览器查询元素的 boundingRect:
# 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才能得到与真实设备一致的截图。
已知限制
-
API 尚无鉴权。
POST/PATCH/DELETE目前任何人都能调用, 仅适合本地开发或内网使用。上线前必须加鉴权(Session + HttpOnly Cookie 或 API Key),并给写操作加上速率限制。 -
<html lang>固定为zh-CN。 根布局app/layout.tsx渲染<html>, 而语言信息要到app/[locale]/layout.tsx才能确定,因此无法直接改写该属性。 实际语言已通过 canonical、og:locale、hreflang正确告知搜索引擎。 -
文章内容不随语言切换。 语言切换只影响 UI 文案,文章本身是单一版本。 如需多语言文章,可给
posts表加locale列并调整唯一索引为(slug, locale)。 -
列表接口用 offset 分页。 数据量增大后 offset 会变慢 (需扫描并丢弃前 N 行),届时应改为基于
published_at + id的游标分页, 接口形状可以保持不变(meta.hasMore已预留)。 -
详情页未启用 ISR 缓存。 为保证返回真正的 404,详情页使用
dynamic = "force-dynamic"。查询走 slug 唯一索引,开销很小; 若要进一步提速,可在数据层加 Redis 缓存。 -
没有路由级
loading.tsx。 为保证notFound()能正确设置 404 状态码, 全局骨架屏被移除(原因见上文「草稿机制」)。如需加载态, 请在页面内部用<Suspense>包裹具体的数据区块。 -
标签目前仅作展示,没有标签聚合页。
GET /api/tags与listTags()已实现, 加标签页时可直接复用。 -
无内容版本历史。 更新会直接覆盖原记录(
updated_at会变化), 没有 revision 表。如需回溯,应增加post_revisions表。 -
暗色模式未实现。
DESIGN.md明确说明上游品牌尚未发布暗色令牌, 因此globals.css中固定color-scheme: light,未做自动反转。
部署到 Vercel
- 将仓库推送到 GitHub / GitLab
- 在 Vercel 导入项目(会自动识别 Next.js)
- 配置环境变量:
DATABASE_URL:生产数据库连接串(Neon / Supabase 等)NEXT_PUBLIC_SITE_URL:正式域名,如https://blog.example.com
- 首次部署后,在本地对生产库执行一次迁移:
pnpm db:migrate - 在 Vercel 的 Domains 中添加自定义域名,HTTPS 证书自动签发
Serverless 注意:Vercel 的每个函数实例都会建立自己的连接池。 建议使用 Neon 的 pooled 连接串(
-pooler主机名), 并把DATABASE_POOL_MAX调小(如2)以避免耗尽数据库连接数。
pnpm build 已包含类型检查,构建失败会直接阻断部署。
构建过程不需要数据库可用(页面为动态渲染,不在构建期取数)。