ENGINEERING NOTES · 工程笔记

佣金账本设计:每笔返佣都要有据可查

先说结论:返佣系统的核心不是"算得对",而是"错了以后说得清"。我们的多租户小程序 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 个测试方法的矩阵,生产每天重放明细。

七、三句话总结

  1. 返佣账本的第一属性是凭证:逐笔可溯、快照固化、只增不改,保证每笔钱都说得清来龙去脉;
  2. 入账跟着结算事件走、幂等键打到"订单号+分账周期+条目类型",重复投递就不再构成威胁;
  3. 余额是视图不是事实:三层视图隔离在途与可用,冲正只用负数行,账本才敢在出错时保持诚实。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询