《移动游戏产品的架构·商业化与发行工程》4.1 商品目录、定价档位与区域价格

商品目录是商业化的事实源,而不是一张运营 Excel。本节以《潮汐消消》¥6/¥18/¥30/¥68/¥128 五档与《潮汐方舟》的五档为样本,拆开 SKU、档位与区域价格三层,给出币种、档位一致性与单位价格单调性的校验规则,并说明目录版本化对客户端缓存、商店后台与回滚的代价。

一张 Excel 引发的三份不一致

运营在群里发来一张「新春礼包」表:售价 ¥30,内含 350 钻石加一个头像框。客户端把商品 id 写成 3001,服务端发货配置里写的是 3002,商店后台创建的内购项目又是另一个字符串。玩家付款成功,页面显示「已到账」,背包里却没有东西。

排查这类问题时,团队常先怀疑支付回调。但这次的根因在更前面:没有人能说清「这个商品」到底由哪份数据定义。客户端、服务端、商店后台、财务各有一份表,彼此靠人对齐。支付通道只是把已经存在的错误放大了一次。

商品目录要解决的就是这个问题。它不是运营表格的电子化,而是一份被四个系统共同消费的、可校验、可版本化的只读契约。本节先划清目录的边界,再给出一个能落地的校验器;支付怎么走、费率怎么算,留到 4.2。

商品目录是四方的同一份契约

一个商品从配置到玩家手里,至少经过四个消费方。它们对同一份数据的需求并不相同:

消费方需要什么目录缺失时的典型故障
客户端展示名称、图标、价格、限购标记显示旧价格,或点了没反应
服务端用 SKU 找到发货内容与校验金额发货内容对不上,或金额校验失败
商店后台与 SKU 一一对应的内购项目客户端能点,商店查无此商品
数据与财务稳定的 SKU、档位、币种、版本流水按名称归类,改名后历史断档

四个消费方里,只有商店后台是外部系统,它的商品创建、审核和生效都有自己的节奏。这意味着目录不能等到发版时才生成,也不能由客户端在运行时拼接。它必须先在内部定稿,再同步到商店,最后随客户端发布。

目录的另一个约束是「只读」。运行中的服务可以缓存目录,但不能在内存里改价格。任何变更都产生一个新版本,旧版本继续可查。原因很直接:一笔退款可能发生在下单几个月之后,财务需要知道当时的价格,而不是今天的价格。

先把三件事分开:SKU、档位、区域价格

很多目录事故来自把三个概念压成一个字段。它们回答的问题、变更频率和责任人都不一样:

概念回答什么变更频率示例
SKU这是哪件商品低,新增后不轻易改tide.gems.200
档位这件商品卖多少钱中,随定价策略调整1800 分,即 ¥18
区域价格在某个区域用什么币种、收多少中,随汇率与税务调整中国区 CNY 1800;海外 USD 299

SKU 是身份。它一旦进入订单、回执和账本,就不能复用,也不能因为改名而改变。档位是商品设计:同样 200 钻石,可以放在 ¥18,也可以放在 ¥30,这是运营决策。区域价格是商务与合规决策:中国区必须落在商店的固定价格点上,海外区要兼顾汇率、税率和当地购买力。

还有一个维度是商品类型,它决定「买到之后能不能恢复」:

  • consumable:消耗品,例如钻石、体力。每次购买都发一次货,不能恢复。
  • non-consumable:一次性解锁,例如去广告、章节包。需要恢复购买能力。
  • subscription:订阅,例如月卡。需要续订状态、到期时间和取消入口。

商品类型不属于价格,但必须和 SKU 放在同一份目录里。否则客户端只能靠命名猜测,而命名是最不可靠的约定。

档位不是价格表,是付费节奏

《潮汐消消》的档位是 ¥6、¥18、¥30、¥68、¥128,主力档位 ¥18 占订单 46%。它一个月的订单量是 42 万笔,平均客单价 ¥14.5,内购月流水约 609 万元。把订单量和客单价相乘,42 万 × ¥14.5 ≈ 609 万,与事实表的内购流水一致。

这组数字说明了一件事:主力档位不等于平均客单价。¥18 是订单最多的一档,但大量 ¥6 订单把平均值拉低到 ¥14.5。档位设计的第一目标不是「让平均值好看」,而是让不同付费意愿的玩家都能找到一步之遥的选择。

