Fork me on GitHub
余鸢

微信公众号开发(三):微信消息中间件和素材处理

获取token

请求官方API的地址将获取到的数据存到数据库,除此之外还有很多与微信服务器之间的交互场景,最好是把token和其他异步请求封装到一个文件里,就可以把这个文件统一看做微信请求的构造函数,一切与微信交互的功能都放到这个文件里,把它当做一个入口。

新建server/wechat-lib/index.js作为整个微信异步场景的入口文件,统一管理微信API地址。

server/wechat-lib/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import request from 'request-promise'
const base = 'https://api.weixin.qq.com/cgi-bin/'
const api = {
accessToken: base + 'token?grant_type=client_credential'
}
export default class wechat {
constructor (opts) {
this.opts = Object.assign({}, opts)
this.appID = opts.appID
this.appSecret = opts.appSecret
this.getAccessToken = opts.getAccessToken
this.saveAccessToken = opts.saveAccessToken
this.fetchAccessToken()
}
async request (options) {
options = Object.assign({}, options, {json: true})
try {
const response = await request(options)
console.log(response)
return response
} catch (error) {
console.error(error)
}
}
// 获取token
async fetchAccessToken () {
const data = await this.getAccessToken()
// 验证token是否正确
if (!this.isValidAccessToken(data)) {
return await this.updateAccessToken()
}
await this.saveAccessToken()
return data
}
// 更新token
async updateAccessToken () {
const url = api.accessToken + '&appid=' + this.appID + '&secret=' + this.appSecret
const data = await this.request({url: url})
const now = (new Date().getTime())
const expiresIn = now + (data.expires_in - 20) * 1000
data.expires_in = expiresIn
return data
}
// 验证token
isValidAccessToken (data) {
if (!data || !data.accessToken || !data.expires_in) {
return false
}
const expiresIn = data.expires_in
const now = (new Date().getTime())
if (now < expiresIn) {
return true
} else {
return false
}
}
}

定义api地址,request方法,所有的异步函数都通过它统一管理,获取access_token,更新access_token。

新建server/wechat/index.js对微信异步场景函数做初始化。传递配置参数及方法,生成wechat实例。

server/wechat/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import mongoose from 'mongoose'
import config from '../config'
import Wechat from '../wechat-lib'
const Token = mongoose.model('Token')
const wechatConfig = {
wechat: {
appID: config.wechat.appID,
appSecret: config.wechat.appSecret,
token: config.wechat.token,
getAccessToken: async () => await Token.getAccessToken(),
saveAccessToken: async() => await Token.saveAccessToken()
}
}
export const getWechat = () => {
const wechatClient = new Wechat(wechatConfig.wechat)
return wechatClient
}
getWechat()

在server/middlewares/router.js引入

1
import '../wechat'

引入这个文件就会去通过执行getWechat()创建Wechat实例new Wechat(wechatConfig.wechat),然后调用fetchAccessToken()方法获取access_token。在控制台成功打印出access_token,如图:

wx24

保存access_token

获取access_token后判断access_token是否过期,后期的话重启获取一次,再次保存access_token,修改代码:

server/wechat-lib/index.js

1
2
3
4
5
6
7
8
9
10
11
async fetchAccessToken () {
// 获取当前token
let data = await this.getAccessToken()
// 验证token是否正确
if (!this.isValidAccessToken(data)) {
data = await this.updateAccessToken()
}
await this.saveAccessToken(data)
return data
}

server/wechat/index.js

1
2
3
4
5
6
7
8
9
const wechatConfig = {
wechat: {
appID: config.wechat.appID,
appSecret: config.wechat.appSecret,
token: config.wechat.token,
getAccessToken: async () => await Token.getAccessToken(),
saveAccessToken: async (data) => await Token.saveAccessToken(data)
}
}

server/database/schema/token.js

1
2
3
4
5
6
7
8
async getAccessToken () {
const token = await this.findOne({name: 'access_token'}).exec()
if (token && token.token) {
token.access_token = token.token
}
return token
},

