发布于 更新于 约 10 分钟读完 · 3,781 字

本文目录 12 节
  1. 一句话总结
  2. 背景与事实
  3. 六条可复用 insight
  4. 1. 半开比关着更糟(核心)
  5. 2. 对外一句话,对内留全量
  6. 3. 一柜多抽屉:本机多账号的会话模型
  7. 4. 登录与注册分成两页,分的是两种心态
  8. 5. 断言测行为,截图测观感——两者不可互替
  9. 6. 权限边界决定分工,交接靠一份带「不要做的事」的 runbook
  10. 上外部发信通道的顺序清单
  11. 三条顺手教训
  12. 关联文档

账号系统对标一线 · 多账号抽屉与外部通道的半开陷阱

入档:2026-07-28 来源:蛛网之上(tiaozhuxiansheng.com)账号系统升级 + 自助重置密码上线(above-the-web 仓库 ce2e287…29175e4),同日续 静态站接账号系统_内容归git状态归库的双源切分_v1 状态:已上线;服务端回归 27 项、真浏览器 19 + 17 项全过,线上以一次真实的「忘记密码」请求收口(审计 mailed:true)

一句话总结

功能开关要接在「能力被验证过」上,不能接在「配置填了」上。 发信通道半开时,页面照常说「信已经发出去了」而信根本没发出去——这种半开状态比功能压根没上线更糟:它把失败伪装成了成功,用户干等,站长无感。

背景与事实

  • 起点:上一篇把账号系统接上了静态站,但账号本身还很粗——登录和注册挤在 /account/ 一个页面的两个 tab,退出登录要翻到个人中心底部,换个账号得先退再登;忘了密码只能找站长人工改。
  • 本次交付:登录 / 注册 / 找回密码 / 设新密码各自成页,/account/ 收敛成登录后的个人中心(概览 · 资料 · 安全);顶栏账号菜单支持切换账号与退出登录;个人中心「安全」能看登录设备、单独踢掉某一处或退出所有设备;自助重置密码全链路(发信 → 一次性链接 → 改密 → 全设备下线)。
  • 验证链:服务端 node:test 起真 http + 临时 sqlite 27 项(含把发信打到一个本地假 Resend 上,验真实 HTTP 载荷) → 真浏览器(Playwright 驱动本机 Chrome)19 + 17 项 → 上线后用真实请求 + 审计记录收口。
  • 分工:域名验证那一步由能操作浏览器的助手(Claude 桌面版 + Chrome 插件)在两个控制台点完,本会话只做服务器侧与代码侧。

六条可复用 insight

1. 半开比关着更糟(核心)

上线发信通道时的实际顺序是:拿到 API key → 写进服务器 .env → 重启 → 站点的 selfServiceReset 开关翻成 true。然后才发现发件域名还没验证,服务商一律 403 domain is not verified。

此时站点处于最坏的一种状态:用户在「忘记密码」页填了邮箱,页面照常回「信已经发出去了,60 分钟内有效」,而信一封都没发出去。用户会一直等、翻垃圾箱、怀疑自己填错了邮箱;站长这边毫无动静。没有这个功能的时候,页面老老实实写「站内暂时不发信,找站长人工重置」,反而是可用的。

处置:立刻把 key 清空、重启,退回人工兜底那条路;等域名验证通过再填回去。

沉淀成两条可执行的判据:

  • 开关由能力探测驱动:selfServiceReset 该由「通道确实能发出去」决定,而不是由「配置项非空」决定。本次代码里它取的是 apiKey && from 都非空——这只是”配置填了”,离”能用”还差一个域名验证。
  • 降级路径必须先写好、且是诚实的:通道不可用时,页面要改口而不是照说不误。本次这条做对了(没配 key 时 /account/forgot/ 自动变成「找站长人工发链接」,管理台每个用户旁边有「重置链接」按钮),所以摘掉 key 之后站点立刻回到一个可用状态,而不是变成半瘫。

