This commit is contained in:
2026-03-13 15:51:59 +08:00
parent 4db2386bbf
commit 4e91f4cede
133 changed files with 19502 additions and 37 deletions

View 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 ""
}