service.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. // Package service 出口自助扫码支付的订单服务。
  2. // 设计要点(doc/扫码支付出场设计.md、docs/adr/0001、docs/adr/0002):
  3. // - 订单在出口屏感知到临停车后创建,锁定应付金额,TTL 内未支付自动过期;
  4. // - 到账确认以主动查单为主(页面轮询触发 + 定时任务兜底);
  5. // - 确认到账后复用 ExitConfirm 完成结算,再主动开闸(审计来源=支付放行)。
  6. package service
  7. import (
  8. "context"
  9. "encoding/json"
  10. "errors"
  11. "fmt"
  12. "strings"
  13. "sync"
  14. "time"
  15. "github.com/gofrs/uuid/v5"
  16. "go.uber.org/zap"
  17. "gorm.io/gorm"
  18. "wails-app/internal/dao"
  19. "wails-app/internal/global"
  20. common "wails-app/internal/model/common"
  21. vehicleRequest "wails-app/internal/model/vehicle/request"
  22. vehicleResponse "wails-app/internal/model/vehicle/response"
  23. incidentService "wails-app/internal/modules/incident/service"
  24. repo "wails-app/internal/modules/order/repository"
  25. paymentChannel "wails-app/internal/modules/payment/channel"
  26. parkingSvc "wails-app/internal/service/parking"
  27. uhf "wails-app/internal/service/uhf"
  28. vehicleSvc "wails-app/internal/service/vehicle"
  29. )
  30. // PaymentEntryExitKiosk 出口自助支付入口代码(payment_entry)。
  31. const PaymentEntryExitKiosk = "exit_kiosk"
  32. // settleRetryInterval 结算失败后的重试间隔:钱已收,结算要兜底重试但不能刷库。
  33. const settleRetryInterval = 30 * time.Second
  34. type OrderService struct {
  35. repo *repo.OrderRepository
  36. mu sync.Mutex
  37. // lastQueryAt 渠道查单节流:同一订单两次查单的最小间隔由配置控制。
  38. lastQueryAt map[string]time.Time
  39. // lastSettleAt 结算重试节流。
  40. lastSettleAt map[string]time.Time
  41. }
  42. func NewOrderService() *OrderService {
  43. return &OrderService{
  44. repo: &repo.OrderRepository{},
  45. lastQueryAt: map[string]time.Time{},
  46. lastSettleAt: map[string]time.Time{},
  47. }
  48. }
  49. // OrderView 出口屏展示用的订单视图。
  50. type OrderView struct {
  51. OrderNo string `json:"order_no"`
  52. Channel string `json:"channel"`
  53. Amount float64 `json:"amount"`
  54. Currency string `json:"currency"`
  55. CodeURL string `json:"code_url"`
  56. State string `json:"state"`
  57. ExpireAt int64 `json:"expire_at"`
  58. PaidAt int64 `json:"paid_at"`
  59. SettleError string `json:"settle_error,omitempty"` // 已收款但结算/开闸失败时的提示
  60. }
  61. // KioskState 出口屏轮询的聚合状态:最近出场识别 + 费用预览 + 订单。
  62. type KioskStateResponse struct {
  63. DeviceCode string `json:"device_code"`
  64. ChannelID uint `json:"channel_id"`
  65. ChannelName string `json:"channel_name"`
  66. EventID uint64 `json:"event_id"`
  67. Recognized bool `json:"recognized"`
  68. PlateNumber string `json:"plate_number"`
  69. StayTime int64 `json:"stay_time"`
  70. Fee float64 `json:"fee"`
  71. SessionID uint `json:"session_id"`
  72. TicketNo string `json:"ticket_no"`
  73. TicketState string `json:"ticket_state"`
  74. Blacklisted bool `json:"blacklisted"`
  75. PreviewHint string `json:"preview_hint,omitempty"` // 无法进入支付流程的原因
  76. Order *OrderView `json:"order,omitempty"`
  77. }
  78. func orderTTL() time.Duration {
  79. minutes := global.GVA_CONFIG.ExitKiosk.OrderTTLMinutes
  80. if minutes <= 0 {
  81. minutes = 5
  82. }
  83. return time.Duration(minutes) * time.Minute
  84. }
  85. func queryInterval() time.Duration {
  86. seconds := global.GVA_CONFIG.ExitKiosk.QueryIntervalSeconds
  87. if seconds <= 0 {
  88. seconds = 2
  89. }
  90. return time.Duration(seconds) * time.Second
  91. }
  92. // KioskState 出口屏主轮询接口:按设备取最近的出场识别事件,
  93. // 有临停车费用时确保存在未过期订单,并对待支付订单做节流查单。
  94. func (s *OrderService) KioskState(deviceCode string) (*KioskStateResponse, error) {
  95. if global.GVA_DB == nil {
  96. return nil, errors.New("数据库未初始化")
  97. }
  98. deviceCode = strings.TrimSpace(deviceCode)
  99. if deviceCode == "" {
  100. return nil, errors.New("缺少出口设备编码")
  101. }
  102. resp := &KioskStateResponse{DeviceCode: deviceCode}
  103. var reader dao.UHFReader
  104. if err := global.GVA_DB.Preload("Channel").Where("device_code = ?", deviceCode).First(&reader).Error; err != nil {
  105. return nil, errors.New("出口设备未注册或已停用")
  106. }
  107. if reader.Channel == nil {
  108. return nil, errors.New("出口设备未绑定通道")
  109. }
  110. resp.ChannelID = reader.Channel.ID
  111. resp.ChannelName = reader.Channel.ChannelName
  112. event, ok := latestExitEvent(deviceCode)
  113. if !ok {
  114. return resp, nil
  115. }
  116. resp.EventID = event.ID
  117. resp.Recognized = true
  118. resp.PlateNumber = event.PlateNumber
  119. if resp.PlateNumber == "" {
  120. // 只有 RFID 的识别无法在出口屏发起扫码支付
  121. resp.PreviewHint = "识别结果缺少车牌,无法扫码支付"
  122. return resp, nil
  123. }
  124. var shortlist vehicleSvc.ShortlistService
  125. isBlack, _ := shortlist.CheckVehicleShortlist(resp.PlateNumber, event.RFIDTag)
  126. if isBlack {
  127. resp.Blacklisted = true
  128. return resp, nil
  129. }
  130. vehicleService := &vehicleSvc.VehicleService{}
  131. preview, err := vehicleService.ExitPreview(vehicleRequest.ExitPreviewRequest{
  132. PlateNumber: resp.PlateNumber,
  133. RFIDTag: event.RFIDTag,
  134. })
  135. if err != nil {
  136. // 会话已结束(例如刚在本屏支付完成)或确实无入场记录
  137. resp.PreviewHint = "车辆未入场或已出场"
  138. return resp, nil
  139. }
  140. resp.SessionID = preview.SessionID
  141. resp.TicketNo = preview.TicketNo
  142. resp.TicketState = preview.TicketState
  143. resp.StayTime = preview.StayTime
  144. resp.Fee = preview.Fee
  145. if preview.Fee <= 0 {
  146. resp.PreviewHint = "免费放行,无需缴费"
  147. return resp, nil
  148. }
  149. order, err := s.EnsureOrder(preview, &reader)
  150. if err != nil {
  151. resp.PreviewHint = "创建订单失败,请稍候重试"
  152. if global.GVA_LOG != nil {
  153. global.GVA_LOG.Error("出口订单创建失败", zap.String("plate", resp.PlateNumber), zap.Error(err))
  154. }
  155. return resp, nil
  156. }
  157. if err := s.refreshOrderIfDue(order); err != nil && global.GVA_LOG != nil {
  158. global.GVA_LOG.Warn("出口订单查单失败", zap.String("order_no", order.OrderNo), zap.Error(err))
  159. }
  160. resp.Order = orderView(order)
  161. return resp, nil
  162. }
  163. // EnsureOrder 复用同会话未过期的待支付订单;没有则创建并调用渠道下单。
  164. func (s *OrderService) EnsureOrder(preview vehicleResponse.ExitPreviewResponse, reader *dao.UHFReader) (*dao.ExitOrder, error) {
  165. var existing dao.ExitOrder
  166. err := global.GVA_DB.Where("session_id = ? AND state = ? AND expire_at > ?",
  167. preview.SessionID, dao.ExitOrderPending, time.Now()).Order("id DESC").First(&existing).Error
  168. if err == nil {
  169. return &existing, nil
  170. }
  171. if !errors.Is(err, gorm.ErrRecordNotFound) {
  172. return nil, err
  173. }
  174. orderNo, err := generateOrderNo()
  175. if err != nil {
  176. return nil, err
  177. }
  178. currency := strings.ToUpper(strings.TrimSpace(global.GVA_CONFIG.WechatPay.Currency))
  179. if currency == "" {
  180. currency = "CNY"
  181. }
  182. order := &dao.ExitOrder{
  183. OrderNo: orderNo,
  184. SessionID: preview.SessionID,
  185. TicketNo: preview.TicketNo,
  186. PlateNumber: preview.PlateNumber,
  187. Amount: preview.Fee,
  188. Currency: currency,
  189. Channel: paymentChannel.DefaultChannel(),
  190. State: dao.ExitOrderPending,
  191. ExpireAt: time.Now().Add(orderTTL()),
  192. ExitChannelID: reader.Channel.ID,
  193. ExitChannelCode: reader.Channel.ChannelCode,
  194. ExitChannelName: reader.Channel.ChannelName,
  195. ExitDeviceCode: reader.DeviceCode,
  196. ExitDeviceName: reader.DeviceName,
  197. }
  198. if order.Channel == "" {
  199. order.Channel = paymentChannel.ChannelMock
  200. }
  201. if err := global.GVA_DB.Create(order).Error; err != nil {
  202. return nil, err
  203. }
  204. s.appendEvent(order, "created", fmt.Sprintf("金额 %.2f %s 渠道 %s", order.Amount, order.Currency, order.Channel))
  205. if err := s.createChannelOrder(order); err != nil {
  206. // 渠道下单失败不阻塞:订单保留,下次轮询重试补 code_url
  207. s.appendEvent(order, "channel_create_failed", err.Error())
  208. return order, err
  209. }
  210. return order, nil
  211. }
  212. // createChannelOrder 调用渠道下单并回填二维码内容;确保 code_url 为空时重试。
  213. func (s *OrderService) createChannelOrder(order *dao.ExitOrder) error {
  214. ch, err := paymentChannel.Get(order.Channel)
  215. if err != nil {
  216. return err
  217. }
  218. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  219. defer cancel()
  220. result, err := ch.CreateOrder(ctx, paymentChannel.CreateOrderRequest{
  221. OrderNo: order.OrderNo,
  222. Amount: order.Amount,
  223. Currency: order.Currency,
  224. Description: fmt.Sprintf("停车费-%s", order.PlateNumber),
  225. })
  226. if err != nil {
  227. return err
  228. }
  229. return global.GVA_DB.Model(order).Update("code_url", result.CodeURL).Error
  230. }
  231. // GetOrderStatus 取订单状态,对待支付订单按节流间隔触发渠道查单。
  232. func (s *OrderService) GetOrderStatus(orderNo string) (*OrderView, error) {
  233. order, err := s.repo.GetByOrderNo(global.GVA_DB, orderNo)
  234. if err != nil {
  235. return nil, errors.New("订单不存在")
  236. }
  237. if order.State == dao.ExitOrderPending {
  238. if err := s.refreshOrderIfDue(order); err != nil && global.GVA_LOG != nil {
  239. global.GVA_LOG.Warn("订单查单失败", zap.String("order_no", orderNo), zap.Error(err))
  240. }
  241. }
  242. return orderView(order), nil
  243. }
  244. // refreshOrderIfDue 到达查单间隔才真正访问渠道,避免高频轮询打爆渠道。
  245. // 已支付订单在此兜底重试结算(settleOrder 内置 30 秒节流,重复结算幂等)。
  246. func (s *OrderService) refreshOrderIfDue(order *dao.ExitOrder) error {
  247. if order.State == dao.ExitOrderPaid {
  248. _, err := s.settleOrder(order)
  249. return err
  250. }
  251. if order.State != dao.ExitOrderPending {
  252. return nil
  253. }
  254. s.mu.Lock()
  255. last, seen := s.lastQueryAt[order.OrderNo]
  256. now := time.Now()
  257. if seen && now.Sub(last) < queryInterval() {
  258. s.mu.Unlock()
  259. return nil
  260. }
  261. s.lastQueryAt[order.OrderNo] = now
  262. s.mu.Unlock()
  263. // 二维码还没拿到(渠道下单失败)先补下单
  264. if order.CodeURL == "" {
  265. if err := s.createChannelOrder(order); err != nil {
  266. s.appendEvent(order, "channel_create_retry_failed", err.Error())
  267. return err
  268. }
  269. }
  270. ch, err := paymentChannel.Get(order.Channel)
  271. if err != nil {
  272. return err
  273. }
  274. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  275. defer cancel()
  276. result, err := ch.QueryOrder(ctx, order.OrderNo)
  277. if err != nil {
  278. return err
  279. }
  280. switch result.Status {
  281. case paymentChannel.QueryPaid:
  282. _, err = s.ConfirmOrderPaid(order, result.TransactionID)
  283. return err
  284. case paymentChannel.QueryClosed:
  285. return s.markExpired(order)
  286. }
  287. return nil
  288. }
  289. // ConfirmOrderPaid 渠道确认到账后:订单 CAS 转 paid,随后结算出场并开闸。
  290. // 结算失败不回滚订单(钱已收),记录事件并按间隔兜底重试。
  291. func (s *OrderService) ConfirmOrderPaid(order *dao.ExitOrder, txnID string) (*OrderView, error) {
  292. now := time.Now()
  293. result := global.GVA_DB.Model(&dao.ExitOrder{}).
  294. Where("id = ? AND state = ?", order.ID, dao.ExitOrderPending).
  295. Updates(map[string]interface{}{
  296. "state": dao.ExitOrderPaid,
  297. "channel_txn_id": txnID,
  298. "paid_at": now,
  299. })
  300. if result.Error != nil {
  301. return nil, result.Error
  302. }
  303. if result.RowsAffected == 0 {
  304. // 并发下别处已确认;重载保证幂等
  305. reloaded, err := s.repo.GetByOrderNo(global.GVA_DB, order.OrderNo)
  306. if err != nil {
  307. return nil, err
  308. }
  309. order = reloaded
  310. } else {
  311. order.State = dao.ExitOrderPaid
  312. order.ChannelTxnID = txnID
  313. order.PaidAt = &now
  314. s.appendEvent(order, "paid", "渠道确认到账 "+txnID)
  315. }
  316. if _, err := s.settleOrder(order); err != nil {
  317. return orderView(order), err
  318. }
  319. return orderView(order), nil
  320. }
  321. // settleOrder 结算出场并开闸。ExitConfirm 对已出场会话报错视为已结算成功,
  322. // 保证重复触发时的幂等。
  323. func (s *OrderService) settleOrder(order *dao.ExitOrder) (bool, error) {
  324. s.mu.Lock()
  325. if last, seen := s.lastSettleAt[order.OrderNo]; seen && time.Since(last) < settleRetryInterval {
  326. s.mu.Unlock()
  327. // 节流窗口内不重复尝试;若此前已成功则无副作用
  328. return true, nil
  329. }
  330. s.lastSettleAt[order.OrderNo] = time.Now()
  331. s.mu.Unlock()
  332. vehicleService := &vehicleSvc.VehicleService{}
  333. resp, err := vehicleService.ExitConfirm(vehicleRequest.ExitConfirmRequest{
  334. SessionID: order.SessionID,
  335. TicketNo: order.TicketNo,
  336. PlateNumber: order.PlateNumber,
  337. PaymentEntry: PaymentEntryExitKiosk,
  338. PaymentMethod: settleMethod(order.Channel),
  339. PaidAmount: order.Amount,
  340. ExitChannelID: order.ExitChannelID,
  341. ExitChannelCode: order.ExitChannelCode,
  342. ExitChannelName: order.ExitChannelName,
  343. ExitDeviceCode: order.ExitDeviceCode,
  344. ExitDeviceName: order.ExitDeviceName,
  345. TriggerSource: "exit_kiosk",
  346. }, 0)
  347. if err != nil {
  348. if isAlreadyExitedError(err) {
  349. return true, nil
  350. }
  351. recordSettleIncident(order, err)
  352. return false, err
  353. }
  354. if global.GVA_LOG != nil {
  355. global.GVA_LOG.Info("出口自助支付放行", zap.String("order_no", order.OrderNo),
  356. zap.String("plate", order.PlateNumber), zap.Float64("fee", resp.Fee))
  357. }
  358. s.appendEvent(order, "settled", fmt.Sprintf("结算完成 fee=%.2f,开闸", resp.Fee))
  359. // 结算成功后主动开闸(handleExit 的等价动作,来源=支付放行)
  360. parking := &parkingSvc.PassageService{}
  361. if _, err := parking.OpenGateWithContext(parkingSvc.GateCommandContext{
  362. Source: incidentService.SourcePayment,
  363. SessionID: order.SessionID,
  364. }, order.ExitDeviceCode, 2); err != nil {
  365. if global.GVA_LOG != nil {
  366. global.GVA_LOG.Error("出口自助支付开闸失败", zap.String("order_no", order.OrderNo), zap.Error(err))
  367. }
  368. recordGateIncident(order, err)
  369. return true, fmt.Errorf("开闸失败,请联系管理员放行: %w", err)
  370. }
  371. return true, nil
  372. }
  373. // ConfirmPendingOrders 定时任务兜底:对待支付订单查单确认、过期单转 expired。
  374. func (s *OrderService) ConfirmPendingOrders() (confirmed int, expired int64, err error) {
  375. if global.GVA_DB == nil {
  376. return 0, 0, errors.New("数据库未初始化")
  377. }
  378. expired = s.repo.ExpireStale(global.GVA_DB)
  379. orders, err := s.repo.ListPendingForQuery(global.GVA_DB, 50)
  380. if err != nil {
  381. return 0, expired, err
  382. }
  383. for i := range orders {
  384. order := orders[i]
  385. if err := s.refreshOrderIfDue(&order); err != nil {
  386. if global.GVA_LOG != nil {
  387. global.GVA_LOG.Warn("定时查单失败", zap.String("order_no", order.OrderNo), zap.Error(err))
  388. }
  389. continue
  390. }
  391. reloaded, rerr := s.repo.GetByOrderNo(global.GVA_DB, order.OrderNo)
  392. if rerr == nil && reloaded.State == dao.ExitOrderPaid {
  393. confirmed++
  394. }
  395. }
  396. return confirmed, expired, nil
  397. }
  398. func (s *OrderService) markExpired(order *dao.ExitOrder) error {
  399. result := global.GVA_DB.Model(&dao.ExitOrder{}).
  400. Where("id = ? AND state = ?", order.ID, dao.ExitOrderPending).
  401. Update("state", dao.ExitOrderExpired)
  402. if result.Error != nil {
  403. return result.Error
  404. }
  405. if result.RowsAffected > 0 {
  406. order.State = dao.ExitOrderExpired
  407. s.appendEvent(order, "expired", "超时未支付,订单过期")
  408. }
  409. return nil
  410. }
  411. func (s *OrderService) appendEvent(order *dao.ExitOrder, event, detail string) {
  412. var log []map[string]interface{}
  413. if order.EventLog != "" {
  414. _ = json.Unmarshal([]byte(order.EventLog), &log)
  415. }
  416. log = append(log, map[string]interface{}{
  417. "time": time.Now().Format(time.RFC3339), "event": event, "detail": detail,
  418. })
  419. if encoded, err := json.Marshal(log); err == nil {
  420. order.EventLog = string(encoded)
  421. if err := global.GVA_DB.Model(&dao.ExitOrder{}).Where("id = ?", order.ID).
  422. Update("event_log", order.EventLog).Error; err != nil && global.GVA_LOG != nil {
  423. global.GVA_LOG.Warn("订单事件写入失败", zap.String("order_no", order.OrderNo), zap.Error(err))
  424. }
  425. }
  426. }
  427. // settleMethod mock 渠道结算时归入 wechat 方式(仅联调环境使用)。
  428. func settleMethod(channelCode string) string {
  429. if channelCode == paymentChannel.ChannelAlipay {
  430. return paymentChannel.ChannelAlipay
  431. }
  432. return paymentChannel.ChannelWechat
  433. }
  434. func isAlreadyExitedError(err error) bool {
  435. if err == nil {
  436. return false
  437. }
  438. msg := err.Error()
  439. return strings.Contains(msg, "未入场、已出场") || strings.Contains(msg, "不允许出场")
  440. }
  441. func latestExitEvent(deviceCode string) (uhf.ChannelEvent, bool) {
  442. events := uhf.GetChannelEvents(0)
  443. for i := len(events) - 1; i >= 0; i-- {
  444. if events[i].DeviceCode == deviceCode && events[i].Direction == "out" {
  445. return events[i], true
  446. }
  447. }
  448. return uhf.ChannelEvent{}, false
  449. }
  450. func generateOrderNo() (string, error) {
  451. id, err := uuid.NewV4()
  452. if err != nil {
  453. return "", err
  454. }
  455. // 微信 out_trade_no 限制 6-32 字节:EK + 30 位 hex
  456. return "EK" + strings.ReplaceAll(id.String(), "-", "")[:30], nil
  457. }
  458. func orderView(order *dao.ExitOrder) *OrderView {
  459. view := &OrderView{
  460. OrderNo: order.OrderNo, Channel: order.Channel, Amount: order.Amount,
  461. Currency: order.Currency, CodeURL: order.CodeURL, State: order.State,
  462. ExpireAt: order.ExpireAt.Unix(),
  463. }
  464. if order.PaidAt != nil {
  465. view.PaidAt = order.PaidAt.Unix()
  466. }
  467. var log []map[string]interface{}
  468. if order.EventLog != "" {
  469. _ = json.Unmarshal([]byte(order.EventLog), &log)
  470. }
  471. for i := len(log) - 1; i >= 0; i-- {
  472. if log[i]["event"] == "settle_failed" {
  473. if detail, _ := log[i]["detail"].(string); detail != "" {
  474. view.SettleError = detail
  475. }
  476. break
  477. }
  478. }
  479. return view
  480. }
  481. func recordSettleIncident(order *dao.ExitOrder, err error) {
  482. incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
  483. Category: incidentService.CategoryPaymentFailed,
  484. Source: incidentService.SourcePayment,
  485. TicketNo: order.TicketNo,
  486. PlateNumber: order.PlateNumber,
  487. VehicleRecordID: order.SessionID,
  488. ChannelID: order.ExitChannelID,
  489. ChannelCode: order.ExitChannelCode,
  490. DeviceCode: order.ExitDeviceCode,
  491. Description: "扫码支付已到账但出场结算失败",
  492. Detail: err.Error(),
  493. })
  494. }
  495. func recordGateIncident(order *dao.ExitOrder, err error) {
  496. incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
  497. Category: incidentService.CategoryGateFailed,
  498. Source: incidentService.SourcePayment,
  499. TicketNo: order.TicketNo,
  500. PlateNumber: order.PlateNumber,
  501. VehicleRecordID: order.SessionID,
  502. ChannelID: order.ExitChannelID,
  503. ChannelCode: order.ExitChannelCode,
  504. DeviceCode: order.ExitDeviceCode,
  505. Description: "扫码支付已完成结算,但开闸失败",
  506. Detail: err.Error(),
  507. })
  508. }
  509. // SimulateRecognition 模拟一次 LPR 识别:走真实 HandlePassage 管线,
  510. // 并与 devicebus 一致地把成功/失败结果推入通道事件(出口屏靠它感知识别)。
  511. func SimulateRecognition(deviceCode, plate, direction string) (*common.PassageResult, error) {
  512. passages := &parkingSvc.PassageService{}
  513. result, err := passages.HandlePassage(common.PassageRequest{
  514. DeviceCode: deviceCode,
  515. PlateNumber: plate,
  516. Direction: direction,
  517. TriggerSource: "camera",
  518. })
  519. status, message, fee, stay := "completed", "", 0.0, int64(0)
  520. if err != nil {
  521. status = "failed"
  522. message = err.Error()
  523. } else if result != nil {
  524. fee = result.Fee
  525. stay = result.StayTime
  526. if result.Message != "" {
  527. message = result.Message
  528. }
  529. }
  530. uhf.PushChannelEvent(uhf.ChannelEvent{
  531. DeviceCode: deviceCode, PlateNumber: plate,
  532. Direction: direction, Status: status, Message: message, Fee: fee, StayTime: stay,
  533. })
  534. return result, err
  535. }
  536. // FirstExitDevice 取第一个绑定了出场通道的启用设备。
  537. func FirstExitDevice() (string, error) {
  538. var readers []dao.UHFReader
  539. if err := global.GVA_DB.Preload("Channel").Where("is_active = ?", true).Find(&readers).Error; err != nil {
  540. return "", err
  541. }
  542. for _, r := range readers {
  543. if r.Channel != nil && (r.Channel.Direction == "out" || r.Channel.Direction == "inout") {
  544. return r.DeviceCode, nil
  545. }
  546. }
  547. return "", errors.New("未找到绑定了出场通道的启用设备")
  548. }
  549. // BackfillEntryTime 回拨在场车辆(及其数字票)的入场时间,
  550. // 让停留时长跨过免费窗口,用于联调时稳定产生费用。仅调试端点调用。
  551. func BackfillEntryTime(plate string, hours int) error {
  552. if hours <= 0 {
  553. hours = 26
  554. }
  555. var record dao.VehicleRecord
  556. if err := global.GVA_DB.Where("plate_number = ? AND exit_time IS NULL", plate).
  557. Order("id DESC").First(&record).Error; err != nil {
  558. return fmt.Errorf("未找到在场会话: %w", err)
  559. }
  560. past := time.Now().Add(-time.Duration(hours) * time.Hour)
  561. if err := global.GVA_DB.Model(&dao.VehicleRecord{}).Where("id = ?", record.ID).
  562. Update("entry_time", past).Error; err != nil {
  563. return err
  564. }
  565. return global.GVA_DB.Model(&dao.DigitalTicket{}).Where("vehicle_record_id = ?", record.ID).
  566. Update("entry_time", past).Error
  567. }
  568. // firstEntryDevice 取第一个绑定了进场通道的启用设备。
  569. func FirstEntryDevice() (string, error) {
  570. var readers []dao.UHFReader
  571. if err := global.GVA_DB.Preload("Channel").Where("is_active = ?", true).Find(&readers).Error; err != nil {
  572. return "", err
  573. }
  574. for _, r := range readers {
  575. if r.Channel != nil && (r.Channel.Direction == "in" || r.Channel.Direction == "inout") {
  576. return r.DeviceCode, nil
  577. }
  578. }
  579. return "", errors.New("未找到绑定了进场通道的启用设备")
  580. }
  581. // DebugFullFlow 一键联调链路:确保车辆在场(不在则模拟入场识别)→
  582. // 回拨入场时间使费用必然产生 → 模拟出口识别 → mock 渠道标记支付 → 立即查单确认。
  583. // 返回最终聚合状态;黑名单等无法建单的场景原样返回原因。
  584. func (s *OrderService) DebugFullFlow(deviceCode, plate string, backfillHours int) (*KioskStateResponse, error) {
  585. if strings.TrimSpace(deviceCode) == "" {
  586. return nil, errors.New("缺少出口设备编码")
  587. }
  588. plate = strings.TrimSpace(plate)
  589. if plate == "" {
  590. return nil, errors.New("缺少车牌")
  591. }
  592. if backfillHours <= 0 {
  593. backfillHours = 26
  594. }
  595. vehicleService := &vehicleSvc.VehicleService{}
  596. if _, err := vehicleService.ExitPreview(vehicleRequest.ExitPreviewRequest{PlateNumber: plate}); err != nil {
  597. // 不在场:先模拟入口识别(真实管线,建会话+数字票)
  598. entryDevice, derr := FirstEntryDevice()
  599. if derr != nil {
  600. return nil, derr
  601. }
  602. if _, eerr := SimulateRecognition(entryDevice, plate, "in"); eerr != nil {
  603. return nil, fmt.Errorf("模拟入场失败: %w", eerr)
  604. }
  605. }
  606. // 回拨入场时间,保证出场时费用 > 0(仅 DebugRoutes 开启时可达的测试逻辑)
  607. if err := BackfillEntryTime(plate, backfillHours); err != nil {
  608. return nil, err
  609. }
  610. // 入出口同一车牌 3 秒防抖,等窗口过去再触发出场识别
  611. time.Sleep(3500 * time.Millisecond)
  612. // 出口识别:临时车有费用被拒放行属预期结果,事件已推送
  613. if _, err := SimulateRecognition(deviceCode, plate, "out"); err != nil && global.GVA_LOG != nil {
  614. global.GVA_LOG.Info("联调出口识别被拒(预期)", zap.String("plate", plate), zap.Error(err))
  615. }
  616. state, err := s.KioskState(deviceCode)
  617. if err != nil {
  618. return nil, err
  619. }
  620. if state.Order == nil || state.Order.OrderNo == "" {
  621. return state, nil
  622. }
  623. // mock 渠道模拟车主付款并立即确认(绕过查单节流)
  624. if state.Order.Channel != paymentChannel.ChannelMock {
  625. return state, nil
  626. }
  627. if err := paymentChannel.MarkMockPaid(state.Order.OrderNo); err != nil {
  628. return nil, err
  629. }
  630. order, err := s.repo.GetByOrderNo(global.GVA_DB, state.Order.OrderNo)
  631. if err != nil {
  632. return nil, err
  633. }
  634. ch, err := paymentChannel.Get(order.Channel)
  635. if err != nil {
  636. return nil, err
  637. }
  638. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  639. defer cancel()
  640. result, err := ch.QueryOrder(ctx, order.OrderNo)
  641. if err != nil {
  642. return nil, err
  643. }
  644. if result.Status == paymentChannel.QueryPaid {
  645. if _, err := s.ConfirmOrderPaid(order, result.TransactionID); err != nil && global.GVA_LOG != nil {
  646. global.GVA_LOG.Warn("联调确认到账后结算异常", zap.String("order_no", order.OrderNo), zap.Error(err))
  647. }
  648. }
  649. return s.KioskState(deviceCode)
  650. }