Lee's Blog
  • 首页
  • 博客
  • 作品
  • 成长
  • 学习
  • 打榜
  • 练习
  • 关于
登录 / 注册
✦

保持联系

关注我的最新动态

GitHubbilibili

© 2026 Lee's Blog

首页/博客/博客开发/把博客搞崩两次的那次部署:一次 OTP 重构复盘
把博客搞崩两次的那次部署:一次 OTP 重构复盘
博客开发2026年8月1日8 分钟阅读

把博客搞崩两次的那次部署:一次 OTP 重构复盘

本以为只是删掉几个文件的事,结果部署上去 admin 500,然后用 npm install 在服务器现场装依赖,把博客搞崩了两次。复盘根因:deploy.sh 没把动态 import 的依赖打进 standalone bundle。

部署服务器维护
X微博

把博客搞崩两次的那次部署:一次 OTP 重构复盘

本以为只是删掉几个文件的事。结果部署上去 admin 直接 500,然后我用最差的修法——在服务器上 npm install——把服务器搞挂了两回。

背景:想把 magic-link 砍掉

这个博客原来用「邮箱 magic-link」登录——用户在邮件里点链接,跳回来自动登录。看起来优雅,实际有两坑:

  1. QQ Mail、Gmail、企业微信这些客户端打开邮件链接时,会用它们自带的 WebView 打开,cookie 跟主浏览器不共享
  2. 用户体验割裂:明明在浏览器里读邮件,点完链接还要再回原浏览器看是不是登录了

之前为这个 WebView 问题做过一次「跨浏览器配对握手」:邮件里嵌一个浏览器生成的 pairToken,主浏览器轮询,链接被点之后给主浏览器发 cookie。架构复杂度直接起飞,代码两千多行。

后来用户提需求:「别在邮件里放链接了,直接发 6 位验证码就行。」

我心想:这才对。然后开始动手。

要改的东西

按用户的需求应该只删几行。但实际上涉及:

  • 邮件模板去掉「一键登录」按钮,只剩 6 位码
  • /login 去掉 pairToken 生成 + localStorage + 跨浏览器 polling
  • 删除整条 verify 路由(处理链接点击的)
  • 删除整条 pair-status 路由(跨浏览器配对用的)
  • 删除 VerificationToken 表里的 pairToken / paired 列
  • /admin/login 改回用户名 + 密码(不再走邮箱 magic-link)
  • 新增 scripts/set-admin-password.js,给 admin 设密码
  • 更新 migrate-db.js 兼容新 schema

代码改了两千多行,删了 7 个死路由。本地 npm run build 过了,npm run lint 干净,自测一遍 OTP 流程没问题。

我以为万事俱备。

第一次部署:看上去很顺利

打包、上传、解压、重启 pm2,看日志:

[migrate] dropped "VerificationToken"."pairToken" + index "VerificationToken_pairToken_key"
[migrate] dropped "VerificationToken"."paired"
[migrate] creator.email cleared (admin now logs in via /admin/login)
[migrate] schema sync complete
✓ Ready in 0ms

标准流程。HTTP 200。

然后试着用 admin 账号登录——

HTTP/2 302
location: /admin/login?error=CredentialsSignin

admin 登录挂了。NextAuth 静默返回 CredentialsSignin,日志里没堆栈。

根因:bcryptjs 漏打包

去看 NextAuth 的 credentials provider:

authorize: async (credentials) => {
  const { compare } = await import('bcryptjs')
  const user = await prisma.user.findUnique({ where: { username } })
  if (!user || !user.password || user.deletedAt) return null
  const valid = await compare(credentials.password, user.password)
  if (!valid) return null
  // ...
}

哦。await import('bcryptjs')。

Next.js standalone 构建会把静态导入的依赖自动 trace 进去,但 await import('bcryptjs') 是动态导入,静态分析看不到,于是被打包时漏掉了。

正确的修法:改 deploy.sh,显式把 bcryptjs 拷进 bundle。

cp -r node_modules/bcryptjs "$DEPLOY_DIR/node_modules/bcryptjs"

但我没意识到这个根因。

第一次崩溃:npm install 把 sshd 卡住了