《潮汐方舟》的档位是 ¥30、¥68、¥128、¥328、¥648,主力档位 ¥128 占订单 38%。它一个月 15 万笔订单,平均客单价 ¥182,内购月流水约 2,730 万元。

对照来看,方舟的平均客单价高于主力档位,因为 ¥328 与 ¥648 的长尾把均值抬了上去;消消则相反,¥6 档承担了大量小额订单。这是休闲与中重度在付费节奏上的差别,不是定价对错。

档位阶梯还有一条硬规则:大额包的单位价格不能高于小额包。如果 200 钻石卖 ¥18、350 钻石卖 ¥30,前者每颗 9 分,后者每颗约 8.57 分,阶梯是顺的;如果 350 钻石卖到 ¥36,每颗超过 10 分,玩家没有任何理由买大包,运营也会失去客单价提升的杠杆。

但折扣也不能无限加深。单位价格下降过陡,会鼓励原本只买小额包的玩家一次性囤货,短期流水上升、长期复购下降;同时给工作室留下套利空间。档位表因此是一个有边界的阶梯,而不是「越大越便宜」的单调竞赛。

区域价格不是汇率乘一下

最常见的错误是拿人民币价格除以汇率,得到海外售价。¥18 按某个汇率换算可能得到 2.5 美元,但商店只接受固定价格点,2.5 美元并不存在;即使存在,也忽略了税率、支付通道成本和当地购买力。

区域价格至少要处理四件事:

  1. 价格点。App Store 与 Google Play 各自维护一套价格网格,开发者只能从中选择。客户端展示的价格必须来自商店返回的本地化价格,不能用人民币价格实时换算。
  2. 币种。中国区用 CNY,海外区用 USD 或其他本地币种。币种写错,商店会拒绝创建商品,或者按错误币种展示。
  3. 含税口径。商店代扣代缴的地区,标价即玩家支付价;需要商户自行处理的地区,目录里要明确是含税价还是未税价,不能让财务从流水倒推。
  4. 调整节奏。汇率每天波动,但价格不能每天改。目录按版本发布区域价格,汇率只作为人工调价的参考,不进入运行时计算。

本节的校验器只做一件保守的事:中国区三个入口(iOS、国内安卓、TapTap)的 CNY 价格必须与档位完全相等;海外区只校验币种为 USD,不强制金额等于人民币档位。这条规则牺牲了「全球统一价」的灵活性,换来的是中国区订单金额可以直接和档位对账。

把目录写成可校验的产物

下面的实现只依赖整数分和纯函数。它不连接商店,也不发网络请求;输入一份目录,输出问题列表或可读文本。这样它既能挂在发布流水线上,也能在本地被单测覆盖。

// file: src/biz/catalog.ts
// tier: L1
// verify: node --test test/biz/catalog.test.ts

export type ProductKind = "consumable" | "non-consumable" | "subscription";
export type Region = "cn-ios" | "cn-android" | "tap-tap" | "overseas";
export type Currency = "CNY" | "USD";

export interface PricePoint {
  readonly region: Region;
  readonly currency: Currency;
  readonly amountCents: number;
}

export interface Grant {
  readonly item: string;
  readonly quantity: number;
}

export interface Product {
  readonly sku: string;
  readonly kind: ProductKind;
  readonly tierCents: number;
  readonly grant: Grant;
  readonly prices: readonly PricePoint[];
}

export interface Catalog {
  readonly version: string;
  readonly products: readonly Product[];
}

export interface CatalogPolicy {
  readonly allowedTiers: readonly number[];
  readonly regions: readonly Region[];
}

export type IssueCode =
  | "duplicate-sku"
  | "tier-not-allowed"
  | "missing-region-price"
  | "currency-mismatch"
  | "tier-price-mismatch"
  | "invalid-grant"
  | "unit-price-inversion";

export interface CatalogIssue {
  readonly code: IssueCode;
  readonly sku: string;
  readonly detail: string;
}

export const REGION_CURRENCY: Record<Region, Currency> = {
  "cn-ios": "CNY",
  "cn-android": "CNY",
  "tap-tap": "CNY",
  overseas: "USD",
};

export function priceFor(product: Product, region: Region): PricePoint | undefined {
  return product.prices.find((price) => price.region === region);
}

export function unitPriceCents(product: Product): number | undefined {
  if (product.grant.quantity <= 0) {
    return undefined;
  }
  return product.tierCents / product.grant.quantity;
}

