From a74cba1112e04f8674d473827458e83564a33c78 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 28 Jul 2026 14:25:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E4=BA=A7=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E5=AE=A1=E8=AE=A1=E6=A0=87=E8=AF=86=EF=BC=8807-28-ceo?= =?UTF-8?q?-finance-fallback=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../07-28-ceo-finance-fallback/implement.md | 4 + .../src/scripts/apply-ceo-finance-fallback.js | 9 ++- .../apply-ceo-finance-fallback.test.js | 80 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/.trellis/tasks/07-28-ceo-finance-fallback/implement.md b/.trellis/tasks/07-28-ceo-finance-fallback/implement.md index 21edb6dd..4ea72095 100644 --- a/.trellis/tasks/07-28-ceo-finance-fallback/implement.md +++ b/.trellis/tasks/07-28-ceo-finance-fallback/implement.md @@ -8,6 +8,7 @@ - [x] 更新工作流、规则 API、报销提交回归测试,覆盖 CEO 三档唯一 `finance`、普通员工链、自审、无持有人和 legacy `finance_mgr`。 - [x] 修正 `seed:config` 对既有审批规则的删除重建副作用;仅空库创建默认规则,生产既有规则保留到独立原子回填阶段。 - [x] 新增默认 dry-run、双确认 apply 的生产规则回填脚本;事务内锁定并校验三档规则、CEO/财务/admin/binding 基线,更新三条规则、写三条脱敏审计并复核受保护计数。 +- [x] 生产 dry-run 发现原生 `audit_logs` 插入未携带 Prisma 客户端侧 UUID;事务已回滚。脚本已改为显式写入审计 UUID,并新增执行级回归覆盖三条审计 SQL 与 dry-run 回滚。 - [x] 更新当前业务事实源,区分目标、代码部署和生产规则回填状态。 ## 2. Local Validation @@ -21,6 +22,7 @@ - [x] `python3 ./.trellis/scripts/task.py validate 07-28-ceo-finance-fallback` - [x] `node scripts/qa/check-trellis-governance.mjs && git diff --check` - [x] `bash scripts/check-minimal.sh`(167 个测试文件、1053 项后端测试、前端 build 与矩阵门禁通过) +- [x] dry-run 缺陷修复后复跑 Prisma validate 与 5 个聚焦测试文件(59 项通过)。 ## 3. Review Gate @@ -36,6 +38,8 @@ - [ ] 只读解析 CEO 小/中/大额快照均为唯一 `finance`,普通员工链符合目标;不创建真实报销。 - [ ] 复核无角色、主管、binding、历史快照或企微写入变化。 +部署执行备注:`3594872` 已通过标准部署、migration、runtime/external-deps smoke、独立公网构建号和前端 bundle 核验;同时将附件从同步树内迁至独立数据目录并保留校验一致的回退副本,自动备份/WARCH3 timer 已刷新运行身份。首次生产 dry-run 因审计表 UUID 缺失失败并完整回滚;需部署后续修复提交后重新完成本节全部勾选。 + ## 5. Stop Conditions - 生产 active `finance` 不是唯一非 CEO 持有人,或目标人员 inactive/unbound。 diff --git a/server/src/scripts/apply-ceo-finance-fallback.js b/server/src/scripts/apply-ceo-finance-fallback.js index ade7d20f..26838f38 100644 --- a/server/src/scripts/apply-ceo-finance-fallback.js +++ b/server/src/scripts/apply-ceo-finance-fallback.js @@ -215,12 +215,12 @@ async function execute(options, pool = new Pool({ connectionString: process.env. await client.query( ` INSERT INTO audit_logs - (request_id, actor_id, action, target_type, target_id, ip_address, user_agent, extra) + (id, request_id, actor_id, action, target_type, target_id, ip_address, user_agent, extra) VALUES - ($1, $2::uuid, 'APPROVAL_RULE_UPDATED', 'approval_rule', $3::uuid, NULL, - 'controlled-script/07-28-ceo-finance-fallback', $4::jsonb) + ($1::uuid, $2, $3::uuid, 'APPROVAL_RULE_UPDATED', 'approval_rule', $4::uuid, NULL, + 'controlled-script/07-28-ceo-finance-fallback', $5::jsonb) `, - [requestId, admins[0].id, item.id, JSON.stringify({ + [crypto.randomUUID(), requestId, admins[0].id, item.id, JSON.stringify({ task: "07-28-ceo-finance-fallback", band: item.band, before: item.before, @@ -298,6 +298,7 @@ module.exports = { TARGET_BY_BAND, assertPeopleBaseline, buildRulePlan, + execute, getBand, parseArgs, }; diff --git a/server/tests/unit/scripts/apply-ceo-finance-fallback.test.js b/server/tests/unit/scripts/apply-ceo-finance-fallback.test.js index 659567ca..60f3c1fa 100644 --- a/server/tests/unit/scripts/apply-ceo-finance-fallback.test.js +++ b/server/tests/unit/scripts/apply-ceo-finance-fallback.test.js @@ -1,6 +1,7 @@ const { assertPeopleBaseline, buildRulePlan, + execute, parseArgs, } = require("../../../src/scripts/apply-ceo-finance-fallback"); @@ -100,4 +101,83 @@ describe("CEO finance fallback production apply contract", () => { expect(parseArgs(["--apply", "--format=json"])).toEqual({ apply: true, format: "json" }); expect(() => parseArgs(["--force"])).toThrow("Unsupported argument"); }); + + it("supplies an explicit UUID for each raw audit-log insert", async () => { + const ruleIds = [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", + ]; + const sourceRows = currentRows().map((row, index) => ({ ...row, id: ruleIds[index] })); + const targetRows = sourceRows.map((row) => ({ + ...row, + level2_role: row.id === ruleIds[2] ? "ceo" : "finance", + level3_role: row.id === ruleIds[2] ? "finance" : null, + owner_self_fallback_role: "finance", + })); + const adminId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const auditCalls = []; + const transactionCommands = []; + let ruleSelectCount = 0; + let invariantCount = 0; + const client = { + query: vi.fn(async (sql, params = []) => { + const normalized = String(sql).replace(/\s+/g, " ").trim(); + if (normalized.startsWith("BEGIN ") || normalized.startsWith("ROLLBACK")) { + transactionCommands.push(normalized); + return {}; + } + if (normalized.includes("FROM approval_rules")) { + ruleSelectCount += 1; + return { rows: ruleSelectCount === 1 ? sourceRows : targetRows }; + } + if (normalized.includes("JOIN employee_roles selected_role")) { + if (params[0] === "ceo") { + return { rows: [{ id: "ceo-id", direct_manager_id: null, has_wecom_binding: true, roles: ["ceo"] }] }; + } + if (params[0] === "finance") { + return { rows: [{ id: "finance-id", direct_manager_id: null, has_wecom_binding: true, roles: ["finance"] }] }; + } + return { rows: [{ id: adminId, direct_manager_id: null, has_wecom_binding: false, roles: ["admin"] }] }; + } + if (normalized.includes("AS active_employees")) { + invariantCount += 1; + return { rows: [{ active_employees: 6, active_role_rows: 8, active_bindings: 6, workflow_snapshots: 0 }] }; + } + if (normalized.startsWith("UPDATE approval_rules")) return { rowCount: 1, rows: [{ id: params[0] }] }; + if (normalized.startsWith("INSERT INTO audit_logs")) { + auditCalls.push({ sql: normalized, params }); + return { rowCount: 1 }; + } + throw new Error(`unexpected query: ${normalized}`); + }), + release: vi.fn(), + }; + const pool = { connect: vi.fn(async () => client), end: vi.fn(async () => {}) }; + + await expect(execute({ apply: false, format: "json" }, pool)).resolves.toMatchObject({ + status: "DRY_RUN_PASS", + rules: 3, + auditRows: 0, + }); + expect(invariantCount).toBe(2); + expect(transactionCommands).toEqual([ + "BEGIN ISOLATION LEVEL SERIALIZABLE", + "ROLLBACK", + ]); + expect(auditCalls).toHaveLength(3); + for (const [index, call] of auditCalls.entries()) { + expect(call.sql).toContain("(id, request_id, actor_id"); + expect(call.sql).toContain("VALUES ($1::uuid, $2, $3::uuid"); + expect(call.params[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(call.params[1]).toMatch(/^ceo-finance-fallback-[0-9a-f-]+$/i); + expect(call.params[2]).toBe(adminId); + expect(call.params[3]).toBe(ruleIds[index]); + expect(JSON.parse(call.params[4])).toMatchObject({ + task: "07-28-ceo-finance-fallback", + band: ["small", "medium", "large"][index], + }); + expect(call.params).toHaveLength(5); + } + }); });