修复企微通讯录生产导入兼容性(07-26-internal-ops-recovery-baseline)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run

这个提交包含在:
2026-07-27 18:31:04 +08:00
父节点 cf63d3cb1b
当前提交 4886f9b081
共修改 7 个文件,包含 84 行新增和 8 行删除
+25
查看文件
@@ -251,6 +251,9 @@ business role assignment -> separate administrator-audited operation
- CLI: `npm run import:wecom-directory-export -- --input=- --dry-run`. - CLI: `npm run import:wecom-directory-export -- --input=- --dry-run`.
- Apply: `WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply - Apply: `WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply
--actor-employee-no=<employee-no>`. --actor-employee-no=<employee-no>`.
- Binary stdin reader: `fs.readFileSync(0)`; do not assume
`fs.promises.readFile(0)` accepts a numeric file descriptor on the production
Node runtime.
- User API and frontend employee/user types expose `phone: string | null`; - User API and frontend employee/user types expose `phone: string | null`;
create/update accepts an omitted or explicit `null` phone. create/update accepts an omitted or explicit `null` phone.
@@ -266,6 +269,9 @@ business role assignment -> separate administrator-audited operation
supplied valid phone participates in update and unique-conflict checks. supplied valid phone participates in update and unique-conflict checks.
- The XLSX is accepted only from stdin and defaults to dry-run. Apply requires - The XLSX is accepted only from stdin and defaults to dry-run. Apply requires
both `--apply` and `WECOM_DIRECTORY_EXPORT_APPLY=1`, plus an active actor. both `--apply` and `WECOM_DIRECTORY_EXPORT_APPLY=1`, plus an active actor.
- Read the XLSX directly from fd 0 without a temporary file. A runtime that
rejects the stdin read must stop before Prisma sync; do not fall back to
persisting the workbook on the host or in the container.
- CLI output contains counts and categories only. The workbook, full local path, - CLI output contains counts and categories only. The workbook, full local path,
names, accounts, phones, emails, departments and raw rows must not enter Git, names, accounts, phones, emails, departments and raw rows must not enter Git,
Trellis artifacts or reports. Trellis artifacts or reports.
@@ -283,6 +289,9 @@ business role assignment -> separate administrator-audited operation
`duplicate_values`; error output contains counts only. `duplicate_values`; error output contains counts only.
- Existing OA unique-field or binding conflict -> conflict; no automatic merge. - Existing OA unique-field or binding conflict -> conflict; no automatic merge.
- `--apply` without confirmation env or active actor -> refuse before sync. - `--apply` without confirmation env or active actor -> refuse before sync.
- Promise-based stdin fd read returns `ERR_INVALID_ARG_TYPE` on the deployed
Node runtime -> no sync/write; use the fd-compatible reader and rerun dry-run
before requesting apply approval.
- Migration rollback with any `phone IS NULL` row -> stop; do not fill fake - Migration rollback with any `phone IS NULL` row -> stop; do not fill fake
values merely to restore `NOT NULL`. values merely to restore `NOT NULL`.
@@ -293,6 +302,8 @@ business role assignment -> separate administrator-audited operation
only aggregate counts. only aggregate counts.
- Base: a later export still omits a member phone; the member's existing OA - Base: a later export still omits a member phone; the member's existing OA
phone is preserved. phone is preserved.
- Base: the local and production Node versions differ; the same binary stdin
workbook parses through `fs.readFileSync(0)` without a temporary file.
- Bad: write an empty string or shared placeholder, use personal email in place - Bad: write an empty string or shared placeholder, use personal email in place
of the required enterprise email for bulk identity, or auto-grant a business of the required enterprise email for bulk identity, or auto-grant a business
role during import. role during import.
@@ -302,6 +313,8 @@ business role assignment -> separate administrator-audited operation
- Prisma validation plus a migration review proving only `DROP NOT NULL`. - Prisma validation plus a migration review proving only `DROP NOT NULL`.
- Unit tests for instruction rows/Chinese headers, five-present/one-missing, - Unit tests for instruction rows/Chinese headers, five-present/one-missing,
malformed phone, missing enterprise email and duplicate account/phone/email. malformed phone, missing enterprise email and duplicate account/phone/email.
- Unit regression asserts the CLI stdin helper calls `fs.readFileSync(0)`;
production dry-run must prove aggregate parsing/sync counts before apply.
- Route/service tests for create/update/import `null`, non-empty unique checks, - Route/service tests for create/update/import `null`, non-empty unique checks,
OAuth missing phone and empty-source preservation of an existing phone. OAuth missing phone and empty-source preservation of an existing phone.
- Frontend typecheck/build and displays that render a clear empty-phone label. - Frontend typecheck/build and displays that render a clear empty-phone label.
@@ -323,3 +336,15 @@ phone: source.phone || "00000000000"
phone: source.phone || null phone: source.phone || null
// Existing employee updates omit `phone` entirely when the source is empty. // Existing employee updates omit `phone` entirely when the source is empty.
``` ```
#### Wrong
```js
const workbook = await fs.promises.readFile(0);
```
#### Correct
```js
const workbook = fs.readFileSync(0);
```
@@ -75,14 +75,20 @@ isolated drill
## 真实职责写入门 ## 真实职责写入门
- 先读取全量候选并在内存中按目标姓名匹配,只输出两个匹配计数、身份哈希是否不同、缺失字段数和计划动作数量。 - 从管理员通讯录文件在本地内存中筛选两名目标,只把两人子集通过 stdin 送入生产 dry-run/apply;其余四人不进入生产输入、不创建账号,留待后续独立批次。
- dry-run 和 apply 只输出两个匹配计数、身份是否不同、缺失字段数、冲突数和计划动作数量,不持久化筛选文件、完整路径或原始行。
- 只有两个匹配计数均为 1、企微身份不同、均为 active、必填资料完整且手机号/邮箱不冲突时,才进入 apply。 - 只有两个匹配计数均为 1、企微身份不同、均为 active、必填资料完整且手机号/邮箱不冲突时,才进入 apply。
- 员工和企微绑定通过既有 HR master sync 服务创建或更新;随后通过既有角色写入语义分别设置 `employee + finance` 和 `employee + ceo`。 - 员工和企微绑定通过既有 HR master sync 服务创建或更新;随后通过既有角色写入语义分别设置 `employee + finance` 和 `employee + ceo`。
- 角色变更递增 `token_version`、撤销活动会话并写 `SYSTEM_USER_ROLES_SET` 审计;最终只核对数量、角色集合、不同 employee id、审计存在和付款业务零写入。 - `finance` 的批准范围明确包含 `payroll.manage`、`employee.bank.read`、财务付款和付款批次管理,不包含 `asset.transfer`;不以 `finance_mgr` 解决 CEO 本人报销兜底。
- 角色变更递增 `token_version`、撤销活动会话并写 `SYSTEM_USER_ROLES_SET` 审计;最终只核对数量、角色集合、不同 employee id、角色待办计数变化、审计存在和付款业务零写入。
- maker-checker 只验证付款批次创建人不能复核同一批次,不宣称费用审批与财务处理完全分离;生产不创建测试付款批次,使用合成路由测试和生产只读身份/权限证据。
- 回滚是通过相同角色接口恢复两人的写入前角色集合,并再次撤销会话、保留审计;不得通过整库恢复回退角色。
- 任一门禁失败即停止,不进行部分角色写入;如员工同步已经成功而角色阶段失败,保留同步审计并立即报告,不用删除真实员工记录进行破坏性回滚。 - 任一门禁失败即停止,不进行部分角色写入;如员工同步已经成功而角色阶段失败,保留同步审计并立即报告,不用删除真实员工记录进行破坏性回滚。
## 企微本人敏感信息授权边界 ## 企微本人敏感信息授权边界
- 本轮生产身份建档不走本人授权,而走管理员通讯录两人子集。以下 OAuth 合同继续保留为已实现、默认关闭的未来补资料能力,本轮验收标记为不适用,不要求成员点击授权链接。
- `/user/get` 和人事助手批量读取继续只负责 userid、姓名、部门等可读字段;不得把批量缺字段误判为企微完全不支持手机号和邮箱。 - `/user/get` 和人事助手批量读取继续只负责 userid、姓名、部门等可读字段;不得把批量缺字段误判为企微完全不支持手机号和邮箱。
- 2022-06-20 后新建自建应用的敏感字段走独立 OAuth 数据流:管理员在应用详情选择字段,成员以 `snsapi_privateinfo` 手工确认,`getuserinfo` 返回短时 `user_ticket`,服务端立即调用 `/cgi-bin/auth/getuserdetail`。 - 2022-06-20 后新建自建应用的敏感字段走独立 OAuth 数据流:管理员在应用详情选择字段,成员以 `snsapi_privateinfo` 手工确认,`getuserinfo` 返回短时 `user_ticket`,服务端立即调用 `/cgi-bin/auth/getuserdetail`。
- `user_ticket`、授权 code 和完整敏感响应不得写日志、任务证据或审计 extra;只保存业务所需的规范化手机号/邮箱及字段存在性审计。 - `user_ticket`、授权 code 和完整敏感响应不得写日志、任务证据或审计 extra;只保存业务所需的规范化手机号/邮箱及字段存在性审计。
@@ -115,6 +121,7 @@ GET /auth/wecom/login?mode=onboard
- `Employee.phone` 改为 nullable unique:缺失值统一保存为 `NULL`,非空值继续唯一。企业邮箱仍必填且唯一,企微账号仍是绑定主键;不生成占位手机号。 - `Employee.phone` 改为 nullable unique:缺失值统一保存为 `NULL`,非空值继续唯一。企业邮箱仍必填且唯一,企微账号仍是绑定主键;不生成占位手机号。
- 来源未提供手机号时,不得把既有 OA 手机号清空;来源提供手机号时才进入变更和唯一性检查。本人授权同样允许手机号缺失,但邮箱或企业邮箱仍必须有效。 - 来源未提供手机号时,不得把既有 OA 手机号清空;来源提供手机号时才进入变更和唯一性检查。本人授权同样允许手机号缺失,但邮箱或企业邮箱仍必须有效。
- apply 需要命令参数和独立环境变量双门,且真实运行另行批准;dry-run 与 apply 的报告都只含计数、动作、冲突类别和脱敏摘要。 - apply 需要命令参数和独立环境变量双门,且真实运行另行批准;dry-run 与 apply 的报告都只含计数、动作、冲突类别和脱敏摘要。
- 本轮在内存中筛选两名目标后再送入 stdin;筛选结果不落盘,目标外四人不进入 dry-run/apply,也不因出现在原始导出文件中而获得 OA 活跃账号。
- 生产迁移回滚前必须确认不存在 `phone IS NULL`;否则停止回滚,不用假数据填充。 - 生产迁移回滚前必须确认不存在 `phone IS NULL`;否则停止回滚,不用假数据填充。
## 部署目录统一设计约束 ## 部署目录统一设计约束
@@ -27,8 +27,12 @@
- [x] 让成对 manifest 尊重显式 `BUILD_SHA` 并支持活动镜像 digest;补 Node/Python 等价与非法来源标识负向测试。 - [x] 让成对 manifest 尊重显式 `BUILD_SHA` 并支持活动镜像 digest;补 Node/Python 等价与非法来源标识负向测试。
- [x] 把旧 Compose project 实测、镜像 digest、58 项迁移与关键表计数、影子逐表/附件计数验收、停机后复制并保留旧目录写入 runbook/checklist/spec。 - [x] 把旧 Compose project 实测、镜像 digest、58 项迁移与关键表计数、影子逐表/附件计数验收、停机后复制并保留旧目录写入 runbook/checklist/spec。
- [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。 - [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。
- [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。 - [x] 本轮不适用/延后:不启用企微敏感资料 OAuth 开关,不要求两名目标点击一次性授权;入口保持默认关闭,未来仅用于本人补资料。
- [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。 - [x] 从管理员通讯录在内存中筛选两名目标,先执行生产脱敏 dry-run;其余四人不进入输入、不创建账号,另轮处理。
- [ ] dry-run 零冲突后另行批准 apply,确认两人账号/企微绑定唯一且初始角色严格为 `employee`,不持久化通讯录文件。
- [ ] 记录两人原角色、活跃会话、角色待办和审计基线,再分别写入 `employee + finance` / `employee + ceo`;明确 `finance` 含薪资/银行资料权限且不含 `asset.transfer`,不授予 `finance_mgr`。
- [ ] 复核角色接口递增 token version、撤销会话和 `SYSTEM_USER_ROLES_SET` 审计;以合成测试证明付款批次 maker-checker,不在生产创建测试付款数据,也不把结论扩大为费用审批完全分离。
- [ ] 如需回滚,只通过相同角色接口恢复两人原角色集合并复核会话/审计,不恢复整库;记录 CEO 既有角色待办可见性变化及本人无主管报销继续阻断的已知限制。
- [x] 将 `Employee.phone` 改为 nullable unique,并同步员工创建/批量导入、企微 HR master、本人授权、登录查询和前端空值合同;不执行生产 migration。 - [x] 将 `Employee.phone` 改为 nullable unique,并同步员工创建/批量导入、企微 HR master、本人授权、登录查询和前端空值合同;不执行生产 migration。
- [x] 增加企微通讯录 XLSX/stdin 默认 dry-run 入口,复用 HR master 写入语义,apply 使用独立双门且本阶段不执行。 - [x] 增加企微通讯录 XLSX/stdin 默认 dry-run 入口,复用 HR master 写入语义,apply 使用独立双门且本阶段不执行。
- [x] 用完全合成的通讯录覆盖“5 个手机号 + 1 个缺失”、重复账号/手机号/邮箱、说明行/中文表头、既有手机号不被空来源清除和报告隐私门禁。 - [x] 用完全合成的通讯录覆盖“5 个手机号 + 1 个缺失”、重复账号/手机号/邮箱、说明行/中文表头、既有手机号不被空来源清除和报告隐私门禁。
@@ -81,6 +85,8 @@
- `prisma validate`、四个后端改动文件语法、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。 - `prisma validate`、四个后端改动文件语法、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。
- 管理员提供的真实通讯录仅执行解析级只读检查:6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库、未执行 apply、未分配角色、未发送企微消息,文件保持 Git 排除。 - 管理员提供的真实通讯录仅执行解析级只读检查:6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库、未执行 apply、未分配角色、未发送企微消息,文件保持 Git 排除。
- 最终只读复核确认 migration 仅删除手机号 `NOT NULL`、唯一索引保留;导入及本人开通只创建 `employee`,不自动授予 `finance`、`ceo`、`admin` 或 `system_admin`;空来源更新省略 phone 字段,真实文件名、完整路径和个人字段未进入差异。 - 最终只读复核确认 migration 仅删除手机号 `NOT NULL`、唯一索引保留;导入及本人开通只创建 `employee`,不自动授予 `finance`、`ceo`、`admin` 或 `system_admin`;空来源更新省略 phone 字段,真实文件名、完整路径和个人字段未进入差异。
- 获批生产脱敏 dry-run 只接收内存筛选的两人子集,结果为输入 2、active 2、不完整 0、计划创建 2、更新 0、冲突/跳过/失败均为 0;两名目标均有手机号和企业邮箱。dry-run 未写库、未创建账号、未分配角色,目标外四人未进入输入。
- 生产 Node 对 Promise 版 `readFile(0)` 返回 `ERR_INVALID_ARG_TYPE`,正式 CLI 在解析前安全失败;本地改为 `fs.readFileSync(0)` 并增加 fd 读取回归。生产证据使用同一解析、同步和报告逻辑的 fd 兼容只读入口取得,正式 CLI 修复部署前不用于 apply。
## 生产部署与 timer 收口证据(2026-07-27) ## 生产部署与 timer 收口证据(2026-07-27)
@@ -23,6 +23,7 @@
- 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。 - 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。
- 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。 - 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。
- 支持管理员提供的企微通讯录 XLSX 通过 stdin 做脱敏 dry-run,并允许个别成员暂缺手机号;企业邮箱、企微账号、姓名和部门仍作为建档门禁。 - 支持管理员提供的企微通讯录 XLSX 通过 stdin 做脱敏 dry-run,并允许个别成员暂缺手机号;企业邮箱、企微账号、姓名和部门仍作为建档门禁。
- 本轮通讯录导入只筛选两名已具备职责验收计划的目标成员;其余四名成员不进入 dry-run apply 输入、不创建活跃账号,明确留待后续独立批次。
- 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。 - 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。
## Requirements ## Requirements
@@ -40,9 +41,14 @@
- 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。 - 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。
- `Employee.phone` 允许为 `NULL`,但不得写空字符串或假手机号;非空手机号继续保持唯一。企微账号和企业邮箱必须非空且唯一,缺手机号成员使用企微或邮箱登录,后续可由本人授权或管理员补齐。 - `Employee.phone` 允许为 `NULL`,但不得写空字符串或假手机号;非空手机号继续保持唯一。企微账号和企业邮箱必须非空且唯一,缺手机号成员使用企微或邮箱登录,后续可由本人授权或管理员补齐。
- 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。 - 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。
- 目标角色固定为 `employee + finance` 与 `employee + ceo`,不授予 `finance_mgr`。接受 `finance` 同时具备 `payroll.manage` 和 `employee.bank.read`,意味着财务处理人可访问薪资和员工银行账号;`finance`、`finance_mgr` 均不得拥有 `asset.transfer`。
- 本轮 maker-checker 验收只针对付款批次复核:创建人复核同一批次必须被 `maker_checker_conflict` 拒绝;不得把结论表述为费用审批、财务付款或全部财务操作已完全按自然人分离。
- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并至少实际同意邮箱或企业邮箱授权;手机号可暂缺,但返回时必须规范化并参与唯一性检查。 - 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并至少实际同意邮箱或企业邮箱授权;手机号可暂缺,但返回时必须规范化并参与唯一性检查。
- `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。 - `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。
- 自助开通按企微 userid、非空手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。 - 自助开通按企微 userid、非空手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。
- 本轮真实建档改用管理员导出的通讯录两人子集:先以 stdin dry-run/apply 创建或更新 employee 与企微绑定,且新账号严格只有 `employee`;再由管理员单独写入附加角色。一次性 `snsapi_privateinfo/getuserdetail` 入口保持默认关闭,仅作为未来本人补资料能力,不作为本轮生产验收门禁。
- 角色写入前后记录目标原角色集合、不同 employee 身份、对应角色待办计数、活跃会话数量和审计计数;回滚只把两人恢复为写入前角色集合,复用 token version、会话撤销和审计,不恢复整库。
- CEO 角色会让既有 `assignee_role=ceo` 待办立即可见,并影响新提交生成的角色节点,但不重写既有审批快照,也不恢复过去失败的提交。CEO 本人无主管且没有 active 非本人 `finance_mgr` 时,报销保持阻断,本轮不通过扩权绕过。
- 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。 - 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。
- 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。 - 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。
- Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。 - Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。
@@ -60,14 +66,14 @@
- [x] 相关 shell 语法、QA 脚本、Trellis 治理、敏感扫描和 `git diff --check` 通过。 - [x] 相关 shell 语法、QA 脚本、Trellis 治理、敏感扫描和 `git diff --check` 通过。
- [ ] 在获得单独授权后,确认真实运行 commit、访问边界、职责分离、异机备份及恢复后 `/ready`;未授权前保持未勾选且不宣称正式可用。 - [ ] 在获得单独授权后,确认真实运行 commit、访问边界、职责分离、异机备份及恢复后 `/ready`;未授权前保持未勾选且不宣称正式可用。
- [x] 双 Secret 调用测试证明成员 ID 由通讯录 token 读取,成员详情、部门和 HR 资料由自建应用 token 读取。 - [x] 双 Secret 调用测试证明成员 ID 由通讯录 token 读取,成员详情、部门和 HR 资料由自建应用 token 读取。
- [ ] 真实脱敏 dry-run 证明两个目标各唯一匹配、身份不同、必填资料和唯一约束无阻塞。 - [x] 真实脱敏 dry-run 证明两个目标各唯一匹配、身份不同、必填资料和唯一约束无阻塞。
- [ ] 获批写入后,两名活跃员工分别具有 `employee + finance` 与 `employee + ceo`,企微绑定不同,角色审计存在且未产生付款业务写入。 - [ ] 获批写入后,两名活跃员工分别具有 `employee + finance` 与 `employee + ceo`,企微绑定不同,角色审计存在且未产生付款业务写入。
- [x] 普通企微登录仍生成 `snsapi_base`,一次性开通入口生成 `snsapi_privateinfo`;缺少开关、`user_ticket`、成员授权字段、active 状态或唯一性时均拒绝创建(本地 synthetic 验证)。 - [x] 普通企微登录仍生成 `snsapi_base`,一次性开通入口生成 `snsapi_privateinfo`;缺少开关、`user_ticket`、成员授权字段、active 状态或唯一性时均拒绝创建(本地 synthetic 验证)。
- [ ] 一次性授权通过 `getuserdetail` 获取成员同意的手机和邮箱,自动创建或补齐 OA 员工与企微绑定;新建账号角色严格等于 `employee`,日志和审计不含票据或完整敏感响应。 - [x] 本轮不适用:生产不启用 `snsapi_privateinfo/getuserdetail` 本人授权,OAuth 入口保持默认关闭并延后为补资料能力;真实建档改用企微通讯录两人子集,仍要求新账号角色严格等于 `employee`,且不持久化通讯录文件或原始行。
- [x] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。 - [x] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。
- [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。 - [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。
- [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。 - [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。
- [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。 - [ ] 两名目标成员经企微通讯录两人子集 dry-run/apply 后账号唯一、企微绑定不同且初始角色仅为 `employee`,再由管理员写入 `employee + finance` 与 `employee + ceo`;其余四人保持未导入,付款批次 maker-checker、会话撤销和角色审计通过。
- [x] 本地合成企微导出证明 5 人有手机号、1 人手机号为空时 dry-run 可通过;空值保存为 `NULL`,非空手机号/邮箱/企微账号重复均被拒绝,报告不含个人字段原文。 - [x] 本地合成企微导出证明 5 人有手机号、1 人手机号为空时 dry-run 可通过;空值保存为 `NULL`,非空手机号/邮箱/企微账号重复均被拒绝,报告不含个人字段原文。
## Out of Scope ## Out of Scope
@@ -77,6 +83,7 @@
- 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。 - 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。
- 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。 - 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。
- 不发送企微消息、不创建企微审批模板、不把邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。 - 不发送企微消息、不创建企微审批模板、不把邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。
- 不导入通讯录中的其余四名成员,不授予 `finance_mgr`,不修改审批规则来放行 CEO 本人无主管报销。
- 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。 - 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。
## Test Plan ## Test Plan
@@ -124,3 +131,5 @@
- 企微结构化探测证明 6 名成员的批量详情接口均成功并返回部门,但手机号、邮箱和企业邮箱可读数量均为 0;HR 接口均成功但没有 `staff_info`。官方文档确认这不代表字段不可获取:2022-06-20 后新建自建应用应由管理员在应用详情选择敏感字段,再由成员通过 `snsapi_privateinfo` 手工授权,并用 `user_ticket` 调 `/cgi-bin/auth/getuserdetail` 获取本人手机号、邮箱或企业邮箱。 - 企微结构化探测证明 6 名成员的批量详情接口均成功并返回部门,但手机号、邮箱和企业邮箱可读数量均为 0;HR 接口均成功但没有 `staff_info`。官方文档确认这不代表字段不可获取:2022-06-20 后新建自建应用应由管理员在应用详情选择敏感字段,再由成员通过 `snsapi_privateinfo` 手工授权,并用 `user_ticket` 调 `/cgi-bin/auth/getuserdetail` 获取本人手机号、邮箱或企业邮箱。
- 当前 OA OAuth 只使用 `snsapi_base`,且没有调用 `/cgi-bin/auth/getuserdetail`,因此现有登录链路不能采集敏感字段。两名目标各唯一匹配且身份不同,但批量 dry-run 均缺 `phone`、`email`,真实写入已按门禁停止。 - 当前 OA OAuth 只使用 `snsapi_base`,且没有调用 `/cgi-bin/auth/getuserdetail`,因此现有登录链路不能采集敏感字段。两名目标各唯一匹配且身份不同,但批量 dry-run 均缺 `phone`、`email`,真实写入已按门禁停止。
- 本轮已在本地实现默认关闭的一次性 `onboard` 入口;普通登录继续静默。全量后端门禁与安全补修后的企微聚焦回归结果记录在 `implement.md`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。 - 本轮已在本地实现默认关闭的一次性 `onboard` 入口;普通登录继续静默。全量后端门禁与安全补修后的企微聚焦回归结果记录在 `implement.md`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。
- 2026-07-27 经明确授权,仅把管理员通讯录的两人内存子集通过 stdin 送入活动 server 做生产 dry-run:输入 2、active 2、手机号存在 2、企业邮箱存在 2、不完整 0,计划创建 2、更新 0、冲突 0、跳过 0、失败 0;未执行 apply、未写数据库、未持久化筛选文件,其余四人未进入输入。
- 首次正式 CLI dry-run 暴露生产 Node 对 `fs.promises.readFile(0)` 不兼容并在解析前停止;随后用同一解析器、同一 HR master 服务和同一脱敏报告构造器,仅将 stdin 读取替换为 fd 兼容方式完成上述 dry-run。本地已修复 CLI 读取方式并补回归,修复部署前不得直接使用正式 CLI 执行 apply。
+11
查看文件
@@ -152,3 +152,14 @@
### R-3 资产转移权限 ### R-3 资产转移权限
- `asset.transfer` 只给 `administration / 资产管理员` - `asset.transfer` 只给 `administration / 资产管理员`
- `finance_mgr` 有 `asset.manage` 但不拥有实物调拨权限,这是有意设计 - `finance_mgr` 有 `asset.manage` 但不拥有实物调拨权限,这是有意设计
### R-4 小团队财务处理与付款复核边界
- 日常财务处理角色使用 `finance`,付款复核角色使用 `ceo`;不得为了负责人本人报销兜底而顺带授予权限更宽的 `finance_mgr`。
- `finance` 除费用审批、财务付款和付款批次管理外,还包含 `payroll.manage` 与 `employee.bank.read`,因此会接触薪资数据和员工银行账号;这属于明确接受的财务职责范围,不是隐含副作用。
- `ceo` 通过 `payment.batch.review` 承担付款批次复核。maker-checker 只保证付款批次创建人与复核人不同,不代表费用审批、财务付款和全部财务操作已经按自然人完全分离。
- `finance`、`finance_mgr` 均不拥有 `asset.transfer`;实物调拨继续只属于 `administration`。
### R-5 CEO 本人无主管报销限制
- 现有负责人本人报销规则只允许 active、非本人的 `finance_mgr` 作为 `ownerSelfFallbackRole`;普通 `finance` 不满足该兜底角色。
- 当 CEO 本人没有可解析直属主管且公司没有合适的非本人 `finance_mgr` 时,报销提交保持阻断。不得通过给财务人员顺带增加 `finance_mgr`、使用 `admin` 代批或恢复整库来绕过。
- 授予 `ceo` 后,既有 `assignee_role=ceo` 待办会对该角色持有人可见,新提交单据按当前规则生成 CEO 节点;既有审批快照不重路由,过去提交失败的单据也不会自动恢复。若未来要放开 CEO 本人报销,应另开产品任务调整兜底规则和最小权限,而不是在账号配置阶段扩权。
@@ -35,6 +35,10 @@ Reports contain counts and categories only; personal field values are never prin
`); `);
} }
function readStdinBuffer() {
return fs.readFileSync(0);
}
function parseArgs(argv) { function parseArgs(argv) {
const options = { const options = {
input: "-", input: "-",
@@ -258,7 +262,7 @@ async function main() {
printHelp(); printHelp();
return; return;
} }
const parsed = await parseDirectoryExport(await fs.promises.readFile(0)); const parsed = await parseDirectoryExport(readStdinBuffer());
resources = createPrismaClient(); resources = createPrismaClient();
const actorId = await resolveActorId(resources.prisma, options.actorEmployeeNo); const actorId = await resolveActorId(resources.prisma, options.actorEmployeeNo);
const syncRun = await runWecomHrMasterSync({ const syncRun = await runWecomHrMasterSync({
@@ -293,4 +297,5 @@ module.exports = {
findHeaderRow, findHeaderRow,
parseArgs, parseArgs,
parseDirectoryExport, parseDirectoryExport,
readStdinBuffer,
}; };
@@ -1,3 +1,4 @@
const fs = require("node:fs");
const ExcelJS = require("exceljs"); const ExcelJS = require("exceljs");
const { const {
@@ -5,6 +6,7 @@ const {
buildSafeReport, buildSafeReport,
parseArgs, parseArgs,
parseDirectoryExport, parseDirectoryExport,
readStdinBuffer,
} = require("../../../src/scripts/import-wecom-directory-export"); } = require("../../../src/scripts/import-wecom-directory-export");
async function buildDirectoryWorkbook(rows) { async function buildDirectoryWorkbook(rows) {
@@ -39,6 +41,17 @@ async function buildDirectoryWorkbook(rows) {
} }
describe("WeCom directory export importer", () => { describe("WeCom directory export importer", () => {
it("reads stdin through the fd-compatible synchronous API", () => {
const expected = Buffer.from("synthetic-xlsx");
const readFileSync = vi.spyOn(fs, "readFileSync").mockReturnValue(expected);
try {
expect(readStdinBuffer()).toBe(expected);
expect(readFileSync).toHaveBeenCalledWith(0);
} finally {
readFileSync.mockRestore();
}
});
it("requires stdin, the apply environment gate, and an actor for apply", () => { it("requires stdin, the apply environment gate, and an actor for apply", () => {
const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY; const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY;
delete process.env.WECOM_DIRECTORY_EXPORT_APPLY; delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;