社群团购系统快团团模式开发——数据库设计:佣金、结算表的资金安全设计

先给结论:资金表的 6 条军规

上一篇解决了"货"的表设计,这一篇解决"钱"。订单表设计错了顶多体验差,佣金表设计错了是直接出账——要么多给团长钱,要么少给,两头都是事故。这个系统所有和钱沾边的表设计,收束成 6 条军规,先全部亮出来,再逐条展开:

  1. 金额一律以「分」为单位存 BIGINT——上一篇已立规矩,本篇只补一个例外场景的取舍;
  2. 每一笔资金变动都有唯一业务序列号(幂等)——微信回调会重发、定时任务会重跑、消息会重复消费,唯一键是最后防线;
  3. 佣金在下单事务内锁定快照——结算时绝不重算,按"当时的规则"付钱;
  4. 分片键 leader_id 前置埋点,双表冗余——设计期埋好,不能等拆表时回填;
  5. 资金状态机只前进不回跳——撤销不删记录,用冲正(反向记录)表达;
  6. 所有资金写操作可对账——来源、关联单号、版本号,留痕字段在设计期就长在表里。

这 6 条不是这套系统发明的,是所有跑过真实资金的交易系统用事故换来的共识。下面逐条讲清楚"为什么"和"怎么落地"。


1. 佣金表:先看它要承载什么业务

先把业务说清,表结构才立得住。一笔订单产生后,资金流向两方:

  • 供货团长:收到货款对应的结算款(供货价 × 数量);
  • 帮卖团长(如果有):收到佣金(加价部分或按比例)。

每笔佣金有自己的生命周期:下单时生成(待生效)→ 支付成功后冻结(冻结期内可退款)→ 订单完成 + 冻结期(7~15 天)届满 → 可结算 → 汇入结算单打款 → 已结算;售后退款则触发回滚。这就是佣金状态机的全部状态:

10 待生效 ──支付成功──> 20 冻结中 ──冻结期满且订单完成──> 30 可结算
                              │                              │
                            售后退款                       汇入结算单
                              ↓                              ↓
                          50 已回滚                       40 已结算

对应的表结构:

