feat: 建立管理端分组菜单权限基线 (#66)
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -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/%';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user