package service import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" "safesight-control/internal/config" "safesight-control/internal/storage" ) var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) type ConfigPreviewService struct { cfg *config.Config assets *storage.AssetsRepo } type ConfigSource struct { Name string `json:"name"` Path string `json:"path"` } type ConfigPreviewSources struct { Root string `json:"root"` Templates []ConfigSource `json:"templates"` Profiles []ConfigSource `json:"profiles"` Overlays []ConfigSource `json:"overlays"` } type ConfigPreviewRequest struct { Template string Profile string Overlays []string ConfigID string ConfigVersion string DeviceID string } type ConfigPreviewResult struct { Request ConfigPreviewRequest `json:"request"` Root string `json:"root"` Sha256 string `json:"sha256"` Size int `json:"size"` Metadata map[string]any `json:"metadata"` JSON string `json:"json"` } type ConfigAssetImportResult struct { Root string `json:"root"` Templates int `json:"templates"` Profiles int `json:"profiles"` Overlays int `json:"overlays"` } func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *ConfigPreviewService { var assets *storage.AssetsRepo if len(repo) > 0 { assets = repo[0] } return &ConfigPreviewService{cfg: cfg, assets: assets} } func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) { out := ConfigPreviewSources{} if s == nil || s.assets == nil { return out, nil } templates, err := s.assets.ListTemplates() if err != nil { 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 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)}) } 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) { if err := validateConfigName(req.Template); err != nil { return nil, fmt.Errorf("invalid template: %w", err) } 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 } profilePath := repoAssetPath("profiles", req.Profile) profileRaw := map[string]any{} if strings.TrimSpace(req.Profile) != "" { editor, err := s.GetProfileEditor(req.Profile) if err != nil { return nil, err } profileRaw, err = s.BuildProfileDocument(*editor) if err != nil { return nil, err } } selectedOverlays := req.Overlays if len(selectedOverlays) == 0 { selectedOverlays = profileOverlayNames(profileRaw) } overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays)) for _, overlay := range selectedOverlays { raw, path, err := s.readAssetJSON("overlays", overlay) if err != nil { return nil, err } overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) } req.Overlays = append([]string(nil), selectedOverlays...) return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays) } func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) { 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 } if strings.TrimSpace(req.Profile) == "" { req.Profile = strings.TrimSpace(editor.Name) } templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template) if err != nil { return nil, err } selectedOverlays := req.Overlays if len(selectedOverlays) == 0 { selectedOverlays = profileOverlayNames(doc) } overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays)) for _, overlay := range selectedOverlays { raw, path, err := s.readAssetJSON("overlays", overlay) if err != nil { return nil, err } overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) } req.Overlays = append([]string(nil), selectedOverlays...) return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays) } func (s *ConfigPreviewService) BuildProfileEditorForDeviceAssignment(deviceID string) (*ConfigProfileEditor, *DeviceAssignmentAsset, error) { assignment, err := s.GetDeviceAssignment(deviceID) if err != nil { return nil, nil, err } editor, err := s.GetProfileEditor(assignment.ProfileName) if err != nil { return nil, nil, err } if len(assignment.RecognitionUnits) == 0 { editor.Instances = nil return editor, assignment, nil } selected := make(map[string]struct{}, len(assignment.RecognitionUnits)) for _, ref := range assignment.RecognitionUnits { selected[ref] = struct{}{} } filtered := make([]ConfigProfileInstanceEditor, 0, len(assignment.RecognitionUnits)) for _, inst := range editor.Instances { if _, ok := selected[recognitionUnitRef(editor.Name, inst.Name)]; ok { filtered = append(filtered, inst) } } editor.Instances = filtered return editor, assignment, nil } func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string) (*ConfigPreviewResult, error) { editor, assignment, err := s.BuildProfileEditorForDeviceAssignment(deviceID) if err != nil { return nil, err } if len(editor.Instances) == 0 { return nil, fmt.Errorf("设备 %s 还没有分配识别单元", deviceID) } templateName := firstProfileTemplate(editor.Instances) if strings.TrimSpace(templateName) == "" { templateName = editor.RawTemplateName() } return s.RenderProfileEditor(*editor, ConfigPreviewRequest{ Template: templateName, Profile: assignment.ProfileName, ConfigID: "assignment_" + deviceID, ConfigVersion: time.Now().Format("20060102.150405"), DeviceID: deviceID, }) } func profileOverlayNames(raw map[string]any) []string { items, _ := raw["overlays"].([]any) if len(items) == 0 { return nil } out := make([]string, 0, len(items)) for _, item := range items { if v := stringAny(item); strings.TrimSpace(v) != "" { out = append(out, strings.TrimSpace(v)) } } if len(out) == 0 { return nil } return out } 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) } 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) } } if req.ConfigID == "" { req.ConfigID = "preview_" + time.Now().Format("20060102.150405") } if req.ConfigVersion == "" { req.ConfigVersion = time.Now().Format("20060102.150405") } resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw) if err != nil { return nil, err } metadata := map[string]any{ "config_id": req.ConfigID, "config_version": req.ConfigVersion, "rendered_at": time.Now().Format(time.RFC3339), "rendered_by": "safesightd", } if strings.TrimSpace(req.DeviceID) != "" { metadata["device_id"] = strings.TrimSpace(req.DeviceID) } doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata) if err != nil { return nil, err } body, err := marshalConfigJSON(doc) if err != nil { return nil, err } renderedMetadata, _ := doc["metadata"].(map[string]any) sum := sha256.Sum256(body) return &ConfigPreviewResult{ Request: req, Root: previewRenderRoot(templatePath, profilePath), Sha256: hex.EncodeToString(sum[:]), Size: len(body), Metadata: renderedMetadata, JSON: string(body), }, nil } func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) { if raw == nil { return map[string]any{}, nil } body, err := json.Marshal(raw) if err != nil { return nil, err } var clone map[string]any if err := json.Unmarshal(body, &clone); err != nil { return nil, err } 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 } inputBindings, _ := instanceMap["input_bindings"].(map[string]any) if inputBindings == nil { inputBindings = map[string]any{} instanceMap["input_bindings"] = inputBindings } 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 } } return clone, nil } 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 } 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 } } func previewRenderRoot(templatePath string, profilePath string) string { if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") { return "SQLite" } if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." { return dir } return "safesightd" } func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) { body, err := marshalConfigJSON(raw) if err != nil { return "", err } tempFile, err := os.CreateTemp("", pattern) if err != nil { return "", err } path := tempFile.Name() if _, err := tempFile.Write(body); err != nil { _ = tempFile.Close() _ = os.Remove(path) return "", err } _ = tempFile.Close() return path, nil } func setAnyString(m map[string]any, key string, value string) { if strings.TrimSpace(value) != "" { m[key] = strings.TrimSpace(value) } } func (s *ConfigPreviewService) mediaRepoRoot() string { if s.cfg == nil { return "" } if strings.TrimSpace(s.cfg.MediaRepoPath) == "" { return "" } 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) for _, file := range files { if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { continue } name := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) out = append(out, ConfigSource{Name: name, Path: filepath.Join(dir, file.Name())}) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } func validateConfigName(name string) error { if !safeConfigName.MatchString(strings.TrimSpace(name)) { return fmt.Errorf("must contain only letters, numbers, dot, underscore, or dash") } if strings.Contains(name, "..") { return fmt.Errorf("must not contain '..'") } return nil } 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("asset repository is not configured") } root := s.mediaRepoRoot() if root == "" { 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}, } { sources, err := listConfigSources(filepath.Join(root, "configs", item.kind)) if err != nil { return nil, err } for _, source := range sources { body, err := os.ReadFile(source.Path) if err != nil { return nil, err } var raw map[string]any if err := json.Unmarshal(body, &raw); err != nil { return nil, err } name := firstString(raw["name"], source.Name) 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 } case "profiles": if err := s.assets.SaveProfile(name, profileRawTemplateName(raw), stringValue(raw["business_name"]), description, string(body)); err != nil { return nil, err } case "overlays": if err := s.assets.SaveOverlay(name, description, string(body)); err != nil { return nil, err } } *item.inc = *item.inc + 1 } } return result, nil } func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte, string, error) { if err := validateConfigName(name); err != nil { return nil, "", err } if s == nil || s.assets == nil { return nil, "", fmt.Errorf("asset repository is not configured") } if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil { return body, name + ".json", err } return nil, "", os.ErrNotExist } func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) { var ( record *storage.AssetRecord err error ) switch kind { case "templates": record, err = s.assets.GetTemplate(name) case "profiles": record, err = s.assets.GetProfile(name) case "overlays": record, err = s.assets.GetOverlay(name) default: return nil, true, fmt.Errorf("unsupported asset kind: %s", kind) } if err != nil { return nil, true, err } if record == nil { return nil, false, nil } return []byte(record.BodyJSON), true, nil } func profileRawTemplateName(raw map[string]any) string { instances, _ := raw["instances"].([]any) for _, item := range instances { instanceMap, _ := item.(map[string]any) if v := stringValue(instanceMap["template"]); v != "" { return v } } return stringValue(raw["primary_template_name"]) }