等数据库连接成功之后再做微信初始化,require('../wechat')的时间应该是整个项目跑起来对外提供服务的时候

server/middlewares/router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
router.get('/wx', (ctx, next) => {
require('../wechat')
const token = config.wechat.token
// 参数
const {signature, nonce, timestamp, echostr} = ctx.query
// 对参数进行排序加密
const str = [token, timestamp, nonce].sort().join('')
const sha = sha1(str)
if (sha === signature) {
ctx.body = echostr
} else {
ctx.body = 'Failed'
}
})

保存新增access_token成功,如图:

wx25

微信消息中间件

通过access_token和消息中间件结合就可以给用户回复了。无论http是get或post请求都要对query里的参数进行排序加密比对,比对正确的话进行数据分析。这里直接改成router.all(),无论是get、post还是delete都可以拿到请求。写个中间件wechatMiddle(opts, reply)

  • opts:配置参数。描述了微信公众号对access_token的外部获取方式
  • reply:回复策略。

server/middlewares/router.js

1
2
3
4
5
export const router = app => {
const router = new Router()
router.get('/wx', wechatMiddle(config.wechat, reply))
...
}

消息中间件函数,server/wechat-lib/middleware.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import sha1 from 'sha1'
import getRawBody from 'raw-body'
import * as util from './util'
export default function (opts, reply) {
return async function wechatMiddel (ctx, next) {
const token = opts.token
// 参数
const {signature, nonce, timestamp, echostr} = ctx.query
// 对参数进行排序加密
const str = [token, timestamp, nonce].sort().join('')
const sha = sha1(str)
if (ctx.method === 'GET') {
if (sha === signature) {
ctx.body = echostr
} else {
ctx.body = 'Failed'
}
} else if (ctx.method === 'POST') {
if (sha !== signature) {
ctx.body = 'Failed'
return false
}
const data = await getRawBody(ctx.req, {
length: ctx.length,
limit: '1mb',
encoding: ctx.charset
})
const content = await util.parseXML(data)
// const message = util.formatMessage(content,xml)
console.log(content)
ctx.weixin = {}
await reply.apply(ctx, [ctx, next])
const replyBody = ctx.body
const msg = ctx.weixin
// const xml = util.tpl(replyBody, msg)
console.log(replyBody)
const xml = `<xml>
<ToUserName>< ![CDATA[toUser] ]></ToUserName>
<FromUserName>< ![CDATA[fromUser] ]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType>< ![CDATA[text] ]></MsgType>
<Content>< ![CDATA[你好] ]></Content>
</xml>`
ctx.status = 200
ctx.type = 'application/xml'
ctx.body = xml
}
}
}

因为前面用的是router.all所以在使用时要对请求方法做个判断,方法匹配成功后先拿到请求过来的数据,然后把它解析成xml util.parseXML(data),再对它转换成json或者对象格式util.formatMessage(content,xml),这样就能拿到对应的key和value。解析后的数据挂到context.weixin上,在后面的代码单元就能访问到context.weixin,转义上下文reply.apply(ctx, [ctx, next])。在router.js引入middleware.js

server/middlewares/router.js

1
import wechatMiddle from '../wechat-lib/middleware'

回复策略server/wechat/reply.js

1
2
3
4
5
6
7
const tip = '亲爱的黑虎,欢迎来到青青大草原\n' + '点击 <a href="http://coding.imooc.com">一起放风筝啊</a>'
export default async (ctx, next) => {
const message = ctx.weixin
console.log(message)
ctx.body = tip
}

重启项目,微信扫公众测试号二维码

wx26

扫码之后在会出现与测试号的对话窗口,随便打个字会看到自动回复内容,如图:

wx28

同时我也在控制台打印出回复内容,如图:

wx27

问题

