Skip to content

2026-09-13 迭代 TC 离线预跑报告(2026-09-12) ​

  • 目标用例集:tc.md §4–§6
  • 执行环境:https://nexo-auth-test.snailuu.cn(nexo-auth:sha-489b5825)+ https://nexo-im-api-test.snailuu.cn(nexo-im-api:sha-1a14a8d2)
  • 服务器:139.199.173.121,部署目录 /opt/nexo-test/nexo-{auth,im-api}
  • 工作分支:docs/issue-67-preflight(base = docs/issue-67-acceptance-tc)
  • 执行边界:只在本 docs worktree 归档;夹具账号一律用精确 id 清理,未做无 WHERE 的 UPDATE/DELETE
  • 采集脚本与原始输出:evidence/scripts/、evidence/logs/

1. 结论先行 ​

2026-09-12 晚已追加 UI 补跑,本报告结论随之更新,见 §9。 最大的变化:P08 由阻塞转通过,B04 一度由通过转为不通过(安全准入项), 修复后已复测通过,见 §9.5。

17 条用例中 16 条通过、1 条阻塞(含 UI 补跑与 B04 修复复测后的最终计数),另有 3 项实现/体验偏差:

分组通过不通过阻塞
主路径 P01—P088(UI 补跑后全部通过)00
边界与安全 B01—B076(B04 修复后复测通过)01(B07,缺迁移前快照)
非功能硬阈值 N01—N033(三项硬阈值全部达标)00

三项硬阈值实测全部达标,且都取的是服务端口径(而非网络往返):

用例判定项实测采样量
N01通知发出 → 连接关闭 ≤150ms端到端上界最大 12ms(段 B 精确值最大 1ms)21 轮 ×(20 轮单连接 + 1 轮三连接)
N01后续接口 401 阻断 ≤1ms服务端最大 1ms21 次采样
N02GET /internal/users?ids= P99 ≤50ms热组 P99 7.81ms、冷组 P99 11.31ms、满批 200 P99 14.00ms各组 ≥200 次
N03Dry-Run 校验 ≤1s最大 47.6ms5 轮 × 500 行
N03入库单事务 ≤1s服务端 txMs 最大 126ms5 轮 × 500 行

2. 环境阻塞与降级说明(务必先读) ​

2.1 控制台未部署 → 三条用例降级为 API 级(已于 09-12 晚解除) ​

