[SEN] 分离ONVIF/RTSP凭据并持久化Profile (#50) #52

Open
ila wants to merge 21 commits from agent/codex/50-sense-split-camera-credentials into agent/codex/48-sense-onvif-digest
21 changed files with 461 additions and 65 deletions
@@ -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, "")
}
@@ -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"`,
@@ -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)
);
`
+29 -19
View File
@@ -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", "无法读取设备信息,请检查地址和网络"
}
}
@@ -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)
}
}
+86
View File
@@ -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()
}
@@ -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)
}
+19 -3
View File
@@ -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()})
}
@@ -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;`
+29 -4
View File
@@ -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)
}
+7 -5
View File
@@ -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{}
+16 -12
View File
@@ -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"`
}
+9 -1
View File
@@ -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
View File
@@ -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
})
}
@@ -7,7 +7,7 @@
<template #header><strong>接入检查</strong></template>
<el-alert title="发现功能只在实施人员配置获准网卡后启用,不会扫描其他网络。" type="info" show-icon :closable="false" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="admission-form">
<el-form-item label="设备" prop="device_id"><el-select v-model="form.device_id" filterable placeholder="选择已登记的视频设备" style="width: 100%"><el-option v-for="item in devices" :key="item.id" :label="`${item.name} · ${item.location || '未填写位置'}`" :value="item.id" /></el-select></el-form-item>
<el-form-item label="设备" prop="device_id"><el-select v-model="form.device_id" filterable placeholder="选择已登记的视频设备" style="width: 100%" @change="loadSavedResult"><el-option v-for="item in devices" :key="item.id" :label="`${item.name} · ${item.location || '未填写位置'}`" :value="item.id" /></el-select></el-form-item>
<el-form-item label="ONVIF 服务地址" prop="address"><el-input v-model="form.address" placeholder="例如:http://设备地址/onvif/device_service" /><div class="field-hint">地址中不能包含用户名或密码;凭据来自设备管理中的安全配置。</div></el-form-item>
<el-form-item><el-button type="primary" :loading="probing" @click="probe">检查设备与视频</el-button><el-button :loading="discovering" @click="discover">发现设备</el-button></el-form-item>
</el-form>
@@ -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)
</script>
<style scoped>.admission-form{margin-top:18px}.field-hint{color:#86909c;font-size:12px;line-height:1.5}.result-header{display:flex;align-items:center;justify-content:space-between}</style>
+13 -7
View File
@@ -14,7 +14,7 @@
<el-table-column prop="location" label="安装位置" min-width="150" />
<el-table-column label="类型" width="110"><template #default="scope">{{ scope.row.modality === 'video' ? '视频设备' : scope.row.modality }}</template></el-table-column>
<el-table-column label="接入能力" width="140"><template #default="scope"><el-tag :type="scope.row.adapter_status === 'ready' ? 'success' : 'warning'">{{ scope.row.adapter_status === 'ready' ? '可接入' : '适配器未就绪' }}</el-tag></template></el-table-column>
<el-table-column label="凭据" width="110"><template #default="scope"><el-tag :type="scope.row.credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured ? '已配置' : '未配置' }}</el-tag></template></el-table-column>
<el-table-column label="设备凭据" width="150"><template #default="scope"><el-tag :type="scope.row.credential_configured && scope.row.rtsp_credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured && scope.row.rtsp_credential_configured ? '已配置' : '未完整配置' }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.status === 'disabled' ? 'info' : 'primary'">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column v-if="canWrite" label="操作" width="210" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button link type="primary" @click="openCredential(scope.row)">更新凭据</el-button><el-button v-if="scope.row.status !== 'disabled'" link type="danger" @click="disable(scope.row)">停用</el-button></template></el-table-column>
</el-table>
@@ -33,8 +33,13 @@
<el-dialog v-model="credentialDialog" title="更新设备凭据" width="520px" destroy-on-close>
<el-alert title="凭据保存后不能查看,只能再次更新。请勿把密码写入设备地址。" type="warning" show-icon :closable="false" />
<el-form ref="credentialFormRef" :model="credentialForm" :rules="credentialRules" label-width="82px" class="credential-form">
<el-form-item label="用户名" prop="username"><el-input v-model="credentialForm.username" autocomplete="off" /></el-form-item>
<el-form-item label="密码" prop="password"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" /></el-form-item>
<el-form-item label="ONVIF 用户名" prop="onvif_username"><el-input v-model="credentialForm.onvif_username" autocomplete="off" /></el-form-item>
<el-form-item label="ONVIF 密码" prop="onvif_password"><el-input v-model="credentialForm.onvif_password" type="password" show-password autocomplete="new-password" /></el-form-item>
<el-form-item label-width="0"><el-checkbox v-model="credentialForm.rtsp_same_as_onvif">RTSP 与 ONVIF 使用相同账号</el-checkbox></el-form-item>
<template v-if="!credentialForm.rtsp_same_as_onvif">
<el-form-item label="RTSP 用户名" prop="rtsp_username"><el-input v-model="credentialForm.rtsp_username" autocomplete="off" /></el-form-item>
<el-form-item label="RTSP 密码" prop="rtsp_password"><el-input v-model="credentialForm.rtsp_password" type="password" show-password autocomplete="new-password" /></el-form-item>
</template>
</el-form>
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">安全保存</el-button></template>
</el-dialog>
@@ -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)
</script>
@@ -73,3 +78,4 @@ onMounted(load)
<style scoped>
.credential-form { margin-top: 20px; }
</style>
+4 -4
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -100,8 +100,8 @@ Sense/Brain 生成事件
Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server/cmd/sense/modules_<feature>.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/`:归一化多边形/方向警戒线、不可变版本、并发版本校验和分辨率变化后的重新校准。
+5 -4
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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**:只有处置人或管理员可以完成;必须选择“确认有危险、误报、现场正常、无法确认”之一,可附备注。相同重复请求幂等,其他改变结果的请求被拒绝。
- **审计事实**:成功生命周期事实只追加;失败、重复和拒绝尝试写入安全审计,但不记录令牌、密码或连接密钥。
<!-- bell-mvp:end -->
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -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;确认媒体路径和外部进程状态。 |
@@ -0,0 +1,72 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 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 镜像。
+4
View File
@@ -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"
}
]
}