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

上一篇文章基本上把技术上的实现都讲了一遍,证明实现这个功能是切实可行的。下面就对建好的服务雏形进行完善和增加功能。

先放这个系列的文章:

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

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

一、默认回复

前面设置的默认回复就是简单的一句话,一点都不够智能,当然了,我再怎么改造,只要不用到聊天机器人这种功能,那就不会特别智能。但聊天机器人的API都是要收费的哎,而如果我用python在服务器上自己搭一个,那我真怕服务器会受不了这么大的压力。

那要怎么办呢?至少每次给用户的回复都不一样吧。这时我想到了hitokoto这个API,接口基本上没什么限制,每次都会返回一个新的句子,挺适合我的需求的。

下面新建/api/api.js,用于请求接口数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 此处放置有关请求API接口的代码
var request = require('request')

// 获取一言数据
const getHitokoto = () => {
return new Promise((resolve, reject) => {
request({
method: 'GET',
url: 'https://international.v1.hitokoto.cn/'
}, (err, res, body) => {
if (res) {
resolve(JSON.parse(body))
} else {
reject(err)
}
})
})
}

module.exports = {
getHitokoto
}

请求接口有了,下面就要使用这个接口,这里用到上篇文章说的链式调用处理信息。首先建立/service/default.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
var db = require('./db')
var api = require('../api/api')

// 功能分发
const doQuery = (payload) => {
if (payload.Content === '一言' || payload.Content === 'hitokoto') {
return returnHitokoto(payload, true)
} else {
return returnHitokoto(payload)
}
}

// 获取一言
const returnHitokoto = ({FromUserName, CreateTime, Content}, isTarget = false) => {
return api.getHitokoto().then(data => {
let tips = ''
if (!isTarget) {
tips = '主人我太笨了,没有明白你的意思。不过我准备了一句话,你看你喜不喜欢?\n\n'
// 构造未识别对话记录
const unknownWord = {
openid: FromUserName,
content: Content,
createTime: CreateTime
}
db.unkonwnDb.insert(unknownWord)
}
const msg = `${tips}${data.hitokoto}\n\nby ${data.from}`
return msg
})
}

module.exports = doQuery

因为获取一句话同样是一个小功能,所以就先判断是否是专门获取一句话的,如果是的话,就不再添加我不懂我不懂之类的废话了,哈哈。

然后就是链式调用的实现了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 普通文字消息
moneyManager.doQuery(json.xml).then(msg => {
if (msg) {
return msg
} else {
return defaultFilter(json.xml)
}
}).then(msg => {
// ...
resbody = jParser.parse(tempBody)
res.send(resbody)
}).catch(error => {
console.error(error)
res.send('服务器发生异常,请联系微信号aqzscn解决')
})

可以这么做的原因是如果上一个过滤器匹配到命令,它的返回值就不会为空,反之则会为空。这里当然是用的最笨的过滤器实现方式–手动添加,不过至少功能是实现了。

然后就可以尝试发送一条系统不懂的消息了:

怎么样?效果还可以吧

但这时我遇到一个问题,我请求hitokoto源是要消耗一段时间的,但微信服务器等不及呀,它就启动了它的三次重试机制。那我就只好增加一个过滤机制了,打开app.js,在系统启动时新增一个全局变量:

1
2
// 定义缓存消息id的数组,防止重复响应
global.tempMsg = []

然后在/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
// 因为系统还会请求其他服务器,返回可能会有延时,因此在这里判断并保存每个消息的openid和msgid(可能是在if语句外面,不过目前只是处理文字信息,先不考虑那么多了吧)
let mIndex = -1
global.tempMsg.some((item, index) => {
if (item.openid === openid && item.msgid === json.xml.MsgId) {
mIndex = index
return true
}
})
if (mIndex >= 0) {
res.send(resbody)
return
} else {
mIndex = global.tempMsg.length
// 添加到缓存
global.tempMsg.push({
openid: openid,
msgid: json.xml.MsgId
})
}

// ...

// 在请求执行完之后,再将缓存中的数据删除
global.tempMsg.splice(mIndex, 1)

这样就能防止重复对一个请求做出响应了。但不足的是代码实现不够优雅,后面应该增加一个前置过滤器来处理这种事情。

二、账单分页

这个功能的实现就比较简单了,预期的实现效果是:

1
2
3
4
5
请求:账单
回复:第一页账单

请求:账单2
回复:第二页账单

首先就是对功能分发的改造:

1
2
// 匹配以账单开头的命令
if (payload.Content.indexOf('账单') === 0)

