Files
pay-bridge/backend/internal/service/payment_match.go
2026-03-13 15:51:59 +08:00

281 lines
7.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}