|
@@ -0,0 +1,700 @@
|
|
|
|
|
+// 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)
|
|
|
|
|
+}
|