package service import ( "context" "log/slog" "regexp" "strings" "time" "pay-bridge/internal/model" "pay-bridge/internal/repository" ) // orderNoPatterns 从备注中提取订单号的正则列表(优先级从高到低) var orderNoPatterns = []*regexp.Regexp{ regexp.MustCompile(`PAY\d{14}`), // pay-bridge 交易号格式 PAYyyMMddNNNNNNNN regexp.MustCompile(`REF\d{14}`), // 退款单号 regexp.MustCompile(`[A-Z0-9]{16,32}`), // 通用订单号格式 } // IncomingPayment 入账通知数据 type IncomingPayment struct { AccountNo string // 收款账号 Amount int64 // 入账金额(分) Remark string // 转账备注 PayerName string // 付款方名称 ChannelBillNo string // 渠道流水号 } // matchWindow 匹配时间窗口(7天内的待支付订单) const matchWindow = 7 * 24 * time.Hour // PaymentMatchService 收款匹配服务 type PaymentMatchService struct { matchRepo *repository.PaymentMatchRepository tradeRepo *repository.TradeOrderRepository notifySvc *NotifyService tradeSvc *TradeService } func NewPaymentMatchService( matchRepo *repository.PaymentMatchRepository, tradeRepo *repository.TradeOrderRepository, notifySvc *NotifyService, tradeSvc *TradeService, ) *PaymentMatchService { return &PaymentMatchService{ matchRepo: matchRepo, tradeRepo: tradeRepo, notifySvc: notifySvc, tradeSvc: tradeSvc, } } // HandleIncomingPayment 处理入账通知(核心匹配流程) func (s *PaymentMatchService) HandleIncomingPayment(ctx context.Context, incoming *IncomingPayment) error { // 幂等检查 if existing, _ := s.matchRepo.GetMatchLogByBillNo(ctx, incoming.ChannelBillNo); existing != nil { return nil } // 查询收款账户 account, err := s.matchRepo.GetAccountByNo(ctx, incoming.AccountNo) if err != nil { return err } if account == nil { slog.WarnContext(ctx, "incoming payment: account not found", "account_no", incoming.AccountNo) return nil } // 执行匹配 result := s.match(ctx, incoming, account) // 记录匹配结果 now := time.Now() log := &model.PaymentMatchLog{ AccountID: account.ID, IncomingAmount: incoming.Amount, IncomingRemark: incoming.Remark, PayerName: incoming.PayerName, ChannelBillNo: incoming.ChannelBillNo, MatchStatus: result.status, NameDiff: result.nameDiff, } if result.tradeNo != "" { log.TradeNo = result.tradeNo log.MatchTime = &now } if err := s.matchRepo.CreateMatchLog(ctx, log); err != nil { return err } // 匹配成功:更新订单状态并通知下游 if result.tradeNo != "" { updates := map[string]any{ "status": model.TradeStatusPaid, "pay_time": now, } ok, err := s.tradeRepo.UpdateStatus(ctx, result.tradeNo, model.TradeStatusPaying, model.TradeStatusPaid, updates) if err != nil { return err } if ok { order, _ := s.tradeRepo.GetByTradeNo(ctx, result.tradeNo) if order != nil && s.notifySvc != nil { go func() { bgCtx := context.Background() s.notifySvc.SendNotify(bgCtx, result.tradeNo, model.NotifyTypePayment, order.NotifyURL) }() } } slog.InfoContext(ctx, "payment matched", "trade_no", result.tradeNo, "amount", incoming.Amount, "status", result.status, "name_diff", result.nameDiff, ) } else { slog.InfoContext(ctx, "payment pending manual", "channel_bill_no", incoming.ChannelBillNo, "amount", incoming.Amount, ) } return nil } // ManualBindOrder 人工关联入账与订单 func (s *PaymentMatchService) ManualBindOrder(ctx context.Context, matchID uint64, tradeNo, operator string) error { order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo) if err != nil || order == nil { return err } now := time.Now() updates := map[string]any{ "trade_no": tradeNo, "match_status": model.MatchStatusMatched, "match_time": now, "operator": operator, } if err := s.matchRepo.UpdateMatchLog(ctx, matchID, updates); err != nil { return err } // 更新订单状态 s.tradeRepo.UpdateStatus(ctx, tradeNo, model.TradeStatusPaying, model.TradeStatusPaid, map[string]any{"pay_time": now}) if s.notifySvc != nil { go func() { bgCtx := context.Background() s.notifySvc.SendNotify(bgCtx, tradeNo, model.NotifyTypePayment, order.NotifyURL) }() } return nil } // ListPendingManual 查询待人工确认的收款记录 func (s *PaymentMatchService) ListPendingManual(ctx context.Context, appID string, limit, offset int) ([]*model.PaymentMatchLog, error) { return s.matchRepo.ListPendingManual(ctx, appID, limit, offset) } // --- 内部匹配逻辑 --- type matchResult struct { tradeNo string status model.MatchStatus nameDiff int8 } func (s *PaymentMatchService) match(ctx context.Context, incoming *IncomingPayment, account *model.SubMerchantAccount) matchResult { // Step 1: 从备注中提取订单号 candidates := extractOrderNos(incoming.Remark) var matched *model.TradeOrder for _, orderNo := range candidates { // 先按 trade_no 查,再按 merchant_order_no 查 order, _ := s.tradeRepo.GetByTradeNo(ctx, orderNo) if order == nil { order, _ = s.tradeRepo.GetByMerchantOrderNo(ctx, account.AppID, orderNo) } if order == nil || order.AppID != account.AppID { continue } if order.Status != model.TradeStatusPaying { continue } // Step 2: 金额精确匹配 if order.Amount != incoming.Amount { continue } matched = order break } // 备注匹配失败,降级为金额匹配 if matched == nil { orders, _ := s.matchRepo.ListPayingByAmount(ctx, account.AppID, incoming.Amount, matchWindow) if len(orders) == 1 { matched = orders[0] } else if len(orders) > 1 { // Step 3: 用付款方名称缩小范围 matched = filterByPayerName(orders, incoming.PayerName) if matched == nil { return matchResult{status: model.MatchStatusPendingManual} } } else { return matchResult{status: model.MatchStatusPendingManual} } } // Step 3: 付款方名称一致性检查 var nameDiff int8 = 0 invoiceName := getInvoiceName(matched) if invoiceName != "" && incoming.PayerName != "" { if !strings.EqualFold(strings.TrimSpace(invoiceName), strings.TrimSpace(incoming.PayerName)) { nameDiff = 1 } } status := model.MatchStatusMatched if nameDiff == 1 { status = model.MatchStatusNameDiff } return matchResult{ tradeNo: matched.TradeNo, status: status, nameDiff: nameDiff, } } // extractOrderNos 从备注字符串中提取可能的订单号 func extractOrderNos(remark string) []string { if remark == "" { return nil } var results []string seen := map[string]bool{} for _, re := range orderNoPatterns { matches := re.FindAllString(remark, -1) for _, m := range matches { if !seen[m] { seen[m] = true results = append(results, m) } } } return results } // filterByPayerName 从多个候选订单中,选择 invoice_name 与付款方名称匹配的订单 // invoice_name 暂存在 extra 字段中 func filterByPayerName(orders []*model.TradeOrder, payerName string) *model.TradeOrder { if payerName == "" { return nil } for _, o := range orders { name := getInvoiceName(o) if name != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(payerName)) { return o } } return nil } // getInvoiceName 从 extra 字段获取开票名称 func getInvoiceName(order *model.TradeOrder) string { if order.Extra == nil { return "" } if v, ok := order.Extra["invoice_name"]; ok { if s, ok := v.(string); ok { return s } } return "" }