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

前言

本文代碼獲取方式:Github,走過路過,歡迎點贊和 star,本人會持續優化該項目html

系列文章傳送門git

在前面的文章中,咱們完成了微信回調服務器基本流程的的打通,但實際業務場景中,用戶給咱們發送的不單單是文本類型的消息,咱們給用戶回覆的也須要支持不一樣的類型。另外,微信對回調消息的響應時間作了規定,不能超過 5 秒,當咱們的業務須要對用戶消息處理較長時間再給予答覆時,得采用另外的手段來進行消息的回覆github

解析多種類型的用戶消息

咱們查看 接收普通消息 文檔能夠得知,當用戶發送的消息爲圖片、語音、視頻等媒體類型的消息時,微信給咱們的網關的回調信息中,會有一個 mediaIdredis

微信會將用戶消息以 臨時素材 的形式存儲,並提供 API 讓開發者獲取該媒體文件。經過 獲取臨時素材 文檔能夠發現,除了 mediaId,咱們還須要一個 access_token 用於請求該接口數據庫

關於 access_token 此文檔 已經作了很是詳細的說明,咱們須要注意如下的信息npm

  • access_token 每次獲取均會從新生成,咱們不能同時有兩個不一樣的服務來維護它
  • access_token 獲取的頻率有限制,咱們須要進行緩存,超過必定頻率的調用會被阻止,致使業務沒法正常使用
  • 須要在公衆號後臺配置 ip 白名單,以知足安全認證的需求。若是不知道咱們本身服務的 ip 地址,能夠直接請求一下該接口,查看微信返回的錯誤信息,其中就有咱們的當前 ip,拿到後配置到白名單中便可

綜合以上信息,咱們能夠固化出,微信網關調用微信 API 的流程json

service/utils/cache

咱們先來處理緩存,此處使用 redis 來實現,若沒有 redis,則可使用內存、本地文件或者數據庫的方式。集中實現方式的對比,可參考我以前的 文章api

import { Service } from 'egg';

// 緩存數據,若項目中無 redis,則修改方法,使用內存或數據庫實現
export default class extends Service {
  public async get(key: string) {
    const { ctx } = this;
    return await ctx.app.redis.get(key);
  }

  public async set(key: string, value: string, expires = 60 * 60) {
    const { ctx } = this;
    await ctx.app.redis.set(key, value);
    await ctx.app.redis.expire(key, expires);
  }

  public async expire(key: string, expires = 60 * 60) {
    const { ctx } = this;
    await ctx.app.redis.expire(key, expires);
  }

  public async delete(key: string) {
    const { ctx } = this;
    await ctx.app.redis.del(key);
  }
}
複製代碼

再把咱們以前的 service/wechat 拆分一層,把 encode 和 decode 方法放入 service/wechat/adapter 中,而後建立 service/wechat/util 用於實現輔助方法,咱們將獲取當前有效的 access_token 的方法放入其中緩存

service/wechat/util

import { Service } from 'egg';

export default class extends Service {
  public async getAccessToken() {
    const { ctx } = this;
    const config = ctx.app.config.wechat;
    const cacheKey = `wechat_token_${config.appId}`;
    let token = await ctx.service.utils.cache.get(cacheKey);
    if (!token) {
      const result = await ctx.app.curl(
        `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.appId}&secret=${config.secret}`,
        {
          dataType: 'json'
        }
      );
      if (result.data.errcode) {
        throw new Error('get mp access_token error');
      }
      token = result.data.access_token;
      if (!token) {
        throw new Error('get mp access_token error');
      }
      await ctx.service.utils.cache.set(cacheKey, token, 60 * 60);
    }
    return { token };
  }
}
複製代碼

service/wechat/media

咱們把媒體文件處理的方法單獨出來,實現獲取媒體文件流的方法安全

import { Service } from 'egg';

export default class extends Service {
  public async getMedia(mediaId) {
    const { ctx } = this;
    const { token } = await ctx.service.wechat.util.getAccessToken();
    const result = await ctx.app.curl(
      `https://api.weixin.qq.com/cgi-bin/media/get?access_token=${token}&media_id=${mediaId}`,
      {
        timeout: 30000
      }
    );
    return result;
  }
}

複製代碼

controller/wechat

