Fork me on GitHub
余鸢

微信公众号开发(四):用户管理和自定义菜单以及微信网页开发

用户标签管理

一个公众号里的用户可能是来自不同地方不同行业或者不同渠道,这时为了更方便的区分用户,需要把用户归类到不同的组里。

接口

获取用户标签管理的接口地址,修改server/wechat-lib/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
const api = {
...
tags: {
create: base + 'tags/create?', // 创建标签
fetch: base + 'tags/get?', // 获取公众号已创建的标签
update: base + 'tags/update?', // 编辑标签
del: base + 'tags/delete?', // 删除标签
fetchUsers: base + 'user/tag/get?', // 获取标签下粉丝列表
batchTag: base + 'tags/members/batchtagging?', // 批量为用户打标签
batchUnTag: base + 'tags/members/batchuntagging?', // 批量为用户取消标签
getTagList: base + 'tags/getidlist?' // 获取用户身上的标签列表
}
}

实现

创建标签

1
2
3
4
5
6
7
createTag (token, name) {
const form = {
tag: {name: name}
}
const url = api.tags.create + 'access_token=' + token
return {method: 'POST', url: url, body: form}
}

获取公众号已创建的标签

1
2
3
4
fetchTags (token) {
const url = api.tags.fetch + 'access_token=' + token
return {url: url}
}

编辑标签

1
2
3
4
5
6
7
8
9
10
updateTag (token, tagId, name) {
const form = {
tag: {
id: tagId,
name: name
}
}
const url = api.tags.updateTag + 'access_token=' + token
return {method: 'POST', url: url, body: form}
}

删除标签

1
2
3
4
5
6
7
delTag (token, tagId) {
const form = {
tag: {id: tagId}
}
const url = api.tags.delTag + 'access_token=' + token
return {method: 'POST', url: url, body: form}
}

获取标签下粉丝列表

1
2
3
4
5
6
7
8
fetchTagUsers (token, tagId, openId) {
const form = {
tagid: tagId,
next_openid: openId || ''
}
const url = api.tags.fetchUsers + 'access_token=' + token
return {method: 'GET', url: url, body: form}
}

批量为用户打标签/取消标签

unTag表示判断用户打标签还是为用户取消标签

1
2
3
4
5
6
7
8
9
10
11
12
batchTag (token, openIdList, tagId, unTag) {
const form = {
openid_list: openIdList,
tagid: tagId
}
let url = api.tags.batchTag
if (unTag) {
url = api.tags.batchUnTag
}
url += 'access_token=' + token
return {method: 'POST', url: url, body: from}
}

获取用户身上的标签列表

1
2
3
4
5
getTagList (token, openId) {
const from = {openid: openId}
const url = api.tags.getTagList + 'access_token=' + token
return {method: 'POST', url: url, body: from}
}

#

接口

获取用户的接口地址,修改server/wechat-lib/index.js

1
2
3
4
5
6
7
8
9
10
11
12
const api = {
...
user: {
remark: base + 'user/info/updateremark?', // 设置用户备注名
info: base + 'user/info?', // 获取用户基本信息
batchInfo: base + 'user/info/batchget?', // 批量获取用户基本信息
fetchUserList: base + 'user/get?', // 获取用户列表
getBlackList: base + 'tags/members/getblacklist?', // 获取公众号的黑名单列表
batchBlackUsers: base + 'tags/members/batchblacklist?', // 拉黑用户
batchUnblackUsers: base + 'tags/members/batchunblacklist?' // 取消拉黑用户
}
}

实现

设置用户备注名

1
2
3
4
5
6
7
8
9
10
remarkUser (token, openId, remark) {
const form = {
tag: {
openid: openId,
remark: remark
}
}
const url = api.user.remark + 'access_token=' + token
return {method: 'POST', url: url, body: form}
}

获取用户基本信息

1
2
3
4
getUserInfo (token, openId, lang) {
const url = `${api.user.info}access_token=${token}&openid=${openId}&lang=${lang || 'zh_CN'}`
return {url: url}
}

批量获取用户基本信息

1
2
3
4
5
6
7
fetchUserList (token, userList) {
const url = api.user.batchInfo + 'access_token=' + token
const form = {
user_list: userList
}
return {method: 'POST', url: url, body: form}
}

获取用户列表

1
2
3
4
fetchUserList (token, openId) {
const url = `${api.user.fetchUserList}access_token=${token}&next_openid=${openId || ''}`
return {url: url}
}

测试

通过消息回复的形式来打印回复接口的信息。

