diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index e032508..7397615 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.- -wiki_revision: 2ea61cf082225af867299f24eb6c44661ca7052b -synchronized_at: 2026-08-26T03:32:56Z +wiki_revision: bdf8a4ce362f9d404fb45a87b9347e2381689f16 +synchronized_at: 2026-08-26T09:59:54Z # 架构与代码地图 @@ -466,3 +466,14 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP - attempts JSON 解析失败时 Admin 返回空时间线而不是使列表接口失败。系统事件不使用数组下标冒充 Provider 尝试序号;Provider 时间线使用持久化的 `provider_ordinal`。 - 所有耗时均为响应/界面派生值;本变更不修改 migration、worker、路由、重试、租约或生成状态机。 + + + +## #66 管理端四分组菜单与权限基线 + +Migration 000009 将原“Chorus 运营”平铺导航改为四个一级分组:`/chorus/configuration`(生成配置)、`/chorus/monitoring`(运行监控)、`/chorus/access`(用户与访问)和 `/chorus/system`(系统管理)。现有 8 个业务页面保持原 URL 和组件路径,只调整父菜单、`paths`、排序和 breadcrumb;`/chorus/users` 的显示名改为“终端用户”。 + +系统管理新增管理员账号、角色权限、菜单结构、接口清单和登录日志 5 个入口,组件分别复用 `admin/sys-user/index`、`admin/sys-role/index`、`admin/sys-menu/index`、`admin/sys-api/index` 和 `admin/sys-login-log/index`。Migration 只为菜单结构、接口清单和登录日志登记 GET API 与 Casbin 权限,不登记写权限;管理员和角色页面登记后续生产适配所需的受控写接口。 + +`chorus_operator` 的菜单、菜单 API 和 Casbin 基线全部由 migration 管理。up 在删除旧父菜单前拒绝未知子菜单,down 在删除四个新父菜单前同样拒绝未知子菜单;down 恢复“Chorus 运营”父菜单、8 个业务入口原顺序和“用户管理”旧显示名。生产仍禁止 AutoMigrate。#67 完成后才能开放系统管理页面,因为现有 go-admin handler 的写路由和管理员保护仍需收紧。 + diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index f4cf99d..1cbc153 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.- -wiki_revision: dd1d0ceaddf4b5ba61b46a626a7e0d5135fb965e -synchronized_at: 2026-08-26T02:31:05Z +wiki_revision: 565ea24330f7bda365a3a067b7fe535368ceb8e5 +synchronized_at: 2026-08-26T10:00:00Z # 业务规则与术语 @@ -206,3 +206,14 @@ synchronized_at: 2026-08-26T02:31:05Z - `images` 文生图不接受输入图,不能通过把 URL 改为 `images/edits` 冒充图片编辑。两种能力使用独立 ProviderModel、模板和路由。 - Portal 工作台必须按活动路由快照呈现图片能力:仅 `image_edit` 可用时默认图片编辑并禁用文生图;仅 `image_generate` 可用时反向处理;两者都不可用时禁用提交并显示明确提示。页面状态不能替代服务端提交校验。 + + + +## #66 管理端导航与权限边界 + +- 管理端导航的正式信息架构为生成配置、运行监控、用户与访问、系统管理四组;终端用户(`users`)与管理员账号(`sys_user`)名称和凭据继续严格分离。 +- 菜单结构、接口清单和登录日志是只读管理模块。Migration 只向 `chorus_operator` 分配 GET 权限,不允许通过菜单权限获得新增、修改、删除或清空能力。 +- 管理员账号和角色权限属于受控写模块;migration 只建立权限基线,当前账号、最后一个管理员和内置 `chorus_operator` 的后端保护由 #67 实现。在 #67 完成前不得仅凭菜单出现就认为这些模块可安全开放。 +- 部门、岗位、字典、参数、操作日志、定时任务、服务监控和开发工具不属于 Chorus 管理端范围。 +- 菜单、API、角色菜单和 Casbin 关系只经版本化 migration 演进;页面不得成为这些记录的配置事实来源。 + diff --git a/migrations/000009_admin_grouped_navigation.down.sql b/migrations/000009_admin_grouped_navigation.down.sql new file mode 100644 index 0000000..b8eff5d --- /dev/null +++ b/migrations/000009_admin_grouped_navigation.down.sql @@ -0,0 +1,124 @@ +CREATE TABLE IF NOT EXISTS chorus_admin_grouped_navigation_down_guard ( + id TINYINT NOT NULL, + CONSTRAINT chk_chorus_admin_grouped_navigation_down_guard CHECK (id = 0) +) ENGINE=InnoDB; + +DELETE FROM chorus_admin_grouped_navigation_down_guard; +INSERT INTO chorus_admin_grouped_navigation_down_guard (id) +SELECT 1 +WHERE EXISTS ( + SELECT 1 + FROM sys_menu child + JOIN sys_menu parent ON parent.menu_id = child.parent_id + WHERE parent.path IN ( + '/chorus/configuration', + '/chorus/monitoring', + '/chorus/access', + '/chorus/system' + ) + AND child.path NOT IN ( + '/chorus/providers', + '/chorus/models', + '/chorus/routes', + '/chorus/templates', + '/chorus/health', + '/chorus/generations', + '/chorus/users', + '/chorus/api-keys', + '/chorus/system/admins', + '/chorus/system/roles', + '/chorus/system/menus', + '/chorus/system/apis', + '/chorus/system/login-logs' + ) +); +DROP TABLE chorus_admin_grouped_navigation_down_guard; + +DELETE casbin +FROM sys_casbin_rule casbin +JOIN sys_api api + ON casbin.ptype = 'p' + AND casbin.v0 = 'chorus_operator' + AND casbin.v1 = api.path + AND casbin.v2 = api.action +JOIN sys_menu_api_rule menu_api ON menu_api.sys_api_id = api.id +JOIN sys_menu menu ON menu.menu_id = menu_api.menu_id +WHERE menu.path LIKE '/chorus/system/%'; + +DELETE menu_api +FROM sys_menu_api_rule menu_api +JOIN sys_menu menu ON menu.menu_id = menu_api.menu_id +WHERE menu.path IN ( + '/chorus/system/admins', + '/chorus/system/roles', + '/chorus/system/menus', + '/chorus/system/apis', + '/chorus/system/login-logs' +); + +DELETE FROM sys_api +WHERE handle LIKE 'chorus.system.%'; + +DELETE FROM sys_menu +WHERE path IN ( + '/chorus/system/admins', + '/chorus/system/roles', + '/chorus/system/menus', + '/chorus/system/apis', + '/chorus/system/login-logs' +); + +INSERT INTO sys_menu + (menu_name, title, icon, path, paths, menu_type, action, permission, parent_id, no_cache, breadcrumb, component, sort, visible, is_frame) +VALUES + ('chorus', 'Chorus 运营', 'ri:magic-line', '/chorus', '', 'M', '', 'chorus:menu:view', 0, FALSE, 'Chorus 运营', 'Layout', 900, '0', '0') +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), title = VALUES(title), icon = VALUES(icon), menu_type = VALUES(menu_type), + action = VALUES(action), permission = VALUES(permission), parent_id = VALUES(parent_id), no_cache = VALUES(no_cache), + breadcrumb = VALUES(breadcrumb), component = VALUES(component), sort = VALUES(sort), visible = VALUES(visible), + is_frame = VALUES(is_frame), deleted_at = NULL; + +UPDATE sys_menu SET paths = CONCAT('/0/', menu_id) WHERE path = '/chorus'; + +UPDATE sys_menu child +JOIN ( + SELECT '/chorus/providers' AS child_path, '上游服务商' AS title, 10 AS sort_order + UNION ALL SELECT '/chorus/models', '模型配置', 20 + UNION ALL SELECT '/chorus/routes', '路由策略', 30 + UNION ALL SELECT '/chorus/templates', '提示词模板', 40 + UNION ALL SELECT '/chorus/health', '上游健康', 50 + UNION ALL SELECT '/chorus/generations', '生成记录', 60 + UNION ALL SELECT '/chorus/users', '用户管理', 70 + UNION ALL SELECT '/chorus/api-keys', 'API 密钥', 80 +) desired ON desired.child_path = child.path +JOIN sys_menu parent ON parent.path = '/chorus' +SET child.parent_id = parent.menu_id, + child.paths = CONCAT(parent.paths, '/', child.menu_id), + child.title = desired.title, + child.breadcrumb = CONCAT(parent.title, ' / ', desired.title), + child.sort = desired.sort_order, + child.deleted_at = NULL; + +INSERT IGNORE INTO sys_role_menu (role_id, menu_id) +SELECT role_record.role_id, menu.menu_id +FROM sys_role role_record +JOIN sys_menu menu ON menu.path IN ( + '/chorus', + '/chorus/providers', + '/chorus/models', + '/chorus/routes', + '/chorus/templates', + '/chorus/health', + '/chorus/generations', + '/chorus/users', + '/chorus/api-keys' +) +WHERE role_record.role_key = 'chorus_operator'; + +DELETE FROM sys_menu +WHERE path IN ( + '/chorus/configuration', + '/chorus/monitoring', + '/chorus/access', + '/chorus/system' +); diff --git a/migrations/000009_admin_grouped_navigation.up.sql b/migrations/000009_admin_grouped_navigation.up.sql new file mode 100644 index 0000000..f4a3be0 --- /dev/null +++ b/migrations/000009_admin_grouped_navigation.up.sql @@ -0,0 +1,186 @@ +-- Refuse to delete the legacy parent if another deployment has attached +-- unknown children to it. The failed CHECK deliberately leaves migration +-- recovery to an operator instead of silently orphaning those menus. +CREATE TABLE IF NOT EXISTS chorus_admin_grouped_navigation_up_guard ( + id TINYINT NOT NULL, + CONSTRAINT chk_chorus_admin_grouped_navigation_up_guard CHECK (id = 0) +) ENGINE=InnoDB; + +DELETE FROM chorus_admin_grouped_navigation_up_guard; +INSERT INTO chorus_admin_grouped_navigation_up_guard (id) +SELECT 1 +WHERE EXISTS ( + SELECT 1 + FROM sys_menu child + JOIN sys_menu parent ON parent.menu_id = child.parent_id + WHERE parent.path = '/chorus' + AND child.path NOT IN ( + '/chorus/providers', + '/chorus/models', + '/chorus/routes', + '/chorus/templates', + '/chorus/health', + '/chorus/generations', + '/chorus/users', + '/chorus/api-keys' + ) +); +DROP TABLE chorus_admin_grouped_navigation_up_guard; + +INSERT INTO sys_menu + (menu_name, title, icon, path, paths, menu_type, action, permission, parent_id, no_cache, breadcrumb, component, sort, visible, is_frame) +VALUES + ('chorus-configuration', '生成配置', 'ri:settings-3-line', '/chorus/configuration', '', 'M', '', 'chorus:configuration:view', 0, FALSE, '生成配置', 'Layout', 900, '0', '0'), + ('chorus-monitoring', '运行监控', 'ri:pulse-line', '/chorus/monitoring', '', 'M', '', 'chorus:monitoring:view', 0, FALSE, '运行监控', 'Layout', 910, '0', '0'), + ('chorus-access', '用户与访问', 'ri:team-line', '/chorus/access', '', 'M', '', 'chorus:access:view', 0, FALSE, '用户与访问', 'Layout', 920, '0', '0'), + ('chorus-system', '系统管理', 'ri:shield-keyhole-line', '/chorus/system', '', 'M', '', 'chorus:system:view', 0, FALSE, '系统管理', 'Layout', 930, '0', '0') +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), title = VALUES(title), icon = VALUES(icon), menu_type = VALUES(menu_type), + action = VALUES(action), permission = VALUES(permission), parent_id = VALUES(parent_id), no_cache = VALUES(no_cache), + breadcrumb = VALUES(breadcrumb), component = VALUES(component), sort = VALUES(sort), visible = VALUES(visible), + is_frame = VALUES(is_frame), deleted_at = NULL; + +UPDATE sys_menu +SET paths = CONCAT('/0/', menu_id) +WHERE path IN ('/chorus/configuration', '/chorus/monitoring', '/chorus/access', '/chorus/system'); + +UPDATE sys_menu child +JOIN ( + SELECT '/chorus/providers' AS child_path, '/chorus/configuration' AS parent_path, '上游服务商' AS title, 10 AS sort_order + UNION ALL SELECT '/chorus/models', '/chorus/configuration', '模型配置', 20 + UNION ALL SELECT '/chorus/routes', '/chorus/configuration', '路由策略', 30 + UNION ALL SELECT '/chorus/templates', '/chorus/configuration', '提示词模板', 40 + UNION ALL SELECT '/chorus/health', '/chorus/monitoring', '上游健康', 10 + UNION ALL SELECT '/chorus/generations', '/chorus/monitoring', '生成记录', 20 + UNION ALL SELECT '/chorus/users', '/chorus/access', '终端用户', 10 + UNION ALL SELECT '/chorus/api-keys', '/chorus/access', 'API 密钥', 20 +) desired ON desired.child_path = child.path +JOIN sys_menu parent ON parent.path = desired.parent_path +SET child.parent_id = parent.menu_id, + child.paths = CONCAT(parent.paths, '/', child.menu_id), + child.title = desired.title, + child.breadcrumb = CONCAT(parent.title, ' / ', desired.title), + child.sort = desired.sort_order, + child.deleted_at = NULL; + +DELETE FROM sys_menu WHERE path = '/chorus'; + +INSERT INTO sys_menu + (menu_name, title, icon, path, paths, menu_type, action, permission, parent_id, no_cache, breadcrumb, component, sort, visible, is_frame) +SELECT desired.menu_name, desired.title, desired.icon, desired.path, '', 'C', '', desired.permission, + parent.menu_id, FALSE, CONCAT(parent.title, ' / ', desired.title), desired.component, desired.sort_order, '0', '0' +FROM ( + SELECT 'chorus-system-admins' AS menu_name, '管理员账号' AS title, 'ri:admin-line' AS icon, + '/chorus/system/admins' AS path, 'chorus:system:admins:view' AS permission, + 'admin/sys-user/index' AS component, 10 AS sort_order + UNION ALL SELECT 'chorus-system-roles', '角色权限', 'ri:shield-user-line', + '/chorus/system/roles', 'chorus:system:roles:view', 'admin/sys-role/index', 20 + UNION ALL SELECT 'chorus-system-menus', '菜单结构', 'ri:menu-line', + '/chorus/system/menus', 'chorus:system:menus:view', 'admin/sys-menu/index', 30 + UNION ALL SELECT 'chorus-system-apis', '接口清单', 'ri:braces-line', + '/chorus/system/apis', 'chorus:system:apis:view', 'admin/sys-api/index', 40 + UNION ALL SELECT 'chorus-system-login-logs', '登录日志', 'ri:login-box-line', + '/chorus/system/login-logs', 'chorus:system:login-logs:view', 'admin/sys-login-log/index', 50 +) desired +JOIN sys_menu parent ON parent.path = '/chorus/system' +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), title = VALUES(title), icon = VALUES(icon), menu_type = VALUES(menu_type), + action = VALUES(action), permission = VALUES(permission), parent_id = VALUES(parent_id), no_cache = VALUES(no_cache), + breadcrumb = VALUES(breadcrumb), component = VALUES(component), sort = VALUES(sort), visible = VALUES(visible), + is_frame = VALUES(is_frame), deleted_at = NULL; + +UPDATE sys_menu child +JOIN sys_menu parent ON parent.path = '/chorus/system' +SET child.paths = CONCAT(parent.paths, '/', child.menu_id) +WHERE child.path IN ( + '/chorus/system/admins', + '/chorus/system/roles', + '/chorus/system/menus', + '/chorus/system/apis', + '/chorus/system/login-logs' +); + +-- Existing deployments may already have a matching path/action row from a +-- go-admin seed. In that case retain it instead of taking ownership of it. +INSERT IGNORE INTO sys_api (handle, title, path, type, action) +VALUES + ('chorus.system.admins.list', '管理员账号列表', '/api/v1/sys-user', 'SYS', 'GET'), + ('chorus.system.admins.get', '管理员账号详情', '/api/v1/sys-user/:id', 'SYS', 'GET'), + ('chorus.system.admins.create', '创建管理员账号', '/api/v1/sys-user', 'SYS', 'POST'), + ('chorus.system.admins.update', '更新管理员账号', '/api/v1/sys-user', 'SYS', 'PUT'), + ('chorus.system.admins.delete', '删除管理员账号', '/api/v1/sys-user', 'SYS', 'DELETE'), + ('chorus.system.admins.status', '更新管理员状态', '/api/v1/user/status', 'SYS', 'PUT'), + ('chorus.system.admins.password-reset', '重置管理员密码', '/api/v1/user/pwd/reset', 'SYS', 'PUT'), + ('chorus.system.roles.list', '角色列表', '/api/v1/role', 'SYS', 'GET'), + ('chorus.system.roles.get', '角色详情', '/api/v1/role/:id', 'SYS', 'GET'), + ('chorus.system.roles.create', '创建角色', '/api/v1/role', 'SYS', 'POST'), + ('chorus.system.roles.update', '更新角色', '/api/v1/role/:id', 'SYS', 'PUT'), + ('chorus.system.roles.delete', '删除角色', '/api/v1/role', 'SYS', 'DELETE'), + ('chorus.system.roles.status', '更新角色状态', '/api/v1/role-status', 'SYS', 'PUT'), + ('chorus.system.roles.menu', '角色菜单树', '/api/v1/menurole', 'SYS', 'GET'), + ('chorus.system.menus.list', '菜单结构列表', '/api/v1/menu', 'SYS', 'GET'), + ('chorus.system.menus.get', '菜单结构详情', '/api/v1/menu/:id', 'SYS', 'GET'), + ('chorus.system.apis.list', '接口清单', '/api/v1/sys-api', 'SYS', 'GET'), + ('chorus.system.apis.get', '接口详情', '/api/v1/sys-api/:id', 'SYS', 'GET'), + ('chorus.system.login-logs.list', '登录日志列表', '/api/v1/sys-login-log', 'SYS', 'GET'), + ('chorus.system.login-logs.get', '登录日志详情', '/api/v1/sys-login-log/:id', 'SYS', 'GET'); + +INSERT IGNORE INTO sys_role_menu (role_id, menu_id) +SELECT role_record.role_id, menu.menu_id +FROM sys_role role_record +JOIN sys_menu menu ON menu.path IN ( + '/chorus/configuration', + '/chorus/monitoring', + '/chorus/access', + '/chorus/system', + '/chorus/providers', + '/chorus/models', + '/chorus/routes', + '/chorus/templates', + '/chorus/health', + '/chorus/generations', + '/chorus/users', + '/chorus/api-keys', + '/chorus/system/admins', + '/chorus/system/roles', + '/chorus/system/menus', + '/chorus/system/apis', + '/chorus/system/login-logs' +) +WHERE role_record.role_key = 'chorus_operator'; + +INSERT IGNORE INTO sys_menu_api_rule (menu_id, sys_api_id) +SELECT menu.menu_id, api.id +FROM sys_menu menu +JOIN sys_api api ON ( + (menu.path = '/chorus/system/admins' AND ( + (api.path = '/api/v1/sys-user' AND api.action IN ('GET', 'POST', 'PUT', 'DELETE')) OR + (api.path = '/api/v1/sys-user/:id' AND api.action = 'GET') OR + (api.path IN ('/api/v1/user/status', '/api/v1/user/pwd/reset') AND api.action = 'PUT') + )) OR + (menu.path = '/chorus/system/roles' AND ( + (api.path = '/api/v1/role' AND api.action IN ('GET', 'POST', 'DELETE')) OR + (api.path = '/api/v1/role/:id' AND api.action IN ('GET', 'PUT')) OR + (api.path = '/api/v1/role-status' AND api.action = 'PUT') OR + (api.path = '/api/v1/menurole' AND api.action = 'GET') + )) OR + (menu.path = '/chorus/system/menus' AND ( + (api.path = '/api/v1/menu' AND api.action = 'GET') OR + (api.path = '/api/v1/menu/:id' AND api.action = 'GET') + )) OR + (menu.path = '/chorus/system/apis' AND ( + (api.path = '/api/v1/sys-api' AND api.action = 'GET') OR + (api.path = '/api/v1/sys-api/:id' AND api.action = 'GET') + )) OR + (menu.path = '/chorus/system/login-logs' AND ( + (api.path = '/api/v1/sys-login-log' AND api.action = 'GET') OR + (api.path = '/api/v1/sys-login-log/:id' AND api.action = 'GET') + )) +); + +INSERT IGNORE INTO sys_casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT DISTINCT 'p', 'chorus_operator', api.path, api.action, '', '', '' +FROM sys_menu_api_rule menu_api +JOIN sys_menu menu ON menu.menu_id = menu_api.menu_id +JOIN sys_api api ON api.id = menu_api.sys_api_id +WHERE menu.path LIKE '/chorus/system/%'; diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index a122340..54ebd58 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -30,6 +30,7 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { "000006_mvp2_openapi_governance.up.sql", "000007_admin_navigation_localization.up.sql", "000008_portal_username_login.up.sql", + "000009_admin_grouped_navigation.up.sql", } slices.Sort(upFiles) if !slices.Equal(upFiles, wantFiles) { @@ -61,6 +62,82 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { } } +func TestAdminGroupedNavigationMigrationContracts(t *testing.T) { + read := func(name string) string { + t.Helper() + content, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(content) + } + + up := read("000009_admin_grouped_navigation.up.sql") + for _, required := range []string{ + "chorus_admin_grouped_navigation_up_guard", + "'/chorus/configuration'", + "'/chorus/monitoring'", + "'/chorus/access'", + "'/chorus/system'", + "'/chorus/users', '/chorus/access', '终端用户'", + "'/chorus/system/admins'", + "'/chorus/system/roles'", + "'/chorus/system/menus'", + "'/chorus/system/apis'", + "'/chorus/system/login-logs'", + "'admin/sys-user/index'", + "'admin/sys-role/index'", + "'admin/sys-menu/index'", + "'admin/sys-api/index'", + "'admin/sys-login-log/index'", + "chorus.system.admins.list", + "chorus.system.roles.list", + "chorus.system.menus.list", + "chorus.system.apis.list", + "chorus.system.login-logs.list", + "sys_role_menu", + "sys_menu_api_rule", + "sys_casbin_rule", + } { + if !strings.Contains(up, required) { + t.Errorf("grouped navigation migration is missing %s", required) + } + } + for _, forbidden := range []string{ + "chorus.system.menus.create", + "chorus.system.menus.update", + "chorus.system.menus.delete", + "chorus.system.apis.create", + "chorus.system.apis.update", + "chorus.system.apis.delete", + "chorus.system.login-logs.delete", + "automigrate", + "insert into sys_user", + } { + if strings.Contains(strings.ToLower(up), forbidden) { + t.Errorf("grouped navigation migration contains forbidden %s", forbidden) + } + } + + down := read("000009_admin_grouped_navigation.down.sql") + for _, required := range []string{ + "chorus_admin_grouped_navigation_down_guard", + "'Chorus 运营'", + "'/chorus/users', '用户管理', 70", + "DELETE FROM sys_api", + "handle LIKE 'chorus.system.%'", + "DELETE FROM sys_menu", + "'/chorus/configuration'", + "'/chorus/monitoring'", + "'/chorus/access'", + "'/chorus/system'", + } { + if !strings.Contains(down, required) { + t.Errorf("grouped navigation rollback is missing %s", required) + } + } +} + func TestPortalUsernameLoginMigrationContracts(t *testing.T) { up, err := os.ReadFile("000008_portal_username_login.up.sql") if err != nil { diff --git a/migrations/mysql_integration_test.go b/migrations/mysql_integration_test.go index 7f27566..cd5e308 100644 --- a/migrations/mysql_integration_test.go +++ b/migrations/mysql_integration_test.go @@ -145,17 +145,23 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) { assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.api-keys.revoke'`, 1) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus/api-keys'`, 1) assertCount(t, ctx, db, `SELECT COUNT(*) FROM generations WHERE id = 1 AND available_at IS NOT NULL`, 1) - assertMenuTitleSort(t, ctx, db, "/chorus", "Chorus 运营", 900) - assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10) - assertMenuTitleSort(t, ctx, db, "/chorus/models", "模型配置", 20) - assertMenuTitleSort(t, ctx, db, "/chorus/routes", "路由策略", 30) - assertMenuTitleSort(t, ctx, db, "/chorus/templates", "提示词模板", 40) - assertMenuTitleSort(t, ctx, db, "/chorus/health", "上游健康", 50) - assertMenuTitleSort(t, ctx, db, "/chorus/generations", "生成记录", 60) - assertMenuTitleSort(t, ctx, db, "/chorus/users", "用户管理", 70) - assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80) assertColumnExists(t, ctx, db, "users", "username", true) assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1) + + assertGroupedAdminNavigation(t, ctx, db) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus'`, 0) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path LIKE '/chorus/system/%'`, 5) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.%'`, 20) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.menus.%' AND action <> 'GET'`, 0) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.apis.%' AND action <> 'GET'`, 0) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.login-logs.%' AND action <> 'GET'`, 0) + + runMigrate("down", "1") + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus'`, 1) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path IN ('/chorus/configuration', '/chorus/monitoring', '/chorus/access', '/chorus/system')`, 0) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path LIKE '/chorus/system/%'`, 0) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.%'`, 0) + assertFlatAdminNavigation(t, ctx, db) if _, err := db.ExecContext(ctx, `INSERT INTO users (username, email, password_hash, display_name, status) VALUES ('UpperCase', 'uppercase@chorus.invalid', 'synthetic', 'Invalid Username', 'active')`); err == nil { t.Fatal("username check accepted uppercase input") } @@ -190,9 +196,53 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) { assertTableExists(t, ctx, db, "api_audit_events", true) assertColumnExists(t, ctx, db, "generations", "available_at", true) assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10) - assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80) assertColumnExists(t, ctx, db, "users", "username", true) assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1) + assertGroupedAdminNavigation(t, ctx, db) +} + +func assertGroupedAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) { + t.Helper() + assertMenuTitleSort(t, ctx, db, "/chorus/configuration", "生成配置", 900) + assertMenuTitleSort(t, ctx, db, "/chorus/monitoring", "运行监控", 910) + assertMenuTitleSort(t, ctx, db, "/chorus/access", "用户与访问", 920) + assertMenuTitleSort(t, ctx, db, "/chorus/system", "系统管理", 930) + assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10) + assertMenuTitleSort(t, ctx, db, "/chorus/generations", "生成记录", 20) + assertMenuTitleSort(t, ctx, db, "/chorus/users", "终端用户", 10) + assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 20) + assertMenuParentPath(t, ctx, db, "/chorus/providers", "/chorus/configuration") + assertMenuParentPath(t, ctx, db, "/chorus/generations", "/chorus/monitoring") + assertMenuParentPath(t, ctx, db, "/chorus/users", "/chorus/access") + assertMenuParentPath(t, ctx, db, "/chorus/system/login-logs", "/chorus/system") + assertCount(t, ctx, db, ` + SELECT COUNT(*) + FROM sys_role_menu role_menu + JOIN sys_role role_record ON role_record.role_id = role_menu.role_id + JOIN sys_menu menu ON menu.menu_id = role_menu.menu_id + WHERE role_record.role_key = 'chorus_operator' + AND menu.path IN ('/chorus/configuration', '/chorus/monitoring', '/chorus/access', '/chorus/system')`, 4) + assertCount(t, ctx, db, ` + SELECT COUNT(*) + FROM sys_casbin_rule + WHERE ptype = 'p' AND v0 = 'chorus_operator' + AND v1 IN ('/api/v1/menu', '/api/v1/menu/:id', '/api/v1/sys-api', '/api/v1/sys-api/:id', + '/api/v1/sys-login-log', '/api/v1/sys-login-log/:id') + AND v2 <> 'GET'`, 0) +} + +func assertFlatAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) { + t.Helper() + assertMenuTitleSort(t, ctx, db, "/chorus", "Chorus 运营", 900) + assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10) + assertMenuTitleSort(t, ctx, db, "/chorus/models", "模型配置", 20) + assertMenuTitleSort(t, ctx, db, "/chorus/routes", "路由策略", 30) + assertMenuTitleSort(t, ctx, db, "/chorus/templates", "提示词模板", 40) + assertMenuTitleSort(t, ctx, db, "/chorus/health", "上游健康", 50) + assertMenuTitleSort(t, ctx, db, "/chorus/generations", "生成记录", 60) + assertMenuTitleSort(t, ctx, db, "/chorus/users", "用户管理", 70) + assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80) + assertMenuParentPath(t, ctx, db, "/chorus/api-keys", "/chorus") } func resetDisposableSchema(t *testing.T, ctx context.Context, db *sql.DB) { @@ -279,6 +329,21 @@ func assertMenuTitleSort(t *testing.T, ctx context.Context, db *sql.DB, path, wa } } +func assertMenuParentPath(t *testing.T, ctx context.Context, db *sql.DB, childPath, wantParentPath string) { + t.Helper() + var parentPath string + if err := db.QueryRowContext(ctx, ` + SELECT parent.path + FROM sys_menu child + JOIN sys_menu parent ON parent.menu_id = child.parent_id + WHERE child.path = ?`, childPath).Scan(&parentPath); err != nil { + t.Fatalf("load parent for menu %s: %v", childPath, err) + } + if parentPath != wantParentPath { + t.Fatalf("menu %s parent = %q, want %q", childPath, parentPath, wantParentPath) + } +} + func assertColumnExists(t *testing.T, ctx context.Context, db *sql.DB, table, column string, want bool) { t.Helper() var count int