微信公众号制作记账功能(一)

一直想写一个记账软件,但界面的设计真让我头大。这不,刚买了个服务器,那用这个当后台存储,微信公众号菜单作为界面交互,练个手?

我的服务器只有512M内存,10G硬盘,跑Java就别想了。刚刚看自己的SpringBoot应用跑在自己电脑上就占了479M内存,这还没说Mysql呢,服务器还挂着SSR,没办法,不能用Java来开发了。

从网上查找Java、Python、nodejs、php的性能比较,都说nodejs性能最好,那就玩nodejs喽(当然我在python上纠结过,不过nodejs一是比较熟悉,而是自己想实现的聊天机器人可以通过API调用来实现,也就是python能做的nodejs都有替代方案,性能又好,那必须选它了)

一、环境搭建

1、安装express

这里就参考Express官方文档一步步操作的。

不过官方文档稍微有点啰嗦,说了前一步,结果后面告诉你前面的方法太Low了。所以这里我就把关键部分再描述一遍。

全局安装Express脚手架:

1
$ yarn global add express-generator

创建项目:

1
$ express --view=pug myapp

如果不输入项目名[myapp],就会在当前目录生成各种文件。

如果不输入–view=pug,控制台会报错jade模板不能用之类的事,我怂,我输入。

进入项目,安装依赖并运行:

1
2
3
$ cd myapp
$ yarn
$ yarn start

2、控制台输出端口信息

这时控制台是很干巴巴的,对于就喜欢看控制台不停输出信息的Java开发者来说,难受吖。

打开/bin/www,找到末尾的onListening方法,这样进行改动:

1
2
3
4
5
function onListening() {
// ...
debug('Listening on ' + bind);
console.log('Listening on ' + bind)
}

然后控制台就会输出Listening on port 3000了,舒服了舒服了。

3、代码自动刷新

习惯了vuejs的自动刷新,每每改完文件还要Ctrl + CY是真的痛苦。还好我有~

nodemon

首先全局安装nodemon:

1
$ yarn global add nodemon

打开package.json,修改scripts

1
"start": "nodemon ./bin/www"

依旧是原来的配方,yarn start启动项目,舒服了舒服了。

二、与微信服务器“配对”

申请微信公众号之后,在开发者工具中找到公众平台测试账号,填写接口配置信息。

当然现在点击保存肯定是提示配置失败的,下面我们开始配置自己的Express服务器来正确响应微信服务器的消息。

/routes/目录下新建wx.js文件,直接把user.js的内容复制过来。打开app.js,仿照user.js导入路由的方式:

1
2
var wxRouter = require('./routes/wx')
app.use('/cqm/wx', wxRouter)

然后在测试号的页面再次点击保存,这次不是为了配对成功,而是查看接口会传来什么参数,然后我们需要验证这是微信服务器发来的消息。

此时需要安装一个模块用来加密信息:

1
$ yarn add jssha --dev

然后就是根据传来的参数验证信息,成功后把echostr字段返回给微信服务器。

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
// 微信服务器认证
router.get('/', function(req, res, next) {
// 1 获取微信服务器请求参数
var signature = req.query.signature // 加密签名
var timestamp = req.query.timestamp // 时间戳
var nonce = req.query.nonce // 随机数
var echostr = req.query.echostr // 随机字符串
console.log(`接收到微信服务器认证请求`)
// 2 将token timestamp nonce 按字典序排序
var array = [config.token, timestamp, nonce]
array.sort()
// 3 将参数加密
var tempStr = array.join('')
var shaObj = new jsSHA('SHA-1', 'TEXT')
shaObj.update(tempStr)
var scyptoStr = shaObj.getHash('HEX')
// 4 将加密后的字符串与signature对比,相同则表示验证成功
if (signature === scyptoStr) {
console.log('验证成功')
res.send(echostr)
} else {
console.log('验证失败')
res.send('验证失败')
}
});

保存后在微信测试号里再次保存配置信息,就可以保存成功了。

我在测试时用到了一个公司的反向代理工具,把自己电脑映射到服务器的指定网址了。其实原理是差不多的,你只要有一台自己的服务器,在上面配置nginx反向代理,映射到你本机,就可以实现开发环境与微信服务器联调了。

三、响应用户发来的消息