直觉告诉我:bcryptjs 没装。我 SSH 进服务器,在 deploy 目录里跑:

npm install --omit=dev --no-save bcryptjs

这是我犯的第一个错。

服务器在 Aliyun 国内机房,拉 npm 默认 registry 本身就慢。这次不知道为什么卡得特别死。npm install 一直不退出,SSH channel 被它占着,新连接根本进不来。

我等了几分钟,看到 CPU 飙到 70%。再等,SSH 开始 timeout。访问网站也打不开了——HTTPS 502。

永远不要在生产服务器上跑 npm install。

服务器上的依赖应该跟着 deploy 一起打包过去。缺了什么东西就改打包脚本,不要在服务器上现场装。

第二次崩溃:我以为 systemctl start nginx 能救场

用户那边反馈网站彻底打不开了,没办法,重启了服务器。重启之后我 SSH 进去:

  • pm2 自动拉起了 lb-blog,监听 127.0.0.1:3001 ✓
  • curl http://127.0.0.1:3001/login 返回 200 ✓
  • 但 curl https://blog.xxx.cyou/login 还是挂 ❌

我看到 systemctl status nginx 是 inactive (dead),以为 nginx 没起:

systemctl start nginx

失败:/etc/nginx/nginx.conf not found。

但这没救回网站。更糟的是这次操作又触发了什么,SSH 又开始拒绝新连接。用户又得重启一次。

宝塔面板不是 systemd 管的 nginx。

宝塔用 s6-supervise 跑 nginx,配置文件在 /www/server/nginx/conf/,二进制在 /www/server/nginx/sbin/nginx(宝塔自编译,支持 stream 指令)。

我跑的 systemctl start nginx 跟实际在跑的 nginx 一点关系都没有。systemd 那个 service 单元是 Ubuntu 默认装的,从来没被启用过。

真相:s6 启动的是错的 nginx

重启之后我终于摸清楚:

路径 角色
/usr/sbin/nginx Ubuntu 自带 nginx 1.18,默认配置 /etc/nginx/nginx.conf
/www/server/nginx/sbin/nginx 宝塔自编译 nginx 1.28,支持 stream,配置 /www/server/nginx/conf/nginx.conf

pm2-logrotate、svc-nginx 这些 s6 service 启动的是前一个——Ubuntu 自带的。它没找到 /etc/nginx/nginx.conf,于是加载了一个空配置,没监听任何端口。所以你访问 443,根本没人接。

正确做法:

/etc/init.d/nginx start
# 或
/www/server/nginx/sbin/nginx -c /www/server/nginx/conf/nginx.conf

跑完之后 HTTPS 立刻恢复,监听 80/443,proxy_pass 到 127.0.0.1:3001。

真正的修复:补 deploy.sh

网站活了,但 admin 登录还是 CredentialsSignin。这次我终于看清了:bcryptjs 在 deploy 产物里就是没的。

不是服务器的问题,是我打包时就漏了。所以正确的修法不是现场装,是改 deploy.sh:

# bcryptjs is used by /admin/login + /api/admin/password for admin password
# auth. Next.js standalone doesn't auto-trace dynamic imports of it, so we
# have to copy it explicitly. Without this, admin login throws
# "Cannot find package 'bcryptjs'" at runtime (visible as CredentialsSignin).
cp -r node_modules/bcryptjs "$DEPLOY_DIR/node_modules/bcryptjs"

下次部署自带,再也不缺。

教训

1. 服务器上能用的命令集,比你想的小得多

特别是用了宝塔面板这种「自带 nginx、s6、特殊目录布局」的环境:

  • systemctl 管的 service 大概率跟你以为的不是同一个
  • apt 装的包可能跟宝塔自编译的二进制撞版本
  • 配置文件路径不在 /etc/ 下,而在 /www/server/...

动手之前先 ps auxf 看清楚进程树、谁管谁、再决定用什么工具。

2. 缺依赖就改打包脚本,别现场装

npm install 在生产服务器上跑是个反模式:

  • 网络环境差,慢
  • 可能跟 deploy 里已有的版本冲突
  • 会偷偷下载 devDependencies
  • 拉一堆你不需要的包
  • 卡死 SSH channel 的概率极高

