微信小程序打怪之定时发送模板消息(node版)

背景描述

小程序答题签到功能,为了促进日活,需要每天定时向当日未签到的用户推送消息提醒签到。

读本篇之前最好已经了解微信关于发送模板消息的相关文档:

  1. 模板消息指南
  2. 模板消息服务接口

说明: 作者也是第一次写小程序的定时模板消息功能,作为一个纯种前端攻城狮,可能在建表操作数据库等后端代码上有不严谨或不合理的地方,欢迎大佬们拍砖指正(轻拍)。本文以提供解决思路为主,仅供学习交流,如有不合理的地方还请留言哦。😆

实现思路

官方限制

微信小程序推送模板消息下发条件:

  1. 支付
    当用户在小程序内完成过支付行为,可允许开发者向用户在 7天 内推送有限条数的模板消息 (1次支付可下发3条,多次支付下发条数独立,互相不影响)

  2. 提交表单
    当用户在小程序内发生过提交表单行为且该表单声明为要发模板消息的,开发者需要向用户提供服务时,可允许开发者向用户在 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;

表建好了,来捋一捋逻辑:

  1. 用户提交表单,将open_id,user_id(根据自身需求存此字段),form_idexpire 以及status=0插入到wx_save_form_id表中
  2. 开启定时任务(比如每天10:00执行),到固定时间查询表wx_save_form_id,拿到status=0的数据,然后再调微信的templateMessage.send接口给对应的用户发送提示信息
  3. 发送完的用户将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 上一次推送消息的时间,用来判断当天是否再推送

再重新捋一捋逻辑:

  1. 用户提交表单,将open_id,user_id(根据自身需求存此字段),form_idexpire 以及status=0插入到wx_save_form_id表中,同时将wx_message_push_status表中的count自身+1
  2. 开启定时任务(比如每天10:00执行),到固定时间查询表wx_message_push_status,通过筛选条件 count>0last_date不为当天,拿到可以推送消息的user_id再去查询wx_save_form_id
  3. 查询条件user_id=上面拿到的status=0, expire >= 当前时间戳,然后再调微信的templateMessage.send接口给对应的用户发送提示信息
  4. 发送完的用户将status字段更新为1,下次查询的时候讲筛选掉已发送的状态。
  5. 开启另一个定时任务(比如间隔20分钟执行一次),先去查询wx_save_form_id,筛选条件status=0exprie<当前时间戳(即未发送,且过期的数据)
  6. 将筛选到的数据status改为2,且查询wx_message_push_status表对应的user_id,将count自身减1。

完美结束。

理清开发逻辑,就准备动手写码

代码实现

前端页面

页面的 form 组件,属性 report-submittrue 时,可以声明为需要发送模板消息,此时点击按钮提交表单可以获取 formId

demo.wxml

<form report-submit="true" bindsubmit="uploadFormId">
    <button form-type="submit" hover-class="none" >提交</button>
</form>

可以将页面中的绑定事件都用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版)

上上一篇: 微信小程序踩坑指南

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容