站内《上线变现与长线运营》 那一卷讲的是广告形态怎么选、频次怎么定——激励视频放在哪个节点、插屏多久弹一次、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,解析矩阵的结果是(同机相对量级,绝对数值不可移植):
| 平台 | 组合数 | 远端命中 | 默认兜底 | 覆盖率‰ |
|---|---|---|---|---|
| 8 | 4 | 4 | 500 | |
| douyin | 8 | 4 | 4 | 500 |
| web | 8 | 4 | 4 | 500 |
三个平台都只有一半组合命中远端规则,另一半落默认兜底——banner_home 只在 Web 有规则,rv_double 只在微信有规则。几个典型解析结果(wechat/rv_double/B 同时命中三条规则,解析器取分最高的那条):
| 平台 | 广告位 | 组 | 命中规则 | slotId | 底价(分) |
|---|---|---|---|---|---|
| rv_double | B | wx-rv-double-B | wx_rv_double_b | 500 | |
| rv_double | A | wx-rv-double-A | wx_rv_double_a | 300 | |
| douyin | itl_level_end | A | dy-itl-v2 | dy_itl_v2 | 400 |
| web | rv_offline | A | all-rv-offline | rv_offline | 200 |
| banner_home | A | —(兜底) | def_banner_home | 0 |
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 分层与流量分组 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。