微信服务器会把用户发送的消息以post方式发送到刚刚验证的接口,同样的,我们需要正确回复微信服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 微信服务器消息接收
router.post('/', (req, res, next) => {
var signature = req.query.signature
var timestamp = req.query.timestamp
var nonce = req.query.nonce
var openid = req.query.openid
// 需要回复success微信才会认为服务器已经收到消息
var to = `<ToUserName>${openid}</ToUserName>`
var from = `<FromUserName>${config.userName}</FromUserName>`
var time = `<CreateTime>${timestamp}</CreateTime>`
var type = `<MsgType>text</MsgType>`
var msg = `<Content>你好,我在努力进步中哦...</Content>`
res.send(`<xml>${to}${from}${time}${type}${msg}</xml>`)
})

然后手机关注自己的测试号并随便发送一个消息,就可以看到自己的回复了:

还是很神奇的,不过到这一步只是简单实现了回复消息,实际上我们没有识别用户的输入,也没有做其他复杂的机制,比如5秒内无法回复时要先返回success告知微信服务器我收到消息了,还要应对微信服务器的3次重试机制。

下面就先实现识别用户发送消息的功能:

在nodejs中,接收post请求参数的方式和Java的不一样,Java直接就可以获取了,而nodejs还要等待数据传输完毕。因此我们就需要先监听数据传输事件,并将数据保存到一个buffer中,然后监听接收完成的事件,接着才是处理数据及返回消息的步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
	var buffer = []
// 监听data事件 用于接收数据
req.on('data', data => {
buffer.push(data)
})
// 监听end事件,用于处理接收完成的数据
req.on('end', () => {
console.log(Buffer.concat(buffer).toString('utf-8'))
// 需要回复success微信才会认为服务器已经收到消息
var to = `<ToUserName>${openid}</ToUserName>`
var from = `<FromUserName>${config.userName}</FromUserName>`
var time = `<CreateTime>${timestamp}</CreateTime>`
var type = `<MsgType>text</MsgType>`
var msg = `<Content>你好,我在努力进步中哦...</Content>`
res.send(`<xml>${to}${from}${time}${type}${msg}</xml>`)
})

用惯了Java的我,发现不能直接获取post请求参数之后,我还傻傻地去找转换工具,结果看到了一个body-parser,用了之后发现没效果。算了算了,入乡随俗。

这样修改之后,就可以获取到用户发来的消息了:

下面就是解析信息了(看到请求消息才明白官方文档的![CDATA[]]是不能省略的,但竟然识别出了我的回复,强吖)

安装fast-xml-parser

1
$ yarn add fast-xml-parser

解析xml数据:

1
2
3
4
5
var xmlParser = require('fast-xml-parser')
var xml = Buffer.concat(buffer).toString('utf-8')
console.log(xml)
var json = xmlParser.convertToJson(xmlParser.getTraversalObj(xml))
console.log(json)

然后,我们熟悉的json就又回来啦:

四、自定义菜单

被动回复我们现在可以做到了,下面就是要主动向微信服务器发送消息告诉它我们要设置菜单项!

1、获取access_token

与微信服务器的很多交互都需要用到access_token的,而access_token有7200秒(2小时)的有效期,那我们需要做的,就是在项目启动后获取一次access_token,并且启动一个定时器,每隔7000秒重新获取一次。

因为习惯了vue.js的开发模式,我这里在项目根目录下新建了/api/wx.js文件,专门负责发送微信请求。在请求库的选择上,因为我们现在是服务端,不可能只请求一个域名,那么用axios设置baseUrl的方式就不是特别合适,就直接用request库来发送请求就好了,每次请求都定义一下url,也没有特别麻烦,而且我看很多人都这么用,那应该不会错了。

安装requestquerystring

1
2
$ yarn add request
$ yarn add querystring

其中querystring是将json数据转换为url请求参数的库

然后开始编写请求方法:

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
var config = require('../config/index')
var qs = require('querystring')
var request = require('request')

// 获取AccessToken
const getAccessToken = () => {
const params = {
'grant_type': 'client_credential',
'appid': config.appid,
'secret': config.appsecret
}
const url = `${config.wxUrl}cgi-bin/token?${qs.stringify(params)}`
// console.log('getAccessToken请求地址为', url)
return new Promise((resolve, reject) => {
request({
method: 'GET',
url: url
}, (err, res, body) => {
if (res) {
resolve(JSON.parse(body))
}else{
reject(err)
}
})
})
}

