账对不平是迟早的事
我们同时对接微信、支付宝、银联好几个渠道,每天几十万笔交易,渠道回调可能丢、可能重放、可能金额被篡改,还可能跨日清算。靠运营每天拉 Excel 肉眼比对?迟早出大事。
所以我主导设计了对账模块,目标三个:T+1 自动拉渠道账单、多维度聚合比对、差异自动分类并产出处理工单。
从拉文件到建工单,分四层
整个模块分成四层:
- 数据采集层:每天凌晨定时拉取各渠道对账文件(微信是 gz 压缩的 CSV、支付宝是 ZIP、银联是定长文本),统一解析成内部
ChannelBill 结构落库。 - 数据聚合层:把平台订单按"商户 + 渠道 + 日"维度聚合,算出订单笔数、订单金额、手续费、退款金额;同样把渠道账单按相同维度聚合。
- 对账引擎层:以渠道账单为基准,左连接平台订单,逐笔比对四个字段:订单号、金额、状态、时间。差异分为四类:长款(渠道有平台无)、短款(平台有渠道无)、金额不符、状态不符。
- 差异处理层:差异自动建单,能自动处理的(如跨日清算)自动核销,不能自动处理的推给运营工单系统,并附带原始凭证。

先聚合,再逐笔比对
聚合查询我用一条 SQL 同时算出四个指标,避免来回扫表:
1
2
3
4
5
6
7
8
| SELECT merchant_id, channel, trade_date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
SUM(fee) AS total_fee,
SUM(refund_amount) AS total_refund
FROM orders
WHERE trade_date = ?
GROUP BY merchant_id, channel, trade_DATE
|
GORM 里我直接用 Scan 到结构体切片:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| type Aggregate struct {
MerchantID string
Channel string
TradeDate string
OrderCount int64
TotalAmount decimal.Decimal
TotalFee decimal.Decimal
TotalRefund decimal.Decimal
}
var platformAggs []Aggregate
db.WithContext(ctx).Raw(`
SELECT merchant_id, channel, DATE(paid_at) AS trade_date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
SUM(fee) AS total_fee,
COALESCE(SUM(refund_amount),0) AS total_refund
FROM orders
WHERE paid_at >= ? AND paid_at < ?
GROUP BY merchant_id, channel, DATE(paid_at)
`, start, end).Scan(&platformAggs)
|
逐笔比对用 channel bill 左连 platform order,在内存里做(两边都按日期分片,单日数据量可控):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| type DiffType string
const (
DiffShort DiffType = "SHORT" // 平台有渠道无
DiffLong DiffType = "LONG" // 渠道有平台无
DiffAmount DiffType = "AMOUNT_MISMATCH"
DiffStatus DiffType = "STATUS_MISMATCH"
)
type Diff struct {
Type DiffType
OutTradeNo string
Platform *Order
Channel *ChannelBill
Reason string
}
func Reconcile(platform map[string]*Order, channel map[string]*ChannelBill) []Diff {
var diffs []Diff
seen := make(map[string]struct{}, len(platform))
for no, cb := range channel {
seen[no] = struct{}{}
po, ok := platform[no]
if !ok {
diffs = append(diffs, Diff{Type: DiffLong, OutTradeNo: no, Channel: cb,
Reason: "渠道存在订单但平台无记录"})
continue
}
if !po.Amount.Equal(cb.Amount) {
diffs = append(diffs, Diff{Type: DiffAmount, OutTradeNo: no,
Platform: po, Channel: cb, Reason: "金额不一致"})
continue
}
if po.Status == "PAID" && cb.Status == "REFUNDED" {
diffs = append(diffs, Diff{Type: DiffStatus, OutTradeNo: no,
Platform: po, Channel: cb, Reason: "平台未同步退款状态"})
}
}
for no, po := range platform {
if _, ok := seen[no]; !ok {
diffs = append(diffs, Diff{Type: DiffShort, OutTradeNo: no, Platform: po,
Reason: "平台存在订单但渠道无记录"})
}
}
return diffs
}
|
差异能自动核销的,别麻烦运营
差异处理用责任链,每条规则先试着自己核销,处理不了再往下游传。短款的典型情况是跨日清算:平台今天记了账,渠道账单第二天才出现,查一下次日账单,金额对得上就自动核销:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| type Handler interface {
Handle(ctx context.Context, diff Diff) (resolved bool, err error)
}
type CrossDayHandler struct{ next Handler }
func (h *CrossDayHandler) Handle(ctx context.Context, diff Diff) (bool, error) {
if diff.Type != DiffShort || diff.Platform == nil {
return h.next.Handle(ctx, diff)
}
// 短款可能是渠道 T+1 清算,查次日账单
var cb ChannelBill
err := db.WithContext(ctx).Where("out_trade_no = ? AND trade_date = ?",
diff.OutTradeNo, diff.Platform.PaidAt.AddDate(0,0,1).Format("2006-01-02")).
First(&cb).Error
if err == nil && cb.Amount.Equal(diff.Platform.Amount) {
return true, markResolved(ctx, diff, "跨日清算自动核销")
}
return h.next.Handle(ctx, diff)
}
|
最隐蔽的坑是时区
渠道账单的"交易日"通常用渠道侧时区(微信、支付宝都是北京时间),我们数据库存的是 UTC。一笔 23:50 的交易,平台算 T 日,渠道可能算 T+1 日,聚合一对就冒出一堆伪差异。所以聚合时必须 CONVERT_TZ,或者在应用层明确按商户时区切日。
金额比对一律用 decimal,而且比较前先做币种归一。出过美元订单按人民币比对的错误,低级,但真发生了,后来加了币种一致性校验。
手续费差异最麻烦。渠道按渠道规则算,我们按自己计费引擎算,两边规则不同,差异必然存在。后来专门建了张 fee_adjustment 表,单笔小于 0.01 元的尾差自动归到"手续费尾差"科目,不报警。
对账任务还可能因为渠道文件没就绪而重跑,所以差异工单按 (trade_date, out_trade_no, diff_type) 建唯一索引,避免重复建单骚扰运营。
聚合粒度一度想按小时做,跑下来发现渠道账单本来就是按天的,按小时聚合反而徒增复杂度,最终定为天级,商户级对账单再下钻到明细。
后来
这套设计上线后,每天自动出对账结果:差异自动分类,能自动核销的不打扰运营,不能的带着凭证进工单。对账模块听起来不性感,但它是支付平台的"良心":账对得平,财务才睡得着;差异处理有迹可循,客诉来了也能很快定位是平台的问题、渠道的问题,还是跨日的问题。
封面图:ccPixs.com / Flickr · CC BY 2.0