feat(v2): T-309 SQLite fall-event persistence (mattn/go-sqlite3)

Persist confirmed fall events to a local SQLite DB under artifacts/:
camera host/port/channel + event id/track/source/config version/state/
latency/screenshot path/confirmed-at. Schema stores no password (guarded
by test). Wire openRecorder/saveRecord into the alerts loop; failures are
non-fatal. Re-applied the SILVER-POSE vendored os_windows.go patch that
go mod vendor reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-23 22:15:55 +08:00
co-authored by Claude Opus 4.8
parent 4b7dfc2db6
commit 6c779aafb4
62 changed files with 292138 additions and 32 deletions
+27 -32
View File
@@ -34,6 +34,9 @@ type SourceConfig struct {
ID string
URL string
RTSPURLEnv string
Host string
Port int
Channel string
Transport string
Timeout time.Duration
LowLatency bool
@@ -202,11 +205,31 @@ func resolveSource(raw rawSource) (SourceConfig, error) {
}
if raw.Host != nil {
streamURL, err := structuredSourceURL(raw)
if err != nil {
return SourceConfig{}, err
host := strings.TrimSpace(*raw.Host)
if host == "" {
return SourceConfig{}, fmt.Errorf("source.host must be non-empty")
}
resolved.URL = streamURL
if raw.Username == nil || strings.TrimSpace(*raw.Username) == "" {
return SourceConfig{}, fmt.Errorf("source.username must be non-empty")
}
if raw.Password == nil || *raw.Password == "" {
return SourceConfig{}, fmt.Errorf("source.password must be non-empty")
}
port := 554
if raw.Port != nil {
port = *raw.Port
}
if port < 1 || port > 65535 {
return SourceConfig{}, fmt.Errorf("source.port must be between 1 and 65535")
}
channel := "101"
if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" {
channel = strings.TrimSpace(*raw.Channel)
}
resolved.Host = host
resolved.Port = port
resolved.Channel = channel
resolved.URL = BuildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel)
return resolved, nil
}
@@ -223,34 +246,6 @@ func resolveSource(raw rawSource) (SourceConfig, error) {
return resolved, nil
}
// structuredSourceURL builds an RTSP URL from local host/credential fields.
// net/url percent-encodes the userinfo, so a password with @/:/ does not break
// parsing. These fields are only permitted in the untracked local config.
func structuredSourceURL(raw rawSource) (string, error) {
host := strings.TrimSpace(*raw.Host)
if host == "" {
return "", fmt.Errorf("source.host must be non-empty")
}
if raw.Username == nil || strings.TrimSpace(*raw.Username) == "" {
return "", fmt.Errorf("source.username must be non-empty")
}
if raw.Password == nil || *raw.Password == "" {
return "", fmt.Errorf("source.password must be non-empty")
}
port := 554
if raw.Port != nil {
port = *raw.Port
}
if port < 1 || port > 65535 {
return "", fmt.Errorf("source.port must be between 1 and 65535")
}
channel := "101"
if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" {
channel = strings.TrimSpace(*raw.Channel)
}
return BuildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel), nil
}
// BuildRTSPURL assembles a Hikvision RTSP URL with percent-encoded credentials.
func BuildRTSPURL(host string, port int, username, password, channel string) string {
address := url.URL{
+93
View File
@@ -0,0 +1,93 @@
// Package store persists confirmed fall events to a local SQLite database.
// The database is local, untracked evidence: it records the camera host, port
// and channel but never the password.
package store
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
// Record is one confirmed fall event row.
type Record struct {
EventID string
TrackID string
SourceID string
CameraHost string
CameraPort int
CameraChannel string
ConfigVersion string
State string
LatencySeconds float64
ScreenshotPath string
ConfirmedAtUTC string
}
type Store struct {
db *sql.DB
}
const schema = `
CREATE TABLE IF NOT EXISTS fall_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL,
track_id TEXT,
source_id TEXT,
camera_host TEXT,
camera_port INTEGER,
camera_channel TEXT,
config_version TEXT,
state TEXT,
latency_seconds REAL,
screenshot_path TEXT,
confirmed_at_utc TEXT
);`
// Open opens (creating if needed) the SQLite database and ensures the schema.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, fmt.Errorf("open fall database: %w", err)
}
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("create fall table: %w", err)
}
return &Store{db: db}, nil
}
// Save inserts one confirmed fall event.
func (store *Store) Save(record Record) error {
_, err := store.db.Exec(
`INSERT INTO fall_events
(event_id, track_id, source_id, camera_host, camera_port, camera_channel,
config_version, state, latency_seconds, screenshot_path, confirmed_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.EventID, record.TrackID, record.SourceID,
record.CameraHost, record.CameraPort, record.CameraChannel,
record.ConfigVersion, record.State, record.LatencySeconds,
record.ScreenshotPath, record.ConfirmedAtUTC,
)
if err != nil {
return fmt.Errorf("insert fall event: %w", err)
}
return nil
}
// Count returns the number of stored events (used by tests).
func (store *Store) Count() (int, error) {
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM fall_events`).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
func (store *Store) Close() error {
if store == nil || store.db == nil {
return nil
}
return store.db.Close()
}
+41
View File
@@ -0,0 +1,41 @@
package store
import (
"path/filepath"
"strings"
"testing"
)
func TestSaveAndCount(t *testing.T) {
database, err := Open(filepath.Join(t.TempDir(), "fall.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
record := Record{
EventID: "FALL-1", TrackID: "P-1", SourceID: "cam",
CameraHost: "192.0.2.10", CameraPort: 554, CameraChannel: "102",
ConfigVersion: "cfg-x", State: "CONFIRMED", LatencySeconds: 1.8,
ScreenshotPath: "20260723/FALL-1.png", ConfirmedAtUTC: "2026-07-23T12:00:00Z",
}
if err := database.Save(record); err != nil {
t.Fatal(err)
}
if err := database.Save(record); err != nil {
t.Fatal(err)
}
count, err := database.Count()
if err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
}
func TestSchemaHasNoPasswordColumn(t *testing.T) {
if strings.Contains(strings.ToLower(schema), "password") {
t.Fatal("fall_events schema must not store a password")
}
}