Files

360 lines
13 KiB
Go

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
}