一张 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 美元并不存在;即使存在,也忽略了税率、支付通道成本和当地购买力。
区域价格至少要处理四件事:
- 价格点。App Store 与 Google Play 各自维护一套价格网格,开发者只能从中选择。客户端展示的价格必须来自商店返回的本地化价格,不能用人民币价格实时换算。
- 币种。中国区用 CNY,海外区用 USD 或其他本地币种。币种写错,商店会拒绝创建商品,或者按错误币种展示。
- 含税口径。商店代扣代缴的地区,标价即玩家支付价;需要商户自行处理的地区,目录里要明确是含税价还是未税价,不能让财务从流水倒推。
- 调整节奏。汇率每天波动,但价格不能每天改。目录按版本发布区域价格,汇率只作为人工调价的参考,不进入运行时计算。
本节的校验器只做一件保守的事:中国区三个入口(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。
目录变更的代价
把目录版本化,代价是发布流程变长。每次改价、加档、换发货内容,都要走一遍「内部定稿 → 商店创建或更新 → 客户端灰度 → 全量」。但这个代价换来了三件东西:
- 可回滚。旧版本目录仍然可读,出问题时回滚的是数据,不是重新发一个客户端。
- 可审计。每笔订单能追溯到当时的目录版本,退款和财务对账有依据。
- 可测试。校验器在发布前跑,问题暴露在运营群,而不是玩家客诉。
具体操作上,有几条顺序不能反:
- 新 SKU 必须先在商店后台创建并通过审核,再让客户端可见。反过来会出现「客户端有入口、商店查无商品」。
- 删除 SKU 不能物理删除。历史订单、回执和退款都引用它,正确做法是标记退役,保留只读记录。
- 改价不修改旧版本。新版本发布后,旧客户端仍按旧目录展示;服务端校验金额时要接受「客户端目录版本对应的价格」,而不是只认最新价。
- 商品类型不可原地变更。把消耗品改成非消耗品,会改变恢复购买和重复发货的语义,必须新建 SKU。
这些规则会让运营觉得「改个价格很麻烦」。但支付系统的第一要求不是改得快,而是每一笔钱都能解释清楚。目录版本化就是把「解释清楚」的成本前移到发布时。
目录不负责收钱
需要明确边界:目录回答「卖什么、多少钱、发货内容是什么」,不回答「这笔钱走哪条通道、通道扣多少、什么时候到账」。同样是 ¥18,在 App Store、Google Play、国内安卓渠道和 TapTap 上,平台分成不同,净收入也不同。把费率写进目录,会让一次通道调整变成一次全量商品发布。
费率和路由属于下一节。目录只提供稳定的 SKU、档位和区域价格,让路由层可以在不改商品定义的前提下计算净收入。
小结
- 商品目录是客户端、服务端、商店后台、财务共同消费的只读契约,不是运营 Excel 的电子化。
- SKU 是身份,档位是定价,区域价格是商务与合规;三者必须分开建模,变更频率不同。
- 档位阶梯要让大额包的单位价格不高于小额包,但折扣要有边界,避免囤货与套利。
- 区域价格按商店价格点人工维护,不做运行时汇率换算;中国区 CNY 价格与档位严格相等。
- 校验器只报告不修复,金额用整数分,输出稳定可 diff;目录版本化换来可回滚、可审计、可测试。
阅读导航:上一节:3.3 渠道包构建:变体、签名与合规检查 · 下一节:4.2 支付通道抽象:苹果 IAP、Google Play 与渠道支付 。
常见错误与边界
- 把商品名称当 SKU 用。名称一改,历史订单和回执就对不上,财务无法按商品归类。
- 在客户端硬编码价格。商店调价或区域价格变化后,客户端仍显示旧价,玩家看到的价格与实际扣款不一致。
- 用浮点数存金额。0.1 + 0.2 不等于 0.3,多次累加后对账出现分差;金额一律用整数分。
- 让海外价格等于人民币除以汇率。商店只接受固定价格点,换算结果既无法创建商品,也忽略税务与购买力。
- 新增 SKU 先发客户端、后建商店商品。玩家能看到入口却无法完成支付,客诉会直接打到客服。
- 物理删除退役 SKU。历史回执和退款仍引用它,删除后校验与对账都会失败;应标记退役并保留只读记录。
- 把费率写进商品目录。通道费率调整会触发全量商品重新发布;费率属于路由层,不属于商品定义。
- 只校验 SKU 存在,不校验阶梯。大额包单位价格倒挂不会报错,但玩家会用脚投票,客单价提升失效。
- 认为目录校验通过就等于支付正确。目录只保证「卖什么、多少钱」,回调真伪、幂等发货和退款仍由支付链路负责。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。