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
相关推荐
步步为营DotNet2 小时前
深度解析.NET 中IAsyncEnumerable:异步迭代的高效实现与应用】
服务器·数据库·.net
web守墓人2 小时前
【前端】ikun-pptx编辑器前瞻问题五:pptx中的xml命名空间
xml·前端
APIshop2 小时前
实战解析:1688详情api商品sku、主图数据
java·服务器·windows
学Linux的语莫2 小时前
本地部署ollama
linux·服务器·langchain
oMcLin2 小时前
如何在 CentOS 7 上通过配置和调优 OpenResty,提升高并发 Web 应用的 API 请求处理能力?
前端·centos·openresty
IT_陈寒2 小时前
Java开发者必知的5个性能优化技巧,让应用速度提升300%!
前端·人工智能·后端
深圳市恒讯科技2 小时前
常见服务器漏洞及防护方法
服务器·网络·安全
程序媛哪有这么可爱!2 小时前
【删除远程服务器vscode缓存】
服务器·人工智能·vscode·缓存·边缘计算