做支付相关项目,最难过的往往不是技术关,而是概念关。我见过太多团队把“交易、支付、清结算、账务”当成同一件事来做,结果系统上线之后对账对不平、账目对不上、资金差一分钱查一星期。这四个词其实说的是资金链路上不同层次的事情,分别回答“业务上卖了什么”“资金上钱走到哪了”“谁还欠谁多少钱”“账面上记成什么样”。这篇文章不绕圈子,直接把这套链路的全貌拆开,讲清楚每个环节的职责边界、设计逻辑和落地时需要避开的坑,适合正在做电商后台、支付模块、财务系统或者刚接触清结算体系的朋友参考。
1. 四个概念到底在说什么
1.1 从一次网购说起
你在电商平台下单买了一台手机,价格2999元,用支付宝付款。这个过程中,“交易”在点击“立即购买”那一刻就产生了,它代表买卖双方达成了一个业务契约:买家同意付2999元,卖家同意发货。“支付”是资金动作,平台调用支付宝接口,把用户银行卡里的钱划到平台在支付宝开设的账户里。“清结算”做的是批量梳理:当天平台一共发生了多少笔交易、每笔应该收多少手续费、哪些钱要打给商户、哪些钱要留给自己,汇总之后算出净额再做资金划拨。“账务”则是把这些变化用会计语言记录下来,最终形成资产负债表、利润表里的数字。
用一个生活化的类比很容易理解:交易是谈判,支付是付钱,清算是算账,结算是打款,账务是做账。
五个动作各有各的关注点,不能混在一起。谈判关心的是“交易条件是什么”,付钱关心的是“资金有没有安全到账”,算账关心的是“这一堆交易轧下来谁欠谁”,打款关心的是“钱按什么周期、什么金额划出去”,做账关心的是“每一笔变化能不能追溯”。明白这个分层逻辑,后面的系统设计才有基础。
1.2 每个词对应的系统和职责
| 概念 | 核心问题 | 关注点 | 典型系统或模块 |
|---|---|---|---|
| 交易 | 业务上买卖了什么 | 商品、优惠、订单状态、履约 | 交易订单系统 |
| 支付 | 资金怎么从A到B | 渠道、金额、回调、授权 | 支付核心系统 |
| 清结算 | 一段时间内谁欠谁多少 | 轧差、手续费、结算周期、净额 | 清结算系统 |
| 账务 | 所有资金变化如何记录 | 会计科目、借贷、余额、平衡 | 账务记账系统 |
很多项目失败,不是因为代码写得烂,而是因为边界划分不清楚。比如把订单状态和支付状态放同一个字段,业务上已经退款了,支付流水还挂在“成功”状态;又比如没有独立的清结算层,每一笔订单都直接实时打款给商户,手续费、退款、冻结这些场景一多,资金账立刻乱成一锅粥。
1.3 分不清这四个字,系统会怎样
最典型的问题是业务状态和资金状态互相污染。订单系统把“已支付”当成终态,但一个订单可以被部分退款、部分支付、部分关闭,如果订单状态直接等于支付状态,这些场景全都表达不了。更麻烦的是,运营想统计“今日成交额”,财务想看“今日到账金额”,两套口径如果共用一张表,两边永远吵不完。
拆开之后反而简单:交易系统只管业务订单的状态变化,支付系统只管支付单的成功失败,清结算系统按周期做资金计算,账务系统负责借贷记账。层与层之间靠流水号衔接,互不干扰,任何一层出了问题都能单独排查。拆开不是增加工作量,是给未来减少灾难。
2. 一条订单走完整条链路
2.1 完整流程与状态流转
一次成功的支付,在系统里大概经历这些步骤:
- 用户下单,交易系统创建交易订单,状态为“待支付”。
- 交易系统调用支付系统,生成支付单,状态为“支付中”。
- 支付系统根据支付渠道创建渠道请求,跳转或唤起收银台。
- 用户在渠道侧完成付款,渠道返回成功。
- 渠道异步通知支付系统,支付系统校验金额、商户号、签名后,把支付单更新为“支付成功”。
- 支付系统通知交易系统,交易订单状态更新为“已支付”,进入发货流程。
- 每日日切后,渠道提供对账单文件,支付系统拉取并与本地流水逐笔比对。
- 清结算系统根据已核对成功的交易,按结算周期生成结算单,计算手续费和净额。
- 资金从支付渠道账户划到平台账户,再从平台账户分账给各商户。
- 账务系统把以上资金变化全部登记为会计流水,保证日结平衡。
这条链路里最核心的两个设计原则:所有状态变化必须在数据库留痕;层与层之间必须用唯一流水号关联。任何一笔钱出了问题,只要顺着流水号往回查,就能定位到是在哪个环节断掉的。
2.2 为什么状态机设计是命根子
交易订单和支付单都不能随便改状态。一个合理的状态机,必须明确每个状态允许从哪些状态流转过来、允许流转到哪些状态去。拿支付单来说,常见的状态集合是:
| 状态 | 含义 | 允许流转到 |
|---|---|---|
| CREATE | 已创建,待支付 | PAYING、CLOSED |
| PAYING | 支付中,已发起渠道请求 | SUCCESS、FAILED、CLOSED |
| SUCCESS | 支付成功 | REFUNDING、PARTIAL_REFUNDED、REFUNDED |
| FAILED | 支付失败 | CLOSED、PAYING(重试时重建) |
| CLOSED | 已关闭 | 无 |
| REFUNDED | 已全额退款 | 无 |
很多人觉得状态机是理论,实际开发时图省事,直接“成功就置1,失败就置0”,后面一旦出现重复回调、退款冲正、部分支付,代码就会炸。状态机不是用来好看的,它是用来保证资金数据不出错的底线。
2.3 幂等、回调与对账:链路的延长线
支付做完不代表流程走完,后面还挂着三件大事。
幂等是指同一个支付请求被重复提交时,系统只处理一次。渠道回调不是只推一次,网络抖动、渠道重试都可能导致同一个成功结果推好几次。如果处理回调的代码没有幂等控制,用户支付一次,系统可能给订单加两次“已支付”标记,资金流水也会重复入账。常用的做法是:在支付流水表上建唯一索引,以“支付单号+渠道流水号”作为唯一键,重复通知直接丢弃。
回调天然不可靠。渠道说“我回调了”,但你的服务可能刚好重启、网络恰好超时,回调就丢了。所以系统必须设计主动查询补偿:每天跑补单任务,把支付中状态的支付单捞出来,调用渠道查单接口确认最终状态,然后更新本地数据。这个任务我建议每5到10分钟跑一次,既能及时修复回调丢失,又不至于给渠道造成压力。
对账是最后一道保险。日终从渠道拉取对账单文件,逐笔和本地支付流水比对,差异分成三类:本地有、渠道没有的叫“长款”;本地没有、渠道有的叫“短款”;两边都有但金额不一致的叫“金额不符”。这三类问题各有各的处理办法,但前提是日终文件拿得到、本地流水完整,所以从第一天设计开始就要重视流水留痕。
3. 核心细节与设计决策
3.1 金额精度:分成最小单位依然不够
金额处理是支付系统最容易出问题的地方,尤其是精度。浮点数在计算0.1+0.2的时候会变成0.30000000000000004,绝不能用来做资金计算。推荐的存储方式是以“分”为最小单位存整数,比如金额100元存成10000分;更严谨一点,有些计费场景要按“厘”存,避免四舍五入累积误差。
但存整数还不够,还要保证每一笔金额都有来源。用户买三件商品,每件都有折扣,还有满减、运费、税,最后支付总价是53.8元。如果你只存一个53.8元,退款的时候就会出现大问题:到底退哪件商品的钱?所以下单时就要把商品金额、优惠分摊、运费、税分开登记,每行都有独立流水,支付总价等于所有明细金额之和。我建议在核心表里同时保留“订单原金额”“支付金额”“优惠金额”等多个字段,并且定期跑校验脚本,保证“原金额 - 优惠金额 = 支付金额”恒成立。
3.2 订单、支付单、渠道流水的拆分
业务上一个订单可能被拆成多次支付,比如先付定金、再付尾款;也可能一次支付覆盖多个订单,比如购物车合并付款。如果只用一张表承接这些状态,一定会乱。
正确做法是分成三层:
- 订单:表达业务契约,一个订单可以有多个商品明细,也可以被拆成多期支付。
- 支付单:表达一次支付行为,一个支付单对应一次用户付款动作,可能关联一个或多个订单。
- 渠道流水:表达支付渠道侧的一次成功扣款记录,一个支付单可能有多次渠道尝试,最终只有一笔成功。
下单的时候创建订单,用户发起付款时生成支付单,支付完成后写入渠道流水。三者之间通过关联字段串起来。这样的好处是:任何一层出现异常,都可以只影响自己这一层,不需要动其他层的数据。
多商户场景还要再加一层“分账”。比如平台上有多个商家卖货,用户一次付款100元给平台,实际要分给商家A 80元、商家B 15元、平台抽佣5元。此时支付单只有一笔,但清结算层要拆出多个结算方。分账的时机、手续费怎么摊、退款时怎么原路退回,都是在这个层面解决的。
3.3 清结算怎么设计与计算
清结算的核心不是“打钱”,而是“算账”。每天产生大量交易,如果每笔都实时、逐笔打款给商户,手续费率不同、退款不断发生、头寸不可控,资金流会彻底失控。所以行业通用的做法是按周期、按净额结算。
举个例子:某商户一天产生三笔交易,金额分别是100元、200元、50元,平台手续费率1%,当天有一笔20元的退款,退款的手续费原路退回。
| 项目 | 金额(元) | 手续费(元) |
|---|---|---|
| 交易1 | 100 | 1.00 |
| 交易2 | 200 | 2.00 |
| 交易3 | 50 | 0.50 |
| 退款 | -20 | -0.20 |
| 汇总 | 330 | 3.30 |
当天商户的结算金额 = 交易总额330元 - 手续费总额3.30元 = 326.70元。清结算系统要做的就是把这些明细汇总成一张结算单,算清每一笔交易的应结金额,再把多笔交易净额合并成一次打款。按T+1结算为例,就是第二天资金到账。
清结算必须注意“日切”时点,通常以支付渠道或清算机构的时间为准。比如渠道的日切点是凌晨0点,那么晚上11点59分59秒发生的交易和凌晨0点00分01秒发生的交易,会落在不同的对账日,结算日期也会不同。很多人对账不平,就是没搞明白日切规则。
3.4 账户体系与复式记账
账务层解决的是“这钱该怎么记”的问题。支付流水可以说明“钱从用户那里到了平台”,但财务上需要的是借贷平衡、科目清晰。
比如用户通过支付宝付款100元购买商品,在账务上应记:
| 科目 | 借方 | 贷方 |
|---|---|---|
| 第三方支付账户-支付宝 | 100 | |
| 主营业务收入 | 100 |
如果是平台抽佣模式,用户付款后还要分账给商户,账务上就变成:
| 科目 | 借方 | 贷方 |
|---|---|---|
| 第三方支付账户-支付宝 | 100 | |
| 主营业务收入 | 5 | |
| 应付商户款 | 95 |
这里的核心是“每一笔账都必须有借方和贷方,两边永远相等”。复式记账保证了资金流动的闭环,不会出现钱凭空多出来或者少掉的情况。账务系统要有独立的账户表和流水表,账户按科目分类,流水每笔都要有借贷标志、业务流水号、关联单据号,方便追溯。
4. 实操过程与落地实现
4.1 表结构怎么设计(可直接复制参考)
以下是一套可以落地的核心表结构,覆盖交易、支付、渠道流水、清结算、账务五层,字段做了精简,生产环境可以基于这个扩展。
交易订单表:
CREATE TABLE `trade_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '业务订单号', `user_id` bigint NOT NULL COMMENT '用户ID', `merchant_id` bigint NOT NULL COMMENT '商户ID', `total_amount` bigint NOT NULL COMMENT '商品总额,单位分', `discount_amount` bigint NOT NULL DEFAULT 0 COMMENT '优惠金额,单位分', `pay_amount` bigint NOT NULL COMMENT '应付金额,单位分', `status` tinyint NOT NULL COMMENT '订单状态:0待支付 1已支付 2已发货 3已完成 4已关闭 5已退款', `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易订单表';支付单表:
CREATE TABLE `payment_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `payment_no` varchar(64) NOT NULL COMMENT '支付单号', `order_no` varchar(64) NOT NULL COMMENT '业务订单号', `channel` varchar(32) NOT NULL COMMENT '支付渠道:alipay/wechat/unionpay', `pay_amount` bigint NOT NULL COMMENT '支付金额,单位分', `status` tinyint NOT NULL COMMENT '支付单状态:0创建 1支付中 2成功 3失败 4关闭 5部分退款 6全额退款', `channel_payment_no` varchar(128) DEFAULT NULL COMMENT '渠道支付单号', `notify_time` datetime DEFAULT NULL COMMENT '渠道回调时间', `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_payment_no` (`payment_no`), UNIQUE KEY `uk_channel_no` (`channel_payment_no`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付单表';渠道流水表:
CREATE TABLE `channel_transaction` ( `id` bigint NOT NULL AUTO_INCREMENT, `channel_txn_id` varchar(128) NOT NULL COMMENT '渠道交易流水号', `payment_no` varchar(64) NOT NULL COMMENT '支付单号', `channel` varchar(32) NOT NULL COMMENT '渠道', `amount` bigint NOT NULL COMMENT '金额,单位分', `txn_type` tinyint NOT NULL COMMENT '流水类型:1支付 2退款', `txn_time` datetime NOT NULL COMMENT '渠道交易时间', `status` tinyint NOT NULL DEFAULT 0 COMMENT '对账状态:0未对账 1已对账 2差异', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_channel_txn` (`channel_txn_id`), KEY `idx_payment_no` (`payment_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道流水表';结算单表:
CREATE TABLE `settlement_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `settlement_no` varchar(64) NOT NULL COMMENT '结算单号', `merchant_id` bigint NOT NULL COMMENT '商户ID', `channel` varchar(32) NOT NULL COMMENT '渠道', `trade_date` date NOT NULL COMMENT '交易日(以日切规则为准)', `total_amount` bigint NOT NULL COMMENT '交易总金额,单位分', `fee_amount` bigint NOT NULL COMMENT '手续费总金额,单位分', `refund_amount` bigint NOT NULL DEFAULT 0 COMMENT '退款金额,单位分', `net_amount` bigint NOT NULL COMMENT '净结算金额,单位分', `status` tinyint NOT NULL DEFAULT 0 COMMENT '结算状态:0待确认 1待打款 2已打款 3已确认', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_settlement` (`merchant_id`, `trade_date`, `channel`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算单表';账务流水表:
CREATE TABLE `account_flow` ( `id` bigint NOT NULL AUTO_INCREMENT, `flow_no` varchar(64) NOT NULL COMMENT '账务流水号', `account_no` varchar(64) NOT NULL COMMENT '账户号', `biz_type` varchar(32) NOT NULL COMMENT '业务类型:PAY/REFUND/FEE/SETTLE', `biz_no` varchar(64) NOT NULL COMMENT '关联业务单号', `amount` bigint NOT NULL COMMENT '金额,单位分', `balance` bigint NOT NULL COMMENT '交易后余额,单位分', `dc_flag` tinyint NOT NULL COMMENT '借贷方向:1借 2贷', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_flow_no` (`flow_no`), KEY `idx_account_no` (`account_no`), KEY `idx_biz_no` (`biz_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账务流水表';这套表结构有几个关键设计意图:唯一索引保证幂等;乐观锁版本号防止并发更新覆盖;每张表都保留关联业务单号,方便从任意一端反查链路;账务流水表记录交易后余额,是为了快速核对账户余额是否正确。
4.2 核心代码参考(支付回调处理逻辑)
回调处理是支付系统里最核心的入口,要同时处理幂等、金额校验、状态流转三个问题。下面用Python风格写一个伪代码示例,展示核心思路:
def handle_channel_notify(payment_no, channel_txn_id, pay_amount, channel): # 1. 幂等控制:用支付单号+渠道流水号做唯一约束 if channel_transaction_exists(channel_txn_id): return success("duplicate notify, ignored") # 2. 查询支付单并锁定 payment = get_payment_order_by_no(payment_no) if payment is None: raise Exception("payment order not found") # 3. 金额校验:回调金额必须与支付单金额一致 if pay_amount != payment.pay_amount: mark_diff(payment_no, channel_txn_id) raise Exception("amount mismatch") # 4. 状态机校验:只有支付中状态才能置为成功 if payment.status != PAYING: return success("status not PAYING, ignored") # 5. 更新支付单状态 + 写入渠道流水(同一事务) with transaction(): update_payment_status(payment_no, SUCCESS) insert_channel_transaction(channel_txn_id, payment_no, pay_amount) # 6. 通知交易系统,更新业务订单状态 notify_trade_service(payment_no) return success("ok")这里的第3步非常关键。有些攻击者会伪造回调通知,如果你不做金额校验,就可能导致订单金额和实际支付金额不一致却标记为“已支付”。第5步必须放在同一个数据库事务里,确保支付单状态和渠道流水同时变化,否则一旦中间崩了,两边数据就错位了。
日终对账的伪代码也不复杂:
def daily_reconcile(trade_date, channel): # 1. 拉取渠道对账单文件并解析 channel_file = fetch_channel_bill(trade_date, channel) channel_map = {item.txn_id: item for item in channel_file} # 2. 拉取本地流水 local_txns = get_local_transactions(trade_date, channel) # 3. 逐笔比对 for txn in local_txns: if txn.channel_txn_id not in channel_map: mark_long_amount(txn) # 本地有、渠道没有 elif channel_map[txn.channel_txn_id].amount != txn.amount: mark_amount_diff(txn) # 金额不符 else: mark_verified(txn) # 对账通过 # 4. 检查渠道侧有、本地没有的流水 local_ids = {txn.channel_txn_id for txn in local_txns} for txn_id, item in channel_map.items(): if txn_id not in local_ids: mark_short_amount(item) # 渠道有、本地没有 # 5. 生成对账差异报表 generate_reconcile_report(trade_date, channel)对账任务跑完之后,不等于结束,还要人工或异步处理差异记录。短款要先查支付单,确认渠道侧是否真的扣款成功;长款要看本地是否已经退款或关闭。这些都是资金安全的最后防线。
4.3 定时任务怎么编排
支付系统的定时任务,本质上是把“不靠谱的异步回调”和“必须准时的日终逻辑”用后台任务兜底。我的建议编排顺序:
- 每天凌晨2点:拉取渠道对账单文件。选择凌晨是因为渠道文件通常需要一段时间才生成完整,2点之后基本稳定。
- 凌晨2点30分:跑对账比对任务,生成差异报表。如果拉取文件失败,要支持自动重试,最多重试3次,同时发送告警。
- 凌晨3点:跑短款核实任务,将“渠道侧扣款成功但本地无流水”的数据捞出来,发送给运维人员处理。
- 早上7点:跑结算任务,把已对账完成的交易按商户汇总,生成结算单。
- 早上8点前:跑账务入账任务,将结算数据和手续费数据导入账务系统,生成会计凭证。
- 全天每5分钟:跑回调补单任务,把支付中状态的支付单主动查渠道。
这个编排顺序的核心原则是:对账必须先于结算,结算必须先于账务入账。如果渠道文件还没拿全就先结算了,后面对出差异,资金就要追回来,非常麻烦。
5. 常见问题与排查技巧实录
5.1 用户反馈“钱扣了,订单却显示未支付”
这个是最常见的支付问题,原因基本是渠道回调没送达或者回调处理失败。正确排查顺序是:先看支付单状态,再看渠道流水,最后看交易订单。如果支付单还是“支付中”,说明回调确实没到或没处理成功;如果支付单已经是“成功”但订单没更新,说明支付系统到交易系统的通知环节出了问题。
碰到这种问题,补单任务是最好的兜底。我看到不少团队把补单任务设置成每1分钟跑一次,结果渠道接口被频繁调用触发限流。建议5分钟左右,结合业务容忍度来定。补单操作本身要幂等,重复执行不能产生副作用。
5.2 用户重复支付/重复扫码
用户扫了两次码、点了两次确认按钮,系统生成了两笔支付单,其中一笔成功一笔失败,或者两笔都成功。根因多半是前端没有做“支付中锁定”处理,后端也没有限制“同一订单只能有一笔支付中状态的支付单”。
解决思路有两层:前端支付按钮点击后立即置灰,二维码过期就关闭;后端在创建支付单时,先检查该订单是否存在“支付中”状态的支付单,有则直接返回旧的支付单号,不生成新单。如果已经出现两笔成功支付,原则是以“业务上只能收一笔”为准,另一笔原路退回,不要让用户去找客服人工退款。
5.3 对账差一分钱,怎么查
这是清结算里最让人头秃的问题。差一分钱往往不是真的只差一分,而是某个环节的金额处理方式不一致。排查顺序我总结成一个表格:
| 排查步骤 | 具体动作 |
|---|---|
| 1. 核对日切时点 | 确认本地“交易日”和渠道日切是否一致,尤其关注23:59到00:01的交易 |
| 2. 检查退款 | 退款是否有原路退回手续费,退款流水是否计入了对账范围 |
| 3. 检查优惠和补贴 | 平台补贴是否被错误计算为商户收入,优惠金额是否直接冲减了支付金额 |
| 4. 检查部分支付 | 一个订单被拆成多次支付时,每笔支付是否都独立对账 |
| 5. 打印差异明细 | 把差异流水逐笔打出来,人工核对渠道侧和本地的具体金额 |
| 6. 总账平衡校验 | 用账务系统的借贷平衡来判断是账务层还是支付层出错 |
经验教训是:不要试图靠“肉眼找补”,先把差异流水完整导出来,再按订单号维度分组,通常很快能定位是哪一种异常类型。
5.4 结算金额和预期不符
商户反馈“为什么我后台看到的交易金额和外结算金额差很多”。这类问题通常是手续费、退款、冻结期三者叠加导致的。商户看到的是订单维度的流水汇总,结算单是按净额维度计算的,两者天然存在差异,系统必须在结算单里体现明细。
解决的办法是结算单要支持“穿透”:从结算单能点进去看到每一笔原始交易、每一笔手续费、每一笔退款。同时提供“待结算金额”“已结算金额”“退款预留金额”三个维度,让商户或者运营人员看得明明白白。还有一个容易被忽略的点:结算状态不能和支付状态混用。支付成功是资金到了平台,结算成功是资金从平台到了商户,两者时间差可能就是T+N的结算周期,不能把结算成功当成交易完成的标志。
5.5 多商户、四方/聚合场景下的特殊点
如果你做的是聚合支付或第四方模式,清结算的不确定性会放大。一个平台收钱,要分给多个商户,每个商户还可能涉及不同的分账比例、结算周期、提现规则。这时候在清结算层必须增加“分账明细”,每一笔支付的资金要明确拆分成“平台收入”“商户A应收”“商户B应收”,分账完成后才能做结算。
C2C场景下还有担保交易,资金要先进入冻结状态,物流确认收货后才能解冻并结算给卖家。冻结、解冻、超时自动解冻、纠纷退款,这些动作都要在账户体系上留痕,否则资金状态根本说不清楚。我的建议是:做这类业务之前,先把资金状态图画出来,列清楚“谁的钱、从哪里来、现在冻结在哪里、什么时候打给谁”,画不清楚就不动工。
5.6 平账小技巧
最后分享几个我自己踩坑总结出来的小技巧。每天跑一个“支付流水双边平衡校验”脚本,把支付流水、渠道流水、账务流水的总额放在一起比对,任何一方不平立刻告警,不要等月底财务来报账。数据库里所有金额字段都尽量用整数,避免浮点误差,展示层再转成元。任何手工修改数据的行为都必须留操作日志,最好直接禁止手工改流水表,只允许通过“冲正”“调账”这类正规操作来做。
处理资金类问题还有一个原则:先锁定数据再处理,不锁数据就改,两个并发任务会互相覆盖。乐观锁版本号的字段不是摆设,更新时一定要带上version条件,更新失败就说明有并发冲突,重新查询再处理,远比直接无条件更新安全。
我把这套链路在真实项目里反复摸过几遍之后,最大的体会是:交易、支付、清结算、账务这四层的边界越清晰,系统就越抗风险。不要指望一张大表一个状态机解决所有问题,合理的分层加上严谨的状态流转,再加上每天坚持跑对账,才是支付系统稳定运行的本质。刚开始做的人,建议先花半天时间把“一条订单从下单到财务记账”的完整流程画出来,再动手写代码,这个顺序对了,项目已经成功一半。