社区/学习指南/小程序·云开发高级教程

订阅消息

订阅消息是小程序能力中的重要组成,当用户自主订阅之后,可以向用户以服务通知的方式发送消息的能力,当用户点击订阅消息卡片可以跳转到小程序的页面,这样就可以实现服务的闭环和更优的体验,提高活跃度和用户粘性。

13.6.1 获取订阅消息授权

1、小程序端获取订阅消息授权次数

要获取订阅消息授权,首先要调用接口 wx.requestSubscribeMessage,这个接口会调起小程序订阅消息界面,返回用户订阅消息的操作结果。注意这个接口只能在小程序端使用 tap 点击或支付完成后触发。如果是使用页面加载或其他非用户点击类的事件来调用这个接口,就会报requestSubscribeMessage:fail can only be invoked by user TAP gesture的错误。

要调用 wx.requestSubscribeMessage,需要我们首先要有订阅消息的模板 ID,一次性模板 id 和永久模板 id 不可同时使用,基础库 2.8.4 之后一次性可以调起 3 个模板 ID(不能多于 3 个)。

使用开发者工具新建一个页面,如 subscribe,然后在 subscribe.wxml 里输入以下代码,我们通过点击 tap 来触发事件处理函数:


<button bindtap="subscribeMessage">订阅订阅消息</button>

然后再在 subscribe.js 里输入以下代码,我们在事件处理函数 subscribeMessage 里调用 wx.requestSubscribeMessage 接口:


subscribeMessage() {

  wx.requestSubscribeMessage({

    tmplIds: [

      "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44",//模板

      "RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU",

      "EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0"

    ],

    success(res) {

      console.log("订阅消息API调用成功:",res)

    },

    fail(res) {

      console.log("订阅消息API调用失败:",res)

    }

  })

},

建议大家在手机上进行真机调试这个接口,点击订阅消息 button,就能弹出授权弹窗。

  • 当用户点击“允许”就会累积一次授权,如果点击 N 次允许就能累积 N 次授权,这个授权是长期的,没有时间限制,你可以在一天内发完 N 次授权,也可以在未来分批次发完;也就是说这个虽然是一次性订阅消息,但是只要用户授权了 N 次,我们在短时间就可以发 N 次,而不是只能发一次;累积了多少次就可以发送多少次。发送一次就会消耗一次,累积的授权次数被消耗完之后,还继续发,就会报错"errcode":"43101","errmsg":"user refuse to accept the msg hint..."

  • 当用户勾选了订阅面板中的“总是保持以上选择,不再询问”时,且允许或拒绝之后,订阅消息的授权弹窗则永远不会再弹出,订阅消息也会被添加到用户的小程序设置页,我们可以通过 wx.getSetting 接口可获取用户对相关模板消息的订阅状态。wx.getSetting 的 withSubscriptions 可以获取用户订阅消息的订阅状态,当然只能返回用户勾选过订阅面板中的“总是保持以上选择,不再询问”的订阅消息。

  • 如果用户勾选了总是允许,那由于用户点击按钮都不会弹出授权弹窗,用户点击了授权按钮仍然会累积授权,起到一个静默收集授权次数的效果。也就是说,如果你通过 wx.getSetting 的 withSubscriptions 获取到用户对某条模板消息勾选了“总是保持以上选择,不再询问”,那你可以设置一个静默收集用户授权次数的 button,用户不会弹出授权弹窗,但是会累积授权次数。

注意该接口调用成功之后返回的对象,[TEMPLATE_ID]是动态的键,即模板 id,值包括'accept'、'reject'、'ban'。'accept'表示用户同意订阅该条 id 对应的模板消息,'reject'表示用户拒绝订阅该条 id 对应的模板消息,'ban'表示已被后台封禁,如下所示(以下值仅为案例):


{errMsg: "requestSubscribeMessage:ok", RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU: "accept", qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44: "reject", EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0: "accept"}

