避坑指南)
微信小程序做消息推送前前后后折騰了大半年從最開始的模板消息一路踩到訂閱消息中間被各種文檔坑得夠嗆。今天把這段經驗完整整理出來標題就叫從模板消息到訂閱消息的實戰(zhàn)避坑指南——這不僅僅是接口替換的問題整個推送體系的思路都變了。如果你正準備給自己的小程序接入推送或者正在被用戶授權了為什么還是發(fā)不出去折磨這篇文章應該能幫你少走不少彎路。先說結論消息推送這事方案選型比代碼實現重要得多。微信把模板消息砍掉、全面轉向訂閱消息本質上是把推送主動權從開發(fā)者手里拿回來一部分交給用戶。你可以在用戶每次操作后請求一次訂閱授權攢夠了授權額度再定向推送。這套機制看起來簡單但實際跑起來會發(fā)現授權時機、額度消耗、模板字段、調試環(huán)境這些環(huán)節(jié)處處是坑。1. 先把規(guī)則吃透模板消息為什么退場訂閱消息到底怎么運作1.1 模板消息退場的真實原因不是技術不行是容易騷擾用戶老開發(fā)者應該都記得模板消息的流程用戶在小程序里完成一次操作后彈窗征求用戶同意同意后開發(fā)者就能在后續(xù)任意時間點給用戶推送模板消息而且推送次數不受嚴格限制。我早期的項目就是靠這個做訂單狀態(tài)通知的體驗確實方便用戶只要授權一次后面發(fā)貨、簽收、售后每個節(jié)點都能推。但問題恰恰出在授權一次永久使用上。很多小程序把模板消息當免費短信用用戶稍微有點互動就狂推營銷內容最終結果就是用戶被騷擾到直接把小程序的通知權限關了。微信官方后來把模板消息逐步下線新注冊的小程序后臺已經看不到模板消息入口老接口也在 2020 年初徹底停用。本質原因就一句話這種授權模式沒法約束開發(fā)者必須改成一次授權、一次推送的單次消耗模型這就是訂閱消息的雛形。1.2 訂閱消息的規(guī)則核心授權一次只能推一條訂閱消息最核心的規(guī)則就一句用戶的每次允許授權只對應一次消息推送的額度。用戶點了允許你拿到一條推送額度用掉之后想再推必須讓用戶再次授權。這個設計意味著你不能像以前那樣先囤授權再慢慢推而要把推送動作和用戶的具體操作強綁定。比如用戶下單成功后你彈訂閱框拿到額度后立刻發(fā)送下單成功通知這就是最標準的一次消耗閉環(huán)。如果你想在發(fā)貨時再推一條就要在用戶下單時多次彈窗請求授權或者等到發(fā)貨前再找機會觸發(fā)授權彈窗。很多第一次做訂閱消息的開發(fā)者會習慣性地問用戶每點一次允許我只能推一條那我的業(yè)務有五個節(jié)點需要通知怎么辦答案只有一個你的業(yè)務需要在不同的用戶動作節(jié)點上分別去拿對應的授權。比如下單節(jié)點拿下單成功通知的授權發(fā)貨節(jié)點拿發(fā)貨提醒的授權每一個節(jié)點獨立授權、獨立消耗。1.3 一次性訂閱和長期訂閱別再混為一談了訂閱消息分為兩種一次性訂閱消息和長期訂閱消息。一次性訂閱消息是目前絕大多數小程序都在用的類型任何主體都能申請用戶每次授權對應一條推送額度。長期訂閱消息則完全不同。它允許開發(fā)者一次授權后在用戶沒有再次操作的情況下多次推送但它對行業(yè)類目有嚴格限制僅限醫(yī)療、政務、金融、教育等民生服務領域個人主體和絕大多數普通企業(yè)主體根本沒資格申請。我在后臺翻過類目列表普通電商、工具類目基本找不到長期訂閱的入口。所以對大部分開發(fā)者來說只需要無腦關注一次性訂閱消息就行。如果有人在技術社區(qū)跟你說我這里可以開通長期訂閱基本可以斷定是違規(guī)代開通或者營銷騙局微信對這種灰色操作的打擊力度很大不要碰。2. 前端實戰(zhàn)授權鏈路從 openid 開始2.1 openid 從哪來code 換 openid 的基礎流程不能錯訂閱消息推送時后端必須知道接收者的 openid這個 openid 是每個用戶在每個小程序下的唯一標識。獲取 openid 的標準姿勢就是 wx.login 拿 code然后后端拿著 code 換 openid 和 session_key。前端只有一小段代碼// 前端用戶進入小程序時執(zhí)行 wx.login({ success(res) { if (res.code) { // 把 code 傳給后端 wx.request({ url: https://your-api.com/api/login, data: { code: res.code }, success(result) { // 后端返回 openid前端可以存起來備用 console.log(result.data.openid) } }) } } })后端拿到 code 后調微信的 jscode2session 接口GET https://api.weixin.qq.com/sns/jscode2session?appidAPPIDsecretAPPSECRETjs_codeCODEgrant_typeauthorization_code注意兩個坑第一這個接口用的是 appid 加 secret不需要 access_token很多人會下意識以為所有微信接口都要帶 access_token結果在這里先卡一道第二code 有效期只有 5 分鐘而且只能用一次用完作廢。我之前排過一個線上問題前端并發(fā)請求把同一個 code 用了兩次第二次直接報 invalid code排查了半天才發(fā)現是重復消費了。換回來的結果是一個 JSON包含 openid 和 session_key。openid 建議在后端直接和用戶體系綁定不要反復通過前端傳來傳去避免被偽造。2.2 授權彈窗的正確喚起姿勢別在 onLoad 里瞎彈wx.requestSubscribeMessage 是前端拉起訂閱授權彈窗的接口但它的調用時機非常有講究。最基礎的規(guī)則是必須在用戶點擊行為tap的同步回調里調用不能在 onLoad、onShow 這些生命周期里直接彈否則在某些基礎庫版本下會出現接口調用成功但彈窗死活不出來的情況或者被微信靜默降級處理。更深一層的問題是頻控。如果用戶在一段時間內被你反復彈窗詢問訂閱微信會直接限制你的彈窗喚起權限。我實測下來的體感是同一用戶同一模板短時間內彈兩次以上第二次彈窗出來的概率就明顯下降如果用戶連續(xù)拒絕兩三次后面基本就彈不出來了。所以正確做法是不要在頁面加載時就請求訂閱而要把訂閱動作綁定到真實的業(yè)務操作節(jié)點上。比如下單成功、支付完成、報名成功這些用戶主動完成動作的按鈕回調里順勢彈出訂閱框。用戶剛完成一個動作心理預期里確實需要收到后續(xù)通知這時候彈窗的接受率最高。2.3 前端實操代碼一個干凈的訂閱按鈕示例下面這個示例是支付成功后引導用戶訂閱訂單狀態(tài)通知的標準寫法。// 支付成功回調里觸發(fā)訂閱 function handlePaySuccess(orderId) { // 先做業(yè)務請求再拉起訂閱 wx.requestSubscribeMessage({ tmplIds: [TEMPLATE_ID_HERE], // 在 mp 后臺申請到的模板 ID success(res) { // 返回結果是一個對象key 是模板 ID if (res[TEMPLATE_ID_HERE] accept) { // 用戶點了允許拿到一條推送額度 // 把授權結果上報后端由后端記錄授權庫存 reportSubscribeAuth(orderId, TEMPLATE_ID_HERE) } else if (res[TEMPLATE_ID_HERE] reject) { // 用戶拒絕不要反復彈 console.log(用戶拒絕了訂閱) } else if (res[TEMPLATE_ID_HERE] ban) { // 被微信限制彈窗需要引導用戶去設置頁手動開啟 console.log(訂閱被限制) } }, fail(err) { // 彈窗喚起失敗常見原因是調用時機不對或頻控 console.error(訂閱調用失敗, err) } }) }重點說一下返回值的判斷。wx.requestSubscribeMessage 的 success 回調里返回值是一個以模板 ID 為 key 的對象value 有三種情況accept 表示允許、reject 表示拒絕、ban 表示被限制。很多人只判斷了 success 就默認用戶一定允許了這是不嚴謹的。必須根據模板 ID 逐個取 value再看是不是 accept。另外 tmplIds 參數一次最多傳 3 個模板 ID這是官方限制。但我實際測試下來一次彈 3 個模板的轉化率會明顯下降用戶看到三連彈窗往往直接全拒。我的建議是一個業(yè)務節(jié)點只彈一個最相關的模板寧可多設計幾個觸發(fā)節(jié)點也別在一個彈窗里塞多個模板。3. 后端發(fā)送access_token、模板字段與接口調試3.1 access_token 的緩存策略別每次都去換會被限流后端發(fā)送訂閱消息前必須拿到 access_token這是調用所有微信 cgi-bin 接口的通行證。access_token 的有效期是 7200 秒兩小時每次獲取都有頻率限制每日獲取上限是 2000 次。如果用戶量稍微上來一點每次發(fā)送都現取 token很容易把 2000 次配額打爆接著就會報 45009接口調用超過限額。我比較推薦的做法是內存緩存加過期時間JVM 或 Node 進程里掛一個定時刷新任務提前 5 分鐘把 token 續(xù)上。偽代碼如下let cachedToken null let tokenExpireTime 0 async function getAccessToken() { // 提前 5 分鐘刷新避免邊緣過期 if (cachedToken tokenExpireTime - 300 Date.now()) { return cachedToken } const url https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappid${APPID}secret${APPSECRET} const res await axios.get(url) cachedToken res.data.access_token tokenExpireTime Date.now() res.data.expires_in * 1000 return cachedToken }如果是多實例部署建議把 token 放到 Redis 里加鎖更新避免多個實例同時去刷新導致 token 互相覆蓋。這一點在線上環(huán)境很重要我見過測試環(huán)境單機跑著沒事一上生產多實例部署立刻出現 40001 的案例原因就是各實例各自緩存了不同的 token后一個獲取的把前一個頂掉了。3.2 訂閱消息發(fā)送接口Node.js 完整示例發(fā)送訂閱消息的接口是POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_tokenACCESS_TOKEN請求體是一個 JSON{ touser: OPENID, template_id: TEMPLATE_ID, page: pages/order/detail?id123, miniprogram_state: formal, lang: zh_CN, data: { thing1: { value: 您的訂單已發(fā)貨 }, time2: { value: 2024年6月30日 15:00 }, character_string3: { value: SF1234567890 } } }對應 Node.js 后端代碼const axios require(axios) async function sendSubscribeMessage(openid, templateId, data, page) { const token await getAccessToken() const url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token${token} const body { touser: openid, template_id: templateId, page: page || pages/index/index, miniprogram_state: formal, lang: zh_CN, data } const res await axios.post(url, body) if (res.data.errcode 0) { // 發(fā)送成功同時消耗一條用戶授權額度 return { success: true } } else { // 發(fā)送失敗根據 errcode 做不同處理 return { success: false, errcode: res.data.errcode, errmsg: res.data.errmsg } } }這里有一個很多新手會卡住的點miniprogram_state 參數。它有三個可選值developer開發(fā)版、trial體驗版、formal正式版。如果你當前測試的小程序是體驗版但 miniprogram_state 傳了 formal接口可能返回成功但用戶手機上根本收不到這條訂閱消息。反過來也一樣正式版環(huán)境用 developer 也收不到。正確的測試姿勢是開發(fā)調試時用 developer 或 trial發(fā)布上線后用 formal。我當時就是在體驗版環(huán)境測試忘了改這個參數結果后端日志顯示發(fā)送成功手機一直收不到排查了一個下午才發(fā)現是環(huán)境狀態(tài)不匹配。3.3 data 字段匹配模板字段的類型和長度限制訂閱消息的 data 字段是最容易報 47003參數格式錯誤的地方。你在 mp 后臺申請模板時模板里每個字段都有固定的 key比如 thing1、time2、number3、character_string4 這些。data 里的 key 必須和模板字段完全一致少一個、多一個、key 名寫錯都會直接報錯。每種字段類型對 value 的格式和長度都有硬性限制thing20 個以內漢字適合放物品名、訂單備注等文本number數字類型適合放金額、數量time時間格式需要按指定格式傳一般是2024年6月30日 15:00這種樣式character_string20 個以內字符適合放訂單號、快遞單號phrase5 個以內漢字適合放一句話狀態(tài)我踩過最深的坑是 thing 和 phrase 的長度限制。開發(fā)時測試數據短沒事一上線用戶輸入長一點就直接報 47003。有個比較穩(wěn)妥的處理后端在組裝 data 之前先對每個字段做長度截斷或校驗超長的給用戶提示別讓臟數據打到微信接口。4. 從模板消息遷移到訂閱消息一份改造清單4.1 模板 ID 的變化從老接口到新模板 ID模板消息時代模板 ID 一般是一串很長的數字加字母混合的值訂閱消息的模板 ID 則是 T 字母開頭的一串字符。你在 mp 后臺的訂閱消息模塊里申請模板審核通過后就能拿到 T 開頭的模板 ID。申請的路徑是登錄微信公眾平臺 → 功能 → 訂閱消息 → 公共模板庫 → 選類目 → 選關鍵詞 → 組合成自定義模板。關鍵詞是從該行業(yè)類目下的固定詞庫里選的不能自己隨意造詞。比如電商類目下會有訂單發(fā)貨提醒物流簽收通知這些關鍵詞可用。申請審核一般比較快快的時候幾小時就過了慢的話一到兩個工作日。我建議提前把業(yè)務需要的模板一次性申請好別等上線了再補審核期間業(yè)務會卡住。從模板消息遷移到訂閱消息時你舊的后端邏輯里所有調用模板消息發(fā)送接口的地方都要換成訂閱消息接口模板 ID 全部替換授權邏輯也要重寫。相比代碼改動業(yè)務邏輯的調整更關鍵模板消息是一次授權無限推送訂閱消息是一次授權一條推送你的推送節(jié)點設計、授權觸發(fā)時機全都要重新規(guī)劃。4.2 授權庫存管理把每條授權當成一種資產訂閱消息的授權額度是稀缺資源不能隨用隨丟。我建議后端建一張表專門記錄授權和消耗情況用戶 openid模板 ID授權時間是否已消耗消耗時間關聯(lián)的業(yè)務單號用戶在哪個業(yè)務節(jié)點授權了哪個模板額度是否已經用來發(fā)送過消息都要能隨時查出來。后面如果用戶投訴我沒授權怎么給我推消息你可以直接拉出這張表自證清白。這里涉及一個關鍵的消費邏輯當你調用發(fā)送接口返回 errcode 0 之后微信默認消耗了用戶的一次授權。如果返回的是 43101用戶拒絕說明當前沒有可用授權額度這次發(fā)送不消耗任何額度。但要注意一種特殊情況用戶剛在前端點完允許你立刻在后端發(fā)消息有一定概率仍然返回 43101因為微信服務端對授權狀態(tài)的寫入存在輕微延遲。我的解決辦法是授權后不立即發(fā)送而是把發(fā)送任務丟進延遲隊列等 5 到 10 秒再發(fā)實測能把 43101 的概率降到很低。4.3 提高送達率的三個手段別只會調接口訂閱消息能不能真正到達用戶手機接口返回成功只是第一步用戶是否愿意點開、是否愿意繼續(xù)授權才是關鍵。第一授權彈窗的時機要貼近用戶真實需求。下單成功后問要不要接收發(fā)貨提醒通過率很高用戶剛打開首頁就彈允許我們給你推送消息基本是找拒。把訂閱動作嵌到業(yè)務流程里而不是做成獨立環(huán)節(jié)。第二推送內容要一條是一條。訂閱消息的本質是服務通知不是營銷短信。模板里能放的字數有限你更應該確保每條消息對用戶有實際價值。我見過一個電商項目用戶一注冊就被彈訂閱彈窗通過率不到 10%后來改成支付完成頁彈發(fā)貨通知通過率直接翻倍。第三要關注用戶主動關閉通知的情況。用戶在小程序右上角的...菜單里可以關閉整個小程序的服務通知開關關閉后你發(fā)訂閱消息接口照樣返回成功但用戶收不到。這不是技術能解決的只能靠內容質量把用戶求回來。5. 高頻報錯排查與避坑實錄5.1 高頻報錯速查表這里整理了我實際開發(fā)中遇到的幾個高頻錯誤碼建議大家收藏備查錯誤碼錯誤含義排查思路40001access_token 無效或過期檢查 token 緩存邏輯是否多實例互相覆蓋重新獲取40003openid 不正確確認 openid 是否來自同一小程序前后端環(huán)境是否一致40037模板 ID 不正確確認模板 ID 是否 T 開頭是否在后臺申請通過41030page 路徑不正確page 必須以 pages/ 開頭且在 app.json 中注冊43101用戶拒絕接受消息授權額度已消耗或用戶拒絕了授權檢查授權庫存47003參數格式錯誤檢查 data 字段 key、value 類型和長度限制45009接口調用超過限額access_token 是否做了緩存是否觸發(fā)微信頻控43101 是大家遇到最多的錯誤但它的原因其實就那么幾個要么用戶確實拒絕了彈窗要么額度已經消耗完了要么授權狀態(tài)還沒來得及同步。第一次遇到建議先等幾秒重試一次還不行就查授權庫存。5.2 審核合規(guī)紅線這些操作一碰就涼訂閱消息最大的合規(guī)紅線是誘導授權。公眾號后臺審核時會重點檢查你的頁面有沒有訂閱有禮開啟通知送優(yōu)惠券這類誘導話術。微信對誘導用戶開啟訂閱的行為定性很明確一旦發(fā)現輕則模板被清退重則封禁消息推送接口。我親眼見過一個項目把訂閱彈窗和紅包活動綁定用戶點允許才能領紅包上線第二天模板就被封了。微信的邏輯很簡單訂閱授權必須是用戶自愿的、出于真實需求的操作不能跟利益掛鉤。另外模板的使用場景要和用戶動作保持一致。你在訂單發(fā)貨模板里推送廣告內容第一次可能沒事被用戶舉報后微信會倒查到時候整個后臺的訂閱消息功能都可能被限制。推送內容務必和模板聲明的場景強綁定。5.3 我踩過的坑和一些實測心得開發(fā)這大半年的小程序消息推送功能我印象最深的是三個教訓第一個是授權彈窗頻控。有一版產品經理要求每個頁面都要引導訂閱結果用戶從一個頁面跳到另一個頁面連續(xù)被彈了四次訂閱框。第二天測試手機就再也彈不出訂閱框了微信對用戶的保護機制直接把我們拉黑了。后來我們收斂成每個用戶生命周期最多彈三次訂閱只在最核心的業(yè)務節(jié)點觸發(fā)。第二個是 data 字段超長的問題。有個后臺配置的功能運營人員填了超過 20 個字的商品名發(fā)送時一直報 47003前端頁面還看不到具體錯誤排查了很久才發(fā)現是字段長度問題。后來我在后端加了一層參數校驗超過長度直接截斷并打日志問題再也沒出現過。第三個是授權庫存的統(tǒng)計口徑。剛開始我們只記錄了用戶授權成功的事件忽略了發(fā)送失敗和用戶主動關閉開關的情況導致運營看的數據和實際情況嚴重不符。后來把授權、消耗、失敗、關閉四種事件全部埋點才算把推送鏈路的完整數據串起來。最后一個實用技巧調試訂閱消息時建議在開發(fā)者工具里先把模擬訂閱消息的功能用起來這個功能可以讓你不用真機彈窗就能測后端發(fā)送鏈路。但注意工具模擬和真機行為存在差異比如授權彈窗的觸發(fā)時機、頻控限制這些最終還是要以真機為準。我一般先用工具調通接口再上真機驗證完整鏈路兩邊配合能省不少時間。