归档(07-28-all-account-login-readiness): 完成全员登录可用性任务
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-23 19:25:01 +08:00
父节点 b32b5f2967
当前提交 45a1fa8d03
共修改 8 个文件,包含 3 行新增和 3 行删除
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,91 @@
# 全员登录可用性设计
## 1. Boundary
本任务在现有企微 OAuth 回调内补充确定性自动绑定,不新增公开认证端点、不读取或记录任何真实密码。普通企微登录只绑定既有 active OA 账户,不创建员工、不修改角色、不写企微通讯录。
## 1.1 WeCom Direct-login Contract
```text
企微 OAuth callback 得到内部 userid
-> 已有唯一 binding:直接创建会话
-> 无 binding:读取该 userid 的企微通讯录资料
-> 企业邮箱精确匹配唯一 active OA 账户
-> 目标账户无 binding:事务内 upsert binding + 脱敏审计 + 创建会话
-> 任何缺失/无匹配/inactive/冲突/API 失败:失败关闭
```
匹配键优先级固定为已有 userid binding,其次为企微返回的规范化企业邮箱。姓名不得参与自动决策;手机号不作为本轮普通登录兜底,避免号码复用或格式漂移扩大身份边界。
## 2. Login Baseline
每个 active 账户必须具备以下两类能力,实际真人覆盖另行记录:
1. `WeCom SSO ready`:属于当前企微通讯录的员工存在唯一 binding,并由本人完成一次工作台直接登录;首次登录不需要 OA 密码。
2. `password fallback ready`:员工本人可通过受控自助路径设置并掌握密码;它是企微异常时的独立回退,不是首次 binding 的前置条件。本轮代表性生产验收不等于每人都已经执行该路径。
inactive/archived 账户不进入本轮登录覆盖率分母。
## 3. Administrator Recovery
```text
受控终端 -> 精确定位唯一 active 管理员 -> 静默设置一次性密码
-> 清锁定、撤旧会话、递增 token version、失效旧验证码并留脱敏审计
电脑浏览器 -> 使用一次性密码登录 -> 立即设置本人新密码
手机企微工作台 -> 重新进入并完成 OAuth userid 校验
-> 新版 OAuth 回调用 userid 或企微企业邮箱精确匹配既有 active OA 账户
-> 自动建立唯一 binding 并直接创建会话
-> 再次从工作台进入并验证免登
```
一次性密码只允许由用户在受控终端静默输入,或写入仓库外权限为 `0600` 的临时文件供用户本机读取一次;不得出现在聊天、argv、环境变量、日志或仓库。它只用于管理员账号恢复,不参与企微首次 binding。
若自动匹配缺失或冲突,停止操作并只读核对 userid、企业邮箱和 OA active 账户的唯一映射;不得按姓名猜测、不得用密码登录覆盖 binding。个人设置主动绑定只保留为管理员核实后的显式维护入口。
## 4. Remaining Active Accounts
其余 active 账户已有 binding,但尚无真实登录证据。每名员工先直接从企微工作台进入,验证 binding 和免登是否可用;企微免登通过后再按优先级安排密码回退,不让密码流程阻塞主入口验收。管理员只记录 `password_login_pass`、`wecom_login_pass`、`blocked_reason` 三类脱敏结果,不收集密码、验证码或完整账号。
邮件未送达时才进入兜底:管理员通过现有 `reset-password` 接口生成一次性密码,受控现场只显示一次并当面交付;接口强制首次改密、清除锁定、撤销旧会话、使旧凭据失效并写审计。兜底写入需在实际发生前再次获得批准。
## 5. Failure Matrix
- 邮件验证码未收到:先核对投递状态和企业邮箱可用性;不反复请求导致限流。确认邮件路径不可用后才考虑管理员一次性密码。
- 企微内无法同时看登录页和邮箱:改用电脑浏览器完成忘记密码,手机只收验证码;不要为看邮件关闭正在执行 binding 的企微 WebView。
- 验证码过期/错误:由员工重新请求;不得让管理员代收或索取验证码。
- 页面显示已发送但未看到新邮件:先只读确认是否真正签发新 code、有效期、失败次数和邮件发送失败计数。新签发会使旧 code 失效;不得混用旧邮件,也不得在请求上限内连续点击重发。
- 旧验证码被拒绝:归类为“新签发使旧码失效”,证明失败发生在 `/reset-password` 成功之前;不得据此推断前端缓存或首次改密标记未清除。
- 正确验证码重设成功后仍跳到改密页:才归类为前端状态/缓存问题,并记录跳转 URL、页面和报错;不要与旧码失效混为一类。
- 点击登录页“忘记密码”只进入找回页面,不会签发验证码。只有填写企业邮箱并点击“发送验证码/重新发送验证码”才会请求签发;页面导航次数不能作为验证码请求次数。
- 账号或邮箱锁定:只读确认锁定类别和窗口;使用现有解锁接口前另行批准。
- 企微仍显示 unbound:确认是否在企微内打开、OAuth 回调是否取得内部 userid、现有 binding 是否指向另一账户;不得按姓名猜测绑定。
- binding conflict:失败关闭,保留原映射和审计,单独核实身份后再提出精确修正。
- 邮箱验证码登录成功但必须改密页面要求未知旧密码:说明员工走了 `/email-login` 路径;它不同于会清除首次改密标记的 `/reset-password`,返回忘记密码流程,不绕过旧密码校验。
## 6. Rollback
- 员工自助重设密码后旧密码和旧会话按设计失效,无需回滚到旧密码。
- 新 binding 若经本人验证仍错误,只能在精确确认原/目标映射后使用现有解绑/绑定路径恢复;不得直接删除数据库记录。
- 任一员工未来出现阻断时不影响其他员工使用;按实际覆盖记录个人缺口,不把代表性系统 PASS 改写为全员真人 PASS。
## 7. Prevention
新账号从创建/导入进入 active 后,不得只以“有密码哈希/有 binding 行”宣告可用。启用完成必须包含安全凭据交付、首次改密、密码登录和企微成员本人工作台免登四项;缺一项则保持 onboarding 未完成并由管理员跟踪。
## 8. Compatibility And Audit
- 已有 binding 的 3 个账户保持原路径,不额外请求通讯录详情。
- 自动补绑定使用现有 `wecom_identity_bindings` 唯一约束,并在同一事务内二次核对 userid 与员工两侧冲突。
- 新审计动作只记录匹配方式和布尔字段,不记录完整 userid、邮箱或企微原始 payload。
- 未绑定页面不再指示员工输入密码完成正常绑定;错误只显示稳定类别,完整身份资料不得进入 URL、日志或前端状态。
- 生产回填先 dry-run 汇总唯一匹配、无匹配和冲突计数,另行批准后才 apply。
## 9. Password Input Sequential Focus
认证表单采用国内连续填表优先的顺序焦点:密码输入框按 Tab 跳过框内显示/隐藏按钮并进入 DOM 中下一个顺序表单控件。登录页现有“忘记密码?”链接仍保持键盘可达;按钮继续使用原生 `button type="button"`,保留“显示密码/隐藏密码”可访问名称、按下状态和目标输入关联,鼠标与触屏行为不变;不得通过正数 `tabIndex` 人工重排表单。
组件级调整同时覆盖登录、修改密码和忘记密码页,避免三个认证入口产生不同键盘习惯。测试固定顺序焦点、点击切换、可访问名称、输入值保留以及确认密码框 Enter 提交;不改变认证 API 或生产配置。
## 10. Representative Password-Recovery Evidence
系统能力与逐人覆盖分开记录:生产 `/ready`、认证 capabilities、SMTP 连接/认证和 1 次真人完整重置/登录共同证明共享链路可用;个人邮箱访问、投递和账号特例只有本人操作才能证明。任务收口允许系统链路 PASS,但覆盖率保持 4/6,另外 2 人标记为预计可用而非真人 PASS。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,95 @@
# 全员登录可用性实施计划
## 1. Read-only Baseline
- [x] 核对公网 `/ready` 和认证 capabilities;企业邮箱验证码登录、忘记密码均启用。
- [x] 聚合核对 4 个 active 账户的邮箱/手机号、密码状态、锁定、企微 binding 和成功登录类型,不输出个人标识或认证数据。
- [x] 确认唯一管理员缺企微 binding,其余 3 个 active 账户已有 binding;当前企微成功登录证据为 0。
- [x] 确认 3 个账户仍需首次改密且未知随机初始密码无法作为已交付凭据。
## 2. Administrator Self-Recovery
- [x] 用户批准进入实施并确认本人可访问企业邮箱。
- [x] 首次电脑自助请求已签发新的有效验证码;旧验证码失效后被误用 1 次,账户未锁定,服务端未记录邮件发送失败。
- [x] 失败现场已定性并保留:失败发生在 `/reset-password` 成功之前,是第二次签发使旧验证码失效;没有前端缓存跳转证据。
- [x] 执行已批准的管理员 break-glass:精确定位唯一 active 管理员,设置受控一次性密码,清除锁定、撤销旧会话、递增 token version、失效旧验证码并写脱敏审计。
- [x] 独立只读复核通过:必须首次改密、锁定清零、活动会话与活动验证码均为 0,审计存在;公网 `/ready` 返回 200。
- [x] 用户以一次性密码登录并完成强制改密;只读核对首次改密标记已清除、当前仅 1 个活动会话、改密与 break-glass 审计均存在。
- [x] 新版部署后用户从企微工作台重新进入,由 userid/唯一企业邮箱自动补 binding 并直接登录,不输入 OA 密码。
- [x] 首次复测仍显示 unbound;只读诊断确认管理员尚无 binding,近 30 分钟没有来自企微 WebView 的成功或失败密码登录,也没有 bind 审计,说明尚未提交页面下方的账号密码表单。
- [x] 用户再次从企微工作台进入,确认直接到 OA 且不再出现 unbound。
- [x] 只读核对管理员新增密码成功、binding 和企微登录审计,输出仅限布尔值。
## 3. Remaining Active Accounts
- [x] 首批 2–3 名同事完成企微工作台真实登录试用,用户确认本轮参与者全部成功;未收集账号、userid、密码、验证码或个人明细。
- [x] 已由其余 3 名已有 binding 的 active 员工按不含账号/密码的操作说明,由本人直接测试企微工作台免登。
- [x] 每名企微成员均已从工作台真机进入;用户于 2026-08-23 确认其余 3 人全部可以进入,结合此前已通过人员,当前 6/6 唯一 binding 与真人免登验收完成。
- [x] 企微免登验证后,密码回退按最终批准采用代表性生产验收;实际真人覆盖保持 4/6,另外 2 人不要求本轮逐人操作,管理员不接触其密码或验证码。
- [ ] 邮件失败者单独登记稳定失败类别;确需管理员一次性密码时先给出目标数量、审计和交付方案并另行获批。
## 4. Verification and Prevention
- [x] 腾讯云生产补充只读聚合已复核:active 6、binding 6、密码历史成功覆盖 3、企微成功审计历史覆盖 4、三类认证锁定均为 0;企微审计计数与用户 6/6 真人可进入证据分开记录,不输出人员明细。
- [x] 当前快照 binding 缺失/冲突、inactive binding 和 inactive 账户活跃会话均为 0;近 7 天角色变更、企微通讯录写入和 binding 写入审计均为 0。
- [x] 曾按用户初次指令将唯一 active `manager` 的直属主管设为 `wangshunli`;用户随后澄清其仅为系统开发/系统管理员,该中间关系需按最终职责口径修正。
- [x] 只读确认唯一 active `manager` 与 `majianyong` CEO 账户,排除自环/环路后原子将 `direct_manager_id` 从 `wangshunli` 修正为 `majianyong`、写脱敏审计,并复核组织链;不改角色、邮箱、密码、binding 或企微通讯录。
- [x] 只读解析手工企微通讯录导出并与生产 OA 对账:6 个 userid/binding 和 6 个企业邮箱全部精确一致,缺失、重复、冲突、未匹配和需修改数均为 0。
- [x] 只读确认 `majianyong` 保持 `ceo` 且无直属主管、`wangshunli` 不新增业务审批角色;生产只有 active `finance`、没有 active `finance_mgr`,CEO 本人报销在后续最小权限修正前保持阻断。
- [x] 更新现有管理员手册/试运行 checklist:active 账号启用必须包含安全凭据交付、首次改密、密码登录和企微本人免登。
- [x] 运行认证聚焦测试、前端 typecheck/build、Trellis task/governance、敏感扫描、文档审计和 `git diff --check`;获批后已完成腾讯云真实生产发布与公网 smoke。
## 4A. Passwordless First WeCom Login
- [x] 抽取只读企微成员资料获取能力:输入 OAuth 已确认的内部 userid,只返回规范化企业邮箱和必要状态,不记录原始 payload。
- [x] 普通企微登录在 binding 缺失时,按 userid/唯一企业邮箱匹配既有 active OA 账户;事务内二次检查双向冲突、建立 binding、写脱敏审计并创建会话。
- [x] 对无匹配、inactive、userid 已绑定他人、账户已绑定其他 userid 和企微 API 失败逐类失败关闭;不按姓名绑定、不创建账户、不改角色。企微邮箱缺失时仅允许 userid 精确匹配。
- [x] 保持已有 binding 快路径不变,避免每次登录都调用通讯录详情接口;密码登录不再消费 pending bind 建立企微映射。
- [x] 修正 unbound 页面文案:不再把账号密码作为首次企微绑定说明,显示稳定的映射缺失/冲突处理指引。
- [x] 增加后端路由与服务测试、前端文案/交互测试;覆盖首次免密自动绑定、已有 binding、负向矩阵和审计脱敏。
- [x] 生成生产存量 dry-run 脱敏汇总;部署和 apply 回填分别取得明确批准后执行。
- [x] 最新生产 live 只读复核确认 OA active 6、企微 active 6、binding 6、唯一匹配 6;缺失、冲突、状态漂移和待回填均为 0,因此本轮无需 apply。剩余 6 条企微资料必填字段 finding 沿用既有接受口径,不作为 binding 阻断。
## 4B. 2026-08-23 Production Read-only Follow-up
- [x] 复用既有生产身份引导、运行构建精确比较和 production preflight;腾讯云真实生产身份、migration pending=none、rollback window `OPEN/0` 均通过。
- [x] 首个只读事务在聚合前因冲突统计把 UUID 与文本直接 `UNION` 而被 PostgreSQL 拒绝,事务已回滚、写入为 0,且未自动重试;同一错误在本地只读数据库复现并修正类型合同。
- [x] 修正版先在本地同结构数据库通过,再执行一次明确的补充生产只读事务;有效查询 1、自动重试 0,只输出白名单计数和布尔值。
- [x] 最终生产计数:active 6、must-change 3、binding 6、binding 缺失/冲突/inactive 漂移 0、密码历史成功覆盖 3、企微历史成功审计覆盖 4、近 7 天企微成功审计覆盖 1、三类认证锁定 0、异常活跃会话 0。
- [x] 近 7 天角色变更、企微通讯录写入和 binding 写入审计均为 0;本轮未登录真人账号,未修改数据库、角色、binding、会话或企微通讯录。
## 4C. 2026-08-23 Production Email Recovery Enablement
- [x] 先以公网只读方式确认 OA `/ready` 正常、认证 capabilities 返回 `email_disabled`;页面在 POST 前拦截,未签发验证码、未增加请求次数,排除员工邮箱或账号本身导致的现场失败。
- [x] 无发送邮件的 SMTP 连接与认证探测通过;历史生产证据显示邮件开关此前持续关闭,任务中“忘记密码已启用”的旧记录不再作为当前事实。
- [x] 用户明确批准启用腾讯云真实生产邮件能力;生产 `.env` 已原子改为 `EMAIL_ENABLED=true`,未改其他配置、数据库、账号、角色、binding、审批规则或企业微信通讯录。
- [x] 第一次尝试因 Compose 缺少显式构建标识在写入前停止,第二次尝试因发现生产没有 worker 在写入前停止;两次均为零写入、未自动重试。
- [x] 只重建当前唯一 server,保持生产 `server=1、worker=0、JOBS_ENABLED=true` 的既有拓扑,未擅自启动 worker,避免形成重复作业调度;运行构建未改变。
- [x] 初始健康等待器错误要求 Docker healthcheck,但当前 server 容器没有 healthcheck;随后以容器 running、restartCount=0、公网 `/ready=200` 以及 capabilities 通过确认服务正常,因此未回滚。
- [x] 最终 capabilities 为 `emailDeliveryReady=true`、`passwordResetEnabled=true` 且 `statusReasons=[]`;SMTP 连接与认证通过,未发送测试邮件,migration pending=none,rollback window 为 `OPEN/0`。
- [x] 临时敏感回滚副本已删除,生产 `.env` 保留已批准的启用状态;本轮未部署代码、未运行 migration、未登录员工账号、未发送邮件。
- [x] 剩余首次改密范围按用户最终批准采用代表性生产验收:1 人本人完成验证码重置和密码登录,另外 2 人不要求本轮逐一操作,仅记为预计可用;管理员不接触其密码或验证码。
- [x] 用户确认其中 1 人已完整通过“忘记密码 → 设置新密码 → 使用新密码登录”;只记录通过计数,不记录邮箱、验证码或密码。当前密码历史成功覆盖按真人验收口径为 4/6,尚余 2 人。
## 4D. Password Input Tab Order
- [x] 调研 W3C 键盘/焦点顺序、GOV.UK 密码组件,以及 Ant Design、Arco、Element Plus、TDesign 的显示密码实现;国内组件存在“图标进入/跳过 Tab”两套默认。
- [x] 按用户裁决调整共享 `PasswordInput`:顺序 Tab 跳过显示/隐藏按钮,保留原生按钮、动态可访问名称、按下状态和输入关联,不使用正数 `tabIndex`。
- [x] 增加认证聚焦回归:登录页密码 → 忘记密码链接;修改密码页旧密码 → 新密码 → 确认密码 → 提交;忘记密码页新密码 → 确认密码 → 提交。
- [x] 保留点击显示/隐藏、输入值不丢失和确认密码框 Enter 提交测试;前端 typecheck/build、3 条聚焦 E2E、Trellis/流程/敏感/差异门禁通过。
- [x] 首次直接运行两份 E2E 文件时本地入口未启动,20/20 均以 `ERR_CONNECTION_REFUSED` 停在导航前;启动 Vite 并显式使用本地前端基址后,本次相关 3 条用例全部通过,未把环境失败归类为产品回归。
## 4E. 2026-08-23 Production Rollout
- [x] 用户批准推送当前生产候选并部署腾讯云真实生产;`main` 的 26 个待推送提交经范围核对、migration 差异检查、Trellis/流程/敏感/差异门禁后进入上游,未使用未发布提交 override。
- [x] 通过生产身份引导器唯一识别腾讯云部署身份与 forced-command refresh 身份;部署前 13/13 聚合体检、目标与数据库身份、migration pending=none、rollback window `OPEN/0` 均通过。
- [x] 使用既有部署标准入口发布 `7b86ffb`,固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing`;未覆盖生产 `.env`,未执行 reset、db push、手工 DDL 或业务写入。
- [x] 部署后 Prisma、`/ready`、运行构建、企业微信/SMTP/OCR/消息桥接契约、runtime smoke、公网 bundle 扫描与备份合同 refresh 均通过;Compose 保持 `server=1、worker=0`,未启动 worker。
- [x] 无凭据真实浏览器公网复核通过:根入口登录页可见,密码框 Tab 直接进入“忘记密码?”链接,点击后忘记密码页面正常打开;未登录账号、未发送验证码、未写生产数据。
- [ ] 直接请求或刷新 `/login`、`/forgot-password` 当前返回 404;从 OA 根入口的 SPA 实际流程正常。本问题不由本次 Tab 改动引入,后续应单独治理反向代理/静态资源 history fallback,不在本任务内扩大变更。
## 5. Stop Conditions
- 企业邮箱不可达、验证码投递异常或账户锁定:停止该员工操作,不连续请求。
- 企微回调缺 userid、binding conflict 或映射不唯一:停止写入并只读诊断。
- 任何步骤要求在聊天、任务、命令参数或普通日志中暴露密码、验证码、完整账号或 userid:立即停止并改用受控现场交付。
@@ -0,0 +1,131 @@
# 全员登录可用性
## Goal
确保每个 active OA 账户至少有一条实际可验收的登录路径,并为企业微信成员预先建立 userid/企业邮箱到 OA 账户的唯一映射;员工第一次从企微工作台进入即直接登录,不把输入 OA 用户名和密码作为首次绑定步骤。
## Scope
- 任务启动时生产 4 个 active 账户;当前生产已扩展为 6 个 active 账户,登录基线仍同时覆盖密码回退与企业微信工作台免登。
- 管理员本人优先通过已批准的受控 break-glass 恢复密码,再绑定企微并真机验收;其余已有 binding 的账户先验证企微免登,再按需完善密码回退。
- 邮件失败时使用现有管理员一次性密码接口作为另行批准的兜底。
- 更新现有账号启用/入职操作门禁,防止后续 active 账户再次只有未知随机密码或未经本人验证的 binding。
- 改造企微登录缺失 binding 时的唯一企业邮箱自动匹配、事务绑定、审计和失败关闭逻辑;同步修正未绑定页面指引。
## Background And Confirmed Facts
- 管理员本人从企业微信工作台进入时收到“当前企业微信账号尚未绑定”,且没有已知可用密码。
- 当前登录页的未绑定流程要求先使用账号密码登录,再到个人设置完成企业微信绑定;这对不知道随机初始密码的导入账户会形成死锁。
- 现有管理员手册已经规定:全员应能账号密码登录,已绑定员工还应能从企业微信工作台进入。
- OA 已有管理员重置普通账户密码接口:生成一次性密码、强制首次改密、清空锁定、撤销旧会话并写审计。
- OA 也支持企业邮箱验证码登录,但真实邮件投递和边缘 challenge 端点是否当前可用必须现场核对,不能假定为可用兜底。
- 企业微信普通登录只按回调 userid 查询唯一 binding;找不到 active 绑定就进入 `wecom=unbound`,不会按姓名、邮箱或手机号自动猜测账户。
- 生产只读基线为:active 账户 4 个,全部有企业邮箱和密码哈希,3 个有手机号,当前密码/邮箱验证码均无锁定。
- 4 个 active 账户中 3 个处于 `must_change_password=true`,但没有实际交付随机初始密码的证据;仅 1 个账户有历史成功密码登录,邮箱验证码成功登录和企微成功登录均为 0。
- 3 个 active 账户已有企微 binding;唯一 active 管理员正是缺 binding 的账户,且该管理员已有历史密码成功登录、当前无需强制改密但本人已不知道密码。
- 企业微信通讯录本身拥有管理员 userid,但生产只读核对显示 OA 侧没有该管理员的目录同步 userid、成功 HR 主数据映射或 identity binding;当前 OAuth 回调只能拿到 userid,无法仅凭它反推出唯一 OA 账户。
- 现有企微 HR 主数据同步已经能够按 binding、userid/员工编号、手机号或企业邮箱解析员工并在 apply 时写入 binding,但当前普通 `mode=login` 回调只查 binding;binding 缺失时不会利用企微通讯录企业邮箱匹配既有 OA 账户。
- 生产认证能力在 2026-08-23 现场复核时曾报告 `email_disabled`:OA `/ready` 正常,但生产邮件开关关闭,找回密码页面在请求签发前即失败;该次操作未签发验证码、未增加请求次数,也不是员工邮箱或账号问题。
- 用户于 2026-08-23 明确批准启用腾讯云真实生产邮件能力;启用后公网 capabilities 已恢复 `emailDeliveryReady=true`、`passwordResetEnabled=true` 且无状态原因,SMTP 连接与认证通过,未发送测试邮件。
- `/forgot-password -> /reset-password` 是完整的验证码重设密码路径;正确验证码会设置新密码并清除 `must_change_password`,不是死路。
- 本次实际失败发生在 `/reset-password` 成功之前:第二次签发使第一次验证码失效,用户收到并使用旧验证码后被后端拒绝;未发现“重置成功后被前端缓存再次送回改密页”的现场证据。
- `/email-login/code -> /email-login/verify` 是另一条邮箱验证码登录路径;现有前端在验证成功后仍遵守 `must_change_password` 跳转,而修改密码页强制要求旧密码,因此它对未知随机初始密码账户可能形成死路,但与本次失败无关。
- 2026-08-23 腾讯云生产补充只读聚合确认:active 6、binding 6,缺失、冲突、三类认证锁定和 inactive 账户活跃会话均为 0;仍有 3 个账户处于首次改密状态,历史密码成功登录覆盖为 3/6。
- 生产邮件找回恢复后,用户确认其中 1 名首次改密人员已完整通过忘记密码、设置新密码和新密码登录;当前真人密码回退验收覆盖为 4/6,尚余 2 人。该计数待最终生产只读聚合复核,不记录个人认证材料。
- 用户确认另外 2 人无需在本轮逐人实测:同一腾讯云生产忘记密码链路已有代表性真人全链路通过,可作为系统能力验收;另外 2 人仅记为“预计可用、未真人密码登录验收”,不得扩大表述为 6/6 真人密码回退完成。
- 真人操作发现密码框输入完成后按 Tab 会先聚焦框内的显示/隐藏按钮,而不是下一个输入项。国内组件并无单一默认:Ant Design、Arco 当前默认让图标进入顺序焦点,Element Plus、TDesign 默认跳过;用户裁决本 OA 优先采用国内常见的连续填表习惯。
- 企业微信成功审计历史覆盖为 4/6、近 7 天为 1/6;该计数只代表实际经过 OAuth 成功回调的审计覆盖,不能替代或否定用户确认的 6/6 真人工作台可进入结果,已有会话直接进入不会必然新增 OAuth 审计。
## Requirements
- 登录基线固定为双路径:每个 active 账户都必须具备可由本人通过受控自助路径完成首次改密的账号密码回退能力;属于当前企业微信通讯录的 active 员工还必须具备唯一 binding 和工作台免登。实际真人覆盖与系统能力分开记录。
- 先以只读方式核对所有 active 账户的状态、可登录标识、密码是否为强制首次改密状态、登录锁定、企业微信 binding、最近成功登录类型和企业邮箱验证码能力;只输出计数和脱敏状态。
- 把“数据库里存在密码哈希”与“员工实际掌握可用凭据”区分开,后者必须由一次真实登录或受控的一次性密码交付证明。
- 管理员本人先通过已批准的受控 break-glass 恢复一条安全登录路径,再完成企业微信绑定和工作台真机登录,不通过聊天、命令参数、环境变量、Trellis、日志或仓库传递密码。
- 其余 3 个已有 binding 的 active 账户先直接验证企业微信工作台免登;只有免登不通或需要独立回退时,才进入密码找回或管理员一次性密码流程。
- 企微成员首次工作台登录不得依赖 OA 密码:已有 userid binding 直接登录;binding 缺失时,仅允许使用企微接口返回的企业邮箱精确匹配唯一 active OA 账户,事务内创建 binding、写审计并继续登录。
- 企业邮箱缺失、无 active OA 匹配、目标账户已绑定另一 userid、userid 已绑定另一账户或企微资料接口失败时必须失败关闭;姓名只用于人工核对,不得作为自动绑定键。
- 自动匹配只允许绑定既有 active OA 账户,不得在普通登录中自动创建员工、修改角色、更新通讯录或覆盖冲突 binding。
- 通讯录/HR 主数据同步应在账号启用前建立 binding;登录时自动补绑定仅作为确定性兜底,不替代入职门禁。
- 系统级账号密码回退以腾讯云生产代表性真人全链路验证收口;未逐人操作的 active 账户只记“预计可用”,不虚构本人密码登录证据。属于当前企业微信通讯录的 active 员工仍应具备唯一 binding 和工作台免登。
- 把“密码回退能力已验证 + 企微成员免登已完成”写入新账号启用/入职门禁,避免后续导入账户再次只有未知随机密码;逐人真人覆盖单独记录,不以系统能力验证替代个人操作事实。
- 密码重置必须使用现有审计接口或经单独批准的受控 break-glass 路径;生成的一次性密码仅在受控现场显示一次,首次登录立即改密。
- 任何 binding 修正必须先证明目标 OA 员工和企微 userid 唯一对应;不得按姓名模糊匹配,不得修改企业微信通讯录成员。
- 生产 binding、密码、账户状态或认证配置写入前另行给出精确变更清单并取得批准。
- 最终业务职责以用户最新确认口径为准:`majianyong` 是 `ceo`,`wangshunli` 仅承担系统开发和系统管理,不是业务审批人;当前唯一 active `manager` 的 OA 直属主管应为 `majianyong`。
- `majianyong` 本人报销不配置直属主管、不允许自审,目标由 active 非本人 `finance` 审批;当前实现只支持 `finance_mgr` 且生产无持有人,本轮保持失败关闭并记录后续最小权限修正,不修改任何角色、邮箱、密码、binding、审批规则或企微通讯录。
- 认证页密码框的顺序 Tab 行为采用“当前密码输入框 → DOM 中下一个顺序表单控件”;框内显示/隐藏按钮保留鼠标、触屏和辅助技术语义,但不占普通连续填表的 Tab 次序。登录页的“忘记密码?”链接仍保持键盘可达,不通过正数 `tabIndex` 重排其他控件。
## Decision: Credential Recovery
**Context**:管理员缺企微 binding 且未知密码,忘记密码现场又出现第二封验证码未送达;其余 3 个 active 账户已有企微 binding,但尚无真实企微登录记录。让管理员接触所有员工密码会扩大敏感信息处理面。
**Decision**:管理员先执行已批准的受控 break-glass,随后绑定企微并验证免登;其余 3 人先直接验证企微免登。只有免登不通或需要建立独立密码回退时,员工才使用忘记密码;邮件确实无法送达时,再由管理员使用审计能力生成一次性密码并线下当面交付。
**Consequences**:先验证系统预期主入口,避免让 3 名员工重复经历尚未稳定的邮件找回流程;最终双路径基线不变。任何一次性密码都不得经聊天、任务、邮件正文、命令参数、环境变量或普通日志转发。
## Decision (ADR-lite)
**Context**:仅保证一种登录方式会在企微 binding 漂移或未知随机初始密码时再次形成登录死锁。
**Decision**:采用“双路径基线”。账号密码是每个 active 账户的独立回退;企业微信成员在此基础上必须完成工作台免登。
**Consequences**:双路径能力仍是账号启用基线;企微免登逐人验收,密码找回的本轮生产验收则由后续“代表性密码找回验收”决策细化。任何单一入口故障都不应把员工锁在 OA 外。
## Decision: Representative Password-Recovery Acceptance
**Context**:腾讯云生产邮件找回能力已恢复,且 1 名剩余首次改密人员已由本人完整通过忘记密码、设置新密码和新密码登录;其余 2 人走相同代码与生产配置路径。继续强制逐人测试会增加协调和验证码操作,但能覆盖个人邮箱访问、投递与账户特例。
**Decision**:按用户批准,以该次生产代表性真人全链路作为系统级密码找回验收;其余 2 人不要求本轮逐人实测,状态固定为“预计可用、未真人密码登录验收”。
**Consequences**:本任务可按系统能力收口,但事实记录永久保留实际覆盖 4/6,禁止写成全员真人密码回退 6/6。两人未来实际使用时若出现邮箱投递、锁定或账号特例,按新的现场事实单独诊断。
## Acceptance Criteria
- [x] 管理员本人能使用一次性凭据完成首次改密,且凭据未进入聊天、任务材料、日志或仓库。
- [x] 管理员本人能从企业微信工作台直接进入 OA,不再出现未绑定提示。
- [x] 一个“无 binding、企微企业邮箱唯一匹配 active OA 账户”的合成场景能在不输入 OA 密码的情况下自动建立 binding、写审计并直接登录。
- [x] 企业邮箱缺失、无匹配、inactive、userid/binding 冲突等负向场景全部失败关闭,且不创建账户、不改角色、不按姓名绑定。
- [x] 腾讯云生产忘记密码、设置新密码和新密码登录已由代表性真人完整通过;实际真人密码回退覆盖为 4/6,另外 2 人按用户批准记为预计可用、未逐人实测,不宣称 6/6 真人密码登录。
- [x] 所有属于当前企业微信通讯录的 active 账户均有唯一 binding,并完成本人企微真机登录验收;用户于 2026-08-23 确认其余 3 人全部可以进入,当前 6/6 真人企微主入口通过。
- [x] 不存在 active 账户同时缺少密码找回能力和可用企微 binding;当前 6/6 均已有经本人验证的企微主入口,密码找回系统能力已有代表性生产 PASS,实际真人密码回退覆盖为 4/6。
- [x] 本任务实际发生的重置、绑定、解锁和会话撤销均有 OA 审计;生产复核未发现角色扩张或企业微信通讯录写入。
- [x] 当前唯一 active `manager` 的直属主管唯一解析为 active `majianyong`,不存在自环或主管链环路,并有脱敏审计;`wangshunli` 不进入业务审批链。
- [x] `majianyong` 保持 `ceo` 且无直属主管;其本人报销目标为 active 非本人 `finance`,当前 `finance_mgr` 配置缺口未修复前保持阻断且不发生自审。
- [x] 自动化、Trellis、敏感扫描、生产 `/ready` 和关联认证 smoke 已通过;未实测人员未来需要时可使用同一找回流程,但本轮不要求逐人操作。
- [x] 登录、修改密码和忘记密码页的密码框按 Tab 跳过框内显示/隐藏按钮并进入 DOM 中下一个顺序表单控件;显示/隐藏按钮仍可点击、保留稳定可访问名称,且切换不丢失输入值。
当前员工可测试边界:生产邮件能力、公网 `/ready`、认证 capabilities 与 SMTP 连接/认证已通过;已有 1 名首次改密人员完成全链路,另外 2 人按批准不要求本轮逐人实测,仅记为预计可用。其本人未来使用找回流程时仍不得在聊天中传递密码或验证码,不连续点击重发;新签发验证码会使旧验证码失效。公网根入口到登录、忘记密码页面及密码框 Tab 行为已通过真实浏览器复核;直接刷新前端子路由的 history fallback 另行治理,不属于本任务登录能力和 Tab 改动的验收范围。
## Definition of Done
- 只读基线、写入前后差异、代表性生产验收、实际真人覆盖和剩余阻断均以脱敏计数记录。
- 相关后端测试、前端 typecheck/build、认证 smoke、Trellis 治理和敏感扫描通过。
- 生产变更具备最小回滚:binding 修正可恢复原映射,密码重置通过 token version 和会话撤销确保旧凭据失效。
- 现有管理员手册和试运行事实源与最终登录基线一致。
## Test Plan
- 生产前后只读聚合:active 总数、必须改密数、锁定数、binding 数和各成功登录类型覆盖数。
- 企业微信工作台免登按 6/6 真人确认记录;密码找回按代表性生产全链路与实际覆盖 4/6 分开记录,未实测 2 人只标记预计可用。人工证据只记录 PASS/BLOCKED 和稳定原因。
- 若发生管理员一次性密码兜底,核对强制改密、旧会话撤销、token version 和审计增量,不记录密码正文。
- 运行认证聚焦后端测试、前端 typecheck/build、生产 `/ready` 与认证 capabilities、公网只读 smoke。
- 运行 Trellis task/governance、敏感扫描、文档审计、流程合同和 `git diff --check`。
## Out of Scope
- 不在 OA 中创建或修改企业微信成员。
- 不把真实密码、完整账号、手机号、邮箱、企微 userid 或认证原始 payload 写入任务证据。
- 不启用自助 OAuth onboarding,不重构认证架构,不放宽登录、绑定或账户管理权限。
- 本轮不修改“邮箱验证码登录后首次改密仍要求旧密码”的认证流程。
- 本轮 Tab 优化不改变密码校验、提交、自动填充、验证码、邮件投递或登录接口。
- 不启用会自动创建 OA 员工的通用企微 OAuth self-provisioning;本轮普通登录只允许匹配并绑定既有 active OA 账户。
- 不顺带导入剩余通讯录成员、修改角色或业务审批规则;主管字段仅包含用户最终批准的单条 `manager -> majianyong` OA 关系。
## Technical Notes
- 企微登录入口与 binding:`server/src/routes/wecomAuth.js`。
- 账号密码和邮箱验证码:`server/src/routes/auth.js`;普通密码登录不再消费 pending bind 建立企微映射。
- 管理员一次性密码重置:`server/src/routes/users.js`。
- 当前产品事实源:`docs/管理员手册.md` 第 10、12 节。
- 安全边界:`.trellis/spec/hyb-oa/security-privacy.md`、`.trellis/spec/hyb-oa/wecom-boundary.md`。
@@ -0,0 +1,129 @@
"use strict";
const path = require("node:path");
const OBSERVATION_WINDOW_DAYS = 7;
const SAFE_RESULT_KEYS = Object.freeze([
"abnormalActiveSessions",
"activeEmployeesWithSession",
"activeSessionsTotal",
"activeTotal",
"bindingConflictGroups",
"bindingCovered",
"bindingMissing",
"bindingMutationAudits7d",
"directoryMutationAudits7d",
"emailLoginLocked",
"inactiveBindingRows",
"mustChangePassword",
"passwordLoginCoveredAllTime",
"passwordLoginCovered7d",
"passwordLocked",
"passwordResetLocked",
"roleMutationAudits7d",
"wecomLoginCoveredAllTime",
"wecomLoginCovered7d",
"wecomLoginEvents7d",
]);
const READINESS_SQL = String.raw`
WITH active AS (
SELECT id
FROM employees
WHERE status = 'active' AND archived_at IS NULL
), binding_conflicts AS (
SELECT count(*)::int AS groups
FROM (
SELECT employee_id::text
FROM wecom_identity_bindings
GROUP BY employee_id
HAVING count(*) > 1
UNION ALL
SELECT external_user_id
FROM wecom_identity_bindings
GROUP BY external_user_id
HAVING count(*) > 1
) conflicts
), metrics AS (
SELECT
(SELECT count(*) FROM active)::int AS "activeTotal",
(SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.must_change_password)::int AS "mustChangePassword",
(SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.login_locked_until > now())::int AS "passwordLocked",
(SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.email_login_code_locked_until > now())::int AS "emailLoginLocked",
(SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.password_reset_locked_until > now())::int AS "passwordResetLocked",
(SELECT count(DISTINCT b.employee_id) FROM wecom_identity_bindings b JOIN active a ON a.id = b.employee_id)::int AS "bindingCovered",
(SELECT count(*) FROM active a LEFT JOIN wecom_identity_bindings b ON b.employee_id = a.id WHERE b.id IS NULL)::int AS "bindingMissing",
(SELECT groups FROM binding_conflicts)::int AS "bindingConflictGroups",
(SELECT count(*) FROM wecom_identity_bindings b JOIN employees e ON e.id = b.employee_id WHERE e.status <> 'active' OR e.archived_at IS NOT NULL)::int AS "inactiveBindingRows",
(SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_WECOM_LOGIN_SUCCESS')::int AS "wecomLoginCoveredAllTime",
(SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_WECOM_LOGIN_SUCCESS' AND l.created_at >= now() - interval '7 days')::int AS "wecomLoginCovered7d",
(SELECT count(*) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_WECOM_LOGIN_SUCCESS' AND l.created_at >= now() - interval '7 days')::int AS "wecomLoginEvents7d",
(SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_LOGIN_SUCCESS')::int AS "passwordLoginCoveredAllTime",
(SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_LOGIN_SUCCESS' AND l.created_at >= now() - interval '7 days')::int AS "passwordLoginCovered7d",
(SELECT count(*) FROM audit_logs WHERE action = 'SYSTEM_USER_ROLES_SET' AND created_at >= now() - interval '7 days')::int AS "roleMutationAudits7d",
(SELECT count(*) FROM audit_logs WHERE action LIKE 'WECOM_DIRECTORY_%' AND created_at >= now() - interval '7 days')::int AS "directoryMutationAudits7d",
(SELECT count(*) FROM audit_logs WHERE action = 'AUTH_WECOM_BIND' AND created_at >= now() - interval '7 days')::int AS "bindingMutationAudits7d",
(SELECT count(*) FROM auth_sessions s WHERE s.revoked_at IS NULL AND s.expires_at > now())::int AS "activeSessionsTotal",
(SELECT count(DISTINCT s.employee_id) FROM auth_sessions s JOIN active a ON a.id = s.employee_id WHERE s.revoked_at IS NULL AND s.expires_at > now())::int AS "activeEmployeesWithSession",
(SELECT count(*) FROM auth_sessions s JOIN employees e ON e.id = s.employee_id WHERE s.revoked_at IS NULL AND s.expires_at > now() AND (e.status <> 'active' OR e.archived_at IS NOT NULL))::int AS "abnormalActiveSessions"
)
SELECT * FROM metrics
`;
function assertSafeMetrics(metrics) {
const keys = Object.keys(metrics).sort();
if (JSON.stringify(keys) !== JSON.stringify([...SAFE_RESULT_KEYS].sort())) {
throw new Error("result_field_contract_invalid");
}
if (Object.values(metrics).some((value) => !Number.isInteger(value) || value < 0)) {
throw new Error("result_value_contract_invalid");
}
}
async function run() {
const appRoot = path.resolve(process.env.AUTH_CENSUS_APP_ROOT || process.cwd());
const { Client } = require(path.join(appRoot, "node_modules/pg"));
const { getConfig } = require(path.join(appRoot, "src/config.js"));
const config = getConfig();
const client = new Client({ connectionString: process.env.DATABASE_URL });
let inTransaction = false;
try {
await client.connect();
await client.query("BEGIN TRANSACTION READ ONLY");
inTransaction = true;
await client.query("SET LOCAL statement_timeout = '30s'");
await client.query("SET LOCAL lock_timeout = '2s'");
const readOnly = await client.query("SELECT current_setting('transaction_read_only') AS value");
if (readOnly.rows[0]?.value !== "on") throw new Error("transaction_not_read_only");
const result = await client.query(READINESS_SQL);
const metrics = result.rows[0];
assertSafeMetrics(metrics);
await client.query("COMMIT");
inTransaction = false;
process.stdout.write(`${JSON.stringify({
status: "PASS",
transactionReadOnly: true,
sourceBuildSha: config.buildSha,
observationWindowDays: OBSERVATION_WINDOW_DAYS,
metrics,
}, null, 2)}\n`);
} catch {
if (inTransaction) {
try { await client.query("ROLLBACK"); } catch { /* fail closed */ }
}
process.stderr.write("[auth-readiness-census][FAILED] code=QUERY_FAILED\n");
process.exitCode = 1;
} finally {
await client.end().catch(() => {});
}
}
module.exports = { OBSERVATION_WINDOW_DAYS, READINESS_SQL, SAFE_RESULT_KEYS, assertSafeMetrics };
if (require.main === module || process.argv[1] === "-") {
run();
}
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
TASK_RESEARCH_DIR="$ROOT_DIR/.trellis/tasks/07-28-all-account-login-readiness/research"
QUERY_SCRIPT="$TASK_RESEARCH_DIR/auth-readiness-census.cjs"
IDENTITY_MODULE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap.mjs"
IDENTITY_REMOTE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap-remote.sh"
PREFLIGHT_SCRIPT="$ROOT_DIR/deploy/trialrun/production-preflight.mjs"
CONTRACT_LIB="$ROOT_DIR/deploy/trialrun/deployment-contract-lib.sh"
CONTRACT_UNIT="hyb-oa-automatic-backup-daily.service"
CONFIRMATION="READ_ONLY_AUTH_READINESS"
# shellcheck source=../../../../deploy/trialrun/deployment-contract-lib.sh
source "$CONTRACT_LIB"
fail() {
printf '[auth-readiness-runner][FAILED] code=%s\n' "$1" >&2
exit 1
}
shell_quote() {
printf "'%s'" "${1//\'/\'\\\'\'}"
}
mode="${1:---preflight}"
case "$mode" in
--preflight)
[[ "$#" -eq 1 ]] || fail "UNSUPPORTED_ARGUMENTS"
;;
--execute)
[[ "$#" -eq 2 && "${2:-}" == "$CONFIRMATION" ]] || fail "EXPLICIT_CONFIRMATION_REQUIRED"
;;
*)
fail "UNSUPPORTED_ARGUMENTS"
;;
esac
for file in "$QUERY_SCRIPT" "$IDENTITY_MODULE" "$IDENTITY_REMOTE" "$PREFLIGHT_SCRIPT"; do
[[ -f "$file" && ! -L "$file" ]] || fail "LOCAL_CONTRACT_MISSING"
done
cd "$ROOT_DIR"
identity_fields="$({
node --input-type=module - "$IDENTITY_MODULE" <<'NODE'
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const modulePath = process.argv[2];
const mod = await import(pathToFileURL(modulePath));
const targetSource = execFileSync("git", ["show", "34181e3:deploy/trialrun/deploy.sh"], { encoding: "utf8" });
const projectSource = execFileSync("git", ["show", "5e2d038938265c926e06d3cc6d0841f6dcc06148:deploy/trialrun/deploy.sh"], { encoding: "utf8" });
const identity = mod.parseHistoricalDeploymentIdentity(targetSource, projectSource);
const runbook = fs.readFileSync("docs/trialrun/OPS7运维交接Runbook.md", "utf8");
const origin = mod.parseExpectedProductionOrigin(runbook);
process.stdout.write(`${identity.deployServer}\t${identity.composeProjectName}\t${origin}`);
NODE
} 2>/dev/null)" || fail "LOCAL_IDENTITY_PARSE_FAILED"
IFS=$'\t' read -r deploy_server expected_project expected_origin <<<"$identity_fields"
deployment_contract_safe_ssh_target "$deploy_server" || fail "DEPLOY_SERVER_INVALID"
deployment_contract_safe_project "$expected_project" || fail "COMPOSE_PROJECT_INVALID"
[[ "$expected_origin" == https://* ]] || fail "PRODUCTION_ORIGIN_INVALID"
PRODUCTION_IDENTITY_ONLY=1 node "$IDENTITY_MODULE" || fail "PRODUCTION_IDENTITY_FAILED"
declare -A contract=()
deployment_contract_load "$deploy_server" "$CONTRACT_UNIT" contract || fail "SYSTEMD_CONTRACT_UNAVAILABLE"
[[ "${contract[COMPOSE_PROJECT_NAME]}" == "$expected_project" ]] || fail "COMPOSE_PROJECT_MISMATCH"
origin_hash="$(printf '%s' "$expected_origin" | sha256sum | awk '{print $1}')"
database_output="$(
ssh "$deploy_server" \
"IDENTITY_BOOTSTRAP_MODE='database' REMOTE_DIR=$(shell_quote "${contract[REMOTE_DIR]}") REMOTE_ENV_FILE=$(shell_quote "${contract[REMOTE_ENV_FILE]}") COMPOSE_FILE=$(shell_quote "${contract[COMPOSE_FILE]}") COMPOSE_PROJECT_NAME=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") EXPECTED_PRODUCTION_ORIGIN_SHA256='$origin_hash' bash -s" \
< "$IDENTITY_REMOTE"
)" || fail "DATABASE_IDENTITY_FAILED"
database_hash="$(printf '%s' "$database_output" | sed -n 's/^\[production-identity-bootstrap\]\[DB_SHA256\] \([0-9a-f]\{64\}\)$/\1/p')"
[[ "$database_hash" =~ ^[0-9a-f]{64}$ ]] || fail "DATABASE_IDENTITY_PAYLOAD_INVALID"
candidate_commit="$(git rev-parse origin/main)"
[[ "$candidate_commit" =~ ^[0-9a-f]{40,64}$ ]] || fail "DEPLOYED_COMMIT_INVALID"
[[ -z "$(git diff --name-only "$candidate_commit"..HEAD -- client/src server/src deploy/trialrun/docker-compose.yml)" ]] || fail "PRODUCT_DELTA_AFTER_DEPLOYED_COMMIT"
runtime_check_command="
set -euo pipefail
server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\"
[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]]
runtime_sha=\"\$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' \"\$server_ids\" | sed -n 's/^BUILD_SHA=//p' | tail -n 1)\"
[[ \"\$runtime_sha\" == $(shell_quote "$candidate_commit") ]]
printf '[auth-readiness-runner][PASS] runtime_build_exact\\n'
"
ssh "$deploy_server" "bash -c $(shell_quote "$runtime_check_command")" || fail "RUNTIME_BUILD_MISMATCH"
report_dir="$(mktemp -d)"
trap 'rm -rf -- "$report_dir"' EXIT
EXPECTED_PRODUCTION_ORIGIN="$expected_origin" \
EXPECTED_PRODUCTION_DB_NAME_SHA256="$database_hash" \
EXPECTED_PENDING_MIGRATIONS="" \
EXPECTED_DEPLOY_SERVER="$deploy_server" \
EXPECTED_REMOTE_DIR="${contract[REMOTE_DIR]}" \
EXPECTED_COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \
DEPLOY_SERVER="$deploy_server" \
REMOTE_DIR="${contract[REMOTE_DIR]}" \
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
COMPOSE_FILE="${contract[COMPOSE_FILE]}" \
COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \
POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \
STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \
CANDIDATE_COMMIT="$candidate_commit" \
PRODUCTION_PREFLIGHT_REPORT_DIR="$report_dir/preflight" \
node "$PREFLIGHT_SCRIPT" || fail "PRODUCTION_PREFLIGHT_FAILED"
printf '[auth-readiness-runner][PASS] preflight deployedSource=origin/main productDelta=0\n'
if [[ "$mode" == "--preflight" ]]; then
printf '[auth-readiness-runner][PASS] read_only_preflight_complete queryExecuted=0\n'
exit 0
fi
remote_query_command="
set -euo pipefail
server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\"
[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]]
docker exec -i -w /app/server \"\$server_ids\" node -
"
query_output="$(ssh "$deploy_server" "bash -c $(shell_quote "$remote_query_command")" < "$QUERY_SCRIPT")" || fail "QUERY_EXECUTION_FAILED"
validated_output="$(printf '%s' "$query_output" | node -e '
const fs = require("node:fs");
const expected = process.argv[1];
const value = JSON.parse(fs.readFileSync(0, "utf8"));
if (value.status !== "PASS" || value.transactionReadOnly !== true) process.exit(1);
if (value.sourceBuildSha !== expected.slice(0, 12)) process.exit(1);
if (value.observationWindowDays !== 7 || !value.metrics) process.exit(1);
const allowed = [
"abnormalActiveSessions", "activeEmployeesWithSession", "activeSessionsTotal", "activeTotal",
"bindingConflictGroups", "bindingCovered", "bindingMissing", "bindingMutationAudits7d",
"directoryMutationAudits7d", "emailLoginLocked", "inactiveBindingRows", "mustChangePassword",
"passwordLoginCoveredAllTime", "passwordLoginCovered7d", "passwordLocked", "passwordResetLocked",
"roleMutationAudits7d", "wecomLoginCoveredAllTime", "wecomLoginCovered7d", "wecomLoginEvents7d",
].sort();
if (JSON.stringify(Object.keys(value.metrics).sort()) !== JSON.stringify(allowed)) process.exit(1);
if (Object.values(value.metrics).some((item) => !Number.isInteger(item) || item < 0)) process.exit(1);
process.stdout.write(JSON.stringify(value, null, 2));
' "$candidate_commit")" || fail "QUERY_OUTPUT_INVALID"
printf '%s\n' "$validated_output"
printf '[auth-readiness-runner][PASS] queryExecuted=1 zeroRetry=1\n' >&2
@@ -0,0 +1,26 @@
{
"id": "all-account-login-readiness",
"name": "all-account-login-readiness",
"title": "全员登录可用性",
"description": "核对并修复 active OA 账户登录路径,实现 userid/唯一企业邮箱预绑定或自动补绑定,保证员工首次从企微工作台进入无需 OA 密码即可直接登录。",
"status": "completed",
"dev_type": "operations",
"scope": "authentication",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": "2026-08-23",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "首次企微免密登录代码已进入生产,当前 6/6 真人企微主入口通过;生产邮件找回经批准恢复且代表性真人全链路通过,实际真人密码回退覆盖为 4/6,另外 2 人按批准仅记为预计可用。认证密码框顺序 Tab 已按国内连续填表习惯在本地调整:跳过显示/隐藏按钮并进入下一顺序表单控件,同时保留点击、动态可访问名称、按下状态和输入关联;前端构建、3 条聚焦 E2E 与治理门禁通过。代码尚未提交、未部署生产。",
"meta": {}
}