feat: add Sense device inventory and credential boundary (#23)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type CredentialVault struct{ key []byte }
|
||||
|
||||
func NewCredentialVault(encodedKey string, allowRandom bool) (*CredentialVault, error) {
|
||||
var key []byte
|
||||
if encodedKey != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must be base64 encoded")
|
||||
}
|
||||
key = decoded
|
||||
} else if allowRandom {
|
||||
key = make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must decode to 32 bytes")
|
||||
}
|
||||
return &CredentialVault{key: key}, nil
|
||||
}
|
||||
|
||||
func (v *CredentialVault) Encrypt(username, password string) ([]byte, error) {
|
||||
plaintext, err := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, []byte("sense-device-credential-v1")), nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type Module struct{ service *Service }
|
||||
|
||||
func NewModule(service *Service) *Module { return &Module{service: service} }
|
||||
func (m *Module) Register(app *platform.App) {
|
||||
app.Handle("GET /api/v1/devices", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.list)))
|
||||
app.Handle("POST /api/v1/devices", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.create)))
|
||||
app.Handle("GET /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get)))
|
||||
app.Handle("PATCH /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.update)))
|
||||
app.Handle("POST /api/v1/devices/{id}/credentials", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.credential)))
|
||||
app.Handle("POST /api/v1/devices/{id}/disable", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.disable)))
|
||||
}
|
||||
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
size, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
|
||||
result, err := m.service.List(r.Context(), ListFilter{Keyword: r.URL.Query().Get("keyword"), Page: page, PageSize: size})
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := m.service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Create(r.Context(), actor, req.Name, req.Location, req.Modality, req.Capabilities)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
func (m *Module) update(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Location, req.Capabilities, req.Version)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) credential(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"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 err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) disable(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func writeDeviceError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusBadRequest
|
||||
code := "device_invalid"
|
||||
if err == ErrNotFound {
|
||||
status = http.StatusNotFound
|
||||
code = "device_not_found"
|
||||
}
|
||||
if err == ErrConflict {
|
||||
status = http.StatusConflict
|
||||
code = "version_conflict"
|
||||
}
|
||||
platform.WriteError(w, &platform.APIError{Status: status, Code: code, Message: err.Error()})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
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);`
|
||||
@@ -0,0 +1,126 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
vault *CredentialVault
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store, vault *CredentialVault) *Service {
|
||||
return &Service{store: store, vault: vault, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, actor identity.Principal, name, location, modality string, capabilities []string) (Device, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Device{}, fmt.Errorf("设备名称不能为空")
|
||||
}
|
||||
if modality == "" {
|
||||
modality = ModalityVideo
|
||||
}
|
||||
adapter := AdapterNotReady
|
||||
if modality == ModalityVideo {
|
||||
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}
|
||||
if err := s.store.Create(ctx, item); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.create", item.ID, "success", map[string]any{"modality": modality})
|
||||
return item, nil
|
||||
}
|
||||
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.CredentialCiphertext = nil
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
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
|
||||
}
|
||||
return page, err
|
||||
}
|
||||
func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, location string, capabilities []string, expected int64) (Device, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
item.Name = strings.TrimSpace(name)
|
||||
item.Location = strings.TrimSpace(location)
|
||||
item.Capabilities = capabilities
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if item.Name == "" {
|
||||
return Device{}, fmt.Errorf("设备名称不能为空")
|
||||
}
|
||||
if err := s.store.Update(ctx, item, expected); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.update", id, "success", map[string]any{"version": item.Version})
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = 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 Device{}, fmt.Errorf("用户名和密码不能为空")
|
||||
}
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
ciphertext, err := s.vault.Encrypt(username, password)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
expected := item.Version
|
||||
item.CredentialCiphertext = ciphertext
|
||||
item.CredentialConfigured = 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})
|
||||
item.CredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Device, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
item.Status = StatusDisabled
|
||||
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.disable", id, "success", nil)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
func testService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
vault, err := NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewService(NewMemoryStore(), vault)
|
||||
}
|
||||
func TestDeviceLifecycleAndCredentialNeverReturned(t *testing.T) {
|
||||
svc := testService(t)
|
||||
ctx := context.Background()
|
||||
actor := identity.Principal{UserID: "test"}
|
||||
item, err := svc.Create(ctx, actor, "东门摄像机", "东门", ModalityVideo, []string{"video"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.AdapterStatus != AdapterReady {
|
||||
t.Fatalf("adapter=%s", item.AdapterStatus)
|
||||
}
|
||||
item, err = svc.SetCredential(ctx, actor, item.ID, "camera-user", "camera-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !item.CredentialConfigured || len(item.CredentialCiphertext) != 0 {
|
||||
t.Fatal("credential leaked or not configured")
|
||||
}
|
||||
page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20})
|
||||
if err != nil || page.Total != 1 || len(page.Items[0].CredentialCiphertext) != 0 {
|
||||
t.Fatalf("page=%#v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
func TestNonVideoAdapterNotReadyAndPaginationNotCappedAt16(t *testing.T) {
|
||||
svc := testService(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 20; i++ {
|
||||
item, err := svc.Create(ctx, identity.Principal{}, strings.Repeat("x", i+1), "", "radar", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.AdapterStatus != AdapterNotReady {
|
||||
t.Fatal("unsupported adapter shown ready")
|
||||
}
|
||||
}
|
||||
page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20})
|
||||
if err != nil || len(page.Items) != 20 {
|
||||
t.Fatalf("items=%d err=%v", len(page.Items), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("device not found")
|
||||
ErrConflict = errors.New("device version conflict")
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Create(context.Context, Device) error
|
||||
Get(context.Context, string) (Device, error)
|
||||
Update(context.Context, Device, int64) error
|
||||
List(context.Context, ListFilter) (Page, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]Device
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{devices: map[string]Device{}} }
|
||||
|
||||
func (s *MemoryStore) Create(_ context.Context, item Device) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.devices[item.ID] = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Get(_ context.Context, id string) (Device, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
item, ok := s.devices[id]
|
||||
if !ok {
|
||||
return Device{}, ErrNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Update(_ context.Context, item Device, expected int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, ok := s.devices[item.ID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if current.Version != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
s.devices[item.ID] = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) List(_ context.Context, filter ListFilter) (Page, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]Device, 0, len(s.devices))
|
||||
keyword := strings.ToLower(strings.TrimSpace(filter.Keyword))
|
||||
for _, item := range s.devices {
|
||||
if keyword == "" || strings.Contains(strings.ToLower(item.Name+" "+item.Location), keyword) {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) })
|
||||
page, size := normalizePage(filter.Page, filter.PageSize)
|
||||
total := len(items)
|
||||
start := (page - 1) * size
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + size
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return Page{Items: append([]Device(nil), items[start:end]...), Total: total, Page: page, PageSize: size}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
}
|
||||
item.Capabilities = splitCapabilities(capabilities)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, error) {
|
||||
page, size := normalizePage(filter.Page, filter.PageSize)
|
||||
keyword := "%" + strings.TrimSpace(filter.Keyword) + "%"
|
||||
var total int
|
||||
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)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Device{}
|
||||
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 {
|
||||
return Page{}, err
|
||||
}
|
||||
item.Capabilities = splitCapabilities(caps)
|
||||
items = append(items, item)
|
||||
}
|
||||
return Page{Items: items, Total: total, Page: page, PageSize: size}, rows.Err()
|
||||
}
|
||||
func normalizePage(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
func splitCapabilities(value string) []string {
|
||||
if value == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(value, ",")
|
||||
}
|
||||
|
||||
var _ = time.Time{}
|
||||
@@ -0,0 +1,41 @@
|
||||
package device
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ModalityVideo = "video"
|
||||
StatusPending = "pending"
|
||||
StatusActive = "active"
|
||||
StatusOffline = "offline"
|
||||
StatusDisabled = "disabled"
|
||||
AdapterReady = "ready"
|
||||
AdapterNotReady = "adapter_not_ready"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type ListFilter struct {
|
||||
Keyword string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Device `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package identity
|
||||
|
||||
import "context"
|
||||
|
||||
// RecordAudit is the narrow project-internal audit port used by Sense business
|
||||
// modules. It never accepts secrets and does not expose identity persistence.
|
||||
func RecordAudit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) {
|
||||
module := activeModule.Load()
|
||||
if module == nil {
|
||||
return
|
||||
}
|
||||
module.service.audit(ctx, actor, action, target, outcome, metadata)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"os"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
memory := app.Config().DatabaseMode == platform.DatabaseModeMemory
|
||||
vault, err := device.NewCredentialVault(os.Getenv("SENSE_CREDENTIAL_KEY"), memory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var store device.Store
|
||||
if memory {
|
||||
store = device.NewMemoryStore()
|
||||
} else {
|
||||
store = device.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081202, Name: "sense_device", SQL: device.MigrationSQL})
|
||||
device.NewModule(device.NewService(store, vault)).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Device acceptance
|
||||
|
||||
Package tests verify CRUD state, pagination beyond 16 items, unsupported
|
||||
adapter status, optimistic versions and that encrypted camera credentials are
|
||||
write-only. Fixtures use generated values only; no address or real credential
|
||||
is stored in the repository.
|
||||
@@ -0,0 +1,8 @@
|
||||
import request from '../../../bootstrap/request'
|
||||
|
||||
export const listDevices = (params) => request.get('/devices', { params })
|
||||
export const createDevice = (data) => request.post('/devices', data)
|
||||
export const updateDevice = (id, data) => request.patch(`/devices/${id}`, data)
|
||||
export const updateCredential = (id, data) => request.post(`/devices/${id}/credentials`, data)
|
||||
export const disableDevice = (id, version) => request.post(`/devices/${id}/disable`, { version })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Devices from '../../views/sense/device/Devices.vue'
|
||||
|
||||
export default {
|
||||
path: 'devices',
|
||||
name: 'sense-devices',
|
||||
component: Devices,
|
||||
meta: { title: '设备管理', icon: 'VideoCamera', order: 10, permission: 'device.read' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<section class="page-container">
|
||||
<div class="page-heading"><div><h1>设备管理</h1><p>登记设备和安装位置;摄像机密码只可更新,不能查看。</p></div></div>
|
||||
<div class="search-toolbar">
|
||||
<el-form :inline="true" @submit.prevent="load">
|
||||
<el-form-item label="名称/位置"><el-input v-model="query.keyword" clearable placeholder="输入设备名称或位置" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="load">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<div class="table-actions"><strong>设备列表</strong><el-button v-if="canWrite" type="primary" :icon="Plus" @click="openCreate">添加设备</el-button></div>
|
||||
<el-table v-loading="loading" :data="page.items" border>
|
||||
<el-table-column prop="name" label="设备名称" min-width="160" />
|
||||
<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="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>
|
||||
<div class="pagination"><el-pagination v-model:current-page="query.page" v-model:page-size="query.page_size" background layout="total, sizes, prev, pager, next" :total="page.total" :page-sizes="[20, 50, 100]" @change="load" /></div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="deviceDialog" :title="editing ? '编辑设备' : '添加设备'" width="560px" destroy-on-close>
|
||||
<el-form ref="deviceFormRef" :model="deviceForm" :rules="deviceRules" label-width="92px">
|
||||
<el-form-item label="设备名称" prop="name"><el-input v-model="deviceForm.name" /></el-form-item>
|
||||
<el-form-item label="安装位置" prop="location"><el-input v-model="deviceForm.location" /></el-form-item>
|
||||
<el-form-item label="设备类型" prop="modality"><el-select v-model="deviceForm.modality" :disabled="Boolean(editing)" style="width: 100%"><el-option label="视频设备" value="video" /><el-option label="雷达(适配器未就绪)" value="radar" /><el-option label="按钮(适配器未就绪)" value="button" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="deviceDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveDevice">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<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>
|
||||
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">安全保存</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { createDevice, disableDevice, listDevices, updateCredential, updateDevice } from '../../../api/sense/device'
|
||||
|
||||
const store = useStore()
|
||||
const loading = ref(false), saving = ref(false), deviceDialog = ref(false), credentialDialog = ref(false)
|
||||
const editing = ref(null), credentialTarget = ref(null), deviceFormRef = ref(), credentialFormRef = 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 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 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 }
|
||||
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 disable(row) { await ElMessageBox.confirm(`停用“${row.name}”后将停止后续接入,设备记录和审计仍保留。`, '确认停用', { type: 'warning' }); await disableDevice(row.id, row.version); ElMessage.success('设备已停用'); await load() }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.credential-form { margin-top: 20px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user