1025 行
33 KiB
JavaScript
1025 行
33 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 {
|
|
APPROVAL_GET_INFO_ENDPOINT,
|
|
APPROVAL_POC_TARGETS,
|
|
fetchWecomApprovalDetail,
|
|
fetchWecomApprovalInfoList,
|
|
maskApprovalIdentifier,
|
|
} = require("../services/integrations/wecomApproval");
|
|
const { readCallbackEvidence } = require("../services/integrations/wecomCallbackCrypto");
|
|
|
|
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
|
const DEFAULT_WINDOW_HOURS = 72;
|
|
const DEFAULT_PAGE_SIZE = 100;
|
|
const DEFAULT_MAX_PAGES = 10;
|
|
const DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO = "SMOKE002";
|
|
|
|
const REPORT_PRIVACY =
|
|
"approval counts/status summaries/masked sp_no/template/userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload";
|
|
|
|
const TEMPLATE_ENV = Object.freeze({
|
|
leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID",
|
|
missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID",
|
|
});
|
|
|
|
function printHelp() {
|
|
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-compensation-poc.js [options]
|
|
|
|
QA-only 企业微信审批补偿拉取 POC。
|
|
默认 dry-run 只检查配置和计划;--live 仅做只读 getapprovalinfo/getapprovaldetail,不写 OA/企微数据。
|
|
|
|
Options:
|
|
--live 启用企微 live 只读拉取。
|
|
--format=text|json 输出格式,默认 text。
|
|
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-compensation-poc-<timestamp>。
|
|
--start-time=<iso|epoch> 窗口开始时间,默认 end-time 前 72 小时。
|
|
--end-time=<iso|epoch> 窗口结束时间,默认当前时间。
|
|
--template=leave:<id> 指定请假模板 ID,用于 template_id filter。
|
|
--template=missed_punch:<id> 指定补卡模板 ID,用于 template_id filter。
|
|
--discover-template-ids 缺模板 ID 时,按申请人只读发现 OA-POC 模板 ID。
|
|
--discover-applicant-employee-no=<no>
|
|
自动发现使用的 OA 申请人员工号,默认 SMOKE002。
|
|
--size=<1..100> 单页审批单号数量,默认 100。
|
|
--help 显示帮助。
|
|
|
|
Secret 固定规则:
|
|
审批单号批量查询和审批详情读取:WECOM_AGENT_SECRET。
|
|
不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
|
|
|
|
隐私边界:
|
|
报告只输出审批类型、计数、状态摘要、脱敏 sp_no/template/userid 前缀和错误分类。
|
|
不输出手机号、邮箱、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 supportedTargetKeys() {
|
|
return APPROVAL_POC_TARGETS.map((item) => item.key);
|
|
}
|
|
|
|
function parseTargetPair(value, flagName) {
|
|
const raw = toSafeString(value, 500);
|
|
const separatorIndex = raw.indexOf(":");
|
|
if (separatorIndex <= 0) {
|
|
throw new Error(`${flagName} requires <target>:<value>`);
|
|
}
|
|
const target = raw.slice(0, separatorIndex).trim();
|
|
const itemValue = raw.slice(separatorIndex + 1).trim();
|
|
if (!supportedTargetKeys().includes(target)) {
|
|
throw new Error(`${flagName} unsupported target: ${target}`);
|
|
}
|
|
if (!itemValue) {
|
|
throw new Error(`${flagName} requires non-empty value`);
|
|
}
|
|
return { target, value: itemValue };
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
live: false,
|
|
format: "text",
|
|
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-compensation-poc-${timestamp()}`),
|
|
startTime: "",
|
|
endTime: "",
|
|
templates: {},
|
|
discoverTemplateIds: false,
|
|
discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO,
|
|
size: DEFAULT_PAGE_SIZE,
|
|
maxPages: DEFAULT_MAX_PAGES,
|
|
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 === "--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 === "--start-time") {
|
|
options.startTime = argv[index + 1] || "";
|
|
index += 1;
|
|
} else if (arg.startsWith("--start-time=")) {
|
|
options.startTime = arg.slice("--start-time=".length);
|
|
} else if (arg === "--end-time") {
|
|
options.endTime = argv[index + 1] || "";
|
|
index += 1;
|
|
} else if (arg.startsWith("--end-time=")) {
|
|
options.endTime = arg.slice("--end-time=".length);
|
|
} else if (arg === "--template") {
|
|
const pair = parseTargetPair(argv[index + 1], "--template");
|
|
options.templates[pair.target] = pair.value;
|
|
index += 1;
|
|
} else if (arg.startsWith("--template=")) {
|
|
const pair = parseTargetPair(arg.slice("--template=".length), "--template");
|
|
options.templates[pair.target] = pair.value;
|
|
} else if (arg === "--discover-template-ids") {
|
|
options.discoverTemplateIds = true;
|
|
} else if (arg === "--discover-applicant-employee-no") {
|
|
options.discoverApplicantEmployeeNo = toSafeString(argv[index + 1], 80);
|
|
index += 1;
|
|
} else if (arg.startsWith("--discover-applicant-employee-no=")) {
|
|
options.discoverApplicantEmployeeNo = toSafeString(arg.slice("--discover-applicant-employee-no=".length), 80);
|
|
} else if (arg === "--size") {
|
|
options.size = Number(argv[index + 1] || options.size);
|
|
index += 1;
|
|
} else if (arg.startsWith("--size=")) {
|
|
options.size = Number(arg.slice("--size=".length));
|
|
} else {
|
|
throw new Error(`Unsupported argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!["text", "json"].includes(options.format)) {
|
|
throw new Error(`Unsupported --format: ${options.format}`);
|
|
}
|
|
if (!Number.isFinite(options.size) || options.size < 1 || options.size > 100) {
|
|
throw new Error("--size must be between 1 and 100");
|
|
}
|
|
if (!options.discoverApplicantEmployeeNo) {
|
|
throw new Error("--discover-applicant-employee-no requires non-empty value");
|
|
}
|
|
options.size = Math.floor(options.size);
|
|
return options;
|
|
}
|
|
|
|
function parseTimeInput(value) {
|
|
const raw = toSafeString(value, 80);
|
|
if (!raw) return null;
|
|
if (/^\d+$/.test(raw)) {
|
|
const numeric = Number(raw);
|
|
return numeric > 100000000000 ? Math.floor(numeric / 1000) : numeric;
|
|
}
|
|
const parsed = Date.parse(raw);
|
|
if (!Number.isFinite(parsed)) {
|
|
throw new Error(`Invalid time value: ${raw}`);
|
|
}
|
|
return Math.floor(parsed / 1000);
|
|
}
|
|
|
|
function buildTimeWindow(options, generatedAt) {
|
|
const generatedEnd = Math.floor(new Date(generatedAt).getTime() / 1000);
|
|
const endTime = parseTimeInput(options.endTime) || generatedEnd;
|
|
const startTime = parseTimeInput(options.startTime) || (endTime - DEFAULT_WINDOW_HOURS * 60 * 60);
|
|
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || startTime <= 0 || endTime <= 0 || startTime >= endTime) {
|
|
throw new Error("Invalid approval compensation time window");
|
|
}
|
|
return {
|
|
startTime,
|
|
endTime,
|
|
startIso: new Date(startTime * 1000).toISOString(),
|
|
endIso: new Date(endTime * 1000).toISOString(),
|
|
defaultWindowHours: DEFAULT_WINDOW_HOURS,
|
|
};
|
|
}
|
|
|
|
function envValue(env, name) {
|
|
return toSafeString(env[name], 500);
|
|
}
|
|
|
|
function buildInputs(options, env = process.env) {
|
|
return APPROVAL_POC_TARGETS.map((target) => {
|
|
const cliTemplateId = toSafeString(options.templates[target.key], 500);
|
|
const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]);
|
|
const templateId = cliTemplateId || envTemplateId;
|
|
return {
|
|
key: target.key,
|
|
label: target.label,
|
|
templateId,
|
|
templateIdPrefix: maskApprovalIdentifier(templateId),
|
|
templateSource: templateId ? (cliTemplateId ? "cli" : TEMPLATE_ENV[target.key]) : null,
|
|
templateMissingEnv: templateId ? [] : [TEMPLATE_ENV[target.key]],
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildSecretCheck(config) {
|
|
const missing = [];
|
|
if (!config.wecomCorpId) missing.push("WECOM_CORP_ID");
|
|
if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET");
|
|
return {
|
|
id: "approval_compensation_secret_matrix",
|
|
source: "WECOM_AGENT_SECRET",
|
|
endpoints: [
|
|
`POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
|
"POST /cgi-bin/oa/getapprovaldetail",
|
|
],
|
|
status: missing.length ? "BLOCKED" : "OK",
|
|
missing,
|
|
noFallback: [
|
|
"WECOM_CONTACT_SECRET",
|
|
"WECOM_CHECKIN_SECRET",
|
|
"WECOM_HR_ASSISTANT_SECRET",
|
|
],
|
|
};
|
|
}
|
|
|
|
function isOaActive(row) {
|
|
return row?.status === "active" && !row?.archived;
|
|
}
|
|
|
|
function sanitizeDiscoveryActor(row) {
|
|
if (!row) return null;
|
|
return {
|
|
employeeNo: row.employeeNo,
|
|
status: row.status,
|
|
archived: Boolean(row.archived),
|
|
hasWecomBinding: Boolean(row.wecomExternalUserId),
|
|
wecomUserIdPrefix: row.wecomExternalUserId ? maskApprovalIdentifier(row.wecomExternalUserId) : null,
|
|
};
|
|
}
|
|
|
|
async function queryApplicantActorRowReadOnly(employeeNo) {
|
|
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 result = await client.query(`
|
|
SELECT
|
|
e.id::text,
|
|
e.employee_no,
|
|
e.status::text,
|
|
(e.archived_at IS NOT NULL) AS archived,
|
|
w.external_user_id AS wecom_external_user_id
|
|
FROM employees e
|
|
LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id
|
|
WHERE e.employee_no = $1
|
|
LIMIT 1
|
|
`, [employeeNo]);
|
|
await client.query("COMMIT");
|
|
const row = result.rows[0];
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
employeeNo: toSafeString(row.employee_no, 80),
|
|
status: toSafeString(row.status, 40),
|
|
archived: Boolean(row.archived),
|
|
wecomExternalUserId: toSafeString(row.wecom_external_user_id, 160),
|
|
};
|
|
} catch (error) {
|
|
try {
|
|
await client.query("ROLLBACK");
|
|
} catch {
|
|
// Preserve the original read-only error.
|
|
}
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
function inferTargetFromApprovalDetail(detailResult) {
|
|
const text = [
|
|
detailResult?.spName,
|
|
detailResult?.summary?.spName,
|
|
].filter(Boolean).join(" ");
|
|
if (/补卡|missed[_ -]?punch/i.test(text)) return "missed_punch";
|
|
if (/请假|leave/i.test(text)) return "leave";
|
|
return null;
|
|
}
|
|
|
|
function buildSkippedDiscoveryCheck(reason) {
|
|
return {
|
|
id: "approval_template_auto_discovery",
|
|
status: "SKIPPED",
|
|
reason,
|
|
applicantEmployeeNo: null,
|
|
applicant: null,
|
|
pageCount: 0,
|
|
spNoCount: 0,
|
|
detailCount: 0,
|
|
discovered: {},
|
|
};
|
|
}
|
|
|
|
async function discoverTemplateIds({
|
|
config,
|
|
inputs,
|
|
timeWindow,
|
|
options,
|
|
fetchApprovalInfoListImpl,
|
|
fetchApprovalDetailImpl,
|
|
queryApplicantActorImpl,
|
|
}) {
|
|
const missingTargets = inputs.filter((item) => !item.templateId);
|
|
if (!options.discoverTemplateIds) {
|
|
return buildSkippedDiscoveryCheck("discover_template_ids_not_requested");
|
|
}
|
|
if (!options.live) {
|
|
return {
|
|
...buildSkippedDiscoveryCheck("live_required_for_template_discovery"),
|
|
status: "NEEDS_CONFIG",
|
|
};
|
|
}
|
|
if (!missingTargets.length) {
|
|
return buildSkippedDiscoveryCheck("all_template_ids_already_configured");
|
|
}
|
|
|
|
let applicant;
|
|
try {
|
|
applicant = await queryApplicantActorImpl(options.discoverApplicantEmployeeNo);
|
|
} catch (error) {
|
|
return {
|
|
id: "approval_template_auto_discovery",
|
|
status: "NEEDS_CONFIG",
|
|
reason: "applicant_lookup_failed",
|
|
message: safeMessage(error?.message || error),
|
|
applicantEmployeeNo: options.discoverApplicantEmployeeNo,
|
|
applicant: null,
|
|
pageCount: 0,
|
|
spNoCount: 0,
|
|
detailCount: 0,
|
|
discovered: {},
|
|
};
|
|
}
|
|
|
|
const actorGaps = [];
|
|
if (!applicant) actorGaps.push("applicant_missing");
|
|
if (applicant && !isOaActive(applicant)) actorGaps.push("applicant_inactive");
|
|
if (applicant && !applicant.wecomExternalUserId) actorGaps.push("applicant_missing_wecom_binding");
|
|
if (actorGaps.length) {
|
|
return {
|
|
id: "approval_template_auto_discovery",
|
|
status: "NEEDS_CONFIG",
|
|
reason: "applicant_not_ready",
|
|
applicantEmployeeNo: options.discoverApplicantEmployeeNo,
|
|
applicant: sanitizeDiscoveryActor(applicant),
|
|
gaps: actorGaps,
|
|
pageCount: 0,
|
|
spNoCount: 0,
|
|
detailCount: 0,
|
|
discovered: {},
|
|
};
|
|
}
|
|
|
|
const pages = [];
|
|
const spNos = [];
|
|
let cursor = "";
|
|
let pageIndex = 0;
|
|
do {
|
|
pageIndex += 1;
|
|
const result = await fetchApprovalInfoListImpl({
|
|
config,
|
|
startTime: timeWindow.startTime,
|
|
endTime: timeWindow.endTime,
|
|
size: options.size,
|
|
nextCursor: cursor,
|
|
filters: [{ key: "creator", value: applicant.wecomExternalUserId }],
|
|
});
|
|
pages.push({
|
|
page: pageIndex,
|
|
status: result.ok ? "OK" : "BLOCKED",
|
|
reason: result.ok ? null : result.code,
|
|
message: result.message ? safeMessage(result.message) : null,
|
|
source: result.tokenSource || "WECOM_AGENT_SECRET",
|
|
response: summarizeApiResponse(result.response),
|
|
spNoCount: result.spNos?.length || 0,
|
|
spNoPrefixes: (result.spNos || []).map(maskApprovalIdentifier),
|
|
nextCursorPresent: Boolean(result.nextCursor),
|
|
});
|
|
if (!result.ok) {
|
|
return {
|
|
id: "approval_template_auto_discovery",
|
|
status: "BLOCKED",
|
|
reason: result.code || "wecom_approval_info_failed",
|
|
message: result.message ? safeMessage(result.message) : null,
|
|
applicantEmployeeNo: options.discoverApplicantEmployeeNo,
|
|
applicant: sanitizeDiscoveryActor(applicant),
|
|
pageCount: pages.length,
|
|
spNoCount: 0,
|
|
detailCount: 0,
|
|
pages,
|
|
discovered: {},
|
|
};
|
|
}
|
|
spNos.push(...(result.spNos || []));
|
|
const nextCursor = toSafeString(result.nextCursor, 160);
|
|
cursor = nextCursor && nextCursor !== cursor ? nextCursor : "";
|
|
} while (cursor && pageIndex < options.maxPages);
|
|
|
|
const discovered = {};
|
|
const details = [];
|
|
for (const spNo of Array.from(new Set(spNos))) {
|
|
const detailResult = await fetchApprovalDetailImpl({ config, spNo });
|
|
const target = detailResult.ok ? inferTargetFromApprovalDetail(detailResult) : null;
|
|
const templateId = target ? toSafeString(detailResult.templateId, 160) : "";
|
|
details.push({
|
|
status: detailResult.ok ? "OK" : "BLOCKED",
|
|
reason: detailResult.ok ? null : detailResult.code || "wecom_approval_detail_failed",
|
|
spNoPrefix: maskApprovalIdentifier(spNo),
|
|
templateIdPrefix: maskApprovalIdentifier(templateId),
|
|
target,
|
|
});
|
|
if (target && templateId && !discovered[target]) {
|
|
discovered[target] = templateId;
|
|
}
|
|
}
|
|
|
|
const missingAfterDiscovery = missingTargets
|
|
.map((item) => item.key)
|
|
.filter((key) => !discovered[key]);
|
|
|
|
return {
|
|
id: "approval_template_auto_discovery",
|
|
status: missingAfterDiscovery.length ? "PARTIAL" : "OK",
|
|
reason: missingAfterDiscovery.length ? "template_id_not_found_for_all_targets" : null,
|
|
applicantEmployeeNo: options.discoverApplicantEmployeeNo,
|
|
applicant: sanitizeDiscoveryActor(applicant),
|
|
filter: {
|
|
key: "creator",
|
|
useridPrefix: maskApprovalIdentifier(applicant.wecomExternalUserId),
|
|
},
|
|
pageCount: pages.length,
|
|
spNoCount: Array.from(new Set(spNos)).length,
|
|
detailCount: details.length,
|
|
pages,
|
|
details,
|
|
discoveredTemplatePrefixes: Object.fromEntries(
|
|
Object.entries(discovered).map(([key, value]) => [key, maskApprovalIdentifier(value)]),
|
|
),
|
|
discovered,
|
|
};
|
|
}
|
|
|
|
function applyDiscoveredTemplateIds(inputs, discoveryCheck) {
|
|
const discovered = discoveryCheck?.discovered || {};
|
|
return inputs.map((input) => {
|
|
if (input.templateId || !discovered[input.key]) return input;
|
|
return {
|
|
...input,
|
|
templateId: discovered[input.key],
|
|
templateIdPrefix: maskApprovalIdentifier(discovered[input.key]),
|
|
templateSource: `auto_discovery:${discoveryCheck.applicantEmployeeNo}`,
|
|
templateMissingEnv: [],
|
|
};
|
|
});
|
|
}
|
|
|
|
function sanitizeDiscoveryCheck(discoveryCheck) {
|
|
if (!discoveryCheck || typeof discoveryCheck !== "object") return discoveryCheck;
|
|
return {
|
|
...discoveryCheck,
|
|
discovered: undefined,
|
|
};
|
|
}
|
|
|
|
function summarizeApiResponse(response) {
|
|
if (!response || typeof response !== "object") return null;
|
|
const summary = {};
|
|
for (const key of [
|
|
"errcode",
|
|
"errmsg",
|
|
"spNoCount",
|
|
"spNoPrefixes",
|
|
"nextCursorPresent",
|
|
"spStatus",
|
|
"applyFieldCount",
|
|
"approvalNodeCount",
|
|
"notifyerCount",
|
|
"commentCount",
|
|
]) {
|
|
if (response[key] != null) {
|
|
summary[key] = key === "errmsg" ? safeMessage(response[key]) : response[key];
|
|
}
|
|
}
|
|
return Object.keys(summary).length ? summary : null;
|
|
}
|
|
|
|
function statusLabelFromSpStatus(value) {
|
|
const key = toSafeString(value, 20);
|
|
return {
|
|
"1": "审批中",
|
|
"2": "已通过",
|
|
"3": "已驳回",
|
|
"4": "已撤销",
|
|
"6": "通过后撤销",
|
|
"7": "已删除",
|
|
"10": "已支付",
|
|
}[key] || (key ? `状态${key}` : null);
|
|
}
|
|
|
|
function summarizeDetailResult(target, spNo, result) {
|
|
const summary = result?.summary || {};
|
|
const spStatus = summary.spStatus == null ? null : toSafeString(summary.spStatus, 40);
|
|
return {
|
|
target: target.key,
|
|
label: target.label,
|
|
status: result?.ok ? "OK" : "BLOCKED",
|
|
reason: result?.ok ? null : result?.code || "wecom_approval_detail_failed",
|
|
message: result?.message ? safeMessage(result.message) : null,
|
|
source: result?.tokenSource || "WECOM_AGENT_SECRET",
|
|
spNoPrefix: maskApprovalIdentifier(spNo),
|
|
templateIdPrefix: summary.templateIdPrefix || target.templateIdPrefix,
|
|
spStatus,
|
|
statusLabel: statusLabelFromSpStatus(spStatus),
|
|
applyFieldCount: summary.applyFieldCount,
|
|
approvalNodeCount: summary.approvalNodeCount,
|
|
notifyerCount: summary.notifyerCount,
|
|
commentCount: summary.commentCount,
|
|
response: summarizeApiResponse(result?.response),
|
|
};
|
|
}
|
|
|
|
function buildMissingTemplateCheck(input) {
|
|
return {
|
|
id: "approval_compensation_pull",
|
|
target: input.key,
|
|
label: input.label,
|
|
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
|
source: "WECOM_AGENT_SECRET",
|
|
status: "NEEDS_CONFIG",
|
|
missingEnv: input.templateMissingEnv,
|
|
templateIdPrefix: null,
|
|
pageCount: 0,
|
|
spNoCount: 0,
|
|
details: [],
|
|
};
|
|
}
|
|
|
|
function buildDryRunTargetCheck(input) {
|
|
return {
|
|
id: "approval_compensation_pull",
|
|
target: input.key,
|
|
label: input.label,
|
|
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
|
source: "WECOM_AGENT_SECRET",
|
|
status: "DRY_RUN_READY",
|
|
templateIdPrefix: input.templateIdPrefix,
|
|
configuredFrom: input.templateSource,
|
|
pageCount: 0,
|
|
spNoCount: 0,
|
|
details: [],
|
|
};
|
|
}
|
|
|
|
async function runLiveTarget({
|
|
config,
|
|
input,
|
|
timeWindow,
|
|
options,
|
|
fetchApprovalInfoListImpl,
|
|
fetchApprovalDetailImpl,
|
|
}) {
|
|
const pages = [];
|
|
const spNos = [];
|
|
let cursor = "";
|
|
let pageIndex = 0;
|
|
let blocked = null;
|
|
|
|
do {
|
|
pageIndex += 1;
|
|
const result = await fetchApprovalInfoListImpl({
|
|
config,
|
|
startTime: timeWindow.startTime,
|
|
endTime: timeWindow.endTime,
|
|
size: options.size,
|
|
nextCursor: cursor,
|
|
filters: [{ key: "template_id", value: input.templateId }],
|
|
});
|
|
pages.push({
|
|
page: pageIndex,
|
|
status: result.ok ? "OK" : "BLOCKED",
|
|
reason: result.ok ? null : result.code,
|
|
message: result.message ? safeMessage(result.message) : null,
|
|
source: result.tokenSource || "WECOM_AGENT_SECRET",
|
|
response: summarizeApiResponse(result.response),
|
|
spNoCount: result.spNos?.length || 0,
|
|
spNoPrefixes: (result.spNos || []).map(maskApprovalIdentifier),
|
|
nextCursorPresent: Boolean(result.nextCursor),
|
|
});
|
|
if (!result.ok) {
|
|
blocked = result;
|
|
break;
|
|
}
|
|
spNos.push(...(result.spNos || []));
|
|
const nextCursor = toSafeString(result.nextCursor, 160);
|
|
cursor = nextCursor && nextCursor !== cursor ? nextCursor : "";
|
|
} while (cursor && pageIndex < options.maxPages);
|
|
|
|
if (blocked) {
|
|
return {
|
|
id: "approval_compensation_pull",
|
|
target: input.key,
|
|
label: input.label,
|
|
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
|
source: blocked.tokenSource || "WECOM_AGENT_SECRET",
|
|
status: "BLOCKED",
|
|
reason: blocked.code,
|
|
message: blocked.message ? safeMessage(blocked.message) : null,
|
|
templateIdPrefix: input.templateIdPrefix,
|
|
configuredFrom: input.templateSource,
|
|
pageCount: pages.length,
|
|
spNoCount: 0,
|
|
pages,
|
|
details: [],
|
|
};
|
|
}
|
|
|
|
const uniqueSpNos = Array.from(new Set(spNos));
|
|
const details = [];
|
|
for (const spNo of uniqueSpNos) {
|
|
const detailResult = await fetchApprovalDetailImpl({ config, spNo });
|
|
details.push(summarizeDetailResult(input, spNo, detailResult));
|
|
}
|
|
|
|
const detailBlocked = details.find((item) => item.status === "BLOCKED");
|
|
return {
|
|
id: "approval_compensation_pull",
|
|
target: input.key,
|
|
label: input.label,
|
|
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
|
source: "WECOM_AGENT_SECRET",
|
|
status: detailBlocked ? "BLOCKED" : "OK",
|
|
reason: detailBlocked ? detailBlocked.reason : null,
|
|
templateIdPrefix: input.templateIdPrefix,
|
|
configuredFrom: input.templateSource,
|
|
pageCount: pages.length,
|
|
spNoCount: uniqueSpNos.length,
|
|
pages,
|
|
details,
|
|
findings: cursor ? [{ id: "approval_compensation_pagination_truncated", status: "FINDING" }] : [],
|
|
};
|
|
}
|
|
|
|
async function runTargetChecks({
|
|
config,
|
|
inputs,
|
|
timeWindow,
|
|
options,
|
|
fetchApprovalInfoListImpl,
|
|
fetchApprovalDetailImpl,
|
|
}) {
|
|
const targetChecks = [];
|
|
for (const input of inputs) {
|
|
if (!input.templateId) {
|
|
targetChecks.push(buildMissingTemplateCheck(input));
|
|
} else if (!options.live) {
|
|
targetChecks.push(buildDryRunTargetCheck(input));
|
|
} else {
|
|
targetChecks.push(await runLiveTarget({
|
|
config,
|
|
input,
|
|
timeWindow,
|
|
options,
|
|
fetchApprovalInfoListImpl,
|
|
fetchApprovalDetailImpl,
|
|
}));
|
|
}
|
|
}
|
|
return targetChecks;
|
|
}
|
|
|
|
function sanitizeEvidenceEvent(event) {
|
|
return {
|
|
callbackKind: toSafeString(event.callbackKind, 80),
|
|
approvalType: toSafeString(event.approvalType, 80),
|
|
spStatus: event.spStatus == null ? null : toSafeString(event.spStatus, 40),
|
|
statusLabel: toSafeString(event.statusLabel, 80) || null,
|
|
spNoPrefix: toSafeString(event.spNoPrefix, 80) || null,
|
|
templateIdPrefix: toSafeString(event.templateIdPrefix, 80) || null,
|
|
recordedAt: toSafeString(event.recordedAt, 80) || null,
|
|
};
|
|
}
|
|
|
|
async function buildEvidenceCheck({ config, readCallbackEvidenceImpl = readCallbackEvidence }) {
|
|
const events = await readCallbackEvidenceImpl({
|
|
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
|
|
});
|
|
const approvalEvents = (events || [])
|
|
.filter((item) => item?.callbackKind === "approval_status_change")
|
|
.map(sanitizeEvidenceEvent);
|
|
const bySpNoPrefix = {};
|
|
for (const event of approvalEvents) {
|
|
if (!event.spNoPrefix) continue;
|
|
bySpNoPrefix[event.spNoPrefix] = bySpNoPrefix[event.spNoPrefix] || [];
|
|
bySpNoPrefix[event.spNoPrefix].push(event);
|
|
}
|
|
return {
|
|
id: "callback_event_evidence",
|
|
status: approvalEvents.length ? "OK" : "PENDING",
|
|
eventCount: approvalEvents.length,
|
|
latestEvent: approvalEvents[approvalEvents.length - 1] || null,
|
|
bySpNoPrefix,
|
|
};
|
|
}
|
|
|
|
function collectPulledDetails(targetChecks) {
|
|
return targetChecks.flatMap((target) => (
|
|
Array.isArray(target.details)
|
|
? target.details.filter((item) => item.status === "OK")
|
|
: []
|
|
));
|
|
}
|
|
|
|
function compareCallbackEvidence(targetChecks, evidenceCheck) {
|
|
const findings = [];
|
|
const pulledDetails = collectPulledDetails(targetChecks);
|
|
const evidenceBySpNo = evidenceCheck.bySpNoPrefix || {};
|
|
const pulledPrefixes = new Set();
|
|
|
|
for (const detail of pulledDetails) {
|
|
if (!detail.spNoPrefix) continue;
|
|
pulledPrefixes.add(detail.spNoPrefix);
|
|
const events = evidenceBySpNo[detail.spNoPrefix] || [];
|
|
if (!events.length) {
|
|
findings.push({
|
|
id: "callback_missing_for_pulled_detail",
|
|
target: detail.target,
|
|
spNoPrefix: detail.spNoPrefix,
|
|
status: "FINDING",
|
|
});
|
|
continue;
|
|
}
|
|
if (events.length > 1) {
|
|
findings.push({
|
|
id: "callback_duplicate_for_pulled_detail",
|
|
target: detail.target,
|
|
spNoPrefix: detail.spNoPrefix,
|
|
count: events.length,
|
|
status: "FINDING",
|
|
});
|
|
}
|
|
const hasStatusMatch = events.some((event) => (
|
|
(event.spStatus && detail.spStatus && event.spStatus === detail.spStatus)
|
|
|| (event.statusLabel && detail.statusLabel && event.statusLabel === detail.statusLabel)
|
|
));
|
|
if (!hasStatusMatch) {
|
|
findings.push({
|
|
id: "callback_status_mismatch",
|
|
target: detail.target,
|
|
spNoPrefix: detail.spNoPrefix,
|
|
pulledStatus: detail.statusLabel || detail.spStatus,
|
|
callbackStatus: events[0]?.statusLabel || events[0]?.spStatus || null,
|
|
status: "FINDING",
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const [spNoPrefix, events] of Object.entries(evidenceBySpNo)) {
|
|
if (!pulledPrefixes.has(spNoPrefix)) {
|
|
findings.push({
|
|
id: "callback_not_in_compensation_window",
|
|
spNoPrefix,
|
|
count: events.length,
|
|
status: "FINDING",
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: "callback_compensation_compare",
|
|
status: findings.length ? "FINDINGS" : "OK",
|
|
pulledDetailCount: pulledDetails.length,
|
|
callbackEventCount: evidenceCheck.eventCount,
|
|
findings,
|
|
};
|
|
}
|
|
|
|
function collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck) {
|
|
const gaps = [];
|
|
for (const check of targetChecks) {
|
|
if (check.status === "NEEDS_CONFIG") {
|
|
gaps.push({
|
|
id: check.id,
|
|
target: check.target,
|
|
status: "NEEDS_CONFIG",
|
|
missingEnv: check.missingEnv || [],
|
|
});
|
|
}
|
|
}
|
|
for (const gap of verificationGaps) {
|
|
gaps.push(gap);
|
|
}
|
|
const pulledCount = compareCheck?.pulledDetailCount || 0;
|
|
const hasTemplateConfigGap = targetChecks.some((item) => item.status === "NEEDS_CONFIG");
|
|
if (pulledCount === 0
|
|
&& !verificationGaps.length
|
|
&& !hasTemplateConfigGap
|
|
&& !targetChecks.some((item) => item.status === "BLOCKED")) {
|
|
gaps.push({
|
|
id: "approval_compensation_window_empty",
|
|
status: "NEEDS_CONFIG",
|
|
reason: "no_existing_oa_poc_approval_in_window",
|
|
});
|
|
}
|
|
return gaps;
|
|
}
|
|
|
|
function collectMainBlockers(secretCheck, targetChecks) {
|
|
const blockers = [];
|
|
if (secretCheck.status === "BLOCKED") {
|
|
blockers.push({
|
|
id: secretCheck.id,
|
|
reason: "missing_required_wecom_approval_config",
|
|
missing: secretCheck.missing,
|
|
});
|
|
}
|
|
for (const check of targetChecks) {
|
|
if (check.status === "BLOCKED") {
|
|
blockers.push({
|
|
id: check.id,
|
|
target: check.target,
|
|
reason: check.reason || "wecom_approval_compensation_api_blocked",
|
|
});
|
|
}
|
|
}
|
|
return blockers;
|
|
}
|
|
|
|
function evaluateConclusion({ mainBlockers, configGaps, compareCheck }) {
|
|
if (mainBlockers.length) {
|
|
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
|
|
}
|
|
if (configGaps.length) {
|
|
return {
|
|
status: "PARTIAL_COMPENSATION_NEEDS_CONFIG",
|
|
conclusion: "PARTIAL_COMPENSATION_NEEDS_CONFIG",
|
|
};
|
|
}
|
|
if (compareCheck?.findings?.length) {
|
|
return { status: "PASS_WITH_FINDINGS", conclusion: "PASS_WITH_FINDINGS" };
|
|
}
|
|
return { status: "PASS_COMPENSATION_POC", conclusion: "PASS_COMPENSATION_POC" };
|
|
}
|
|
|
|
async function executeCompensationPoc(options, deps = {}) {
|
|
const env = deps.env || process.env;
|
|
const generatedAt = deps.generatedAt || new Date().toISOString();
|
|
const config = deps.config || getConfig();
|
|
const timeWindow = buildTimeWindow(options, generatedAt);
|
|
const inputs = buildInputs(options, env);
|
|
const secretCheck = buildSecretCheck(config);
|
|
const discoveryCheck = await discoverTemplateIds({
|
|
config,
|
|
inputs,
|
|
timeWindow,
|
|
options,
|
|
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
|
|
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
|
|
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
|
|
});
|
|
const resolvedInputs = applyDiscoveredTemplateIds(inputs, discoveryCheck);
|
|
const targetChecks = await runTargetChecks({
|
|
config,
|
|
inputs: resolvedInputs,
|
|
timeWindow,
|
|
options,
|
|
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
|
|
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
|
|
});
|
|
const evidenceCheck = await buildEvidenceCheck({
|
|
config,
|
|
readCallbackEvidenceImpl: deps.readCallbackEvidenceImpl,
|
|
});
|
|
const compareCheck = compareCallbackEvidence(targetChecks, evidenceCheck);
|
|
const verificationGaps = options.live ? [] : [{
|
|
id: "live_read",
|
|
status: "DRY_RUN",
|
|
reason: "run_with_--live_for_public_readonly_gate",
|
|
}];
|
|
const mainBlockers = collectMainBlockers(secretCheck, targetChecks);
|
|
if (discoveryCheck.status === "BLOCKED") {
|
|
mainBlockers.push({
|
|
id: discoveryCheck.id,
|
|
reason: discoveryCheck.reason || "approval_template_auto_discovery_blocked",
|
|
});
|
|
}
|
|
const configGaps = collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck);
|
|
const conclusion = evaluateConclusion({ mainBlockers, configGaps, compareCheck });
|
|
|
|
return {
|
|
mode: "qa_only",
|
|
generatedAt,
|
|
status: conclusion.status,
|
|
conclusion: conclusion.conclusion,
|
|
privacy: REPORT_PRIVACY,
|
|
scope: {
|
|
issue: "WACP3",
|
|
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
|
|
writeBehavior: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
|
|
liveRead: Boolean(options.live),
|
|
autoDiscovery: Boolean(options.discoverTemplateIds),
|
|
deepMirror: "not_started",
|
|
nextStage: "WREAD1",
|
|
},
|
|
timeWindow,
|
|
inputs: {
|
|
templates: Object.fromEntries(inputs.map((item) => [item.key, {
|
|
configured: Boolean(item.templateId),
|
|
configuredFrom: item.templateSource,
|
|
templateIdPrefix: item.templateIdPrefix,
|
|
}])),
|
|
pageSize: options.size,
|
|
maxPages: options.maxPages,
|
|
discoveryApplicantEmployeeNo: options.discoverTemplateIds ? options.discoverApplicantEmployeeNo : null,
|
|
},
|
|
checks: {
|
|
secretMatrix: secretCheck,
|
|
templateDiscovery: sanitizeDiscoveryCheck(discoveryCheck),
|
|
targets: targetChecks,
|
|
callbackEvidence: {
|
|
...evidenceCheck,
|
|
bySpNoPrefix: undefined,
|
|
},
|
|
callbackCompare: compareCheck,
|
|
},
|
|
configGaps,
|
|
mainBlockers,
|
|
};
|
|
}
|
|
|
|
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-compensation-poc] status=${report.status} conclusion=${report.conclusion}\n`);
|
|
process.stdout.write(`[wecom-approval-compensation-poc] report=${reportPath}\n`);
|
|
process.stdout.write(`[wecom-approval-compensation-poc] privacy=${report.privacy}\n`);
|
|
process.stdout.write(`[wecom-approval-compensation-poc] liveRead=${report.scope.liveRead} targets=${report.scope.targets.join(",")}\n`);
|
|
if (report.configGaps?.length) {
|
|
process.stdout.write(`[wecom-approval-compensation-poc] configGaps=${JSON.stringify(report.configGaps)}\n`);
|
|
}
|
|
if (report.mainBlockers?.length) {
|
|
process.stdout.write(`[wecom-approval-compensation-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
const report = await executeCompensationPoc(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-compensation-poc][FAIL] ${safeMessage(error?.message || error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
buildInputs,
|
|
buildSecretCheck,
|
|
buildTimeWindow,
|
|
compareCallbackEvidence,
|
|
discoverTemplateIds,
|
|
evaluateConclusion,
|
|
executeCompensationPoc,
|
|
parseArgs,
|
|
statusLabelFromSpStatus,
|
|
};
|