Files
pay-bridge/backend/internal/service/reconciliation.go
2026-03-13 15:51:59 +08:00

222 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"fmt"
"log/slog"
"time"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// ReconciliationService T+1 自动对账服务
type ReconciliationService struct {
reconRepo *repository.ReconciliationRepository
tradeRepo *repository.TradeOrderRepository
channelSvc *ChannelService
appRepo *repository.AppRepository
}
func NewReconciliationService(
reconRepo *repository.ReconciliationRepository,
tradeRepo *repository.TradeOrderRepository,
channelSvc *ChannelService,
appRepo *repository.AppRepository,
) *ReconciliationService {
return &ReconciliationService{
reconRepo: reconRepo,
tradeRepo: tradeRepo,
channelSvc: channelSvc,
appRepo: appRepo,
}
}
// RunDailyReconciliation 执行 T+1 对账cron 每日触发)
func (s *ReconciliationService) RunDailyReconciliation(ctx context.Context) error {
// 对账日期:昨天
billDate := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
slog.InfoContext(ctx, "reconciliation started", "bill_date", billDate)
apps, err := s.appRepo.ListActive(ctx)
if err != nil {
return err
}
for _, app := range apps {
if err := s.reconcileApp(ctx, app.AppID, billDate); err != nil {
slog.ErrorContext(ctx, "reconciliation failed for app",
"app_id", app.AppID,
"bill_date", billDate,
"error", err,
)
}
}
return nil
}
// reconcileApp 对指定应用执行对账
func (s *ReconciliationService) reconcileApp(ctx context.Context, appID, billDate string) error {
// 获取所有活跃渠道配置
channelCodes, err := s.channelSvc.ListChannelCodes(ctx, appID)
if err != nil {
return err
}
for _, code := range channelCodes {
if err := s.reconcileChannel(ctx, appID, code, billDate); err != nil {
slog.ErrorContext(ctx, "channel reconciliation failed",
"app_id", appID,
"channel", code,
"bill_date", billDate,
"error", err,
)
}
}
return nil
}
// reconcileChannel 对单个渠道执行对账
func (s *ReconciliationService) reconcileChannel(ctx context.Context, appID, channelCode, billDate string) error {
// 幂等检查
existing, err := s.reconRepo.GetReport(ctx, appID, billDate, channelCode)
if err != nil {
return err
}
if existing != nil && existing.Status == model.ReconciliationStatusMatched {
return nil // 已对账完成
}
// 创建对账报告
report := &model.ReconciliationReport{
AppID: appID,
ChannelCode: channelCode,
BillDate: billDate,
Status: model.ReconciliationStatusPending,
}
if existing == nil {
if err := s.reconRepo.CreateReport(ctx, report); err != nil {
return err
}
} else {
report = existing
}
// 下载渠道对账单
ch, err := s.channelSvc.GetChannel(ctx, appID, channelCode)
if err != nil {
return err
}
billData, err := ch.DownloadBill(ctx, &channel.DownloadBillReq{BillDate: billDate})
if err != nil {
return fmt.Errorf("download bill: %w", err)
}
// 查询本地已支付订单
localOrders, err := s.reconRepo.ListPaidOrdersByDate(ctx, appID, billDate)
if err != nil {
return err
}
// 建立本地订单索引
localIndex := make(map[string]*model.TradeOrder, len(localOrders))
for _, o := range localOrders {
localIndex[o.TradeNo] = o
}
// 建立渠道账单索引
channelIndex := make(map[string]*channel.BillRecord, len(billData.Records))
for i := range billData.Records {
channelIndex[billData.Records[i].TradeNo] = &billData.Records[i]
}
matched := 0
exceptions := 0
// 检查渠道账单中有,本地没有的(漏单)
for _, rec := range billData.Records {
local, ok := localIndex[rec.TradeNo]
if !ok {
// 本地缺失
ex := &model.ReconciliationException{
ReportID: report.ID,
TradeNo: rec.TradeNo,
ChannelBillNo: rec.ChannelBillNo,
ExceptionType: "MISSING_LOCAL",
ChannelAmount: rec.Amount,
Remark: "渠道有记录,本地无此订单",
}
s.reconRepo.CreateException(ctx, ex)
exceptions++
continue
}
// 金额比对
if local.Amount != rec.Amount {
ex := &model.ReconciliationException{
ReportID: report.ID,
TradeNo: rec.TradeNo,
ChannelBillNo: rec.ChannelBillNo,
ExceptionType: "AMOUNT_MISMATCH",
LocalAmount: local.Amount,
ChannelAmount: rec.Amount,
Remark: fmt.Sprintf("金额不符:本地%d 渠道%d", local.Amount, rec.Amount),
}
s.reconRepo.CreateException(ctx, ex)
exceptions++
} else {
matched++
}
}
// 检查本地有,渠道账单中没有的(多单)
for tradeNo, local := range localIndex {
if _, ok := channelIndex[tradeNo]; !ok {
ex := &model.ReconciliationException{
ReportID: report.ID,
TradeNo: tradeNo,
ExceptionType: "MISSING_CHANNEL",
LocalAmount: local.Amount,
Remark: "本地已支付,渠道账单无记录",
}
s.reconRepo.CreateException(ctx, ex)
exceptions++
}
}
// 更新对账报告
status := model.ReconciliationStatusMatched
if exceptions > 0 {
status = model.ReconciliationStatusException
}
updates := map[string]any{
"total_count": len(billData.Records),
"total_amount": billData.TotalAmount,
"matched_count": matched,
"exception_count": exceptions,
"status": status,
}
if err := s.reconRepo.UpdateReport(ctx, report.ID, updates); err != nil {
return err
}
slog.InfoContext(ctx, "reconciliation done",
"app_id", appID,
"channel", channelCode,
"bill_date", billDate,
"matched", matched,
"exceptions", exceptions,
)
return nil
}
// GetReport 查询对账报告
func (s *ReconciliationService) GetReport(ctx context.Context, appID, billDate, channelCode string) (*model.ReconciliationReport, error) {
return s.reconRepo.GetReport(ctx, appID, billDate, channelCode)
}
// GetExceptions 查询对账异常明细
func (s *ReconciliationService) GetExceptions(ctx context.Context, reportID uint64) ([]*model.ReconciliationException, error) {
return s.reconRepo.ListExceptions(ctx, reportID)
}