7.1 平台内购通道与虚拟支付

平台内购通道与虚拟支付讲的是同一件商品在微信、抖音、Web 上各自该走哪条支付通道:把远端下发的支付路由表按「平台精确压过通配、版本门槛递增」解析成当前端该用的通道参数,并给出可跑的 Python 解析矩阵与 TypeScript 内核跨语言逐位对拍。

站内《上线变现与长线运营》 第 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
wechatp_monthcardwx-monthcard-subvirtual_pay_sub0需
wechatp_gems_midwx-all-virtualvirtual_pay0需
douyinp_gems_big(端 v3)dy-big-cash-v2cash_pay_v22需
douyinp_gems_big(端 v1)dy-all-cashcash_pay0需
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 订单、回调与补单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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