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

这个提交包含在:
2026-05-19 15:00:22 +08:00
父节点 a32ec1e658
当前提交 8122898348
共修改 30 个文件,包含 1650 行新增和 53 行删除
@@ -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,
};