订阅消息是小程序触达用户的核心通道。本文从机制原理到实战优化,系统讲解如何把订阅消息的触达率做到极致。
一、订阅消息机制概述
1.1 一次性订阅 vs 长期订阅
微信小程序的订阅消息分为两种授权模式:
| 维度 | 一次性订阅 | 长期订阅 |
|---|---|---|
| 授权次数 | 每次授权对应1条消息 | 一次授权,可长期发送 |
| 适用主体 | 所有小程序 | 仅限政务、医疗、交通等特定类目 |
| 前端API | wx.requestSubscribeMessage | 同左 |
| 适用场景 | 订单通知、到货提醒、活动通知 | 社保查询、公积金变动、航班动态 |
关键理解:一次性订阅模式下,用户每点一次"允许",后台就多一张"发消息券"。用户拒绝则该场景的券为零,后续无法发送。
1.2 消息发送机制
code复制
用户授权 → 后端记录订阅次数 → 业务触发 → 后端检查剩余次数 → 调用发送API → 用户收到消息
核心限制:
- 每个模板对应独立的订阅次数池
- 发送一条消息消耗一次对应模板的订阅次数
- 订阅次数不跨模板、不跨用户
- 同一模板对同一用户每天最多发送1条(风控限制)
二、订阅消息接入流程
2.1 模板申请
- 登录小程序管理后台 → 订阅消息 → 公共模板库
- 搜索合适的模板(如"订单支付成功通知"、"发货提醒")
- 申请使用,填写模板所属类目
- 审核通过后获得模板ID
模板字段示例(订单支付成功通知):
code复制
模板ID: xxx
{{thing1}} - 订单编号
{{amount2}} - 支付金额
{{thing3}} - 商品名称
{{time4}} - 支付时间
2.2 前端订阅触发
javascript复制
// 最基础的调用
wx.requestSubscribeMessage({
tmplIds: ['模板ID1', '模板ID2'],
success(res) {
console.log('订阅结果:', res)
// res = { errMsg: '...', 模板ID1: 'accept', 模板ID2: 'reject' }
},
fail(err) {
console.error('订阅失败:', err)
}
})
2.3 后端发送消息
javascript复制
// services/subscribeService.js
const axios = require('axios')
const tokenManager = require('./tokenManager')
class SubscribeService {
/**
* 发送订阅消息
* @param {Object} params
* @param {string} params.openid 用户openid
* @param {string} params.templateId 模板ID
* @param {Object} params.data 模板数据
* @param {string} params.page 点击跳转页面
*/
async send({ openid, templateId, data, page }) {
// 1. 检查剩余订阅次数
const remaining = await this.getSubscribeCount(openid, templateId)
if (remaining <= 0) {
console.log(`用户${openid}模板${templateId}订阅次数不足`)
return { success: false, reason: 'NO_SUBSCRIPTION' }
}
// 2. 获取access_token
const accessToken = await tokenManager.getAccessToken()
// 3. 发送消息
try {
const res = await axios.post(
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
{
touser: openid,
template_id: templateId,
page: page || '',
data: this.formatData(data)
}
)
if (res.data.errcode === 0) {
// 发送成功,扣减订阅次数
await this.decrementSubscribeCount(openid, templateId)
return { success: true, msgid: res.data.msgid }
} else {
console.error('发送失败:', res.data)
return { success: false, errcode: res.data.errcode, errmsg: res.data.errmsg }
}
} catch (err) {
console.error('发送异常:', err)
return { success: false, reason: 'NETWORK_ERROR' }
}
}
/**
* 格式化模板数据
* 微信要求每个字段必须是字符串
*/
formatData(data) {
const formatted = {}
for (const [key, value] of Object.entries(data)) {
formatted[key] = { value: String(value) }
}
return formatted
}
}
module.exports = new SubscribeService()
三、最佳订阅时机
核心原则:在用户有明确收益预期的时刻请求授权。
3.1 时机评分表
| 时机 | 用户心理 | 授权率预期 | 推荐指数 |
|---|---|---|---|
| 支付成功后 | "我想知道发货进度" | 70-85% | ⭐⭐⭐⭐⭐ |
| 提交订单后 | "确认一下我的订单" | 60-75% | ⭐⭐⭐⭐ |
| 加购时 | "降价了通知我" | 50-65% | ⭐⭐⭐⭐ |
| 浏览商品详情 | "可能有兴趣但不确定" | 20-35% | ⭐⭐ |
| 进入小程序首页 | "什么?还没看就让我授权?" | 5-15% | ❌ |
| 离开页面前 | "别拦我" | 10-20% | ⭐ |
3.2 前端订阅触发组件(通用封装)
javascript复制
// components/subscribe-trigger/subscribe-trigger.js
Component({
properties: {
// 模板ID列表
tmplIds: {
type: Array,
value: []
},
// 触发场景(用于埋点)
scene: {
type: String,
value: 'default'
},
// 触发条件,满足时自动弹出
autoTrigger: {
type: Boolean,
value: false
},
// 冷却时间(秒),避免频繁打扰
cooldown: {
type: Number,
value: 86400 // 默认24小时
},
// 最多自动弹出次数
maxAutoTrigger: {
type: Number,
value: 3
}
},
lifetimes: {
attached() {
if (this.properties.autoTrigger) {
this.checkAndAutoTrigger()
}
}
},
methods: {
// 检查是否应该自动触发
checkAndAutoTrigger() {
const storageKey = `sub_${this.properties.scene}`
const lastTime = wx.getStorageSync(storageKey) || 0
const triggerCount = wx.getStorageSync(`${storageKey}_count`) || 0
const now = Date.now()
// 冷却期内或超过最大次数,不触发
if (now - lastTime < this.properties.cooldown * 1000) return
if (triggerCount >= this.properties.maxAutoTrigger) return
this.triggerSubscribe()
},
// 手动触发订阅
triggerSubscribe() {
const tmplIds = this.properties.tmplIds
if (!tmplIds.length) return
wx.requestSubscribeMessage({
tmplIds,
success: (res) => {
// 记录授权结果
this.recordResult(res)
// 通知父组件
this.triggerEvent('result', res)
},
fail: (err) => {
console.error('订阅失败:', err)
this.triggerEvent('fail', err)
}
})
// 更新触发记录
const storageKey = `sub_${this.properties.scene}`
wx.setStorageSync(storageKey, Date.now())
const count = (wx.getStorageSync(`${storageKey}_count`) || 0) + 1
wx.setStorageSync(`${storageKey}_count`, count)
},
// 记录订阅结果到服务端(用于数据分析)
recordResult(res) {
wx.request({
url: `${getApp().globalData.apiBase}/subscribe/record`,
method: 'POST',
data: {
scene: this.properties.scene,
results: res,
timestamp: Date.now()
}
})
}
}
})
xml复制
<!-- components/subscribe-trigger/subscribe-trigger.wxml -->
<!-- 这是一个隐形组件,不需要UI -->
在页面中使用:
xml复制
<!-- 支付成功后触发 -->
<subscribe-trigger
tmplIds="{{['支付成功模板ID', '发货提醒模板ID']}}"
scene="pay_success"
auto-trigger="{{paySuccess}}"
bind:result="onSubscribeResult"
/>
<!-- 离开页面时触发(手动) -->
<view wx:if="{{showSubscribeBeforeLeave}}">
<view class="subscribe-modal">
<view class="modal-content">
<text>开启订单提醒,不错过任何优惠活动</text>
<subscribe-trigger
tmplIds="{{['优惠活动模板ID']}}"
scene="page_leave"
/>
<button bindtap="onCloseModal">暂不需要</button>
</view>
</view>
</view>
四、提升触达率的策略
4.1 分场景分频次订阅
核心思路:不同业务场景使用不同模板,分散用户的"授权疲劳"。
javascript复制
// 场景-模板映射表
const SCENE_TEMPLATE_MAP = {
// 支付场景
PAY_SUCCESS: ['template_pay_success'],
PAY_REFUND: ['template_refund'],
// 物流场景
SHIP_NOTIFY: ['template_ship'],
DELIVER_SUCCESS: ['template_deliver'],
// 营销场景
PRICE_DROP: ['template_price_drop'],
RESTOCK: ['template_restock'],
// 服务场景
APPOINTMENT_REMIND: ['template_appointment'],
SERVICE_COMPLETE: ['template_service']
}
// 在对应场景触发时只弹出该场景相关的1-2个模板
function triggerSceneSubscribe(scene) {
const templates = SCENE_TEMPLATE_MAP[scene]
wx.requestSubscribeMessage({ tmplIds: templates })
}
反面教材:一次性弹出5-6个模板,用户直接全部拒绝。
4.2 订阅消息 + A/B测试
javascript复制
// services/subscribeABTest.js
class SubscribeABTest {
/**
* A/B测试:不同弹窗时机对授权率的影响
*/
async recordImpression(userId, scene, variant) {
await db.query(`
INSERT INTO subscribe_ab_test (user_id, scene, variant, created_at)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE variant = VALUES(variant)
`, [userId, scene, variant])
}
async recordConversion(userId, scene, accepted) {
await db.query(`
UPDATE subscribe_ab_test
SET converted = 1, converted_at = NOW(), accepted = ?
WHERE user_id = ? AND scene = ?
AND converted = 0
`, [accepted ? 1 : 0, userId, scene])
}
// 获取A/B测试结果
async getTestResults(scene) {
return db.query(`
SELECT
variant,
COUNT(*) AS total,
SUM(CASE WHEN accepted = 1 THEN 1 ELSE 0 END) AS accepts,
ROUND(SUM(CASE WHEN accepted = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS rate
FROM subscribe_ab_test
WHERE scene = ? AND converted = 1
GROUP BY variant
ORDER BY rate DESC
`, [scene])
}
}
module.exports = new SubscribeABTest()
4.3 引导用户重新授权
当用户的订阅次数用完后,需要在合适的时机引导重新授权:
javascript复制
// 检查用户订阅次数,不足时标记
async function checkAndPromptResubscribe(openid, templateId) {
const count = await getSubscribeCount(openid, templateId)
if (count <= 1) {
// 存储标记,下次进入相关页面时提示
await redis.set(
`resub_prompt:${openid}:${templateId}`,
'1',
'EX', 86400 * 7 // 7天内有效
)
return { needResubscribe: true, remaining: count }
}
return { needResubscribe: false, remaining: count }
}
// 在订单列表页检查并弹出提示
Page({
async onLoad() {
const openid = getApp().globalData.userInfo.openid
const result = await checkAndPromptResubscribe(openid, '订单通知模板ID')
if (result.needResubscribe) {
this.setData({ showResubPrompt: true })
}
}
})
五、模板消息内容优化
5.1 标题优化原则
好的标题 = 具体场景 + 情感触发 + 时间紧迫感
code复制
❌ 差: "订单通知"
✅ 好: "您的包裹已到达驿站,请及时取件"
❌ 差: "价格变动"
✅ 好: "您关注的商品降价50元,手慢无!"
❌ 差: "活动通知"
✅ 好: "限时3折:会员日专属优惠明天结束"
5.2 内容字段最佳实践
javascript复制
// 订单发货通知 - 模板数据填充示例
const shipNotifyData = {
thing1: { value: '您的包裹已发出' }, // 事件描述
character_string2: { value: 'SF1234567890' }, // 快递单号
thing3: { value: '顺丰速运' }, // 快递公司
thing4: { value: '预计明天18:00前送达' }, // 预计送达
thing5: { value: '点击查看物流详情' } // 引导语
}
// 降价通知 - 模板数据填充示例
const priceDropData = {
thing1: { value: '商品降价提醒' },
thing2: { value: 'Nike Air Max 270' },
amount3: { value: '¥399.00(原价¥699.00)' },
time4: { value: '2026-06-20 10:00' },
thing5: { value: '限时特价,仅剩23件' }
}
关键技巧:
- 在模板允许的范围内,用"仅剩X件""还剩X天"制造紧迫感
- 数字具体化:用"¥399"代替"有优惠"
- 加上明确的行动指引:用"点击查看"代替"详情"
- page 参数务必填写,缩短用户转化路径
六、后端订阅管理服务
6.1 Access Token管理
javascript复制
// services/tokenManager.js
const axios = require('axios')
class TokenManager {
constructor() {
this.token = null
this.expiresAt = 0
}
async getAccessToken() {
const now = Date.now()
// 提前5分钟刷新,避免临界点问题
if (this.token && now < this.expiresAt - 300000) {
return this.token
}
const res = await axios.get(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${process.env.WX_APPID}&secret=${process.env.WX_SECRET}`
)
if (res.data.errcode) {
throw new Error(`获取token失败: ${res.data.errmsg}`)
}
this.token = res.data.access_token
this.expiresAt = now + res.data.expires_in * 1000
return this.token
}
}
module.exports = new TokenManager()
6.2 订阅次数管理
javascript复制
// services/subscribeCountService.js
const db = require('../db')
class SubscribeCountService {
/**
* 增加订阅次数(用户授权时调用)
*/
async increment(openid, templateId, count = 1) {
await db.query(`
INSERT INTO user_subscribe_count (openid, template_id, count, updated_at)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
count = count + VALUES(count),
updated_at = NOW()
`, [openid, templateId, count])
}
/**
* 获取剩余订阅次数
*/
async getCount(openid, templateId) {
const [rows] = await db.query(`
SELECT count FROM user_subscribe_count
WHERE openid = ? AND template_id = ?
`, [openid, templateId])
return rows.length > 0 ? rows[0].count : 0
}
/**
* 扣减订阅次数(发送消息时调用)
*/
async decrement(openid, templateId) {
const result = await db.query(`
UPDATE user_subscribe_count
SET count = count - 1
WHERE openid = ? AND template_id = ? AND count > 0
`, [openid, templateId])
return result.affectedRows > 0
}
}
module.exports = new SubscribeCountService()
6.3 发送频率控制
javascript复制
// middleware/sendRateLimit.js
/**
* 发送频率限制
* 同一用户同一模板:每天最多1条
*/
async function checkSendFrequency(openid, templateId) {
const today = new Date().toISOString().slice(0, 10)
const [rows] = await db.query(`
SELECT COUNT(*) AS sent_count
FROM subscribe_send_log
WHERE openid = ? AND template_id = ? AND DATE(send_time) = ?
`, [openid, templateId, today])
if (rows[0].sent_count >= 1) {
return { allowed: false, reason: 'DAILY_LIMIT' }
}
return { allowed: true }
}
/**
* 记录发送日志
*/
async function logSend(openid, templateId, msgid, success) {
await db.query(`
INSERT INTO subscribe_send_log
(openid, template_id, msgid, success, send_time)
VALUES (?, ?, ?, ?, NOW())
`, [openid, templateId, msgid, success ? 1 : 0])
}
七、订阅消息数据分析
7.1 核心指标统计SQL
sql复制
-- 1. 各场景订阅授权率
SELECT
scene,
COUNT(*) AS total_requests,
SUM(CASE WHEN accepted = 1 THEN 1 ELSE 0 END) AS accepts,
ROUND(SUM(CASE WHEN accepted = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS accept_rate
FROM subscribe_record
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY scene
ORDER BY accept_rate DESC;
-- 2. 模板消息触达率
SELECT
template_id,
COUNT(*) AS total_sent,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS delivered,
ROUND(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS delivery_rate
FROM subscribe_send_log
WHERE send_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY template_id;
-- 3. 消息打开率(通过page跳转统计)
SELECT
s.template_id,
COUNT(DISTINCT s.msgid) AS total_messages,
COUNT(DISTINCT p.msgid) AS clicked_messages,
ROUND(COUNT(DISTINCT p.msgid) * 100.0 / NULLIF(COUNT(DISTINCT s.msgid), 0), 2) AS click_rate
FROM subscribe_send_log s
LEFT JOIN subscribe_click_log p ON s.msgid = p.msgid
WHERE s.send_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND s.success = 1
GROUP BY s.template_id
ORDER BY click_rate DESC;
-- 4. 订阅消耗趋势(每天/每周)
SELECT
DATE(send_time) AS date,
COUNT(*) AS sent_count,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count
FROM subscribe_send_log
WHERE send_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(send_time)
ORDER BY date;
-- 5. 用户订阅深度分布(订阅模板数 vs 活跃度)
SELECT
template_count,
COUNT(*) AS user_count,
ROUND(AVG(last_active_days), 1) AS avg_active_days
FROM (
SELECT
openid,
COUNT(DISTINCT template_id) AS template_count,
DATEDIFF(NOW(), MAX(updated_at)) AS last_active_days
FROM user_subscribe_count
WHERE count > 0
GROUP BY openid
) t
GROUP BY template_count
ORDER BY template_count;
7.2 数据看板关键指标
| 指标 | 计算方式 | 目标值 |
|---|---|---|
| 授权率 | 接受次数 / 弹窗次数 | >60% |
| 触达率 | 发送成功次数 / 发送总次数 | >98% |
| 打开率 | 点击次数 / 投递成功次数 | >15% |
| 消耗率 | 实际使用次数 / 获得授权次数 | >70% |
| 二次授权率 | 重新授权次数 / 订阅用尽次数 | >30% |
八、常见踩坑与解决方案
8.1 模板审核不通过
常见原因:
- 模板类目与小程序类目不匹配
- 模板内容涉及营销诱导("限时""秒杀""打折"等词需谨慎)
- 关键字使用不规范
解决:选择公共模板库中已审核通过的模板;自定义模板时避免使用营销敏感词。
8.2 用户拒绝后无法再弹窗
微信的订阅弹窗不会因为用户拒绝就永远不弹出 。下次调用 wx.requestSubscribeMessage 仍然可以弹出,但用户体验差。
策略:用户拒绝后,设置冷却期(如7天内不再弹同场景),换场景触发。
javascript复制
function canShowSubscribe(scene) {
const rejectKey = `sub_rejected_${scene}`
const rejectTime = wx.getStorageSync(rejectKey)
if (!rejectTime) return true
// 拒绝后7天内不再弹
return Date.now() - rejectTime > 7 * 86400 * 1000
}
// 在请求失败的reject回调中记录
wx.requestSubscribeMessage({
tmplIds,
fail(err) {
if (err.errMsg.includes('refuse') || err.errMsg.includes('cancel')) {
wx.setStorageSync(`sub_rejected_${scene}`, Date.now())
}
}
})
8.3 Access Token过期
症状 :发送消息返回 errcode: 42001, errmsg: access_token expired
方案:
- 使用Redis集中管理token,所有服务实例共享
- 设置提前刷新机制(提前5分钟)
- 增加
refresh_token接口用于强制刷新
javascript复制
// Redis版Token管理(多实例共享)
class RedisTokenManager {
constructor(redis) {
this.redis = redis
this.KEY = 'wx:access_token'
}
async getAccessToken() {
// 从Redis读取
const cached = await this.redis.get(this.KEY)
if (cached) {
const { token, expiresAt } = JSON.parse(cached)
if (Date.now() < expiresAt - 300000) return token
}
// 刷新
const newToken = await this.refresh()
return newToken
}
async refresh() {
const res = await axios.get(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${process.env.WX_APPID}&secret=${process.env.WX_SECRET}`
)
if (res.data.errcode) throw new Error(res.data.errmsg)
const tokenData = {
token: res.data.access_token,
expiresAt: Date.now() + res.data.expires_in * 1000
}
await this.redis.set(this.KEY, JSON.stringify(tokenData), 'EX', res.data.expires_in - 300)
return tokenData.token
}
}
8.4 模板参数错误
症状 :errcode: 47003, errmsg: data format error
常见原因:
- 参数值类型错误(要求字符串,传了数字)
- 参数值超出长度限制(
thing类型最长20个字符) - 参数值包含不支持的字符(换行符、emoji)
javascript复制
// 参数校验工具
function validateTemplateData(data) {
const TYPE_LIMITS = {
thing: 20,
number: 32,
letter: 32,
character_string: 32,
time: 32,
date: 32,
amount: 32,
phone_number: 17,
car_number: 20,
name: 10,
phrase: 5,
first: 20,
remark: 120
}
for (const [key, config] of Object.entries(data)) {
const value = String(config.value || '')
const type = key.replace(/\d+$/, '') // 提取类型前缀
// 查找对应的类型限制
const limit = TYPE_LIMITS[type] || 20
if (value.length > limit) {
console.warn(`模板参数 ${key} 超长: ${value.length}/${limit}`)
// 自动截断
data[key].value = value.substring(0, limit)
}
// 移除特殊字符
data[key].value = data[key].value
.replace(/[\r\n]/g, ' ')
.replace(/[\u{10000}-\u{10FFFF}]/gu, '') // 移除emoji
}
}
九、总结
提升订阅消息触达率的完整方法论:
- 时机为王:在用户有明确收益预期的时刻请求授权(支付成功后、订单创建时)
- 克制弹出:不要一次弹多个模板,一次一个,分场景逐步积累
- 内容致胜:消息内容要有紧迫感和明确行动指引
- 持续运营:订阅用完后在合理时机引导重新授权
- 数据驱动:A/B测试不同时机和文案,用数据指导优化
- 技术稳定:access_token集中管理、发送频率控制、完整的错误处理
订阅消息不是"接入就完事"的功能,而是需要持续运营优化的用户触达通道。按以上方法执行,授权率和触达率通常可以提升2-3倍。
