账号系统对标一线 · 多账号抽屉与外部通道的半开陷阱
入档: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 之后站点立刻回到一个可用状态,而不是变成半瘫。
这一族假成功信号在本库已经反复现形:云端定时内容生产连环坑复盘_全绿不等于已发_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 的注释里——把顺序写在配置文件旁边,比写在事后复盘里管用:
- 在发信服务商处添加发件域名,拿到 DKIM / SPF / MX 三条记录;
- 去 DNS 托管方加记录(本域 NS 在阿里云,记录加在云解析),等状态变 Verified;
- 最后才把 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 模式、服务空闲时并发写一条是安全的),但备份是先做的。顺带暴露一个产品缺口:站内没有「改邮箱」的入口,所以注册时填错邮箱的人,自助重置对他无效——自助流程的每一个前置数据项,都得有一条自助修改它的路,否则那条自助路对一部分人天然失效。
关联文档
- ⭐ 静态站接账号系统_内容归git状态归库的双源切分_v1 —— 直接上篇:那篇把账号系统接上静态站(内容归 git、状态归库),本篇把账号本身做到一线水准(分板块 / 多账号 / 自助重置),并首次给站点接了外部发信通道
- ⚠️ 零作品的比赛_界面演完不等于链路存在_v1 —— 同族最强呼应:那篇是”界面全都在但
submissions表 0 行”,本篇是”页面说信已发出但一封没发”;验收都要绕过界面去问最终产物 - ⚠️ 云端定时内容生产连环坑复盘_全绿不等于已发_v1 —— 同族:「全绿≠已发」与本篇「配置填了≠通道能用」是同一条规律的两个形态
- ⚠️ ping通不等于路通_fake-ip假信号与节点带宽实测选型_v1 —— 同族:永远绿的信号最没用;本篇的
selfServiceReset:true就是一个自说自话的绿灯 - 任务系统演进_站内新建管理台拆分首页瘦身_v1 —— 本轮续篇:管理台从一页拆成六页,门禁逻辑沿用本篇「功能开关接在能力验证上」;调价面板是新的运行时状态分离实例
- ⭐ 全站文字截断体检_检测工具本身要先被证伪_v1 —— 一体两面:那篇讲检测工具的判据本身要先被证伪,本篇讲再对的断言也覆盖不到观感,验证链里必须有”真的看一眼”这一步
- ⚠️ reduced-motion本机陷阱_动效降级纯淡入而非跳过_v1 —— 同族降级设计:那篇是动效降级要”退到纯淡入”而非跳过,本篇是通道不可用要”改口成人工兜底”而非照说不误
- 内测反馈渠道上线_匿名兜底与422探针验证_v1 —— 同族产品判断:登录墙该架在哪、哪些入口必须在登录之外也能用