diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index ed1c083..1dbec70 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -5,6 +5,7 @@ import ( "log" "net/http" "os" + "path/filepath" "3588AdminBackend/internal/api" "3588AdminBackend/internal/config" @@ -39,6 +40,11 @@ func main() { defer store.Close() taskRepo := storage.NewTasksRepo(store.DB()) assetsRepo := storage.NewAssetsRepo(store.DB()) + if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil { + log.Fatalf("import standard templates: %v", err) + } else if imported > 0 { + log.Printf("imported %d standard templates", imported) + } stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) auditRepo := storage.NewAuditLogsRepo(store.DB()) taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo) diff --git a/internal/config/config.go b/internal/config/config.go index 480e666..16b3394 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,7 +17,7 @@ type Config struct { DataDir string `json:"data_dir,omitempty"` DBPath string `json:"db_path,omitempty"` LogDir string `json:"log_dir,omitempty"` - MediaRepoPath string `json:"media_repo_path,omitempty"` + MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering DeviceAliases map[string]string `json:"device_aliases,omitempty"` path string } diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index e91863e..9a90c88 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -17,21 +17,22 @@ var legacyBuiltinTemplateAliases = map[string]string{ } 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"` - 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"` + 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 { @@ -39,9 +40,6 @@ type ConfigProfileAsset struct { Path string `json:"path"` Description string `json:"description"` BusinessName string `json:"business_name"` - ObjectStorageRef string `json:"object_storage_ref"` - TokenServiceRef string `json:"token_service_ref"` - AlarmServiceRef string `json:"alarm_service_ref"` QueueSize int `json:"queue_size"` QueueStrategy string `json:"queue_strategy"` Instances []ConfigProfileInstanceAsset `json:"instances"` @@ -51,10 +49,10 @@ type ConfigProfileAsset struct { 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"` - RTSPURL string `json:"rtsp_url"` PublishHLSPath string `json:"publish_hls_path"` PublishRTSPPort string `json:"publish_rtsp_port"` PublishRTSPPath string `json:"publish_rtsp_path"` @@ -107,29 +105,34 @@ type AlarmServiceConfig struct { 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 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{} - root := s.mediaRepoRoot() - if root != "" { - sources, err := listConfigSources(filepath.Join(root, "configs", "templates")) - if err != nil { - if s.hasExplicitRoot() { - return nil, err - } - } else { - for _, source := range sources { - item, err := s.templateAssetFromPath(source.Name, source.Path, "builtin", true) - if err != nil { - continue - } - items = append(items, *item) - seen[item.Name] = true - } - } - } - if s != nil && s.assets != nil { records, err := s.assets.ListTemplates() if err != nil { @@ -140,7 +143,12 @@ func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, erro if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" { continue } - item, err := s.templateAssetFromRecord(record, "user", false) + readOnly := isStandardTemplateName(name) + origin := "user" + if readOnly { + origin = "standard" + } + item, err := s.templateAssetFromRecord(record, origin, readOnly) if err != nil { continue } @@ -163,16 +171,18 @@ func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAss if err := validateConfigName(name); err != nil { return nil, err } - if path, ok := s.mediaAssetPath("templates", name); ok { - return s.templateAssetFromPath(name, path, "builtin", true) - } if s != nil && s.assets != nil { record, err := s.assets.GetTemplate(name) if err != nil { return nil, err } if record != nil { - return s.templateAssetFromRecord(*record, "user", false) + readOnly := isStandardTemplateName(name) + origin := "user" + if readOnly { + origin = "standard" + } + return s.templateAssetFromRecord(*record, origin, readOnly) } } return nil, os.ErrNotExist @@ -186,7 +196,7 @@ func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string if err := validateConfigName(name); err != nil { return err } - if s.templateIsBuiltin(name) { + 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) @@ -204,10 +214,10 @@ func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName strin if err := validateConfigName(newName); err != nil { return err } - if s.templateIsBuiltin(oldName) { + if isStandardTemplateName(oldName) { return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName) } - if oldName != newName && s.templateIsBuiltin(newName) { + if oldName != newName && isStandardTemplateName(newName) { return fmt.Errorf("standard template name %q is reserved", newName) } return s.assets.RenameTemplate(oldName, newName, description, bodyJSON) @@ -221,7 +231,7 @@ func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error { if err := validateConfigName(name); err != nil { return err } - if s.templateIsBuiltin(name) { + if isStandardTemplateName(name) { return fmt.Errorf("standard template %q is read-only and cannot be deleted", name) } refs, err := s.profileNamesReferencingTemplate(name) @@ -279,12 +289,15 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset for _, item := range instancesRaw { instanceMap, _ := item.(map[string]any) paramsMap, _ := instanceMap["params"].(map[string]any) + sceneMeta, _ := instanceMap["scene_meta"].(map[string]any) + inputBindings, _ := instanceMap["input_bindings"].(map[string]any) + outputBindings, _ := instanceMap["output_bindings"].(map[string]any) advanced := cloneMap(paramsMap) for _, key := range []string{ "display_name", "device_code", "site_name", - "rtsp_url", + "video_source_ref", "publish_hls_path", "publish_rtsp_port", "publish_rtsp_path", @@ -298,29 +311,26 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset instances = append(instances, ConfigProfileInstanceAsset{ Name: stringValue(instanceMap["name"]), Template: stringValue(instanceMap["template"]), - DisplayName: stringValue(paramsMap["display_name"]), - DeviceCode: stringValue(paramsMap["device_code"]), - SiteName: stringValue(paramsMap["site_name"]), - RTSPURL: stringValue(paramsMap["rtsp_url"]), - PublishHLSPath: stringValue(paramsMap["publish_hls_path"]), - PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]), - PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]), - ChannelNo: stringValue(paramsMap["channel_no"]), + VideoSourceRef: bindingField(inputBindings, "video_input_main", "video_source_ref"), + DisplayName: stringValue(sceneMeta["display_name"]), + DeviceCode: stringValue(sceneMeta["device_code"]), + SiteName: stringValue(sceneMeta["site_name"]), + PublishHLSPath: bindingField(outputBindings, "stream_output_main", "publish_hls_path"), + PublishRTSPPort: valueString(bindingAny(outputBindings, "stream_output_main", "publish_rtsp_port")), + PublishRTSPPath: bindingField(outputBindings, "stream_output_main", "publish_rtsp_path"), + ChannelNo: bindingField(outputBindings, "stream_output_main", "channel_no"), AdvancedParams: advanced, }) } return &ConfigProfileAsset{ - Name: firstString(raw["name"], name), - Path: path, - Description: stringValue(raw["description"]), - BusinessName: stringValue(raw["business_name"]), - ObjectStorageRef: stringValue(raw["object_storage_ref"]), - TokenServiceRef: stringValue(raw["token_service_ref"]), - AlarmServiceRef: stringValue(raw["alarm_service_ref"]), - QueueSize: intValue(queueMap["size"]), - QueueStrategy: stringValue(queueMap["strategy"]), - Instances: instances, - Raw: raw, + 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 } @@ -385,6 +395,109 @@ func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationSer 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") @@ -426,6 +539,72 @@ func (s *ConfigPreviewService) DeleteIntegrationService(name string) error { return s.assets.DeleteIntegrationService(name) } +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 "token_service": + if asset.TokenService == nil { + return fmt.Errorf("token service config is required") + } + setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL) + setAnyString(configMap, "username", asset.TokenService.Username) + setAnyString(configMap, "password", asset.TokenService.Password) + setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode) + if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" { + return fmt.Errorf("token service get_token_url is required") + } + case "alarm_service": + if asset.AlarmService == nil { + return fmt.Errorf("alarm service config is required") + } + setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL) + setAnyString(configMap, "username", asset.AlarmService.Username) + setAnyString(configMap, "password", asset.AlarmService.Password) + setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode) + if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" { + return fmt.Errorf("alarm service put_message_url is required") + } + default: + return fmt.Errorf("unsupported third-party service type: %s", serviceType) + } + 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 @@ -447,8 +626,50 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st if raw == nil { continue } - for _, key := range []string{"object_storage_ref", "token_service_ref", "alarm_service_ref"} { - if strings.TrimSpace(stringValue(raw[key])) == name { + instances, _ := raw["instances"].([]any) + for _, item := range instances { + instanceMap, _ := item.(map[string]any) + serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) + found := false + for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} { + if strings.TrimSpace(bindingField(serviceBindings, slot, "service_ref")) == name { + refs = append(refs, record.Name) + found = true + break + } + } + if found { + break + } + } + } + sort.Strings(refs) + return refs, nil +} + +func (s *ConfigPreviewService) profileNamesReferencingVideoSource(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 + } + instances, _ := raw["instances"].([]any) + for _, item := range instances { + instanceMap, _ := item.(map[string]any) + inputBindings, _ := instanceMap["input_bindings"].(map[string]any) + if strings.TrimSpace(bindingField(inputBindings, "video_input_main", "video_source_ref")) == name { refs = append(refs, record.Name) break } @@ -459,32 +680,17 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st } func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) { - if s != nil && s.assets != nil { - raw, path, ok, err := s.readRepoAssetJSON(kind, name) - if err != nil { - return nil, "", err - } - if ok { - return raw, path, nil - } + if s == nil || s.assets == nil { + return nil, "", fmt.Errorf("asset repository is not configured") } - root := s.mediaRepoRoot() - if root == "" { - return nil, "", fmt.Errorf("media repo path is not configured") - } - if err := validateConfigName(name); err != nil { - return nil, "", err - } - path := filepath.Join(root, "configs", kind, name+".json") - body, err := os.ReadFile(path) + raw, path, ok, err := s.readRepoAssetJSON(kind, name) if err != nil { return nil, "", err } - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, "", err + if ok { + return raw, path, nil } - return raw, path, nil + return nil, "", os.ErrNotExist } func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) { @@ -523,7 +729,7 @@ func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[ } if kind == "profiles" { if strings.TrimSpace(record.TemplateName) != "" { - raw["template_name"] = record.TemplateName + raw["primary_template_name"] = record.TemplateName } if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" { raw["business_name"] = record.BusinessName @@ -543,16 +749,9 @@ func cloneMap(in map[string]any) map[string]any { return out } -func (s *ConfigPreviewService) templateAssetFromPath(name string, path string, origin string, readOnly bool) (*ConfigTemplateAsset, error) { - body, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - return buildTemplateAsset(raw, path, origin, readOnly), nil +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) { @@ -574,6 +773,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly 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", @@ -594,6 +794,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly ReadOnly: readOnly, Description: stringValue(raw["description"]), Source: stringValue(raw["source"]), + Slots: slots, NodeCount: len(nodes), EdgeCount: len(edges), MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]), @@ -657,6 +858,42 @@ func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) 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": @@ -670,22 +907,42 @@ func integrationTypeLabel(v string) string { } } -func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) { - root := s.mediaRepoRoot() - if root == "" { - return "", false +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) } - path := filepath.Join(root, "configs", kind, name+".json") - if _, err := os.Stat(path); err != nil { - return "", false +} + +func validateVideoSourceName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("不能为空") } - return path, true + 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) - _, ok := s.mediaAssetPath("templates", name) - return ok + return strings.HasPrefix(strings.TrimSpace(name), "std_") } func canonicalTemplateAssetName(name string) string { diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index ed91e1d..ed5e76c 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -11,10 +11,34 @@ import ( "3588AdminBackend/internal/storage" ) +func mustSaveTemplateRecord(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) { + t.Helper() + if err := repo.SaveTemplate(name, description, body); err != nil { + t.Fatalf("SaveTemplate(%s): %v", name, err) + } +} + +func mustReadFileBytes(t *testing.T, path string) []byte { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return body +} + +func mustImportPreviewAssets(t *testing.T, svc *ConfigPreviewService) { + t.Helper() + if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } +} + func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ + templateBody := `{ "name": "std_workshop_face_recognition_shoe_alarm", + "source": "standard", "params": { "minio_endpoint": "http://10.0.0.49:9000", "minio_bucket": "myminio", @@ -24,31 +48,38 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { "snapshot_region": "us-east-1" }, "template": {"nodes": [], "edges": []} -}`) +}` mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name": "local_3588_test", "business_name": "A厂区视觉识别", "description": "test profile", "queue": {"size": 8, "strategy": "drop_oldest"}, - "instances": [{ + "instances": [{ "name": "cam1", "template": "std_workshop_face_recognition_shoe_alarm", - "params": { - "display_name": "东门入口", - "device_code": "rk3588-a-001", - "site_name": "A厂区", - "rtsp_url": "rtsp://10.0.0.1/live", - "publish_hls_path": "./web/hls/cam1/index.m3u8", - "publish_rtsp_port": 8555, - "publish_rtsp_path": "/live/cam1", - "channel_no": "cam1", - "queue_debug": true - } + "params": {"queue_debug": true}, + "scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"}, + "input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}}, + "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}} }] }`) mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody) + + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil { + t.Fatalf("SaveOverlay: %v", err) + } item, err := svc.GetProfileAsset("local_3588_test") if err != nil { t.Fatalf("GetProfileAsset: %v", err) @@ -70,9 +101,6 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil { t.Fatalf("GetTemplateAsset: %v", err) } else { - if item.MinIOEndpoint != "http://10.0.0.49:9000" || item.TenantCode != "32" { - t.Fatalf("expected shared service params on template asset, got %#v", item) - } if _, ok := item.AdvancedParams["snapshot_region"]; !ok { t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams) } @@ -91,7 +119,14 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) { } }`) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportPreviewAssets(t, svc) item, err := svc.GetOverlayAsset("face_debug") if err != nil { t.Fatalf("GetOverlayAsset: %v", err) @@ -116,34 +151,34 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { { "name": "cam1", "template": "std_workshop_face_recognition_shoe_alarm", - "params": { - "display_name": "东门入口", - "device_code": "rk3588-a-001", - "site_name": "A厂区", - "rtsp_url": "rtsp://10.0.0.1/live", - "publish_hls_path": "./web/hls/cam1/index.m3u8", - "publish_rtsp_port": 8555, - "publish_rtsp_path": "/live/cam1", - "channel_no": "cam1", - "queue_debug": true - } + "params": {"queue_debug": true}, + "scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"}, + "input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}}, + "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}} }, { "name": "cam2", "template": "std_workshop_face_recognition_shoe_alarm", - "params": { - "display_name": "西门入口", - "rtsp_url": "rtsp://10.0.0.2/live", - "publish_hls_path": "./web/hls/cam2/index.m3u8", - "publish_rtsp_port": 8555, - "publish_rtsp_path": "/live/cam2", - "channel_no": "cam2" - } + "scene_meta": {"display_name": "西门入口", "site_name": "A厂区"}, + "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_02"}}, + "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam2/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam2", "channel_no": "cam2"}} } ] }`) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) editor, err := svc.GetProfileEditor("local_3588_test") if err != nil { t.Fatalf("GetProfileEditor: %v", err) @@ -167,7 +202,7 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" { t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0]) } - if editor.Instances[1].Name != "cam2" || editor.Instances[1].RTSPURL != "rtsp://10.0.0.2/live" { + if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" { t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1]) } if editor.Instances[0].PublishRTSPPort != "8555" { @@ -176,8 +211,8 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" { t.Fatalf("unexpected queue model: %#v", editor.Queue) } - if editor.ObjectStorageRef != "" || editor.TokenServiceRef != "" || editor.AlarmServiceRef != "" { - t.Fatalf("expected no integration refs in legacy fixture, got %#v", editor) + if editor.Instances[0].VideoSourceRef != "gate_cam_01" { + t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0]) } if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok { t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams) @@ -192,9 +227,6 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { Description: "test profile", DeviceCode: "rk3588-a-001", SiteName: "A厂区", - ObjectStorageRef: "minio_main", - TokenServiceRef: "token_main", - AlarmServiceRef: "alarm_main", Queue: ConfigProfileQueueEditor{ Size: "8", Strategy: "drop_oldest", @@ -203,8 +235,8 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { { Name: "cam1", Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "gate_cam_01", DisplayName: "东门入口", - RTSPURL: "rtsp://10.0.0.1/live", PublishHLSPath: "./web/hls/cam1/index.m3u8", PublishRTSPPort: "8555", PublishRTSPPath: "/live/cam1", @@ -216,8 +248,8 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { { Name: "cam2", Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "line_cam_02", DisplayName: "视觉识别终端-B厂区", - RTSPURL: "rtsp://10.0.0.2/live", PublishHLSPath: "./web/hls/cam2/index.m3u8", PublishRTSPPort: "8556", PublishRTSPPath: "/live/cam2", @@ -237,9 +269,6 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { if doc["business_name"] != "A厂区视觉识别" { t.Fatalf("unexpected business name: %#v", doc) } - if doc["object_storage_ref"] != "minio_main" || doc["token_service_ref"] != "token_main" || doc["alarm_service_ref"] != "alarm_main" { - t.Fatalf("expected integration refs in document, got %#v", doc) - } queue, _ := doc["queue"].(map[string]any) if queue["size"] != 8 || queue["strategy"] != "drop_oldest" { t.Fatalf("unexpected queue doc: %#v", queue) @@ -249,24 +278,169 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { t.Fatalf("expected two instances, got %#v", doc["instances"]) } params, _ := instances[0]["params"].(map[string]any) - if params["publish_rtsp_port"] != 8555 { - t.Fatalf("expected numeric rtsp port, got %#v", params["publish_rtsp_port"]) - } if params["queue_debug"] != true { t.Fatalf("expected advanced param to survive rebuild, got %#v", params) } - if params["device_code"] != "rk3588-a-001" { - t.Fatalf("expected legacy device code to be preserved in params, got %#v", params) - } - if params["site_name"] != "A厂区" { - t.Fatalf("expected profile site name to be written to instance params, got %#v", params) + if _, exists := params["video_source_ref"]; exists { + t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params) } params2, _ := instances[1]["params"].(map[string]any) - if params2["publish_rtsp_path"] != "/live/cam2" { - t.Fatalf("expected second instance to survive rebuild, got %#v", params2) + if len(params2) != 0 { + t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2) } - if params2["site_name"] != "A厂区" { - t.Fatalf("expected profile site name on second instance, got %#v", params2) + sceneMeta, _ := instances[0]["scene_meta"].(map[string]any) + if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" { + t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta) + } +} + +func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) { + svc := NewConfigPreviewService(&config.Config{}) + doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + DisplayName: "B厂区通道1", + SiteName: "B厂区", + InputBindings: map[string]InputBindingEditor{ + "video_input_main": {VideoSourceRef: "gate_cam_01"}, + }, + ServiceBindings: map[string]ServiceBindingEditor{ + "object_storage_main": {ServiceRef: "minio_main"}, + "token_service_main": {ServiceRef: "token_main"}, + "alarm_service_main": {ServiceRef: "alarm_main"}, + }, + OutputBindings: map[string]OutputBindingEditor{ + "stream_output_main": { + PublishHLSPath: "./web/hls/cam1/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/cam1", + ChannelNo: "cam1", + }, + }, + }}, + }) + if err != nil { + t.Fatalf("BuildProfileDocument: %v", err) + } + instances, _ := doc["instances"].([]map[string]any) + if len(instances) != 1 { + t.Fatalf("expected one instance, got %#v", doc["instances"]) + } + inst := instances[0] + inputBindings, _ := inst["input_bindings"].(map[string]any) + if inputBindings == nil { + t.Fatalf("expected input_bindings, got %#v", inst) + } + videoInput, _ := inputBindings["video_input_main"].(map[string]any) + if videoInput["video_source_ref"] != "gate_cam_01" { + t.Fatalf("unexpected input binding: %#v", videoInput) + } + serviceBindings, _ := inst["service_bindings"].(map[string]any) + objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) + if objectStorage["service_ref"] != "minio_main" { + t.Fatalf("unexpected service binding: %#v", serviceBindings) + } + outputBindings, _ := inst["output_bindings"].(map[string]any) + streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) + if streamOutput["publish_rtsp_port"] != 8555 { + t.Fatalf("unexpected output binding: %#v", streamOutput) + } + sceneMeta, _ := inst["scene_meta"].(map[string]any) + if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" { + t.Fatalf("unexpected scene meta: %#v", sceneMeta) + } +} + +func TestListVideoSources(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + items, err := svc.ListVideoSources() + if err != nil { + t.Fatalf("ListVideoSources: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 video source, got %#v", items) + } + if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" { + t.Fatalf("unexpected video source summary: %#v", items[0]) + } + if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" { + t.Fatalf("unexpected video source config: %#v", items[0]) + } +} + +func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + err = svc.DeleteVideoSource("gate_cam_01") + if err == nil || !strings.Contains(err.Error(), "已被场景配置引用") { + t.Fatalf("expected referenced delete to be blocked, got %v", err) + } +} + +func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) + err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{ + Name: "东门主入口", + SourceType: "rtsp", + Area: "东门", + Description: "入口相机", + Config: VideoSourceConfig{ + URL: "rtsp://10.0.0.1/live", + }, + }) + if err != nil { + t.Fatalf("expected chinese video source name to be accepted, got %v", err) + } + item, err := svc.GetVideoSource("东门主入口") + if err != nil { + t.Fatalf("GetVideoSource: %v", err) + } + if item == nil || item.Name != "东门主入口" { + t.Fatalf("unexpected saved item: %#v", item) } } @@ -277,8 +451,8 @@ func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) { Instances: []ConfigProfileInstanceEditor{ { Name: "cam1", + VideoSourceRef: "gate_cam_01", DisplayName: "视觉识别终端-A厂区", - RTSPURL: "rtsp://10.0.0.1/live", PublishRTSPPort: "bad-port", }, }, @@ -294,9 +468,9 @@ func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) { Name: "local_3588_test", Instances: []ConfigProfileInstanceEditor{ { - Name: "cam1", - DisplayName: "视觉识别终端-A厂区", - RTSPURL: "rtsp://10.0.0.1/live", + Name: "cam1", + VideoSourceRef: "gate_cam_01", + DisplayName: "视觉识别终端-A厂区", }, }, }) @@ -323,7 +497,7 @@ func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) { if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil { @@ -427,7 +601,7 @@ func TestListIntegrationServicesCountsProfileReferences(t *testing.T) { ); err != nil { t.Fatalf("SaveIntegrationService: %v", err) } - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","object_storage_ref":"minio_primary","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } @@ -506,7 +680,7 @@ func TestDeleteIntegrationServiceBlocksWhenReferenced(t *testing.T) { ); err != nil { t.Fatalf("SaveIntegrationService: %v", err) } - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","object_storage_ref":"minio_primary","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } @@ -533,10 +707,10 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) { SiteName: "A厂区", Instances: []ConfigProfileInstanceEditor{ { - Name: "cam1", - Template: "helmet", - DisplayName: "东门入口", - RTSPURL: "rtsp://10.0.0.1/live", + Name: "cam1", + Template: "helmet", + DisplayName: "东门入口", + VideoSourceRef: "gate_cam_01", }, }, } @@ -563,7 +737,7 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) { } } -func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T) { +func TestConfigPreviewServicePrefersRepoTemplateOverBuiltinFallback(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{ "name": "helmet", @@ -585,24 +759,23 @@ func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T) if err != nil { t.Fatalf("GetTemplateAsset: %v", err) } - if !item.ReadOnly || item.Origin != "builtin" { - t.Fatalf("expected builtin readonly template, got %#v", item) + if item.ReadOnly || item.Origin != "user" { + t.Fatalf("expected sqlite template to be preferred, got %#v", item) } - if item.Description != "builtin template" { - t.Fatalf("expected builtin template payload, got %#v", item) + if item.Description != "shadow template" { + t.Fatalf("expected sqlite template payload, got %#v", item) } items, err := svc.ListTemplateAssets() if err != nil { t.Fatalf("ListTemplateAssets: %v", err) } - if len(items) != 1 || items[0].Name != "helmet" || !items[0].ReadOnly { - t.Fatalf("expected only builtin template in merged list, got %#v", items) + if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly { + t.Fatalf("expected only sqlite template in merged list, got %#v", items) } } -func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) { +func TestConfigPreviewServiceRejectsSavingStandardTemplateName(t *testing.T) { root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) @@ -611,7 +784,7 @@ func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) { repo := storage.NewAssetsRepo(store.DB()) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - err = svc.SaveTemplateAsset("helmet", "new body", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`) + err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) if err == nil || !strings.Contains(err.Error(), "read-only") { t.Fatalf("expected readonly rejection, got %v", err) } @@ -627,7 +800,7 @@ func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) @@ -663,7 +836,7 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) { if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) @@ -674,10 +847,10 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) { } } -func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) { +func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) { root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`) - mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`) + mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { @@ -691,14 +864,51 @@ func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) { if err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } - if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 { + if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { t.Fatalf("unexpected import result: %#v", result) } - record, err := repo.GetTemplate("helmet") + record, err := repo.GetTemplate("std_face_recognition_stream") if err != nil { t.Fatalf("GetTemplate: %v", err) } - if record != nil { - t.Fatalf("expected builtin template to stay out of sqlite, got %#v", record) + if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) { + t.Fatalf("expected standard template to be imported into sqlite, got %#v", record) + } +} + +func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`) + mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + + imported, err := ImportStandardTemplatesFromDir(repo, root) + if err != nil { + t.Fatalf("ImportStandardTemplatesFromDir: %v", err) + } + if imported != 1 { + t.Fatalf("expected one imported standard template, got %d", imported) + } + face, err := repo.GetTemplate("std_face_recognition_stream") + if err != nil || face == nil { + t.Fatalf("expected imported face template, got %#v err=%v", face, err) + } + if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) { + t.Fatalf("expected imported template source marker, got %#v", face) + } + serviceRecord, err := repo.GetTemplate("std_service_test_stream") + if err != nil || serviceRecord == nil { + t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err) + } + if serviceRecord.Description != "existing service" { + t.Fatalf("expected existing template preserved, got %#v", serviceRecord) } } diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index 1904cae..8242bf8 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -1,13 +1,11 @@ package service import ( - "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "regexp" "sort" @@ -70,159 +68,105 @@ func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *C } func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) { - root := s.mediaRepoRoot() - out := ConfigPreviewSources{Root: root} - seenTemplates := map[string]bool{} - if root != "" { - templates, err := listConfigSources(filepath.Join(root, "configs", "templates")) - if err != nil { - if s.hasExplicitRoot() { - return out, err - } - } else { - out.Templates = append(out.Templates, templates...) - for _, item := range templates { - seenTemplates[item.Name] = true - } - } - profiles, err := listConfigSources(filepath.Join(root, "configs", "profiles")) - if err != nil { - if s.hasExplicitRoot() { - return out, err - } - } else { - out.Profiles = profiles - } - overlays, err := listConfigSources(filepath.Join(root, "configs", "overlays")) - if err != nil { - if s.hasExplicitRoot() { - return out, err - } - } else { - out.Overlays = overlays - } - } - - if s != nil && s.assets != nil { - templates, err := s.assets.ListTemplates() - if err != nil { - return out, err - } - for _, item := range templates { - if seenTemplates[item.Name] { - continue - } - out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)}) - } - profiles, err := s.assets.ListProfiles() - if err != nil { - return out, err - } - if len(profiles) > 0 { - out.Profiles = out.Profiles[:0] - for _, item := range profiles { - out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)}) - } - } - overlays, err := s.assets.ListOverlays() - if err != nil { - return out, err - } - if len(overlays) > 0 { - out.Overlays = out.Overlays[:0] - for _, item := range overlays { - out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)}) - } - } - } - sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name }) - sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name }) - sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name }) - if out.Root == "" && s != nil && s.assets != nil && (len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0) { - out.Root = "SQLite" - } - if out.Root == "" && len(out.Templates) == 0 && len(out.Profiles) == 0 && len(out.Overlays) == 0 { - return defaultConfigPreviewSources(""), nil - } - return out, nil -} - -func (s *ConfigPreviewService) listRepoSources() (ConfigPreviewSources, bool, error) { + out := ConfigPreviewSources{} if s == nil || s.assets == nil { - return ConfigPreviewSources{}, false, nil + return out, nil } templates, err := s.assets.ListTemplates() if err != nil { - return ConfigPreviewSources{}, true, err + return out, err + } + for _, item := range templates { + out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)}) } profiles, err := s.assets.ListProfiles() if err != nil { - return ConfigPreviewSources{}, true, err - } - overlays, err := s.assets.ListOverlays() - if err != nil { - return ConfigPreviewSources{}, true, err - } - if len(templates) == 0 && len(profiles) == 0 && len(overlays) == 0 { - return ConfigPreviewSources{}, false, nil - } - out := ConfigPreviewSources{Root: "SQLite"} - for _, item := range templates { - out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)}) + return out, err } for _, item := range profiles { out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)}) } + overlays, err := s.assets.ListOverlays() + if err != nil { + return out, err + } for _, item := range overlays { out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)}) } - return out, true, nil + sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name }) + sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name }) + sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name }) + if len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0 { + out.Root = "SQLite" + } + return out, nil } func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) { - root := s.mediaRepoRoot() - if root == "" { - return nil, fmt.Errorf("media repo path is not configured") + if err := validateConfigName(req.Template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) } - templatePath := filepath.Join(root, "configs", "templates", req.Template+".json") - profilePath := filepath.Join(root, "configs", "profiles", req.Profile+".json") - return s.renderFromPaths(root, req, templatePath, profilePath) + if strings.TrimSpace(req.Profile) != "" { + if err := validateConfigName(req.Profile); err != nil { + return nil, fmt.Errorf("invalid profile: %w", err) + } + } + for _, overlay := range req.Overlays { + if err := validateConfigName(overlay); err != nil { + return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) + } + } + templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template) + if err != nil { + return nil, err + } + profileRaw, profilePath, err := s.readAssetJSON("profiles", req.Profile) + if err != nil { + return nil, err + } + overlays := make([]runtimeOverlayInput, 0, len(req.Overlays)) + for _, overlay := range req.Overlays { + raw, path, err := s.readAssetJSON("overlays", overlay) + if err != nil { + return nil, err + } + overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) + } + return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays) } func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) { - root := s.mediaRepoRoot() - if root == "" { - return nil, fmt.Errorf("media repo path is not configured") + if err := validateConfigName(req.Template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + for _, overlay := range req.Overlays { + if err := validateConfigName(overlay); err != nil { + return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) + } } doc, err := s.BuildProfileDocument(editor) if err != nil { return nil, err } - body, err := marshalConfigJSON(doc) - if err != nil { - return nil, err - } - tempProfile, err := os.CreateTemp("", "rk3588-profile-editor-*.json") - if err != nil { - return nil, err - } - tempProfilePath := tempProfile.Name() - if _, err := tempProfile.Write(body); err != nil { - _ = tempProfile.Close() - _ = os.Remove(tempProfilePath) - return nil, err - } - _ = tempProfile.Close() - defer os.Remove(tempProfilePath) - if strings.TrimSpace(req.Profile) == "" { req.Profile = strings.TrimSpace(editor.Name) } - templatePath := filepath.Join(root, "configs", "templates", req.Template+".json") - return s.renderFromPaths(root, req, templatePath, tempProfilePath) + templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template) + if err != nil { + return nil, err + } + overlays := make([]runtimeOverlayInput, 0, len(req.Overlays)) + for _, overlay := range req.Overlays { + raw, path, err := s.readAssetJSON("overlays", overlay) + if err != nil { + return nil, err + } + overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) + } + return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays) } -func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewRequest, templatePath string, profilePath string) (*ConfigPreviewResult, error) { +func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) { if err := validateConfigName(req.Template); err != nil { return nil, fmt.Errorf("invalid template: %w", err) } @@ -242,76 +186,28 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq if req.ConfigVersion == "" { req.ConfigVersion = time.Now().Format("20060102.150405") } - if _, err := os.Stat(templatePath); err != nil { - return nil, err - } - if _, err := os.Stat(profilePath); err != nil { - return nil, fmt.Errorf("invalid profile: %w", err) - } - profileRaw, err := readConfigJSONFile(profilePath) - if err != nil { - return nil, fmt.Errorf("read profile for integrations: %w", err) - } - integrationParams, err := s.integrationParamsForProfile(profileRaw) + resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw) if err != nil { return nil, err } - resolvedTemplatePath := templatePath - if len(integrationParams) > 0 { - tempTemplatePath, err := buildResolvedTemplateFile(templatePath, integrationParams) - if err != nil { - return nil, err - } - resolvedTemplatePath = tempTemplatePath - defer os.Remove(tempTemplatePath) - } - - out, err := os.CreateTemp("", "rk3588-config-preview-*.json") + doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, map[string]any{ + "config_id": req.ConfigID, + "config_version": req.ConfigVersion, + "rendered_at": time.Now().Format(time.RFC3339), + "rendered_by": "managerd", + }) if err != nil { return nil, err } - outPath := out.Name() - _ = out.Close() - defer os.Remove(outPath) - - args := []string{ - filepath.Join(root, "tools", "render_config.py"), - "--template", resolvedTemplatePath, - "--profile", profilePath, - "--out", outPath, - "--config-id", req.ConfigID, - "--config-version", req.ConfigVersion, - "--rendered-at", time.Now().Format(time.RFC3339), - } - for _, overlay := range req.Overlays { - args = append(args, "--overlay", filepath.Join(root, "configs", "overlays", overlay+".json")) - } - - cmd := exec.Command("python", args...) - cmd.Dir = root - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg == "" { - msg = err.Error() - } - return nil, fmt.Errorf("render config preview: %s", msg) - } - - body, err := os.ReadFile(outPath) + body, err := marshalConfigJSON(doc) if err != nil { return nil, err } - var doc map[string]any - if err := json.Unmarshal(body, &doc); err != nil { - return nil, err - } metadata, _ := doc["metadata"].(map[string]any) sum := sha256.Sum256(body) return &ConfigPreviewResult{ Request: req, - Root: root, + Root: previewRenderRoot(templatePath, profilePath), Sha256: hex.EncodeToString(sum[:]), Size: len(body), Metadata: metadata, @@ -319,114 +215,160 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq }, nil } -func (s *ConfigPreviewService) integrationParamsForProfile(raw map[string]any) (map[string]any, error) { - params := map[string]any{} +func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) { if raw == nil { - return params, nil + return map[string]any{}, nil } - type refDef struct { - key string - expected string - apply func(asset *ConfigIntegrationServiceAsset) + body, err := json.Marshal(raw) + if err != nil { + return nil, err } - defs := []refDef{ - { - key: "object_storage_ref", - expected: "object_storage", - apply: func(asset *ConfigIntegrationServiceAsset) { - if asset.ObjectStorage == nil { - return - } - setAnyString(params, "minio_endpoint", asset.ObjectStorage.Endpoint) - setAnyString(params, "minio_bucket", asset.ObjectStorage.Bucket) - setAnyString(params, "minio_access_key", asset.ObjectStorage.AccessKey) - setAnyString(params, "minio_secret_key", asset.ObjectStorage.SecretKey) - }, - }, - { - key: "token_service_ref", - expected: "token_service", - apply: func(asset *ConfigIntegrationServiceAsset) { - if asset.TokenService == nil { - return - } - setAnyString(params, "external_get_token_url", asset.TokenService.GetTokenURL) - setAnyString(params, "tenant_code", asset.TokenService.TenantCode) - }, - }, - { - key: "alarm_service_ref", - expected: "alarm_service", - apply: func(asset *ConfigIntegrationServiceAsset) { - if asset.AlarmService == nil { - return - } - setAnyString(params, "external_put_message_url", asset.AlarmService.PutMessageURL) - setAnyString(params, "tenant_code", asset.AlarmService.TenantCode) - }, - }, + var clone map[string]any + if err := json.Unmarshal(body, &clone); err != nil { + return nil, err } - for _, def := range defs { - refName := strings.TrimSpace(stringValue(raw[def.key])) - if refName == "" { - continue + instances, _ := clone["instances"].([]any) + for _, item := range instances { + instanceMap, _ := item.(map[string]any) + paramsMap, _ := instanceMap["params"].(map[string]any) + if paramsMap == nil { + paramsMap = map[string]any{} + instanceMap["params"] = paramsMap } - asset, err := s.GetIntegrationService(refName) - if err != nil { - return nil, fmt.Errorf("load %s %q: %w", def.key, refName, err) + inputBindings, _ := instanceMap["input_bindings"].(map[string]any) + if inputBindings == nil { + inputBindings = map[string]any{} + instanceMap["input_bindings"] = inputBindings } - if strings.TrimSpace(asset.Type) != def.expected { - return nil, fmt.Errorf("%s %q has type %q, expected %q", def.key, refName, asset.Type, def.expected) + videoSourceRef := bindingField(inputBindings, "video_input_main", "video_source_ref") + if videoSourceRef != "" { + asset, err := s.GetVideoSource(videoSourceRef) + if err != nil { + return nil, fmt.Errorf("load video_source_ref %q: %w", videoSourceRef, err) + } + entry, _ := inputBindings["video_input_main"].(map[string]any) + if entry == nil { + entry = map[string]any{} + } + entry["video_source_ref"] = videoSourceRef + entry["resolved"] = map[string]any{ + "url": asset.Config.URL, + "resolution": asset.Config.Resolution, + "frame_size": asset.Config.FrameSize, + "fps": asset.Config.FPS, + "video_format": asset.Config.VideoFormat, + } + inputBindings["video_input_main"] = entry + } + + serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) + if serviceBindings == nil { + serviceBindings = map[string]any{} + instanceMap["service_bindings"] = serviceBindings + } + for _, binding := range []struct { + slot string + expected string + }{ + {slot: "object_storage_main", expected: "object_storage"}, + {slot: "token_service_main", expected: "token_service"}, + {slot: "alarm_service_main", expected: "alarm_service"}, + } { + serviceRef := bindingField(serviceBindings, binding.slot, "service_ref") + if serviceRef == "" { + continue + } + asset, err := s.GetIntegrationService(serviceRef) + if err != nil { + return nil, fmt.Errorf("load service_ref %q: %w", serviceRef, err) + } + if strings.TrimSpace(asset.Type) != binding.expected { + return nil, fmt.Errorf("service_ref %q has type %q, expected %q", serviceRef, asset.Type, binding.expected) + } + entry, _ := serviceBindings[binding.slot].(map[string]any) + if entry == nil { + entry = map[string]any{} + } + entry["service_ref"] = serviceRef + entry["resolved"] = resolvedServiceBinding(asset) + serviceBindings[binding.slot] = entry } - def.apply(asset) } - return params, nil + return clone, nil } -func buildResolvedTemplateFile(templatePath string, params map[string]any) (string, error) { - raw, err := readConfigJSONFile(templatePath) - if err != nil { - return "", err +func bindingField(bindings map[string]any, slot string, field string) string { + entry, _ := bindings[slot].(map[string]any) + return stringValue(entry[field]) +} + +func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any { + if asset == nil { + return nil } - paramsMap, _ := raw["params"].(map[string]any) - if paramsMap == nil { - paramsMap = map[string]any{} + switch asset.Type { + case "object_storage": + if asset.ObjectStorage == nil { + return nil + } + return map[string]any{ + "endpoint": asset.ObjectStorage.Endpoint, + "bucket": asset.ObjectStorage.Bucket, + "access_key": asset.ObjectStorage.AccessKey, + "secret_key": asset.ObjectStorage.SecretKey, + } + case "token_service": + if asset.TokenService == nil { + return nil + } + return map[string]any{ + "get_token_url": asset.TokenService.GetTokenURL, + "username": asset.TokenService.Username, + "password": asset.TokenService.Password, + "tenant_code": asset.TokenService.TenantCode, + } + case "alarm_service": + if asset.AlarmService == nil { + return nil + } + return map[string]any{ + "put_message_url": asset.AlarmService.PutMessageURL, + "username": asset.AlarmService.Username, + "password": asset.AlarmService.Password, + "tenant_code": asset.AlarmService.TenantCode, + } + default: + return nil } - for key, value := range params { - paramsMap[key] = value +} + +func previewRenderRoot(templatePath string, profilePath string) string { + if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") { + return "SQLite" } - raw["params"] = paramsMap + if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." { + return dir + } + return "managerd" +} + +func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) { body, err := marshalConfigJSON(raw) if err != nil { return "", err } - tempTemplate, err := os.CreateTemp("", "rk3588-template-resolved-*.json") + tempFile, err := os.CreateTemp("", pattern) if err != nil { return "", err } - tempTemplatePath := tempTemplate.Name() - if _, err := tempTemplate.Write(body); err != nil { - _ = tempTemplate.Close() - _ = os.Remove(tempTemplatePath) + path := tempFile.Name() + if _, err := tempFile.Write(body); err != nil { + _ = tempFile.Close() + _ = os.Remove(path) return "", err } - _ = tempTemplate.Close() - return tempTemplatePath, nil -} - -func readConfigJSONFile(path string) (map[string]any, error) { - body, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - if raw == nil { - raw = map[string]any{} - } - return raw, nil + _ = tempFile.Close() + return path, nil } func setAnyString(m map[string]any, key string, value string) { @@ -436,36 +378,21 @@ func setAnyString(m map[string]any, key string, value string) { } func (s *ConfigPreviewService) mediaRepoRoot() string { - if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" { - return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath)) - } - if env := strings.TrimSpace(os.Getenv("ORANGEPI_MEDIA_REPO")); env != "" { - return filepath.Clean(env) - } - wd, err := os.Getwd() - if err != nil { + if s.cfg == nil { return "" } - candidates := []string{ - filepath.Join(wd, "..", "OrangePi3588Media"), - filepath.Join(wd, "..", "..", "OrangePi3588Media"), - filepath.Join(filepath.Dir(wd), "OrangePi3588Media"), + if strings.TrimSpace(s.cfg.MediaRepoPath) == "" { + return "" } - for _, candidate := range candidates { - if _, err := os.Stat(filepath.Join(candidate, "tools", "render_config.py")); err == nil { - return filepath.Clean(candidate) - } - } - return "" -} - -func (s *ConfigPreviewService) hasExplicitRoot() bool { - return s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" + return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath)) } func listConfigSources(dir string) ([]ConfigSource, error) { files, err := os.ReadDir(dir) if err != nil { + if os.IsNotExist(err) { + return []ConfigSource{}, nil + } return nil, err } out := make([]ConfigSource, 0) @@ -490,45 +417,24 @@ func validateConfigName(name string) error { return nil } -func defaultConfigPreviewSources(root string) ConfigPreviewSources { - return ConfigPreviewSources{ - Root: root, - Templates: []ConfigSource{ - {Name: "std_face_recognition_stream"}, - {Name: "std_service_test_stream"}, - {Name: "std_workshoe_detection_stream"}, - {Name: "std_workshop_face_recognition_shoe_alarm"}, - }, - Profiles: []ConfigSource{ - {Name: "local_3588_test"}, - }, - Overlays: []ConfigSource{ - {Name: "face_debug"}, - {Name: "face_test_sensitive"}, - {Name: "production_quiet"}, - {Name: "shoe_debug"}, - {Name: "shoe_test_sensitive"}, - }, - } -} - func repoAssetPath(kind string, name string) string { return "sqlite:" + kind + "/" + strings.TrimSpace(name) } func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportResult, error) { if s == nil || s.assets == nil { - return nil, fmt.Errorf("assets repository is not configured") + return nil, fmt.Errorf("asset repository is not configured") } root := s.mediaRepoRoot() if root == "" { - return nil, fmt.Errorf("media repo path is not configured") + return nil, fmt.Errorf("legacy import source path is not configured") } result := &ConfigAssetImportResult{Root: root} for _, item := range []struct { kind string inc *int }{ + {kind: "templates", inc: &result.Templates}, {kind: "profiles", inc: &result.Profiles}, {kind: "overlays", inc: &result.Overlays}, } { @@ -549,6 +455,16 @@ func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportRe description := stringValue(raw["description"]) switch item.kind { case "templates": + if raw == nil { + raw = map[string]any{} + } + if isStandardTemplateName(name) && strings.TrimSpace(stringValue(raw["source"])) == "" { + raw["source"] = "standard" + body, err = marshalConfigJSON(raw) + if err != nil { + return nil, err + } + } if err := s.assets.SaveTemplate(name, description, string(body)); err != nil { return nil, err } @@ -571,21 +487,13 @@ func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte if err := validateConfigName(name); err != nil { return nil, "", err } - if s != nil && s.assets != nil { - if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil { - return body, name + ".json", err - } + if s == nil || s.assets == nil { + return nil, "", fmt.Errorf("asset repository is not configured") } - root := s.mediaRepoRoot() - if root == "" { - return nil, "", fmt.Errorf("media repo path is not configured") + if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil { + return body, name + ".json", err } - path := filepath.Join(root, "configs", kind, name+".json") - body, err := os.ReadFile(path) - if err != nil { - return nil, "", err - } - return body, name + ".json", nil + return nil, "", os.ErrNotExist } func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) { @@ -620,5 +528,5 @@ func profileRawTemplateName(raw map[string]any) string { return v } } - return stringValue(raw["template_name"]) + return stringValue(raw["primary_template_name"]) } diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index 89b2340..7cb96f3 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -11,20 +11,34 @@ import ( "3588AdminBackend/internal/storage" ) +func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) { + t.Helper() + if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } +} + func TestConfigPreviewServiceListsSources(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) sources, err := svc.ListSources() if err != nil { t.Fatalf("ListSources: %v", err) } - if sources.Root != root { - t.Fatalf("expected root %q, got %q", root, sources.Root) + if sources.Root != "SQLite" { + t.Fatalf("expected root %q, got %q", "SQLite", sources.Root) } if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" { t.Fatalf("unexpected templates: %v", got) @@ -37,6 +51,35 @@ func TestConfigPreviewServiceListsSources(t *testing.T) { } } +func TestConfigPreviewServiceListSourcesAllowsEmptyConfigsDir(t *testing.T) { + root := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + sources, err := svc.ListSources() + if err != nil { + t.Fatalf("ListSources: %v", err) + } + if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { + t.Fatalf("unexpected templates: %#v", got) + } + if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "line_a" { + t.Fatalf("unexpected profiles: %#v", got) + } +} + func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) { root := t.TempDir() svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) @@ -54,8 +97,8 @@ func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) { func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) { root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`) - mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`) + mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) @@ -70,7 +113,7 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) { if err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } - if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 { + if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { t.Fatalf("unexpected import result: %#v", result) } @@ -78,13 +121,13 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) { if err != nil { t.Fatalf("ListSources: %v", err) } - if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { + if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" { t.Fatalf("unexpected templates after import: %#v", got) } - if record, err := repo.GetTemplate("helmet"); err != nil { + if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil { t.Fatalf("GetTemplate: %v", err) - } else if record != nil { - t.Fatalf("expected builtin template to remain outside sqlite, got %#v", record) + } else if record == nil { + t.Fatal("expected imported standard template") } } @@ -114,82 +157,18 @@ func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) { } } -func TestConfigPreviewServiceIntegrationParamsForProfile(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - - repo := storage.NewAssetsRepo(store.DB()) - saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) - saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) - - svc := NewConfigPreviewService(&config.Config{}, repo) - params, err := svc.integrationParamsForProfile(map[string]any{ - "object_storage_ref": "minio_main", - "token_service_ref": "token_main", - "alarm_service_ref": "alarm_main", - }) - if err != nil { - t.Fatalf("integrationParamsForProfile: %v", err) - } - for key, want := range map[string]string{ - "minio_endpoint": "http://10.0.0.49:9000", - "minio_bucket": "myminio", - "minio_access_key": "admin", - "minio_secret_key": "password", - "external_get_token_url": "http://10.0.0.49:8080/api/getToken", - "external_put_message_url": "http://10.0.0.49:8080/api/putMessage", - "tenant_code": "32", - } { - if got := params[key]; got != want { - t.Fatalf("expected %s=%q, got %#v", key, want, got) - } - } -} - -func TestConfigPreviewServiceRenderProfileEditorExpandsIntegrationRefs(t *testing.T) { +func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name":"std_workshop_face_recognition_shoe_alarm", - "params":{"existing":"keep"}, - "template":{"nodes":[],"edges":[]} + "template":{ + "nodes":[ + {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}, + {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]} + ], + "edges":[] + } }`) - mustWrite(t, filepath.Join(root, "tools", "render_config.py"), `import argparse -import json - -parser = argparse.ArgumentParser() -parser.add_argument("--template", required=True) -parser.add_argument("--profile", required=True) -parser.add_argument("--out", required=True) -parser.add_argument("--config-id", required=True) -parser.add_argument("--config-version", required=True) -parser.add_argument("--rendered-at", required=True) -parser.add_argument("--overlay", action="append", default=[]) -args = parser.parse_args() - -with open(args.template, "r", encoding="utf-8") as fh: - template = json.load(fh) -with open(args.profile, "r", encoding="utf-8") as fh: - profile = json.load(fh) - -doc = { - "metadata": { - "template": template.get("name"), - "profile": profile.get("name"), - }, - "template_params": template.get("params", {}), - "profile_refs": { - "object_storage_ref": profile.get("object_storage_ref"), - "token_service_ref": profile.get("token_service_ref"), - "alarm_service_ref": profile.get("alarm_service_ref"), - } -} -with open(args.out, "w", encoding="utf-8") as fh: - json.dump(doc, fh, ensure_ascii=False, indent=2) -`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { @@ -197,20 +176,36 @@ with open(args.out, "w", encoding="utf-8") as fh: } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) editor := ConfigProfileEditor{ - Name: "line_a", - ObjectStorageRef: "minio_main", - TokenServiceRef: "token_main", - AlarmServiceRef: "alarm_main", + Name: "line_a", Instances: []ConfigProfileInstanceEditor{{ - Name: "cam1", - Template: "std_workshop_face_recognition_shoe_alarm", - RTSPURL: "rtsp://10.0.0.1/live", + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "gate_cam_01", + PublishHLSPath: "./web/hls/cam1/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/cam1", + ChannelNo: "cam1", + ServiceBindings: map[string]ServiceBindingEditor{ + "object_storage_main": {ServiceRef: "minio_main"}, + "token_service_main": {ServiceRef: "token_main"}, + "alarm_service_main": {ServiceRef: "alarm_main"}, + }, }}, } @@ -227,20 +222,181 @@ with open(args.out, "w", encoding="utf-8") as fh: if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { t.Fatalf("unmarshal render result: %v", err) } - templateParams, _ := doc["template_params"].(map[string]any) - for key, want := range map[string]string{ - "existing": "keep", - "minio_endpoint": "http://10.0.0.49:9000", - "minio_bucket": "myminio", - "minio_access_key": "admin", - "minio_secret_key": "password", - "external_get_token_url": "http://10.0.0.49:8080/api/getToken", - "external_put_message_url": "http://10.0.0.49:8080/api/putMessage", - "tenant_code": "32", - } { - if got := stringValue(templateParams[key]); got != want { - t.Fatalf("expected template param %s=%q, got %#v", key, want, templateParams[key]) - } + templates, _ := doc["templates"].(map[string]any) + renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) + nodes, _ := renderedTemplate["nodes"].([]any) + inputNode, _ := nodes[0].(map[string]any) + if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { + t.Fatalf("expected expanded input url, got %#v", inputNode) + } + publishNode, _ := nodes[1].(map[string]any) + outputs, _ := publishNode["outputs"].([]any) + output, _ := outputs[0].(map[string]any) + if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" { + t.Fatalf("expected expanded output path, got %#v", output) + } + metadata, _ := doc["metadata"].(map[string]any) + if got := stringValue(metadata["rendered_by"]); got != "managerd" { + t.Fatalf("expected managerd renderer metadata, got %#v", metadata) + } +} + +func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ + "name":"std_service_test_stream", + "template":{ + "nodes":[ + {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}, + {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]} + ], + "edges":[] + } +}`) + + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{ + "name":"line_a", + "business_name":"Line A", + "instances":[ + { + "name":"cam1", + "template":"std_service_test_stream", + "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}, + "output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}} + } + ] +}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil { + t.Fatalf("SaveOverlay: %v", err) + } + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) + result, err := svc.Render(ConfigPreviewRequest{ + Template: "std_service_test_stream", + Profile: "line_a", + Overlays: []string{"night_relaxed"}, + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err != nil { + t.Fatalf("Render: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { + t.Fatalf("unmarshal render result: %v", err) + } + metadata, _ := doc["metadata"].(map[string]any) + if got := stringValue(metadata["profile"]); got != "line_a" { + t.Fatalf("expected sqlite profile metadata, got %#v", metadata) + } + names, _ := metadata["overlays"].([]any) + if len(names) != 1 || stringValue(names[0]) != "night_relaxed" { + t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"]) + } + templates, _ := doc["templates"].(map[string]any) + renderedTemplate, _ := templates["std_service_test_stream__cam1"].(map[string]any) + nodes, _ := renderedTemplate["nodes"].([]any) + inputNode, _ := nodes[0].(map[string]any) + if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { + t.Fatalf("expected expanded input url, got %#v", inputNode) + } + instances, _ := doc["instances"].([]any) + instance, _ := instances[0].(map[string]any) + params, _ := instance["params"].(map[string]any) + if got := boolValue(params["debug"], false); !got { + t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance) + } +} + +func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) { + svc := NewConfigPreviewService(&config.Config{}) + err := svc.SaveProfileEditor(ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "helmet", + VideoSourceRef: "gate_cam_01", + }}, + }) + if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") { + t.Fatalf("expected asset repository error, got %v", err) + } +} + +func TestConfigPreviewServiceResolveSceneBindings(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + + svc := NewConfigPreviewService(&config.Config{}, repo) + resolved, err := svc.resolveSceneBindings(map[string]any{ + "name": "line_a", + "instances": []any{ + map[string]any{ + "name": "cam1", + "template": "std_workshop_face_recognition_shoe_alarm", + "input_bindings": map[string]any{ + "video_input_main": map[string]any{ + "video_source_ref": "gate_cam_01", + }, + }, + "service_bindings": map[string]any{ + "object_storage_main": map[string]any{ + "service_ref": "minio_main", + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("resolveSceneBindings: %v", err) + } + instances, _ := resolved["instances"].([]any) + instanceMap, _ := instances[0].(map[string]any) + inputBindings, _ := instanceMap["input_bindings"].(map[string]any) + videoInput, _ := inputBindings["video_input_main"].(map[string]any) + resolvedInput, _ := videoInput["resolved"].(map[string]any) + if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" { + t.Fatalf("expected resolved input url, got %#v", resolvedInput) + } + serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) + objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) + resolvedService, _ := objectStorage["resolved"].(map[string]any) + if got := stringValue(resolvedService["bucket"]); got != "myminio" { + t.Fatalf("expected resolved service binding, got %#v", resolvedService) } } diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go new file mode 100644 index 0000000..bee4e55 --- /dev/null +++ b/internal/service/config_runtime_render.go @@ -0,0 +1,415 @@ +package service + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strings" +) + +var slotTokenRE = regexp.MustCompile(`^\$\{slot:([A-Za-z0-9_.-]+)\.([A-Za-z0-9_.-]+)\}$`) + +func renderRuntimeConfig(templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, metadata map[string]any) (map[string]any, error) { + tplName, err := runtimeTemplateName(templateRaw, templatePath) + if err != nil { + return nil, err + } + instances, err := runtimeProfileInstances(profileRaw, tplName) + if err != nil { + return nil, err + } + instances, err = mergeRuntimeTemplateParams(instances, runtimeTemplateParams(templateRaw)) + if err != nil { + return nil, err + } + + renderedTemplates := map[string]any{} + renderedInstances := make([]any, 0, len(instances)) + for _, instance := range instances { + boundName, boundTemplate, renderedInstance, err := renderRuntimeSceneInstance(templateRaw, templatePath, instance) + if err != nil { + return nil, err + } + renderedTemplates[boundName] = boundTemplate + renderedInstances = append(renderedInstances, renderedInstance) + } + + root := map[string]any{ + "templates": renderedTemplates, + "instances": renderedInstances, + } + for _, key := range []string{"global", "queue"} { + if value, ok := profileRaw[key]; ok { + root[key] = deepCopyAny(value) + } + } + + for _, overlay := range overlays { + var err error + root, err = applyRuntimeOverlay(root, overlay.Raw) + if err != nil { + return nil, err + } + } + + if metadata != nil { + root["metadata"] = buildRuntimeMetadata(tplName, templatePath, profileRaw, profilePath, overlays, root, metadata) + } + return root, nil +} + +type runtimeOverlayInput struct { + Name string + Path string + Raw map[string]any +} + +func runtimeTemplateName(templateRaw map[string]any, templatePath string) (string, error) { + name := strings.TrimSpace(firstString(templateRaw["name"], strings.TrimSuffix(filepath.Base(templatePath), filepath.Ext(templatePath)))) + if name == "" { + return "", fmt.Errorf("%s: template name is empty", templatePath) + } + return name, nil +} + +func runtimeTemplateBody(templateRaw map[string]any) (map[string]any, error) { + body := templateRaw + if nested, ok := templateRaw["template"].(map[string]any); ok { + body = nested + } + nodes, hasNodes := body["nodes"].([]any) + edges, hasEdges := body["edges"].([]any) + if !hasNodes || !hasEdges { + return nil, fmt.Errorf("template body must contain nodes[] and edges[]") + } + out := map[string]any{ + "nodes": deepCopyAny(nodes), + "edges": deepCopyAny(edges), + } + if executor, ok := body["executor"]; ok { + out["executor"] = deepCopyAny(executor) + } + return out, nil +} + +func runtimeTemplateParams(templateRaw map[string]any) map[string]any { + params, _ := templateRaw["params"].(map[string]any) + if params == nil { + return map[string]any{} + } + return cloneMap(params) +} + +func runtimeProfileInstances(profileRaw map[string]any, tplName string) ([]map[string]any, error) { + if items, ok := profileRaw["instances"].([]any); ok { + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + instance, _ := item.(map[string]any) + if instance == nil { + return nil, fmt.Errorf("profile.instances entries must be objects") + } + cloned := deepCopyMap(instance) + if strings.TrimSpace(stringValue(cloned["template"])) == "" { + cloned["template"] = tplName + } + out = append(out, cloned) + } + return out, nil + } + name := strings.TrimSpace(stringValue(profileRaw["name"])) + if name == "" { + return nil, fmt.Errorf("profile must contain name or instances[]") + } + instance := map[string]any{ + "name": name, + "template": tplName, + } + if params, ok := profileRaw["params"].(map[string]any); ok && len(params) > 0 { + instance["params"] = deepCopyMap(params) + } + if override, ok := profileRaw["override"].(map[string]any); ok && len(override) > 0 { + instance["override"] = deepCopyMap(override) + } + return []map[string]any{instance}, nil +} + +func mergeRuntimeTemplateParams(instances []map[string]any, sharedParams map[string]any) ([]map[string]any, error) { + if len(sharedParams) == 0 { + return instances, nil + } + out := make([]map[string]any, 0, len(instances)) + for _, item := range instances { + inst := deepCopyMap(item) + params, _ := inst["params"].(map[string]any) + if params == nil { + params = map[string]any{} + } + inst["params"] = deepMergeMap(sharedParams, params) + out = append(out, inst) + } + return out, nil +} + +func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, instance map[string]any) (string, map[string]any, map[string]any, error) { + instanceName := strings.TrimSpace(stringValue(instance["name"])) + if instanceName == "" { + return "", nil, nil, fmt.Errorf("scene instance name is required") + } + tplName, err := runtimeTemplateName(templateRaw, templatePath) + if err != nil { + return "", nil, nil, err + } + boundName := tplName + "__" + instanceName + context, err := buildRuntimeBindingContext(instance) + if err != nil { + return "", nil, nil, err + } + templateBody, err := runtimeTemplateBody(templateRaw) + if err != nil { + return "", nil, nil, err + } + renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context) + if err != nil { + return "", nil, nil, err + } + renderedTemplate, _ := renderedTemplateAny.(map[string]any) + renderedInstance := map[string]any{ + "name": instanceName, + "template": boundName, + } + if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 { + renderedInstance["scene_meta"] = deepCopyMap(sceneMeta) + } + if params, ok := instance["params"].(map[string]any); ok && len(params) > 0 { + renderedInstance["params"] = deepCopyMap(params) + } + return boundName, renderedTemplate, renderedInstance, nil +} + +func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) { + context := map[string]any{} + if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 { + context["scene"] = deepCopyMap(sceneMeta) + } + for _, groupName := range []string{"input_bindings", "service_bindings", "output_bindings"} { + group, _ := instance[groupName].(map[string]any) + for slotName, raw := range group { + entry, _ := raw.(map[string]any) + if entry == nil { + return nil, fmt.Errorf("binding entry must be an object") + } + context[slotName] = resolvedRuntimeBindingValue(entry) + } + } + return context, nil +} + +func resolvedRuntimeBindingValue(entry map[string]any) map[string]any { + if resolved, ok := entry["resolved"].(map[string]any); ok && resolved != nil { + return deepCopyMap(resolved) + } + return deepCopyMap(entry) +} + +func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) { + switch typed := value.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for key, item := range typed { + expanded, err := expandRuntimeSlotTokens(item, context) + if err != nil { + return nil, err + } + out[key] = expanded + } + return out, nil + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + expanded, err := expandRuntimeSlotTokens(item, context) + if err != nil { + return nil, err + } + out = append(out, expanded) + } + return out, nil + case string: + match := slotTokenRE.FindStringSubmatch(strings.TrimSpace(typed)) + if len(match) != 3 { + return typed, nil + } + slotValues, _ := context[match[1]].(map[string]any) + if slotValues == nil { + return nil, fmt.Errorf("required slot '%s' is not bound", match[1]) + } + fieldValue, ok := slotValues[match[2]] + if !ok { + return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2]) + } + return deepCopyAny(fieldValue), nil + default: + return deepCopyAny(value), nil + } +} + +func applyRuntimeOverlay(root map[string]any, overlay map[string]any) (map[string]any, error) { + out := deepCopyMap(root) + for _, key := range []string{"global", "queue", "templates"} { + if value, ok := overlay[key]; ok { + out[key] = deepMergeAny(out[key], value) + } + } + if rawPatches, ok := overlay["instance_overrides"]; ok { + patches, _ := rawPatches.(map[string]any) + if patches == nil { + return nil, fmt.Errorf("overlay.instance_overrides must be an object") + } + instances, _ := out["instances"].([]any) + mergedInstances := make([]any, 0, len(instances)) + for _, item := range instances { + instance, _ := item.(map[string]any) + merged := deepCopyMap(instance) + if patch, ok := patches["*"].(map[string]any); ok { + merged = mergeRuntimeInstancePatch(merged, patch) + } + name := stringValue(merged["name"]) + if patch, ok := patches[name].(map[string]any); ok { + merged = mergeRuntimeInstancePatch(merged, patch) + } + mergedInstances = append(mergedInstances, merged) + } + out["instances"] = mergedInstances + } + if rawInstances, ok := overlay["instances"]; ok { + patchList, _ := rawInstances.([]any) + if patchList == nil { + return nil, fmt.Errorf("overlay.instances must be an array") + } + instances, _ := out["instances"].([]any) + byName := map[string]int{} + for i, item := range instances { + instance, _ := item.(map[string]any) + byName[stringValue(instance["name"])] = i + } + for _, item := range patchList { + patch, _ := item.(map[string]any) + name := stringValue(patch["name"]) + if patch == nil || name == "" { + return nil, fmt.Errorf("overlay.instances entries must be objects with name") + } + idx, ok := byName[name] + if !ok { + return nil, fmt.Errorf("overlay instance not found in profile: %s", name) + } + instance, _ := instances[idx].(map[string]any) + instances[idx] = mergeRuntimeInstancePatch(instance, patch) + } + out["instances"] = instances + } + return out, nil +} + +func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) map[string]any { + merged := deepCopyMap(instance) + if params, ok := patch["params"].(map[string]any); ok { + existing, _ := merged["params"].(map[string]any) + merged["params"] = deepMergeMap(existing, params) + } + if override, ok := patch["override"].(map[string]any); ok { + existing, _ := merged["override"].(map[string]any) + merged["override"] = deepMergeMap(existing, override) + } + for key, value := range patch { + if key == "name" || key == "template" || key == "params" || key == "override" { + continue + } + merged[key] = deepMergeAny(merged[key], value) + } + return merged +} + +func buildRuntimeMetadata(templateName string, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, root map[string]any, metadata map[string]any) map[string]any { + instanceNames := make([]string, 0) + instanceDisplayNames := make([]string, 0) + instances, _ := root["instances"].([]any) + for _, item := range instances { + instance, _ := item.(map[string]any) + if name := strings.TrimSpace(stringValue(instance["name"])); name != "" { + instanceNames = append(instanceNames, name) + } + if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok { + if displayName := strings.TrimSpace(stringValue(sceneMeta["display_name"])); displayName != "" { + instanceDisplayNames = append(instanceDisplayNames, displayName) + } + } + } + overlayNames := make([]any, 0, len(overlays)) + overlayPaths := make([]any, 0, len(overlays)) + for _, overlay := range overlays { + overlayNames = append(overlayNames, overlay.Name) + overlayPaths = append(overlayPaths, overlay.Path) + } + out := map[string]any{ + "template": templateName, + "template_path": templatePath, + "profile": strings.TrimSpace(firstString(profileRaw["name"], strings.TrimSuffix(filepath.Base(profilePath), filepath.Ext(profilePath)))), + "business_name": strings.TrimSpace(stringValue(profileRaw["business_name"])), + "profile_path": profilePath, + "instance_names": instanceNames, + "instance_display_names": instanceDisplayNames, + "overlays": overlayNames, + "overlay_paths": overlayPaths, + } + for key, value := range metadata { + out[key] = deepCopyAny(value) + } + return out +} + +func deepMergeMap(base map[string]any, override map[string]any) map[string]any { + if base == nil { + base = map[string]any{} + } + return deepMergeAny(base, override).(map[string]any) +} + +func deepMergeAny(base any, override any) any { + baseMap, baseIsMap := base.(map[string]any) + overrideMap, overrideIsMap := override.(map[string]any) + if baseIsMap && overrideIsMap { + merged := deepCopyMap(baseMap) + for key, value := range overrideMap { + if existing, ok := merged[key]; ok { + merged[key] = deepMergeAny(existing, value) + } else { + merged[key] = deepCopyAny(value) + } + } + return merged + } + return deepCopyAny(override) +} + +func deepCopyMap(in map[string]any) map[string]any { + if in == nil { + return map[string]any{} + } + out, _ := deepCopyAny(in).(map[string]any) + return out +} + +func deepCopyAny(value any) any { + if value == nil { + return nil + } + body, err := json.Marshal(value) + if err != nil { + return value + } + var out any + if err := json.Unmarshal(body, &out); err != nil { + return value + } + return out +} diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go index 90d1131..7976aab 100644 --- a/internal/service/profile_editor.go +++ b/internal/service/profile_editor.go @@ -3,25 +3,20 @@ package service import ( "encoding/json" "fmt" - "os" - "path/filepath" "strconv" "strings" ) type ConfigProfileEditor struct { - Name string `json:"name"` - Path string `json:"path"` - BusinessName string `json:"business_name"` - Description string `json:"description"` - DeviceCode string `json:"device_code"` - SiteName string `json:"site_name"` - ObjectStorageRef string `json:"object_storage_ref"` - TokenServiceRef string `json:"token_service_ref"` - AlarmServiceRef string `json:"alarm_service_ref"` - Queue ConfigProfileQueueEditor `json:"queue"` - Instances []ConfigProfileInstanceEditor `json:"instances"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + BusinessName string `json:"business_name"` + Description string `json:"description"` + DeviceCode string `json:"device_code"` + SiteName string `json:"site_name"` + Queue ConfigProfileQueueEditor `json:"queue"` + Instances []ConfigProfileInstanceEditor `json:"instances"` + Raw map[string]any `json:"raw"` } type ConfigProfileQueueEditor struct { @@ -29,17 +24,36 @@ type ConfigProfileQueueEditor struct { Strategy string `json:"strategy"` } +type InputBindingEditor struct { + VideoSourceRef string `json:"video_source_ref"` +} + +type ServiceBindingEditor struct { + ServiceRef string `json:"service_ref"` +} + +type OutputBindingEditor struct { + PublishHLSPath string `json:"publish_hls_path"` + PublishRTSPPort string `json:"publish_rtsp_port"` + PublishRTSPPath string `json:"publish_rtsp_path"` + ChannelNo string `json:"channel_no"` +} + type ConfigProfileInstanceEditor struct { - Name string `json:"name"` - Template string `json:"template"` - DisplayName string `json:"display_name"` - RTSPURL string `json:"rtsp_url"` - 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"` - Delete bool `json:"delete"` + Name string `json:"name"` + Template string `json:"template"` + VideoSourceRef string `json:"video_source_ref"` + DisplayName string `json:"display_name"` + 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"` + InputBindings map[string]InputBindingEditor `json:"input_bindings,omitempty"` + ServiceBindings map[string]ServiceBindingEditor `json:"service_bindings,omitempty"` + OutputBindings map[string]OutputBindingEditor `json:"output_bindings,omitempty"` + AdvancedParams map[string]any `json:"advanced_params"` + Delete bool `json:"delete"` } func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEditor, error) { @@ -55,17 +69,22 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit for _, item := range instancesRaw { instanceMap, _ := item.(map[string]any) paramsMap, _ := instanceMap["params"].(map[string]any) + sceneMeta, _ := instanceMap["scene_meta"].(map[string]any) + inputBindings := parseInputBindingEditors(instanceMap["input_bindings"]) + serviceBindings := parseServiceBindingEditors(instanceMap["service_bindings"]) + outputBindings := parseOutputBindingEditors(instanceMap["output_bindings"]) if deviceCode == "" { - deviceCode = stringValue(paramsMap["device_code"]) + deviceCode = firstString(sceneMeta["device_code"], stringValue(paramsMap["device_code"])) } if siteName == "" { - siteName = stringValue(paramsMap["site_name"]) + siteName = firstString(sceneMeta["site_name"], stringValue(paramsMap["site_name"])) } advanced := cloneMap(paramsMap) for _, key := range []string{ "display_name", "device_code", "site_name", + "video_source_ref", "rtsp_url", "publish_hls_path", "publish_rtsp_port", @@ -80,25 +99,26 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit instances = append(instances, ConfigProfileInstanceEditor{ Name: stringValue(instanceMap["name"]), Template: stringValue(instanceMap["template"]), - DisplayName: stringValue(paramsMap["display_name"]), - RTSPURL: stringValue(paramsMap["rtsp_url"]), - PublishHLSPath: stringValue(paramsMap["publish_hls_path"]), - PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]), - PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]), - ChannelNo: stringValue(paramsMap["channel_no"]), + VideoSourceRef: inputBindingRef(inputBindings, "video_input_main"), + DisplayName: stringValue(sceneMeta["display_name"]), + SiteName: stringValue(sceneMeta["site_name"]), + PublishHLSPath: outputBindingValue(outputBindings, "stream_output_main", "publish_hls_path"), + PublishRTSPPort: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_port"), + PublishRTSPPath: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_path"), + ChannelNo: outputBindingValue(outputBindings, "stream_output_main", "channel_no"), + InputBindings: inputBindings, + ServiceBindings: serviceBindings, + OutputBindings: outputBindings, AdvancedParams: advanced, }) } return &ConfigProfileEditor{ - Name: firstString(raw["name"], name), - Path: path, - BusinessName: stringValue(raw["business_name"]), - Description: stringValue(raw["description"]), - DeviceCode: deviceCode, - SiteName: siteName, - ObjectStorageRef: stringValue(raw["object_storage_ref"]), - TokenServiceRef: stringValue(raw["token_service_ref"]), - AlarmServiceRef: stringValue(raw["alarm_service_ref"]), + Name: firstString(raw["name"], name), + Path: path, + BusinessName: stringValue(raw["business_name"]), + Description: stringValue(raw["description"]), + DeviceCode: deviceCode, + SiteName: siteName, Queue: ConfigProfileQueueEditor{ Size: valueString(queueMap["size"]), Strategy: stringValue(queueMap["strategy"]), @@ -138,37 +158,48 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor) } seen[instanceName] = struct{}{} - rtspURL := strings.TrimSpace(inst.RTSPURL) - if rtspURL == "" { - return nil, fmt.Errorf("rtsp url is required for %s", instanceName) + videoSourceRef := strings.TrimSpace(inputBindingRef(inst.InputBindings, "video_input_main")) + if videoSourceRef == "" { + videoSourceRef = strings.TrimSpace(inst.VideoSourceRef) + } + if videoSourceRef == "" { + return nil, fmt.Errorf("video source is required for %s", instanceName) } params := map[string]any{} - setString(params, "display_name", inst.DisplayName) - setString(params, "device_code", editor.DeviceCode) - setString(params, "site_name", editor.SiteName) - setString(params, "rtsp_url", rtspURL) - setString(params, "publish_hls_path", inst.PublishHLSPath) - setString(params, "publish_rtsp_path", inst.PublishRTSPPath) - setString(params, "channel_no", inst.ChannelNo) - - if port := strings.TrimSpace(inst.PublishRTSPPort); port != "" { - value, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("publish rtsp port must be a number for %s", instanceName) - } - params["publish_rtsp_port"] = value - } - for key, value := range cloneMap(inst.AdvancedParams) { params[key] = value } instance := map[string]any{ - "name": instanceName, - "params": params, + "name": instanceName, + } + if len(params) > 0 { + instance["params"] = params } setString(instance, "template", inst.Template) + sceneMeta := map[string]any{} + setString(sceneMeta, "display_name", inst.DisplayName) + setString(sceneMeta, "site_name", firstString(inst.SiteName, editor.SiteName)) + setString(sceneMeta, "device_code", editor.DeviceCode) + if len(sceneMeta) > 0 { + instance["scene_meta"] = sceneMeta + } + inputBindings := buildInputBindingDocument(inst) + if len(inputBindings) > 0 { + instance["input_bindings"] = inputBindings + } + serviceBindings := buildServiceBindingDocument(inst) + if len(serviceBindings) > 0 { + instance["service_bindings"] = serviceBindings + } + outputBindings, err := buildOutputBindingDocument(inst) + if err != nil { + return nil, err + } + if len(outputBindings) > 0 { + instance["output_bindings"] = outputBindings + } instances = append(instances, instance) } if len(instances) == 0 { @@ -181,10 +212,6 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor) } setString(doc, "business_name", editor.BusinessName) setString(doc, "description", editor.Description) - setString(doc, "object_storage_ref", editor.ObjectStorageRef) - setString(doc, "token_service_ref", editor.TokenServiceRef) - setString(doc, "alarm_service_ref", editor.AlarmServiceRef) - queue := map[string]any{} if size := strings.TrimSpace(editor.Queue.Size); size != "" { value, err := strconv.Atoi(size) @@ -219,12 +246,7 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err string(body), ) } - root := s.mediaRepoRoot() - if root == "" { - return fmt.Errorf("media repo path is not configured") - } - path := filepath.Join(root, "configs", "profiles", strings.TrimSpace(editor.Name)+".json") - return os.WriteFile(path, body, 0o644) + return fmt.Errorf("asset repository is not configured") } func setString(m map[string]any, key string, value string) { @@ -252,3 +274,161 @@ func firstProfileTemplate(instances []ConfigProfileInstanceEditor) string { } return "" } + +func parseInputBindingEditors(raw any) map[string]InputBindingEditor { + items, _ := raw.(map[string]any) + if len(items) == 0 { + return nil + } + out := map[string]InputBindingEditor{} + for key, value := range items { + entry, _ := value.(map[string]any) + out[key] = InputBindingEditor{ + VideoSourceRef: stringValue(entry["video_source_ref"]), + } + } + return out +} + +func parseServiceBindingEditors(raw any) map[string]ServiceBindingEditor { + items, _ := raw.(map[string]any) + if len(items) == 0 { + return nil + } + out := map[string]ServiceBindingEditor{} + for key, value := range items { + entry, _ := value.(map[string]any) + out[key] = ServiceBindingEditor{ + ServiceRef: stringValue(entry["service_ref"]), + } + } + return out +} + +func parseOutputBindingEditors(raw any) map[string]OutputBindingEditor { + items, _ := raw.(map[string]any) + if len(items) == 0 { + return nil + } + out := map[string]OutputBindingEditor{} + for key, value := range items { + entry, _ := value.(map[string]any) + out[key] = OutputBindingEditor{ + PublishHLSPath: stringValue(entry["publish_hls_path"]), + PublishRTSPPort: valueString(entry["publish_rtsp_port"]), + PublishRTSPPath: stringValue(entry["publish_rtsp_path"]), + ChannelNo: stringValue(entry["channel_no"]), + } + } + return out +} + +func inputBindingRef(bindings map[string]InputBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) +} + +func outputBindingValue(bindings map[string]OutputBindingEditor, slot string, field string) string { + if len(bindings) == 0 { + return "" + } + item, ok := bindings[slot] + if !ok { + return "" + } + switch field { + case "publish_hls_path": + return strings.TrimSpace(item.PublishHLSPath) + case "publish_rtsp_port": + return strings.TrimSpace(item.PublishRTSPPort) + case "publish_rtsp_path": + return strings.TrimSpace(item.PublishRTSPPath) + case "channel_no": + return strings.TrimSpace(item.ChannelNo) + default: + return "" + } +} + +func buildInputBindingDocument(inst ConfigProfileInstanceEditor) map[string]any { + out := map[string]any{} + for key, value := range inst.InputBindings { + entry := map[string]any{} + setString(entry, "video_source_ref", value.VideoSourceRef) + if len(entry) > 0 { + out[key] = entry + } + } + if strings.TrimSpace(inst.VideoSourceRef) != "" { + if _, ok := out["video_input_main"]; !ok { + out["video_input_main"] = map[string]any{"video_source_ref": strings.TrimSpace(inst.VideoSourceRef)} + } + } + if len(out) == 0 { + return nil + } + return out +} + +func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]any { + out := map[string]any{} + for key, value := range inst.ServiceBindings { + entry := map[string]any{} + setString(entry, "service_ref", value.ServiceRef) + if len(entry) > 0 { + out[key] = entry + } + } + if len(out) == 0 { + return nil + } + return out +} + +func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) { + out := map[string]any{} + for key, value := range inst.OutputBindings { + entry, err := outputBindingEntry(value) + if err != nil { + return nil, err + } + if len(entry) > 0 { + out[key] = entry + } + } + if _, ok := out["stream_output_main"]; !ok { + entry, err := outputBindingEntry(OutputBindingEditor{ + PublishHLSPath: inst.PublishHLSPath, + PublishRTSPPort: inst.PublishRTSPPort, + PublishRTSPPath: inst.PublishRTSPPath, + ChannelNo: inst.ChannelNo, + }) + if err != nil { + return nil, err + } + if len(entry) > 0 { + out["stream_output_main"] = entry + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) { + entry := map[string]any{} + setString(entry, "publish_hls_path", value.PublishHLSPath) + setString(entry, "publish_rtsp_path", value.PublishRTSPPath) + setString(entry, "channel_no", value.ChannelNo) + if port := strings.TrimSpace(value.PublishRTSPPort); port != "" { + parsed, err := strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("publish rtsp port must be a number") + } + entry["publish_rtsp_port"] = parsed + } + return entry, nil +} diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go new file mode 100644 index 0000000..8b62b31 --- /dev/null +++ b/internal/service/standard_templates.go @@ -0,0 +1,75 @@ +package service + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "3588AdminBackend/internal/storage" +) + +func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int, error) { + if repo == nil { + return 0, fmt.Errorf("asset repository is not configured") + } + dir = filepath.Clean(strings.TrimSpace(dir)) + if dir == "" { + return 0, fmt.Errorf("standard template dir is empty") + } + files, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + imported := 0 + for _, file := range files { + if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { + continue + } + path := filepath.Join(dir, file.Name()) + body, err := os.ReadFile(path) + if err != nil { + return imported, err + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return imported, fmt.Errorf("%s: %w", path, err) + } + if raw == nil { + raw = map[string]any{} + } + name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())))) + if name == "" { + return imported, fmt.Errorf("%s: template name is empty", path) + } + if err := validateConfigName(name); err != nil { + return imported, fmt.Errorf("%s: invalid template name %q: %w", path, name, err) + } + if !isStandardTemplateName(name) { + return imported, fmt.Errorf("%s: standard template name must start with std_", path) + } + existing, err := repo.GetTemplate(name) + if err != nil { + return imported, err + } + if existing != nil { + continue + } + if strings.TrimSpace(stringValue(raw["source"])) == "" { + raw["source"] = "standard" + } + body, err = marshalConfigJSON(raw) + if err != nil { + return imported, err + } + if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil { + return imported, err + } + imported++ + } + return imported, nil +} diff --git a/internal/service/template_slots.go b/internal/service/template_slots.go new file mode 100644 index 0000000..241dcef --- /dev/null +++ b/internal/service/template_slots.go @@ -0,0 +1,67 @@ +package service + +import "fmt" + +type TemplateSlotGroup struct { + Inputs []TemplateSlot `json:"inputs"` + Services []TemplateSlot `json:"services"` + Outputs []TemplateSlot `json:"outputs"` +} + +type TemplateSlot struct { + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description"` +} + +func parseTemplateSlots(raw map[string]any) (TemplateSlotGroup, error) { + group := TemplateSlotGroup{} + slotsMap, _ := raw["slots"].(map[string]any) + if slotsMap == nil { + return group, nil + } + var err error + if group.Inputs, err = parseTemplateSlotList(slotsMap["inputs"]); err != nil { + return TemplateSlotGroup{}, fmt.Errorf("parse input slots: %w", err) + } + if group.Services, err = parseTemplateSlotList(slotsMap["services"]); err != nil { + return TemplateSlotGroup{}, fmt.Errorf("parse service slots: %w", err) + } + if group.Outputs, err = parseTemplateSlotList(slotsMap["outputs"]); err != nil { + return TemplateSlotGroup{}, fmt.Errorf("parse output slots: %w", err) + } + return group, nil +} + +func parseTemplateSlotList(raw any) ([]TemplateSlot, error) { + items, _ := raw.([]any) + if len(items) == 0 { + return nil, nil + } + out := make([]TemplateSlot, 0, len(items)) + for _, item := range items { + slotMap, _ := item.(map[string]any) + if slotMap == nil { + return nil, fmt.Errorf("slot entry must be an object") + } + slot := TemplateSlot{ + Name: stringValue(slotMap["name"]), + Type: stringValue(slotMap["type"]), + Required: boolValue(slotMap["required"], false), + Description: stringValue(slotMap["description"]), + } + if slot.Name == "" { + return nil, fmt.Errorf("slot name is required") + } + if err := validateConfigName(slot.Name); err != nil { + return nil, fmt.Errorf("invalid slot name %q: %w", slot.Name, err) + } + if slot.Type == "" { + return nil, fmt.Errorf("slot type is required for %s", slot.Name) + } + out = append(out, slot) + } + return out, nil +} + diff --git a/internal/service/template_slots_test.go b/internal/service/template_slots_test.go new file mode 100644 index 0000000..e2c4b85 --- /dev/null +++ b/internal/service/template_slots_test.go @@ -0,0 +1,34 @@ +package service + +import "testing" + +func TestParseTemplateSlots(t *testing.T) { + raw := map[string]any{ + "slots": map[string]any{ + "inputs": []any{ + map[string]any{"name": "video_input_main", "type": "video_source", "required": true}, + }, + "services": []any{ + map[string]any{"name": "object_storage_main", "type": "object_storage", "required": true}, + }, + "outputs": []any{ + map[string]any{"name": "stream_output_main", "type": "stream_publish", "required": true}, + }, + }, + } + + slots, err := parseTemplateSlots(raw) + if err != nil { + t.Fatalf("parseTemplateSlots: %v", err) + } + if len(slots.Inputs) != 1 || slots.Inputs[0].Name != "video_input_main" { + t.Fatalf("unexpected input slots: %#v", slots.Inputs) + } + if len(slots.Services) != 1 || slots.Services[0].Type != "object_storage" { + t.Fatalf("unexpected service slots: %#v", slots.Services) + } + if len(slots.Outputs) != 1 || slots.Outputs[0].Name != "stream_output_main" { + t.Fatalf("unexpected output slots: %#v", slots.Outputs) + } +} + diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index cdfd607..1dc9954 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -28,6 +28,16 @@ type IntegrationServiceRecord struct { UpdatedAt string } +type VideoSourceRecord struct { + Name string + SourceType string + Area string + Description string + BodyJSON string + CreatedAt string + UpdatedAt string +} + type AssetsRepo struct { db *sql.DB } @@ -80,6 +90,24 @@ ON CONFLICT(name) DO UPDATE SET return err } +func (r *AssetsRepo) SaveVideoSource(name string, sourceType string, area string, description string, bodyJSON string) error { + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` +INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM video_sources WHERE name = ?), ?), ?) +ON CONFLICT(name) DO UPDATE SET + source_type=excluded.source_type, + area=excluded.area, + description=excluded.description, + body_json=excluded.body_json, + updated_at=excluded.updated_at +`, name, sourceType, area, description, bodyJSON, name, now, now) + return err +} + func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) { return r.listAssets("templates") } @@ -117,6 +145,31 @@ ORDER BY updated_at DESC, name ASC return out, rows.Err() } +func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` +SELECT name, source_type, area, description, body_json, created_at, updated_at +FROM video_sources +ORDER BY updated_at DESC, name ASC +`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []VideoSourceRecord + for rows.Next() { + var item VideoSourceRecord + if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) { return r.getAsset("templates", name) } @@ -148,6 +201,25 @@ WHERE name = ? return &item, nil } +func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + var item VideoSourceRecord + err := r.db.QueryRow(` +SELECT name, source_type, area, description, body_json, created_at, updated_at +FROM video_sources +WHERE name = ? +`, name).Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil +} + func (r *AssetsRepo) DeleteTemplate(name string) error { return r.deleteAsset("templates", name) } @@ -160,6 +232,14 @@ func (r *AssetsRepo) DeleteIntegrationService(name string) error { return err } +func (r *AssetsRepo) DeleteVideoSource(name string) error { + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name) + return err +} + func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error { if r == nil || r.db == nil { return nil @@ -210,7 +290,7 @@ WHERE name = ? rows, err := tx.Query(` SELECT name, description, business_name, body_json FROM profiles -WHERE template_name = ? OR body_json LIKE ? +WHERE primary_template_name = ? OR body_json LIKE ? `, oldName, "%\"template\":\""+oldName+"\"%") if err != nil { return err @@ -244,7 +324,7 @@ WHERE template_name = ? OR body_json LIKE ? for _, item := range updates { if _, err := tx.Exec(` UPDATE profiles -SET template_name = ?, body_json = ?, updated_at = ? +SET primary_template_name = ?, body_json = ?, updated_at = ? WHERE name = ? `, newName, item.bodyJSON, now, item.name); err != nil { return err @@ -279,10 +359,10 @@ ON CONFLICT(name) DO UPDATE SET return err case "profiles": _, err := r.db.Exec(` -INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at) +INSERT INTO profiles(name, primary_template_name, business_name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM profiles WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET - template_name=excluded.template_name, + primary_template_name=excluded.primary_template_name, business_name=excluded.business_name, description=excluded.description, body_json=excluded.body_json, @@ -305,7 +385,7 @@ ORDER BY updated_at DESC, name ASC ` if table == "profiles" { query = ` -SELECT name, description, body_json, created_at, updated_at, template_name, business_name +SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name FROM profiles ORDER BY updated_at DESC, name ASC ` @@ -338,7 +418,7 @@ WHERE name = ? ` if table == "profiles" { query = ` -SELECT name, description, body_json, created_at, updated_at, template_name, business_name +SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name FROM profiles WHERE name = ? ` diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index 0c9d679..e891ecb 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -143,3 +143,44 @@ func TestAssetsRepoStoresIntegrationServiceDisabledState(t *testing.T) { t.Fatalf("expected body_json payload preserved, got %#v", record) } } + +func TestAssetsRepoStoresVideoSource(t *testing.T) { + store := openTestStore(t) + defer store.Close() + + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + + records, err := repo.ListVideoSources() + if err != nil { + t.Fatalf("ListVideoSources: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one video source, got %#v", records) + } + if records[0].SourceType != "rtsp" || records[0].Area != "东门入口" { + t.Fatalf("unexpected video source record: %#v", records[0]) + } + + record, err := repo.GetVideoSource("gate_cam_01") + if err != nil { + t.Fatalf("GetVideoSource: %v", err) + } + if record == nil { + t.Fatal("expected video source record") + } + if record.SourceType != "rtsp" || record.Area != "东门入口" { + t.Fatalf("unexpected video source fetch: %#v", record) + } + if !strings.Contains(record.BodyJSON, `"resolution":"1080p"`) { + t.Fatalf("expected body_json payload preserved, got %#v", record) + } +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index 0b56b6a..443722f 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -1,6 +1,9 @@ package storage -import "database/sql" +import ( + "database/sql" + "fmt" +) const schema001 = ` CREATE TABLE IF NOT EXISTS templates ( @@ -14,7 +17,7 @@ CREATE TABLE IF NOT EXISTS templates ( CREATE TABLE IF NOT EXISTS profiles ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, - template_name TEXT NOT NULL DEFAULT '', + primary_template_name TEXT NOT NULL DEFAULT '', business_name TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', body_json TEXT NOT NULL, @@ -39,6 +42,16 @@ CREATE TABLE IF NOT EXISTS integration_services ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS video_sources ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + source_type TEXT NOT NULL DEFAULT '', + area TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + body_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS devices ( device_id TEXT PRIMARY KEY, hostname TEXT NOT NULL DEFAULT '', @@ -93,6 +106,82 @@ CREATE TABLE IF NOT EXISTS audit_logs ( ` func migrate(db *sql.DB) error { - _, err := db.Exec(schema001) - return err + if _, err := db.Exec(schema001); err != nil { + return err + } + return migrateProfilePrimaryTemplateName(db) +} + +func migrateProfilePrimaryTemplateName(db *sql.DB) error { + hasPrimary, err := hasColumn(db, "profiles", "primary_template_name") + if err != nil { + return err + } + if hasPrimary { + return nil + } + hasLegacy, err := hasColumn(db, "profiles", "template_name") + if err != nil { + return err + } + if !hasLegacy { + return fmt.Errorf("profiles table is missing both primary_template_name and template_name") + } + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil { + return err + } + if _, err := tx.Exec(` +CREATE TABLE profiles ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + primary_template_name TEXT NOT NULL DEFAULT '', + business_name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + body_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +)`); err != nil { + return err + } + if _, err := tx.Exec(` +INSERT INTO profiles(id, name, primary_template_name, business_name, description, body_json, created_at, updated_at) +SELECT id, name, template_name, business_name, description, body_json, created_at, updated_at +FROM profiles_legacy_template_name +`); err != nil { + return err + } + if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil { + return err + } + return tx.Commit() +} + +func hasColumn(db *sql.DB, table string, column string) (bool, error) { + rows, err := db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dfltValue sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 8efea62..e0b3bf9 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -1,6 +1,7 @@ package storage import ( + "database/sql" "path/filepath" "testing" ) @@ -18,6 +19,7 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) { "profiles", "overlays", "integration_services", + "video_sources", "devices", "device_config_state", "tasks", @@ -33,3 +35,58 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) { } } } + +func TestSQLiteStoreMigratesLegacyProfileTemplateColumn(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "app.db") + legacyDB, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy sqlite: %v", err) + } + _, err = legacyDB.Exec(` +CREATE TABLE profiles ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + template_name TEXT NOT NULL DEFAULT '', + business_name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + body_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at) +VALUES('line_a', 'helmet', 'gate', 'desc', '{"name":"line_a","instances":[{"name":"cam1","template":"helmet"}]}', '2026-04-29T00:00:00+08:00', '2026-04-29T00:00:00+08:00'); +`) + if err != nil { + t.Fatalf("seed legacy schema: %v", err) + } + _ = legacyDB.Close() + + store, err := OpenSQLite(dbPath) + if err != nil { + t.Fatalf("OpenSQLite migrate legacy: %v", err) + } + defer store.Close() + + hasOld, err := hasColumn(store.DB(), "profiles", "template_name") + if err != nil { + t.Fatalf("has old column: %v", err) + } + if hasOld { + t.Fatal("expected legacy template_name column to be removed") + } + hasNew, err := hasColumn(store.DB(), "profiles", "primary_template_name") + if err != nil { + t.Fatalf("has new column: %v", err) + } + if !hasNew { + t.Fatal("expected primary_template_name column to exist after migration") + } + repo := NewAssetsRepo(store.DB()) + profile, err := repo.GetProfile("line_a") + if err != nil { + t.Fatalf("GetProfile after migration: %v", err) + } + if profile == nil || profile.TemplateName != "helmet" { + t.Fatalf("expected migrated primary template value, got %#v", profile) + } +} diff --git a/internal/web/graph_node_types.go b/internal/web/graph_node_types.go index 94fd14a..6cec1e7 100644 --- a/internal/web/graph_node_types.go +++ b/internal/web/graph_node_types.go @@ -21,8 +21,8 @@ type graphNodeTypeInfo struct { func graphNodeTypesCatalog() []graphNodeTypeInfo { return []graphNodeTypeInfo{ - nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${rtsp_url}"}, []graphNodeParam{ - textParam("url", "RTSP 地址", "${rtsp_url}"), + nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${slot:video_input_main.url}"}, []graphNodeParam{ + textParam("url", "RTSP 地址", "${slot:video_input_main.url}"), numberParam("fps", "输入帧率", "1"), numberParam("width", "宽度", "1"), numberParam("height", "高度", "1"), diff --git a/internal/web/ui.go b/internal/web/ui.go index 7569e14..6037400 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -74,7 +74,14 @@ type PageData struct { AssetProfiles []service.ConfigProfileAsset AssetProfile *service.ConfigProfileAsset AssetProfileEditor *service.ConfigProfileEditor + ActiveInstanceIndex int + AssetTemplateMap map[string]service.ConfigTemplateAsset + AssetVideoSources []service.ConfigVideoSourceAsset + AssetVideoSource *service.ConfigVideoSourceAsset + AssetVideoSourceEditing bool AssetIntegrations []service.ConfigIntegrationServiceAsset + AssetIntegration *service.ConfigIntegrationServiceAsset + AssetIntegrationEditing bool AssetOverlays []service.ConfigOverlayAsset AssetOverlay *service.ConfigOverlayAsset AssetInstanceCount int @@ -327,6 +334,55 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic "icon": func(name string) template.HTML { return template.HTML(tablerIconSVG(name)) }, + "slotTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "video_source": + return "视频源" + case "object_storage": + return "对象存储" + case "token_service": + return "认证服务" + case "alarm_service": + return "告警服务" + case "stream_publish": + return "视频输出" + default: + return strings.TrimSpace(v) + } + }, + "inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) + }, + "serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].ServiceRef) + }, + "outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string { + if len(bindings) == 0 { + return "" + } + item, ok := bindings[slot] + if !ok { + return "" + } + switch strings.TrimSpace(field) { + case "publish_hls_path": + return strings.TrimSpace(item.PublishHLSPath) + case "publish_rtsp_port": + return strings.TrimSpace(item.PublishRTSPPort) + case "publish_rtsp_path": + return strings.TrimSpace(item.PublishRTSPPath) + case "channel_no": + return strings.TrimSpace(item.ChannelNo) + default: + return "" + } + }, }).ParseFS(uiFS, "ui/templates/*.html") if err != nil { return nil, err @@ -440,6 +496,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/plans/{name}/export", u.pagePlanExport) r.Get("/assets", u.pageAssets) r.Get("/assets/video-sources", u.pageAssetVideoSources) + r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) + r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) r.Get("/assets/templates", u.pageAssetTemplates) r.Post("/assets/templates/create", u.actionAssetTemplateCreate) r.Get("/assets/templates/{name}", u.pageAssetTemplate) @@ -453,12 +511,15 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/assets/profiles/{name}", u.actionPlanSave) r.Get("/assets/profiles/{name}/export", u.pagePlanExport) r.Get("/assets/integrations", u.pageAssetIntegrations) + r.Post("/assets/integrations", u.actionAssetIntegrationSave) + r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) r.Get("/assets/overlays", u.pageAssetOverlays) r.Get("/assets/overlays/{name}", u.pageAssetOverlay) r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) r.Get("/audit", u.pageAudit) r.Get("/system", u.pageSystem) r.Get("/system/db-backup", u.pageSystemDBBackup) + r.Get("/resources", u.pageResources) r.Post("/system/db-restore", u.actionSystemDBRestore) r.Get("/api/graph-node-types", u.apiGraphNodeTypes) r.Get("/device-config", u.pageDeviceConfig) @@ -475,6 +536,7 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/devices/{id}/logs", u.pageDeviceLogs) r.Get("/devices/{id}/graphs", u.pageDeviceGraphs) r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply) + r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply) r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI) r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly) r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview) @@ -784,6 +846,35 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData { data.PersistedConfig = state } } + if u.preview != nil { + if profiles, err := u.preview.ListProfileAssets(); err == nil { + data.AssetProfiles = profiles + selectedProfile := "" + if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" { + selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile) + } else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" { + selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName) + } + if selectedProfile == "" && len(profiles) > 0 { + selectedProfile = profiles[0].Name + } + data.SelectedProfile = selectedProfile + for i := range profiles { + if strings.TrimSpace(profiles[i].Name) == selectedProfile { + data.AssetProfile = &profiles[i] + data.SelectedTemplate = profileAssetTemplate(&profiles[i]) + break + } + } + if data.AssetProfile == nil && len(profiles) > 0 { + data.AssetProfile = &profiles[0] + data.SelectedProfile = profiles[0].Name + data.SelectedTemplate = profileAssetTemplate(&profiles[0]) + } + } else if data.Error == "" { + data.Error = err.Error() + } + } return data } @@ -1169,7 +1260,11 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { } func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) { - u.render(w, r, "diagnostics", PageData{Title: "诊断", Devices: u.registry.GetDevices()}) + u.render(w, r, "diagnostics", PageData{Title: "日志审计", Devices: u.registry.GetDevices()}) +} + +func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) { + u.render(w, r, "resources", PageData{Title: "资源管理", Devices: u.registry.GetDevices()}) } func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) { @@ -1513,6 +1608,7 @@ func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { data.SelectedTemplate = editor.Instances[0].Template } + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query())) } else if data.Error == "" { data.Error = err.Error() } @@ -1532,6 +1628,7 @@ func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) { return } data.Title = "场景配置" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query())) u.render(w, r, "plans", data) } @@ -1554,6 +1651,23 @@ func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + if strings.TrimSpace(r.FormValue("add_instance")) == "1" { + data.AssetProfileEditor = &editor + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), len(editor.Instances)-1) + data.Title = "场景配置" + u.render(w, r, "plans", data) + return + } + if raw := strings.TrimSpace(r.FormValue("remove_instance")); raw != "" { + if idx, convErr := strconv.Atoi(raw); convErr == nil && idx >= 0 && idx < len(editor.Instances) { + editor.Instances = append(editor.Instances[:idx], editor.Instances[idx+1:]...) + } + data.AssetProfileEditor = &editor + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) + data.Title = "场景配置" + u.render(w, r, "plans", data) + return + } if err := u.preview.SaveProfileEditor(editor); err != nil { data.Error = err.Error() data.Title = "场景配置" @@ -1588,15 +1702,170 @@ func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("video-sources") data.Title = "基础配置" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + selected := strings.TrimSpace(r.URL.Query().Get("name")) + if selected != "" { + if item, err := u.preview.GetVideoSource(selected); err == nil { + data.AssetVideoSource = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if !newMode && len(data.AssetVideoSources) > 0 { + if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil { + data.AssetVideoSource = item + } + } + if data.AssetVideoSource == nil { + data.AssetVideoSource = &service.ConfigVideoSourceAsset{ + SourceType: "rtsp", + SourceTypeLabel: "RTSP", + } + data.AssetVideoSourceEditing = true + } else { + data.AssetVideoSourceEditing = newMode || editMode + } u.render(w, r, "assets", data) } +func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + _ = r.ParseForm() + asset := service.ConfigVideoSourceAsset{ + Name: strings.TrimSpace(r.FormValue("name")), + SourceType: strings.TrimSpace(r.FormValue("source_type")), + Area: strings.TrimSpace(r.FormValue("area")), + Description: strings.TrimSpace(r.FormValue("description")), + Config: service.VideoSourceConfig{ + URL: strings.TrimSpace(r.FormValue("url")), + Resolution: strings.TrimSpace(r.FormValue("resolution")), + FrameSize: strings.TrimSpace(r.FormValue("frame_size")), + FPS: strings.TrimSpace(r.FormValue("fps")), + VideoFormat: strings.TrimSpace(r.FormValue("video_format")), + FocalLength: strings.TrimSpace(r.FormValue("focal_length")), + MountHeight: strings.TrimSpace(r.FormValue("mount_height")), + MountAngle: strings.TrimSpace(r.FormValue("mount_angle")), + }, + } + if err := u.preview.SaveVideoSourceAsset(asset); err != nil { + data := u.assetPageData("video-sources") + data.Title = "基础配置" + data.Error = err.Error() + data.AssetVideoSource = &asset + data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType) + data.AssetVideoSourceEditing = true + u.render(w, r, "assets", data) + return + } + http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) +} + +func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name := chi.URLParam(r, "name") + if err := u.preview.DeleteVideoSource(name); err != nil { + http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound) +} + func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("integrations") data.Title = "基础配置" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + selected := strings.TrimSpace(r.URL.Query().Get("name")) + if selected != "" { + if item, err := u.preview.GetIntegrationService(selected); err == nil { + data.AssetIntegration = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if !newMode && len(data.AssetIntegrations) > 0 { + if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil { + data.AssetIntegration = item + } + } + if data.AssetIntegration == nil { + data.AssetIntegration = &service.ConfigIntegrationServiceAsset{ + Type: "object_storage", + TypeLabel: "对象存储", + Enabled: true, + ObjectStorage: &service.ObjectStorageConfig{}, + } + data.AssetIntegrationEditing = true + } else { + data.AssetIntegrationEditing = newMode || editMode + } u.render(w, r, "assets", data) } +func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + _ = r.ParseForm() + enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on") + asset := service.ConfigIntegrationServiceAsset{ + Name: strings.TrimSpace(r.FormValue("name")), + Type: strings.TrimSpace(r.FormValue("type")), + Description: strings.TrimSpace(r.FormValue("description")), + Enabled: enabled, + ObjectStorage: &service.ObjectStorageConfig{ + Endpoint: strings.TrimSpace(r.FormValue("endpoint")), + Bucket: strings.TrimSpace(r.FormValue("bucket")), + AccessKey: strings.TrimSpace(r.FormValue("access_key")), + SecretKey: strings.TrimSpace(r.FormValue("secret_key")), + }, + TokenService: &service.TokenServiceConfig{ + GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")), + Username: strings.TrimSpace(r.FormValue("username")), + Password: strings.TrimSpace(r.FormValue("password")), + TenantCode: strings.TrimSpace(r.FormValue("tenant_code")), + }, + AlarmService: &service.AlarmServiceConfig{ + PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")), + Username: strings.TrimSpace(r.FormValue("alarm_username")), + Password: strings.TrimSpace(r.FormValue("alarm_password")), + TenantCode: strings.TrimSpace(r.FormValue("alarm_tenant_code")), + }, + } + if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil { + http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) +} + +func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name := chi.URLParam(r, "name") + if err := u.preview.DeleteIntegrationService(name); err != nil { + http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound) +} + func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("overlays") if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { @@ -1647,6 +1916,10 @@ func (u *UI) assetPageData(tab string) PageData { } if items, listErr := u.preview.ListTemplateAssets(); listErr == nil { data.AssetTemplates = items + data.AssetTemplateMap = make(map[string]service.ConfigTemplateAsset, len(items)) + for _, item := range items { + data.AssetTemplateMap[item.Name] = item + } } else if data.Error == "" { data.Error = listErr.Error() } @@ -1663,6 +1936,11 @@ func (u *UI) assetPageData(tab string) PageData { } else if data.Error == "" { data.Error = listErr.Error() } + if items, listErr := u.preview.ListVideoSources(); listErr == nil { + data.AssetVideoSources = items + } else if data.Error == "" { + data.Error = listErr.Error() + } if items, listErr := u.preview.ListIntegrationServices(); listErr == nil { data.AssetIntegrations = items } else if data.Error == "" { @@ -1690,6 +1968,31 @@ func (u *UI) profileEditorPageData(name string) (PageData, error) { return data, nil } +func clampActiveInstanceIndex(count int, preferred int) int { + if count <= 0 { + return 0 + } + if preferred < 0 { + return 0 + } + if preferred >= count { + return count - 1 + } + return preferred +} + +func activeInstanceIndexFromValues(values url.Values) int { + raw := strings.TrimSpace(values.Get("active_instance")) + if raw == "" { + return 0 + } + idx, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return idx +} + func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) { data, err := u.profileEditorPageData(name) if err != nil { @@ -1697,13 +2000,10 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf } _ = r.ParseForm() editor := service.ConfigProfileEditor{ - Name: strings.TrimSpace(r.FormValue("profile_name")), - BusinessName: strings.TrimSpace(r.FormValue("business_name")), - Description: strings.TrimSpace(r.FormValue("description")), - SiteName: strings.TrimSpace(r.FormValue("site_name")), - ObjectStorageRef: strings.TrimSpace(r.FormValue("object_storage_ref")), - TokenServiceRef: strings.TrimSpace(r.FormValue("token_service_ref")), - AlarmServiceRef: strings.TrimSpace(r.FormValue("alarm_service_ref")), + Name: strings.TrimSpace(r.FormValue("profile_name")), + BusinessName: strings.TrimSpace(r.FormValue("business_name")), + Description: strings.TrimSpace(r.FormValue("description")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), Queue: service.ConfigProfileQueueEditor{ Size: strings.TrimSpace(r.FormValue("queue_size")), Strategy: strings.TrimSpace(r.FormValue("queue_strategy")), @@ -1721,6 +2021,7 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf } data.AssetProfileEditor = &editor data.SelectedProfile = editor.Name + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) return editor, data, nil } @@ -1838,6 +2139,21 @@ func normalizeConfigName(name string) (string, error) { return name, nil } +func serviceVideoSourceTypeLabel(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 compactJSON(v any) (string, error) { body, err := json.Marshal(v) if err != nil { @@ -2177,6 +2493,104 @@ func parseAdvancedParams(raw string) map[string]any { return out } +func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + data := u.deviceDetailPageData(dev) + req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))} + if req.Profile == "" { + req.Profile = data.SelectedProfile + } + data.SelectedProfile = req.Profile + if req.Profile != "" && len(data.AssetProfiles) > 0 { + for i := range data.AssetProfiles { + if strings.TrimSpace(data.AssetProfiles[i].Name) == req.Profile { + data.AssetProfile = &data.AssetProfiles[i] + data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i]) + break + } + } + } + if req.Profile == "" { + data.Error = "请先选择场景配置" + u.render(w, r, "device", data) + return + } + if data.SelectedTemplate == "" { + data.Error = "所选场景配置缺少可用模板,无法生成下发内容" + u.render(w, r, "device", data) + return + } + if u.tasks == nil { + data.Error = "task service not initialized" + u.render(w, r, "device", data) + return + } + req.Template = data.SelectedTemplate + preview, err := u.preview.Render(req) + data.ConfigPreview = preview + if err != nil { + data.Error = err.Error() + u.render(w, r, "device", data) + return + } + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + data.Error = "生成配置 JSON 无效: " + err.Error() + u.render(w, r, "device", data) + return + } + task, err := u.tasks.CreateTask("config_apply", []string{dev.DeviceID}, map[string]any{"config": configDoc}) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device", data) + return + } + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) +} + +func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string { + used := make(map[string]struct{}, len(instances)) + for _, inst := range instances { + name := strings.TrimSpace(inst.Name) + if name == "" { + continue + } + used[name] = struct{}{} + } + for i := 1; ; i++ { + candidate := fmt.Sprintf("cam%d", i) + if _, ok := used[candidate]; !ok { + return candidate + } + } +} + +func newProfileInstanceDraft(templateName string, channelName string) service.ConfigProfileInstanceEditor { + outputs := map[string]service.OutputBindingEditor{ + "stream_output_main": { + PublishHLSPath: "./web/hls/" + channelName + "/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/" + channelName, + ChannelNo: channelName, + }, + } + return service.ConfigProfileInstanceEditor{ + Name: channelName, + Template: templateName, + PublishHLSPath: outputs["stream_output_main"].PublishHLSPath, + PublishRTSPPort: outputs["stream_output_main"].PublishRTSPPort, + PublishRTSPPath: outputs["stream_output_main"].PublishRTSPPath, + ChannelNo: outputs["stream_output_main"].ChannelNo, + OutputBindings: outputs, + } +} + func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEditor { indices := make([]int, 0) seen := map[int]struct{}{} @@ -2203,19 +2617,25 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd out := make([]service.ConfigProfileInstanceEditor, 0, len(indices)) for _, idx := range indices { prefix := fmt.Sprintf("instances[%d].", idx) + inputBindings := parseInputBindingForm(form, prefix) + serviceBindings := parseServiceBindingForm(form, prefix) + outputBindings := parseOutputBindingForm(form, prefix) inst := service.ConfigProfileInstanceEditor{ Name: strings.TrimSpace(form.Get(prefix + "name")), Template: strings.TrimSpace(form.Get(prefix + "template")), + VideoSourceRef: firstString(inputBindingValue(inputBindings, "video_input_main"), strings.TrimSpace(form.Get(prefix+"video_source_ref"))), DisplayName: strings.TrimSpace(form.Get(prefix + "display_name")), - RTSPURL: strings.TrimSpace(form.Get(prefix + "rtsp_url")), - PublishHLSPath: strings.TrimSpace(form.Get(prefix + "publish_hls_path")), - PublishRTSPPort: strings.TrimSpace(form.Get(prefix + "publish_rtsp_port")), - PublishRTSPPath: strings.TrimSpace(form.Get(prefix + "publish_rtsp_path")), - ChannelNo: strings.TrimSpace(form.Get(prefix + "channel_no")), + PublishHLSPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_hls_path"), strings.TrimSpace(form.Get(prefix+"publish_hls_path"))), + PublishRTSPPort: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_port"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_port"))), + PublishRTSPPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_path"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_path"))), + ChannelNo: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "channel_no"), strings.TrimSpace(form.Get(prefix+"channel_no"))), + InputBindings: inputBindings, + ServiceBindings: serviceBindings, + OutputBindings: outputBindings, AdvancedParams: parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))), Delete: strings.TrimSpace(form.Get(prefix+"delete")) == "1", } - if inst.Name != "" || inst.RTSPURL != "" || inst.Delete { + if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete { out = append(out, inst) } } @@ -2224,10 +2644,7 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" { templateName = strings.TrimSpace(out[0].Template) } - out = append(out, service.ConfigProfileInstanceEditor{ - Template: templateName, - PublishRTSPPort: "8555", - }) + out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out))) } if len(out) > 0 { fallbackTemplate := strings.TrimSpace(out[0].Template) @@ -2243,6 +2660,134 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd return out } +func parseInputBindingForm(form url.Values, prefix string) map[string]service.InputBindingEditor { + bindings := map[string]service.InputBindingEditor{} + for key := range form { + if !strings.HasPrefix(key, prefix+"input_bindings.") || !strings.HasSuffix(key, ".video_source_ref") { + continue + } + slot := strings.TrimPrefix(key, prefix+"input_bindings.") + slot = strings.TrimSuffix(slot, ".video_source_ref") + slot = strings.TrimSpace(slot) + if slot == "" { + continue + } + value := strings.TrimSpace(form.Get(key)) + if value == "" { + continue + } + bindings[slot] = service.InputBindingEditor{VideoSourceRef: value} + } + if len(bindings) == 0 { + return nil + } + return bindings +} + +func parseServiceBindingForm(form url.Values, prefix string) map[string]service.ServiceBindingEditor { + bindings := map[string]service.ServiceBindingEditor{} + for key := range form { + if !strings.HasPrefix(key, prefix+"service_bindings.") || !strings.HasSuffix(key, ".service_ref") { + continue + } + slot := strings.TrimPrefix(key, prefix+"service_bindings.") + slot = strings.TrimSuffix(slot, ".service_ref") + slot = strings.TrimSpace(slot) + if slot == "" { + continue + } + value := strings.TrimSpace(form.Get(key)) + if value == "" { + continue + } + bindings[slot] = service.ServiceBindingEditor{ServiceRef: value} + } + if len(bindings) == 0 { + return nil + } + return bindings +} + +func parseOutputBindingForm(form url.Values, prefix string) map[string]service.OutputBindingEditor { + bindings := map[string]service.OutputBindingEditor{} + for key := range form { + if !strings.HasPrefix(key, prefix+"output_bindings.") { + continue + } + slotField := strings.TrimPrefix(key, prefix+"output_bindings.") + dot := strings.LastIndex(slotField, ".") + if dot <= 0 { + continue + } + slot := strings.TrimSpace(slotField[:dot]) + field := strings.TrimSpace(slotField[dot+1:]) + if slot == "" || field == "" { + continue + } + value := strings.TrimSpace(form.Get(key)) + entry := bindings[slot] + switch field { + case "publish_hls_path": + entry.PublishHLSPath = value + case "publish_rtsp_port": + entry.PublishRTSPPort = value + case "publish_rtsp_path": + entry.PublishRTSPPath = value + case "channel_no": + entry.ChannelNo = value + default: + continue + } + if strings.TrimSpace(entry.PublishHLSPath) == "" && strings.TrimSpace(entry.PublishRTSPPort) == "" && + strings.TrimSpace(entry.PublishRTSPPath) == "" && strings.TrimSpace(entry.ChannelNo) == "" { + continue + } + bindings[slot] = entry + } + if len(bindings) == 0 { + return nil + } + return bindings +} + +func inputBindingValue(bindings map[string]service.InputBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) +} + +func outputBindingFormValue(bindings map[string]service.OutputBindingEditor, slot string, field string) string { + if len(bindings) == 0 { + return "" + } + item, ok := bindings[slot] + if !ok { + return "" + } + switch field { + case "publish_hls_path": + return strings.TrimSpace(item.PublishHLSPath) + case "publish_rtsp_port": + return strings.TrimSpace(item.PublishRTSPPort) + case "publish_rtsp_path": + return strings.TrimSpace(item.PublishRTSPPath) + case "channel_no": + return strings.TrimSpace(item.ChannelNo) + default: + return "" + } +} + +func firstString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func selectedIDsFromQuery(values []string) []string { values = cleanFormList(values) if len(values) == 0 { diff --git a/internal/web/ui/assets/graph_editor.js b/internal/web/ui/assets/graph_editor.js index cc3c9d6..e04a549 100644 --- a/internal/web/ui/assets/graph_editor.js +++ b/internal/web/ui/assets/graph_editor.js @@ -29,7 +29,7 @@ doc.template = graph; const fallbackNodeTypes = [ - { type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${rtsp_url}" } }, + { type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${slot:video_input_main.url}" } }, { type: "input_file", label: "文件输入", category: "输入", icon: "file", description: "从本地视频文件读取帧,常用于离线验证和回放。", defaults: { id: "input_file", type: "input_file", role: "source", enable: true } }, { type: "preprocess", label: "图像预处理", category: "处理", icon: "adjust", description: "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", defaults: { id: "preprocess", type: "preprocess", role: "filter", enable: true, dst_format: "rgb" } }, { type: "ai_scrfd", label: "SCRFD 人脸检测", category: "AI 推理", icon: "scan-face", description: "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", defaults: { id: "ai_scrfd", type: "ai_scrfd", role: "filter", enable: true } }, @@ -56,7 +56,7 @@ const coreEdgeKeys = new Set(["from", "to"]); const paramSchemas = { input_rtsp: [ - { key: "url", label: "RTSP 地址", type: "text", placeholder: "${rtsp_url}" }, + { key: "url", label: "RTSP 地址", type: "text", placeholder: "${slot:video_input_main.url}" }, { key: "fps", label: "输入帧率", type: "number", step: "1" }, { key: "width", label: "宽度", type: "number", step: "1" }, { key: "height", label: "高度", type: "number", step: "1" }, diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 2183c4f..413c702 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -116,6 +116,18 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} .nav-section{padding:14px 10px 6px;font-size:11px;color:var(--sidebar-muted);text-transform:uppercase;letter-spacing:.06em} .side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500} .side-nav a:hover{background:var(--sidebar-hover)} +.side-nav .nav-group{margin:0} +.side-nav .nav-group summary{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500;list-style:none;cursor:pointer;user-select:none} +.side-nav .nav-group summary:hover{background:var(--sidebar-hover)} +.side-nav .nav-group summary::-webkit-details-marker{display:none} +.side-nav .nav-group summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--sidebar-muted);border-bottom:1.5px solid var(--sidebar-muted);transform:rotate(45deg);transition:transform .16s ease,border-color .16s ease} +.side-nav .nav-group[open] summary::after{transform:rotate(225deg);border-color:var(--sidebar-text)} +.side-nav .nav-group[open] summary{background:var(--sidebar-hover)} +.side-nav .nav-group-items{display:flex;flex-direction:column;gap:4px;margin-top:4px} +.side-nav .nav-subitem{padding:8px 10px 8px 34px;font-size:12px;font-weight:500;color:var(--sidebar-muted)} +.side-nav .nav-subitem:hover{background:var(--sidebar-hover);color:var(--sidebar-text)} +.side-nav .nav-subicon{width:22px;height:20px;font-size:9px} +.side-nav .nav-subicon .ui-icon{width:12px;height:12px} .nav-icon{width:28px;height:24px;border-radius:3px;border:1px solid rgba(255,255,255,.12);display:grid;place-items:center;font-size:10px;color:var(--primary)} .nav-icon .ui-icon{width:14px;height:14px;stroke-width:1.75} @@ -232,8 +244,19 @@ tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary .selector-card .actions{margin-top:auto} .panel-block{border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);padding:14px} .panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px} +.service-actions-row{margin-top:16px} +.scene-config-form{margin-top:14px} +.scene-summary-details{margin-top:12px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)} +.scene-summary-details summary{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;font-weight:500;color:var(--muted);list-style:none} +.scene-summary-details summary::before{content:"";width:7px;height:7px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .16s ease} +.scene-summary-details summary::-webkit-details-marker{display:none} +.scene-summary-details[open] summary::before{transform:rotate(45deg)} +.scene-summary-details[open] summary{margin-bottom:10px;color:var(--text)} +.scene-summary-details .info-list{margin-top:0} +.scene-actions-row{margin-top:12px} .field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px} -.field-grid label span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)} +.field-grid label>span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)} +.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px} .field-grid .full{grid-column:1/-1} .field-grid input,.field-grid select,.field-grid textarea{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--input-bg);color:var(--text);font:inherit} .field-grid textarea{resize:vertical;min-height:120px} diff --git a/internal/web/ui/templates/api.html b/internal/web/ui/templates/api.html index 920b72a..e18e4c6 100644 --- a/internal/web/ui/templates/api.html +++ b/internal/web/ui/templates/api.html @@ -1,6 +1,6 @@ {{define "api"}}
| 叠加项 | +调试参数 | 描述 | 目标 | {{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}} | {{else}} -
|---|---|---|---|
还没有叠加项 | |||
还没有调试参数 | |||
{{json .AssetProfileEditor.Raw}}
-