MeteorCat / 广告系统(一)

Created Thu, 30 Nov 2023 20:04:45 +0800 Modified Wed, 29 Oct 2025 23:24:45 +0800
5366 Words

广告系统(一)

第三方广告系统集成基本上有三个角色, 以游戏相关为例:

  • business: 常规 CP 商户集成后台
  • api: 应用接口对象, 用于接口接入
  • admin: 我们主体的管理对象

注意: 还有平台 app 比如接入我们对应的 SDK, 才能监控到初始化(init,类似于安装)/注册(register,归因关联)/支付( recharge,付费玩家)

主要基础采用这三方对象, 广告系统当中基本含有如下的元素:

  • platform: 推广平台对象, 比如 IOS/Android/Window 等对应平台对象, 一般由 admin 定义.
  • app: 推广平台应用, 比如上架某个应用广告需要进行系统接入, 一般由 business 提交, admin 审批通过就能操作.
  • channel: 推广平台渠道, 即为接入的第三方SDK对象, 如 快手/抖音/FB/Google 之类, 是由 admin 定义和接入SDK后 business 填写所需配置来记录事件.
  • plan: 平台广告计划, 这里就是将之前那些关联起来之后生成我们自己的平台广告对象, 由 adminbusiness 做具体配置, 需要第三方SDK相关配置提交发布.

对于 cusotmer 来说应该提交对应资源让我们广告计划后台生成推广地址, 比如 CP 方提交应用之后我们需要构建个落地页面提供下载, 后面就是第三方平台配置了.

这里假设目前 CP 方面审核完广告计划, 之后就是我们自己配置第三方流程. 我们在后台生成一个专属的下载链接:

  1. 比如后台提交了 app_id = 10001 的应用, 在审核完成之后就需要我们广告平台比如 FaceBook 后台追加 Ad 广告信息
  2. Ad-Destination 当中配置 Website URL 落地页地址或者下载地址(我们平台的落地页面), 还有我们作为广告平台配置广告素材等
  3. 开始跑量时候, 玩家通过点击 Website URL 地址渲染落地页, 之后前端检索 url 所带参数推送 browse(浏览) 事件(接口匹配参数).
  4. 玩家点击下载 app 的时候 app 接入我们平台 SDK 触发 init, 这里继续推送 install(登录) 事件(接口匹配参数).
  5. 最后玩家注册账号, 这时候玩家已经是成功的广告转化用户( 注意必须唯一标识参数匹配, 否则只能是自然量用户 ), 推送到我们 register(注册) 事件(接口匹配参数).
  6. OK, 当玩家支付的时候就能完全推送 recharge(支付) 事件(接口匹配参数), 这时候就是优秀的数据转化玩家, 也就是对平台有价值的用户.

上面涉及到的 (接口匹配参数) 不止要对点击时候的参数匹配绑定广告来源, 还需要对其反过去上报第三方从而切换流量池.

这里提出新的概念: 流量池, 在所有广告系统为了精准广告推送衍生了对用户群体采用 tag 标识区分出特定人群的 group; 比如会对频繁浏览游戏的用户打上 gamer 从而放置在 gamer 分组, 第三方会精准推送游戏类型相关广告到对象上.

在第三方广告平台要求所有点击触发转化过程都要反过来上报流程, 用于标识用户有效转化率从而保证 流量池 内部和我们广告匹配, 如果出现大量流量不匹配的情况, 第三方广告平台会即时切换新的 流量池 直至满足我们自己平台推送的广告流量对象.

反过来上报第三方及其重要, 关系到第三方给你分配的流量精确度.

之后更加核心的点就是流量数据对账, Facebook 之前曾经发生过内部错误导致浏览安装量疯狂暴涨导致广告计费也一起暴涨( FB 的骗广告主流量也是臭名昭著 ), 所以在提供给 business 对象平台需要做大量的实时统计保证数据能够尽量匹配上第三方的流量消耗.

基础实现

开发初期最好细分好需要提供服务的主题对象, 也就是 business/admin/api, 这里采用 RestAPI 处理:

# admin 相关接口
POST /admin/user/login 管理员登录

GET  /admin/platform 平台列表获取
GET  /admin/platform/{id} 平台指定数据获取
POST /admin/platform/create 平台创建与更新
POST /admin/platform/update/{id} 指定平台更新

GET  /admin/channel 第三方列表获取
GET  /admin/channel/{id} 第三方指定数据获取
POST /admin/channel/create 第三方创建
POST /admin/channel/update/{id} 指定第三方更新

GET  /admin/app 获取所有的应用列表, 管理员是获取全部
GET  /admin/app/{id} 获取指定的应用id信息
POST /admin/app/verify/{id} 审核指定应用