状态更新:控制台已部署到测试环境(https://nexo-user-console-test.snailuu.cn), 本节列的 UI 判定已全部补跑完毕,见 §9。下文保留预跑当时的原始记录。

预跑执行时 nexo-user-console 尚未部署(该仓 CI 因缺 GitHub Secrets 跑不起来)。因此:

  • P05:后端契约(单事务停用 + 吊销全部 Session + 发通知)已实测;防误触确认框的禁用态/可点态截图未验证。
  • P08:后端契约(Dry-Run 逐行报告、单事务入库、导出 CSV、超限拒绝)已实测; 上传界面、逐行标红、一键导出按钮未验证。
  • B03:后端对四类绕过的拒绝已实测;前端禁用态截图未验证。
  • P07:【重新启用】按钮未验证,API 已实测。

以上四条在 §8 记录里逐条标注「UI 未验证,阻塞于控制台部署」,未按通过记入 UI 判定。

2.2 预跑时修复的测试环境配置缺陷(非产品缺陷) ​

预跑首次执行 N01 时,认证中心→IM 的停用通知全部投递超时(单次请求 4s,4 次重试全败), 连接只在 60 秒轮询兜底时才断。根因:

AUTH_USER_DISABLED_WEBHOOK_URL 在 /opt/nexo-test/nexo-auth/.env 中缺失,
compose 默认值回落到 http://nexo-im-api:3000/...(正式环境的容器名),
测试网络 nexo-test 里解析不到该主机名。

AUTH_REVOCATION_WEBHOOK_URL 是同文件里显式配了 nexo-im-api-test 的,唯独 AUTH_USER_DISABLED_WEBHOOK_URL 漏了——两个变量在 .env 里只配了一个。已补配并重建容器(改动前备份到 .env.bak-before-disabled-webhook), 随后 N01 21 轮全部达标。这条已开 issue 跟踪(见 §5),因为它是部署清单层面的缺口: config-matrix.md 把该变量标为「可选」,但漏配的后果正是 BUG-1 要修的那个现象。

3. 采集口径(三项硬阈值) ​

3.1 N01 停用断连 ​

<preflight> 三段时间分别取:

段口径含义
A认证中心日志 <-- POST /api/users/{id}/disable → IM 日志 <-- POST /internal/users/{id}/disabled上界(真实「通知发出」晚于左端,因为左端还含停用事务)
BIM 日志 <-- POST /internal/users/{id}/disabled → 收到账号停用通知 ... closed=N精确值,IM 侧三步(失效缓存→记吊销→掐线)同步执行且都在响应前
端到端上界A + B服务端 「收到停用请求 → 连接关闭」

另有一路客户端观测:采样脚本跑在 IM 容器内(与两服务同处一个 docker 网络), 从发出 POST /disable 到收到 WebSocket 关闭帧。它含 docker bridge 往返, 因此是一个比服务端口径略大的上界,用来做交叉验证。

采样 21 轮(TC 要求 ≥20),其中 1 轮为单账号 3 条并发连接。

3.2 N02 批量拉取 ​

采样在 nexo-im-api-test 容器内发起(http://nexo-auth-test:3001), 与认证中心同处一个 docker 网络:本机经 SSH 隧道采样会把隧道抖动混进来, 首轮采样出现过 954ms 的单点尖峰,而同期服务端日志记录的处理耗时是十几毫秒。 容器内采样后,客户端数值与服务端口径一致,尖峰消失(热组 max 74ms 为单次 GC 抖动)。

服务端口径另从认证中心日志取 --> GET /internal/users?ids=... 200 Nms。 注意:结构化日志对超长 path 会截断(尾部 …),因此服务端数值只能覆盖批 3 两组, 批 20/100/200 的服务端数值拿不到——已在日志里如实标注,对应的客户端采样是完整的。

3.3 N03 批量导入 ​

TC 的判定项是"入库单事务 ≤1s",因此判定取认证中心自己记录的 txMs (日志 批量导入完成 ... txMs=...)。同一批次的请求端到端耗时另记:实测 ~11.5s, 差异来自 batch-import-service.ts 刻意把 500 次 scrypt 派生(单次约 24ms)放在事务外。 这一项必须在会上讲清楚:事务本身 126ms 达标,但控制台管理员实际等待约 11.5 秒。

4. 预跑发现的实现偏差 ​

4.1 N03 关联:500 行批次的激活凭据 TTL 比 24h 少约 12 秒 ​

tc.md P01 的判定项写的是 expires_at - created_at 为 24 小时(±1s)。 单人建号场景实测为 86400(通过),但500 行批次实测为 86388 秒,偏差 12 秒,5 轮样本之外另取一次 500 行批次复核,500 张票的该值全部一致为 86388。

SELECT round(extract(epoch FROM (expires_at - created_at)))::int
  FROM account_tickets → 86388(500/500 张一致)

根因(代码事实,非推测):prepareCredentials() 在函数开头取 expiresAt = new Date(Date.now() + 24h),随后才在事务外算完 500 份 scrypt 派生 (实测这部分约 11.4s),事务内的 created_at 取 now()。差值因此等于 24h 减去派生耗时。

影响:激活链接的实际有效期比票面少约 12 秒;对 24 小时量级的时效判定无实质影响, 但 tc.md「±1s」这条判定项在批量路径上不成立。

处置建议(不在本次预跑范围内,交由实现仓决定):把 expiresAt 移到事务内、或改为在 派生完成后取时刻。若不改,则应在 tc.md 里把该判定项限定为"单人建号路径"。

证据:evidence/logs/ticket-drift.json。

5. 待回填的 issue ​

编号标题类型证据
nexo-auth#75测试环境 .env 缺 AUTH_USER_DISABLED_WEBHOOK_URL,停用通知静默降级为 60s 轮询部署清单(config-matrix.md 标"可选",但漏配直接触发 BUG-1)evidence/logs/n01-p06.json + 本文 §2.2
nexo-auth#74500 行批量导入的 activation ticket 实际 TTL 为 86388s(少 ~12s)实现偏差evidence/logs/ticket-drift.json + 本文 §4.1
nexo-auth#76 ✅ 已修复(#77)管理员停用的存量账号可被【重新生成激活链接】复活(B04)安全缺陷(p0)evidence/ui-2026-09-12/B04-*.png + 本文 §9.2、§9.5

预跑当时只在 docs 仓落档、未跨仓建号;09-12 UI 补跑后三条均已开出 issue(上表已回填编号)。

6. 会上需复核的阻塞项 ​

  • B07(迁移回滚):测试环境无迁移前 nexo_auth 快照 —— 库早在 08-30 之前就完成结构迁移, 服务器上只有已迁移的测试库与正式库,没有可用作回滚靶子的快照。已做的部分: 迁移 SQL 7 个、回滚脚本 2 个均存在;users 表确无 password_hash 列;当前库登录可用。 完整链路(迁移 → 断言 → 回滚 → 再登录)无法执行,不记通过。
  • P08、P05、B03、P07 的 UI 判定:阻塞于控制台部署。 已于 09-12 晚补跑完毕,见 §9。
  • B04(安全准入项)不通过 已修复并复测通过,见 §9.5。会上只需复核复测证据。

7. 逐用例结果 ​

7.1 主路径 ​

编号结果证据要点备注
P01通过(API 级)logs/p01.json:201 + activate?code=<43 位>;三表各一行;expires_at-created_at=86400;重复 username 409 且计数不变控制台抽屉表单未验证
P02通过logs/p02.json:提交载荷为 43 位 base64url H1;单次快照 ACTIVE|t|t;新口令登录成功(TTL 180s);再访问链接 3006页面 /activate 由认证中心托管,故本用例含真实 UI
P03通过logs/p03-b02-expiry.json:重启容器触发真实定时任务(日志 count=2);25h 账号转 DISABLED;重发 201 且旧凭据作废;新链接激活成功未激活过的判定口径(used_at IS NOT NULL 计数=0)已单独断言
P04通过logs/p04.json:冷启动首次读取恰好 1 次 GET /internal/users?ids=;5 分钟内重复读取 0 次;返回字段仅 6 个公开字段;源码无 startDirectorySync、src/routes/users 下无 setInterval部署镜像 sha 与 origin/dev 一致
P05通过(API 级)logs/p05-b03.json:单事务停用 + 全部会话吊销(0 条未吊销)+ IM 收到通知;全仓检索无 ADMIN_USERNAMES(排除测试注释后为空)、无 requireAdmin确认框禁用态未验证
P06通过logs/n01-p06.json:21 轮全部 4001/「账号已停用」;多连接轮 3 条全关;旧令牌 REST 一律 401客户端"退回登录页"录屏未做(IM 前端未部署)
P07通过(API 级)logs/p07.json:重启后 ACTIVE;停用前 1 条会话在启用后仍为吊销态;旧 refresh token 启用后仍 401;原密码登录成功(TTL 180s);IM /conversations 200【重新启用】按钮未验证
P08阻塞(UI)logs/n03-p08-b05.json:Dry-Run 报告逐行(issueLines)、合法批单事务 500 行、导出 CSV 头 username,real_name,activation_url(BOM+CRLF,3 行,抽样链接可核销)、501 行 3005 拒绝上传/标红/导出界面未验证

7.2 边界与安全 ​

编号结果证据要点备注
B01通过logs/b01.json:12 并发恰好 1 次 200、11 次 400/3006;used_at 只写一次;成功者密码可登录、失败者密码一律不可登录
B02通过logs/p03-b02-expiry.json:23h 仍 PENDING 且链接可核销;25h 转 DISABLED 且原链接 3006;生产默认 ACTIVATION_TICKET_TTL_HOURS=24夹具同步改写了 ticket 有效期,否则"25h 链接仍可用"是夹具造得不全
B03通过(API 级)logs/p05-b03.json:省略/空串/部分/错字四类全 400 且状态保持 ACTIVE;全名全等 200;首尾空格会被 trim 后放行;通知数增量 = 成功次数前端禁用态未验证
B04通过logs/b04.json:前置 used_at 非空 1 条;停用后重发 4xx 且提示含「重新启用」;0 条未核销新凭据;原密码登录失败;状态仍 DISABLED
B05通过logs/n03-p08-b05.json:预检通过(49 valid)但入库撞唯一约束 → 409;三表计数前后一致(除预置占用账号外零新增)50 行批次
B06通过logs/b06.json:无签名/错签名/重放第二轮/超窗 四类全 401;同 nonce 首次 200、第二次 401;拒绝日志含 reason= 且不含签名与密钥;公网 /internal/ 404对照组(合法签名 POST)确实产生 1 次效果,证明非"全 401 所以看着干净"
B07阻塞logs/b07.json:缺迁移前快照见 §6

7.3 非功能硬阈值 ​

编号结果采样与口径实测
N01通过21 轮;服务端日志分段 + 容器内客户端交叉验证段 B 最大 1ms;端到端上界最大 12ms;客户端最大 14.7ms;401 阻断服务端最大 1ms
N02通过热/冷各 210 次,批 20/100/200 各 200/200/120 次;容器内采样 + 服务端日志热 P99 7.81ms / 冷 P99 11.31ms / 批 200 P99 14.00ms;IM 210 次会话列表读取仅 1 次内部拉取
N03通过(含 1 项偏差记录)5 轮 × 500 行;客户端 + 服务端 txMsDry-Run 最大 47.6ms;事务 txMs 最大 126ms;请求端到端最大 11.7s(含事务外 scrypt 派生)

N02 的"冷""热"说明:端点本身无状态,冷热只落在 PG 缓冲与批形状上 (热 = 固定批重复请求;冷 = 2 个真实 id + 每次不同的无效 UUID)。 这一点在 tc.md 里未界定,本报告如实标注,供评审确认口径。

8. 复现方式 ​

bash
# 0. 建立到服务器内部端口的隧道(/internal/* 被反代 404 拦掉,只能走内网口)
ssh -f -N -L 13011:127.0.0.1:3011 -L 13010:127.0.0.1:3010 139.199.173.121

cd products/nexo-im/iterations/2026-09-13/evidence/scripts

node p01-create-user.mjs
node p02-activate.mjs
node p03-b02-expiry.mjs
node p04-on-demand-fetch.mjs
node p05-b03-disable-guard.mjs
node p07-enable.mjs
node b01-concurrent-redeem.mjs
node b04-disabled-cannot-reactivate.mjs
node b06-internal-hmac.mjs
node b07-migration-rollback.mjs
node n01-p06-disable-disconnect.mjs
node n02-batch-fetch.mjs
node n03-p08-b05-batch-import.mjs
node measure-ticket-drift.mjs   # N03 TTL 偏差单点复核
node cleanup-fixtures.mjs       # 清理全部 preflight- 夹具

脚本内的环境常量(端点、AUTH_INTERNAL_SHARED_SECRET、HMAC 签名实现)与测试环境一致; lib.mjs 的签名实现与 @lamolabs/nexo-backend-sdk 的 signInternalRequest 逐字节等价。

每个脚本都在结束时按精确 id 清理夹具;cleanup-fixtures.mjs 作为兜底。 预跑结束后测试库已回到 5 个种子账号的初始状态(users=5、credentials=5、account_tickets=0)。

9. UI 补跑(2026-09-12 晚) ​

控制台 nexo-user-console 合入 dev 并部署到测试环境后,把 §2.1 列的 UI 判定在真实浏览器里补跑完毕。

  • 环境:https://nexo-user-console-test.snailuu.cn(Cloudflare Pages,dev 分支产物); 认证中心 nexo-auth-test 镜像 sha-a3c55fa3(含 PR #73 的回调白名单修复)
  • 账号:user1(种子账号,ACTIVE);临时夹具统一用 tc0912- 前缀
  • 证据:evidence/ui-2026-09-12/(截图、上传用的 CSV、导出的激活链接 CSV 已对 code= 打码)
  • 清理:夹具按精确用户名删除,补跑后测试库回到 users=5、credentials=5、account_tickets=0,5 账号全 ACTIVE

9.1 补跑通过的用例 ​

用例补跑内容结果
P08错误 CSV 预检、501 行超限、合法批入库、一键导出阻塞 → 通过。错误行红字 rgb(200,58,74) + 浅红底 rgb(252,235,237);逐行给物理行号与原因(真实姓名不能为空 / 用户名与第 3 行重复 / 用户名已被占用);未通过时【确认导入】disabled=true;501 行文件被前端就地拒绝(「单次最多导入 500 行,当前文件有 501 行,请拆分后再上传」)且未发起上传;合法批 3 行入库后三表 5/5/0 → 8/8/3,三个账号全 PENDING,票据 TTL 恰为 86400s(小批次无 §4.1 的 12s 偏差);导出 CSV 带 BOM + CRLF、表头 username,real_name,activation_url,抽样链接打开托管设密页正常
P01新建用户页通过。页面 input[type=password] 计数 0,仅 3 个 text 输入(用户名/真实姓名/昵称);"密码"只出现在说明文案「管理员全程不接触任何明文密码」;建号后出现激活链接面板 + 一键复制 + 「仅本次可见」「24 小时内有效」两条提醒
P05 / B03停用防误触确认框通过。空输入、「用户」(部分)、「用户 五」(中间空格)、「用户5」(错字)四档,【确认停用】均 disabled=true;仅「用户五」逐字符全等时点亮;确认后 users.status='DISABLED'、活跃会话数 0
P07重新启用通过。用户列表操作列对 DISABLED 账号显示【重新启用】;二次确认弹窗明示「停用期间被吊销的登录态不会恢复,本人需要重新登录」;确认后状态回 ACTIVE
P03重新生成激活链接通过。PENDING 账号点【重新生成激活链接】→ 二次确认 → 新链接展示;票据总数 2、未核销 1,旧链接已作废

9.2 补跑推翻的结论:B04 不通过 ​

预跑把 B04 记为通过,前置账号存在 used_at IS NOT NULL 的 activation ticket。 补跑改用没有票据历史的种子账号 user5,结论反转:

断言项期望实际
重发接口响应4xx,提示含「重新启用」200,签发新 24h 链接
users.status保持 DISABLEDDISABLED → PENDING
未核销 activation ticket0 条1 条
credentials.password_hash—未轮换(updated_at 保持 2026-08-27)

判定依据疑似是「是否存在已核销的 activation ticket」,用来区分管理员主动停用与 24h 超时未激活自动停用(T08)。种子账号与迁移而来的存量账号没有这段历史, 被当作「超时停用」放行重发。

影响:正式库里的存量账号正是这一类。任一存量账号被管理员停用后可被拉回 PENDING, 持有新链接者可自行设密接管该账号 —— 正是 B04 想堵的路径。 B04 属安全准入项,按 tc.md §8.5 直接构成迭代不通过条件。

已开 nexo-auth#76(p0)。 建议在 users 上显式记录停用来源(ADMIN / TIMEOUT),重发时 fail-closed。

9.3 补跑新增的两项体验偏差(非阻塞) ​

  1. 控制台顶栏当前登录人显示的是**用户 ID(UUID)**而非真实姓名 —— 用户列表里同一账号显示为「用户一」,顶栏却是 9670161b-…。
  2. 对管理员主动停用的账号,详情页「凭据与链接」区仍提示「该账号正在等待激活,重发会作废旧链接」,文案与实际状态不符(与 §9.2 是同一处判定逻辑的两个表现面)。

9.4 仍未覆盖的部分 ​

  • B07(迁移回滚):与控制台无关,仍缺迁移前 nexo_auth 快照,维持阻塞。
  • P06 的客户端录屏(「退回登录页并禁用自动重连」):缺的是 IM 桌面端 nexo-im-pc,未部署到测试环境,与控制台无关。
  • P08 / P02 的「抽样链接可完成激活」最后一步(在设密页真实提交口令):补跑只验证到链接能正确打开托管设密页; 提交环节沿用预跑的 API 级结论(logs/n03-p08-b05.json、logs/p02.json)。

9.5 B04 修复复测 ​

nexo-auth#77 合入 dev(491faad)后, 测试环境自动部署到镜像 sha-d013a330,迁移 0007 生效(drizzle.__drizzle_migrations 共 8 条、 users.disabled_reason 列存在)。按 §9.2 的同一路径复测:

步骤结果
控制台停用 user5(种子账号,无任何票据历史 —— 修复前正是这种账号被复活),输全名确认status='DISABLED'、disabled_reason='ADMIN'、未核销票据 0 条
点【重新生成激活链接】→【确认生成新链接】被拒:「该账号已被管理员停用,请先重新启用后再重发凭据」;status 仍 DISABLED、disabled_reason 仍 ADMIN、票据仍 0 条
把 disabled_reason 改为 'TIMEOUT' 后重试(验证放行路径未被误伤)放行:拉回 PENDING、签发 24h 新链接、disabled_reason 清为 null
还原为 DISABLED/ADMIN 后走控制台【重新启用】status='ACTIVE'、disabled_reason 清为 null

两个方向都验到了:该拒的拒、该放的放。只验拒绝那一侧的话,一个把所有 DISABLED 都拒掉的实现同样会绿,而那会让超时未激活账号失去唯一的自助补救路径。

证据:evidence/ui-2026-09-12/B04-03-fixed-rejected.png、B04-04-enable-clears-reason.png。

复测后测试库已还原:5 个种子账号全 ACTIVE、disabled_reason 全 null、account_tickets 0 条。

9.6 复测中发现的文案瑕疵(非阻塞) ​

被拒时页面显示「该账号已被管理员停用,请先重新启用后再重发凭据(本页无法补发凭据。)」—— 服务端文案后面又被前端追加了一句括注,读起来是两句重复的话。功能无影响,归入 §9.3 同类。