|
|
@@ -0,0 +1,686 @@
|
|
|
+package service
|
|
|
+
|
|
|
+import (
|
|
|
+ "encoding/json"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "time"
|
|
|
+ "wails-app/internal/dao"
|
|
|
+ "wails-app/internal/global"
|
|
|
+ "wails-app/internal/modules/incident/repository"
|
|
|
+
|
|
|
+ "go.uber.org/zap"
|
|
|
+)
|
|
|
+
|
|
|
+// ===================== 常量 =====================
|
|
|
+
|
|
|
+// 异常状态
|
|
|
+const (
|
|
|
+ StatusPending = "pending"
|
|
|
+ StatusProcessing = "processing"
|
|
|
+ StatusResolved = "resolved"
|
|
|
+ StatusClosed = "closed"
|
|
|
+)
|
|
|
+
|
|
|
+// 异常来源
|
|
|
+const (
|
|
|
+ SourcePassage = "passage"
|
|
|
+ SourceManual = "manual"
|
|
|
+ SourceDevice = "device"
|
|
|
+ SourcePayment = "payment"
|
|
|
+ SourcePrinter = "printer"
|
|
|
+ SourceSystem = "system"
|
|
|
+)
|
|
|
+
|
|
|
+// 异常分类
|
|
|
+const (
|
|
|
+ CategoryGateFailed = "gate_failed"
|
|
|
+ CategoryDuplicateEntry = "duplicate_entry"
|
|
|
+ CategoryBlacklist = "blacklist"
|
|
|
+ CategoryLotFull = "lot_full"
|
|
|
+ CategoryNoEntryExit = "no_entry_exit"
|
|
|
+ CategoryManualRaise = "manual_raise"
|
|
|
+ CategoryDeviceOffline = "device_offline"
|
|
|
+ CategoryPrintFailed = "print_failed"
|
|
|
+ CategoryPaymentFailed = "payment_failed"
|
|
|
+ CategoryPaymentUncertain = "payment_uncertain"
|
|
|
+)
|
|
|
+
|
|
|
+// 异常等级
|
|
|
+const (
|
|
|
+ LevelInfo = "info"
|
|
|
+ LevelWarning = "warning"
|
|
|
+ LevelCritical = "critical"
|
|
|
+)
|
|
|
+
|
|
|
+// 处置方式
|
|
|
+const (
|
|
|
+ HandleManualGate = "manual_gate"
|
|
|
+ HandleForceFree = "force_free"
|
|
|
+ HandleReset = "reset"
|
|
|
+ HandleDeviceRepaired = "device_repaired"
|
|
|
+ HandleReprint = "reprint"
|
|
|
+ HandleIgnore = "ignore"
|
|
|
+ HandleOther = "other"
|
|
|
+)
|
|
|
+
|
|
|
+// 指令流水结果
|
|
|
+const (
|
|
|
+ CommandSuccess = "success"
|
|
|
+ CommandFailed = "failed"
|
|
|
+)
|
|
|
+
|
|
|
+// 资金相关分类(统计与权限关注)
|
|
|
+var fundRelatedCategories = map[string]bool{
|
|
|
+ CategoryPaymentFailed: true,
|
|
|
+ CategoryPaymentUncertain: true,
|
|
|
+ CategoryGateFailed: true,
|
|
|
+ CategoryNoEntryExit: true,
|
|
|
+}
|
|
|
+
|
|
|
+// 分类默认等级
|
|
|
+var categoryDefaultLevel = map[string]string{
|
|
|
+ CategoryGateFailed: LevelCritical,
|
|
|
+ CategoryDuplicateEntry: LevelWarning,
|
|
|
+ CategoryBlacklist: LevelWarning,
|
|
|
+ CategoryLotFull: LevelInfo,
|
|
|
+ CategoryNoEntryExit: LevelCritical,
|
|
|
+ CategoryManualRaise: LevelWarning,
|
|
|
+ CategoryDeviceOffline: LevelWarning,
|
|
|
+ CategoryPrintFailed: LevelWarning,
|
|
|
+ CategoryPaymentFailed: LevelWarning,
|
|
|
+ CategoryPaymentUncertain: LevelCritical,
|
|
|
+}
|
|
|
+
|
|
|
+// 状态机:仅允许以下流转
|
|
|
+var allowedTransitions = map[string]map[string]bool{
|
|
|
+ StatusPending: {StatusProcessing: true, StatusResolved: true},
|
|
|
+ StatusProcessing: {StatusResolved: true},
|
|
|
+ StatusResolved: {StatusClosed: true, StatusPending: true},
|
|
|
+ StatusClosed: {StatusPending: true},
|
|
|
+}
|
|
|
+
|
|
|
+var (
|
|
|
+ ErrIncidentNotFound = errors.New("异常记录不存在")
|
|
|
+ ErrIncidentChanged = errors.New("异常状态已被他人变更,请刷新后重试")
|
|
|
+)
|
|
|
+
|
|
|
+// ===================== 服务 =====================
|
|
|
+
|
|
|
+// logError/logWarn/logInfo 带 nil 防护的日志(测试环境与单测中 GVA_LOG 可为空)。
|
|
|
+func logError(msg string, fields ...zap.Field) {
|
|
|
+ if global.GVA_LOG != nil {
|
|
|
+ global.GVA_LOG.Error(msg, fields...)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func logWarn(msg string, fields ...zap.Field) {
|
|
|
+ if global.GVA_LOG != nil {
|
|
|
+ global.GVA_LOG.Warn(msg, fields...)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func logInfo(msg string, fields ...zap.Field) {
|
|
|
+ if global.GVA_LOG != nil {
|
|
|
+ global.GVA_LOG.Info(msg, fields...)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type IncidentService struct {
|
|
|
+ repo *repository.IncidentRepository
|
|
|
+}
|
|
|
+
|
|
|
+func NewIncidentService() *IncidentService {
|
|
|
+ return &IncidentService{repo: &repository.IncidentRepository{}}
|
|
|
+}
|
|
|
+
|
|
|
+// RecordIncidentRequest 自动埋点入参。HandleType/HandleRemark 非空时创建即置为已解决
|
|
|
+// (用于人工抬杆等"动作已完成、仅需留痕"的场景)。
|
|
|
+type RecordIncidentRequest struct {
|
|
|
+ Category string
|
|
|
+ Source string
|
|
|
+ Level string
|
|
|
+ VehicleRecordID uint
|
|
|
+ DigitalTicketID uint
|
|
|
+ PaymentRecordID uint
|
|
|
+ GateCommandID uint
|
|
|
+ TicketNo string
|
|
|
+ PlateNumber string
|
|
|
+ RFIDTag string
|
|
|
+ ParkingLotID uint
|
|
|
+ ParkingLotName string
|
|
|
+ ChannelID uint
|
|
|
+ ChannelCode string
|
|
|
+ ChannelName string
|
|
|
+ DeviceCode string
|
|
|
+ DeviceName string
|
|
|
+ OperatorID uint
|
|
|
+ Description string
|
|
|
+ Detail interface{}
|
|
|
+ HandleType string
|
|
|
+ HandleRemark string
|
|
|
+}
|
|
|
+
|
|
|
+func enrichSessionAssociation(req *RecordIncidentRequest) {
|
|
|
+ var record dao.VehicleRecord
|
|
|
+ if req.VehicleRecordID != 0 && global.GVA_DB.First(&record, req.VehicleRecordID).Error == nil {
|
|
|
+ if req.PlateNumber == "" {
|
|
|
+ req.PlateNumber = record.PlateNumber
|
|
|
+ }
|
|
|
+ if req.RFIDTag == "" {
|
|
|
+ req.RFIDTag = record.RFIDTag
|
|
|
+ }
|
|
|
+ if req.ParkingLotID == 0 {
|
|
|
+ req.ParkingLotID = record.ParkingLotID
|
|
|
+ }
|
|
|
+ if req.ChannelID == 0 {
|
|
|
+ if record.ExitTime != nil && record.ExitChannelID != 0 {
|
|
|
+ req.ChannelID = record.ExitChannelID
|
|
|
+ } else {
|
|
|
+ req.ChannelID = record.EntryChannelID
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.ChannelCode == "" {
|
|
|
+ if record.ExitTime != nil && record.ExitChannelCode != "" {
|
|
|
+ req.ChannelCode = record.ExitChannelCode
|
|
|
+ } else {
|
|
|
+ req.ChannelCode = record.EntryChannelCode
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.ChannelName == "" {
|
|
|
+ if record.ExitTime != nil && record.ExitChannelName != "" {
|
|
|
+ req.ChannelName = record.ExitChannelName
|
|
|
+ } else {
|
|
|
+ req.ChannelName = record.EntryChannelName
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.DeviceCode == "" {
|
|
|
+ if record.ExitTime != nil && record.ExitDeviceCode != "" {
|
|
|
+ req.DeviceCode = record.ExitDeviceCode
|
|
|
+ } else {
|
|
|
+ req.DeviceCode = record.EntryDeviceCode
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.DeviceName == "" {
|
|
|
+ if record.ExitTime != nil && record.ExitDeviceName != "" {
|
|
|
+ req.DeviceName = record.ExitDeviceName
|
|
|
+ } else {
|
|
|
+ req.DeviceName = record.EntryDeviceName
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// enrichAssociations 根据稳定 ID、票号和设备编码补齐异常上下文快照。
|
|
|
+// 业务埋点通常只知道会话或设备,统一在异常服务中解析,避免各调用方重复查询。
|
|
|
+func enrichAssociations(req *RecordIncidentRequest) {
|
|
|
+ if req == nil || global.GVA_DB == nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ enrichSessionAssociation(req)
|
|
|
+
|
|
|
+ var ticket dao.DigitalTicket
|
|
|
+ if req.DigitalTicketID != 0 {
|
|
|
+ _ = global.GVA_DB.First(&ticket, req.DigitalTicketID).Error
|
|
|
+ } else if req.TicketNo != "" {
|
|
|
+ _ = global.GVA_DB.Where("ticket_no = ?", req.TicketNo).First(&ticket).Error
|
|
|
+ } else if req.VehicleRecordID != 0 {
|
|
|
+ _ = global.GVA_DB.Where("vehicle_record_id = ?", req.VehicleRecordID).First(&ticket).Error
|
|
|
+ }
|
|
|
+ if ticket.ID != 0 {
|
|
|
+ req.DigitalTicketID = ticket.ID
|
|
|
+ if req.VehicleRecordID == 0 {
|
|
|
+ req.VehicleRecordID = ticket.VehicleRecordID
|
|
|
+ }
|
|
|
+ if req.TicketNo == "" {
|
|
|
+ req.TicketNo = ticket.TicketNo
|
|
|
+ }
|
|
|
+ if req.PlateNumber == "" {
|
|
|
+ req.PlateNumber = ticket.PlateNumber
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 仅传票号时会在上一步得到停车会话 ID,此处继续补齐车辆与进出场位置快照。
|
|
|
+ enrichSessionAssociation(req)
|
|
|
+
|
|
|
+ if req.PaymentRecordID == 0 && req.VehicleRecordID != 0 {
|
|
|
+ var payment dao.PaymentRecord
|
|
|
+ if global.GVA_DB.Where("record_id = ?", req.VehicleRecordID).Order("id DESC").First(&payment).Error == nil {
|
|
|
+ req.PaymentRecordID = payment.ID
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.GateCommandID != 0 {
|
|
|
+ var command dao.DeviceCommandLog
|
|
|
+ if global.GVA_DB.First(&command, req.GateCommandID).Error == nil {
|
|
|
+ if req.DeviceCode == "" {
|
|
|
+ req.DeviceCode = command.DeviceCode
|
|
|
+ }
|
|
|
+ if req.DeviceName == "" {
|
|
|
+ req.DeviceName = command.DeviceName
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.ChannelID != 0 {
|
|
|
+ var channel dao.Channel
|
|
|
+ if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
|
|
|
+ if req.ChannelCode == "" {
|
|
|
+ req.ChannelCode = channel.ChannelCode
|
|
|
+ }
|
|
|
+ if req.ChannelName == "" {
|
|
|
+ req.ChannelName = channel.ChannelName
|
|
|
+ }
|
|
|
+ if req.ParkingLotID == 0 {
|
|
|
+ req.ParkingLotID = channel.ParkingLotID
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.DeviceCode != "" {
|
|
|
+ var device dao.UHFReader
|
|
|
+ if global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&device).Error == nil {
|
|
|
+ if req.DeviceName == "" {
|
|
|
+ req.DeviceName = device.DeviceName
|
|
|
+ }
|
|
|
+ if req.ChannelID == 0 {
|
|
|
+ req.ChannelID = device.ChannelID
|
|
|
+ }
|
|
|
+ if req.ParkingLotID == 0 {
|
|
|
+ req.ParkingLotID = device.ParkingLotID
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 设备可能刚刚补齐通道 ID,再查询一次通道名称和所属停车场。
|
|
|
+ if req.ChannelID != 0 && (req.ChannelCode == "" || req.ChannelName == "" || req.ParkingLotID == 0) {
|
|
|
+ var channel dao.Channel
|
|
|
+ if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
|
|
|
+ if req.ChannelCode == "" {
|
|
|
+ req.ChannelCode = channel.ChannelCode
|
|
|
+ }
|
|
|
+ if req.ChannelName == "" {
|
|
|
+ req.ChannelName = channel.ChannelName
|
|
|
+ }
|
|
|
+ if req.ParkingLotID == 0 {
|
|
|
+ req.ParkingLotID = channel.ParkingLotID
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.ParkingLotID != 0 && req.ParkingLotName == "" {
|
|
|
+ var lot dao.ParkingLot
|
|
|
+ if global.GVA_DB.First(&lot, req.ParkingLotID).Error == nil {
|
|
|
+ req.ParkingLotName = lot.LotName
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func validateAssociationIDs(req CreateIncidentRequest) error {
|
|
|
+ checks := []struct {
|
|
|
+ id uint
|
|
|
+ model interface{}
|
|
|
+ name string
|
|
|
+ }{
|
|
|
+ {req.VehicleRecordID, &dao.VehicleRecord{}, "停车会话"},
|
|
|
+ {req.DigitalTicketID, &dao.DigitalTicket{}, "数字票"},
|
|
|
+ {req.PaymentRecordID, &dao.PaymentRecord{}, "支付流水"},
|
|
|
+ {req.GateCommandID, &dao.DeviceCommandLog{}, "闸机指令"},
|
|
|
+ {req.ParkingLotID, &dao.ParkingLot{}, "停车场"},
|
|
|
+ {req.ChannelID, &dao.Channel{}, "通道"},
|
|
|
+ }
|
|
|
+ for _, check := range checks {
|
|
|
+ if check.id == 0 {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ var count int64
|
|
|
+ if err := global.GVA_DB.Model(check.model).Where("id = ?", check.id).Count(&count).Error; err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if count == 0 {
|
|
|
+ return fmt.Errorf("关联的%s不存在: %d", check.name, check.id)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// RecordIncident 自动埋点入口(best-effort):
|
|
|
+// 内部任何错误只记日志并返回 0,绝不影响业务主流程;成功返回异常记录 ID(供指令流水关联)。
|
|
|
+func (s *IncidentService) RecordIncident(req RecordIncidentRequest) uint {
|
|
|
+ if req.Category == "" {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ if global.GVA_DB == nil {
|
|
|
+ logWarn("异常埋点跳过:数据库未初始化", zap.String("category", req.Category))
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ enrichAssociations(&req)
|
|
|
+ if req.Source == "" {
|
|
|
+ req.Source = SourceSystem
|
|
|
+ }
|
|
|
+ if req.Level == "" {
|
|
|
+ req.Level = categoryDefaultLevel[req.Category]
|
|
|
+ if req.Level == "" {
|
|
|
+ req.Level = LevelWarning
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 去重:离线异常同设备未关闭不重复;通行类同标识时间窗内不重复
|
|
|
+ var exists bool
|
|
|
+ var err error
|
|
|
+ if req.Category == CategoryDeviceOffline {
|
|
|
+ exists, err = s.repo.HasOpenDeviceOffline(req.DeviceCode)
|
|
|
+ } else {
|
|
|
+ exists, err = s.repo.ExistsRecent(req.DeviceCode, req.Category, req.PlateNumber, req.RFIDTag, 5)
|
|
|
+ }
|
|
|
+ if err != nil {
|
|
|
+ logWarn("异常去重查询失败,继续记录", zap.Error(err))
|
|
|
+ } else if exists {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+
|
|
|
+ status := StatusPending
|
|
|
+ if req.HandleType != "" {
|
|
|
+ status = StatusResolved
|
|
|
+ }
|
|
|
+ detail := ""
|
|
|
+ if req.Detail != nil {
|
|
|
+ if data, marshalErr := json.Marshal(req.Detail); marshalErr == nil {
|
|
|
+ detail = string(data)
|
|
|
+ if len(detail) > 1000 {
|
|
|
+ detail = detail[:1000]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ no, err := s.generateIncidentNo()
|
|
|
+ if err != nil {
|
|
|
+ logError("生成异常编号失败", zap.Error(err))
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+
|
|
|
+ now := time.Now()
|
|
|
+ event := map[string]interface{}{"event": "created", "source": req.Source}
|
|
|
+ record := &dao.IncidentRecord{
|
|
|
+ IncidentNo: no,
|
|
|
+ Category: req.Category,
|
|
|
+ Source: req.Source,
|
|
|
+ Level: req.Level,
|
|
|
+ Status: status,
|
|
|
+ VehicleRecordID: req.VehicleRecordID,
|
|
|
+ DigitalTicketID: req.DigitalTicketID,
|
|
|
+ PaymentRecordID: req.PaymentRecordID,
|
|
|
+ GateCommandID: req.GateCommandID,
|
|
|
+ TicketNo: req.TicketNo,
|
|
|
+ PlateNumber: req.PlateNumber,
|
|
|
+ RFIDTag: req.RFIDTag,
|
|
|
+ ParkingLotID: req.ParkingLotID,
|
|
|
+ ParkingLotName: req.ParkingLotName,
|
|
|
+ ChannelID: req.ChannelID,
|
|
|
+ ChannelCode: req.ChannelCode,
|
|
|
+ ChannelName: req.ChannelName,
|
|
|
+ DeviceCode: req.DeviceCode,
|
|
|
+ DeviceName: req.DeviceName,
|
|
|
+ OperatorID: req.OperatorID,
|
|
|
+ Description: req.Description,
|
|
|
+ Detail: detail,
|
|
|
+ HandlerID: req.OperatorID,
|
|
|
+ HandleType: req.HandleType,
|
|
|
+ HandleRemark: req.HandleRemark,
|
|
|
+ EventLog: appendEvent("[]", event),
|
|
|
+ }
|
|
|
+ if status == StatusResolved {
|
|
|
+ record.HandledAt = &now
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := s.repo.Create(record); err != nil {
|
|
|
+ logError("记录异常事件失败",
|
|
|
+ zap.Error(err), zap.String("category", req.Category), zap.String("device_code", req.DeviceCode))
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ logInfo("异常事件已记录",
|
|
|
+ zap.String("incident_no", no), zap.String("category", req.Category), zap.String("status", status))
|
|
|
+ return record.ID
|
|
|
+}
|
|
|
+
|
|
|
+// CreateIncidentRequest 人工上报入参。
|
|
|
+type CreateIncidentRequest struct {
|
|
|
+ Category string `json:"category" binding:"required"`
|
|
|
+ Level string `json:"level"`
|
|
|
+ VehicleRecordID uint `json:"vehicle_record_id"`
|
|
|
+ DigitalTicketID uint `json:"digital_ticket_id"`
|
|
|
+ PaymentRecordID uint `json:"payment_record_id"`
|
|
|
+ GateCommandID uint `json:"gate_command_id"`
|
|
|
+ TicketNo string `json:"ticket_no"`
|
|
|
+ PlateNumber string `json:"plate_number"`
|
|
|
+ RFIDTag string `json:"rfid_tag"`
|
|
|
+ ParkingLotID uint `json:"parking_lot_id"`
|
|
|
+ ParkingLotName string `json:"parking_lot_name"`
|
|
|
+ ChannelID uint `json:"channel_id"`
|
|
|
+ ChannelCode string `json:"channel_code"`
|
|
|
+ ChannelName string `json:"channel_name"`
|
|
|
+ DeviceCode string `json:"device_code"`
|
|
|
+ DeviceName string `json:"device_name"`
|
|
|
+ Description string `json:"description" binding:"required"`
|
|
|
+ Detail string `json:"detail"`
|
|
|
+}
|
|
|
+
|
|
|
+// CreateIncident 人工上报异常(来源 manual,待处理状态)。
|
|
|
+func (s *IncidentService) CreateIncident(req CreateIncidentRequest, operatorID uint) (*dao.IncidentRecord, error) {
|
|
|
+ if _, known := categoryDefaultLevel[req.Category]; !known {
|
|
|
+ return nil, errors.New("未知异常分类: " + req.Category)
|
|
|
+ }
|
|
|
+ if err := validateAssociationIDs(req); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ level := req.Level
|
|
|
+ if level == "" {
|
|
|
+ level = categoryDefaultLevel[req.Category]
|
|
|
+ if level == "" {
|
|
|
+ level = LevelWarning
|
|
|
+ }
|
|
|
+ }
|
|
|
+ autoReq := RecordIncidentRequest{
|
|
|
+ VehicleRecordID: req.VehicleRecordID, DigitalTicketID: req.DigitalTicketID, PaymentRecordID: req.PaymentRecordID, GateCommandID: req.GateCommandID,
|
|
|
+ TicketNo: req.TicketNo, PlateNumber: req.PlateNumber, RFIDTag: req.RFIDTag, ParkingLotID: req.ParkingLotID, ParkingLotName: req.ParkingLotName,
|
|
|
+ ChannelID: req.ChannelID, ChannelCode: req.ChannelCode, ChannelName: req.ChannelName, DeviceCode: req.DeviceCode, DeviceName: req.DeviceName,
|
|
|
+ }
|
|
|
+ enrichAssociations(&autoReq)
|
|
|
+ no, err := s.generateIncidentNo()
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ record := &dao.IncidentRecord{
|
|
|
+ IncidentNo: no,
|
|
|
+ Category: req.Category,
|
|
|
+ Source: SourceManual,
|
|
|
+ Level: level,
|
|
|
+ Status: StatusPending,
|
|
|
+ VehicleRecordID: autoReq.VehicleRecordID,
|
|
|
+ DigitalTicketID: autoReq.DigitalTicketID,
|
|
|
+ PaymentRecordID: autoReq.PaymentRecordID,
|
|
|
+ GateCommandID: autoReq.GateCommandID,
|
|
|
+ TicketNo: autoReq.TicketNo,
|
|
|
+ PlateNumber: autoReq.PlateNumber,
|
|
|
+ RFIDTag: autoReq.RFIDTag,
|
|
|
+ ParkingLotID: autoReq.ParkingLotID,
|
|
|
+ ParkingLotName: autoReq.ParkingLotName,
|
|
|
+ ChannelID: autoReq.ChannelID,
|
|
|
+ ChannelCode: autoReq.ChannelCode,
|
|
|
+ ChannelName: autoReq.ChannelName,
|
|
|
+ DeviceCode: autoReq.DeviceCode,
|
|
|
+ DeviceName: autoReq.DeviceName,
|
|
|
+ OperatorID: operatorID,
|
|
|
+ Description: req.Description,
|
|
|
+ Detail: req.Detail,
|
|
|
+ EventLog: appendEvent("[]", map[string]interface{}{"event": "created", "source": SourceManual}),
|
|
|
+ }
|
|
|
+ if err := s.repo.Create(record); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ return record, nil
|
|
|
+}
|
|
|
+
|
|
|
+// TransitionRequest 状态流转+处置入参。
|
|
|
+type TransitionRequest struct {
|
|
|
+ ToStatus string `json:"to_status" binding:"required"`
|
|
|
+ HandleType string `json:"handle_type"`
|
|
|
+ HandleRemark string `json:"handle_remark"`
|
|
|
+ ForceFreeAmount float64 `json:"force_free_amount"`
|
|
|
+}
|
|
|
+
|
|
|
+// TransitionIncident 状态流转(CAS 条件更新)。
|
|
|
+// isAdmin 表示是否允许资金类处置(force_free)。
|
|
|
+func (s *IncidentService) TransitionIncident(id uint, req TransitionRequest, operatorID uint, isAdmin bool) (*dao.IncidentRecord, error) {
|
|
|
+ incident, err := s.repo.GetByID(id)
|
|
|
+ if err != nil {
|
|
|
+ return nil, ErrIncidentNotFound
|
|
|
+ }
|
|
|
+
|
|
|
+ toStatus := req.ToStatus
|
|
|
+ if toStatus == incident.Status {
|
|
|
+ return incident, nil
|
|
|
+ }
|
|
|
+ if !allowedTransitions[incident.Status][toStatus] {
|
|
|
+ return nil, fmt.Errorf("状态转换不合法: %s → %s", incident.Status, toStatus)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处置校验
|
|
|
+ if toStatus == StatusResolved {
|
|
|
+ if req.HandleType == "" {
|
|
|
+ return nil, errors.New("解决异常必须填写处置方式")
|
|
|
+ }
|
|
|
+ if req.HandleRemark == "" {
|
|
|
+ return nil, errors.New("解决异常必须填写处置备注")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if req.HandleType == HandleForceFree {
|
|
|
+ if !isAdmin {
|
|
|
+ return nil, errors.New("强制免费处置需要管理员权限")
|
|
|
+ }
|
|
|
+ if req.ForceFreeAmount < 0 {
|
|
|
+ return nil, errors.New("强制免费金额不能为负数")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ updates := map[string]interface{}{
|
|
|
+ "handler_id": operatorID,
|
|
|
+ "handle_type": req.HandleType,
|
|
|
+ "handle_remark": req.HandleRemark,
|
|
|
+ }
|
|
|
+ event := map[string]interface{}{"event": toStatus, "handler_id": operatorID}
|
|
|
+ if toStatus == StatusResolved {
|
|
|
+ now := time.Now()
|
|
|
+ updates["handled_at"] = now
|
|
|
+ updates["force_free_amount"] = req.ForceFreeAmount
|
|
|
+ event["handle_type"] = req.HandleType
|
|
|
+ if req.HandleType == HandleForceFree {
|
|
|
+ event["force_free_amount"] = req.ForceFreeAmount
|
|
|
+ }
|
|
|
+ }
|
|
|
+ updates["event_log"] = appendEvent(incident.EventLog, event)
|
|
|
+
|
|
|
+ updated, err := s.repo.UpdateStateIfCurrent(id, incident.Status, toStatus, updates)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if !updated {
|
|
|
+ return nil, ErrIncidentChanged
|
|
|
+ }
|
|
|
+ return s.repo.GetByID(id)
|
|
|
+}
|
|
|
+
|
|
|
+// GetIncident 查询详情。
|
|
|
+func (s *IncidentService) GetIncident(id uint) (*dao.IncidentRecord, error) {
|
|
|
+ return s.repo.GetByID(id)
|
|
|
+}
|
|
|
+
|
|
|
+// ListIncidents 分页筛选。
|
|
|
+func (s *IncidentService) ListIncidents(q repository.IncidentListQuery) ([]repository.IncidentListItem, int64, error) {
|
|
|
+ return s.repo.List(q)
|
|
|
+}
|
|
|
+
|
|
|
+// GetIncidentStats 异常统计。
|
|
|
+func (s *IncidentService) GetIncidentStats() (repository.IncidentStats, error) {
|
|
|
+ return s.repo.Stats()
|
|
|
+}
|
|
|
+
|
|
|
+// ResolveDeviceOffline 设备恢复在线:关闭该设备未处理的离线异常(best-effort)。
|
|
|
+func (s *IncidentService) ResolveDeviceOffline(deviceCode string) {
|
|
|
+ if err := s.repo.ResolveDeviceOfflineByDevice(deviceCode, "设备恢复在线"); err != nil {
|
|
|
+ logWarn("关闭设备离线异常失败",
|
|
|
+ zap.Error(err), zap.String("device_code", deviceCode))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// GateCommandRecord 道闸指令流水入参。
|
|
|
+type GateCommandRecord struct {
|
|
|
+ DeviceCode string
|
|
|
+ DeviceName string
|
|
|
+ Action string // open / close
|
|
|
+ Source string // passage / manual / test
|
|
|
+ OperatorID uint
|
|
|
+ SessionID uint
|
|
|
+ Result string // success / failed
|
|
|
+ ErrorMessage string
|
|
|
+ DurationMs int64
|
|
|
+}
|
|
|
+
|
|
|
+// RecordGateCommand 记录道闸指令流水(best-effort),返回流水 ID 供异常关联。
|
|
|
+func (s *IncidentService) RecordGateCommand(rec GateCommandRecord) uint {
|
|
|
+ if rec.DeviceCode == "" {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ if rec.Source == "" {
|
|
|
+ rec.Source = SourcePassage
|
|
|
+ }
|
|
|
+ log := &dao.DeviceCommandLog{
|
|
|
+ DeviceCode: rec.DeviceCode,
|
|
|
+ DeviceName: rec.DeviceName,
|
|
|
+ Action: rec.Action,
|
|
|
+ Source: rec.Source,
|
|
|
+ OperatorID: rec.OperatorID,
|
|
|
+ SessionID: rec.SessionID,
|
|
|
+ Result: rec.Result,
|
|
|
+ ErrorMessage: rec.ErrorMessage,
|
|
|
+ DurationMs: rec.DurationMs,
|
|
|
+ }
|
|
|
+ if err := s.repo.CreateCommandLog(log); err != nil {
|
|
|
+ logWarn("记录道闸指令流水失败", zap.Error(err))
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ return log.ID
|
|
|
+}
|
|
|
+
|
|
|
+// LinkIncidentToCommand 回填指令流水关联的异常事件 ID(best-effort)。
|
|
|
+func (s *IncidentService) LinkIncidentToCommand(logID, incidentID uint) {
|
|
|
+ if err := s.repo.UpdateIncidentID(logID, incidentID); err != nil {
|
|
|
+ logWarn("回填指令流水异常关联失败", zap.Error(err))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// generateIncidentNo 生成唯一编号:INC + yyyyMMdd + 当日序号(冲突时重试)。
|
|
|
+func (s *IncidentService) generateIncidentNo() (string, error) {
|
|
|
+ prefix := "INC" + time.Now().Format("20060102")
|
|
|
+ for attempt := 0; attempt < 5; attempt++ {
|
|
|
+ var count int64
|
|
|
+ if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
|
|
|
+ Where("incident_no LIKE ?", prefix+"%").Count(&count).Error; err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ no := fmt.Sprintf("%s-%04d", prefix, count+1)
|
|
|
+ var dup int64
|
|
|
+ if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
|
|
|
+ Where("incident_no = ?", no).Count(&dup).Error; err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ if dup == 0 {
|
|
|
+ return no, nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "", errors.New("生成异常编号失败,请重试")
|
|
|
+}
|
|
|
+
|
|
|
+// appendEvent 向事件日志 JSON 数组追加事件(沿用数字票 event_log 约定)。
|
|
|
+func appendEvent(log string, event map[string]interface{}) string {
|
|
|
+ var events []map[string]interface{}
|
|
|
+ json.Unmarshal([]byte(log), &events)
|
|
|
+ event["at"] = time.Now().Format(time.RFC3339)
|
|
|
+ events = append(events, event)
|
|
|
+ data, _ := json.Marshal(events)
|
|
|
+ return string(data)
|
|
|
+}
|