6.1 广告位 ID 体系与配置下发

广告位 ID 体系与配置下发是广告变现的工程底座:把远端规则表解析成当前端该用哪个广告位,用特异性打分消解冲突、用可播种 LCG 做 A/B 分桶、用兜底规则防配置缺失;配可跑的 Python 解析矩阵与 TypeScript 内核跨语言对拍。

站内《上线变现与长线运营》 那一卷讲的是广告形态怎么选、频次怎么定——激励视频放在哪个节点、插屏多久弹一次、eCPM 与填充率怎么建模。但它只回答了「该有哪几个广告位」。真到要上线,你会撞上另一个更底层的问题:这几个广告位在微信、抖音、Web 三个平台上,各自的广告位 ID 是什么、底价多少、预加载几条、超时多久——这些参数不可能硬编码进包,必须由服务端下发、客户端解析。这一节讲的就是广告位配置下发这件工程活。它与站内小程序变现那篇 分工不同:那篇讲小程序生态里变现通道的选型,本节只做配置系统本身。

广告位不是一个 ID,而是一张可下发的规则表

外行把「广告位」理解成一个 ID:激励视频有一个 ID,插屏有一个 ID。真实系统里它是三层东西,混在一起就会出事故:

层名字谁定义变不变
逻辑位placement(如 rv_double)你的玩法几乎不变,写死在代码里
实体位slotId(如 wx_rv_double_b)平台 / 聚合后台会变,可能按平台、按 A/B 分叉
组合位mediationGroupId聚合后台最常变,调底价 / 换网络都在这一层

代码里只该写逻辑位;slotId 与 mediationGroupId 全部来自远端配置。好处很直接:运营想把激励视频底价从 300 分调到 500 分,改配置下发即可,不用发版。代价是——客户端必须有一套确定性的解析器,否则同一台设备两次启动可能拿到不同广告位,A/B 实验就废了。

解析公式:特异性打分 + 兜底 + 可播种分桶

规则表可能对同一组合命中多条规则(微信的激励视频既有「A/B 精确规则」也有「通配规则」)。必须有一套确定性的冲突消解:

score(rule) = (platform=="*" ? 0 : 4) + (abGroup=="*" ? 0 : 2) + minSdk
resolve(p, pl, g, sdk) = 命中规则里 score 最大者;并列取规则表靠前者
无命中 → 回落 defaults(source = "default")
bucket(seed) = LCG(seed+salt) 第 3 步 mod 1000 < 分桶线 → "A" else "B"

打分只用了「精确压过通配」这一条直觉:精确平台值 4 分、精确 A/B 值 2 分、版本门槛按 minSdk 递增。minSdk 让「同一广告位的新老版本配置」可以共存——新端命中 v2 规则,老端自动落 v1,不用服务端做版本判断。

S 层:把解析矩阵枚举出来

