#支付系统对账设计
#一笔对不上的账
2022年,我们财务团队发现了一个问题:
这个月支付宝流水显示收到 1000 万,但我们的系统记录只有 999.5 万。差了 5000 笔交易,金额差了 5000 元。
排查了整整一周,发现原因:
- 部分支付回调因为网络问题没有收到,导致系统记录缺失
- 部分退款回调重复收到,导致系统记录重复
- 某些极端情况下,支付成功但我们的状态更新失败
对账系统的核心是:找出谁的数据是对的。
#二、对账的整体流程🔴
#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 | 能设计完整的对账系统 |