553 行
17 KiB
JavaScript
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,
|
|
};
|