function checkProduct(product: Product, policy: CatalogPolicy): CatalogIssue[] {
  const issues: CatalogIssue[] = [];
  if (!policy.allowedTiers.includes(product.tierCents)) {
    issues.push({
      code: "tier-not-allowed",
      sku: product.sku,
      detail: `tier ${product.tierCents} not in ${policy.allowedTiers.join("/")}`,
    });
  }
  if (product.grant.item.trim() === "" || product.grant.quantity <= 0) {
    issues.push({
      code: "invalid-grant",
      sku: product.sku,
      detail: "grant item must be non-empty and quantity must be positive",
    });
  }
  for (const region of policy.regions) {
    const price = priceFor(product, region);
    if (price === undefined) {
      issues.push({
        code: "missing-region-price",
        sku: product.sku,
        detail: `region ${region} has no price`,
      });
      continue;
    }
    if (price.currency !== REGION_CURRENCY[region]) {
      issues.push({
        code: "currency-mismatch",
        sku: product.sku,
        detail: `${region} expects ${REGION_CURRENCY[region]}, got ${price.currency}`,
      });
      continue;
    }
    if (price.currency === "CNY" && price.amountCents !== product.tierCents) {
      issues.push({
        code: "tier-price-mismatch",
        sku: product.sku,
        detail: `tier ${product.tierCents} but ${region} price ${price.amountCents}`,
      });
    }
  }
  return issues;
}

function checkLadders(products: readonly Product[]): CatalogIssue[] {
  const issues: CatalogIssue[] = [];
  const groups = new Map<string, Product[]>();
  for (const product of products) {
    if (product.kind !== "consumable" || product.grant.quantity <= 0) {
      continue;
    }
    const group = groups.get(product.grant.item) ?? [];
    group.push(product);
    groups.set(product.grant.item, group);
  }
  for (const group of groups.values()) {
    const ladder = [...group].sort((a, b) => a.tierCents - b.tierCents);
    for (let i = 1; i < ladder.length; i += 1) {
      const small = ladder[i - 1];
      const large = ladder[i];
      const smallUnit = small.tierCents / small.grant.quantity;
      const largeUnit = large.tierCents / large.grant.quantity;
      if (largeUnit > smallUnit) {
        issues.push({
          code: "unit-price-inversion",
          sku: large.sku,
          detail: `${large.sku} unit ${largeUnit.toFixed(2)} > ${small.sku} unit ${smallUnit.toFixed(2)}`,
        });
      }
    }
  }
  return issues;
}

export function validateCatalog(catalog: Catalog, policy: CatalogPolicy): CatalogIssue[] {
  const issues: CatalogIssue[] = [];
  const seen = new Set<string>();
  for (const product of catalog.products) {
    if (seen.has(product.sku)) {
      issues.push({
        code: "duplicate-sku",
        sku: product.sku,
        detail: "sku appears more than once",
      });
    }
    seen.add(product.sku);
    issues.push(...checkProduct(product, policy));
  }
  issues.push(...checkLadders(catalog.products));
  return issues;
}

export function formatCatalog(catalog: Catalog): string {
  const lines = catalog.products.map((product) => {
    const regions = product.prices.map((price) => price.region).join(",");
    return `${product.sku}\tCNY ${(product.tierCents / 100).toFixed(2)}\t${product.grant.quantity} ${product.grant.item}\t${regions}`;
  });
  return [`catalog ${catalog.version} (${catalog.products.length} products)`, ...lines].join("\n");
}

校验器只回答五个问题

代码里的 validateCatalog 不做「智能修复」,只报告问题。它回答五个问题:

问题对应 code为什么必须在发布前拦住
SKU 是否唯一duplicate-sku重复 SKU 会让发货逻辑随机命中一条
档位是否在允许列表tier-not-allowed多一个零或少一个零,价格就错一个数量级
每个区域是否都有价格missing-region-price缺失区域在客户端表现为空白或崩溃
币种是否与区域匹配currency-mismatch币种错误会被商店拒绝或按错误金额结算
中国区价格是否等于档位tier-price-mismatch价格与档位漂移,订单无法按档位对账

此外还有两条与商品结构有关的检查:发货内容必须是非空物品且数量为正;同一物品的消耗品阶梯,大额包的单位价格不能高于小额包。后者用整数交叉相乘比较,避免浮点误差:

large.tier / large.qty > small.tier / small.qty
<=> large.tier * small.qty > small.tier * large.qty