订阅消息的累积次数决定了我们是否可以给用户发送订阅消息,也决定了可以发送几次,因此记录用户给某个模板 ID 授权了多少次这个也就显得很重要了,比如我们可以结合接口返回的 res 对象和 inc 原子自增在数据库里记录订阅次数,当发送一次也会消耗一次,再用 inc 自减:


  subscribeMessage() {

    const tmplIds= [

      "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44",

      "RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU",

      "EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0"

    ];

    wx.requestSubscribeMessage({

      tmplIds:tmplIds,

      success(res) {

        console.log("订阅消息API调用成功:",res)

        tmplIds.map(function(item,index){

          if(res[item] === "accept"){

            console.log("该模板ID用户同意了",item)

            //可以使用原子自增指令inc往数据库里某个记录授权次数的字段+1

          }

        })

      },

      fail(res) {

        console.log("订阅消息API调用失败:",res)

      }

    })

  },

wx.requestSubscribeMessage 的参数 tmplIds 是数组可以容纳 3 个模板 ID,当用户点击授权弹窗,三个模板 ID 都是默认勾选的,只要用户点击允许,就会同时给三个模板 ID 累积次数;如果用户取消勾选了其中一个模板 ID,并点击总是允许,那另外两个勾选的模板 ID 将不会再有授权弹窗。

2、订阅消息授权与次数累积实践

订阅消息最核心的在于用户的授权与授权次数,也就是你在写订阅消息代码时或在发送订阅消息之前,最好是先用数据库记录用户是否已经授权以及授权的次数,关于订阅消息的授权次数的累积需要再说明的是:

  • 只能在小程序端通过调用 wx.requestSubscribeMessage 来进行授权以及累积授权次数,wx.requestSubscribeMessage 也不能写在云函数端;

  • 只能记录和累积当前用户的授权与授权次数,这个要注意分清楚,比如我们希望学生点击之后通知老师、老师点击之后通知学生,这个前提始终是你要通知谁,谁必须有授权或授权次数才能通知;通知谁就会消耗谁的授权次数。比如要做到学生完成作业点击按钮就可以通知老师,此时学生不必有授权次数,老师必须有,而订阅消息的通知则需要在云函数端进行;

  • 授权次数只能增不能减,如果你想实现用户订阅了订阅消息之后(使用的是 wx.requestSubscribeMessage 接口),又取消了该订阅消息的通知(不需要使用 wx.requestSubscribeMessage 接口),你可以在数据库里记录,不再发消息给用户,但是用户的授权次数并没有减少。所以取消订阅我们可以使用布尔型字段,而授权次数我们可以使用整数方便原子操作。

订阅消息的种类很多,比如有的订阅消息用户接收一次之后就不会再接收,这时我们侧重于记录订阅消息是否被用户同意就可以了;但是有的订阅消息记录用户授权的次数有利于我们可以更好的为用户服务,比如日报、周报、活动消息等一些与用户交互比较频繁的信息。在前面我们已经多次强调了云数据库的原子操作,这里再以订阅消息次数累积的增加(授权只能增加)为例,来看原子操作是如何处理的。

使用云开发控制台新建一个 messages 集合,messages 集合的记录结构如下所示,在设计上我们把同一个用户多个不同类型的订阅消息内嵌到一个数组 templs 里面。

_id: ""; //可以直接为用户的openid,这样我们可以使用db.collection('messages').doc(openid)来处理;不过我们的案例的_id不是openid

_openid: ""; //云开发自动生成的openid

templs: [
  {
    //把用户授权过的模板列表都记录在这里

    templateId: "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44", //订阅

    page: "",

    data: {}, //订阅消息内容对象,建议内嵌到里面,免得查两次

    status: 1, //用户对该条模板消息是否接受'accept'、'reject'、'ban',

    subStyle: "daily", //订阅类型,比如是每天daily,还是每周weekly

    done: false, //本次是否发送了

    subNum: 22, //该条订阅消息用户授权累积的次数;
  },
  {},
];

下面是用户在小程序端点击订阅消息之后的完整代码,记录不同的订阅消息被用户点击之后,次数的累积。代码没有记录用户是否拒绝 reject,如果业务上有需要也是可以记录的,不过拒绝不存在累积次数的问题。


