feat: 建立管理端分组菜单权限基线 (#66)

This commit is contained in:
ila
2026-08-26 18:03:40 +08:00
parent 72bcdf2256
commit 3cc98f2984
6 changed files with 488 additions and 14 deletions
+13 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -466,3 +466,14 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP
- attempts JSON 解析失败时 Admin 返回空时间线而不是使列表接口失败。系统事件不使用数组下标冒充 Provider 尝试序号;Provider 时间线使用持久化的 `provider_ordinal`。
- 所有耗时均为响应/界面派生值;本变更不修改 migration、worker、路由、重试、租约或生成状态机。
<!-- issue-65:end -->
<!-- issue-66:start -->
## #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 的写路由和管理员保护仍需收紧。
<!-- issue-66:end -->
+13 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -206,3 +206,14 @@ synchronized_at: 2026-08-26T02:31:05Z
- `images` 文生图不接受输入图,不能通过把 URL 改为 `images/edits` 冒充图片编辑。两种能力使用独立 ProviderModel、模板和路由。
- Portal 工作台必须按活动路由快照呈现图片能力:仅 `image_edit` 可用时默认图片编辑并禁用文生图;仅 `image_generate` 可用时反向处理;两者都不可用时禁用提交并显示明确提示。页面状态不能替代服务端提交校验。
<!-- issue-60:end -->
<!-- issue-66:start -->
## #66 管理端导航与权限边界
- 管理端导航的正式信息架构为生成配置、运行监控、用户与访问、系统管理四组;终端用户(`users`)与管理员账号(`sys_user`)名称和凭据继续严格分离。
- 菜单结构、接口清单和登录日志是只读管理模块。Migration 只向 `chorus_operator` 分配 GET 权限,不允许通过菜单权限获得新增、修改、删除或清空能力。
- 管理员账号和角色权限属于受控写模块;migration 只建立权限基线,当前账号、最后一个管理员和内置 `chorus_operator` 的后端保护由 #67 实现。在 #67 完成前不得仅凭菜单出现就认为这些模块可安全开放。
- 部门、岗位、字典、参数、操作日志、定时任务、服务监控和开发工具不属于 Chorus 管理端范围。
- 菜单、API、角色菜单和 Casbin 关系只经版本化 migration 演进;页面不得成为这些记录的配置事实来源。
<!-- issue-66:end -->
@@ -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'
);
@@ -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/%';
+77
View File
@@ -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 {
+75 -10
View File
@@ -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