javascript
复制代码
// crypto-utils.js
const crypto = require('crypto');
class WXBizMsgCrypt {
constructor(token, encodingAESKey, corpid) {
this.token = token;
this.key = Buffer.from(encodingAESKey + '=', 'base64');
this.iv = this.key.slice(0, 16);
this.corpid = corpid;
}
// 验证URL签名
verifyURL(msgSignature, timestamp, nonce, echostr) {
const signature = this.getSignature(timestamp, nonce, echostr);
return signature === msgSignature;
}
// 解密消息
decryptMsg(msgSignature, timestamp, nonce, encryptedMsg) {
// 验证签名
const signature = this.getSignature(timestamp, nonce, encryptedMsg);
if (signature !== msgSignature) {
throw new Error('Invalid signature');
}
// Base64解码
const encrypted = Buffer.from(encryptedMsg, 'base64');
// AES解密
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, this.iv);
decipher.setAutoPadding(false);
let decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
// 去除填充
const pad = decrypted[decrypted.length - 1];
if (pad < 1 || pad > 32) {
pad = 0;
}
decrypted = decrypted.slice(0, decrypted.length - pad);
// 获取消息长度
const content = decrypted.slice(16);
const msgLen = content.slice(0, 4).readUInt32BE(0);
const msg = content.slice(4, 4 + msgLen).toString();
const fromCorpid = content.slice(4 + msgLen).toString();
// 验证corpid
if (fromCorpid !== this.corpid) {
throw new Error('Invalid corpid');
}
return msg;
}
// 加密消息
encryptMsg(replyMsg, timestamp, nonce, msg_signature) {
// 1. 生成16字节随机数
const randomBytes = crypto.randomBytes(16);
// 2. 构造消息:随机数(16字节) + 消息长度(4字节,大端序) + 消息内容 + corpid
const msgBuffer = Buffer.from(replyMsg, 'utf8');
const msgLen = Buffer.allocUnsafe(4);
msgLen.writeUInt32BE(msgBuffer.length, 0);
const corpidBuffer = Buffer.from(this.corpid, 'utf8');
const plainText = Buffer.concat([randomBytes, msgLen, msgBuffer, corpidBuffer]);
// 3. AES-256-CBC加密(使用PKCS7填充)
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, this.iv);
cipher.setAutoPadding(true);
let encrypted = Buffer.concat([
cipher.update(plainText),
cipher.final()
]);
// 4. Base64编码
const encryptedMsg = encrypted.toString('base64');
// 5. 生成签名
const signature = msg_signature || this.getSignature(timestamp, nonce, encryptedMsg);
return {
encryptedMsg,
signature,
timestamp,
nonce
};
}
// 生成签名
getSignature(timestamp, nonce, encryptedMsg) {
const arr = [this.token, timestamp, nonce, encryptedMsg].sort();
const str = arr.join('');
const sha1 = crypto.createHash('sha1');
sha1.update(str);
return sha1.digest('hex');
}
}
module.exports = WXBizMsgCrypt;
javascript
复制代码
// 核心代码示例(Node.js,适配外部群场景):
// // 安装依赖:npm install express crypto-js axios mysql2 dotenv
require('dotenv').config();
const express = require('express');
const WXBizMsgCrypt = require('./crypto-utils');
const crypto = require('crypto');
const xml2js = require('xml2js');
const axios = require('axios');
const mysql = require('mysql2/promise');
const { log } = require('console');
const app = express();
// 自定义中间件:保存原始请求体到 req.rawBody(用于企业微信回调)
// app.use((req, res, next) => {
// if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
// const chunks = [];
// req.on('data', chunk => chunks.push(chunk));
// req.on('end', () => {
// req.rawBody = Buffer.concat(chunks);
// next();
// });
// } else {
// next();
// }
// });
// 接收企业微信推送的XML数据(支持 application/xml 和 text/xml)
app.use(express.text({ type: 'text/xml' }));
app.use(express.json()); // 解析JSON请求
app.use(express.static('public')); // 提供静态文件访问,public文件夹中的文件可以通过根路径访问
// 解析中间件
// app.use((req, res, next) => {
// if (req.is('text/xml')) {
// xml2js.parseString(req.body, (err, result) => {
// if (!err) {
// req.xml = result;
// }
// next();
// });
// } else {
// next();
// }
// });
// 企业微信核心参数(从.env文件读取,避免硬编码)
const CORP_ID = process.env.CORP_ID;
const AGENT_ID = process.env.AGENT_ID;
const APP_SECRET = process.env.APP_SECRET;
const TOKEN = '3w2GHWixx02z';
const ENCODING_AES_KEY = 'LEFyKsqsVqL3yMip6I73beCWK0HNaOKKuL2uvLZeJ4q';
console.log("ENCODING_AES_KEY:", '--' + ENCODING_AES_KEY)
console.log("TOKEN:", '--' + TOKEN)
// 创建加解密实例
const wxcpt = new WXBizMsgCrypt(
TOKEN,
ENCODING_AES_KEY,
CORP_ID
);
// 数据库配置
const DB_CONFIG = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'wecom_external_group_qa',
charset: 'utf8mb4'
};
// 全局缓存access_token(生产环境建议用Redis)
let accessTokenCache = { token: '', expireTime: 0 };
// ---------------------- 工具函数 ----------------------
/**
* 获取企业微信access_token
*/
async function getAccessToken() {
// 缓存未过期直接返回
if (accessTokenCache.token && Date.now() < accessTokenCache.expireTime) {
return accessTokenCache.token;
}
try {
const response = await axios.get('https://qyapi.weixin.qq.com/cgi-bin/gettoken', {
params: { corpid: CORP_ID, corpsecret: APP_SECRET }
});
const { errcode, errmsg, access_token, expires_in } = response.data;
if (errcode !== 0) throw new Error(`获取access_token失败:${errmsg}`);
// 缓存7100秒(预留100秒缓冲)
accessTokenCache = { token: access_token, expireTime: Date.now() + (expires_in - 100) * 1000 };
return access_token;
} catch (error) {
log('[ERROR] 获取access_token异常:', error.message);
return null;
}
}
/**
* 验证RoomId是否为外部群ID
* @param {string} roomId 群ID
*/
async function isExternalGroup(roomId) {
const accessToken = await getAccessToken();
if (!accessToken) return false;
try {
const response = await axios.post(
`https://qyapi.weixin.qq.com/cgi-bin/externalcontact/groupchat/get?access_token=${accessToken}`,
{ offset: 0, limit: 100 } // 分页拉取,生产环境需处理多页
);
const { errcode, errmsg, group_chat_list } = response.data;
if (errcode !== 0) throw new Error(`获取外部群列表失败:${errmsg}`);
const externalGroupIds = group_chat_list?.map(group => group.chat_id) || [];
return externalGroupIds.includes(roomId);
} catch (error) {
log('[ERROR] 验证外部群ID异常:', error.message);
return false;
}
}
/**
* 验证URL有效性 - 企业微信回调验证专用函数
* @param {string} msgSignature 消息签名
* @param {string} timestamp 时间戳
* @param {string} nonce 随机数
* @param {string} echostr 加密的字符串(Base64编码)
* @returns {string|null} 返回解密后的msg字段内容,失败返回null
*/
function verifyURL(msgSignature, timestamp, nonce, echostr) {
try {
// 1. 验证签名:使用 token、timestamp、nonce、echostr 计算签名
const sortStr = [TOKEN, timestamp, nonce, echostr].sort().join('');
const signature = crypto.createHash('sha1').update(sortStr).digest('hex');
if (signature !== msgSignature) {
throw new Error('URL验证签名失败');
}
// 2. 解码EncodingAESKey(43字节Base64编码,需要补全到44字节)
const key = Buffer.from(ENCODING_AES_KEY + '=', 'base64');
// 3. AES-CBC解密(初始向量取key的前16字节)
const iv = key.slice(0, 16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
decipher.setAutoPadding(true); // 自动处理PKCS7填充
const decryptData = Buffer.concat([
decipher.update(echostr, 'base64'),
decipher.final()
]);
// 4. 解析明文结构:16字节随机数 + 4字节消息长度 + 消息内容(msg) + CorpID
const msgLen = decryptData.readUInt32BE(16); // 大端序读取消息长度
const msgContent = decryptData.slice(20, 20 + msgLen).toString('utf8');
// 5. 返回msg字段(文档要求:不能加引号、不能带BOM头、不能带换行符)
return msgContent.trim();
} catch (error) {
log('[ERROR] URL验证异常:', error.message);
return null;
}
}
/**
* AES解密企业微信推送的消息(PKCS7填充)
* @param {string} msgSignature 消息签名
* @param {string} timestamp 时间戳
* @param {string} nonce 随机数
* @param {string} encryptMsg 加密消息(Base64编码)
*/
function decryptMsg(msgSignature, timestamp, nonce, encryptMsg) {
try {
// 1. 验证签名
const sortStr = [TOKEN, timestamp, nonce, encryptMsg].sort().join('');
const signature = crypto.createHash('sha1').update(sortStr).digest('hex');
if (signature !== msgSignature) throw new Error('消息签名验证失败');
// 2. 解码EncodingAESKey
const key = Buffer.from(ENCODING_AES_KEY + '=', 'base64');
// 3. AES-CBC解密
const iv = key.slice(0, 16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
decipher.setAutoPadding(true); // 自动处理PKCS7填充
let decryptData = Buffer.concat([decipher.update(encryptMsg, 'base64'), decipher.final()]);
// 4. 解析明文结构:16字节随机数 + 4字节消息长度 + 消息内容 + CorpID
const msgLen = decryptData.readUInt32BE(16);
const msgContent = decryptData.slice(20, 20 + msgLen).toString('utf8');
return JSON.parse(msgContent);
} catch (error) {
log('[ERROR] 消息解密异常:', error.message);
return null;
}
}
/**
* 向外部群发送回复消息
* @param {string} roomId 外部群ID
* @param {string} content 回复内容
*/
async function sendGroupMsg(roomId, content) {
const accessToken = await getAccessToken();
if (!accessToken) return false;
try {
const response = await axios.post(
`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${accessToken}`,
{
touser: '',
toparty: '',
totag: '',
msgtype: 'text',
agentid: AGENT_ID,
text: { content },
chatid: roomId // 外部群ID
}
);
const { errcode, errmsg } = response.data;
if (errcode !== 0) throw new Error(`发送群消息失败:${errmsg}`);
log(`[INFO] 向外部群${roomId}发送消息成功`);
return true;
} catch (error) {
log('[ERROR] 发送群消息异常:', error.message);
return false;
}
}
/**
* 存储外部群问答记录到数据库
* @param {string} groupId 外部群ID
* @param {string} userId 客户ID
* @param {string} question 客户提问
* @param {string} reply 机器人回复
*/
async function saveQARecord(groupId, userId, question, reply) {
let connection;
try {
connection = await mysql.createConnection(DB_CONFIG);
// 初始化表(不存在则创建)
await connection.execute(`
CREATE TABLE IF NOT EXISTS external_group_qa (
id INT AUTO_INCREMENT PRIMARY KEY,
group_id VARCHAR(64) NOT NULL COMMENT '外部群ID',
user_id VARCHAR(64) NOT NULL COMMENT '客户ID',
question TEXT NOT NULL COMMENT '客户提问内容',
reply TEXT NOT NULL COMMENT '机器人回复内容',
create_time INT NOT NULL COMMENT '消息时间戳'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 插入数据
const [result] = await connection.execute(
'INSERT INTO external_group_qa (group_id, user_id, question, reply, create_time) VALUES (?, ?, ?, ?, ?)',
[groupId, userId, question, reply, Math.floor(Date.now() / 1000)]
);
log(`[INFO] 存储问答记录成功:群${groupId},用户${userId}`);
return result.affectedRows > 0;
} catch (error) {
log('[ERROR] 存储问答记录异常:', error.message);
return false;
} finally {
if (connection) await connection.end();
}
}
/**
* 简单关键词匹配回复(可替换为AI大模型调用)
* @param {string} question 客户提问
*/
function getAiReply(question) {
const replyMap = {
'价格': '您好,具体价格可查看群公告中的产品手册,或联系群内客服咨询~',
'售后': '售后问题可直接私信群内客服,提供订单号即可快速处理~',
'发货': '订单付款后48小时内发货,物流信息会通过企业微信消息推送您~'
};
for (const [key, reply] of Object.entries(replyMap)) {
if (question.includes(key)) return reply;
}
return '您好,您的问题已收到,我们将尽快为您解答~';
}
// ---------------------- 回调接口 ----------------------
/**
* 企业微信消息回调接口(GET验证,POST接收消息)
*/
app.all('/wecom/robot/callback', async (req, res) => {
console.log('/wecom/robot/callback');
// console.log(req);
const { msg_signature, timestamp, nonce } = req.query;
console.log("msg_signature : ", msg_signature)
console.log("timestamp : ", timestamp)
console.log("nonce : ", nonce)
console.log("req.method : ", req.method)
// 1. GET请求:回调验证
if (req.method === 'GET') {
const { echostr } = req.query;
console.log("echostr : ", "--" + echostr)
// 验证签名
// const sortStr = [TOKEN, timestamp, nonce, echostr].sort().join('');
// const signature = crypto.createHash('sha1').update(sortStr).digest('hex');
// console.log("signature : ", signature)
// log('[DEBUG] GET验证签名:计算签名=', signature, '接收签名=', msg_signature);
//if (signature === msg_signature) {
const msgContent = verifyURL(msg_signature, timestamp, nonce, echostr);
if (msgContent === null) {
return res.status(403).send('Invalid Signature or Decrypt Failed');
}
console.log("msgContent : ", msgContent)
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
return res.send(msgContent);
// } else {
// log('[ERROR] GET回调验证失败:');
// return res.status(403).send('Invalid Signature');
// }
}
// 2. POST请求:处理外部群消息
if (req.method === 'POST') {
// const { msg_signature, timestamp, nonce } = req.query;
const xmlData = req.body;
try {
// 解析XML获取加密消息
const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(xmlData);
const encryptedMsg = result.xml.Encrypt[0];
// 解密消息
const decryptedMsg = wxcpt.decryptMsg(
msg_signature,
timestamp,
nonce,
encryptedMsg
);
// 解析解密后的消息
const decryptedResult = await parser.parseStringPromise(decryptedMsg);
const message = decryptedResult.xml;
//
console.log("message : ", message.toString());
const msgType = message.MsgType?.[0];
const eventType = message.Event?.[0];
console.log('收到消息:', {
FromUserName: message.FromUserName?.[0],
MsgType: msgType,
Content: message.Content?.[0] || '无内容',
Event: eventType || '无事件'
});
// 检查消息类型,只有文本消息才支持被动回复
// 注意:事件消息(如 enter_agent)通常不支持被动回复
if (msgType !== 'text') {
console.log(`[INFO] 消息类型为 ${msgType},不支持被动回复,返回空响应`);
// 对于不支持被动回复的消息类型,返回空响应或成功响应
return res.status(200).send('');
}
// 构造回复
const reply = {
ToUserName: message.FromUserName[0],
FromUserName: message.ToUserName[0],
CreateTime: Math.floor(Date.now() / 1000),
MsgType: 'text',
Content: '收到消息',
};
console.log("reply : ", reply)
// 生成XML回复(紧凑格式,无多余空格)
const replyXml = `<?xml version="1.0" encoding="UTF-8"?><xml><ToUserName><![CDATA[${reply.ToUserName}]]></ToUserName><FromUserName><![CDATA[${reply.FromUserName}]]></FromUserName><CreateTime>${reply.CreateTime}</CreateTime><MsgType><![CDATA[${reply.MsgType}]]></MsgType><Content><![CDATA[${reply.Content}]]></Content></xml>`;
console.log("replyXml : ", replyXml)
// 加密回复消息(企业微信要求回复也必须加密)
// 使用新的 timestamp 和 nonce(企业微信规范要求)
const replyTimestamp = Math.floor(Date.now() / 1000).toString();
const replyNonce = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const encrypted = wxcpt.encryptMsg(replyXml, replyTimestamp, replyNonce);
// 构造加密后的XML格式(紧凑格式)
const encryptedXml = `<xml><Encrypt><![CDATA[${encrypted.encryptedMsg}]]></Encrypt><MsgSignature><![CDATA[${encrypted.signature}]]></MsgSignature><TimeStamp>${replyTimestamp}</TimeStamp><Nonce><![CDATA[${replyNonce}]]></Nonce></xml>`;
console.log("------------ encryptedXml ------------")
console.log(" encryptedXml : ", encryptedXml)
// 设置正确的响应头
res.set({
'Content-Type': 'application/xml; charset=utf-8',
// 'Content-Length': Buffer.byteLength(encryptedXml, 'utf8').toString()
});
res.send(encryptedXml);
} catch (error) {
console.error('处理消息失败:', error);
res.status(500).send('Server error');
}
return;
}
res.status(405).send('Method Not Allowed');
});
app.all('/', async (req, res) => {
console.log('/');
//console.log(req);
if (req.method === 'GET') {
try {
// 1.1 对收到的请求做URL解码处理(重要!否则验证会失败)
const msg_signature = decodeURIComponent(req.query.msg_signature || '');
const timestamp = decodeURIComponent(req.query.timestamp || '');
const nonce = decodeURIComponent(req.query.nonce || '');
const echostr = decodeURIComponent(req.query.echostr || '');
if (!msg_signature || !timestamp || !nonce || !echostr) {
return res.status(400).send('Missing required parameters');
}
console.log("msg_signature : ", msg_signature)
console.log("timestamp : ", timestamp)
console.log("nonce : ", nonce)
console.log("echostr : ", echostr)
// 1.2 验证签名并解密echostr,获取msg字段
const msgContent = verifyURL(msg_signature, timestamp, nonce, echostr);
if (msgContent === null) {
return res.status(403).send('Invalid Signature or Decrypt Failed');
}
console.log("msgContent : ", msgContent)
// 1.3 在1秒内原样返回明文消息内容(不能加引号、不能带BOM头、不能带换行符)
// 注意:直接返回字符串,不要使用res.json()(会加引号)
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
return res.send(msgContent);
} catch (error) {
log('[ERROR] GET验证异常:', error.message);
return res.status(500).send('Internal Server Error');
}
}
})
// ---------------------- 启动服务 ----------------------
const PORT = process.env.PORT || 443;
// 生产环境需配置HTTPS证书(使用nginx反向代理更推荐)
const http = require('http');
http.createServer({}, app).listen(PORT, () => {
log(`[INFO] 服务已启动,监听端口 ${PORT}(HTTP)`);
});
// const https = require('https');
// const fs = require('fs');
// const options = {
// key: fs.readFileSync('key.pem'), // 你的私钥文件路径
// cert: fs.readFileSync('cert.pem') // 你的证书文件路径
// };
// https.createServer(options, app).listen(PORT, () => {
// log(`[INFO] 服务已启动,监听端口 ${PORT}(HTTPS)`);
// });