subscribeMessage() {

  const that = this

  //模板ID建议放置在数据库中,便于以后修改

  const tmplIds= [

    "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44",

    "RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU",

    "EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0"

  ];

  wx.requestSubscribeMessage({

    tmplIds:tmplIds,

    success: res => {

      console.log("订阅消息API调用成功:",res)

      that.addMessages().then( id =>{

        tmplIds.map(function(item,index){

          if(res[item] === "accept"){

            console.log("该模板ID用户同意了",item)

            that.subscribeNum(item,id)

          }

        })

      })


    },

    fail(res) {

      console.log("订阅消息API调用失败:",res)

    }

  })

},


async addMessages(){

  //查询用户订阅过的订阅消息,只会有一条记录,所以没有limit等限制

  const messages = await db.collection('messages').where({

    _openid:'{openid}'

  }).get()


  //如果用户没有订阅过订阅消息,就创建一条记录

  if(messages.data.length == 0){

    var newMsg = await db.collection('messages').add({

      data:{

        templs:[]

      }

    })

  }

  var id = messages.data[0] ? messages.data[0]._id : newMsg._id

  return id

},


async subscribeNum(item,id){

  //注意传入的item是遍历,id为addMessages的id

  const subs = await db.collection('messages').where({

    _openid:'{openid}',

    "templs":_.elemMatch({

      templateId:item

    })

  }).get()



  console.log('用户订阅列表',subs)

  //如果用户之前没有订阅过订阅消息就创建一个订阅消息的记录

  if(subs.data.length == 0){

    db.collection('messages').doc(id).update({

      data: {

        templs:_.push({

          each:[{templateId:item,//订阅

            page:"",

            data:{},

            status:1,

            subStyle:"daily",

            done:false,

            subNum:1}],

          position:2

        })

      }

    })

  }else{

    db.collection('messages').where({

      _id:id,

      "templs.templateId":item

    })

    .update({

      data:{

        "templs.$.subNum":_.inc(1)

      }

    })

  }

}

这里的"templs.$.subNum":_.inc(1)就是当用于同意哪条订阅消息,就会给该订阅消息的授权次数进行原子加 1。

13.3.2 发送订阅消息方式说明

当我们在小程序端累积了某个模板 ID 的授权次数之后,就可以通过云函数来调用 subscribeMessage.send 接口发送订阅消息了。而这个云函数我们可以在小程序端调用,也可以使用云函数来调用云函数,还能使用定时触发器来调用云函数。

  • 小程序端发送订阅消息,有些业务需要在用户在小程序内完成了某个操作之后,就需要向用户发送订阅消息,比如打卡、签到、支付、发表成功等,这些业务都依赖于用户的操作,当操作完成之后就可以在回调函数里调用发送订阅消息的云函数;

  • 还有一种就是,如果你是小程序的管理员,订阅消息的管理界面也在小程序里,当管理员在小程序端点击定点或群发订阅消息时,也可以调用云函数来发送订阅消息;

  • 使用定时触发器发送订阅消息,这时订阅消息就可以周期性、定时发送,不再需要用户/管理员点击就可以结合业务场景发送。

云函数调用 subscribeMessage.send 接口的方式有两种,一种是 HTTPS 调用,还有一种就是云调用,建议使用云调用。调用 subscribeMessage.send 接口时有很多细节需要注意,尤其是 data 格式,必须符合格式要求。

订阅消息的 data 必须与模板消息一一对应

比如我们申请到一个订阅课程开课提醒的模板,它的格式如下:


姓名{{phrase1.DATA}}

课程标题{{thing2.DATA}}

课程内容{{thing3.DATA}}

时间{{date5.DATA}}

课程进度{{character_string6.DATA}}

与之相应的 data 的写法如下 phrase1、thing2、thing3、date5、character_string6,这些需要一一对应,参数不能多也不能少,参数后面的数字比如 date5 不能改成 date6,否则会报"openapi.subscribeMessage.send:fail argument invalid! hint:的错误,也就是模板里有什么参数,你就只能按部就班写什么参数:


