Featured image of post 支付平台账单对账模块设计:多维度数据聚合与差异处理

支付平台账单对账模块设计:多维度数据聚合与差异处理

统一支付平台对账模块多维聚合与差异自动处理

账对不平是迟早的事

我们同时对接微信、支付宝、银联好几个渠道,每天几十万笔交易,渠道回调可能丢、可能重放、可能金额被篡改,还可能跨日清算。靠运营每天拉 Excel 肉眼比对?迟早出大事。

所以我主导设计了对账模块,目标三个:T+1 自动拉渠道账单、多维度聚合比对、差异自动分类并产出处理工单。

从拉文件到建工单,分四层

整个模块分成四层:

  1. 数据采集层:每天凌晨定时拉取各渠道对账文件(微信是 gz 压缩的 CSV、支付宝是 ZIP、银联是定长文本),统一解析成内部 ChannelBill 结构落库。
  2. 数据聚合层:把平台订单按"商户 + 渠道 + 日"维度聚合,算出订单笔数、订单金额、手续费、退款金额;同样把渠道账单按相同维度聚合。
  3. 对账引擎层:以渠道账单为基准,左连接平台订单,逐笔比对四个字段:订单号、金额、状态、时间。差异分为四类:长款(渠道有平台无)、短款(平台有渠道无)、金额不符、状态不符。
  4. 差异处理层:差异自动建单,能自动处理的(如跨日清算)自动核销,不能自动处理的推给运营工单系统,并附带原始凭证。

对账模块四层:从渠道文件到差异工单

先聚合,再逐笔比对

聚合查询我用一条 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

Built with Hugo
Theme Stack designed by Jimmy