From be091f093d1476b19471d23e2ddf6d6e5f102530 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Fri, 14 Aug 2026 14:59:34 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E9=87=8D=E5=BB=BA=E8=AE=BE?=
=?UTF-8?q?=E5=A4=87=E5=8F=B0=E8=B4=A6=E4=B8=8E=E5=87=AD=E6=8D=AE=E8=BE=B9?=
=?UTF-8?q?=E7=95=8C=20(#65)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Sense/README.md | 4 +-
Sense/server/app/admin/router/sense_device.go | 27 ++
Sense/server/app/sense/credential/vault.go | 132 ++++++++
.../server/app/sense/credential/vault_test.go | 37 +++
Sense/server/app/sense/device/apis/device.go | 159 +++++++++
.../app/sense/device/apis/device_test.go | 22 ++
.../server/app/sense/device/models/device.go | 33 ++
.../server/app/sense/device/service/device.go | 314 ++++++++++++++++++
.../app/sense/device/service/device_test.go | 118 +++++++
.../app/sense/device/service/dto/device.go | 65 ++++
.../migration/version/2026081414000_device.go | 123 +++++++
Sense/server/common/middleware/logger.go | 3 +-
Sense/server/common/middleware/logger_test.go | 12 +
Sense/server/config/credential.env.example | 3 +
Sense/ui/src/api/sense/device.js | 25 ++
.../src/views/sense/device/devicePayload.js | 32 ++
Sense/ui/src/views/sense/device/index.vue | 305 +++++++++++++++++
.../ui/tests/unit/sense/devicePayload.spec.js | 56 ++++
18 files changed, 1468 insertions(+), 2 deletions(-)
create mode 100644 Sense/server/app/admin/router/sense_device.go
create mode 100644 Sense/server/app/sense/credential/vault.go
create mode 100644 Sense/server/app/sense/credential/vault_test.go
create mode 100644 Sense/server/app/sense/device/apis/device.go
create mode 100644 Sense/server/app/sense/device/apis/device_test.go
create mode 100644 Sense/server/app/sense/device/models/device.go
create mode 100644 Sense/server/app/sense/device/service/device.go
create mode 100644 Sense/server/app/sense/device/service/device_test.go
create mode 100644 Sense/server/app/sense/device/service/dto/device.go
create mode 100644 Sense/server/cmd/migrate/migration/version/2026081414000_device.go
create mode 100644 Sense/server/config/credential.env.example
create mode 100644 Sense/ui/src/api/sense/device.js
create mode 100644 Sense/ui/src/views/sense/device/devicePayload.js
create mode 100644 Sense/ui/src/views/sense/device/index.vue
create mode 100644 Sense/ui/tests/unit/sense/devicePayload.spec.js
diff --git a/Sense/README.md b/Sense/README.md
index 7897949..afd911b 100644
--- a/Sense/README.md
+++ b/Sense/README.md
@@ -40,4 +40,6 @@ corepack pnpm@9.15.1 build:prod
go run . server -c C:\secure-path\sense-settings.yml
```
-仓库不提供默认账号、默认密码或可用密钥。管理员安全初始化由后续工单实现。
+设备台账本身可以在不配置摄像头凭据的情况下使用。创建或更新 ONVIF/RTSP 凭据前,还必须在启动进程环境中设置 `SENSE_CREDENTIAL_KEY`:该值是随机 32 字节密钥的 Base64 编码,仅保存在仓库外。变量名模板见 `server/config/credential.env.example`;不要把真实值写入配置、脚本、日志或工单。密钥缺失或格式不正确时,Sense 会拒绝凭据写入,不会降级为明文存储。
+
+仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
diff --git a/Sense/server/app/admin/router/sense_device.go b/Sense/server/app/admin/router/sense_device.go
new file mode 100644
index 0000000..d8987b6
--- /dev/null
+++ b/Sense/server/app/admin/router/sense_device.go
@@ -0,0 +1,27 @@
+package router
+
+import (
+ "github.com/gin-gonic/gin"
+ jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
+
+ senseapis "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/apis"
+ "git.ilapage.cn/ila/yovision/Sense/server/common/actions"
+ "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
+)
+
+func init() {
+ routerCheckRole = append(routerCheckRole, registerSenseDeviceRouter)
+}
+
+func registerSenseDeviceRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
+ api := senseapis.Device{}
+ r := v1.Group("/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
+ {
+ r.GET("", api.GetPage)
+ r.GET("/:id", api.Get)
+ r.POST("", api.Insert)
+ r.PUT("/:id", api.Update)
+ r.PUT("/:id/disable", api.Disable)
+ r.PUT("/:id/credentials", api.UpdateCredentials)
+ }
+}
diff --git a/Sense/server/app/sense/credential/vault.go b/Sense/server/app/sense/credential/vault.go
new file mode 100644
index 0000000..55ed8df
--- /dev/null
+++ b/Sense/server/app/sense/credential/vault.go
@@ -0,0 +1,132 @@
+package credential
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+const (
+ EnvironmentKey = "SENSE_CREDENTIAL_KEY"
+ PurposeONVIF = "onvif"
+ PurposeRTSP = "rtsp"
+ keyVersion = "v1"
+)
+
+var ErrKeyUnavailable = errors.New("摄像头凭据加密密钥不可用")
+
+// DeviceCredential is deliberately stored separately from the device ledger.
+// No HTTP response type embeds this model.
+type DeviceCredential struct {
+ DeviceID string `gorm:"size:36;primaryKey" json:"-"`
+ Purpose string `gorm:"size:16;primaryKey" json:"-"`
+ Ciphertext []byte `gorm:"type:bytea;not null" json:"-"`
+ KeyVersion string `gorm:"size:16;not null" json:"-"`
+ CreatedAt time.Time `json:"-"`
+ UpdatedAt time.Time `json:"-"`
+}
+
+func (DeviceCredential) TableName() string { return "sense_device_credentials" }
+
+type Vault struct{ key []byte }
+
+func NewVaultFromEnvironment() (*Vault, error) {
+ encoded := strings.TrimSpace(os.Getenv(EnvironmentKey))
+ if encoded == "" {
+ return nil, ErrKeyUnavailable
+ }
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil || len(decoded) != 32 {
+ return nil, ErrKeyUnavailable
+ }
+ return NewVault(decoded)
+}
+
+func NewVault(key []byte) (*Vault, error) {
+ if len(key) != 32 {
+ return nil, ErrKeyUnavailable
+ }
+ copyOfKey := append([]byte(nil), key...)
+ return &Vault{key: copyOfKey}, nil
+}
+
+func (v *Vault) Encrypt(deviceID, purpose, username, password string) ([]byte, error) {
+ if err := validateScope(deviceID, purpose); err != nil {
+ return nil, err
+ }
+ plaintext, err := json.Marshal(struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }{Username: username, Password: password})
+ if err != nil {
+ return nil, fmt.Errorf("encode credential: %w", err)
+ }
+ block, err := aes.NewCipher(v.key)
+ if err != nil {
+ return nil, fmt.Errorf("initialize credential cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("initialize credential gcm: %w", err)
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
+ return nil, fmt.Errorf("generate credential nonce: %w", err)
+ }
+ return gcm.Seal(nonce, nonce, plaintext, associatedData(deviceID, purpose)), nil
+}
+
+// Decrypt is an internal adapter boundary. It is intentionally not exposed by
+// any Sense HTTP handler and must only be used for the matching device/purpose.
+func (v *Vault) Decrypt(deviceID, purpose string, ciphertext []byte) (string, string, error) {
+ if err := validateScope(deviceID, purpose); err != nil {
+ return "", "", err
+ }
+ block, err := aes.NewCipher(v.key)
+ if err != nil {
+ return "", "", fmt.Errorf("initialize credential cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", "", fmt.Errorf("initialize credential gcm: %w", err)
+ }
+ if len(ciphertext) < gcm.NonceSize() {
+ return "", "", errors.New("invalid credential ciphertext")
+ }
+ plaintext, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], associatedData(deviceID, purpose))
+ if err != nil {
+ return "", "", errors.New("credential ciphertext does not match device purpose")
+ }
+ var value struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+ if err = json.Unmarshal(plaintext, &value); err != nil {
+ return "", "", errors.New("invalid credential payload")
+ }
+ return value.Username, value.Password, nil
+}
+
+func Version() string { return keyVersion }
+
+func validateScope(deviceID, purpose string) error {
+ if strings.TrimSpace(deviceID) == "" {
+ return errors.New("device id is required")
+ }
+ if purpose != PurposeONVIF && purpose != PurposeRTSP {
+ return errors.New("unsupported credential purpose")
+ }
+ return nil
+}
+
+func associatedData(deviceID, purpose string) []byte {
+ return []byte("sense-device-credential-v1\x00" + deviceID + "\x00" + purpose)
+}
diff --git a/Sense/server/app/sense/credential/vault_test.go b/Sense/server/app/sense/credential/vault_test.go
new file mode 100644
index 0000000..57ce627
--- /dev/null
+++ b/Sense/server/app/sense/credential/vault_test.go
@@ -0,0 +1,37 @@
+package credential
+
+import (
+ "crypto/rand"
+ "testing"
+)
+
+func TestVaultSeparatesDeviceAndPurpose(t *testing.T) {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ t.Fatal(err)
+ }
+ vault, err := NewVault(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ciphertext, err := vault.Encrypt("device-a", PurposeONVIF, "synthetic-user", "synthetic-password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ username, password, err := vault.Decrypt("device-a", PurposeONVIF, ciphertext)
+ if err != nil || username != "synthetic-user" || password != "synthetic-password" {
+ t.Fatalf("credential round trip failed: username=%q err=%v", username, err)
+ }
+ if _, _, err = vault.Decrypt("device-a", PurposeRTSP, ciphertext); err == nil {
+ t.Fatal("credential ciphertext was reusable for another purpose")
+ }
+ if _, _, err = vault.Decrypt("device-b", PurposeONVIF, ciphertext); err == nil {
+ t.Fatal("credential ciphertext was reusable for another device")
+ }
+}
+
+func TestVaultRejectsInvalidKeyLength(t *testing.T) {
+ if _, err := NewVault(make([]byte, 16)); err == nil {
+ t.Fatal("short credential key accepted")
+ }
+}
diff --git a/Sense/server/app/sense/device/apis/device.go b/Sense/server/app/sense/device/apis/device.go
new file mode 100644
index 0000000..da1d995
--- /dev/null
+++ b/Sense/server/app/sense/device/apis/device.go
@@ -0,0 +1,159 @@
+package apis
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-admin-team/go-admin-core/sdk/api"
+ "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
+)
+
+type Device struct{ api.Api }
+
+func (e Device) GetPage(c *gin.Context) {
+ service := deviceService.Device{}
+ req := dto.PageReq{}
+ if err := e.MakeContext(c).MakeOrm().Bind(&req).MakeService(&service.Service).Errors; err != nil {
+ e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
+ return
+ }
+ list := make([]dto.DeviceResponse, 0)
+ var count int64
+ if err := service.GetPage(&req, &list, &count); err != nil {
+ e.Error(http.StatusInternalServerError, err, "设备列表查询失败")
+ return
+ }
+ e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
+}
+
+func (e Device) Get(c *gin.Context) {
+ service := deviceService.Device{}
+ if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
+ e.Error(http.StatusInternalServerError, err, "服务初始化失败")
+ return
+ }
+ var response dto.DeviceResponse
+ if err := service.Get(c.Param("id"), &response); err != nil {
+ e.writeServiceError(err)
+ return
+ }
+ e.OK(response, "查询成功")
+}
+
+func (e Device) Insert(c *gin.Context) {
+ service := deviceService.Device{}
+ if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
+ e.Error(http.StatusInternalServerError, err, "服务初始化失败")
+ return
+ }
+ req := dto.CreateReq{}
+ if err := bindStrictJSON(c, &req); err != nil {
+ e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
+ return
+ }
+ req.CreateBy = user.GetUserId(c)
+ var response dto.DeviceResponse
+ if err := service.Insert(&req, &response); err != nil {
+ e.writeServiceError(err)
+ return
+ }
+ e.OK(response, "设备创建成功")
+}
+
+func (e Device) Update(c *gin.Context) {
+ service := deviceService.Device{}
+ if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
+ e.Error(http.StatusInternalServerError, err, "服务初始化失败")
+ return
+ }
+ req := dto.UpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
+ if err := bindStrictJSON(c, &req); err != nil {
+ e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
+ return
+ }
+ var response dto.DeviceResponse
+ if err := service.Update(&req, &response); err != nil {
+ e.writeServiceError(err)
+ return
+ }
+ e.OK(response, "设备更新成功")
+}
+
+func (e Device) Disable(c *gin.Context) {
+ service := deviceService.Device{}
+ if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
+ e.Error(http.StatusInternalServerError, err, "服务初始化失败")
+ return
+ }
+ req := dto.DisableReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
+ if err := bindStrictJSON(c, &req); err != nil {
+ e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
+ return
+ }
+ var response dto.DeviceResponse
+ if err := service.Disable(&req, &response); err != nil {
+ e.writeServiceError(err)
+ return
+ }
+ e.OK(response, "设备已停用")
+}
+
+func (e Device) UpdateCredentials(c *gin.Context) {
+ service := deviceService.Device{}
+ if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
+ e.Error(http.StatusInternalServerError, err, "服务初始化失败")
+ return
+ }
+ req := dto.CredentialUpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
+ if err := bindStrictJSON(c, &req); err != nil {
+ e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
+ return
+ }
+ var response dto.DeviceResponse
+ if err := service.UpdateCredentials(&req, &response); err != nil {
+ e.writeServiceError(err)
+ return
+ }
+ e.OK(response, "凭据已安全更新,已请求重新验证")
+}
+
+func (e Device) writeServiceError(err error) {
+ switch {
+ case errors.Is(err, deviceService.ErrInvalidDevice):
+ e.Error(http.StatusBadRequest, err, "设备信息不符合要求")
+ case errors.Is(err, deviceService.ErrDeviceNotFound):
+ e.Error(http.StatusNotFound, err, err.Error())
+ case errors.Is(err, deviceService.ErrVersionConflict):
+ e.Error(http.StatusConflict, err, err.Error())
+ case errors.Is(err, credential.ErrKeyUnavailable):
+ e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
+ default:
+ e.Error(http.StatusInternalServerError, err, "设备操作失败")
+ }
+}
+
+func bindStrictJSON(c *gin.Context, target any) error {
+ if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
+ return errors.New("content type must be application/json")
+ }
+ decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ if err == nil {
+ return errors.New("request body must contain one JSON object")
+ }
+ return err
+ }
+ return nil
+}
diff --git a/Sense/server/app/sense/device/apis/device_test.go b/Sense/server/app/sense/device/apis/device_test.go
new file mode 100644
index 0000000..2c38d69
--- /dev/null
+++ b/Sense/server/app/sense/device/apis/device_test.go
@@ -0,0 +1,22 @@
+package apis
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
+ "github.com/gin-gonic/gin"
+)
+
+func TestBindStrictJSONRejectsUnknownDeviceField(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest("POST", "/api/v1/devices", strings.NewReader(`{"name":"测试设备","location":"东门","modality":"video","capabilities":["video"],"password":"must-not-be-accepted"}`))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+ var req dto.CreateReq
+ if err := bindStrictJSON(ctx, &req); err == nil {
+ t.Fatal("unknown credential-like field was accepted")
+ }
+}
diff --git a/Sense/server/app/sense/device/models/device.go b/Sense/server/app/sense/device/models/device.go
new file mode 100644
index 0000000..e9d52aa
--- /dev/null
+++ b/Sense/server/app/sense/device/models/device.go
@@ -0,0 +1,33 @@
+package models
+
+import (
+ "time"
+
+ common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
+)
+
+const (
+ ModalityVideo = "video"
+ StatusPending = "pending"
+ StatusDisabled = "disabled"
+ AdapterReady = "ready"
+ AdapterNotReady = "adapter_not_ready"
+)
+
+type Device struct {
+ ID string `gorm:"size:36;primaryKey" json:"id"`
+ Name string `gorm:"size:128;not null" json:"name"`
+ Location string `gorm:"size:255;not null;default:''" json:"location"`
+ Modality string `gorm:"size:32;not null;index" json:"modality"`
+ CapabilitiesJSON string `gorm:"column:capabilities;type:jsonb;not null;default:'[]'" json:"-"`
+ Status string `gorm:"size:32;not null;index" json:"status"`
+ AdapterStatus string `gorm:"size:32;not null" json:"adapterStatus"`
+ RTSPSameAsONVIF bool `gorm:"not null;default:true" json:"rtspCredentialSameAsOnvif"`
+ CredentialUpdatedAt *time.Time `json:"credentialUpdatedAt,omitempty"`
+ RetryRequestedAt *time.Time `json:"retryRequestedAt,omitempty"`
+ Version int64 `gorm:"not null;default:1" json:"version"`
+ common.ControlBy
+ common.ModelTime
+}
+
+func (Device) TableName() string { return "sense_devices" }
diff --git a/Sense/server/app/sense/device/service/device.go b/Sense/server/app/sense/device/service/device.go
new file mode 100644
index 0000000..579e2fd
--- /dev/null
+++ b/Sense/server/app/sense/device/service/device.go
@@ -0,0 +1,314 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ coreService "github.com/go-admin-team/go-admin-core/sdk/service"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
+)
+
+var (
+ ErrDeviceNotFound = errors.New("设备不存在")
+ ErrVersionConflict = errors.New("设备已被其他用户更新,请刷新后重试")
+ ErrInvalidDevice = errors.New("设备信息不符合要求")
+)
+
+var supportedValues = map[string]struct{}{
+ "video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
+}
+
+type Device struct {
+ coreService.Service
+ VaultFactory func() (*credential.Vault, error)
+}
+
+type credentialPresence struct {
+ DeviceID string
+ Purpose string
+}
+
+func (e *Device) GetPage(req *dto.PageReq, list *[]dto.DeviceResponse, count *int64) error {
+ query := e.Orm.Model(&models.Device{})
+ if keyword := strings.TrimSpace(req.Keyword); keyword != "" {
+ pattern := "%" + strings.ToLower(keyword) + "%"
+ query = query.Where("LOWER(name) LIKE ? OR LOWER(location) LIKE ?", pattern, pattern)
+ }
+ if req.Modality != "" {
+ query = query.Where("modality = ?", req.Modality)
+ }
+ if req.Status != "" {
+ query = query.Where("status = ?", req.Status)
+ }
+ if err := query.Count(count).Error; err != nil {
+ return fmt.Errorf("count devices: %w", err)
+ }
+ pageSize := req.GetPageSize()
+ if pageSize > 100 {
+ pageSize = 100
+ }
+ req.PageSize = pageSize
+ var devices []models.Device
+ if err := query.Order("created_at DESC").Limit(pageSize).Offset((req.GetPageIndex() - 1) * pageSize).Find(&devices).Error; err != nil {
+ return fmt.Errorf("list devices: %w", err)
+ }
+ responses, err := e.responses(devices)
+ if err != nil {
+ return err
+ }
+ *list = responses
+ return nil
+}
+
+func (e *Device) Get(id string, response *dto.DeviceResponse) error {
+ var model models.Device
+ if err := e.Orm.First(&model, "id = ?", id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrDeviceNotFound
+ }
+ return fmt.Errorf("get device: %w", err)
+ }
+ responses, err := e.responses([]models.Device{model})
+ if err != nil {
+ return err
+ }
+ *response = responses[0]
+ return nil
+}
+
+func (e *Device) Insert(req *dto.CreateReq, response *dto.DeviceResponse) error {
+ name, location, modality, capabilities, err := normalizeDevice(req.Name, req.Location, req.Modality, req.Capabilities)
+ if err != nil {
+ return err
+ }
+ encoded, _ := json.Marshal(capabilities)
+ adapterStatus := models.AdapterNotReady
+ if modality == models.ModalityVideo {
+ adapterStatus = models.AdapterReady
+ }
+ model := models.Device{
+ ID: uuid.NewString(),
+ Name: name,
+ Location: location,
+ Modality: modality,
+ CapabilitiesJSON: string(encoded),
+ Status: models.StatusPending,
+ AdapterStatus: adapterStatus,
+ RTSPSameAsONVIF: true,
+ Version: 1,
+ }
+ model.CreateBy = req.CreateBy
+ model.UpdateBy = req.CreateBy
+ if err = e.Orm.Create(&model).Error; err != nil {
+ return fmt.Errorf("create device: %w", err)
+ }
+ return e.Get(model.ID, response)
+}
+
+func (e *Device) Update(req *dto.UpdateReq, response *dto.DeviceResponse) error {
+ name := strings.TrimSpace(req.Name)
+ if name == "" || len([]rune(name)) > 128 || len([]rune(req.Location)) > 255 || req.Version < 1 {
+ return ErrInvalidDevice
+ }
+ capabilities, err := normalizeCapabilities(req.Capabilities)
+ if err != nil {
+ return err
+ }
+ encoded, _ := json.Marshal(capabilities)
+ updates := map[string]any{
+ "name": name, "location": strings.TrimSpace(req.Location), "capabilities": string(encoded),
+ "version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
+ }
+ result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(updates)
+ if result.Error != nil {
+ return fmt.Errorf("update device: %w", result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return e.notFoundOrConflict(req.ID)
+ }
+ return e.Get(req.ID, response)
+}
+
+func (e *Device) Disable(req *dto.DisableReq, response *dto.DeviceResponse) error {
+ if req.Version < 1 {
+ return ErrInvalidDevice
+ }
+ result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
+ "status": models.StatusDisabled, "version": req.Version + 1,
+ "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
+ })
+ if result.Error != nil {
+ return fmt.Errorf("disable device: %w", result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return e.notFoundOrConflict(req.ID)
+ }
+ return e.Get(req.ID, response)
+}
+
+func (e *Device) UpdateCredentials(req *dto.CredentialUpdateReq, response *dto.DeviceResponse) error {
+ if req.Version < 1 || strings.TrimSpace(req.ONVIFUsername) == "" || req.ONVIFPassword == "" {
+ return ErrInvalidDevice
+ }
+ var target models.Device
+ if err := e.Orm.Select("id", "modality").First(&target, "id = ?", req.ID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrDeviceNotFound
+ }
+ return fmt.Errorf("read credential target: %w", err)
+ }
+ if target.Modality != models.ModalityVideo {
+ return ErrInvalidDevice
+ }
+ if len(req.ONVIFUsername) > 255 || len(req.ONVIFPassword) > 1024 {
+ return ErrInvalidDevice
+ }
+ rtspUsername, rtspPassword := req.RTSPUsername, req.RTSPPassword
+ if req.RTSPSameAsONVIF {
+ rtspUsername, rtspPassword = req.ONVIFUsername, req.ONVIFPassword
+ } else if strings.TrimSpace(rtspUsername) == "" || rtspPassword == "" || len(rtspUsername) > 255 || len(rtspPassword) > 1024 {
+ return ErrInvalidDevice
+ }
+ factory := e.VaultFactory
+ if factory == nil {
+ factory = credential.NewVaultFromEnvironment
+ }
+ vault, err := factory()
+ if err != nil {
+ return err
+ }
+ onvifCiphertext, err := vault.Encrypt(req.ID, credential.PurposeONVIF, strings.TrimSpace(req.ONVIFUsername), req.ONVIFPassword)
+ if err != nil {
+ return err
+ }
+ rtspCiphertext, err := vault.Encrypt(req.ID, credential.PurposeRTSP, strings.TrimSpace(rtspUsername), rtspPassword)
+ if err != nil {
+ return err
+ }
+ now := time.Now().UTC()
+ err = e.Orm.Transaction(func(tx *gorm.DB) error {
+ result := tx.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
+ "rtsp_same_as_onvif": req.RTSPSameAsONVIF,
+ "credential_updated_at": now, "retry_requested_at": now,
+ "version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": now,
+ })
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return e.notFoundOrConflictWith(tx, req.ID)
+ }
+ rows := []credential.DeviceCredential{
+ {DeviceID: req.ID, Purpose: credential.PurposeONVIF, Ciphertext: onvifCiphertext, KeyVersion: credential.Version()},
+ {DeviceID: req.ID, Purpose: credential.PurposeRTSP, Ciphertext: rtspCiphertext, KeyVersion: credential.Version()},
+ }
+ return tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "device_id"}, {Name: "purpose"}},
+ DoUpdates: clause.AssignmentColumns([]string{"ciphertext", "key_version", "updated_at"}),
+ }).Create(&rows).Error
+ })
+ if err != nil {
+ if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrVersionConflict) {
+ return err
+ }
+ return fmt.Errorf("update device credential: %w", err)
+ }
+ return e.Get(req.ID, response)
+}
+
+func (e *Device) responses(devices []models.Device) ([]dto.DeviceResponse, error) {
+ result := make([]dto.DeviceResponse, 0, len(devices))
+ if len(devices) == 0 {
+ return result, nil
+ }
+ ids := make([]string, 0, len(devices))
+ for _, device := range devices {
+ ids = append(ids, device.ID)
+ }
+ var presence []credentialPresence
+ if err := e.Orm.Model(&credential.DeviceCredential{}).Select("device_id", "purpose").Where("device_id IN ?", ids).Find(&presence).Error; err != nil {
+ return nil, fmt.Errorf("read credential status: %w", err)
+ }
+ configured := make(map[string]map[string]bool, len(devices))
+ for _, row := range presence {
+ if configured[row.DeviceID] == nil {
+ configured[row.DeviceID] = map[string]bool{}
+ }
+ configured[row.DeviceID][row.Purpose] = true
+ }
+ for _, device := range devices {
+ var capabilities []string
+ if err := json.Unmarshal([]byte(device.CapabilitiesJSON), &capabilities); err != nil {
+ return nil, fmt.Errorf("decode device capabilities: %w", err)
+ }
+ result = append(result, dto.DeviceResponse{
+ ID: device.ID, Name: device.Name, Location: device.Location, Modality: device.Modality,
+ Capabilities: capabilities, Status: device.Status, AdapterStatus: device.AdapterStatus,
+ ONVIFCredentialConfigured: configured[device.ID][credential.PurposeONVIF],
+ RTSPCredentialConfigured: configured[device.ID][credential.PurposeRTSP],
+ RTSPCredentialSameAsONVIF: device.RTSPSameAsONVIF,
+ RetryPending: device.RetryRequestedAt != nil, Version: device.Version,
+ CreatedAt: device.CreatedAt, UpdatedAt: device.UpdatedAt,
+ })
+ }
+ return result, nil
+}
+
+func (e *Device) notFoundOrConflict(id string) error { return e.notFoundOrConflictWith(e.Orm, id) }
+
+func (e *Device) notFoundOrConflictWith(db *gorm.DB, id string) error {
+ var count int64
+ if err := db.Model(&models.Device{}).Where("id = ?", id).Count(&count).Error; err != nil {
+ return err
+ }
+ if count == 0 {
+ return ErrDeviceNotFound
+ }
+ return ErrVersionConflict
+}
+
+func normalizeDevice(name, location, modality string, capabilities []string) (string, string, string, []string, error) {
+ name, location, modality = strings.TrimSpace(name), strings.TrimSpace(location), strings.TrimSpace(modality)
+ if modality == "" {
+ modality = models.ModalityVideo
+ }
+ if name == "" || len([]rune(name)) > 128 || len([]rune(location)) > 255 {
+ return "", "", "", nil, ErrInvalidDevice
+ }
+ if _, ok := supportedValues[modality]; !ok {
+ return "", "", "", nil, ErrInvalidDevice
+ }
+ if len(capabilities) == 0 {
+ capabilities = []string{modality}
+ }
+ normalized, err := normalizeCapabilities(capabilities)
+ return name, location, modality, normalized, err
+}
+
+func normalizeCapabilities(values []string) ([]string, error) {
+ if len(values) == 0 || len(values) > 16 {
+ return nil, ErrInvalidDevice
+ }
+ seen := map[string]bool{}
+ result := make([]string, 0, len(values))
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if _, ok := supportedValues[value]; !ok {
+ return nil, ErrInvalidDevice
+ }
+ if !seen[value] {
+ seen[value] = true
+ result = append(result, value)
+ }
+ }
+ return result, nil
+}
diff --git a/Sense/server/app/sense/device/service/device_test.go b/Sense/server/app/sense/device/service/device_test.go
new file mode 100644
index 0000000..3e6d44a
--- /dev/null
+++ b/Sense/server/app/sense/device/service/device_test.go
@@ -0,0 +1,118 @@
+package service
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ coreService "github.com/go-admin-team/go-admin-core/sdk/service"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
+)
+
+func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}); err != nil {
+ t.Fatal(err)
+ }
+ key := make([]byte, 32)
+ if _, err = rand.Read(key); err != nil {
+ t.Fatal(err)
+ }
+ vault, err := credential.NewVault(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service := &Device{Service: coreService.Service{Orm: db}, VaultFactory: func() (*credential.Vault, error) { return vault, nil }}
+ return service, db
+}
+
+func TestDeviceLifecycleUsesAllowlistedFieldsAndOptimisticVersion(t *testing.T) {
+ service, _ := testDeviceService(t)
+ var created dto.DeviceResponse
+ err := service.Insert(&dto.CreateReq{Name: "东门摄像机", Location: "教学楼一楼东门", Modality: "video", Capabilities: []string{"video"}, CreateBy: 7}, &created)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.Name != "东门摄像机" || created.Location != "教学楼一楼东门" || created.Version != 1 || created.AdapterStatus != models.AdapterReady {
+ t.Fatalf("unexpected device: %#v", created)
+ }
+ var updated dto.DeviceResponse
+ err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "东门主摄像机", Location: "教学楼一楼东门", Capabilities: []string{"video"}, Version: 1, UpdateBy: 8}, &updated)
+ if err != nil || updated.Version != 2 || updated.Name != "东门主摄像机" {
+ t.Fatalf("update failed: device=%#v err=%v", updated, err)
+ }
+ err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "过期写入", Capabilities: []string{"video"}, Version: 1}, &updated)
+ if err != ErrVersionConflict {
+ t.Fatalf("stale update error=%v", err)
+ }
+}
+
+func TestCredentialIsPurposeSeparatedNeverReturnedAndRequestsRetry(t *testing.T) {
+ service, db := testDeviceService(t)
+ var created dto.DeviceResponse
+ if err := service.Insert(&dto.CreateReq{Name: "测试摄像机", Modality: "video", Capabilities: []string{"video"}}, &created); err != nil {
+ t.Fatal(err)
+ }
+ var updated dto.DeviceResponse
+ if err := service.UpdateCredentials(&dto.CredentialUpdateReq{
+ ID: created.ID, ONVIFUsername: "synthetic-onvif-user", ONVIFPassword: "synthetic-onvif-password",
+ RTSPSameAsONVIF: false, RTSPUsername: "synthetic-rtsp-user", RTSPPassword: "synthetic-rtsp-password", Version: created.Version,
+ }, &updated); err != nil {
+ t.Fatal(err)
+ }
+ if !updated.ONVIFCredentialConfigured || !updated.RTSPCredentialConfigured || !updated.RetryPending || updated.Version != 2 {
+ t.Fatalf("credential status not reflected: %#v", updated)
+ }
+ encoded, err := json.Marshal(updated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, secretPart := range []string{"synthetic-onvif", "synthetic-rtsp", "ciphertext", "password", "username"} {
+ if strings.Contains(strings.ToLower(string(encoded)), secretPart) {
+ t.Fatalf("response leaked credential material: %s", encoded)
+ }
+ }
+ var stored []credential.DeviceCredential
+ if err = db.Order("purpose").Find(&stored).Error; err != nil || len(stored) != 2 {
+ t.Fatalf("stored credentials=%d err=%v", len(stored), err)
+ }
+ if string(stored[0].Ciphertext) == string(stored[1].Ciphertext) {
+ t.Fatal("ONVIF and RTSP credentials were not purpose-separated")
+ }
+}
+
+func TestUnsupportedModalityIsExplicitlyNotReadyAndPaginationExceedsDefaultQuota(t *testing.T) {
+ service, _ := testDeviceService(t)
+ var radar dto.DeviceResponse
+ for index := 0; index < 20; index++ {
+ var response dto.DeviceResponse
+ if err := service.Insert(&dto.CreateReq{Name: "雷达" + strings.Repeat("号", index+1), Modality: "radar", Capabilities: []string{"radar"}}, &response); err != nil {
+ t.Fatal(err)
+ }
+ if response.AdapterStatus != models.AdapterNotReady {
+ t.Fatalf("adapter status=%s", response.AdapterStatus)
+ }
+ radar = response
+ }
+ var credentialResponse dto.DeviceResponse
+ if err := service.UpdateCredentials(&dto.CredentialUpdateReq{ID: radar.ID, ONVIFUsername: "synthetic", ONVIFPassword: "synthetic", RTSPSameAsONVIF: true, Version: radar.Version}, &credentialResponse); err != ErrInvalidDevice {
+ t.Fatalf("non-video credential update error=%v", err)
+ }
+ request := &dto.PageReq{}
+ request.PageIndex, request.PageSize = 1, 20
+ var list []dto.DeviceResponse
+ var count int64
+ if err := service.GetPage(request, &list, &count); err != nil || count != 20 || len(list) != 20 {
+ t.Fatalf("count=%d items=%d err=%v", count, len(list), err)
+ }
+}
diff --git a/Sense/server/app/sense/device/service/dto/device.go b/Sense/server/app/sense/device/service/dto/device.go
new file mode 100644
index 0000000..4b51090
--- /dev/null
+++ b/Sense/server/app/sense/device/service/dto/device.go
@@ -0,0 +1,65 @@
+package dto
+
+import (
+ "time"
+
+ commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
+)
+
+type PageReq struct {
+ commonDto.Pagination `search:"-"`
+ Keyword string `form:"keyword"`
+ Modality string `form:"modality"`
+ Status string `form:"status"`
+}
+
+type CreateReq struct {
+ Name string `json:"name"`
+ Location string `json:"location"`
+ Modality string `json:"modality"`
+ Capabilities []string `json:"capabilities"`
+ CreateBy int `json:"-"`
+}
+
+type UpdateReq struct {
+ ID string `json:"-"`
+ Name string `json:"name"`
+ Location string `json:"location"`
+ Capabilities []string `json:"capabilities"`
+ Version int64 `json:"version"`
+ UpdateBy int `json:"-"`
+}
+
+type DisableReq struct {
+ ID string `json:"-"`
+ Version int64 `json:"version"`
+ UpdateBy int `json:"-"`
+}
+
+type CredentialUpdateReq struct {
+ ID string `json:"-"`
+ ONVIFUsername string `json:"onvifUsername"`
+ ONVIFPassword string `json:"onvifPassword"`
+ RTSPSameAsONVIF bool `json:"rtspSameAsOnvif"`
+ RTSPUsername string `json:"rtspUsername"`
+ RTSPPassword string `json:"rtspPassword"`
+ Version int64 `json:"version"`
+ UpdateBy int `json:"-"`
+}
+
+type DeviceResponse 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:"adapterStatus"`
+ ONVIFCredentialConfigured bool `json:"onvifCredentialConfigured"`
+ RTSPCredentialConfigured bool `json:"rtspCredentialConfigured"`
+ RTSPCredentialSameAsONVIF bool `json:"rtspCredentialSameAsOnvif"`
+ RetryPending bool `json:"retryPending"`
+ Version int64 `json:"version"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
diff --git a/Sense/server/cmd/migrate/migration/version/2026081414000_device.go b/Sense/server/cmd/migrate/migration/version/2026081414000_device.go
new file mode 100644
index 0000000..7eae8e8
--- /dev/null
+++ b/Sense/server/cmd/migrate/migration/version/2026081414000_device.go
@@ -0,0 +1,123 @@
+package version
+
+import (
+ "fmt"
+ "runtime"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
+ "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
+ migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
+ common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
+)
+
+type deviceCasbinRule struct {
+ ID uint `gorm:"primaryKey;autoIncrement"`
+ Ptype string `gorm:"size:512;uniqueIndex:unique_index"`
+ V0 string `gorm:"size:512;uniqueIndex:unique_index"`
+ V1 string `gorm:"size:512;uniqueIndex:unique_index"`
+ V2 string `gorm:"size:512;uniqueIndex:unique_index"`
+ V3 string `gorm:"size:512;uniqueIndex:unique_index"`
+ V4 string `gorm:"size:512;uniqueIndex:unique_index"`
+ V5 string `gorm:"size:512;uniqueIndex:unique_index"`
+}
+
+func (deviceCasbinRule) TableName() string { return "casbin_rule" }
+
+func init() {
+ _, fileName, _, _ := runtime.Caller(0)
+ migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseDeviceLedger)
+}
+
+func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
+ return db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
+ return err
+ }
+ page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
+ MenuName: "SenseDeviceManage", Title: "设备管理", Icon: "monitor", Path: "/sense/devices",
+ MenuType: "C", Permission: "sense:device:list", ParentId: 0, Component: "/sense/device/index",
+ Sort: 5, Visible: "0", IsFrame: "1",
+ })
+ if err != nil {
+ return err
+ }
+ buttons := make([]migrationModels.SysMenu, 0, 4)
+ for index, definition := range []struct{ name, title, action, permission string }{
+ {"SenseDeviceAdd", "新增设备", "POST", "sense:device:add"},
+ {"SenseDeviceEdit", "编辑设备", "PUT", "sense:device:edit"},
+ {"SenseDeviceDisable", "停用设备", "PUT", "sense:device:disable"},
+ {"SenseDeviceCredential", "更新设备凭据", "PUT", "sense:device:credential"},
+ } {
+ button, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
+ MenuName: definition.name, Title: definition.title, MenuType: "F", Action: definition.action,
+ Permission: definition.permission, ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d", page.MenuId),
+ Sort: index + 1, Visible: "1", IsFrame: "1",
+ })
+ if err != nil {
+ return err
+ }
+ buttons = append(buttons, button)
+ }
+ if err = attachDeviceRole(tx, "implementation_operator", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
+ return err
+ }
+ if err = attachDeviceRole(tx, "site_admin", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
+ return err
+ }
+ if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
+ return err
+ }
+ readPolicies := [][2]string{{"/api/v1/devices", "GET"}, {"/api/v1/devices/:id", "GET"}}
+ writePolicies := [][2]string{{"/api/v1/devices", "POST"}, {"/api/v1/devices/:id", "PUT"}, {"/api/v1/devices/:id/disable", "PUT"}, {"/api/v1/devices/:id/credentials", "PUT"}}
+ for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
+ policies := append([][2]string{}, readPolicies...)
+ if role != "viewer" {
+ policies = append(policies, writePolicies...)
+ }
+ for _, policy := range policies {
+ rule := deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}
+ if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
+ return err
+ }
+ }
+ }
+ return tx.Create(&common.Migration{Version: version}).Error
+ })
+}
+
+func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
+ var menu migrationModels.SysMenu
+ err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
+ if err != nil && err != gorm.ErrRecordNotFound {
+ return menu, err
+ }
+ if err == gorm.ErrRecordNotFound {
+ menu = desired
+ if err = tx.Create(&menu).Error; err != nil {
+ return menu, err
+ }
+ }
+ paths := desired.Paths
+ if desired.ParentId == 0 {
+ paths = fmt.Sprintf("/0/%d", menu.MenuId)
+ }
+ err = tx.Model(&menu).Updates(map[string]any{
+ "title": desired.Title, "icon": desired.Icon, "path": desired.Path, "paths": paths,
+ "menu_type": desired.MenuType, "action": desired.Action, "permission": desired.Permission,
+ "parent_id": desired.ParentId, "component": desired.Component, "sort": desired.Sort,
+ "visible": desired.Visible, "is_frame": desired.IsFrame,
+ }).Error
+ return menu, err
+}
+
+func attachDeviceRole(tx *gorm.DB, roleKey string, menus []migrationModels.SysMenu) error {
+ var role migrationModels.SysRole
+ if err := tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
+ return err
+ }
+ return tx.Model(&role).Association("SysMenu").Append(menus)
+}
diff --git a/Sense/server/common/middleware/logger.go b/Sense/server/common/middleware/logger.go
index 92c9195..fc035ff 100644
--- a/Sense/server/common/middleware/logger.go
+++ b/Sense/server/common/middleware/logger.go
@@ -136,7 +136,8 @@ func redactAuditValue(value interface{}) {
func isSensitiveAuditKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
switch normalized {
- case "password", "oldpassword", "newpassword", "token", "refreshtoken", "secret", "code", "uuid":
+ case "password", "oldpassword", "newpassword", "onvifusername", "onvifpassword", "rtspusername", "rtsppassword",
+ "token", "refreshtoken", "secret", "code", "uuid":
return true
default:
return false
diff --git a/Sense/server/common/middleware/logger_test.go b/Sense/server/common/middleware/logger_test.go
index 3a1eb38..d194684 100644
--- a/Sense/server/common/middleware/logger_test.go
+++ b/Sense/server/common/middleware/logger_test.go
@@ -18,6 +18,18 @@ func TestSanitizeAuditJSON(t *testing.T) {
}
}
+func TestSanitizeAuditJSONHidesCameraCredentialFields(t *testing.T) {
+ value := sanitizeAuditJSON(`{"onvifUsername":"camera-user","onvifPassword":"camera-password","rtspUsername":"stream-user","rtspPassword":"stream-password","name":"东门摄像机"}`)
+ for _, forbidden := range []string{"camera-user", "camera-password", "stream-user", "stream-password"} {
+ if strings.Contains(value, forbidden) {
+ t.Fatalf("credential value leaked in audit JSON: %s", value)
+ }
+ }
+ if !strings.Contains(value, "东门摄像机") {
+ t.Fatalf("non-sensitive device field was unexpectedly removed: %s", value)
+ }
+}
+
func TestSanitizeAuditJSONRejectsUnstructuredBodies(t *testing.T) {
if got := sanitizeAuditJSON("password=secret-value"); strings.Contains(got, "secret-value") {
t.Fatalf("unstructured body leaked: %s", got)
diff --git a/Sense/server/config/credential.env.example b/Sense/server/config/credential.env.example
new file mode 100644
index 0000000..3825f79
--- /dev/null
+++ b/Sense/server/config/credential.env.example
@@ -0,0 +1,3 @@
+# Required only when creating or updating camera credentials.
+# Set outside the repository to a Base64-encoded random 32-byte key.
+SENSE_CREDENTIAL_KEY=
diff --git a/Sense/ui/src/api/sense/device.js b/Sense/ui/src/api/sense/device.js
new file mode 100644
index 0000000..51e1826
--- /dev/null
+++ b/Sense/ui/src/api/sense/device.js
@@ -0,0 +1,25 @@
+import request from '@/utils/request'
+
+export function listDevices(query) {
+ return request({ url: '/api/v1/devices', method: 'get', params: query })
+}
+
+export function getDevice(id) {
+ return request({ url: `/api/v1/devices/${id}`, method: 'get' })
+}
+
+export function addDevice(data) {
+ return request({ url: '/api/v1/devices', method: 'post', data })
+}
+
+export function updateDevice(id, data) {
+ return request({ url: `/api/v1/devices/${id}`, method: 'put', data })
+}
+
+export function disableDevice(id, data) {
+ return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
+}
+
+export function updateDeviceCredentials(id, data) {
+ return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
+}
diff --git a/Sense/ui/src/views/sense/device/devicePayload.js b/Sense/ui/src/views/sense/device/devicePayload.js
new file mode 100644
index 0000000..3e6ab58
--- /dev/null
+++ b/Sense/ui/src/views/sense/device/devicePayload.js
@@ -0,0 +1,32 @@
+export function createDevicePayload(form) {
+ return {
+ name: form.name,
+ location: form.location,
+ modality: form.modality,
+ capabilities: [...form.capabilities]
+ }
+}
+
+export function updateDevicePayload(form) {
+ return {
+ name: form.name,
+ location: form.location,
+ capabilities: [...form.capabilities],
+ version: form.version
+ }
+}
+
+export function credentialPayload(form) {
+ return {
+ onvifUsername: form.onvifUsername,
+ onvifPassword: form.onvifPassword,
+ rtspSameAsOnvif: form.rtspSameAsOnvif,
+ rtspUsername: form.rtspSameAsOnvif ? '' : form.rtspUsername,
+ rtspPassword: form.rtspSameAsOnvif ? '' : form.rtspPassword,
+ version: form.version
+ }
+}
+
+export function disableDevicePayload(version) {
+ return { version }
+}
diff --git a/Sense/ui/src/views/sense/device/index.vue b/Sense/ui/src/views/sense/device/index.vue
new file mode 100644
index 0000000..871bed4
--- /dev/null
+++ b/Sense/ui/src/views/sense/device/index.vue
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索
+ 重置
+
+
+
+
+ 新增设备
+
+
+
+
+
+
+ {{ modalityLabel(scope.row.modality) }}
+
+
+
+ {{ modalityLabel(capability) }}
+
+
+
+
+
+ {{ scope.row.adapterStatus === 'ready' ? '可接入' : '适配器未就绪' }}
+
+
+
+
+
+
+ {{ scope.row.onvifCredentialConfigured ? '已配置' : '未配置' }}
+
+
+
+
+
+ {{ scope.row.status === 'disabled' ? '已停用' : '待接入' }}
+
+
+
+
+ {{ parseTime(scope.row.updatedAt) }}
+
+
+
+ 编辑
+
+ 更新凭据
+
+ 停用
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 该类型尚未提供接入适配器,将显示“适配器未就绪”。
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 与 ONVIF 相同
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sense/ui/tests/unit/sense/devicePayload.spec.js b/Sense/ui/tests/unit/sense/devicePayload.spec.js
new file mode 100644
index 0000000..c0325fc
--- /dev/null
+++ b/Sense/ui/tests/unit/sense/devicePayload.spec.js
@@ -0,0 +1,56 @@
+import {
+ createDevicePayload,
+ credentialPayload,
+ disableDevicePayload,
+ updateDevicePayload
+} from '@/views/sense/device/devicePayload'
+
+describe('Sense device payload allowlists', () => {
+ const form = {
+ id: 'read-only-id',
+ name: '东门摄像机',
+ location: '教学楼一楼东门',
+ modality: 'video',
+ capabilities: ['video'],
+ version: 9,
+ adapterStatus: 'ready',
+ unexpected: 'must-not-be-sent'
+ }
+
+ test('create keeps Chinese fields and only writable create fields', () => {
+ expect(createDevicePayload(form)).toEqual({
+ name: '东门摄像机',
+ location: '教学楼一楼东门',
+ modality: 'video',
+ capabilities: ['video']
+ })
+ })
+
+ test('update excludes id, modality and server state while carrying version', () => {
+ expect(updateDevicePayload(form)).toEqual({
+ name: '东门摄像机',
+ location: '教学楼一楼东门',
+ capabilities: ['video'],
+ version: 9
+ })
+ })
+
+ test('credential payload does not send unused RTSP fields', () => {
+ expect(credentialPayload({
+ onvifUsername: 'synthetic-user',
+ onvifPassword: 'synthetic-password',
+ rtspSameAsOnvif: true,
+ rtspUsername: 'must-not-be-sent',
+ rtspPassword: 'must-not-be-sent',
+ version: 9
+ })).toEqual({
+ onvifUsername: 'synthetic-user',
+ onvifPassword: 'synthetic-password',
+ rtspSameAsOnvif: true,
+ rtspUsername: '',
+ rtspPassword: '',
+ version: 9
+ })
+ expect(disableDevicePayload(9)).toEqual({ version: 9 })
+ })
+})
--
2.34.1
From d31c35f098ea850ab68567d071d5c67498b1d72a Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Fri, 14 Aug 2026 15:07:13 +0800
Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Sense=20?=
=?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=8F=B0=E8=B4=A6=E4=B8=8E=E5=87=AD=E6=8D=AE?=
=?UTF-8?q?=E8=BE=B9=E7=95=8C=20(#65)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/02-architecture-and-code-map.md | 8 ++++++--
docs/03-business-rules-and-glossary.md | 15 +++++++++++++--
docs/04-local-development-and-verification.md | 16 ++++++++++++++--
docs/delivery/README.md | 12 ++++++++++--
4 files changed, 43 insertions(+), 8 deletions(-)
diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index 66f3856..4f2fd24 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
-wiki_revision: eb1174040657f2064e26b6448ca92e7a93666423
-synchronized_at: 2026-08-14T04:09:07Z
+wiki_revision: 6880a2918c46be27738467361d8d3f923f33fc69
+synchronized_at: 2026-08-14T07:02:35Z
# 架构与代码地图
@@ -88,6 +88,10 @@ Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入
工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
+
+工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。
+
+设备写入采用版本号乐观并发控制;视频设备适配器状态为可接入,雷达、门磁、按钮、穿戴和其他类型明确显示“适配器未就绪”,不得伪装成已接入。`admin`、`implementation_operator`、`site_admin` 可维护设备,`viewer` 只读;停用替代物理删除。
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index 6f223a8..d560731 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
-wiki_revision: 53a9c59534560841cd65fec74492ac3a55ffebe1
-synchronized_at: 2026-08-14T04:09:11Z
+wiki_revision: 013865e0b48d156bb984515d6fabd42139829459
+synchronized_at: 2026-08-14T07:02:39Z
# 业务规则与术语
@@ -94,3 +94,14 @@ synchronized_at: 2026-08-14T04:09:11Z
## 旧实现迁移边界
旧 Sense 中已验证的中文设备字段、请求白名单、凭据隔离和媒体链路行为可作为迁移验收用例;不得把旧自研基础框架直接复制到新 `dev`。
+
+
+## Sense 设备台账与凭据规则
+
+- Device 是 Sense 内的设备根实体,类型包括视频、雷达、门磁、按钮、穿戴和其他;16 路仍只是默认交付配额,分页和台账容量不得硬编码为 16。
+- 当前仅视频设备具备可用接入适配器;其余类型必须显示“适配器未就绪”,不允许通过虚假成功状态掩盖能力缺口。
+- 设备台账支持列表、新建、编辑和停用;停用保留审计与后续关联,不提供物理删除。编辑、停用和凭据更新均使用版本号检测并发冲突。
+- 摄像头 ONVIF 与 RTSP 凭据按设备和用途分离,以 AES-256-GCM 密文保存;加密密钥只从外部环境 `SENSE_CREDENTIAL_KEY` 注入,不写入数据库、仓库、工单、Wiki 或日志。
+- 凭据只写不可读:HTTP 和页面仅显示“已配置/未配置”,不得回填用户名、密码或密文;更新凭据后只记录状态并请求后续接入流程重试。
+- `admin`、`implementation_operator`、`site_admin` 可维护设备与凭据,`viewer` 仅可查看设备台账。
+
diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md
index 29d92c1..d4e92cf 100644
--- a/docs/04-local-development-and-verification.md
+++ b/docs/04-local-development-and-verification.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
-wiki_revision: 9007f0ff45178b7535c2a38b6e38d44628c3210d
-synchronized_at: 2026-08-14T04:09:15Z
+wiki_revision: f68bbd8c7a99689e65453ae00c7bbd2c3ee00357
+synchronized_at: 2026-08-14T07:02:43Z
# 本地开发与验证
@@ -151,6 +151,18 @@ Remove-Variable bootstrapToken, bootstrapBody
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`。
+
+设备台账启用凭据写入前,还必须在服务进程环境提供独立随机密钥;示例文件 `Sense/server/config/credential.env.example` 只保留空值:
+
+```powershell
+# 生成一次随机 32 字节密钥并以 Base64 形式注入当前进程;不要打印或写入仓库
+$keyBytes = New-Object byte[] 32
+[System.Security.Cryptography.RandomNumberGenerator]::Fill($keyBytes)
+$env:SENSE_CREDENTIAL_KEY = [Convert]::ToBase64String($keyBytes)
+[Array]::Clear($keyBytes, 0, $keyBytes.Length)
+```
+
+缺少或格式错误的密钥时,普通设备台账仍可读写,但凭据更新返回服务不可用且不得产生部分写入。设备回归至少覆盖:中文名称与位置、未知 JSON 字段拒绝、版本冲突返回 409、非视频设备显示适配器未就绪、viewer 只读、凭据响应/操作日志不含明文,以及 PostgreSQL 迁移重复执行不增加菜单或权限记录。
diff --git a/docs/delivery/README.md b/docs/delivery/README.md
index c8b6069..df954e4 100644
--- a/docs/delivery/README.md
+++ b/docs/delivery/README.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Documentation-Guide
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
-wiki_revision: c754ebd705c55329a46da12e1860ffd7d137f99a
-synchronized_at: 2026-08-14T04:09:55Z
+wiki_revision: 93cda004538c5114c5c4ca8a03b19e3e975b503d
+synchronized_at: 2026-08-14T07:03:33Z
# 交付文档指南
@@ -104,3 +104,11 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
+
+
+### Sense 设备台账交付说明
+
+设备管理已开放列表、新建、编辑、停用和摄像头凭据更新。系统管理员、实施/运维和站点管理员可以维护;只读用户只能查看。现场人员应使用中文设备名称和安装位置,类型选择“视频”时才可配置 ONVIF/RTSP 凭据;其他类型会明确提示适配器尚未就绪。
+
+凭据窗口每次均为空,不会回显已保存用户名或密码;“已配置”标签只表示服务器保存了密文。部署人员必须在仓库外为服务进程配置 Base64 编码的随机 32 字节 `SENSE_CREDENTIAL_KEY`,丢失或更换该密钥会使旧凭据不可用,因此应纳入受控秘密备份。停用设备不会物理删除台账。
+
--
2.34.1
From f7eab5d8fa413799174b96110b77ee7a37da0c11 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Fri, 14 Aug 2026 15:23:37 +0800
Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=E5=BD=92=E6=A1=A3=E5=B7=A5?=
=?UTF-8?q?=E5=8D=95=20#65=20=E5=BE=85=E9=AA=8C=E6=94=B6=E8=AF=81=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../65-Sense设备台账与凭据边界.md | 80 +++++++++++++++++++
wiki-docs.json | 4 +
2 files changed, 84 insertions(+)
create mode 100644 docs/task/65-Sense设备台账与凭据边界.md
diff --git a/docs/task/65-Sense设备台账与凭据边界.md b/docs/task/65-Sense设备台账与凭据边界.md
new file mode 100644
index 0000000..d1e6f46
--- /dev/null
+++ b/docs/task/65-Sense设备台账与凭据边界.md
@@ -0,0 +1,80 @@
+
+generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
+wiki_page: Task-65-Sense设备台账与凭据边界
+wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-65-Sense%E8%AE%BE%E5%A4%87%E5%8F%B0%E8%B4%A6%E4%B8%8E%E5%87%AD%E6%8D%AE%E8%BE%B9%E7%95%8C.-
+wiki_revision: 35274c48097516957b189b8361b4f0513bb5ee31
+synchronized_at: 2026-08-14T07:20:49Z
+
+
+# 65 Sense设备台账与凭据边界
+
+- 类型:需求
+- 所属 Epic:#7
+- 所属 MVP / 版本:#8
+- 状态:待验收
+- 日期:2026-08-14
+- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/65
+- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/83
+- 主项目 / 主 Agent:Sense / Sense Agent
+- 写路径:仅 `Sense/` 与本任务对应 Wiki、docs 镜像
+
+## 背景与目标
+
+在 #61 冻结 GoAdmin 产品骨架和 #64 独立身份能力上,重建设备台账、角色权限和摄像头凭据边界。界面服务网管、实施人员及非技术现场人员,通用外壳和组件复用 go-admin-ui;Brain、Bell 不参与且不作为验收依赖。
+
+## 最终方案
+
+- 新增视频、雷达、门磁、按钮、穿戴和其他类型设备台账,提供列表、新建、编辑和停用;停用替代物理删除。
+- 编辑、停用、凭据更新使用版本号乐观并发控制,过期写入返回 409;分页上限为每页 100,不把默认 16 路配额写成容量上限。
+- 当前视频类型标记适配器可接入,其他类型明确显示“适配器未就绪”。
+- ONVIF/RTSP 凭据按设备和用途分离,以外部 `SENSE_CREDENTIAL_KEY` 驱动 AES-256-GCM 加密;HTTP 和页面只返回配置状态,不提供读取凭据或密文接口。
+- 复用 GoAdmin 的 JWT、Casbin、操作审计、动态菜单、页面容器、Element Plus 表单/表格/分页/Dialog/Tag、权限按钮和 Axios 封装。
+- admin、implementation_operator、site_admin 可维护设备与凭据,viewer 只读;未知 JSON 字段拒绝,凭据字段加入日志脱敏。
+- 与建单方案的路径差异已在工单记录:按当前 GoAdmin 目录使用 `cmd/migrate/migration/version/` 和 `app/admin/router/sense_device.go`,没有修改共享路径。
+
+## 修改文件
+
+- `Sense/server/app/sense/credential/`:凭据模型、AES-256-GCM vault 和作用域测试。
+- `Sense/server/app/sense/device/`:设备模型、白名单 DTO、服务、API 与测试。
+- `Sense/server/app/admin/router/sense_device.go`:注册受 JWT、Casbin 和权限动作保护的设备路由。
+- `Sense/server/cmd/migrate/migration/version/2026081414000_device.go`:设备表、凭据表、菜单、角色关联和 Casbin 权限。
+- `Sense/server/common/middleware/logger.go`:摄像头凭据字段脱敏。
+- `Sense/ui/src/views/sense/device/`、`Sense/ui/src/api/sense/device.js`:设备管理页面、请求白名单和接口。
+- `Sense/README.md`、Wiki 架构/业务规则/本地验证/交付指南:配置、安全边界和操作说明。
+
+## 验收结果
+
+| 验收标准 | 结果 |
+|---|---|
+| 设备列表、新建、编辑、停用与中文字段 | 自动化及 PostgreSQL HTTP 集成通过 |
+| 版本冲突和严格请求白名单 | 过期写入 409、未知字段 400 |
+| 多类型与非视频适配器状态 | 通过;20 条非视频分页验证未受 16 路配额限制 |
+| 凭据分离加密、只写不可读、审计脱敏 | 通过;两种 purpose、密文最短 138 字节、审计明文匹配 0 |
+| 角色权限 | admin/operator/site 可维护,viewer 读取 200、写入 403 |
+| GoAdmin/go-admin-ui 复用与无多余入口 | 通过代码与路由检查;未新增通用 UI 组件 |
+| 文档 | Wiki 已更新、docs 镜像检查通过 |
+
+## 测试
+
+- `go test ./...`:通过。
+- `go vet ./...`:通过。
+- `go build ./...`:通过。
+- `corepack pnpm@9.15.1 install --frozen-lockfile`:通过。
+- `corepack pnpm@9.15.1 lint`:0 错误,30 条继承的上游 warning。
+- `corepack pnpm@9.15.1 test:unit`:8 个 suite、32 项测试通过。
+- `corepack pnpm@9.15.1 build:prod`:通过,6 条继承的构建 warning。
+- PostgreSQL 17 迁移及重复迁移:通过,菜单和权限记录未重复。
+- HTTP 集成:bootstrap 200、未知字段 400、创建 200、更新 200、过期写入 409、凭据更新 200、viewer 读取 200、viewer 写入 403。
+- 数据库证据:设备 3 条;凭据 2 条、2 种 purpose;凭据审计 1 条;审计测试明文匹配 0。
+- `python dev_scripts/sync_wiki_docs.py --check`:通过。
+- `git diff --check`:通过。
+- **未验证部分**:真实摄像头接入、ONVIF/RTSP 连通和目标客户浏览器属于后续 #66 及现场验收;本工单不伪造其结果。
+
+## 回退与遗留
+
+回退 PR #83 的两个提交可移除本次入口;数据库表与权限记录保留,避免破坏审计或凭据数据。后续 #66 才消费本工单的凭据边界并实现真实 ONVIF/RTSP 接入。
+
+## 相关提交
+
+- `be091f0` feat: 重建设备台账与凭据边界 (#65)
+- `d31c35f` docs: 记录 Sense 设备台账与凭据边界 (#65)
diff --git a/wiki-docs.json b/wiki-docs.json
index 8cb8c02..c928a1d 100644
--- a/wiki-docs.json
+++ b/wiki-docs.json
@@ -107,6 +107,10 @@
{
"page": "Task-64-Sense登录RBAC与审计",
"path": "docs/task/64-Sense登录RBAC与审计.md"
+ },
+ {
+ "page": "Task-65-Sense设备台账与凭据边界",
+ "path": "docs/task/65-Sense设备台账与凭据边界.md"
}
]
}
--
2.34.1
From 3e859c38482b37b9bbf080f9bcbe37ef4bc91ecb Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Fri, 14 Aug 2026 16:27:32 +0800
Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E5=B7=A5?=
=?UTF-8?q?=E5=8D=95=20#65=20=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/task/65-Sense设备台账与凭据边界.md | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/docs/task/65-Sense设备台账与凭据边界.md b/docs/task/65-Sense设备台账与凭据边界.md
index d1e6f46..687a14e 100644
--- a/docs/task/65-Sense设备台账与凭据边界.md
+++ b/docs/task/65-Sense设备台账与凭据边界.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-65-Sense设备台账与凭据边界
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-65-Sense%E8%AE%BE%E5%A4%87%E5%8F%B0%E8%B4%A6%E4%B8%8E%E5%87%AD%E6%8D%AE%E8%BE%B9%E7%95%8C.-
-wiki_revision: 35274c48097516957b189b8361b4f0513bb5ee31
-synchronized_at: 2026-08-14T07:20:49Z
+wiki_revision: 9864d7fece44ca2e88ee8c3661101e3d6f74b030
+synchronized_at: 2026-08-14T08:24:46Z
# 65 Sense设备台账与凭据边界
@@ -11,7 +11,7 @@ synchronized_at: 2026-08-14T07:20:49Z
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8
-- 状态:待验收
+- 状态:已完成
- 日期:2026-08-14
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/65
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/83
@@ -70,6 +70,11 @@ synchronized_at: 2026-08-14T07:20:49Z
- `git diff --check`:通过。
- **未验证部分**:真实摄像头接入、ONVIF/RTSP 连通和目标客户浏览器属于后续 #66 及现场验收;本工单不伪造其结果。
+## 人工验收
+
+- 2026-08-14:用户明确回复“#65 验收通过”。
+- 验收后按工作流将 PR #83 合入 `dev`,不合入 `main`;同步关闭工单并更新父工单索引。
+
## 回退与遗留
回退 PR #83 的两个提交可移除本次入口;数据库表与权限记录保留,避免破坏审计或凭据数据。后续 #66 才消费本工单的凭据边界并实现真实 ONVIF/RTSP 接入。
--
2.34.1