打开/bin/www

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var wxAPI = require('../api/wx')
function onListening() {
// ...
var getToken = () => {
wxAPI.getAccessToken().then(data => {
global.access_token = data.access_token
console.log(global.access_token)
}).catch(error => {
console.log(error)
})
}
getToken()
// 每隔7000秒重新获取一次access_token
setInterval(() => {
getToken()
}, 7000 * 1000);
}

然后重新运行项目,就会在控制台打印出我们获取到的token,这里将access_token保存到了global全局对象中,方便其他地方使用。

nodemon好像不会监听www文件的修改,所以需要我们重新启动服务。

2、配置自定义菜单

有了access_token,我们就可以放心地向微信服务器发送请求了。但在此之前,我们还没有定义自己的菜单呢。我这里将菜单保存为json格式,同时也是熟悉一下文件操作:

/config/wxmenus.json

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
{
"button": [
{
"name": "记账",
"sub_button": [
{
"type": "click",
"name": "收入",
"key": "add_money"
},
{
"type": "click",
"name": "支出",
"key": "sub_money"
},
{
"type": "click",
"name": "账单",
"key": "bill"
},
{
"type": "click",
"name": "预算",
"key": "wish"
}
]
},
{
"type": "click",
"name": "待办",
"key": "todo_list"
}
]
}

因为我要做记账的功能,所以就预先把菜单定义好,后面也省得再去调整了。

首先尝试一下能否正确读取出json文件:

/api/wx.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var fs = require('fs')
// 设置自定义菜单
const setMenu = () => {
return new Promise((resolve, reject) => {
// 读取json文件
fs.readFile('./config/wxmenus.json', (err, data) => {
if (err) {
reject(err)
} else {
// 得到配置的菜单信息
var m = JSON.parse(data.toString())
resolve(m)
}
})
})
}

注意这里的文件路径是基于项目路径而不是wx.js当前路径。

然后同样在www中的onListening添加调用:

1
2
3
4
5
6
// 设置微信菜单
wxAPI.setMenu().then(data => {
console.log(data)
}).catch(error => {
console.error(error)
})

重启服务就会在控制台看到我们要的结果:

下面继续修改wx.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
// 设置自定义菜单
const setMenu = () => {
return new Promise((resolve, reject) => {
// 读取json文件
fs.readFile('./config/wxmenus.json', (err, data) => {
if (err) {
reject(err)
} else {
// 得到配置的菜单信息
const menuData = data.toString()
const url = `${config.wxUrl}cgi-bin/menu/create?access_token=${global.access_token}`
request({
method: 'POST',
url: url,
form: menuData
}, (err, res, body) => {
if (err) {
reject(err)
} else {
resolve(JSON.parse(body))
}
})
}
})
})
}

改完这个之后还不够,因为这里用到了access_token,我们必须保证在发送菜单的这个请求时我们已经获取到了access_token,那么我们就要修改www文件:

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
var isInit = false
function onListening() {
// ...
var getToken = () => {
wxAPI.getAccessToken().then(data => {
global.access_token = data.access_token
console.log(global.access_token)
if (!isInit) {
// 设置微信菜单
wxAPI.setMenu().then(data => {
console.log(data)
}).catch(error => {
console.error(error)
})
isInit = true
}
}).catch(error => {
console.log(error)
})
}
getToken()
// 每隔7000秒重新获取一次access_token
setInterval(() => {
getToken()
}, 7000 * 1000)
}

这里就是通过一个标志位来判断获取access_token后是否还需要发送设置菜单的请求。然后取消关注测试号,再重新关注一下:

可以看到我们的菜单已经设置生效了。在这个过程中我还注意到控制台还接收到了用户关注和取消关注事件的请求,这表明这个post接口不只是用来接收消息的,应该还有其他用处。

3、接收菜单点击事件