deploy.sh 应该是个白名单:列出所有需要的依赖,显式 cp 进 bundle。这次 bcryptjs 漏了,下次可能就是别的。

3. 部署完必须真的登录一次

我之前在本地 npm run dev 测过 admin 登录,过了。但 npm run dev 加载的是完整 node_modules,npm run build + standalone 加载的是裁剪过的子集——两个环境的依赖图不一样。本地过了不代表 prod 过。

现在我在 deploy.sh 之后加了一段验证:

# 真登录一次 admin + 跑一遍 OTP,不通过就 fail deploy
ssh $HOST "node scripts/verify-deploy.js"

部署完跑一次,比事后救火强。

这次发版改了什么

删除:

  • src/app/api/auth/public/verify/route.ts — 邮件链接落地
  • src/app/api/auth/pair-status/route.ts — 跨浏览器配对
  • src/app/api/auth/request-link/route.ts — admin 邮箱 magic-link
  • src/app/api/auth/verify/route.ts — admin magic-link 落地
  • src/app/api/auth/options/route.ts — 用来控制是否显示密码登录
  • src/app/post-verify/page.tsx — magic-link 落地的中转页
  • src/app/login/success/page.tsx — OTP 成功页(现在改成客户端内联渲染)

重命名:

  • src/app/api/auth/public/request-link/ → request-code/

新增:

  • scripts/set-admin-password.js — 给 admin 设/重置 bcrypt 密码

修改:

  • src/lib/magic-link.ts — 砍掉 token / pairToken / paired 相关函数,只留 OTP
  • src/lib/email.ts — 邮件模板只剩 6 位码,字号还大了
  • src/lib/auth.ts — 砍掉 magic-link / magic-code provider,只剩 credentials
  • src/app/login/page.tsx — 砍掉 pairToken 生成、localStorage、双 polling
  • src/app/admin/login/page.tsx — 只剩用户名 + 密码
  • scripts/migrate-db.js — 新增 dropColumnAndIndex,加 creator.email 清空逻辑
  • prisma/schema.prisma — VerificationToken 删掉 pairToken / paired
  • deploy.sh — 加一行 cp bcryptjs

VerificationToken 表瘦了一圈,邮件模板瘦了一圈,登录路径也瘦了一圈。但这一圈改动差点把博客送走——值得记一下。

后记:阿里云 DirectMail 的 AK secret 在对话里已经泄露过,下次更新时记得去 RAM 控制台禁用旧 AK,新建一对替换。

← 上一篇当提示词成了产品——一次多人协作 Prompt 设计的复盘(AI自己写的认罪书)下一篇 →TOC 悬浮折腾记:6 次 commit 才彻底搞定

相关文章

  • 一次登录 / 资料 / 头像的全面体检——5 个用户报告 + curl 抓出的真问题约 9 分钟
  • 用户登录后看不到自己的头像——一次 DOM 顺序撞车约 5 分钟
  • TOC 悬浮折腾记:6 次 commit 才彻底搞定约 6 分钟

评论

需要登录账号才能发表评论。

  • 加载中…

本页内容

  • 把博客搞崩两次的那次部署:一次 OTP 重构复盘
  • 背景:想把 magic-link 砍掉
  • 要改的东西
  • 第一次部署:看上去很顺利
  • 根因:bcryptjs 漏打包
  • 第一次崩溃:`npm install` 把 sshd 卡住了
  • 第二次崩溃:我以为 `systemctl start nginx` 能救场
  • 真相:s6 启动的是错的 nginx
  • 或
  • 真正的修复:补 deploy.sh
  • bcryptjs is used by /admin/login + /api/admin/password for admin password
  • auth. Next.js standalone doesn't auto-trace dynamic imports of it, so we
  • have to copy it explicitly. Without this, admin login throws
  • "Cannot find package 'bcryptjs'" at runtime (visible as CredentialsSignin).
  • 教训
  • 1. 服务器上能用的命令集,比你想的小得多
  • 2. 缺依赖就改打包脚本,别现场装
  • 3. 部署完必须真的登录一次
  • 真登录一次 admin + 跑一遍 OTP,不通过就 fail deploy
  • 这次发版改了什么