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

1,523 阅读4分钟

前言

本文代码获取方式:Github,走过路过,欢迎点赞和 star,本人会持续优化该项目

系列文章传送门

在前面的文章中,我们完成了微信回调服务器基本流程的的打通,但实际业务场景中,用户给我们发送的不仅仅是文本类型的消息,我们给用户回复的也需要支持不同的类型。另外,微信对回调消息的响应时间做了规定,不能超过 5 秒,当我们的业务需要对用户消息处理较长时间再给予答复时,得采用另外的手段来进行消息的回复

解析多种类型的用户消息

我们查看 接收普通消息 文档可以得知,当用户发送的消息为图片、语音、视频等媒体类型的消息时,微信给我们的网关的回调信息中,会有一个 mediaId

微信会将用户消息以 临时素材 的形式存储,并提供 API 让开发者获取该媒体文件。通过 获取临时素材 文档可以发现,除了 mediaId,我们还需要一个 access_token 用于请求该接口

关于 access_token 此文档 已经做了非常详细的说明,我们需要注意以下的信息

  • access_token 每次获取均会重新生成,我们不能同时有两个不同的服务来维护它
  • access_token 获取的频率有限制,我们需要进行缓存,超过一定频率的调用会被阻止,导致业务无法正常使用
  • 需要在公众号后台配置 ip 白名单,以满足安全认证的需求。如果不知道我们自己服务的 ip 地址,可以直接请求一下该接口,查看微信返回的错误信息,其中就有我们的当前 ip,拿到后配置到白名单中即可

综合以上信息,我们可以固化出,微信网关调用微信 API 的流程

service/utils/cache

我们先来处理缓存,此处使用 redis 来实现,若没有 redis,则可以使用内存、本地文件或者数据库的方式。集中实现方式的对比,可参考我之前的 文章

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