到这一步就要参考微信官方文档给的事件类型了,然后在/routes/wx.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
// 监听end事件,用于处理接收完成的数据
req.on('end', () => {
var xml = Buffer.concat(buffer).toString('utf-8')
var json = xmlParser.convertToJson(xmlParser.getTraversalObj(xml))
console.log(json)
// 定义返回消息
var resbody = 'success'
var tempBody = {
xml: {
ToUserName: json.xml.FromUserName,
FromUserName: json.xml.ToUserName,
CreateTime: timestamp,
MsgType: 'text'
}
}
var jParser = new xmlParser.j2xParser()
// 判断消息类型
if (json.xml.MsgType === 'text') {
// 普通文字消息
tempBody.xml.Content = '你好,我在努力进步中哦...'
resbody = jParser.parse(tempBody)
} else if (json.xml.MsgType === 'event') {
// 事件类型 一共有六种事件1 关注/取消关注事件 2 扫描带参数二维码事件 3 上报地理位置事件 4 自定义菜单事件 5 点击菜单拉取消息时的事件推送 6 点击菜单跳转链接时的事件推送
if (json.xml.Event === 'CLICK') {
// 自定义菜单事件
tempBody.xml.Content = `您点击了${json.xml.EventKey}`
resbody = jParser.parse(tempBody)
} else if (json.xml.Event === 'subscribe') {
// 关注事件
tempBody.xml.Content = '欢迎关注'
resbody = jParser.parse(tempBody)
} else if (json.xml.Event === 'unsubscribe') {
// 取消关注事件 该事件不能给用户发送消息
resbody = ''
} else if (json.xml.Event === 'LOCATION') {
// 上报地理位置事件 暂时不考虑
resbody = ''
} else {
// 其他事件有点复杂,先不做处理
}
}
res.send(resbody)
})

然后取消关注测试号再重新关注,将刚刚的几种事件类型试一下,看看会不会做出正确的响应即可。

五、部署到正式的公众号

我的服务器是CentOS,并且是刚刚创建的,一片空白,那现在就需要安装nodejs环境和ftp服务了。

以前自己在Linux下安装过ftp,不过失败了。但这次!看了leoxuan的CentOS7 FTP安装与配置后,我成功了!

大家在按着这个教程来的时候用户名一定要弄对,上面截图中的一个错误就是我直接复制文章中的代码忽略了用户名才出错的。

传输文件时我这里一直无法传输,按文章中说的关闭SeLinux也不行,更改权限775不起作用,最后索性改为777才可以成功上传。

下面,在app.js中修改我们的代码,不再监听/cqm/wx了,这次翻身做主人,直接监听/wx

然后打包(压缩)项目,上传到服务器。

下面需要在服务器安装nodejs,这里我是参照CentOS7.5安装nodejs进行安装的。我选择的是第二种方法,毕竟以后全局安装插件就可以省点事了。

不过编译真的好慢啊,我写完这行字的时候它还没有编译好。。。

上面的安装对我来说完全不起作用,又参考了一篇文章在CentOS 7上安装Node.js的4种方法(包含npm),用其中的第四种方法,终于是成功了,下面我把命令粘贴一下,方便以后使用。

1
2
3
4
5
6
curl https://raw.githubusercontent.com/creationix/nvm/v0.13.1/install.sh | bash
source ~/.bash_profile
nvm list-remote
nvm install v10.16.0
node -v
npm

然后再安装我们需要的各种依赖,就可以使用yarn start启动项目了。

但我们的服务是放在3000端口的,我们需要配置nginx服务器的反向代理。

1
2
3
4
5
6
7
server {
listen 80;
server_name aqzscn.com;
location / {
proxy_pass http://127.0.0.1:3000/wx;
}
}

经过一番配置,运行,然后在微信公众号上配置自己的服务器信息(不要忘记修改Appid和Secret为公众号的),然后你就会发现,还有一大堆问题要解决。

其中ip不在白名单的问题好解决,直接在公众号上设置就好了,但下面的问题是真的没办法解决。

没有权限。。好忧桑。。兴奋了大半天,没办法搞下去吖

服务号又只能企业来做,那我只好等有钱了注册个公司搞这个了。

那最后的最后,就试一下发送消息是否成功把。

看样子是可以的,那还算是有点安慰,不枉这么长时间的辛苦。

看着这少得可怜的权限,以后怕是只能玩聊天机器人了。

总的来说,下面就要想点奇招才能完成自己的记账功能了。那么各位看官,请看下回分解。

Godbobo wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!