| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700 |
- // 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)
- }
|