Compare commits

...
Author SHA1 Message Date
QiuSW 3e859c3848 docs: 记录工单 #65 验收通过 2026-08-14 16:27:32 +08:00
QiuSW f7eab5d8fa docs: 归档工单 #65 待验收证据 2026-08-14 15:23:37 +08:00
QiuSW d31c35f098 docs: 记录 Sense 设备台账与凭据边界 (#65) 2026-08-14 15:07:13 +08:00
QiuSW be091f093d feat: 重建设备台账与凭据边界 (#65) 2026-08-14 14:59:34 +08:00
ila 1ef4a1fe0b Merge PR #82: rebuild Sense authentication RBAC and audit
User accepted issue #64 on 2026-08-14.
2026-08-14 14:26:09 +08:00
QiuSW 654c444c18 docs: 记录工单 #64 验收通过 2026-08-14 14:25:55 +08:00
QiuSW 903a423ed5 docs: 归档 Sense 登录权限任务 (#64) 2026-08-14 12:22:39 +08:00
QiuSW 1dd29b68fc docs: 记录 Sense 身份权限规则 (#64) 2026-08-14 12:12:42 +08:00
QiuSW 0411aa37c5 feat: 重建 Sense 认证权限与审计 (#64) 2026-08-14 12:03:10 +08:00
ila c515f00319 Merge PR #81: rebuild Sense from frozen GoAdmin sources
User accepted issue #61 on 2026-08-14.
2026-08-14 11:31:51 +08:00
55 changed files with 2235 additions and 141 deletions
+3 -1
View File
@@ -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 的本地开发与验证页为准。
+1 -2
View File
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
e.Error(500, err, "服务初始化失败!")
return
}
id, b64s, answer, err := captcha.DriverDigitFunc()
id, b64s, _, err := captcha.DriverDigitFunc()
if err != nil {
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
e.Error(500, err, "验证码获取失败")
return
}
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
e.Custom(gin.H{
"code": 200,
"data": b64s,
@@ -0,0 +1,94 @@
package apis
import (
"crypto/subtle"
"errors"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
)
const bootstrapTokenHeader = "X-Sense-Bootstrap-Token"
type bootstrapRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
NickName string `json:"nickName"`
}
// Bootstrap creates the first Sense administrator. The one-time authorization
// token is supplied out of band and never accepted in the JSON request body.
func (e System) Bootstrap(c *gin.Context) {
if err := e.MakeContext(c).MakeOrm().Errors; err != nil {
e.Error(http.StatusInternalServerError, err, "安全初始化失败")
return
}
db := e.Orm
var req bootstrapRequest
if err := c.ShouldBindJSON(&req); err != nil {
e.Custom(gin.H{"code": http.StatusBadRequest, "msg": "安全初始化不可用或输入不符合要求"})
return
}
req.Username = strings.TrimSpace(req.Username)
if req.NickName == "" {
req.NickName = req.Username
}
if err := validateBootstrapInput(req); err != nil || !validBootstrapToken(c.GetHeader(bootstrapTokenHeader)) {
handler.WriteIdentityAudit(c, "1", "安全初始化被拒绝", req.Username)
e.Custom(gin.H{"code": http.StatusForbidden, "msg": "安全初始化不可用或输入不符合要求"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("LOCK TABLE sys_user IN EXCLUSIVE MODE").Error; err != nil {
return err
}
var count int64
if err := tx.Model(&models.SysUser{}).Count(&count).Error; err != nil {
return err
}
if count != 0 {
return errors.New("identity already initialized")
}
var role models.SysRole
if err := tx.Where("role_key = ? AND status = ?", "admin", "2").First(&role).Error; err != nil {
return err
}
return tx.Create(&models.SysUser{
Username: req.Username,
Password: req.Password,
NickName: req.NickName,
RoleId: role.RoleId,
Status: "2",
}).Error
})
if err != nil {
handler.WriteIdentityAudit(c, "1", "安全初始化被拒绝", req.Username)
e.Custom(gin.H{"code": http.StatusForbidden, "msg": "安全初始化不可用或输入不符合要求"})
return
}
handler.WriteIdentityAudit(c, "2", "首个管理员创建成功", req.Username)
e.Custom(gin.H{"code": http.StatusOK, "msg": "管理员创建成功"})
}
func validateBootstrapInput(req bootstrapRequest) error {
if len(req.Username) < 3 || len(req.Username) > 64 || strings.ContainsAny(req.Username, " \t\r\n") {
return errors.New("invalid username")
}
return service.ValidatePassword(req.Password)
}
func validBootstrapToken(candidate string) bool {
expected := os.Getenv("SENSE_BOOTSTRAP_TOKEN")
if len(expected) < 32 || len(candidate) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(candidate), []byte(expected)) == 1
}
@@ -0,0 +1,33 @@
package apis
import "testing"
func TestValidateBootstrapInput(t *testing.T) {
if err := validateBootstrapInput(bootstrapRequest{Username: "admin", Password: "abcdef"}); err != nil {
t.Fatalf("expected valid bootstrap input: %v", err)
}
for _, req := range []bootstrapRequest{
{Username: "ab", Password: "abcdef"},
{Username: "bad name", Password: "abcdef"},
{Username: "admin", Password: "abcde"},
} {
if err := validateBootstrapInput(req); err == nil {
t.Fatalf("expected invalid bootstrap input: %+v", req)
}
}
}
func TestValidBootstrapToken(t *testing.T) {
const token = "0123456789abcdef0123456789abcdef"
t.Setenv("SENSE_BOOTSTRAP_TOKEN", token)
if !validBootstrapToken(token) {
t.Fatal("expected configured token to be accepted")
}
if validBootstrapToken("wrong") {
t.Fatal("expected wrong token to be rejected")
}
t.Setenv("SENSE_BOOTSTRAP_TOKEN", "short")
if validBootstrapToken("short") {
t.Fatal("expected short configured token to be rejected")
}
}
+19 -5
View File
@@ -1,9 +1,8 @@
package apis
import (
"github.com/gin-gonic/gin/binding"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"golang.org/x/crypto/bcrypt"
"github.com/gin-gonic/gin/binding"
"net/http"
"github.com/gin-gonic/gin"
@@ -15,6 +14,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
)
type SysUser struct {
@@ -113,6 +113,10 @@ func (e SysUser) Insert(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
if err = service.ValidatePassword(req.Password); err != nil {
e.Error(http.StatusBadRequest, err, err.Error())
return
}
// 设置创建人
req.SetCreateBy(user.GetUserId(c))
err = s.Insert(&req)
@@ -306,6 +310,11 @@ func (e SysUser) ResetPwd(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
if err = service.ValidatePassword(req.Password); err != nil {
handler.WriteIdentityAudit(c, "1", "管理员重置密码失败", user.GetUserName(c))
e.Error(http.StatusBadRequest, err, err.Error())
return
}
req.SetUpdateBy(user.GetUserId(c))
@@ -314,9 +323,11 @@ func (e SysUser) ResetPwd(c *gin.Context) {
err = s.ResetPwd(&req, p)
if err != nil {
handler.WriteIdentityAudit(c, "1", "管理员重置密码失败", user.GetUserName(c))
e.Logger.Error(err)
return
}
handler.WriteIdentityAudit(c, "2", "管理员重置密码成功", user.GetUserName(c))
e.OK(req.GetId(), "更新成功")
}
@@ -346,18 +357,21 @@ func (e SysUser) UpdatePwd(c *gin.Context) {
// 数据权限检查
p := actions.GetPermissionFromContext(c)
var hash []byte
if hash, err = bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost); err != nil {
req.NewPassword = string(hash)
if err = service.ValidatePassword(req.NewPassword); err != nil {
handler.WriteIdentityAudit(c, "1", "修改密码失败", user.GetUserName(c))
e.Error(http.StatusBadRequest, err, err.Error())
return
}
err = s.UpdatePwd(user.GetUserId(c), req.OldPassword, req.NewPassword, p)
if err != nil {
handler.WriteIdentityAudit(c, "1", "修改密码失败", user.GetUserName(c))
e.Logger.Error(err)
e.Error(http.StatusForbidden, err, "密码修改失败")
return
}
handler.WriteIdentityAudit(c, "2", "修改密码成功", user.GetUserName(c))
e.OK(nil, "密码修改成功")
}
+1 -1
View File
@@ -3,10 +3,10 @@ package router
import (
"os"
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
// InitRouter 路由初始化,不要怀疑,这里用到了
@@ -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)
}
}
-1
View File
@@ -9,7 +9,6 @@ import (
)
func init() {
routerCheckRole = append(routerCheckRole, registerSysApiRouter)
}
// registerSysApiRouter
@@ -9,7 +9,6 @@ import (
)
func init() {
routerCheckRole = append(routerCheckRole, registerSysConfigRouter)
}
// 需认证的路由代码
+4 -4
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
@@ -24,9 +24,9 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r.DELETE("", api.Delete)
}
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
r1.GET("/deptTree", api.Get2Tree)
}
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
//r1.GET("/menuids", api.GetMenuIDS)
}
}
}
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
r.PUT("/:id", api.Update)
r.DELETE("", api.Delete)
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func registerSysRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r.PUT("/:id", api.Update)
r.DELETE("", api.Delete)
}
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
r1.PUT("/role-status", api.Update2Status)
r1.PUT("/roledatascope", api.Update2DataScope)
+3 -2
View File
@@ -68,6 +68,8 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
v1 := r.Group("/api/v1")
{
v1.GET("/captcha", apis.System{}.GenerateCaptchaHandler)
v1.POST("/bootstrap", apis.System{}.Bootstrap)
v1.POST("/login", authMiddleware.LoginHandler)
// Refresh time can be longer than token timeout
v1.GET("/refresh_token", authMiddleware.RefreshHandler)
@@ -81,8 +83,7 @@ func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlewar
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect)
//v1.GET("/menuTreeselect", api.GetMenuTreeSelect)
v1auth.GET("/roleDeptTreeselect/:roleId", api2.GetDeptTreeRoleSelect)
v1auth.POST("/logout", handler.LogOut)
}
v1.Group("").Use(authMiddleware.MiddlewareFunc()).POST("/logout", handler.LogOut)
}
+9 -6
View File
@@ -1,11 +1,11 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
@@ -24,16 +24,19 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r.DELETE("", api.Delete)
}
user := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
user := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(actions.PermissionAction())
{
user.GET("/profile", api.GetProfile)
user.POST("/avatar", api.InsetAvatar)
user.PUT("/pwd/set", api.UpdatePwd)
user.PUT("/pwd/reset", api.ResetPwd)
user.PUT("/status", api.UpdateStatus)
}
userAdmin := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
{
userAdmin.PUT("/pwd/reset", api.ResetPwd)
userAdmin.PUT("/status", api.UpdateStatus)
}
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc())
{
v1auth.GET("/getinfo", api.GetInfo)
}
}
}
@@ -0,0 +1,23 @@
package service
import (
"errors"
"unicode/utf8"
)
const (
MinPasswordLength = 6
MaxPasswordBytes = 72
)
// ValidatePassword enforces the frozen Sense policy. Lowercase-only passwords
// are valid; complexity rules are intentionally not added here.
func ValidatePassword(password string) error {
if utf8.RuneCountInString(password) < MinPasswordLength {
return errors.New("密码至少需要 6 个字符")
}
if len(password) > MaxPasswordBytes {
return errors.New("密码不能超过 72 个字节")
}
return nil
}
@@ -0,0 +1,26 @@
package service
import (
"strings"
"testing"
)
func TestValidatePassword(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{name: "lowercase accepted", value: "chengma", wantErr: false},
{name: "six characters accepted", value: "abcdef", wantErr: false},
{name: "too short", value: "abcde", wantErr: true},
{name: "bcrypt byte limit", value: strings.Repeat("a", MaxPasswordBytes+1), wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidatePassword(tt.value); (got != nil) != tt.wantErr {
t.Fatalf("ValidatePassword() error = %v, wantErr %v", got, tt.wantErr)
}
})
}
}
+8 -6
View File
@@ -103,12 +103,13 @@ func (e *SysRole) Insert(c *dto.SysRoleInsertReq, cb *casbin.SyncedEnforcer) err
return err
}
mp := make(map[string]interface{}, 0)
mp := make(map[string]struct{})
polices := make([][]string, 0)
for _, menu := range dataMenu {
for _, api := range menu.SysApi {
if mp[data.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
mp[data.RoleKey+"-"+api.Path+"-"+api.Action] = ""
key := data.RoleKey + "-" + api.Path + "-" + api.Action
if _, exists := mp[key]; !exists {
mp[key] = struct{}{}
polices = append(polices, []string{data.RoleKey, api.Path, api.Action})
}
}
@@ -169,12 +170,13 @@ func (e *SysRole) Update(c *dto.SysRoleUpdateReq, cb *casbin.SyncedEnforcer) err
e.Log.Errorf("delete policy error:%s", err)
return err
}
mp := make(map[string]interface{}, 0)
mp := make(map[string]struct{})
polices := make([][]string, 0)
for _, menu := range mlist {
for _, api := range menu.SysApi {
if mp[model.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
mp[model.RoleKey+"-"+api.Path+"-"+api.Action] = ""
key := model.RoleKey + "-" + api.Path + "-" + api.Action
if _, exists := mp[key]; !exists {
mp[key] = struct{}{}
//_, err = cb.AddNamedPolicy("p", model.RoleKey, api.Path, api.Action)
polices = append(polices, []string{model.RoleKey, api.Path, api.Action})
}
+5 -2
View File
@@ -161,6 +161,9 @@ func (e *SysUser) UpdateStatus(c *dto.UpdateSysUserStatusReq, p *actions.DataPer
// ResetPwd 重置用户密码
func (e *SysUser) ResetPwd(c *dto.ResetSysUserPwdReq, p *actions.DataPermission) error {
var err error
if err = ValidatePassword(c.Password); err != nil {
return err
}
var model models.SysUser
db := e.Orm.Scopes(
actions.Permission(model.TableName(), p),
@@ -204,8 +207,8 @@ func (e *SysUser) Remove(c *dto.SysUserById, p *actions.DataPermission) error {
func (e *SysUser) UpdatePwd(id int, oldPassword, newPassword string, p *actions.DataPermission) error {
var err error
if newPassword == "" {
return nil
if err = ValidatePassword(newPassword); err != nil {
return err
}
c := &models.SysUser{}
+132
View File
@@ -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)
}
@@ -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")
}
}
@@ -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
}
@@ -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")
}
}
@@ -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" }
@@ -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
}
@@ -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)
}
}
@@ -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"`
}
@@ -0,0 +1,124 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
type identityRoleDefinition struct {
name string
key string
sort int
menuIDs []int
policies [][2]string
}
type identityCasbinRule 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 (identityCasbinRule) TableName() string { return "casbin_rule" }
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseIdentityBaseline)
}
func migrateSenseIdentityBaseline(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&identityCasbinRule{}); err != nil {
return err
}
resetPasswordMenu, err := ensureIdentityButton(tx, "SenseResetPassword", "重置用户密码", "admin:sysUser:resetPassword", 154)
if err != nil {
return err
}
changeStatusMenu, err := ensureIdentityButton(tx, "SenseChangeUserStatus", "启停用户", "admin:sysUser:edit", 155)
if err != nil {
return err
}
definitions := []identityRoleDefinition{
{name: "系统管理员", key: "admin", sort: 1},
{
name: "实施运维", key: "implementation_operator", sort: 10,
menuIDs: []int{2, 211, 212, 216, 248, 250},
policies: [][2]string{{"/api/v1/sys-login-log", "GET"}, {"/api/v1/sys-login-log/:id", "GET"}, {"/api/v1/sys-opera-log", "GET"}, {"/api/v1/sys-opera-log/:id", "GET"}, {"/api/v1/dict-data/option-select", "GET"}},
},
{
name: "站点管理员", key: "site_admin", sort: 20,
menuIDs: []int{2, 3, 43, 44, 45, resetPasswordMenu.MenuId, changeStatusMenu.MenuId},
policies: [][2]string{{"/api/v1/sys-user", "GET"}, {"/api/v1/sys-user/:id", "GET"}, {"/api/v1/sys-user", "POST"}, {"/api/v1/sys-user", "PUT"}, {"/api/v1/user/pwd/reset", "PUT"}, {"/api/v1/user/status", "PUT"}, {"/api/v1/deptTree", "GET"}, {"/api/v1/post", "GET"}, {"/api/v1/role", "GET"}, {"/api/v1/dict-data/option-select", "GET"}},
},
{name: "只读用户", key: "viewer", sort: 30},
}
for _, definition := range definitions {
role, err := ensureIdentityRole(tx, definition)
if err != nil {
return err
}
if err = attachIdentityMenus(tx, &role, definition.menuIDs); err != nil {
return err
}
for _, policy := range definition.policies {
rule := identityCasbinRule{Ptype: "p", V0: definition.key, 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 ensureIdentityRole(tx *gorm.DB, definition identityRoleDefinition) (models.SysRole, error) {
var role models.SysRole
err := tx.Where("role_key = ?", definition.key).First(&role).Error
if err != nil && err != gorm.ErrRecordNotFound {
return role, err
}
if err == gorm.ErrRecordNotFound {
role = models.SysRole{RoleName: definition.name, RoleKey: definition.key, RoleSort: definition.sort, Status: "2", Admin: definition.key == "admin", DataScope: "1", Remark: "Sense 内置最小权限角色"}
return role, tx.Create(&role).Error
}
err = tx.Model(&role).Updates(map[string]interface{}{"role_name": definition.name, "status": "2", "role_sort": definition.sort, "admin": definition.key == "admin"}).Error
return role, err
}
func ensureIdentityButton(tx *gorm.DB, name, title, permission string, apiID int) (models.SysMenu, error) {
menu := models.SysMenu{MenuName: name, Title: title, MenuType: "F", Action: "PUT", Permission: permission, ParentId: 3, Paths: "/0/2/3", Sort: 25, Visible: "1", IsFrame: "1"}
if err := tx.Where("menu_name = ?", name).FirstOrCreate(&menu).Error; err != nil {
return menu, err
}
var api models.SysApi
if err := tx.First(&api, apiID).Error; err != nil {
return menu, err
}
return menu, tx.Model(&menu).Association("SysApi").Append(&api)
}
func attachIdentityMenus(tx *gorm.DB, role *models.SysRole, menuIDs []int) error {
if len(menuIDs) == 0 {
return nil
}
var menus []models.SysMenu
if err := tx.Where("menu_id IN ?", menuIDs).Find(&menus).Error; err != nil {
return err
}
return tx.Model(role).Association("SysMenu").Append(&menus)
}
@@ -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)
}
+4 -4
View File
@@ -3,9 +3,9 @@ package middleware
import (
"time"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
"github.com/go-admin-team/go-admin-core/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
)
// AuthInit jwt验证new
@@ -19,7 +19,7 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
}
}
return jwt.New(&jwt.GinJWTMiddleware{
Realm: "test zone",
Realm: "Sense",
Key: []byte(config.JwtConfig.Secret),
Timeout: timeout,
MaxRefresh: time.Hour,
@@ -28,9 +28,9 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
Authenticator: handler.Authenticator,
Authorizator: handler.Authorizator,
Unauthorized: handler.Unauthorized,
TokenLookup: "header: Authorization, query: token, cookie: jwt",
TokenLookup: "header: Authorization, cookie: sense_session",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
})
}
}
+37 -28
View File
@@ -6,7 +6,6 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
@@ -15,7 +14,6 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/mssola/user_agent"
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
)
func PayloadFunc(data interface{}) jwt.MapClaims {
@@ -28,7 +26,7 @@ func PayloadFunc(data interface{}) jwt.MapClaims {
jwt.RoleKey: r.RoleKey,
jwt.NiceKey: u.Username,
jwt.DataScopeKey: r.DataScope,
jwt.RoleNameKey: r.RoleName,
jwt.RoleNameKey: r.RoleKey,
}
}
return jwt.MapClaims{}
@@ -108,36 +106,44 @@ func Authenticator(c *gin.Context) (interface{}, error) {
// LoginLogToDB Write log to database
func LoginLogToDB(c *gin.Context, status string, msg string, username string) {
if !config.LoggerConfig.EnabledDB {
WriteIdentityAudit(c, status, msg, username)
}
// WriteIdentityAudit persists identity/security events synchronously. It never
// records request bodies, credentials, tokens, captcha answers, or cookies.
func WriteIdentityAudit(c *gin.Context, status string, msg string, username string) {
log := api.GetRequestLogger(c)
db, err := pkg.GetOrm(c)
if err != nil {
log.Errorf("identity audit database unavailable: %s", err.Error())
return
}
log := api.GetRequestLogger(c)
l := make(map[string]interface{})
ua := user_agent.New(c.Request.UserAgent())
l["ipaddr"] = common.GetClientIP(c)
l["loginLocation"] = "" // pkg.GetLocation(common.GetClientIP(c),gaConfig.ExtConfig.AMap.Key)
l["loginTime"] = pkg.GetCurrentTime()
l["status"] = status
l["remark"] = c.Request.UserAgent()
browserName, browserVersion := ua.Browser()
l["browser"] = browserName + " " + browserVersion
l["os"] = ua.OS()
l["platform"] = ua.Platform()
l["username"] = username
l["msg"] = msg
q := sdk.Runtime.GetMemoryQueue(c.Request.Host)
message, err := sdk.Runtime.GetStreamMessage("", global.LoginLog, l)
if err != nil {
log.Errorf("GetStreamMessage error, %s", err.Error())
//日志报错错误,不中断请求
} else {
err = q.Append(message)
if err != nil {
log.Errorf("Append message error, %s", err.Error())
}
l := models.SysLoginLog{
Username: truncateAuditText(username, 128),
Status: status,
Ipaddr: truncateAuditText(common.GetClientIP(c), 255),
LoginLocation: "",
Browser: truncateAuditText(browserName+" "+browserVersion, 255),
Os: truncateAuditText(ua.OS(), 255),
Platform: truncateAuditText(ua.Platform(), 255),
LoginTime: pkg.GetCurrentTime(),
Remark: truncateAuditText(c.Request.Method+" "+c.FullPath(), 255),
Msg: truncateAuditText(msg, 255),
}
if err = db.Create(&l).Error; err != nil {
log.Errorf("identity audit write failed: %s", err.Error())
}
}
func truncateAuditText(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
// LogOut
@@ -164,7 +170,7 @@ func Authorizator(data interface{}, c *gin.Context) bool {
if v, ok := data.(map[string]interface{}); ok {
u, _ := v["user"].(models.SysUser)
r, _ := v["role"].(models.SysRole)
c.Set("role", r.RoleName)
c.Set("role", r.RoleKey)
c.Set("roleIds", r.RoleId)
c.Set("userId", u.UserId)
c.Set("userName", u.Username)
@@ -175,6 +181,9 @@ func Authorizator(data interface{}, c *gin.Context) bool {
}
func Unauthorized(c *gin.Context, code int, message string) {
if c.FullPath() != "/api/v1/login" {
WriteIdentityAudit(c, "1", "未认证访问被拒绝", "")
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": message,
@@ -0,0 +1,12 @@
package handler
import "testing"
func TestTruncateAuditText(t *testing.T) {
if got := truncateAuditText("中文审计记录", 3); got != "中文审" {
t.Fatalf("unexpected truncation: %q", got)
}
if got := truncateAuditText("short", 10); got != "short" {
t.Fatalf("unexpected unchanged value: %q", got)
}
}
+45 -1
View File
@@ -95,11 +95,55 @@ func LoggerToFile() gin.HandlerFunc {
log.Fields(map[string]interface{}{})
}()
if c.Request.Method != "OPTIONS" && config.LoggerConfig.EnabledDB && statusCode != 404 {
SetDBOperLog(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, body, result, statusBus)
SetDBOperLog(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, sanitizeAuditJSON(body), sanitizeAuditJSON(result), statusBus)
}
}
}
func sanitizeAuditJSON(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
var decoded interface{}
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
return "[内容已隐藏]"
}
redactAuditValue(decoded)
encoded, err := json.Marshal(decoded)
if err != nil {
return "[内容已隐藏]"
}
return string(encoded)
}
func redactAuditValue(value interface{}) {
switch current := value.(type) {
case map[string]interface{}:
for key, item := range current {
if isSensitiveAuditKey(key) {
current[key] = "[已隐藏]"
continue
}
redactAuditValue(item)
}
case []interface{}:
for _, item := range current {
redactAuditValue(item)
}
}
}
func isSensitiveAuditKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
switch normalized {
case "password", "oldpassword", "newpassword", "onvifusername", "onvifpassword", "rtspusername", "rtsppassword",
"token", "refreshtoken", "secret", "code", "uuid":
return true
default:
return false
}
}
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
@@ -0,0 +1,37 @@
package middleware
import (
"strings"
"testing"
)
func TestSanitizeAuditJSON(t *testing.T) {
input := `{"username":"operator","password":"secret-value","nested":{"newPassword":"another-secret"},"token":"jwt-value"}`
got := sanitizeAuditJSON(input)
for _, secret := range []string{"secret-value", "another-secret", "jwt-value"} {
if strings.Contains(got, secret) {
t.Fatalf("sensitive value leaked: %s", got)
}
}
if !strings.Contains(got, "operator") {
t.Fatalf("non-sensitive context was unexpectedly removed: %s", got)
}
}
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)
}
}
@@ -4,6 +4,7 @@ import (
"github.com/casbin/casbin/v2/util"
"net/http"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/api"
@@ -49,6 +50,7 @@ func AuthCheckRole() gin.HandlerFunc {
c.Next()
} else {
log.Warnf("isTrue: %v role: %s method: %s path: %s message: %s", res, v["rolekey"], c.Request.Method, c.Request.URL.Path, "当前request无权限,请管理员确认!")
handler.WriteIdentityAudit(c, "1", "拒绝访问 "+c.Request.Method+" "+c.FullPath(), userNameFromClaims(v))
c.JSON(http.StatusOK, gin.H{
"code": 403,
"msg": "对不起,您没有该接口访问权限,请联系管理员",
@@ -59,3 +61,10 @@ func AuthCheckRole() gin.HandlerFunc {
}
}
func userNameFromClaims(claims jwtauth.MapClaims) string {
if value, ok := claims["nice"].(string); ok {
return value
}
return ""
}
+1 -35
View File
@@ -6,38 +6,4 @@ type UrlInfo struct {
}
// CasbinExclude casbin 排除的路由列表
var CasbinExclude = []UrlInfo{
{Url: "/api/v1/dict/type-option-select", Method: "GET"},
{Url: "/api/v1/dict-data/option-select", Method: "GET"},
{Url: "/api/v1/deptTree", Method: "GET"},
{Url: "/api/v1/db/tables/page", Method: "GET"},
{Url: "/api/v1/db/columns/page", Method: "GET"},
{Url: "/api/v1/gen/toproject/:tableId", Method: "GET"},
{Url: "/api/v1/gen/todb/:tableId", Method: "GET"},
{Url: "/api/v1/gen/tabletree", Method: "GET"},
{Url: "/api/v1/gen/preview/:tableId", Method: "GET"},
{Url: "/api/v1/gen/apitofile/:tableId", Method: "GET"},
{Url: "/api/v1/getCaptcha", Method: "GET"},
{Url: "/api/v1/getinfo", Method: "GET"},
{Url: "/api/v1/menuTreeselect", Method: "GET"},
{Url: "/api/v1/menurole", Method: "GET"},
{Url: "/api/v1/menuids", Method: "GET"},
{Url: "/api/v1/roleMenuTreeselect/:roleId", Method: "GET"},
{Url: "/api/v1/roleDeptTreeselect/:roleId", Method: "GET"},
{Url: "/api/v1/refresh_token", Method: "GET"},
{Url: "/api/v1/configKey/:configKey", Method: "GET"},
{Url: "/api/v1/app-config", Method: "GET"},
{Url: "/api/v1/user/profile", Method: "GET"},
{Url: "/info", Method: "GET"},
{Url: "/api/v1/login", Method: "POST"},
{Url: "/api/v1/logout", Method: "POST"},
{Url: "/api/v1/user/avatar", Method: "POST"},
{Url: "/api/v1/user/pwd", Method: "PUT"},
{Url: "/api/v1/metrics", Method: "GET"},
{Url: "/api/v1/health", Method: "GET"},
{Url: "/", Method: "GET"},
{Url: "/api/v1/server-monitor", Method: "GET"},
{Url: "/api/v1/public/uploadFile", Method: "POST"},
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
{Url: "/api/v1/sys-user", Method: "PUT"},
}
var CasbinExclude = []UrlInfo{}
@@ -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=
+3
View File
@@ -0,0 +1,3 @@
# 首次创建管理员时临时设置为至少 32 个字符的随机值。
# 初始化成功后应从运行环境移除;不要把真实值写入本文件或仓库。
SENSE_BOOTSTRAP_TOKEN=
+1 -1
View File
@@ -19,7 +19,7 @@ settings:
stdout: '' #控制台日志,启用后,不输出到文件
# 日志等级, trace, debug, info, warn, error, fatal
level: info
# 数据库日志开关
# 通用操作数据库日志开关;登录、退出、密码和拒绝访问等身份审计始终写入数据库。
enableddb: false
jwt:
# 必填。生产环境至少 32 个字符;不得提交真实值。
+25
View File
@@ -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 })
}
+1 -1
View File
@@ -1,6 +1,6 @@
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
const TokenKey = 'Sense-Admin-Token'
export function getToken() {
return Cookies.get(TokenKey)
+7 -8
View File
@@ -353,8 +353,6 @@ export default {
open: false,
// 部门名称
deptName: undefined,
// 默认密码
initPassword: undefined,
// 日期范围
dateRange: [],
// 状态数据字典
@@ -400,7 +398,10 @@ export default {
username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }],
nickName: [{ required: true, message: '用户昵称不能为空', trigger: 'blur' }],
deptId: [{ required: true, message: '归属部门不能为空', trigger: 'blur' }],
password: [{ required: true, message: '用户密码不能为空', trigger: 'blur' }],
password: [
{ required: true, message: '用户密码不能为空', trigger: 'blur' },
{ min: 6, max: 72, message: '密码长度为 6 至 72 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '邮箱地址不能为空', trigger: 'blur' },
{ type: 'email', message: "'请输入正确的邮箱地址", trigger: ['blur', 'change'] }
@@ -427,9 +428,6 @@ export default {
this.getDicts('sys_user_sex').then(response => {
this.sexOptions = response.data
})
this.getConfigKey('sys_user_initPassword').then(response => {
this.initPassword = response.data.configValue
})
},
methods: {
/** 查询用户列表 */
@@ -556,7 +554,6 @@ export default {
})
this.open = true
this.title = '添加用户'
this.form.password = this.initPassword
},
/** 修改按钮操作 */
handleUpdate(row) {
@@ -580,7 +577,9 @@ export default {
handleResetPwd(row) {
this.$prompt('请输入"' + row.username + '"的新密码', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消'
cancelButtonText: '取消',
inputPattern: /^.{6,72}$/,
inputErrorMessage: '密码长度为 6 至 72 个字符'
}).then(({ value }) => {
resetUserPwd(row.userId, value).then(response => {
if (response.code === 200) {
@@ -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 }
}
+305
View File
@@ -0,0 +1,305 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<el-form ref="queryForm" :model="queryParams" :inline="true" class="search-form">
<el-form-item label="设备信息" prop="keyword">
<el-input v-model="queryParams.keyword" placeholder="名称或安装位置" clearable size="small" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="设备类型" prop="modality">
<el-select v-model="queryParams.modality" placeholder="全部类型" clearable size="small">
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable size="small">
<el-option label="待接入" value="pending" />
<el-option label="已停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" :icon="Search" @click="handleQuery">搜索</el-button>
<el-button size="small" :icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar mb8">
<el-button v-permisaction="['sense:device:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增设备</el-button>
</div>
<el-table v-loading="loading" :data="deviceList" border stripe>
<el-table-column label="设备名称" min-width="150" prop="name" />
<el-table-column label="安装位置" min-width="180" prop="location" show-overflow-tooltip />
<el-table-column label="设备类型" width="110" align="center">
<template #default="scope">{{ modalityLabel(scope.row.modality) }}</template>
</el-table-column>
<el-table-column label="能力" min-width="130">
<template #default="scope">
<el-tag v-for="capability in scope.row.capabilities" :key="capability" size="small" class="capability-tag">{{ modalityLabel(capability) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="接入能力" width="130" align="center">
<template #default="scope">
<el-tag :type="scope.row.adapterStatus === 'ready' ? 'success' : 'warning'">
{{ scope.row.adapterStatus === 'ready' ? '可接入' : '适配器未就绪' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="凭据" width="120" align="center">
<template #default="scope">
<el-tag :type="scope.row.onvifCredentialConfigured ? 'success' : 'info'">
{{ scope.row.onvifCredentialConfigured ? '已配置' : '未配置' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'disabled' ? 'info' : 'success'">{{ scope.row.status === 'disabled' ? '已停用' : '待接入' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="80" align="center" prop="version" />
<el-table-column label="更新时间" width="180" align="center">
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" align="center" fixed="right" class-name="small-padding fixed-width">
<template #default="scope">
<el-button v-permisaction="['sense:device:edit']" type="primary" link size="small" :icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
<el-divider direction="vertical" />
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
<el-divider direction="vertical" />
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="queryParams.pageIndex" v-model:page-size="queryParams.pageSize" :total="total" @pagination="getList" />
<el-dialog v-model="deviceOpen" :title="deviceTitle" width="620px" :close-on-click-modal="false">
<el-form ref="deviceForm" :model="deviceForm" :rules="deviceRules" label-width="100px">
<el-form-item label="设备名称" prop="name">
<el-input v-model="deviceForm.name" maxlength="128" show-word-limit placeholder="例如:东门摄像机" />
</el-form-item>
<el-form-item label="安装位置" prop="location">
<el-input v-model="deviceForm.location" maxlength="255" show-word-limit placeholder="例如:教学楼一楼东门" />
</el-form-item>
<el-form-item label="设备类型" prop="modality">
<el-select v-model="deviceForm.modality" :disabled="Boolean(deviceForm.id)" @change="handleModalityChange">
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span v-if="deviceForm.modality !== 'video'" class="form-tip">该类型尚未提供接入适配器,将显示“适配器未就绪”。</span>
</el-form-item>
<el-form-item label="设备能力" prop="capabilities">
<el-checkbox-group v-model="deviceForm.capabilities">
<el-checkbox v-for="item in modalityOptions" :key="item.value" :label="item.value">{{ item.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitDevice">确定</el-button>
<el-button @click="deviceOpen = false">取消</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="credentialOpen" title="更新摄像头凭据" width="620px" :close-on-click-modal="false" @closed="resetCredential">
<el-alert title="凭据只可写入,保存后不会回显。更新成功会请求后续接入流程重新验证。" type="warning" :closable="false" show-icon class="credential-alert" />
<el-form ref="credentialForm" :model="credentialForm" :rules="credentialRules" label-width="120px" autocomplete="off">
<el-form-item label="ONVIF 用户名" prop="onvifUsername">
<el-input v-model="credentialForm.onvifUsername" maxlength="255" autocomplete="off" />
</el-form-item>
<el-form-item label="ONVIF 密码" prop="onvifPassword">
<el-input v-model="credentialForm.onvifPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
</el-form-item>
<el-form-item label="RTSP 凭据">
<el-checkbox v-model="credentialForm.rtspSameAsOnvif">与 ONVIF 相同</el-checkbox>
</el-form-item>
<template v-if="!credentialForm.rtspSameAsOnvif">
<el-form-item label="RTSP 用户名" prop="rtspUsername">
<el-input v-model="credentialForm.rtspUsername" maxlength="255" autocomplete="off" />
</el-form-item>
<el-form-item label="RTSP 密码" prop="rtspPassword">
<el-input v-model="credentialForm.rtspPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
</el-form-item>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitCredential">安全保存</el-button>
<el-button @click="credentialOpen = false">取消</el-button>
</div>
</template>
</el-dialog>
</el-card>
</template>
</BasicLayout>
</template>
<script>
import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
addDevice,
disableDevice,
getDevice,
listDevices,
updateDevice,
updateDeviceCredentials
} from '@/api/sense/device'
import {
createDevicePayload,
credentialPayload,
disableDevicePayload,
updateDevicePayload
} from './devicePayload'
export default {
name: 'SenseDeviceManage',
setup() {
return { Edit, Key, Plus, Refresh, Search }
},
data() {
return {
loading: false,
total: 0,
deviceList: [],
deviceOpen: false,
credentialOpen: false,
deviceTitle: '',
queryParams: { pageIndex: 1, pageSize: 10, keyword: '', modality: '', status: '' },
modalityOptions: [
{ value: 'video', label: '视频设备' },
{ value: 'radar', label: '雷达' },
{ value: 'contact', label: '门磁' },
{ value: 'button', label: '按钮' },
{ value: 'wearable', label: '穿戴设备' },
{ value: 'other', label: '其他' }
],
deviceForm: {},
credentialForm: {},
deviceRules: {
name: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
modality: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
capabilities: [{ type: 'array', required: true, min: 1, message: '至少选择一项设备能力', trigger: 'change' }]
},
credentialRules: {
onvifUsername: [{ required: true, message: 'ONVIF 用户名不能为空', trigger: 'blur' }],
onvifPassword: [{ required: true, message: 'ONVIF 密码不能为空', trigger: 'blur' }],
rtspUsername: [{ required: true, message: 'RTSP 用户名不能为空', trigger: 'blur' }],
rtspPassword: [{ required: true, message: 'RTSP 密码不能为空', trigger: 'blur' }]
}
}
},
created() {
this.resetDevice()
this.resetCredential()
this.getList()
},
methods: {
getList() {
this.loading = true
listDevices(this.queryParams).then(response => {
this.deviceList = response.data.list
this.total = response.data.count
}).finally(() => {
this.loading = false
})
},
modalityLabel(value) {
return this.modalityOptions.find(item => item.value === value)?.label || value
},
resetDevice() {
this.deviceForm = { id: undefined, name: '', location: '', modality: 'video', capabilities: ['video'], version: undefined }
this.$nextTick(() => this.resetForm('deviceForm'))
},
resetCredential() {
this.credentialForm = {
id: undefined,
onvifUsername: '',
onvifPassword: '',
rtspSameAsOnvif: true,
rtspUsername: '',
rtspPassword: '',
version: undefined
}
this.$nextTick(() => this.resetForm('credentialForm'))
},
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleAdd() {
this.resetDevice()
this.deviceTitle = '新增设备'
this.deviceOpen = true
},
handleUpdate(row) {
this.resetDevice()
getDevice(row.id).then(response => {
this.deviceForm = { ...response.data, capabilities: [...response.data.capabilities] }
this.deviceTitle = '编辑设备'
this.deviceOpen = true
})
},
handleModalityChange(value) {
this.deviceForm.capabilities = [value]
},
submitDevice() {
this.$refs.deviceForm.validate(valid => {
if (!valid) return
const request = this.deviceForm.id
? updateDevice(this.deviceForm.id, updateDevicePayload(this.deviceForm))
: addDevice(createDevicePayload(this.deviceForm))
request.then(response => {
this.msgSuccess(response.msg)
this.deviceOpen = false
this.getList()
})
})
},
handleCredential(row) {
this.resetCredential()
this.credentialForm.id = row.id
this.credentialForm.version = row.version
this.credentialOpen = true
},
submitCredential() {
this.$refs.credentialForm.validate(valid => {
if (!valid) return
updateDeviceCredentials(this.credentialForm.id, credentialPayload(this.credentialForm)).then(response => {
this.msgSuccess(response.msg)
this.credentialOpen = false
this.getList()
})
})
},
handleDisable(row) {
this.$confirm(`停用“${row.name}”后,后续接入流程不会继续使用该设备。是否继续?`, '停用设备', {
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning'
}).then(() => disableDevice(row.id, disableDevicePayload(row.version))).then(response => {
this.msgSuccess(response.msg)
this.getList()
}).catch(() => {})
}
}
}
</script>
<style scoped>
.capability-tag {
margin-right: 4px;
}
.form-tip {
display: block;
width: 100%;
color: #909399;
font-size: 12px;
line-height: 20px;
}
.credential-alert {
margin-bottom: 18px;
}
</style>
@@ -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 })
})
})
+11 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: 7619efaad2d1e5567ceb9474b8a059c48f34973e
synchronized_at: 2026-08-14T03:13:56Z
wiki_revision: 6880a2918c46be27738467361d8d3f923f33fc69
synchronized_at: 2026-08-14T07:02:35Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -83,7 +83,15 @@ Sense/Brain 生成事件
<!-- sense-runtime:start -->
## Sense 新代码入口
Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。初始可见模块仅保留用户、角色、菜单和登录/操作审计;上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。仓库不提供默认账号、默认密码或可用 JWT 密钥。
Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。
工单 #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` 只读;停用替代物理删除。
<!-- sense-runtime:end -->
<!-- sense-mvp:start -->
+23 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: 85ddfe99c2f9c97b18d5e39849a50b8c5fca276e
synchronized_at: 2026-08-14T01:17:28Z
wiki_revision: 013865e0b48d156bb984515d6fabd42139829459
synchronized_at: 2026-08-14T07:02:39Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -60,13 +60,22 @@ synchronized_at: 2026-08-14T01:17:28Z
- GoAdmin/go-admin-ui 的固定上游 commit、许可证记录和裁剪模块;
- Go、Node、Python、CUDA、PostgreSQL、MediaMTX 的精确版本;
- Sense/Bell 数据库 schema、Cookie 名称、部署域名和备份命令;
- Bell 数据库 schema、Sense/Bell 部署域名和备份命令;Sense 浏览器 Cookie 与后端会话名称已由工单 #64 固定;
- Brain 模型、推理 FPS、硬件容量和商用许可;
- 试点最终留存期、通知供应商和验收阈值;
- 三个交付单元的正式版本与发布流程。
未确认项不得从旧代码或模板默认值推断为当前事实。
## Sense 身份与权限规则
- Sense 仅使用自己的用户库、JWT 密钥和会话;浏览器 Cookie 为 `Sense-Admin-Token`,后端可接受的独立会话 Cookie 为 `sense_session`,不得与 Bell 共享。
- 当前身份管理固定四个角色:`admin`(系统管理员)、`implementation_operator`(实施/运维)、`site_admin`(站点管理员)、`viewer`(只读用户)。业务设备权限由后续设备工单在这些角色上扩展,不复制新的身份事实源。
- 仓库和迁移不创建默认账户或默认密码。首位管理员只能通过仓库外的 `SENSE_BOOTSTRAP_TOKEN` 一次性创建;令牌至少 32 个字符,初始化完成后必须从进程环境移除,已有任一用户时接口拒绝再次初始化。
- Sense 密码至少 6 个字符、最多 72 字节,不要求大小写、数字或符号组合,允许全小写;交付时仍应使用难猜且不复用的密码。
- 登录成功/失败、登出、密码变更和鉴权拒绝必须留下身份审计;密码、令牌、Cookie、验证码、数据库连接和摄像头凭据不得进入审计正文。
- `site_admin` 可在账户维护流程中读取角色、部门、岗位和字典等必要支撑数据,但不能修改角色、菜单或系统配置;`implementation_operator` 与 `viewer` 不具备账户管理权限。未注册的配置和接口管理路由对所有角色返回 404。
<!-- sense-mvp:start -->
## Sense 旧 MVP 规则状态
@@ -85,3 +94,14 @@ synchronized_at: 2026-08-14T01:17:28Z
## 旧实现迁移边界
旧 Sense 中已验证的中文设备字段、请求白名单、凭据隔离和媒体链路行为可作为迁移验收用例;不得把旧自研基础框架直接复制到新 `dev`。
<!-- sense-device-ledger:start -->
## Sense 设备台账与凭据规则
- Device 是 Sense 内的设备根实体,类型包括视频、雷达、门磁、按钮、穿戴和其他;16 路仍只是默认交付配额,分页和台账容量不得硬编码为 16。
- 当前仅视频设备具备可用接入适配器;其余类型必须显示“适配器未就绪”,不允许通过虚假成功状态掩盖能力缺口。
- 设备台账支持列表、新建、编辑和停用;停用保留审计与后续关联,不提供物理删除。编辑、停用和凭据更新均使用版本号检测并发冲突。
- 摄像头 ONVIF 与 RTSP 凭据按设备和用途分离,以 AES-256-GCM 密文保存;加密密钥只从外部环境 `SENSE_CREDENTIAL_KEY` 注入,不写入数据库、仓库、工单、Wiki 或日志。
- 凭据只写不可读:HTTP 和页面仅显示“已配置/未配置”,不得回填用户名、密码或密文;更新凭据后只记录状态并请求后续接入流程重试。
- `admin`、`implementation_operator`、`site_admin` 可维护设备与凭据,`viewer` 仅可查看设备台账。
<!-- sense-device-ledger:end -->
+37 -4
View File
@@ -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: e97ea48e8028118b2dcdf4e5fb0199d2ad7e824e
synchronized_at: 2026-08-14T03:14:04Z
wiki_revision: f68bbd8c7a99689e65453ae00c7bbd2c3ee00357
synchronized_at: 2026-08-14T07:02:43Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -114,7 +114,7 @@ pnpm --version
<!-- sense-runtime:start -->
## Sense 本地构建与验证
Sense 后端与前端已由工单 #61 从冻结源码建立。先确认 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,再执行:
Sense 后端与前端已从冻结源码建立。先确认 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,再执行:
```powershell
cd Sense/server
@@ -129,7 +129,40 @@ corepack pnpm@9.15.1 test:unit
corepack pnpm@9.15.1 build:prod
```
数据库固定为 PostgreSQL。复制 `Sense/server/config/settings.yml` 到仓库外,填写连接串和至少 32 个字符的随机 JWT 密钥;仓库模板本身不可直接启动生产服务,也不包含默认管理员。迁移和启动命令分别为 `sense migrate -c <配置路径>` 与 `sense server -c <配置路径>`。详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`.
数据库固定为 PostgreSQL。复制 `Sense/server/config/settings.yml` 到仓库外,填写连接串和至少 32 个字符的随机 JWT 密钥;仓库模板本身不可直接启动生产服务。首次运行先执行 `sense migrate -c <配置路径>`,再通过只存在于当前进程的高熵令牌启动服务并创建首位管理员:
```powershell
$env:SENSE_BOOTSTRAP_TOKEN = Read-Host "输入至少 32 个字符的一次性初始化令牌"
sense server -c <配置路径>
# 在另一个 PowerShell 中输入相同令牌;不要把真实值写入脚本或命令历史
$bootstrapToken = Read-Host "输入一次性初始化令牌"
$bootstrapBody = @{
username = "admin"
password = Read-Host "输入至少 6 个字符的管理员密码"
nickname = "系统管理员"
} | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:<端口>/api/v1/bootstrap" -Headers @{ "X-Sense-Bootstrap-Token" = $bootstrapToken } -ContentType "application/json" -Body $bootstrapBody
Remove-Variable bootstrapToken, bootstrapBody
```
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。生产登录需要先调用验证码接口并提交验证码;自动化集成验证不得通过关闭生产安全约束来冒充生产结果。
身份回归至少覆盖: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 迁移重复执行不增加菜单或权限记录。
<!-- sense-runtime:end -->
+17 -7
View File
@@ -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: d4f2bfcef46493361ba019839b36957ed9947003
synchronized_at: 2026-08-14T01:18:58Z
wiki_revision: 93cda004538c5114c5c4ca8a03b19e3e975b503d
synchronized_at: 2026-08-14T07:03:33Z
<!-- gitea-wiki-mirror:end -->
# 交付文档指南
@@ -84,12 +84,14 @@ synchronized_at: 2026-08-14T01:18:58Z
Sense 面向网管、实施人员和非技术现场人员,菜单按日常任务组织:工作台 → 设备管理 → 视频接入 → 视频服务 → 实时监看 → 区域与警戒线。普通操作优先展示中文状态与下一步,不要求用户理解 ONVIF、RTSP 或 MediaMTX 内部模型。
- 系统管理员:完成一次性安全初始化,维护 Sense 用户与角色,查看操作记录。首次管理员和新用户密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名;仍应由管理员选择难猜且不复用的密码。
- 实施/运维:添加设备、只写更新凭据、在获准网卡发现或手工接入、检查主辅码流、处理媒体进程与对账。
- 站点管理员:查看/维护站点设备,实时监看,绘制和发布区域版本;无权创建系统管理员。
- 只读用户:查看设备、媒体状态、实时画面和区域,不能修改。
- 系统管理员:使用仓库外、至少 32 个字符的一次性初始化令牌创建首位管理员;系统已有用户后不得再次初始化。随后维护 Sense 用户、角色并查看登录和操作记录。仓库没有默认账号或默认密码。
- 实施/运维:查看实施所需日志和字典支撑信息;设备接入、凭据更新、码流检查与媒体服务处置能力由后续业务工单逐步启用。
- 站点管理员:维护普通账户并读取角色、部门、岗位和字典选项,但不能修改角色、菜单或系统配置;设备、实时监看和区域能力由后续业务工单逐步启用。
- 只读用户:当前不能进入账户、权限或系统管理接口;后续只获得设备、媒体状态、实时画面和区域的查看权限。
交付验收必须使用仓库外测试账户和获准实验室设备;文档、截图、工单和日志不得包含摄像机密码、Cookie、令牌、含凭据 URI 或客户真实画面。当前自动化已覆盖内部状态和安全边界;真实 PostgreSQL、MediaMTX、摄像机兼容与目标浏览器画面仍需实施人员验证。
所有 Sense 密码至少 6 个字符、最多 72 字节,可使用全小写,不强制字符组合;管理员仍应选择难猜、不复用的密码。登录成功/失败、登出、密码变更和权限拒绝会进入身份审计,交付检查必须确认审计不含密码、令牌、Cookie、验证码或摄像头凭据。
交付验收必须使用仓库外测试账户和获准实验室设备;文档、截图、工单和日志不得包含摄像机密码、Cookie、令牌、含凭据 URI 或客户真实画面。当前身份自动化已覆盖四角色权限矩阵和安全边界;真实生产 PostgreSQL、反向代理、目标浏览器和后续设备链路仍需实施人员按相应工单验证。
<!-- sense-mvp:end -->
<!-- sense-windows-package:start -->
@@ -102,3 +104,11 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
<!-- sense-windows-package:end -->
<!-- sense-device-ledger:start -->
### Sense 设备台账交付说明
设备管理已开放列表、新建、编辑、停用和摄像头凭据更新。系统管理员、实施/运维和站点管理员可以维护;只读用户只能查看。现场人员应使用中文设备名称和安装位置,类型选择“视频”时才可配置 ONVIF/RTSP 凭据;其他类型会明确提示适配器尚未就绪。
凭据窗口每次均为空,不会回显已保存用户名或密码;“已配置”标签只表示服务器保存了密文。部署人员必须在仓库外为服务进程配置 Base64 编码的随机 32 字节 `SENSE_CREDENTIAL_KEY`,丢失或更换该密钥会使旧凭据不可用,因此应纳入受控秘密备份。停用设备不会物理删除台账。
<!-- sense-device-ledger:end -->
+78
View File
@@ -0,0 +1,78 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-64-Sense登录RBAC与审计
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-64-Sense%E7%99%BB%E5%BD%95RBAC%E4%B8%8E%E5%AE%A1%E8%AE%A1.-
wiki_revision: 6167ebdd6a1d8df9f085d7e2af3e3683e4f02bc7
synchronized_at: 2026-08-14T06:23:02Z
<!-- gitea-wiki-mirror:end -->
# 64 Sense登录RBAC与审计
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8 / Sense MVP
- 状态:已完成
- 日期:2026-08-14
- 验收:2026-08-14,用户明确回复“#64 验收通过”
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/64
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/82
- Wiki 页面:Task-64-Sense登录RBAC与审计
- Wiki revision:见本地镜像头
## 背景与目标
工单 #61 只建立了冻结 GoAdmin/go-admin-ui 产品骨架,没有提供可交付的首位管理员初始化、四角色权限边界和完整身份审计。本工单在该基线上重建 Sense 自有登录、用户、角色、菜单/按钮、会话与审计能力,使系统管理员、实施/运维、站点管理员和只读用户具有可自动验证的最小权限,并确保 Sense 不与 Bell 共享账户、JWT 或 Cookie。
## 最终方案
- 增加公开的 `POST /api/v1/bootstrap`,只接受请求头中的仓库外 `SENSE_BOOTSTRAP_TOKEN`;令牌至少 32 个字符,通过常量时间比较校验,数据库已有任一用户后永久拒绝再次初始化。仓库、迁移和配置模板不包含默认账户、默认密码或有效令牌。
- PostgreSQL 迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色、菜单映射与 Casbin 最小策略。修复上游角色策略重复写入问题,确保迁移和角色创建可重复执行。
- JWT realm 固定为 `Sense`;前端 Cookie 使用 `Sense-Admin-Token`,后端仅从 Authorization Bearer 或独立 `sense_session` Cookie 取令牌,不再接受查询参数令牌。
- 密码策略统一为 6–72 字节,不强制大小写、数字或符号组合,允许全小写;用户创建、管理员重置和本人改密共用同一规则,并修复上游改密重复哈希问题。
- 登录成功/失败、登出、密码变更和未登录/无权限拒绝同步写入身份审计。通用操作审计递归清除密码、令牌、Cookie、验证码、secret 等字段,对无法结构化的正文不做原文记录。
- 继续复用 go-admin-ui 的动态菜单、Store、Axios、Layout、权限指令和 Element Plus 通用组件;没有新增通用 UI 组件。配置、接口管理等无关管理路由不注册,即使管理员直接访问也返回 404。
- 相对建单方案的范围校正:上游迁移真实入口位于 `Sense/server/cmd/migrate/migration/version/`,前端 Cookie 封装位于 `Sense/ui/src/utils/auth.js`;两项均已在工单中先行记录,目标和验收标准未改变。
## 修改文件
- `Sense/server/app/admin/apis/`、`router/`、`middleware/`:一次性初始化、认证入口、路由保护和身份审计。
- `Sense/server/app/admin/models/`、`service/`、`dto/`:四角色策略、密码规则、改密与审计脱敏。
- `Sense/server/cmd/migrate/migration/version/*identity*`:角色、按钮权限、菜单映射与 Casbin 策略迁移。
- `Sense/server/config/`:独立 JWT/Cookie 安全配置与空白初始化令牌示例。
- `Sense/ui/src/api/login.js`、`store/`、`utils/auth.js`、用户/角色页面:Sense 会话名称、角色标识、密码校验和移除默认初始密码。
- 架构、业务规则、本地验证和交付指南 Wiki:身份边界、初始化步骤、角色操作路径与审计要求。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| Sense 可独立完成首位管理员初始化、登录、登出和密码变更,无默认账户或密码 | 通过 |
| admin、implementation_operator、site_admin、viewer 四角色菜单、按钮和直接 API 权限符合最小边界 | 通过 |
| 密码至少 6 个字符并允许全小写,过短密码被一致拒绝 | 通过 |
| 登录成功/失败、登出、改密和权限拒绝可审计且不记录秘密 | 通过 |
| Sense JWT、Cookie、账户库不与 Bell 共享,查询参数令牌被禁用 | 通过 |
| 无关 GoAdmin 模块没有可见入口或可直接调用的后端路由 | 通过 |
| 复用 go-admin-ui 现有外壳、权限和通用组件,没有重复新增组件 | 通过 |
## 测试
- Go 工具链:`go version go1.26.5 windows/amd64`。
- 后端:`go test ./...`、`go vet ./...`、`go build ./...` 全通过;新增密码策略、初始化输入/令牌、审计截断和审计脱敏单元测试。
- 前端:`corepack pnpm@9.15.1 install --frozen-lockfile` 通过;lint 0 error,保留 30 条冻结上游 warning;7 suites / 29 tests 全通过;生产构建通过,保留 6 条冻结上游兼容/体积 warning。
- PostgreSQL 17 隔离集成:新库迁移后用户 0、角色 4、策略 15;错误初始化令牌 403、正确令牌 200、二次初始化 403;验证码接口 200;未登录直接访问用户接口 401 且有审计。
- 四角色矩阵:admin 可访问账户和审计;implementation_operator 可查看日志/字典但用户、部门、岗位、角色接口 403;site_admin 可维护用户并读取角色、部门、岗位、字典但日志 403;viewer 上述管理接口均 403。
- 安全回归:配置/接口管理路由对管理员与站点管理员均 404;短密码返回 400,6 位全小写密码改密及重新登录成功;登录、登出、改密和拒绝审计中没有测试密码或令牌。
- Wiki 镜像:`python dev_scripts/sync_wiki_docs.py --check` 通过。
- **未验证部分**:未在真实生产 PostgreSQL、反向代理和目标浏览器执行人工 UI 验收;真实设备业务权限由后续 Sense 工单覆盖。
## 遗留问题
- 冻结 go-admin-ui 上游仍有 30 条 lint warning 和 6 条构建 warning,本工单不扩大范围清理。
- 设备、视频接入、媒体服务、实时监看和区域业务权限由后续独立工单在四角色基础上扩展。
- 登出沿用 GoAdmin 的无状态 JWT 语义,由客户端删除令牌;本工单未增加令牌黑名单或服务端即时吊销机制。
## 相关提交
- `0411aa3` feat: 重建 Sense 认证权限与审计 (#64)
- `1dd29b6` docs: 记录 Sense 身份权限规则 (#64)
- Pull Request:#82
@@ -0,0 +1,85 @@
<!-- gitea-wiki-mirror:start -->
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: 9864d7fece44ca2e88ee8c3661101e3d6f74b030
synchronized_at: 2026-08-14T08:24:46Z
<!-- gitea-wiki-mirror:end -->
# 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 及现场验收;本工单不伪造其结果。
## 人工验收
- 2026-08-14:用户明确回复“#65 验收通过”。
- 验收后按工作流将 PR #83 合入 `dev`,不合入 `main`;同步关闭工单并更新父工单索引。
## 回退与遗留
回退 PR #83 的两个提交可移除本次入口;数据库表与权限记录保留,避免破坏审计或凭据数据。后续 #66 才消费本工单的凭据边界并实现真实 ONVIF/RTSP 接入。
## 相关提交
- `be091f0` feat: 重建设备台账与凭据边界 (#65)
- `d31c35f` docs: 记录 Sense 设备台账与凭据边界 (#65)
+8
View File
@@ -103,6 +103,14 @@
{
"page": "Task-61-Sense冻结GoAdmin源码产品骨架",
"path": "docs/task/61-Sense冻结GoAdmin源码产品骨架.md"
},
{
"page": "Task-64-Sense登录RBAC与审计",
"path": "docs/task/64-Sense登录RBAC与审计.md"
},
{
"page": "Task-65-Sense设备台账与凭据边界",
"path": "docs/task/65-Sense设备台账与凭据边界.md"
}
]
}