背景描述
小程序答题签到功能,为了促进日活,需要每天定时向当日未签到的用户推送消息提醒签到。
读本篇之前最好已经了解微信关于发送模板消息的相关文档:
- 模板消息指南
- 模板消息服务接口
说明: 作者也是第一次写小程序的定时模板消息功能,作为一个纯种前端攻城狮,可能在建表操作数据库等后端代码上有不严谨或不合理的地方,欢迎大佬们拍砖指正(轻拍)。本文以提供解决思路为主,仅供学习交流,如有不合理的地方还请留言哦。
实现思路
官方限制
微信小程序推送模板消息下发条件:
支付
当用户在小程序内完成过支付行为,可允许开发者向用户在 7天 内推送有限条数的模板消息 (1次支付可下发3条,多次支付下发条数独立,互相不影响)提交表单
当用户在小程序内发生过提交表单行为且该表单声明为要发模板消息的,开发者需要向用户提供服务时,可允许开发者向用户在 7天 内推送有限条数的模板消息 (1次提交表单可下发1条,多次提交下发条数独立,相互不影响)
根据官方的规则,显然用户1次触发7天内推送1条通知是明显不够用的,比如签到功能,只有用户在前一天签到情况下才能获取一次推送消息的机会,然后用于第二天向该用户发送签到提醒。倘若用户忘记了签到,系统便失去了提醒用户的权限,导致和用户断开了联系。
如何突破限制?
既然用户1次提及表单可以下发1条消息通知,且多次提交下发条数独立且互不影响。
那我们可以合理利用规则,将页面绑定点击事件的按钮都用form
表单 report-submit=true
包裹 button
form-type=submit
伪装起来,收集formId
,将formId
存入数据库中,然后通过定时任务再去向用户发送模板消息。
开发步骤
后台配置消息模板
微信公众平台->功能->模板消息->我的模板中添加模板消息,如下:
其中模板ID和关键词需要在发送模板消息的时候用到。
数据库设计
建表之前,思考一下都需要存哪些数据?
根据微信的发送消息接口templateMessage.send
可知,要给用户发送一条消息需要将touser
(即用户的openid
),form_id
需要存入数据库。
另外获取用户form_id
时的expire
(过期时间)也需要存下来,另外还需要知道form_id
是否使用以及过期的状态需要存一下。
于是表的结构为:
表: wx_save_form_id
id | open_id | user_id | form_id | expire | status |
---|---|---|---|---|---|
1 | xxxxxx | 1234 | xxxx | 1562642733399 | 0 |
sql
CREATE TABLE `wx_save_form_id` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`open_id` char(100) NOT NULL DEFAULT '',
`user_id` int(11) NOT NULL,
`form_id` char(100) NOT NULL DEFAULT '',
`expire` bigint(20) NOT NULL COMMENT 'form_id过期时间(时间戳)',
`status` int(1) DEFAULT '0' COMMENT '0 未推送 1已推送 2 过期',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=114 DEFAULT CHARSET=utf8;
表建好了,来捋一捋逻辑:
- 用户提交表单,将
open_id
,user_id
(根据自身需求存此字段),form_id
,expire
以及status=0
插入到wx_save_form_id
表中 - 开启定时任务(比如每天10:00执行),到固定时间查询表
wx_save_form_id
,拿到status=0
的数据,然后再调微信的templateMessage.send
接口给对应的用户发送提示信息 - 发送完的用户将
status
字段更新为1
,下次查询的时候讲筛选掉已发送的状态。
想想是不是漏掉点什么?
一条form_id
的过期时间是7天,那如果过期了怎么去将状态改完已过期呢?
一个解决办法是,再开一个定时任务(比如20min执行一次),去查询哪条form_id
已经过期,然后再更改状态。如果数据只存在wx_save_form_id
一张表中感觉效率会很低,不方便,也不合理。于是想到再去建立一张表:
表: wx_message_push_status
id | user_id | count | last_push |
---|---|---|---|
1 | 1234 | 5 | 20190701 |
sql
CREATE TABLE `wx_message_push_status` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`count` int(11) NOT NULL DEFAULT '1' COMMENT '可推送消息次数',
`last_date` bigint(20) NOT NULL DEFAULT '0' COMMENT '最后一次推送消息时间',
PRIMARY KEY (`id`),
UNIQUE KEY `user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;
其中 user_id
(根据自身需求,也可以是open_id
) 用户id, count
可向用户推送消息的次数 last_date
上一次推送消息的时间,用来判断当天是否再推送
再重新捋一捋逻辑:
- 用户提交表单,将
open_id
,user_id
(根据自身需求存此字段),form_id
,expire
以及status=0
插入到wx_save_form_id
表中,同时将wx_message_push_status
表中的count
自身+1 - 开启定时任务(比如每天10:00执行),到固定时间查询表
wx_message_push_status
,通过筛选条件count>0
且last_date
不为当天,拿到可以推送消息的user_id
再去查询wx_save_form_id
表 - 查询条件
user_id=上面拿到的
,status=0
,expire >= 当前时间戳
,然后再调微信的templateMessage.send
接口给对应的用户发送提示信息 - 发送完的用户将
status
字段更新为1
,下次查询的时候讲筛选掉已发送的状态。 - 开启另一个定时任务(比如间隔20分钟执行一次),先去查询
wx_save_form_id
,筛选条件status=0
且exprie<当前时间戳
(即未发送,且过期的数据) - 将筛选到的数据
status
改为2,且查询wx_message_push_status
表对应的user_id
,将count
自身减1。
完美结束。
理清开发逻辑,就准备动手写码
代码实现
前端页面
页面的 form
组件,属性 report-submit
为 true
时,可以声明为需要发送模板消息,此时点击按钮提交表单可以获取 formId
demo.wxml
可以将页面中的绑定事件都用form
组件来伪装,换取更多的formId
。
注: 获取form_id
必须在真机上获取,模拟器会报the formId is a mock one
;
demo.js
Page({
...
uploadFormId(e){
//上传form_id 发模板消息
wx.request({
url: 'xx/xx/uploadFormId',
data: {
form_id: e.detail.formId
}
});
}
...
})
服务端接口
server.js //node中间层 去调底层接口
async updateFormIdAction(){
/*
*我们的userId和openId是存在server端,不需从前端传回。
*不必纠结接口的实现语法,和自身框架有关。
*/
const {ctx} = this;
const user = ctx.user;
const userId = user ? user.userId : '';
const loginSession = ctx.loginSession;
const body = ctx.request.body;
let openId = loginSession.getData().miniProgram_openId || '';
const result = await this.callService('nodeMarket.saveUserFormId', openId, userId, body.form_id);
return this.json(result);
}
底层接口以及定时任务
service.js //Node 操作数据库接口
const request = require('request');
/*
* 根据用户userId openId 保存用户的formId
* 存储formId的表 wx_save_form_id
*/
async saveUserFormIdAction(){
const http = this.http;
const req = http.req;
const body = req.body;
//7天后过期时间戳
let expire = new Date().getTime() + (7 * 24 * 60 * 60 *1000);
const sql = `INSERT INTO wx_save_form_id (open_id, user_id, form_id, expire) VALUES(${body.openId}, ${body.userId}, ${body.formId}, ${expire}) `;
//自行封装好的mysql实例
let tmpResult = await mysqlClient.query(sql);
let result = tmpResult.results;
if (! result || result.affectedRows !== 1) {
...
}
await this._updateMessagePushStatusByUserId(body.userId);
return this.json({
status: 0,
message: '成功'
});
}
// 更新用户可推送消息次数
_updateMessagePushStatusByUserId(user_id){
const http = this.http;
try{
const selectSql = `SELECT user_id, count from wx_message_push_status WHERE user_id = ${user_id}`;
let temp = await mysqlClient.query(sql);
let result = temp.results;
if(result.length){
//有该user_id的记录 则更新数据
const updateSql = `UPDATE wx_message_push_status SET count = count + 1 WHERE user_id = ${user_id}`;
await mysqlClient.query(sql);
...
}else {
//无记录 则插入新的记录
const insertSql = `INSERT INTO wx_message_push_status user_id VALUES $(user_id)`;
await mysqlClient.query(sql);
...
}
}catch(err){
...
}
}
//发送消息的定时任务
async sendMessageTaskAction(){
const http = this.http;
const Today = utils.getCurrentDateInt(); //当天日期 返回YYYYMMDD格式 具体实现忽略
//筛选count>0 且当天没有推送过的user_id
const selectCanPushSql = `select user_id from wx_message_push_status WHERE count > 0 AND last_date != ${Today}`;
let temp = await mysqlClient.query(selectCanPushSql);
let selectCanPush = temp.results;
if(selectCanPush.length){
selectCanPush.forEach(async (record)=>{
try{
let user_id = record.user_id;
//筛选出 status = 0, 且formId未过期 且 过期时间最近的数据
const currentTime = new Date().getTime();
const getFormIdSql = `select open_id, user_id, form_id from wx_save_form_id WHERE user_id = ${user_id} AND status = 0 AND expire >= ${currentTime} AND form_id != 'the formId is a mock one' ORDER BY expire ASC`;
let getFormIdTemp = await mysqlClient.query(getFormIdSql);
//获取可用的form_id列表
let getUserFormIds = getFormIdTemp.results;
//取出第一条可用的formId记录 发送消息
const { open_id, form_id } = getUserFormIds[0];
let sendStatus = await this._sendMessageToUser(open_id, form_id);
/*
*发送完消息之后
* 无论成功失败 将这条form_id置为已使用 最后推送时间为当天
* 将可发消息次数减1
*/
let updateCountSql = `UPDATE wx_message_push_status SET count = count - 1, last_date = ${Today} WHERE count >0 AND user_id = ${user_id}; ` ;
await mysqlClient.query(updateCountSql);
let updateStatusSql = `UPDATE wx_save_form_id SET status = 1 WHERE user_id = ${user_id} AND open_id = ${open_id} AND form_id = ${form_id}`;
await mysqlClient.query(updateStatusSql);
...
}catch(err){
...
}
});
}
this.json({
status: 0
});
}
//发送模板消息
_sendMessageToUser(open_id, form_id){
let accessToken = await this._getAccessToken();//获取token方法省略
const oDate = new Date();
const time = oDate.getFullYear() + '.' + (oDate.getMonth()+1) + '.' + oDate.getDate();
if(accessToken){
const url = `https://api.weixin.qq.com/cgi-bin/message/wxopen/template/send?access_token=${accessToken}`;
request({
url,
method: 'POST',
data: {
access_token,
touser: open_id,
form_id,
page: 'pages/xxx/xxx',
template_id: '你的模板ID',
data: {
keyword1: {
value: "日领积分"
},
keyword2: {
value: '已经连续答题N天,连续答题7天有惊喜,加油~'
},
keyword3: {
value: "叮!该签到啦~锲而不舍,金石可镂。"
},
keyword4: {
value: time
}
}
}
},(res)=>{
...
})
}
}
/*
* 检查wx_save_form_id表中的 expire字段是否过期,如果过期则将status 置为2 并且
* 对应的 wx_message_push_status表中的count字段减1
*/
async amendExpireTaskAction(){
let now = new Date().getTime();
try {
//筛选已经过期且未使用的记录
const expiredSql = `select * from wx_save_form_id WHERE status = 0 AND expire < ${now}`;
let expiredTemp = await mysqlClient.query(expiredSql);
let expired = expiredTemp.results;
if (expired.length){
expired.forEach(async (record)=>{
//将过期的记录状态更新我为2
const updateStatusSql = `UPDATE wx_save_form_id SET status = 2 WHERE open_id = '${record.open_id}' AND user_id = ${record.user_id} AND form_id = '${record.form_id}' `;
await mysqlClient.query(updateStatusSql);
//将推送次数减1
let updateCountSql = `UPDATE wx_message_push_status SET count = count - 1 WHERE count >0 AND user_id = ${record.user_id}; ` ;
await mysqlClient.query(updateCountSql);
});
}
}catch (e) {
}
this.json({
status: 0
});
}
执行定时任务发送消息
呼~ 完整代码码完了。
大概思路是这样的,操作数据库没有考虑性能问题,如果数据量大会出现的问题,也没有考虑事务,索引等操作(主要是不会T_T),读者可以自行优化。
最后需要开两个定时任务分别执行sendMessageTask
接口和amendExpireTask
接口,我们的定时任务也是找的开源的node框架,具体实现不陈述。
最终效果:
参考文献
突破微信小程序模板消息限制,实现无限制主动推送
人人贷大前端技术博客中心
最后广而告之。
欢迎访问人人贷大前端技术博客中心
里面有关nodejs
react
reactNative
小程序 前端工程化等相关的技术文章陆续更新中,欢迎访问和吐槽~
上一篇: 小程序打怪之在线客服自动回复功能(node版)
上上一篇: 微信小程序踩坑指南