一、为什么分润系统的账总是对不上

做转卡码平台的朋友应该都有过这种经历:月底一算账,佣金总和跟账户余额差几十块;代理说"我明明有 800 佣金,怎么只能提 600";退款订单的佣金没冲正,钱就这么"凭空消失"了。问题几乎都出在同一个地方——把分润流水当普通业务表用,余额直接 UPDATE、流水可删可改、入账没有幂等约束。钱的事情,必须用账务系统的思路来设计。

本文分享一套生产环境验证过的三层账本数据库设计:账户余额表(账)、流水明细表(证)、结算批次表(单)。核心原则就三条:余额只由流水驱动、流水只增不改、入账必须幂等,再配合日切快照与自动对账,让每一分佣金都有据可查。这套设计已内置在商城在售的转卡码系统 V3 的代理分润模块中,下面把完整建表与代码分享出来。

二、三层账本:账户、流水、批次

先看三张表的分工:

  • agent_account(账户余额表):每个代理一行,记录可用余额与冻结余额,是唯一允许"改"的表;
  • account_flow(流水明细表):每一笔入账、扣款、冻结、解冻都记一行,append-only,只增不改不删;
  • settle_batch(结算批次表):提现打款按批次处理,批次与流水、打款单关联,方便对账与失败重试。

流水是"因",余额是"果"。任何时候余额对不上,只要重放流水就能算出正确余额——这是账务系统最核心的故障恢复能力。

三、账户余额表:乐观锁 + 冻结余额

-- 账户余额表:version 乐观锁,防止并发扣款互相覆盖
CREATE TABLE `agent_account` (
  `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `agent_id` INT UNSIGNED NOT NULL COMMENT '代理ID',
  `balance` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '可用余额',
  `frozen_balance` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '冻结余额(售后期)',
  `version` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY `uk_agent` (`agent_id`)
) ENGINE=InnoDB COMMENT='代理账户余额表';

金额一律用 DECIMAL(10,2),禁止 FLOAT。更新余额必须带 version 条件,否则并发提现时后提交的事务会覆盖先提交的,出现"超扣"。冻结余额单独一列,退款冲正时先从冻结里扣,保证售后期内代理无法提走可能被追回的钱。

四、流水明细表:不可变账本与幂等

-- 流水表:biz_no 全局唯一,天然幂等;只 INSERT,绝不 UPDATE/DELETE
CREATE TABLE `account_flow` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `agent_id` INT UNSIGNED NOT NULL COMMENT '代理ID',
  `biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号:订单号/批次号/提现单号',
  `flow_type` TINYINT NOT NULL COMMENT '1入账 2扣款 3冻结 4解冻 5冲正',
  `amount` DECIMAL(10,2) NOT NULL COMMENT '变动金额(恒为正)',
  `balance_after` DECIMAL(10,2) NOT NULL COMMENT '变动后余额快照',
  `remark` VARCHAR(255) DEFAULT '' COMMENT '关联订单/原因',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY `uk_biz_type` (`biz_no`, `flow_type`),
  KEY `idx_agent_time` (`agent_id`, `created_at`)
) ENGINE=InnoDB COMMENT='账户流水明细表';

uk_biz_type 是幂等的关键:同一订单的入账流水重复插入会直接报唯一键冲突,代码里"捕获冲突当作成功"即可。即使支付回调重试一百次,也不会重复入账。balance_after 快照则是审计和排障的利器。

五、入账与扣款:一个事务搞定

# 入账:先写流水,再更新余额,同一事务;biz_no 冲突即幂等成功
def credit(agent_id, biz_no, amount, remark=''):
    try:
        with db.transaction():
            db.execute("""
                INSERT INTO account_flow
                  (agent_id, biz_no, flow_type, amount, balance_after, remark)
                SELECT %s, %s, 1, %s, balance + %s, %s
                FROM agent_account WHERE agent_id = %s
            """, (agent_id, biz_no, amount, amount, remark, agent_id))
            db.execute("""
                UPDATE agent_account
                SET balance = balance + %s, version = version + 1
                WHERE agent_id = %s
            """, (amount, agent_id))
    except IntegrityError:
        # 唯一键冲突 = 该业务单已入过账,直接返回,保证幂等
        return False
    return True

扣款同理,只是 UPDATE 时额外加 AND balance >= %s 条件,余额不足则影响行数为 0,直接判失败——避免"先查余额再扣款"的竞态窗口。

六、日切快照与自动对账

每天 0 点生成账户快照表 account_snapshot(agent_id, balance, frozen_balance, snapshot_date)。对账脚本把"昨日快照 + 今日流水"重放出来的余额与当前余额比对:

-- 对账:找出余额不一致的账户(差异即异常)
SELECT a.agent_id, a.balance AS cur_balance, s.balance AS expect_balance
FROM agent_account a
JOIN account_snapshot s ON a.agent_id = s.agent_id
  AND s.snapshot_date = CURDATE() - INTERVAL 1 DAY
WHERE a.balance <> s.balance + (
  SELECT COALESCE(SUM(CASE WHEN flow_type IN (1,4) THEN amount
                           WHEN flow_type IN (2,3,5) THEN -amount END), 0)
  FROM account_flow f
  WHERE f.agent_id = a.agent_id AND f.created_at >= CURDATE() - INTERVAL 1 DAY
);

有差异就告警人工复核。别小看这条 SQL,它能挡住 99% 的"钱去哪了"投诉。批次表再按日汇总打款金额与流水轧差,双保险。

七、千万级流水:按月分表与归档

流水表只增不改,最适合分表。按 created_at 做月分表 account_flow_202608,查询强制带月份条件路由到对应分表;超过 12 个月的流水归档到冷表或对象存储,热表只保留近期数据,索引体积小、写入快。注意分表后幂等唯一键要升级为 (biz_no, flow_type, 月份),或引入全局发号器保证 biz_no 跨表唯一。

八、小结

把分润系统当账务系统设计,本质是三个坚持:余额只由流水驱动、流水只增不改、入账必须幂等。做到这三点,对账就是一条 SQL 的事,而不是一个通宵。如果你不想从零造轮子,商城在售的转卡码系统 V3 已内置完整的代理分润与账务模块(多级分润、冻结解冻、批次打款、日终对账),开箱即用;再搭配 Codex Desktop,还能让 AI 帮你写对账脚本、查异常流水,效率翻倍。