draft
This commit is contained in:
221
backend/internal/service/reconciliation.go
Normal file
221
backend/internal/service/reconciliation.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user