前言
现如今,前端开发涉及到的越来越多,基于微信的开发(公众号、企业微信、小程序)成了前端必备的开发技能
然而,很多前端开发还是仅限于客户端,与微信之间的对接依旧是依赖于后端来处理,大大限制了自己的输出范围。其实,因为 Node.js 的出现,我们拥有了 复杂度相对较低 的后端开发能力。在微信网关这个场景上,我们完全有能力自己搞定
接下来,我们就通过不断提出的需求,来一步步搭建和完善我们的微信网关
我们的重点在于网关服务的实现,至于怎么样进行服务部署并暴露给微信去调用,以及公众号、企业微信、小程序等的申请,不在此赘述
本文代码获取方式:github.com/splendourhu… ,走过路过,欢迎点赞和 star,本人会持续优化该项目
开启服务搭建之旅
初始化
使用 Egg 官方文档 提供的初始化命令进行项目初始化
mkdir store && cd store
npm init egg --type=ts
npm i
npm run dev
开启回调模式
我们需要提供一个 API,用于让微信服务器认证回调接口是否正常运行,以便开启回调模式。先来看看官方提供的回调接口认证流程
先安装一个包含参数校验逻辑的包
npm i --save wechat-crypto
然后,把校验过程封装在 service 中
service/wechat
export default class extends Service {
// 微信回调校验
public async callbackVerify(): Promise<{ message: string } | void> {
const { ctx } = this;
const {
echostr,
msg_signature: signature,
timestamp,
nonce,
encrypt_type
} = ctx.query;
const config = ctx.app.config.wechat;
// 判断是否加密传输
const encrypted = !!(encrypt_type && encrypt_type === 'aes' && signature);
if (!encrypted) {
return { message: echostr };
}
// 使用官方提供的包进行验证和解密
const cryptor = encrypted
? new WXBizMsgCrypt(config.token, config.encodingAESKey, config.appId)
: {};
if (signature !== cryptor.getSignature(timestamp, nonce, echostr)) {
return;
}
return cryptor.decrypt(echostr);
}
}
接着,实现该接口的 controller
controller/wechat
export default class extends Controller {
public async callback() {
const { ctx } = this;
// 回调校验
if (ctx.method === 'GET') {
const verifyResult = await ctx.service.wechat.callbackVerify();
ctx.body = verifyResult ? verifyResult.message : '';
return;
}
}
最后,在 router 中增加路由定义
router.all('/wechat/callback', controller.wechat.callback);
这里用 all,是因为微信回调消息会采用和校验消息一样的地址,GET 请求用于校验,POST 请求用于消息回调,在 controller 中也可以体现
接着,在微信回调配置界面,生成 Token
和 EncodingAESKey
(如果是测试号,则不需要配置;如果开启了明文模式,则该配置不生效),并且配置到项目的配置文件中。启动服务,保存微信回调配置,即可校验成功
接收消息和被动响应
微信回调配置成功后,则后续用户给公众号发送的消息,以及在公众号中触发的事件,都会产生一条信息回调给我们的服务。我们来看看官方提供的消息回调流程(以文本消息为例)
我们把这个过程分为两个阶段:消息解析(decode)和消息转化(encode)
在 decode 阶段,我们处理 xml 消息的接收、解析和解密,利用 xml2js 库来将 xml 格式的数据转换为 json 数据以方便处理,参考 co-wechat 库来实现整个解析过程
service/wechat
在 service/wechat 中,添加解析函数
// 将 xml 转化后的格式变成 json 格式
private formatMessage(result) {
let message = {};
if (typeof result === 'object') {
for (const key in result) {
if (result[key].length === 1) {
const val = result[key][0];
if (typeof val === 'object') {
message[key] = this.formatMessage(val);
} else {
message[key] = (val || '').trim();
}
} else {
message = result[key].map(this.formatMessage);
}
}
}
return message;
}
// 微信属性格式转换为驼峰式,未来有更多消息类型时,需要在此补充新字段
private mapMsg(msg: any): IMessage {
return {
createdTime: msg.CreateTime,
msgType: msg.MsgType,
content: msg.Content,
mediaId: msg.MediaId,
recognition: msg.Recognition,
userId: msg.FromUserName
};
}
public async decodeMsg(): Promise<IMessage> {
const { ctx } = this;
const {
msg_signature: signature,
timestamp,
nonce,
encrypt_type
} = ctx.query;
const config = ctx.app.config.wechat;
// 判断是否加密传输
const encrypted = !!(encrypt_type && encrypt_type === 'aes' && signature);
const cryptor = encrypted
? new WXBizMsgCrypt(config.token, config.encodingAESKey, config.appId)
: {};
// 流式获取请求体
return new Promise((resolve, reject) => {
let data = '';
ctx.req.setEncoding('utf8');
ctx.req.on('data', (chunk: string) => {
data += chunk;
});
ctx.req.on('error', (err: Error) => {
reject(err);
});
ctx.req.on('end', () => {
// 解析原始 xml
xml2js.parseString(data, { trim: true }, (err, result) => {
if (err) {
reject(new Error('xml 解析失败'));
}
// 原始 xml 转换为 json
const originMessage: any = this.formatMessage(result.xml);
if (!encrypted) {
// 不加密时,originMessage 已经是解析好的参数
ctx.mySession.message = originMessage;
resolve(this.mapMsg(originMessage));
return;
}
const encryptMessage = originMessage.Encrypt;
if (
signature !== cryptor.getSignature(timestamp, nonce, encryptMessage)
) {
reject(new Error('signature 校验失败'));
}
const decrypted = cryptor.decrypt(encryptMessage);
// 提取出解密后的 xml
const messageWrapXml = decrypted.message;
if (messageWrapXml === '') {
reject(new Error('xml 解析失败'));
}
// 解析解密后的 xml
xml2js.parseString(messageWrapXml, { trim: true }, (err, result) => {
if (err) {
reject(new Error('xml 解析失败'));
}
const message: any = this.formatMessage(result.xml);
ctx.mySession.message = message;
resolve(this.mapMsg(message));
});
});
});
});
}
这里将解析后的消息存放在 ctx.mySession
中,以便后续使用,需要增加一个简单的中间件
middleware/my_session
export default () => {
return async (ctx, next) => {
if (!ctx.mySession) {
ctx.mySession = {};
}
await next();
};
};
这里还用到了一个接口 IMessage,我们根据微信响应消息的类型和内容,定义一个 interface,未来有更多类型时,在此基础上扩展
export interface IMessage {
createdTime: string;
userId: string;
msgType?: 'voice' | 'image' | 'video';
title?: string;
content?: string;
// 语音识别结果
recognition?: string;
// 文本消息
text?: string;
// 文件消息
fileName?: string;
contentType?: string;
// 媒体文件消息
mediaId?: string;
// 图文消息
articles?: any[];
}
完成了 decode 的过程,我们拿到了用户的消息,这时候就需要做出响应,我们称之为「被动响应消息」,参考文档 被动回复用户消息
本质上,就是在获取用户消息后,根据业务需求,给予微信服务器一个特定的响应,让微信服务器给用户回复对应的内容。我们先以最简单的文本消息回复为,例子
service/wechat
在 service/wechat 中,我们实现一个通过将 json 格式转化为微信需要的 xml 格式消息的方法,并且兼容直接传字符串作为文本消息的回复
// 获取原始回复 xml
private compileReply(info) {
return ejs.compile(
[
'<xml>',
'<ToUserName><![CDATA[<%-toUsername%>]]></ToUserName>',
'<FromUserName><![CDATA[<%-fromUsername%>]]></FromUserName>',
'<CreateTime><%=createTime%></CreateTime>',
'<MsgType><![CDATA[<%=msgType%>]]></MsgType>',
'<% if (msgType === "text") { %>',
'<Content><![CDATA[<%-content%>]]></Content>',
'<% } %>',
'</xml>'
].join('')
)(info);
}
// 获取加密 xml
private compileWrap(wrap) {
return ejs.compile(
[
'<xml>',
'<Encrypt><![CDATA[<%-encrypt%>]]></Encrypt>',
'<MsgSignature><![CDATA[<%-signature%>]]></MsgSignature>',
'<TimeStamp><%-timestamp%></TimeStamp>',
'<Nonce><![CDATA[<%-nonce%>]]></Nonce>',
'</xml>'
].join('')
)(wrap);
}
// 将回复内容转换为微信需要的 xml 格式
private getReplyXml(content, fromUsername, toUsername) {
const info: any = {};
let type = 'text';
info.content = content || '';
if (typeof content === 'object') {
if (content.hasOwnProperty('type')) {
type = content.type;
info.content = content.content;
}
}
info.msgType = type;
info.createTime = new Date().getTime();
info.toUsername = toUsername;
info.fromUsername = fromUsername;
return this.compileReply(info);
}
// 将 json 或者 string 格式的内容转换为微信需要的 xml 格式
public async encodeMsg(content) {
const { ctx } = this;
if (!content) {
return '';
}
const { msg_signature: signature, encrypt_type } = ctx.query;
const config = ctx.app.config.wechat;
// 判断是否加密传输
const encrypted = !!(encrypt_type && encrypt_type === 'aes' && signature);
const cryptor = encrypted
? new WXBizMsgCrypt(config.token, config.encodingAESKey, config.appId)
: {};
const { message } = ctx.mySession;
// 组装 xml
const xml = this.getReplyXml(
content,
message.ToUserName,
message.FromUserName
);
// 不需加密时,返回原始 xml
if (!encrypted) {
return xml;
}
// 组装加密 xml
const wrap: any = {};
wrap.encrypt = cryptor.encrypt(xml);
wrap.nonce = parseInt((Math.random() * 100000000000) as any, 10);
wrap.timestamp = new Date().getTime();
wrap.signature = cryptor.getSignature(
wrap.timestamp,
wrap.nonce,
wrap.encrypt
);
return this.compileWrap(wrap);
}
最后,在 controller/wechat 中,完成消息解析和回复的过程
controller/wechat
export default class extends Controller {
public async callback() {
const { ctx } = this;
// 回调校验
if (ctx.method === 'GET') {
const verifyResult = await ctx.service.wechat.callbackVerify();
ctx.body = verifyResult ? verifyResult.message : '';
return;
}
// 接收消息
try {
const message = await ctx.service.wechat.decodeMsg();
ctx.logger.info('Get message from wechat', message);
// 在此补充自己的业务逻辑,根据用户消息,转换为需要回复的消息
// eg: 纯文本消息回复
ctx.body = await ctx.service.wechat.encodeMsg('收到消息了');
} catch (error) {
ctx.logger.error(error);
// 给微信空值响应,避免公众号出现报错
ctx.body = '';
}
}
}
总结
至此,我们搭建起来一个基本的微信回调服务,当收到用户消息时,能给用户做一个最简单的文本回复。在下一篇内容里,我们继续来探索,当用户发送不同类型的消息,以及我们需要给用户响应不同类型的消息时,应该作何处理