diff --git a/Bell/server/cmd/migrate/migration/version-local/2026082917001_bell_minimal_menu.go b/Bell/server/cmd/migrate/migration/version-local/2026082917001_bell_minimal_menu.go new file mode 100644 index 0000000..9f910c4 --- /dev/null +++ b/Bell/server/cmd/migrate/migration/version-local/2026082917001_bell_minimal_menu.go @@ -0,0 +1,52 @@ +package version_local + +import ( + "runtime" + + "gorm.io/gorm" + + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellMinimalMenu) +} + +func migrateBellMinimalMenu(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := ApplyBellMinimalMenuVisibility(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +// ApplyBellMinimalMenuVisibility keeps the imported GoAdmin menu records for +// rollback and upgrades, but exposes only Bell product entries and the three +// RBAC administration pages required to maintain local accounts. +func ApplyBellMinimalMenuVisibility(tx *gorm.DB) error { + if err := tx.Exec(` + UPDATE sys_menu + SET visible = '1', updated_at = now() + WHERE menu_type IN ('M', 'C') + AND deleted_at IS NULL + AND visible IS DISTINCT FROM '1' + `).Error; err != nil { + return err + } + + return tx.Exec(` + UPDATE sys_menu + SET visible = '0', updated_at = now() + WHERE menu_type IN ('M', 'C') + AND deleted_at IS NULL + AND ( + path IN ('/admin', '/admin/sys-user', '/admin/sys-menu', '/admin/sys-role', '/bell') + OR permission IN ('admin:sysUser:list', 'admin:sysMenu:list', 'admin:sysRole:list', + 'bell:alert:list', 'bell:event:list', 'bell:rule:list') + ) + AND visible IS DISTINCT FROM '0' + `).Error +} diff --git a/Bell/server/tests/bell_minimal_menu/minimal_menu_test.go b/Bell/server/tests/bell_minimal_menu/minimal_menu_test.go new file mode 100644 index 0000000..b7f7264 --- /dev/null +++ b/Bell/server/tests/bell_minimal_menu/minimal_menu_test.go @@ -0,0 +1,120 @@ +package bell_minimal_menu_test + +import ( + "os" + "reflect" + "sort" + "testing" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + + versionlocal "go-admin/cmd/migrate/migration/version-local" +) + +var expectedVisibleMenus = []string{ + "事件查询", + "用户管理", + "系统管理", + "菜单管理", + "角色管理", + "规则配置", + "预警中心", + "预警管理", +} + +func TestBellMinimalMenuMigration(t *testing.T) { + databaseURL := os.Getenv("BELL_MINIMAL_MENU_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("minimal menu database is not configured") + } + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + + assertVisibleMenus(t, db) + assertUnusedMenusRetainedAndHidden(t, db) + assertOperatorHasNoDefaultMenus(t, db) + + var rowCountBefore int64 + if err = db.Table("sys_menu").Count(&rowCountBefore).Error; err != nil { + t.Fatal(err) + } + if err = db.Exec(`UPDATE sys_menu SET visible = '0' WHERE title IN ('开发工具','定时任务','系统工具')`).Error; err != nil { + t.Fatal(err) + } + if err = db.Exec(`UPDATE sys_menu SET visible = '1' WHERE title IN ('系统管理','预警中心')`).Error; err != nil { + t.Fatal(err) + } + if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil { + t.Fatal(err) + } + if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil { + t.Fatalf("reapplying minimal menu policy failed: %v", err) + } + var rowCountAfter int64 + if err = db.Table("sys_menu").Count(&rowCountAfter).Error; err != nil { + t.Fatal(err) + } + if rowCountAfter != rowCountBefore { + t.Fatalf("menu records changed during visibility migration: before=%d after=%d", rowCountBefore, rowCountAfter) + } + assertVisibleMenus(t, db) + assertUnusedMenusRetainedAndHidden(t, db) +} + +func assertVisibleMenus(t *testing.T, db *gorm.DB) { + t.Helper() + var titles []string + if err := db.Table("sys_menu"). + Where("menu_type IN ? AND deleted_at IS NULL AND visible = ?", []string{"M", "C"}, "0"). + Order("title").Pluck("title", &titles).Error; err != nil { + t.Fatal(err) + } + sort.Strings(titles) + expected := append([]string(nil), expectedVisibleMenus...) + sort.Strings(expected) + if !reflect.DeepEqual(titles, expected) { + t.Fatalf("visible menu mismatch\nwant: %v\n got: %v", expected, titles) + } +} + +func assertUnusedMenusRetainedAndHidden(t *testing.T, db *gorm.DB) { + t.Helper() + for _, title := range []string{"开发工具", "定时任务", "系统工具"} { + var values []string + if err := db.Table("sys_menu").Where("title = ? AND deleted_at IS NULL", title).Pluck("visible", &values).Error; err != nil { + t.Fatal(err) + } + if len(values) == 0 { + t.Fatalf("unused upstream menu %q was deleted", title) + } + for _, visible := range values { + if visible != "1" { + t.Fatalf("unused upstream menu %q remains visible=%q", title, visible) + } + } + } +} + +func assertOperatorHasNoDefaultMenus(t *testing.T, db *gorm.DB) { + t.Helper() + var titles []string + err := db.Raw(` + SELECT DISTINCT m.title + FROM sys_role r + JOIN sys_role_menu rm ON rm.role_id = r.role_id + JOIN sys_menu m ON m.menu_id = rm.menu_id + WHERE r.role_key = 'operator' + AND m.menu_type IN ('M', 'C') + AND m.deleted_at IS NULL + AND (m.path LIKE '/admin%' OR m.permission LIKE 'admin:%') + `).Scan(&titles).Error + if err != nil { + t.Fatal(err) + } + if len(titles) != 0 { + t.Fatalf("operator retains default administration menus: %v", titles) + } +} diff --git a/Bell/server/tests/bell_minimal_menu/run-postgres.ps1 b/Bell/server/tests/bell_minimal_menu/run-postgres.ps1 new file mode 100644 index 0000000..472e9e4 --- /dev/null +++ b/Bell/server/tests/bell_minimal_menu/run-postgres.ps1 @@ -0,0 +1,79 @@ +[CmdletBinding()] +param([string]$PostgresBin = 'D:\pgsql17\bin') + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' +$started = $false +$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-140-' + [guid]::NewGuid().ToString('N')) +$data = Join-Path $root 'postgres' +$log = Join-Path $root 'postgres.log' +$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +function Get-FreePort { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Wait-ForPort([int]$Port) { + for ($attempt = 0; $attempt -lt 120; $attempt++) { + try { + $client = [Net.Sockets.TcpClient]::new() + $connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected + $client.Dispose() + if ($connected) { return } + } catch { + } + Start-Sleep -Milliseconds 250 + } + throw 'PostgreSQL did not start' +} + +New-Item -ItemType Directory -Path $root | Out-Null +$port = Get-FreePort +try { + foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) { throw "Missing $name" } + } + & (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'initdb failed' } + $arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start" + Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null + Wait-ForPort $port + $started = $true + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_140 + if ($LASTEXITCODE -ne 0) { throw 'createdb failed' } + + $env:GOTOOLCHAIN = 'go1.26.5' + $env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_140 sslmode=disable" + $env:BELL_MINIMAL_MENU_TEST_DATABASE_URL = $env:BELL_DATABASE_URL + $env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') + $env:BELL_BOOTSTRAP_USERNAME = 'bell_140_admin' + $env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N') + $env:BELL_HOST = '127.0.0.1' + $env:BELL_PORT = (Get-FreePort).ToString() + + Push-Location $serverRoot + try { + go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log') + if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" } + go test ./tests/bell_minimal_menu -count=1 -v + if ($LASTEXITCODE -ne 0) { throw 'minimal menu test failed' } + go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate-repeat.log') + if ($LASTEXITCODE -ne 0) { throw "repeat migration failed; evidence: $root" } + } finally { + Pop-Location + } + Write-Output 'BELL_140_MINIMAL_MENU fresh=true upgrade=true repeat=true admin_whitelist=true operator_default_menu=false' +} finally { + if ($started) { + & (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log') + } + foreach ($name in @('BELL_DATABASE_URL', 'BELL_MINIMAL_MENU_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } +}