safesight-control/internal/storage/assets_repo.go
tian 286dcaa945 feat: persist device features to DB as source of truth
- device_features 表:持久化设备检测功能选择
- pageConsole 从 DB 读取功能选择(非从模板反推)
- actionConsoleSave 先存 DB 再从 DB 读取运行流水线
- 模板选择改为最精确匹配(单功能只用该功能模板)
- 修复内存指标解析(补充 memory 字段)
2026-05-12 13:40:37 +08:00

801 lines
25 KiB
Go

package storage
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
type AssetRecord struct {
Name string
Description string
TemplateName string
BusinessName string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type IntegrationServiceRecord struct {
Name string
ServiceType string
Description string
Enabled bool
BodyJSON string
CreatedAt string
UpdatedAt string
}
type VideoSourceRecord struct {
Name string
SourceType string
Area string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type DeviceAssignmentRecord struct {
DeviceID string
ProfileName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type RecognitionUnitRecord struct {
SceneTemplateName string
Name string
DisplayName string
SiteName string
VideoSourceRef string
OutputChannel string
RTSPPort string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type AssetsRepo struct {
db *sql.DB
}
func NewAssetsRepo(db *sql.DB) *AssetsRepo {
return &AssetsRepo{db: db}
}
func (r *AssetsRepo) SaveTemplate(name string, description string, bodyJSON string) error {
return r.saveAsset("templates", AssetRecord{
Name: name,
Description: description,
BodyJSON: bodyJSON,
})
}
func (r *AssetsRepo) SaveProfile(name string, templateName string, businessName string, description string, bodyJSON string) error {
return r.saveAsset("profiles", AssetRecord{
Name: name,
TemplateName: templateName,
BusinessName: businessName,
Description: description,
BodyJSON: bodyJSON,
})
}
func (r *AssetsRepo) SaveOverlay(name string, description string, bodyJSON string) error {
return r.saveAsset("overlays", AssetRecord{
Name: name,
Description: description,
BodyJSON: bodyJSON,
})
}
func (r *AssetsRepo) SaveIntegrationService(name string, serviceType string, description string, enabled bool, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO integration_services(name, type, description, enabled, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM integration_services WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
type=excluded.type,
description=excluded.description,
enabled=excluded.enabled,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, name, serviceType, description, enabled, bodyJSON, name, now, now)
return err
}
func (r *AssetsRepo) SaveVideoSource(name string, sourceType string, area string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM video_sources WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
source_type=excluded.source_type,
area=excluded.area,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, name, sourceType, area, description, bodyJSON, name, now, now)
return err
}
func (r *AssetsRepo) SaveDeviceAssignment(deviceID string, profileName string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO device_assignments(device_id, profile_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM device_assignments WHERE device_id = ?), ?), ?)
ON CONFLICT(device_id) DO UPDATE SET
profile_name=excluded.profile_name,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, deviceID, profileName, description, bodyJSON, deviceID, now, now)
return err
}
func (r *AssetsRepo) SaveRecognitionUnit(record RecognitionUnitRecord) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
ON CONFLICT(scene_template_name, name) DO UPDATE SET
display_name=excluded.display_name,
site_name=excluded.site_name,
video_source_ref=excluded.video_source_ref,
output_channel=excluded.output_channel,
rtsp_port=excluded.rtsp_port,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, record.SceneTemplateName, record.Name, record.DisplayName, record.SiteName, record.VideoSourceRef, record.OutputChannel, record.RTSPPort, record.Description, record.BodyJSON, record.SceneTemplateName, record.Name, now, now)
return err
}
func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) {
return r.listAssets("templates")
}
func (r *AssetsRepo) ListProfiles() ([]AssetRecord, error) {
return r.listAssets("profiles")
}
func (r *AssetsRepo) ListOverlays() ([]AssetRecord, error) {
return r.listAssets("overlays")
}
func (r *AssetsRepo) ListIntegrationServices() ([]IntegrationServiceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT name, type, description, enabled, body_json, created_at, updated_at
FROM integration_services
ORDER BY updated_at DESC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []IntegrationServiceRecord
for rows.Next() {
var item IntegrationServiceRecord
if err := rows.Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT name, source_type, area, description, body_json, created_at, updated_at
FROM video_sources
ORDER BY updated_at DESC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []VideoSourceRecord
for rows.Next() {
var item VideoSourceRecord
if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) ListDeviceAssignments() ([]DeviceAssignmentRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT device_id, profile_name, description, body_json, created_at, updated_at
FROM device_assignments
ORDER BY updated_at DESC, device_id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DeviceAssignmentRecord
for rows.Next() {
var item DeviceAssignmentRecord
if err := rows.Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) ListRecognitionUnits() ([]RecognitionUnitRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
FROM recognition_units
ORDER BY scene_template_name ASC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RecognitionUnitRecord
for rows.Next() {
var item RecognitionUnitRecord
if err := rows.Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) {
return r.getAsset("templates", name)
}
func (r *AssetsRepo) GetProfile(name string) (*AssetRecord, error) {
return r.getAsset("profiles", name)
}
func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) {
return r.getAsset("overlays", name)
}
func (r *AssetsRepo) GetIntegrationService(name string) (*IntegrationServiceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item IntegrationServiceRecord
err := r.db.QueryRow(`
SELECT name, type, description, enabled, body_json, created_at, updated_at
FROM integration_services
WHERE name = ?
`, name).Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item VideoSourceRecord
err := r.db.QueryRow(`
SELECT name, source_type, area, description, body_json, created_at, updated_at
FROM video_sources
WHERE name = ?
`, name).Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) GetDeviceAssignment(deviceID string) (*DeviceAssignmentRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item DeviceAssignmentRecord
err := r.db.QueryRow(`
SELECT device_id, profile_name, description, body_json, created_at, updated_at
FROM device_assignments
WHERE device_id = ?
`, deviceID).Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) GetRecognitionUnit(sceneTemplateName string, name string) (*RecognitionUnitRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item RecognitionUnitRecord
err := r.db.QueryRow(`
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
FROM recognition_units
WHERE scene_template_name = ? AND name = ?
`, sceneTemplateName, name).Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) DeleteTemplate(name string) error {
return r.deleteAsset("templates", name)
}
func (r *AssetsRepo) DeleteProfile(name string) error {
return r.deleteAsset("profiles", name)
}
func (r *AssetsRepo) DeleteIntegrationService(name string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM integration_services WHERE name = ?`, name)
return err
}
func (r *AssetsRepo) DeleteVideoSource(name string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name)
return err
}
func (r *AssetsRepo) DeleteDeviceAssignment(deviceID string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM device_assignments WHERE device_id = ?`, deviceID)
return err
}
func (r *AssetsRepo) DeleteRecognitionUnit(sceneTemplateName string, name string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ? AND name = ?`, sceneTemplateName, name)
return err
}
func (r *AssetsRepo) DeleteOverlay(name string) error {
return r.deleteAsset("overlays", name)
}
func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return fmt.Errorf("template name is required")
}
if oldName == newName {
return r.SaveTemplate(newName, description, bodyJSON)
}
tx, err := r.db.Begin()
if err != nil {
return err
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
var exists int
if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, oldName).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return sql.ErrNoRows
}
if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, newName).Scan(&exists); err != nil {
return err
}
if exists > 0 {
return fmt.Errorf("template %q already exists", newName)
}
now := time.Now().Format(time.RFC3339)
if _, err := tx.Exec(`
INSERT INTO templates(name, description, body_json, created_at, updated_at)
SELECT ?, ?, ?, created_at, ?
FROM templates
WHERE name = ?
`, newName, description, bodyJSON, now, oldName); err != nil {
return err
}
rows, err := tx.Query(`
SELECT name, description, business_name, body_json
FROM scene_templates
WHERE primary_template_name = ? OR body_json LIKE ?
`, oldName, "%\"primary_template_name\": \""+oldName+"\"%")
if err != nil {
return err
}
defer rows.Close()
type profileUpdate struct {
name string
description string
businessName string
bodyJSON string
}
updates := make([]profileUpdate, 0)
for rows.Next() {
var item profileUpdate
if err := rows.Scan(&item.name, &item.description, &item.businessName, &item.bodyJSON); err != nil {
return err
}
rewritten, changed, err := rewriteProfileTemplateRefs(item.bodyJSON, oldName, newName)
if err != nil {
return err
}
if changed {
item.bodyJSON = rewritten
}
updates = append(updates, item)
}
if err := rows.Err(); err != nil {
return err
}
for _, item := range updates {
if _, err := tx.Exec(`
UPDATE scene_templates
SET primary_template_name = ?, body_json = ?, updated_at = ?
WHERE name = ?
`, newName, item.bodyJSON, now, item.name); err != nil {
return err
}
}
if _, err := tx.Exec(`
UPDATE recognition_units
SET body_json = REPLACE(body_json, ?, ?), updated_at = ?
WHERE body_json LIKE ?
`, `"`+"template"+`": "`+oldName+`"`, `"`+"template"+`": "`+newName+`"`, now, "%\"template\": \""+oldName+"\"%"); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
tx = nil
return nil
}
func (r *AssetsRepo) saveAsset(table string, record AssetRecord) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
switch table {
case "templates", "overlays":
_, err := r.db.Exec(`
INSERT INTO `+table+`(name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, COALESCE((SELECT created_at FROM `+table+` WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, record.Name, record.Description, record.BodyJSON, record.Name, now, now)
return err
case "profiles":
normalized, units, replaceUnits, err := normalizeProfileRecord(record)
if err != nil {
return err
}
tx, err := r.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(`
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM scene_templates WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
primary_template_name=excluded.primary_template_name,
business_name=excluded.business_name,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, normalized.Name, normalized.TemplateName, normalized.BusinessName, normalized.Description, normalized.BodyJSON, normalized.Name, now, now)
if err != nil {
return err
}
if replaceUnits {
if _, err := tx.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil {
return err
}
for _, unit := range units {
if _, err := tx.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
ON CONFLICT(scene_template_name, name) DO UPDATE SET
display_name=excluded.display_name,
site_name=excluded.site_name,
video_source_ref=excluded.video_source_ref,
output_channel=excluded.output_channel,
rtsp_port=excluded.rtsp_port,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, unit.SceneTemplateName, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, unit.Description, unit.BodyJSON, unit.SceneTemplateName, unit.Name, now, now); err != nil {
return err
}
}
}
return tx.Commit()
default:
return nil
}
}
func normalizeProfileRecord(record AssetRecord) (AssetRecord, []RecognitionUnitRecord, bool, error) {
raw := map[string]any{}
if strings.TrimSpace(record.BodyJSON) != "" {
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
return AssetRecord{}, nil, false, err
}
}
if raw == nil {
raw = map[string]any{}
}
_, hasInstances := raw["instances"]
if !hasInstances {
return record, nil, false, nil
}
templateDoc, units, err := splitLegacyProfileDocument(struct {
Name string
TemplateName string
BusinessName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}{
Name: record.Name,
TemplateName: record.TemplateName,
BusinessName: record.BusinessName,
Description: record.Description,
BodyJSON: record.BodyJSON,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
})
if err != nil {
return AssetRecord{}, nil, false, err
}
body, err := json.MarshalIndent(templateDoc, "", " ")
if err != nil {
return AssetRecord{}, nil, false, err
}
normalized := record
normalized.BodyJSON = string(append(body, '\n'))
outUnits := make([]RecognitionUnitRecord, 0, len(units))
for _, unit := range units {
outUnits = append(outUnits, RecognitionUnitRecord{
SceneTemplateName: record.Name,
Name: unit.Name,
DisplayName: unit.DisplayName,
SiteName: unit.SiteName,
VideoSourceRef: unit.VideoSourceRef,
OutputChannel: unit.OutputChannel,
RTSPPort: unit.RTSPPort,
Description: record.Description,
BodyJSON: unit.BodyJSON,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
})
}
return normalized, outUnits, true, nil
}
func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
query := `
SELECT name, description, body_json, created_at, updated_at, '', ''
FROM ` + table + `
ORDER BY updated_at DESC, name ASC
`
if table == "profiles" {
query = `
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM scene_templates
ORDER BY updated_at DESC, name ASC
`
}
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AssetRecord
for rows.Next() {
var item AssetRecord
if err := rows.Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) getAsset(table string, name string) (*AssetRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
query := `
SELECT name, description, body_json, created_at, updated_at, '', ''
FROM ` + table + `
WHERE name = ?
`
if table == "profiles" {
query = `
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM scene_templates
WHERE name = ?
`
}
var item AssetRecord
err := r.db.QueryRow(query, name).Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) deleteAsset(table string, name string) error {
if r == nil || r.db == nil {
return nil
}
if table == "profiles" {
table = "scene_templates"
}
_, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name)
return err
}
func rewriteProfileTemplateRefs(bodyJSON string, oldName string, newName string) (string, bool, error) {
var raw map[string]any
if err := json.Unmarshal([]byte(bodyJSON), &raw); err != nil {
return "", false, err
}
changed := false
if strings.TrimSpace(valueString(raw["primary_template_name"])) == oldName {
raw["primary_template_name"] = newName
changed = true
}
instances, _ := raw["instances"].([]any)
for _, item := range instances {
instanceMap, _ := item.(map[string]any)
if strings.TrimSpace(valueString(instanceMap["template"])) == oldName {
instanceMap["template"] = newName
changed = true
}
}
if !changed {
return bodyJSON, false, nil
}
body, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return "", false, err
}
return string(append(body, '\n')), true, nil
}
func valueString(v any) string {
switch vv := v.(type) {
case string:
return vv
case float64:
return fmt.Sprintf("%v", vv)
default:
return ""
}
}
// ---- device_features ----
// DeviceFeatureRecord holds the persisted feature selection for a device.
type DeviceFeatureRecord struct {
DeviceID string `json:"device_id"`
FeaturesJSON string `json:"features_json"` // JSON array of feature keys, e.g. ["face","shoe"]
UpdatedAt string `json:"updated_at"`
}
// SaveDeviceFeatures upserts the feature selection for a device.
func (r *AssetsRepo) SaveDeviceFeatures(deviceID string, featuresJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO device_features(device_id, features_json, updated_at)
VALUES(?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
features_json=excluded.features_json,
updated_at=excluded.updated_at
`, deviceID, featuresJSON, now)
return err
}
// GetDeviceFeatures returns the persisted features for a device, or nil.
func (r *AssetsRepo) GetDeviceFeatures(deviceID string) (*DeviceFeatureRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var rec DeviceFeatureRecord
err := r.db.QueryRow(
"SELECT device_id, features_json, updated_at FROM device_features WHERE device_id = ?",
deviceID,
).Scan(&rec.DeviceID, &rec.FeaturesJSON, &rec.UpdatedAt)
if err != nil {
return nil, nil // not found is not an error
}
return &rec, nil
}