service.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. package service
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "time"
  7. "wails-app/internal/dao"
  8. "wails-app/internal/global"
  9. "wails-app/internal/modules/incident/repository"
  10. "go.uber.org/zap"
  11. )
  12. // ===================== 常量 =====================
  13. // 异常状态
  14. const (
  15. StatusPending = "pending"
  16. StatusProcessing = "processing"
  17. StatusResolved = "resolved"
  18. StatusClosed = "closed"
  19. )
  20. // 异常来源
  21. const (
  22. SourcePassage = "passage"
  23. SourceManual = "manual"
  24. SourceDevice = "device"
  25. SourcePayment = "payment"
  26. SourcePrinter = "printer"
  27. SourceSystem = "system"
  28. )
  29. // 异常分类
  30. const (
  31. CategoryGateFailed = "gate_failed"
  32. CategoryDuplicateEntry = "duplicate_entry"
  33. CategoryBlacklist = "blacklist"
  34. CategoryLotFull = "lot_full"
  35. CategoryNoEntryExit = "no_entry_exit"
  36. CategoryManualRaise = "manual_raise"
  37. CategoryDeviceOffline = "device_offline"
  38. CategoryPrintFailed = "print_failed"
  39. CategoryPaymentFailed = "payment_failed"
  40. CategoryPaymentUncertain = "payment_uncertain"
  41. )
  42. // 异常等级
  43. const (
  44. LevelInfo = "info"
  45. LevelWarning = "warning"
  46. LevelCritical = "critical"
  47. )
  48. // 处置方式
  49. const (
  50. HandleManualGate = "manual_gate"
  51. HandleForceFree = "force_free"
  52. HandleReset = "reset"
  53. HandleDeviceRepaired = "device_repaired"
  54. HandleReprint = "reprint"
  55. HandleIgnore = "ignore"
  56. HandleOther = "other"
  57. )
  58. // 指令流水结果
  59. const (
  60. CommandSuccess = "success"
  61. CommandFailed = "failed"
  62. )
  63. // 资金相关分类(统计与权限关注)
  64. var fundRelatedCategories = map[string]bool{
  65. CategoryPaymentFailed: true,
  66. CategoryPaymentUncertain: true,
  67. CategoryGateFailed: true,
  68. CategoryNoEntryExit: true,
  69. }
  70. // 分类默认等级
  71. var categoryDefaultLevel = map[string]string{
  72. CategoryGateFailed: LevelCritical,
  73. CategoryDuplicateEntry: LevelWarning,
  74. CategoryBlacklist: LevelWarning,
  75. CategoryLotFull: LevelInfo,
  76. CategoryNoEntryExit: LevelCritical,
  77. CategoryManualRaise: LevelWarning,
  78. CategoryDeviceOffline: LevelWarning,
  79. CategoryPrintFailed: LevelWarning,
  80. CategoryPaymentFailed: LevelWarning,
  81. CategoryPaymentUncertain: LevelCritical,
  82. }
  83. // 状态机:仅允许以下流转
  84. var allowedTransitions = map[string]map[string]bool{
  85. StatusPending: {StatusProcessing: true, StatusResolved: true},
  86. StatusProcessing: {StatusResolved: true},
  87. StatusResolved: {StatusClosed: true, StatusPending: true},
  88. StatusClosed: {StatusPending: true},
  89. }
  90. var (
  91. ErrIncidentNotFound = errors.New("异常记录不存在")
  92. ErrIncidentChanged = errors.New("异常状态已被他人变更,请刷新后重试")
  93. )
  94. // ===================== 服务 =====================
  95. // logError/logWarn/logInfo 带 nil 防护的日志(测试环境与单测中 GVA_LOG 可为空)。
  96. func logError(msg string, fields ...zap.Field) {
  97. if global.GVA_LOG != nil {
  98. global.GVA_LOG.Error(msg, fields...)
  99. }
  100. }
  101. func logWarn(msg string, fields ...zap.Field) {
  102. if global.GVA_LOG != nil {
  103. global.GVA_LOG.Warn(msg, fields...)
  104. }
  105. }
  106. func logInfo(msg string, fields ...zap.Field) {
  107. if global.GVA_LOG != nil {
  108. global.GVA_LOG.Info(msg, fields...)
  109. }
  110. }
  111. type IncidentService struct {
  112. repo *repository.IncidentRepository
  113. }
  114. func NewIncidentService() *IncidentService {
  115. return &IncidentService{repo: &repository.IncidentRepository{}}
  116. }
  117. // RecordIncidentRequest 自动埋点入参。HandleType/HandleRemark 非空时创建即置为已解决
  118. // (用于人工抬杆等"动作已完成、仅需留痕"的场景)。
  119. type RecordIncidentRequest struct {
  120. Category string
  121. Source string
  122. Level string
  123. VehicleRecordID uint
  124. DigitalTicketID uint
  125. PaymentRecordID uint
  126. GateCommandID uint
  127. TicketNo string
  128. PlateNumber string
  129. RFIDTag string
  130. ParkingLotID uint
  131. ParkingLotName string
  132. ChannelID uint
  133. ChannelCode string
  134. ChannelName string
  135. DeviceCode string
  136. DeviceName string
  137. OperatorID uint
  138. Description string
  139. Detail interface{}
  140. HandleType string
  141. HandleRemark string
  142. }
  143. func enrichSessionAssociation(req *RecordIncidentRequest) {
  144. var record dao.VehicleRecord
  145. if req.VehicleRecordID != 0 && global.GVA_DB.First(&record, req.VehicleRecordID).Error == nil {
  146. if req.PlateNumber == "" {
  147. req.PlateNumber = record.PlateNumber
  148. }
  149. if req.RFIDTag == "" {
  150. req.RFIDTag = record.RFIDTag
  151. }
  152. if req.ParkingLotID == 0 {
  153. req.ParkingLotID = record.ParkingLotID
  154. }
  155. if req.ChannelID == 0 {
  156. if record.ExitTime != nil && record.ExitChannelID != 0 {
  157. req.ChannelID = record.ExitChannelID
  158. } else {
  159. req.ChannelID = record.EntryChannelID
  160. }
  161. }
  162. if req.ChannelCode == "" {
  163. if record.ExitTime != nil && record.ExitChannelCode != "" {
  164. req.ChannelCode = record.ExitChannelCode
  165. } else {
  166. req.ChannelCode = record.EntryChannelCode
  167. }
  168. }
  169. if req.ChannelName == "" {
  170. if record.ExitTime != nil && record.ExitChannelName != "" {
  171. req.ChannelName = record.ExitChannelName
  172. } else {
  173. req.ChannelName = record.EntryChannelName
  174. }
  175. }
  176. if req.DeviceCode == "" {
  177. if record.ExitTime != nil && record.ExitDeviceCode != "" {
  178. req.DeviceCode = record.ExitDeviceCode
  179. } else {
  180. req.DeviceCode = record.EntryDeviceCode
  181. }
  182. }
  183. if req.DeviceName == "" {
  184. if record.ExitTime != nil && record.ExitDeviceName != "" {
  185. req.DeviceName = record.ExitDeviceName
  186. } else {
  187. req.DeviceName = record.EntryDeviceName
  188. }
  189. }
  190. }
  191. }
  192. // enrichAssociations 根据稳定 ID、票号和设备编码补齐异常上下文快照。
  193. // 业务埋点通常只知道会话或设备,统一在异常服务中解析,避免各调用方重复查询。
  194. func enrichAssociations(req *RecordIncidentRequest) {
  195. if req == nil || global.GVA_DB == nil {
  196. return
  197. }
  198. enrichSessionAssociation(req)
  199. var ticket dao.DigitalTicket
  200. if req.DigitalTicketID != 0 {
  201. _ = global.GVA_DB.First(&ticket, req.DigitalTicketID).Error
  202. } else if req.TicketNo != "" {
  203. _ = global.GVA_DB.Where("ticket_no = ?", req.TicketNo).First(&ticket).Error
  204. } else if req.VehicleRecordID != 0 {
  205. _ = global.GVA_DB.Where("vehicle_record_id = ?", req.VehicleRecordID).First(&ticket).Error
  206. }
  207. if ticket.ID != 0 {
  208. req.DigitalTicketID = ticket.ID
  209. if req.VehicleRecordID == 0 {
  210. req.VehicleRecordID = ticket.VehicleRecordID
  211. }
  212. if req.TicketNo == "" {
  213. req.TicketNo = ticket.TicketNo
  214. }
  215. if req.PlateNumber == "" {
  216. req.PlateNumber = ticket.PlateNumber
  217. }
  218. }
  219. // 仅传票号时会在上一步得到停车会话 ID,此处继续补齐车辆与进出场位置快照。
  220. enrichSessionAssociation(req)
  221. if req.PaymentRecordID == 0 && req.VehicleRecordID != 0 {
  222. var payment dao.PaymentRecord
  223. if global.GVA_DB.Where("record_id = ?", req.VehicleRecordID).Order("id DESC").First(&payment).Error == nil {
  224. req.PaymentRecordID = payment.ID
  225. }
  226. }
  227. if req.GateCommandID != 0 {
  228. var command dao.DeviceCommandLog
  229. if global.GVA_DB.First(&command, req.GateCommandID).Error == nil {
  230. if req.DeviceCode == "" {
  231. req.DeviceCode = command.DeviceCode
  232. }
  233. if req.DeviceName == "" {
  234. req.DeviceName = command.DeviceName
  235. }
  236. }
  237. }
  238. if req.ChannelID != 0 {
  239. var channel dao.Channel
  240. if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
  241. if req.ChannelCode == "" {
  242. req.ChannelCode = channel.ChannelCode
  243. }
  244. if req.ChannelName == "" {
  245. req.ChannelName = channel.ChannelName
  246. }
  247. if req.ParkingLotID == 0 {
  248. req.ParkingLotID = channel.ParkingLotID
  249. }
  250. }
  251. }
  252. if req.DeviceCode != "" {
  253. var device dao.UHFReader
  254. if global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&device).Error == nil {
  255. if req.DeviceName == "" {
  256. req.DeviceName = device.DeviceName
  257. }
  258. if req.ChannelID == 0 {
  259. req.ChannelID = device.ChannelID
  260. }
  261. if req.ParkingLotID == 0 {
  262. req.ParkingLotID = device.ParkingLotID
  263. }
  264. }
  265. }
  266. // 设备可能刚刚补齐通道 ID,再查询一次通道名称和所属停车场。
  267. if req.ChannelID != 0 && (req.ChannelCode == "" || req.ChannelName == "" || req.ParkingLotID == 0) {
  268. var channel dao.Channel
  269. if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
  270. if req.ChannelCode == "" {
  271. req.ChannelCode = channel.ChannelCode
  272. }
  273. if req.ChannelName == "" {
  274. req.ChannelName = channel.ChannelName
  275. }
  276. if req.ParkingLotID == 0 {
  277. req.ParkingLotID = channel.ParkingLotID
  278. }
  279. }
  280. }
  281. if req.ParkingLotID != 0 && req.ParkingLotName == "" {
  282. var lot dao.ParkingLot
  283. if global.GVA_DB.First(&lot, req.ParkingLotID).Error == nil {
  284. req.ParkingLotName = lot.LotName
  285. }
  286. }
  287. }
  288. func validateAssociationIDs(req CreateIncidentRequest) error {
  289. checks := []struct {
  290. id uint
  291. model interface{}
  292. name string
  293. }{
  294. {req.VehicleRecordID, &dao.VehicleRecord{}, "停车会话"},
  295. {req.DigitalTicketID, &dao.DigitalTicket{}, "数字票"},
  296. {req.PaymentRecordID, &dao.PaymentRecord{}, "支付流水"},
  297. {req.GateCommandID, &dao.DeviceCommandLog{}, "闸机指令"},
  298. {req.ParkingLotID, &dao.ParkingLot{}, "停车场"},
  299. {req.ChannelID, &dao.Channel{}, "通道"},
  300. }
  301. for _, check := range checks {
  302. if check.id == 0 {
  303. continue
  304. }
  305. var count int64
  306. if err := global.GVA_DB.Model(check.model).Where("id = ?", check.id).Count(&count).Error; err != nil {
  307. return err
  308. }
  309. if count == 0 {
  310. return fmt.Errorf("关联的%s不存在: %d", check.name, check.id)
  311. }
  312. }
  313. return nil
  314. }
  315. // RecordIncident 自动埋点入口(best-effort):
  316. // 内部任何错误只记日志并返回 0,绝不影响业务主流程;成功返回异常记录 ID(供指令流水关联)。
  317. func (s *IncidentService) RecordIncident(req RecordIncidentRequest) uint {
  318. if req.Category == "" {
  319. return 0
  320. }
  321. if global.GVA_DB == nil {
  322. logWarn("异常埋点跳过:数据库未初始化", zap.String("category", req.Category))
  323. return 0
  324. }
  325. enrichAssociations(&req)
  326. if req.Source == "" {
  327. req.Source = SourceSystem
  328. }
  329. if req.Level == "" {
  330. req.Level = categoryDefaultLevel[req.Category]
  331. if req.Level == "" {
  332. req.Level = LevelWarning
  333. }
  334. }
  335. // 去重:离线异常同设备未关闭不重复;通行类同标识时间窗内不重复
  336. var exists bool
  337. var err error
  338. if req.Category == CategoryDeviceOffline {
  339. exists, err = s.repo.HasOpenDeviceOffline(req.DeviceCode)
  340. } else {
  341. exists, err = s.repo.ExistsRecent(req.DeviceCode, req.Category, req.PlateNumber, req.RFIDTag, 5)
  342. }
  343. if err != nil {
  344. logWarn("异常去重查询失败,继续记录", zap.Error(err))
  345. } else if exists {
  346. return 0
  347. }
  348. status := StatusPending
  349. if req.HandleType != "" {
  350. status = StatusResolved
  351. }
  352. detail := ""
  353. if req.Detail != nil {
  354. if data, marshalErr := json.Marshal(req.Detail); marshalErr == nil {
  355. detail = string(data)
  356. if len(detail) > 1000 {
  357. detail = detail[:1000]
  358. }
  359. }
  360. }
  361. no, err := s.generateIncidentNo()
  362. if err != nil {
  363. logError("生成异常编号失败", zap.Error(err))
  364. return 0
  365. }
  366. now := time.Now()
  367. event := map[string]interface{}{"event": "created", "source": req.Source}
  368. record := &dao.IncidentRecord{
  369. IncidentNo: no,
  370. Category: req.Category,
  371. Source: req.Source,
  372. Level: req.Level,
  373. Status: status,
  374. VehicleRecordID: req.VehicleRecordID,
  375. DigitalTicketID: req.DigitalTicketID,
  376. PaymentRecordID: req.PaymentRecordID,
  377. GateCommandID: req.GateCommandID,
  378. TicketNo: req.TicketNo,
  379. PlateNumber: req.PlateNumber,
  380. RFIDTag: req.RFIDTag,
  381. ParkingLotID: req.ParkingLotID,
  382. ParkingLotName: req.ParkingLotName,
  383. ChannelID: req.ChannelID,
  384. ChannelCode: req.ChannelCode,
  385. ChannelName: req.ChannelName,
  386. DeviceCode: req.DeviceCode,
  387. DeviceName: req.DeviceName,
  388. OperatorID: req.OperatorID,
  389. Description: req.Description,
  390. Detail: detail,
  391. HandlerID: req.OperatorID,
  392. HandleType: req.HandleType,
  393. HandleRemark: req.HandleRemark,
  394. EventLog: appendEvent("[]", event),
  395. }
  396. if status == StatusResolved {
  397. record.HandledAt = &now
  398. }
  399. if err := s.repo.Create(record); err != nil {
  400. logError("记录异常事件失败",
  401. zap.Error(err), zap.String("category", req.Category), zap.String("device_code", req.DeviceCode))
  402. return 0
  403. }
  404. logInfo("异常事件已记录",
  405. zap.String("incident_no", no), zap.String("category", req.Category), zap.String("status", status))
  406. return record.ID
  407. }
  408. // CreateIncidentRequest 人工上报入参。
  409. type CreateIncidentRequest struct {
  410. Category string `json:"category" binding:"required"`
  411. Level string `json:"level"`
  412. VehicleRecordID uint `json:"vehicle_record_id"`
  413. DigitalTicketID uint `json:"digital_ticket_id"`
  414. PaymentRecordID uint `json:"payment_record_id"`
  415. GateCommandID uint `json:"gate_command_id"`
  416. TicketNo string `json:"ticket_no"`
  417. PlateNumber string `json:"plate_number"`
  418. RFIDTag string `json:"rfid_tag"`
  419. ParkingLotID uint `json:"parking_lot_id"`
  420. ParkingLotName string `json:"parking_lot_name"`
  421. ChannelID uint `json:"channel_id"`
  422. ChannelCode string `json:"channel_code"`
  423. ChannelName string `json:"channel_name"`
  424. DeviceCode string `json:"device_code"`
  425. DeviceName string `json:"device_name"`
  426. Description string `json:"description" binding:"required"`
  427. Detail string `json:"detail"`
  428. }
  429. // CreateIncident 人工上报异常(来源 manual,待处理状态)。
  430. func (s *IncidentService) CreateIncident(req CreateIncidentRequest, operatorID uint) (*dao.IncidentRecord, error) {
  431. if _, known := categoryDefaultLevel[req.Category]; !known {
  432. return nil, errors.New("未知异常分类: " + req.Category)
  433. }
  434. if err := validateAssociationIDs(req); err != nil {
  435. return nil, err
  436. }
  437. level := req.Level
  438. if level == "" {
  439. level = categoryDefaultLevel[req.Category]
  440. if level == "" {
  441. level = LevelWarning
  442. }
  443. }
  444. autoReq := RecordIncidentRequest{
  445. VehicleRecordID: req.VehicleRecordID, DigitalTicketID: req.DigitalTicketID, PaymentRecordID: req.PaymentRecordID, GateCommandID: req.GateCommandID,
  446. TicketNo: req.TicketNo, PlateNumber: req.PlateNumber, RFIDTag: req.RFIDTag, ParkingLotID: req.ParkingLotID, ParkingLotName: req.ParkingLotName,
  447. ChannelID: req.ChannelID, ChannelCode: req.ChannelCode, ChannelName: req.ChannelName, DeviceCode: req.DeviceCode, DeviceName: req.DeviceName,
  448. }
  449. enrichAssociations(&autoReq)
  450. no, err := s.generateIncidentNo()
  451. if err != nil {
  452. return nil, err
  453. }
  454. record := &dao.IncidentRecord{
  455. IncidentNo: no,
  456. Category: req.Category,
  457. Source: SourceManual,
  458. Level: level,
  459. Status: StatusPending,
  460. VehicleRecordID: autoReq.VehicleRecordID,
  461. DigitalTicketID: autoReq.DigitalTicketID,
  462. PaymentRecordID: autoReq.PaymentRecordID,
  463. GateCommandID: autoReq.GateCommandID,
  464. TicketNo: autoReq.TicketNo,
  465. PlateNumber: autoReq.PlateNumber,
  466. RFIDTag: autoReq.RFIDTag,
  467. ParkingLotID: autoReq.ParkingLotID,
  468. ParkingLotName: autoReq.ParkingLotName,
  469. ChannelID: autoReq.ChannelID,
  470. ChannelCode: autoReq.ChannelCode,
  471. ChannelName: autoReq.ChannelName,
  472. DeviceCode: autoReq.DeviceCode,
  473. DeviceName: autoReq.DeviceName,
  474. OperatorID: operatorID,
  475. Description: req.Description,
  476. Detail: req.Detail,
  477. EventLog: appendEvent("[]", map[string]interface{}{"event": "created", "source": SourceManual}),
  478. }
  479. if err := s.repo.Create(record); err != nil {
  480. return nil, err
  481. }
  482. return record, nil
  483. }
  484. // TransitionRequest 状态流转+处置入参。
  485. type TransitionRequest struct {
  486. ToStatus string `json:"to_status" binding:"required"`
  487. HandleType string `json:"handle_type"`
  488. HandleRemark string `json:"handle_remark"`
  489. ForceFreeAmount float64 `json:"force_free_amount"`
  490. }
  491. // TransitionIncident 状态流转(CAS 条件更新)。
  492. // isAdmin 表示是否允许资金类处置(force_free)。
  493. func (s *IncidentService) TransitionIncident(id uint, req TransitionRequest, operatorID uint, isAdmin bool) (*dao.IncidentRecord, error) {
  494. incident, err := s.repo.GetByID(id)
  495. if err != nil {
  496. return nil, ErrIncidentNotFound
  497. }
  498. toStatus := req.ToStatus
  499. if toStatus == incident.Status {
  500. return incident, nil
  501. }
  502. if !allowedTransitions[incident.Status][toStatus] {
  503. return nil, fmt.Errorf("状态转换不合法: %s → %s", incident.Status, toStatus)
  504. }
  505. // 处置校验
  506. if toStatus == StatusResolved {
  507. if req.HandleType == "" {
  508. return nil, errors.New("解决异常必须填写处置方式")
  509. }
  510. if req.HandleRemark == "" {
  511. return nil, errors.New("解决异常必须填写处置备注")
  512. }
  513. }
  514. if req.HandleType == HandleForceFree {
  515. if !isAdmin {
  516. return nil, errors.New("强制免费处置需要管理员权限")
  517. }
  518. if req.ForceFreeAmount < 0 {
  519. return nil, errors.New("强制免费金额不能为负数")
  520. }
  521. }
  522. updates := map[string]interface{}{
  523. "handler_id": operatorID,
  524. "handle_type": req.HandleType,
  525. "handle_remark": req.HandleRemark,
  526. }
  527. event := map[string]interface{}{"event": toStatus, "handler_id": operatorID}
  528. if toStatus == StatusResolved {
  529. now := time.Now()
  530. updates["handled_at"] = now
  531. updates["force_free_amount"] = req.ForceFreeAmount
  532. event["handle_type"] = req.HandleType
  533. if req.HandleType == HandleForceFree {
  534. event["force_free_amount"] = req.ForceFreeAmount
  535. }
  536. }
  537. updates["event_log"] = appendEvent(incident.EventLog, event)
  538. updated, err := s.repo.UpdateStateIfCurrent(id, incident.Status, toStatus, updates)
  539. if err != nil {
  540. return nil, err
  541. }
  542. if !updated {
  543. return nil, ErrIncidentChanged
  544. }
  545. return s.repo.GetByID(id)
  546. }
  547. // GetIncident 查询详情。
  548. func (s *IncidentService) GetIncident(id uint) (*dao.IncidentRecord, error) {
  549. return s.repo.GetByID(id)
  550. }
  551. // ListIncidents 分页筛选。
  552. func (s *IncidentService) ListIncidents(q repository.IncidentListQuery) ([]repository.IncidentListItem, int64, error) {
  553. return s.repo.List(q)
  554. }
  555. // GetIncidentStats 异常统计。
  556. func (s *IncidentService) GetIncidentStats() (repository.IncidentStats, error) {
  557. return s.repo.Stats()
  558. }
  559. // ResolveDeviceOffline 设备恢复在线:关闭该设备未处理的离线异常(best-effort)。
  560. func (s *IncidentService) ResolveDeviceOffline(deviceCode string) {
  561. if err := s.repo.ResolveDeviceOfflineByDevice(deviceCode, "设备恢复在线"); err != nil {
  562. logWarn("关闭设备离线异常失败",
  563. zap.Error(err), zap.String("device_code", deviceCode))
  564. }
  565. }
  566. // GateCommandRecord 道闸指令流水入参。
  567. type GateCommandRecord struct {
  568. DeviceCode string
  569. DeviceName string
  570. Action string // open / close
  571. Source string // passage / manual / test
  572. OperatorID uint
  573. SessionID uint
  574. Result string // success / failed
  575. ErrorMessage string
  576. DurationMs int64
  577. }
  578. // RecordGateCommand 记录道闸指令流水(best-effort),返回流水 ID 供异常关联。
  579. func (s *IncidentService) RecordGateCommand(rec GateCommandRecord) uint {
  580. if rec.DeviceCode == "" {
  581. return 0
  582. }
  583. if rec.Source == "" {
  584. rec.Source = SourcePassage
  585. }
  586. log := &dao.DeviceCommandLog{
  587. DeviceCode: rec.DeviceCode,
  588. DeviceName: rec.DeviceName,
  589. Action: rec.Action,
  590. Source: rec.Source,
  591. OperatorID: rec.OperatorID,
  592. SessionID: rec.SessionID,
  593. Result: rec.Result,
  594. ErrorMessage: rec.ErrorMessage,
  595. DurationMs: rec.DurationMs,
  596. }
  597. if err := s.repo.CreateCommandLog(log); err != nil {
  598. logWarn("记录道闸指令流水失败", zap.Error(err))
  599. return 0
  600. }
  601. return log.ID
  602. }
  603. // LinkIncidentToCommand 回填指令流水关联的异常事件 ID(best-effort)。
  604. func (s *IncidentService) LinkIncidentToCommand(logID, incidentID uint) {
  605. if err := s.repo.UpdateIncidentID(logID, incidentID); err != nil {
  606. logWarn("回填指令流水异常关联失败", zap.Error(err))
  607. }
  608. }
  609. // generateIncidentNo 生成唯一编号:INC + yyyyMMdd + 当日序号(冲突时重试)。
  610. func (s *IncidentService) generateIncidentNo() (string, error) {
  611. prefix := "INC" + time.Now().Format("20060102")
  612. for attempt := 0; attempt < 5; attempt++ {
  613. var count int64
  614. if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
  615. Where("incident_no LIKE ?", prefix+"%").Count(&count).Error; err != nil {
  616. return "", err
  617. }
  618. no := fmt.Sprintf("%s-%04d", prefix, count+1)
  619. var dup int64
  620. if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
  621. Where("incident_no = ?", no).Count(&dup).Error; err != nil {
  622. return "", err
  623. }
  624. if dup == 0 {
  625. return no, nil
  626. }
  627. }
  628. return "", errors.New("生成异常编号失败,请重试")
  629. }
  630. // appendEvent 向事件日志 JSON 数组追加事件(沿用数字票 event_log 约定)。
  631. func appendEvent(log string, event map[string]interface{}) string {
  632. var events []map[string]interface{}
  633. json.Unmarshal([]byte(log), &events)
  634. event["at"] = time.Now().Format(time.RFC3339)
  635. events = append(events, event)
  636. data, _ := json.Marshal(events)
  637. return string(data)
  638. }