站内《上线变现与长线运营》 第 2 章把内购点怎么定价、混合变现怎么配比讲完了——那是「卖什么、卖多少钱」的商业决策。站内小程序变现那篇 则从生态视角讲了小程序里的变现通道选型。本节与两者分工不同:它不重做定价,也不重选通道,只做一件工程活——同一件商品在微信、抖音、Web 上,各自到底该走哪条支付通道、下单要带什么参数。这是内购能不能收到钱的第一道关。
前六章我们一直在广告这一侧打转:账户结构、出价放量、素材、归因、聚合、广告位。到第 7 章换一条完全不同的变现通道——内购。广告是「平台替你找广告主、按曝光结算」,内购是「玩家直接付钱、钱先进平台、平台再分给你」。后者每一步都更依赖平台规则:能不能收、怎么收、收完怎么知道,三件事分别属于 7.1、7.2、7.3。
一条商品,三个平台,三套支付通道
外行以为内购就是「调一个支付 API」。真实系统里,同一个商品 ID 在不同平台上走的是完全不同的通道,参数也各不相同:
| 平台 | 内购通道(公开可观察机制) | 下单需服务端 token | 需资质 | 观察时点 |
|---|---|---|---|---|
| 微信小游戏 | 虚拟支付(代币 / 道具直购,需开通) | 是 | 是 | 2026-10 |
| 抖音小游戏 | 现金支付(需资质) | 是 | 是 | 2026-10 |
| Web / H5 | 无平台内购(纯 IAA 或第三方 H5 支付) | 否 | 否 | 2026-10 |
这张表是公开可观察机制的口径,不是任何平台后台的实测快照——真实的商户号、支付通道 ID、虚拟支付开通结果,都要在你自己的开发者后台里才看得到(X 层,见后文)。它要说明的是一件事:「商品」是逻辑概念,「支付通道」是平台概念,两者之间需要一张可下发的路由表。
这和 6.1 讲的广告位配置是同一个思路:代码里只写逻辑 ID,平台相关的实体参数全部来自远端。区别在于,广告位的实体是「广告位 ID」,内购的实体是「支付通道 + 下单参数」。
解析公式:特异性打分 + 版本门槛 + 兜底
路由表可能对同一组合命中多条规则(微信的月卡既有「商品精确规则」也有「通配规则」)。必须有一套确定性的冲突消解:
score(route) = (platform=="*" ? 0 : 4) + (product=="*" ? 0 : 2) + minAppVersion
resolve(p, pr, ver) = 命中路由里 score 最大者;并列取路由表靠前者
无命中 → 回落 defaults(source = "default")
打分沿用「精确压过通配」:平台精确 4 分、商品精确 2 分、版本门槛按 minAppVersion 递增。minAppVersion 让同一商品的支付通道可以随端版本演进——新版端走 v2 通道,老版端自动落回 v1,服务端不用做版本判断。兜底则保证「配置缺失时不崩、不静默不收款」,而是明确回落到「该平台无内购通道」。
S 层:把解析矩阵枚举出来
下面这段是 S 层仿真脚本 sim/o07_pay_matrix.py。它读 config/o07_pay_channel.json,把 platform × product 的 12 个组合全部解析一遍,统计远端命中率、用到的通道种类与「需要服务端 token」的组合数,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o07_pay_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o07_pay_matrix.py --config config/ --out out/
"""平台内购通道解析矩阵仿真:读 config/o07_pay_channel.json,把「远端下发的支付路由表」
在 platform × product 上枚举成完整解析矩阵。
score(route) = (platform=="*" ? 0 : 4) + (product=="*" ? 0 : 2) + minAppVersion
resolve(p, pr, v) = 命中路由里 score 最大者;并列取路由表靠前者
无命中 → 回落 defaults(source = "default")
打分沿用「精确压过通配」:平台精确 4 分、商品精确 2 分、版本门槛按 minAppVersion 递增,
让同一商品的新老版本支付通道可以共存(新端命中 v2,老端自动落 v1)。
口径:平台内购通道与虚拟支付是「公开可观察机制」的建模假设,不是任何平台后台的实测;
真实商户号 / 支付通道 ID / 虚拟支付开通结果需真实业务环境,命令行不可复现(X)。
产出 out/o07_pay_matrix.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
APP_PROBE = 3 # 探针端版本:>= dy-big-cash-v2 的 minAppVersion=2,用于演示版本门槛
FIELDS = ("platform", "product", "appVersion", "channel", "currency", "needServerToken",
"requireQualification", "callbackMode", "minAge", "amountFen", "routeId", "source")
def score_route(r: dict, platform: str, product: str) -> int:
return (0 if r["platform"] == "*" else 4) + (0 if r["product"] == "*" else 2) + r["minAppVersion"]
def resolve(cfg: dict, platform: str, product: str, app_version: int, amount_fen: int) -> dict:
best, best_score = None, -1
for r in cfg["routes"]:
if r["platform"] != "*" and r["platform"] != platform:
continue
if r["product"] != "*" and r["product"] != product:
continue
if r["minAppVersion"] > app_version:
continue
s = score_route(r, platform, product)
if s > best_score:
best, best_score = r, s
d = cfg["defaults"]
src = best or {"channel": d["channel"], "currency": d["currency"], "needServerToken": d["needServerToken"],
"requireQualification": d["requireQualification"], "callbackMode": d["callbackMode"],
"minAge": d["minAge"], "id": ""}
return {"platform": platform, "product": product, "appVersion": app_version,
"channel": src["channel"], "currency": src["currency"], "needServerToken": src["needServerToken"],
"requireQualification": src["requireQualification"], "callbackMode": src["callbackMode"],
"minAge": src["minAge"], "amountFen": amount_fen, "routeId": src["id"],
"source": "remote" if best else "default"}
def draw_svg(path: str, rows: list, platforms: list, products: int) -> int:
"""每个平台一根横条:远端命中 vs 默认兜底。"""
W, H, PAD, ROW = 720, 70 + 46 * len(platforms), 200, 46
total = products
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, "o07_pay_channel.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = [resolve(cfg, p, pr["id"], APP_PROBE, pr["amountFen"])
for p in cfg["platforms"] for pr in cfg["products"]]
remote = sum(1 for r in rows if r["source"] == "remote")
distinct = sorted({r["channel"] for r in rows})
token_sum = sum(r["needServerToken"] for r in rows)
digest = (f"remote={remote},default={len(rows) - remote},distinct={len(distinct)},tokenSum={token_sum}")
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o07_pay_matrix.svg"), rows, cfg["platforms"], len(cfg["products"]))
with open(os.path.join(a.out, "o07_pay_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"],
"appProbe": APP_PROBE, "combos": len(rows), "remote": remote, "default": len(rows) - remote,
"coveragePermille": remote * 1000 // len(rows), "distinctChannels": distinct,
"tokenSum": token_sum, "digest": digest, "matrix": rows,
"svg": os.path.join(a.out, "o07_pay_matrix.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o07_pay_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"],
"distinctChannels": distinct, "tokenSum": token_sum,
"matrix": [(r["platform"], r["product"], r["channel"], r["routeId"], r["source"]) for r in rows]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
路由表放在 config/o07_pay_channel.json。它是纯数据,没有任何浮点:金额用「分」、比例用千分比、版本门槛用整数。真实平台的支付通道 ID 由各家后台生成,属于 X 层——需真实业务环境,命令行不可复现。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "平台内购通道解析表。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测快照。channel=支付通道类型(virtual_pay=微信小游戏虚拟支付、virtual_pay_sub=虚拟支付订阅/月卡、cash_pay=抖音现金支付、cash_pay_v2=新版支付 SDK、none=无平台内购);needServerToken=下单前是否需服务端换 token(1/0);requireQualification=是否需资质(1/0);minAge=最低可支付年龄;minAppVersion=生效的最低端版本。真实商户号 / 支付通道 ID / 虚拟支付开通结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"defaults": { "channel": "none", "currency": "CNY", "needServerToken": 0, "requireQualification": 0, "callbackMode": "none", "minAge": 0 },
"platforms": ["wechat", "douyin", "web"],
"products": [
{ "id": "p_starter", "name": "新手礼包", "amountFen": 600 },
{ "id": "p_gems_mid", "name": "宝石中包", "amountFen": 3000 },
{ "id": "p_gems_big", "name": "宝石大包", "amountFen": 9800 },
{ "id": "p_monthcard", "name": "月卡", "amountFen": 2500 }
],
"routes": [
{ "id": "wx-all-virtual", "platform": "wechat", "product": "*", "minAppVersion": 0, "channel": "virtual_pay", "currency": "CNY", "needServerToken": 1, "requireQualification": 1, "callbackMode": "server", "minAge": 8 },
{ "id": "wx-monthcard-sub", "platform": "wechat", "product": "p_monthcard", "minAppVersion": 0, "channel": "virtual_pay_sub", "currency": "CNY", "needServerToken": 1, "requireQualification": 1, "callbackMode": "server", "minAge": 8 },
{ "id": "dy-all-cash", "platform": "douyin", "product": "*", "minAppVersion": 0, "channel": "cash_pay", "currency": "CNY", "needServerToken": 1, "requireQualification": 1, "callbackMode": "server", "minAge": 16 },
{ "id": "dy-big-cash-v2", "platform": "douyin", "product": "p_gems_big", "minAppVersion": 2, "channel": "cash_pay_v2", "currency": "CNY", "needServerToken": 1, "requireQualification": 1, "callbackMode": "server", "minAge": 16 }
]
}
在本机跑 for f in sim/o07_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,解析矩阵的结果是(同机相对量级,绝对数值不可移植):12 个组合里 8 个命中远端路由、4 个落默认兜底(remote=8,default=4),用到的通道共 5 种(virtual_pay / virtual_pay_sub / cash_pay / cash_pay_v2 / none),需要服务端 token 的组合共 8 个(tokenSum=8,即微信 4 + 抖音 4,Web 全为 0)。
几个典型解析结果:
| 平台 | 商品 | 命中路由 | 通道 | 端版本门槛 | 服务端 token |
|---|---|---|---|---|---|
| p_monthcard | wx-monthcard-sub | virtual_pay_sub | 0 | 需 | |
| p_gems_mid | wx-all-virtual | virtual_pay | 0 | 需 | |
| douyin | p_gems_big(端 v3) | dy-big-cash-v2 | cash_pay_v2 | 2 | 需 |
| douyin | p_gems_big(端 v1) | dy-all-cash | cash_pay | 0 | 需 |
| web | 任意商品 | —(兜底) | none | — | 否 |
抖音大包这一行是最能说明「版本门槛」价值的:同一商品,端版本 ≥ 2 命中新版支付 SDK,老版本自动落回旧通道,运营不必关心端上跑的是哪个版本。Web 那一行则说明兜底的语义:不是「出错」,而是明确「该平台没有平台内购通道」。
P 层内核:跨语言对拍,证明解析器没写错
一张表看不出「是解析对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o07/pay.ts 复算同一套解析逻辑:同样的特异性打分、同样的版本门槛、同样的兜底。它读同一份 config/o07_pay_channel.json,产出与 Python 逐字符一致的摘要。
// file: core/src/o07/pay.ts
// tier: P
// verify: node --test 'core/test/o07_*.test.ts'
//
// 平台内购通道解析内核:把「远端下发的支付路由表」解析成「当前端该走哪条支付通道」。
// 与 sim/o07_pay_matrix.py 逐位等价的 TypeScript 对偶实现;读同一份 config/o07_pay_channel.json。
// 全整数、无浮点、无 Math.random。
export interface PayRoute {
readonly id: string; readonly platform: string; readonly product: string; readonly minAppVersion: number;
readonly channel: string; readonly currency: string; readonly needServerToken: number;
readonly requireQualification: number; readonly callbackMode: string; readonly minAge: number;
}
export interface PayDefaults {
readonly channel: string; readonly currency: string; readonly needServerToken: number;
readonly requireQualification: number; readonly callbackMode: string; readonly minAge: number;
}
export interface PayProduct { readonly id: string; readonly name: string; readonly amountFen: number; }
export interface PayConfig {
readonly version: number; readonly game: string; readonly defaults: PayDefaults;
readonly platforms: readonly string[]; readonly products: readonly PayProduct[]; readonly routes: readonly PayRoute[];
}
export interface ResolvedPay {
readonly platform: string; readonly product: string; readonly appVersion: number;
readonly channel: string; readonly currency: string; readonly needServerToken: number;
readonly requireQualification: number; readonly callbackMode: string; readonly minAge: number;
readonly amountFen: number; readonly routeId: string; readonly source: string;
}
/** 路由特异性打分:平台精确 +4、商品精确 +2,minAppVersion 越大越优先。 */
export function scoreRoute(route: PayRoute, platform: string, product: string): number {
return (route.platform === "*" ? 0 : 4) + (route.product === "*" ? 0 : 2) + route.minAppVersion;
}
/** 解析当前端该走的支付通道;无命中路由时回落 defaults(source = "default")。 */
export function resolveRoute(
cfg: PayConfig, platform: string, product: string, appVersion: number, amountFen: number,
): ResolvedPay {
let best: PayRoute | null = null;
let bestScore = -1;
for (const r of cfg.routes) {
if (r.platform !== "*" && r.platform !== platform) continue;
if (r.product !== "*" && r.product !== product) continue;
if (r.minAppVersion > appVersion) continue;
const s = scoreRoute(r, platform, product);
if (s > bestScore) { best = r; bestScore = s; }
}
const base = { platform, product, appVersion, amountFen };
if (best === null) {
const d = cfg.defaults;
return { ...base, channel: d.channel, currency: d.currency, needServerToken: d.needServerToken,
requireQualification: d.requireQualification, callbackMode: d.callbackMode, minAge: d.minAge,
routeId: "", source: "default" };
}
return { ...base, channel: best.channel, currency: best.currency, needServerToken: best.needServerToken,
requireQualification: best.requireQualification, callbackMode: best.callbackMode, minAge: best.minAge,
routeId: best.id, source: "remote" };
}
/** 枚举 platform × product 的完整解析矩阵(appVersion 固定)。 */
export function enumerateMatrix(cfg: PayConfig, appVersion: number): ResolvedPay[] {
const rows: ResolvedPay[] = [];
for (const p of cfg.platforms) {
for (const pr of cfg.products) rows.push(resolveRoute(cfg, p, pr.id, appVersion, pr.amountFen));
}
return rows;
}
/** 跨语言对拍摘要:与 sim/o07_pay_matrix.py 的 digest 逐字符一致。 */
export function digest(rows: readonly ResolvedPay[]): string {
let remote = 0; let tokenSum = 0;
const distinct = new Set<string>();
for (const r of rows) {
if (r.source === "remote") remote += 1;
tokenSum += r.needServerToken; distinct.add(r.channel);
}
return `remote=${remote},default=${rows.length - remote},distinct=${distinct.size},tokenSum=${tokenSum}`;
}
配套测试 core/test/o07_pay.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验版本门槛、兜底与打分。在本机跑 node --test 'core/test/o07_*.test.ts',本节的 6 个用例全过;两套独立实现给出同一个摘要:
digest = remote=8,default=4,distinct=5,tokenSum=8
只要有一边的打分权重、版本门槛、并列取法或兜底写错,摘要立刻分叉。这与卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实商户号与支付通道不可复现
必须把边界划清。上面算的是配置侧:给定路由表,解析结果是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的商户号、支付通道 ID、虚拟支付开通结果(由微信 / 抖音各自后台审核发放,命名不可移植);
- 真实的下单 token 换取流程与风控拦截(服务端换 token 可能被平台风控拒绝,拒绝原因不透明);
- 真实的平台规则差异(同一商品在不同平台的合规要求、未成年限额、退款政策由平台定义,且会变)。
工程上正确的做法是:把可复现的解析器与不可复现的配置内容分开——解析器进单测(本节),配置内容进远端(运营改)。规则表带版本号,下发出错时客户端能回滚到上一个已知 good 版本。
自检表
| 检查项 | 合格标准 |
|---|---|
| 通道分清 | 代码只写逻辑商品,通道与下单参数全部来自远端 |
| 冲突确定 | 同一组合多次解析结果逐位相同,无随机 |
| 版本可分流 | 新老端命中不同通道,服务端不做版本判断 |
| 兜底可用 | 无内购通道的平台明确回落 none,不崩、不静默 |
| 跨语言一致 | Python 与 TS 两套实现的摘要逐字符相等 |
| 边界清晰 | 真实商户号与通道 ID 标 X,不写成「实测」 |
内购通道解析最危险的错觉,是把「解析器写对了」当成「一定收得到钱」。解析器只保证给定配置,通道确定;通道本身能不能用,要靠开通、资质与真机联调去证。下一节我们处理通道打通之后的下一环——玩家付了钱,我们怎么知道、怎么保证一定发货:订单、回调与补单。
阅读导航:上一节:6.3 填充率与展示率诊断 · 下一节:7.2 订单、回调与补单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。