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

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