data: {

  "phrase1": {

    "value": '李东'

  },

  "thing2": {

    "value": '零基础云开发技术训练营第7课'

  },

  "thing3": {

    "value": '列表渲染与条件渲染'

  },

  "date5": {

    "value": '2019年10月20日 20:00'

  },

  "character_string6": {

    "value": 3

  }

}

订阅消息参数值的内容格式必须要符合要求

在技术文档里,有一个关于订阅消息参数值的内容格式要求,这个在写订阅消息内容的时候需要严格的一一对应,否则会出现格式错误。

参数类别 参数说明 参数值限制 说明
thing.DATA 事物 20个以内字符 可汉字、数字、字母或符号组合
number.DATA 数字 32位以内数字 只能数字,可带小数
letter.DATA 字母 32位以内字母 只能字母
symbol.DATA 符号 5位以内符号 只能符号
character_string.DATA 字符串 32位以内数字、字母或符号 可数字、字母或符号组合
time.DATA 时间 24小时制时间格式(支持+年月日) 例如:15:01,或:2019年10月1日 15:01
date.DATA 日期 年月日格式(支持+24小时制时间) 例如:2019年10月1日,或:2019年10月1日 15:01
amount.DATA 金额 1个币种符号+10位以内纯数字,可带小数,结尾可带“元” 可带小数
phone_number.DATA 电话 17位以内,数字、符号 电话号码,例:+86-0766-66888866
car_number.DATA 车牌 8位以内,第一位与最后一位可为汉字,其余为字母或数字 车牌号码:粤A8Z888挂
name.DATA 姓名 10个以内纯汉字或20个以内纯字母或符号 中文名10个汉字内;纯英文名20个字母内;中文和字母混合按中文名算,10个字内
phrase.DATA 汉字 5个以内汉字 5个以内纯汉字,例如:配送中

下面列举一些在使用过程中容易犯的错误:

  • 可能已有的模板消息的格式和你想要的不一致,比如你希望发送的消息是用户的昵称,而不是姓名{{phrase1.DATA}},因为姓名只能是中文,且必须 5 个字以内,那你就没法擅自改动,只能去申请或复用其他的模板 ID;

  • 每个格式对字符串的长度和类型都有严格的要求,比如 thing,要求必须是 20 个以内的字符,不能超过 20 个字符;有些只能是数字或字母,就不能是其他格式

13.6.3 使用云调用发送订阅消息

在前面我们说过,在小程序端哪个用户点击授权就只会给哪个用户增加授权次数,而借助于云函数发送订阅消息则用户可以给任何人发送订阅消息,发给哪个人就需要哪个人有授权次数,就会减少哪个人的授权次数,这一点要注意区分。

1、发送单条订阅消息

新建一个云函数比如 subscribeMessage,然后再在 config.json 的添加 subscribeMessage.send 权限,使用云函数增量上传更新这个配置文件。


{

  "permissions": {

    "openapi": [

      "subscribeMessage.send"

    ]

  }

}

然后再在 index.js 里输入以下代码,注意这里的 openid,是用户自己的,这种适用于用户在小程序端完成某个业务操作之后,就给用户自己发订阅消息;当然这里的 openid 可以是其他累积了授权次数的用户的,也就是当我们在小程序端调用该云函数就能给其他人发订阅消息了,这主要适用于管理员:

const cloud = require("wx-server-sdk");

cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV,
});

exports.main = async (event, context) => {
  const { OPENID } = cloud.getWXContext();

  try {
    const result = await cloud.openapi.subscribeMessage.send({
      touser: "oUL-m5FuRmuVmxvbYOGuXbuEDsn8",

      page: "index",

      templateId: "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44",

      data: {
        phrase1: {
          value: "小明",
        },

        thing2: {
          value: "零基础云开发技术训练营第7课",
        },

        thing3: {
          value: "列表渲染与条件渲染",
        },

        date5: {
          value: "2019年10月20日 20:00",
        },

        character_string6: {
          value: 3,
        },
      },
    });

    return result;
  } catch (err) {
    console.log(err);

    return err;
  }
};

2、批量发送订阅消息