这一族假成功信号在本库已经反复现形:2026-07-03_云端定时内容生产连环坑复盘_全绿不等于已发_v1 的「全绿≠已发」、零作品的比赛_界面演完不等于链路存在_v1 的「界面演完≠链路存在」、ping通不等于路通_fake-ip假信号与节点带宽实测选型_v1 的「ping 通≠路通」。今天多一个变体:配置填了≠通道能用。共同的解法都是同一句:别信中间信号,去问最终产物(数据库里多了行没有?收件人收到没有?)。

2. 对外一句话,对内留全量

「忘记密码」接口有条铁律:不管这个邮箱在不在库里,回的东西必须一模一样,否则它就成了「查某个邮箱注册过没有」的探测器。本次实现里连发信失败都被裹进同一句回答。

但这条安全设计和上一条撞了——防枚举的沉默,和故障的沉默,是同一种沉默。信发不出去时,用户看到的和一切正常时完全一致,站长也就永远不会知道。

解法不是放弃沉默,而是把两条通道分开:

  • 对外(HTTP 响应):永远同一句,连状态码都一样;
  • 对内(日志 + 审计表):每一次请求都记一条,带 mailed: true/false、provider、失败原因。

上线后的收口正是靠这条:发一次真实请求,然后去审计表里确认那行是 mailed:true 而不是 mailed:false——如果没有这条对内记录,“发出去了”就只是我方的一厢情愿。

同族的还有两条克制:同一账号一小时最多 3 封(挡「拿别人注册过的邮箱刷他收件箱」)、同一时间只有一张有效票(新发一张,旧的没用过的立刻作废)。

3. 一柜多抽屉:本机多账号的会话模型

「切换账号」不是 UI 糖,它要求本地会话模型从「一个 token」改成一柜多抽屉:

localStorage: { active: <userId>, list: [{ userId, token, expiresAt, user }, …] }

活跃的只有一个(active 指针),切号 = 换指针。这个模型一改,四个行为跟着自然落位:

  • 退出登录只丢当前这把抽屉,本机其余账号照旧;服务端也只吊销这一个会话,别的设备不受影响。
  • 退出所有设备必须是一个显式动作(接口上是 {all:true}),而不是退出登录的副作用。
  • 令牌失效时只丢那一把抽屉。原来的写法是 401 就 clearSession() 清空整个登录态——多账号之后这会误伤:切到 A 号发现 A 的令牌过期了,顺手把还好好的 B 号也登出了。正确做法是拿”挂掉的那个 token”去匹配,只删它。
  • 「移除」与「退出登录」是两件事:移除只把令牌从这台机器上抹掉(公用电脑清干净用),不动服务端会话;退出登录才连服务端一起吊销。UI 上要分开写清楚,否则用户以为自己”退了”其实没退。

代价很小:每个令牌在服务端本来就是一条独立会话记录,多账号只是让客户端同时持有几把。收益是”换个号看看”从「退出→重输密码」变成「点一下」。

4. 登录与注册分成两页,分的是两种心态

把两个表单拆成两个页面,不是排版偏好:

  • 登录页服务的是「我已经有身份,让我回去」——所以它该做的是减少输入:本机存过的账号直接列出来一点就进(免密,走的是抽屉里的令牌)、密码可见开关、大写锁定提示、next 回跳。
  • 注册页服务的是「我还不知道要不要开这个号」——所以它该做的是解释与校验:为什么要账号、要不要手机号、隐私边界在哪;用户名格式、邮箱、两次密码一致、密码强度全部当场反馈。

挤在一个页面的两个 tab 里时,这两套诉求会互相稀释,而且像「已经登录着又点了登录页」「要再加登一个账号(?add=1)」「登录后回到哪儿」这些状态没有地方放。拆开之后每页只有一条主线,状态机反而变简单了。

5. 断言测行为,截图测观感——两者不可互替

