跳到主要内容
工程实践

从 Next.js 单体到 NestJS + MySQL:一次全栈重构的架构复盘

WEIPING2026年11月3日工程实践
创建时间: 2026年11月3日
从 Next.js 单体到 NestJS + MySQL:一次全栈重构的架构复盘

从 Next.js 单体到 NestJS + MySQL:一次全栈重构的架构复盘

一次重构的价值,不在于换了多少技术,而在于半年后还敢不敢改它。

先说结果

这个站点现在有 12 个公开页面路由、中英两套界面文案、236 个文案键;前端约 1.1 万行、后端约 5.4 千行 TypeScript/TSX(不含依赖和构建产物);33 个后端单元/集成用例、8 个 Playwright 端到端用例;一条从 git push 到健康检查、失败自动回滚的部署流水线。

首页那颗不断形变的 3D 雕塑,没有模型文件,也没有 HDR 贴图——它是浏览器里用一条数学公式实时算出来的。

这篇文章不复述“用了哪些库”,而是讲清楚四件事:为什么这么拆、每个关键选择换来了什么、付出了什么代价、哪些地方还没做好。

一、起点:单应用不够用了

最初它就是一个 Next.js 应用:页面在 app/ 里,文章是仓库里的 Markdown 文件。

这个形态在只有十几篇文章时很舒服,但它有几个绕不过去的问题:

  1. 发布文章等于改代码。 改一篇稿子要走一次完整的提交、构建、部署;没有草稿,没有历史版本,发错了只能回滚整个站点。
  2. 链接暴露了仓库结构。 文章地址是 /article/src/doc/小说/经卷背面.md 这种形式,读者能看到我的目录组织,重命名一个文件夹就会产生死链。
  3. 没有后台,也没有数据。 想改首页展示位要改代码;想知道哪篇文章有人看,只能猜。
  4. 内容、展示、管理耦合在一个进程里。 调整后台逻辑和调整页面渲染,风险和发布节奏被迫绑在一起。

所以重构的目标不是“上微服务”,而是把四件事变成系统能力:内容有生命周期、发布可回滚、运行可观察、密钥不进入代码库。

二、系统全景:四层,而不是一堆服务

层 选型 职责
前端 Next.js 16 App Router、React 19、next-intl、Tailwind 公开页面、双语路由、管理后台、SEO
接口 NestJS 11 + Fastify、Zod、Swagger 内容读写、认证、访问统计、媒体
数据 MySQL 8.4 + Prisma 6 文章、版本、项目、消息、访问明细
媒体 RustFS(S3 兼容对象存储)+ S3 客户端 SDK 封面与正文图片

请求路径很短:

浏览器
  └─ Nginx(TLS 终结,按路径分流到前端与接口)
       ├─ Next.js standalone(页面渲染)
       └─ NestJS + Fastify(REST API)
            ├─ MySQL(Prisma)
            └─ RustFS(对象存储)

为什么前端不直接把接口写在 Route Handler 里?因为内容系统需要的东西——会话、角色、审计、限流、状态机——放进 Next 的目录约定里会越写越像框架,而 Nest 的模块、守卫、管道、过滤器本来就是为这些边界准备的。前端只负责渲染和交互,接口只负责业务和数据,各自可以独立构建、独立排错。

为什么后端跑在 Fastify 上?Nest 默认的 Express 适配器也能用,但 Fastify 的请求生命周期更短、JSON 日志更规整,requestId 可以直接作为排障的主键。对一个单机部署的站点来说,这点开销差异不是决定性的,真正值钱的是日志里每条请求都能串起来。

数据层用 Prisma 处理日常 CRUD 和迁移,访问统计那类聚合则直接写 SQL。Prisma 的 groupBy 表达 COUNT(DISTINCT ...) 和时区转换并不顺手,这种时候没必要为了“统一”而绕路。

