🌸 从 Flask 到 Next.js:我的个人博客 V2.5 全栈重构实录(含腾讯云部署完整指南)
📌 文章导览:本文完整记录了个人博客从 Flask + SQLite 单体架构重构为 Next.js 16 (App Router) + Prisma 6 + PostgreSQL 16 全栈应用的全过程。全文约 18000 字,含 30+ 可直接复用的代码片段,覆盖架构设计、核心模块实现、本地环境搭建、腾讯云生产部署、性能优化、故障排查六大板块。适合有一定前端基础、准备落地全栈个人项目的开发者参考。
🎯 阅读收益:掌握 App Router 的 SSR/SSG/ISR 混合渲染策略 · 自建 JWT + GitHub OAuth 双轨认证 · Prisma 状态机建模 · Ubuntu + Nginx + PM2 生产级部署 · 常见故障的定位与解决
关键词:Next.js 16 · Prisma · PostgreSQL · 全栈博客 · 腾讯云部署 · GitHub OAuth · 状态机设计 · SSR 优化
📑 目录
- 一、重构背景与目标
- 二、技术选型与架构设计
- 三、核心功能实现
- 四、本地开发环境搭建
- 五、部署到腾讯云(Ubuntu + PM2 + Nginx)
- 六、性能优化实践
- 七、安全加固建议
- 八、踩坑实录与故障排查手册
- 九、总结与后续规划
- 附录:常用命令速查表
一、重构背景与目标
1.1 V1 的问题清单
V1 版本基于 Flask + SQLite,跑了半年后暴露出四个致命问题:
| SEO 几乎为零 | 客户端渲染为主,搜索引擎抓取不到正文 | 博客无自然流量 | 🔴 P0 |
| 无用户体系 | 仅作者能发文 | 无法评论/点赞/互动 | 🔴 P0 |
| 后台体验差 | 写文章需要手动改 md 文件再上传 | 发布耗时 5-10 分钟 | 🔴 P0 |
| UI 无法演进 | Flask Jinja 模板改动成本极高 | 视觉体验落后 | 🟢 P2 |
1.2 重构目标(V2.5 的验收标准)
- ✅ Lighthouse 首屏性能 90+
- ✅ 完整的用户体系:GitHub OAuth 登录、个人主页、设置页
- ✅ 内容治理能力:专栏审核状态机(DRAFT → PENDING → PUBLISHED)
- ✅ 多媒体动态:支持文字 / 多图 / 视频混发
- ✅ 可视化的粉色系动漫风格 UI:整屏视频轮播 + 毛玻璃导航
- ✅ 单机可部署:1核2G 轻量服务器跑得动
1.3 版本演进路径
V1.0 Flask + SQLite + Jinja 模板(单体)
V1.3 Flask + 一点客户端渲染(SEO 问题凸显)
│
├── 断点重构 ──►
│
V2.0 Next.js 14 + App Router + Prisma + PostgreSQL(基础骨架)
V2.3 用户系统 + GitHub OAuth(闭环可用)
V2.5 专栏审核状态机 + 动态板块合并 + 视觉大改(当前版本)
#mermaid-svg-h4eHmG0qAGnpdQGH{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-h4eHmG0qAGnpdQGH .error-icon{fill:#552222;}#mermaid-svg-h4eHmG0qAGnpdQGH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-h4eHmG0qAGnpdQGH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-h4eHmG0qAGnpdQGH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-h4eHmG0qAGnpdQGH .marker.cross{stroke:#333333;}#mermaid-svg-h4eHmG0qAGnpdQGH svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-h4eHmG0qAGnpdQGH p{margin:0;}#mermaid-svg-h4eHmG0qAGnpdQGH .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-h4eHmG0qAGnpdQGH .cluster-label text{fill:#333;}#mermaid-svg-h4eHmG0qAGnpdQGH .cluster-label span{color:#333;}#mermaid-svg-h4eHmG0qAGnpdQGH .cluster-label span p{background-color:transparent;}#mermaid-svg-h4eHmG0qAGnpdQGH .label text,#mermaid-svg-h4eHmG0qAGnpdQGH span{fill:#333;color:#333;}#mermaid-svg-h4eHmG0qAGnpdQGH .node rect,#mermaid-svg-h4eHmG0qAGnpdQGH .node circle,#mermaid-svg-h4eHmG0qAGnpdQGH .node ellipse,#mermaid-svg-h4eHmG0qAGnpdQGH .node polygon,#mermaid-svg-h4eHmG0qAGnpdQGH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-h4eHmG0qAGnpdQGH .rough-node .label text,#mermaid-svg-h4eHmG0qAGnpdQGH .node .label text,#mermaid-svg-h4eHmG0qAGnpdQGH .image-shape .label,#mermaid-svg-h4eHmG0qAGnpdQGH .icon-shape .label{text-anchor:middle;}#mermaid-svg-h4eHmG0qAGnpdQGH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-h4eHmG0qAGnpdQGH .rough-node .label,#mermaid-svg-h4eHmG0qAGnpdQGH .node .label,#mermaid-svg-h4eHmG0qAGnpdQGH .image-shape .label,#mermaid-svg-h4eHmG0qAGnpdQGH .icon-shape .label{text-align:center;}#mermaid-svg-h4eHmG0qAGnpdQGH .node.clickable{cursor:pointer;}#mermaid-svg-h4eHmG0qAGnpdQGH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-h4eHmG0qAGnpdQGH .arrowheadPath{fill:#333333;}#mermaid-svg-h4eHmG0qAGnpdQGH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-h4eHmG0qAGnpdQGH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-h4eHmG0qAGnpdQGH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-h4eHmG0qAGnpdQGH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-h4eHmG0qAGnpdQGH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-h4eHmG0qAGnpdQGH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-h4eHmG0qAGnpdQGH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-h4eHmG0qAGnpdQGH .cluster text{fill:#333;}#mermaid-svg-h4eHmG0qAGnpdQGH .cluster span{color:#333;}#mermaid-svg-h4eHmG0qAGnpdQGH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-h4eHmG0qAGnpdQGH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-h4eHmG0qAGnpdQGH rect.text{fill:none;stroke-width:0;}#mermaid-svg-h4eHmG0qAGnpdQGH .icon-shape,#mermaid-svg-h4eHmG0qAGnpdQGH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-h4eHmG0qAGnpdQGH .icon-shape p,#mermaid-svg-h4eHmG0qAGnpdQGH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-h4eHmG0qAGnpdQGH .icon-shape .label rect,#mermaid-svg-h4eHmG0qAGnpdQGH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-h4eHmG0qAGnpdQGH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-h4eHmG0qAGnpdQGH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-h4eHmG0qAGnpdQGH :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
V1.0 Flask + SQLite 单体
V1.3 引入客户端渲染
断点重构
V2.0 Next.js 14 + Prisma + PostgreSQL
V2.3 用户系统 + GitHub OAuth
V2.5 审核状态机 + 动态合并 + 视觉大改(当前版本)
二、技术选型与架构设计
2.1 技术栈全景
| 渲染框架 | Next.js (App Router) | 16.4.3 | 路由 + SSR/SSG/ISR + API Route |
| 语言 | TypeScript | 5.x | 全链路类型安全 |
| 数据层 | Prisma ORM | 6.19.3 | schema 驱动 + 类型生成 |
| 数据库 | PostgreSQL | 16 | 全文搜索 + JSONB + 生成列 |
| 样式 | Tailwind CSS | 4 | 原子化 + CSS 变量主题 |
| 认证 | 自建 JWT + GitHub OAuth | — | 轻量、可控、可定制 |
| 进程管理 | PM2 | 5.x | 守护 + 开机自启 + 日志 |
| 反向代理 | Nginx | 稳定版 | TLS 终止 + 静态资源 + 限流 |
2.2 架构分层
┌──────────────────────────────────────────────────┐
│ 浏览器 / 爬虫 │
└─────────────────┬────────────────────────────────┘
│ HTTP(S)
┌─────────────────▼────────────────────────────────┐
│ Nginx (80 / 443) │
│ · TLS 终止 · 静态资源 · gzip · 限流 │
└─────────────────┬────────────────────────────────┘
│ proxy_pass 127.0.0.1:3000
┌─────────────────▼────────────────────────────────┐
│ Next.js 16 (PM2 守护) │
│ ┌────────────────────────────────────────────┐ │
│ │ App Router (RSC 服务端组件 + 客户端组件) │ │
│ │ ├── 页面路由 /, /post/[slug], /u/[name] │ │
│ │ └── API 路由 /api/auth/*, /api/me │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ Prisma Client (连接池 + 类型化查询) │ │
│ └────────────────────────────────────────────┘ │
└─────────────────┬────────────────────────────────┘
│
┌─────────────────▼────────────────────────────────┐
│ PostgreSQL 16 (本地 socket 连接) │
└──────────────────────────────────────────────────┘
对应的分层关系也可以用流程图表示:
#mermaid-svg-eIXJvDcJUfrFXZmH{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eIXJvDcJUfrFXZmH .error-icon{fill:#552222;}#mermaid-svg-eIXJvDcJUfrFXZmH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eIXJvDcJUfrFXZmH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eIXJvDcJUfrFXZmH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eIXJvDcJUfrFXZmH .marker.cross{stroke:#333333;}#mermaid-svg-eIXJvDcJUfrFXZmH svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eIXJvDcJUfrFXZmH p{margin:0;}#mermaid-svg-eIXJvDcJUfrFXZmH .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-eIXJvDcJUfrFXZmH .cluster-label text{fill:#333;}#mermaid-svg-eIXJvDcJUfrFXZmH .cluster-label span{color:#333;}#mermaid-svg-eIXJvDcJUfrFXZmH .cluster-label span p{background-color:transparent;}#mermaid-svg-eIXJvDcJUfrFXZmH .label text,#mermaid-svg-eIXJvDcJUfrFXZmH span{fill:#333;color:#333;}#mermaid-svg-eIXJvDcJUfrFXZmH .node rect,#mermaid-svg-eIXJvDcJUfrFXZmH .node circle,#mermaid-svg-eIXJvDcJUfrFXZmH .node ellipse,#mermaid-svg-eIXJvDcJUfrFXZmH .node polygon,#mermaid-svg-eIXJvDcJUfrFXZmH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eIXJvDcJUfrFXZmH .rough-node .label text,#mermaid-svg-eIXJvDcJUfrFXZmH .node .label text,#mermaid-svg-eIXJvDcJUfrFXZmH .image-shape .label,#mermaid-svg-eIXJvDcJUfrFXZmH .icon-shape .label{text-anchor:middle;}#mermaid-svg-eIXJvDcJUfrFXZmH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eIXJvDcJUfrFXZmH .rough-node .label,#mermaid-svg-eIXJvDcJUfrFXZmH .node .label,#mermaid-svg-eIXJvDcJUfrFXZmH .image-shape .label,#mermaid-svg-eIXJvDcJUfrFXZmH .icon-shape .label{text-align:center;}#mermaid-svg-eIXJvDcJUfrFXZmH .node.clickable{cursor:pointer;}#mermaid-svg-eIXJvDcJUfrFXZmH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eIXJvDcJUfrFXZmH .arrowheadPath{fill:#333333;}#mermaid-svg-eIXJvDcJUfrFXZmH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eIXJvDcJUfrFXZmH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eIXJvDcJUfrFXZmH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eIXJvDcJUfrFXZmH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eIXJvDcJUfrFXZmH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eIXJvDcJUfrFXZmH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eIXJvDcJUfrFXZmH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eIXJvDcJUfrFXZmH .cluster text{fill:#333;}#mermaid-svg-eIXJvDcJUfrFXZmH .cluster span{color:#333;}#mermaid-svg-eIXJvDcJUfrFXZmH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eIXJvDcJUfrFXZmH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eIXJvDcJUfrFXZmH rect.text{fill:none;stroke-width:0;}#mermaid-svg-eIXJvDcJUfrFXZmH .icon-shape,#mermaid-svg-eIXJvDcJUfrFXZmH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eIXJvDcJUfrFXZmH .icon-shape p,#mermaid-svg-eIXJvDcJUfrFXZmH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eIXJvDcJUfrFXZmH .icon-shape .label rect,#mermaid-svg-eIXJvDcJUfrFXZmH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eIXJvDcJUfrFXZmH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eIXJvDcJUfrFXZmH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eIXJvDcJUfrFXZmH :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
HTTP(S)
proxy_pass 127.0.0.1:3000
本地 socket
App Router
页面路由 / · /post/[slug] · /u/[name]
API 路由 /api/auth/* · /api/me
浏览器 / 爬虫
Nginx 80/443TLS 终止 · 静态资源 · gzip · 限流
Next.js 16(PM2 守护)
Prisma Client(连接池 + 类型化查询)
PostgreSQL 16
2.3 三个关键选型决策
决策 1:为什么不用 NextAuth,而要自建 JWT?
NextAuth 功能全面,但对我们这种定制化会话字段(比如角色 role、审核权限)的场景,需要不断写回调(jwt / session callbacks),反而增加心智负担。自建 JWT 的好处:
- 会话字段完全可控(只放 userId + role)
- GitHub OAuth 的回调流一目了然(两步走:拿 code 换 token → 拿 token 换 user)
- 后续要接微信 / QQ 登录,只需要新增回调路由
⚠️ 重要原则:会话里只存标识(userId、role),所有展示数据从 DB 实时读取。这样修改昵称、头像后能立刻生效(这就是踩坑 3 的经验)。
决策 2:为什么用 PostgreSQL 而不是继续用 SQLite?
| 并发写 | 单文件锁 | MVCC 支持 | ✅ 需要 |
| 全文搜索 | FTS5 够用 | tsvector + GIN 索引 | ✅ 中文分词需求 |
| JSON 字段 | 支持但查询弱 | JSONB + GIN | ✅ 动态板块元数据 |
| 生成列 | 不支持 | 支持 | ✅ searchVector 生成列 |
| 运维 | 零成本 | 需安装 | ⚠️ 可接受 |
决策 3:为什么用 App Router 而不是 Pages Router?
- Server Components 是默认:页面默认在服务端渲染,客户端 bundle 天然更小
- 混合渲染粒度:同一页面内,RSC 可以 SSR,而评论组件可以走客户端 CSR
- Parallel Routes / Intercepting Routes:后续做图片预览弹窗会很方便
- 流式渲染:loading.tsx 自动生成 Suspense 边界
三、核心功能实现
3.1 🎨 视觉系统
视觉部分迭代了 7 个版本,最终方案:
| 整屏视频轮播 | 首屏 3 个 <video> 叠加 + CSS opacity 过渡 | 移动端降级为静态封面 |
| 全局视频背景 | 固定定位 + filter: blur(20px) | preload=“metadata” |
| 粉色光点鼠标跟随 | Canvas + requestAnimationFrame | 卸载时 cancelAnimationFrame |
| 毛玻璃导航栏 | backdrop-filter: blur(12px) | 加 -webkit- 兼容前缀 |
| 卡片悬停 3D | transform: perspective() rotateX() | 加 will-change: transform |
💡 视觉选型的经验:试过粒子连线、3D 粒子球、极光、星空月光等 6 种方案,最终选视频背景。越复杂的动效越容易喧宾夺主,博客的核心永远是内容。
3.2 ✍️ 文章系统
完整的内容创作闭环:
- Markdown 编辑器 + 上传 .md 文件直接导入
- 分类 / 标签(下拉搜索)/ 专栏三级分类
- 草稿 / 发布状态机
- 封面图上传
- 代码高亮(Shiki)+ 一键复制
关键交互:分类、标签、专栏选择直接放在标题下方,用户无需滚动到底部。标签用 <datalist> 下拉搜索,避免标签爆炸。
3.3 👥 用户系统(含关键代码)
数据模型
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum UserRole {
USER
ADMIN
}
model User {
id String @id @default(cuid())
githubId String? @unique
name String
avatar String?
bio String?
role UserRole @default(USER)
posts Post[]
columns Column[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([githubId])
}
会话工具(JWT 签发与校验)
// lib/session.ts
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
const secret = new TextEncoder().encode(process.env.SESSION_SECRET!)
const COOKIE_NAME = 'session'
export interface SessionPayload {
userId: string
role: 'USER' | 'ADMIN'
}
/** 签发会话 */
export async function createSession(payload: SessionPayload) {
const token = await new SignJWT({ …payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret)
const cookieStore = await cookies()
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
}
/** 解析会话(服务端组件 / API Route 中使用) */
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get(COOKIE_NAME)?.value
if (!token) return null
try {
const { payload } = await jwtVerify(token, secret)
return payload as unknown as SessionPayload
} catch {
return null
}
}
🔑 使用 jose 而不是 jsonwebtoken:jose 基于 Web Crypto API,在 Edge Runtime(Next.js Middleware)中可直接运行;jsonwebtoken 依赖 Node 原生模块,在 Edge 环境下不可用。
/api/me 接口(解决昵称不同步)
// app/api/me/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/session'
export const dynamic = 'force-dynamic' // 强制不缓存
export async function GET() {
const session = await getSession()
if (!session?.userId) {
return NextResponse.json({ user: null }, { status: 401 })
}
// 关键:每次都从 DB 取最新数据,避免会话里数据过期
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, avatar: true, bio: true, role: true },
})
return NextResponse.json({ user })
}
GitHub OAuth 两步走(核心逻辑)
// app/api/auth/callback/github/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createSession } from '@/lib/session'
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get('code')
if (!code) {
return NextResponse.redirect(new URL('/login?error=no_code', req.url))
}
// Step 1: code → access_token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
}),
})
const { access_token } = await tokenRes.json()
// Step 2: token → user info
const userRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` },
})
const ghUser = await userRes.json()
// Step 3: upsert 到 DB
const user = await prisma.user.upsert({
where: { githubId: String(ghUser.id) },
update: { name: ghUser.name ?? ghUser.login, avatar: ghUser.avatar_url },
create: {
githubId: String(ghUser.id),
name: ghUser.name ?? ghUser.login,
avatar: ghUser.avatar_url,
},
})
await createSession({ userId: user.id, role: user.role })
return NextResponse.redirect(new URL('/', req.url))
}
⚠️ 注意:真实项目中需要校验 state 参数防 CSRF,这里为了聚焦主流程省略了。生产环境务必实现。
整个登录流程可以概括为:
PostgreSQLGitHubNext.js 服务端用户浏览器PostgreSQLGitHubNext.js 服务端用户浏览器#mermaid-svg-ou1yicPyBAC4IG16{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ou1yicPyBAC4IG16 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ou1yicPyBAC4IG16 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ou1yicPyBAC4IG16 .error-icon{fill:#552222;}#mermaid-svg-ou1yicPyBAC4IG16 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ou1yicPyBAC4IG16 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ou1yicPyBAC4IG16 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ou1yicPyBAC4IG16 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ou1yicPyBAC4IG16 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ou1yicPyBAC4IG16 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ou1yicPyBAC4IG16 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ou1yicPyBAC4IG16 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ou1yicPyBAC4IG16 .marker.cross{stroke:#333333;}#mermaid-svg-ou1yicPyBAC4IG16 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ou1yicPyBAC4IG16 p{margin:0;}#mermaid-svg-ou1yicPyBAC4IG16 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ou1yicPyBAC4IG16 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ou1yicPyBAC4IG16 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ou1yicPyBAC4IG16 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ou1yicPyBAC4IG16 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ou1yicPyBAC4IG16 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ou1yicPyBAC4IG16 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ou1yicPyBAC4IG16 .sequenceNumber{fill:white;}#mermaid-svg-ou1yicPyBAC4IG16 #sequencenumber{fill:#333;}#mermaid-svg-ou1yicPyBAC4IG16 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ou1yicPyBAC4IG16 .messageText{fill:#333;stroke:none;}#mermaid-svg-ou1yicPyBAC4IG16 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ou1yicPyBAC4IG16 .labelText,#mermaid-svg-ou1yicPyBAC4IG16 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ou1yicPyBAC4IG16 .loopText,#mermaid-svg-ou1yicPyBAC4IG16 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ou1yicPyBAC4IG16 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ou1yicPyBAC4IG16 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ou1yicPyBAC4IG16 .noteText,#mermaid-svg-ou1yicPyBAC4IG16 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ou1yicPyBAC4IG16 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ou1yicPyBAC4IG16 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ou1yicPyBAC4IG16 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ou1yicPyBAC4IG16 .actorPopupMenu{position:absolute;}#mermaid-svg-ou1yicPyBAC4IG16 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ou1yicPyBAC4IG16 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ou1yicPyBAC4IG16 .actor-man circle,#mermaid-svg-ou1yicPyBAC4IG16 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ou1yicPyBAC4IG16 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}点击「GitHub 登录」重定向到授权页(client_id)用户授权回调携带 codePOST /login/oauth/access_token(code 换 token)access_tokenGET /user(Bearer token)用户信息upsert User(githubId 唯一)user 记录签发 JWT 并写入 httpOnly Cookie重定向首页(已登录)
3.4 📚 专栏审核状态机(V2.5 新增)
状态流转图
┌──────────────────┐
创建 ────► │ DRAFT (草稿) │
└────────┬─────────┘
│ 用户点"申请发布"
▼
┌──────────────────┐
│ PENDING (待审核) │
└────────┬─────────┘
┌─────┴──────┐
通过 │ │ 拒绝
▼ ▼
┌──────────────┐ ┌──────────┐
│ PUBLISHED │ │ DRAFT │
│ (已发布) │ │ (退回) │
└──────┬───────┘ └──────────┘
│ 管理员下架
▼
┌──────────────┐
│ PUBLISHED → │
│ DRAFT │
└──────────────┘
用 Mermaid 状态图表达更直观:
#mermaid-svg-ZXuE6wSYxjayntpK{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZXuE6wSYxjayntpK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZXuE6wSYxjayntpK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZXuE6wSYxjayntpK .error-icon{fill:#552222;}#mermaid-svg-ZXuE6wSYxjayntpK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZXuE6wSYxjayntpK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZXuE6wSYxjayntpK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZXuE6wSYxjayntpK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZXuE6wSYxjayntpK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZXuE6wSYxjayntpK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZXuE6wSYxjayntpK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZXuE6wSYxjayntpK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZXuE6wSYxjayntpK .marker.cross{stroke:#333333;}#mermaid-svg-ZXuE6wSYxjayntpK svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZXuE6wSYxjayntpK p{margin:0;}#mermaid-svg-ZXuE6wSYxjayntpK defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ZXuE6wSYxjayntpK g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ZXuE6wSYxjayntpK g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ZXuE6wSYxjayntpK g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ZXuE6wSYxjayntpK g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ZXuE6wSYxjayntpK g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ZXuE6wSYxjayntpK .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ZXuE6wSYxjayntpK .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ZXuE6wSYxjayntpK .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ZXuE6wSYxjayntpK .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZXuE6wSYxjayntpK .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ZXuE6wSYxjayntpK .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ZXuE6wSYxjayntpK .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ZXuE6wSYxjayntpK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZXuE6wSYxjayntpK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZXuE6wSYxjayntpK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZXuE6wSYxjayntpK .edgeLabel .label text{fill:#333;}#mermaid-svg-ZXuE6wSYxjayntpK .label div .edgeLabel{color:#333;}#mermaid-svg-ZXuE6wSYxjayntpK .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ZXuE6wSYxjayntpK .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ZXuE6wSYxjayntpK .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ZXuE6wSYxjayntpK .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ZXuE6wSYxjayntpK .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ZXuE6wSYxjayntpK .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZXuE6wSYxjayntpK .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZXuE6wSYxjayntpK #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZXuE6wSYxjayntpK .cluster-label,#mermaid-svg-ZXuE6wSYxjayntpK .nodeLabel{color:#131300;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ZXuE6wSYxjayntpK .note-edge{stroke-dasharray:5;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-note text{fill:black;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagram .edgeLabel{color:red;}#mermaid-svg-ZXuE6wSYxjayntpK #dependencyStart,#mermaid-svg-ZXuE6wSYxjayntpK #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ZXuE6wSYxjayntpK .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZXuE6wSYxjayntpK :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
创建专栏
用户申请发布
管理员通过
管理员拒绝(退回)
管理员下架
DRAFT
PENDING
PUBLISHED
数据模型
enum ColumnStatus {
DRAFT
PENDING
PUBLISHED
}
model Column {
id String @id @default(cuid())
slug String @unique
title String
intro String?
status ColumnStatus @default(DRAFT)
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
@@index([authorId])
}
关键查询:按状态过滤
// 前台专栏列表:只展示已发布
export async function getPublicColumns() {
return prisma.column.findMany({
where: { status: 'PUBLISHED' },
orderBy: { createdAt: 'desc' },
select: { id: true, slug: true, title: true, intro: true },
})
}
// 个人主页:显示自己的全部状态
export async function getMyColumns(userId: string) {
return prisma.column.findMany({
where: { authorId: userId },
orderBy: { updatedAt: 'desc' },
select: { id: true, slug: true, title: true, status: true },
})
}
// 管理员审核队列
export async function getPendingColumns() {
return prisma.column.findMany({
where: { status: 'PENDING' },
orderBy: { updatedAt: 'asc' }, // 先进先审
include: { author: { select: { name: true, avatar: true } } },
})
}
💡 设计要点:状态字段 = 数据过滤维度。前台 /columns 只查 PUBLISHED;个人主页查全部;管理后台查 PENDING。这是最简单也最可靠的内容治理模式。
3.5 💬 动态板块(相册 + 说说合并)
- 纯文字 / 多图 / 视频混发
- 页面两个 Tab:相册、说说
- 导航栏从原「相册」「动态」两个入口合并为一个
信息架构的减法往往比加法更难做,但做对了用户体验会明显提升。
四、本地开发环境搭建
4.1 环境要求
| Node.js | 20 LTS+ | Next.js 16 最低要求 |
| PostgreSQL | 14+(推荐 16) | 支持 tsvector 生成列 |
| Git | 任意 | 拉源码 |
4.2 用 Docker 快速起数据库(推荐)
docker run -d \\
–name blog-pg \\
-e POSTGRES_USER=blog \\
-e POSTGRES_PASSWORD=blog123 \\
-e POSTGRES_DB=blog_v2 \\
-p 5432:5432 \\
-v blog-pg-data:/var/lib/postgresql/data \\
postgres:16
💡 加了 -v blog-pg-data:/var/lib/postgresql/data,数据持久化到卷,重启容器不丢数据。
4.3 克隆与安装
git clone https://github.com/Wasd2490/Full_stack-_blog-_version2.git
cd Full_stack-_blog-_version2
npm install
4.4 环境变量配置
创建 .env:
# ========== 数据库 ==========
DATABASE_URL="postgresql://blog:blog123@localhost:5432/blog_v2?schema=public"
# ========== GitHub OAuth ==========
# 创建路径:GitHub → Settings → Developer settings → OAuth Apps → New
# 本地 callback URL: http://localhost:3000/api/auth/callback/github
GITHUB_CLIENT_ID="你的 Client ID"
GITHUB_CLIENT_SECRET="你的 Client Secret"
# ========== 会话密钥 ==========
# 生成命令:openssl rand -base64 32
SESSION_SECRET="粘贴生成的随机串"
# ========== 站点信息 ==========
SITE_NAME="我的博客"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
🔑 生成 SESSION_SECRET 的命令:
openssl rand -base64 32
长度不足会降低 JWT 签名强度。生产环境必须与本地不同。
4.5 初始化数据库与启动
# 1. 同步表结构
npx prisma db push
# 2. 生成 Prisma Client(类型)
npx prisma generate
# 3. 启动开发服务器
npm run dev
访问 http://localhost:3000。开发模式下 Prisma 查询日志直接打印到终端。
⚠️ Windows 用户:npm run build 会卡死,改用:
npx next build –webpack
4.6 推荐开启 Prisma Studio(可视化 DB)
npx prisma studio
浏览器打开 http://localhost:5555,可以可视化查看/编辑所有表数据,调试 OAuth 和审核流程时非常方便。
五、部署到腾讯云(Ubuntu + PM2 + Nginx)
目标环境:腾讯云轻量应用服务器,Ubuntu 22.04 LTS,2核 2G。
5.1 安装系统依赖
# 1. Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash –
sudo apt-get install -y nodejs
node -v # 应输出 v20.x
# 2. PostgreSQL 16
sudo apt-get install -y postgresql postgresql-contrib
sudo systemctl status postgresql # 确认 running
# 3. PM2
sudo npm install -g pm2
# 4. Nginx
sudo apt-get install -y nginx
5.2 创建数据库与用户
sudo -u postgres psql
psql 中执行:
CREATE USER blog WITH PASSWORD '强密码(生产专用)';
CREATE DATABASE blog_v2 OWNER blog;
GRANT ALL PRIVILEGES ON DATABASE blog_v2 TO blog;
\\q
🔐 安全原则:永远不要用 postgres 超级用户直接跑应用。独立用户权限最小化,是生产环境基线。
5.3 上传代码并安装依赖
方式 A:上传 zip 后解压
unzip my_blog_V2.5_src.zip && cd my_blog_V2.5_src2.0.1_src_20261030
方式 B:直接 git clone
git clone https://github.com/Wasd2490/Full_stack-_blog-_version2.git
cd Full_stack-_blog-_version2
# 生产环境推荐 npm ci(严格按 lock 文件,更快)
npm ci –omit=dev
5.4 配置生产环境变量
DATABASE_URL="postgresql://blog:强密码@localhost:5432/blog_v2?schema=public"
GITHUB_CLIENT_ID="你的 GitHub Client ID"
GITHUB_CLIENT_SECRET="你的 GitHub Client Secret"
SESSION_SECRET="生产环境专用随机串(与本地不同)"
SITE_NAME="我的博客"
NEXT_PUBLIC_SITE_URL="https://你的域名"
⚠️ NEXT_PUBLIC_SITE_URL 会在构建时被注入到客户端 bundle,修改后必须重新 build。
5.5 初始化数据库并构建
npx prisma db push
npx prisma generate
npm run build
首次部署验证:构建产物应生成 .next/ 目录:
ls -la .next/ # 应含 BUILD_ID、server/、static/
5.6 PM2 启动与开机自启
# 启动
pm2 start npm –name "blog" — start
# 保存进程列表
pm2 save
# 生成开机脚本(会输出一条需要 sudo 执行的命令)
pm2 startup
PM2 常用运维命令:
pm2 status # 查看状态(online / errored)
pm2 logs blog # 实时日志(排障第一入口)
pm2 logs blog –lines 200 # 查看最近 200 行
pm2 restart blog # 重启
pm2 reload blog # 零停机重载
pm2 stop blog # 停止
pm2 delete blog # 移除
💡 进阶:可以写 ecosystem.config.js 管理多环境:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'blog',
script: 'npm',
args: 'start',
cwd: '/var/www/blog',
instances: 1, // 单核服务器保持 1;多核可改 'max' 走 cluster 模式
exec_mode: 'fork',
max_memory_restart: '500M',
env: { NODE_ENV: 'production', PORT: 3000 },
}],
}
启动:pm2 start ecosystem.config.js
5.7 Nginx 反向代理
sudo nano /etc/nginx/sites-available/blog
配置内容:
server {
listen 80;
server_name 你的域名或IP;
# 视频 / 多图上传支持
client_max_body_size 50M;
# gzip 压缩(Next.js 已内置压缩,此处双保险)
gzip on;
gzip_types text/plain text/css application/json application/javascript application/xml+rss;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置(长请求如 OAuth 处理)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Next.js 静态资源强缓存
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
启用配置:
sudo ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/
sudo nginx -t # 语法检查,必须通过再重启
sudo systemctl reload nginx
💡 Upgrade / Connection 头的作用:HMR(开发)和部分 WebSocket 场景需要;生产环境保留无副作用。/_next/static/ 的强缓存让静态资源一年不用回源。
5.8 修改 GitHub OAuth 回调地址
GitHub OAuth App 设置 → Authorization callback URL:
https://你的域名/api/auth/callback/github
⚠️ 协议、域名、路径必须完全匹配,少一个 / 或 http/https 不对都会拒绝授权。这是部署阶段最高频的卡点。
5.9 配置 HTTPS(强烈推荐)
sudo apt install -y certbot python3-certbot-nginx
sudo certbot –nginx -d 你的域名
Certbot 会自动修改 Nginx 配置并注册续期定时任务,可通过 systemctl status certbot.timer 确认定时续期服务处于 Active 状态。
5.10 部署验证清单
| 1 | 首页可访问 | curl -I http://服务器IP | HTTP 200 |
| 2 | 数据库连通 | 首页文章列表能加载 | 有数据展示 |
| 3 | GitHub 登录 | 点击登录 → 授权 → 回站 | 显示头像,/api/me 返回用户 |
| 4 | 发布文章 | 后台发一篇 → 前台访问 | 详情页正常渲染 |
| 5 | 上传头像 | 上传 → 刷新 | 头像 URL 可访问 |
| 6 | 专栏审核 | 建草稿 → 申请 → 后台通过 → 前台可见 | 状态正确流转 |
| 7 | 日志无异常 | pm2 logs blog | 无 error 日志 |
| 8 | PM2 守护 | sudo reboot 后重连 | 服务自动恢复 |
六、性能优化实践
按优先级排列,这些点是从「能跑」进化到「跑得好」的关键。
6.1 渲染策略选型(App Router 精华)
不同页面用不同策略,是 Next.js 的最大优势:
| 首页文章列表 | ISR | export const revalidate = 60 | 内容变化不频繁,60s 一次重建 |
| 文章详情页 | SSG | generateStaticParams | 已发布文章趋于稳定 |
| 个人主页 | SSR | export const dynamic = 'force-dynamic' | 需要实时数据 |
| 管理后台 | CSR | 客户端组件 + SWR | 无需 SEO,交互复杂 |
| 搜索页 | SSR | 按查询参数动态渲染 | 依赖查询参数,无法预生成 |
#mermaid-svg-vh3B0l8LKbO0h8LU{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vh3B0l8LKbO0h8LU .error-icon{fill:#552222;}#mermaid-svg-vh3B0l8LKbO0h8LU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vh3B0l8LKbO0h8LU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vh3B0l8LKbO0h8LU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vh3B0l8LKbO0h8LU .marker.cross{stroke:#333333;}#mermaid-svg-vh3B0l8LKbO0h8LU svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vh3B0l8LKbO0h8LU p{margin:0;}#mermaid-svg-vh3B0l8LKbO0h8LU .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-vh3B0l8LKbO0h8LU .cluster-label text{fill:#333;}#mermaid-svg-vh3B0l8LKbO0h8LU .cluster-label span{color:#333;}#mermaid-svg-vh3B0l8LKbO0h8LU .cluster-label span p{background-color:transparent;}#mermaid-svg-vh3B0l8LKbO0h8LU .label text,#mermaid-svg-vh3B0l8LKbO0h8LU span{fill:#333;color:#333;}#mermaid-svg-vh3B0l8LKbO0h8LU .node rect,#mermaid-svg-vh3B0l8LKbO0h8LU .node circle,#mermaid-svg-vh3B0l8LKbO0h8LU .node ellipse,#mermaid-svg-vh3B0l8LKbO0h8LU .node polygon,#mermaid-svg-vh3B0l8LKbO0h8LU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vh3B0l8LKbO0h8LU .rough-node .label text,#mermaid-svg-vh3B0l8LKbO0h8LU .node .label text,#mermaid-svg-vh3B0l8LKbO0h8LU .image-shape .label,#mermaid-svg-vh3B0l8LKbO0h8LU .icon-shape .label{text-anchor:middle;}#mermaid-svg-vh3B0l8LKbO0h8LU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vh3B0l8LKbO0h8LU .rough-node .label,#mermaid-svg-vh3B0l8LKbO0h8LU .node .label,#mermaid-svg-vh3B0l8LKbO0h8LU .image-shape .label,#mermaid-svg-vh3B0l8LKbO0h8LU .icon-shape .label{text-align:center;}#mermaid-svg-vh3B0l8LKbO0h8LU .node.clickable{cursor:pointer;}#mermaid-svg-vh3B0l8LKbO0h8LU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vh3B0l8LKbO0h8LU .arrowheadPath{fill:#333333;}#mermaid-svg-vh3B0l8LKbO0h8LU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vh3B0l8LKbO0h8LU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vh3B0l8LKbO0h8LU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vh3B0l8LKbO0h8LU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vh3B0l8LKbO0h8LU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vh3B0l8LKbO0h8LU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vh3B0l8LKbO0h8LU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vh3B0l8LKbO0h8LU .cluster text{fill:#333;}#mermaid-svg-vh3B0l8LKbO0h8LU .cluster span{color:#333;}#mermaid-svg-vh3B0l8LKbO0h8LU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vh3B0l8LKbO0h8LU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vh3B0l8LKbO0h8LU rect.text{fill:none;stroke-width:0;}#mermaid-svg-vh3B0l8LKbO0h8LU .icon-shape,#mermaid-svg-vh3B0l8LKbO0h8LU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vh3B0l8LKbO0h8LU .icon-shape p,#mermaid-svg-vh3B0l8LKbO0h8LU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vh3B0l8LKbO0h8LU .icon-shape .label rect,#mermaid-svg-vh3B0l8LKbO0h8LU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vh3B0l8LKbO0h8LU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vh3B0l8LKbO0h8LU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vh3B0l8LKbO0h8LU :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
页面类型
首页文章列表
文章详情页
个人主页
管理后台
搜索页
ISR:revalidate = 60
SSG:generateStaticParams
SSR:force-dynamic
CSR:客户端组件 + SWR
SSR:按查询参数动态渲染
示例:文章详情页 SSG
// app/post/[slug]/page.tsx
import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation'
// 构建时预生成所有已发布文章
export async function generateStaticParams() {
const posts = await prisma.post.findMany({
where: { status: 'PUBLISHED' },
select: { slug: true },
})
return posts.map((p) => ({ slug: p.slug }))
}
// 增量生成:未预生成的新文章首次访问时按需生成
export const dynamicParams = true
export const revalidate = 3600 // 1 小时重建一次
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug, status: 'PUBLISHED' },
include: { author: true, column: true },
})
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
{/* 内容渲染… */}
</article>
)
}
6.2 图片优化
统一用 next/image,自动 AVIF/WebP 转换 + 懒加载:
import Image from 'next/image'
<Image
src={post.cover}
alt={post.title}
width={800}
height={450}
sizes="(max-width: 768px) 100vw, 800px"
priority={isAboveFold} // 首屏图开启,其他交给懒加载
placeholder="blur"
blurDataURL="data:image/svg+xml;base64,…"
/>
6.3 视频背景加载策略
<video
autoPlay muted loop playsInline
preload={isFirstScreen ? 'auto' : 'metadata'}
poster="/poster.jpg"
className="fixed inset-0 -z-10 object-cover"
>
<source src="/bg.mp4" type="video/mp4" />
</video>
💡 移动端可以用 CSS media query 直接降级为静态封面,节省流量。
6.4 数据库查询优化
// ❌ 反例:N+1 查询
const posts = await prisma.post.findMany()
for (const p of posts) {
p.author = await prisma.user.findUnique({ where: { id: p.authorId } })
}
// ✅ 正例:include 一次查完
const posts = await prisma.post.findMany({
include: {
author: { select: { name: true, avatar: true } },
column: { select: { title: true, slug: true } },
},
orderBy: { createdAt: 'desc' },
take: 10,
})
6.5 Prisma 连接池
单机部署通常无需调整。若后续上多实例/容器,建议配合 PgBouncer:
# 直连:用于 migrations
DATABASE_URL="postgresql://blog:pwd@localhost:5432/blog_v2?schema=public"
# 连接池:用于运行时
DATABASE_URL="postgresql://blog:pwd@localhost:6432/blog_v2?pgbouncer=true&connection_limit=1"
七、安全加固建议
| JWT 密钥泄露 | SESSION_SECRET 用 openssl rand -base64 32,生产与开发不同 | 🔴 P0 |
| Cookie 被 XSS 窃取 | httpOnly: true + sameSite: ‘lax’ + 生产 secure: true | 🔴 P0 |
| OAuth CSRF | 实现 state 参数校验(sessionStorage 保存 + 回调比对) | 🔴 P0 |
| SQL 注入 | 全部通过 Prisma 查询,不手拼 SQL | 🟢 P2 |
| 越权修改 | 修改文章/专栏时校验 authorId === session.userId | 🔴 P0 |
| 上传攻击 | 校验 MIME + 扩展名 + 大小限制(Nginx 层 + 应用层双重) | 🟡 P1 |
| 暴力破解 | Nginx limit_req_zone 限流登录接口 | 🟡 P1 |
| 敏感信息泄露 | .env 加入 .gitignore,日志中脱敏 | 🔴 P0 |
越权修改的正确写法:
export async function updatePost(postId: string, data: UpdatePostInput) {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
// 关键:必须在 where 条件里带上归属校验
const result = await prisma.post.updateMany({
where: {
id: postId,
authorId: session.userId, // ← 越权防线
},
data,
})
if (result.count === 0) {
throw new Error('Not found or forbidden')
}
}
八、踩坑实录与故障排查手册
坑 1:Windows 下构建卡死
| 现象 | npm run build 卡十分钟以上无输出 |
| 原因 | Next.js 16 默认 Turbopack,Windows 下文件系统事件监听存在已知问题 |
| 解决 | npx next build –webpack |
| 验证 | 构建应在 1-3 分钟内完成 |
坑 2:prisma db push 删列警告
| 现象 | 加新字段时 Prisma 提示要删除 searchVector 列 |
| 原因 | searchVector 是原生 SQL 创建的 tsvector 生成列,不受 Prisma 管理 |
| 解决 | npx prisma db push –accept-data-loss |
| ⚠️ 警告 | 此参数会跳过所有破坏性变更,生产环境慎用,操作前必须备份 |
坑 3:改昵称后导航栏不同步
| 现象 | 用户改了昵称,导航栏仍显示旧值 |
| 原因 | 最初把昵称存进了 JWT 会话,DB 更新后会话不刷新 |
| 解决 | 会话只存 userId + role,展示数据通过 /api/me 从 DB 实时查 |
| 经验 | 会话存标识,数据实时查 —— 这条原则适用于所有认证系统 |
坑 4:专栏公开范围失控
| 现象 | 所有专栏创建后立刻公开,前台内容质量失控 |
| 解决 | 引入 DRAFT → PENDING → PUBLISHED 三级状态机 |
| 经验 | 凡是用户可创建、会出现在公开列表的内容,都应该有状态字段 + 审核流 |
🔧 故障排查速查表
| 502 Bad Gateway | Next.js 未启动 / 端口不对 | pm2 status + curl localhost:3000 |
| 页面白屏 | 构建产物缺失 / 客户端报错 | F12 控制台 + pm2 logs blog |
| 登录后仍显示未登录 | Cookie 未设置 / 域名不匹配 | F12 → Application → Cookies 检查 |
| GitHub 授权报错 | callback URL 不匹配 | 对比 GitHub 设置与 .env 中的 NEXT_PUBLIC_SITE_URL |
| 上传失败 | client_max_body_size 太小 | 改 Nginx 配置并 reload |
| DB 连接超时 | PG 未启动 / 密码错 | sudo systemctl status postgresql + psql -U blog |
| 字体/样式错乱 | _next/static 缓存失效 | 强刷 Ctrl+Shift+R |
| 图片 404 | 上传目录权限 / 路径错 | ls -la ./public/uploads 检查权限 |
九、总结与后续规划
9.1 从 Flask 到 Next.js 的收益
| SEO | 客户端渲染为主,抓不到 | RSC + SSG/ISR | 🔺 质变 |
| 首屏性能 | 一般 | Lighthouse 90+ | 🔺 显著 |
| 开发效率 | 模板改样式慢 | Tailwind + 组件化 | 🔺 显著 |
| 内容治理 | 无 | 状态机 + 审核流 | ✅ 从 0 到 1 |
| 用户体系 | 无 | OAuth + JWT | ✅ 从 0 到 1 |
最直观的感受:Server Components 默认渲染让 SEO 天然友好;Prisma 让数据库操作不用手写 SQL;文件路由不用再维护路由表。
9.2 V2.6 规划路线
- 评论系统:支持 Markdown + 楼中楼嵌套 + 敏感词过滤
- 点赞收藏:Redis 计数器 + 防刷策略
- RSS / Sitemap:让爬虫更友好
- 深色模式:CSS 变量主题切换(Tailwind dark: 类)
- 全文搜索:PostgreSQL tsvector + GIN 索引
- 图片 CDN:接腾讯云 COS,减轻服务器带宽压力
- 监控告警:接入 Sentry(错误)+ UptimeRobot(可用性)
🔗 项目地址
- GitHub:https://github.com/Wasd2490/Full_stack-_blog-_version2
- 技术栈:Next.js 16 · TypeScript · Prisma · PostgreSQL 16 · Tailwind CSS 4

附录:常用命令速查表
| 同步 DB 表结构 | npx prisma db push |
| 强制同步(跳过警告) | npx prisma db push –accept-data-loss |
| 生成 Prisma Client | npx prisma generate |
| 打开可视化 DB | npx prisma studio |
| 生产构建(Windows) | npx next build –webpack |
| 生成随机密钥 | openssl rand -base64 32 |
| PM2 启动 | pm2 start npm –name "blog" — start |
| PM2 查看日志 | pm2 logs blog |
| PM2 零停机重载 | pm2 reload blog |
| Nginx 语法检查 | sudo nginx -t |
| Nginx 重载 | sudo systemctl reload nginx |
| 签发 HTTPS 证书 | sudo certbot –nginx -d 域名 |
📝 声明:本文所有代码片段均来自真实项目,可直接复现。生产部署前请务必阅读「安全加固建议」一节。如果你在部署过程中遇到本文未覆盖的问题,欢迎交流。
👍 如果觉得有帮助,欢迎点赞 + 收藏 + 关注,后续会继续更新本项目的迭代实录。
网硕互联帮助中心





评论前必须登录!
注册