文件
hyb-oa/server/src/scripts/audit-wecom-oa-user-consistency.js
T

553 行
17 KiB
JavaScript

#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { Pool } = require("pg");
const { getConfig } = require("../config");
const {
WecomHrMasterSyncError,
fetchWecomHrProfiles,
maskExternalUserId,
} = require("../services/integrations/wecomHrMaster");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_LIMIT = 200;
const MAX_REPORT_ITEMS = 200;
function printHelp() {
process.stdout.write(`Usage: node src/scripts/audit-wecom-oa-user-consistency.js [options]
只读对账 OA active 员工与企业微信通讯录 + 人事助手资料。
脚本不会调用 WHMS dry-run/apply API,不会写 OA/企微用户数据。
Options:
--live 启用企微 live 只读读取;未指定时输出 LIVE_DISABLED SKIP。
--format=text|json 输出格式,默认 text。
--limit=<n> 企微通讯录读取上限,默认 200。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-<timestamp>。
--help 显示帮助。
隐私边界:
输出只包含员工号、状态、角色摘要、脱敏企微 userid 前缀、计数和问题类型。
不输出手机号、邮箱、密码哈希、token、secret、完整企微 userid 或原始 payload。
`);
}
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function parseArgs(argv) {
const options = {
live: process.env.WOUA_LIVE_READ === "1",
format: "text",
limit: Number(process.env.WOUA_LIMIT || DEFAULT_LIMIT),
reportDir: process.env.WOUA_REPORT_DIR || path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-${timestamp()}`),
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 === "--live") {
options.live = true;
} else if (arg === "--no-live") {
options.live = false;
} 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 === "--limit") {
options.limit = Number(argv[index + 1] || options.limit);
index += 1;
} else if (arg.startsWith("--limit=")) {
options.limit = Number(arg.slice("--limit=".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 {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
options.limit = Number.isFinite(options.limit)
? Math.max(1, Math.min(200, Math.floor(options.limit)))
: DEFAULT_LIMIT;
return options;
}
function normalizeKey(value) {
const raw = String(value ?? "").trim();
return raw ? raw.toLowerCase() : "";
}
function safeMessage(value) {
return String(value ?? "")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
.slice(0, 180);
}
function isOaActive(employee) {
return employee.status === "active" && !employee.archived;
}
function isWecomActive(profile) {
return profile.sourceStatus === "active";
}
function sanitizeRoles(roles) {
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string").sort() : [];
}
function sanitizeEmployee(employee) {
if (!employee) return null;
return {
employeeNo: employee.employeeNo,
status: employee.status,
archived: Boolean(employee.archived),
roles: sanitizeRoles(employee.roles),
};
}
function sanitizeWecomProfile(profile) {
if (!profile) return null;
return {
externalUserIdPrefix: maskExternalUserId(profile.externalUserId),
sourceStatus: profile.sourceStatus,
};
}
function sanitizeMatched(employee, profile, matchedBy) {
return {
employee: sanitizeEmployee(employee),
wecom: sanitizeWecomProfile(profile),
matchedBy,
};
}
function addToIndex(index, key, employee) {
if (!key) return;
const bucket = index.get(key) || [];
bucket.push(employee);
index.set(key, bucket);
}
function buildOaIndexes(employees) {
const byBinding = new Map();
const byEmployeeNo = new Map();
const byPhone = new Map();
const byEmail = new Map();
for (const employee of employees) {
addToIndex(byBinding, normalizeKey(employee.wecomExternalUserId), employee);
addToIndex(byEmployeeNo, normalizeKey(employee.employeeNo), employee);
addToIndex(byPhone, normalizeKey(employee.phone), employee);
addToIndex(byEmail, normalizeKey(employee.email), employee);
}
return { byBinding, byEmployeeNo, byPhone, byEmail };
}
function firstIndexMatch(index, key) {
const bucket = key ? index.get(key) || [] : [];
if (bucket.length === 1) return { employee: bucket[0], duplicate: false };
if (bucket.length > 1) return { employee: null, duplicate: true, employees: bucket };
return { employee: null, duplicate: false };
}
function collectMatchCandidates(profile, indexes) {
const externalIdKey = normalizeKey(profile.externalUserId);
const checks = [
["binding_external_user_id", indexes.byBinding, externalIdKey],
["employee_no_or_userid", indexes.byEmployeeNo, externalIdKey],
["phone", indexes.byPhone, normalizeKey(profile.phone)],
["email", indexes.byEmail, normalizeKey(profile.email)],
];
const candidates = [];
const duplicateKeys = [];
for (const [method, index, key] of checks) {
if (!key) continue;
const match = firstIndexMatch(index, key);
if (match.duplicate) {
duplicateKeys.push({ method, count: match.employees.length });
} else if (match.employee) {
candidates.push({ method, employee: match.employee });
}
}
const unique = new Map();
for (const candidate of candidates) {
if (!unique.has(candidate.employee.id)) {
unique.set(candidate.employee.id, { employee: candidate.employee, methods: [candidate.method] });
} else {
unique.get(candidate.employee.id).methods.push(candidate.method);
}
}
return { unique: Array.from(unique.values()), duplicateKeys };
}
function buildConflictItem(profile, reason, details = {}) {
return {
reason,
wecom: sanitizeWecomProfile(profile),
...details,
};
}
function sample(items) {
return items.slice(0, MAX_REPORT_ITEMS);
}
function summarizeFindings(findings) {
const summary = {};
for (const [key, items] of Object.entries(findings)) {
summary[key] = Array.isArray(items) ? items.length : 0;
}
return summary;
}
function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new Date().toISOString(), limit = DEFAULT_LIMIT }) {
const findings = {
matchedActive: [],
oaActiveMissingInWecom: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
inactiveWithBinding: [],
statusMismatch: [],
missingRequiredFields: [],
matchConflict: [],
};
const matchedOrConflictedEmployeeIds = new Set();
const seenWecomIds = new Set();
const indexes = buildOaIndexes(oaEmployees);
for (const employee of oaEmployees) {
if (!isOaActive(employee) && employee.wecomExternalUserId) {
findings.inactiveWithBinding.push(sanitizeEmployee(employee));
}
}
for (const profile of wecomProfiles) {
const externalKey = normalizeKey(profile.externalUserId);
if (externalKey && seenWecomIds.has(externalKey)) {
findings.matchConflict.push(buildConflictItem(profile, "duplicate_wecom_external_user_id"));
continue;
}
if (externalKey) seenWecomIds.add(externalKey);
const candidates = collectMatchCandidates(profile, indexes);
if (candidates.duplicateKeys.length) {
findings.matchConflict.push(buildConflictItem(profile, "duplicate_oa_match_key", { duplicateKeys: candidates.duplicateKeys }));
for (const candidate of candidates.unique) matchedOrConflictedEmployeeIds.add(candidate.employee.id);
continue;
}
if (candidates.unique.length > 1) {
findings.matchConflict.push(buildConflictItem(profile, "multiple_oa_candidates", {
candidates: candidates.unique.map((candidate) => ({
employee: sanitizeEmployee(candidate.employee),
methods: candidate.methods,
})),
}));
for (const candidate of candidates.unique) matchedOrConflictedEmployeeIds.add(candidate.employee.id);
continue;
}
const match = candidates.unique[0] || null;
if (!match) {
if (isWecomActive(profile)) {
findings.wecomActiveMissingInOa.push({ wecom: sanitizeWecomProfile(profile) });
}
if (profile.missingRequiredFields?.length) {
findings.missingRequiredFields.push({
employee: null,
wecom: sanitizeWecomProfile(profile),
fields: [...profile.missingRequiredFields].sort(),
});
}
continue;
}
const employee = match.employee;
matchedOrConflictedEmployeeIds.add(employee.id);
const matchedBy = match.methods[0];
const oaActive = isOaActive(employee);
const wecomActive = isWecomActive(profile);
if (oaActive && wecomActive) {
findings.matchedActive.push(sanitizeMatched(employee, profile, matchedBy));
}
if (oaActive && !employee.wecomExternalUserId) {
findings.oaActiveMissingBinding.push(sanitizeMatched(employee, profile, matchedBy));
}
if (oaActive !== wecomActive) {
findings.statusMismatch.push({
...sanitizeMatched(employee, profile, matchedBy),
oaActive,
wecomActive,
});
}
if (profile.missingRequiredFields?.length) {
findings.missingRequiredFields.push({
...sanitizeMatched(employee, profile, matchedBy),
fields: [...profile.missingRequiredFields].sort(),
});
}
}
for (const employee of oaEmployees) {
if (isOaActive(employee) && !matchedOrConflictedEmployeeIds.has(employee.id)) {
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
}
}
const findingCounts = summarizeFindings(findings);
const findingTotal = Object.entries(findingCounts)
.filter(([key]) => key !== "matchedActive")
.reduce((total, [, count]) => total + count, 0);
return {
mode: "read_only",
generatedAt,
status: findingTotal > 0 ? "PASS_WITH_FINDINGS" : "PASS_NO_DRIFT",
privacy: "employee_no/status/roles/masked wecom userid only; no phone/email/password/token/secret/full userid/raw payload",
scope: {
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",
},
inputs: {
limit,
mayBeTruncated: wecomProfiles.length >= limit,
},
totals: {
oaEmployees: oaEmployees.length,
oaActiveEmployees: oaEmployees.filter(isOaActive).length,
oaActiveWithBinding: oaEmployees.filter((employee) => isOaActive(employee) && employee.wecomExternalUserId).length,
wecomProfiles: wecomProfiles.length,
wecomActiveProfiles: wecomProfiles.filter(isWecomActive).length,
},
findingCounts,
findings: Object.fromEntries(Object.entries(findings).map(([key, value]) => [key, sample(value)])),
};
}
async function queryOaEmployees(client) {
const result = await client.query(`
SELECT
e.id::text,
e.employee_no,
e.name,
e.phone,
e.email,
e.status::text,
(e.archived_at IS NOT NULL) AS archived,
COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles,
w.external_user_id AS wecom_external_user_id
FROM employees e
LEFT JOIN employee_roles er ON er.employee_id = e.id
LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id
GROUP BY
e.id,
e.employee_no,
e.name,
e.phone,
e.email,
e.status,
e.archived_at,
w.external_user_id
ORDER BY e.employee_no ASC
`);
return result.rows.map((row) => ({
id: row.id,
employeeNo: row.employee_no,
name: row.name,
phone: row.phone,
email: row.email,
status: row.status,
archived: Boolean(row.archived),
roles: row.roles || [],
wecomExternalUserId: row.wecom_external_user_id || "",
}));
}
async function queryOaEmployeesReadOnly() {
if (!process.env.DATABASE_URL) {
throw new Error("Missing DATABASE_URL");
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = await pool.connect();
try {
await client.query("BEGIN READ ONLY");
const employees = await queryOaEmployees(client);
await client.query("COMMIT");
return employees;
} catch (error) {
try {
await client.query("ROLLBACK");
} catch {
// Keep the original error.
}
throw error;
} finally {
client.release();
await pool.end();
}
}
function buildSkipReport({ generatedAt, reason, details, oaEmployees = [], limit }) {
const findings = {
matchedActive: [],
oaActiveMissingInWecom: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
inactiveWithBinding: [],
statusMismatch: [],
missingRequiredFields: [],
matchConflict: [],
};
return {
mode: "read_only",
generatedAt,
status: "SKIP",
conclusion: reason,
privacy: "employee_no/status/roles only; no phone/email/password/token/secret/full userid/raw payload",
scope: {
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",
},
inputs: { limit },
totals: {
oaEmployees: oaEmployees.length,
oaActiveEmployees: oaEmployees.filter(isOaActive).length,
oaActiveWithBinding: oaEmployees.filter((employee) => isOaActive(employee) && employee.wecomExternalUserId).length,
},
findingCounts: summarizeFindings(findings),
findings,
details,
};
}
async function executeAudit(options) {
const generatedAt = new Date().toISOString();
const oaEmployees = await queryOaEmployeesReadOnly();
if (!options.live) {
return buildSkipReport({
generatedAt,
reason: "LIVE_DISABLED",
details: { runWith: "--live" },
oaEmployees,
limit: options.limit,
});
}
try {
const config = getConfig();
const { profiles, tokenSource } = await fetchWecomHrProfiles({
config,
userids: [],
limit: options.limit,
});
const report = buildConsistencyReport({
oaEmployees,
wecomProfiles: profiles,
generatedAt,
limit: options.limit,
});
return {
...report,
tokenSource: {
contact: tokenSource?.contact || null,
hr: tokenSource?.hr || null,
},
};
} catch (error) {
const details = {
code: error?.code || (error instanceof WecomHrMasterSyncError ? "wecom_hr_master_sync_error" : "wecom_read_failed"),
message: safeMessage(error?.message || error),
};
return buildSkipReport({
generatedAt,
reason: "BLOCKED_BY_EXTERNAL_CONFIG",
details,
oaEmployees,
limit: options.limit,
});
}
}
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(`[wecom-oa-user-consistency] status=${report.status}${report.conclusion ? ` conclusion=${report.conclusion}` : ""}\n`);
process.stdout.write(`[wecom-oa-user-consistency] report=${reportPath}\n`);
process.stdout.write(`[wecom-oa-user-consistency] privacy=${report.privacy}\n`);
if (report.totals) {
process.stdout.write(`[wecom-oa-user-consistency] totals=${JSON.stringify(report.totals)}\n`);
}
if (report.findingCounts) {
process.stdout.write(`[wecom-oa-user-consistency] findingCounts=${JSON.stringify(report.findingCounts)}\n`);
}
if (report.details) {
process.stdout.write(`[wecom-oa-user-consistency] details=${JSON.stringify(report.details)}\n`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await executeAudit(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 (require.main === module) {
main().catch((error) => {
process.stderr.write(`[wecom-oa-user-consistency][FAIL] ${safeMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
buildConsistencyReport,
collectMatchCandidates,
executeAudit,
isOaActive,
isWecomActive,
normalizeKey,
parseArgs,
sanitizeEmployee,
sanitizeWecomProfile,
summarizeFindings,
};