云计算百科
云计算领域专业知识百科平台

从Flask到Next.js博客V2.5重构实录

🌸 从 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?

维度SQLitePostgreSQL我们的需求
并发写 单文件锁 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 的收益

维度V1 (Flask)V2.5 (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 域名

📝 声明:本文所有代码片段均来自真实项目,可直接复现。生产部署前请务必阅读「安全加固建议」一节。如果你在部署过程中遇到本文未覆盖的问题,欢迎交流。

👍 如果觉得有帮助,欢迎点赞 + 收藏 + 关注,后续会继续更新本项目的迭代实录。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 从Flask到Next.js博客V2.5重构实录
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!