先说结论:返佣系统的核心不是"算得对",而是"错了以后说得清"。我们的多租户小程序 SaaS(Java 21 / Spring Boot / PostgreSQL,27 个业务域)带代理商佣金体系:代理商发展商户,商户交易按约定比例返佣。钱走微信支付服务商模式、直接进商户的特约商户账户,平台不碰钱——系统只做记账与分账编排。
这正是佣金账本的特殊性:它是"替别人记的钱"。替别人记的账错一分,少记是欠代理商的钱,多记是公司的真金白银,都是事故。某年 3 月 31 日 23:58:41 的月末对账,账面比佣金明细多了 0.03 元,两个同事对到凌晨一点半,发现是一处金额没按分存储、四舍五入漂移——本文每条设计原则,都是被这类夜晚逼出来的。
一、"替别人记的钱"都错在哪
佣金出错翻来覆去三类。费率漂移:代理商升级后费率从 5% 调到 8%,运营把当月已结算的账按新费率重算,代理商拿旧截图来问"我三月那笔怎么变了"。退款追不回:佣金释放进可提现后第 6 天用户全额退款,钱已被提走,只能扯皮。重复入账:结算事件重复投递,同一订单入账两次,月底差额才暴露。共同根源:把佣金当成订单表上的字段、余额表上的数字,而不是一本有纪律的独立账本。
二、三条设计原则
逐笔可溯。每笔条目必须回答三个问题:从哪笔订单来(源单号)、按什么比例(费率与基数快照)、谁受益(代理商 ID)。余额不是事实,明细才是事实——余额永远等于明细的聚合,对账脚本每天跑"SUM(明细) == 余额视图",不等即告警。
快照固化。比例、计算基数、金额在结算那一刻冻结成快照写进条目。之后改费率,只影响新结算,历史账纹丝不动。审计问"3 月那笔为什么按 8% 算",答案永远是:它结算时快照就是 8%。
只增不改。条目一旦落库禁止 UPDATE。金额记错、订单退款,一律追加反向行冲正,原始行保留。账本因此是 append-only 的,任何时刻可从第一行重放出当前余额——与状态机"终态不可逆、复活就新建单据"是同一条纪律。
三、入账时机与幂等
佣金不在支付成功时入账,而在订单结算事件(核销完成并过可退期)驱动——支付后仍可能退款,入早了追回成本更高。
事件至少一次投递,幂等靠唯一索引兜底:(订单号, 分账周期, 条目类型)。重复事件撞唯一键,捕获冲突直接视为成功,不告警不重试。早期幂等键只有订单号,一笔部分退款后重新结算的订单被误判成重复、少入账一次——之后键升到三维。
四、余额的三层视图
代理商端看到的余额,实际是三层:
| 层 | 含义 | 何时变化 |
|---|---|---|
| 入账中 | 条目已生成,仍在可退期内 | 结算入账;过可退期释放 |
| 可提现 | 可发起提现的部分 | 释放、冲正后增减 |
| 提现冻结 | 已申请提现、打款在途 | 申请提现时划入 |
提现申请把金额从"可提现"原子划入"提现冻结",余额不足直接拒绝;打款成功冲销冻结,失败原额退回。冻结层隔离"打款在途"与"可用余额"——打款耗时几分钟到几十分钟,期间新入账与冲正照常发生,互不干扰。
五、脱敏伪代码与表结构
表结构示意(脱敏,金额按分存储):
CREATE TABLE commission_entry (
id BIGINT PRIMARY KEY,
agent_id BIGINT NOT NULL, -- 受益代理商
order_no VARCHAR(32) NOT NULL, -- 源订单
settle_period CHAR(7) NOT NULL, -- 分账周期 yyyy-MM
entry_type SMALLINT NOT NULL, -- 1入账 2冲正
base_amount BIGINT NOT NULL, -- 计算基数(分)
rate_snapshot NUMERIC(5,4) NOT NULL, -- 费率快照
amount BIGINT NOT NULL, -- 佣金(分,冲正为负)
reversed_id BIGINT, -- 冲正指向原条目
UNIQUE (order_no, settle_period, entry_type) -- 幂等键
);
入账与冲正(Java 风格伪代码):
// 结算事件 -> 入账:撞唯一键即幂等成功
@Transactional
public void onOrderSettled(SettleEvent e) {
try {
entryRepo.insert(CommissionEntry.of(
e.agentId(), e.orderNo(), e.period(), INCOME,
e.baseAmount(), e.rateSnapshot(),
amountOf(e.baseAmount(), e.rateSnapshot())));
} catch (DuplicateKeyException ok) { return; } // 重复投递
}
// 退款事件 -> 冲正:不动原行,追加负数行
@Transactional
public void onOrderRefunded(RefundEvent e) {
var origin = entryRepo.findIncome(e.orderNo(), e.period());
long back = refundShare(origin, e.refundRate()); // 按退款比例、上限为原佣金
try {
entryRepo.insert(CommissionEntry.reversalOf(origin, -back));
} catch (DuplicateKeyException ok) { return; }
}
六、踩坑清单
费率跨期。现象:3 月 31 日 23:58 改费率,4 月 1 日凌晨结算的一批"3 月订单"按新费率入账,两个口径都觉得自己对。定位:周期按结算时间还是下单时间,从未定义。修法:归属显式定义为"结算时间所在月",费率表加生效时间戳,跨期订单按结算点取快照。
退款追回的上限。现象:客服问"这笔全额退款,佣金能不能全额扣回来",答案是:不一定,已提现部分追不回。修法:负数行金额以原佣金为上限;可提现不足时允许余额为负,后续入账先抵扣;追不回的转应收走线下,绝不在账本里"技术性抹平"。
余额当事实。现象:早期版本单独维护余额表实时 UPDATE,一次并发提现把它更新成负数。修法:余额退化为三层视图(明细的带条件聚合),唯一事实是条目表;对账脚本纳入 1241 个测试方法的矩阵,生产每天重放明细。
七、三句话总结
- 返佣账本的第一属性是凭证:逐笔可溯、快照固化、只增不改,保证每笔钱都说得清来龙去脉;
- 入账跟着结算事件走、幂等键打到"订单号+分账周期+条目类型",重复投递就不再构成威胁;
- 余额是视图不是事实:三层视图隔离在途与可用,冲正只用负数行,账本才敢在出错时保持诚实。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
