文件
hyb-oa/server/src/scripts/repair-oa-only-smoke-accounts.js
T
wangsli ec68256588
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
实现(08-10-production-employee-smoke-break-glass-reset): 增加员工Smoke受控重置工具
2026-08-10 21:01:59 +08:00

658 行
20 KiB
JavaScript

#!/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=<path> 输出目录,默认 artifacts/qa-flow/oa-only-smoke-repair-<timestamp>。
--target=<key>:<identifier>:<role+role>
追加一个目标;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 <key>:<identifier>:<role+role>");
}
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,
};