package service import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "strings" ) // FeatureDef describes a detection feature that users can toggle on/off. type FeatureDef struct { Key string // unique key, e.g. "face", "shoe" Label string // human-readable label Templates []string // template names supporting this feature, ordered by coverage (broadest first) } // FeatureRegistry centralises which templates cover which detection features. type FeatureRegistry struct { features map[string]*FeatureDef } // NewFeatureRegistry creates a registry with the built-in feature definitions. func NewFeatureRegistry() *FeatureRegistry { defs := []FeatureDef{ { Key: "face", Label: "人脸识别", Templates: []string{"std_workshop_face_recognition_shoe_alarm", "std_face_recognition_stream"}, }, { Key: "shoe", Label: "劳保鞋检测", Templates: []string{"std_workshop_face_recognition_shoe_alarm", "std_workshoe_detection_stream"}, }, { Key: "helmet", Label: "安全帽检测", Templates: []string{}, // 模型就绪后再补充 }, { Key: "smoking", Label: "抽烟检测", Templates: []string{}, // 模型就绪后再补充 }, { Key: "intrusion", Label: "区域入侵", Templates: []string{}, // 模板就绪后再补充 }, } r := &FeatureRegistry{features: make(map[string]*FeatureDef, len(defs))} for i := range defs { r.features[defs[i].Key] = &defs[i] } return r } // FeatureKeys returns all registered feature keys in a stable order. func (r *FeatureRegistry) FeatureKeys() []string { keys := make([]string, 0, len(r.features)) for k := range r.features { keys = append(keys, k) } sort.Strings(keys) return keys } // Feature returns the definition for a feature key, or nil. func (r *FeatureRegistry) Feature(key string) *FeatureDef { return r.features[key] } // AllFeatures returns a copy of all feature definitions sorted by key. func (r *FeatureRegistry) AllFeatures() []FeatureDef { out := make([]FeatureDef, 0, len(r.features)) for _, key := range r.FeatureKeys() { if f := r.features[key]; f != nil { out = append(out, *f) } } return out } // ResolveResult describes the outcome of template resolution. type ResolveResult struct { TemplateName string `json:"template_name"` // selected template name; may be a composed name when Composed is true Composed bool `json:"composed"` // true when no single template covers all features and composition is needed // internal fields for the template composer (unexported) composePrimary string `json:"-"` composeFeatures []string `json:"-"` } // ResolveTemplate picks the best template covering all requested features. // // Strategy: // 1. Single-template: find the intersection of all features' template lists. // Return the first (highest-coverage) match. // 2. Composition needed: every feature has at least one template but no single // template covers all. This case will be handled by the template composer // later — for now we return the broadest template and set Composed=true. // 3. Error: one or more features have no templates at all. func (r *FeatureRegistry) ResolveTemplate(featureKeys []string) (*ResolveResult, error) { if len(featureKeys) == 0 { return nil, fmt.Errorf("未选择任何检测功能") } // Deduplicate and sort seen := map[string]struct{}{} cleaned := make([]string, 0, len(featureKeys)) for _, key := range featureKeys { key = strings.TrimSpace(key) if key == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} if _, ok := r.features[key]; !ok { return nil, fmt.Errorf("未知的检测功能: %q", key) } cleaned = append(cleaned, key) } if len(cleaned) == 0 { return nil, fmt.Errorf("未选择任何检测功能") } sort.Strings(cleaned) // Collect template lists for each feature. lists := make([][]string, 0, len(cleaned)) for _, key := range cleaned { def := r.features[key] if len(def.Templates) == 0 { return nil, fmt.Errorf("%s(%s)的模板尚未就绪", def.Label, def.Key) } lists = append(lists, def.Templates) } // Find intersection. intersection := intersectTemplateLists(lists) if len(intersection) > 0 { // Pick the most specific template: the one covering the fewest features // (i.e. listed by the fewest FeatureDefs), not the broadest. // This ensures selecting only "face" doesn't pull in a face+shoe template. best := intersection[0] bestScore := r.templateFeatureCount(best) for _, tpl := range intersection[1:] { if score := r.templateFeatureCount(tpl); score < bestScore { best = tpl bestScore = score } } return &ResolveResult{ TemplateName: best, Composed: false, }, nil } // No single template covers all features → composition needed. // For now, pick the template that covers the most features. primary := pickBroadestTemplate(lists) composedName := "auto_" + strings.Join(cleaned, "_") return &ResolveResult{ TemplateName: composedName, Composed: true, composePrimary: primary, composeFeatures: append([]string(nil), cleaned...), }, nil } // intersectTemplateLists returns templates that appear in every list, preserving // the order of the first list. func intersectTemplateLists(lists [][]string) []string { if len(lists) == 0 { return nil } // Count occurrences across all lists. counts := map[string]int{} for _, list := range lists { lseen := map[string]struct{}{} // dedupe within one list for _, t := range list { if _, ok := lseen[t]; ok { continue } lseen[t] = struct{}{} counts[t]++ } } total := len(lists) // Return in the order of the first list (which is sorted by coverage). result := make([]string, 0) seen := map[string]struct{}{} for _, t := range lists[0] { if counts[t] == total { if _, ok := seen[t]; !ok { seen[t] = struct{}{} result = append(result, t) } } } return result } // pickBroadestTemplate returns the template that appears earliest in the // first list (assumed to be sorted by coverage breadth). func pickBroadestTemplate(lists [][]string) string { if len(lists) == 0 || len(lists[0]) == 0 { return "" } return lists[0][0] } // templateFeatureCount returns how many features list this template. // Lower = more specific template. func (r *FeatureRegistry) templateFeatureCount(templateName string) int { count := 0 for _, def := range r.features { for _, t := range def.Templates { if t == templateName { count++ break } } } return count } func (r *FeatureRegistry) ValidateFeatureKeys(keys []string) error { seen := map[string]struct{}{} for _, key := range keys { key = strings.TrimSpace(key) if key == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} if _, ok := r.features[key]; !ok { return fmt.Errorf("未知的检测功能: %q", key) } } if len(seen) == 0 { return fmt.Errorf("未选择任何检测功能") } return nil } // ──────────────────────────────────────────────────────────── // AutoConfigService — one-click configuration pipeline // ──────────────────────────────────────────────────────────── // AutoConfigRequest is a single device's configuration intent from the console. type AutoConfigRequest struct { DeviceID string `json:"device_id"` Features []string `json:"features"` // feature keys: "face", "shoe", ... SourceNames []string `json:"source_names"` // assigned video source names } // AutoConfigResult reports what happened for a single device. type AutoConfigResult struct { DeviceID string `json:"device_id"` TemplateName string `json:"template_name"` Composed bool `json:"composed"` UnitsCreated int `json:"units_created"` ConfigSHA256 string `json:"config_sha256"` TaskID string `json:"task_id,omitempty"` Error string `json:"error,omitempty"` } // AutoConfigService orchestrates the full pipeline from user intent to deployed config. type AutoConfigService struct { preview *ConfigPreviewService tasks *TaskService registry *FeatureRegistry } // NewAutoConfigService creates a new AutoConfigService. func NewAutoConfigService(preview *ConfigPreviewService, tasks *TaskService, registry *FeatureRegistry) *AutoConfigService { return &AutoConfigService{ preview: preview, tasks: tasks, registry: registry, } } // BuildPipeline executes the full auto-config pipeline for a single device: // 1. Resolve template from feature selection // 2. If composed, generate the composed template // 3. Create/update the scene template (profile) // 4. Create/update recognition units for each video source // 5. Create/update the device assignment // 6. Render the final config // 7. Create a deploy task // // If deploy is false, steps 6-7 are skipped (dry-run mode). func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*AutoConfigResult, error) { result := &AutoConfigResult{DeviceID: req.DeviceID} // ---- 1. Resolve template ---- cleanSources := cleanSourceNames(req.SourceNames) if len(cleanSources) == 0 { result.Error = "未分配任何视频源" return result, fmt.Errorf(result.Error) } resolveResult, err := s.registry.ResolveTemplate(req.Features) if err != nil { result.Error = err.Error() return result, err } templateName := resolveResult.TemplateName result.Composed = resolveResult.Composed // ---- 2. Compose template if needed ---- if resolveResult.Composed { composed, err := s.composeFromFeatureTemplates(req.Features, resolveResult.composePrimary, resolveResult.composeFeatures) if err != nil { result.Error = fmt.Sprintf("模板组合失败: %v", err) return result, fmt.Errorf(result.Error) } body, err := marshalConfigJSON(composed) if err != nil { result.Error = fmt.Sprintf("序列化组合模板失败: %v", err) return result, fmt.Errorf(result.Error) } if err := s.preview.SaveTemplateAsset(templateName, "自动组合: "+strings.Join(req.Features, "+"), string(body)); err != nil { result.Error = fmt.Sprintf("保存组合模板失败: %v", err) return result, fmt.Errorf(result.Error) } } result.TemplateName = templateName // ---- 3. Ensure profile (scene template) ---- profileName := "auto_" + req.DeviceID editor := ConfigProfileEditor{ Name: profileName, PrimaryTemplateName: templateName, BusinessName: req.DeviceID + " 自动配置", Description: "管控台一键生成", } // ---- 4. Create recognition units for each video source ---- for _, srcName := range cleanSources { unitName := deriveSafeInstanceName(srcName) inst := ConfigProfileInstanceEditor{ Name: unitName, Template: templateName, VideoSourceRef: srcName, DisplayName: srcName, InputBindings: map[string]InputBindingEditor{ "video_input_main": {VideoSourceRef: srcName}, }, OutputBindings: map[string]OutputBindingEditor{ "stream_output_main": { PublishHLSPath: "./web/hls/" + unitName + "/index.m3u8", PublishRTSPPort: "8555", PublishRTSPPath: "/live/" + unitName, ChannelNo: unitName, }, }, } editor.Instances = append(editor.Instances, inst) } if err := s.preview.SaveProfileEditor(editor); err != nil { result.Error = fmt.Sprintf("保存场景模板失败: %v", err) return result, fmt.Errorf(result.Error) } // ---- 4b. Save recognition units via the dedicated API ---- for _, inst := range editor.Instances { unit := RecognitionUnitAsset{ SceneTemplateName: profileName, Name: inst.Name, DisplayName: inst.DisplayName, VideoSourceRef: inst.VideoSourceRef, OutputChannel: inst.ChannelNo, RTSPPort: inst.PublishRTSPPort, } ref := recognitionUnitRef(profileName, inst.Name) if err := s.preview.SaveRecognitionUnit(unit, ref); err != nil { result.Error = fmt.Sprintf("保存识别单元 %s 失败: %v", inst.Name, err) return result, fmt.Errorf(result.Error) } result.UnitsCreated++ } // ---- 5. Device assignment ---- unitRefs := make([]string, 0, len(cleanSources)) for _, inst := range editor.Instances { unitRefs = append(unitRefs, recognitionUnitRef(profileName, inst.Name)) } assignment := DeviceAssignmentAsset{ DeviceID: req.DeviceID, ProfileName: profileName, Description: "管控台自动分配", RecognitionUnits: unitRefs, } if err := s.preview.SaveDeviceAssignment(assignment); err != nil { result.Error = fmt.Sprintf("保存设备分配失败: %v", err) return result, fmt.Errorf(result.Error) } if !deploy { preview, err := s.preview.RenderDeviceAssignment(req.DeviceID) if err != nil { result.Error = fmt.Sprintf("配置渲染失败: %v", err) return result, fmt.Errorf(result.Error) } result.ConfigSHA256 = preview.Sha256 return result, nil } // ---- 6 & 7. Render and deploy ---- preview, err := s.preview.RenderDeviceAssignment(req.DeviceID) if err != nil { result.Error = fmt.Sprintf("配置渲染失败: %v", err) return result, fmt.Errorf(result.Error) } result.ConfigSHA256 = preview.Sha256 var configDoc any if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { result.Error = fmt.Sprintf("配置 JSON 无效: %v", err) return result, fmt.Errorf(result.Error) } task, err := s.tasks.CreateTask("config_apply", []string{req.DeviceID}, map[string]any{"config": configDoc}) if err != nil { result.Error = fmt.Sprintf("创建下发任务失败: %v", err) return result, fmt.Errorf(result.Error) } result.TaskID = task.ID return result, nil } // BuildPipelineBatch executes the pipeline for multiple devices. func (s *AutoConfigService) BuildPipelineBatch(requests []AutoConfigRequest, deploy bool) ([]AutoConfigResult, error) { results := make([]AutoConfigResult, 0, len(requests)) var firstErr error for _, req := range requests { result, err := s.BuildPipeline(req, deploy) if result != nil { results = append(results, *result) } if err != nil && firstErr == nil { firstErr = err } } return results, firstErr } // composeFromFeatureTemplates loads templates for each feature and composes them. func (s *AutoConfigService) composeFromFeatureTemplates(featureKeys []string, primaryName string, allFeatures []string) (map[string]any, error) { primaryRaw, _, err := s.preview.readAssetJSON("templates", primaryName) if err != nil { return nil, fmt.Errorf("load primary template %q: %w", primaryName, err) } templates := []map[string]any{primaryRaw} for _, key := range featureKeys { if def := s.registry.Feature(key); def != nil { for _, tplName := range def.Templates { if tplName == primaryName { continue } raw, _, err := s.preview.readAssetJSON("templates", tplName) if err != nil { continue } templates = append(templates, raw) break } } } if len(templates) <= 1 { return primaryRaw, nil } return ComposeTemplates(templates) } // ---- Device feature persistence ---- // GetDeviceFeatures returns the persisted feature keys for a device. func (s *ConfigPreviewService) GetDeviceFeatures(deviceID string) ([]string, error) { if s == nil || s.assets == nil { return nil, nil } rec, err := s.assets.GetDeviceFeatures(deviceID) if err != nil || rec == nil { return nil, err } var features []string if err := json.Unmarshal([]byte(rec.FeaturesJSON), &features); err != nil { return nil, nil } return features, nil } // SaveDeviceFeatures persists the feature keys for a device. func (s *ConfigPreviewService) SaveDeviceFeatures(deviceID string, features []string) error { if s == nil || s.assets == nil { return fmt.Errorf("asset repository is not configured") } if len(features) == 0 { features = []string{} } body, err := json.Marshal(features) if err != nil { return err } return s.assets.SaveDeviceFeatures(deviceID, string(body)) } func cleanSourceNames(names []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(names)) for _, n := range names { n = strings.TrimSpace(n) if n == "" { continue } if _, ok := seen[n]; ok { continue } seen[n] = struct{}{} out = append(out, n) } sort.Strings(out) return out } // deriveSafeInstanceName converts a user-facing video source name into a valid // recognition unit instance name ([A-Za-z0-9_.-]+). If the source name is already // valid, it's used as-is. Otherwise, a sanitised version is generated. func deriveSafeInstanceName(srcName string) string { // If already valid per validateConfigName rules, use as-is. if safeConfigName.MatchString(srcName) { return srcName } // Build a safe name from the source: keep only ASCII letters, digits, underscores. var b strings.Builder for _, r := range srcName { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' { b.WriteRune(r) } } derived := strings.Trim(b.String(), "-_.") if derived != "" && safeConfigName.MatchString(derived) { return derived } // Fallback: use a short hash suffix to guarantee uniqueness. sum := sha256.Sum256([]byte(srcName)) return "src_" + strings.ToLower(hex.EncodeToString(sum[:4])) }