From d0e947ca02cc25f120ddbb365332dda9c3871698 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:42 +0800 Subject: [PATCH 01/21] feat: complete split camera credentials (#50) --- .../server/app/sense/adapters/onvif/client.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sense/server/app/sense/adapters/onvif/client.go b/Sense/server/app/sense/adapters/onvif/client.go index ffae991..1f5acc9 100644 --- a/Sense/server/app/sense/adapters/onvif/client.go +++ b/Sense/server/app/sense/adapters/onvif/client.go @@ -9,6 +9,7 @@ import ( "encoding/xml" "fmt" "io" + "net" "net/http" "net/url" "strconv" @@ -82,9 +83,32 @@ func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Cr if err != nil { return nil, err } + profiles[i].StreamURI, err = normalizeStreamURI(deviceEndpoint, profiles[i].StreamURI) + if err != nil { + return nil, err + } } return profiles, nil } + +func normalizeStreamURI(deviceEndpoint, streamURI string) (string, error) { + device, err := url.Parse(deviceEndpoint) + if err != nil || device.Hostname() == "" { + return "", fmt.Errorf("invalid ONVIF address") + } + stream, err := url.Parse(streamURI) + if err != nil || stream.Scheme != "rtsp" || stream.Host == "" || stream.User != nil { + return "", fmt.Errorf("invalid RTSP stream URI") + } + if !strings.EqualFold(stream.Hostname(), device.Hostname()) { + port := stream.Port() + stream.Host = device.Hostname() + if port != "" { + stream.Host = net.JoinHostPort(device.Hostname(), port) + } + } + return stream.String(), nil +} func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) { return c.soapAttempt(ctx, endpoint, credential, body, "") } -- 2.34.1 From 74070aa0f9e667e3eabb55393e81e9a604b62b77 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:43 +0800 Subject: [PATCH 02/21] feat: complete split camera credentials (#50) --- .../app/sense/adapters/onvif/client_test.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sense/server/app/sense/adapters/onvif/client_test.go b/Sense/server/app/sense/adapters/onvif/client_test.go index c8d6154..cf8664a 100644 --- a/Sense/server/app/sense/adapters/onvif/client_test.go +++ b/Sense/server/app/sense/adapters/onvif/client_test.go @@ -43,7 +43,8 @@ func TestProfilesDiscoversMediaServiceAndUsesDigest(t *testing.T) { if err != nil { t.Fatal(err) } - if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://camera.invalid/live" { + serverURL, _ := url.Parse(server.URL) + if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://"+serverURL.Hostname()+"/live" { t.Fatalf("profiles=%#v", profiles) } if digestRequests.Load() != 2 { @@ -80,6 +81,19 @@ func TestNormalizeServiceEndpoint(t *testing.T) { } } +func TestNormalizeStreamURI(t *testing.T) { + got, err := normalizeStreamURI("http://192.0.2.10:80/onvif/device_service", "rtsp://unusable.local:8554/live/main?channel=1") + if err != nil || got != "rtsp://192.0.2.10:8554/live/main?channel=1" { + t.Fatalf("got=%q err=%v", got, err) + } + if _, err := normalizeStreamURI("http://camera.local/onvif", "rtsp://user:pass@camera.local/live"); err == nil { + t.Fatal("credential stream URI accepted") + } + if _, err := normalizeStreamURI("http://camera.local/onvif", "http://camera.local/live"); err == nil { + t.Fatal("non-RTSP URI accepted") + } +} + func TestRejectUnsupportedDigestChallenge(t *testing.T) { for _, challenge := range []string{ `Digest realm="camera", nonce="n", algorithm=SHA-512, qop="auth"`, -- 2.34.1 From b383f0a8d85aaa8aaa6560af6f12722469620f45 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:44 +0800 Subject: [PATCH 03/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/admission/migration.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 Sense/server/app/sense/admission/migration.go diff --git a/Sense/server/app/sense/admission/migration.go b/Sense/server/app/sense/admission/migration.go new file mode 100644 index 0000000..a1634f4 --- /dev/null +++ b/Sense/server/app/sense/admission/migration.go @@ -0,0 +1,26 @@ +package admission + +const MigrationSQL = ` +CREATE TABLE IF NOT EXISTS sense_admission_results ( + device_id TEXT PRIMARY KEY REFERENCES sense_devices(id), + address TEXT NOT NULL, + status TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + checked_at TIMESTAMPTZ NOT NULL +); +CREATE TABLE IF NOT EXISTS sense_admission_profiles ( + device_id TEXT NOT NULL REFERENCES sense_devices(id), + token TEXT NOT NULL, + name TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + encoding TEXT NOT NULL, + stream_uri TEXT NOT NULL, + kind TEXT NOT NULL, + verification_status TEXT NOT NULL, + verification_latency_ms BIGINT NOT NULL DEFAULT 0, + verification_detail TEXT NOT NULL DEFAULT '', + PRIMARY KEY(device_id, token) +); +` + -- 2.34.1 From 65798d01b688abd782e7149ff37db3e0e9a46014 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:45 +0800 Subject: [PATCH 04/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/admission/service.go | 48 +++++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/Sense/server/app/sense/admission/service.go b/Sense/server/app/sense/admission/service.go index d980b2b..674ce1f 100644 --- a/Sense/server/app/sense/admission/service.go +++ b/Sense/server/app/sense/admission/service.go @@ -5,7 +5,6 @@ import ( "fmt" "sort" "strings" - "sync" "time" "yovision.local/sense/app/sense/adapters/onvif" @@ -37,13 +36,16 @@ type Service struct { rtsp rtsp.Verifier discoveryIP string discoveryTimeout time.Duration - mu sync.RWMutex - results map[string]Result + store Store now func() time.Time } -func NewService(client onvif.Client, verifier rtsp.Verifier, discoveryIP string) *Service { - return &Service{onvif: client, rtsp: verifier, discoveryIP: discoveryIP, discoveryTimeout: 3 * time.Second, results: map[string]Result{}, now: time.Now} +func NewService(client onvif.Client, verifier rtsp.Verifier, discoveryIP string, stores ...Store) *Service { + var store Store = NewMemoryStore() + if len(stores) > 0 && stores[0] != nil { + store = stores[0] + } + return &Service{onvif: client, rtsp: verifier, discoveryIP: discoveryIP, discoveryTimeout: 3 * time.Second, store: store, now: time.Now} } func (s *Service) Discover(ctx context.Context) ([]string, error) { if strings.TrimSpace(s.discoveryIP) == "" { @@ -52,21 +54,25 @@ func (s *Service) Discover(ctx context.Context) ([]string, error) { return onvif.Discover(ctx, s.discoveryIP, s.discoveryTimeout) } func (s *Service) Probe(ctx context.Context, actor identity.Principal, deviceID, address string) (Result, error) { - credential, err := device.ReadCredential(ctx, deviceID) + onvifCredential, err := device.ReadONVIFCredential(ctx, deviceID) if err != nil { return Result{}, fmt.Errorf("credential_required") } - profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: credential.Username, Password: credential.Password}) + profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: onvifCredential.Username, Password: onvifCredential.Password}) if err != nil { status, detail := classify(err) result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, CheckedAt: s.now().UTC()} - s.save(result) + _ = s.store.Save(ctx, result) identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "failure", map[string]any{"status": status}) return result, nil } + rtspCredential, err := device.ReadRTSPCredential(ctx, deviceID) + if err != nil { + return Result{}, fmt.Errorf("rtsp_credential_required") + } items := make([]Profile, 0, len(profiles)) for _, profile := range profiles { - verification, verifyErr := s.rtsp.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: credential.Username, Password: credential.Password}) + verification, verifyErr := s.rtsp.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: rtspCredential.Username, Password: rtspCredential.Password}) if verifyErr != nil { verification = rtsp.Result{Status: "failed", Detail: "视频地址格式不正确"} } @@ -89,20 +95,23 @@ func (s *Service) Probe(ctx context.Context, actor identity.Principal, deviceID, } } result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, Profiles: items, CheckedAt: s.now().UTC()} - s.save(result) + if err := s.store.Save(ctx, result); err != nil { + return Result{}, err + } + for _, item := range items { + if item.Verification.Status == "ready" { + if err := device.MarkActive(ctx, deviceID); err != nil { + return Result{}, err + } + break + } + } identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "success", map[string]any{"profile_count": len(items), "status": status}) return result, nil } func (s *Service) Get(deviceID string) (Result, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - value, ok := s.results[deviceID] - return value, ok -} -func (s *Service) save(result Result) { - s.mu.Lock() - defer s.mu.Unlock() - s.results[result.DeviceID] = result + value, err := s.store.Get(context.Background(), deviceID) + return value, err == nil } func classify(err error) (string, string) { value := strings.ToLower(err.Error()) @@ -117,3 +126,4 @@ func classify(err error) (string, string) { return "unreachable", "无法读取设备信息,请检查地址和网络" } } + -- 2.34.1 From 60754334024f787f8a8b005e4709c93933742c06 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:46 +0800 Subject: [PATCH 05/21] feat: complete split camera credentials (#50) --- .../app/sense/admission/service_test.go | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/Sense/server/app/sense/admission/service_test.go b/Sense/server/app/sense/admission/service_test.go index 5a407c0..1463e62 100644 --- a/Sense/server/app/sense/admission/service_test.go +++ b/Sense/server/app/sense/admission/service_test.go @@ -22,6 +22,20 @@ type fakeRTSP struct{} func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) { return rtsp.Result{Status: "ready"}, nil } + +type credentialCapturingONVIF struct{ got onvif.Credential } + +func (f *credentialCapturingONVIF) Profiles(_ context.Context, _ string, credential onvif.Credential) ([]onvif.Profile, error) { + f.got = credential + return fakeONVIF{}.Profiles(context.Background(), "", credential) +} + +type credentialCapturingRTSP struct{ got rtsp.Credential } + +func (f *credentialCapturingRTSP) Verify(_ context.Context, _ string, credential rtsp.Credential) (rtsp.Result, error) { + f.got = credential + return rtsp.Result{Status: "ready"}, nil +} func TestProbeProfilesWithoutCredentialURI(t *testing.T) { vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false) if err != nil { @@ -44,3 +58,41 @@ func TestProbeProfilesWithoutCredentialURI(t *testing.T) { t.Fatalf("result=%#v err=%v", result, err) } } + +func TestProbeUsesSeparateCredentialsPersistsProfilesAndActivatesDevice(t *testing.T) { + vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false) + if err != nil { + t.Fatal(err) + } + deviceStore := device.NewMemoryStore() + deviceService := device.NewService(deviceStore, vault) + device.NewModule(deviceService).Register(platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, nil)) + item, err := deviceService.Create(context.Background(), identity.Principal{}, "camera", "gate", device.ModalityVideo, nil) + if err != nil { + t.Fatal(err) + } + if _, err = deviceService.SetCredentials(context.Background(), identity.Principal{}, item.ID, "onvif-user", "onvif-password", false, "rtsp-user", "rtsp-password"); err != nil { + t.Fatal(err) + } + onvifClient := &credentialCapturingONVIF{} + rtspVerifier := &credentialCapturingRTSP{} + store := NewMemoryStore() + service := NewService(onvifClient, rtspVerifier, "", store) + result, err := service.Probe(context.Background(), identity.Principal{}, item.ID, "http://camera.invalid/onvif/device_service") + if err != nil { + t.Fatal(err) + } + if onvifClient.got.Username != "onvif-user" || rtspVerifier.got.Username != "rtsp-user" { + t.Fatal("credentials were not separated") + } + restarted := NewService(onvifClient, rtspVerifier, "", store) + persisted, ok := restarted.Get(item.ID) + if !ok || len(persisted.Profiles) != len(result.Profiles) { + t.Fatalf("persisted=%#v", persisted) + } + updated, err := deviceService.Get(context.Background(), item.ID) + if err != nil || updated.Status != device.StatusActive { + t.Fatalf("device=%#v err=%v", updated, err) + } +} + -- 2.34.1 From 136caf189ec0959f04a2da12209a59f2e81930bb Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:47 +0800 Subject: [PATCH 06/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/admission/store.go | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 Sense/server/app/sense/admission/store.go diff --git a/Sense/server/app/sense/admission/store.go b/Sense/server/app/sense/admission/store.go new file mode 100644 index 0000000..39c5571 --- /dev/null +++ b/Sense/server/app/sense/admission/store.go @@ -0,0 +1,86 @@ +package admission + +import ( + "context" + "database/sql" + "errors" + "sync" +) + +var ErrNotFound = errors.New("admission result not found") + +type Store interface { + Save(context.Context, Result) error + Get(context.Context, string) (Result, error) +} + +type MemoryStore struct { + mu sync.RWMutex + results map[string]Result +} + +func NewMemoryStore() *MemoryStore { return &MemoryStore{results: map[string]Result{}} } +func (s *MemoryStore) Save(_ context.Context, result Result) error { + s.mu.Lock() + defer s.mu.Unlock() + s.results[result.DeviceID] = result + return nil +} +func (s *MemoryStore) Get(_ context.Context, deviceID string) (Result, error) { + s.mu.RLock() + defer s.mu.RUnlock() + result, ok := s.results[deviceID] + if !ok { + return Result{}, ErrNotFound + } + return result, nil +} + +type PostgresStore struct{ database *sql.DB } + +func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} } +func (s *PostgresStore) Save(ctx context.Context, result Result) error { + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO sense_admission_results(device_id,address,status,detail,checked_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(device_id) DO UPDATE SET address=EXCLUDED.address,status=EXCLUDED.status,detail=EXCLUDED.detail,checked_at=EXCLUDED.checked_at`, result.DeviceID, result.Address, result.Status, result.Detail, result.CheckedAt) + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM sense_admission_profiles WHERE device_id=$1`, result.DeviceID); err != nil { + return err + } + for _, profile := range result.Profiles { + _, err = tx.ExecContext(ctx, `INSERT INTO sense_admission_profiles(device_id,token,name,width,height,encoding,stream_uri,kind,verification_status,verification_latency_ms,verification_detail) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, result.DeviceID, profile.Token, profile.Name, profile.Width, profile.Height, profile.Encoding, profile.StreamURI, profile.Kind, profile.Verification.Status, profile.Verification.LatencyMS, profile.Verification.Detail) + if err != nil { + return err + } + } + return tx.Commit() +} +func (s *PostgresStore) Get(ctx context.Context, deviceID string) (Result, error) { + var result Result + err := s.database.QueryRowContext(ctx, `SELECT device_id,address,status,detail,checked_at FROM sense_admission_results WHERE device_id=$1`, deviceID).Scan(&result.DeviceID, &result.Address, &result.Status, &result.Detail, &result.CheckedAt) + if errors.Is(err, sql.ErrNoRows) { + return Result{}, ErrNotFound + } + if err != nil { + return Result{}, err + } + rows, err := s.database.QueryContext(ctx, `SELECT token,name,width,height,encoding,stream_uri,kind,verification_status,verification_latency_ms,verification_detail FROM sense_admission_profiles WHERE device_id=$1 ORDER BY width*height DESC`, deviceID) + if err != nil { + return Result{}, err + } + defer rows.Close() + for rows.Next() { + var profile Profile + if err := rows.Scan(&profile.Token, &profile.Name, &profile.Width, &profile.Height, &profile.Encoding, &profile.StreamURI, &profile.Kind, &profile.Verification.Status, &profile.Verification.LatencyMS, &profile.Verification.Detail); err != nil { + return Result{}, err + } + result.Profiles = append(result.Profiles, profile) + } + return result, rows.Err() +} + -- 2.34.1 From 4042011345c0d1a693c343a70c3145d31a6e8d97 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:48 +0800 Subject: [PATCH 07/21] feat: complete split camera credentials (#50) --- .../app/sense/device/credential_port.go | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Sense/server/app/sense/device/credential_port.go b/Sense/server/app/sense/device/credential_port.go index aee2741..4068672 100644 --- a/Sense/server/app/sense/device/credential_port.go +++ b/Sense/server/app/sense/device/credential_port.go @@ -16,6 +16,9 @@ var activeService atomic.Pointer[Service] // ReadCredential is an internal adapter port. Credentials must never be // returned from HTTP handlers, logged, or placed in a URL. func ReadCredential(ctx context.Context, id string) (Credential, error) { + return ReadONVIFCredential(ctx, id) +} +func ReadONVIFCredential(ctx context.Context, id string) (Credential, error) { service := activeService.Load() if service == nil { return Credential{}, fmt.Errorf("device service is not ready") @@ -30,3 +33,39 @@ func ReadCredential(ctx context.Context, id string) (Credential, error) { username, password, err := service.vault.Decrypt(item.CredentialCiphertext) return Credential{Username: username, Password: password}, err } + +func ReadRTSPCredential(ctx context.Context, id string) (Credential, error) { + service := activeService.Load() + if service == nil { + return Credential{}, fmt.Errorf("device service is not ready") + } + item, err := service.store.Get(ctx, id) + if err != nil { + return Credential{}, err + } + if len(item.RTSPCredentialCiphertext) == 0 { + return Credential{}, fmt.Errorf("RTSP credential is not configured") + } + username, password, err := service.vault.Decrypt(item.RTSPCredentialCiphertext) + return Credential{Username: username, Password: password}, err +} + +func MarkActive(ctx context.Context, id string) error { + service := activeService.Load() + if service == nil { + return fmt.Errorf("device service is not ready") + } + item, err := service.store.Get(ctx, id) + if err != nil { + return err + } + if item.Status == StatusDisabled || item.Status == StatusActive { + return nil + } + expected := item.Version + item.Status = StatusActive + item.Version++ + item.UpdatedAt = service.now().UTC() + return service.store.Update(ctx, item, expected) +} + -- 2.34.1 From da5318f8f06f673e2c87e2b67f3d68ec8e5fe8d2 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:49 +0800 Subject: [PATCH 08/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/device/http.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Sense/server/app/sense/device/http.go b/Sense/server/app/sense/device/http.go index 858e600..5bf8a88 100644 --- a/Sense/server/app/sense/device/http.go +++ b/Sense/server/app/sense/device/http.go @@ -78,15 +78,30 @@ func (m *Module) update(w http.ResponseWriter, r *http.Request) { } func (m *Module) credential(w http.ResponseWriter, r *http.Request) { var req struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password string `json:"password"` + ONVIFUsername string `json:"onvif_username"` + ONVIFPassword string `json:"onvif_password"` + RTSPSameAsONVIF *bool `json:"rtsp_same_as_onvif"` + RTSPUsername string `json:"rtsp_username"` + RTSPPassword string `json:"rtsp_password"` } if err := platform.DecodeJSON(r, &req); err != nil { platform.WriteError(w, err) return } actor, _ := identity.PrincipalFromContext(r.Context()) - item, err := m.service.SetCredential(r.Context(), actor, r.PathValue("id"), req.Username, req.Password) + if req.ONVIFUsername == "" { + req.ONVIFUsername = req.Username + } + if req.ONVIFPassword == "" { + req.ONVIFPassword = req.Password + } + rtspSame := true + if req.RTSPSameAsONVIF != nil { + rtspSame = *req.RTSPSameAsONVIF + } + item, err := m.service.SetCredentials(r.Context(), actor, r.PathValue("id"), req.ONVIFUsername, req.ONVIFPassword, rtspSame, req.RTSPUsername, req.RTSPPassword) if err != nil { writeDeviceError(w, err) return @@ -122,3 +137,4 @@ func writeDeviceError(w http.ResponseWriter, err error) { } platform.WriteError(w, &platform.APIError{Status: status, Code: code, Message: err.Error()}) } + -- 2.34.1 From a8a01d9a0a853ff017d707fe3d056ba4f2c12693 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:50 +0800 Subject: [PATCH 09/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/device/migration.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sense/server/app/sense/device/migration.go b/Sense/server/app/sense/device/migration.go index aab11fe..25939e7 100644 --- a/Sense/server/app/sense/device/migration.go +++ b/Sense/server/app/sense/device/migration.go @@ -1,3 +1,6 @@ package device const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,location TEXT NOT NULL DEFAULT '',modality TEXT NOT NULL,capabilities TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,adapter_status TEXT NOT NULL,credential_ciphertext BYTEA NULL,version BIGINT NOT NULL,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL);CREATE INDEX IF NOT EXISTS sense_devices_created_idx ON sense_devices(created_at DESC);` + +const SplitCredentialMigrationSQL = `ALTER TABLE sense_devices ADD COLUMN IF NOT EXISTS rtsp_credential_ciphertext BYTEA NULL;ALTER TABLE sense_devices ADD COLUMN IF NOT EXISTS rtsp_credential_same_as_onvif BOOLEAN NOT NULL DEFAULT TRUE;UPDATE sense_devices SET rtsp_credential_ciphertext=credential_ciphertext WHERE rtsp_credential_ciphertext IS NULL AND credential_ciphertext IS NOT NULL AND rtsp_credential_same_as_onvif=TRUE;` + -- 2.34.1 From 3297937e53b632500a04d1d9a9e249f0fc5a93e9 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:51 +0800 Subject: [PATCH 10/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/device/service.go | 33 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/Sense/server/app/sense/device/service.go b/Sense/server/app/sense/device/service.go index 0f7da54..0ea037c 100644 --- a/Sense/server/app/sense/device/service.go +++ b/Sense/server/app/sense/device/service.go @@ -34,7 +34,7 @@ func (s *Service) Create(ctx context.Context, actor identity.Principal, name, lo adapter = AdapterReady } now := s.now().UTC() - item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, Version: 1, CreatedAt: now, UpdatedAt: now} + item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, RTSPCredentialSameAsONVIF: true, Version: 1, CreatedAt: now, UpdatedAt: now} if err := s.store.Create(ctx, item); err != nil { return Device{}, err } @@ -45,7 +45,9 @@ func (s *Service) Get(ctx context.Context, id string) (Device, error) { item, err := s.store.Get(ctx, id) if err == nil { item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0 item.CredentialCiphertext = nil + item.RTSPCredentialCiphertext = nil } return item, err } @@ -53,6 +55,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) (Page, error) { page, err := s.store.List(ctx, filter) for i := range page.Items { page.Items[i].CredentialCiphertext = nil + page.Items[i].RTSPCredentialCiphertext = nil } return page, err } @@ -74,31 +77,50 @@ func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name } identity.RecordAudit(ctx, actor.UserID, "device.update", id, "success", map[string]any{"version": item.Version}) item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0 item.CredentialCiphertext = nil + item.RTSPCredentialCiphertext = nil return item, nil } func (s *Service) SetCredential(ctx context.Context, actor identity.Principal, id, username, password string) (Device, error) { - if strings.TrimSpace(username) == "" || password == "" { + return s.SetCredentials(ctx, actor, id, username, password, true, "", "") +} +func (s *Service) SetCredentials(ctx context.Context, actor identity.Principal, id, onvifUsername, onvifPassword string, rtspSame bool, rtspUsername, rtspPassword string) (Device, error) { + if strings.TrimSpace(onvifUsername) == "" || onvifPassword == "" { return Device{}, fmt.Errorf("用户名和密码不能为空") } + if !rtspSame && (strings.TrimSpace(rtspUsername) == "" || rtspPassword == "") { + return Device{}, fmt.Errorf("RTSP 用户名和密码不能为空") + } item, err := s.store.Get(ctx, id) if err != nil { return Device{}, err } - ciphertext, err := s.vault.Encrypt(username, password) + ciphertext, err := s.vault.Encrypt(onvifUsername, onvifPassword) if err != nil { return Device{}, err } expected := item.Version item.CredentialCiphertext = ciphertext + item.RTSPCredentialSameAsONVIF = rtspSame + if rtspSame { + item.RTSPCredentialCiphertext = append([]byte(nil), ciphertext...) + } else { + item.RTSPCredentialCiphertext, err = s.vault.Encrypt(rtspUsername, rtspPassword) + if err != nil { + return Device{}, err + } + } item.CredentialConfigured = true + item.RTSPCredentialConfigured = true item.Version++ item.UpdatedAt = s.now().UTC() if err := s.store.Update(ctx, item, expected); err != nil { return Device{}, err } - identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true}) + identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true, "rtsp_same_as_onvif": rtspSame}) item.CredentialCiphertext = nil + item.RTSPCredentialCiphertext = nil return item, nil } func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Device, error) { @@ -114,7 +136,9 @@ func (s *Service) Disable(ctx context.Context, actor identity.Principal, id stri } identity.RecordAudit(ctx, actor.UserID, "device.disable", id, "success", nil) item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0 item.CredentialCiphertext = nil + item.RTSPCredentialCiphertext = nil return item, nil } func newID() string { @@ -124,3 +148,4 @@ func newID() string { } return "dev_" + hex.EncodeToString(b) } + -- 2.34.1 From 18a4b8379cb7b5639093df234a0b816431175ab3 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:52 +0800 Subject: [PATCH 11/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/device/store.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sense/server/app/sense/device/store.go b/Sense/server/app/sense/device/store.go index a0a32f1..6710003 100644 --- a/Sense/server/app/sense/device/store.go +++ b/Sense/server/app/sense/device/store.go @@ -89,22 +89,23 @@ type PostgresStore struct{ database *sql.DB } func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} } func (s *PostgresStore) Create(ctx context.Context, item Device) error { - _, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.CreatedAt, item.UpdatedAt) + _, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,rtsp_credential_ciphertext,rtsp_credential_same_as_onvif,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.RTSPCredentialCiphertext, item.RTSPCredentialSameAsONVIF, item.Version, item.CreatedAt, item.UpdatedAt) return err } func (s *PostgresStore) Get(ctx context.Context, id string) (Device, error) { var item Device var capabilities string - err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.Version, &item.CreatedAt, &item.UpdatedAt) + err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,rtsp_credential_ciphertext,rtsp_credential_same_as_onvif,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.RTSPCredentialCiphertext, &item.RTSPCredentialSameAsONVIF, &item.Version, &item.CreatedAt, &item.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Device{}, ErrNotFound } item.Capabilities = splitCapabilities(capabilities) item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0 return item, err } func (s *PostgresStore) Update(ctx context.Context, item Device, expected int64) error { - result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,version=$9,updated_at=$10 WHERE id=$1 AND version=$11`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.UpdatedAt, expected) + result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,rtsp_credential_ciphertext=$9,rtsp_credential_same_as_onvif=$10,version=$11,updated_at=$12 WHERE id=$1 AND version=$13`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.RTSPCredentialCiphertext, item.RTSPCredentialSameAsONVIF, item.Version, item.UpdatedAt, expected) if err != nil { return err } @@ -121,7 +122,7 @@ func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, erro if err := s.database.QueryRowContext(ctx, `SELECT count(*) FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1`, keyword).Scan(&total); err != nil { return Page{}, err } - rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size) + rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),(rtsp_credential_ciphertext IS NOT NULL),rtsp_credential_same_as_onvif,version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size) if err != nil { return Page{}, err } @@ -130,7 +131,7 @@ func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, erro for rows.Next() { var item Device var caps string - if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil { + if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.RTSPCredentialConfigured, &item.RTSPCredentialSameAsONVIF, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil { return Page{}, err } item.Capabilities = splitCapabilities(caps) @@ -158,3 +159,4 @@ func splitCapabilities(value string) []string { } var _ = time.Time{} + -- 2.34.1 From 20066172c31ac099c4aefdfe659d795693864ac4 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:53 +0800 Subject: [PATCH 12/21] feat: complete split camera credentials (#50) --- Sense/server/app/sense/device/types.go | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/Sense/server/app/sense/device/types.go b/Sense/server/app/sense/device/types.go index 6dc773d..74b0fa4 100644 --- a/Sense/server/app/sense/device/types.go +++ b/Sense/server/app/sense/device/types.go @@ -13,18 +13,21 @@ const ( ) type Device struct { - ID string `json:"id"` - Name string `json:"name"` - Location string `json:"location"` - Modality string `json:"modality"` - Capabilities []string `json:"capabilities"` - Status string `json:"status"` - AdapterStatus string `json:"adapter_status"` - CredentialConfigured bool `json:"credential_configured"` - CredentialCiphertext []byte `json:"-"` - Version int64 `json:"version"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + Modality string `json:"modality"` + Capabilities []string `json:"capabilities"` + Status string `json:"status"` + AdapterStatus string `json:"adapter_status"` + CredentialConfigured bool `json:"credential_configured"` + CredentialCiphertext []byte `json:"-"` + RTSPCredentialConfigured bool `json:"rtsp_credential_configured"` + RTSPCredentialSameAsONVIF bool `json:"rtsp_credential_same_as_onvif"` + RTSPCredentialCiphertext []byte `json:"-"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListFilter struct { @@ -39,3 +42,4 @@ type Page struct { Page int `json:"page"` PageSize int `json:"page_size"` } + -- 2.34.1 From be1b479f680802b3e383708035cc34bce3ae67b2 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:54 +0800 Subject: [PATCH 13/21] feat: complete split camera credentials (#50) --- Sense/server/cmd/sense/modules_admission.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sense/server/cmd/sense/modules_admission.go b/Sense/server/cmd/sense/modules_admission.go index 30c2712..14d35bb 100644 --- a/Sense/server/cmd/sense/modules_admission.go +++ b/Sense/server/cmd/sense/modules_admission.go @@ -11,8 +11,16 @@ import ( func init() { registerModule(func(app *platform.App) error { - service := admission.NewService(onvif.NewHTTPClient(8*time.Second), rtsp.NetVerifier{Timeout: 5 * time.Second}, os.Getenv("SENSE_ONVIF_DISCOVERY_IP")) + var store admission.Store + if app.Config().DatabaseMode == platform.DatabaseModeMemory { + store = admission.NewMemoryStore() + } else { + store = admission.NewPostgresStore(app.Database()) + } + app.RegisterMigration(platform.Migration{Version: 2026081302, Name: "sense_admission_profiles", SQL: admission.MigrationSQL}) + service := admission.NewService(onvif.NewHTTPClient(8*time.Second), rtsp.NetVerifier{Timeout: 5 * time.Second}, os.Getenv("SENSE_ONVIF_DISCOVERY_IP"), store) admission.NewModule(service).Register(app) return nil }) } + -- 2.34.1 From f07952cb98884e7f7eff58716208b95b2970568e Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:54 +0800 Subject: [PATCH 14/21] feat: complete split camera credentials (#50) --- Sense/server/cmd/sense/modules_device.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sense/server/cmd/sense/modules_device.go b/Sense/server/cmd/sense/modules_device.go index b29169a..fdd2243 100644 --- a/Sense/server/cmd/sense/modules_device.go +++ b/Sense/server/cmd/sense/modules_device.go @@ -20,7 +20,9 @@ func init() { store = device.NewPostgresStore(app.Database()) } app.RegisterMigration(platform.Migration{Version: 2026081202, Name: "sense_device", SQL: device.MigrationSQL}) + app.RegisterMigration(platform.Migration{Version: 2026081301, Name: "sense_device_split_credentials", SQL: device.SplitCredentialMigrationSQL}) device.NewModule(device.NewService(store, vault)).Register(app) return nil }) } + -- 2.34.1 From ea9a89f8519b4aa27e79f6e422a10d8e8c9141e7 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:56 +0800 Subject: [PATCH 15/21] feat: complete split camera credentials (#50) --- Sense/ui/src/views/sense/admission/Admission.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sense/ui/src/views/sense/admission/Admission.vue b/Sense/ui/src/views/sense/admission/Admission.vue index 4d42e85..2257e28 100644 --- a/Sense/ui/src/views/sense/admission/Admission.vue +++ b/Sense/ui/src/views/sense/admission/Admission.vue @@ -7,7 +7,7 @@ - +
地址中不能包含用户名或密码;凭据来自设备管理中的安全配置。
检查设备与视频发现设备
@@ -38,15 +38,17 @@ import { onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { listDevices } from '../../../api/sense/device' -import { discoverDevices, probeDevice } from '../../../api/sense/admission' +import { admissionResult, discoverDevices, probeDevice } from '../../../api/sense/admission' const devices=ref([]),probing=ref(false),discovering=ref(false),discoveryDialog=ref(false),discovered=ref([]),formRef=ref() const form=reactive({device_id:'',address:''}),result=reactive({}) const rules={device_id:[{required:true,message:'请选择设备',trigger:'change'}],address:[{required:true,message:'请输入 ONVIF 服务地址',trigger:'blur'},{validator:(_r,v,done)=>v.includes('@')?done(new Error('地址中不能包含凭据')):done(),trigger:'blur'}]} function statusLabel(value){return({ready:'接入正常',profile_failed:'部分码流失败',authentication_failed:'认证失败',timeout:'响应超时',clock_skew:'需要校时',unreachable:'设备不可达'})[value]||value} async function loadDevices(){devices.value=(await listDevices({page:1,page_size:100})).items.filter(item=>item.modality==='video'&&item.status!=='disabled')} +async function loadSavedResult(){Object.keys(result).forEach(key=>delete result[key]);if(!form.device_id)return;try{Object.assign(result,await admissionResult(form.device_id))}catch{/* 尚未接入时保持空状态 */}} async function probe(){const valid=await formRef.value?.validate().catch(()=>false);if(!valid)return;probing.value=true;try{Object.assign(result,await probeDevice(form))}catch(error){ElMessage.error(error.message||'检查失败')}finally{probing.value=false}} async function discover(){discovering.value=true;try{discovered.value=(await discoverDevices()).items||[];discoveryDialog.value=true}catch(error){ElMessage.warning(error.message||'未配置获准发现网卡')}finally{discovering.value=false}} function useAddress(value){form.address=value;discoveryDialog.value=false} onMounted(loadDevices) + -- 2.34.1 From f6fda99aa2732c1883a388db556ef0820738e8ca Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:23:58 +0800 Subject: [PATCH 16/21] feat: complete split camera credentials (#50) --- Sense/ui/src/views/sense/device/Devices.vue | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Sense/ui/src/views/sense/device/Devices.vue b/Sense/ui/src/views/sense/device/Devices.vue index 9c27d89..43b4090 100644 --- a/Sense/ui/src/views/sense/device/Devices.vue +++ b/Sense/ui/src/views/sense/device/Devices.vue @@ -14,7 +14,7 @@ - + @@ -33,8 +33,13 @@ - - + + + RTSP 与 ONVIF 使用相同账号 + @@ -54,18 +59,18 @@ const editing = ref(null), credentialTarget = ref(null), deviceFormRef = ref(), const query = reactive({ keyword: '', page: 1, page_size: 20 }) const page = reactive({ items: [], total: 0 }) const deviceForm = reactive({ name: '', location: '', modality: 'video', capabilities: ['video'] }) -const credentialForm = reactive({ username: '', password: '' }) +const credentialForm = reactive({ onvif_username: '', onvif_password: '', rtsp_same_as_onvif: true, rtsp_username: '', rtsp_password: '' }) const deviceRules = { name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], modality: [{ required: true, message: '请选择类型', trigger: 'change' }] } -const credentialRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }] } +const credentialRules = { onvif_username: [{ required: true, message: '请输入 ONVIF 用户名', trigger: 'blur' }], onvif_password: [{ required: true, message: '请输入 ONVIF 密码', trigger: 'blur' }], rtsp_username: [{ validator: (_r, value, done) => !credentialForm.rtsp_same_as_onvif && !value ? done(new Error('请输入 RTSP 用户名')) : done(), trigger: 'blur' }], rtsp_password: [{ validator: (_r, value, done) => !credentialForm.rtsp_same_as_onvif && !value ? done(new Error('请输入 RTSP 密码')) : done(), trigger: 'blur' }] } const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('device.write')) function statusLabel(value) { return ({ pending: '待接入', active: '正常', offline: '离线', disabled: '已停用' })[value] || value } async function load() { loading.value = true; try { Object.assign(page, await listDevices(query)) } finally { loading.value = false } } function reset() { Object.assign(query, { keyword: '', page: 1, page_size: 20 }); load() } function openCreate() { editing.value = null; Object.assign(deviceForm, { name: '', location: '', modality: 'video', capabilities: ['video'] }); deviceDialog.value = true } function openEdit(row) { editing.value = row; Object.assign(deviceForm, { name: row.name, location: row.location, modality: row.modality, capabilities: row.capabilities }); deviceDialog.value = true } -function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { username: '', password: '' }); credentialDialog.value = true } +function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { onvif_username: '', onvif_password: '', rtsp_same_as_onvif: row.rtsp_credential_same_as_onvif !== false, rtsp_username: '', rtsp_password: '' }); credentialDialog.value = true } async function saveDevice() { const valid = await deviceFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { if (editing.value) await updateDevice(editing.value.id, { ...deviceForm, version: editing.value.version }); else await createDevice(deviceForm); ElMessage.success('设备已保存'); deviceDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false } } -async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { username: '', password: '' }); ElMessage.success('凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } } +async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { onvif_username: '', onvif_password: '', rtsp_same_as_onvif: true, rtsp_username: '', rtsp_password: '' }); ElMessage.success('ONVIF 与 RTSP 凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } } async function disable(row) { await ElMessageBox.confirm(`停用“${row.name}”后将停止后续接入,设备记录和审计仍保留。`, '确认停用', { type: 'warning' }); await disableDevice(row.id, row.version); ElMessage.success('设备已停用'); await load() } onMounted(load) @@ -73,3 +78,4 @@ onMounted(load) + -- 2.34.1 From 86b4dd7a075c364135962ef3217a26524db106cd Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:24:00 +0800 Subject: [PATCH 17/21] feat: complete split camera credentials (#50) --- docs/02-architecture-and-code-map.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 566294a..2e3df3d 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: fbb20f3bcffe5c632236108e7883ac9a0061d830 -synchronized_at: 2026-08-13T03:39:20Z +wiki_revision: e6fc9d8663d8e9fb5ebfacb3c33a029b2659ae85 +synchronized_at: 2026-08-13T04:16:00Z # 架构与代码地图 @@ -100,8 +100,8 @@ Sense/Brain 生成事件 Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server/cmd/sense/modules_.go` 独立注册: - `identity/`:Sense 独立账户、bcrypt 密码、会话、四角色 RBAC 与统一审计;签发者和受众只属于 Sense。 -- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;读取模型只返回 `credential_configured`。 -- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Media 服务发现、Basic/Digest 认证、Profile/StreamUri 读取和 RTSP 验证。摄像机广播跨主机 Media 地址时固定回用户已授权的 Device Service origin,仅保留服务路径和查询参数。 +- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;ONVIF 与 RTSP 凭据可分离或显式复用,读取模型只返回两组凭据是否已配置。 +- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Media 服务发现、Basic/Digest 认证、Profile/StreamUri 读取和 RTSP 验证。跨主机 Media 地址固定回用户已授权的 Device Service origin;跨主机 RTSP URI 只替换为授权主机并保留报告端口与路径。接入结果和脱敏 Profile 持久化到 PostgreSQL,重启后可恢复。 - `adapters/mediamtx/`、`media/`:外部 MediaMTX 进程所有权、localhost Control API、媒体期望态与实际态对账。 - `liveview/`:绑定当前用户、最长两分钟的单路播放会话;只投影媒体路径,不暴露源 URI 或摄像机秘密。 - `area/`:归一化多边形/方向警戒线、不可变版本、并发版本校验和分辨率变化后的重新校准。 -- 2.34.1 From e83b359cc516ef9575e769320b4b656fbfed7e37 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:24:02 +0800 Subject: [PATCH 18/21] feat: complete split camera credentials (#50) --- docs/03-business-rules-and-glossary.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 6367d26..0ca54df 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.- -wiki_revision: c9d47108e45f66875726e3eef79eedb2a5942514 -synchronized_at: 2026-08-13T01:15:01Z +wiki_revision: 351a92bd514dd5d3315201284205a5596ccd0084 +synchronized_at: 2026-08-13T04:16:00Z # 业务规则与术语 @@ -73,9 +73,9 @@ synchronized_at: 2026-08-13T01:15:01Z - **Sense 账户**:只登录 Sense;不得接受 Bell JWT、Cookie 或用户数据。角色为系统管理员、实施/运维、站点管理员和只读用户,后端权限是最终边界。 - **安全初始化**:系统不提供默认账户、默认密码或默认签名密钥;首次管理员由仓库外一次性令牌创建。所有模式的密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名。负责人已明确接受该生产密码策略的字典猜测与凭据填充风险。 - **Device**:设备不可变逻辑 ID 是后续 Profile、媒体和区域的内部引用。非视频适配器未实现时必须显示 `adapter_not_ready`。 -- **摄像机凭据**:只写不读,使用仓库外 32 字节密钥加密;不得进入 URL、日志、审计、工单或响应。 +- **摄像机凭据**:ONVIF 与 RTSP 可使用不同账号,也可显式复用;两组均只写不读,使用仓库外 32 字节密钥分别加密。密码不得进入 URL、日志、审计、工单或响应。 - **受控发现**:ONVIF Discovery 默认关闭,只有显式设置获准本机 IP 才能发送发现;不得扫描未授权网段。 -- **Profile**:主辅码流按分辨率分类并分别验证;认证失败、不可达、超时与校时问题使用可定位状态。 +- **Profile**:主辅码流按分辨率分类并使用 RTSP 凭据分别验证;脱敏 Stream URI、验证状态和时间持久化,重启后保留。至少一个 Profile 验证成功后 Device 进入 `active`;认证失败、不可达、超时与校时问题使用可定位状态。 - **MediaMTX**:保持外部进程。Sense 只停止自己启动并持有句柄的进程,最多自动重启三次;摄像机凭据只在 localhost 控制请求中瞬时组装,不持久化、不返回。 - **播放会话**:由当前 Sense 用户创建,最长两分钟;设备分页和媒体路径不以 16 路作为硬上限,页面一次只打开一路流。 - **区域版本**:坐标为 0..1 归一化值,并绑定 Device、Profile、宽高。每次发布或停用形成新版本;范围、点数、自交、退化、方向和期望版本由后端校验。Profile 分辨率变化后旧版本必须标记为需要重新校准。 @@ -96,3 +96,4 @@ synchronized_at: 2026-08-13T01:15:01Z - **close**:只有处置人或管理员可以完成;必须选择“确认有危险、误报、现场正常、无法确认”之一,可附备注。相同重复请求幂等,其他改变结果的请求被拒绝。 - **审计事实**:成功生命周期事实只追加;失败、重复和拒绝尝试写入安全审计,但不记录令牌、密码或连接密钥。 + -- 2.34.1 From 15340507ebe1a73986aeeb8d9bc679931702a9ff Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:24:03 +0800 Subject: [PATCH 19/21] feat: complete split camera credentials (#50) --- docs/06-troubleshooting.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 141bac4..9782184 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting -wiki_revision: 832eace3bb0083168cc9ff11f689180e2d13bdea -synchronized_at: 2026-08-13T03:39:34Z +wiki_revision: 0110fbc618f77660bf447c04def347fe9098510f +synchronized_at: 2026-08-13T04:16:00Z # 故障排查 @@ -50,7 +50,7 @@ synchronized_at: 2026-08-13T03:39:34Z | 管理员登录后侧栏没有模块 | 先确认 /api/v1/identity/me 返回角色与权限;若权限正常,检查前端是否从具有 children 的应用布局路由派生菜单,不得依赖重复 / 路由记录顺序。 | | `adapter_not_ready` | 当前设备类型尚无适配器,不代表网络故障;首期完整支持 video。 | | `discovery_unavailable` | 未设置获准的 `SENSE_ONVIF_DISCOVERY_IP`,或该 IP 不属于本机网卡。可改用手工 ONVIF 地址。 | -| `authentication_failed` | 在设备管理中重新写入凭据后再次执行接入检查;不要把凭据写进地址。Sense 支持 ONVIF Basic 与 Digest;若 Profile 能读取但视频验证失败,确认 ONVIF 与 RTSP 是否使用同一组账号。当前每台设备只保存一组凭据,不同账号需后续凭据模型支持。 | +| `authentication_failed` | 在设备管理中分别检查 ONVIF 与 RTSP 凭据,只有设备确实共用账号时才勾选“RTSP 与 ONVIF 使用相同账号”;不要把凭据写进地址。Sense 支持 ONVIF Basic 与 Digest,并会安全归一化摄像机广播的跨主机 Media/RTSP 地址。 | | `clock_skew` | 校准摄像机时间后重新探测。 | | `process_failed` | 检查仓库外 `SENSE_MEDIAMTX_BINARY`、基础配置和进程退出原因;达到三次重启上限后需人工处理。 | | `apply_failed` / `unconverged` | 检查 localhost Control API 是否启用并为 v3;确认媒体路径和外部进程状态。 | -- 2.34.1 From 64b9ea8462020eaac34ed8f58c0b7497e51639f1 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:24:11 +0800 Subject: [PATCH 20/21] docs: archive split camera credentials (#50) --- ...F与RTSP分离凭据并持久化Profile.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/task/50-ONVIF与RTSP分离凭据并持久化Profile.md diff --git a/docs/task/50-ONVIF与RTSP分离凭据并持久化Profile.md b/docs/task/50-ONVIF与RTSP分离凭据并持久化Profile.md new file mode 100644 index 0000000..69d12e9 --- /dev/null +++ b/docs/task/50-ONVIF与RTSP分离凭据并持久化Profile.md @@ -0,0 +1,72 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-50-ONVIF与RTSP分离凭据并持久化Profile +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-50-ONVIF%E4%B8%8ERTSP%E5%88%86%E7%A6%BB%E5%87%AD%E6%8D%AE%E5%B9%B6%E6%8C%81%E4%B9%85%E5%8C%96Profile.- +wiki_revision: 27ac0fe8ef97f9ec835fa62713f3a8c23c077940 +synchronized_at: 2026-08-13T04:18:00Z + + +# 50 ONVIF与RTSP分离凭据并持久化Profile + +- 类型:功能 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 +- 状态:待验收 +- 日期:2026-08-13 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/50 +- Wiki 页面:Task-50-ONVIF与RTSP分离凭据并持久化Profile +- Wiki revision:见本地镜像头 + +## 背景与目标 + +真实摄像机的 ONVIF 与 RTSP 使用不同账号,旧版 Sense 每台设备只有一组凭据;接入结果还只保存在内存中。目标是分别安全保存两组凭据、持久化脱敏 Profile,并让接入成功的设备进入 active。 + +## 最终方案 + +- Device 新增独立 RTSP 密文与“复用 ONVIF”标志,旧凭据通过版本化迁移安全回填;API 只返回配置状态。 +- 设备页面同时维护 ONVIF/RTSP 凭据,默认允许显式复用;审计只记录是否复用,不记录值。 +- Admission 使用 ONVIF 凭据读取 Profile、RTSP 凭据验证视频;结果与脱敏 Stream URI、主/子码流、验证状态写入 PostgreSQL。 +- 摄像机广播跨主机 RTSP URI 时,只把主机归一化到用户授权的 Device Service 主机,保留报告端口与路径。 +- 至少一个 Profile 验证成功后 Device 进入 active;接入页切换设备时读取持久化结果。 +- 真实设备使用分离凭据后 2 个 Profile 均 ready,重启 Sense 后仍可读取。 + +## 修改文件 + +- `Sense/server/app/sense/device/**`:分离凭据模型、迁移、加密读写与内部端口。 +- `Sense/server/app/sense/admission/**`:Profile Store、迁移、持久化和设备状态更新。 +- `Sense/server/app/sense/adapters/onvif/**`:安全归一化跨主机 RTSP URI。 +- `Sense/server/cmd/sense/modules_device.go`、`modules_admission.go`:注册版本化迁移与 PostgreSQL Store。 +- `Sense/ui/src/views/sense/device/Devices.vue`、`admission/Admission.vue`:分离凭据表单与持久结果恢复。 +- Wiki 架构、业务规则和排错页面:记录新安全边界。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 两组凭据分别加密且只写不可读 | 通过 | +| 同凭据复用与分离凭据均受支持 | 通过 | +| Profile 重启后存在 | 通过,2 个 Profile | +| 至少一个 Profile ready 后设备 active | 通过 | +| 真实摄像机 ONVIF 与 RTSP 验证 | 通过,2/2 ready | +| 不泄露地址、密码、Authorization 或 Stream URI | 通过 | + +## 测试 + +- `go test ./...`:通过。 +- `pnpm lint`:0 error,存在基线格式 warning。 +- `pnpm build`:通过,存在既有 webpack 体积 warning。 +- Go 1.26.5 Windows 打包:通过。 +- 真实设备脱敏验证:`admission_status=ready`、`profile_count=2`、`ready_profile_count=2`、`device_status=active`。 +- 重启验证:`persisted_status=ready`、`persisted_profile_count=2`。 +- **未验证部分**:MediaMTX 自动路由与实时监看属于后续工单 #51。 + +## 遗留问题 + +- 无本工单范围内遗留;自动媒体路由在 #51 实施。 +- 严格 Harness 仍受既有 #44 归档缺少“最终方案”章节影响。 + +## 相关提交 + +- `1a63264` 分离凭据、持久化 Profile 和真实地址归一化。 +- `032f8c5` 更新长期 Wiki 镜像。 + -- 2.34.1 From dde84c8687de505445376f537dd60f10a78a6698 Mon Sep 17 00:00:00 2001 From: ila <2+ila@noreply.git.ilapage.cn> Date: Thu, 13 Aug 2026 12:24:11 +0800 Subject: [PATCH 21/21] docs: archive split camera credentials (#50) --- wiki-docs.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wiki-docs.json b/wiki-docs.json index 62b2a93..b7c434f 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -135,6 +135,10 @@ { "page": "Task-48-支持ONVIF-Digest认证与安全Media地址归一化", "path": "docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md" + }, + { + "page": "Task-50-ONVIF与RTSP分离凭据并持久化Profile", + "path": "docs/task/50-ONVIF与RTSP分离凭据并持久化Profile.md" } ] } -- 2.34.1