微信小程序訂閱消息推送實(shí)戰(zhàn)圖文教程(Java?Spring?Boot?+?Redis)
前言
最近在做“村民意見反饋”小程序,需要實(shí)現(xiàn):村民提交意見后,網(wǎng)格員能立刻收到微信通知。微信小程序提供了“訂閱消息”能力,用戶授權(quán)一次后,服務(wù)端就能主動(dòng)推送消息。本文將完整記錄從申請模板、后端開發(fā)到前端調(diào)試的全過程,并提供可直接運(yùn)行的代碼。
首先說一下微信小程序關(guān)于額度以及訂閱消息的簡略信息(消息訂閱相關(guān)信息):
微信小程序的訂閱消息發(fā)送額度規(guī)則如下:
對于一次性訂閱消息,用戶每次授權(quán)僅可觸發(fā)一次消息發(fā)送,無總量限制,但必須在用戶授權(quán)后立即使用。
對于長期訂閱消息,需滿足特定條件(如政務(wù)、醫(yī)療、交通等公共服務(wù)場景)并申請?zhí)厥饽0澹?jīng)平臺(tái)審核后方可使用,普通企業(yè)主體通常無法申請。
目前,微信平臺(tái)不對企業(yè)主體的小程序設(shè)置獨(dú)立的訂閱消息總量額度。只要用戶完成授權(quán),且消息內(nèi)容符合模板規(guī)范,即可發(fā)送。發(fā)送成功率主要取決于以下因素:
- 用戶是否已授權(quán)對應(yīng)模板ID的消息訂閱。
- 消息內(nèi)容是否符合模板字段要求。
- 是否在用戶授權(quán)后的有效時(shí)間內(nèi)調(diào)用發(fā)送接口(通常為7天內(nèi))。
因此,企業(yè)小程序的訂閱消息發(fā)送能力主要由用戶授權(quán)行為驅(qū)動(dòng),而非平臺(tái)分配的固定額度。
一、為什么需要訂閱消息?
微信早期有“模板消息”,但限制較多且容易騷擾用戶。后來推出了訂閱消息,核心特點(diǎn)是:
用戶主動(dòng)訂閱:每次發(fā)送前必須獲得用戶授權(quán)(一次性訂閱)或長期授權(quán)(長期訂閱)。
服務(wù)端主動(dòng)推送:用戶授權(quán)后,你可以在業(yè)務(wù)觸發(fā)時(shí)(如訂單狀態(tài)變更、意見處理)向用戶推送服務(wù)通知。
適合場景:訂單提醒、物流通知、政務(wù)辦事進(jìn)度、意見處理反饋等。
二、前置準(zhǔn)備
2.1 小程序賬號與類目
訂閱消息對類目基本無限制(一次性訂閱),但長期訂閱只對政務(wù)、醫(yī)療、交通等民生類目開放。
本文以一次性訂閱為例,實(shí)現(xiàn)一個(gè)簡單的“意見提交 → 通知網(wǎng)格員”場景。
2.2 申請訂閱消息模板
1.登錄小程序后臺(tái) → 功能 → 訂閱消息 → 從公共模板庫添加模板(或自定義模板)。


2.選擇一個(gè)適合的模板,例如“監(jiān)理報(bào)告提交通知”,模板字段可能包含:
- 1.服務(wù)名稱
{{thing1.DATA}}
- 2.檢測結(jié)果
{{thing2.DATA}}
- 3.提交時(shí)間
{{time3.DATA}}
3.記下模板ID。
2.3 后端技術(shù)選型
Spring Boot 2.x
Redis:緩存
access_token和用戶訂閱狀態(tài)Hutool:簡化 HTTP 請求和 JSON 處理
JDK 8+
三、整體流程(看圖理解)
text
用戶(網(wǎng)格員)進(jìn)入小程序
│
├─ 點(diǎn)擊“訂閱消息”按鈕
│ └─ wx.requestSubscribeMessage() 彈窗授權(quán)
│ └─ 允許 → 前端調(diào)用后端接口 /subscribe/record
│ └─ 后端存儲(chǔ) openId + templateId(Redis,30天有效期)
│
村民提交意見
│
├─ 后端根據(jù)業(yè)務(wù)找到對應(yīng)的網(wǎng)格員 openId
├─ 檢查該 openId 是否已訂閱(Redis 查詢)
├─ 若已訂閱 → 獲取 access_token(帶緩存的)
├─ 調(diào)用微信發(fā)送消息接口 https://api.weixin.qq.com/cgi-bin/message/subscribe/send
└─ 網(wǎng)格員在微信“服務(wù)通知”中收到消息
四、后端核心實(shí)現(xiàn)(Java)
獲取APPID以及secret以及templateId(模板id)

