文件
hyb-oa/server/src/routes/wecomApprovalCallback.js
T

178 行
6.2 KiB
JavaScript

const {
WecomCallbackError,
assertWecomSignature,
decryptWecomMessage,
extractEncryptedFromXml,
maskWecomIdentifier,
normalizeApprovalCallbackEvent,
recordCallbackEvidence,
sanitizeErrorMessage,
validateCallbackConfig,
} = require("../services/integrations/wecomCallbackCrypto");
const { recordApprovalCallbackEvent } = require("../services/integrations/wecomApprovalArchive");
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: async (eventSummary) => {
let stored = null;
if (fastify.prisma) {
stored = await recordApprovalCallbackEvent({
prisma: fastify.prisma,
config: {
wecomCorpId: runtimeConfig.receiveId,
},
summary: eventSummary,
});
}
if (runtimeConfig.evidenceFile || !fastify.prisma) {
await recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile });
}
return stored || { recorded: true, duplicate: false };
},
};
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;