WCBK1 实现企微审批回调只读 POC
这个提交包含在:
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户