222 lines
5.9 KiB
Go
222 lines
5.9 KiB
Go
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)
|
||
}
|