281 lines
7.4 KiB
Go
281 lines
7.4 KiB
Go
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 ""
|
||
}
|