如果键入内容没有自动回复消息而是出现“该公众号提供的服务出现故障请稍后再试”这样的提示,可能是你的接口配置url无法启用,就需要你再次修改url。我之前用Sunny-Ngrok配置的url但是突然不能用了,于是就是换了ngrok,可以从这里下载客户端,解压即可。

在命令(cmd)行下进入到ngrok客户端目录下,执行 ngrok -config=ngrok.cfg -subdomain xxx 80 (xxx 是你自定义的域名前缀),建议批处理

wx29

如果连接成功,会提示如下信息:

wx30

微信消息解析

把xml格式的数据转成Object。先判断如果数据是object的话进行遍历,得到一个key对应的一条数据item,接着遍历某一条数据的key。判断item是否是数组或者item的长度为0,继续执行,如果item的长度为1,取出值判断如果类型是object进行深层的遍历。

server/wechat-lib/util.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function formatMessage (result) {
let message = {}
if (typeof result === 'object') {
const keys = Object.keys(result)
for (let i = 0; i < keys.length; i++) {
let item = result[keys[i]]
let key = keys[i]
if (!(item instanceof Array) || item.length === 0) {
continue
}
if (item.length === 1) {
let val = item[0]
if (typeof val === 'object') {
message[key] = formatMessage(val)
} else {
message[key] = (val || '').trim()
}
} else {
message[key] = []
for (let j = 0; j < item.length; j++) {
message[key].push(formatMessage(item(j)))
}
}
}
}
return message
}

将xml格式的数据转化为Object,挂在ctx.weixin,再将回复内容解析出来,得到解析后的微信消息,修改middleware.js

1
2
3
4
5
6
7
8
9
const content = await util.parseXML(data)
const message = util.formatMessage(content.xml)
ctx.weixin = message
await reply.apply(ctx, [ctx, next])
const replyBody = ctx.body
const msg = ctx.weixin
const xml = util.tpl(replyBody, msg)

通过套模板渲染出xml的数据。

封装回复消息模板

回复用户消息类型包括回复文本消息、回复图片消息、回复语音消息、回复视频消息、回复音乐消息、回复图文消息,这里使用ejs模板。

server/wechat-lib/tpi.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import ejs from 'ejs'
const tpl = `
<xml>
<ToUserName><![CDATA[<%= toUserName %>]]></ToUserName>
<FromUserName><![CDATA[<%= fromUser %>]]></FromUserName>
<CreateTime><%= createTime %></CreateTime>
<MsgType><![CDATA[<%= msgType %>]]></MsgType>
<% if (msgType === 'text') { %>
<Content><![CDATA[<%- content%>]]></Content>
<% } else if (msgType === 'image') {%>
...
<% } else if (msgType === 'voice') {%>
...
<% } else if (msgType === 'video') {%>
...
<% } else if (msgType === 'music') {%>
...
<% } else if (msgType === 'news') {%>
...
<% } %>
</xml>
`
const compiled = ejs.compile(tpl)
export default compiled

把所有的消息类型放到一个模板中,根据msgType的值匹配相应的模板。

重启服务,在手机上发送消息1,控制台打印消息的类型、内容以及消息id,如图:

wx31

七种微信普通消息的接收

先对消息类型进行筛选。修改reply.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export default async (ctx, next) => {
const message = ctx.weixin
console.log(message)
if (message.MsgType === 'text') {
ctx.body = message.Content
} else if (message.MsgType === 'image') {
ctx.body = {
type: 'image',
mediaId: message.MediaId
}
} else if (message.MsgType === 'voice') {
ctx.body = {
type: 'voice',
mediaId: message.MediaId
}
} else if (message.MsgType === 'video') {
ctx.body = {
title: message.ThumbMediaId,
type: 'video',
mediaId: message.MediaId
}
} else if (message.MsgType === 'location') {
ctx.body = message.Location_X + ' : ' + message.Location_Y + ' : ' + message.Label
} else if (message.MsgType === 'link') {
ctx.body = [{
title: message.Title,
description: message.Description,
picUrl: 'http://mmbiz.qpic.cn/mmbiz_jpg/8U36YTkvibWtRgIHqiacSLeSJpANdafutLibrEvXlLQNhxToj45wyj5FemhzRRppp4mC8nRFd9n4kV3vgCXXDRXicw/0',
url: message.url
}]
}
}