4.1 配置類:配置 appid / secret / templateId
#小程序appid wechat.appid=xxxxx #小程序密鑰 wechat.secret=xxxxxx #訂閱消息的模板id wechat.templateId=xxxxxxxxx #登錄wx登錄驗(yàn)證 wechat.loginUrl=https://api.weixin.qq.com/sns/jscode2session #wx訂閱消息發(fā)送 wechat.sendUrl=https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=
4.2 Redis 工具類(簡化版)
@Component
public class RedisUtils {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public String get(String key) {
return redisTemplate.opsForValue().get(key);
}
public void set(String key, String value, long expireSeconds) {
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(expireSeconds));
}
public boolean setIfAbsent(String key, String value, long expireSeconds) {
return Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(key, value, Duration.ofSeconds(expireSeconds)));
}
public Long executeLua(String script, String key, String value) {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
return redisTemplate.execute(redisScript, Collections.singletonList(key), value);
}
}4.3 獲取 access_token(Redis 緩存 + 分布式鎖)
@Service
public class AccessTokenService {
@Autowired
private RedisUtils redisUtils;
private static final String TOKEN_KEY = "WECHAT:ACCESS_TOKEN";
private static final String LOCK_KEY = "WECHAT:APPEAL_TOKEN_LOCK";
private static final long LOCK_EXPIRE_SECONDS = 5;
private static final String SUBSCRIBE_PREFIX = "SUBSCRIBE:";
private static final String LUA_RELEASE_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
/**
* 獲取AccessToken
*
* @return
*/
public String getAccessToken() {
//先取緩存
String token = (String) redisUtils.get(TOKEN_KEY);
if (token != null && !token.isEmpty()) {
return token;
}
//緩存失效,嘗試加鎖
String lockValue = String.valueOf(System.currentTimeMillis());
boolean locked = redisUtils.setIfAbsent(LOCK_KEY, lockValue, LOCK_EXPIRE_SECONDS);
if (locked) {
try {
// 雙重檢查
token = (String) redisUtils.getWechat(TOKEN_KEY);
if (token != null) {
return token;
}
return refreshAccessToken();
} finally {
redisUtils.executeLua(LUA_RELEASE_SCRIPT, LOCK_KEY, lockValue);
}
} else {
// 未獲得鎖,等待后重試
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getAccessToken();
}
}
/**
* 刷新AccessToken
*/
private String refreshAccessToken() {
String url = String.format(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
appid, secret
);
try {
String response = HttpRequest.get(url).timeout(5000).execute().body();
JSONObject json = JSONUtil.parseObj(response);
if (json.containsKey("errcode")) {
throw new CommonException("微信獲取AccessToken失敗: " + json.getStr("errmsg"));
}
String token = json.getStr("access_token");
Integer expiresIn = json.getInt("expires_in");
// 存入 Redis,有效期設(shè)為 7000 秒(微信是7200秒)
redisUtils.setWechat(TOKEN_KEY, token, expiresIn - 200);
return token;
} catch (Exception e) {
throw new CommonException("獲取AccessToken網(wǎng)絡(luò)異常", e);
}
}
}4.4 發(fā)送訂閱消息
@Service
@Slf4j
public class SubscribeMessageService {
@Value("${wechat.appid}")
private String appid;
@Value("${wechat.secret}")
private String secret;
@Value("${wechat.templateId}")
private String templateId;
@Value("${wechat.sendUrl}")
private String sendUrl;
private static final String SUBSCRIBE_PREFIX = "SUBSCRIBE:";
@Autowired
private RedisUtils redisUtils;
/**
* 記錄用戶訂閱
*/
public void record() {
// 獲取登錄用戶獲取對應(yīng)的openId
SaBaseLoginUser loginUser = null;
try {
loginUser = StpLoginUserUtil.getLoginUser();
String openId = bizUserService.getOpenIdById(loginUser.getId());
String key = SUBSCRIBE_PREFIX + templateId + ":" + openId;
redisUtils.setWechat(key, "1", 30 * 24 * 3600L); // 存儲(chǔ)30天
} catch (Exception e) {
throw new CommonException(e.getMessage());
}
}
/**
* 判斷用戶是否訂閱
*/
public boolean isSubscribed(String openId, String templateId) {
String key = SUBSCRIBE_PREFIX + templateId + ":" + openId;
return "1".equals(redisUtils.getWechat(key));
}
private void getUserListByGridId(String openId) {
// 此處代碼可替換為具體的業(yè)務(wù)實(shí)現(xiàn) 就是獲取需要推送用戶的openId
// 推送消息給網(wǎng)格員
// 判斷用戶是否訂閱了消息
if (isSubscribed(openId, templateId)) {
Map<String, String> dataMap = new HashMap<>();
dataMap.put("thing1", "意見提醒");
dataMap.put("thing2", "這是一條測試信息");
dataMap.put("time3", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
boolean b = sendSubscribeMessage(openId, templateId, null, dataMap);
if (!b) {
throw new CommonException("推送消息失敗,請重試");
}
}
}
/**
* 發(fā)送訂閱消息
*/
public boolean sendSubscribeMessage(String openId, String templateId,
String page, Map<String, String> data) {
String accessToken = getAccessToken();
String url = sendUrl + accessToken;
JSONObject requestBody = new JSONObject();
requestBody.set("touser", openId);
requestBody.set("template_id", templateId);
if (page != null && !page.isEmpty()) {
requestBody.set("page", page);
}
requestBody.set("miniprogram_state", "formal");
JSONObject dataJson = new JSONObject();
for (Map.Entry<String, String> entry : data.entrySet()) {
JSONObject item = new JSONObject();
item.set("value", entry.getValue());
dataJson.set(entry.getKey(), item);
}
requestBody.set("data", dataJson);
try {
String response = HttpRequest.post(url)
.body(requestBody.toString())
.timeout(5000)
.execute()
.body();
JSONObject result = JSONUtil.parseObj(response);
Integer errCode = result.getInt("errcode");
return errCode == null || errCode == 0;
} catch (Exception e) {
log.error("調(diào)用微信發(fā)送消息接口異常", e);
return false;
}
}
}4.5 提供 REST 接口
@RestController
@RequestMapping("/api/subscribe")
public class SubscribeController {
@Autowired
private SubscribeMessageService subService;
/**
* 前端需要訂閱消息推送
*/
@PostMapping("/record")
public Result record(@RequestBody Map<String, String> req) {
subService.record(req.get("openId"),req.get("templateId"));
return Result.success("訂閱成功");
}
}五、小程序前端(簡單示例)
獲取 openId 的標(biāo)準(zhǔn)流程:wx.login 獲取 code → 傳給后端 → 后端調(diào)用 jscode2session 接口換取 openId。
Page({
subscribe() {
const templateId = '你的模板ID'; // 與后端配置一致
wx.requestSubscribeMessage({
tmplIds: [templateId],
success(res) {
if (res[templateId] === 'accept') {
// 用戶同意,上報(bào)后端
wx.request({
url: 'https://你的域名/api/subscribe/record',
method: 'POST',
data: { openId: '當(dāng)前用戶的openId' }, // openId 需提前通過 wx.login 獲取
success() { wx.showToast({ title: '訂閱成功' }); }
});
}
}
});
}
});六、踩坑經(jīng)驗(yàn)與常見問題
| 錯(cuò)誤碼 | 含義 | 解決方案 |
|---|---|---|
43101 | 用戶拒絕接收 | 用戶未訂閱或訂閱已過期,需要前端重新引導(dǎo)訂閱 |
40037 | template_id 無效 | 檢查模板ID是否正確,且已在后臺(tái)添加到“我的模板” |
40003 | openId 無效 | 確認(rèn) openId 與當(dāng)前小程序 appid 匹配 |
48001 | API 未授權(quán) | 調(diào)用了公眾號的接口?檢查 URL 是否正確 |
access_token 失效(40001) | token 過期 | 檢查緩存刷新邏輯,確保提前200秒刷新 |
其他注意點(diǎn):
真機(jī)調(diào)試時(shí),
wx.requestSubscribeMessage必須在用戶點(diǎn)擊事件中同步調(diào)用,不能異步(比如在setTimeout里調(diào)用會(huì)失?。?。開發(fā)者工具模擬器可能無法彈出授權(quán)窗口,請用手機(jī)真機(jī)預(yù)覽測試。
一次性訂閱:用戶授權(quán)一次,你只能發(fā)一條消息。如果需要多次發(fā)送,需每次重新授權(quán)或申請長期訂閱。
七、總結(jié)
小程序訂閱消息是實(shí)現(xiàn)服務(wù)端主動(dòng)推送的官方推薦方式。核心要點(diǎn):
用戶授權(quán)是前提:前端必須調(diào)用
wx.requestSubscribeMessage且用戶同意。后端緩存 access_token:避免頻繁調(diào)用微信接口。
記錄用戶訂閱狀態(tài):發(fā)送前檢查,減少無效調(diào)用。
錯(cuò)誤處理:針對常見錯(cuò)誤碼(43101、40037)做友好提示。
ok,結(jié)束。
到此這篇關(guān)于微信小程序訂閱消息推送(Java Spring Boot+Redis)的文章就介紹到這了,更多相關(guān)微信小程序訂閱消息推送內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
查找native方法的本地實(shí)現(xiàn)函數(shù)native_function詳解
JDK開放給用戶的源碼中隨處可見Native方法,被Native關(guān)鍵字聲明的方法說明該方法不是以Java語言實(shí)現(xiàn)的,而是以本地語言實(shí)現(xiàn)的,Java可以直接拿來用。這里介紹下查找native方法的本地實(shí)現(xiàn)函數(shù)native_function,感興趣的朋友跟隨小編一起看看吧2021-12-12
java 靜態(tài)工廠代替多參構(gòu)造器的適用情況與優(yōu)劣
這篇文章主要介紹了java 靜態(tài)工廠代替多參構(gòu)造器的優(yōu)劣,幫助大家更好的理解和使用靜態(tài)工廠方法,感興趣的朋友可以了解下2020-12-12
Spring Data JPA 如何使用QueryDsl查詢并分頁
這篇文章主要介紹了Spring Data JPA 如何使用QueryDsl查詢并分頁,具有很好的參考價(jià)值,希望對大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-11-11
SpringBoot集成quartz實(shí)現(xiàn)定時(shí)任務(wù)
這篇文章主要介紹了如何使用SpringBoot整合Quartz,并將定時(shí)任務(wù)寫入庫中(持久化存儲(chǔ)),還可以任意對定時(shí)任務(wù)進(jìn)行如刪除、暫停、恢復(fù)等操作,需要的可以了解下2023-09-09
解析Neatbeans(常見錯(cuò)誤) build-impl.xml:305: Compile failed
本篇文章是對Neatbeans(常見錯(cuò)誤) build-impl.xml:305: Compile failed的解決方法進(jìn)行了詳細(xì)的分析介紹,需要的朋友參考下2013-07-07
MyBatis Plus實(shí)現(xiàn)一對多的查詢場景的三種方法
MyBatis Plus提供了多種簡便的方式來進(jìn)行一對多子查詢,本文主要介紹了MyBatis Plus實(shí)現(xiàn)一對多的查詢場景的三種方法,具有一定的參考價(jià)值,感興趣的可以了解一下2024-07-07
maven項(xiàng)目中<scope>provided</scope>的作用及說明
這篇文章主要介紹了maven項(xiàng)目中<scope>provided</scope>的作用及說明,具有很好的參考價(jià)值,希望對大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-12-12

