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