微信对测试号发送消息回复成功,如图:

wx32

关注/取消关注事件

关注和取消关注事件是根据MsgType区分的,但event不一样,修改server/wechat/reply.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default async (ctx, next) => {
const message = ctx.weixin
// 关注/取消关注
if (message.MsgType === 'event') {
if (message.Event === 'subscribe') {
ctx.body = tip
} else if (message.Event === 'unsubscribe') {
console.log('取消关注')
} else if (message.Event === 'LOCATION') {
ctx.body = message.Latitude + ' : ' + message.Longitude
}
} else if (message.MsgType === 'text') {
ctx.body = message.Content
}
...
}

多媒体与图文素材处理

项目中可能会用到图片上传图片处理,所以要把图像素材管理的相关接口获取到。

上传素材

增加素材系列相关的请求地址,修改server/wechat-lib/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const api = {
accessToken: base + 'token?grant_type=client_credential',
temporary: {
upload: base + 'media/upload?', // 新增临时素材
fetch: base + 'media/get?' // 获取临时素材
},
permanent: {
upload: base + 'material/add_material?', // 新增其他类型永久素材
uploadNews: base + 'material/add_news?', // 新增永久图文素材
uploadNewsPic: base + 'media/uploadimg?', // 上传图文消息内的图片
fetch: base + 'material/get_material?', // 获取永久素材
del: base + 'material/del_material?', // 删除永久素材
update: base + 'material/update_news?', // 修改永久图文素材
count: base + 'material/get_materialcount?', // 获取素材总数
batch: base + 'material/batchget_material?' // 获取素材列表
}
}

上传参数:token,类型type,素材路径material,标识永久素材还是临时素材permanent。上传素材其实就是构建表单,声明对象form,拿到上传地址url,默认是临时素材地址api.temporary.upload。如果指定是永久素材就改成永久素材地址api.permanent.upload,通过loadsh继承permanent数据,如果类型是pic修改成上传图片的地址api.permanent.uploadNewsPic,如果类型是news修改成上传图文地址api.permanent.uploadNews,,同时form修改成material。;反之,如果不是图文类型素材的话可能是图片或者视频,这时就需要构建表单了。通过formsteam()生成表单对象,声明statFile()函数读取文件大小,拿到图片的大小通过form.file()增加字段media。如果不是永久类型, uploadUrl后面追加type,反之,把access_token加到表单里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
uploadMaterial (token, type, material, permanent) {
let form = {}
let url = api.temporary.upload
if (permanent) {
url = api.permanent.upload
_.extend(form, permanent)
}
if (type === 'pic') {
url = api.permanent.uploadNewsPic
}
if (type === 'news') {
url = api.permanent.uploadNews
form = material
} else {
form = formsteam()
const stat = await statFile(material)
form.file('media', material, path.basename(material), stat.size)
}
let uploadUrl = url + 'assess_token' + token
if (!permanent) {
uploadUrl += '&type=' + type
} else {
form.field('access_token', token)
}
// 构建上传需要的对象
const options = {
method: 'POST',
url: uploadUrl,
json: true
}
if (type === 'news') {
options.body = form
} else {
options.formData = form
}
return options
}

构建上传需要的对象,如果type是图文类型news的话,设置options.body为当前form,否则上传图片的表单域,最后返回options。也就是说uploadMaterial()不会真正上传,只是配置好它要上传的参数。

封装上传动作:传进operation和参数args,拿到token和哪种类型的options,通过request()获取数据。

1
2
3
4
5
6
7
async handle (operation, ...args) {
const tokenData = await this.fetchAccessToken()
const options = this[operation](tokenData.access_token, ...args)
const data = await this.request(options)
return data
}