# business 相关接口
POST /business/user/login 使用方登录

GET  /business/platform 平台列表获取, 用来提供上架筛选处理
GET  /business/channel 第三方列表获取, 用来提供上架筛选处理

GET  /business/app 获取所属使用方的应用
GET  /business/app/{id} 具体应用数据获取
POST /business/app/create 应用创建
POST /business/app/update/{id} 应用更新

GET /business/page/{app_code} 生成的应用广告落地页地址链接, 用于放置第三方广告平台落地页地址


# api 相关接口
POST|GET /api/event/push 事件上报地址, 这里可以是接入的SDK推送, 也可以是落地页推送浏览玩家事件返回

这里主要的 /business/page/{app_code} 就是应用具体的落地页地址, 可能在第三方SDK当中会传入大量 ?xxx=yyy 参数附加回来, 所以这个接口就是统计浏览和渲染页面主要入口; 可以先实现下这个渲染接口, 因为该接口没有权限控制相关比较复杂的逻辑, 相对比较简单容易实现:

package com.app.cloud.controllers.business;

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;

import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/business/page")
public class BusinessPage {

    /**
     * 日志句柄
     */
    final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 请求句柄
     */
    final HttpServletRequest request;

    public BusinessPage(HttpServletRequest request) {
        this.request = request;
    }

    @ResponseBody
    @GetMapping("/{code}")
    public Object show(@PathVariable String code) {
        // todo: 数据库检索是否有对应 app:code 唯一标识码匹配


        // 检索出所有的GET参数准备匹配
        Enumeration<String> names = request.getParameterNames();
        Map<String, String> params = new HashMap<>();
        while (names.hasMoreElements()) {
            String name = names.nextElement();
            String value = request.getParameter(name);
            params.put(name, value);
        }

        // 这里实际上渲染落地下载页面, 有两种方式处理
        // 1. 页面监听JS点击下载事件, 然后推送事件到 /event/push 接口访问量+1(下载才算访问+1)
        // 2. 页面进来直接读取链接的GET参数, 这时候内部渲染完成也代表了访问量+1(渲染完成才算访问+1)
        // 按照上面方式不同浏览量的统计也相应有所不同


        // 以下是提交落地页访问涵盖参数入库, 访问进来也结算为访问量+1
        logger.info("提交参数: {}", params);

        // 数据入库, 注意这里的页面做好限流, 但是要求比如将参数完整入库不要怕浪费数据库空间
        // todo: 这里后续是要做对比跟踪广告链条, 所以数据库需要将 app_id/ip/params(json) 写入数据库

        return String.format("这是应用:%s的落地渲染模板页面", code);
    }

}

另外的就是事件上报功能, 事件的上报也是不需要权限但是需要判断应用信息从而按照渠道反过来上报给第三方筛选 流量池:

package com.app.cloud.controllers.api;


import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api/event")
public class ApiEvent {

    /**
     * 日志句柄
     */
    final Logger logger = LoggerFactory.getLogger(getClass());


    /**
     * 请求句柄
     */
    final HttpServletRequest request;

    public ApiEvent(HttpServletRequest request) {
        this.request = request;
    }


    /**
     * 事件上报必须以POST方式提交, 同时相应传统Rest方式
     * 同时这里要求必须以JSON原生数据提交, 也就是直接提交 { 'xxx':'yyy',... } 方式(contentType: 'application/json;charset=UTF-8')推送上来
     *
     * @return JSON
     */
    @PostMapping("/push")
    public Object push(@RequestBody Map<String, Object> data) {
        // 注意: 虽然空间很宝贵, 但是不要舍不得写入数据库, 广告系统很多都没有调试模式, 在后续排查问题的时候必须不断对比日志来排查问题.
        // 所以建议将本身提交的数据直接入库处理, 在加上IP请求次数限流从而保证数据完整性.
        logger.debug("data: {}", data);

        // todo: 数据库入库保存, 后续就是匹配数据关联出应用数据然后完成用户注册, 这种情况叫做数据归因(用户已经完成注册转化链条)
        // 数据表当中默认 app_id 字段相关留空, 当推送事件的时候插入事件并且尝试对其事件匹配 app_id 和广告计划id

        // todo: 这里需要对数据进行清洗, 方便给事件数据归因到指定应用
        // 内部提交的设备识别码等可以被识别的唯一标识和落地页识别出来唯一标识匹配, 那么就代表这个用户是广告转化用户
        // 如果没有匹配则代表这个用户是自然量用户, 也就是不走第三方广告直接进来的用户, 也就不进行广告成效统计

        // todo: 还有需要注意, 不可能点击之后无限时间的记录都可以被触发为归因玩家, 所以需要设定浏览|下载之后到达N天才被计算归因玩家  
        // 所以需要归因天数的区间, 最好是 CP 方可以灵活配置, 天数为 N > 1 的情况.
        
        // 响应返回JSON
        return "{}";
    }
    
}