由于 subscribeMessage.send 的参数 templateId 和 touser 都是字符串,因此执行一次 subscribeMessage.send 只能给一个用户发送一条订阅消息,那要给更多用户比如 1000 人以内(云函数一次可以获取到 1000 条数据)发订阅消息,则需要结合数据库的查询数据库内所有有授权次数的用户然后循环执行来发消息,并在发完之后使用 inc 自减来减去授权次数。

由于我们把用户授权的所有订阅消息内嵌到 templs 这个数组里,而要发送的订阅消息的内容则来自 templs 数组里符合条件的对象,这里涉及到相对比较复杂的数组的处理,因此数据分析处理神器聚合就派上用场了(当然我们也可以使用普通查询,普通查询得到的是记录列表,再使用一些数组方法如 filter、map 等取出列表里的 templs 嵌套的对象列表)。

const cloud = require("wx-server-sdk");

cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV,
});

const db = cloud.database();

const _ = db.command;

const $ = db.command.aggregate;

exports.main = async (event, context) => {
  const templateId = "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44";

  try {
    const messages = (
      await db
        .collection("messages")
        .aggregate()

        .match({
          //使用match匹配查询

          "templs.templateId": templateId, //注意这里templs.templateId的写法

          done: false,

          status: 1,
        })

        .project({
          _id: 0,

          templs: $.filter({
            //从嵌套的templs数组里取出模板ID满足条件的对象

            input: "$templs",

            as: "item",

            cond: $.eq(["$$item.templateId", templateId]),
          }),
        })

        .project({
          message: $.arrayElemAt(["$templs", 0]), //符号条件的是只有1个对象的数组,取出这个对象
        })

        .end()
    ).list; //使用聚合查询到的是一个list对象

    const tasks = [];

    for (let item in messages) {
      const promise = cloud.openapi.subscribeMessage.send({
        touser: item.message._openid,

        page: "index",

        templateId: item.message.templateId,

        data: item.message.data,
      });

      tasks.push(promise);
    }

    return (await Promise.all(tasks)).reduce((acc, cur) => {
      return {
        data: acc.data.concat(cur.data),

        errMsg: acc.errMsg,
      };
    });
  } catch (err) {
    console.log(err);

    return err;
  }
};

特别注意的是,不要把查询数据库的语句放到循环里面,也就是我们可以一次性取出 1000 条需要发订阅消息的用户,然后再结合 map 和 Promise.all 方法给这 1000 个用户发送订阅消息,然后再一次性给所有这 1000 条数据进行原子自增,不能一条一条处理,否则会造成数据库性能的极大浪费以及超出最大连接数,而且也会导致云函数在最高 60s 的生命周期里也发送不了几百条订阅消息。

13.6.4 使用定时触发器发订阅消息

但是当要发送订阅消息的用户有几十万几百万,那应该怎么处理呢?如果全部让云函数来执行,即使将云函数的执行超时时间修改为 60s,也应该会超时,这时候我们可以结合定时器来发送订阅消息。

使用定时触发器来发送订阅消息,也就是在小程序的云开发服务端,用定时触发器调用订阅消息的云调用接口 openapi.subscribeMessage.send。当我们每天要给数十万人定时发送订阅消息时,这时候定时触发器就不仅仅需要比如每天早上 9 点触发,而且还需要在 9 点之后能够每隔一段时间比如 40s,就来执行一次云函数以便给数十万用户发送订阅消息。

这时候 Cron 表达式可以这样写,意思是每天早上 9 点到 11 点每隔 40s 执行一次云函数:


0/40 * 9-11 * * * *

当然这里的周期设置可以结合云函数实际执行的时间来定,要充分考虑到云函数的超时时间。

云调用还支持组合模板并添加至帐号下的个人模板库的接口subscribeMessage.addTemplate、删除帐号下的个人模板subscribeMessage.deleteTemplate、获取小程序账号的类目subscribeMessage.getCategory、获取当前帐号下的个人模板列表subscribeMessage.getTemplateList等等接口,这里就不一一介绍啦。

本文出自 李东bbsky