支付系统对账设计

一笔对不上的账

2022年,我们财务团队发现了一个问题:

这个月支付宝流水显示收到 1000 万,但我们的系统记录只有 999.5 万。差了 5000 笔交易,金额差了 5000 元。

排查了整整一周,发现原因:

  1. 部分支付回调因为网络问题没有收到,导致系统记录缺失
  2. 部分退款回调重复收到,导致系统记录重复
  3. 某些极端情况下,支付成功但我们的状态更新失败

对账系统的核心是:找出谁的数据是对的。


二、对账的整体流程🔴

2.1 对账流程

T+1 日(交易次日):
┌─────────────────────────────────────────────────────────┐
│                    日终对账                               │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │  拉取渠道流水  │  │  拉取本地流水  │  │   比对差异    │ │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘ │
│         │                  │                  │         │
│         └────────────────┼──────────────────┘         │
│                          ▼                              │
│                 ┌──────────────────┐                    │
│                 │    生成差错单      │                    │
│                 └────────┬─────────┘                    │
│                          ▼                              │
│                 ┌──────────────────┐                    │
│                 │    人工/自动处理   │                    │
│                 └──────────────────┘                    │
└─────────────────────────────────────────────────────────┘

2.2 核心代码

@Service
class ReconciliationService {
    @Autowired
    private PaymentDao paymentDao;
    @Autowired
    private ChannelApiService channelApi;

    /**
     * 日终对账
     */
    public ReconciliationReport reconcile(String date) {
        // 1. 获取本地交易记录
        List<PaymentTransaction> localTxs = paymentDao.findByDate(date);

        // 2. 获取渠道交易记录
        List<ChannelTransaction> channelTxs = channelApi.fetchDailyTransactions(date);

        // 3. 构建本地交易 Map
        Map<String, PaymentTransaction> localMap = localTxs.stream()
            .collect(Collectors.toMap(
                PaymentTransaction::getChannelOrderNo,
                tx -> tx,
                (a, b) -> a // 重复时保留第一个
            ));

        // 4. 比对
        List<Difference> differences = new ArrayList<>();

        for (ChannelTransaction channelTx : channelTxs) {
            PaymentTransaction localTx = localMap.get(channelTx.getOrderNo());

            if (localTx == null) {
                // 长款:渠道有,本地没有
                differences.add(new Difference(
                    Difference.Type.LONG,
                    channelTx.getOrderNo(),
                    null,
                    channelTx
                ));
            } else if (!localTx.getAmount().equals(channelTx.getAmount())) {
                // 金额不一致
                differences.add(new Difference(
                    Difference.Type.AMOUNT_MISMATCH,
                    channelTx.getOrderNo(),
                    localTx,
                    channelTx
                ));
            } else {
                // 匹配成功
                localTx.setReconciled(true);
            }
        }

        // 5. 查找本地有多、渠道没有的(短款)
        for (PaymentTransaction localTx : localTxs) {
            if (!localTx.isReconciled()) {
                differences.add(new Difference(
                    Difference.Type.SHORT,
                    localTx.getChannelOrderNo(),
                    localTx,
                    null
                ));
            }
        }

        return new ReconciliationReport(date, localTxs.size(),
            channelTxs.size(), differences);
    }
}

三、差错处理🔴

3.1 长款处理(渠道有、本地没有)

@Service
class LongDifferenceHandler {
    /**
     * 处理长款:渠道有但本地没有
     * 可能原因:回调通知失败、本地记录丢失
     */
    public void handle(LongDifference diff) {
        ChannelTransaction channelTx = diff.getChannelTransaction();

        // 1. 查询渠道确认交易状态
        ChannelQueryResult result = channelApi.query(channelTx.getOrderNo());

        if (result.isSuccess()) {
            // 渠道确认成功,补录本地记录
            PaymentTransaction tx = new PaymentTransaction();
            tx.setChannelOrderNo(channelTx.getOrderNo());
            tx.setAmount(channelTx.getAmount());
            tx.setStatus(PaymentStatus.PAID);
            tx.setPaidTime(channelTx.getPayTime());
            tx.setRemark("对账补录");
            paymentDao.save(tx);

            // 2. 自动退款(钱不能白收)
            refundService.autoRefund(tx.getId());

            // 3. 记录处理日志
            logService.log("LONG_DIFFERENCE_RESOLVED",
                "补录并退款: " + channelTx.getOrderNo());
        } else {
            // 渠道状态不明,标记待人工处理
            markForManual(diff);
        }
    }
}