下面这段是 S 层仿真脚本 sim/o06_adslot_matrix.py。它读 config/o06_adslot.json,把 platform × placement × A/B 的 24 个组合全部解析一遍,统计覆盖率、冲突消解结果与分桶确定性,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o06_adslot_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o06_adslot_matrix.py --config config/ --out out/
"""广告位配置下发矩阵仿真:读 config/o06_adslot.json,把「远端规则表」在
platform × placement × A/B 上枚举成完整解析矩阵。

  覆盖率 = 命中远端规则(remote) / 总组合;冲突消解 = 命中里 score 最大者(并列取靠前者);
  分桶确定性 = 同一 deviceSeed 反复解析永远落同一 A/B 组(禁 Math.random)。
口径:slotId/mediationGroupId 是配置侧命名;真实广告位 ID 由各家后台生成,
需真实业务环境,命令行不可复现(X)。产出 JSON + CSV + SVG,全整数。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET

SDK_PROBE = 3  # 探针 SDK 版本:≥ dy-itl-v2 的 minSdk=2,用于演示版本门槛
FIELDS = ("platform", "placement", "abGroup", "sdkVersion", "slotId", "mediationGroupId",
          "floorPriceCent", "preloadCount", "timeoutMs", "ruleId", "source")

def score_rule(r: dict, platform: str, ab: str) -> int:
    return (0 if r["platform"] == "*" else 4) + (0 if r["abGroup"] == "*" else 2) + r["minSdk"]

def resolve(cfg: dict, platform: str, placement: str, ab: str, sdk: int) -> dict:
    best, best_score = None, -1
    for r in cfg["rules"]:
        if r["placement"] != placement or (r["platform"] != "*" and r["platform"] != platform):
            continue
        if (r["abGroup"] != "*" and r["abGroup"] != ab) or r["minSdk"] > sdk:
            continue
        s = score_rule(r, platform, ab)
        if s > best_score:
            best, best_score = r, s
    d = cfg["defaults"]
    src = best or {"slotId": f"def_{placement}", "mediationGroupId": d["mediationGroupId"],
                   "floorPriceCent": d["floorPriceCent"], "preloadCount": d["preloadCount"],
                   "timeoutMs": d["timeoutMs"], "id": ""}
    return {"platform": platform, "placement": placement, "abGroup": ab, "sdkVersion": sdk,
            "slotId": src["slotId"], "mediationGroupId": src["mediationGroupId"],
            "floorPriceCent": src["floorPriceCent"], "preloadCount": src["preloadCount"],
            "timeoutMs": src["timeoutMs"], "ruleId": src["id"], "source": "remote" if best else "default"}

def bucket_of(cfg: dict, seed: int, salt: int) -> str:
    """可播种整数 LCG:state = (1103515245*state + 12345) mod 2^31;第 3 步 mod 1000 定组。"""
    s = (seed + salt) % 2147483648
    for _ in range(3):
        s = (1103515245 * s + 12345) % 2147483648
    a = cfg["abSplit"]["A"] * 1000 // (cfg["abSplit"]["A"] + cfg["abSplit"]["B"])
    return "A" if s % 1000 < a else "B"

def draw_svg(path: str, rows: list, platforms: list, placements: int) -> int:
    W, H, PAD, ROW = 720, 70 + 46 * len(platforms), 200, 46
    total = placements * 2
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
                             "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
                                "fill": "#111827"}).text = "各平台广告位解析:远端命中 vs 默认兜底"
    for i, p in enumerate(platforms):
        remote = sum(1 for r in rows if r["platform"] == p and r["source"] == "remote")
        y, w = 54 + i * ROW, (W - PAD - 40) * remote // max(total, 1)
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = p
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
                                    "rx": "4", "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "12",
                                    "fill": "#6b7280"}).text = f"远端 {remote} / {total} · 兜底 {total - remote}"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))

def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "o06_adslot.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows = [resolve(cfg, p, pl["id"], g, SDK_PROBE)
            for p in cfg["platforms"] for pl in cfg["placements"] for g in ("A", "B")]
    remote = sum(1 for r in rows if r["source"] == "remote")
    distinct = sorted({r["slotId"] for r in rows})
    floor_sum = sum(r["floorPriceCent"] for r in rows)
    digest = f"remote={remote},default={len(rows) - remote},distinct={len(distinct)},floorSum={floor_sum}"
    buckets = {seed: bucket_of(cfg, seed, 7) for seed in range(1, 9)}
    buckets2 = {seed: bucket_of(cfg, seed, 7) for seed in range(1, 9)}

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o06_adslot_matrix.svg"), rows, cfg["platforms"], len(cfg["placements"]))
    with open(os.path.join(a.out, "o06_adslot_matrix.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(FIELDS)
        for r in rows:
            w.writerow([r[k] for k in FIELDS])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "sdkProbe": SDK_PROBE, "combos": len(rows), "remote": remote, "default": len(rows) - remote,
           "coveragePermille": remote * 1000 // len(rows), "distinctSlotIds": distinct,
           "floorSumCent": floor_sum, "digest": digest, "bucketBySeed": buckets,
           "bucketDeterministic": buckets == buckets2, "matrix": rows,
           "svg": os.path.join(a.out, "o06_adslot_matrix.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o06_adslot_matrix.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "combos": len(rows), "coveragePermille": rep["coveragePermille"],
                      "distinctSlotIds": distinct, "bucketBySeed": buckets,
                      "bucketDeterministic": rep["bucketDeterministic"]}, ensure_ascii=False, indent=2))
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

规则表放在 config/o06_adslot.json。它是纯数据,没有任何浮点:底价用「分」、比例用千分比、版本门槛用整数。真实平台的广告位 ID 由各家后台生成,属于 X 层——需真实业务环境,命令行不可复现。

{
  "version": 3,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "广告位配置下发表。口径是「公开可观察机制」的建模假设,不是任何平台后台或聚合后台的实测快照。slotId/mediationGroupId 是配置侧命名,真实平台的广告位 ID 由各家后台生成、不可复现(X)。floorPriceCent=底价(分);preloadCount=预加载条数;timeoutMs=拉取超时毫秒;networks=该广告位挂接的广告网络顺序。全整数,无浮点。",
  "defaults": { "mediationGroupId": "mg_default", "floorPriceCent": 0, "preloadCount": 2, "timeoutMs": 4000 },
  "abSplit": { "A": 500, "B": 500 },
  "platforms": ["wechat", "douyin", "web"],
  "placements": [
    { "id": "rv_double", "name": "激励视频·翻倍", "format": "rewarded" },
    { "id": "rv_offline", "name": "激励视频·离线收益", "format": "rewarded" },
    { "id": "itl_level_end", "name": "插屏·过关", "format": "interstitial" },
    { "id": "banner_home", "name": "Banner·主页", "format": "banner" }
  ],
  "rules": [
    { "id": "wx-rv-double-A", "platform": "wechat", "placement": "rv_double", "abGroup": "A", "minSdk": 0, "slotId": "wx_rv_double_a", "mediationGroupId": "mg_rv_double_a", "floorPriceCent": 300, "preloadCount": 3, "timeoutMs": 4000, "networks": ["csj", "ylh"] },
    { "id": "wx-rv-double-B", "platform": "wechat", "placement": "rv_double", "abGroup": "B", "minSdk": 0, "slotId": "wx_rv_double_b", "mediationGroupId": "mg_rv_double_b", "floorPriceCent": 500, "preloadCount": 3, "timeoutMs": 4000, "networks": ["csj", "ylh"] },
    { "id": "wx-rv-double-any", "platform": "wechat", "placement": "rv_double", "abGroup": "*", "minSdk": 0, "slotId": "wx_rv_double", "mediationGroupId": "mg_rv_double", "floorPriceCent": 300, "preloadCount": 2, "timeoutMs": 4000, "networks": ["csj"] },
    { "id": "all-rv-offline", "platform": "*", "placement": "rv_offline", "abGroup": "*", "minSdk": 0, "slotId": "rv_offline", "mediationGroupId": "mg_rv_offline", "floorPriceCent": 200, "preloadCount": 2, "timeoutMs": 4000, "networks": ["csj", "ylh", "admob"] },
    { "id": "dy-itl-v2", "platform": "douyin", "placement": "itl_level_end", "abGroup": "*", "minSdk": 2, "slotId": "dy_itl_v2", "mediationGroupId": "mg_itl_v2", "floorPriceCent": 400, "preloadCount": 1, "timeoutMs": 3500, "networks": ["csj", "ylh"] },
    { "id": "dy-itl-v1", "platform": "douyin", "placement": "itl_level_end", "abGroup": "*", "minSdk": 0, "slotId": "dy_itl_v1", "mediationGroupId": "mg_itl_v1", "floorPriceCent": 200, "preloadCount": 1, "timeoutMs": 3500, "networks": ["csj"] },
    { "id": "web-banner", "platform": "web", "placement": "banner_home", "abGroup": "*", "minSdk": 0, "slotId": "web_banner_home", "mediationGroupId": "mg_web_banner", "floorPriceCent": 0, "preloadCount": 1, "timeoutMs": 5000, "networks": ["adsense", "house"] }
  ]
}

在本机跑 for f in sim/o06_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,解析矩阵的结果是(同机相对量级,绝对数值不可移植):

平台组合数远端命中默认兜底覆盖率‰
wechat844500
douyin844500
web844500

三个平台都只有一半组合命中远端规则,另一半落默认兜底——banner_home 只在 Web 有规则,rv_double 只在微信有规则。几个典型解析结果(wechat/rv_double/B 同时命中三条规则,解析器取分最高的那条):

平台广告位组命中规则slotId底价(分)
wechatrv_doubleBwx-rv-double-Bwx_rv_double_b500
wechatrv_doubleAwx-rv-double-Awx_rv_double_a300
douyinitl_level_endAdy-itl-v2dy_itl_v2400
webrv_offlineAall-rv-offlinerv_offline200
wechatbanner_homeA—(兜底)def_banner_home0

P 层内核:跨语言对拍,证明解析器没写错

一张表看不出「是解析对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o06/adslot.ts 复算同一套解析逻辑:同样的特异性打分、同样的兜底、同样的 LCG 分桶。它读同一份 config/o06_adslot.json,产出与 Python 逐字符一致的摘要。

// file: core/src/o06/adslot.ts
// tier: P
// verify: node --test 'core/test/o06_*.test.ts'
//
// 广告位配置下发内核:把「远端下发的规则表」解析成「当前端该用哪个广告位」。
// 与 sim/o06_adslot_matrix.py 逐位等价的 TypeScript 对偶实现;读同一份 config/o06_adslot.json。
// 全整数、无浮点、无 Math.random——A/B 分桶用可播种整数 LCG(同一 deviceSeed 永远落同一组)。

export interface AdslotRule {
  readonly id: string; readonly platform: string; readonly placement: string; readonly abGroup: string;
  readonly minSdk: number; readonly slotId: string; readonly mediationGroupId: string;
  readonly floorPriceCent: number; readonly preloadCount: number; readonly timeoutMs: number; readonly networks: readonly string[];
}
export interface AdslotConfig {
  readonly version: number; readonly game: string;
  readonly defaults: { readonly mediationGroupId: string; readonly floorPriceCent: number; readonly preloadCount: number; readonly timeoutMs: number };
  readonly abSplit: { readonly A: number; readonly B: number };
  readonly platforms: readonly string[];
  readonly placements: readonly { readonly id: string; readonly name: string; readonly format: string }[];
  readonly rules: readonly AdslotRule[];
}
export interface ResolvedSlot {
  readonly platform: string; readonly placement: string; readonly abGroup: string; readonly sdkVersion: number;
  readonly slotId: string; readonly mediationGroupId: string; readonly floorPriceCent: number;
  readonly preloadCount: number; readonly timeoutMs: number; readonly ruleId: string; readonly source: string;
}

/** 规则特异性打分:平台精确 +4、A/B 精确 +2,minSdk 越大越优先。 */
export function scoreRule(rule: AdslotRule, platform: string, abGroup: string): number {
  return (rule.platform === "*" ? 0 : 4) + (rule.abGroup === "*" ? 0 : 2) + rule.minSdk;
}

/** 解析当前端该用的广告位;无命中规则时回落 defaults(source = "default")。 */
export function resolveSlot(
  cfg: AdslotConfig, platform: string, placement: string, abGroup: string, sdkVersion: number,
): ResolvedSlot {
  let best: AdslotRule | null = null;
  let bestScore = -1;
  for (const r of cfg.rules) {
    if (r.placement !== placement) continue;
    if (r.platform !== "*" && r.platform !== platform) continue;
    if (r.abGroup !== "*" && r.abGroup !== abGroup) continue;
    if (r.minSdk > sdkVersion) continue;
    const s = scoreRule(r, platform, abGroup);
    if (s > bestScore) { best = r; bestScore = s; }
  }
  const base = { platform, placement, abGroup, sdkVersion };
  if (best === null) {
    const d = cfg.defaults;
    return { ...base, slotId: `def_${placement}`, mediationGroupId: d.mediationGroupId,
      floorPriceCent: d.floorPriceCent, preloadCount: d.preloadCount, timeoutMs: d.timeoutMs,
      ruleId: "", source: "default" };
  }
  return { ...base, slotId: best.slotId, mediationGroupId: best.mediationGroupId,
    floorPriceCent: best.floorPriceCent, preloadCount: best.preloadCount, timeoutMs: best.timeoutMs,
    ruleId: best.id, source: "remote" };
}

export const LCG_MOD = 2147483648n;

/** 可播种整数 LCG:state = (1103515245 * state + 12345) mod 2^31。用 BigInt 防溢出。 */
export function lcgNext(state: bigint): bigint {
  return (1103515245n * state + 12345n) % LCG_MOD;
}

/** 按 abSplit 把 deviceSeed 分到 A / B:桶位 = LCG 第 3 步 mod 1000。 */
export function bucketOf(cfg: AdslotConfig, deviceSeed: number, salt: number): string {
  let s = (BigInt(deviceSeed) + BigInt(salt)) % LCG_MOD;
  s = lcgNext(s); s = lcgNext(s); s = lcgNext(s);
  const slot = Number(s % 1000n);
  const a = Math.floor((cfg.abSplit.A * 1000) / (cfg.abSplit.A + cfg.abSplit.B));
  return slot < a ? "A" : "B";
}

/** 跨语言对拍摘要:与 sim/o06_adslot_matrix.py 的 digest 逐字符一致。 */
export function digest(rows: readonly ResolvedSlot[]): string {
  let remote = 0; let floorSum = 0;
  const distinct = new Set<string>();
  for (const r of rows) {
    if (r.source === "remote") remote += 1;
    floorSum += r.floorPriceCent; distinct.add(r.slotId);
  }
  return `remote=${remote},default=${rows.length - remote},distinct=${distinct.size},floorSum=${floorSum}`;
}

/** 枚举 platform × placement × abGroup 的完整解析矩阵(sdkVersion 固定)。 */
export function enumerateMatrix(cfg: AdslotConfig, sdkVersion: number): ResolvedSlot[] {
  const rows: ResolvedSlot[] = [];
  for (const p of cfg.platforms) {
    for (const pl of cfg.placements) {
      for (const g of ["A", "B"]) rows.push(resolveSlot(cfg, p, pl.id, g, sdkVersion));
    }
  }
  return rows;
}

配套测试 core/test/o06_adslot.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验版本门槛、兜底与分桶。在本机跑 node --test 'core/test/o06_*.test.ts',12 个用例全过;两套独立实现给出同一个摘要:

digest = remote=12,default=12,distinct=8,floorSum=2800

只要有一边的打分权重、并列取法、兜底或 LCG 溢出处理写错,摘要立刻分叉。这与卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。

X 层:真实广告位 ID 与聚合后台不可复现

必须把边界划清。上面算的是配置侧:给定规则表,解析结果是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的广告位 ID 与聚合组 ID(由微信 / 抖音 / 穿山甲 / 优量汇 / AdMob 各自后台生成,命名不可移植)、真实的下发通道行为(CDN 缓存、灰度批次、配置回滚、端上缓存过期);
  • 真实的广告网络在某个底价下的填充率(第 6.2 节会建模,但真实值只有投放后才有)。

工程上正确的做法是:把可复现的解析器与不可复现的配置内容分开——解析器进单测(本节),配置内容进远端(运营改)。这也是为什么规则表要带版本号:下发出错时,客户端能回滚到上一个已知good版本。

自检表

检查项合格标准
三层分清代码只写逻辑位,slotId / 组合位全部来自远端
冲突确定同一组合多次解析结果逐位相同,无随机
兜底可用配置缺失时回落到 defaults,不崩、不静默无广告
分桶可复现A/B 分桶由 seed 决定,禁用 Math.random
版本可回滚规则表带版本号,出错能回退
跨语言一致Python 与 TS 两套实现的摘要逐字符相等
边界清晰真实广告位 ID 与下发通道标 X,不写成「实测」

广告位配置下发最危险的错觉,是把「解析器写对了」当成「线上一定正确」。解析器只保证给定配置,结果确定;配置本身对不对,要靠灰度与回滚兜住。下一节我们处理配置里最值钱的那个参数——底价:怎么把流量按价值分层、怎么给每层配一个合适的底价,让同一批请求赚到更多。

阅读导航:上一节:5.3 瀑布流与竞价配置 · 下一节:6.2 eCPM 分层与流量分组 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制