以上就是针对 用户->广告->落地页->下载 的访问流程交互, 这两个接口基本上完成我们作为广告流程, 之后就是细化 CP 方的业务和我们广告方的具体业务.

管理员授权

最开始 adminbusiness 模块是合并在一起的, 为了页面复用所以两者只在服务端方面对数据做过滤处理. 但是在工作多年之后发现这种方式扩展性问题很大, 两个角色合并在一起需要大量的工作来判断 is_supper=1 确定是否允许默认查询全部或者只查询账号下的自己部分数据.

在后续的情况之后, 功能权限需要越来越复杂的拦截代码 is_supper=1 等过滤返回数据; 所以后续功能最好提取出管理角色, 将最高管理员抽象出来另外做权限.

这里采用 RestAPI 方式先做后台登录授权, 方便后续前后端分离来处理 adminbusiness 授权方式, 这里设定登录接口( /admin/user/login ):

package com.app.cloud.controllers.admin;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;


@RestController
@RequestMapping("/admin/user")
public class AdminUser {


    /**
     * 日志句柄
     */
    final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 请求句柄
     */
    final HttpServletRequest request;

    public AdminUser(HttpServletRequest request) {
        this.request = request;
    }


    /**
     * 玩家信息结构
     */
    @Getter
    public static class UserForm {

        private String username;

        private String password;
    }


    /**
     * 登录接口, 颁发 token 授权对象
     *
     * @param userForm 提交玩家数据
     * @return JSON
     */
    @PostMapping("/login")
    public Object login(@Valid UserForm userForm) {
        // todo:数据库认证出 `is_supper=1` 的最高管理员即可, 这里后续访问拦截器可以网上查询下 SpringBoot 访问拦截认证
        logger.debug("Login User:{}", userForm);

        // 注意这里响应的 Token 需要在 `Header` 上面加上 `Authoritarian: Token xxx` 做认证处理
        // 拦截器去判断玩家头信息之后认证即可
        String username = userForm.username;
        return new HashMap<>() {{
            put("status", 200);
            put("message", "Ok");
            put("data", new HashMap<>() {{
                put("username", username);
                put("token", "login_token");
            }});
        }};
    }
}

这里就是相对简单的登录授权方式, 但是请注意后台最好提供 admin 默认账户, 具体流程:

  1. 首次 admin 登录, 目前数据库找不到该用户, 但是 admin 触发最高管理员标识
  2. 默认找不到 admin, 先随机生成 md5 密码写入在项目启动或者指定临时目录文件 .password
  3. 数据库创建 admin 账号, 密码为 .password 的32位字符串内容
  4. 弹出错误说明默认最高管理员账号已经创建, 要求玩家去服务器的 .password 去查看
  5. 用户查看 .password 内容登录系统, 后台直接修改自己密码即可

这里就是默认最高管理员的生成方式, 之后最高管理员是可以生成同级 admin 管理员, 还有创建 business 账号.

注: business 账号不能自己去创建, 一般是商务和 CP 谈妥之后在 admin 后台创建商务账号提供, 并且账号只能被隐藏而不能被删除!

后续就是具体功能归宿给不同角色来处理.

管理员功能

管理员对象基本上走位我们平台方的处理对象, 需要去实现:

  • 创建应用平台方(platform): 给 business 创建可选平台方( IOS/安卓-Google/安卓-HuaWei/安卓-Amazon/Windows/Linux ) 归属
  • 创建第三方渠道( channel ): 给 business 创建目前支持的第三方渠道 ( Facebook/Google/Tick )
  • 创建第三方支持的事件( event ): 给 business 广告计划追加绑定事件上报功能
  • 创建广告平台计划( plan ): 商务和 CP 谈妥广告量级就可以在 admin 后台创建自己广告事件并在第三方创建 ad 对象标识
  • 对接第三方广告SDK: 我们需要去 api 中对接反向推送第三方SDK上报广告使用情况

另外补充下, 默认事件必须有 brower/install/register , 这是广告最基础的概念( recharge 可能没有 ); 后续可以自定义扩展的所有事件, 但是这三个事件基本上是必须要有的.

