单笔用户充值,背后是一次复杂的资金拆分:用户充值 100 元,既是平台的收入,也是分销代理的佣金来源,可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题,是现金流的问题——差一分钱就是漏账,重复一次就是挪用。
分销账本设计的核心就四条铁律和一个恒等式。遵循它们,系统能撑到任何规模;跳过其中任何一条,早晚会在对账时翻车。
规则一:佣金计算基数要先定死
从什么数字出发算佣金?这个问题比看起来复杂。
通常的选项有三个:订单总金额、用户实付金额、或者订单到账净额。乍看没区别,一旦遇上退款就完全不同。
采用的方案是用户充值净额(billing 系统中已确认到账的实付分)。理由很直白:
- 退款处理天然免疫。用户充值后退款,billing 的该用户账户余额已经扣掉,充值净额自动反映了这笔冲销。分成计算只需聚合这个净额乘以比例,不用单独写退款冲正逻辑。
- 避免应收账款。如果以订单金额算,还没到账时代理已经看得到分成,这在 reporting-only 设计下容易造成认知错位(代理以为钱已经是他的,实际还在支付处理中)。
- 同源唯一。billing 是平台的权威账本,分成的基数来自这里,对账时只需验证"代理分成之和 + 平台收入 = billing 总充值",一个公式搞定。
反过来说,如果公司后续引入退款主动冲补(而非被动扣减),这个基数设定会变得复杂。但在初期,基数 = billing 已确认充值 是最简洁的切口。
规则二:结算时点决定了数据流向
到底是在订单成交时计提佣金,还是账期结束时一次性结算?
这里的选择是 reporting-only 只读聚合,实时查询,不计提、不累积。
具体含义是:代理看到的"我的分成"不是一条条流水记录,而是每次查询时现场计算出来的聚合数字。算法是"我名下所有用户的充值净额总和 × 我的佣金比例"。没有单独的"分成计提"操作,没有一条条的"分成到账"记录。
好处和代价是对偶的。
好处:
- 免除计提时点的争议。不用决定在订单成交时、支付完成时、还是 T+1 时计提,因为根本不计提。
- 天然避免双扣。既然分成不落库不累积,就不存在"发放一次、又重复发放一次"的并发风险。同一笔充值无论被查询多少次,贡献的佣金永远相同。
- 简化对账。代理的分成数字永远等于"最新充值净额 × 比例",无需追溯历史。
代价:
- 代理无法看到"分成流水"。有些运营场景下,需要展示"哪笔订单产生了多少佣金"这样的明细,reporting-only 做不了(可以通过关联用户的充值明细变通,但那是用户维度的流水,不是分成维度的)。
- 退款时必须同步。如果用户退了 50 块钱,billing 系统立刻反映这笔扣减,代理的分成下一秒查询就会跌下来。这对代理来说是透明的(分成就是动态的),但运营沟通时需要提前说清楚。
选择 reporting-only 的核心原因是:初期不出金、无提现。既然分成只是一个数字展示、不涉及真金白银的打款,那就不用建立复杂的流水账体系。等到未来做提现时,可以在 reporting-only 的基础上加一层"快照 + 冻结"机制(即每个提现周期开始时拍一个快照,这个快照才是可提的分成额)。
规则三:层级上限和循环检测
分销是分多少层级?
首期方案是单层。用户通过一个特定的渠道码注册(如 AB-48210377),永久绑定到某个代理。代理无法有"上级代理",也就无法有"上级佣金"这样的递归结构。
这一约束看起来很强,但在无提现的 reporting-only 下,是合理的。理由是:
- 简化代理运维。总台只需管理一套代理的佣金比例(per-代理),不用维护代理之间的树形关系。
- 避免环形链。单层天然杜绝了"A 的上级是 B,B 的上级是 A"这类配置错误。多层结构下,环形检测本身又是一个故障点。
- 初期够用。大多数分销场景早期就是"直销商(代理)→ 用户"的二元关系,不需要分级。
但要注意,这个约束是数据模型层的,不是业务规则层的。如果未来需要升级到多层结构,数据模型需要改(Channel 表可能要加 parentResellerId 等),但已经发出去的单层记录无需回溯改造——它们天然是单层的。
规则四:幂等键设计(防重复计提)
同一笔充值可能被多个系统调用、被回调多次。分成必须严格幂等:无论这笔充值被聚合几次,贡献给代理的佣金永远是"充值额 × 比例"这一个数字,不能是两倍、三倍。
因为采用 reporting-only + 只读聚合的设计,幂等性自动满足。
推理如下:
- billing 侧的充值流水本身是幂等的。同一个订单号的充值,billing 确保只入账一次(通过订单号的唯一性约束)。
- 分成聚合是无状态的。每次查询时,服务端都是"遍历该代理名下的用户 → 调用 billing 的 summaryByUsers 接口 → 汇总充值净额 → 乘以比例"。这个聚合过程不依赖任何之前的计提记录。
- 结论:即使 billing 错误地返回了同一笔充值两次,聚合结果也只会包含一次(因为底层是"用户ID → 总充值净额"的映射,不是"订单 → 充值"的流水列表)。
相反,如果设计成"订单成交时计提一条分成记录"的模式,就需要在分成记录上加幂等键(如 orderId),确保同一订单的分成只计提一次。这个幂等键检查本身就是一个额外的故障点。
一个恒等式:对账的唯一标准
前面四条规则规范了流程,但最后的验证还是要靠一个简单的数学公式。
$$ \sum_^{n} \text{Commission}_i + \text{PlatformNetIncome} = \text{TotalTopup} $$
其中:
- $\text{Commission}_i$ 是第 $i$ 个代理的分成(所有名下用户的充值净额 × 佣金比例)
- $\text{PlatformNetIncome}$ 是平台的净收入(总充值 - 所有代理的分成总额)
- $\text{TotalTopup}$ 是 billing 系统的总充值额(所有用户的到账充值之和)
这个等式是唯一可靠的对账标尺。任何时刻,只要这个等式不成立,就说明某个环节出了问题:
- 等式左侧大于右侧 → 某个代理的分成算重了,或者平台收入算多了
- 等式左侧小于右侧 → 某个代理的分成算少了,或者某笔收入漏了
而且这个等式不需要建立任何新表。完全可以通过查询三个现成的数据源验证:
- billing 的 SummaryByUsers(每个用户的充值净额)
- 代理表的 commissionRate(每个代理的佣金比例)
- 一行 SQL 的聚合(sum 和乘法)
技术实现的两个关键点
光有规则还不够,实现层要支撑这些规则。素材中的设计有两个细节值得指出。
其一,billing 要暴露 summaryByUsers 接口。
分成聚合依赖"按用户ID汇总充值净额和消耗"这个操作。如果 billing 侧没有这个批量接口,代理端就得自己拼接多个单用户查询,性能和一致性都会打折扣。实现计划里新增的 POST /analytics/summary-by-users 就是为了这个。
其二,reseller 侧的所有查询要服务端注入 channelId。
代理登录后调用 /api/reseller/summary 或 /api/reseller/users,后端不能信任请求体里的 channelId 参数。而是从 token 解析出代理身份 → 查表得出该代理对应的 channelId → 强制注入到查询条件里。这是防代理 A 越权查看代理 B 渠道的唯一有效方式。
代码里体现为:从 Admin token 反查 Channel 表的 resellerId 字段,确保该 Admin 只能看自己那行 Channel。
与既有分账设计的呼应
这套账本设计不是凭空造出来的。平台此前在另一个系统里实现过五类角色的分账体系(Platform、Agency、Escort、Distributor、Merchant),每个角色各维护一张 Ledger 流水表。那个设计的核心思想是"分账入表"——即每一笔影响各角色收入的交易,都要对应地在各自的 Ledger 表里落一条记录。
当前这套分销设计采用了相反的思路:不建 Ledger 表,而是在查询时通过聚合来推导分成。这的背景是 reporting-only 属性(不出金、只展示),使得可以接受动态聚合的方案。但底层的思想是一致的——通过对账恒等式来保证多角色之间的收支平衡。
结尾
账本设计的目标不是漂亮的表格或丰富的报表,而是一个简单的数学等式永远成立。一旦等式破裂,对账人员立刻能定位是哪个环节失守。这比事后扑火要高效得多。
■