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) }