本次两个 bug,测试全绿,是看截图看出来的:

  • [hidden] 属性被自己写的 display 覆盖。.atw-form label { display: grid } 的优先级高于 [hidden] 自带的 UA 样式 display:none,于是注册表单里那个「邀请码」字段一直露在外面——而它本该只在服务端要求邀请码时出现。这个 bug 在上一轮就存在,26 项测试全绿,因为所有断言查的都是”元素在不在、值对不对”,没有一条断言问”它看起来在不在”。修法是全局兜一条 [hidden] { display: none !important; }。
  • 进行中的提示被显示成错误。提示函数写成 say(text, ok),用一个布尔表达状态,于是「登录中…」「保存中…」这类进行中的话被归进 error 分支,在页面上是红的。三态(进行中 / 成功 / 失败)硬塞进布尔,必然有一态被挤到错误的那边。修法是换成具名口吻 busy / ok / error。

断言测的是行为,截图测的是观感,谁也替代不了谁。 凡是有视觉状态的功能,验证链里必须有一步是”真的看一眼”——本次还顺带看出日期在窄格子里断成两行、同一句错误提示在字段下和表单底重复出现两处。

这条与 全站文字截断体检_检测工具本身要先被证伪_v1 是一体两面:那篇说”检测工具的判据可能是错的”,这篇说”再对的断言也覆盖不到观感”。

6. 权限边界决定分工,交接靠一份带「不要做的事」的 runbook

本次卡在域名验证:那一步要在两个控制台(发信服务商 + DNS 服务商)点击操作,SSH 进不去;而本会话手里有服务器权限、没有浏览器。硬凑的办法是要一对 DNS 服务商的长期 AccessKey——为三条 DNS 记录发一把长期钥匙,权限代价远大于收益。

实际做法:写一份 runbook 交给能操作浏览器的助手。这份文档里真正值钱的不是步骤,是边界:

  • 起点写清楚(哪几条记录已有、哪几条没有、DNS 托管在哪家);
  • 完成的判据可自查(控制台状态 + 命令行能查到);
  • 「不要做的事」单独成节:不改任何现有记录(根域 TXT 可能有别的站点验证、根域 MX 可能是企业邮箱在用)、不动 NS、不碰 API key 不登服务器、遇到要手机验证码就停下来问人;
  • 做完回报什么、交回给谁接着做(填 key、重启、发真实测试信)。

回来的反馈直接可用,而且对方还补了两条我方不知道的现场信息(默认解析线路怎么选、他那边环境没有 dig 只能用服务商自己的校验结果)。跨会话协作的质量,取决于交接文档里”边界”和”判据”写得有多具体,而不是步骤有多详细。

上外部发信通道的顺序清单

顺序反了就会掉进 insight 1。写进了 .env.example 的注释里——把顺序写在配置文件旁边,比写在事后复盘里管用:

  1. 在发信服务商处添加发件域名,拿到 DKIM / SPF / MX 三条记录;
  2. 去 DNS 托管方加记录(本域 NS 在阿里云,记录加在云解析),等状态变 Verified;
  3. 最后才把 API key 填进服务器配置并重启。

三条顺手教训

  • 受限 key 查不了管理接口,探活只能靠真发一封。 本次用的是「只允许发信」的受限 key(least privilege,该这么用),代价是调不了域名列表接口,没法先查域名验证状态——只能真发一封,靠它返回 403 domain is not verified 反推。权限收得越紧,越要提前想好”怎么探活”。
  • 带显示名的发件人要加引号:ATW_MAIL_FROM="站名 <no-reply@example.com>"。systemd 读 EnvironmentFile 时会自己去掉引号,不加则空格与尖括号的解析要看具体实现,别赌。改完可以用 tr '\0' '\n' < /proc/<pid>/environ 确认进程里拿到的是什么——配置文件里写了什么不算数,进程里拿到什么才算数。
  • 改生产库前先备份、且别赌能停服务。本次要改一条账号记录,systemctl stop 因权限策略被拦,于是不停服务直接写(库是 WAL 模式、服务空闲时并发写一条是安全的),但备份是先做的。顺带暴露一个产品缺口:站内没有「改邮箱」的入口,所以注册时填错邮箱的人,自助重置对他无效——自助流程的每一个前置数据项,都得有一条自助修改它的路,否则那条自助路对一部分人天然失效。

关联文档

类型/平台工程