3.2 短款处理(本地有、渠道没有)

@Service
class ShortDifferenceHandler {
    /**
     * 处理短款:本地有但渠道没有
     * 可能原因:渠道侧失败但本地状态错误更新
     */
    public void handle(ShortDifference diff) {
        PaymentTransaction localTx = diff.getLocalTransaction();

        // 1. 主动查询渠道状态
        ChannelQueryResult result = channelApi.query(
            localTx.getChannelOrderNo()
        );

        if (result.isSuccess()) {
            // 渠道确认成功,更新本地状态
            localTx.setStatus(PaymentStatus.PAID);
            localTx.setPaidTime(result.getPayTime());
            paymentDao.update(localTx);

            logService.log("SHORT_DIFFERENCE_RESOLVED",
                "状态补录: " + localTx.getChannelOrderNo());
        } else {
            // 渠道确认失败,可能需要退款
            // 本地可能是错误的已支付状态
            markForManual(diff);
        }
    }
}

四、退款对账🟡

4.1 退款对账流程

@Service
class RefundReconciliationService {
    /**
     * 退款对账
     */
    public void reconcileRefunds(String date) {
        // 1. 获取本地退款记录
        List<RefundTransaction> localRefunds = refundDao.findByDate(date);

        // 2. 获取渠道退款记录
        List<ChannelRefundTransaction> channelRefunds =
            channelApi.fetchDailyRefunds(date);

        // 3. 比对
        Map<String, RefundTransaction> localMap = localRefunds.stream()
            .collect(Collectors.toMap(
                RefundTransaction::getChannelRefundNo,
                r -> r,
                (a, b) -> a
            ));

        for (ChannelRefundTransaction channelRefund : channelRefunds) {
            RefundTransaction localRefund =
                localMap.get(channelRefund.getRefundNo());

            if (localRefund == null) {
                // 长款:需要补录退款
                handleRefundLong(channelRefund);
            } else if (!localRefund.getAmount()
                .equals(channelRefund.getAmount())) {
                // 金额不一致
                handleRefundMismatch(localRefund, channelRefund);
            } else {
                localRefund.setReconciled(true);
            }
        }
    }
}

五、自动补偿机制🟡

5.1 定时任务对账

@Configuration
class ReconciliationConfig {
    @Bean
    public String reconciliationTask(
            @Value("${reconciliation.cron}") String cron) {
        return cron;
    }
}

@Service
class ReconciliationScheduler {
    /**
     * 日终对账任务
     * 每天凌晨 4:30 执行
     */
    @Scheduled(cron = "0 30 4 * * ?")
    public void runDailyReconciliation() {
        String yesterday = LocalDate.now().minusDays(1).toString();

        ReconciliationReport report = reconciliationService.reconcile(yesterday);

        if (report.hasDifferences()) {
            // 发送告警
            alertService.alert(
                "对账存在差异",
                String.format("差异数: %d", report.getDifferenceCount())
            );

            // 自动处理可自动化的差异
            autoResolver.resolve(report);
        }

        // 生成对账报告
        reportService.generateReport(report);
    }
}

5.2 实时监控

@Service
class ReconciliationMonitor {
    /**
     * 实时监控:检测异常交易率
     */
    @Scheduled(fixedRate = 300000) // 每 5 分钟
    public void monitor() {
        double localCount = paymentDao.countToday();
        double channelCount = channelApi.countToday();

        double diffRate = Math.abs(localCount - channelCount) / localCount;

        if (diffRate > 0.001) { // 差异率超过 0.1%
            alertService.alert(
                "交易差异率异常",
                String.format("差异率: %.2f%%", diffRate * 100)
            );
        }
    }
}

【架构权衡】 对账系统的核心是找出差异并正确处理。自动化能处理 80% 的常见差异,剩下 20% 需要人工介入。


六、面试总结

级别期望回答
P5能说出对账的基本流程
P6能说出长款、短款的处理方式
P7能设计完整的对账系统