CREATE TABLE `t_commission_record` (
  `id`              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `leader_id`       BIGINT      NOT NULL COMMENT '收益团长ID(供货或帮卖,未来分片键)',
  `order_id`        BIGINT      NOT NULL COMMENT '来源订单ID',
  `order_no`        CHAR(32)    NOT NULL COMMENT '来源订单号(对账用冗余)',
  `role_type`       TINYINT     NOT NULL COMMENT '10供货佣金 20帮卖佣金',
  `amount`          BIGINT      NOT NULL COMMENT '佣金金额(分)',
  `status`          TINYINT     NOT NULL DEFAULT 10
                    COMMENT '10待生效 20冻结中 30可结算 40已结算 50已回滚',
  `frozen_until`    DATETIME    NULL COMMENT '冻结到期时间,结算触发的依据',
  `settle_id`       BIGINT      NULL COMMENT '汇入的结算单ID',
  `biz_serial`      CHAR(40)    NOT NULL COMMENT '幂等序列=order_id:role_type:rule_version',
  `source`          TINYINT     NOT NULL DEFAULT 10 COMMENT '10下单生成 20售后冲正 30人工调整',
  `related_id`      BIGINT      NULL COMMENT '冲正时指向被冲正的佣金记录ID',
  `create_time`     DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `update_time`     DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
                    ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz_serial` (`biz_serial`),
  KEY `idx_comm_leader_status` (`leader_id`, `status`, `frozen_until`),
  KEY `idx_comm_order` (`order_id`)
) ENGINE=InnoDB COMMENT='佣金明细表';

这张表就是军规 2、4、5、6 的物理载体,下面逐个拆。


2. 军规四:分片键前置埋点——leader_id 为什么要双表冗余

先说结论:leader_id 同时出现在订单表和佣金表,且这个决定必须在第一天做。

单表时代看,这个字段确实"冗余"——订单表顺着 group_buy_id 能查到团购,团购上有供货团长;佣金表顺着 order_id 能查到订单。三层 JOIN 而已,为什么要多存一列?

因为要往前推演一步:分库分表时按什么分片? 这套系统的答案很明确——按 leader_id(团长 ID)分。理由看查询画像:佣金结算按团长汇总、团长看板按团长聚合、订单导出按团长发起、帮卖查自己的单按团长查——绝大多数高频查询的聚合维度都是团长。按团长分片,这些查询全部落在单片内,跨片查询几乎为零;按订单号分片,上面每一个查询都变成全分片扫描。

分片之后,"查某团长的全部佣金记录"要求佣金表自己必须带 leader_id(不能跨片 JOIN 订单表);"导出某团长的全部订单"要求订单表必须带 leader_id。所以是双表冗余,不是二选一。

关键在时机:字段加在第一天,成本是零;等单表跑到两千万行决定拆分时再回填,是一条 UPDATE t_order SET leader_id = ... 扫全表的长事务——锁表、主从延迟、写坏了无法回滚,整套操作要停机窗口。分片键是设计期决策,不是运维期补丁。 顺带一提,第 5 篇订单表 DDL 里那行 leader_id 注释写的"未来分片键,前置埋点",和这里是一套动作的两个落点。


3. 军规二:uk_biz_serial——资金幂等的保险丝

资金链路上有三类"同一件事发生两次"的常态:

  1. 微信支付回调重发(微信的重试策略是 15s/15s/30s/...,最多重试多次);
  2. 定时任务重跑(XXL-Job 手动补数、失败重试);
  3. 未来引入消息队列后的重复消费。

每一种的应对思路都一样:业务代码去重不可靠,数据库唯一键才可靠。biz_serial 就是为此设计的幂等序列:

biz_serial = order_id : role_type : rule_version
例:"8829340:20:v1"

三段式构成的原因:同一笔订单会同时产生供货佣金和帮卖佣金两条记录(role_type 区分),同一条佣金规则改版后可能重算(rule_version 区分版本)。唯一键加在这三段组合上,"同一订单、同一角色、同一规则版本"在物理上只可能存在一条佣金。

代码侧的用法不是"先查再插"——先查再插在并发下有窗口,两个线程同时查不到然后同时插。正确姿势是直接 insert,靠捕获唯一键冲突实现幂等:

public void generateCommission(Order order, CommissionResult calc, String ruleVersion) {
    CommissionRecord record = CommissionRecord.create(order, calc, ruleVersion);
    try {
        commissionMapper.insert(record);
    } catch (DuplicateKeyException e) {
        // uk_biz_serial 冲突 = 幂等命中:同一订单同规则已生成过佣金
        // 直接吞掉异常返回,这正是我们想要的行为
        log.info("佣金重复生成被拦截, orderNo={}, serial={}",
                order.getOrderNo(), record.getBizSerial());
    }
}

这个 catch 块不是异常处理,是正常分支——回调重发、任务重跑走进这里,系统纹丝不动。评审资金代码时看到"先 SELECT 再 INSERT"的写法直接打回,唯一键冲突捕获才是幂等的标准实现。


4. 军规三:下单事务内锁佣金快照——完整代码

这是本篇的核心。先说反面方案为什么不行:

方案 A(被否决):支付成功后、或结算时再算佣金。 问题在"算"这个动作依赖两个会变的东西——SKU 的价格(供货团长随时改价)和佣金规则(平台调比例、帮卖调加价)。支付到结算之间隔着几天,任何一次变动都会让"最终结算金额"和"用户下单时看到的金额"对不上,团长看到佣金缩水来投诉,你连当时的计算依据都拿不出来。

方案 B(采纳):下单事务内一次算清、落库快照,之后所有环节只读快照。 佣金金额在下单那一刻就变成不可变的历史事实,改价、调规则、改加价都影响不了已生成的佣金。

@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderCmd cmd) {
    // 1. 校验团购状态:status=20 且未到截单时间(双重约束,缺一不可)
    GroupBuy gb = groupBuyService.checkBuyable(cmd.groupBuyId());

    // 2. 锁定价格快照:成交价、供货价、帮卖加价,一次取定
    PriceSnapshot price = priceService.lockSnapshot(cmd, gb);

    // 3. 事务内计算佣金:供货佣金 + 帮卖佣金(规则只在 domain 层一处实现)
    CommissionResult calc = commissionCalculator.calculate(cmd, price);

    // 4. 创建订单 + 明细(快照字段落库,见第 5 篇)
    Order order = orderRepository.save(Order.create(cmd, gb, price));

    // 5. 佣金快照落库:供货佣金与帮卖佣金各一条,uk_biz_serial 兜底幂等
    commissionService.generateCommission(order, calc, RuleVersion.CURRENT);

    // 6. Redis 预扣库存失败则整体回滚(DB 兜底校验在第 12 篇展开)
    stockService.preDeduct(cmd.skuId(), cmd.quantity());
    return order;
}

要点有三:佣金计算只发生在这一处(domain 层的 commissionCalculator,全系统唯一一份资金规则代码,Controller/Job 里出现金额计算即评审否决);计算和落库在同一个本地事务里,价格快照、佣金快照、订单三者原子生效——这正是第 3 篇选模块化单体的核心回报,同一逻辑拆成微服务后 @Transactional 失效,要换分布式事务重写;支付回调只改状态不算钱——回调处理里出现任何"重新计算"字样都是设计回归。


5. 军规五:冻结期与结算单——只前进的状态机

佣金为什么要有冻结期?因为售后退款窗口存在。用户收货后 7 天内可能退款,如果佣金当天就结算给团长,退款时钱已经被提走,平台垫资追讨——这是初创团购平台最常见的资损姿势。所以佣金从"支付成功"到"可结算"之间隔着一段冻结期(按订单完成起算 7~15 天,按品类风险配置)。

frozen_until 字段就是冻结期的数据落点,结算的触发条件是 status = 30 可结算,而状态从 20 推进到 30 的定时任务这样扫:

@XxlJob("commissionUnfreezeJob")
public void unfreeze() {
    // 幂等设计:UPDATE 带 status 条件,重跑只会更新 0 行,天然安全
    int n = commissionMapper.update(
        "UPDATE t_commission_record " +
        "SET status = 30 " +
        "WHERE status = 20 AND frozen_until <= NOW(3)");
    log.info("佣金解冻 {} 条", n);
}

注意这条 UPDATE 的写法:状态条件放在 WHERE 里,重跑一遍影响 0 行——这就是"任务必须幂等"在 SQL 层的样子,第 4 篇 XXL-Job 一节的伏笔在这里兑现。

冻结期满的佣金汇入结算单。结算单按"团长 + 周期"汇总,结构是一对多的汇总-明细关系:

CREATE TABLE `t_settlement` (
  `id`               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `settle_no`        CHAR(32)     NOT NULL COMMENT '结算单号',
  `leader_id`        BIGINT       NOT NULL COMMENT '团长ID(分片键,与明细一致)',
  `period`           CHAR(10)     NOT NULL COMMENT '结算周期,如 2026W36',
  `total_amount`     BIGINT       NOT NULL COMMENT '结算总金额(分)= SUM(明细)',
  `item_count`       INT          NOT NULL COMMENT '佣金明细条数,对账校验用',
  `status`           TINYINT      NOT NULL DEFAULT 10
                     COMMENT '10待打款 20打款中 30已完成 40失败',
  `create_time`      DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_leader_period` (`leader_id`, `period`),
  UNIQUE KEY `uk_settle_no` (`settle_no`)
) ENGINE=InnoDB COMMENT='结算单';