代码里仍然用了除法来生成可读的错误信息,但判断本身可以用交叉相乘实现。对于金额,代码全程使用「分」为单位的整数,不用浮点数表示价格;只有在展示时才除以 100。

formatCatalog 是给人和日志看的稳定输出:第一行是版本与商品数,后面每行一个 SKU、档位、发货内容和覆盖区域。它不追求美观,追求两次相同输入产生逐字节相同的文本,便于在发布 diff 里 review。

目录变更的代价

把目录版本化,代价是发布流程变长。每次改价、加档、换发货内容,都要走一遍「内部定稿 → 商店创建或更新 → 客户端灰度 → 全量」。但这个代价换来了三件东西:

  • 可回滚。旧版本目录仍然可读,出问题时回滚的是数据,不是重新发一个客户端。
  • 可审计。每笔订单能追溯到当时的目录版本,退款和财务对账有依据。
  • 可测试。校验器在发布前跑,问题暴露在运营群,而不是玩家客诉。

具体操作上,有几条顺序不能反:

  1. 新 SKU 必须先在商店后台创建并通过审核,再让客户端可见。反过来会出现「客户端有入口、商店查无商品」。
  2. 删除 SKU 不能物理删除。历史订单、回执和退款都引用它,正确做法是标记退役,保留只读记录。
  3. 改价不修改旧版本。新版本发布后,旧客户端仍按旧目录展示;服务端校验金额时要接受「客户端目录版本对应的价格」,而不是只认最新价。
  4. 商品类型不可原地变更。把消耗品改成非消耗品,会改变恢复购买和重复发货的语义,必须新建 SKU。

这些规则会让运营觉得「改个价格很麻烦」。但支付系统的第一要求不是改得快,而是每一笔钱都能解释清楚。目录版本化就是把「解释清楚」的成本前移到发布时。

目录不负责收钱

需要明确边界:目录回答「卖什么、多少钱、发货内容是什么」,不回答「这笔钱走哪条通道、通道扣多少、什么时候到账」。同样是 ¥18,在 App Store、Google Play、国内安卓渠道和 TapTap 上,平台分成不同,净收入也不同。把费率写进目录,会让一次通道调整变成一次全量商品发布。

费率和路由属于下一节。目录只提供稳定的 SKU、档位和区域价格,让路由层可以在不改商品定义的前提下计算净收入。

小结

  • 商品目录是客户端、服务端、商店后台、财务共同消费的只读契约,不是运营 Excel 的电子化。
  • SKU 是身份,档位是定价,区域价格是商务与合规;三者必须分开建模,变更频率不同。
  • 档位阶梯要让大额包的单位价格不高于小额包,但折扣要有边界,避免囤货与套利。
  • 区域价格按商店价格点人工维护,不做运行时汇率换算;中国区 CNY 价格与档位严格相等。
  • 校验器只报告不修复,金额用整数分,输出稳定可 diff;目录版本化换来可回滚、可审计、可测试。

阅读导航:上一节:3.3 渠道包构建:变体、签名与合规检查 · 下一节:4.2 支付通道抽象:苹果 IAP、Google Play 与渠道支付 。

常见错误与边界

  1. 把商品名称当 SKU 用。名称一改,历史订单和回执就对不上,财务无法按商品归类。
  2. 在客户端硬编码价格。商店调价或区域价格变化后,客户端仍显示旧价,玩家看到的价格与实际扣款不一致。
  3. 用浮点数存金额。0.1 + 0.2 不等于 0.3,多次累加后对账出现分差;金额一律用整数分。
  4. 让海外价格等于人民币除以汇率。商店只接受固定价格点,换算结果既无法创建商品,也忽略税务与购买力。
  5. 新增 SKU 先发客户端、后建商店商品。玩家能看到入口却无法完成支付,客诉会直接打到客服。
  6. 物理删除退役 SKU。历史回执和退款仍引用它,删除后校验与对账都会失败;应标记退役并保留只读记录。
  7. 把费率写进商品目录。通道费率调整会触发全量商品重新发布;费率属于路由层,不属于商品定义。
  8. 只校验 SKU 存在,不校验阶梯。大额包单位价格倒挂不会报错,但玩家会用脚投票,客单价提升失效。
  9. 认为目录校验通过就等于支付正确。目录只保证「卖什么、多少钱」,回调真伪、幂等发货和退款仍由支付链路负责。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查