工程组织是 pnpm workspace + Turbo:apps/frontend、apps/backend、packages/*。Dockerfile 都是多阶段构建,前端用 output: "standalone" 把运行时缩到只带必要文件,前后端容器都以非 root 用户运行。

三、首页:用数学生成视觉主体

我不想在首页放一个下载来的 GLB。模型越精致,越像别人的作品;而且 3D 资源往往是首屏最大的负担。于是我把主体换成了运行时计算的曲面——Gielis 超级公式(superformula)。

3.1 一条公式生成一类形体

极坐标下,它长这样:

r(θ) = ( |cos(m·θ/4)|^n2 + |sin(m·θ/4)|^n3 )^(-1/n1)

m 控制角向的瓣数,n1、n2、n3 控制尖锐程度和凹凸。把经度、纬度各用一组参数算一次,再映射到球面坐标,就得到一个“supershape”。

公式来自 Johan Gielis;我最初的实现参考了 Paul Bourke 的参数化整理和 The Coding Train 的教程,然后按站点视觉重写。数学本身不新,值钱的是怎么把它变成一个有帧率预算、能交互、能降级的组件。

代码里就是一个不到十行的函数:

function superRadius(angle: number, p: SuperParams): number {
  const t = (p.m * angle) / 4;
  const base =
    Math.pow(Math.abs(Math.cos(t)), p.n2) +
    Math.pow(Math.abs(Math.sin(t)), p.n3);
  return Math.pow(base, -1 / p.n1);
}

我准备了四组参数,对应四种气质:锋锐星芒、圆球休止、方晶、花瓣星芒。它们之间的过渡不是切换模型,而是插值参数。

3.2 形变发生在同一个顶点缓冲里

几何体只创建一次:经度 160 段、纬度 80 段,共 161 × 81 个顶点、12,800 个四边形。每帧形变时,重新计算顶点位置写回同一个 Float32Array,而不是销毁重建 BufferGeometry。

const k = smootherstep(m.timer / MORPH_SECONDS);
const to = SHAPE_PRESETS[m.index];
writeSupershape(
  attr.array as Float32Array,
  lerpParams(m.from.lon, to.lon, k),
  lerpParams(m.from.lat, to.lat, k),
);
attr.needsUpdate = true;
writeGlow(geometry);

几个细节决定了它看起来是否“稳”:

  • 每次写入后按最大半径归一化,否则不同参数下形体忽大忽小,像在呼吸而不是在变形。
  • 缓动用 smootherstep,两端一阶、二阶导数都为零,起步和收尾没有突兀的顿挫。
  • 形变结束才刷新包围球,避免每帧做无用的射线检测准备。
  • 棱缝发光跟着重算:只有半径小于阈值的内凹区域才亮,圆球档位自然不发光。

3.3 金属感来自环境,不是贴图

金属材质最怕“大棱面整块发黑”。我没有下载 HDR,而是在代码里搭了一个极小的环境场景:顶部暖白柔光、左侧站点红、右侧冷蓝、背后橙色补光,然后用 PMREMGenerator 把它烘成环境贴图。成本是几张平面,收益是任何朝向都有反射层次。

棱缝的光也不是另加一层发光网格,而是往 MeshStandardMaterial 里注入一段 shader:

mat.onBeforeCompile = (shader) => {
  shader.uniforms.uGlowColor = { value: new THREE.Color("#ff8a2a") };
  shader.uniforms.uGlowStrength = { value: 0.6 };
  shader.vertexShader = shader.vertexShader
    .replace("#include <common>", "#include <common>\nattribute float aGlow;\nvarying float vGlow;")
    .replace("#include <begin_vertex>", "#include <begin_vertex>\nvGlow = aGlow;");
  shader.fragmentShader = shader.fragmentShader
    .replace("#include <common>",
      "#include <common>\nvarying float vGlow;\nuniform vec3 uGlowColor;\nuniform float uGlowStrength;")
    .replace("#include <emissivemap_fragment>",
      "#include <emissivemap_fragment>\ntotalEmissiveRadiance += uGlowColor * (vGlow * vGlow) * uGlowStrength;");
};

这样它仍然参与标准光照,只是在凹陷处多了一层自发光。flatShading 保证面片感,法线只作占位——真正的着色走屏幕空间导数。

3.4 交互:点击换形,拖拽旋转,松手回正

指针事件全部绑在 gl.domElement 上,状态放在 ref 里,不触发 React 重渲染:

  • 按下即停转,松手后角速度用 damp 缓慢回到基础自转;
  • 位移小于 6px 视为点击,切换形体;超过阈值才进入拖拽;
  • 横向拖拽映射到 Y 轴旋转,纵向可以整圈翻转,松手后俯仰朝最近的整圈角度回正,不会为了“回正”多转大半圈;
  • 用 setPointerCapture 保证指针移出画布后拖拽不中断。

这些都不复杂,但每一条都对应一种“手感不对”的具体表现。

3.5 降级是功能的一部分

  • dpr={[1, 1.75]}:高分屏清晰,但不为 3 倍屏付出 4 倍像素成本。
  • 只有后台把首页展示位切到「3D 模型」时,才 dynamic(..., { ssr: false }) 加载 three.js;默认页面不为它付首屏成本。
  • 系统开启 prefers-reduced-motion 时,frameloop 切到 demand,不自转、不形变、不脉动,只保留一张低亮度的静帧。
  • 轨道环、彗尾、珠点刻度盘都是实例化网格或基础几何体,没有额外纹理。

同样思路也用在「创意工坊」的心电监视器上:PQRST 波形是若干高斯函数叠加,WEIPING 粒子字先从离屏 canvas 采样再散开;主题切换时换整套调色板,页面不可见时暂停 requestAnimationFrame,减动效时只画静帧。创意和性能并不天然对立,前提是从一开始就把它们放在同一张预算表里。

四、内容系统:Markdown 是源,数据库是运行时

4.1 导入默认 dry-run

本地 Markdown 仍然是写作的源头,但它不再直接决定线上内容。导入脚本会:

  • 扫描 src/doc,保留来源路径、原文件名、修改时间、SHA-256、下载文件名;
  • 为每篇文章生成一个雪花 ID 作为对外标识;
  • 默认只预览,传 --apply 才写库;
  • 内容哈希没变的文章直接跳过,重复执行不会制造重复版本;
  • 每次导入记录一个 ImportBatch,成功、跳过、更新、失败都有汇总。

这样“把稿子放进仓库”和“把稿子发到线上”是两个可分离的动作。

4.2 公开链接为什么是雪花 ID

文件路径不适合做公开标识:它暴露目录结构、中文需要编码、重命名就断链;普通自增 ID 又会泄露发布量。雪花 ID 是 41 位毫秒时间戳 + 10 位机器号 + 12 位序列,时间纪元固定在 2025-01-01,生成过程不依赖数据库。

它在数据库里是 BigInt,在 JSON 和 URL 边界上转成十进制字符串。公开接口和页面只认这个 ID,sourcePath、sourceFilename 这类内部字段不会出现在响应里。

4.3 旧链接不抛弃

老文章地址形如 /article/src/doc/小说/经卷背面.md。我保留了 article/[...path] 兼容路由:拿路径去后端查已上架文章,命中就 308 到 /article/<雪花ID>,未上架或不存在则 404。查询同时兼容带和不带 src/doc/ 前缀的写法。

做兼容不是为了好看,是为了不让过去被搜索引擎收录、被别处引用的链接一夜之间全部失效。

4.4 版本、状态、审计

Article 不直接存正文,正文在 ArticleRevision 里,按 articleId + locale + version 唯一。每次保存都在事务里取当前最大版本号加一,再把 currentRevisionId 指向新版本。

状态只有三个:DRAFT、PUBLISHED、ARCHIVED。流转是否合法由服务端判断,不靠前端按钮隐藏。上架、撤回、归档、正文编辑都会写 AuditLog——谁、什么时候、把什么从什么改成了什么。

这套模型的好处是:历史版本永远可读,当前版本只有一个指针,出错时不需要靠备份猜。

4.5 编辑器:双模式,而不是二选一

后台编辑器有两种视图:所见即所得的 Tiptap,和 Markdown 源码。它们在同一个文档上双向同步。

几个容易踩坑、也确实花了时间的地方:

  • 预览必须和读者看到的一致。 编辑器预览直接复用公开页面的 marked 配置,不额外做一套“后台专用渲染”,否则“编辑时好好的,发出去样式变了”会变成常态。
  • 原始 HTML 一律丢弃。 只保留 Markdown 语法,避免把任意 HTML 送进 dangerouslySetInnerHTML。
  • 链接协议白名单。 只允许 http、https、mailto 和站内相对路径;javascript:、data: 之类的链接只渲染文字,不生成 <a>。
  • HTML 回写 Markdown 要保真。 用 Turndown 做反向转换,并为标题、表格、任务列表写了规则:表格单元格里的 | 要转义、换行要变成 <br>;标题里的 1. 不能被误转义成 1\.;任务列表要从 marked 的扁平 <li> 升级成 Tiptap 认识的 data-type 结构。
  • 代码高亮不污染源文。 编辑态的高亮由 Decoration 层负责,文档内容始终保持纯文本,回写时不会把高亮 span 带进 Markdown。

这些不是“编辑器功能”,是内容系统能不能长期用的细节。

4.6 媒体:不信任客户端给的类型

上传接口有单文件大小上限,但真正的类型判断靠文件头魔数,不靠 Content-Type:PNG、JPEG、GIF、WebP 各有自己的签名,声明与实际不符直接拒绝。

文件按 SHA-256 去重,相同内容不会重复占对象存储;元数据落 MediaAsset,读取时由后端从 RustFS 流式转发。

这里只有一套存储:RustFS 是服务端,minio 只是 NestJS 里的 S3 客户端 SDK。项目早期用的是 MinIO,后来迁到 RustFS;因为两者都讲 S3 协议,迁移时对象走 mc mirror、代码只改连接配置,前端 URL 完全不用动。保留这个 SDK 是为了少改代码,不是又引入了一套存储。前端的 next/image 只放行 /images/** 和 /api/v1/media 两类本地路径,避免被当成任意图片代理。

五、后端:把“不信任”写进默认路径

5.1 每个错误都有同一个形状

成功响应是 { data, statusCode, message },错误响应是 { statusCode, code, message, requestId, timestamp }。404 也走同一套结构,不会漏出 Fastify 的默认页面。

所有请求带 requestId;请求日志会先清洗 URL,去掉查询串里可能出现的令牌;authorization、cookie、set-cookie、x-api-key 在日志里直接移除。入参在边界上用 Zod 校验,校验失败返回 VALIDATION_ERROR 和具体字段。

这些做法的共同点是:默认不信任输入,也默认不把内部信息带回给调用方。

5.2 认证:数据库里没有明文 token

管理员密码用 Argon2id 哈希;登录成功后生成高熵随机 token,数据库只存它的 SHA-256。Cookie 是 httpOnly、生产环境 secure、sameSite=lax,并设有明确的过期时间。

每次请求由守卫按 token 哈希查会话:过期、用户停用、角色不是 admin,都会被拒绝;过期会话顺手删除。登录失败和成功都写审计日志,但日志里不会出现密码。

登录限流按 IP + 邮箱 计数,在短窗口内连续失败达到阈值即拒绝;成功一次就清零,避免正常用户被自己之前的失败拖住。

5.3 三种限流,三种语义

同样是限流,三个入口的行为并不一样:

  • 登录:超限返回 429,明确告诉调用方“慢一点”;
  • 联系表单:按 IP 在小时级窗口内限制提交次数,超限 429;
  • 访问统计:按 IP 设置分钟级上限,超限静默丢弃,仍然返回 204。

统计接口不能因为限流影响访客页面,也不能让埋点失败变成用户可见的错误;联系表单则必须让用户知道提交没有成功。语义不同,实现就不该一刀切。

三个限流器都用内存 Map,并且定期清理过期窗口——只增不删的 Map 会被伪造来源慢慢撑爆内存,这是个很容易被忽略的小问题。

5.4 访问统计:不引第三方脚本

公开页面在路由变化时用 sendBeacon 上报一次 PV。服务端签发一个长期有效的 httpOnly 匿名访客 ID 作为 UV 依据;页面传输字节取自 Navigation Timing 的 transferSize。

收集端做了几件事:

  • 用 UA 正则过滤常见爬虫和预取器,避免污染 PV/UV;
  • 用离线的 ip2region 库解析归属地,在写入时解析一次,统计时直接聚合,不重复计算;
  • 按东八区切分自然日,而不是按 UTC;
  • 内网 IP 单独汇总,保留明细供排查,但不进入任何图表和总量;
  • 聚合用 SQL 完成:按天 PV/UV/流量、Top 页面、地区分布、访客 IP 明细。

后台首页有一张流量折线图和一张访客地图,数据全部来自自己的库。代价是要自己维护聚合逻辑,收益是没有第三方脚本、没有跨站追踪,访问数据也不离开服务器。

六、部署:一条能回滚的流水线

推送 master 之后的完整链路:

git push
  └─ Gitee WebHook(HMAC-SHA256 签名 + 时间戳)
       └─ Nginx → 仅监听回环地址的 Node 监听器(systemd)
            └─ 部署锁(flock,同一时间只允许一个任务)
                 ├─ 只读部署密钥拉取代码,强制对齐 origin/master
                 ├─ 构建前后端镜像,标签 srv-<时间戳>-<短SHA>
                 ├─ 两个镜像都成功后才写 .release.env
                 ├─ compose up + prisma migrate deploy + seed
                 ├─ 健康检查就绪接口与首页,超时判定失败
                 └─ 失败则恢复上一版 .release.env 并重新拉起容器

几个关键点:

  • 签名校验用常量时间比较,时间戳超出允许窗口直接拒绝;监听器只绑定回环地址,不直接暴露公网。
  • 部署锁保证并发触发不会互相踩踏。
  • 先构建、后写 release 文件:构建失败时线上仍跑旧版本,不会出现“新镜像没建好、旧容器已经被替换”的窗口。
  • 健康检查失败自动回滚,但只回滚应用镜像;数据库迁移不做自动降级,这一点必须提前知道。
  • 镜像只保留最近若干个版本,避免服务器磁盘被历史版本吃满。
  • MySQL 每天固定时间备份,并保留一个恢复演练命令:从备份恢复到临时库,验证备份真的可用,而不是只看文件存在。

三个踩过的坑

1. Next 的 rewrites 在构建期固化。 BACKEND_API_URL 必须在 next build 时就与运行时一致,否则镜像里的路由表会指向错误地址。所以它同时作为 Docker build arg 和运行时环境变量传入。

2. standalone 产物不会自动带 public 和 .next/static。 Dockerfile 必须显式拷贝这三部分,否则线上页面能开、图片和样式全 404。

3. app 在 src/ 下时,代理文件也必须在 src/ 下。 Next 16 把 middleware 改名为 proxy;放在项目根会被静默忽略,表现为带语言前缀的页面正常、根路径 404。这个坑没有报错,只能靠端到端用例兜住。

七、质量:把“我觉得没问题”换成可执行断言

后端有 33 个单元/集成用例,覆盖限流窗口、客户端 IP 解析、飞书通知失败不阻塞、导入哈希幂等、Markdown 媒体重写、文章与项目服务、后台文章集成流程。

前端有 8 个 Playwright 用例,覆盖的是那些“看起来没事、实际很容易坏”的行为:

  • 匿名访问后台会跳登录页,后台 API 返回 401/403;
  • 公开文章链接必须是雪花 ID,页面里不能出现 .md;
  • canonical、hreflang、robots、sitemap 的站点源必须正确,真实域名下不能漏 localhost;
  • 无语言前缀的路径会按协商结果跳到 /zh 或 /en;
  • 首页在 1440×900 和 390×844 下不出现横向溢出;
  • 法务页面和品牌化 404 可访问;
  • 联系表单会真正落库——但这类会改数据的用例默认跳过,必须显式设置 E2E_ALLOW_MUTATION=1 和临时管理员凭据才执行。

最后一条很重要:端到端测试不应该在别人的生产环境里随手写数据。 默认只读、破坏性用例显式开启,是我更愿意长期保留的做法。

八、代价:这套东西贵在哪

这套架构不是没有成本的,诚实地说:

  • 组件变多了。 两个应用、一个数据库、一个对象存储、一条部署链路。单机部署能扛住,但排障时要多跳几层。
  • 内存限流只适用于单实例。 一旦横向扩容,登录和表单限流需要换成 Redis;现在是有意为之的简单。
  • 统计是同步写库。 每次 PV 都会 insert 一行,量大时应该改成批量或队列;目前站点的量级不值得提前优化。
  • 文章正文目前只有中文。 ArticleTranslation 的双语模型已经就位,公开接口仍只取 ZH;英文界面下面的文章还是中文,这是内容流程没跟上,不是模型不支持。
  • 备份还在同一台机器上。 有保留策略和恢复演练,但没有异地副本;真正的灾备还没做。
  • 自动部署只做构建和健康检查,不跑测试。 推送即上线很爽,但测试没有卡在部署前,这是下一步要补的门禁。
  • 3D 只在需要时加载。 它不是一个“所有设备都必须流畅”的功能,低端设备或减动效用户看到的是静帧——这是设计选择,不是妥协失败。

九、结语

我理解的“技术力”,不是用了多少新框架,而是三件事:

  1. 能说清楚每个选择的代价,而不只是收益;
  2. 让系统在无人值守的时候仍然按预期工作——构建失败不影响线上、健康检查失败能回滚、备份能恢复;
  3. 把“我记得”“我觉得”换成可执行的断言——测试、状态机、审计日志、健康检查。

这个站点还会继续改。但下一次改它时,我不需要先花半小时回忆半年前为什么这么做——代码、测试和部署脚本会替我记得。