Zlog 部署指南
项目概览
Zlog 是一个「静态优先」的个人博客系统,管理后台与前台同仓库:前台在构建时静态导出(SSG),后台在开发模式下提供完整的管理能力。核心技术选型:
| 层 | 选型 | 说明 |
|---|---|---|
| 框架 | Next.js 16(App Router + Turbopack) | 前台静态导出,后台 CSR |
| 样式 | Tailwind CSS v4 + shadcn/ui | 全站组件化 |
| 数据库 | Turso(libSQL)+ @libsql/client | 零运维云数据库,走 HTTP |
| 认证 | bcrypt + jose JWT | 密码哈希 + 签名令牌 + 失败锁定 |
| 评论 | Giscus(GitHub Discussions) | 免自建后端 |
| 图片 | GitHub 仓库 + CDN | 上传走 GitHub Contents API |
| 部署 | Vercel(纯静态托管) | 构建时注入环境变量 |
整体架构
Zlog 是一个 pnpm workspace monorepo:
zlog/
├── apps/
│ └── web/ # Next.js 应用(前台页面 + /admin 后台)
├── packages/
│ ├── core/ # 共享类型与工具(Post 类型、safeSlug 等)
│ ├── auth/ # JWT 签发/校验、登录尝试与锁定状态
│ └── database/ # Turso 客户端、表结构、数据访问层
└── scripts/ # create-admin 等辅助脚本生产环境(静态导出):浏览器 → Vercel 托管的纯静态文件。所有文章在构建时从数据库一次性读取,生成 HTML:
开发模式:next dev 同时提供前台页面和 /api/* 路由,/admin 后台在浏览器中调用这些 API 完成登录、写文章、传图等操作:
前台与后台共享同一套 packages/database 数据访问层,构建时(SSG)与开发时(API)读到的永远是同一份数据。
前端架构
前台是标准的 Next.js App Router 静态导出:
- 构建:
pnpm export(等价于NEXT_EXPORT=true next build),output: 'export'把所有页面预渲染成纯静态 HTML,输出到out/ - 页面:首页文章流、
/posts/[slug]详情、标签、时间线、关于页等,全部通过generateStaticParams从数据库生成 - 文章渲染:MDX(GFM + rehype-pretty-code 高亮),前台与后台编辑器的预览使用同一套组件映射,所见即所得
- 后台:
/admin/*全部是客户端组件(CSR),静态导出后仍可访问页面本身,但增删改依赖 API,因此管理操作在本地开发模式完成 - Monorepo 注意点:
next.config.ts里通过transpilePackages编译@zlog/*工作区包,并显式设置turbopack.root指向 workspace 根目录,避免 Turbopack 向上误判根目录(详见文末踩坑记录)
后端与 API
后端能力全部集中在 API Routes(仅开发/自托管模式可用):
| 路由 | 职责 |
|---|---|
/api/auth/login /logout /me | 登录、登出、当前用户 |
/api/auth/change-password | 修改密码 |
/api/auth/recovery /reset | 忘记密码(恢复码重置) |
/api/posts | 文章列表 / 创建 / 更新 / 删除 |
/api/media /api/upload | 媒体列表 / 上传(写入 GitHub 仓库) |
/api/site-settings | 站点名称、Logo、导航等设置 |
静态导出后 /api/* 不再存在——这是设计使然:生产环境没有服务端,管理操作统一在本地开发环境完成,改动直接落到数据库。
数据库
数据库是 Turso(libSQL):SQLite 的云托管版本,零运维、按量付费,通过 HTTPS 访问(@libsql/client 底层是 HTTP pipeline 协议),构建和开发环境都能直连。
共 5 张表:
-- 文章(核心表)
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL, -- URL 标识
title TEXT NOT NULL,
date TEXT NOT NULL, -- 发布日期
updated TEXT, -- 最后更新
tags TEXT NOT NULL DEFAULT '[]',-- JSON 数组
description TEXT NOT NULL,
cover TEXT, -- 封面图路径
draft INTEGER NOT NULL DEFAULT 0,-- 0=已发布 1=草稿
content TEXT NOT NULL, -- MDX 正文
word_count INTEGER NOT NULL DEFAULT 0,-- 构建时用于阅读时长
reading_time INTEGER NOT NULL DEFAULT 0,
created_at / updated_at TEXT
);
CREATE INDEX idx_posts_date ON posts(date DESC);
CREATE INDEX idx_posts_draft ON posts(draft);
-- 媒体(上传记录,图片本身存在 GitHub 仓库)
CREATE TABLE media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT UNIQUE NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
data BLOB NOT NULL, -- 本地缓冲
github_sha TEXT, -- GitHub 上的 blob SHA
created_at TEXT NOT NULL
);
-- 站点设置(名称、Logo、导航等,admin 可改)
CREATE TABLE site_settings ( ... );
-- 管理员账号
CREATE TABLE users ( ... );
-- 登录失败锁定(节流)
CREATE TABLE auth_lockout ( ... );认证与安全
- 登录:
ADMIN_USERNAME+ADMIN_PASSWORD_HASH(bcrypt 哈希,由scripts/create-admin.mjs生成)比对成功后,用SESSION_SECRET签发 JWT(jose 库),前端持有 token 调用受保护的 API - 锁定:连续失败会写入
auth_lockout表,按失败次数指数级递增锁定时长,防止爆破 - 密钥分级:数据库地址、管理密码、JWT 密钥、GitHub token 全部只存在于服务端环境(Vercel / 本地 .env.local),浏览器只拿得到
NEXT_PUBLIC_*变量
媒体存储
图片不走数据库大字段,而是:上传时通过 GitHub Contents API 写入独立的图片仓库(默认 zephyr110/blog-img),media 表记录文件名与 GitHub blob SHA,页面 URL 由 BLOG_IMG_CDN_BASE 拼接(可换任何 CDN)。这样数据库小、加载快、GitHub 天然带版本历史。
GitHub 配置
- 主仓库:
zephyr110/zlog,main分支为发布分支,改动合入后触发 CI 与 Vercel 部署 - 图片仓库:
zephyr110/blog-img(可选配置BLOG_IMG_REPO),需要一个 fine-grained token:权限只勾选该仓库的 Contents: Read and write,写入BLOG_IMG_GITHUB_TOKEN - Giscus:在 giscus.app 完成 GitHub App 授权,选择仓库并生成四项配置(repo / repoId / category / categoryId),分别填入
NEXT_PUBLIC_GISCUS_* - 仓库设置里开启 Discussions(Giscus 的评论载体)
Vercel 配置
- 导入仓库:New Project → 选择
zlog仓库,Framework Preset 选 Next.js(自动识别) - Monorepo:Root Directory 设为
apps/web;Build Command 使用pnpm export(等价NEXT_EXPORT=true next build,产物在out/,Next.js preset 会自动识别输出目录);Node.js 版本 24 - 环境变量:把下方清单全部粘贴到 Environment Variables(Production 与 Preview 一致)
- 域名:默认
https://zephyr110.vercel.app,可在 Domains 添加自定义域名 - 部署后每次 push 到
main自动重新构建:构建时从 Turso 拉取最新文章,静态站点随之更新
“注意:修改 Vercel 的 Project Name 会影响 OpenID Connect Token claims 等配置引用;若改了名称,请同步检查环境变量(尤其是
NEXT_PUBLIC_GISCUS_REPO),并重新部署一次。
环境变量清单
客户端(NEXT_PUBLIC_*,构建时注入,会暴露给浏览器):
| 变量 | 用途 | 示例 |
|---|---|---|
NEXT_PUBLIC_SITE_URL | 站点根 URL(OG 图、feed、sitemap 拼接) | https://zephyr110.vercel.app |
NEXT_PUBLIC_OG_IMAGE | 默认分享卡片图 | /images/og-default.jpg |
NEXT_PUBLIC_GISCUS_REPO | 评论仓库 | zephyr110/zlog |
NEXT_PUBLIC_GISCUS_REPO_ID | Giscus repo ID | R_kgDO… |
NEXT_PUBLIC_GISCUS_CATEGORY | 评论分类 | Announcements |
NEXT_PUBLIC_GISCUS_CATEGORY_ID | 分类 ID | DIC_kwDO… |
服务端(仅构建/运行环境,禁止泄露):
| 变量 | 用途 | 示例 |
|---|---|---|
TURSO_DATABASE_URL | Turso 数据库地址 | libsql://zlog-xxx.turso.io |
TURSO_AUTH_TOKEN | 数据库访问令牌 | eyJhbGciOi… |
ADMIN_USERNAME | 管理员账号 | admin |
ADMIN_PASSWORD_HASH | bcrypt 密码哈希 | $2b$10$… |
SESSION_SECRET | JWT 签名密钥(长随机串) | openssl rand -hex 32 生成 |
BLOG_IMG_GITHUB_TOKEN | 图片仓库 fine-grained token | github_pat_… |
BLOG_IMG_REPO | 图片仓库(可选) | zephyr110/blog-img |
BLOG_IMG_BRANCH | 图片分支(可选) | main |
BLOG_IMG_CDN_BASE | 图片 CDN 前缀(可选) | https://cdn.jsdelivr.net/gh/… |
BLOG_IMG_QUALITY | 图片压缩质量(可选) | 80 |
本地开发时全部写入 apps/web/.env.local(已 gitignore);Vercel 部署时在项目 Settings → Environment Variables 里配置。
部署与发布流程
要点:
- 发布即入库:草稿
draft=1不出现在前台,改为发布(draft=0)后,下次构建就会包含它 - 静态导出的特性:文章变更必须触发一次重新构建才会上线(没有 SSR 实时性);推送一个空提交或 Vercel 手动 Redeploy 均可
- 回滚:把文章改回草稿再构建,文章即从线上消失
踩坑记录
- Turbopack root 误判:Next.js 会自动向上查找 lockfile 推断 workspace root;若机器上存在多余的
package-lock.json(比如全局 npm 残留),root 会被推断到错误位置,导致@zlog/*无法解析。显式在next.config.ts设置turbopack.root到 workspace 根目录解决 - Giscus 报 "not installed on this repository":
NEXT_PUBLIC_GISCUS_REPO必须与 GitHub 上的实际仓库名一致;改过仓库名或 Vercel 项目名后最容易忘更新这个变量,改完记得 Redeploy - 改 Vercel Project Name 有连锁影响:会提示影响 OIDC token claims,且环境变量面板不变——重命名后应检查一遍所有
NEXT_PUBLIC_*并重新部署 - admin 在静态站上不可写:生产环境没有
/api,管理操作请在本地pnpm dev完成(这是架构的取舍,不是缺陷)