修改代码server/middlewares/router.js

1
2
3
4
5
6
7
export const router = app => {
const router = new Router()
router.all('/wx', wechatMiddle(config.wechat, reply))
app
.use(router.routes())
.use(router.allowedMethods())
}

server/wechat/reply.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default async (ctx, next) => {
const message = ctx.weixin
let mp = require('../wechat')
let client = mp.getWechat()
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') {
if (message.Content === '1') {
const data = await client.handle('fetchUserList')
console.log(data)
}
ctx.body = message.Content
} else if (message.MsgType === 'image') {
...
}
}

测试获取用户列表fetchUserList,拿到openid,如图:

wx34

也可以测试获取用户基本信息等等其他功能,选择哪个功能修改const data = await client.handle('fetchUserList')即可,这里不一一演示了。

自定义菜单

接口

获取自定义菜单接口地址,修改server/wechat-lib/index.js

1
2
3
4
5
6
7
8
menu: {
create: base + 'menu/create?', // 创建自定义菜单
get: base + 'menu/get?', // 查询自定义菜单
del: base + 'menu/delete?', // 删除自定义菜单
addCondition: base + 'menu/addconditional?', // 创建个性化菜单
delCondition: base + 'menu/delconditional?', // 删除个性化菜单
getInfo: base + 'get_current_selfmenu_info?' // 获取自定义菜单配置
}

实现

创建自定义菜单

1
2
3
4
createMenu (token, menu) {
const url = api.menu.create + 'access_token=' + token
return {method: 'POST', url: url, body: menu}
}

查询自定义菜单

1
2
3
4
getMenu (token) {
const url = api.menu.get + 'access_token=' + token
return {url: url}
}

删除自定义菜单

1
2
3
4
delMenu (token) {
const url = api.menu.del + 'access_token=' + token
return {url: url}
}

创建个性化菜单

1
2
3
4
5
6
7
8
addConditionMenu (token, rule) {
const form = {
button: menu,
matchrule: rule
}
const url = api.menu.addCondition + 'access_token=' + token
return {method: 'POST', url: url, body: form}
}

删除个性化菜单

1
2
3
4
5
delConditionMenu (token, menuId) {
const form = {menuid: menuId}
const url = api.menu.delCondition + 'access_token=' + token
return {method: 'POST', url: url, body: form}
}

获取自定义菜单配置

1
2
3
4
getCurrentMenuInfo (token) {
const url = api.menu.getInfo + 'access_token=' + token
return {url: url}
}

测试

创建菜单,新建server/wechat/menu.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
export default {
button: [{
'name': '稻米周边',
'sub_button': [{
'name': '最新种子',
'type': 'click',
'key': 'mini_clicked'
}, {
'name': '追随稻米',
'type': 'click',
'key': 'contact'
}, {
'name': '手办',
'type': 'click',
'key': 'gift'
}]
}, {
'name': '稻米联盟',
'type': 'view',
'url': 'https://iceandfire.iblack7.com'
}, {
'name': '一起看片',
'type': 'location_select',
'key': 'location'
}]
}

删除菜单需要取消关注公众号再重新关注,这样才生效。修改reply.js

1
2
3
4
5
6
7
8
9
if (message.Content === '1') {
const data = await client.handle('batchUserInfo', userList)
console.log(data)
} else if (message.Content === '2') {
const menu = require('./menu').default
await client.handle('delMenu')
const menuData = await client.handle('createMenu', menu)
console.log(menuData)
}

测试成功,效果如图:

wx35

自定义菜单事件推送

用户可以通过菜单跟微信服务后台交互。修改menu.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
button: [{
'name': '稻米周边',
'sub_button': [{
'name': '最新种子',
'type': 'click',
'key': 'mini_clicked'
}, {
'name': '拍照',
'type': 'pic_sysphoto',
'key': 'photo'
}]
...
}

reply.js

1
2
3
4
5
6
7
8
9
10
11
12
13
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.Event === 'VIEW') {
ctx.body = message.EventKey + message.MenuId
} else if (message.Event === 'pic_sysphoto') {
ctx.body = message.Count + 'photos sent'
}
}

测试点击菜单跳转链接时的事件推送和弹出系统拍照发图的事件推送成功,控制台打印message信息。效果如图:

wx36

微信网页开发

微信网页开发主要分两大部分,第一部分是微信网页授权(比如获取用户资料),第二部分是微信JS-SDK的接口调用。

