// Package service 出口自助扫码支付的订单服务。 // 设计要点(doc/扫码支付出场设计.md、docs/adr/0001、docs/adr/0002): // - 订单在出口屏感知到临停车后创建,锁定应付金额,TTL 内未支付自动过期; // - 到账确认以主动查单为主(页面轮询触发 + 定时任务兜底); // - 确认到账后复用 ExitConfirm 完成结算,再主动开闸(审计来源=支付放行)。 package service import ( "context" "encoding/json" "errors" "fmt" "strings" "sync" "time" "github.com/gofrs/uuid/v5" "go.uber.org/zap" "gorm.io/gorm" "wails-app/internal/dao" "wails-app/internal/global" common "wails-app/internal/model/common" vehicleRequest "wails-app/internal/model/vehicle/request" vehicleResponse "wails-app/internal/model/vehicle/response" incidentService "wails-app/internal/modules/incident/service" repo "wails-app/internal/modules/order/repository" paymentChannel "wails-app/internal/modules/payment/channel" parkingSvc "wails-app/internal/service/parking" uhf "wails-app/internal/service/uhf" vehicleSvc "wails-app/internal/service/vehicle" ) // PaymentEntryExitKiosk 出口自助支付入口代码(payment_entry)。 const PaymentEntryExitKiosk = "exit_kiosk" // settleRetryInterval 结算失败后的重试间隔:钱已收,结算要兜底重试但不能刷库。 const settleRetryInterval = 30 * time.Second type OrderService struct { repo *repo.OrderRepository mu sync.Mutex // lastQueryAt 渠道查单节流:同一订单两次查单的最小间隔由配置控制。 lastQueryAt map[string]time.Time // lastSettleAt 结算重试节流。 lastSettleAt map[string]time.Time } func NewOrderService() *OrderService { return &OrderService{ repo: &repo.OrderRepository{}, lastQueryAt: map[string]time.Time{}, lastSettleAt: map[string]time.Time{}, } } // OrderView 出口屏展示用的订单视图。 type OrderView struct { OrderNo string `json:"order_no"` Channel string `json:"channel"` Amount float64 `json:"amount"` Currency string `json:"currency"` CodeURL string `json:"code_url"` State string `json:"state"` ExpireAt int64 `json:"expire_at"` PaidAt int64 `json:"paid_at"` SettleError string `json:"settle_error,omitempty"` // 已收款但结算/开闸失败时的提示 } // KioskState 出口屏轮询的聚合状态:最近出场识别 + 费用预览 + 订单。 type KioskStateResponse struct { DeviceCode string `json:"device_code"` ChannelID uint `json:"channel_id"` ChannelName string `json:"channel_name"` EventID uint64 `json:"event_id"` Recognized bool `json:"recognized"` PlateNumber string `json:"plate_number"` StayTime int64 `json:"stay_time"` Fee float64 `json:"fee"` SessionID uint `json:"session_id"` TicketNo string `json:"ticket_no"` TicketState string `json:"ticket_state"` Blacklisted bool `json:"blacklisted"` PreviewHint string `json:"preview_hint,omitempty"` // 无法进入支付流程的原因 Order *OrderView `json:"order,omitempty"` } func orderTTL() time.Duration { minutes := global.GVA_CONFIG.ExitKiosk.OrderTTLMinutes if minutes <= 0 { minutes = 5 } return time.Duration(minutes) * time.Minute } func queryInterval() time.Duration { seconds := global.GVA_CONFIG.ExitKiosk.QueryIntervalSeconds if seconds <= 0 { seconds = 2 } return time.Duration(seconds) * time.Second } // KioskState 出口屏主轮询接口:按设备取最近的出场识别事件, // 有临停车费用时确保存在未过期订单,并对待支付订单做节流查单。 func (s *OrderService) KioskState(deviceCode string) (*KioskStateResponse, error) { if global.GVA_DB == nil { return nil, errors.New("数据库未初始化") } deviceCode = strings.TrimSpace(deviceCode) if deviceCode == "" { return nil, errors.New("缺少出口设备编码") } resp := &KioskStateResponse{DeviceCode: deviceCode} var reader dao.UHFReader if err := global.GVA_DB.Preload("Channel").Where("device_code = ?", deviceCode).First(&reader).Error; err != nil { return nil, errors.New("出口设备未注册或已停用") } if reader.Channel == nil { return nil, errors.New("出口设备未绑定通道") } resp.ChannelID = reader.Channel.ID resp.ChannelName = reader.Channel.ChannelName event, ok := latestExitEvent(deviceCode) if !ok { return resp, nil } resp.EventID = event.ID resp.Recognized = true resp.PlateNumber = event.PlateNumber if resp.PlateNumber == "" { // 只有 RFID 的识别无法在出口屏发起扫码支付 resp.PreviewHint = "识别结果缺少车牌,无法扫码支付" return resp, nil } var shortlist vehicleSvc.ShortlistService isBlack, _ := shortlist.CheckVehicleShortlist(resp.PlateNumber, event.RFIDTag) if isBlack { resp.Blacklisted = true return resp, nil } vehicleService := &vehicleSvc.VehicleService{} preview, err := vehicleService.ExitPreview(vehicleRequest.ExitPreviewRequest{ PlateNumber: resp.PlateNumber, RFIDTag: event.RFIDTag, }) if err != nil { // 会话已结束(例如刚在本屏支付完成)或确实无入场记录 resp.PreviewHint = "车辆未入场或已出场" return resp, nil } resp.SessionID = preview.SessionID resp.TicketNo = preview.TicketNo resp.TicketState = preview.TicketState resp.StayTime = preview.StayTime resp.Fee = preview.Fee if preview.Fee <= 0 { resp.PreviewHint = "免费放行,无需缴费" return resp, nil } order, err := s.EnsureOrder(preview, &reader) if err != nil { resp.PreviewHint = "创建订单失败,请稍候重试" if global.GVA_LOG != nil { global.GVA_LOG.Error("出口订单创建失败", zap.String("plate", resp.PlateNumber), zap.Error(err)) } return resp, nil } if err := s.refreshOrderIfDue(order); err != nil && global.GVA_LOG != nil { global.GVA_LOG.Warn("出口订单查单失败", zap.String("order_no", order.OrderNo), zap.Error(err)) } resp.Order = orderView(order) return resp, nil } // EnsureOrder 复用同会话未过期的待支付订单;没有则创建并调用渠道下单。 func (s *OrderService) EnsureOrder(preview vehicleResponse.ExitPreviewResponse, reader *dao.UHFReader) (*dao.ExitOrder, error) { var existing dao.ExitOrder err := global.GVA_DB.Where("session_id = ? AND state = ? AND expire_at > ?", preview.SessionID, dao.ExitOrderPending, time.Now()).Order("id DESC").First(&existing).Error if err == nil { return &existing, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } orderNo, err := generateOrderNo() if err != nil { return nil, err } currency := strings.ToUpper(strings.TrimSpace(global.GVA_CONFIG.WechatPay.Currency)) if currency == "" { currency = "CNY" } order := &dao.ExitOrder{ OrderNo: orderNo, SessionID: preview.SessionID, TicketNo: preview.TicketNo, PlateNumber: preview.PlateNumber, Amount: preview.Fee, Currency: currency, Channel: paymentChannel.DefaultChannel(), State: dao.ExitOrderPending, ExpireAt: time.Now().Add(orderTTL()), ExitChannelID: reader.Channel.ID, ExitChannelCode: reader.Channel.ChannelCode, ExitChannelName: reader.Channel.ChannelName, ExitDeviceCode: reader.DeviceCode, ExitDeviceName: reader.DeviceName, } if order.Channel == "" { order.Channel = paymentChannel.ChannelMock } if err := global.GVA_DB.Create(order).Error; err != nil { return nil, err } s.appendEvent(order, "created", fmt.Sprintf("金额 %.2f %s 渠道 %s", order.Amount, order.Currency, order.Channel)) if err := s.createChannelOrder(order); err != nil { // 渠道下单失败不阻塞:订单保留,下次轮询重试补 code_url s.appendEvent(order, "channel_create_failed", err.Error()) return order, err } return order, nil } // createChannelOrder 调用渠道下单并回填二维码内容;确保 code_url 为空时重试。 func (s *OrderService) createChannelOrder(order *dao.ExitOrder) error { ch, err := paymentChannel.Get(order.Channel) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() result, err := ch.CreateOrder(ctx, paymentChannel.CreateOrderRequest{ OrderNo: order.OrderNo, Amount: order.Amount, Currency: order.Currency, Description: fmt.Sprintf("停车费-%s", order.PlateNumber), }) if err != nil { return err } return global.GVA_DB.Model(order).Update("code_url", result.CodeURL).Error } // GetOrderStatus 取订单状态,对待支付订单按节流间隔触发渠道查单。 func (s *OrderService) GetOrderStatus(orderNo string) (*OrderView, error) { order, err := s.repo.GetByOrderNo(global.GVA_DB, orderNo) if err != nil { return nil, errors.New("订单不存在") } if order.State == dao.ExitOrderPending { if err := s.refreshOrderIfDue(order); err != nil && global.GVA_LOG != nil { global.GVA_LOG.Warn("订单查单失败", zap.String("order_no", orderNo), zap.Error(err)) } } return orderView(order), nil } // refreshOrderIfDue 到达查单间隔才真正访问渠道,避免高频轮询打爆渠道。 // 已支付订单在此兜底重试结算(settleOrder 内置 30 秒节流,重复结算幂等)。 func (s *OrderService) refreshOrderIfDue(order *dao.ExitOrder) error { if order.State == dao.ExitOrderPaid { _, err := s.settleOrder(order) return err } if order.State != dao.ExitOrderPending { return nil } s.mu.Lock() last, seen := s.lastQueryAt[order.OrderNo] now := time.Now() if seen && now.Sub(last) < queryInterval() { s.mu.Unlock() return nil } s.lastQueryAt[order.OrderNo] = now s.mu.Unlock() // 二维码还没拿到(渠道下单失败)先补下单 if order.CodeURL == "" { if err := s.createChannelOrder(order); err != nil { s.appendEvent(order, "channel_create_retry_failed", err.Error()) return err } } ch, err := paymentChannel.Get(order.Channel) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() result, err := ch.QueryOrder(ctx, order.OrderNo) if err != nil { return err } switch result.Status { case paymentChannel.QueryPaid: _, err = s.ConfirmOrderPaid(order, result.TransactionID) return err case paymentChannel.QueryClosed: return s.markExpired(order) } return nil } // ConfirmOrderPaid 渠道确认到账后:订单 CAS 转 paid,随后结算出场并开闸。 // 结算失败不回滚订单(钱已收),记录事件并按间隔兜底重试。 func (s *OrderService) ConfirmOrderPaid(order *dao.ExitOrder, txnID string) (*OrderView, error) { now := time.Now() result := global.GVA_DB.Model(&dao.ExitOrder{}). Where("id = ? AND state = ?", order.ID, dao.ExitOrderPending). Updates(map[string]interface{}{ "state": dao.ExitOrderPaid, "channel_txn_id": txnID, "paid_at": now, }) if result.Error != nil { return nil, result.Error } if result.RowsAffected == 0 { // 并发下别处已确认;重载保证幂等 reloaded, err := s.repo.GetByOrderNo(global.GVA_DB, order.OrderNo) if err != nil { return nil, err } order = reloaded } else { order.State = dao.ExitOrderPaid order.ChannelTxnID = txnID order.PaidAt = &now s.appendEvent(order, "paid", "渠道确认到账 "+txnID) } if _, err := s.settleOrder(order); err != nil { return orderView(order), err } return orderView(order), nil } // settleOrder 结算出场并开闸。ExitConfirm 对已出场会话报错视为已结算成功, // 保证重复触发时的幂等。 func (s *OrderService) settleOrder(order *dao.ExitOrder) (bool, error) { s.mu.Lock() if last, seen := s.lastSettleAt[order.OrderNo]; seen && time.Since(last) < settleRetryInterval { s.mu.Unlock() // 节流窗口内不重复尝试;若此前已成功则无副作用 return true, nil } s.lastSettleAt[order.OrderNo] = time.Now() s.mu.Unlock() vehicleService := &vehicleSvc.VehicleService{} resp, err := vehicleService.ExitConfirm(vehicleRequest.ExitConfirmRequest{ SessionID: order.SessionID, TicketNo: order.TicketNo, PlateNumber: order.PlateNumber, PaymentEntry: PaymentEntryExitKiosk, PaymentMethod: settleMethod(order.Channel), PaidAmount: order.Amount, ExitChannelID: order.ExitChannelID, ExitChannelCode: order.ExitChannelCode, ExitChannelName: order.ExitChannelName, ExitDeviceCode: order.ExitDeviceCode, ExitDeviceName: order.ExitDeviceName, TriggerSource: "exit_kiosk", }, 0) if err != nil { if isAlreadyExitedError(err) { return true, nil } recordSettleIncident(order, err) return false, err } if global.GVA_LOG != nil { global.GVA_LOG.Info("出口自助支付放行", zap.String("order_no", order.OrderNo), zap.String("plate", order.PlateNumber), zap.Float64("fee", resp.Fee)) } s.appendEvent(order, "settled", fmt.Sprintf("结算完成 fee=%.2f,开闸", resp.Fee)) // 结算成功后主动开闸(handleExit 的等价动作,来源=支付放行) parking := &parkingSvc.PassageService{} if _, err := parking.OpenGateWithContext(parkingSvc.GateCommandContext{ Source: incidentService.SourcePayment, SessionID: order.SessionID, }, order.ExitDeviceCode, 2); err != nil { if global.GVA_LOG != nil { global.GVA_LOG.Error("出口自助支付开闸失败", zap.String("order_no", order.OrderNo), zap.Error(err)) } recordGateIncident(order, err) return true, fmt.Errorf("开闸失败,请联系管理员放行: %w", err) } return true, nil } // ConfirmPendingOrders 定时任务兜底:对待支付订单查单确认、过期单转 expired。 func (s *OrderService) ConfirmPendingOrders() (confirmed int, expired int64, err error) { if global.GVA_DB == nil { return 0, 0, errors.New("数据库未初始化") } expired = s.repo.ExpireStale(global.GVA_DB) orders, err := s.repo.ListPendingForQuery(global.GVA_DB, 50) if err != nil { return 0, expired, err } for i := range orders { order := orders[i] if err := s.refreshOrderIfDue(&order); err != nil { if global.GVA_LOG != nil { global.GVA_LOG.Warn("定时查单失败", zap.String("order_no", order.OrderNo), zap.Error(err)) } continue } reloaded, rerr := s.repo.GetByOrderNo(global.GVA_DB, order.OrderNo) if rerr == nil && reloaded.State == dao.ExitOrderPaid { confirmed++ } } return confirmed, expired, nil } func (s *OrderService) markExpired(order *dao.ExitOrder) error { result := global.GVA_DB.Model(&dao.ExitOrder{}). Where("id = ? AND state = ?", order.ID, dao.ExitOrderPending). Update("state", dao.ExitOrderExpired) if result.Error != nil { return result.Error } if result.RowsAffected > 0 { order.State = dao.ExitOrderExpired s.appendEvent(order, "expired", "超时未支付,订单过期") } return nil } func (s *OrderService) appendEvent(order *dao.ExitOrder, event, detail string) { var log []map[string]interface{} if order.EventLog != "" { _ = json.Unmarshal([]byte(order.EventLog), &log) } log = append(log, map[string]interface{}{ "time": time.Now().Format(time.RFC3339), "event": event, "detail": detail, }) if encoded, err := json.Marshal(log); err == nil { order.EventLog = string(encoded) if err := global.GVA_DB.Model(&dao.ExitOrder{}).Where("id = ?", order.ID). Update("event_log", order.EventLog).Error; err != nil && global.GVA_LOG != nil { global.GVA_LOG.Warn("订单事件写入失败", zap.String("order_no", order.OrderNo), zap.Error(err)) } } } // settleMethod mock 渠道结算时归入 wechat 方式(仅联调环境使用)。 func settleMethod(channelCode string) string { if channelCode == paymentChannel.ChannelAlipay { return paymentChannel.ChannelAlipay } return paymentChannel.ChannelWechat } func isAlreadyExitedError(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "未入场、已出场") || strings.Contains(msg, "不允许出场") } func latestExitEvent(deviceCode string) (uhf.ChannelEvent, bool) { events := uhf.GetChannelEvents(0) for i := len(events) - 1; i >= 0; i-- { if events[i].DeviceCode == deviceCode && events[i].Direction == "out" { return events[i], true } } return uhf.ChannelEvent{}, false } func generateOrderNo() (string, error) { id, err := uuid.NewV4() if err != nil { return "", err } // 微信 out_trade_no 限制 6-32 字节:EK + 30 位 hex return "EK" + strings.ReplaceAll(id.String(), "-", "")[:30], nil } func orderView(order *dao.ExitOrder) *OrderView { view := &OrderView{ OrderNo: order.OrderNo, Channel: order.Channel, Amount: order.Amount, Currency: order.Currency, CodeURL: order.CodeURL, State: order.State, ExpireAt: order.ExpireAt.Unix(), } if order.PaidAt != nil { view.PaidAt = order.PaidAt.Unix() } var log []map[string]interface{} if order.EventLog != "" { _ = json.Unmarshal([]byte(order.EventLog), &log) } for i := len(log) - 1; i >= 0; i-- { if log[i]["event"] == "settle_failed" { if detail, _ := log[i]["detail"].(string); detail != "" { view.SettleError = detail } break } } return view } func recordSettleIncident(order *dao.ExitOrder, err error) { incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{ Category: incidentService.CategoryPaymentFailed, Source: incidentService.SourcePayment, TicketNo: order.TicketNo, PlateNumber: order.PlateNumber, VehicleRecordID: order.SessionID, ChannelID: order.ExitChannelID, ChannelCode: order.ExitChannelCode, DeviceCode: order.ExitDeviceCode, Description: "扫码支付已到账但出场结算失败", Detail: err.Error(), }) } func recordGateIncident(order *dao.ExitOrder, err error) { incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{ Category: incidentService.CategoryGateFailed, Source: incidentService.SourcePayment, TicketNo: order.TicketNo, PlateNumber: order.PlateNumber, VehicleRecordID: order.SessionID, ChannelID: order.ExitChannelID, ChannelCode: order.ExitChannelCode, DeviceCode: order.ExitDeviceCode, Description: "扫码支付已完成结算,但开闸失败", Detail: err.Error(), }) } // SimulateRecognition 模拟一次 LPR 识别:走真实 HandlePassage 管线, // 并与 devicebus 一致地把成功/失败结果推入通道事件(出口屏靠它感知识别)。 func SimulateRecognition(deviceCode, plate, direction string) (*common.PassageResult, error) { passages := &parkingSvc.PassageService{} result, err := passages.HandlePassage(common.PassageRequest{ DeviceCode: deviceCode, PlateNumber: plate, Direction: direction, TriggerSource: "camera", }) status, message, fee, stay := "completed", "", 0.0, int64(0) if err != nil { status = "failed" message = err.Error() } else if result != nil { fee = result.Fee stay = result.StayTime if result.Message != "" { message = result.Message } } uhf.PushChannelEvent(uhf.ChannelEvent{ DeviceCode: deviceCode, PlateNumber: plate, Direction: direction, Status: status, Message: message, Fee: fee, StayTime: stay, }) return result, err } // FirstExitDevice 取第一个绑定了出场通道的启用设备。 func FirstExitDevice() (string, error) { var readers []dao.UHFReader if err := global.GVA_DB.Preload("Channel").Where("is_active = ?", true).Find(&readers).Error; err != nil { return "", err } for _, r := range readers { if r.Channel != nil && (r.Channel.Direction == "out" || r.Channel.Direction == "inout") { return r.DeviceCode, nil } } return "", errors.New("未找到绑定了出场通道的启用设备") } // BackfillEntryTime 回拨在场车辆(及其数字票)的入场时间, // 让停留时长跨过免费窗口,用于联调时稳定产生费用。仅调试端点调用。 func BackfillEntryTime(plate string, hours int) error { if hours <= 0 { hours = 26 } var record dao.VehicleRecord if err := global.GVA_DB.Where("plate_number = ? AND exit_time IS NULL", plate). Order("id DESC").First(&record).Error; err != nil { return fmt.Errorf("未找到在场会话: %w", err) } past := time.Now().Add(-time.Duration(hours) * time.Hour) if err := global.GVA_DB.Model(&dao.VehicleRecord{}).Where("id = ?", record.ID). Update("entry_time", past).Error; err != nil { return err } return global.GVA_DB.Model(&dao.DigitalTicket{}).Where("vehicle_record_id = ?", record.ID). Update("entry_time", past).Error } // firstEntryDevice 取第一个绑定了进场通道的启用设备。 func FirstEntryDevice() (string, error) { var readers []dao.UHFReader if err := global.GVA_DB.Preload("Channel").Where("is_active = ?", true).Find(&readers).Error; err != nil { return "", err } for _, r := range readers { if r.Channel != nil && (r.Channel.Direction == "in" || r.Channel.Direction == "inout") { return r.DeviceCode, nil } } return "", errors.New("未找到绑定了进场通道的启用设备") } // DebugFullFlow 一键联调链路:确保车辆在场(不在则模拟入场识别)→ // 回拨入场时间使费用必然产生 → 模拟出口识别 → mock 渠道标记支付 → 立即查单确认。 // 返回最终聚合状态;黑名单等无法建单的场景原样返回原因。 func (s *OrderService) DebugFullFlow(deviceCode, plate string, backfillHours int) (*KioskStateResponse, error) { if strings.TrimSpace(deviceCode) == "" { return nil, errors.New("缺少出口设备编码") } plate = strings.TrimSpace(plate) if plate == "" { return nil, errors.New("缺少车牌") } if backfillHours <= 0 { backfillHours = 26 } vehicleService := &vehicleSvc.VehicleService{} if _, err := vehicleService.ExitPreview(vehicleRequest.ExitPreviewRequest{PlateNumber: plate}); err != nil { // 不在场:先模拟入口识别(真实管线,建会话+数字票) entryDevice, derr := FirstEntryDevice() if derr != nil { return nil, derr } if _, eerr := SimulateRecognition(entryDevice, plate, "in"); eerr != nil { return nil, fmt.Errorf("模拟入场失败: %w", eerr) } } // 回拨入场时间,保证出场时费用 > 0(仅 DebugRoutes 开启时可达的测试逻辑) if err := BackfillEntryTime(plate, backfillHours); err != nil { return nil, err } // 入出口同一车牌 3 秒防抖,等窗口过去再触发出场识别 time.Sleep(3500 * time.Millisecond) // 出口识别:临时车有费用被拒放行属预期结果,事件已推送 if _, err := SimulateRecognition(deviceCode, plate, "out"); err != nil && global.GVA_LOG != nil { global.GVA_LOG.Info("联调出口识别被拒(预期)", zap.String("plate", plate), zap.Error(err)) } state, err := s.KioskState(deviceCode) if err != nil { return nil, err } if state.Order == nil || state.Order.OrderNo == "" { return state, nil } // mock 渠道模拟车主付款并立即确认(绕过查单节流) if state.Order.Channel != paymentChannel.ChannelMock { return state, nil } if err := paymentChannel.MarkMockPaid(state.Order.OrderNo); err != nil { return nil, err } order, err := s.repo.GetByOrderNo(global.GVA_DB, state.Order.OrderNo) if err != nil { return nil, err } ch, err := paymentChannel.Get(order.Channel) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() result, err := ch.QueryOrder(ctx, order.OrderNo) if err != nil { return nil, err } if result.Status == paymentChannel.QueryPaid { if _, err := s.ConfirmOrderPaid(order, result.TransactionID); err != nil && global.GVA_LOG != nil { global.GVA_LOG.Warn("联调确认到账后结算异常", zap.String("order_no", order.OrderNo), zap.Error(err)) } } return s.KioskState(deviceCode) }