一、启用API接入1、开启使用在【开发配置】页面顶部点击【开始使用】API接入。// 后台链接 urlhttps://kf.weixin.qq.com/kf/frame#/config2、填写回调地址并设置秘钥回调地址我测试了不用https域名直接用【ip地址端口】的回调也可以通过反正速通测试而已秘钥和token随机生成就好了自己保存下就可以了。3、代码验证1、新建\.env文件存储参数配置前两个参数就是前面随机生成的token和秘钥第三个参数在【企业信息】页面如下图# 配置 # 从官方后台获取 # 回调 URL 验证 Token WECHAT_TOKEN6********cz # 消息加解密密钥 (43位字符) WECHAT_ENCODING_AES_KEYY4************************************XAOSu # 企业 ID WECHAT_CORP_IDw***********8af # 服务器配置 PORT3000 # 日志配置 LOG_LEVELdebug LOG_DIR NODE_ENV2、核心验证文件\src\utils\wxCrypt.jsconst crypto require(crypto); const { parseString } require(xml2js); /** * 错误码定义 */ const ErrorCode { ValidateSignatureError: -40001, ParseXmlError: -40002, ComputeSignatureError: -40003, IllegalAesKey: -40004, ValidateCorpidError: -40005, EncryptAESError: -40006, DecryptAESError: -40007, IllegalBuffer: -40008, EncodeBase64Error: -40009, DecodeBase64Error: -40010, }; /** * 微信消息加解密核心类 * 基于微信官方 WXBizMsgCrypt 逻辑实现 */ class WXBizMsgCrypt { /** * 构造函数 * param {string} token - 微信后台配置的 Token * param {string} encodingAesKey - 微信后台配置的 EncodingAESKey * param {string} receiverId - 企业 CorpID */ constructor(token, encodingAesKey, receiverId) { this.token token; // 微信 EncodingAESKey 为43位字符补齐后进行Base64解码取前32字节作为AES密钥 this.encodingAesKey encodingAesKey ; const aesKeyBuffer Buffer.from(this.encodingAesKey, base64); this.aesKey aesKeyBuffer.slice(0, 32); this.receiverId receiverId; } /** * 计算签名 * param {string} timestamp - 时间戳 * param {string} nonce - 随机数 * param {string} data - 待签名数据 * returns {string} SHA1 签名 */ calSignature(timestamp, nonce, data) { const sortArr [this.token, timestamp, nonce, data].sort(); const sha1 crypto.createHash(sha1); sha1.update(sortArr.join()); return sha1.digest(hex); } /** * PKCS#7 填充 * param {string} plaintext - 明文 * param {number} blockSize - 块大小 * returns {Buffer} 填充后的明文 */ pkcs7Padding(plaintext, blockSize) { const padding blockSize - (Buffer.byteLength(plaintext) % blockSize); const padText Buffer.alloc(padding, padding); return Buffer.concat([Buffer.from(plaintext, utf8), padText]); } /** * PKCS#7 去填充 * param {Buffer} plaintext - 填充后的明文 * param {number} blockSize - 块大小 * returns {Buffer} 去填充后的明文 */ pkcs7Unpadding(plaintext, blockSize) { const plaintextLen plaintext.length; if (plaintextLen 0 || plaintextLen % blockSize ! 0) { throw { code: ErrorCode.DecryptAESError, msg: Invalid padding }; } const paddingLen plaintext[plaintextLen - 1]; if (paddingLen 1 || paddingLen blockSize) { throw { code: ErrorCode.DecryptAESError, msg: Invalid padding length }; } return plaintext.slice(0, plaintextLen - paddingLen); } /** * AES-CBC 加密 * param {string} plaintext - 明文 * returns {string} Base64 编码的密文 */ cbcEncrypt(plaintext) { try { const aesKey Buffer.from(this.encodingAesKey, base64); // 确保密钥为32字节 (AES-256) const key aesKey.slice(0, 32); const blockSize 32; const padMsg this.pkcs7Padding(plaintext, blockSize); // 使用密钥的前16字节作为IV这是微信官方的实现 const iv key.slice(0, 16); const cipher crypto.createCipheriv(aes-256-cbc, key, iv); cipher.setAutoPadding(false); const ciphertext Buffer.concat([cipher.update(padMsg), cipher.final()]); return ciphertext.toString(base64); } catch (err) { throw { code: ErrorCode.EncryptAESError, msg: err.message }; } } /** * AES-CBC 解密 * param {string} base64EncryptMsg - Base64 编码的密文 * returns {Buffer} 解密后的明文 */ cbcDecrypt(base64EncryptMsg) { try { const aesKey Buffer.from(this.encodingAesKey, base64); // 确保密钥为32字节 (AES-256) const key aesKey.slice(0, 32); const encryptMsg Buffer.from(base64EncryptMsg, base64); if (encryptMsg.length 16) { throw { code: ErrorCode.DecryptAESError, msg: Encrypted message too short }; } // 使用密钥的前16字节作为IV const iv key.slice(0, 16); const decipher crypto.createDecipheriv(aes-256-cbc, key, iv); decipher.setAutoPadding(false); const decrypted Buffer.concat([decipher.update(encryptMsg), decipher.final()]); // 返回原始解密数据包含 padding return decrypted; } catch (err) { if (err.code) throw err; throw { code: ErrorCode.DecryptAESError, msg: err.message }; } } /** * 解析解密后的明文结构 * param {Buffer} plaintext - 解密后的明文 * returns {object} 包含 random, msgLen, msg, receiverId */ parsePlainText(plaintext) { const blockSize 32; const unpadded this.pkcs7Unpadding(plaintext, blockSize); if (unpadded.length 20) { throw { code: ErrorCode.IllegalBuffer, msg: Plaintext too short }; } const random unpadded.slice(0, 16); const msgLen unpadded.readUInt32BE(16); if (unpadded.length 20 msgLen) { throw { code: ErrorCode.IllegalBuffer, msg: Plaintext invalid length }; } const msg unpadded.slice(20, 20 msgLen).toString(utf8); const receiverId unpadded.slice(20 msgLen).toString(utf8); return { random, msgLen, msg, receiverId }; } /** * 验证 URL 签名并解密 echostr * param {string} msgSignature - 消息签名 * param {string} timestamp - 时间戳 * param {string} nonce - 随机数 * param {string} echostr - 加密的 echo 字符串 * returns {string} 解密后的 echostr */ verifyURL(msgSignature, timestamp, nonce, echostr) { const signature this.calSignature(timestamp, nonce, echostr); if (signature ! msgSignature) { throw { code: ErrorCode.ValidateSignatureError, msg: 签名验证失败: 计算值${signature}, 期望值${msgSignature} }; } const plaintext this.cbcDecrypt(echostr); const parsed this.parsePlainText(plaintext); if (this.receiverId parsed.receiverId ! this.receiverId) { throw { code: ErrorCode.ValidateCorpidError, msg: CorpID不匹配: 配置${this.receiverId}, 实际${parsed.receiverId} }; } return parsed.msg; } /** * 解密消息 * param {string} msgSignature - 消息签名 * param {string} timestamp - 时间戳 * param {string} nonce - 随机数 * param {string} postData - XML 请求体 * returns {object} 解密后的消息内容 */ decryptMsg(msgSignature, timestamp, nonce, postData) { return new Promise((resolve, reject) { parseString(postData, (err, parsed) { if (err) { reject({ code: ErrorCode.ParseXmlError, msg: XML解析失败 }); return; } try { const encrypt parsed.xml.Encrypt[0]; const signature this.calSignature(timestamp, nonce, encrypt); if (signature ! msgSignature) { reject({ code: ErrorCode.ValidateSignatureError, msg: 签名验证失败: 计算值${signature}, 期望值${msgSignature} }); return; } const plaintext this.cbcDecrypt(encrypt); const parsedText this.parsePlainText(plaintext); if (this.receiverId parsedText.receiverId ! this.receiverId) { reject({ code: ErrorCode.ValidateCorpidError, msg: Receiver ID mismatch }); return; } resolve(JSON.parse(parsedText.msg)); } catch (innerErr) { reject(innerErr); } }); }); } /** * 加密消息 (用于回复微信) * param {string} replyMsg - 回复消息内容 * param {string} timestamp - 时间戳 * param {string} nonce - 随机数 * returns {string} 加密后的 XML 字符串 */ encryptMsg(replyMsg, timestamp, nonce) { const xml2js require(xml2js); const randomStr crypto.randomBytes(16).toString(binary); const msgBuffer Buffer.from(replyMsg, utf8); const msgLenBuffer Buffer.alloc(4); msgLenBuffer.writeUInt32BE(msgBuffer.length, 0); const plaintext Buffer.concat([ Buffer.from(randomStr, binary), msgLenBuffer, msgBuffer, Buffer.from(this.receiverId, utf8) ]); const ciphertext this.cbcEncrypt(plaintext.toString(binary)); const signature this.calSignature(timestamp, nonce, ciphertext); const msg4Send { xml: { Encrypt: { _: ciphertext }, MsgSignature: signature, TimeStamp: timestamp, Nonce: { _: nonce } } }; const builder new xml2js.Builder({ cdata: true }); return builder.buildObject(msg4Send); } } module.exports { WXBizMsgCrypt, ErrorCode };3)、其他相关文件主启动文件\src\app.jsrequire(dotenv).config(); const express require(express); const wechatRoutes require(./routes/wechat); const logger require(./utils/logger); const app express(); const PORT process.env.PORT || 3000; /** * 中间件配置 */ app.use(express.json({ limit: 2mb })); app.use(express.urlencoded({ extended: true, limit: 2mb })); /** * HTTP 请求日志中间件 */ app.use((req, res, next) { const start Date.now(); res.on(finish, () { const duration Date.now() - start; logger.info(${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms, { method: req.method, url: req.originalUrl, status: res.statusCode, duration, ip: req.ip }); }); next(); }); /** * 路由配置 */ app.use(/api/wechat, wechatRoutes); /** * 健康检查接口 */ app.get(/health, (req, res) { res.json({ status: ok, timestamp: new Date().toISOString() }); }); /** * 错误处理中间件 */ app.use((err, req, res, next) { logger.error(未处理的错误, { error: err.message, stack: err.stack, url: req.originalUrl }); res.status(500).json({ error: Internal Server Error }); }); /** * 启动服务器 */ app.listen(PORT, () { logger.info(微信客服 API 服务已启动: http://localhost:${PORT}); logger.info(回调URL: http://localhost:${PORT}/api/wechat/callback); }); module.exports app;路由文件\src\routes\wechat.jsconst express require(express); const { WXBizMsgCrypt, ErrorCode } require(../utils/wxCrypt); const logger require(../utils/logger); /** * 获取错误码对应的提示信息 * param {number} code - 错误码 * returns {string} 错误提示 */ function getErrorHint(code) { const hints { [-40001]: 签名验证失败: 检查Token是否正确, [-40002]: XML解析失败: 检查请求体格式, [-40004]: 非法AES密钥: 检查EncodingAESKey是否正确, [-40005]: CorpID不匹配: 检查CorpID是否与微信后台一致, [-40007]: AES解密失败: 检查EncodingAESKey是否正确, [-40008]: 非法缓冲区: 解密数据格式异常 }; return hints[code] || 未知错误; } const router express.Router(); /** * 获取微信加解密实例 * returns {WXBizMsgCrypt} */ function getWXBizMsgCrypt() { return new WXBizMsgCrypt( process.env.WECHAT_TOKEN, process.env.WECHAT_ENCODING_AES_KEY, process.env.WECHAT_CORP_ID ); } /** * GET 请求 - URL 验证接口 * 微信客服后台配置回调URL时微信会发送GET请求进行验证 */ router.get(/callback, (req, res) { const { msg_signature, timestamp, nonce, echostr } req.query; logger.info(收到URL验证请求, { timestamp, nonce }); if (!msg_signature || !timestamp || !nonce || !echostr) { logger.warn(URL验证参数缺失, { msg_signature: !!msg_signature, timestamp: !!timestamp, nonce: !!nonce, echostr: !!echostr }); return res.status(400).send(Missing required parameters); } try { const wxcpt getWXBizMsgCrypt(); const echostr_decrypted wxcpt.verifyURL(msg_signature, timestamp, nonce, echostr); logger.info(URL验证成功, { timestamp, nonce }); // 必须原样返回解密后的 echostr res.send(echostr_decrypted); } catch (err) { const errorMsg err.msg || err.message || err; logger.error(URL验证失败, { error: errorMsg, code: err.code, timestamp, nonce, hint: getErrorHint(err.code) }); res.status(500).send(Verification failed); } }); /** * POST 请求 - 接收消息接口 * 微信客服发送消息事件时会通过POST请求推送数据 */ router.post(/callback, express.text({ type: */xml }), async (req, res) { const { msg_signature, timestamp, nonce } req.query; logger.info(收到消息回调请求, { timestamp, nonce }); if (!msg_signature || !timestamp || !nonce) { logger.warn(消息回调参数缺失, { msg_signature: !!msg_signature, timestamp: !!timestamp, nonce: !!nonce }); return res.status(400).send(Missing required parameters); } try { const wxcpt getWXBizMsgCrypt(); const msg await wxcpt.decryptMsg(msg_signature, timestamp, nonce, req.body); logger.info(消息解密成功, { eventType: msg.EventType, msgId: msg.MsgId, timestamp, nonce }); // 根据 msg.EventType 判断事件类型 (kf_msg_or_event, enter_tempsession 等) switch (msg.EventType) { case kf_msg_or_event: logger.info(客户消息事件, { msg }); break; case enter_tempsession: logger.info(用户进入会话事件, { msg }); break; default: logger.warn(未知事件类型, { eventType: msg.EventType, msg }); } // 必须返回success表示接收成功 res.send(success); } catch (err) { const errorMsg err.msg || err.message || err; logger.error(消息处理失败, { error: errorMsg, code: err.code, timestamp, nonce, hint: getErrorHint(err.code) }); res.status(500).send(Processing failed); } }); module.exports router;日志文件\src\utils\logger.jsconst winston require(winston); const DailyRotateFile require(winston-daily-rotate-file); const path require(path); const LOG_DIR process.env.LOG_DIR || path.join(__dirname, ../../logs); /** * 日志格式定义 */ const logFormat winston.format.combine( winston.format.timestamp({ format: YYYY-MM-DD HH:mm:ss }), winston.format.errors({ stack: true }), winston.format.printf(({ timestamp, level, message, stack, ...meta }) { const metaStr Object.keys(meta).length ? ${JSON.stringify(meta)} : ; if (stack) { return [${timestamp}] [${level.toUpperCase()}] ${message}\n${stack}${metaStr}; } return [${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}; }) ); /** * 控制台输出格式带颜色 */ const consoleFormat winston.format.combine( winston.format.timestamp({ format: YYYY-MM-DD HH:mm:ss }), winston.format.colorize(), winston.format.printf(({ timestamp, level, message, stack }) { if (stack) { return [${timestamp}] ${level}: ${message}\n${stack}; } return [${timestamp}] ${level}: ${message}; }) ); /** * 按日期轮转的文件传输配置 */ const fileTransport new DailyRotateFile({ dirname: LOG_DIR, filename: app-%DATE%.log, datePattern: YYYY-MM-DD, maxSize: 20m, maxFiles: 14d, format: logFormat }); /** * 错误日志单独文件 */ const errorFileTransport new DailyRotateFile({ dirname: LOG_DIR, filename: error-%DATE%.log, datePattern: YYYY-MM-DD, level: error, maxSize: 20m, maxFiles: 30d, format: logFormat }); /** * 创建 logger 实例 */ const logger winston.createLogger({ level: process.env.LOG_LEVEL || info, transports: [ fileTransport, errorFileTransport ] }); // 非生产环境同时输出到控制台 if (process.env.NODE_ENV ! production) { logger.add(new winston.transports.Console({ format: consoleFormat })); } module.exports logger;4、验证通过效果1、后台效果2、官方效果二、接入AI客服DeepSeek通过RAG框架对接本地知识库实现定制AI客服-CSDN博客