SDK config接口注入权限认证,需要调用wx.confog()传入参数,其中参数signature很重要,它是服务器直接返回的,是权限认证时最重要的参数。生成签名之前必须先了解jsapi_ticket,它是公众号用于调用微信JS接口的临时票据。生成签名之前先要获取到access_token,再通过GET请求获得jsapi_ticket才能进行签名算法,最后生成签名。

存储ticket

存储ticket和存储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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const TicketSchema = new mongoose.Schema({
name: String,
ticket: String,
expires_in: Number,
meta: {
createAt: {
type: Date,
default: Date.now()
},
updateAt: {
type: Date,
default: Date.now()
}
}
})
// 保存每条数据之前先经过中间件的处理,判断是否是新增数据
TicketSchema.pre('save', function (next) {
if (this.isNew) {
this.meta.createAt = this.meta.updateAt = Date.now()
} else {
this.meta.updateAt = Date.now()
}
next()
})
TicketSchema.statics = {
// 获取ticket
async getAccessticket () {
const ticket = await this.findOne({ name: 'ticket' }).exec()
if (ticket && ticket.ticket) {
ticket.ticket = ticket.ticket
}
return ticket
},
// 保存ticket
async saveAccessticket (data) {
let ticket = await this.findOne({ name: 'ticket' }).exec()
if (ticket) {
ticket.ticket = data.ticket
ticket.expires_in = data.expires_in
} else {
ticket = new Ticket({
name: 'ticket',
expires_in: data.expires_in,
ticket: data.ticket
})
}
await ticket.save()
return data
}
}
const Ticket = mongoose.model('Ticket', TicketSchema)

创建getTicket和saveTicket两个方法作为配置参数传给wechat构造函数生成实例,修改wechat/index.js

1
2
3
4
5
6
7
const wechatConfig = {
wechat: {
...
getTicket: async () => await Token.getTicket(),
saveTicket: async (data) => await Token.saveTicket(data)
}
}

获取ticket

新增获取ticket的方法,修改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
async fetchAccessToken () {
let data = await this.getAccessToken()
if (!this.isValidToken(data, 'access_token')) {
data = await this.updateAccessToken()
}
await this.saveAccessToken(data)
return data
}
async fetchTicket (token) {
let data = await this.getTicke()
if (!this.isValidToken(data, 'ticket')) {
data = await this.updateTicke(token)
}
await this.saveTicke(data)
return data
}
async updateTicket (token) {
const url = api.ticket.get + 'access_token=' + token + '&type=jsapi'
let 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
}

签名算法

生成签名,修改wechat-lib/index.js

1
2
3
sign (ticket, url) {
return this.sign(ticket, url)
}

签名算法,修改wechat-lib/util.js

1
2
3
4
5
6
7
8
9
10
11
function sign (ticket, url) {
const nonce = createNonce()
const timestamp = createTimestamp()
const signature = signIt(nonce, ticket, timestamp, url)
return {
noncestr: nonce,
timestamp: timestamp,
signature: signature
}
}

把这些参数配置到网页上,这样就能够调用jsapi_ticket权限。

API封装,签名流程

新建server/api文件,用于封装底层数据交互的方法,做底层服务(包括数据操作),api下新建wechat.js用于微信相关的api调用。通过getWechat()拿到实例

server/api/wechat.js

1
2
import {getWechat} from '../wechat'
const client = getWechat()

调用getAccessToken()获取access_token,拿到token后调用getTicket(token)获取ticket,调用sign(ticket, url),获取signature

1
2
3
4
5
6
7
8
9
10
11
export async function getSignatureAsync (url) {
const data = await client.getAccessToken()
const token = data.access_token
const ticketData = await client.getTicket(token)
const ticket = ticketData.ticket
let params = client.sign(ticket, url)
params.appId = client.appID
return params
}

新建server/controllers/wechat.js,放微信业务相关的控制逻辑

1
2
3
4
5
6
7
8
9
10
11
import * as api from '../api'
export async function signature (ctx, next) {
const url = ctx.query.url
if (!url) ctx.throw(404)
const params = await api.getSignatureAsync(url)
ctx.body = {
success: true,
params: params
}
}

从浏览器端发送异步请求,拿到认证权限的参数,修改server/middlewares/router.js

1
router.get('/wx-signature', signature)

测试页面

引入js文件,修改nuxt.config.js

1
2
3
4
5
6
7
8
head: {
...
scripts: [
{
src: 'http://res.wx.qq.com/open/js/jweixin-1.2.0.js'
}
]
},