两个设计点:uk_leader_period 唯一键保证"同一个团长同一个周期只生成一张结算单"——结算任务重跑撞唯一键,幂等;item_count 记录明细条数,打款前的校验是 SUM(明细金额) = total_amount AND COUNT(明细) = item_count,任何一个对不上,结算单卡住人工介入。汇总单和明细之间的金额自洽,是对账系统的第一道基础校验(第 23 篇展开)。佣金从明细汇入结算单时状态从 30 推进到 40 并回写 settle_id,反向不可逆——资金状态机只前进,做错了不 UPDATE 原记录,插入一条冲正记录(source=20 冲正,related_id 指向原记录)。为什么用冲正而不用修改?因为修改会抹掉历史,冲正让"曾经发生过什么"永远可查——这是财务记账的基本原则,资金表设计同理。

顺带把"重复发生"的场景和拦截层做一个全景对照,这张表可以直接当资金模块的评审 checklist 用:

重复场景 典型来源 拦截层 代码位置
同一订单重复生成佣金 回调重发、下单重试 uk_biz_serial 唯一键 佣金生成,捕获冲突即幂等返回
同一周期重复生成结算单 结算任务重跑 uk_leader_period 唯一键 结算单生成
同一佣金重复汇入结算 结算任务与人工操作并发 佣金状态条件更新(30→40 带 WHERE) 汇入动作
同一订单重复退款冲正 售后回调重发 冲正记录的 uk_biz_serial(冲正单也有序列号) 冲正生成
解冻任务重复执行 XXL-Job 重试 UPDATE 带状态条件,重跑影响 0 行 解冻任务

