#!/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= 企微通讯录读取上限,默认 200。 --report-dir= 报告目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-。 --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, };