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

19 KiB
Raw Blame History

个人博客 · 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 才能得到与真实设备一致的截图。


已知限制

  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 已包含类型检查,构建失败会直接阻断部署。 构建过程不需要数据库可用(页面为动态渲染,不在构建期取数)。