基于 Egg.js 一步步搭建微信网关(一)

1,737 阅读6分钟

前言

现如今,前端开发涉及到的越来越多,基于微信的开发(公众号、企业微信、小程序)成了前端必备的开发技能

然而,很多前端开发还是仅限于客户端,与微信之间的对接依旧是依赖于后端来处理,大大限制了自己的输出范围。其实,因为 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 中也可以体现

接着,在微信回调配置界面,生成 TokenEncodingAESKey(如果是测试号,则不需要配置;如果开启了明文模式,则该配置不生效),并且配置到项目的配置文件中。启动服务,保存微信回调配置,即可校验成功

接收消息和被动响应

微信回调配置成功后,则后续用户给公众号发送的消息,以及在公众号中触发的事件,都会产生一条信息回调给我们的服务。我们来看看官方提供的消息回调流程(以文本消息为例)

我们把这个过程分为两个阶段:消息解析(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 = '';
    }
  }
}

总结

至此,我们搭建起来一个基本的微信回调服务,当收到用户消息时,能给用户做一个最简单的文本回复。在下一篇内容里,我们继续来探索,当用户发送不同类型的消息,以及我们需要给用户响应不同类型的消息时,应该作何处理