package service import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "safesight-control/internal/models" "safesight-control/internal/storage" ) var legacyBuiltinTemplateAliases = map[string]string{ "workshop_face_shoe_alarm": "std_workshop_face_recognition_shoe_alarm", "std_service_smoke_stream": "std_service_test_stream", } type ConfigTemplateAsset struct { Name string `json:"name"` Path string `json:"path"` Origin string `json:"origin"` ReadOnly bool `json:"read_only"` Description string `json:"description"` Source string `json:"source"` Slots TemplateSlotGroup `json:"slots"` NodeCount int `json:"node_count"` EdgeCount int `json:"edge_count"` MinIOEndpoint string `json:"minio_endpoint"` MinIOBucket string `json:"minio_bucket"` ExternalGetTokenURL string `json:"external_get_token_url"` ExternalPutMessageURL string `json:"external_put_message_url"` TenantCode string `json:"tenant_code"` AdvancedParams map[string]any `json:"advanced_params"` Raw map[string]any `json:"raw"` } type ConfigProfileAsset struct { Name string `json:"name"` Path string `json:"path"` Description string `json:"description"` BusinessName string `json:"business_name"` QueueSize int `json:"queue_size"` QueueStrategy string `json:"queue_strategy"` Instances []ConfigProfileInstanceAsset `json:"instances"` Raw map[string]any `json:"raw"` } type ConfigProfileInstanceAsset struct { Name string `json:"name"` Template string `json:"template"` VideoSourceRef string `json:"video_source_ref"` DisplayName string `json:"display_name"` DeviceCode string `json:"device_code"` SiteName string `json:"site_name"` PublishHLSPath string `json:"publish_hls_path"` PublishRTSPPort string `json:"publish_rtsp_port"` PublishRTSPPath string `json:"publish_rtsp_path"` ChannelNo string `json:"channel_no"` AdvancedParams map[string]any `json:"advanced_params"` } type ConfigOverlayAsset struct { Name string `json:"name"` Path string `json:"path"` Description string `json:"description"` ReadOnly bool `json:"read_only"` OverrideTargets []string `json:"override_targets"` OverrideTargetNum int `json:"override_target_num"` Raw map[string]any `json:"raw"` } type ConfigIntegrationServiceAsset struct { Name string `json:"name"` Path string `json:"path"` Type string `json:"type"` TypeLabel string `json:"type_label"` Description string `json:"description"` Enabled bool `json:"enabled"` AddressSummary string `json:"address_summary"` RefCount int `json:"ref_count"` ObjectStorage *ObjectStorageConfig `json:"object_storage,omitempty"` TokenService *TokenServiceConfig `json:"token_service,omitempty"` AlarmService *AlarmServiceConfig `json:"alarm_service,omitempty"` Raw map[string]any `json:"raw"` } type ObjectStorageConfig struct { Endpoint string `json:"endpoint"` Bucket string `json:"bucket"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` } type TokenServiceConfig struct { GetTokenURL string `json:"get_token_url"` Username string `json:"username"` Password string `json:"password"` TenantCode string `json:"tenant_code"` } type AlarmServiceConfig struct { GetTokenURL string `json:"get_token_url"` PutMessageURL string `json:"put_message_url"` TenantCode string `json:"tenant_code"` } type ConfigVideoSourceAsset struct { Name string `json:"name"` Path string `json:"path"` SourceType string `json:"source_type"` SourceTypeLabel string `json:"source_type_label"` Area string `json:"area"` Description string `json:"description"` RefCount int `json:"ref_count"` SourceSummary string `json:"source_summary"` Config VideoSourceConfig `json:"config"` Raw map[string]any `json:"raw"` } type RecognitionUnitAsset struct { Ref string `json:"ref"` SceneTemplateName string `json:"scene_template_name"` Name string `json:"name"` DisplayName string `json:"display_name"` SiteName string `json:"site_name"` VideoSourceRef string `json:"video_source_ref"` OutputChannel string `json:"output_channel"` RTSPPort string `json:"rtsp_port"` Description string `json:"description"` AdvancedParams map[string]any `json:"advanced_params,omitempty"` } type DeviceAssignmentAsset struct { DeviceID string `json:"device_id"` ProfileName string `json:"profile_name"` Description string `json:"description"` RecognitionUnits []string `json:"recognition_units"` RecognitionCount int `json:"recognition_count"` } type DeviceAssignmentBoardStats struct { TotalUnits int `json:"total_units"` TotalDevices int `json:"total_devices"` AssignedUnits int `json:"assigned_units"` UnassignedUnits int `json:"unassigned_units"` AverageLoad float64 `json:"average_load"` OverloadedDevices int `json:"overloaded_devices"` } type DeviceAssignmentBoardUnit struct { Ref string `json:"ref"` SceneTemplateName string `json:"scene_template_name"` Name string `json:"name"` DisplayName string `json:"display_name"` VideoSourceRef string `json:"video_source_ref"` OutputChannel string `json:"output_channel"` } type DeviceAssignmentBoardCard struct { DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` ProfileName string `json:"profile_name"` AssignedCount int `json:"assigned_count"` MaxUnits int `json:"max_units"` Status string `json:"status"` Units []DeviceAssignmentBoardUnit `json:"units"` } type DeviceAssignmentBoard struct { MaxUnitsPerDevice int `json:"max_units_per_device"` Stats DeviceAssignmentBoardStats `json:"stats"` Cards []DeviceAssignmentBoardCard `json:"cards"` Unassigned []DeviceAssignmentBoardUnit `json:"unassigned"` } type VideoSourceConfig struct { URL string `json:"url"` Resolution string `json:"resolution"` FrameSize string `json:"frame_size"` FPS string `json:"fps"` VideoFormat string `json:"video_format"` FocalLength string `json:"focal_length"` MountHeight string `json:"mount_height"` MountAngle string `json:"mount_angle"` } func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) { items := make([]ConfigTemplateAsset, 0) seen := map[string]bool{} if s != nil && s.assets != nil { records, err := s.assets.ListTemplates() if err != nil { return nil, err } for _, record := range records { name := strings.TrimSpace(record.Name) if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" { continue } readOnly := isStandardTemplateName(name) origin := "user" if readOnly { origin = "standard" } item, err := s.templateAssetFromRecord(record, origin, readOnly) if err != nil { continue } items = append(items, *item) seen[item.Name] = true } } sort.Slice(items, func(i, j int) bool { if items[i].ReadOnly != items[j].ReadOnly { return items[i].ReadOnly } return items[i].Name < items[j].Name }) return items, nil } func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAsset, error) { name = canonicalTemplateAssetName(name) if err := validateConfigName(name); err != nil { return nil, err } if s != nil && s.assets != nil { record, err := s.assets.GetTemplate(name) if err != nil { return nil, err } if record != nil { readOnly := isStandardTemplateName(name) origin := "user" if readOnly { origin = "standard" } return s.templateAssetFromRecord(*record, origin, readOnly) } } return nil, os.ErrNotExist } func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string, bodyJSON string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } name = canonicalTemplateAssetName(name) if err := validateConfigName(name); err != nil { return err } if isStandardTemplateName(name) { return fmt.Errorf("standard template %q is read-only; please copy it before editing", name) } return s.assets.SaveTemplate(name, description, bodyJSON) } func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName string, description string, bodyJSON string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } oldName = canonicalTemplateAssetName(oldName) newName = canonicalTemplateAssetName(newName) if err := validateConfigName(oldName); err != nil { return err } if err := validateConfigName(newName); err != nil { return err } if isStandardTemplateName(oldName) { return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName) } if oldName != newName && isStandardTemplateName(newName) { return fmt.Errorf("standard template name %q is reserved", newName) } return s.assets.RenameTemplate(oldName, newName, description, bodyJSON) } func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } name = canonicalTemplateAssetName(name) if err := validateConfigName(name); err != nil { return err } if isStandardTemplateName(name) { return fmt.Errorf("standard template %q is read-only and cannot be deleted", name) } refs, err := s.profileNamesReferencingTemplate(name) if err != nil { return err } if len(refs) > 0 { return fmt.Errorf("template %q is used by business configs: %s", name, strings.Join(refs, ", ")) } return s.assets.DeleteTemplate(name) } func (s *ConfigPreviewService) ListProfileAssets() ([]ConfigProfileAsset, error) { sources, err := s.ListSources() if err != nil { return nil, err } items := make([]ConfigProfileAsset, 0, len(sources.Profiles)) for _, source := range sources.Profiles { item, err := s.GetProfileAsset(source.Name) if err != nil { continue } items = append(items, *item) } return items, nil } func (s *ConfigPreviewService) profileNamesReferencingTemplate(templateName string) ([]string, error) { if s == nil || s.assets == nil { return nil, nil } records, err := s.assets.ListProfiles() if err != nil { return nil, err } refs := make([]string, 0) for _, record := range records { if strings.TrimSpace(record.TemplateName) == templateName || strings.Contains(record.BodyJSON, `"`+"template"+`": "`+templateName+`"`) { refs = append(refs, record.Name) } } sort.Strings(refs) return refs, nil } func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset, error) { raw, path, err := s.readAssetJSON("profiles", name) if err != nil { return nil, err } queueMap, _ := raw["queue"].(map[string]any) units, err := s.ListRecognitionUnits() if err != nil { return nil, err } instances := make([]ConfigProfileInstanceAsset, 0) for _, unit := range units { if unit.SceneTemplateName != name { continue } channel := firstString(unit.OutputChannel, unit.Name) instances = append(instances, ConfigProfileInstanceAsset{ Name: unit.Name, Template: stringValue(raw["primary_template_name"]), VideoSourceRef: unit.VideoSourceRef, DisplayName: unit.DisplayName, SiteName: unit.SiteName, PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", PublishRTSPPort: unit.RTSPPort, PublishRTSPPath: "/live/" + channel, ChannelNo: channel, AdvancedParams: cloneMap(unit.AdvancedParams), }) } return &ConfigProfileAsset{ Name: firstString(raw["name"], name), Path: path, Description: stringValue(raw["description"]), BusinessName: stringValue(raw["business_name"]), QueueSize: intValue(queueMap["size"]), QueueStrategy: stringValue(queueMap["strategy"]), Instances: instances, Raw: raw, }, nil } func (s *ConfigPreviewService) DeleteProfileAsset(name string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } if err := validateConfigName(name); err != nil { return err } units, err := s.ListRecognitionUnits() if err != nil { return err } for _, unit := range units { if unit.SceneTemplateName == name { return fmt.Errorf("场景模板 %q 下仍有识别单元,无法删除", name) } } return s.assets.DeleteProfile(name) } func recognitionUnitRef(profileName string, unitName string) string { return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) } func parseRecognitionUnitRef(ref string) (string, string, error) { parts := strings.SplitN(strings.TrimSpace(ref), "::", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref) } return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil } func (s *ConfigPreviewService) ListRecognitionUnits() ([]RecognitionUnitAsset, error) { if s == nil || s.assets == nil { return nil, fmt.Errorf("asset repository is not configured") } records, err := s.assets.ListRecognitionUnits() if err != nil { return nil, err } items := make([]RecognitionUnitAsset, 0, len(records)) for _, record := range records { item, err := recognitionUnitFromRecord(record) if err != nil { continue } items = append(items, *item) } sort.Slice(items, func(i, j int) bool { if items[i].SceneTemplateName != items[j].SceneTemplateName { return items[i].SceneTemplateName < items[j].SceneTemplateName } return items[i].Name < items[j].Name }) return items, nil } func (s *ConfigPreviewService) GetRecognitionUnit(ref string) (*RecognitionUnitAsset, error) { sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) if err != nil { return nil, err } record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) if err != nil { return nil, err } if record == nil { return nil, os.ErrNotExist } return recognitionUnitFromRecord(*record) } func (s *ConfigPreviewService) SaveRecognitionUnit(unit RecognitionUnitAsset, originalRef string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } sceneTemplate := strings.TrimSpace(unit.SceneTemplateName) if err := validateConfigName(sceneTemplate); err != nil { return fmt.Errorf("invalid scene template: %w", err) } unitName := strings.TrimSpace(unit.Name) if err := validateConfigName(unitName); err != nil { return fmt.Errorf("invalid recognition unit name: %w", err) } if strings.TrimSpace(unit.VideoSourceRef) == "" { return fmt.Errorf("video source is required") } originalTemplate := sceneTemplate originalName := unitName if strings.TrimSpace(originalRef) != "" { if p, n, err := parseRecognitionUnitRef(originalRef); err == nil { originalTemplate = p originalName = n } } if _, err := s.GetProfileEditor(sceneTemplate); err != nil { return err } if sceneTemplate != originalTemplate { if _, err := s.GetProfileEditor(originalTemplate); err != nil { return err } } if sceneTemplate != originalTemplate || unitName != originalName { existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName) if err != nil { return err } if existing != nil { return fmt.Errorf("duplicate recognition unit name: %s", unitName) } } if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil { return err } if sceneTemplate != originalTemplate || unitName != originalName { if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil { return err } } return nil } func (s *ConfigPreviewService) loadRecognitionUnitEditors(sceneTemplateName string, templateName string) ([]ConfigProfileInstanceEditor, string, string, error) { units, err := s.ListRecognitionUnits() if err != nil { return nil, "", "", err } instances := make([]ConfigProfileInstanceEditor, 0) siteName := "" deviceCode := "" for _, unit := range units { if unit.SceneTemplateName != sceneTemplateName { continue } instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName)) if siteName == "" && strings.TrimSpace(unit.SiteName) != "" { siteName = strings.TrimSpace(unit.SiteName) } if deviceCode == "" { if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" { deviceCode = code } } } return instances, siteName, deviceCode, nil } func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName string) ConfigProfileInstanceEditor { channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) return ConfigProfileInstanceEditor{ Name: strings.TrimSpace(unit.Name), Template: templateName, VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), DisplayName: strings.TrimSpace(unit.DisplayName), SiteName: strings.TrimSpace(unit.SiteName), ChannelNo: channel, PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", PublishRTSPPort: rtspPort, PublishRTSPPath: "/live/" + channel, InputBindings: map[string]InputBindingEditor{ "video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)}, }, OutputBindings: map[string]OutputBindingEditor{ "stream_output_main": { PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", PublishRTSPPort: rtspPort, PublishRTSPPath: "/live/" + channel, ChannelNo: channel, }, }, AdvancedParams: cloneMap(unit.AdvancedParams), } } func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitRecord { channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) if channel == "" { channel = strings.TrimSpace(unit.Name) } rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) raw := map[string]any{ "name": strings.TrimSpace(unit.Name), "scene_meta": map[string]any{ "display_name": strings.TrimSpace(unit.DisplayName), "site_name": strings.TrimSpace(unit.SiteName), }, "input_bindings": map[string]any{ "video_input_main": map[string]any{ "video_source_ref": strings.TrimSpace(unit.VideoSourceRef), }, }, "output_bindings": map[string]any{ "stream_output_main": map[string]any{ "publish_hls_path": "./web/hls/" + unit.Name + "/index.m3u8", "publish_rtsp_port": rtspPort, "publish_rtsp_path": "/live/" + channel, "channel_no": channel, }, }, } if params := cloneMap(unit.AdvancedParams); len(params) > 0 { raw["params"] = params } body, _ := marshalConfigJSON(raw) return storage.RecognitionUnitRecord{ SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName), Name: strings.TrimSpace(unit.Name), DisplayName: strings.TrimSpace(unit.DisplayName), SiteName: strings.TrimSpace(unit.SiteName), VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), OutputChannel: channel, RTSPPort: rtspPort, Description: strings.TrimSpace(unit.Description), BodyJSON: string(body), } } func recognitionUnitFromRecord(record storage.RecognitionUnitRecord) (*RecognitionUnitAsset, error) { raw := map[string]any{} if strings.TrimSpace(record.BodyJSON) != "" { if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { return nil, err } } sceneMeta, _ := raw["scene_meta"].(map[string]any) params, _ := raw["params"].(map[string]any) advanced := cloneMap(params) if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" { advanced["device_code"] = deviceCode } return &RecognitionUnitAsset{ Ref: recognitionUnitRef(record.SceneTemplateName, record.Name), SceneTemplateName: strings.TrimSpace(record.SceneTemplateName), Name: strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)), DisplayName: strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)), SiteName: strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)), VideoSourceRef: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)), OutputChannel: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)), RTSPPort: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)), Description: strings.TrimSpace(record.Description), AdvancedParams: advanced, }, nil } func bindingStringFromAnyMap(raw map[string]any, bindingKey string, slot string, field string) string { bindings, _ := raw[bindingKey].(map[string]any) if bindings == nil { return "" } return bindingStringFromBindings(bindings, slot, field) } func bindingStringFromBindings(bindings map[string]any, slot string, field string) string { entry, _ := bindings[slot].(map[string]any) return strings.TrimSpace(stringAny(entry[field])) } func (e *ConfigProfileEditor) RawTemplateName() string { if e == nil { return "" } if strings.TrimSpace(e.PrimaryTemplateName) != "" { return strings.TrimSpace(e.PrimaryTemplateName) } return firstProfileTemplate(e.Instances) } func (s *ConfigPreviewService) DeleteRecognitionUnit(ref string) error { sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) if err != nil { return err } if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil { return err } else if len(refs) > 0 { return fmt.Errorf("识别单元 %q 已被设备分配引用:%s", unitName, strings.Join(refs, ", ")) } item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) if err != nil { return err } if item == nil { return os.ErrNotExist } return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName) } func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) { sources, err := s.ListSources() if err != nil { return nil, err } items := make([]ConfigOverlayAsset, 0, len(sources.Overlays)) for _, source := range sources.Overlays { item, err := s.GetOverlayAsset(source.Name) if err != nil { continue } items = append(items, *item) } sort.Slice(items, func(i, j int) bool { if items[i].ReadOnly != items[j].ReadOnly { return items[i].ReadOnly } return items[i].Name < items[j].Name }) return items, nil } func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset, error) { raw, path, err := s.readAssetJSON("overlays", name) if err != nil { return nil, err } targets := make([]string, 0) if overrides, ok := raw["instance_overrides"].(map[string]any); ok { for key := range overrides { targets = append(targets, key) } sort.Strings(targets) } return &ConfigOverlayAsset{ Name: name, Path: path, Description: stringValue(raw["description"]), ReadOnly: isStandardTemplateName(name), OverrideTargets: targets, OverrideTargetNum: len(targets), Raw: raw, }, nil } func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw map[string]any) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } name := strings.TrimSpace(asset.Name) if err := validateConfigName(name); err != nil { return err } if isStandardTemplateName(name) { return fmt.Errorf("标准调试参数 %q 为只读,请先复制后再编辑", name) } if raw == nil { raw = map[string]any{} } raw["name"] = name raw["description"] = strings.TrimSpace(asset.Description) body, err := marshalConfigJSON(raw) if err != nil { return err } return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body)) } func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } if err := validateConfigName(name); err != nil { return err } if isStandardTemplateName(name) { return fmt.Errorf("标准调试参数 %q 为只读,无法删除", name) } refs, err := s.profileNamesReferencingOverlay(name) if err != nil { return err } if len(refs) > 0 { return fmt.Errorf("调试参数 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) } return s.assets.DeleteOverlay(name) } func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) { if s == nil || s.assets == nil { return nil, nil } records, err := s.assets.ListIntegrationServices() if err != nil { return nil, err } items := make([]ConfigIntegrationServiceAsset, 0, len(records)) for _, record := range records { item, err := integrationServiceAssetFromRecord(record) if err != nil { continue } if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { item.RefCount = len(refs) } items = append(items, *item) } sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) return items, nil } func (s *ConfigPreviewService) ListVideoSources() ([]ConfigVideoSourceAsset, error) { if s == nil || s.assets == nil { return nil, nil } records, err := s.assets.ListVideoSources() if err != nil { return nil, err } items := make([]ConfigVideoSourceAsset, 0, len(records)) for _, record := range records { item, err := videoSourceAssetFromRecord(record) if err != nil { continue } if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { item.RefCount = len(refs) } items = append(items, *item) } sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) return items, nil } func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAsset, error) { if s == nil || s.assets == nil { return nil, fmt.Errorf("基础配置仓库未初始化") } if err := validateVideoSourceName(name); err != nil { return nil, err } record, err := s.assets.GetVideoSource(name) if err != nil { return nil, err } if record == nil { return nil, os.ErrNotExist } item, err := videoSourceAssetFromRecord(*record) if err != nil { return nil, err } if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { item.RefCount = len(refs) } return item, nil } func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error { if s == nil || s.assets == nil { return fmt.Errorf("基础配置仓库未初始化") } name := strings.TrimSpace(asset.Name) if err := validateVideoSourceName(name); err != nil { return fmt.Errorf("视频源名称不合法:%w", err) } sourceType := strings.TrimSpace(asset.SourceType) if sourceType == "" { return fmt.Errorf("视频源类型不能为空") } urlValue := strings.TrimSpace(asset.Config.URL) if urlValue == "" { return fmt.Errorf("视频源地址不能为空") } raw := map[string]any{ "name": name, "source_type": sourceType, "area": strings.TrimSpace(asset.Area), "description": strings.TrimSpace(asset.Description), "config": map[string]any{}, } configMap := raw["config"].(map[string]any) setAnyString(configMap, "url", asset.Config.URL) setAnyString(configMap, "resolution", asset.Config.Resolution) setAnyString(configMap, "frame_size", asset.Config.FrameSize) setAnyString(configMap, "fps", asset.Config.FPS) setAnyString(configMap, "video_format", asset.Config.VideoFormat) setAnyString(configMap, "focal_length", asset.Config.FocalLength) setAnyString(configMap, "mount_height", asset.Config.MountHeight) setAnyString(configMap, "mount_angle", asset.Config.MountAngle) body, err := marshalConfigJSON(raw) if err != nil { return err } return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body)) } func (s *ConfigPreviewService) DeleteVideoSource(name string) error { if s == nil || s.assets == nil { return fmt.Errorf("基础配置仓库未初始化") } if err := validateVideoSourceName(name); err != nil { return err } refs, err := s.profileNamesReferencingVideoSource(name) if err != nil { return err } if len(refs) > 0 { return fmt.Errorf("视频源 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) } return s.assets.DeleteVideoSource(name) } func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) { if s == nil || s.assets == nil { return nil, fmt.Errorf("asset repository is not configured") } if err := validateConfigName(name); err != nil { return nil, err } record, err := s.assets.GetIntegrationService(name) if err != nil { return nil, err } if record == nil { return nil, os.ErrNotExist } item, err := integrationServiceAssetFromRecord(*record) if err != nil { return nil, err } if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { item.RefCount = len(refs) } return item, nil } func (s *ConfigPreviewService) DeleteIntegrationService(name string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } if err := validateConfigName(name); err != nil { return err } refs, err := s.profileNamesReferencingIntegrationService(name) if err != nil { return err } if len(refs) > 0 { return fmt.Errorf("third-party service %q is used by scene configs: %s", name, strings.Join(refs, ", ")) } return s.assets.DeleteIntegrationService(name) } func (s *ConfigPreviewService) ListDeviceAssignments() ([]DeviceAssignmentAsset, error) { if s == nil || s.assets == nil { return nil, nil } records, err := s.assets.ListDeviceAssignments() if err != nil { return nil, err } items := make([]DeviceAssignmentAsset, 0, len(records)) for _, record := range records { item, err := deviceAssignmentFromRecord(record) if err != nil { continue } items = append(items, *item) } sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID }) return items, nil } func DefaultMaxUnitsPerDevice() int { return 4 } func (s *ConfigPreviewService) BuildDeviceAssignmentBoard(devices []*models.Device, maxUnitsPerDevice int) (*DeviceAssignmentBoard, error) { assignments, err := s.ListDeviceAssignments() if err != nil { return nil, err } units, err := s.ListRecognitionUnits() if err != nil { return nil, err } return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil } func BuildAutoDeviceAssignments(devices []*models.Device, units []RecognitionUnitAsset, maxUnitsPerDevice int) []DeviceAssignmentAsset { maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) type card struct { deviceID string deviceName string profile string refs []string } cards := make([]*card, 0, len(devices)) for _, dev := range devices { if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { continue } cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()}) } sort.Slice(cards, func(i, j int) bool { return cards[i].deviceID < cards[j].deviceID }) sortedUnits := append([]RecognitionUnitAsset(nil), units...) sort.Slice(sortedUnits, func(i, j int) bool { if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName { return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName } return sortedUnits[i].Name < sortedUnits[j].Name }) for _, unit := range sortedUnits { var target *card for _, candidate := range cards { if candidate.profile != "" && candidate.profile != unit.SceneTemplateName { continue } if len(candidate.refs) >= maxUnitsPerDevice { continue } if target == nil || len(candidate.refs) < len(target.refs) { target = candidate } } if target == nil { continue } if target.profile == "" { target.profile = unit.SceneTemplateName } target.refs = append(target.refs, unit.Ref) } out := make([]DeviceAssignmentAsset, 0, len(cards)) for _, card := range cards { if len(card.refs) == 0 { continue } out = append(out, DeviceAssignmentAsset{ DeviceID: card.deviceID, ProfileName: card.profile, RecognitionUnits: append([]string(nil), card.refs...), RecognitionCount: len(card.refs), }) } sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID }) return out } func BuildDeviceAssignmentBoardData(devices []*models.Device, assignments []DeviceAssignmentAsset, units []RecognitionUnitAsset, maxUnitsPerDevice int) *DeviceAssignmentBoard { maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) unitMap := make(map[string]RecognitionUnitAsset, len(units)) for _, unit := range units { unitMap[unit.Ref] = unit } deviceByID := map[string]*models.Device{} for _, dev := range devices { if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { continue } deviceByID[strings.TrimSpace(dev.DeviceID)] = dev } assignmentByDevice := map[string]DeviceAssignmentAsset{} for _, assignment := range assignments { assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment } deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice)) seenDeviceIDs := map[string]struct{}{} for deviceID := range deviceByID { seenDeviceIDs[deviceID] = struct{}{} deviceIDs = append(deviceIDs, deviceID) } for deviceID := range assignmentByDevice { if _, ok := seenDeviceIDs[deviceID]; ok { continue } seenDeviceIDs[deviceID] = struct{}{} deviceIDs = append(deviceIDs, deviceID) } sort.Strings(deviceIDs) assignedRefs := map[string]struct{}{} cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs)) for _, deviceID := range deviceIDs { assignment := assignmentByDevice[deviceID] card := DeviceAssignmentBoardCard{ DeviceID: deviceID, DeviceName: boardDeviceName(deviceByID[deviceID], deviceID), ProfileName: strings.TrimSpace(assignment.ProfileName), MaxUnits: maxUnitsPerDevice, } for _, ref := range assignment.RecognitionUnits { unit, ok := unitMap[ref] if !ok { continue } card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit)) assignedRefs[ref] = struct{}{} } card.AssignedCount = len(card.Units) card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice) cards = append(cards, card) } sort.Slice(cards, func(i, j int) bool { li := boardStatusRank(cards[i].Status) lj := boardStatusRank(cards[j].Status) if li != lj { return li < lj } if cards[i].AssignedCount != cards[j].AssignedCount { return cards[i].AssignedCount > cards[j].AssignedCount } return cards[i].DeviceID < cards[j].DeviceID }) unassigned := make([]DeviceAssignmentBoardUnit, 0) for _, unit := range units { if _, ok := assignedRefs[unit.Ref]; ok { continue } unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit)) } sort.Slice(unassigned, func(i, j int) bool { if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName { return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName } return unassigned[i].Name < unassigned[j].Name }) stats := DeviceAssignmentBoardStats{ TotalUnits: len(units), TotalDevices: len(deviceIDs), AssignedUnits: len(assignedRefs), UnassignedUnits: len(unassigned), } if stats.TotalDevices > 0 { stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices) } for _, card := range cards { if card.Status == "full" { stats.OverloadedDevices++ } } return &DeviceAssignmentBoard{ MaxUnitsPerDevice: maxUnitsPerDevice, Stats: stats, Cards: cards, Unassigned: unassigned, } } func deviceAssignmentFromRecord(record storage.DeviceAssignmentRecord) (*DeviceAssignmentAsset, error) { raw := map[string]any{} if strings.TrimSpace(record.BodyJSON) != "" { if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { return nil, err } } unitRefs := make([]string, 0) if items, ok := raw["recognition_units"].([]any); ok { for _, item := range items { if v := strings.TrimSpace(stringValue(item)); v != "" { unitRefs = append(unitRefs, v) } } } return &DeviceAssignmentAsset{ DeviceID: record.DeviceID, ProfileName: firstString(record.ProfileName, stringValue(raw["profile_name"])), Description: firstString(record.Description, stringValue(raw["description"])), RecognitionUnits: unitRefs, RecognitionCount: len(unitRefs), }, nil } func normalizeMaxUnitsPerDevice(v int) int { if v < 1 { return DefaultMaxUnitsPerDevice() } if v > 8 { return 8 } return v } func boardDeviceName(dev *models.Device, fallback string) string { if dev == nil { return fallback } return firstString(strings.TrimSpace(dev.DisplayName()), fallback) } func boardUnitFromRecognitionUnit(unit RecognitionUnitAsset) DeviceAssignmentBoardUnit { return DeviceAssignmentBoardUnit{ Ref: unit.Ref, SceneTemplateName: unit.SceneTemplateName, Name: unit.Name, DisplayName: unit.DisplayName, VideoSourceRef: unit.VideoSourceRef, OutputChannel: firstString(unit.OutputChannel, unit.Name), } } func boardCardStatus(count int, max int) string { if count <= 0 { return "idle" } if count >= max { return "full" } if count*2 >= max { return "busy" } return "low" } func boardStatusRank(status string) int { switch status { case "full": return 0 case "busy": return 1 case "low": return 2 case "idle": return 3 default: return 4 } } func (s *ConfigPreviewService) GetDeviceAssignment(deviceID string) (*DeviceAssignmentAsset, error) { if s == nil || s.assets == nil { return nil, fmt.Errorf("asset repository is not configured") } record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID)) if err != nil { return nil, err } if record == nil { return nil, os.ErrNotExist } return deviceAssignmentFromRecord(*record) } func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } deviceID := strings.TrimSpace(asset.DeviceID) if deviceID == "" { return fmt.Errorf("device id is required") } if strings.TrimSpace(asset.ProfileName) == "" { return fmt.Errorf("scene template is required") } seen := map[string]struct{}{} for _, ref := range asset.RecognitionUnits { profileName, _, err := parseRecognitionUnitRef(ref) if err != nil { return err } if profileName != asset.ProfileName { return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName) } if _, ok := seen[ref]; ok { return fmt.Errorf("duplicate recognition unit: %s", ref) } seen[ref] = struct{}{} } raw := map[string]any{ "device_id": deviceID, "profile_name": strings.TrimSpace(asset.ProfileName), "description": strings.TrimSpace(asset.Description), "recognition_units": asset.RecognitionUnits, } body, err := marshalConfigJSON(raw) if err != nil { return err } return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body)) } func (s *ConfigPreviewService) SaveDeviceAssignmentBoard(assignments map[string][]string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } existing, err := s.ListDeviceAssignments() if err != nil { return err } existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing)) for _, item := range existing { existingByDevice[item.DeviceID] = item } for deviceID, refs := range assignments { deviceID = strings.TrimSpace(deviceID) if deviceID == "" { continue } cleanRefs := make([]string, 0, len(refs)) seen := map[string]struct{}{} for _, ref := range refs { ref = strings.TrimSpace(ref) if ref == "" { continue } if _, ok := seen[ref]; ok { continue } seen[ref] = struct{}{} cleanRefs = append(cleanRefs, ref) } if len(cleanRefs) == 0 { if _, ok := existingByDevice[deviceID]; ok { if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil { return err } } continue } firstUnit, err := s.GetRecognitionUnit(cleanRefs[0]) if err != nil { return err } profileName := firstUnit.SceneTemplateName for _, ref := range cleanRefs[1:] { unit, err := s.GetRecognitionUnit(ref) if err != nil { return err } if unit.SceneTemplateName != profileName { return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID) } } description := existingByDevice[deviceID].Description if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{ DeviceID: deviceID, ProfileName: profileName, Description: description, RecognitionUnits: cleanRefs, }); err != nil { return err } } return nil } func (s *ConfigPreviewService) DeleteDeviceAssignment(deviceID string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID)) } func (s *ConfigPreviewService) deviceAssignmentsReferencingRecognitionUnit(ref string) ([]string, error) { items, err := s.ListDeviceAssignments() if err != nil { return nil, err } out := make([]string, 0) for _, item := range items { for _, unitRef := range item.RecognitionUnits { if unitRef == ref { out = append(out, item.DeviceID) break } } } sort.Strings(out) return out, nil } func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } name := strings.TrimSpace(asset.Name) if err := validateConfigName(name); err != nil { return fmt.Errorf("invalid third-party service name: %w", err) } serviceType := strings.TrimSpace(asset.Type) if serviceType == "" { return fmt.Errorf("third-party service type is required") } raw := map[string]any{ "name": name, "type": serviceType, "description": strings.TrimSpace(asset.Description), "enabled": asset.Enabled, } configMap := map[string]any{} switch serviceType { case "object_storage": if asset.ObjectStorage == nil { return fmt.Errorf("object storage config is required") } setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint) setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket) setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey) setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey) for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} { if strings.TrimSpace(stringValue(configMap[key])) == "" { return fmt.Errorf("object storage %s is required", key) } } case "alarm_service": if asset.AlarmService == nil { return fmt.Errorf("alarm service config is required") } setAnyString(configMap, "get_token_url", asset.AlarmService.GetTokenURL) setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL) setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode) if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" && strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" { return fmt.Errorf("alarm service requires at least one of get_token_url or put_message_url") } case "custom": raw["config"] = asset.Raw default: return fmt.Errorf("unsupported third-party service type: %s", serviceType) } if serviceType != "custom" { raw["config"] = configMap } body, err := marshalConfigJSON(raw) if err != nil { return err } return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body)) } func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name string) ([]string, error) { if s == nil || s.assets == nil { return nil, nil } units, err := s.ListRecognitionUnits() if err != nil { return nil, err } name = strings.TrimSpace(name) if name == "" { return nil, nil } seen := map[string]struct{}{} refs := make([]string, 0) for _, unit := range units { record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name) if err != nil || record == nil { continue } raw := map[string]any{} if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { continue } for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} { if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name { if _, ok := seen[unit.SceneTemplateName]; !ok { seen[unit.SceneTemplateName] = struct{}{} refs = append(refs, unit.SceneTemplateName) } break } } } sort.Strings(refs) return refs, nil } func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) { if s == nil || s.assets == nil { return nil, nil } units, err := s.ListRecognitionUnits() if err != nil { return nil, err } name = strings.TrimSpace(name) if name == "" { return nil, nil } seen := map[string]struct{}{} refs := make([]string, 0) for _, unit := range units { if strings.TrimSpace(unit.VideoSourceRef) == name { if _, ok := seen[unit.SceneTemplateName]; !ok { seen[unit.SceneTemplateName] = struct{}{} refs = append(refs, unit.SceneTemplateName) } } } sort.Strings(refs) return refs, nil } func (s *ConfigPreviewService) profileNamesReferencingOverlay(name string) ([]string, error) { if s == nil || s.assets == nil { return nil, nil } records, err := s.assets.ListProfiles() if err != nil { return nil, err } name = strings.TrimSpace(name) if name == "" { return nil, nil } refs := make([]string, 0) for _, record := range records { var raw map[string]any if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { continue } for _, overlay := range profileOverlayNames(raw) { if strings.TrimSpace(overlay) == name { refs = append(refs, record.Name) break } } } sort.Strings(refs) return refs, nil } func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) { if s == nil || s.assets == nil { return nil, "", fmt.Errorf("asset repository is not configured") } raw, path, ok, err := s.readRepoAssetJSON(kind, name) if err != nil { return nil, "", err } if ok { return raw, path, nil } return nil, "", os.ErrNotExist } func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) { if err := validateConfigName(name); err != nil { return nil, "", false, err } var ( record *storage.AssetRecord err error ) switch kind { case "templates": record, err = s.assets.GetTemplate(name) case "profiles": record, err = s.assets.GetProfile(name) case "overlays": record, err = s.assets.GetOverlay(name) default: return nil, "", false, fmt.Errorf("unsupported asset kind: %s", kind) } if err != nil { return nil, "", true, err } if record == nil { return nil, "", false, nil } var raw map[string]any if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { return nil, "", true, err } if raw == nil { raw = map[string]any{} } if strings.TrimSpace(record.Description) != "" { raw["description"] = record.Description } if kind == "profiles" { if strings.TrimSpace(record.TemplateName) != "" { raw["primary_template_name"] = record.TemplateName } if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" { raw["business_name"] = record.BusinessName } } return raw, repoAssetPath(kind, name), true, nil } func cloneMap(in map[string]any) map[string]any { if len(in) == 0 { return map[string]any{} } out := make(map[string]any, len(in)) for k, v := range in { out[k] = v } return out } func bindingAny(bindings map[string]any, slot string, field string) any { entry, _ := bindings[slot].(map[string]any) return entry[field] } func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) { var raw map[string]any if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { return nil, err } if raw == nil { raw = map[string]any{} } if strings.TrimSpace(record.Description) != "" { raw["description"] = record.Description } return buildTemplateAsset(raw, repoAssetPath("templates", record.Name), origin, readOnly), nil } func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly bool) *ConfigTemplateAsset { templateMap, _ := raw["template"].(map[string]any) paramsMap, _ := raw["params"].(map[string]any) nodes, _ := templateMap["nodes"].([]any) edges, _ := templateMap["edges"].([]any) slots, _ := parseTemplateSlots(raw) advanced := cloneMap(paramsMap) for _, key := range []string{ "minio_endpoint", "minio_bucket", "external_get_token_url", "external_put_message_url", "tenant_code", } { delete(advanced, key) } if len(advanced) == 0 { advanced = nil } return &ConfigTemplateAsset{ Name: firstString(raw["name"], filepath.Base(strings.TrimSuffix(path, filepath.Ext(path)))), Path: path, Origin: origin, ReadOnly: readOnly, Description: stringValue(raw["description"]), Source: stringValue(raw["source"]), Slots: slots, NodeCount: len(nodes), EdgeCount: len(edges), MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]), MinIOBucket: stringValue(paramsMap["minio_bucket"]), ExternalGetTokenURL: stringValue(paramsMap["external_get_token_url"]), ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]), TenantCode: valueString(paramsMap["tenant_code"]), AdvancedParams: advanced, Raw: raw, } } func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) (*ConfigIntegrationServiceAsset, error) { var raw map[string]any if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { return nil, err } if raw == nil { raw = map[string]any{} } configMap, _ := raw["config"].(map[string]any) sourceMap := raw if len(configMap) > 0 { sourceMap = configMap } item := &ConfigIntegrationServiceAsset{ Name: firstString(raw["name"], record.Name), Path: repoAssetPath("integration_services", record.Name), Type: firstString(record.ServiceType, stringValue(raw["type"])), Description: firstString(raw["description"], record.Description), Enabled: boolValue(raw["enabled"], record.Enabled), Raw: raw, } item.TypeLabel = integrationTypeLabel(item.Type) switch item.Type { case "object_storage": item.ObjectStorage = &ObjectStorageConfig{ Endpoint: stringValue(sourceMap["endpoint"]), Bucket: stringValue(sourceMap["bucket"]), AccessKey: firstString(sourceMap["access_key"], stringValue(sourceMap["minio_access_key"])), SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])), } item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /")) case "alarm_service": item.AlarmService = &AlarmServiceConfig{ GetTokenURL: stringValue(sourceMap["get_token_url"]), PutMessageURL: stringValue(sourceMap["put_message_url"]), TenantCode: stringValue(sourceMap["tenant_code"]), } summary := strings.TrimSpace(item.AlarmService.PutMessageURL) if summary == "" { summary = item.AlarmService.GetTokenURL } item.AddressSummary = summary case "custom": item.AddressSummary = "自定义" } return item, nil } func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoSourceAsset, error) { var raw map[string]any if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { return nil, err } if raw == nil { raw = map[string]any{} } configMap, _ := raw["config"].(map[string]any) sourceMap := raw if len(configMap) > 0 { sourceMap = configMap } item := &ConfigVideoSourceAsset{ Name: firstString(raw["name"], record.Name), Path: repoAssetPath("video_sources", record.Name), SourceType: firstString(record.SourceType, stringValue(raw["source_type"])), Area: firstString(raw["area"], record.Area), Description: firstString(raw["description"], record.Description), SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))), Config: VideoSourceConfig{ URL: stringValue(sourceMap["url"]), Resolution: stringValue(sourceMap["resolution"]), FrameSize: stringValue(sourceMap["frame_size"]), FPS: valueString(sourceMap["fps"]), VideoFormat: stringValue(sourceMap["video_format"]), FocalLength: stringValue(sourceMap["focal_length"]), MountHeight: stringValue(sourceMap["mount_height"]), MountAngle: stringValue(sourceMap["mount_angle"]), }, Raw: raw, } item.SourceSummary = item.Config.URL return item, nil } func integrationTypeLabel(v string) string { switch strings.TrimSpace(v) { case "object_storage": return "对象存储" case "alarm_service": return "告警服务" case "custom": return "自定义" default: return strings.TrimSpace(v) } } func videoSourceTypeLabel(v string) string { switch strings.TrimSpace(v) { case "rtsp": return "RTSP" case "rtmp": return "RTMP" case "file": return "文件" case "usb_camera": return "USB 摄像头" default: return strings.TrimSpace(v) } } func validateVideoSourceName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("不能为空") } if strings.Contains(name, "..") { return fmt.Errorf("不能包含 '..'") } if strings.ContainsAny(name, `/\`) { return fmt.Errorf("不能包含 / 或 \\") } return nil } func (s *ConfigPreviewService) templateIsBuiltin(name string) bool { return isStandardTemplateName(name) } func isStandardTemplateName(name string) bool { name = canonicalTemplateAssetName(name) return strings.HasPrefix(strings.TrimSpace(name), "std_") } func canonicalTemplateAssetName(name string) string { name = strings.TrimSpace(name) if next := strings.TrimSpace(legacyBuiltinTemplateAliases[name]); next != "" { return next } return name } func stringValue(v any) string { if s, ok := v.(string); ok { return strings.TrimSpace(s) } return "" } func valueString(v any) string { switch value := v.(type) { case string: return strings.TrimSpace(value) case float64: if float64(int(value)) == value { return fmt.Sprintf("%d", int(value)) } return fmt.Sprintf("%v", value) case int: return fmt.Sprintf("%d", value) case int64: return fmt.Sprintf("%d", value) default: return "" } } func boolValue(v any, fallback bool) bool { switch value := v.(type) { case bool: return value case float64: return value != 0 case int: return value != 0 case int64: return value != 0 case string: value = strings.TrimSpace(strings.ToLower(value)) return value == "1" || value == "true" || value == "yes" || value == "on" default: return fallback } } func firstString(v any, fallback string) string { if got := stringValue(v); got != "" { return got } return fallback } func intValue(v any) int { switch value := v.(type) { case int: return value case int64: return int(value) case float64: return int(value) default: return 0 } }