本文只讨论工程实现:表结构、结算算法、边界条件。不谈营销、不涉及任何商业推广。
一、问题定义
差额计酬(业内常称"级差")的核心是:下级出单时,上级拿到的不是固定比例,而是「自己聘阶比例」与「下级已拿比例」之间的差。
形式化描述:
- 用户
u有一个随时变动的聘阶rate(u)(例如 0.05 / 0.10 / 0.15 / 0.20) - 订单
o的实际成交额amount(o)
- 订单
- 关系链
u1 → u2 → u3 → ...(u1是直接推荐人)
- 关系链
- 对每个上级
ui,其佣金 =amount(o) × (rate(ui) − max(rate(u1..u(i-1)))),且该值必须 ≥ 0
关键约束:沿链上行时,取「已出现的最高聘阶」作为封顶基准,否则链上会出现倒挂(下级比例高于上级),差额变成负数。
- 对每个上级
二、表结构设计
1. 用户与聘阶
CREATETABLE`user_rank`(`user_id`BIGINTNOTNULLCOMMENT'用户ID',`rank_id`INTNOTNULLCOMMENT'聘阶ID',`effective_at`DATETIMENOTNULLCOMMENT'生效时间',`expire_at`DATETIMEDEFAULTNULLCOMMENT'失效时间(归零制用)',`period`VARCHAR(7)NOTNULLCOMMENT'归属周期 YYYY-MM',PRIMARYKEY(`user_id`,`period`))COMMENT'用户聘阶快照表(按月快照,不用可变字段)';```要点:**聘阶不要做成`user.rank`这样的可变单值字段**。归零制意味着聘阶按月重算,历史订单必须能还原当时的聘阶,否则退款冲正时算不回去。所以用「按周期快照」。 ### 2. 关系链 ```sqlCREATETABLE`user_relation`(`user_id`BIGINTNOTNULL,`parent_id`BIGINTNOTNULLCOMMENT'直接推荐人',`path`VARCHAR(512)NOTNULLCOMMENT'物化路径 /1/18/233/',`depth`INTNOTNULLDEFAULT1,`bound_at`DATETIMENOTNULL,PRIMARYKEY(`user_id`),KEY`idx_parent`(`parent_id`),KEY`idx_path`(`path`(255)))COMMENT'推荐关系链,物化路径便于一次查出所有上级';```关系链是**只增不改**的(换绑是极少数场景,用新记录+失效时间处理)。用物化路径`path`,查所有上级是一条`LIKE`而不是递归查询——在结算高峰期差别很大。 ### 3. 佣金流水(核心) ```sqlCREATETABLE`commission_ledger`(`id`BIGINTNOTNULLAUTO_INCREMENT,`order_id`BIGINTNOTNULLCOMMENT'来源订单',`order_item_id`BIGINTDEFAULTNULL,`beneficiary`BIGINTNOTNULLCOMMENT'受益人',`biz_type`TINYINTNOTNULLCOMMENT'1差额 2返佣 3团队奖',`base_amount`DECIMAL(12,2)NOTNULLCOMMENT'计佣基数',`from_rate`DECIMAL(6,4)NOTNULLCOMMENT'下级已拿比例',`to_rate`DECIMAL(6,4)NOTNULLCOMMENT'本人聘阶比例',`rate`DECIMAL(6,4)NOTNULLCOMMENT'实际差额比例',`amount`DECIMAL(12,2)NOTNULLCOMMENT'佣金金额',`status`TINYINTNOTNULLDEFAULT0COMMENT'0待结算 1已结算 2已冲正',`settle_batch`VARCHAR(32)DEFAULTNULL,`created_at`DATETIMENOTNULL,`reversed_by`BIGINTDEFAULTNULLCOMMENT'冲正流水ID',PRIMARYKEY(`id`),UNIQUEKEY`uk_order_beneficiary_biz`(`order_id`,`order_item_id`,`beneficiary`,`biz_type`),KEY`idx_settle`(`status`,`settle_batch`))COMMENT'佣金流水,只增不改,冲正走反向记录';```三个设计决定值得说明: 1. **`from_rate`/`to_rate`/`rate`三个字段都落库**。只存金额的话,事后对账或客服答疑时无法解释"为什么这个人只拿了 3%",一线会反复找研发。 2. 2. **唯一索引`order_id+order_item_id+beneficiary+biz_type`**:这是结算幂等的最后一道防线。重复投递的消息(MQ 至少一次语义)会被数据库直接挡掉,而不是靠应用层判断。 3. 3. **冲正不 UPDATE,而是插一条反向记录并回填`reversed_by`**。财务口径要求流水可追溯,`status`只表示聚合态。## 三、结算算法function settle(order):
chain = query_uplines(order.buyer_id) # 按 depth 升序,最多 N 层
rates = []
max_seen = 0 # 已出现的最高聘阶
for u in chain:
r = snapshot_rate(u.user_id, order.period)
diff = r - max_seen
if diff > 0:
emit(u, base=order.amount, from_rate=max_seen,
to_rate=r, rate=diff)
max_seen = r # 只在真的发钱后才抬高基准
# 支付通道 / 平台分成最后从总额里扣,不参与差额计算
```
四个容易写错的地方:
- 基准抬升时机:
max_seen必须在「真的产生差额」之后才更新。若用if diff >= 0或无条件更新,倒挂链上会把更高聘阶的上级压成 0。 - 金额取整:差额比例相乘几乎必然出现分位以下小数。统一用「向下取整到分」,并把舍去部分累计到一个
platform_residue账户,否则总拨出金额可能超过制度上限(哪怕是 0.01 元,对账时也是事故)。
- 金额取整:差额比例相乘几乎必然出现分位以下小数。统一用「向下取整到分」,并把舍去部分累计到一个
- 封顶与截断:若制度设定「最多往上算 N 层」,
chain查询就必须带depth <= N,不能靠循环里 break——否则高并发下每单拉取全链,成本失控。
- 封顶与截断:若制度设定「最多往上算 N 层」,
- 拨出率校验:结算前先聚合本次的拨出总额与订单额,超过制度上限要拒绝结算并告警,不能"先发再说"。
四、边界条件清单
| 场景 | 处理方式 |
|---|---|
| 订单退款 | 按order_id查全部status=1流水,逐条生成反向记录,biz_type不变,amount取负,reversed_by互相回填 |
| 部分退款 | 按退款金额比例冲正,剩余部分重算;不要用「只冲最后一条」的偷懒写法 |
| 聘阶月中变更 | user_rank按周期快照已规避;若必须支持月中生效,则变更时对当月已完成订单不做回溯,仅对变更后订单生效 |
| 归零制周期切分 | 归零是「周期快照重建」,不是「删数据」。历史commission_ledger不动 |
| 关系链换绑 | 旧记录置失效时间,新记录生效;已产生佣金不回滚(合同层面约定) |
| 并发结算 | 靠唯一索引兜底 + 结算批次号做灰度可回滚 |
| 跨月结算 | 以订单支付成功时间归属周期,不以结算执行时间归属 |
五、性能与实现建议
- 关系链用物化路径后,"查所有上级"是索引扫描,单次 O(depth),不需要递归 CTE
- 结算走异步:订单支付成功后发消息,消费者按批次聚合,
settle_batch作为可回滚单元
- 结算走异步:订单支付成功后发消息,消费者按批次聚合,
- 佣金流水表按
created_at月分区,历史查询走归档库
- 佣金流水表按
- 金额一律
DECIMAL(12,2),不要用浮点;比例用DECIMAL(6,4)存,避免二进制浮点误差累积
- 金额一律
- 对外只暴露「已结算」状态的流水,待结算流水不参与提现校验
六、小结
差额佣金引擎的复杂度不在公式,而在三件事:聘阶要按周期快照、流水只增不改、结算必须幂等。这三条做对了,后面无论是加归零制、加累积制还是加团队奖,都只是在同一套骨架上多挂一个biz_type。
反之,如果把聘阶做成一个可变字段、把冲正写成UPDATE amount = 0,那这套引擎从第一天起就注定对不上账。