小程序激励视频广告防刷策略:从客户端埋点到服务端风控实战
1. 项目概述当“白嫖”成为常态广告主如何守住最后防线做小程序开发的朋友尤其是接入了激励视频广告的最近是不是感觉有点“肉疼”用户看完广告你才能拿到平台分成这本是天经地义的商业模式。但不知道从什么时候开始市面上出现了一些所谓的“跳过插件”或“加速器”它们能自动点击激励视频广告里的“跳过”或“关闭”按钮让用户在几秒内就看完一个本该30秒的广告甚至直接模拟广告播放完成回调。结果就是用户“白嫖”了你的内容或服务而你和广告平台一分钱都赚不到。我最近就深度处理了一个棘手的线上问题一个日活还不错的小工具类小程序激励视频广告的完播率数据异常下跌但后台数据显示广告请求量并没减少。一排查发现是一小撮“聪明”的用户在用一些非官方的手段绕过广告。这直接触动了收益的核心。所以今天我们就来彻底拆解一下如何在小程序端特别是使用wx.createRewardedVideoAdAPI 时构建一套有效的防御体系来检测和防止这种“白嫖”行为。这不是简单的技术对抗更是一场关乎产品健康度和商业可持续性的攻防战。2. 激励广告生态与“跳过”原理深度拆解要防御首先得知道敌人是怎么进攻的。我们得把wx.createRewardedVideoAd的工作流程和可能的攻击面掰开揉碎了看。2.1 官方激励视频广告的标准流程微信小程序的激励视频广告其生命周期是清晰且受控的创建广告实例let videoAd wx.createRewardedVideoAd({ adUnitId: ‘你的广告位ID’ })。这一步只是创建了一个对象并未加载广告。加载广告调用videoAd.load()。此时小程序会向腾讯广告服务器请求广告素材。这是第一个关键节点网络请求和服务器响应在这里发生。展示广告调用videoAd.show()。如果加载成功广告视频会全屏播放。这里有个重要细节show()方法返回一个 Promise它仅表示广告展示是否成功触发不代表广告播放完成。监听用户行为这是收益产生的核心。onClose广告关闭时触发。回调函数会收到一个res参数其中res.isEnded是黄金指标。res.isEnded true表示用户看完了广告或达到了平台认定的有效播放时长此时应该发放奖励。res.isEnded false表示用户中途关闭了广告通常不发放奖励。onError广告加载或播放出错时触发。整个流程的设计是建立在客户端与微信客户端环境、广告服务器之间可信交互的基础上的。收益结算的最终依据是广告平台服务器收到的有效播放数据而res.isEnded是客户端据此发放奖励的凭证。2.2 “跳过插件”的常见攻击手段所谓的“跳过插件”其本质是在客户端层面进行自动化或模拟操作干扰上述流程。根据其技术原理大致分两类2.2.1 界面模拟点击类这是较低级但常见的方式。插件作为一个辅助工具Accessibility Service或基于图像识别监测到屏幕上出现特定的“跳过”或“关闭”按钮通常有固定的特征如颜色、位置、文本时自动触发点击事件。攻击点在广告播放中途自动点击关闭按钮。结果导致onClose事件被触发且res.isEnded很可能为false因为未播放完。对于单纯依赖isEnded来判断的简单逻辑用户无法获得奖励。但更狡猾的插件可能会尝试在广告播放的最后几秒点击试图让isEnded变为true。2.2.2 运行时环境钩子与API拦截类这是更高级、更隐蔽的攻击方式。插件通过注入代码、修改运行时环境例如在越狱或Root的设备上或使用修改过的微信客户端直接拦截或篡改 JavaScript 与原生层Native的通信。攻击点伪造onClose事件直接模拟系统调用触发广告实例的onClose回调并传入{ isEnded: true }。劫持wx.createRewardedVideoAd或videoAd.show返回一个被篡改的广告对象其onClose监听器总是收到isEnded: true。加速广告播放修改系统时钟或视频播放组件的内部状态让广告在极短时间内“被播放完成”。结果无论用户是否真的观看了广告客户端逻辑都会认为广告已有效播放从而发放奖励。这种攻击完全绕过了界面交互直接从逻辑层面进行欺骗。注意讨论这些手段是为了防御开发者绝对不应自己制作或传播此类插件。我们的所有策略都应在微信小程序官方规范和安全框架内实施。3. 防御体系设计从客户端到服务端的立体监控单一的防御措施很容易被绕过。一个健壮的防御体系应该是多层次、立体化的结合客户端特征收集、行为分析和服务端决策。核心思想是不轻信客户端上报的任何单一信号尤其是关乎收益的isEnded。3.1 第一道防线客户端异常行为检测与数据采集在广告播放的关键生命周期里我们可以埋点收集一系列环境与行为数据这些数据本身不直接作为“是否作弊”的判断而是作为后续分析的原始日志。3.1.1 关键计时节点埋点这是最基础且重要的数据。我们需要高精度的时间戳建议使用Date.now()。t_load_start: 调用load()的时间。t_load_end:load()的 Promise resolve 或onLoad事件触发的时间。load_duration t_load_end - t_load_start。异常短的加载时间如100ms可能意味着请求被劫持或返回了缓存/空结果。t_show: 调用show()的时间。t_close:onClose回调被触发的时间。watch_duration t_close - t_show。t_reward: 你的业务逻辑里最终调用发放奖励接口的时间。3.1.2 客户端环境信息收集需谨慎合规收集这些信息前务必在你的《隐私政策》中明确告知并获得用户同意。基础环境通过wx.getSystemInfoSync()获取手机型号、系统版本、微信版本、客户端基础库版本。低版本的基础库或非官方微信客户端如某些“破解版”风险更高。网络环境wx.getNetworkType()获取网络类型。频繁在Wi-Fi和蜂窝网络间切换可能异常。屏幕与交互状态wx.onAccelerometerChange监听加速度计数据。在观看全屏广告时手机通常处于相对静止状态。如果广告播放期间加速度数据剧烈变化可能是在进行其他操作。wx.getScreenBrightness获取屏幕亮度突然变化也可能意味着跳出广告。3.1.3 广告播放状态监听增强除了onClose和onError激励视频广告对象还有其他事件onVideoStart视频开始播放时触发。onVideoEnd视频播放结束时触发注意这与用户点击关闭按钮触发的onClose是两回事。 一个正常的流程是show()-onVideoStart- (播放一段时间) -onVideoEnd- 用户点击关闭按钮 -onClose。 如果日志顺序出现onClose先于onVideoStart或者onVideoEnd与onClose的时间差极短如小于1秒这都是高度可疑的。3.2 第二道防线服务端风险决策与规则引擎客户端收集的数据需要实时上报到你的业务服务器。服务器端才是做最终风控决策的大脑。3.2.1 构建用户行为画像为每个用户OpenID建立长期的行为档案历史完播率该用户历史上isEndedtrue的次数占总广告请求的比例。平均观看时长历史watch_duration的平均值。行为频率单位时间内如每分钟、每小时触发广告的频次。正常用户不会连续不断地看广告。奖励领取模式是否总是在广告播放后“恰好”达到某个关键节点如游戏关卡、领取稀有道具。3.2.2 设计风险规则集基于画像和单次请求数据设定一系列规则。以下是一些示例规则阈值需要根据你的实际数据调整规则编号规则描述风险等级可能原因R1单次广告watch_duration 5秒高极可能被跳过插件点击关闭或API被伪造。R2load_duration 50ms 且广告播放成功中广告加载过快可能来自本地缓存或伪造响应。R3onClose触发时间早于onVideoStart高流程错乱肯定是伪造事件。R4onVideoEnd与onClose时间差 2秒中高用户几乎在广告一结束就关闭可能是脚本行为。R5用户近期1小时内广告请求频率 20次高刷广告行为。R6用户历史完播率 95%中完播率过高可能不真实需结合其他规则看。R7客户端基础库版本低于官方稳定版多个大版本低中使用老旧或非官方客户端风险增高。3.2.3 实时决策与异步处理当客户端上报广告播放完成并请求发放奖励时服务端的流程应该是接收请求包含本次广告的日志ID或唯一标识和用户OpenID。异步风控检查从日志系统中拉取该次广告的完整埋点数据送入规则引擎进行匹配。决策实时拒绝如果命中一条“高风险”规则如R1R3可以立即拒绝本次奖励发放并返回一个模糊的错误码如“系统繁忙”避免暴露风控逻辑。延迟发放/标记如果命中“中风险”规则可以正常发放奖励但将该用户或本次行为标记为“待观察”并将其数据权重加入画像。同时可以触发更详细的日志记录。正常发放未命中任何规则或仅命中低风险规则则正常发放奖励。3.3 第三道防线业务逻辑层面的加固与混淆在客户端代码层面也可以增加攻击者的逆向和篡改成本。3.3.1 广告实例生命周期管理不要全局缓存一个广告实例反复使用。可以为每次广告展示都创建新的实例并在奖励发放后销毁引用。这增加了攻击者钩住特定实例的难度。// 不推荐 // let globalVideoAd null; // 全局实例 // 推荐每次展示前创建 async function showRewardedVideo() { const videoAd wx.createRewardedVideoAd({ adUnitId: ‘your-ad-unit-id’ }); try { await videoAd.load(); await videoAd.show(); } catch (err) { // 处理加载或播放失败 console.error(‘广告展示失败’, err); // 销毁实例 videoAd.offClose(); return; } // 监听关闭事件 videoAd.onClose((res) { // 立即移除监听防止重复触发 videoAd.offClose(); // 将关键数据isEnded, 本次实例的日志ID上报给服务端 reportAdLog(日志ID, { isEnded: res.isEnded }); // **关键服务端决定是否发放奖励** // 客户端不直接根据 isEnded 发放奖励而是请求服务端接口 if (res.isEnded) { requestGrantReward(日志ID).then(serverRes { if (serverRes.success) { // 服务端确认才更新UI updateUserReward(); } else { // 服务端拒绝提示用户 wx.showToast({ title: ‘奖励发放失败’ icon: ‘none’ }); } }); } }); }3.3.2 关键逻辑混淆与校验签名校验客户端在上报日志时可以将关键数据如时间戳、用户ID、日志ID按照一定算法生成一个签名一并上报。服务端用同样算法校验防止数据在传输过程中被篡改。代码混淆使用小程序自带的代码压缩和混淆或构建工具如webpack的混淆插件增加核心风控代码的阅读难度。4. 实操部署从零构建风控日志系统理论说完了我们来看一个简化的、可落地的部署方案。假设你已有一个小程序和后台服务。4.1 客户端埋点SDK封装首先封装一个统一的广告监控模块adMonitor.js// adMonitor.js class AdMonitor { constructor(adUnitId) { this.adUnitId adUnitId; this.logId null; // 本次广告的唯一日志ID this.timestamps {}; } // 开始一次广告流程 start(adType ‘rewardedVideo’) { this.logId ad_${Date.now()}_${Math.random().toString(36).substr(2, 9)}; this.timestamps { start: Date.now() }; this.adType adType; return this.logId; } // 记录关键节点 mark(eventName) { this.timestamps[eventName] Date.now(); } // 获取持续时长 getDuration(from, to) { if (this.timestamps[from] this.timestamps[to]) { return this.timestamps[to] - this.timestamps[from]; } return null; } // 上报日志到服务器建议使用wx.request的POST方法这里简写 report(data {}) { const logData { logId: this.logId, adUnitId: this.adUnitId, adType: this.adType, openId: getApp().globalData.openId, // 假设已获取 systemInfo: wx.getSystemInfoSync(), networkType: ‘unknown’, timestamps: this.timestamps, durations: { load: this.getDuration(‘loadStart’, ‘loadEnd’), watch: this.getDuration(‘show’, ‘close’) }, ...data }; // 获取网络类型并上报 wx.getNetworkType({ success: (res) { logData.networkType res.networkType; this._sendReport(logData); }, fail: () this._sendReport(logData) }); } _sendReport(data) { // 这里调用你的服务端日志接收接口 wx.request({ url: ‘https://your-api.com/log/ad’, method: ‘POST’, data: data, fail: (err) console.error(‘日志上报失败’, err) }); } } module.exports AdMonitor;4.2 业务代码中集成监控在页面或组件中使用// pages/reward.js const AdMonitor require(‘../../utils/adMonitor’); Page({ data: { /* ... */ }, onTapWatchAd() { const monitor new AdMonitor(‘your-ad-unit-id-here’); const logId monitor.start(); // 开始记录生成logId monitor.mark(‘loadStart’); const videoAd wx.createRewardedVideoAd({ adUnitId: ‘your-ad-unit-id-here’ }); videoAd.load().then(() { monitor.mark(‘loadEnd’); return videoAd.show(); }).then(() { monitor.mark(‘show’); }).catch(err { console.error(‘广告出错’, err); // 上报错误日志 monitor.report({ error: err.errMsg }); }); videoAd.onLoad(() { monitor.mark(‘loadEnd’); }); videoAd.onVideoStart(() { monitor.mark(‘videoStart’); }); videoAd.onVideoEnd(() { monitor.mark(‘videoEnd’); }); videoAd.onClose((res) { monitor.mark(‘close’); // 上报关闭事件和结果 monitor.report({ closeReason: res.isEnded ? ‘ended’ : ‘userCancel’, isEnded: res.isEnded }); // 重要奖励发放请求附带logId供服务端查询 if (res.isEnded) { wx.request({ url: ‘https://your-api.com/reward/grant’, method: ‘POST’, data: { logId: logId }, success: (res) { if (res.data.code 0) { // 服务端风控通过发放奖励 this.grantReward(); } else { wx.showToast({ title: ‘奖励校验失败’ icon: ‘none’ }); } } }); } }); }, grantReward() { // 实际发放奖励的业务逻辑 // ... } });4.3 服务端风控接口实现示例Node.js一个简单的服务端风控校验接口// reward/grant 接口 const express require(‘express’); const router express.Router(); const RiskEngine require(‘../services/riskEngine’); // 假设的风险引擎 router.post(‘/grant’, async (req, res) { const { logId, openId } req.body; // 假设从认证中间件获取openId // 1. 根据logId查询完整的广告日志 const adLog await AdLogModel.findOne({ logId }); // 假设的日志模型 if (!adLog) { return res.json({ code: 404, msg: ‘日志不存在’ }); } // 2. 调用风控引擎进行分析 const riskResult await RiskEngine.analyze(adLog, openId); // 3. 根据风险等级决策 if (riskResult.level ‘HIGH’) { // 高风险拒绝发放可记录到黑名单或增加风控分数 await UserRiskModel.updateOne({ openId }, { $inc: { riskScore: 10 } }); return res.json({ code: 403, msg: ‘活动太火爆啦请稍后再试’ }); // 模糊提示 } if (riskResult.level ‘MEDIUM’) { // 中风险发放但记录 await UserRiskModel.updateOne({ openId }, { $inc: { riskScore: 5 }, $push: { suspiciousLogs: logId } }); // 继续执行发放逻辑 } // 4. 执行发放奖励的业务逻辑更新用户余额、发放道具等 const grantResult await grantService.grantRewardToUser(openId, ‘video_ad’); if (grantResult.success) { // 5. 标记该日志已处理并发放奖励 await AdLogModel.updateOne({ logId }, { rewarded: true }); return res.json({ code: 0, data: { reward: grantResult.reward } }); } else { return res.json({ code: 500, msg: ‘奖励发放失败’ }); } });5. 常见问题、排查技巧与进阶思考在实际部署和运营中你会遇到各种各样的问题。下面是我踩过的一些坑和总结的经验。5.1 常见问题与解决方案速查表问题现象可能原因排查步骤与解决方案风控规则误杀率高正常用户被拒绝奖励。规则阈值设置过于严格。1. 分析被拒绝用户的日志查看命中规则。2. 将日志按watch_duration等字段分布可视化找到正常用户的区间如10%-90%分位数。3. 逐步放宽阈值并观察完播率、收益和投诉率的变化。客户端上报的日志大量丢失。1. 网络问题。2. 上报接口性能瓶颈或错误。3. 用户强制关闭小程序。1. 客户端实现日志本地缓存队列在网络恢复或下次启动时重发。2. 服务端接口做好限流和降级确保高可用。3. 对于关键奖励发放采用服务端超时查询机制如果一段时间内没收到客户端上报的close日志则主动视为无效。攻击者通过频繁更换OpenID或设备来绕过单用户画像。设备指纹或IP层面的攻击。1. 收集更稳定的设备指纹信息需合规如屏幕分辨率、操作系统、字体列表等组合。2. 结合IP地址进行频次限制注意动态IP问题。3. 关注同一设备指纹在短时间内关联的多个OpenID的行为。onVideoEnd事件在某些安卓机型上不触发。微信客户端或系统兼容性问题。1. 不要强依赖onVideoEnd。2. 将onVideoEnd作为辅助信号而非必要信号。核心依然以onClose中的isEnded为主并结合观看时长 (watch_duration) 进行判断。服务端风控延迟导致用户体验变差。风控规则复杂查询多耗时长。1.分级风控先进行快速规则检查如频率、时长通过则立即发放奖励异步进行复杂画像分析。对于异步分析发现的高风险行为可在下次请求时拦截或进行“追回”操作如扣除积分需谨慎并有明确规则。2. 优化数据库查询对日志表建立合适索引如logId,openId,timestamp。5.2 实操心得平衡的艺术没有银弹绝对的安全不存在。我们的目标是提高攻击者的成本使其无利可图从而转向其他更“软”的目标。将大部分普通用户和低水平脚本阻挡在外就已经成功了。数据驱动迭代风控不是一次性配置。需要建立数据看板持续监控关键指标广告请求量、完播率、奖励发放量、风控拦截量、用户投诉率。根据数据波动及时调整规则。用户体验优先风控过严误杀正常用户伤害产品口碑。风控过松则损失收益。在规则设计上对于高风险行为可以“宁可错杀”因为很可能是机器对于中低风险行为应以“观察记录”为主。给用户清晰的反馈当奖励被拒绝时提示语应模糊且友好如“网络异常奖励发放失败请重试”或“活动过于火爆请稍后再试”避免直接指责用户作弊。关注官方动态微信小程序团队也在不断升级广告组件的安全能力。关注官方文档更新或许会有新的API或配置项来增强广告验证。5.3 进阶思考从防御到博弈当基础防御建立起来后这场攻防可能会升级模拟人类行为高级脚本会模拟随机观看时长、随机点击位置甚至模拟加速度传感器数据。对抗思路可以引入更隐蔽的“挑战”机制。例如在广告播放的某个随机时间点在视频上方透明层显示一个极简的、需要人类认知才能完成的交互如“请点击蓝色的圆”但颜色和形状每次随机。虽然这会影响一点用户体验但对于高价值奖励场景可以作为终极验证手段。注意此方案需谨慎评估可能违反平台规则上线前务必研究清楚。最后记住风控的本质是成本和收益的博弈。作为开发者我们的任务是让“白嫖”的成本远高于收益同时保障绝大多数诚实用户的顺畅体验。这套从客户端埋点到服务端分析的立体方案经过多个项目的实践能有效遏制大部分自动化“跳过”行为将广告收益稳定在一个合理的水平。

相关新闻