基於 Egg.js 一步步搭建微信網關(一)

前言

現現在,前端開發涉及到的愈來愈多,基於微信的開發(公衆號、企業微信、小程序)成了前端必備的開發技能html

然而,不少前端開發仍是僅限於客戶端,與微信之間的對接依舊是依賴於後端來處理,大大限制了本身的輸出範圍。其實,由於 Node.js 的出現,咱們擁有了 複雜度相對較低 的後端開發能力。在微信網關這個場景上,咱們徹底有能力本身搞定前端

接下來,咱們就經過不斷提出的需求,來一步步搭建和完善咱們的微信網關git

咱們的重點在於網關服務的實現,至於怎麼樣進行服務部署並暴露給微信去調用,以及公衆號、企業微信、小程序等的申請,不在此贅述github

本文代碼獲取方式:github.com/splendourhu… ,走過路過,歡迎點贊和 star,本人會持續優化該項目typescript

開啓服務搭建之旅

初始化

使用 Egg 官方文檔 提供的初始化命令進行項目初始化npm

mkdir store && cd store
npm init egg --type=ts
npm i
npm run dev
複製代碼

開啓回調模式

咱們須要提供一個 API,用於讓微信服務器認證回調接口是否正常運行,以便開啓回調模式。先來看看官方提供的回調接口認證流程json

先安裝一個包含參數校驗邏輯的包小程序

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);
  }
}
複製代碼

接着,實現該接口的 controllerbash

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 = '';
    }
  }
}
複製代碼

總結

至此,咱們搭建起來一個基本的微信回調服務,當收到用戶消息時,能給用戶作一個最簡單的文本回復。在下一篇內容裏,咱們繼續來探索,當用戶發送不一樣類型的消息,以及咱們須要給用戶響應不一樣類型的消息時,應該做何處理

相關文章
相關標籤/搜索