小程序隐私弹窗上线后投诉归零:wx.getPrivacySetting 与接口授权时序
适用读者:负责微信小程序迭代的前端开发、被平台合规抽查搞得焦头烂额的小程序负责人、以及正在接手别人老项目、发现 chooseImage 一调用就 fail 的接盘侠。
上个月我们一个跑了三年的工具类小程序收到平台合规整改通知,随后一周里 chooseImage、getLocation 这类接口开始直接返回 fail,用户投诉从每天三五条涨到近五十条。查了两天日志才发现,问题根本不在代码逻辑,而是老项目从来没接过隐私协议授权体系。把 wx.getPrivacySetting 那套时序补上之后,接口恢复正常,投诉第二天就归零。这篇文章把整个改造过程完整拆开讲。
出事之前:接口为什么突然 fail 了
微信在基础库 2.32.3 之后逐步强制执行隐私协议机制。平台会做合规抽查,抽查到你后台没配置「用户隐私保护指引」,或者代码里没实现授权弹窗,就会触发整改期。整改期内,所有被标记为隐私接口的 API 直接调用会失败,err_msg 里带着 privacy permission is not authorized 之类的字样。

我们的日志里是这个样子:
wx.chooseImage fail: chooseImage:fail api scope is not declared in the privacy agreement
受影响的不止一个接口。我们项目里中招的有:
| 接口 | 使用的场景 | 涉及的隐私类型 |
|---|---|---|
| wx.chooseImage | 用户上传头像和凭证截图 | 选中的文件 |
| wx.getLocation | 订单页展示附近门店 | 位置信息 |
| wx.chooseAddress | 收货地址快捷填充 | 位置信息 |
| wx.getUserProfile | 会员页昵称头像 | 用户信息 |
以前这些接口只需要配合 scope 授权(比如 scope.userLocation)就能跑。现在多了一层:得先让用户同意隐私协议,然后才轮到 scope 授权这一步。两层授权顺序不能反,反了就 fail。
机制剖析:平台为什么要这样设计
这一节讲原理,搞清楚运作机制,后面排错会省事很多。
微信小程序生态里用户隐私数据的实际持有方是小程序开发者,平台侧能做的监管手段有限。所以微信把合规动作拆成了三层,用技术手段把「开发者承诺」和「用户知情」绑死在调用链上:
- 后台配置层:开发者在小程序管理后台填写「用户隐私保护指引」,声明你会收集哪些类型的隐私数据、用来干什么。这一步是开发者对平台的承诺,不配等于没承诺。
- 运行时告知层:代码里通过 wx.getPrivacySetting 查询当前是否需要弹窗(needAuthorization 字段),需要的话由开发者自己的 UI 把协议内容(通过 wx.getPrivacyContract 拿到真实文本链接)展示给用户。这一步保证用户是在知情的前提下点的同意。
- 接口拦截层:用户同意之前,隐私接口调用会被基础库直接拦截。拦截是按接口清单逐个来的,清单以官方文档为准,且会随版本调整。
这三层缺一层都过不了关。只配了后台不写代码,接口照样 fail;只写了弹窗但后台指引没声明对应类型,也会被判定不合规。我们第一次整改就栽在第二点:代码接好了,但后台指引里漏了「选中的照片或视频信息」这一项,审核没过。
三层之间的关系用一张图看更直观:
flowchart LR
A[后台配置用户隐私保护指引] --> B[代码实现弹窗告知]
B --> C[基础库拦截未授权调用]
C -->|未完成前两层| D[隐私接口直接 fail]
C -->|三层齐备| E[接口正常返回]
完整实现:弹窗组件加调用封装
下面是可落地的方案,环境要求:基础库 ≥ 2.32.3,开发者工具 ≥ 1.06.240,原生小程序(uni-app 思路相同,API 名一致)。
隐私弹窗组件 privacy-popup
先写一个全屏弹窗组件,负责展示协议链接和处理同意动作:
<!-- components/privacy-popup/privacy-popup.wxml -->
<!-- 全屏遮罩层,wx:if 控制显隐避免常驻渲染开销 -->
<view wx:if="{{show}}" class="mask">
<view class="panel">
<!-- 标题按微信官方弹窗样式来,别自创文案 -->
<view class="title">隐私保护提示</view>
<view class="content">
<text>在使用本小程序前,请阅读并同意</text>
<!-- contractUrl 由 wx.getPrivacyContract 返回,点击可查看协议全文 -->
<text class="link" bindtap="openContract">《用户隐私保护指引》</text>
<text>。如你同意,请点击“同意”开始使用。</text>
</view>
<view class="btns">
<!-- 拒绝按钮只关弹窗,不触发任何隐私授权 -->
<!-- 按钮顺序有讲究:拒绝放左侧弱化视觉权重,别放右侧 -->
<button class="btn plain" bindtap="onReject">拒绝</button>
<!-- open-type 必须是 agreePrivacyAuthorization,普通按钮无效 -->
<!-- 同意按钮是整个时序的起点,基础库会记录这次同意行为 -->
<button class="btn primary" id="agree-btn" open-type="agreePrivacyAuthorization"
bindagreeprivacyauthorization="onAgree">同意</button>
</view>
</view>
</view>
关键在同意按钮的 open-type="agreePrivacyAuthorization",这是官方规定的同意通道,基础库会记录这次同意行为,自己写个普通按钮调 API 是不算数的。
组件逻辑:
// components/privacy-popup/privacy-popup.js
// 组件职责:检查授权状态 + 展示弹窗 + 上报同意/拒绝事件
Component({
data: {
// 弹窗显隐,由 check 方法的查询结果驱动
show: false,
// 协议全文链接,来自 wx.getPrivacyContract
contractUrl: ''
},
methods: {
// 外部调用入口:先查隐私设置,需要授权就弹窗
check() {
wx.getPrivacySetting({
success: (res) => {
// needAuthorization 为 true 表示有协议未同意,必须先弹窗
if (res.needAuthorization) {
this.loadContract();
this.setData({ show: true });
} else {
// 已同意过,直接放行后续业务,不再打扰用户
this.triggerEvent('privacyready');
}
},
fail: () => {
// 查询失败时降级放行,别把用户卡死在入口
this.triggerEvent('privacyready');
}
});
},
// 拉取协议真实链接,这个链接指向后台配置的指引文本
loadContract() {
wx.getPrivacyContract({
success: (res) => {
this.setData({ contractUrl: res.url });
}
});
},
// 用户点同意后基础库已记录授权状态,这里通知业务层继续
onAgree() {
this.setData({ show: false });
this.triggerEvent('privacyready');
},
// 拒绝:只关弹窗。隐私接口此时会被拦截,业务层要兜底
onReject() {
this.setData({ show: false });
this.triggerEvent('privacyreject');
},
// 跳转协议全文,用内置 web-view 能力即可
openContract() {
if (this.data.contractUrl) {
wx.navigateTo({
// 协议链接要做 encodeURIComponent,防止 & 截断参数
url: '/pages/webview/webview?url=' + encodeURIComponent(this.data.contractUrl)
});
}
}
}
});
业务调用封装
弹窗组件解决了「要不要弹」,业务层还需要一个封装来保证时序正确。我们把它做成了一个 promise 风格的工具函数:
// utils/privacy.js
// 保证「先隐私协议、后 scope 授权、再调接口」的时序
// 依赖:基础库 >= 2.32.3;页面需挂载 privacy-popup 组件
// 内部状态:本次会话内已同意就不再重复检查,减少弹窗打扰
// 会话级变量即可,小程序冷启动会重置,跨启动交给基础库记忆
let privacyResolved = false;
// 等待队列:弹窗未关闭前进来的调用先挂起,同意后统一放行
let pendingResolvers = [];
// 等待隐私授权完成。已同意则立即 resolve,否则挂起等待弹窗结果
function ensurePrivacyAuthorized(page) {
if (privacyResolved) {
return Promise.resolve(true);
}
return new Promise((resolve, reject) => {
// 挂起当前调用,等页面在 privacyready 事件里唤醒
pendingResolvers.push({ resolve, reject });
// 触发弹窗组件的检查逻辑,只触发一次,避免重复 setData
const popup = page.selectComponent('#privacyPopup');
if (popup && !popup.data.show) {
popup.check();
}
});
}
// 由页面在 privacyready 事件里调用,放行所有等待者
// 注意要先把状态置 true 再遍历,避免 resolve 回调里再触发检查造成死循环
function resolveAll() {
privacyResolved = true;
// 逐个唤醒挂起中的 Promise,页面里可以有多个接口同时等待
pendingResolvers.forEach((item) => item.resolve(true));
pendingResolvers = [];
}
// 拒绝后放行失败,业务层据此走兜底
// 同样要清空队列,防止残留的 resolver 内存泄漏
function rejectAll() {
pendingResolvers.forEach((item) => item.reject(new Error('privacy_rejected')));
pendingResolvers = [];
}
// 完整封装:确保授权后调用 wx.requirePrivacyAuthorize,再执行真实接口
// page: 当前页面实例;apiName: 隐私接口名;options: 透传给接口的参数
function callWithPrivacy(page, apiName, options) {
return ensurePrivacyAuthorized(page)
// requirePrivacyAuthorize 会触发系统级授权弹窗(scope 层)
.then(() => wx.requirePrivacyAuthorize({}))
.then(() => {
return new Promise((resolve, reject) => {
// 走到这里说明隐私协议和 scope 都已就绪,正常调接口
wx[apiName]({
...options,
success: resolve,
// 失败原样抛给业务层,由业务层决定兜底方式
fail: (err) => reject(err)
});
});
});
}
module.exports = { ensurePrivacyAuthorized, resolveAll, rejectAll, callWithPrivacy };
页面侧的接线大概三行:
// pages/index/index.js 片段
// 引入封装好的隐私时序工具,页面只需处理两个事件
const privacy = require('../../utils/privacy');
// 页面 wxml 里挂载弹窗组件并接线事件:<privacy-popup id="privacyPopup" bind:privacyready="onPrivacyReady" bind:privacyreject="onPrivacyReject" />
// privacyready:用户同意协议,唤醒所有挂起中的接口调用
onPrivacyReady() {
privacy.resolveAll();
},
// privacyreject:用户拒绝协议,所有等待调用转入兜底分支
onPrivacyReject() {
privacy.rejectAll();
},
// 拍照上传按钮的点击处理
onTakePhoto() {
privacy.callWithPrivacy(this, 'wx.chooseImage', { count: 1 })
.then((res) => this.upload(res.tempFilePaths[0]))
// 拒绝协议或接口失败的兜底:提示后改用手动输入,别让流程断掉
// errMsg 里有 privacy 关键字时可以顺带上报,便于统计拒绝率
.catch(() => wx.showToast({ title: '未授权,可手动填写', icon: 'none' }));
}
时序图
正确时序长这样,注意两层授权的先后:
sequenceDiagram
participant U as 用户
participant P as 页面
participant C as 弹窗组件
participant W as 基础库
U->>P: 点击"上传图片"
P->>C: ensurePrivacyAuthorized
C->>W: wx.getPrivacySetting
W-->>C: needAuthorization=true
C-->>U: 展示隐私弹窗
U->>C: 点击"同意"
C->>W: agreePrivacyAuthorization
W-->>C: 隐私授权完成
P->>W: wx.requirePrivacyAuthorize
W-->>U: scope 授权弹窗
U->>W: 允许
W-->>P: chooseImage success
改造前后的行为对比:
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 隐私协议弹窗 | 无 | 首次触发隐私接口前弹出 |
| 接口调用链 | 直接调 API | 检查→弹窗→scope→API |
| 平台合规抽查 | 整改期接口 fail | 全部通过 |
| 用户拒绝时 | 不存在该场景 | 静默兜底,引导手动输入 |
| 用户投诉量 | 每天 50 条上下 | 归零 |
后台「用户隐私保护指引」配置要点
代码只是半件事,后台配置不过审一样白费劲。几个实操要点:
- 逐项对齐代码实际调用的接口。用了 chooseImage 就声明「选中的照片或视频信息」,用了 getLocation 就声明「位置信息」。多声明问题不大,漏声明必挂。
- 用途描述写具体。写「用于上传用户头像」能过,写「业务需要」会被打回。
- 指引文本更新后要重新提交审核,审核期间旧版本照常生效,不会出现真空期。
- 开发版和体验版也会拦截。别指望上线才暴露问题,本地开发者工具切到 2.32.3 以上基础库就能复现拦截行为。
- 配置入口在 mp.weixin.qq.com 的「设置-服务内容声明」里,类目不同可选的隐私类型清单也不同,找不到某个类型时先确认类目是否匹配。我们当时找「选中的照片或视频信息」翻了十分钟,后来发现要先把图片处理类的服务类目勾上。
排错与兜底
上线前我们用开发者工具的「清除模拟器缓存」反复验证首次弹窗,发现的坑主要三个:
- 弹窗弹出时机太晚。一开始把检查放在每个接口调用前,用户点上传才弹窗,体验突兀。后来改成 app 启动后首页 onLoad 就检查一次,进入具体功能时基本不会再弹。
- scope 授权被永久拒绝。隐私协议同意了但用户之前拒绝过位置授权,getLocation 依然 fail。这种情况要在 fail 回调里调 wx.openSetting 引导用户去设置页打开。
- 自定义弹窗文案带诱导。有同行把拒绝按钮做成浅灰小字被平台判定为误导,弹窗文案别耍小聪明。
改造落地那周的具体数字:日志里 privacy 相关 fail 从每天 1200 多次降到个位数,剩下的基本是拒绝协议的用户。接口调用失败不可怕,可怕的是失败之后没有任何兜底,用户只能投诉。所以每个 catch 里都要给用户留一条能完成事情的路。
另外分享一个监控上的小动作。我们在接口封装的 catch 里统一埋了上报,带上 apiName 和 err.errMsg,接进现有的日志平台。这样整改期内哪些接口在 fail、fail 的原因归类(协议未同意、scope 被拒、指引未声明),后台一张报表就能看清。第二轮整改前,我们就是靠这张表确认三个高频 fail 接口,逐个补的弹窗场景,没再靠猜。
误区澄清
最后纠正两个常见误解。一是「配了后台指引就不用写代码」,不对,运行时弹窗是独立要求,两层都要做。二是「等用户触发隐私接口时再弹窗也来得及」,机制上确实合规,但体验上突兀,实际项目里建议入口处预检查、功能点静默通过,弹窗次数越少留存越好。
如果你也接到过整改通知,欢迎评论区聊聊你踩的坑。哪个环节最容易漏,大家体感不太一样,有的卡在后台指引,有的卡在基础库版本。
参考与延伸
- 小程序用户隐私保护指引内容介绍
- wx.getPrivacySetting 官方文档
- wx.requirePrivacyAuthorize 官方文档
- wx.getPrivacyContract 官方文档
微信小程序、隐私协议、wx.getPrivacySetting、requirePrivacyAuthorize、接口授权时序、合规开发、隐私弹窗