支付对账与结算系统设计:从差异发现到资金出款的全链路实践

0 阅读24分钟

引言

在支付系统中,业务系统与第三方渠道(银行、支付机构)之间,由于清算时差、网络延迟、系统故障等原因,不可避免地会产生数据差异。一笔交易在我方记录为成功,渠道侧可能还没入账;一笔扣款渠道已经完成,我方却因为回调丢失而毫不知情。

这些差异如果不能被及时发现和处理,轻则账务混乱,重则造成资金损失。

本文从一笔交易完成后的视角出发,沿着资金流转的方向,完整梳理 对账 → 差异处理 → 平账 → 账单重算 → 结算出款 → 分润 的全链路设计方案。每个环节既是上一步的消费者,也是下一步的前置条件,环环相扣。

全链路概览

在进入细节之前,先建立整体认知。一笔交易从完成到最终分润出款,经过以下阶段:

交易完成
  ↓
第一阶段:对账(发现差异)
  将本方交易记录与渠道清算文件逐笔匹配,识别出一致、待确认和差异三种状态。
  ↓
第二阶段:差异处理与平账(消化差异)
  对发现的差异进行分类、确认、审批,通过补单/调账/冲正/核销等会计动作将差异合理消化。
  ↓
第三阶段:账单重算(修正下游数据)
  平账改变了底层交易数据,依赖这些数据的账单、报表等聚合结果需要相应刷新。
  ↓
第四阶段:结算出款(资金流出)
  只有对账通过的交易才有资格参与结算,按商户配置的结算周期(日结/周结/月结)归集并出款。
  ↓
第五阶段:分润(利益分配)
  结算完成后,按协议比例计算并分配给商户、代理商等各方。

下面逐一展开。


一、对账:差异的发现

对账是整条链路的起点。它的职责是把本方的交易记录和渠道的清算文件逐笔比对,找出"谁和谁对不上"。

1.1 跨天/跨月订单的核心矛盾

对账最大的难点不是匹配逻辑本身,而是 时间错位。业务系统和渠道对同一笔交易的入账时间点经常不一致:

  • 23:59:50 发起支付,业务系统记录为 T 日,渠道清算落到 T+1 日
  • 月末最后一天的交易,业务侧算当月,渠道侧算次月
  • 渠道批量清算延迟,部分交易"漂移"到下一个对账周期

如果按"创建时间 = 对账日"做精确匹配,这些边界交易必然成为差异,但它们并不是真正的异常。

1.2 滑动窗口:给时间差留出缓冲

解决时间错位的核心策略是 滑动窗口 — 对账时不精确卡日期,而是向前后各扩展一段缓冲期:

对账窗口 = [T日 00:00:00 - buffer, T+100: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)
                      ↓
结算出款(按配置化规则:日结/周结/月结)
  ↓
分润(以结算完成为前提)

贯穿整套方案的设计理念可以归纳为六点:

  1. 对差异保持耐心。 跨天/跨月订单先标 PENDING,滑动窗口自动补对,不急着判异常。大部分差异会在 T+1 自行消失。

  2. 平账留痕留据。 每一笔调整都有差异来源、处理原因、审批记录和凭证附件。平账是会计动作,不是数据修改。

  3. 对账是结算的门槛。 从数据层面硬卡 — 没有 MATCHED 或 SETTLE_READY 标记的交易,结算 SQL 根本查不到。不依赖流程文档约束,靠代码保障。

  4. 事件驱动解耦。 平账完成后发布事件,账单、分润、报表等下游模块各自订阅、各自决定是否响应,互不耦合。

  5. 结算规则配置化。 周期类型、出账日、最低金额、生效时间全部可配,运营调整不需要改代码上线。规则变更自动处理新旧过渡。

  6. 先看清再动手。 先把差异看板做好,看清高频差异原因后再针对性自动化。80% 的差异通常就那两三种类型,针对性处理即可,长尾走人工反而更安全。