draft
This commit is contained in:
280
backend/internal/service/payment_match.go
Normal file
280
backend/internal/service/payment_match.go
Normal file
@@ -0,0 +1,280 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user