小程序激励视频广告防刷策略:从客户端埋点到服务端风控实战

1. 项目概述:当“白嫖”成为常态,广告主如何守住最后防线?

做小程序开发的朋友,尤其是接入了激励视频广告的,最近是不是感觉有点“肉疼”?用户看完广告,你才能拿到平台分成,这本是天经地义的商业模式。但不知道从什么时候开始,市面上出现了一些所谓的“跳过插件”或“加速器”,它们能自动点击激励视频广告里的“跳过”或“关闭”按钮,让用户在几秒内就看完一个本该30秒的广告,甚至直接模拟广告播放完成回调。结果就是,用户“白嫖”了你的内容或服务,而你和广告平台一分钱都赚不到。

我最近就深度处理了一个棘手的线上问题:一个日活还不错的小工具类小程序,激励视频广告的完播率数据异常下跌,但后台数据显示广告请求量并没减少。一排查,发现是一小撮“聪明”的用户,在用一些非官方的手段绕过广告。这直接触动了收益的核心。所以,今天我们就来彻底拆解一下,如何在小程序端,特别是使用wx.createRewardedVideoAdAPI 时,构建一套有效的防御体系,来检测和防止这种“白嫖”行为。这不是简单的技术对抗,更是一场关乎产品健康度和商业可持续性的攻防战。

2. 激励广告生态与“跳过”原理深度拆解

要防御,首先得知道敌人是怎么进攻的。我们得把wx.createRewardedVideoAd的工作流程和可能的攻击面掰开揉碎了看。

2.1 官方激励视频广告的标准流程

微信小程序的激励视频广告,其生命周期是清晰且受控的:

  1. 创建广告实例let videoAd = wx.createRewardedVideoAd({ adUnitId: ‘你的广告位ID’ })。这一步只是创建了一个对象,并未加载广告。
  2. 加载广告:调用videoAd.load()。此时,小程序会向腾讯广告服务器请求广告素材。这是第一个关键节点,网络请求和服务器响应在这里发生。
  3. 展示广告:调用videoAd.show()。如果加载成功,广告视频会全屏播放。这里有个重要细节:show()方法返回一个 Promise,它仅表示广告展示是否成功触发,不代表广告播放完成。
  4. 监听用户行为:这是收益产生的核心。
    • 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.createRewardedVideoAdvideoAd.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 广告播放状态监听增强除了onCloseonError,激励视频广告对象还有其他事件:

  • onVideoStart:视频开始播放时触发。
  • onVideoEnd:视频播放结束时触发(注意,这与用户点击关闭按钮触发的onClose是两回事)。 一个正常的流程是:show()->onVideoStart-> (播放一段时间) ->onVideoEnd-> 用户点击关闭按钮 ->onClose。 如果日志顺序出现onClose先于onVideoStart,或者onVideoEndonClose的时间差极短(如小于1秒),这都是高度可疑的。

3.2 第二道防线:服务端风险决策与规则引擎

客户端收集的数据需要实时上报到你的业务服务器。服务器端才是做最终风控决策的大脑。

3.2.1 构建用户行为画像为每个用户(OpenID)建立长期的行为档案:

  • 历史完播率:该用户历史上isEnded=true的次数占总广告请求的比例。
  • 平均观看时长:历史watch_duration的平均值。
  • 行为频率:单位时间内(如每分钟、每小时)触发广告的频次。正常用户不会连续不断地看广告。
  • 奖励领取模式:是否总是在广告播放后“恰好”达到某个关键节点(如游戏关卡、领取稀有道具)。

3.2.2 设计风险规则集基于画像和单次请求数据,设定一系列规则。以下是一些示例规则(阈值需要根据你的实际数据调整):

规则编号规则描述风险等级可能原因
R1单次广告watch_duration< 5秒极可能被跳过插件点击关闭,或API被伪造。
R2load_duration< 50ms 且广告播放成功广告加载过快,可能来自本地缓存或伪造响应。
R3onClose触发时间早于onVideoStart流程错乱,肯定是伪造事件。
R4onVideoEndonClose时间差 < 2秒中高用户几乎在广告一结束就关闭,可能是脚本行为。
R5用户近期(1小时内)广告请求频率 > 20次刷广告行为。
R6用户历史完播率 > 95%完播率过高可能不真实,需结合其他规则看。
R7客户端基础库版本低于官方稳定版多个大版本低中使用老旧或非官方客户端风险增高。

3.2.3 实时决策与异步处理当客户端上报广告播放完成并请求发放奖励时,服务端的流程应该是:

  1. 接收请求,包含本次广告的日志ID(或唯一标识)和用户OpenID。
  2. 异步风控检查:从日志系统中拉取该次广告的完整埋点数据,送入规则引擎进行匹配。
  3. 决策
    • 实时拒绝:如果命中一条“高风险”规则(如R1,R3),可以立即拒绝本次奖励发放,并返回一个模糊的错误码(如“系统繁忙”),避免暴露风控逻辑。
    • 延迟发放/标记:如果命中“中风险”规则,可以正常发放奖励,但将该用户或本次行为标记为“待观察”,并将其数据权重加入画像。同时,可以触发更详细的日志记录。
    • 正常发放:未命中任何规则或仅命中低风险规则,则正常发放奖励。

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 进阶思考:从防御到博弈

当基础防御建立起来后,这场攻防可能会升级:

  • 模拟人类行为:高级脚本会模拟随机观看时长、随机点击位置,甚至模拟加速度传感器数据。
  • 对抗思路:可以引入更隐蔽的“挑战”机制。例如,在广告播放的某个随机时间点,在视频上方透明层显示一个极简的、需要人类认知才能完成的交互(如“请点击蓝色的圆”,但颜色和形状每次随机)。虽然这会影响一点用户体验,但对于高价值奖励场景,可以作为终极验证手段。注意:此方案需谨慎评估,可能违反平台规则,上线前务必研究清楚。

最后,记住风控的本质是成本和收益的博弈。作为开发者,我们的任务是让“白嫖”的成本远高于收益,同时保障绝大多数诚实用户的顺畅体验。这套从客户端埋点到服务端分析的立体方案,经过多个项目的实践,能有效遏制大部分自动化“跳过”行为,将广告收益稳定在一个合理的水平。