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;