pages/about.vue

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
<template>
<section class="container">
<img src="../static/img/logo.png" alt="Nuxt.js Logo" class="logo" />
</section>
</template>
<script>
import { mapState } from 'vuex'
export default {
asyncData({ req }) {
return {
name: req ? 'server' : 'client'
}
},
head() {
return {
title: `测试页面`
}
},
beforeMount () {
const wx = window.wx
const url = window.location.href
this.$store.dispatch('getWechatSignature', url).then(res => {
if (res.data.success) {
const params = res.data.params
wx.config({
debug: true,
appId: params.appId,
timestamp: params.timestamp,
nonceStr: params.nonceStr,
signature: params.signature,
jsApiList: [
'onMenuShareTimeline',
'chooseImage',
'previewImage',
'uploadImage',
'downloadImage',
'hideAllNonBaseMenuItem',
'showMenuItems'
]
})
wx.ready(() => {
wx.hideAllNonBaseMenuItem()
console.log('success')
})
}
})
}
}
</script>

利用vuex的管理机制获取ticket,store/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import Vuex from 'vuex'
import actions from './actions'
import mutations from './mutations'
import getters from './getters'
const createStore = () => {
return new Vuex.Store({
state: {},
getters,
actions,
mutations
})
}
export default createStore

获取ticket,store/action.js

1
2
3
4
5
6
7
import Services from './service'
export default {
// 签名
getWechatSignature({ commit }, url) {
return Services.getWechatSignature(url)
}
}

store/service.js

1
2
3
4
5
6
7
8
import axios from 'axios'
const baseUrl = ''
class Services {
getWechatSignature(url) {
return axios.get(`${baseUrl}/wx-signature?url=${url}`)
}
}
export default new Services()

生成签名流程:通过this.$store.dispatch()派出去getWechatSignture,触发store里的状态变更,去调用service发出当前网站目录下的signature请求,传入url作为参数,这个请求会被middlewares目录下router.js截获,把这个请求交给signature。在controllers/wechat.js里signature拿到传过来的参数url,调用生成签名的api.getSignatureAsync()方法,把签名值以异步的形式返回给about页面。拿到signature后通过wx.config()注入权限验证,验证通过后可以做相关的业务操作。

测试通过,成功打印返回信息,如图:

wx37

微信网页授权机制

微信网页授权是基于OAuth2.0一套认证体系是完全独立的,不仅在微信中可以使用在其他网站也可以使用,比如微博、github或者自己的网站都可以集成这套机制,让用户手动同意之后会获取凭证,凭证会帮用户登录也会帮服务器获得用户的基本信息,它和全局票据access_token完全不一样,只是名字一样其他的都不一样。另外,官方虽然提供了access_token的刷新机制,但是如果不是追求更完备的流程或体验的话,完全可以无视这个刷新机制,它只会让你在初次接触授权时开发量增加,索性就不要去实现它,每次直接重亲获取新的access_token就行。如果是每次都获取新的token,而且官网也没有设置调用门槛限制,也不需要去保存这个token,也不用关心它和用户是一对一或者一对多的关系。就每次让用户同意授权,拿到code获取token,再用token来读取资料 。

注意,必须是认证过的服务号才能从网页中通过OAuth2.0的认证机制获取用户的信息,订阅号无论是认证还是不认证都是不行的。

分析请求后端流程:

比如用户访问页面/a,访问a页面时后端收到请求,根据a页面的url拼接成跳转地址,这个跳转的地址是微信里的地址,接着在用户点击同意授权的按钮,就会发生第二次跳转页面,同时除了拼接地址之外还会追加一些额外参数,在跳转后的页面拿到微信传过来的code、state,state是从用户访问的页面/a传过来的参数。拿到code后获取access_token、openid,有了access_token和openid就能获取用户信息。

简单的说要完成微信网页授权,并获取用户信息要完成以下3步骤。

  1. 用户授权并获取code
  2. 使用code换取access_token
  3. 使用access_token获取用户信息

增加网页授权的请求地址,新建server/wechat-lib/oauth.js

1
2
3
4
5
6
7
const base = 'https://api.weixin.qq.com/sns/'
const api = {
authorize: 'https://open.weixin.qq.com/connect/oauth2/authorize?',
accessToken: base + 'oauth2/access_token?',
userInfo: base + 'userinfo?'
}

创建WechatOAuth实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class WechatOAuth {
constructor (opts) {
this.appID = opts.appID
this.appSecret = opts.appSecret
}
async request (options) {
options = Object.assign({}, options, {json: true})
try {
const response = await request(options)
return response
} catch (error) {
console.error(error)
}
}
}

