引言
在支付系统中,业务系统与第三方渠道(银行、支付机构)之间,由于清算时差、网络延迟、系统故障等原因,不可避免地会产生数据差异。一笔交易在我方记录为成功,渠道侧可能还没入账;一笔扣款渠道已经完成,我方却因为回调丢失而毫不知情。
这些差异如果不能被及时发现和处理,轻则账务混乱,重则造成资金损失。
本文从一笔交易完成后的视角出发,沿着资金流转的方向,完整梳理 对账 → 差异处理 → 平账 → 账单重算 → 结算出款 → 分润 的全链路设计方案。每个环节既是上一步的消费者,也是下一步的前置条件,环环相扣。
全链路概览
在进入细节之前,先建立整体认知。一笔交易从完成到最终分润出款,经过以下阶段:
交易完成
↓
第一阶段:对账(发现差异)
将本方交易记录与渠道清算文件逐笔匹配,识别出一致、待确认和差异三种状态。
↓
第二阶段:差异处理与平账(消化差异)
对发现的差异进行分类、确认、审批,通过补单/调账/冲正/核销等会计动作将差异合理消化。
↓
第三阶段:账单重算(修正下游数据)
平账改变了底层交易数据,依赖这些数据的账单、报表等聚合结果需要相应刷新。
↓
第四阶段:结算出款(资金流出)
只有对账通过的交易才有资格参与结算,按商户配置的结算周期(日结/周结/月结)归集并出款。
↓
第五阶段:分润(利益分配)
结算完成后,按协议比例计算并分配给商户、代理商等各方。
下面逐一展开。
一、对账:差异的发现
对账是整条链路的起点。它的职责是把本方的交易记录和渠道的清算文件逐笔比对,找出"谁和谁对不上"。
1.1 跨天/跨月订单的核心矛盾
对账最大的难点不是匹配逻辑本身,而是 时间错位。业务系统和渠道对同一笔交易的入账时间点经常不一致:
- 23:59:50 发起支付,业务系统记录为 T 日,渠道清算落到 T+1 日
- 月末最后一天的交易,业务侧算当月,渠道侧算次月
- 渠道批量清算延迟,部分交易"漂移"到下一个对账周期
如果按"创建时间 = 对账日"做精确匹配,这些边界交易必然成为差异,但它们并不是真正的异常。
1.2 滑动窗口:给时间差留出缓冲
解决时间错位的核心策略是 滑动窗口 — 对账时不精确卡日期,而是向前后各扩展一段缓冲期:
对账窗口 = [T日 00:00:00 - buffer, T+1日 00:00:00 + buffer]
buffer 通常设为 30 分钟至 2 小时,视渠道清算延迟特征而定。窗口边界内的订单全部纳入匹配范围,避免因几分钟的时差制造大量"伪差异"。
1.3 三态对账模型
每笔订单的对账结果不应只有"对上"和"没对上"两种,而是三种状态:
public enum ReconStatus {
MATCHED, // 已匹配:双方金额、状态一致,对账完成
PENDING, // 待确认:仅单边存在,可能是时间差导致,等待下一周期自动补对
UNMATCHED // 不匹配:双方都有记录但存在实质性差异(金额、状态不符),需人工介入
}
其中 PENDING 是正常中间态,不是异常。跨天订单第一次对账时大概率是 PENDING,留给下一次窗口自动补齐即可。只有超过容忍天数仍无法匹配的,才升级为 UNMATCHED 进入差异处理流程。
1.4 自动补对机制
自动补对的核心思路是"不急着判死刑":每次对账不仅处理当日数据,还会把前 N 天处于 PENDING 状态的订单重新拿出来匹配。
public void dailyReconcile(LocalDate bizDate) {
// 1. 拉取本日新交易 + 前N天仍为 PENDING 的订单
List<Order> pendingOrders = orderRepo.findPending(bizDate.minusDays(RETRY_WINDOW_DAYS));
// 2. 拉取渠道对账文件(通常覆盖 T-1 ~ T 的清算数据)
List<ChannelRecord> channelRecords = channelService.download(bizDate);
// 3. 执行匹配
ReconcileResult result = matcher.match(pendingOrders, channelRecords);
// 4. 之前 PENDING 的订单如果本次匹配上了,自动更新为 MATCHED
result.getNewlyMatched().forEach(order -> {
order.setReconStatus(ReconStatus.MATCHED);
order.setReconDate(bizDate);
});
// 5. 超过最大重试天数仍未匹配的,升级为 UNMATCHED
result.getExpiredPending().forEach(order -> {
order.setReconStatus(ReconStatus.UNMATCHED);
alertService.notify(order);
});
}
RETRY_WINDOW_DAYS 一般设为 3 天。超过才告警,绝大多数跨天订单在 T+1 就自动补对了。
1.5 双向对账
对账必须是双向的 — 不仅要检查"我方有的渠道有没有",还要检查"渠道有的我方有没有":
| 场景 | 含义 | 处理策略 |
|---|---|---|
| 我方有,渠道无 | 长款(本方多记) | 标记 PENDING,等下一周期渠道文件补上 |
| 渠道有,我方无 | 短款(本方漏记) | 反查支付回调日志,若能确认则自动补单 |
| 双方都有但金额不符 | 金额差异 | 直接标记 UNMATCHED,不自动处理 |
| 双方都有但状态不符 | 状态差异 | 直接标记 UNMATCHED,优先级最高 |
1.6 跨月订单的归属
月度对账时,月边界订单的归属月份采用以下判定规则:
public YearMonth resolveSettleMonth(Order order) {
// 优先按渠道清算时间归月,而非业务创建时间
if (order.getChannelSettleTime() != null) {
return YearMonth.from(order.getChannelSettleTime());
}
// 渠道还没给清算时间 → 先挂 PENDING,不强行归月
return null;
}
这样 PENDING 的订单在下月初对账时自然落入正确的月份,月报不会因边界订单反复调整。
1.7 对账数据独立存储
对账结果单独建表,不污染业务订单表:
CREATE TABLE recon_record (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64), -- 关联业务订单号
channel_flow_no VARCHAR(64), -- 渠道流水号
biz_date DATE, -- 业务日期
recon_date DATE, -- 实际对平日期(PENDING 时为空)
recon_status ENUM(
'MATCHED', -- 已匹配
'PENDING', -- 待确认
'UNMATCHED' -- 不匹配
),
channel_amount DECIMAL(18,2), -- 渠道记录金额
local_amount DECIMAL(18,2), -- 本方记录金额
diff_amount DECIMAL(18,2), -- 差异金额
retry_count INT DEFAULT 0, -- 已重试补对次数
max_retry INT DEFAULT 3, -- 最大重试次数
created_at DATETIME,
updated_at DATETIME,
UNIQUE INDEX uk_order_channel (order_no, channel_flow_no)
);
用 order_no + channel_flow_no 做唯一约束,保证同一笔订单在多个对账周期中被拉取时匹配逻辑幂等。
1.8 告警分级
不同程度的未匹配采用不同的响应策略:
- PENDING 1 天:正常,不告警
- PENDING 2~3 天:低优先级通知
- PENDING 超过 3 天或存在金额差异:高优先级告警,进入人工处理队列
对账任务建议安排在凌晨 3:005:00 执行,因为很多渠道的清算文件在 1:003:00 才生成完毕。
二、差异处理:月度闭环与线下对齐
对账产出了差异列表,下一步是确认和处理这些差异。
2.1 当月闭环原则
月度差异必须当月处理完毕。会计期间具有封闭性,月末结账后当月账务数据"锁死"。如果差异拖到下月,要么做跨月调账(审计不接受),要么重新开账期(风险更大)。
标准节奏如下:
| 时间 | 动作 |
|---|---|
| T+1 ~ T+3(月初前 3 个工作日) | 跑完上月全量对账,产出差异报告 |
| T+3 ~ T+5 | 业务/财务/渠道三方线下对齐差异项 |
| T+5 ~ T+7 | 确认处理方案,执行平账 |
| T+7 之前 | 关账 |
如果财务关账日较紧(如 T+3),对账任务要提前到月末最后两天跑"预对账",把能提前发现的问题先处理掉。
2.2 系统与人工的分工
月度差异不应在系统里自动修正。金额类异常没有业务确认就自动改,出了问题没人兜底。正确的做法是 系统负责发现和呈现,人来决策和确认:
系统自动完成:
- 产出差异明细表(订单号、我方金额、渠道金额、差额、差异类型)
- 按差异类型分类汇总(长款多少笔/多少钱,短款多少笔/多少钱)
- 自动关联上下游单据(支付单、退款单、渠道流水号),方便排查
线下协同完成:
- 拿着差异表找渠道确认(邮件或企微群发 Excel)
- 渠道回复确认后,在系统里走审批流做差异处理
- 每一笔处理都有操作人、审批人、处理原因,留审计痕迹
2.3 常见差异类型与处置
| 差异类型 | 典型原因 | 处理方式 |
|---|---|---|
| 我方有渠道无(长款) | 渠道漏记、清算延迟 | 渠道确认后补录;或确认是未通知的退款,我方做冲正 |
| 渠道有我方无(短款) | 回调丢失、系统故障 | 查回调日志补单,补不了的挂「应收差异」科目 |
| 金额不一致 | 手续费扣减规则差异、汇率 | 最常见的"伪差异",确认规则后系统加入自动抵扣逻辑 |
| 状态不一致 | 超时关单但渠道实际成功 | 最危险,需确认资金到账后给用户补发权益或退款 |
三、平账:差异的消化
差异经过确认后,需要通过平账操作将其合理消化。平账不是"把差异抹掉",而是 用一笔有据可查的会计动作,让账务重新归于平衡。
3.1 数据模型
平账涉及两张核心表:差异记录表(对账的产出)和平账操作表(处理的记录)。
差异记录表:
CREATE TABLE recon_diff (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
diff_no VARCHAR(64) UNIQUE, -- 差异单号,全局唯一标识
order_no VARCHAR(64), -- 关联业务订单号
channel_flow_no VARCHAR(64), -- 渠道流水号
diff_type ENUM(
'LONG_FUND', -- 长款:我方有记录但渠道无记录,即本方多记
'SHORT_FUND', -- 短款:渠道有记录但我方无记录,即本方漏记
'AMOUNT_MISMATCH', -- 金额不一致:双方都有记录但金额不符
'STATUS_MISMATCH' -- 状态不一致:双方都有记录但交易状态不符
),
local_amount DECIMAL(18,2), -- 本方记录金额
channel_amount DECIMAL(18,2), -- 渠道记录金额
diff_amount DECIMAL(18,2), -- 差异金额(channel_amount - local_amount)
biz_date DATE, -- 交易业务日期
status ENUM(
'PENDING', -- 待处理:对账产出后的初始状态
'PROCESSING', -- 处理中:已被运营/财务认领
'SETTLED', -- 已平账:差异已通过平账操作消化
'CANCELLED' -- 已取消:经核实为误报,无需处理
) DEFAULT 'PENDING',
created_at DATETIME,
updated_at DATETIME,
INDEX idx_status_biz_date (status, biz_date),
INDEX idx_order_no (order_no)
);
平账操作表:
CREATE TABLE settle_action (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
action_no VARCHAR(64) UNIQUE, -- 平账单号,全局唯一标识
diff_no VARCHAR(64), -- 关联差异单号
action_type ENUM(
'SUPPLEMENT', -- 补单:为回调丢失等原因漏记的交易补录记录
'ADJUST', -- 调账:修正已有交易的金额,按实际到账金额更正
'REVERSAL', -- 冲正:用一笔反向交易抵消掉错误记录
'WRITE_OFF', -- 核销:确认差异无法追回,走坏账损失进损益
'REFUND' -- 退款:状态不一致时将多收的资金退还用户
),
adjust_amount DECIMAL(18,2), -- 平账金额
adjust_direction ENUM(
'IN', -- 资金流入方向(如补单导致收入增加)
'OUT' -- 资金流出方向(如退款导致资金减少)
),
accounting_entry VARCHAR(256), -- 会计分录摘要
reason TEXT, -- 平账原因说明(必填)
evidence_url VARCHAR(512), -- 凭证附件(渠道确认邮件截图等)
operator VARCHAR(64), -- 操作人
approver VARCHAR(64), -- 审批人
approve_status ENUM(
'DRAFT', -- 草稿:操作人创建后尚未提交
'PENDING_APPROVE', -- 待审批:已提交,等待审批人处理
'APPROVED', -- 已批准:审批通过,等待系统执行
'REJECTED', -- 已驳回:审批未通过,需修改后重新提交
'EXECUTED' -- 已执行:系统已完成平账动作,终态
) DEFAULT 'DRAFT',
approved_at DATETIME, -- 审批通过时间
executed_at DATETIME, -- 实际执行时间
created_at DATETIME,
INDEX idx_diff_no (diff_no)
);
3.2 平账类型详解
不同类型的差异对应不同的平账处理逻辑。
补单 — 最常见的场景。渠道已扣款成功但我方没收到回调,订单状态停在"支付中":
public class SupplementHandler implements SettleHandler {
@Transactional
public void handle(SettleAction action) {
ReconDiff diff = diffRepo.findByDiffNo(action.getDiffNo());
Order order = orderRepo.findByOrderNo(diff.getOrderNo());
// 反查渠道,二次确认交易确实成功(防止误补)
ChannelQueryResult result = channelService.query(order.getChannelFlowNo());
if (result.getStatus() != SUCCESS) {
throw new BizException("渠道侧交易未成功,不能补单");
}
// 更新订单状态
order.setStatus(OrderStatus.PAID);
order.setPayTime(result.getPayTime());
order.setRemark("对账补单,平账单号:" + action.getActionNo());
// 记账:确认这笔收入
accountingService.postEntry(
new AccountingEntry()
.debit("应收账款-渠道", action.getAdjustAmount())
.credit("营业收入", action.getAdjustAmount())
.memo("对账补单:" + diff.getDiffNo())
);
// 触发下游业务(发货、权益发放等)
fulfillmentService.trigger(order);
}
}
调账 — 双方都有记录但金额不一致,经渠道确认后按实际到账金额修正:
public class AdjustHandler implements SettleHandler {
@Transactional
public void handle(SettleAction action) {
ReconDiff diff = diffRepo.findByDiffNo(action.getDiffNo());
BigDecimal diffAmount = diff.getDiffAmount();
// 修正账务差额
accountingService.postEntry(
new AccountingEntry()
.debit("应收账款-渠道", diffAmount)
.credit("对账调整", diffAmount)
.memo("对账调账:" + diff.getDiffNo())
);
// 同步更新订单金额
Order order = orderRepo.findByOrderNo(diff.getOrderNo());
order.setActualAmount(diff.getChannelAmount());
order.setRemark("对账调账,差额:" + diffAmount);
}
}
核销 — 经多轮追查确认无法追回的差异,走坏账损失:
public class WriteOffHandler implements SettleHandler {
@Transactional
public void handle(SettleAction action) {
// 核销有单笔金额上限,超过必须走更高级别审批
if (action.getAdjustAmount().compareTo(WRITE_OFF_LIMIT) > 0) {
throw new BizException("超过单笔核销限额,需走高级审批");
}
accountingService.postEntry(
new AccountingEntry()
.debit("营业外支出-坏账损失", action.getAdjustAmount())
.credit("应收账款-渠道", action.getAdjustAmount())
.memo("差异核销:" + action.getDiffNo())
);
}
}
3.3 审批流:按金额和类型分级
平账涉及资金变动,不能"谁都能点",必须按金额和类型走不同审批链:
public ApproveChain resolveChain(SettleAction action) {
// 补单:金额较小的组长审批即可,较大的需要财务经理
if (action.getActionType() == SUPPLEMENT) {
if (action.getAdjustAmount().compareTo(new BigDecimal("1000")) <= 0) {
return ApproveChain.of("team_lead");
}
return ApproveChain.of("team_lead", "finance_manager");
}
// 核销:一律双人审批,大额还需财务总监
if (action.getActionType() == WRITE_OFF) {
if (action.getAdjustAmount().compareTo(new BigDecimal("10000")) > 0) {
return ApproveChain.of("team_lead", "finance_manager", "cfo");
}
return ApproveChain.of("team_lead", "finance_manager");
}
// 调账、冲正:走财务审批
return ApproveChain.of("finance_manager");
}
3.4 执行前的安全校验
以下校验必须硬编码拦住,不依赖流程约束:
public void validateBeforeExecute(SettleAction action) {
// 未审批通过不能执行
if (action.getApproveStatus() != APPROVED) {
throw new BizException("未审批通过,不能执行");
}
// 不能重复执行(幂等保障)
if (action.getExecutedAt() != null) {
throw new BizException("该平账单已执行,不可重复操作");
}
// 单笔限额
if (action.getAdjustAmount().compareTo(SINGLE_LIMIT) > 0) {
throw new BizException("超过单笔平账限额");
}
// 当日累计限额(防止批量异常操作)
BigDecimal todayTotal = actionRepo.sumTodayAmount(action.getOperator());
if (todayTotal.add(action.getAdjustAmount()).compareTo(DAILY_LIMIT) > 0) {
throw new BizException("超过当日累计平账限额");
}
// 原始差异单必须存在且处于 PROCESSING 状态
ReconDiff diff = diffRepo.findByDiffNo(action.getDiffNo());
if (diff == null || diff.getStatus() != DiffStatus.PROCESSING) {
throw new BizException("差异单状态异常");
}
}
3.5 平账状态流转
对账任务产出差异 → 差异表(PENDING)
↓
运营/财务在工作台上认领 → 差异表(PROCESSING)
↓
选择平账类型,填写原因和凭证 → 生成平账单(DRAFT)
↓
提交审批 → 平账单(PENDING_APPROVE)
↓
审批通过 → 平账单(APPROVED)
↓
系统执行 → 平账单(EXECUTED) + 差异表(SETTLED)
四、账单重算:平账的涟漪效应
平账改变了底层交易数据。上层所有依赖这些数据的聚合结果 — 账单、分润、手续费、发票、报表 — 都可能因此失效。这就引出了一个关键问题:平账之后,哪些下游数据需要重算?
4.1 是否重算的判定标准
核心判断只有一条:这次平账有没有改变面向业务方/客户的交易数据。
| 平账类型 | 改变了什么 | 是否需要重算 |
|---|---|---|
| 补单(SUPPLEMENT) | 新增了一笔成功交易 | 必须 — 账单里少了一笔 |
| 调账(ADJUST) | 某笔交易的实际金额变了 | 必须 — 账单金额不对 |
| 冲正(REVERSAL) | 某笔交易被反向抵消 | 必须 — 账单里多了一笔 |
| 核销(WRITE_OFF) | 走损益科目,交易记录不变 | 不需要 — 只影响内部财务 |
| 退款(REFUND) | 产生一笔退款记录 | 视情况 — 账单是否展示退款 |
4.2 事件驱动:解耦平账与重算
平账代码中不应硬编码"平完之后重算账单"这样的逻辑。正确的做法是发布事件,让下游各模块自行订阅和响应:
@Transactional
public void executeSettle(SettleAction action) {
// 1. 执行平账动作
settleHandlerFactory.getHandler(action.getActionType()).handle(action);
// 2. 更新平账单状态
action.setExecutedAt(LocalDateTime.now());
action.setApproveStatus(ApproveStatus.EXECUTED);
// 3. 发布事件,下游自行决定是否响应
eventBus.publish(new SettleExecutedEvent(
action.getActionNo(),
action.getDiffNo(),
action.getActionType(),
action.getAdjustAmount(),
action.getAdjustDirection(),
resolveAffectedBizDate(action)
));
}
账单模块的监听逻辑:
@EventListener
public void onSettleExecuted(SettleExecutedEvent event) {
// 核销类不影响账单,直接跳过
if (event.getActionType() == ActionType.WRITE_OFF) {
return;
}
// 找到受影响的账单,标记待重算
List<Bill> affectedBills = billRepo.findByBizDateRange(
event.getAffectedBizDate(), event.getOrderNo()
);
for (Bill bill : affectedBills) {
bill.setStatus(BillStatus.RECALC_PENDING);
bill.setRecalcReason("平账触发:" + event.getActionNo());
}
// 异步提交重算任务(不阻塞平账主流程)
recalcTaskService.submit(affectedBills);
}
4.3 已出账单不能直接改
账单的状态决定了重算的方式:
public void recalculate(Bill bill) {
// 已发给客户的账单 → 不能悄悄改,必须出调整单
if (bill.getStatus() == BillStatus.SENT || bill.getStatus() == BillStatus.SETTLED) {
createBillAdjustment(bill);
return;
}
// 还没发出去的账单 → 可以直接重算
if (bill.getStatus() == BillStatus.GENERATED || bill.getStatus() == BillStatus.CONFIRMED) {
doRecalculate(bill);
}
}
调整单让客户看到的是"原始账单 + 调整单",而不是账单金额莫名其妙变了:
private void createBillAdjustment(Bill originalBill) {
BillAdjustment adj = new BillAdjustment();
adj.setAdjustNo(idGenerator.next("ADJ"));
adj.setOriginalBillNo(originalBill.getBillNo());
adj.setAdjustAmount(calculateAdjustAmount(originalBill));
adj.setReason("对账平账调整");
adj.setStatus(AdjustStatus.PENDING_CONFIRM);
// 调整单同样需要走审批
approvalService.submit(adj, resolveChain(adj));
// 通知商户:有一笔账单调整
notifyService.sendBillAdjustmentNotice(originalBill.getMerchantId(), adj);
}
4.4 批量重算优于逐笔重算
建议不要平一笔算一笔,而是 攒到一个窗口批量重算。比如每天凌晨统一处理所有 RECALC_PENDING 状态的账单。一个账单可能被多笔平账影响,攒批可以避免同一张账单被反复计算。
五、结算出款:对账是门槛
所有前面的环节 — 对账、差异处理、平账、账单重算 — 最终都是为结算服务的。结算是真金白银出去的环节,必须确保数据完全正确后才能执行。
5.1 核心原则:账没对平,钱不出去
如果先结算再平账,会面临几个棘手问题:
- 分润多给了,要从商户/代理商追回,难度极大且伤害合作关系
- 分润少给了,商户投诉,还得补发,多一次资金操作多一次风险
- 已结算的数据被平账改了,结算记录和实际出款对不上,审计无法通过
因此 对账是结算的硬前置条件,不是靠流程文档约束,而是在数据层面卡死。
5.2 结算资格标记
在交易上增加结算资格字段,作为结算任务的数据过滤条件:
public enum SettleEligibility {
WAITING_RECON, // 等待对账:交易刚完成,还未进入对账流程
RECON_MATCHED, // 对账通过:与渠道记录完全匹配,具备结算资格
RECON_DISPUTED, // 对账异常:存在差异,冻结中,不参与结算
SETTLE_READY, // 可结算:差异已通过平账解决,恢复结算资格
SETTLED // 已结算:结算出款完成,终态
}
结算任务拉数据时只取有资格的交易,有差异的交易连查询条件都不满足:
public List<Order> fetchSettleableOrders(Long merchantId, LocalDate settleDate) {
return orderRepo.findAll(
where(merchantIdEquals(merchantId))
.and(settleEligibilityIn(
SettleEligibility.RECON_MATCHED,
SettleEligibility.SETTLE_READY
))
.and(bizDateBefore(settleDate))
.and(settleStatusEquals(NOT_SETTLED))
);
}
5.3 按单笔卡,不按商户卡
一个商户一天可能有上万笔交易,其中绝大多数对账通过,少量有差异。不能因为几笔差异就卡住整个商户的结算。粒度设计为 按单笔交易控制资格:
public SettleBatch createSettleBatch(Long merchantId, LocalDate settleDate) {
// 只捞可结算的交易
List<Order> eligible = fetchSettleableOrders(merchantId, settleDate);
// 统计被卡住的交易(用于告知商户)
List<Order> disputed = orderRepo.findDisputed(merchantId, settleDate);
SettleBatch batch = new SettleBatch();
batch.setMerchantId(merchantId);
batch.setSettleAmount(sumAmount(eligible));
batch.setSettleCount(eligible.size());
batch.setDisputedCount(disputed.size());
batch.setDisputedAmount(sumAmount(disputed));
// 结算单上明确标注差异情况
batch.setRemark(String.format(
"本期结算 %d 笔,共 %s 元;%d 笔差异待处理,涉及 %s 元,将在差异解决后补结",
eligible.size(), batch.getSettleAmount(),
disputed.size(), batch.getDisputedAmount()
));
return batch;
}
没问题的照常结算保证商户现金流,有差异的单独扣住不放。
5.4 差异解决后自动补结
平账完成后,之前被冻结的交易自动恢复结算资格:
@EventListener
public void onSettleExecuted(SettleExecutedEvent event) {
Order order = orderRepo.findByOrderNo(event.getOrderNo());
// 恢复结算资格,下一个结算周期会自动捞到
order.setSettleEligibility(SettleEligibility.SETTLE_READY);
}
如果业务上希望差异解决后尽快补结,可增加每日补结算检查:
@Scheduled(cron = "0 0 10 * * ?")
public void supplementSettle() {
// 找到"已恢复资格但错过了正常结算周期"的订单
List<Order> overdueReady = orderRepo.findAll(
where(settleEligibilityEquals(SettleEligibility.SETTLE_READY))
.and(bizDateBefore(LocalDate.now().minusDays(settleDelay)))
.and(settleStatusEquals(NOT_SETTLED))
);
if (overdueReady.isEmpty()) return;
// 按商户分组,生成补结算批次
overdueReady.stream()
.collect(groupingBy(Order::getMerchantId))
.forEach((merchantId, orders) -> {
SettleBatch batch = new SettleBatch();
batch.setType(BatchType.SUPPLEMENT); // 标记为补结算
batch.setMerchantId(merchantId);
batch.setSettleAmount(sumAmount(orders));
batch.setRemark("对账差异解决后补结算");
settleBatchRepo.save(batch);
});
}
5.5 分润以结算为前提
分润处于整条链路的末端,它的前置条件是结算完成:
交易 → 对账通过 → 结算完成 → 分润计算
只有 SETTLED 状态的交易才参与分润:
public void calculateCommission(Long agentId, YearMonth period) {
List<Order> settledOrders = orderRepo.findAll(
where(agentIdEquals(agentId))
.and(settleStatusEquals(SettleStatus.SETTLED))
.and(settleDateInMonth(period))
);
// 基于已结算交易计算分润...
}
每一步都是上一步的硬前置条件,跳不过去。
六、结算规则配置化
前面描述了结算的执行逻辑,但还有一个前提问题:怎么知道每个商户该按什么周期结算? 这不能硬编码,必须做成可配置的规则引擎。
6.1 现实中的多样性
不同商户、不同业务线、甚至同一个商户在不同阶段,结算周期都可能不同:
- 大商户议价能力强,要求 T+1 日结
- 中小商户标准月结,每月固定日出款
- 新商户风控期内 T+7 甚至 T+15
- 大促期间临时改成日结,活动结束改回月结
- 海外业务受当地法规约束,结算周期和国内不同
这些全是运营侧随时可能调整的需求。
6.2 规则模型
CREATE TABLE settle_rule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
rule_no VARCHAR(64) UNIQUE, -- 规则编号
-- 适用范围(支持从粗到细的多维度匹配)
biz_line VARCHAR(32), -- 业务线(支付/退款/代付),必填
merchant_id BIGINT, -- 商户ID,NULL表示该业务线通用规则
channel_code VARCHAR(32), -- 渠道编码,NULL表示不限渠道
-- 结算周期
settle_cycle ENUM(
'T1', -- T+1日结:交易次日结算
'T7', -- T+7周结:每周一结算上周交易
'MONTHLY', -- 自然月结:每月固定日结算上月交易
'HALF_MONTHLY', -- 半月结:每月1号和16号各结一次
'CUSTOM' -- 自定义周期:按 custom_cycle_days 指定天数
),
custom_cycle_days INT, -- 自定义周期天数(仅 CUSTOM 类型生效)
settle_day INT, -- 月结出账日(1~28,仅 MONTHLY 类型生效)
-- 起结条件
min_settle_amount DECIMAL(18,2) DEFAULT 0, -- 最低结算金额,不到则顺延至下一期
-- 出款控制
pay_out_delay_days INT DEFAULT 0, -- 出账后延迟打款天数(风控缓冲)
-- 生效时间(支持预设未来生效的规则)
effective_from DATE, -- 生效开始日期
effective_to DATE, -- 生效截止日期,NULL表示长期有效
-- 管理字段
status ENUM(
'ACTIVE', -- 生效中
'INACTIVE' -- 已停用
) DEFAULT 'ACTIVE',
created_by VARCHAR(64), -- 创建人
approved_by VARCHAR(64), -- 审批人
created_at DATETIME,
updated_at DATETIME,
-- 同一适用范围在同一时间段只能有一条生效规则
UNIQUE INDEX uk_scope_period (biz_line, merchant_id, channel_code, effective_from)
);
6.3 规则匹配:从具体到通用
同一笔交易可能命中多条规则(商户级、渠道级、业务线级),按优先级取最具体的一条:
public SettleRule resolveRule(Order order) {
List<SettleRule> candidates = ruleRepo.findActiveRules(
order.getBizLine(), order.getMerchantId(),
order.getChannelCode(), order.getBizDate()
);
// 优先级:商户+渠道+业务线 > 商户+业务线 > 渠道+业务线 > 业务线默认
return candidates.stream()
.max(Comparator.comparingInt(this::calculatePriority))
.orElseThrow(() -> new BizException(
"未找到结算规则,商户:" + order.getMerchantId()
));
}
private int calculatePriority(SettleRule rule) {
int score = 0;
if (rule.getMerchantId() != null) score += 100; // 商户级最优先
if (rule.getChannelCode() != null) score += 10; // 渠道级次之
return score;
}
6.4 结算窗口计算
不同周期类型的核心区别是"这笔交易归到哪个结算窗口、什么时候出款":
public class SettleWindowCalculator {
/**
* 根据规则和业务日期,计算交易归属的结算窗口。
* 返回值包含:交易归集起始日、截止日、实际出款日。
*/
public SettleWindow calculate(SettleRule rule, LocalDate bizDate) {
switch (rule.getSettleCycle()) {
case T1:
return dailyWindow(bizDate, 1);
case T7:
return weeklyWindow(bizDate);
case MONTHLY:
return monthlyWindow(bizDate, rule.getSettleDay());
case HALF_MONTHLY:
return halfMonthlyWindow(bizDate);
case CUSTOM:
return dailyWindow(bizDate, rule.getCustomCycleDays());
default:
throw new IllegalArgumentException("未知结算周期:" + rule.getSettleCycle());
}
}
// T+N 日结:交易日往后推 N 天就是结算日,遇节假日顺延
private SettleWindow dailyWindow(LocalDate bizDate, int delayDays) {
LocalDate settleDate = holidayService.nextWorkday(bizDate.plusDays(delayDays));
return new SettleWindow(bizDate, bizDate, settleDate);
}
// 月结:当月交易归集,下月 settleDay 出款
private SettleWindow monthlyWindow(LocalDate bizDate, int settleDay) {
YearMonth period = YearMonth.from(bizDate);
YearMonth nextMonth = period.plusMonths(1);
int day = Math.min(settleDay, nextMonth.lengthOfMonth());
LocalDate settleDate = holidayService.nextWorkday(nextMonth.atDay(day));
return new SettleWindow(period.atDay(1), period.atEndOfMonth(), settleDate);
}
// 半月结:1-15号交易次月1号结,16-月末交易次月16号结
private SettleWindow halfMonthlyWindow(LocalDate bizDate) {
YearMonth month = YearMonth.from(bizDate);
if (bizDate.getDayOfMonth() <= 15) {
return new SettleWindow(
month.atDay(1), month.atDay(15),
holidayService.nextWorkday(month.plusMonths(1).atDay(1))
);
} else {
return new SettleWindow(
month.atDay(16), month.atEndOfMonth(),
holidayService.nextWorkday(month.plusMonths(1).atDay(16))
);
}
}
}
6.5 统一调度入口
日结和月结使用同一个定时任务,靠规则驱动行为差异:
@Scheduled(cron = "0 0 6 * * ?") // 每天早上6点执行
public void dailySettleJob() {
LocalDate today = LocalDate.now();
// 找到所有"今天是结算日"的规则
List<SettleRule> dueRules = findRulesDueOn(today);
for (SettleRule rule : dueRules) {
SettleWindow window = windowCalculator.calculateBySettleDate(rule, today);
// 拉取窗口内、对账通过、未结算的交易
List<Order> orders = orderRepo.findSettleable(
rule.getMerchantId(), rule.getBizLine(), rule.getChannelCode(),
window.getWindowStart(), window.getWindowEnd()
);
if (orders.isEmpty()) continue;
BigDecimal totalAmount = sumAmount(orders);
// 未达最低结算金额,顺延到下一期
if (totalAmount.compareTo(rule.getMinSettleAmount()) < 0) {
log.info("商户 {} 本期金额 {} 未达起结额 {},顺延",
rule.getMerchantId(), totalAmount, rule.getMinSettleAmount());
continue;
}
createSettleBatch(rule, window, orders);
}
}
判断"今天是不是某条规则的结算日":
private List<SettleRule> findRulesDueOn(LocalDate today) {
return ruleRepo.findByStatus(RuleStatus.ACTIVE).stream()
.filter(rule -> {
switch (rule.getSettleCycle()) {
case T1: return true; // 日结每天都跑
case T7: return today.getDayOfWeek() == DayOfWeek.MONDAY; // 每周一
case MONTHLY: return today.getDayOfMonth() == rule.getSettleDay();
case HALF_MONTHLY: return today.getDayOfMonth() == 1
|| today.getDayOfMonth() == 16;
case CUSTOM:
long days = ChronoUnit.DAYS.between(rule.getEffectiveFrom(), today);
return days % rule.getCustomCycleDays() == 0;
default: return false;
}
})
.collect(toList());
}
6.6 规则变更:新旧规则平滑过渡
结算规则变更时,在途交易的处理原则:变更不影响已归集的交易,只对变更后的新交易生效。
public void changeRule(Long merchantId, SettleRule newRule) {
SettleRule current = ruleRepo.findCurrentActive(merchantId, newRule.getBizLine());
// 旧规则设置截止日期
current.setEffectiveTo(newRule.getEffectiveFrom().minusDays(1));
// 新规则从指定日期开始生效
ruleRepo.save(newRule);
// 记录变更日志
ruleChangeLogRepo.save(new RuleChangeLog(
merchantId, current.getRuleNo(), newRule.getRuleNo(),
"结算周期从 " + current.getSettleCycle()
+ " 变更为 " + newRule.getSettleCycle()
));
}
例如商户从月结切成日结,7 月 15 号生效:7 月 1~14 号的交易仍按月结在 8 月出账,7 月 15 号起的交易按 T+1 日结。两条规则并存,各管各的窗口,互不干扰。resolveRule 方法通过 effective_from / effective_to 自然实现这个效果。
七、总结
回顾全文,整条链路可以用一张图串起来:
交易完成(WAITING_RECON)
↓
对账(滑动窗口匹配 + PENDING 自动补对)
├── MATCHED → 获得结算资格
└── UNMATCHED → 进入差异处理
↓
线下确认 + 系统平账(审批流控制)
↓
平账执行 → 事件通知下游
↓
账单重算 / 调整单
↓
恢复结算资格(SETTLE_READY)
↓
结算出款(按配置化规则:日结/周结/月结)
↓
分润(以结算完成为前提)
贯穿整套方案的设计理念可以归纳为六点:
-
对差异保持耐心。 跨天/跨月订单先标 PENDING,滑动窗口自动补对,不急着判异常。大部分差异会在 T+1 自行消失。
-
平账留痕留据。 每一笔调整都有差异来源、处理原因、审批记录和凭证附件。平账是会计动作,不是数据修改。
-
对账是结算的门槛。 从数据层面硬卡 — 没有 MATCHED 或 SETTLE_READY 标记的交易,结算 SQL 根本查不到。不依赖流程文档约束,靠代码保障。
-
事件驱动解耦。 平账完成后发布事件,账单、分润、报表等下游模块各自订阅、各自决定是否响应,互不耦合。
-
结算规则配置化。 周期类型、出账日、最低金额、生效时间全部可配,运营调整不需要改代码上线。规则变更自动处理新旧过渡。
-
先看清再动手。 先把差异看板做好,看清高频差异原因后再针对性自动化。80% 的差异通常就那两三种类型,针对性处理即可,长尾走人工反而更安全。