#!/usr/bin/env node require("dotenv/config"); const fs = require("node:fs"); const path = require("node:path"); const { Pool } = require("pg"); const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); const ROLE_VALUES = new Set([ "employee", "manager", "ceo", "finance", "finance_mgr", "administration", "hr", "admin", ]); const DEFAULT_TARGETS = [ { key: "admin", identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER", roles: ["admin", "employee"] }, { key: "employee", identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", roles: ["employee"] }, { key: "manager", identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER", roles: ["manager", "employee"] }, { key: "ceo", identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER", roles: ["ceo", "employee"] }, { key: "finance", identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER", roles: ["finance", "employee"] }, { key: "finance_mgr", identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", roles: ["finance", "finance_mgr", "employee"] }, { key: "hr", identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER", roles: ["hr", "employee"] }, ]; function timestamp() { return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); } function printHelp() { process.stdout.write(`Usage: node src/scripts/repair-oa-only-smoke-accounts.js [options] 治理 OA-only smoke 账号。默认 dry-run;真实修复必须同时指定 --apply 且设置 OA_ONLY_SMOKE_REPAIR_APPLY=1。 Options: --dry-run 只生成计划,不写数据库,默认。 --apply 执行受控 OA DB 修复,需 OA_ONLY_SMOKE_REPAIR_APPLY=1。 --format=text|json 输出格式,默认 text。 --report-dir= 输出目录,默认 artifacts/qa-flow/oa-only-smoke-repair-。 --target=:: 追加一个目标;identifier 可为 employee_no/email/phone。 --help 显示帮助。 环境变量: LIVE_SMOKE_*_IDENTIFIER 默认目标来源;不会读取或输出密码。 OA_ONLY_SMOKE_TARGETS_JSON 可选 JSON 数组,元素形如 {"key":"admin","identifier":"...","roles":["admin","employee"]}。 隐私边界: 输出只包含目标 key、脱敏标识、员工号、状态、角色和动作。 不输出手机号、邮箱、密码哈希、密码、token、secret、完整本地路径或原始 payload。 `); } function splitList(value) { return String(value ?? "") .split(/[,+\s]+/) .map((item) => item.trim()) .filter(Boolean); } function normalizeRoleList(roles) { const values = Array.isArray(roles) ? roles : splitList(roles); const seen = new Set(); const result = []; for (const raw of values) { const role = String(raw ?? "").trim(); if (!ROLE_VALUES.has(role) || seen.has(role)) continue; seen.add(role); result.push(role); } return result.length ? result : ["employee"]; } function normalizeIdentifier(value) { return String(value ?? "").trim().slice(0, 256); } function normalizeKey(value) { return String(value ?? "").trim().slice(0, 64) || "target"; } function maskIdentifier(value) { const text = normalizeIdentifier(value); if (!text) return null; if (text.includes("@")) { const [local, domain] = text.split("@"); const safeLocal = local.length <= 2 ? `${local[0] || "*"}***` : `${local.slice(0, 2)}***`; const domainParts = String(domain || "").split("."); const safeDomain = domainParts.length > 1 ? `***.${domainParts.at(-1)}` : "***"; return `${safeLocal}@${safeDomain}`; } if (text.length <= 4) return "***"; return `${text.slice(0, 2)}***${text.slice(-2)}`; } function sanitizeEmployeeNo(value) { return String(value ?? "").trim().slice(0, 64); } function sanitizeRoles(roles) { return Array.isArray(roles) ? roles.filter((role) => typeof role === "string").sort() : []; } function isSmokeLikeValue(value) { const text = String(value ?? "").trim(); if (!text) return false; const lower = text.toLowerCase(); const emailLocal = lower.includes("@") ? lower.split("@")[0] : lower; return ( /^smoke[-_.]/.test(emailLocal) || /^oa[-_.]?smoke[-_.]/.test(emailLocal) || /^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)[A-Z0-9_-]*/i.test(text) || /测试|试运行/.test(text) ); } function isSafeOaOnlySmokeTarget(target, row) { return [ target?.identifier, row?.employeeNo, row?.email, row?.name, ].some(isSmokeLikeValue); } function sanitizeErrorMessage(value) { return String(value ?? "") .replace(/access_token=[^&\s]+/gi, "access_token=***") .replace(/[A-Za-z0-9_-]{24,}/g, "***") .slice(0, 300); } function parseTargetArg(value) { const parts = String(value ?? "").split(":"); if (parts.length < 2) { throw new Error("--target must be ::"); } const [key, identifier, rolesRaw = "employee"] = parts; const normalizedIdentifier = normalizeIdentifier(identifier); if (!normalizedIdentifier) { throw new Error("--target identifier is empty"); } return { key: normalizeKey(key), identifier: normalizedIdentifier, roles: normalizeRoleList(rolesRaw), source: "cli", }; } function parseArgs(argv) { const options = { mode: "dry_run", format: "text", reportDir: process.env.OA_ONLY_SMOKE_REPAIR_REPORT_DIR || path.join(DEFAULT_REPORT_ROOT, `oa-only-smoke-repair-${timestamp()}`), targets: [], help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--help" || arg === "-h") { options.help = true; } else if (arg === "--dry-run") { options.mode = "dry_run"; } else if (arg === "--apply") { options.mode = "apply"; } else if (arg === "--format") { options.format = argv[index + 1] || options.format; index += 1; } else if (arg.startsWith("--format=")) { options.format = arg.slice("--format=".length); } else if (arg === "--report-dir") { options.reportDir = argv[index + 1] || options.reportDir; index += 1; } else if (arg.startsWith("--report-dir=")) { options.reportDir = arg.slice("--report-dir=".length); } else if (arg === "--target") { options.targets.push(parseTargetArg(argv[index + 1] || "")); index += 1; } else if (arg.startsWith("--target=")) { options.targets.push(parseTargetArg(arg.slice("--target=".length))); } else { throw new Error(`Unsupported argument: ${arg}`); } } if (!["text", "json"].includes(options.format)) { throw new Error(`Unsupported --format: ${options.format}`); } return options; } function parseJsonTargets() { const raw = String(process.env.OA_ONLY_SMOKE_TARGETS_JSON ?? "").trim(); if (!raw) return []; let parsed = null; try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid OA_ONLY_SMOKE_TARGETS_JSON: ${error.message}`); } if (!Array.isArray(parsed)) { throw new Error("OA_ONLY_SMOKE_TARGETS_JSON must be a JSON array"); } return parsed.map((item, index) => { const identifier = normalizeIdentifier(item?.identifier); if (!identifier) { throw new Error(`OA_ONLY_SMOKE_TARGETS_JSON[${index}].identifier is empty`); } return { key: normalizeKey(item?.key || `json_${index + 1}`), identifier, roles: normalizeRoleList(item?.roles), source: "env_json", }; }); } function loadTargets(options) { const defaults = DEFAULT_TARGETS .map((target) => { const identifier = normalizeIdentifier(process.env[target.identifierEnv]); if (!identifier) return null; return { key: target.key, identifier, roles: target.roles, source: target.identifierEnv, }; }) .filter(Boolean); const merged = [...defaults, ...parseJsonTargets(), ...options.targets]; const seen = new Set(); const result = []; for (const target of merged) { const identityKey = `${target.key}:${target.identifier.toLowerCase()}`; if (seen.has(identityKey)) continue; seen.add(identityKey); result.push({ key: target.key, identifier: target.identifier, maskedIdentifier: maskIdentifier(target.identifier), roles: normalizeRoleList(target.roles), source: target.source, }); } return result; } function assertApplyAllowed(options) { if (options.mode !== "apply") return; if (process.env.OA_ONLY_SMOKE_REPAIR_APPLY !== "1") { throw new Error("Refusing apply without OA_ONLY_SMOKE_REPAIR_APPLY=1"); } } async function queryTarget(client, identifier) { const result = await client.query( ` SELECT e.id::text, e.employee_no, e.name, e.email, e.phone, e.status::text, e.employment_status::text, (e.archived_at IS NOT NULL) AS archived, e.login_failed_count, e.login_failed_first_at IS NOT NULL AS has_login_failed_first_at, e.login_locked_until, COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles, ( SELECT COUNT(*)::int FROM auth_sessions s WHERE s.employee_id = e.id AND s.revoked_at IS NULL ) AS active_auth_sessions FROM employees e LEFT JOIN employee_roles er ON er.employee_id = e.id WHERE e.employee_no = $1 OR lower(e.email) = lower($1) OR e.phone = $1 GROUP BY e.id ORDER BY CASE WHEN e.status = 'active' AND e.archived_at IS NULL THEN 0 ELSE 1 END, e.employee_no ASC LIMIT 1 `, [identifier], ); const row = result.rows[0]; if (!row) return null; return { id: row.id, employeeNo: sanitizeEmployeeNo(row.employee_no), name: row.name || "", email: row.email || "", phone: row.phone || "", status: row.status, employmentStatus: row.employment_status, archived: Boolean(row.archived), loginFailedCount: Number(row.login_failed_count ?? 0), hasLoginFailedFirstAt: Boolean(row.has_login_failed_first_at), loginLocked: row.login_locked_until ? new Date(row.login_locked_until) > new Date() : false, roles: sanitizeRoles(row.roles), activeAuthSessions: Number(row.active_auth_sessions ?? 0), }; } function planForTarget(target, row) { if (!row) { return { key: target.key, maskedIdentifier: target.maskedIdentifier, expectedRoles: target.roles, employeeNo: null, status: "SKIP", reason: "employee_not_found", actions: [], before: null, }; } if (!isSafeOaOnlySmokeTarget(target, row)) { return { key: target.key, maskedIdentifier: target.maskedIdentifier, expectedRoles: target.roles, employeeNo: row.employeeNo, status: "BLOCKED", reason: "unsafe_non_smoke_target", actions: [], missingRoles: [], before: { status: row.status, employmentStatus: row.employmentStatus, archived: row.archived, roles: row.roles, loginFailedCount: row.loginFailedCount, loginLocked: row.loginLocked, activeAuthSessions: row.activeAuthSessions, safeOaOnlySmoke: false, }, }; } const actions = []; if (row.status !== "active" || row.employmentStatus !== "active" || row.archived) { actions.push("restore_active_employee"); } if (row.loginFailedCount > 0 || row.hasLoginFailedFirstAt || row.loginLocked) { actions.push("clear_login_lock"); } const missingRoles = target.roles.filter((role) => !row.roles.includes(role)); if (missingRoles.length) { actions.push("add_missing_roles"); } return { key: target.key, maskedIdentifier: target.maskedIdentifier, expectedRoles: target.roles, employeeNo: row.employeeNo, status: actions.length ? "PLANNED" : "OK", reason: actions.length ? "needs_repair" : "already_ready", actions, missingRoles, before: { status: row.status, employmentStatus: row.employmentStatus, archived: row.archived, roles: row.roles, loginFailedCount: row.loginFailedCount, loginLocked: row.loginLocked, activeAuthSessions: row.activeAuthSessions, safeOaOnlySmoke: true, }, }; } async function applyTarget(client, target, plan, now) { if (!plan.employeeNo || !plan.actions.length) { return { ...plan, result: plan.status === "OK" ? "noop" : "skipped" }; } const row = await queryTarget(client, target.identifier); if (!row?.id) { return { ...plan, result: "skipped", reason: "employee_not_found_at_apply" }; } await client.query( ` UPDATE employees SET status = 'active', employment_status = 'active', archived_at = NULL, login_failed_count = 0, login_failed_first_at = NULL, login_locked_until = NULL, token_version = token_version + 1, updated_at = $2::timestamptz, updated_by = NULL WHERE id = $1::uuid `, [row.id, now.toISOString()], ); for (const role of target.roles) { await client.query( ` INSERT INTO employee_roles (employee_id, role_code, created_at, created_by) VALUES ($1::uuid, $2::"RoleCode", $3::timestamptz, NULL) ON CONFLICT (employee_id, role_code) DO NOTHING `, [row.id, role, now.toISOString()], ); } await client.query( ` INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by) VALUES (gen_random_uuid(), $1::uuid, 'transfer', $2::date, $3::jsonb, $4::timestamptz, NULL) `, [ row.id, now.toISOString().slice(0, 10), JSON.stringify({ source: "OSMOKE1", reason: "oa_only_smoke_account_repair", actions: plan.actions, expectedRoles: target.roles, }), now.toISOString(), ], ); const after = await queryTarget(client, target.identifier); return { ...plan, result: "applied", after: after ? { status: after.status, employmentStatus: after.employmentStatus, archived: after.archived, roles: after.roles, loginFailedCount: after.loginFailedCount, loginLocked: after.loginLocked, activeAuthSessions: after.activeAuthSessions, } : null, }; } function buildNoDatabaseReport(targets) { return { mode: "dry_run", status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG", generatedAt: new Date().toISOString(), privacy: "target key/masked identifier/role only; no password/token/secret/full email/full phone", reason: "missing_DATABASE_URL", targets: targets.map((target) => ({ key: target.key, maskedIdentifier: target.maskedIdentifier, expectedRoles: target.roles, source: target.source, })), totals: { targets: targets.length, plannedRepairs: 0, appliedRepairs: 0, missingTargets: 0, }, }; } function buildDatabaseUnavailableReport(targets, error) { return { ...buildNoDatabaseReport(targets), reason: "database_unavailable", details: { message: sanitizeErrorMessage(error?.message || error), name: error?.name || null, }, }; } function summarizeReport({ mode, targets, planned, generatedAt }) { const plannedRepairs = planned.filter((item) => item.status === "PLANNED").length; const missingTargets = planned.filter((item) => item.reason === "employee_not_found").length; const blockedTargets = planned.filter((item) => item.status === "BLOCKED").length; const appliedRepairs = planned.filter((item) => item.result === "applied").length; const blockers = missingTargets; let status = "PASS_OA_ONLY_SMOKE_READY"; if (blockedTargets > 0) { status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"; } else if (!targets.length) { status = "PAUSED_MANUAL_CONFIRMATION"; } else if (mode === "apply") { status = blockers ? "PASS_WITH_FINDINGS" : "APPLIED_OA_ONLY_SMOKE_REPAIR"; } else if (plannedRepairs > 0 || missingTargets > 0) { status = "PASS_WITH_REPAIR_PLAN"; } return { mode, status, generatedAt, privacy: "target key/masked identifier/employee_no/status/roles/actions only; no password/token/secret/full email/full phone", scope: { accountClass: "oa_only_smoke", wecomWrites: 0, passwordReset: false, realEmployees: "not_targeted", }, totals: { targets: targets.length, plannedRepairs, appliedRepairs, missingTargets, blockedTargets, readyTargets: planned.filter((item) => item.status === "OK").length, }, targets: planned, manualGate: mode === "dry_run" && plannedRepairs > 0 ? "A:按 dry-run 结果恢复现有 OA-only smoke;B:另建新账号;C:暂停自动化。" : null, }; } async function execute(options) { const targets = loadTargets(options); assertApplyAllowed(options); if (!targets.length) { return summarizeReport({ mode: options.mode, targets, planned: [], generatedAt: new Date().toISOString(), }); } if (!process.env.DATABASE_URL) { return buildNoDatabaseReport(targets); } const pool = new Pool({ connectionString: process.env.DATABASE_URL }); let client; try { client = await pool.connect(); } catch (error) { await pool.end(); if (options.mode !== "apply") { return buildDatabaseUnavailableReport(targets, error); } throw error; } const generatedAt = new Date().toISOString(); const now = new Date(); try { await client.query(options.mode === "apply" ? "BEGIN" : "BEGIN READ ONLY"); const planned = []; for (const target of targets) { const row = await queryTarget(client, target.identifier); const plan = planForTarget(target, row); planned.push(plan); } if (options.mode === "apply" && planned.some((item) => item.status === "BLOCKED")) { await client.query("ROLLBACK"); return summarizeReport({ mode: options.mode, targets, planned, generatedAt }); } if (options.mode === "apply") { const applied = []; for (let index = 0; index < targets.length; index += 1) { applied.push(await applyTarget(client, targets[index], planned[index], now)); } planned.splice(0, planned.length, ...applied); } await client.query("COMMIT"); return summarizeReport({ mode: options.mode, targets, planned, generatedAt }); } catch (error) { try { await client.query("ROLLBACK"); } catch { // Keep original error. } throw error; } finally { client.release(); await pool.end(); } } function ensureReportDir(reportDir) { fs.mkdirSync(reportDir, { recursive: true }); } function writeReport(reportDir, report) { ensureReportDir(reportDir); const filePath = path.join(reportDir, "summary.json"); fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); return filePath; } function printText(report, reportPath) { process.stdout.write(`[oa-only-smoke-repair] status=${report.status} mode=${report.mode}\n`); process.stdout.write(`[oa-only-smoke-repair] report=${reportPath}\n`); process.stdout.write(`[oa-only-smoke-repair] totals=${JSON.stringify(report.totals)}\n`); if (report.manualGate) { process.stdout.write(`[oa-only-smoke-repair] manualGate=${report.manualGate}\n`); } } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } const report = await execute(options); const reportPath = writeReport(options.reportDir, report); if (options.format === "json") { process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`); } else { printText(report, reportPath); } if (report.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") { process.exitCode = 1; } } if (require.main === module) { main().catch((error) => { process.stderr.write(`[oa-only-smoke-repair][FAIL] ${sanitizeErrorMessage(error?.message || error)}\n`); process.exitCode = 1; }); } module.exports = { DEFAULT_TARGETS, applyTarget, execute, isSafeOaOnlySmokeTarget, isSmokeLikeValue, loadTargets, normalizeRoleList, parseArgs, planForTarget, };