package operations import ( "context" "errors" "fmt" "sort" "strings" "time" "gorm.io/gorm" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission" deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/media" coreService "github.com/go-admin-team/go-admin-core/sdk/service" ) var ( ErrInvalidFilter = errors.New("运维中心查询条件不符合要求") ErrProblemNotFound = errors.New("运维问题不存在或已经收敛") ErrVersionConflict = errors.New("状态已经变化,请刷新后重试") ErrRetryInProgress = errors.New("该对象已有重试任务") ErrRetryNotAllowed = errors.New("该问题不允许重试") ) type Service struct { DB *gorm.DB Now func() time.Time LocalInferenceConfigured bool DeviceRetry func(context.Context, admission.ProbeRequest) error } type admissionResult struct { DeviceID string Address string Status string Detail string CheckedAt time.Time } func (admissionResult) TableName() string { return "sense_admission_results" } func NewService(db *gorm.DB) *Service { service := &Service{DB: db, Now: time.Now, LocalInferenceConfigured: false} service.DeviceRetry = func(ctx context.Context, request admission.ProbeRequest) error { base := coreService.Service{} base.Orm = db runtimeService, err := admission.NewRuntime(base) if err != nil { return err } _, err = runtimeService.Probe(ctx, request) if err == nil { // Keep the existing admission boundary: route intent is best-effort and // must not turn a successful device probe into a MediaMTX failure. _ = media.EnsureDeviceRoutes(ctx, db, request.DeviceID) } return err } return service } func (s *Service) List(request PageRequest) (PageResponse, error) { if err := validateFilter(request); err != nil { return PageResponse{}, err } problems, summary, err := s.project() if err != nil { return PageResponse{}, err } filtered := make([]Problem, 0, len(problems)) keyword := strings.ToLower(strings.TrimSpace(request.Keyword)) for _, item := range problems { if request.ObjectType != "" && item.ObjectType != request.ObjectType { continue } if request.ProblemType != "" && item.ProblemType != request.ProblemType { continue } if request.Severity != "" && item.Severity != request.Severity { continue } searchable := strings.ToLower(strings.Join([]string{item.ObjectID, item.ObjectName, item.Location, item.Difference}, " ")) if keyword != "" && !strings.Contains(searchable, keyword) { continue } filtered = append(filtered, item) } pageIndex, pageSize := request.GetPageIndex(), request.GetPageSize() if pageSize > 100 { pageSize = 100 } start := (pageIndex - 1) * pageSize if start > len(filtered) { start = len(filtered) } end := start + pageSize if end > len(filtered) { end = len(filtered) } return PageResponse{Summary: summary, List: filtered[start:end], Count: int64(len(filtered))}, nil } func (s *Service) Get(id string) (Problem, error) { problems, _, err := s.project() if err != nil { return Problem{}, err } for _, item := range problems { if item.ID == id { return item, nil } } return Problem{}, ErrProblemNotFound } func (s *Service) Retry(ctx context.Context, id string, expectedVersion int64, userID int) (Problem, error) { if expectedVersion < 1 { return Problem{}, ErrVersionConflict } problem, err := s.Get(id) if err != nil { return Problem{}, err } if !problem.Retryable { if problem.RetryInProgress { return Problem{}, ErrRetryInProgress } return Problem{}, ErrRetryNotAllowed } now := s.now().UTC() switch problem.ObjectType { case ObjectDevice: var admissionState admissionResult if err := s.DB.First(&admissionState, "device_id = ?", problem.ObjectID).Error; err != nil || strings.TrimSpace(admissionState.Address) == "" { return Problem{}, ErrRetryNotAllowed } result := s.DB.Model(&deviceModels.Device{}). Where("id = ? AND version = ? AND retry_requested_at IS NULL", problem.ObjectID, expectedVersion). Updates(map[string]any{"retry_requested_at": now, "version": expectedVersion + 1, "updated_at": now}) if result.Error != nil { return Problem{}, fmt.Errorf("queue device retry: %w", result.Error) } if result.RowsAffected == 0 { return Problem{}, s.retryConflict(ObjectDevice, problem.ObjectID, expectedVersion) } if s.DeviceRetry != nil { err = s.DeviceRetry(ctx, admission.ProbeRequest{DeviceID: problem.ObjectID, Address: admissionState.Address, Version: expectedVersion + 1, UpdateBy: userID}) if err != nil { clearErr := s.DB.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", problem.ObjectID, expectedVersion+1).Update("retry_requested_at", nil).Error if clearErr != nil { return Problem{}, errors.Join(fmt.Errorf("execute device retry: %w", err), fmt.Errorf("clear device retry gate: %w", clearErr)) } return Problem{}, fmt.Errorf("execute device retry: %w", err) } } case ObjectMedia: result := s.DB.Model(&media.Route{}). Where("id = ? AND version = ? AND actual <> ?", problem.ObjectID, expectedVersion, "retry_pending"). Updates(map[string]any{"actual": "retry_pending", "next_retry_at": now, "detail": "已请求受控重试,等待视频服务执行", "version": expectedVersion + 1, "updated_at": now}) if result.Error != nil { return Problem{}, fmt.Errorf("queue media retry: %w", result.Error) } if result.RowsAffected == 0 { return Problem{}, s.retryConflict(ObjectMedia, problem.ObjectID, expectedVersion) } default: return Problem{}, ErrRetryNotAllowed } updated, err := s.Get(id) if errors.Is(err, ErrProblemNotFound) { problem.Actual, problem.Difference, problem.NextAction = "converged", "重试完成,状态已经收敛", "无需处理" problem.Retryable, problem.RetryInProgress, problem.Version, problem.UpdatedAt = false, false, expectedVersion+2, now return problem, nil } return updated, err } func (s *Service) project() ([]Problem, Summary, error) { var devices []deviceModels.Device if err := s.DB.Order("updated_at DESC").Find(&devices).Error; err != nil { return nil, Summary{}, fmt.Errorf("list operation devices: %w", err) } var admissions []admissionResult if err := s.DB.Find(&admissions).Error; err != nil { return nil, Summary{}, fmt.Errorf("list admission results: %w", err) } var routes []media.Route if err := s.DB.Order("updated_at DESC").Find(&routes).Error; err != nil { return nil, Summary{}, fmt.Errorf("list operation media routes: %w", err) } admissionByDevice := make(map[string]admissionResult, len(admissions)) for _, item := range admissions { admissionByDevice[item.DeviceID] = item } deviceByID := make(map[string]deviceModels.Device, len(devices)) problems := make([]Problem, 0) summary := Summary{ManagedCount: len(devices) + len(routes), LocalInference: "configured"} for _, device := range devices { deviceByID[device.ID] = device if problem, ok := deviceProblem(device, admissionByDevice[device.ID]); ok { problems = append(problems, problem) summary.ActionableProblems++ } else { summary.ConvergedCount++ } } for _, route := range routes { if problem, ok := mediaProblem(route, deviceByID); ok { problems = append(problems, problem) summary.ActionableProblems++ } else { summary.ConvergedCount++ } } if !s.LocalInferenceConfigured { summary.LocalInference = "unavailable" now := s.now().UTC() problems = append(problems, Problem{ ID: "local_inference:adapter", ObjectType: ObjectLocalInference, ObjectID: "adapter", ObjectName: "本地推理适配器", ProblemType: ProblemUnavailable, Severity: "info", Expected: "可选", Actual: "unavailable", Difference: "未配置本地推理适配器;不影响设备和媒体运维", NextAction: "无需处理", SafetyGate: "不读取 Brain 数据库,不阻断本页", OperationalOnly: true, UpdatedAt: now, }) } sort.SliceStable(problems, func(i, j int) bool { rank := map[string]int{"high": 0, "medium": 1, "info": 2} if rank[problems[i].Severity] != rank[problems[j].Severity] { return rank[problems[i].Severity] < rank[problems[j].Severity] } return problems[i].UpdatedAt.After(problems[j].UpdatedAt) }) return problems, summary, nil } func deviceProblem(device deviceModels.Device, admission admissionResult) (Problem, bool) { if device.Status == deviceModels.StatusDisabled { return Problem{}, false } actual, detail, updatedAt := device.AdapterStatus, "设备尚未完成接入验证", device.UpdatedAt if admission.DeviceID != "" { actual, detail, updatedAt = admission.Status, admission.Detail, admission.CheckedAt } if device.Status == deviceModels.StatusActive && (actual == "ready" || actual == "verified") { return Problem{}, false } problemType, severity, nextAction := ProblemUnready, "medium", "检查设备和接入配置后重试" text := strings.ToLower(actual + " " + detail) if strings.Contains(text, "auth") || strings.Contains(text, "认证") || strings.Contains(text, "凭据") { problemType, severity, nextAction = ProblemAuthentication, "high", "确认设备账号未变更后执行受控重试" } else if strings.Contains(text, "clock") || strings.Contains(text, "time drift") || strings.Contains(text, "时间漂移") || strings.Contains(text, "时钟") { problemType, nextAction = ProblemClockDrift, "检查设备 NTP 和时区后重新检测" } retrying := device.RetryRequestedAt != nil retryable := !retrying && strings.TrimSpace(admission.Address) != "" if admission.DeviceID == "" || strings.TrimSpace(admission.Address) == "" { nextAction = "先到视频接入完成地址与凭据验证" } return Problem{ ID: "device:" + device.ID, ObjectType: ObjectDevice, ObjectID: device.ID, ObjectName: device.Name, Location: device.Location, ProblemType: problemType, Severity: severity, Expected: "active / ready", Actual: actual, Difference: detail, NextAction: nextAction, AttemptCount: boolInt(retrying), Retryable: retryable, RetryInProgress: retrying, Version: device.Version, SafetyGate: "校验设备版本且同一设备仅允许一个在途重试", OperationalOnly: true, UpdatedAt: updatedAt, }, true } func mediaProblem(route media.Route, devices map[string]deviceModels.Device) (Problem, bool) { device, exists := devices[route.DeviceID] name, location := route.Path, "" if exists { name, location = device.Name+" / "+route.Path, device.Location } if !exists { return Problem{ ID: "media:" + route.ID, ObjectType: ObjectMedia, ObjectID: route.ID, ObjectName: name, ProblemType: ProblemOrphan, Severity: "medium", Expected: "路由关联有效设备", Actual: "已隔离待确认", Difference: "媒体路由存在,但找不到有效设备归属", NextAction: "人工核对;不会自动删除", Version: route.Version, SafetyGate: "孤儿资源仅隔离和提示,本接口没有删除动作", OperationalOnly: true, UpdatedAt: route.UpdatedAt, }, true } converged := (route.Desired == media.DesiredRunning && (route.Actual == "ready" || route.Actual == "waiting")) || (route.Desired == media.DesiredStopped && route.Actual == "stopped") if converged { return Problem{}, false } problemType, nextAction := ProblemUnready, "检查视频服务状态后重试" if route.NextRetryAt != nil { problemType, nextAction = ProblemBackoff, "等待退避到期或执行受控提前重试" } retrying := route.Actual == "retry_pending" return Problem{ ID: "media:" + route.ID, ObjectType: ObjectMedia, ObjectID: route.ID, ObjectName: name, Location: location, ProblemType: problemType, Severity: "medium", Expected: route.Desired, Actual: route.Actual, Difference: route.Detail, NextAction: nextAction, NextRetryAt: route.NextRetryAt, AttemptCount: route.FailureCount, Retryable: !retrying, RetryInProgress: retrying, Version: route.Version, SafetyGate: "校验路由版本且只排队,不删除路径或修改凭据", OperationalOnly: true, UpdatedAt: route.UpdatedAt, }, true } func (s *Service) retryConflict(objectType, objectID string, expectedVersion int64) error { var version int64 var inProgress bool switch objectType { case ObjectDevice: var item deviceModels.Device if err := s.DB.Select("version", "retry_requested_at").First(&item, "id = ?", objectID).Error; err != nil { return ErrProblemNotFound } version, inProgress = item.Version, item.RetryRequestedAt != nil case ObjectMedia: var item media.Route if err := s.DB.Select("version", "actual").First(&item, "id = ?", objectID).Error; err != nil { return ErrProblemNotFound } version, inProgress = item.Version, item.Actual == "retry_pending" } if inProgress { return ErrRetryInProgress } if version != expectedVersion { return ErrVersionConflict } return ErrVersionConflict } func (s *Service) now() time.Time { if s.Now != nil { return s.Now() } return time.Now() } func validateFilter(request PageRequest) error { valid := func(value string, values ...string) bool { if value == "" { return true } for _, candidate := range values { if value == candidate { return true } } return false } if !valid(request.ObjectType, ObjectDevice, ObjectMedia, ObjectLocalInference) || !valid(request.ProblemType, ProblemAuthentication, ProblemBackoff, ProblemClockDrift, ProblemOrphan, ProblemUnavailable, ProblemUnready) || !valid(request.Severity, "high", "medium", "info") || len([]rune(request.Keyword)) > 128 { return ErrInvalidFilter } return nil } func boolInt(value bool) int { if value { return 1 } return 0 }