查询账单时就是多了一个计算页数的过程,然后分页的逻辑nedb中已经有了,直接用就是了:

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
// 查询账单
const queryBill = ({FromUserName, CreateTime, Content}) => {
return new Promise((res, rej) => {
let pageIndex = 0
if (Content.length > 2) {
const temPage = parseInt(Content.replace('账单',''))
if (temPage) {
pageIndex = temPage
}else {
res('')
return
}
}
if (pageIndex > 0) {
pageIndex--
}
db.moneyDb.find({openid: FromUserName}).sort({recordTime: -1}).skip(pageIndex * config.billRows).limit(config.billRows).exec((err, docs) => {
let str = ''
docs.forEach(item => {
const d = dateUtil.getNowSimpleFormatDate(new Date(item.recordTime * 1000))
const s = `[${d}] ${item.type} ${item.price}元\n`
str += s
})
const msg = str ? ('您的账单如下:\n' + str + '\n回复【账单+数字】可翻页') : '没有查询到账单哦~'
res(msg)
})
})
}

然后就有了这种效果:

刚刚在正式的公众号看时间显示有点问题,待会儿看看怎么回事。

三、统计功能

统计功能的预期实现效果:

1
2
3
4
5
请求:统计
回复:当天收支情况

请求:统计2
回复:近2天收支情况