最後,在 controller 中調用獲取媒體文件的方法,便可獲得媒體文件的文件流,並根據業務需求進行處理,例如轉存到本地,或者自有文件服務器,以便於將來能夠被訪問到(微信只會存儲臨時媒體文件 3 天

// 當用戶消息類型爲圖片、語音、視頻類型時,微信回調一個 mediaId
if (message.mediaId) {
  // 獲取媒體文件流
  const buffer = await ctx.service.wechat.media.getMedia(message.mediaId);
  // 對媒體文件流作一些處理,例如轉存到本地
  console.log(buffer.data);
}
複製代碼

另外,微信回調消息中,也有用戶的一些事件消息,詳情可見 接收事件推送,只須要在 decode 函數中把解析到的參數添加到咱們自定義的 message 對象中便可,在此不作贅述

被動響應多種類型的消息

在前面的文章中,咱們已經實現了被動回覆文本類型的消息。在 被動回覆用戶消息 文檔中,咱們能夠發現,當咱們須要回覆圖片、語音、視頻等類型的消息時,須要把咱們的媒體文件上傳到微信服務器並獲取一個 mediaId,再組裝響應數據

上傳媒體文件的文檔參考 新增臨時素材

service/wechat/media

咱們先安裝一個 form 請求輔助包 formstream

npm i --save formstream
複製代碼

而後在媒體文件的 service 中增長上傳文件的方法

/** * 上傳媒體文件 * @param type 媒體類型,參考微信官方文檔 * @param buffer 媒體文件流 * @param filename 文件名 * @param contentType content-type */
  public async uploadMedia(type, buffer, filename, contentType) {
    const { ctx } = this;
    const { token } = await ctx.service.wechat.util.getAccessToken();
    const form = formstream();
    form.buffer('media', buffer, filename, contentType);
    const result = await ctx.app.curl(
      `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${token}&type=${type}`,
      {
        method: 'POST',
        headers: form.headers(),
        stream: form,
        dataType: 'json',
        timeout: 30000
      }
    );
    return result;
  }
複製代碼

接着,咱們須要完善一下 encode 方法,來支持組裝多種類型的響應消息

service/wechat/adapter

// 獲取原始回覆 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 === "news") { %>',
        '<ArticleCount><%=content.length%></ArticleCount>',
        '<Articles>',
        '<% content.forEach(function(item){ %>',
        '<item>',
        '<Title><![CDATA[<%-item.title%>]]></Title>',
        '<Description><![CDATA[<%-item.description%>]]></Description>',
        '<PicUrl><![CDATA[<%-item.picUrl || item.picurl || item.pic %>]]></PicUrl>',
        '<Url><![CDATA[<%-item.url%>]]></Url>',
        '</item>',
        '<% }); %>',
        '</Articles>',
        '<% } else if (msgType === "music") { %>',
        '<Music>',
        '<Title><![CDATA[<%-content.title%>]]></Title>',
        '<Description><![CDATA[<%-content.description%>]]></Description>',
        '<MusicUrl><![CDATA[<%-content.musicUrl || content.url %>]]></MusicUrl>',
        '<HQMusicUrl><![CDATA[<%-content.hqMusicUrl || content.hqUrl %>]]></HQMusicUrl>',
        '</Music>',
        '<% } else if (msgType === "voice") { %>',
        '<Voice>',
        '<MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>',
        '</Voice>',
        '<% } else if (msgType === "image") { %>',
        '<Image>',
        '<MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>',
        '</Image>',
        '<% } else if (msgType === "video") { %>',
        '<Video>',
        '<MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>',
        '<Title><![CDATA[<%-content.title%>]]></Title>',
        '<Description><![CDATA[<%-content.description%>]]></Description>',
        '</Video>',
        '<% } else { %>',
        '<Content><![CDATA[<%-content%>]]></Content>',
        '<% } %>',
        '</xml>'
      ].join('')
    )(info);
  }
複製代碼

controller/wechat

最後,在 controller 中根據須要響應用戶消息

例如,咱們實現用戶媒體類型消息的回覆,用戶發圖片、語音就原樣回覆過去

// 正常狀況下,服務端須要先將媒體文件經過 ctx.service.wechat.media.uploadMedia 方法上傳至微信服務器獲取 mediaId
// eg: 將用戶原始消息直接回復
if (message.msgType === 'text') {
  ctx.body = await ctx.service.wechat.adapter.encodeMsg({
    type: 'text',
    content: message.content
  });
} else {
  ctx.body = await ctx.service.wechat.adapter.encodeMsg({
    type: message.msgType,
    content: {
      mediaId: message.mediaId
    }
  });
}
複製代碼

若是是回覆圖文消息,則變成

// eg: 圖文消息回覆
ctx.body = await ctx.service.wechat.adapter.encodeMsg({
  type: 'news',
  content: [
    {
      title: '掘金社區',
      description: '一塊兒來學習吧',
      url: 'https://juejin.cn'
    }
  ]
});
複製代碼

主動給用戶發送消息

在有些場景下,咱們並非能夠及時響應用戶的消息,而是要通過必定時間的處理(超過 5 秒),才能給用戶響應,這個時候就須要主動給用戶發送消息

