nodejs 实现 企业微信 自定义应用 接收消息服务器配置和实现

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)`);
// });
javascript 复制代码
{
	"dependencies": {
		"axios": "^1.13.2",
		"crypto-js": "^4.2.0",
		"dotenv": "^17.2.3",
		"express": "^5.2.1",
		"mysql2": "^3.16.0",
		"wechat-crypto": "^0.0.2",
		"xml2js": "^0.6.2"
	}
}
javascript 复制代码
CORP_ID=123
AGENT_ID=123
APP_SECRET=123
TOKEN=123
ENCODING_AES_KEY=123
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=
PORT=8080
相关推荐
小马爱打代码5 小时前
MySQL高可用与扩展:主从复制、读写分离、分库分表
服务器·数据库·mysql
作业逆流成河5 小时前
别再一次性重构枚举了:如何把一个真实后台项目的状态字典,渐进式迁移到enum-plus?
前端·javascript·开源
暗不需求5 小时前
React 性能优化秘籍:深入理解 `useMemo` 与 `useCallback`
前端·react.js·面试
Shingmc35 小时前
【Linux】多路转接之epoll
linux·运维·服务器·开发语言·网络
专注VB编程开发20年6 小时前
我制作excel工作簿的选项卡,发给deep seek, 昨天修改了一天
前端·vue.js·excel
心满意足的大脸猫6 小时前
Win11 开启 SSH 服务器与密钥登录配置记录
服务器·microsoft·ssh
light blue bird6 小时前
工序路径主子表单工序组装图表组件
前端·数据库·信息可视化·.net·web端·razor page
utf8mb4安全女神6 小时前
磁盘管理(交换分区)(MGR分区)(GPT分区)
linux·运维·服务器
linlinlove26 小时前
前端uniapp、后端thinkphp股票系统开发功能展示、代码披露、HQChart
前端·uni-app·echarts·thinkphp·hqchart·配资·deepseek选股票
万少6 小时前
Claude Code 任务结束会自己喊你:一个 Stop Hook 搞定提示音
前端·后端·代码规范