diff --git a/.gitignore b/.gitignore index b4c3873..cc9032f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ managerd~ nul dataset/ __pycache__/ +managerd.db diff --git a/internal/service/auto_config.go b/internal/service/auto_config.go index cef0ede..73bfff1 100644 --- a/internal/service/auto_config.go +++ b/internal/service/auto_config.go @@ -142,9 +142,19 @@ func (r *FeatureRegistry) ResolveTemplate(featureKeys []string) (*ResolveResult, // Find intersection. intersection := intersectTemplateLists(lists) if len(intersection) > 0 { - // Pick the one with the broadest coverage (first in the list). + // 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: intersection[0], + TemplateName: best, Composed: false, }, nil } @@ -204,6 +214,21 @@ func pickBroadestTemplate(lists [][]string) string { 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 { @@ -459,6 +484,39 @@ func (s *AutoConfigService) composeFromFeatureTemplates(featureKeys []string, pr 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)) diff --git a/internal/service/auto_config_test.go b/internal/service/auto_config_test.go index 1f8250b..b8665bb 100644 --- a/internal/service/auto_config_test.go +++ b/internal/service/auto_config_test.go @@ -15,9 +15,10 @@ func TestFeatureRegistry_ResolveTemplate_SingleFeature(t *testing.T) { if result.Composed { t.Errorf("expected single template, got composed=true") } - // std_workshop_face_recognition_shoe_alarm is first (broadest coverage) - if result.TemplateName != "std_workshop_face_recognition_shoe_alarm" { - t.Errorf("expected std_workshop_face_recognition_shoe_alarm, got %s", result.TemplateName) + // Most specific template for face-only: std_face_recognition_stream (covers 1 feature) + // std_workshop_face_recognition_shoe_alarm covers 2 features, less specific. + if result.TemplateName != "std_face_recognition_stream" { + t.Errorf("expected std_face_recognition_stream (most specific), got %s", result.TemplateName) } } @@ -61,6 +62,7 @@ func TestFeatureRegistry_ResolveTemplate_ComposedNeeded(t *testing.T) { if !strings.HasPrefix(result.TemplateName, "auto_") { t.Errorf("composed template should start with auto_, got %s", result.TemplateName) } + // composePrimary is the first template from the first feature (face) if result.composePrimary != "std_workshop_face_recognition_shoe_alarm" { t.Errorf("expected compose primary std_workshop_face_recognition_shoe_alarm, got %s", result.composePrimary) } diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index 4b69a86..ab13edf 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -757,3 +757,44 @@ func valueString(v any) string { return "" } } + +// ---- device_features ---- + +// DeviceFeatureRecord holds the persisted feature selection for a device. +type DeviceFeatureRecord struct { + DeviceID string `json:"device_id"` + FeaturesJSON string `json:"features_json"` // JSON array of feature keys, e.g. ["face","shoe"] + UpdatedAt string `json:"updated_at"` +} + +// SaveDeviceFeatures upserts the feature selection for a device. +func (r *AssetsRepo) SaveDeviceFeatures(deviceID string, featuresJSON string) error { + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` +INSERT INTO device_features(device_id, features_json, updated_at) +VALUES(?, ?, ?) +ON CONFLICT(device_id) DO UPDATE SET + features_json=excluded.features_json, + updated_at=excluded.updated_at +`, deviceID, featuresJSON, now) + return err +} + +// GetDeviceFeatures returns the persisted features for a device, or nil. +func (r *AssetsRepo) GetDeviceFeatures(deviceID string) (*DeviceFeatureRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + var rec DeviceFeatureRecord + err := r.db.QueryRow( + "SELECT device_id, features_json, updated_at FROM device_features WHERE device_id = ?", + deviceID, + ).Scan(&rec.DeviceID, &rec.FeaturesJSON, &rec.UpdatedAt) + if err != nil { + return nil, nil // not found is not an error + } + return &rec, nil +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index d3f6ccc..b95370b 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -150,6 +150,11 @@ CREATE TABLE IF NOT EXISTS device_config_state ( last_applied_task_id TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS device_features ( + device_id TEXT PRIMARY KEY, + features_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY, type TEXT NOT NULL, diff --git a/internal/web/ui.go b/internal/web/ui.go index fca2a95..9b8a8a8 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -918,40 +918,21 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { {Key: "intrusion", Label: "区域入侵"}, } - // Template-to-feature mapping (which templates enable which features) - templateFeatures := map[string][]string{ - "std_face_recognition_stream": {"face"}, - "std_workshoe_detection_stream": {"shoe"}, - "std_workshop_face_recognition_shoe_alarm": {"face", "shoe"}, - "std_service_test_stream": {}, - } - - // Collect assigned template names per device. - // First collect scene template (profile) names from recognition units, - // then resolve each profile to its primary_template_name for feature detection. - deviceProfileNames := map[string]map[string]bool{} - for _, a := range assignments { - if deviceProfileNames[a.DeviceID] == nil { - deviceProfileNames[a.DeviceID] = map[string]bool{} - } - for _, ref := range a.RecognitionUnits { - for _, u := range units { - if u.Ref == ref && u.SceneTemplateName != "" { - deviceProfileNames[a.DeviceID][u.SceneTemplateName] = true - break - } + // Load persisted device features as the source of truth. + // This represents what capabilities the device is configured for, + // not just what the last deployment used. + persistedFeatures := map[string]map[string]bool{} + if u.preview != nil { + for _, dev := range devices { + if dev == nil || dev.DeviceID == "" { + continue } - } - } - // Resolve profile names → actual template names. - deviceTemplates := map[string]map[string]bool{} - for deviceID, profiles := range deviceProfileNames { - deviceTemplates[deviceID] = map[string]bool{} - for profileName := range profiles { - if editor, err := u.preview.GetProfileEditor(profileName); err == nil { - if tpl := editor.RawTemplateName(); tpl != "" { - deviceTemplates[deviceID][tpl] = true + if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 { + m := map[string]bool{} + for _, f := range feats { + m[f] = true } + persistedFeatures[dev.DeviceID] = m } } } @@ -1028,14 +1009,10 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { } } - // Determine which features are enabled - activeKeys := map[string]bool{} - for tpl := range deviceTemplates[dev.DeviceID] { - if keys, ok := templateFeatures[tpl]; ok { - for _, k := range keys { - activeKeys[k] = true - } - } + // Determine which features are enabled from persisted device features. + activeKeys := persistedFeatures[dev.DeviceID] + if activeKeys == nil { + activeKeys = map[string]bool{} } var features []ConsoleFeature @@ -1086,7 +1063,7 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - if u.autoConfig == nil { + if u.autoConfig == nil || u.preview == nil { http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound) return } @@ -1094,17 +1071,40 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() devices := u.registry.GetDevices() - // Build request per device from form data. - // Form fields: - // device_{id}_feature = face (one per checked feature) - // device_{id}_source = cam1 (one per assigned video source) + // Step 1: persist feature selections to DB (source of truth). + for _, dev := range devices { + if dev == nil || dev.DeviceID == "" { + continue + } + features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"]) + if err := u.preview.SaveDeviceFeatures(dev.DeviceID, features); err != nil { + http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("保存功能配置失败: "+err.Error()), http.StatusFound) + return + } + } + + // Step 2: build requests from DB only. + // Features come from device_features table. + // Video sources come from existing device assignments and recognition units. var requests []service.AutoConfigRequest for _, dev := range devices { if dev == nil || dev.DeviceID == "" { continue } - features := r.Form["device_"+dev.DeviceID+"_feature"] - sources := r.Form["device_"+dev.DeviceID+"_source"] + features, err := u.preview.GetDeviceFeatures(dev.DeviceID) + if err != nil { + continue + } + var sources []string + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil { + for _, ref := range assignment.RecognitionUnits { + if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil { + if unit.VideoSourceRef != "" { + sources = append(sources, unit.VideoSourceRef) + } + } + } + } if len(features) == 0 && len(sources) == 0 { continue } @@ -1116,7 +1116,7 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { } if len(requests) == 0 { - http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("未选择任何检测功能或视频源"), http.StatusFound) + http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("未选择检测功能或未分配视频源"), http.StatusFound) return }