WRULE1 实现企微审批链校验器 POC

这个提交包含在:
2026-05-21 00:35:10 +08:00
父节点 1440cfe9a2
当前提交 c119183da6
共修改 14 个文件,包含 1225 行新增和 29 行删除
@@ -0,0 +1,454 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { createPrismaClient } = require("../prismaClient");
const {
APPROVAL_RULE_TARGETS,
countBy,
evaluateRuleReport,
normalizeManualGates,
validateApprovalRuleArchive,
} = require("../services/integrations/wecomApprovalRuleValidator");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_FALLBACK_EMPLOYEE_NO = "HYD000006";
const DEFAULT_APPROVAL_ROLES = Object.freeze(["manager"]);
const REPORT_PRIVACY =
"rule status/counts/masked sp_no/template/employee prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload";
function printHelp() {
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-rule-validator.js [options]
QA-only 企微 HR/考勤审批链校验器 POC。
默认只读取 OA DB 已归档的企微审批详情,不调用企业微信 API,不写 OA/企微数据。
Options:
--format=text|json 输出格式,默认 text。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-rule-validator-<timestamp>。
--template=leave 检查请假。
--template=missed_punch 检查补卡。
--template=outing 检查外出。
--template=overtime 检查加班。
--manual-gate=leave:A WAPPR1 人工门禁,A/B/C/D;支持 --manual-gate=all:A。
--approval-role=manager 无有效直属主管时允许的业务审批角色,默认 manager;可重复。
--fallback-employee-no=<no> 管理员兜底员工号,默认 HYD000006。
--status=<status> 按企微归档规范化状态过滤。
--limit=<1..100> 最大读取条数,默认 50。
--help 显示帮助。
Secret 固定规则:
本脚本默认只读 OA DB,不读取 Secret、不调用企微。
上游企微审批详情读取仍固定 WECOM_AGENT_SECRET,不使用通讯录、打卡或历史 HR assistant Secret 兜底。
隐私边界:
报告只输出结论、计数、脱敏 sp_no/template/employee 前缀和 finding 类型。
不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、表单值、附件、评论或企微原始 payload。
`);
}
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function safeMessage(value) {
return toSafeString(value, 500)
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9+/=_-]{24,}/g, "***")
.slice(0, 240);
}
function parseManualGate(value) {
const raw = toSafeString(value, 80);
const separatorIndex = raw.indexOf(":");
if (separatorIndex <= 0) {
throw new Error("--manual-gate requires <template|all>:<A|B|C|D>");
}
const target = raw.slice(0, separatorIndex).trim();
const gate = raw.slice(separatorIndex + 1).trim().toUpperCase();
if (![...APPROVAL_RULE_TARGETS, "all"].includes(target)) {
throw new Error(`Unsupported manual gate target: ${target}`);
}
if (!["A", "B", "C", "D"].includes(gate)) {
throw new Error(`Unsupported manual gate value: ${gate}`);
}
return { target, gate };
}
function parseArgs(argv) {
const options = {
format: "text",
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-rule-validator-${timestamp()}`),
templates: [],
manualGates: {},
approvalRoles: [],
fallbackEmployeeNo: DEFAULT_FALLBACK_EMPLOYEE_NO,
status: "",
limit: 50,
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 === "--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 === "--template") {
options.templates.push(toSafeString(argv[index + 1], 40));
index += 1;
} else if (arg.startsWith("--template=")) {
options.templates.push(toSafeString(arg.slice("--template=".length), 40));
} else if (arg === "--manual-gate") {
const { target, gate } = parseManualGate(argv[index + 1]);
if (target === "all") {
for (const template of APPROVAL_RULE_TARGETS) options.manualGates[template] = gate;
} else {
options.manualGates[target] = gate;
}
index += 1;
} else if (arg.startsWith("--manual-gate=")) {
const { target, gate } = parseManualGate(arg.slice("--manual-gate=".length));
if (target === "all") {
for (const template of APPROVAL_RULE_TARGETS) options.manualGates[template] = gate;
} else {
options.manualGates[target] = gate;
}
} else if (arg === "--approval-role") {
options.approvalRoles.push(toSafeString(argv[index + 1], 40));
index += 1;
} else if (arg.startsWith("--approval-role=")) {
options.approvalRoles.push(toSafeString(arg.slice("--approval-role=".length), 40));
} else if (arg === "--fallback-employee-no") {
options.fallbackEmployeeNo = toSafeString(argv[index + 1], 80);
index += 1;
} else if (arg.startsWith("--fallback-employee-no=")) {
options.fallbackEmployeeNo = toSafeString(arg.slice("--fallback-employee-no=".length), 80);
} else if (arg === "--status") {
options.status = toSafeString(argv[index + 1], 40);
index += 1;
} else if (arg.startsWith("--status=")) {
options.status = toSafeString(arg.slice("--status=".length), 40);
} 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 {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
const uniqueTemplates = Array.from(new Set(options.templates.filter(Boolean)));
for (const template of uniqueTemplates) {
if (!APPROVAL_RULE_TARGETS.includes(template)) {
throw new Error(`Unsupported --template: ${template}`);
}
}
options.templates = uniqueTemplates.length ? uniqueTemplates : [...APPROVAL_RULE_TARGETS];
const uniqueRoles = Array.from(new Set(options.approvalRoles.filter(Boolean)));
options.approvalRoles = uniqueRoles.length ? uniqueRoles : [...DEFAULT_APPROVAL_ROLES];
if (!options.fallbackEmployeeNo) {
throw new Error("--fallback-employee-no requires non-empty value");
}
if (!Number.isFinite(options.limit) || options.limit < 1 || options.limit > 100) {
throw new Error("--limit must be between 1 and 100");
}
options.limit = Math.floor(options.limit);
options.manualGates = normalizeManualGates(options.manualGates);
return options;
}
function buildArchiveWhere(options) {
const where = { template_type: { in: options.templates } };
if (options.status) where.normalized_status = options.status;
return where;
}
const EMPLOYEE_SELECT = {
id: true,
employee_no: true,
status: true,
roles: { select: { role_code: true } },
wecom_identity_binding: { select: { external_user_id: true } },
};
async function readApprovalRuleInputs(prisma, options) {
const [archives, fallbackEmployee, approvalRoleEmployees] = await Promise.all([
prisma.wecomApprovalArchive.findMany({
where: buildArchiveWhere(options),
orderBy: [{ status_observed_at: "desc" }, { updated_at: "desc" }],
take: options.limit,
select: {
id: true,
sp_no: true,
template_id: true,
template_type: true,
normalized_status: true,
status_observed_at: true,
applicant: {
select: {
...EMPLOYEE_SELECT,
direct_manager_id: true,
direct_manager: { select: EMPLOYEE_SELECT },
},
},
participants: {
where: { role: "approver" },
orderBy: [{ node_index: "asc" }, { created_at: "asc" }],
select: {
role: true,
node_index: true,
employee: { select: EMPLOYEE_SELECT },
},
},
actions: {
orderBy: [{ action_time: "asc" }, { created_at: "asc" }],
select: {
action_type: true,
node_index: true,
operator: { select: EMPLOYEE_SELECT },
},
},
},
}),
prisma.employee.findUnique({
where: { employee_no: options.fallbackEmployeeNo },
select: EMPLOYEE_SELECT,
}).catch(() => null),
prisma.employee.findMany({
where: {
status: "active",
roles: { some: { role_code: { in: options.approvalRoles } } },
},
select: EMPLOYEE_SELECT,
}),
]);
return { archives, fallbackEmployee, approvalRoleEmployees };
}
function manualGateGapsForTemplates(options) {
return manualGateFindingsForTemplates(options)
.filter((item) => item.status === "PAUSED_MANUAL_CONFIRMATION");
}
function manualGateFindingsForTemplates(options) {
return options.templates
.map((template) => {
const gate = options.manualGates[template];
if (!gate) {
return {
templateType: template,
status: "PAUSED_MANUAL_CONFIRMATION",
reason: "manual_wappr1_gate_missing",
};
}
if (gate === "D") {
return {
templateType: template,
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
reason: "manual_wappr1_template_not_visible_or_not_configured",
};
}
if (gate === "B" || gate === "C") {
return {
templateType: template,
status: "FAIL_APPLICANT_CAN_CHOOSE",
reason: gate === "B"
? "manual_wappr1_approver_can_be_changed"
: "manual_wappr1_approver_must_be_chosen_by_applicant",
};
}
return null;
})
.filter(Boolean);
}
function templateDataGapsForTemplates(options, archives) {
const presentTemplates = new Set((archives || []).map((archive) => archive?.template_type).filter(Boolean));
return options.templates
.filter((template) => !presentTemplates.has(template))
.map((template) => ({
templateType: template,
status: options.manualGates[template] ? "SKIP/BLOCKED_BY_EXTERNAL_CONFIG" : "PAUSED_MANUAL_CONFIRMATION",
reason: "no_wecom_approval_archive_rows_for_template_scope",
}));
}
async function executeApprovalRuleValidator(options, deps = {}) {
const generatedAt = deps.generatedAt || new Date().toISOString();
const env = deps.env || process.env;
let ownedPrisma = null;
let ownedPool = null;
let readError = null;
let rawInputs = { archives: [], fallbackEmployee: null, approvalRoleEmployees: [] };
const mainBlockers = [];
try {
let prisma = deps.prisma;
if (!prisma) {
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is required for approval rule validation");
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
ownedPrisma = created.prisma;
ownedPool = created.pool;
prisma = ownedPrisma;
}
rawInputs = await (deps.readApprovalRuleInputsImpl || readApprovalRuleInputs)(prisma, options);
} catch (error) {
readError = error;
mainBlockers.push({
id: "approval_rule_db_read",
reason: "oa_archive_or_org_rule_read_failed",
errorCode: toSafeString(error?.code || "unknown", 40) || "unknown",
message: safeMessage(error?.message || error),
});
} finally {
if (ownedPrisma?.$disconnect) await ownedPrisma.$disconnect().catch(() => null);
if (ownedPool?.end) await ownedPool.end().catch(() => null);
}
const manualGateFindings = manualGateFindingsForTemplates(options);
const manualGateGaps = manualGateFindings.filter((item) => item.status === "PAUSED_MANUAL_CONFIRMATION");
const templateDataGaps = templateDataGapsForTemplates(options, rawInputs.archives || []);
const items = (rawInputs.archives || []).map((archive) => validateApprovalRuleArchive(archive, {
manualGates: options.manualGates,
fallbackEmployee: rawInputs.fallbackEmployee,
approvalRoleEmployees: rawInputs.approvalRoleEmployees,
}));
const noData = !readError && !items.length;
if (noData) {
mainBlockers.push({
id: "approval_archive_data",
reason: "no_wecom_approval_archive_rows_for_rule_scope",
});
}
const status = evaluateRuleReport(items, {
readError,
manualGateFindings,
manualGateGaps,
templateDataGaps,
noData,
});
return {
mode: "qa_only",
generatedAt,
status,
conclusion: status,
privacy: REPORT_PRIVACY,
scope: {
issue: "WRULE1",
source: "OA_ARCHIVED_WECOM_APPROVAL_DETAIL",
writeBehavior: "read-only OA DB; no OA writes, no WeCom writes",
wecomApiCalls: false,
upstreamWecomSecret: "WECOM_AGENT_SECRET",
noSecretFallback: true,
targets: options.templates,
realEmployees: "not_enabled",
outingOvertimeLive: "not_authorized",
nextStage: "WAPPR1_A_then_WEXT1_LIVE_then_WREAL1",
},
filters: {
templates: options.templates,
status: options.status || null,
limit: options.limit,
approvalRoles: options.approvalRoles,
fallbackEmployeeNoPrefix: maskSimple(options.fallbackEmployeeNo),
manualGates: options.manualGates,
},
checks: {
ruleValidation: {
status,
count: items.length,
statusCounts: countBy(items, (item) => item.status),
templateCounts: countBy(items, (item) => item.templateType),
ruleModeCounts: countBy(items, (item) => item.ruleMode),
},
manualGateFindings,
manualGateGaps,
templateDataGaps,
items,
},
mainBlockers,
};
}
function maskSimple(value) {
const raw = toSafeString(value, 80);
if (!raw) return null;
if (raw.length <= 4) return `${raw.slice(0, 1)}***`;
return `${raw.slice(0, 3)}***${raw.slice(-2)}`;
}
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-approval-rule-validator] status=${report.status} conclusion=${report.conclusion}\n`);
process.stdout.write(`[wecom-approval-rule-validator] report=${reportPath}\n`);
process.stdout.write(`[wecom-approval-rule-validator] privacy=${report.privacy}\n`);
process.stdout.write(`[wecom-approval-rule-validator] targets=${report.scope.targets.join(",")} count=${report.checks.ruleValidation.count}\n`);
if (report.checks.manualGateGaps?.length) {
process.stdout.write(`[wecom-approval-rule-validator] manualGateGaps=${JSON.stringify(report.checks.manualGateGaps)}\n`);
}
if (report.mainBlockers?.length) {
process.stdout.write(`[wecom-approval-rule-validator] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await executeApprovalRuleValidator(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-approval-rule-validator][FAIL] ${safeMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
buildArchiveWhere,
executeApprovalRuleValidator,
manualGateFindingsForTemplates,
parseArgs,
readApprovalRuleInputs,
templateDataGapsForTemplates,
};