Skill 方案 S3 · Next.js 构建发布与回滚
一句话:把「改完代码 → build → 重启 → 打不开 → 再 build」这条每次都要现场摸索的链路固化成一条命令,带原子切换和失败回滚;30 多次构建消耗的 0.35 亿 token 可压到 0.06 亿。
一、为什么需要它
两个月里 npm run build 加重启的组合出现 30 多次,集中在笔记站(3102)和词汇站(3001)。
| 指标 | 数值 | 说明 |
|---|---|---|
| 构建+发布次数 | 30+ | 笔记站占七成,词汇站三成 |
| 累计 token | 0.35 亿 | 平均每次 11 万 |
| 平均工具调用 | 6–10 次 | build 日志回读占大头 |
| 首次成功率 | 不到一半 | 多数栽在文件锁与删除拦截 |
| 单次墙钟 | 4–12 分钟 | Next 14.2.15 冷构建约 3–6 分钟 |
三个反复出现的坑,全部与"环境"有关,与业务无关:
.next被文件锁。next build会往.next/trace追加写,Windows 上偶发EPERM,未捕获的流错误会直接终止构建且不留有效日志。项目里已经有notes/scripts/trace-guard.js处理这件事,但每次仍有人用裸npx next build绕过它。rm -rf被 safe-delete 钩子拦。清.next时习惯性写rm -rf .next,钩子返回SAFE_DELETE_FAIL_CLOSED并整体失败。绕过钩子(cmd /c rd /s /q)是错的做法,会把可回滚的产物一起毁掉。- 构建前台跑、超时被杀。冷构建 3–6 分钟,前台命令经常撞超时,进程被回收后
.next处于半成品状态,下一次next start直接白屏。
二、触发词与边界
| 触发词 | 不适用场景 |
|---|---|
| 「构建一下」「build 一下」「发布笔记站」 | 只要重启不改代码 → S2 |
| 「重新打包」「上线」「改完代码了」 | 数据库 schema 变更 → 先 db:push 再回来 |
| 「构建失败」「.next 报错」「页面白屏」 | 改 Next 配置/依赖版本 → 单独评审 |
| 「回滚到上一版」 | 要恢复三个月前的版本 → 查 .next.prev-* 是否存在 |
边界:只管构建产物与服务进程,不动源码。构建失败时不允许"顺手改一行代码再试",那会让失败原因永久丢失,必须先把日志交出来。
三、输入与前置检查
| 项目 | 值 |
|---|---|
| Node | C:\Users\cag\.workbuddy\binaries\node\versions\22.22.2-6\node.exe |
| 笔记站根目录 | C:\Users\cag\WorkBuddy\2026-10-11-06-00-16\notes |
| 词汇站根目录 | C:\Users\cag\WorkBuddy\2026-10-11-06-00-16\kaoyan-vocab |
| 构建脚本 | npm run build(已带 --require ./scripts/trace-guard.js) |
| 服务端口 | 3102 / 3001 |
构建前必须过五道:
$R = "C:\Users\cag\WorkBuddy\2026-10-11-06-00-16\notes"
Set-Location $R
node -v
Get-PSDrive C | Select-Object @{n='FreeGB';e={[math]::Round($_.Free/1GB,1)}}
(Get-ChildItem .next -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum / 1MB
Get-NetTCPConnection -LocalPort 3102 -State Listen -ErrorAction SilentlyContinue
判定:Node 必须 22.x;剩余磁盘 > 3 GB(.next + 备份约 1.5 GB);3102 有监听是正常的,不要为了构建先停服务,构建与运行可以并存,停服务只发生在切换那一刻。
四、执行步骤
步骤 1 · 冻结当前产物。 先留一份可回滚的基线,并且保住构建缓存:
$ts = Get-Date -Format "yyyyMMdd-HHmmss"
if (Test-Path .next) {
Copy-Item .next\cache .next-cache-tmp -Recurse -Force -ErrorAction SilentlyContinue
Move-Item .next ".next.prev-$ts" -Force
}
用 Move-Item 而不是 Remove-Item,既绕开 safe-delete 钩子,又顺手完成备份。rm -rf .next 一律不写。
步骤 2 · 后台构建,日志落盘。
npm run build *> "build-$ts.log"
用后台方式执行,不要前台等:冷构建 3–6 分钟,前台必然撞超时。命令块里的 *> 会把 stdout/stderr 一起写进日志,避免"构建失败但没有输出"。
步骤 3 · 判定构建结果。 只看退出码不够,要同时看产物:
$LASTEXITCODE
Get-Content "build-$ts.log" -Tail 40
Test-Path .next\BUILD_ID
(Get-ChildItem .next\static -Recurse -File | Measure-Object).Count
成功标准:退出码 0、.next/BUILD_ID 存在、static 下文件数 > 50。三者缺一即视为失败,哪怕日志里写着 Compiled successfully。
步骤 4 · 原子切换。 把缓存放回新产物,再重启服务:
if (Test-Path .next-cache-tmp) { Move-Item .next-cache-tmp .next\cache -Force -ErrorAction SilentlyContinue }
Start-ScheduledTask -TaskName "KaoyanNotes"
Start-Sleep -Seconds 14
(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:3102/notes/ -TimeoutSec 10).StatusCode
切换窗口只有"重启"这十几秒,.next 本身在新旧目录之间是整体挪动,不存在半成品对外服务的中间态。
步骤 5 · 失败回滚。
Move-Item .next ".next.failed-$ts" -Force
Move-Item ".next.prev-$ts" .next -Force
Start-ScheduledTask -TaskName "KaoyanNotes"
Start-Sleep -Seconds 14
(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:3102/notes/ -TimeoutSec 10).StatusCode
回滚后必须再验一次 200,验不过说明问题不在产物而在服务或数据层,转 S2 排查。
步骤 6 · 缓存与 GC。 保留最近两份 .next.prev-*,其余超过 7 天的移入回收目录:
Get-ChildItem ".next.prev-*", ".next.failed-*", ".next-cache-tmp" -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
ForEach-Object { Move-Item $_.FullName "C:\Users\cag\WorkBuddy\_trash\next-$(Get-Date -Format yyyyMMdd)-$($_.Name)" -Force }
同样用移动代替删除,钩子不会拦,误删也能捞回来。
五、输出与验收
{
"project": "notes",
"commit": "no-git",
"buildId": "a1b2c3",
"elapsedSec": 214,
"artifacts": { "staticFiles": 318, "sizeMB": 92.4 },
"switch": "ok",
"rollback": null,
"health": { "local": 200, "public": 200 }
}
三条硬断言:
.next/BUILD_ID必须存在且与重启后页面注入的 buildId 一致——不一致说明服务读的是旧目录。- 切换后
http://127.0.0.1:3102/notes/与https://cag.quest/notes/都必须 200;只验本地会漏掉反代层。 - 失败时必须留下
build-*.log与.next.failed-*两份证据,缺任一份即视为流程违规,不允许重跑掩盖。
六、失败处理与回滚
| 现象 | 根因 | 动作 |
|---|---|---|
EPERM .next/trace | 裸跑 next build,没加载 trace-guard | 改用 npm run build,不要用 npx next build |
删除时报 SAFE_DELETE_FAIL_CLOSED | safe-delete 钩子拦截 | 改用 Move-Item 挪走,不绕钩子 |
| 构建进程被超时杀掉 | 前台执行 | 后台跑,日志重定向到文件 |
BUILD_ID 缺失但日志成功 | 构建中途被杀,产物不全 | 按失败处理,直接回滚 |
| 新产物 500 且日志有 Prisma 错 | 生成的 client 与 schema 不匹配 | 停 3102 → prisma generate → 重启(见 S2 步骤 3) |
| 回滚后仍 500 | 问题不在产物 | 保留 .next.failed-*,转 S2 查进程与端口 |
回滚只有一条路径:mv .next.failed-*、mv .next.prev-* → .next、重启、验 200。不要在失败状态下继续叠一次构建,那会覆盖掉唯一可用的基线。
七、收益测算
| 项目 | 现状 | 改造后 |
|---|---|---|
| 单次 token | 11 万 | 1.8 万 |
| 次数 | 30+ | 30+ |
| 累计 | 0.35 亿 | 0.06 亿 |
| 首次成功率 | 不到一半 | 90% 以上 |
| 单次墙钟 | 4–12 分钟 | 4–7 分钟(返工消失) |
省下来的不是构建本身,而是返工与日志回读:现状每次失败都要重新读一遍几十 KB 的构建日志、猜是锁还是钩子,一次就是 8–10 万 token。把 trace-guard、Move-Item、后台化、三条断言写死之后,失败也能一次定位。节省 0.29 亿,约 83%。