WCBK1 实现企微审批回调只读 POC

这个提交包含在:
2026-05-19 15:00:22 +08:00
父节点 a32ec1e658
当前提交 8122898348
共修改 30 个文件,包含 1650 行新增和 53 行删除
+2
查看文件
@@ -17,6 +17,7 @@ const prismaPlugin = require("./plugins/prisma");
const swaggerDocsPlugin = require("./plugins/swagger");
const authRoutes = require("./routes/auth");
const wecomAuthRoutes = require("./routes/wecomAuth");
const wecomApprovalCallbackRoutes = require("./routes/wecomApprovalCallback");
const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements");
const invoicePoolRoutes = require("./routes/invoicePool");
@@ -336,6 +337,7 @@ async function buildApp() {
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
await app.register(eventsRoutes, { prefix: "/api" });
await app.register(meRoutes, { prefix: "/api" });
await app.register(reimbursementRoutes, { prefix: "/api" });
+4
查看文件
@@ -70,6 +70,10 @@ function getConfig() {
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
wecomApprovalCallbackUrl: process.env.WECOM_APPROVAL_CALLBACK_URL ?? "",
wecomCallbackToken: process.env.WECOM_CALLBACK_TOKEN ?? "",
wecomCallbackEncodingAesKey: process.env.WECOM_CALLBACK_ENCODING_AES_KEY ?? "",
wecomApprovalCallbackEvidenceFile: process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE ?? "",
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
storageDir: path.resolve(serverRoot, storageDir),
+161
查看文件
@@ -0,0 +1,161 @@
const {
WecomCallbackError,
assertWecomSignature,
decryptWecomMessage,
extractEncryptedFromXml,
maskWecomIdentifier,
normalizeApprovalCallbackEvent,
recordCallbackEvidence,
sanitizeErrorMessage,
validateCallbackConfig,
} = require("../services/integrations/wecomCallbackCrypto");
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function callbackConfig(fastify) {
const config = fastify.config || {};
return {
receiveId: config.wecomCorpId,
token: config.wecomCallbackToken,
encodingAesKey: config.wecomCallbackEncodingAesKey,
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
};
}
function sendPlain(reply, statusCode, body) {
return reply.status(statusCode).type("text/plain; charset=utf-8").send(body);
}
function classifyCallbackError(error) {
if (error instanceof WecomCallbackError) {
if (error.code === "invalid_msg_signature") return { statusCode: 403, body: "invalid signature" };
if (error.code === "receive_id_mismatch") return { statusCode: 403, body: "receive id mismatch" };
return { statusCode: 400, body: "invalid callback payload" };
}
return { statusCode: 500, body: "callback error" };
}
function readSignatureQuery(request) {
const query = request.query || {};
return {
signature: toSafeString(query.msg_signature, 120),
timestamp: toSafeString(query.timestamp, 80),
nonce: toSafeString(query.nonce, 120),
};
}
async function wecomApprovalCallbackRoutes(fastify, options = {}) {
fastify.addContentTypeParser(
["text/xml", "application/xml", "application/octet-stream"],
{ parseAs: "string" },
(request, body, done) => done(null, body),
);
fastify.addContentTypeParser(
/^application\/.*\+xml$/i,
{ parseAs: "string" },
(request, body, done) => done(null, body),
);
fastify.get("/wecom/approval/callback", async (request, reply) => {
const runtimeConfig = callbackConfig(fastify);
const configCheck = validateCallbackConfig({
wecomCorpId: runtimeConfig.receiveId,
wecomCallbackToken: runtimeConfig.token,
wecomCallbackEncodingAesKey: runtimeConfig.encodingAesKey,
});
if (!configCheck.ok) {
fastify.log.warn({ missing: configCheck.missing }, "WeCom approval callback config missing");
return sendPlain(reply, 503, "callback config missing");
}
const query = readSignatureQuery(request);
const encrypted = toSafeString(request.query?.echostr, 8192);
try {
assertWecomSignature({
token: runtimeConfig.token,
timestamp: query.timestamp,
nonce: query.nonce,
encrypted,
signature: query.signature,
});
const decrypted = decryptWecomMessage({
encrypted,
encodingAesKey: runtimeConfig.encodingAesKey,
receiveId: runtimeConfig.receiveId,
});
return sendPlain(reply, 200, decrypted.plainXml);
} catch (error) {
const classified = classifyCallbackError(error);
fastify.log.warn({
code: error?.code || "callback_url_verify_failed",
message: sanitizeErrorMessage(error?.message || error),
}, "WeCom approval callback URL verification failed");
return sendPlain(reply, classified.statusCode, classified.body);
}
});
fastify.post("/wecom/approval/callback", async (request, reply) => {
const runtimeConfig = callbackConfig(fastify);
const configCheck = validateCallbackConfig({
wecomCorpId: runtimeConfig.receiveId,
wecomCallbackToken: runtimeConfig.token,
wecomCallbackEncodingAesKey: runtimeConfig.encodingAesKey,
});
if (!configCheck.ok) {
fastify.log.warn({ missing: configCheck.missing }, "WeCom approval callback config missing");
return sendPlain(reply, 503, "callback config missing");
}
const query = readSignatureQuery(request);
const body = toSafeString(request.body, 20000);
try {
const encrypted = extractEncryptedFromXml(body);
assertWecomSignature({
token: runtimeConfig.token,
timestamp: query.timestamp,
nonce: query.nonce,
encrypted,
signature: query.signature,
});
const decrypted = decryptWecomMessage({
encrypted,
encodingAesKey: runtimeConfig.encodingAesKey,
receiveId: runtimeConfig.receiveId,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
const evidenceStore = options.evidenceStore || {
record: (eventSummary) => recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile }),
};
const stored = await evidenceStore.record(summary);
fastify.log.info({
eventType: summary.eventType,
callbackKind: summary.callbackKind,
approvalType: summary.approvalType,
statusLabel: summary.statusLabel,
spNoPrefix: summary.spNoPrefix,
templateIdPrefix: summary.templateIdPrefix,
idempotencyKey: maskWecomIdentifier(summary.idempotencyKey),
duplicate: Boolean(stored?.duplicate),
}, "WeCom approval callback event accepted");
return sendPlain(reply, 200, "success");
} catch (error) {
const classified = classifyCallbackError(error);
fastify.log.warn({
code: error?.code || "callback_event_failed",
message: sanitizeErrorMessage(error?.message || error),
}, "WeCom approval callback event rejected");
return sendPlain(reply, classified.statusCode, classified.body);
}
});
}
wecomApprovalCallbackRoutes.__testables = {
classifyCallbackError,
readSignatureQuery,
};
module.exports = wecomApprovalCallbackRoutes;
@@ -0,0 +1,433 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const {
WecomCallbackError,
buildEncryptedApprovalEventSample,
buildUrlVerificationSample,
decryptWecomMessage,
extractEncryptedFromXml,
maskWecomIdentifier,
normalizeApprovalCallbackEvent,
readCallbackEvidence,
validateCallbackConfig,
verifyWecomSignature,
} = require("../services/integrations/wecomCallbackCrypto");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const SYNTHETIC_TOKEN = "wcbk1-synthetic-token";
const SYNTHETIC_AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
const SYNTHETIC_CORP_ID = "wwsyntheticcallbackcorp";
const SYNTHETIC_SP_NO = "SPWCBK1SYNTHETIC000001";
const SYNTHETIC_TEMPLATE_ID = "TPLWCBK1SYNTHETIC000001";
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function usage() {
return `Usage: node src/scripts/check-wecom-approval-callback-poc.js [options]
Options:
--live 访问已部署 Callback URL 做 URL 验证,并检查真实事件证据。
--format=json 输出 JSON 到 stdout。
--callback-url=<url> 临时覆盖 WECOM_APPROVAL_CALLBACK_URL。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-callback-poc-<timestamp>。
--help 显示帮助。
该 runner 为 QA-only,只输出脱敏摘要,不打印 Token、EncodingAESKey、原始 XML、密文、完整 sp_no 或完整 userid。`;
}
function parseArgs(argv = process.argv.slice(2), env = process.env) {
const options = {
live: false,
format: "text",
callbackUrl: "",
reportDir: env.WECOM_APPROVAL_CALLBACK_POC_REPORT_DIR
|| path.join(DEFAULT_REPORT_ROOT, `wecom-approval-callback-poc-${timestamp()}`),
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--live") {
options.live = true;
} else if (arg === "--format=json" || arg === "--json") {
options.format = "json";
} 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 === "--callback-url") {
options.callbackUrl = argv[index + 1] || "";
index += 1;
} else if (arg.startsWith("--callback-url=")) {
options.callbackUrl = arg.slice("--callback-url=".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 === "--help" || arg === "-h") {
options.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`unsupported format: ${options.format}`);
}
return options;
}
function configured(value) {
return String(value ?? "").trim().length > 0;
}
function maskHostLike(url) {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
} catch {
return null;
}
}
function loadCallbackConfig(env = process.env) {
return {
wecomCorpId: env.WECOM_CORP_ID || "",
wecomApprovalCallbackUrl: env.WECOM_APPROVAL_CALLBACK_URL || "",
wecomCallbackToken: env.WECOM_CALLBACK_TOKEN || "",
wecomCallbackEncodingAesKey: env.WECOM_CALLBACK_ENCODING_AES_KEY || "",
wecomApprovalCallbackEvidenceFile: env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || "",
wecomRequestTimeoutMs: Number(env.WECOM_REQUEST_TIMEOUT_MS || 10000),
};
}
function buildCallbackConfigCheck(config, callbackUrl) {
const missing = [];
if (!configured(config.wecomCorpId)) missing.push("WECOM_CORP_ID");
if (!configured(callbackUrl)) missing.push("WECOM_APPROVAL_CALLBACK_URL");
if (!configured(config.wecomCallbackToken)) missing.push("WECOM_CALLBACK_TOKEN");
if (!configured(config.wecomCallbackEncodingAesKey)) missing.push("WECOM_CALLBACK_ENCODING_AES_KEY");
return {
id: "callback_config",
status: missing.length ? "NEEDS_CONFIG" : "OK",
missing,
configured: {
corpId: configured(config.wecomCorpId),
callbackUrl: configured(callbackUrl),
callbackUrlPreview: configured(callbackUrl) ? maskHostLike(callbackUrl) : null,
token: configured(config.wecomCallbackToken),
encodingAesKey: configured(config.wecomCallbackEncodingAesKey),
},
};
}
function runSyntheticCheck(config = {}) {
const token = configured(config.wecomCallbackToken) ? config.wecomCallbackToken : SYNTHETIC_TOKEN;
const encodingAesKey = configured(config.wecomCallbackEncodingAesKey)
? config.wecomCallbackEncodingAesKey
: SYNTHETIC_AES_KEY;
const receiveId = configured(config.wecomCorpId) ? config.wecomCorpId : SYNTHETIC_CORP_ID;
const timestampValue = "1770000000";
const nonce = "wcbk1-synthetic";
try {
const sample = buildEncryptedApprovalEventSample({
token,
encodingAesKey,
receiveId,
timestamp: timestampValue,
nonce,
spNo: SYNTHETIC_SP_NO,
templateId: SYNTHETIC_TEMPLATE_ID,
});
const encrypted = extractEncryptedFromXml(sample.encryptedXml);
const signatureOk = verifyWecomSignature({
token,
timestamp: timestampValue,
nonce,
encrypted,
signature: sample.signature,
});
const decrypted = decryptWecomMessage({
encrypted,
encodingAesKey,
receiveId,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
return {
id: "synthetic_callback_crypto",
status: signatureOk ? "OK" : "BLOCKED",
signatureOk,
decryptOk: Boolean(decrypted.plainXml),
summary: {
eventType: summary.eventType,
callbackKind: summary.callbackKind,
approvalType: summary.approvalType,
spStatus: summary.spStatus,
statusLabel: summary.statusLabel,
spNoPrefix: summary.spNoPrefix,
templateIdPrefix: summary.templateIdPrefix,
idempotencyKeyPrefix: maskWecomIdentifier(summary.idempotencyKey),
},
};
} catch (error) {
return {
id: "synthetic_callback_crypto",
status: "BLOCKED",
reason: error?.code || "synthetic_callback_failed",
message: error instanceof WecomCallbackError ? error.message : "synthetic callback crypto failed",
};
}
}
async function runUrlVerificationCheck({ config, callbackUrl, fetchImpl = fetch, timeoutMs = 10000 }) {
if (!configured(callbackUrl)) {
return {
id: "url_verification",
status: "NEEDS_CONFIG",
missing: ["WECOM_APPROVAL_CALLBACK_URL"],
};
}
const configCheck = validateCallbackConfig({
wecomCorpId: config.wecomCorpId,
wecomCallbackToken: config.wecomCallbackToken,
wecomCallbackEncodingAesKey: config.wecomCallbackEncodingAesKey,
});
if (!configCheck.ok) {
return {
id: "url_verification",
status: "NEEDS_CONFIG",
missing: configCheck.missing,
};
}
try {
const sample = buildUrlVerificationSample({
token: config.wecomCallbackToken,
encodingAesKey: config.wecomCallbackEncodingAesKey,
receiveId: config.wecomCorpId,
timestamp: String(Math.floor(Date.now() / 1000)),
nonce: "wcbk1-live-url",
plainText: "wcbk1-url-verify-ok",
});
const url = new URL(callbackUrl);
url.searchParams.set("msg_signature", sample.signature);
url.searchParams.set("timestamp", sample.timestamp);
url.searchParams.set("nonce", sample.nonce);
url.searchParams.set("echostr", sample.encrypted);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetchImpl(url, { method: "GET", signal: controller.signal });
clearTimeout(timer);
const body = await response.text();
const matched = response.ok && body.trim() === sample.plainText;
return {
id: "url_verification",
status: matched ? "OK" : "BLOCKED",
httpStatus: response.status,
callbackUrlPreview: maskHostLike(callbackUrl),
matched,
reason: matched ? null : "callback_url_verify_failed",
};
} catch (error) {
return {
id: "url_verification",
status: "BLOCKED",
callbackUrlPreview: maskHostLike(callbackUrl),
reason: error?.name === "AbortError" ? "callback_url_timeout" : "callback_url_network_error",
};
}
}
async function buildEvidenceCheck(config = {}) {
const events = await readCallbackEvidence({ evidenceFile: config.wecomApprovalCallbackEvidenceFile });
const approvalEvents = events.filter((item) => item.callbackKind === "approval_status_change");
const lastEvent = approvalEvents[approvalEvents.length - 1] || null;
return {
id: "callback_event_evidence",
status: approvalEvents.length ? "OK" : "PENDING",
eventCount: approvalEvents.length,
lastEvent: lastEvent
? {
eventType: lastEvent.eventType,
approvalType: lastEvent.approvalType,
spStatus: lastEvent.spStatus,
statusLabel: lastEvent.statusLabel,
spNoPrefix: lastEvent.spNoPrefix,
templateIdPrefix: lastEvent.templateIdPrefix,
recordedAt: lastEvent.recordedAt,
idempotencyKeyPrefix: maskWecomIdentifier(lastEvent.idempotencyKey),
}
: null,
};
}
function evaluateConclusion({ configCheck, syntheticCheck, urlCheck, evidenceCheck, live }) {
if (syntheticCheck.status !== "OK") {
return {
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
conclusion: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
mainBlockers: [{ id: syntheticCheck.id, reason: syntheticCheck.reason || "synthetic_callback_failed" }],
};
}
if (configCheck.status !== "OK") {
return {
status: "PARTIAL_CALLBACK_NEEDS_CONFIG",
conclusion: "PARTIAL_CALLBACK_NEEDS_CONFIG",
configGaps: configCheck.missing.map((name) => ({ name, status: "NEEDS_CONFIG" })),
mainBlockers: [],
};
}
if (live && urlCheck?.status === "BLOCKED") {
return {
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
conclusion: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
mainBlockers: [{ id: urlCheck.id, reason: urlCheck.reason || "callback_url_verify_failed" }],
};
}
if (evidenceCheck?.status === "OK") {
return {
status: "PASS_CALLBACK_EVENT_CONFIRMED",
conclusion: "PASS_CALLBACK_EVENT_CONFIRMED",
mainBlockers: [],
};
}
if (live && urlCheck?.status === "OK") {
return {
status: "PASS_CALLBACK_URL_VERIFIED",
conclusion: "PASS_CALLBACK_URL_VERIFIED",
mainBlockers: [],
};
}
return {
status: "PASS_CALLBACK_SYNTHETIC_READY",
conclusion: "PASS_CALLBACK_SYNTHETIC_READY",
mainBlockers: [],
};
}
async function executeCallbackPoc(options = {}, deps = {}) {
const config = deps.config || loadCallbackConfig(deps.env);
const callbackUrl = options.callbackUrl || config.wecomApprovalCallbackUrl || "";
const syntheticCheck = runSyntheticCheck(config);
const configCheck = buildCallbackConfigCheck(config, callbackUrl);
const urlCheck = options.live
? await runUrlVerificationCheck({
config,
callbackUrl,
fetchImpl: deps.fetchImpl || fetch,
timeoutMs: config.wecomRequestTimeoutMs || 10000,
})
: {
id: "url_verification",
status: configCheck.status === "OK" ? "PENDING" : "NEEDS_CONFIG",
};
const evidenceCheck = await buildEvidenceCheck(config);
const conclusion = evaluateConclusion({
configCheck,
syntheticCheck,
urlCheck,
evidenceCheck,
live: Boolean(options.live),
});
return {
status: conclusion.status,
conclusion: conclusion.conclusion,
generatedAt: new Date().toISOString(),
scope: {
targets: ["leave", "missed_punch"],
live: Boolean(options.live),
writeBehavior: "read_only_callback_poc",
callbackUrlPreview: configured(callbackUrl) ? maskHostLike(callbackUrl) : null,
},
privacy: "redacted_no_token_no_secret_no_raw_xml_no_ciphertext_no_full_sp_no_no_full_userid_no_raw_payload",
checks: {
config: configCheck,
synthetic: syntheticCheck,
urlVerification: urlCheck,
eventEvidence: evidenceCheck,
},
configGaps: conclusion.configGaps || [],
mainBlockers: conclusion.mainBlockers || [],
nextManualAction: conclusion.status === "PASS_CALLBACK_URL_VERIFIED"
? "请由 SMOKE101 在企业微信处理一条现有 OA-POC 测试审批单,然后复跑 live runner。"
: null,
};
}
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`);
return filePath;
}
async function main() {
let options;
try {
options = parseArgs();
} catch (error) {
process.stderr.write(`${error.message}\n${usage()}\n`);
process.exit(2);
}
if (options.help) {
process.stdout.write(`${usage()}\n`);
return;
}
try {
const report = await executeCallbackPoc(options);
const reportPath = writeReport(options.reportDir, report);
report.reportPath = reportPath;
if (options.format === "json") {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} else {
process.stdout.write(`[wecom-approval-callback-poc] status=${report.status} conclusion=${report.conclusion}\n`);
process.stdout.write(`[wecom-approval-callback-poc] report=${reportPath}\n`);
process.stdout.write(`[wecom-approval-callback-poc] privacy=${report.privacy}\n`);
if (report.configGaps.length) {
process.stdout.write(`[wecom-approval-callback-poc] configGaps=${JSON.stringify(report.configGaps)}\n`);
}
if (report.mainBlockers.length) {
process.stdout.write(`[wecom-approval-callback-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
}
}
if (report.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
process.exitCode = 1;
}
} catch (error) {
process.stderr.write(`[wecom-approval-callback-poc][FAIL] ${error?.message || error}\n`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
buildCallbackConfigCheck,
buildEvidenceCheck,
evaluateConclusion,
executeCallbackPoc,
loadCallbackConfig,
parseArgs,
runSyntheticCheck,
runUrlVerificationCheck,
writeReport,
};
@@ -296,7 +296,7 @@ function buildApprovalConfigChecks(env = process.env) {
{
id: "approval_callback_url",
label: "真实回调 URL",
env: ["WECOM_APPROVAL_CALLBACK_URL", "WECOM_CALLBACK_URL"],
env: ["WECOM_APPROVAL_CALLBACK_URL"],
mask: maskHostLike,
},
{
@@ -307,7 +307,7 @@ function buildApprovalConfigChecks(env = process.env) {
{
id: "callback_encoding_aes_key",
label: "回调 EncodingAESKey",
env: ["WECOM_CALLBACK_ENCODING_AES_KEY", "WECOM_CALLBACK_AES_KEY"],
env: ["WECOM_CALLBACK_ENCODING_AES_KEY"],
},
];
@@ -0,0 +1,456 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const path = require("node:path");
const { XMLParser, XMLBuilder } = require("fast-xml-parser");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../../artifacts/qa-flow");
const DEFAULT_EVIDENCE_FILE = path.join(
DEFAULT_REPORT_ROOT,
"wecom-approval-callback-events",
"events.jsonl",
);
const CALLBACK_EVENT_TYPES = new Set(["open_approval_change", "sys_approval_change"]);
class WecomCallbackError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = "WecomCallbackError";
this.code = code;
this.details = details;
}
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
parseTagValue: false,
parseAttributeValue: false,
trimValues: true,
});
const xmlBuilder = new XMLBuilder({
ignoreAttributes: false,
suppressEmptyNode: false,
});
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function sanitizeErrorMessage(value) {
return toSafeString(value, 500)
.replace(/(msg_signature=)[^&\s]+/gi, "$1***")
.replace(/(echostr=)[^&\s]+/gi, "$1***")
.replace(/[A-Za-z0-9+/=_-]{32,}/g, "***")
.slice(0, 240);
}
function maskWecomIdentifier(value) {
const raw = toSafeString(value, 160);
if (!raw) return null;
if (raw.length <= 6) return `${raw.slice(0, 2)}***`;
return `${raw.slice(0, Math.min(8, raw.length))}***`;
}
function normalizeEncodingAesKey(encodingAesKey) {
const raw = toSafeString(encodingAesKey, 80);
if (!raw) {
throw new WecomCallbackError("missing_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY is required");
}
if (raw.length !== 43) {
throw new WecomCallbackError("invalid_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY must be 43 characters");
}
const key = Buffer.from(`${raw}=`, "base64");
if (key.length !== 32) {
throw new WecomCallbackError("invalid_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY decoded length is invalid");
}
return key;
}
function createWecomSignature({ token, timestamp, nonce, encrypted }) {
const parts = [token, timestamp, nonce, encrypted].map((item) => toSafeString(item, 4096));
if (parts.some((item) => !item)) {
throw new WecomCallbackError("missing_signature_input", "Token, timestamp, nonce and encrypted payload are required");
}
return crypto.createHash("sha1").update(parts.sort().join("")).digest("hex");
}
function timingSafeEqualHex(left, right) {
const a = Buffer.from(toSafeString(left, 80), "utf8");
const b = Buffer.from(toSafeString(right, 80), "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyWecomSignature({ token, timestamp, nonce, encrypted, signature }) {
const expected = createWecomSignature({ token, timestamp, nonce, encrypted });
return timingSafeEqualHex(expected, signature);
}
function assertWecomSignature(input) {
if (!verifyWecomSignature(input)) {
throw new WecomCallbackError("invalid_msg_signature", "WeCom callback signature mismatch");
}
}
function pkcs7Pad(buffer, blockSize = 32) {
const padding = blockSize - (buffer.length % blockSize || blockSize);
const padLength = padding === 0 ? blockSize : padding;
return Buffer.concat([buffer, Buffer.alloc(padLength, padLength)]);
}
function pkcs7Unpad(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted payload is empty");
}
const padLength = buffer[buffer.length - 1];
if (padLength < 1 || padLength > 32 || padLength > buffer.length) {
throw new WecomCallbackError("invalid_decrypted_padding", "Invalid WeCom callback padding");
}
return buffer.subarray(0, buffer.length - padLength);
}
function decryptWecomMessage({ encrypted, encodingAesKey, receiveId }) {
const safeEncrypted = toSafeString(encrypted, 8192);
if (!safeEncrypted) {
throw new WecomCallbackError("missing_encrypted_payload", "Encrypted callback payload is required");
}
const key = normalizeEncodingAesKey(encodingAesKey);
let encryptedBuffer;
try {
encryptedBuffer = Buffer.from(safeEncrypted, "base64");
} catch (error) {
throw new WecomCallbackError("invalid_encrypted_payload", "Encrypted callback payload is not valid base64");
}
try {
const decipher = crypto.createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
const plain = pkcs7Unpad(decrypted);
if (plain.length < 20) {
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted callback payload is too short");
}
const messageLength = plain.readUInt32BE(16);
const messageStart = 20;
const messageEnd = messageStart + messageLength;
if (messageLength < 0 || messageEnd > plain.length) {
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted callback XML length is invalid");
}
const plainXml = plain.subarray(messageStart, messageEnd).toString("utf8");
const decryptedReceiveId = plain.subarray(messageEnd).toString("utf8");
const expectedReceiveId = toSafeString(receiveId, 160);
if (expectedReceiveId && decryptedReceiveId !== expectedReceiveId) {
throw new WecomCallbackError("receive_id_mismatch", "WeCom callback receiveId mismatch", {
receiveIdPrefix: maskWecomIdentifier(decryptedReceiveId),
});
}
return {
plainXml,
receiveId: decryptedReceiveId,
receiveIdPrefix: maskWecomIdentifier(decryptedReceiveId),
};
} catch (error) {
if (error instanceof WecomCallbackError) throw error;
throw new WecomCallbackError("decrypt_failed", sanitizeErrorMessage(error?.message || "WeCom callback decrypt failed"));
}
}
function encryptWecomMessage({ plainXml, encodingAesKey, receiveId, randomBytes }) {
const key = normalizeEncodingAesKey(encodingAesKey);
const xmlBuffer = Buffer.from(toSafeString(plainXml, 20000), "utf8");
const lengthBuffer = Buffer.alloc(4);
lengthBuffer.writeUInt32BE(xmlBuffer.length, 0);
const random = randomBytes ? Buffer.from(randomBytes).subarray(0, 16) : crypto.randomBytes(16);
if (random.length !== 16) {
throw new WecomCallbackError("invalid_random_bytes", "WeCom encrypted message needs 16 random bytes");
}
const plain = pkcs7Pad(Buffer.concat([
random,
lengthBuffer,
xmlBuffer,
Buffer.from(toSafeString(receiveId, 160), "utf8"),
]));
const cipher = crypto.createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
cipher.setAutoPadding(false);
return Buffer.concat([cipher.update(plain), cipher.final()]).toString("base64");
}
function parseWecomXml(xml) {
const safeXml = toSafeString(xml, 20000);
if (!safeXml) {
throw new WecomCallbackError("missing_xml", "WeCom callback XML is empty");
}
try {
const parsed = xmlParser.parse(safeXml);
const root = parsed?.xml && typeof parsed.xml === "object" ? parsed.xml : parsed;
if (!root || typeof root !== "object") {
throw new Error("missing xml root");
}
return root;
} catch (error) {
throw new WecomCallbackError("invalid_xml", sanitizeErrorMessage(error?.message || "WeCom callback XML parse failed"));
}
}
function buildEncryptedXml(encrypted) {
return xmlBuilder.build({ xml: { Encrypt: toSafeString(encrypted, 8192) } });
}
function extractEncryptedFromXml(xml) {
const root = parseWecomXml(xml);
const encrypted = toSafeString(root.Encrypt, 8192);
if (!encrypted) {
throw new WecomCallbackError("missing_encrypt_node", "WeCom callback XML missing Encrypt node");
}
return encrypted;
}
function pickText(source, names) {
if (!source || typeof source !== "object") return "";
for (const name of names) {
const value = source[name];
if (value == null) continue;
if (typeof value === "object") {
const nested = pickText(value, ["UserId", "userid", "Userid", "Name", "name", "#text"]);
if (nested) return nested;
} else {
const text = toSafeString(value, 300);
if (text) return text;
}
}
return "";
}
function inferApprovalType({ spName, templateId }) {
const text = `${spName || ""} ${templateId || ""}`;
if (/请假|leave/i.test(text)) return "leave";
if (/补卡|missed|punch|打卡/i.test(text)) return "missed_punch";
return "unknown";
}
function statusLabel(status) {
const text = toSafeString(status, 40);
const labels = {
"1": "审批中",
"2": "已通过",
"3": "已驳回",
"4": "已撤销",
"6": "通过后撤销",
"7": "已删除",
"10": "已支付",
};
return labels[text] || text || null;
}
function buildIdempotencyKey(parts) {
return crypto
.createHash("sha256")
.update(parts.map((item) => toSafeString(item, 300)).join("|"))
.digest("hex")
.slice(0, 32);
}
function normalizeApprovalCallbackEvent(plainXml) {
const root = parseWecomXml(plainXml);
const eventType = pickText(root, ["Event", "event"]);
const msgType = pickText(root, ["MsgType", "msgtype"]);
const approvalInfo = root.ApprovalInfo && typeof root.ApprovalInfo === "object"
? root.ApprovalInfo
: {};
const spNo = pickText(approvalInfo, ["SpNo", "sp_no", "SpNO", "Spno"]);
const templateId = pickText(approvalInfo, ["TemplateId", "TemplateID", "template_id"]);
const spName = pickText(approvalInfo, ["SpName", "sp_name", "TemplateName"]);
const spStatus = pickText(approvalInfo, ["SpStatus", "sp_status", "Status"]);
const createTime = pickText(root, ["CreateTime", "create_time"]);
const applyTime = pickText(approvalInfo, ["ApplyTime", "apply_time"]);
const agentId = pickText(root, ["AgentID", "AgentId", "agentid"]);
const approvalType = inferApprovalType({ spName, templateId });
const idempotencyKey = buildIdempotencyKey([
eventType,
spNo,
templateId,
spStatus,
createTime,
applyTime,
]);
return {
eventType: eventType || "unknown",
msgType: msgType || "unknown",
approvalType,
spStatus: spStatus || null,
statusLabel: statusLabel(spStatus),
spNoPrefix: maskWecomIdentifier(spNo),
templateIdPrefix: maskWecomIdentifier(templateId),
createTime: createTime || null,
applyTime: applyTime || null,
agentIdPrefix: maskWecomIdentifier(agentId),
callbackKind: CALLBACK_EVENT_TYPES.has(eventType) ? "approval_status_change" : "unknown_event",
idempotencyKey,
};
}
function validateCallbackConfig(config) {
const missing = [];
if (!toSafeString(config?.wecomCorpId, 160)) missing.push("WECOM_CORP_ID");
if (!toSafeString(config?.wecomCallbackToken, 300)) missing.push("WECOM_CALLBACK_TOKEN");
if (!toSafeString(config?.wecomCallbackEncodingAesKey, 80)) missing.push("WECOM_CALLBACK_ENCODING_AES_KEY");
return {
ok: missing.length === 0,
missing,
};
}
function buildUrlVerificationSample({ token, encodingAesKey, receiveId, timestamp, nonce, plainText }) {
const encrypted = encryptWecomMessage({
plainXml: plainText,
encodingAesKey,
receiveId,
randomBytes: Buffer.from("0123456789abcdef"),
});
const signature = createWecomSignature({ token, timestamp, nonce, encrypted });
return {
encrypted,
signature,
timestamp,
nonce,
plainText,
};
}
function buildEncryptedApprovalEventSample({ token, encodingAesKey, receiveId, timestamp, nonce, spNo, templateId }) {
const plainXml = xmlBuilder.build({
xml: {
ToUserName: receiveId,
FromUserName: "sys",
CreateTime: timestamp,
MsgType: "event",
Event: "open_approval_change",
AgentID: "1000002",
ApprovalInfo: {
SpNo: spNo,
SpName: "OA-POC 请假",
SpStatus: "2",
TemplateId: templateId,
ApplyTime: timestamp,
},
},
});
const encrypted = encryptWecomMessage({
plainXml,
encodingAesKey,
receiveId,
randomBytes: Buffer.from("fedcba9876543210"),
});
return {
plainXml,
encryptedXml: buildEncryptedXml(encrypted),
encrypted,
signature: createWecomSignature({ token, timestamp, nonce, encrypted }),
timestamp,
nonce,
};
}
async function recordCallbackEvidence(summary, options = {}) {
const evidenceFile = options.evidenceFile || process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || DEFAULT_EVIDENCE_FILE;
await fs.mkdir(path.dirname(evidenceFile), { recursive: true });
const idempotencyKey = toSafeString(summary?.idempotencyKey, 80);
let existingKeys = new Set();
try {
const content = await fs.readFile(evidenceFile, "utf8");
existingKeys = new Set(content
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line).idempotencyKey;
} catch {
return null;
}
})
.filter(Boolean));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (idempotencyKey && existingKeys.has(idempotencyKey)) {
return {
recorded: false,
duplicate: true,
evidenceFile,
idempotencyKey,
};
}
const event = {
recordedAt: new Date().toISOString(),
eventType: summary.eventType,
msgType: summary.msgType,
callbackKind: summary.callbackKind,
approvalType: summary.approvalType,
spStatus: summary.spStatus,
statusLabel: summary.statusLabel,
spNoPrefix: summary.spNoPrefix,
templateIdPrefix: summary.templateIdPrefix,
createTime: summary.createTime,
applyTime: summary.applyTime,
agentIdPrefix: summary.agentIdPrefix,
idempotencyKey,
};
await fs.appendFile(evidenceFile, `${JSON.stringify(event)}\n`, "utf8");
return {
recorded: true,
duplicate: false,
evidenceFile,
idempotencyKey,
};
}
async function readCallbackEvidence(options = {}) {
const evidenceFile = options.evidenceFile || process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || DEFAULT_EVIDENCE_FILE;
try {
const content = await fs.readFile(evidenceFile, "utf8");
return content
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
}
module.exports = {
CALLBACK_EVENT_TYPES,
DEFAULT_EVIDENCE_FILE,
WecomCallbackError,
assertWecomSignature,
buildEncryptedApprovalEventSample,
buildEncryptedXml,
buildUrlVerificationSample,
createWecomSignature,
decryptWecomMessage,
encryptWecomMessage,
extractEncryptedFromXml,
maskWecomIdentifier,
normalizeApprovalCallbackEvent,
parseWecomXml,
readCallbackEvidence,
recordCallbackEvidence,
sanitizeErrorMessage,
validateCallbackConfig,
verifyWecomSignature,
};