社群团购系统快团团模式开发——数据库设计:佣金、结算表的资金安全设计
先给结论:资金表的 6 条军规
上一篇解决了"货"的表设计,这一篇解决"钱"。订单表设计错了顶多体验差,佣金表设计错了是直接出账——要么多给团长钱,要么少给,两头都是事故。这个系统所有和钱沾边的表设计,收束成 6 条军规,先全部亮出来,再逐条展开:
- 金额一律以「分」为单位存 BIGINT——上一篇已立规矩,本篇只补一个例外场景的取舍;
- 每一笔资金变动都有唯一业务序列号(幂等)——微信回调会重发、定时任务会重跑、消息会重复消费,唯一键是最后防线;
- 佣金在下单事务内锁定快照——结算时绝不重算,按"当时的规则"付钱;
- 分片键
leader_id前置埋点,双表冗余——设计期埋好,不能等拆表时回填; - 资金状态机只前进不回跳——撤销不删记录,用冲正(反向记录)表达;
- 所有资金写操作可对账——来源、关联单号、版本号,留痕字段在设计期就长在表里。
这 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——资金幂等的保险丝
资金链路上有三类"同一件事发生两次"的常态:
- 微信支付回调重发(微信的重试策略是 15s/15s/30s/...,最多重试多次);
- 定时任务重跑(XXL-Job 手动补数、失败重试);
- 未来引入消息队列后的重复消费。
每一种的应对思路都一样:业务代码去重不可靠,数据库唯一键才可靠。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. 本篇小结
- 资金表 6 条军规:金额 long 分、幂等唯一键、下单事务内锁快照、分片键前置埋点、状态机只前进用冲正、全链路对账留痕;
leader_id双表冗余是分片键前置埋点:按团长分片后订单/佣金查询都要单片命中,回填千万行的代价远大于第一天多存一列;- 幂等靠数据库唯一键不靠先查再插:
biz_serial = order_id:role_type:rule_version,冲突捕获是正常分支,扛住回调重发、任务重跑、消息重复; - 佣金在下单事务内锁定快照:
@Transactional内一次算清落库,支付回调只改状态不算钱,本地事务的原子性是模块化单体送出的核心红利; - 冻结期 + 结算单:
frozen_until是结算触发依据,解冻 UPDATE 带状态条件天然幂等;结算单按团长+周期汇总,SUM/COUNT双校验; - 资金状态机只前进:撤销用冲正记录表达,历史永远可查;比例用万分比 long,资金代码不允许默认舍入;
- 平台库是账本不是金库:实际划付交给持牌机构的分账能力,二清红线不可碰——第 20 篇专文展开。
钱的问题在表结构上立住了规矩,接下来回到"人":一个微信号进来,怎么认出他是买家、帮卖团长、供货团长还是运营?下一篇《微信小程序登录:code2Session 之后还有 5 件事要做》——wx.login 拿到 openid 只是第一步,登录态设计、JWT 过滤器链和账号合并的完整实现都在下一篇。

浙公网安备 33010602011771号