按照首次上架广告流程来说明流程:

  1. admin 创建应用平台: 比如创建 Android:Google
  2. admin 创建第三方渠道: 比如创建 Facebook, 默认渠道下有 brower/install/register 事件且无法被删除, 但是可以自由扩展其他事件
  3. business 创建上架应用: 比如上架 O神 游戏, 需要选择好 应用平台 , 后续还需要提交接入我们SDK的 apk/exe 等跳转地址完成最后上架
  4. admin 审核 O神 游戏通过: 但目前没有我们自己平台广告计划, 所以需要商务和 business 对象洽谈合作推广费用
  5. admin 我们平台广告计划: 商务谈好广告量和, 假设创建 平台=Facebook,App=O神 的推广计划数据, 需要生成出 FB 所需的落地页地址.
  6. admin 生成第三方 FB 广告计划: 需要我们自己的 Facebook 企业广告账号创建出指定量的 ad 对象.
    1. Facebook 为例, 需要去 adsmanager 系统创建 ad(广告)
    2. 广告素材由我们广告方美术处理, 广告美术具体是外国版权问题, 这里不细表
    3. 填入广告落地页 Destination-Website-Website URL 这里就是我们提供应用生成下载落地页地址
    4. 下载落地页需要看下 FB 文档, 他有具体的占位符填充字段如 &adset_id={{adset.id}}&ad_name={{ad.name}}&ad_id={{ad.id}}
    5. 官方的 填充字段(点击查看)
    6. 之后就是在第三方绑定好我们自己对应事件关联, 需要去 events_manager 系统绑定对应事件
    7. 进到面板之后创建自定义的事件监听, 事件直接 Create a custom conversion 创建 conversion 事件
    8. 将自身的 brower/install/register 对应 FB 内部的 Choose a standard event for optimisation 类型创建自定义事件
    9. 注意这里过程当中 events_manager 面板广告组 Settings-Conversions API-Set up direct integration-Generate access token 可以拿到永久上报 access_token 来拿到上报
    10. 注意这里还需要 pixel_id 字段, 这个字段可能需要查下, 可能名称已经变动过了
  7. admin 上架广告计划之前, api 需要开发处理下 FB 等第三方回调上报推送, 用来返回 流量池 的优选效果, 也就是事件都要响应返回给第三方.
  8. api 需要针对所有提交的事件进行关联, 清洗提取其中可以作为关键唯一标识的字段, 之后关联到 business 对应的所有广告计划.
  9. api 需要对接我们自己的接入广告 SDK 从而上报除 browser 的所有应用事件, 跑通之后后续广告计划都是这样生成并投放.
  10. business/admin 后台提供数据报表统计, admin 可以查看到全面的应用数据, 而 business 只能看到针对自己的数据上报统计.

注: 必须优先申请好企业 business/developer 账号, 接入流程需要用到卡在那里的时候十分麻烦.

注意, 不要用 应用id(app_id)/计划id(plan_id)/事件id(event_id) 在数据库的 id 做应用唯一识别码来做传输, 因为很容易被人看出应用/计划ID推算出数据目前ID, 从而知道目前平台的客户数量甚至开始嗅探内部的id来测试不同id的落地页.

注: 不要盲目去随机 8~12 字符串之后不断去数据库判断存在直至没有对应识别码才允许插入数据库, 而是应该将其移交给现代 md5(UUID) + DB(UniqueKey) 可以避免大量无意义唯一标识问题.

容量空间价值

这里是关于数据库记录数据的问题, 之前可以看到事件推送的时候做了大量冗余数据写入, 比如 /business/page/{code} 访问落地页都会把广告的 JSON 数据保存到数据库;
如果这里如果被人 ddos 的时候塞满数据库的时候怎么办? 可以说无论怎么弄, ddos 大量数据都是没办法避免的, 能做到唯一优化就是做高防和接口限流.

不要先考虑到空间爆满的影响, 而是先得有量跑进来; 数据库那些数据库在跑量带来的收益之后, 是可以直接通过扩容满足的服务需求的; 而且对于广告平台方来说, 很少有 debug 来测试量准不准确, 只能先测试买上一部分量再打通流程, 如果数据不做冗余的情况下如果跑不通需要赶快设断点或者比较下日志或者断点排查.

这过程当中浪费的买量费用相比数据库的那些冗余更加是不可估量的, 所以没必要为了珍惜那点数据库空间而不做数据库存底, 所以基本上存底数据结构基本如下( 以 /business/page/{code} 为例子 ):

  • create_time: 创建时间戳
  • create_ip: 创建ip地址
  • data: JSON格式字符串
  • app_code: 应用标识码

这些浏览量记录进去冗余下, 方便如果出现事件异常可以首先看下浏览量是否有误差, 从而知道事件推送过程和网页访问过程是否有差异.