规律很清楚:每一类资金写操作,都要能在这张表里找到自己那一行——找不到,说明这个操作还没有幂等设计,不允许上线。幂等不是某一个环节的属性,是资金链路每一个写入口的标配。


6. 军规一与军规六:金额类型的取舍与对账留痕

军规一的补充:commission_rate_snapshot 为什么用 DECIMAL(5,4) 而不是 BIGINT。 上一篇说金额一律 long 分,比例不是金额是系数,用 DECIMAL(5,4)(精确到万分之一)表达 0.1234 这样的比例。计算时 佣金 = 订单金额 × 比例,为避免 BigDecimal,工程上把比例也整数化:存"万分比"的 long(0.1234 → 1234),佣金(分) = 订单金额(分) × 万分比 / 10000,全部整数运算,舍入规则显式写成一行代码并加注释。资金代码里不允许存在"默认舍入行为"——每一处舍入都要是写出来的决定。

军规六:对账留痕字段在设计期就长在表里。 回看佣金表 DDL,order_no(对账时不用回查订单表)、source(记录这笔资金记录从哪来:下单/冲正/人工调整)、related_id(冲正指向原记录)、create_time/update_time 全量保留。对账的本质是"两本账逐笔比对",任何一笔佣金都能回答"你从哪来、指向谁、什么时候变的"三个问题,比对才能自动化。表设计期少留一个字段,对账系统上线时就多一段人工 Excel——资金表不留痕字段,是给未来的自己埋雷。

最后必须提一句合规:这套表结构解决的是"算得清",但平台不能自己碰资金池做二清——佣金的钱必须由持牌机构(微信支付分账等)完成实际划付,平台系统里只记账不动钱。这个红线第 20 篇用整个篇幅讲,表设计层面先记住一句:平台库里的佣金记录是账本,不是金库。

最后补一个容易被问到的边界场景:退款发生在佣金已汇入结算单之后怎么办? 三个时间窗的处理完全不同:冻结期内退款,佣金直接 20 → 50 回滚,最干净;已结算(30→40)但结算单未打款,佣金转为回滚、结算单金额做负向调整并重算 SUM/COUNT 校验;结算单已打款,则走分账回退(第 21 篇的微信收付通回退接口),平台账上同步生成负向冲正记录,回退失败进入人工追偿队列。三种窗口的共同点是:任何一笔冲正都能追到原记录、能对平资金,这正是冲正模型和留痕字段换来的能力。表结构设计得对,这些棘手场景只是代码分支;设计得错,每一个都是事故。


7. 本篇小结

  1. 资金表 6 条军规:金额 long 分、幂等唯一键、下单事务内锁快照、分片键前置埋点、状态机只前进用冲正、全链路对账留痕;
  2. leader_id 双表冗余是分片键前置埋点:按团长分片后订单/佣金查询都要单片命中,回填千万行的代价远大于第一天多存一列;
  3. 幂等靠数据库唯一键不靠先查再插:biz_serial = order_id:role_type:rule_version,冲突捕获是正常分支,扛住回调重发、任务重跑、消息重复;
  4. 佣金在下单事务内锁定快照:@Transactional 内一次算清落库,支付回调只改状态不算钱,本地事务的原子性是模块化单体送出的核心红利;
  5. 冻结期 + 结算单:frozen_until 是结算触发依据,解冻 UPDATE 带状态条件天然幂等;结算单按团长+周期汇总,SUM/COUNT 双校验;
  6. 资金状态机只前进:撤销用冲正记录表达,历史永远可查;比例用万分比 long,资金代码不允许默认舍入;
  7. 平台库是账本不是金库:实际划付交给持牌机构的分账能力,二清红线不可碰——第 20 篇专文展开。

钱的问题在表结构上立住了规矩,接下来回到"人":一个微信号进来,怎么认出他是买家、帮卖团长、供货团长还是运营?下一篇《微信小程序登录:code2Session 之后还有 5 件事要做》——wx.login 拿到 openid 只是第一步,登录态设计、JWT 过滤器链和账号合并的完整实现都在下一篇。

posted @ 2026-09-07 14:47  15889726201  阅读(9)  评论(0)    收藏  举报