This commit is contained in:
2026-03-13 15:51:59 +08:00
parent 4db2386bbf
commit 4e91f4cede
133 changed files with 19502 additions and 37 deletions

View File

@@ -0,0 +1,221 @@
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)
}