feat: 实现容量与配额安全闸 (#75)

This commit is contained in:
QiuSW
2026-08-28 11:55:11 +08:00
parent debf662138
commit 12f5419f21
26 changed files with 1193 additions and 41 deletions
@@ -0,0 +1,100 @@
package version
import (
"fmt"
"os"
"runtime"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
"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"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseQuota)
}
func migrateSenseQuota(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&quota.Setting{}, &quota.Change{}); err != nil {
return err
}
now := time.Now().UTC()
setting := quota.Setting{ID: quota.SettingID, Limit: initialQuotaLimit(), Source: "migration", Version: 1}
setting.CreatedAt, setting.UpdatedAt = now, now
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&setting).Error; err != nil {
return err
}
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense",
MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1",
})
if err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: "SenseQuota", Title: "容量与配额", Icon: "data-line", Path: "quota",
Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:quota:list",
ParentId: root.MenuId, Component: "/sense/quota/index", Sort: 8, Visible: "0", IsFrame: "1",
})
if err != nil {
return err
}
update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: "SenseQuotaUpdate", Title: "调整配额", MenuType: "F", Action: "PUT",
Permission: "sense:quota:update", ParentId: page.MenuId,
Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1",
})
if err != nil {
return err
}
var devicePage migrationModels.SysMenu
if err = tx.Where("menu_name = ?", "SenseDeviceManage").First(&devicePage).Error; err != nil {
return err
}
enable, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: "SenseDeviceEnable", Title: "启用设备", MenuType: "F", Action: "PUT",
Permission: "sense:device:enable", ParentId: devicePage.MenuId,
Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, devicePage.MenuId), Sort: 5, Visible: "1", IsFrame: "1",
})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page}); err != nil {
return err
}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: "/api/v1/quota", V2: "GET"}).Error; err != nil {
return err
}
}
if err = attachDeviceRole(tx, "site_admin", []migrationModels.SysMenu{update, enable}); err != nil {
return err
}
for _, policy := range [][2]string{{"/api/v1/quota", "PUT"}, {"/api/v1/devices/:id/enable", "PUT"}} {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: "site_admin", V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func initialQuotaLimit() int {
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("SENSE_PROVISIONING_QUOTA")))
if err != nil || value < 1 || value > 100000 {
return 16
}
return value
}
@@ -0,0 +1,90 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func prepareQuotaMigrationDB(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&migrationModels.SysMenu{MenuName: "SenseDeviceManage", Title: "设备管理", Path: "devices", MenuType: "C", Component: "/sense/device/index"}).Error; err != nil {
t.Fatal(err)
}
}
func assertQuotaMigration(t *testing.T, db *gorm.DB, version string) {
t.Helper()
var setting quota.Setting
if err := db.First(&setting, "id = ?", quota.SettingID).Error; err != nil {
t.Fatal(err)
}
var menus, reads, updates, enables, viewerWrites, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name IN ?", []string{"SenseQuota", "SenseQuotaUpdate", "SenseDeviceEnable"}).Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/quota", "GET").Count(&reads)
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/quota", "PUT").Count(&updates)
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/devices/:id/enable", "PUT").Count(&enables)
db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v2 = ?", "viewer", "PUT").Count(&viewerWrites)
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
if setting.Limit != 16 || menus != 3 || reads != 3 || updates != 1 || enables != 1 || viewerWrites != 0 || applied != 1 {
t.Fatalf("setting=%#v menus=%d reads=%d updates=%d enables=%d viewerWrites=%d applied=%d", setting, menus, reads, updates, enables, viewerWrites, applied)
}
}
func TestQuotaMigrationAddsDefaultAndLeastPrivilegeMenus(t *testing.T) {
t.Setenv("SENSE_PROVISIONING_QUOTA", "")
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
prepareQuotaMigrationDB(t, db)
const version = "2026082812000_quota.go"
if err = migrateSenseQuota(db, version); err != nil {
t.Fatal(err)
}
assertQuotaMigration(t, db, version)
}
func TestQuotaMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_QUOTA_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_QUOTA_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
t.Setenv("SENSE_PROVISIONING_QUOTA", "")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_quota_75_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
prepareQuotaMigrationDB(t, db)
const version = "2026082812000_quota.go"
if err = migrateSenseQuota(db, version); err != nil {
t.Fatal(err)
}
assertQuotaMigration(t, db, version)
}