测试上传视频临时素材,修改router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const router = app => {
const router = new Router()
router.all('/wx', wechatMiddle(config.wechat, reply))
router.get('/upload', (ctx, next) => {
let mp = require('../wechat')
let client = mp.getWechat()
client.handle('uploadMaterial', 'video', resolve(__dirname, '../../hu.mp4'))
})
app
.use(router.routes())
.use(router.allowedMethods())
}

测试上传视频永久素材,修改router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const router = app => {
const router = new Router()
router.all('/wx', wechatMiddle(config.wechat, reply))
router.get('/upload', async (ctx, next) => {
let mp = require('../wechat')
let client = mp.getWechat()
const data = await client.handle('uploadMaterial', 'video',
resolve(__dirname, '../../hu.mp4'),
{type: 'video', description: '{"title": "haha", "introduction": "heihei"}'})
console.log(data)
console.log('#################')
})
...
}

测试上传图片永久素材,修改router.js

1
2
3
4
5
const data = await client.handle('uploadMaterial', 'image',
resolve(__dirname, '../../hu.jpg'),
{type: 'iamge'})
console.log(data)
console.log('#################')

测试上传图片临时素材,修改router.js

1
2
3
4
const data = await client.handle('uploadMaterial', 'image',
resolve(__dirname, '../../hu.jpg'))
console.log(data)
console.log('#################')

每次修改router.js后重启项目,输入localhost:8080,看到控制台打印信息,如图:

wx33

图文创建

图文素材必须是永久素材

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
router.get('/upload', async (ctx, next) => {
let mp = require('../wechat')
let client = mp.getWechat()
const news = {
articles: [
{
"title": 'SSR1',
"thumb_media_id": 'MS9ix5MrmsSADOa6HKBpofmkA-LY8ddiEljpegn2P2M',
"author": 'kakajing',
"digest": '无摘要',
"show_cover_pic": 1,
"content": '无内容',
"content_source_url": 'https://www.baidu.com/'
},
{
"title": 'SSR2',
"thumb_media_id": 'MS9ix5MrmsSADOa6HKBpofmkA-LY8ddiEljpegn2P2M',
"author": 'kakajing',
"digest": '无摘要',
"show_cover_pic": 0,
"content": '无内容',
"content_source_url": 'https://www.baidu.com/'
}
]
}
const data = await client.handle('uploadMaterial', 'news', news, {})
console.log(data)
console.log('#################')
})

获取永久素材

获取素材要区分是临时素材还是永久素材

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fetchMaterial (token, mediaId, type, permanent) {
let form = {}
let fetchUrl = api.temporary.fetch
if (permanent) {
fetchUrl = api.permanent.fetch
}
let url = fetchUrl + 'access_token=' + token
let options = {method: "POST", url: url}
if (permanent) {
form.media_id = mediaId
form.access_token = token
options.body = form
} else {
if (type === 'video') {
url = url.replace('https://', 'http://')
}
url += '&media_id' + mediaId
}
return options
}

删除永久素材

1
2
3
4
5
6
deleteMaterial (token, mediaId) {
const form = {media_id: mediaId}
const url = api.permanent.del + 'access_token=' + token + '&media_id=' + mediaId
return {method: 'POST', url: url, body: form}
}

修改永久图文素材

1
2
3
4
5
6
updateMaterial (token, mediaId, news) {
const form = {media_id: mediaId}
_.extend(form, news)
const url = api.permanent.update + 'access_token=' + token + '&media_id=' + mediaId
return {method: 'POST', url: url, body: form}
}

获取素材总数

1
2
3
4
countMaterial (token) {
const url = api.permanent.count + 'access_token=' + token
return {method: 'GET', url: url}
}

获取素材列表

1
2
3
4
5
batchMaterial (token, options) {
options.type = options.type || 'iamge'
options.offset = options.offset || 0
options.count = options.count || 10
}