diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py index 7734fcf..512bbf6 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -179,6 +179,18 @@ class RbacAdminServiceImpl(IRbacAdminService): "is_cache": True, "meta": {"group": "settings"}, }, + { + "route_path": "/usage-stats", + "route_name": "usage-stats", + "component": "usage-stats", + "route_title": "系统使用统计", + "icon": "ri-bar-chart-box-line", + "sort_order": 4, + "parent_path": "/settings", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + }, ] _MANAGEABLE_PERMISSION_BLUEPRINTS: list[dict[str, Any]] = [ @@ -193,6 +205,12 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "doc_type:create:write", "display_name": "创建文档类型", "module": "doc_type", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/document-types", "route_path": "/document-types"}, {"permission_key": "doc_type:update:write", "display_name": "更新文档类型", "module": "doc_type", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/document-types/{id}", "route_path": "/document-types"}, {"permission_key": "doc_type:delete:delete", "display_name": "删除文档类型", "module": "doc_type", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/document-types/{id}", "route_path": "/document-types"}, + {"permission_key": "usage_stats:overview:read", "display_name": "查看统计总览", "module": "usage_stats", "resource": "overview", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/overview", "route_path": "/usage-stats"}, + {"permission_key": "usage_stats:trends:read", "display_name": "查看统计趋势", "module": "usage_stats", "resource": "trends", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/trends", "route_path": "/usage-stats"}, + {"permission_key": "usage_stats:users:read", "display_name": "查看用户统计", "module": "usage_stats", "resource": "users", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-users", "route_path": "/usage-stats"}, + {"permission_key": "usage_stats:departments:read", "display_name": "查看部门统计", "module": "usage_stats", "resource": "departments", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-departments", "route_path": "/usage-stats"}, + {"permission_key": "usage_stats:areas:read", "display_name": "查看地区统计", "module": "usage_stats", "resource": "areas", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-areas", "route_path": "/usage-stats"}, + {"permission_key": "usage_stats:details:read", "display_name": "查看统计明细", "module": "usage_stats", "resource": "details", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/details", "route_path": "/usage-stats"}, {"permission_key": "evaluation_group:list:read", "display_name": "评查点分组列表", "module": "evaluation_group", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"}, {"permission_key": "evaluation_group:create:write", "display_name": "创建评查点分组", "module": "evaluation_group", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"}, {"permission_key": "evaluation_group:update:write", "display_name": "更新评查点分组与绑定", "module": "evaluation_group", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rule-groups"}, diff --git a/scripts/schema_add_usage_stats.sql b/scripts/schema_add_usage_stats.sql new file mode 100644 index 0000000..70b9ede --- /dev/null +++ b/scripts/schema_add_usage_stats.sql @@ -0,0 +1,116 @@ +BEGIN; + +-- ============================================================================ +-- LeAudit Platform Usage Stats / Login Audit Schema Patch +-- 目标: +-- 1. 补齐系统使用统计所需的登录事件明细表 +-- 2. 为 sso_users 增加最近登录时间字段 +-- 3. 为评查触发人统计补齐必要索引 +-- 说明: +-- - 脚本设计为幂等,可重复执行 +-- - 不依赖前端,可由 DBA 或运维先行执行 +-- ============================================================================ + +-- -------------------------------------------------------------------------- +-- 1. 用户最近登录时间 +-- -------------------------------------------------------------------------- +ALTER TABLE sso_users + ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN sso_users.last_login_at IS '最近一次登录成功时间,用于用户维度统计与登录态审计'; + +CREATE INDEX IF NOT EXISTS idx_sso_users_last_login_at + ON sso_users(last_login_at DESC) + WHERE deleted_at IS NULL; + +-- -------------------------------------------------------------------------- +-- 2. 登录事件审计表 +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS usage_login_events ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NULL, + sub VARCHAR(128) NULL, + username_snapshot VARCHAR(128) NULL, + nick_name_snapshot VARCHAR(128) NULL, + department_name_snapshot VARCHAR(255) NULL, + ou_id_snapshot VARCHAR(128) NULL, + ou_name_snapshot VARCHAR(255) NULL, + area_snapshot VARCHAR(64) NULL, + login_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + login_result VARCHAR(16) NOT NULL, + login_type VARCHAR(32) NOT NULL, + ip_address VARCHAR(64) NULL, + user_agent VARCHAR(1024) NULL, + client_type VARCHAR(32) NULL, + token_jti VARCHAR(128) NULL, + failure_reason VARCHAR(255) NULL, + extra JSONB NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_usage_login_events_user + FOREIGN KEY (user_id) REFERENCES sso_users(id) ON DELETE SET NULL, + CONSTRAINT chk_usage_login_events_result + CHECK (login_result IN ('success', 'failed')), + CONSTRAINT chk_usage_login_events_type + CHECK (login_type IN ('password', 'oauth', 'unknown')) +); + +COMMENT ON TABLE usage_login_events IS '系统登录事件审计表:记录成功/失败登录,用于系统使用统计'; +COMMENT ON COLUMN usage_login_events.user_id IS '登录成功时关联用户 ID;失败时允许为空'; +COMMENT ON COLUMN usage_login_events.sub IS '登录标识快照,通常对应 sso_users.sub'; +COMMENT ON COLUMN usage_login_events.department_name_snapshot IS '登录时的部门名称快照'; +COMMENT ON COLUMN usage_login_events.area_snapshot IS '登录时的地区快照'; +COMMENT ON COLUMN usage_login_events.login_result IS '登录结果:success / failed'; +COMMENT ON COLUMN usage_login_events.login_type IS '登录方式:password / oauth / unknown'; +COMMENT ON COLUMN usage_login_events.token_jti IS 'JWT 唯一标识,当前版本可为空'; +COMMENT ON COLUMN usage_login_events.extra IS '扩展信息,预留后续登录来源、设备信息等'; + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_login_time + ON usage_login_events(login_time DESC); + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_user_id + ON usage_login_events(user_id, login_time DESC); + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_department + ON usage_login_events(department_name_snapshot, login_time DESC); + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_area + ON usage_login_events(area_snapshot, login_time DESC); + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_result + ON usage_login_events(login_result, login_time DESC); + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_type + ON usage_login_events(login_type, login_time DESC); + +CREATE INDEX IF NOT EXISTS idx_usage_login_events_sub + ON usage_login_events(sub, login_time DESC); + +-- -------------------------------------------------------------------------- +-- 3. 评查运行触发人索引 +-- -------------------------------------------------------------------------- +CREATE INDEX IF NOT EXISTS idx_leaudit_audit_runs_trigger_user_id + ON leaudit_audit_runs(trigger_user_id, created_at DESC); + +COMMENT ON COLUMN leaudit_audit_runs.trigger_user_id IS '发起本次评查的用户 ID,用于用户/部门/地区维度统计'; + +-- -------------------------------------------------------------------------- +-- 4. 历史回填(仅在存在成功登录记录时回填) +-- -------------------------------------------------------------------------- +WITH latest_success_login AS ( + SELECT + user_id, + MAX(login_time) AS last_login_at + FROM usage_login_events + WHERE user_id IS NOT NULL + AND login_result = 'success' + GROUP BY user_id +) +UPDATE sso_users u +SET last_login_at = l.last_login_at +FROM latest_success_login l +WHERE u.id = l.user_id + AND (u.last_login_at IS NULL OR u.last_login_at < l.last_login_at); + +COMMIT; diff --git a/scripts/seed_usage_stats_rbac.sql b/scripts/seed_usage_stats_rbac.sql new file mode 100644 index 0000000..99d3e15 --- /dev/null +++ b/scripts/seed_usage_stats_rbac.sql @@ -0,0 +1,226 @@ +BEGIN; + +-- ============================================================================ +-- LeAudit Platform Usage Stats RBAC Seed +-- 目标: +-- 1. 为“系统使用统计”补齐菜单路由 +-- 2. 补齐 usage-stats 相关 API 权限点 +-- 3. 为 super_admin / provincial_admin / admin 分配菜单和权限 +-- 说明: +-- - super_admin / provincial_admin 使用 ALL 数据范围 +-- - admin 使用 DEPT 数据范围,对应地区管理员只看本地区 +-- - 幂等脚本,可重复执行 +-- ============================================================================ + +WITH settings_root AS ( + SELECT id + FROM sys_routes + WHERE route_path = '/settings' + AND deleted_at IS NULL + LIMIT 1 +) +INSERT INTO sys_routes ( + route_path, + route_name, + component, + parent_id, + route_title, + icon, + sort_order, + is_hidden, + is_cache, + meta, + status, + created_at, + updated_at, + deleted_at +) +SELECT + '/usage-stats', + 'usage-stats', + 'usage-stats', + settings_root.id, + '系统使用统计', + 'ri-bar-chart-box-line', + 4, + FALSE, + TRUE, + '{"group":"settings"}'::jsonb, + 0, + NOW(), + NOW(), + NULL +FROM settings_root +ON CONFLICT (route_path) WHERE deleted_at IS NULL +DO UPDATE SET + route_name = EXCLUDED.route_name, + component = EXCLUDED.component, + parent_id = EXCLUDED.parent_id, + route_title = EXCLUDED.route_title, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_hidden = EXCLUDED.is_hidden, + is_cache = EXCLUDED.is_cache, + meta = EXCLUDED.meta, + status = 0, + updated_at = NOW(), + deleted_at = NULL; + +WITH usage_route AS ( + SELECT id FROM sys_routes WHERE route_path = '/usage-stats' AND deleted_at IS NULL LIMIT 1 +) +INSERT INTO permissions ( + permission_key, + module, + resource, + action, + description, + display_name, + permission_type, + is_system, + metadata, + created_at, + updated_at, + created_by, + updated_by, + parent_id, + sort_order, + route_id, + api_path, + api_method, + related_routes +) +SELECT + seed.permission_key, + seed.module, + seed.resource, + seed.action, + seed.description, + seed.display_name, + seed.permission_type, + seed.is_system, + seed.metadata, + seed.created_at, + seed.updated_at, + seed.created_by, + seed.updated_by, + seed.parent_id, + seed.sort_order, + usage_route.id AS route_id, + seed.api_path, + seed.api_method, + seed.related_routes +FROM ( + VALUES + ('usage_stats:overview:read', 'usage_stats', 'overview', 'read', '查看系统使用统计总览', '查看统计总览', 'API', TRUE, NULL::jsonb, NOW(), NOW(), NULL::bigint, NULL::bigint, NULL::bigint, 210, NULL::bigint, '/api/v3/usage-stats/overview', 'GET', NULL::bigint[]), + ('usage_stats:trends:read', 'usage_stats', 'trends', 'read', '查看系统使用趋势', '查看统计趋势', 'API', TRUE, NULL::jsonb, NOW(), NOW(), NULL::bigint, NULL::bigint, NULL::bigint, 211, NULL::bigint, '/api/v3/usage-stats/trends', 'GET', NULL::bigint[]), + ('usage_stats:users:read', 'usage_stats', 'users', 'read', '查看用户维度统计', '查看用户统计', 'API', TRUE, NULL::jsonb, NOW(), NOW(), NULL::bigint, NULL::bigint, NULL::bigint, 212, NULL::bigint, '/api/v3/usage-stats/by-users', 'GET', NULL::bigint[]), + ('usage_stats:departments:read', 'usage_stats', 'departments', 'read', '查看部门维度统计', '查看部门统计', 'API', TRUE, NULL::jsonb, NOW(), NOW(), NULL::bigint, NULL::bigint, NULL::bigint, 213, NULL::bigint, '/api/v3/usage-stats/by-departments', 'GET', NULL::bigint[]), + ('usage_stats:areas:read', 'usage_stats', 'areas', 'read', '查看地区维度统计', '查看地区统计', 'API', TRUE, NULL::jsonb, NOW(), NOW(), NULL::bigint, NULL::bigint, NULL::bigint, 214, NULL::bigint, '/api/v3/usage-stats/by-areas', 'GET', NULL::bigint[]), + ('usage_stats:details:read', 'usage_stats', 'details', 'read', '查看统计明细', '查看统计明细', 'API', TRUE, NULL::jsonb, NOW(), NOW(), NULL::bigint, NULL::bigint, NULL::bigint, 215, NULL::bigint, '/api/v3/usage-stats/details', 'GET', NULL::bigint[]) +) AS seed( + permission_key, + module, + resource, + action, + description, + display_name, + permission_type, + is_system, + metadata, + created_at, + updated_at, + created_by, + updated_by, + parent_id, + sort_order, + route_id, + api_path, + api_method, + related_routes +) +CROSS JOIN usage_route +ON CONFLICT (permission_key) DO UPDATE SET + module = EXCLUDED.module, + resource = EXCLUDED.resource, + action = EXCLUDED.action, + description = EXCLUDED.description, + display_name = EXCLUDED.display_name, + permission_type = EXCLUDED.permission_type, + is_system = EXCLUDED.is_system, + updated_at = NOW(), + api_path = EXCLUDED.api_path, + api_method = EXCLUDED.api_method, + sort_order = EXCLUDED.sort_order; + +WITH role_map AS ( + SELECT id, role_key + FROM roles + WHERE role_key IN ('super_admin', 'provincial_admin', 'admin') +), +route_map AS ( + SELECT id, route_path + FROM sys_routes + WHERE deleted_at IS NULL + AND route_path = '/usage-stats' +), +seed(role_key, route_path, permission, status) AS ( + VALUES + ('super_admin', '/usage-stats', 'R', 1), + ('provincial_admin', '/usage-stats', 'R', 1), + ('admin', '/usage-stats', 'R', 1) +) +INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) +SELECT rm.id, tm.id, s.permission, s.status, NOW(), NOW() +FROM seed s +JOIN role_map rm ON rm.role_key = s.role_key +JOIN route_map tm ON tm.route_path = s.route_path +ON CONFLICT (role_id, route_id) DO UPDATE SET + permission = EXCLUDED.permission, + status = EXCLUDED.status, + updated_at = NOW(); + +WITH role_map AS ( + SELECT id, role_key + FROM roles + WHERE role_key IN ('super_admin', 'provincial_admin', 'admin') +), +perm_map AS ( + SELECT id, permission_key + FROM permissions + WHERE permission_key LIKE 'usage_stats:%' +), +seed(role_key, permission_key, grant_type, data_scope) AS ( + VALUES + ('super_admin', 'usage_stats:overview:read', 'GRANT', 'ALL'), + ('super_admin', 'usage_stats:trends:read', 'GRANT', 'ALL'), + ('super_admin', 'usage_stats:users:read', 'GRANT', 'ALL'), + ('super_admin', 'usage_stats:departments:read', 'GRANT', 'ALL'), + ('super_admin', 'usage_stats:areas:read', 'GRANT', 'ALL'), + ('super_admin', 'usage_stats:details:read', 'GRANT', 'ALL'), + + ('provincial_admin', 'usage_stats:overview:read', 'GRANT', 'ALL'), + ('provincial_admin', 'usage_stats:trends:read', 'GRANT', 'ALL'), + ('provincial_admin', 'usage_stats:users:read', 'GRANT', 'ALL'), + ('provincial_admin', 'usage_stats:departments:read', 'GRANT', 'ALL'), + ('provincial_admin', 'usage_stats:areas:read', 'GRANT', 'ALL'), + ('provincial_admin', 'usage_stats:details:read', 'GRANT', 'ALL'), + + ('admin', 'usage_stats:overview:read', 'GRANT', 'DEPT'), + ('admin', 'usage_stats:trends:read', 'GRANT', 'DEPT'), + ('admin', 'usage_stats:users:read', 'GRANT', 'DEPT'), + ('admin', 'usage_stats:departments:read', 'GRANT', 'DEPT'), + ('admin', 'usage_stats:areas:read', 'GRANT', 'DEPT'), + ('admin', 'usage_stats:details:read', 'GRANT', 'DEPT') +) +INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at) +SELECT rm.id, pm.id, seed.grant_type, seed.data_scope, NOW(), NOW() +FROM seed +JOIN role_map rm ON rm.role_key = seed.role_key +JOIN perm_map pm ON pm.permission_key = seed.permission_key +ON CONFLICT (role_id, permission_id) DO UPDATE SET + grant_type = EXCLUDED.grant_type, + data_scope = EXCLUDED.data_scope, + updated_at = NOW(); + +COMMIT;