获取code

1
2
3
4
getAuthorizeURL (scope = 'snsapi_base', target, state) {
const url = `${api.authorize}appid=${this.appID}&redirect_uri=${encodeURIComponent(target)}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`
return url
}

微信授权分两种类型,一种是静默授权snsapi_base(直接跳转,只能获取用户openid),另一种是手动授权snsapi_userinfo(手动获取用户信息),这里使用snsapi_base, target为跳转的地址,state为需要传递的参数

获取access_token

1
2
3
4
5
6
async fetchAccessToken (code) {
const url = `${api.accessToken}appid=${this.appID}&secret=${this.appSecret}&code=${code}&grant_type=authorization_code`
const data = await this.request({url: url})
return data
}

获取用户信息

1
2
3
4
5
6
async getUserInfo (token, openID, lang='zh_CN') {
const url = `${api.userInfo}access_token=${token}&openid=${openID}&lang=${lang}`
const data = await this.request({url: url})
return data
}

增加路由

修改router.js

1
2
3
4
5
6
7
8
9
10
11
export const router = app => {
const router = new Router()
router.all('/wx', wechatMiddle(config.wechat, reply))
router.get('/wx-signature', signature)
router.get('/wx-redirect', redirect)
router.get('/wx-oauth', oauth)
app
.use(router.routes())
.use(router.allowedMethods())
}

router.get('/wx-redirect', redirect)帮用户跳转到另一个地址,router.get('/wx-oauth', oauth)跳转后通过授权机制获取用户信息。

跳转

拼接跳转的目标地址,把用户重定向到这个地址,修改server/controllers/wechat.js

1
2
3
4
5
6
7
8
9
10
export async function redirect (ctx, next) {
const target = config.SITE_ROOT_URL + '/oauth'
const scope = 'snsapi_userinfo'
const {a, b} = ctx.query
const params = `${a}_${b}`
const url = api.getAuthorizeURL(scope, target, params)
ctx.redirect(url)
}

SITE_ROOT_URL:网站的根域名

server/api/index.js

1
2
import {getSignatureAsync, getAuthorizeURL, getUserByCode} from './wechat'
export {getSignatureAsync, getAuthorizeURL, getUserByCode}

生成跳转的url,修改server/api/wechat.js

1
2
3
4
export function getAuthorizeURL (...args) {
const oauth = getOAuth()
return oauth.getAuthorizeURL(...args)
}

OAuth实例,修改server/wechat/index.js

1
2
3
4
export const getOAuth = () => {
const oauth = new WechatOAuth(wechatConfig.wechat)
return oauth
}

接收Oauth,修改server/conrollers/wechat.js

1
2
3
4
5
6
7
8
9
10
11
12
13
export async function oauth (ctx, next) {
let url = ctx.query.url
url = decodeURIComponent(url)
const urlObj = urlParse(url)
const params = queryParse(urlObj.query)
const code = params.code
const user = await api.getUserByCode(code)
console.log(user)
ctx.body = {
success: true,
data: user
}
}

通过code获取用户信息,修改server/api/wechat.js

1
2
3
4
5
6
7
8
export function getUserByCode (code) {
// oauth实例
const oauth = getOAuth()
const data = await oauth.fetchAccessToken(code)
const openid = data.openid
const user = await oauth.getUserInfo(data.access_token, openid)
return user
}

测试页面

新建oauth.vue

1
2
3
4
5
6
7
8
9
beforeMount() {
const url = window.location.href
this.$store.dispatch('getUserByOAuth', encodeURIComponent(url)).then(res => {
if (res.data.success) {
console.log(res.data)
}
})
}

复制about.vue里的代码到oauth.vue,只修改beforeMount()里的代码。

store/actions.js

1
2
3
getUserByOAuth({ commit }, url) {
return Services.getUserByOAuth(url)
}

store/services.js

1
2
3
getUserByOAuth(url) {
return axios.get(`${baseUrl}/wx-oauth?url=${url}`)
}

增加域名,修改server/config/index.js

1
2
3
export default {
SITE_ROOT_URL: 'http://nuxtssr.ngrok.cc',
}

在测试号里增加域名,如图:

wx38

注意:一定要修改网页授权获取去用户基本信息,不然就不能在本地通过微信测试。

在微信开发者工具中输入http://nuxtssr.ngrok.xiaomiqiu.cn/wx-redirect?a=1&b=2,返回success和用户信息。