參考 官方文檔,咱們能夠在收到用戶消息的 48 小時內,調用客服消息接口給用戶主動發送消息,類型包括:文本、圖片、語音、視頻、圖文等

service/wechat/message

咱們新建一個 message 文件用於處理消息發送的邏輯,實現常見的客服消息發送方法

import { Service } from 'egg';
import { IMessage } from '../../interface/message';

export interface IMenuMsg {
  headContent?: string;
  tailContent?: string;
  list: { id: number; content: string }[];
}

export default class extends Service {
  // 調用微信客服消息 API
  public async sendMsg(data: any) {
    const { ctx } = this;
    const { token } = await this.service.wechat.util.getAccessToken();
    const result = await ctx.app.curl(
      `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${token}`,
      {
        method: 'POST',
        contentType: 'json',
        dataType: 'json',
        data
      }
    );
    return result;
  }

  // 文本消息
  public async sendTextMsg(message: IMessage) {
    await this.sendMsg({
      touser: message.userId,
      msgtype: 'text',
      text: {
        content: message.content
      }
    });
  }

  // 圖片消息
  public async sendImageMsg(message: IMessage) {
    await this.sendMsg({
      touser: message.userId,
      msgtype: 'image',
      image: {
        media_id: message.mediaId
      }
    });
  }

  // 語音消息
  public async sendVoiceMsg(message: IMessage) {
    await this.sendMsg({
      touser: message.userId,
      msgtype: 'voice',
      voice: {
        media_id: message.mediaId
      }
    });
  }

  // 視頻消息
  public async sendVideoMsg(message: IMessage) {
    await this.sendMsg({
      touser: message.userId,
      msgtype: 'video',
      video: {
        media_id: message.mediaId
      }
    });
  }

  // 圖文消息
  public async sendNewsMsg(message: IMessage) {
    await this.sendMsg({
      touser: message.userId,
      msgtype: 'news',
      news: {
        articles: message.articles
      }
    });
  }

  // 菜單消息
  public async sendMenuMsg(message: IMessage, data: IMenuMsg) {
    await this.sendMsg({
      touser: message.userId,
      msgtype: 'msgmenu',
      msgmenu: {
        head_content: data.headContent + '\n',
        list: data.list,
        tail_content: '\n' + data.tailContent
      }
    });
  }
}
複製代碼

controller/wechat

在 controller 中,先給予微信一個空值響應,間隔一段時間後,再發送消息給用戶

// 先給微信服務器一個響應
ctx.body = await ctx.service.wechat.adapter.encodeMsg('');
// 模擬一段時間的處理後,給用戶主動推送消息
setTimeout(() => {
  ctx.service.wechat.message.sendTextMsg({
    ...message,
    content: '哈嘍,這是五秒後的回覆'
  });
}, 5000);
複製代碼

同時,爲了給用戶一個更好的體驗,咱們能夠利用 客服輸入狀態 這個 API,讓用戶在服務器處理的期間,能看到 對方正在輸入... 這樣的提示,而不是空蕩蕩的,覺得公衆號出問題了

  1. 在 service/wechat/message 中添加方法
// 客服輸入狀態
public async typing(message: IMessage, isTyping = true) {
  const { ctx } = this;
  const { token } = await this.service.wechat.util.getAccessToken();
  const result = await ctx.app.curl(
    `https://api.weixin.qq.com/cgi-bin/message/custom/typing?access_token=${token}`,
    {
      method: 'POST',
      contentType: 'json',
      dataType: 'json',
      data: {
        touser: message.userId,
        command: isTyping ? 'Typing' : 'CancelTyping'
      }
    }
  );
  return result;
}
複製代碼
  1. 在 controller/wechat 中適當時機進行調用
// 先給微信服務器一個響應
ctx.body = await ctx.service.wechat.adapter.encodeMsg('');
ctx.service.wechat.message.typing(message, true);
// 模擬一段時間的處理後,給用戶主動推送消息
setTimeout(() => {
  ctx.service.wechat.message.typing(message, false);
  ctx.service.wechat.message.sendTextMsg({
    ...message,
    content: '哈嘍,這是五秒後的回覆'
  });
}, 5000);
複製代碼

更多的消息類型大同小異,你們可自行實現

總結

至此,咱們的服務已經實現瞭解析多種類型的用戶消息,按需被動回覆不一樣類型的消息,而且在必要的時候能夠主動給用戶發送消息。咱們實現了 access_token 的通用處理方式,在將來調用微信提供的 API 時能夠直接複用

在下一篇內容裏,咱們將探索基於公衆號的網頁開發,敬請期待

本文代碼獲取方式:Github

相關文章
相關標籤/搜索