这个功能也是很简单就能实现的:

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
// 匹配统计开头的命令
fun doQuery:
if (payload.Content.indexOf('统计') === 0) {

// 统计
const tongji = ({FromUserName, CreateTime, Content}) => {
return new Promise((res, rej) => {
let dayIndex = 0
if (Content.length > 2) {
const temday = parseInt(Content.replace('统计',''))
if (temday && temday >= 0) {
dayIndex = temday
}else {
res('')
return
}
}
const now = new Date(CreateTime * 1000)
now.setDate(now.getDate() - dayIndex)
const d = dateUtil.getStartOfDay(now)
db.moneyDb.find({ recordTime: { $gt: d.getTime()/1000 }, openid: FromUserName}, (err, docs) => {
// 定义结余、收入、支出
let remain = 0
let reward = 0
let cost = 0
docs.forEach(item => {
remain += item.yk * item.price
if (item.yk > 0) {
reward += item.price
} else {
cost += item.price
}
})
const dayTxt = dayIndex === 0 ? '当天' : `近${dayIndex}天`
const msg = `${dayTxt}的收支情况如下:\n【结余】: ${remain} 元\n【收入】: ${reward} 元\n【支出】: ${cost} 元`
res(msg)
})
})
}

然后实现效果如下:

四、固定开支

我们每个月都会有固定开支,如工资、花呗、分期,这些固定的项目其实是不需要用户手动去输入的,系统完全可以帮用户做到。

预期效果:

1
2
3
4
5
6
7
8
请求:月开销预设
回复:请输入\n示例:【1工资2000】【2分期还款-500】\nTips:文字前的数字为每月几日,只支持1-28,不填写默认为1日
==================
30分钟内未回复则放弃当前指令
==================

请求:1工资1999
回复:设置成功\n每月1日工资收入1999

要实现这样的效果,就要缓存用户上一步的指令,并且要知道指令的总步骤数量及当前进行到哪一步。

既然缓存了,就要有删除的步骤,一种方式是定义一个定时器,每隔一段时间清除失效的指令。另一种方式是在用户下一次请求时判断指令是否失效,如果失效就重新开始。

这里也要考虑到如果用户一直不发下个请求呢?难道服务器要一直缓存直到内存爆炸?所以定时器一定要有,但太频繁也不好,所以还是要在读取指令前判断用户之前的指令是否失效。

其实到现在这个步骤,整个后台应用就显得有点复杂了,我们必须要把过滤器的思路贯彻到底,重新改写响应逻辑,实现路由入口处的简洁。

首先,路由入口会收到两种消息类型:普通消息及事件推送,为了区分它们,我们先建立消息类型分发器msgTypeDispatcher.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
72
73
74
75
76
77
78
79
80
81
const config = require('../config/index')
const moneyManager = require('./money')
const defaultManager = require('./default')

const doDispatch = (payload) => {
return new Promise((resolve, reject) => {
// 通过MsgType区分事件还是普通消息
if (payload.MsgType !== 'event') {
resolve(doNormalMsgPreFilter(payload))
} else {
resolve(doEventMsgPreFilter(payload))
}
})
}

// 普通消息的前置过滤器
const doNormalMsgPreFilter = (payload) => {
// 判断系统是否正在处理该消息
let mIndex = -1
global.tempMsg.some((item, index) => {
if (item.openid === payload.FromUserName && item.msgid === payload.MsgId) {
mIndex = index
return true
}
})
if (mIndex >= 0) {
// 如果正在处理,直接返回
return 'success'
} else {
mIndex = global.tempMsg.length
// 添加到缓存,表示系统正在处理
global.tempMsg.push({
openid: payload.FromUserName,
msgid: payload.MsgId
})
}
if (payload.MsgType === 'text') {
return moneyManager.doQuery(payload).then(msg => {
if (msg) {
return msg
} else {
return defaultManager.doQuery(payload)
}
}).then(msg => {
return doNormalMsgSufFilter(mIndex, msg)
})
} else {
return doNormalMsgSufFilter(mIndex, '不支持的消息类型')
}
}

// 普通消息的后置过滤器
const doNormalMsgSufFilter = (index, msg) => {
// 最终请求会走到这里,在这里从缓存中删除该消息
global.tempMsg.splice(index, 1)
return msg
}

// 事件消息的前置过滤器
const doEventMsgPreFilter = (payload) => {
// 暂时不需要对事件有额外操作,判断事件类型后直接返回结果即可
if (payload.Event === 'subscribe') {
return subscribe()
} else if (payload.Event === 'unsubscribe') {
return unsubscribe()
} else {
return '不支持的事件类型'
}
}

// 处理关注事件
const subscribe = () => {
return config.banner
}

// 处理取消关注事件
const unsubscribe = () => {
return ''
}

module.exports = doDispatch

将消息分发的逻辑抽取出来之后,/routes/wx.js接口的代码就变得非常简洁了:

1
2
3
4
5
6
7
8
msgDiapatcher(json.xml).then(msg => {
tempBody.xml.Content = msg
}).catch(error => {
console.error(error)
tempBody.xml.Content = '系统运行出错,请联系微信号aqzscn解决'
}).finally(() => {
res.send(jParser.parse(tempBody))
})

因为设定固定开支的步骤一共需要两步,那么我们就要保存上一步的状态,为了统一所有具有多个步骤的操作,新增一个步骤过滤器,在过滤器中可以直接执行相应步骤的代码。而且为了方便以后拓展,通过设定step来判断步骤执行到第几步。

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
const presetManager = require('./preset')
const garbageManager = require('./garbage')

const doFilter = (payload) => {
// 判断用户是否正在执行上一步操作
let index = -1
global.stepMsg.some((item, idx) => {
if (item.openid === payload.FromUserName) {
index = idx
return true
}
})
if (index < 0) {
return ''
} else if (payload.Content === 'q') {
// 从缓存中移除该命令
global.stepMsg.splice(index, 1)
return '已恢复正常模式'
}
// 进入到指定的命令中
return new Promise((resolve, reject) => {
// 更新步骤消息的最后时间
global.stepMsg[index].time = Math.ceil(new Date().getTime() / 1000)
switch (global.stepMsg[index].command) {
case 'setKz':
const msg = presetManager.setKz(payload, global.stepMsg[index].step)
// 从缓存中移除该命令
global.stepMsg.splice(index, 1)
resolve(msg)
break;
case 'queryGarbage':
resolve(garbageManager.queryStep(payload, global.stepMsg[index].step))
break;
default:
resolve('')
}
})
}

module.exports = {
doFilter
}

建立完成后在消息分发器中调用:

1
2
3
4
5
6
if (payload.MsgType === 'text' || payload.Content) {
// 首先执行步骤过滤器
const filterRes = stepFilter.doFilter(payload)
if (filterRes) {
return filterRes
}

经过上面的处理,步骤消息就能到达指定的方法中,并且该方法也不必去管怎么去缓存当前步骤,怎么去删除缓存的事情,在我看来还算是一个较好的解决方案。下面把处理固定开支的代码放上:

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
72
73
74
75
76
77
78
79
80
const db = require('./db')

const doQuery = (payload) => {
if (payload.Content === '月收支预设') {
return setKz(payload)
} else if (payload.Content === '查询收支预设') {
return queryPreset(payload)
} else {
return ''
}
}

// 预设开支
const setKz = ({FromUserName, CreateTime, Content}, index = 0) => {
let msg = ''
switch (index) {
case 0:
// 将该用户的命令设置到缓存中
global.stepMsg.push({
openid: FromUserName,
step: 1,
command: 'setKz',
time: CreateTime
})
msg = '请输入\n示例1:【1工资2000】\n示例2:【2分期还款-500】\nTips:文字前的数字为每月几日,只支持1-28,不填写默认为1日'
break;
case 1:
const dayReg = Content.match(/^\d{1,2}/)
const titleReg = Content.match(/[^\d-]+/)
const priceReg = Content.match(/-?\d+$/)
if (titleReg && priceReg) {
const day = dayReg ? parseInt(dayReg[0]) : 1
const title = titleReg[0]
const price = parseInt(priceReg[0])
if (day < 1 || day > 28) {
msg = '为保证程序正常执行,只允许在每月1日到28日设置固定收支'
} else {
const kz = {
openid: FromUserName,
day: day,
title: title,
money: price
}
db.presetDb.insert(kz)
msg = `设置成功!\n将于每月${day}日自动添加账单【${title} ${price}元】`
}
} else {
msg = '信息匹配失败,请检查是否符合【1工资2000】的格式'
}
break;
default:
msg = '预设收支命令匹配失败'
break;
}
return msg
}

// 查询收支预设
const queryPreset = ({FromUserName}) => {
return new Promise((resolve, reject) => {
db.presetDb.find({openid: FromUserName}).sort({day: 1}).exec((err, docs) => {
if (docs) {
let msg = '您的预设信息如下:\n'
docs.forEach(item => {
const zy = item.money > 0 ? '增加' : '扣除'
const money = Math.abs(item.money)
msg += `每月${item.day}${zy}${item.title}${money}元】\n`
})
resolve(msg)
} else {
resolve('没有查询到您预设的收支\n回复【月收支预设】来设置一笔吧')
}
})
})
}

module.exports = {
doQuery,
setKz
}

最后的实现效果就是:

当然,这样只是实现了记录的功能,那么自动执行任务还没有做。我的预想是每晚3点定时去判断每个用户当天的收支预设信息,如果是当天,则向账单中添加记录。又因为项目中不可能只有这一个定时器,因此干脆一了百了,再做一个定时器管理的功能。

js中与定时有关的也就是setTimeoutsetInterval了,而且现在需要做的也只是定时(整点)执行任务,那么我们就可以计算当前时间距离下一个整点还有多久,然后setTimeout到下一个整点(也可以是下下个整点,计算时间并相加即可),启动setInterval执行我们真正需要执行的任务。

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
const presetTimer = require('./presetTimer')
const config = require('../../config/index')
const stepTimer = require('./stepTimer')

let i = 1

const timeDefine = config.taskTimerConfig

const init = () => {
// 测试功能
// test()
// 一天的时间 毫秒数
const dayTimeStamp = 60 * 60 * 24 * 1000
// 月固定开支
startOfHourSchedule(timeDefine.presetTime, dayTimeStamp, presetTimer.doSchedule)
// 定时清除缓存步骤消息
setInterval(() => {
stepTimer.doSchedule()
}, timeDefine.clearStepMsgTime);
}

const test = () => {
presetTimer.doSchedule()
}

/**
* 定义在每天的某个小时执行的任务
* @param {Number} targetHour 目标起始时间
* @param {Number} intervalTime 循环时间
* @param {Function} fun 要执行的函数
*/
const startOfHourSchedule = (targetHour, intervalTime, fun) => {
if (targetHour < 0 || targetHour > 23) {
return
}
const date = new Date()
const nowtimeStamp = date.getTime()
const nowHour = date.getHours()
date.setHours(nowHour + 1)
date.setMinutes(0)
date.setSeconds(0)
date.setMilliseconds(0)
const nextHourTimeStamp = date.getTime()
const diffTime = nextHourTimeStamp - nowtimeStamp
const hour = 60 * 60 * 1000
let time = 0
if (nowHour < targetHour ) {
// 当前时间小于预计时间
time = targetHour - nowHour -1
} else {
time = 24 - (nowHour - targetHour + 1)
}
console.log('将于' + Math.ceil((diffTime + hour * time) / 1000 / 60) + '分钟后执行定时任务' + i++)
// 开启24个settiemout 0-23 分别对应下n个整点时间
setTimeout(() => {
fun()
setInterval(() => {
fun()
}, intervalTime);
}, diffTime + hour * time);
}

module.exports = {
init
}

这样写了之后,我们在项目的app.js中调用这个文件的init方法即可。

然后对于具体的固定收支添加账单的逻辑就比较简单了,就不再放到上面了,有兴趣的可以到我的github上看源码。

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