OSMOKE1 修复 OA-only smoke 账号治理
这个提交包含在:
@@ -15,6 +15,15 @@ const {
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_REPORT_ITEMS = 200;
|
||||
const OA_ONLY_SMOKE_IDENTIFIER_ENVS = [
|
||||
"LIVE_SMOKE_ADMIN_IDENTIFIER",
|
||||
"LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
|
||||
"LIVE_SMOKE_MANAGER_IDENTIFIER",
|
||||
"LIVE_SMOKE_CEO_IDENTIFIER",
|
||||
"LIVE_SMOKE_FINANCE_IDENTIFIER",
|
||||
"LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
|
||||
"LIVE_SMOKE_HR_IDENTIFIER",
|
||||
];
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/audit-wecom-oa-user-consistency.js [options]
|
||||
@@ -90,6 +99,43 @@ function normalizeKey(value) {
|
||||
return raw ? raw.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return String(value ?? "")
|
||||
.split(/[\s,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildOaOnlySmokeIdentifierSet(env = process.env) {
|
||||
const identifiers = [
|
||||
...splitList(env.OA_ONLY_SMOKE_IDENTIFIERS),
|
||||
...OA_ONLY_SMOKE_IDENTIFIER_ENVS.map((key) => env[key]).filter(Boolean),
|
||||
];
|
||||
return new Set(identifiers.map(normalizeKey).filter(Boolean));
|
||||
}
|
||||
|
||||
function isDefaultOaOnlySmokeCandidate(employee) {
|
||||
const employeeNo = String(employee?.employeeNo ?? "").trim();
|
||||
const emailLocal = String(employee?.email ?? "").trim().toLowerCase().split("@")[0] || "";
|
||||
const name = String(employee?.name ?? "");
|
||||
return (
|
||||
/^SMOKE[A-Z0-9_-]*/i.test(employeeNo)
|
||||
|| /^smoke[-_.]/.test(emailLocal)
|
||||
|| /^oa[-_.]?smoke[-_.]/.test(emailLocal)
|
||||
|| /测试|试运行/.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
function isOaOnlySmokeEmployee(employee, identifierSet) {
|
||||
if (!employee) return false;
|
||||
if (isDefaultOaOnlySmokeCandidate(employee)) return true;
|
||||
if (!identifierSet?.size) return false;
|
||||
return [employee.employeeNo, employee.email, employee.phone]
|
||||
.map(normalizeKey)
|
||||
.filter(Boolean)
|
||||
.some((key) => identifierSet.has(key));
|
||||
}
|
||||
|
||||
function safeMessage(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
@@ -219,8 +265,10 @@ function summarizeFindings(findings) {
|
||||
}
|
||||
|
||||
function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new Date().toISOString(), limit = DEFAULT_LIMIT }) {
|
||||
const oaOnlySmokeIdentifierSet = buildOaOnlySmokeIdentifierSet();
|
||||
const findings = {
|
||||
matchedActive: [],
|
||||
oaOnlySmokeExempted: [],
|
||||
oaActiveMissingInWecom: [],
|
||||
wecomActiveMissingInOa: [],
|
||||
oaActiveMissingBinding: [],
|
||||
@@ -308,13 +356,20 @@ function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new
|
||||
|
||||
for (const employee of oaEmployees) {
|
||||
if (isOaActive(employee) && !matchedOrConflictedEmployeeIds.has(employee.id)) {
|
||||
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
|
||||
if (isOaOnlySmokeEmployee(employee, oaOnlySmokeIdentifierSet)) {
|
||||
findings.oaOnlySmokeExempted.push({
|
||||
...sanitizeEmployee(employee),
|
||||
reason: "oa_only_smoke_no_wecom_account_required",
|
||||
});
|
||||
} else {
|
||||
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const findingCounts = summarizeFindings(findings);
|
||||
const findingTotal = Object.entries(findingCounts)
|
||||
.filter(([key]) => key !== "matchedActive")
|
||||
.filter(([key]) => !["matchedActive", "oaOnlySmokeExempted"].includes(key))
|
||||
.reduce((total, [, count]) => total + count, 0);
|
||||
|
||||
return {
|
||||
@@ -326,6 +381,7 @@ function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new
|
||||
oa: "active employees and wecom identity bindings",
|
||||
wecom: "contact members plus hr assistant staff info",
|
||||
writeBehavior: "no OA writes, no WeCom writes, no WHMS dry-run/apply API call",
|
||||
oaOnlySmoke: "env-listed OA-only smoke accounts are exempt from WeCom-missing findings",
|
||||
},
|
||||
inputs: {
|
||||
limit,
|
||||
@@ -410,6 +466,7 @@ async function queryOaEmployeesReadOnly() {
|
||||
function buildSkipReport({ generatedAt, reason, details, oaEmployees = [], limit }) {
|
||||
const findings = {
|
||||
matchedActive: [],
|
||||
oaOnlySmokeExempted: [],
|
||||
oaActiveMissingInWecom: [],
|
||||
wecomActiveMissingInOa: [],
|
||||
oaActiveMissingBinding: [],
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
#!/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,
|
||||
execute,
|
||||
isSafeOaOnlySmokeTarget,
|
||||
isSmokeLikeValue,
|
||||
loadTargets,
|
||||
normalizeRoleList,
|
||||
parseArgs,
|
||||
planForTarget,
|
||||
};
|
||||
@@ -23,6 +23,8 @@ Options:
|
||||
--dry-run 只生成计划,不写数据库,默认。
|
||||
--apply 执行受控 OA 写入,需 WOUA_FIX_APPLY=1。
|
||||
--allow-employee-no=<no[,no]> 限定允许处理的员工号;可重复。
|
||||
--oa-only-smoke-employee-no=<no[,no]>
|
||||
明确标记 OA-only smoke 员工号;这些员工缺企微账号时不归档。
|
||||
--clear-inactive-bindings 显式清理 inactiveWithBinding 中的绑定;默认不清理。
|
||||
--repair-manager-effects 将归档测试主管的 active 下属和 pending 待办转给 fallback active manager。
|
||||
--manager-fallback-employee-no=<no>
|
||||
@@ -45,6 +47,7 @@ function parseArgs(argv) {
|
||||
reportDir: process.env.WOUA_FIX_REPORT_DIR
|
||||
|| path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-fix-${timestamp()}`),
|
||||
allowEmployeeNos: [],
|
||||
oaOnlySmokeEmployeeNos: splitList(process.env.OA_ONLY_SMOKE_EMPLOYEE_NOS || ""),
|
||||
clearInactiveBindings: false,
|
||||
repairManagerEffects: false,
|
||||
managerFallbackEmployeeNo: "",
|
||||
@@ -79,6 +82,11 @@ function parseArgs(argv) {
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--allow-employee-no=")) {
|
||||
options.allowEmployeeNos.push(...splitList(arg.slice("--allow-employee-no=".length)));
|
||||
} else if (arg === "--oa-only-smoke-employee-no") {
|
||||
options.oaOnlySmokeEmployeeNos.push(...splitList(argv[index + 1] || ""));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--oa-only-smoke-employee-no=")) {
|
||||
options.oaOnlySmokeEmployeeNos.push(...splitList(arg.slice("--oa-only-smoke-employee-no=".length)));
|
||||
} else if (arg === "--clear-inactive-bindings") {
|
||||
options.clearInactiveBindings = true;
|
||||
} else if (arg === "--repair-manager-effects") {
|
||||
@@ -97,6 +105,7 @@ function parseArgs(argv) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
options.allowEmployeeNos = normalizeEmployeeNoList(options.allowEmployeeNos);
|
||||
options.oaOnlySmokeEmployeeNos = normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -170,18 +179,40 @@ function buildAcceptedMissingRequiredFields(items) {
|
||||
function buildFixPlan(report, options = {}) {
|
||||
const findings = report?.findings ?? {};
|
||||
const allowSet = new Set(normalizeEmployeeNoList(options.allowEmployeeNos));
|
||||
const oaOnlySmokeSet = new Set(normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos));
|
||||
const shouldInclude = (item) => !allowSet.size || allowSet.has(item.employeeNo);
|
||||
const archiveCandidates = buildEmployeeNoActions(
|
||||
const archiveSource = buildEmployeeNoActions(
|
||||
findings.oaActiveMissingInWecom,
|
||||
"archive_oa_test_employee",
|
||||
"user_confirmed_oa_active_missing_in_wecom_are_test_data",
|
||||
).filter(shouldInclude);
|
||||
);
|
||||
const oaOnlySmokeExempted = [
|
||||
...buildEmployeeNoActions(
|
||||
findings.oaOnlySmokeExempted,
|
||||
"oa_only_smoke_exempted",
|
||||
"source_report_classified_oa_only_smoke",
|
||||
),
|
||||
...archiveSource
|
||||
.filter((item) => oaOnlySmokeSet.has(item.employeeNo))
|
||||
.map((item) => ({
|
||||
employeeNo: item.employeeNo,
|
||||
action: "oa_only_smoke_exempted",
|
||||
reason: "explicit_oa_only_smoke_employee_no",
|
||||
})),
|
||||
];
|
||||
const exemptSet = new Set(oaOnlySmokeExempted.map((item) => item.employeeNo));
|
||||
const archiveCandidates = archiveSource
|
||||
.filter((item) => !exemptSet.has(item.employeeNo))
|
||||
.filter(shouldInclude);
|
||||
const excludedByAllowlist = allowSet.size
|
||||
? buildEmployeeNoActions(
|
||||
findings.oaActiveMissingInWecom,
|
||||
"excluded_by_allowlist",
|
||||
"allowlist_did_not_include_employee_no",
|
||||
).filter((item) => !allowSet.has(item.employeeNo))
|
||||
? archiveSource
|
||||
.filter((item) => !exemptSet.has(item.employeeNo))
|
||||
.map((item) => ({
|
||||
employeeNo: item.employeeNo,
|
||||
action: "excluded_by_allowlist",
|
||||
reason: "allowlist_did_not_include_employee_no",
|
||||
}))
|
||||
.filter((item) => !allowSet.has(item.employeeNo))
|
||||
: [];
|
||||
|
||||
const inactiveBindingAction = options.clearInactiveBindings
|
||||
@@ -194,12 +225,14 @@ function buildFixPlan(report, options = {}) {
|
||||
sourceGeneratedAt: report?.generatedAt ?? null,
|
||||
decisions: {
|
||||
oaActiveMissingInWecom: "archive_oa_test_employees",
|
||||
oaOnlySmokeMissingInWecom: "exempt_no_archive",
|
||||
missingRequiredFields: "accepted_no_write",
|
||||
inactiveWithBinding: inactiveBindingAction,
|
||||
managerSideEffects: options.repairManagerEffects ? "repair_to_active_manager" : "report_only",
|
||||
wecomWriteBehavior: "no_wecom_writes",
|
||||
},
|
||||
archiveOaTestEmployees: archiveCandidates,
|
||||
oaOnlySmokeExempted,
|
||||
excludedByAllowlist,
|
||||
acceptedMissingRequiredFields: buildAcceptedMissingRequiredFields(findings.missingRequiredFields),
|
||||
inactiveWithBinding: buildEmployeeNoActions(
|
||||
@@ -234,6 +267,7 @@ function buildDryRunResult(plan, options = {}) {
|
||||
decisions: plan.decisions,
|
||||
totals: {
|
||||
archiveOaTestEmployees: plan.archiveOaTestEmployees.length,
|
||||
oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length,
|
||||
excludedByAllowlist: plan.excludedByAllowlist.length,
|
||||
acceptedMissingRequiredFields: plan.acceptedMissingRequiredFields.length,
|
||||
inactiveWithBinding: plan.inactiveWithBinding.length,
|
||||
@@ -243,6 +277,7 @@ function buildDryRunResult(plan, options = {}) {
|
||||
},
|
||||
actions: {
|
||||
archiveOaTestEmployees: sample(plan.archiveOaTestEmployees),
|
||||
oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted),
|
||||
excludedByAllowlist: sample(plan.excludedByAllowlist),
|
||||
acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields),
|
||||
inactiveWithBinding: sample(plan.inactiveWithBinding),
|
||||
@@ -610,6 +645,7 @@ function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs
|
||||
decisions: plan.decisions,
|
||||
totals: {
|
||||
archiveOaTestEmployees: archived.length,
|
||||
oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length,
|
||||
archivedApplied: countByResult(archived, "applied"),
|
||||
archivedPlanned: countByResult(archived, "planned"),
|
||||
archivedNoop: countByResult(archived, "noop"),
|
||||
@@ -629,6 +665,7 @@ function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs
|
||||
},
|
||||
actions: {
|
||||
archiveOaTestEmployees: sample(archived),
|
||||
oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted),
|
||||
excludedByAllowlist: sample(plan.excludedByAllowlist),
|
||||
acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields),
|
||||
inactiveWithBinding: sample(inactiveBindings),
|
||||
|
||||
在新工单中引用
屏蔽一个用户