482 lines
19 KiB
Markdown
482 lines
19 KiB
Markdown
# 个人博客 · 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` 已包含类型检查,构建失败会直接阻断部署。
|
||
构建过程**不需要**数据库可用(页面为动态渲染,不在构建期取数)。
|