From b3ad4a6f33d3cd7ec7c633d2c18574eda9b4e0f7 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Wed, 29 Apr 2026 15:23:19 +0800 Subject: [PATCH] feat: bootstrap user rbac foundation --- .gitignore | 1 + docs/接口/README.md | 12 +- docs/接口/用户权限与权限点清单.md | 419 +++++++ docs/接口/用户权限初始化SQL.sql | 293 +++++ docs/用户与地区权限完整设计方案.md | 777 ++++++++++++ docs/用户权限开发TaskList.md | 878 ++++++++++++++ .../老系统_docauditai_用户权限架构深度分析.md | 1047 +++++++++++++++++ .../fastapi_common_security/jwtService.py | 4 +- .../fastapi_common_security/security.py | 33 +- .../controllers/auth/authController.py | 43 +- .../fastapi_leaudit/services/authService.py | 5 + .../services/impl/authServiceImpl.py | 310 +++-- scripts/user_rbac_comments_patch.sql | 137 +++ scripts/user_rbac_migration_audit.sql | 85 ++ scripts/user_rbac_schema_patch.sql | 287 +++++ scripts/user_rbac_seed.sql | 271 +++++ 16 files changed, 4498 insertions(+), 104 deletions(-) create mode 100644 docs/接口/用户权限与权限点清单.md create mode 100644 docs/接口/用户权限初始化SQL.sql create mode 100644 docs/用户与地区权限完整设计方案.md create mode 100644 docs/用户权限开发TaskList.md create mode 100644 docs/老系统_docauditai_用户权限架构深度分析.md create mode 100644 scripts/user_rbac_comments_patch.sql create mode 100644 scripts/user_rbac_migration_audit.sql create mode 100644 scripts/user_rbac_schema_patch.sql create mode 100644 scripts/user_rbac_seed.sql diff --git a/.gitignore b/.gitignore index 9168963..518d142 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ logs/ uploads/ temp/ tmp/ +testfile/ # Celery celerybeat-schedule diff --git a/docs/接口/README.md b/docs/接口/README.md index 3bba197..d14fc61 100644 --- a/docs/接口/README.md +++ b/docs/接口/README.md @@ -11,11 +11,15 @@ 当前已整理: - `文档上传与评查接口.md` -- `文档列表接口设计分析.md` +- `新系统版_documents_list接口.md` +- `用户权限与权限点清单.md` +- `用户权限初始化SQL.sql` 建议阅读顺序: 1. 先看 `文档上传与评查接口.md` -2. 再看 `文档列表接口设计分析.md` -3. 再结合 `docs/规则编辑/worker并发执行改造方案.md` -4. 如果要理解底层数据结构,再看 `docs/leaudit/document_schema_design.md` +2. 再看 `新系统版_documents_list接口.md` +3. 再看 `用户权限与权限点清单.md` +4. 如果要初始化角色与权限数据,再执行 `用户权限初始化SQL.sql` +5. 如果要理解 worker 并发执行,再结合 `docs/规则编辑/worker并发执行改造方案.md` +6. 如果要理解底层数据结构,再看 `docs/leaudit/document_schema_design.md` diff --git a/docs/接口/用户权限与权限点清单.md b/docs/接口/用户权限与权限点清单.md new file mode 100644 index 0000000..8952ad1 --- /dev/null +++ b/docs/接口/用户权限与权限点清单.md @@ -0,0 +1,419 @@ +# 用户权限初始化 SQL 与权限点清单 + +这份文档对应:`docs/接口/用户权限初始化SQL.sql` + +用途只有两个: + +- 帮你快速初始化当前新系统最小可用的用户权限体系 +- 帮后续自己看代码、配角色、排查越权时不再忘记“每个权限点到底是干什么的” + +当前这版严格按你确认过的业务收口: + +- 只做 `RBAC + 单地区隔离` +- 用户地区只认 `sso_users.area` +- 角色只认 `provincial_admin`、`admin`、`common` +- `super_admin` 仅作为可选系统维护角色 +- 数据范围只认 `ALL / DEPT / SELF` + +--- + +## 1. 本次交付文件 + +- SQL:`docs/接口/用户权限初始化SQL.sql` +- 说明:`docs/接口/用户权限与权限点清单.md` + +--- + +## 2. SQL 初始化了什么 + +这份 SQL 会初始化 5 类内容: + +1. `roles` 角色基础数据 +2. `sys_routes` 菜单 / 页面路由基础数据 +3. `permissions` 权限点基础数据 +4. `role_route` 角色可见菜单授权 +5. `role_permissions` 角色权限点授权 + +它的定位不是“把所有未来功能一次性配满”,而是: + +- 先把当前 `leaudit-platform` 已经稳定的核心能力配起来 +- 后续有新接口,再继续增量追加权限点 + +--- + +## 3. 当前正式角色 + +## 3.1 `provincial_admin` + +- 省级管理员 +- 默认数据范围:`ALL` +- 能看全省文档、任务、评查结果、规则配置、用户与角色配置 + +## 3.2 `admin` + +- 地区管理员 +- 默认数据范围:`DEPT` +- 实际含义是“同地区”,不是组织部门 +- 只能处理 `region = 当前用户.area` 的业务数据 + +## 3.3 `common` + +- 普通用户 +- 默认数据范围:`SELF` +- 通常只能看自己上传 / 自己触发 / 自己归属的数据 +- 对部分只读接口,可以单独放宽到 `DEPT` + +## 3.4 `super_admin` + +- 可选系统超级管理员 +- 默认数据范围:`ALL` +- 只建议用来做系统维护、初始化、排障 +- 不建议参与日常业务权限设计 + +--- + +## 4. 数据范围解释 + +## 4.1 `ALL` + +- 不做地区过滤 +- 可看全部数据 + +## 4.2 `DEPT` + +- 在本项目里,正式定义为“同地区” +- 一般等价于:`业务表.region = sso_users.area` + +## 4.3 `SELF` + +- 只能看自己的数据 +- 一般等价于: + - `created_by = 当前用户.id` + - 或 `uploaded_by = 当前用户.id` + - 或 `owner_id = 当前用户.id` + +## 4.4 生效优先级 + +后端建议统一这样算: + +1. 先看当前接口是否命中某条 `role_permissions` +2. 若该条有 `data_scope`,优先用它 +3. 若该条没有 `data_scope`,回退到 `roles.data_scope` +4. 多角色命中时,取可见范围最大的那个:`ALL > DEPT > SELF` + +--- + +## 5. 当前菜单建议 + +SQL 里初始化了以下核心菜单: + +- `/documents` +- `/documents/list` +- `/rules` +- `/rules/sets` +- `/audit` +- `/audit/runs` +- `/system` +- `/system/users` +- `/system/roles` + +这套菜单不是说前端必须完全一模一样,而是作为当前后台权限建模的“标准路径集合”。 + +--- + +## 6. 当前权限点清单 + +下面是当前已经纳入初始化 SQL 的权限点。 + +## 6.1 认证类 + +### `auth.me` + +- 接口:`GET /api/auth/me` +- 用途:获取当前登录用户信息 +- 建议角色:全部登录用户都可访问 + +--- + +## 6.2 文档类 + +### `documents.upload` + +- 接口:`POST /api/upload` +- 用途:上传文档,可选自动触发评查 +- `provincial_admin`:`ALL` +- `admin`:`DEPT` +- `common`:`SELF` + +### `documents.list` + +- 接口:`GET /api/documents/list` +- 用途:查看文档列表 +- `provincial_admin`:全量文档 +- `admin`:本地区文档 +- `common`:自己的文档 + +### `documents.detail` + +- 接口:`GET /api/documents/{documentId}` +- 用途:查看文档详情 +- 数据范围逻辑应和 `documents.list` 保持一致 + +### `documents.history` + +- 接口:`GET /api/documents/{documentId}/versions` +- 用途:查看同名文档历史版本 +- 数据范围逻辑应和文档主记录一致 + +### `documents.delete` + +- 接口:`DELETE /api/documents/{documentId}` +- 用途:删除文档 +- 建议只开放给 `provincial_admin` 和 `admin` + +--- + +## 6.3 评查类 + +### `audit.run` + +- 接口:`POST /api/audit/run` +- 用途:手动触发评查 +- `provincial_admin`:可发起任意地区 run +- `admin`:只能发起本地区 run +- `common`:只能发起自己可见文档的 run + +### `audit.run.status` + +- 接口:`GET /api/audit/run/{runId}` +- 用途:查看 run 当前执行状态 + +### `audit.result` + +- 接口:`GET /api/audit/result/{runId}` +- 用途:查看评查结果 + +这两个接口都要跟随文档 / run 的归属范围过滤,不能因为知道 `runId` 就越权读取。 + +--- + +## 6.4 规则类 + +### `rules.list` + +- 接口:`GET /api/rule-sets` +- 用途:查看规则集列表 + +### `rules.version.list` + +- 接口:`GET /api/rule-sets/{ruleType}/versions` +- 用途:查看某规则集的版本列表 + +### `rules.content` + +- 接口:`GET /api/rule-sets/versions/{versionId}/content` +- 用途:查看规则 YAML 正文 + +### `rules.validate` + +- 接口:`POST /api/rule-sets/{ruleType}/validate` +- 用途:校验规则 YAML + +### `rules.version.create` + +- 接口:`POST /api/rule-sets/{ruleType}/versions` +- 用途:创建规则版本 +- 建议开放给 `provincial_admin` +- 如确有地区自管需求,再按地区规则放给 `admin` + +### `rules.publish` + +- 接口:`POST /api/rule-sets/{ruleType}/publish` +- 用途:发布规则版本 +- 建议只开放给 `provincial_admin` + +### `rules.rollback` + +- 接口:`POST /api/rule-sets/{ruleType}/rollback` +- 用途:规则回滚 +- 建议只开放给 `provincial_admin` + +### `rules.binding.list` + +- 接口:`GET /api/rule-sets/bindings` +- 用途:查看规则绑定 + +### `rules.binding.create` + +- 接口:`POST /api/rule-sets/{ruleType}/bindings` +- 用途:创建规则绑定 + +### `rules.binding.update` + +- 接口:`PUT /api/rule-sets/bindings/{bindingId}` +- 用途:更新规则绑定 + +### `rules.binding.delete` + +- 接口:`DELETE /api/rule-sets/bindings/{bindingId}` +- 用途:删除规则绑定 + +--- + +## 6.5 用户类 + +### `users.list` + +- 接口:`GET /api/users/list` +- 用途:查看用户列表 +- `provincial_admin`:全部用户 +- `admin`:本地区用户 +- `common`:通常不开放 + +### `users.create` + +- 接口:`POST /api/users` +- 用途:创建用户 +- 建议只开放给 `provincial_admin` + +### `users.update` + +- 接口:`PUT /api/users/{userId}` +- 用途:修改用户信息 +- `admin` 仅能维护本地区用户 + +### `users.disable` + +- 接口:`PUT /api/users/{userId}/disable` +- 用途:禁用 / 启用用户 +- 建议只开放给 `provincial_admin` + +### `users.roles.assign` + +- 接口:`POST /api/users/{userId}/roles` +- 用途:为用户分配角色 +- 建议只开放给 `provincial_admin` + +--- + +## 6.6 RBAC 管理类 + +### `rbac.roles.list` + +- 接口:`GET /api/rbac/roles` +- 用途:查看角色列表 + +### `rbac.roles.update` + +- 接口:`PUT /api/rbac/roles/{roleId}` +- 用途:修改角色定义 + +### `rbac.permissions.list` + +- 接口:`GET /api/rbac/permissions` +- 用途:查看权限点列表 + +### `rbac.role_permissions.assign` + +- 接口:`POST /api/rbac/roles/{roleId}/permissions` +- 用途:给角色分配权限点 + +### `rbac.role_routes.assign` + +- 接口:`PUT /api/rbac/roles/{roleId}/routes` +- 用途:给角色分配菜单 + +这些建议默认只给: + +- `provincial_admin` +- `super_admin` + +--- + +## 7. 角色默认授权概览 + +## 7.1 `provincial_admin` + +建议拥有: + +- 全部当前权限点 +- 数据范围全部 `ALL` + +## 7.2 `admin` + +建议拥有: + +- 文档上传、列表、详情、删除 +- 评查触发、进度查看、结果查看 +- 规则查看、校验、部分绑定维护 +- 本地区用户列表与部分用户维护 +- 数据范围多数为 `DEPT` + +## 7.3 `common` + +建议拥有: + +- 当前用户信息 +- 文档上传 +- 自己文档列表 / 详情 / 历史版本 +- 自己触发的评查任务与结果 +- 部分规则只读查看 +- 数据范围多数为 `SELF` + +--- + +## 8. 后端接入要求 + +权限初始化 SQL 只是把“数据字典”配起来,真正生效还要靠后端统一遵守下面规则: + +### 8.1 JWT 必须能拿到这些信息 + +至少要能拿到: + +- `user_id` +- `area` +- `roles` +- `permissions` + +### 8.2 接口先做功能权限,再做数据过滤 + +不要只做页面隐藏,必须后端实际校验: + +1. 有没有权限点 +2. 命中哪条 `data_scope` +3. 按 `ALL / DEPT / SELF` 注入查询条件 + +### 8.3 前端传入的地区不能覆盖后端数据权限 + +例如: + +- `GET /api/documents/list?region=省局` + +如果当前用户只是潮州 `admin`,后端仍然只能返回潮州数据,不能因为前端传了别的地区就放开。 + +--- + +## 9. 建议你怎么用这份 SQL + +建议按这个顺序: + +1. 先在测试库执行 `docs/接口/用户权限初始化SQL.sql` +2. 检查 `roles / permissions / role_permissions / role_route` 是否初始化成功 +3. 给几个测试账号分别挂 `provincial_admin / admin / common` +4. 再用接口实际验证: + - 文档列表是否按地区隔离 + - 当前用户信息是否正确返回 `area` + - 上传 / 评查是否存在越权 + +--- + +## 10. 当前文档与总设计文档的关系 + +如果你后面忘了这套体系总原则,看这里: + +- 总设计:`docs/用户与地区权限完整设计方案.md` +- 老系统分析:`docs/老系统_docauditai_用户权限架构深度分析.md` +- 本文:`docs/接口/用户权限与权限点清单.md` +- SQL:`docs/接口/用户权限初始化SQL.sql` + +这四份组合起来,就是当前新系统用户权限方案的完整本地留档。 diff --git a/docs/接口/用户权限初始化SQL.sql b/docs/接口/用户权限初始化SQL.sql new file mode 100644 index 0000000..80196c9 --- /dev/null +++ b/docs/接口/用户权限初始化SQL.sql @@ -0,0 +1,293 @@ +-- 用户权限初始化 SQL(leaudit-platform) +-- 目标:初始化最小可用的 RBAC + 单地区数据隔离模型 +-- 核心约定: +-- 1. 用户地区只认 sso_users.area +-- 2. 数据范围只认 ALL / DEPT / SELF +-- 3. 当前业务角色只保留 provincial_admin / admin / common +-- 4. super_admin 仅作为可选系统维护角色 + +BEGIN; + +-- ------------------------------------------------------------ +-- 1) 角色初始化 +-- ------------------------------------------------------------ +INSERT INTO roles (role_name, description, data_scope, status, created_at, updated_at) +VALUES + ('super_admin', '系统超级管理员(可选,仅系统维护使用)', 'ALL', 0, NOW(), NOW()), + ('provincial_admin', '省级管理员', 'ALL', 0, NOW(), NOW()), + ('admin', '地区管理员', 'DEPT', 0, NOW(), NOW()), + ('common', '普通用户', 'SELF', 0, NOW(), NOW()) +ON CONFLICT (role_name) DO UPDATE SET + description = EXCLUDED.description, + data_scope = EXCLUDED.data_scope, + status = EXCLUDED.status, + updated_at = NOW(); + +-- ------------------------------------------------------------ +-- 2) 菜单 / 路由初始化 +-- 说明:这里只放当前 leaudit-platform 已明确的核心菜单。 +-- ------------------------------------------------------------ +WITH upsert_routes AS ( + INSERT INTO sys_routes (path, name, component, parent_id, sort_order, visible, enabled, meta, created_at, updated_at) + VALUES + ('/documents', '文档管理', 'Layout', NULL, 10, TRUE, TRUE, '{"icon":"files"}'::jsonb, NOW(), NOW()), + ('/documents/list', '文档列表', 'documents/list', NULL, 11, TRUE, TRUE, '{"icon":"table"}'::jsonb, NOW(), NOW()), + ('/rules', '规则管理', 'Layout', NULL, 20, TRUE, TRUE, '{"icon":"rule"}'::jsonb, NOW(), NOW()), + ('/rules/sets', '规则集管理', 'rules/sets', NULL, 21, TRUE, TRUE, '{"icon":"yaml"}'::jsonb, NOW(), NOW()), + ('/audit', '评查任务', 'Layout', NULL, 30, TRUE, TRUE, '{"icon":"audit"}'::jsonb, NOW(), NOW()), + ('/audit/runs', '评查运行', 'audit/runs', NULL, 31, TRUE, TRUE, '{"icon":"history"}'::jsonb, NOW(), NOW()), + ('/system', '系统管理', 'Layout', NULL, 90, TRUE, TRUE, '{"icon":"setting"}'::jsonb, NOW(), NOW()), + ('/system/users', '用户管理', 'system/users', NULL, 91, TRUE, TRUE, '{"icon":"user"}'::jsonb, NOW(), NOW()), + ('/system/roles', '角色权限', 'system/roles', NULL, 92, TRUE, TRUE, '{"icon":"shield"}'::jsonb, NOW(), NOW()) + ON CONFLICT (path) DO UPDATE SET + name = EXCLUDED.name, + component = EXCLUDED.component, + sort_order = EXCLUDED.sort_order, + visible = EXCLUDED.visible, + enabled = EXCLUDED.enabled, + meta = EXCLUDED.meta, + updated_at = NOW() + RETURNING id, path +) +SELECT COUNT(*) FROM upsert_routes; + +-- ------------------------------------------------------------ +-- 3) 权限点初始化 +-- permission_code 建议全局唯一。 +-- ------------------------------------------------------------ +INSERT INTO permissions (permission_code, permission_name, resource_type, method, path, description, created_at, updated_at) +VALUES + ('auth.me', '查看当前登录用户', 'api', 'GET', '/api/auth/me', '获取当前用户登录信息', NOW(), NOW()), + + ('documents.upload', '上传文档', 'api', 'POST', '/api/upload', '上传文档并可选自动触发评查', NOW(), NOW()), + ('documents.list', '查看文档列表', 'api', 'GET', '/api/documents/list', '查看文档列表(按数据范围过滤)', NOW(), NOW()), + ('documents.detail', '查看文档详情', 'api', 'GET', '/api/documents/{documentId}', '查看单个文档详情', NOW(), NOW()), + ('documents.history', '查看文档历史版本', 'api', 'GET', '/api/documents/{documentId}/versions', '查看同名文档历史版本', NOW(), NOW()), + ('documents.delete', '删除文档', 'api', 'DELETE', '/api/documents/{documentId}', '删除文档或逻辑删除', NOW(), NOW()), + + ('audit.run', '发起评查任务', 'api', 'POST', '/api/audit/run', '手动触发文档评查', NOW(), NOW()), + ('audit.run.status', '查看评查运行状态', 'api', 'GET', '/api/audit/run/{runId}', '查看 run 当前状态', NOW(), NOW()), + ('audit.result', '查看评查结果', 'api', 'GET', '/api/audit/result/{runId}', '查看评查结果明细', NOW(), NOW()), + + ('rules.list', '查看规则集列表', 'api', 'GET', '/api/rule-sets', '查看规则集列表', NOW(), NOW()), + ('rules.version.list', '查看规则版本列表', 'api', 'GET', '/api/rule-sets/{ruleType}/versions', '查看某规则集的版本列表', NOW(), NOW()), + ('rules.content', '查看规则正文', 'api', 'GET', '/api/rule-sets/versions/{versionId}/content', '查看某个版本的 YAML 内容', NOW(), NOW()), + ('rules.validate', '校验规则 YAML', 'api', 'POST', '/api/rule-sets/{ruleType}/validate', '校验规则 YAML 是否合法', NOW(), NOW()), + ('rules.version.create', '创建规则版本', 'api', 'POST', '/api/rule-sets/{ruleType}/versions', '创建新规则版本', NOW(), NOW()), + ('rules.publish', '发布规则版本', 'api', 'POST', '/api/rule-sets/{ruleType}/publish', '发布规则版本', NOW(), NOW()), + ('rules.rollback', '回滚规则版本', 'api', 'POST', '/api/rule-sets/{ruleType}/rollback', '回滚规则版本', NOW(), NOW()), + ('rules.binding.list', '查看规则绑定', 'api', 'GET', '/api/rule-sets/bindings', '查看规则绑定列表', NOW(), NOW()), + ('rules.binding.create', '创建规则绑定', 'api', 'POST', '/api/rule-sets/{ruleType}/bindings', '创建规则绑定', NOW(), NOW()), + ('rules.binding.update', '更新规则绑定', 'api', 'PUT', '/api/rule-sets/bindings/{bindingId}', '更新规则绑定', NOW(), NOW()), + ('rules.binding.delete', '删除规则绑定', 'api', 'DELETE', '/api/rule-sets/bindings/{bindingId}', '删除规则绑定', NOW(), NOW()), + + ('users.list', '查看用户列表', 'api', 'GET', '/api/users/list', '查看用户列表(按地区过滤)', NOW(), NOW()), + ('users.create', '创建用户', 'api', 'POST', '/api/users', '创建本地用户或同步用户', NOW(), NOW()), + ('users.update', '更新用户', 'api', 'PUT', '/api/users/{userId}', '更新用户基础信息', NOW(), NOW()), + ('users.disable', '禁用用户', 'api', 'PUT', '/api/users/{userId}/disable', '禁用或启用用户', NOW(), NOW()), + ('users.roles.assign', '分配用户角色', 'api', 'POST', '/api/users/{userId}/roles', '为用户分配角色', NOW(), NOW()), + + ('rbac.roles.list', '查看角色列表', 'api', 'GET', '/api/rbac/roles', '查看角色列表', NOW(), NOW()), + ('rbac.roles.update', '维护角色信息', 'api', 'PUT', '/api/rbac/roles/{roleId}', '维护角色定义', NOW(), NOW()), + ('rbac.permissions.list', '查看权限点列表', 'api', 'GET', '/api/rbac/permissions', '查看权限点列表', NOW(), NOW()), + ('rbac.role_permissions.assign', '分配角色权限', 'api', 'POST', '/api/rbac/roles/{roleId}/permissions', '分配角色权限点', NOW(), NOW()), + ('rbac.role_routes.assign', '分配角色菜单', 'api', 'PUT', '/api/rbac/roles/{roleId}/routes', '分配角色菜单', NOW(), NOW()) +ON CONFLICT (permission_code) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + resource_type = EXCLUDED.resource_type, + method = EXCLUDED.method, + path = EXCLUDED.path, + description = EXCLUDED.description, + updated_at = NOW(); + +-- ------------------------------------------------------------ +-- 4) 角色菜单授权 +-- ------------------------------------------------------------ +WITH role_map AS ( + SELECT id, role_name FROM roles WHERE role_name IN ('super_admin', 'provincial_admin', 'admin', 'common') +), +route_map AS ( + SELECT id, path FROM sys_routes WHERE path IN ( + '/documents', '/documents/list', + '/rules', '/rules/sets', + '/audit', '/audit/runs', + '/system', '/system/users', '/system/roles' + ) +), +seed(role_name, path) AS ( + VALUES + ('super_admin', '/documents'), + ('super_admin', '/documents/list'), + ('super_admin', '/rules'), + ('super_admin', '/rules/sets'), + ('super_admin', '/audit'), + ('super_admin', '/audit/runs'), + ('super_admin', '/system'), + ('super_admin', '/system/users'), + ('super_admin', '/system/roles'), + + ('provincial_admin', '/documents'), + ('provincial_admin', '/documents/list'), + ('provincial_admin', '/rules'), + ('provincial_admin', '/rules/sets'), + ('provincial_admin', '/audit'), + ('provincial_admin', '/audit/runs'), + ('provincial_admin', '/system'), + ('provincial_admin', '/system/users'), + ('provincial_admin', '/system/roles'), + + ('admin', '/documents'), + ('admin', '/documents/list'), + ('admin', '/rules'), + ('admin', '/rules/sets'), + ('admin', '/audit'), + ('admin', '/audit/runs'), + ('admin', '/system'), + ('admin', '/system/users'), + + ('common', '/documents'), + ('common', '/documents/list'), + ('common', '/audit'), + ('common', '/audit/runs') +) +INSERT INTO role_route (role_id, route_id, created_at) +SELECT DISTINCT rm.id, tm.id, NOW() +FROM seed s +JOIN role_map rm ON rm.role_name = s.role_name +JOIN route_map tm ON tm.path = s.path +ON CONFLICT (role_id, route_id) DO NOTHING; + +-- ------------------------------------------------------------ +-- 5) 角色权限点授权 +-- 说明:role_permissions.data_scope 优先级高于 roles.data_scope。 +-- ------------------------------------------------------------ +WITH role_map AS ( + SELECT id, role_name FROM roles WHERE role_name IN ('super_admin', 'provincial_admin', 'admin', 'common') +), +perm_map AS ( + SELECT id, permission_code FROM permissions +), +seed(role_name, permission_code, data_scope) AS ( + VALUES + ('super_admin', 'auth.me', 'ALL'), + ('super_admin', 'documents.upload', 'ALL'), + ('super_admin', 'documents.list', 'ALL'), + ('super_admin', 'documents.detail', 'ALL'), + ('super_admin', 'documents.history', 'ALL'), + ('super_admin', 'documents.delete', 'ALL'), + ('super_admin', 'audit.run', 'ALL'), + ('super_admin', 'audit.run.status', 'ALL'), + ('super_admin', 'audit.result', 'ALL'), + ('super_admin', 'rules.list', 'ALL'), + ('super_admin', 'rules.version.list', 'ALL'), + ('super_admin', 'rules.content', 'ALL'), + ('super_admin', 'rules.validate', 'ALL'), + ('super_admin', 'rules.version.create', 'ALL'), + ('super_admin', 'rules.publish', 'ALL'), + ('super_admin', 'rules.rollback', 'ALL'), + ('super_admin', 'rules.binding.list', 'ALL'), + ('super_admin', 'rules.binding.create', 'ALL'), + ('super_admin', 'rules.binding.update', 'ALL'), + ('super_admin', 'rules.binding.delete', 'ALL'), + ('super_admin', 'users.list', 'ALL'), + ('super_admin', 'users.create', 'ALL'), + ('super_admin', 'users.update', 'ALL'), + ('super_admin', 'users.disable', 'ALL'), + ('super_admin', 'users.roles.assign', 'ALL'), + ('super_admin', 'rbac.roles.list', 'ALL'), + ('super_admin', 'rbac.roles.update', 'ALL'), + ('super_admin', 'rbac.permissions.list', 'ALL'), + ('super_admin', 'rbac.role_permissions.assign', 'ALL'), + ('super_admin', 'rbac.role_routes.assign', 'ALL'), + + ('provincial_admin', 'auth.me', 'ALL'), + ('provincial_admin', 'documents.upload', 'ALL'), + ('provincial_admin', 'documents.list', 'ALL'), + ('provincial_admin', 'documents.detail', 'ALL'), + ('provincial_admin', 'documents.history', 'ALL'), + ('provincial_admin', 'documents.delete', 'ALL'), + ('provincial_admin', 'audit.run', 'ALL'), + ('provincial_admin', 'audit.run.status', 'ALL'), + ('provincial_admin', 'audit.result', 'ALL'), + ('provincial_admin', 'rules.list', 'ALL'), + ('provincial_admin', 'rules.version.list', 'ALL'), + ('provincial_admin', 'rules.content', 'ALL'), + ('provincial_admin', 'rules.validate', 'ALL'), + ('provincial_admin', 'rules.version.create', 'ALL'), + ('provincial_admin', 'rules.publish', 'ALL'), + ('provincial_admin', 'rules.rollback', 'ALL'), + ('provincial_admin', 'rules.binding.list', 'ALL'), + ('provincial_admin', 'rules.binding.create', 'ALL'), + ('provincial_admin', 'rules.binding.update', 'ALL'), + ('provincial_admin', 'rules.binding.delete', 'ALL'), + ('provincial_admin', 'users.list', 'ALL'), + ('provincial_admin', 'users.create', 'ALL'), + ('provincial_admin', 'users.update', 'ALL'), + ('provincial_admin', 'users.disable', 'ALL'), + ('provincial_admin', 'users.roles.assign', 'ALL'), + ('provincial_admin', 'rbac.roles.list', 'ALL'), + ('provincial_admin', 'rbac.roles.update', 'ALL'), + ('provincial_admin', 'rbac.permissions.list', 'ALL'), + ('provincial_admin', 'rbac.role_permissions.assign', 'ALL'), + ('provincial_admin', 'rbac.role_routes.assign', 'ALL'), + + ('admin', 'auth.me', 'DEPT'), + ('admin', 'documents.upload', 'DEPT'), + ('admin', 'documents.list', 'DEPT'), + ('admin', 'documents.detail', 'DEPT'), + ('admin', 'documents.history', 'DEPT'), + ('admin', 'documents.delete', 'DEPT'), + ('admin', 'audit.run', 'DEPT'), + ('admin', 'audit.run.status', 'DEPT'), + ('admin', 'audit.result', 'DEPT'), + ('admin', 'rules.list', 'DEPT'), + ('admin', 'rules.version.list', 'DEPT'), + ('admin', 'rules.content', 'DEPT'), + ('admin', 'rules.validate', 'DEPT'), + ('admin', 'rules.binding.list', 'DEPT'), + ('admin', 'rules.binding.create', 'DEPT'), + ('admin', 'rules.binding.update', 'DEPT'), + ('admin', 'users.list', 'DEPT'), + ('admin', 'users.update', 'DEPT'), + + ('common', 'auth.me', 'SELF'), + ('common', 'documents.upload', 'SELF'), + ('common', 'documents.list', 'SELF'), + ('common', 'documents.detail', 'SELF'), + ('common', 'documents.history', 'SELF'), + ('common', 'audit.run', 'SELF'), + ('common', 'audit.run.status', 'SELF'), + ('common', 'audit.result', 'SELF'), + ('common', 'rules.list', 'DEPT'), + ('common', 'rules.version.list', 'DEPT'), + ('common', 'rules.content', 'DEPT'), + ('common', 'rules.binding.list', 'DEPT') +) +INSERT INTO role_permissions (role_id, permission_id, data_scope, created_at) +SELECT DISTINCT rm.id, pm.id, s.data_scope, NOW() +FROM seed s +JOIN role_map rm ON rm.role_name = s.role_name +JOIN perm_map pm ON pm.permission_code = s.permission_code +ON CONFLICT (role_id, permission_id) DO UPDATE SET + data_scope = EXCLUDED.data_scope; + +COMMIT; + +-- ------------------------------------------------------------ +-- 6) 使用示例(按需执行,不建议直接整段上线) +-- ------------------------------------------------------------ +-- 将一个现有用户提为地区管理员: +-- INSERT INTO user_role (user_id, role_id, created_at) +-- SELECT 1001, id, NOW() FROM roles WHERE role_name = 'admin' +-- ON CONFLICT (user_id, role_id) DO NOTHING; + +-- 查看角色及其默认数据范围: +-- SELECT role_name, data_scope FROM roles ORDER BY id; + +-- 查看某角色已分配的权限点: +-- SELECT r.role_name, p.permission_code, rp.data_scope +-- FROM role_permissions rp +-- JOIN roles r ON r.id = rp.role_id +-- JOIN permissions p ON p.id = rp.permission_id +-- WHERE r.role_name = 'admin' +-- ORDER BY p.permission_code; diff --git a/docs/用户与地区权限完整设计方案.md b/docs/用户与地区权限完整设计方案.md new file mode 100644 index 0000000..cbe8008 --- /dev/null +++ b/docs/用户与地区权限完整设计方案.md @@ -0,0 +1,777 @@ +# 用户与地区权限完整设计方案 + +> 本文档为 `leaudit-platform` 当前阶段的正式权限设计稿,已经按实际业务收口。 +> 核心结论:**只做单地区归属 + 角色权限 + 数据范围隔离**,不再做多地区用户模型,不再通过前端端口推断地区。 + +--- + +## 1. 这份设计最终解决什么问题 + +当前新系统要解决的,不是一个抽象的“大而全权限平台”,而是一个非常明确的业务问题: + +- 用户登录后,系统知道“这个人是谁” +- 系统知道“这个人属于哪个地区” +- 系统知道“这个人是什么角色” +- 接口查询时,系统自动限制他能看到哪些数据 +- 前端菜单、按钮、接口能力,按角色和权限点控制 + +也就是说,新系统最终采用的是: + +- `RBAC + 单地区数据隔离` + +而不是: + +- 多地区用户模型 +- 自定义地区集合授权 +- 通过前端端口识别地区 +- 复杂租户树 / 组织树权限 + +--- + +## 2. 先说最终结论 + +### 2.1 当前真实业务模型 + +当前业务可以收敛为下面四件事: + +1. 一个用户只挂一个主地区 +2. 数据隔离主要按这个地区做 +3. 角色只有少量几类,不需要发明新角色 +4. 权限判断分两层: + - 功能权限:能不能访问某接口 / 菜单 / 动作 + - 数据权限:即使能访问,也不一定能看所有地区的数据 + +### 2.2 当前应保留的角色 + +按你最新确认,当前只保留下面这几类业务角色: + +- `provincial_admin` +- `admin` +- `common` + +技术上可以保留一个可选的: + +- `super_admin` + +但它只建议作为系统级运维 / 超级初始化账号使用,不建议作为日常业务角色依赖。 + +### 2.3 明确不要的东西 + +这次设计里,明确不做下面这些复杂化模型: + +- `city_admin` +- `review_manager` +- `review_user` +- `rule_admin` +- `home_region_code` +- `accessible_regions` +- `user_regions` +- `user_region_scope` +- `CUSTOM_REGIONS` +- 前端端口映射地区 +- 一个用户多地区授权集合 + +这些概念要么不是当前真实业务需要,要么会把系统越做越重。 + +--- + +## 3. 老系统真实逻辑,应该继承什么 + +结合对老项目 `/home/wren-dev/Porject/docauditai` 的代码分析,老系统的真实权限结构不是“只有 6 张表”,而是: + +- 用户主表:`sso_users` +- 角色表:`roles` +- 用户角色关系:`user_role` +- 路由菜单:`sys_routes` +- 角色路由关系:`role_route` +- 权限点:`permissions` +- 角色权限点关系:`role_permissions` +- 数据范围:`roles.data_scope`、`role_permissions.data_scope` +- 用户地区:`sso_users.area` + +所以新系统最合理的做法不是推翻重来,而是: + +- 继承老系统的 `RBAC + area 数据隔离` +- 去掉老系统里不够清晰、靠约定隐式生效的部分 +- 在新系统里把“地区”“角色”“权限点”“数据范围”写清楚 + +--- + +## 4. 新系统最终权限模型 + +## 4.1 总体结构 + +新系统建议正式采用下面这套结构: + +- `sso_users`:用户主表 +- `roles`:角色表 +- `user_role`:用户角色关系 +- `sys_routes`:菜单 / 路由定义 +- `role_route`:角色菜单关系 +- `permissions`:接口 / 动作权限点 +- `role_permissions`:角色权限点关系 + +数据隔离不单独建“用户地区关系表”,而是直接依赖: + +- `sso_users.area` +- 业务表中的 `region` + +### 4.2 权限判断分层 + +每次请求都分两步: + +#### 第一步:功能权限判断 + +判断用户有没有权访问: + +- 某个页面 +- 某个接口 +- 某个动作 + +这个由: + +- `roles` +- `role_route` +- `role_permissions` + +决定。 + +#### 第二步:数据范围判断 + +即使用户有接口权限,也要再判断他能看哪些数据。 + +这个由: + +- 用户角色对应的 `data_scope` +- 用户自身的 `sso_users.area` +- 业务数据的 `region` + +共同决定。 + +--- + +## 5. 地区模型正式收口 + +## 5.1 用户地区字段只保留一个主字段 + +当前阶段,用户只保留一个地区归属字段: + +- `sso_users.area` + +它的语义正式定义为: + +- 用户主归属地区 +- 用户默认数据隔离地区 +- 非省级角色的数据过滤依据 + +它**不再表示**: + +- 前端访问入口地区 +- 临时切换地区 +- 多地区集合 + +### 5.2 业务数据地区字段 + +业务表保留自己的地区字段,例如: + +- `leaudit_documents.region` +- `leaudit_runs.region`(如果后续补) +- `leaudit_rule_bindings.region` + +这些字段的语义是: + +- 该业务数据归属哪个地区 + +### 5.3 地区过滤规则 + +- 如果角色数据范围是 `ALL`,则不按地区过滤 +- 如果角色数据范围是 `DEPT`,则只能看 `region = sso_users.area` 的数据 +- 如果角色数据范围是 `SELF`,则只能看自己创建 / 上传 / 归属自己的数据 + +这里的 `DEPT` 虽然沿用老系统命名,但在新系统里应明确理解为: + +- **同地区** +- 不是传统组织架构里的“同部门” + +也就是说: + +- `DEPT = same area` + +--- + +## 6. 角色定义 + +## 6.1 `provincial_admin` + +建议语义: + +- 省级管理员 +- 可看全省数据 +- 可管理地区级业务配置 +- 可查看全局文档、任务、评查结果 + +建议默认数据范围: + +- `ALL` + +## 6.2 `admin` + +建议语义: + +- 地区管理员 +- 只能看自己地区数据 +- 可管理本地区用户日常业务、文档、规则绑定、评查结果 + +建议默认数据范围: + +- `DEPT` + +## 6.3 `common` + +建议语义: + +- 普通业务用户 +- 通常只能处理自己提交或自己负责的数据 +- 也可以视业务需要,局部开放为“看本地区” + +建议默认数据范围: + +- 默认 `SELF` +- 某些只读类权限点可单独放宽为 `DEPT` + +## 6.4 `super_admin`(可选) + +建议语义: + +- 系统超级管理员 / 初始化管理员 +- 不参与日常地区业务流程 +- 仅用于系统维护、初始化、紧急排障 + +建议默认数据范围: + +- `ALL` + +--- + +## 7. 数据范围模型 + +## 7.1 建议保留三种 scope + +为了兼容老系统思路,并保持简单,正式只保留三种: + +- `ALL` +- `DEPT` +- `SELF` + +### 7.2 含义定义 + +#### `ALL` + +- 查看全部数据 +- 不做地区过滤 + +#### `DEPT` + +- 实际表示“同地区” +- 查询自动加条件:`region = 当前用户.area` + +#### `SELF` + +- 只能查看自己的数据 +- 查询自动加条件,例如: + - `created_by = 当前用户.id` + - 或 `uploaded_by = 当前用户.id` + - 或 `owner_id = 当前用户.id` + +### 7.3 推荐生效顺序 + +如果一个用户有多个角色、多条权限记录,建议按以下规则收敛: + +1. 先确认功能权限是否命中 +2. 再收集该权限点对应的所有 `data_scope` +3. 取范围最大的那个作为最终可见范围 + +范围大小建议定义为: + +- `ALL > DEPT > SELF` + +例如: + +- 用户既是 `common`,又临时带一个 `admin` 角色 +- 对某个接口命中了两条权限:`SELF` 和 `DEPT` +- 那最终按 `DEPT` 生效 + +--- + +## 8. 建议保留 / 改造的表结构 + +以下不是要求把库推倒重建,而是“新系统正式语义定义”。 + +## 8.1 `sso_users` + +建议核心字段至少明确为: + +```sql +CREATE TABLE sso_users ( + id BIGSERIAL PRIMARY KEY, + sub VARCHAR(128) NOT NULL UNIQUE, + username VARCHAR(128), + nick_name VARCHAR(128), + email VARCHAR(256), + phone_number VARCHAR(64), + password_hash VARCHAR(255), + status SMALLINT NOT NULL DEFAULT 0, + is_leader BOOLEAN NOT NULL DEFAULT FALSE, + area VARCHAR(64), + tenant_id BIGINT NULL, + department_id BIGINT NULL, + source_type VARCHAR(32) NOT NULL DEFAULT 'oauth', + source_payload JSONB NULL, + try_count INT NOT NULL DEFAULT 0, + try_login_time TIMESTAMPTZ NULL, + last_login_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL +); +``` + +### `sso_users` 字段解释 + +- `sub`:统一认证唯一标识,OAuth 用户核心身份键 +- `username`:登录名 / 工号 / 本地账号名 +- `nick_name`:姓名 +- `area`:用户归属地区,当前阶段最关键业务字段 +- `source_type`:`oauth` / `local` / `imported` +- `source_payload`:保留外部登录原始字段快照 +- `status`:是否启用 + +### 关键约束 + +- `area` 必须是后台可信字段 +- `area` 不能再由前端端口决定 +- OAuth 登录后,如果需要更新地区,也必须来自后端组织同步或后台管理维护 + +## 8.2 `roles` + +```sql +CREATE TABLE roles ( + id BIGSERIAL PRIMARY KEY, + role_name VARCHAR(64) NOT NULL UNIQUE, + description TEXT, + data_scope VARCHAR(16) NOT NULL DEFAULT 'SELF', + status SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +建议初始化: + +```sql +INSERT INTO roles (role_name, description, data_scope) VALUES +('provincial_admin', '省级管理员', 'ALL'), +('admin', '地区管理员', 'DEPT'), +('common', '普通用户', 'SELF'), +('super_admin', '系统超级管理员', 'ALL'); +``` + +## 8.3 `user_role` + +```sql +CREATE TABLE user_role ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES sso_users(id), + role_id BIGINT NOT NULL REFERENCES roles(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, role_id) +); +``` + +## 8.4 `sys_routes` + +```sql +CREATE TABLE sys_routes ( + id BIGSERIAL PRIMARY KEY, + path VARCHAR(255) NOT NULL, + name VARCHAR(128) NOT NULL, + component VARCHAR(255), + parent_id BIGINT NULL, + sort_order INT NOT NULL DEFAULT 0, + visible BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + meta JSONB NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +## 8.5 `role_route` + +```sql +CREATE TABLE role_route ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL REFERENCES roles(id), + route_id BIGINT NOT NULL REFERENCES sys_routes(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (role_id, route_id) +); +``` + +## 8.6 `permissions` + +```sql +CREATE TABLE permissions ( + id BIGSERIAL PRIMARY KEY, + permission_code VARCHAR(128) NOT NULL UNIQUE, + permission_name VARCHAR(128) NOT NULL, + resource_type VARCHAR(64) NOT NULL, + method VARCHAR(16) NULL, + path VARCHAR(255) NULL, + description TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +建议权限编码风格: + +- `documents.list` +- `documents.upload` +- `documents.detail` +- `documents.delete` +- `rules.binding.manage` +- `users.manage` + +## 8.7 `role_permissions` + +```sql +CREATE TABLE role_permissions ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL REFERENCES roles(id), + permission_id BIGINT NOT NULL REFERENCES permissions(id), + data_scope VARCHAR(16) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (role_id, permission_id) +); +``` + +这里的 `data_scope` 允许覆盖 `roles.data_scope`。 + +推荐生效规则: + +- 优先取 `role_permissions.data_scope` +- 如果为空,再回退到 `roles.data_scope` + +这样可以实现: + +- 角色整体是 `SELF` +- 但对某个只读接口单独放宽为 `DEPT` + +--- + +## 9. JWT 与登录态设计 + +## 9.1 JWT 应携带什么 + +JWT 不要继续塞一堆不稳定字段,但至少应携带: + +```json +{ + "user_id": 1001, + "sub": "oauth-sub-xxx", + "username": "zhangsan", + "nick_name": "张三", + "area": "潮州", + "roles": ["admin"], + "permissions": ["documents.list", "documents.upload"], + "is_super_admin": false, + "exp": 1770000000 +} +``` + +### 核心要求 + +- `area` 要进 JWT,便于接口层直接做地区过滤 +- `roles` 要进 JWT,便于快速做角色识别 +- `permissions` 可按系统实现选择是否进 token + - 若放入 JWT,接口判断更快 + - 若不放入 JWT,则请求时查库 + +## 9.2 登录来源 + +统一登录支持: + +- OAuth 登录 +- 账号密码登录 + +但两种登录最后都必须落到同一套用户主数据: + +- `sso_users` +- `user_role` +- `roles` +- `area` + +### 明确禁止 + +禁止再按下面方式确定地区: + +- 前端端口 +- 前端域名 +- 页面参数 +- 浏览器本地缓存里的地区值 + +--- + +## 10. 接口契约建议 + +下面给的是新系统正式推荐接口契约。 + +## 10.1 登录返回 + +### `POST /api/auth/login` + +#### 返回示例 + +```json +{ + "code": 200, + "message": "ok", + "data": { + "accessToken": "jwt-token", + "tokenType": "bearer", + "expiresIn": 7200, + "userInfo": { + "userId": 1001, + "sub": "oauth-sub-xxx", + "username": "zhangsan", + "nickName": "张三", + "area": "潮州", + "roles": ["admin"], + "permissions": [ + "documents.list", + "documents.upload", + "documents.detail" + ] + } + } +} +``` + +## 10.2 当前用户信息 + +### `GET /api/auth/me` + +#### 返回示例 + +```json +{ + "code": 200, + "message": "ok", + "data": { + "userId": 1001, + "username": "zhangsan", + "nickName": "张三", + "area": "潮州", + "roles": ["admin"], + "permissions": ["documents.list", "documents.upload"] + } +} +``` + +## 10.3 用户列表 + +### `GET /api/users/list` + +查询规则: + +- `provincial_admin` / `super_admin`:可查全部用户 +- `admin`:只能看本地区用户 +- `common`:通常不开放 + +#### 建议查询条件 + +- `username` +- `nickName` +- `area` +- `roleName` +- `status` + +## 10.4 文档列表 + +### `GET /api/documents/list` + +查询规则: + +- `ALL`:不加地区过滤 +- `DEPT`:自动加 `region = 当前用户.area` +- `SELF`:自动加 `uploaded_by = 当前用户.id` + +**注意:前端传了 `region` 也不能绕过后端数据权限。** + +--- + +## 11. 后端权限判定顺序 + +建议每个受保护接口按下面顺序执行。 + +### 11.1 第一步:解析 JWT + +拿到: + +- `user_id` +- `area` +- `roles` +- `permissions` + +### 11.2 第二步:校验功能权限 + +例如访问: + +- `/api/documents/list` + +要求权限码: + +- `documents.list` + +如果没有这个权限,直接返回 403。 + +### 11.3 第三步:确定最终数据范围 + +按顺序取值: + +1. 当前权限点对应的 `role_permissions.data_scope` +2. 角色默认 `roles.data_scope` + +多角色时,取范围最大的那个。 + +### 11.4 第四步:注入数据过滤条件 + +例如文档列表: + +- `ALL` → 不加过滤 +- `DEPT` → `WHERE d.region = :current_user_area` +- `SELF` → `WHERE d.uploaded_by = :current_user_id` + +### 11.5 第五步:执行查询 + +这样做可以避免: + +- 前端绕过地区限制 +- 前端伪造地区参数 +- 页面隐藏了但接口仍可越权访问 + +--- + +## 12. 与文档业务表的关系 + +## 12.1 为什么地区过滤只靠 `sso_users.area` + +因为你已经确认,当前业务不是“一个用户可看多个地区”。 + +所以没必要引入: + +- 用户地区关系表 +- 多地区授权集合 +- 区域切换态 + +当前最稳的做法就是: + +- 用户一个 `area` +- 文档一个 `region` +- 查询时直接比对 + +## 12.2 文档上传时怎么处理地区 + +上传时建议规则: + +- 如果前端未传 `region`,则默认取当前用户 `area` +- 如果前端传了 `region`: + - `provincial_admin` 可指定任意合法地区 + - `admin` 只能传与自己 `area` 相同的值 + - `common` 通常不允许跨地区指定 + +这样能保证: + +- 数据归属清晰 +- 省级管理员可代地区上传 +- 地区管理员不能越区写数据 + +--- + +## 13. 现在不建议做的事 + +以下内容当前都不建议提前上: + +- 多地区切换 +- 自定义用户地区集合 +- 按前端入口决定地区 +- 复杂组织树联动到每个业务接口 +- 为每个业务再单独造一套权限表 +- 设计很多当前不存在的角色 + +原因很简单: + +- 真实业务还没要求到这一步 +- 这些模型一旦上了,后续维护复杂度会明显增加 +- 现阶段最重要的是把“登录 -> 用户 -> 地区 -> 角色 -> 数据过滤”这条链路先做稳定 + +--- + +## 14. 推荐落地顺序 + +## 14.1 第一阶段:先把模型定义收口 + +确认并冻结: + +- 用户地区字段就是 `sso_users.area` +- 角色就是 `provincial_admin / admin / common` +- 数据范围就是 `ALL / DEPT / SELF` + +## 14.2 第二阶段:统一登录返回结构 + +确保 OAuth 登录、账密登录都统一返回: + +- 用户基本信息 +- `area` +- `roles` +- `permissions` + +## 14.3 第三阶段:把接口权限检查和数据范围注入补齐 + +优先补以下接口: + +- `/api/auth/me` +- `/api/documents/list` +- `/api/documents/detail` +- `/api/documents/upload` +- `/api/users/list` + +## 14.4 第四阶段:补文档与前端说明 + +把以下内容写成开发规范: + +- 哪些接口需要什么权限码 +- 哪些接口如何按 `area` 过滤 +- 前端不允许自己决定地区 +- 上传 / 列表 / 详情的地区行为规则 + +--- + +## 15. 最终版设计结论 + +新系统最终建议正式采用: + +- `RBAC + 单地区隔离` + +具体落地为: + +- 用户主表核心字段:`sso_users.area` +- 角色:`provincial_admin / admin / common`,可选 `super_admin` +- 数据范围:`ALL / DEPT / SELF` +- 地区过滤:按 `业务表.region` 对比 `sso_users.area` +- 功能权限:`roles + role_route + permissions + role_permissions` +- 不再做前端端口识别地区 +- 不再做多地区用户模型 + +这是当前最符合你们真实业务、最接近老系统有效逻辑、同时又足够轻量可落地的一版方案。 diff --git a/docs/用户权限开发TaskList.md b/docs/用户权限开发TaskList.md new file mode 100644 index 0000000..a4cac35 --- /dev/null +++ b/docs/用户权限开发TaskList.md @@ -0,0 +1,878 @@ +# 用户权限开发 TaskList + +> 目标:在 `leaudit-platform` 里,基于当前真实业务落地一套 **单地区隔离 + 角色权限 + 数据范围控制** 的用户体系,并能把老系统用户数据平滑迁移过来。 + +这份文档不是泛泛设计稿,而是后续开发的执行清单。 + +--- + +## 1. 当前结论先说清楚 + +当前这条线,应该按下面原则做,不要再发散: + +- 用户地区只认 `sso_users.area` +- 数据隔离只做单地区,不做多地区授权 +- 当前业务角色只保留: + - `provincial_admin` + - `admin` + - `common` +- `super_admin` 只作为可选系统维护角色 +- 数据范围只保留: + - `ALL` + - `DEPT` + - `SELF` +- 新系统要兼容老系统已有数据结构,不要为了“理论优雅”把老数据迁移难度抬高 + +--- + +## 2. 当前新系统现状盘点 + +## 2.0 本次实际检查结果(2026-04-29) + +我已经实际检查了当前 `leaudit_platform` 新库和老库 `docauditai`,结论如下: + +- 新库 `leaudit_platform` 原先 **完全没有** 这 7 张核心 RBAC 表: + - `sso_users` + - `roles` + - `user_role` + - `permissions` + - `role_permissions` + - `sys_routes` + - `role_route` +- 新库现阶段只有 `leaudit_*` 业务表,所以原有认证/权限代码其实处于“代码已写、库表未落地”的状态 +- 我已经补了可执行 SQL: + - `scripts/user_rbac_schema_patch.sql` + - `scripts/user_rbac_seed.sql` + - `scripts/user_rbac_migration_audit.sql` +- 我已经把 `schema_patch + seed` 执行进当前新库 +- 当前新库 RBAC 初始化结果: + - `roles = 4` + - `permissions = 30` + - `role_permissions = 90` + - `sys_routes = 9` + - `role_route = 30` + - `sso_users = 0` + - `user_role = 0` + +老库 `docauditai` 的迁移审计关键结果: + +- `sso_users = 4106` +- `roles = 3` +- `user_role = 11` +- `permissions = 133` +- `role_permissions = 222` +- `sys_routes = 34` +- `role_route = 95` +- 老库 **没有空地区、没有重复 sub、没有重复 username** +- 老库 **4106 个用户里有 4098 个没有角色** +- 老库真实角色分布非常集中: + - `common = 6` + - `admin = 4` + - `provincial_admin = 1` + +这说明: + +- 老系统用户登录主数据是可迁的 +- 但角色并不是“每个用户都显式分配了 user_role” +- 新系统迁移时必须补一轮“默认角色落地策略”,不能只机械复制 `user_role` + +## 2.1 已有能力 + +当前新系统已经有这些基础: + +- 已有 `sso_users` 登录查询逻辑 +- 已有 JWT 签发与鉴权 +- 已有 `user_role / roles / role_permissions / permissions` 的查询代码雏形 +- 已有受保护路由统一挂载 JWT 依赖:`fastapi_modules/fastapi_leaudit/controllers/__init__.py` +- 已有认证入口:`/auth/login`、`/auth/password_login` + +## 2.2 当前实际代码路径 + +当前这条线的核心代码在: + +- `fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/services/impl/permissionServiceImpl.py` +- `fastapi_common/fastapi_common_security/jwtService.py` +- `fastapi_common/fastapi_common_security/security.py` +- `fastapi_modules/fastapi_leaudit/controllers/auth/authController.py` + +## 2.3 当前最关键的问题 + +### 问题 1:权限设计文档和实际代码字段名不一致 + +当前代码查的是: + +- `roles.role_key` +- `permissions.permission_key` +- `role_permissions.grant_type` + +但我前面新写的初始化 SQL 用的是: + +- `roles.role_name` +- `permissions.permission_code` + +这说明: + +- **现阶段不能直接执行那份初始化 SQL 到现有库** +- 必须先按当前实际数据库字段重新收一版正式建表/初始化方案 + +### 问题 2:登录后只取“一个角色” + +当前 `authServiceImpl.py` 里: + +```python +SELECT r.role_key ... LIMIT 1 +``` + +这意味着: + +- 多角色用户会被截断成单角色 +- JWT 中的 `roles` 没真正使用起来 +- 后续 `ALL > DEPT > SELF` 的数据范围收敛逻辑无法成立 + +### 问题 3:JWT 里没有真正放入完整权限集 + +当前 JWT 虽然支持 `roles` 字段,但登录时只传了: + +- `userRole` +- `area` + +没有把: + +- 全量角色列表 +- 权限点列表 +- 最终数据范围摘要 + +稳定打进去。 + +这会导致: + +- `/me` 接口还不完整 +- 前端拿不到统一用户权限视图 +- 后端每次都要重新查库,且现有逻辑并未闭环 + +### 问题 4:当前没有统一的数据范围注入层 + +现在只是“能查角色 / 权限”,但还没有真正统一实现: + +- 文档列表自动按 `area` 过滤 +- 用户列表自动按 `area` 过滤 +- 评查 run / result 自动按文档归属校验 + +也就是说: + +- **功能权限雏形有了,数据权限还没有真正落地** + +### 问题 5:OAuth 登录仍然可能把 area 当外部输入覆盖 + +当前 OAuth 登录里: + +- `Area=requestData.get("area")` +- 然后直接 `UPDATE sso_users SET ... area = :area` + +这是很危险的。 + +因为这意味着: + +- 前端或调用方传什么 area,就可能把用户地区改掉 +- 数据隔离基础字段可能被外部请求污染 + +后续必须改成: + +- area 只能来自后台可信源 +- 登录请求不能直接覆盖 area + +### 问题 6:新用户自动创建后没有默认角色闭环 + +当前 OAuth 首登自动插入 `sso_users`,但代码里没有看到紧接着分配默认 `common` 角色的闭环。 + +风险: + +- 新用户建出来了,但没有角色 +- 没角色则权限查询为空 +- 前端表现为“登录成功但啥也看不到” + +### 问题 7:密码仍然是明文比对 + +当前 `PasswordLogin()` 里: + +- 直接 `user.get("password") != Password` + +这是明显安全风险。 + +即使当前先沿用旧系统,也至少要在 tasklist 里明确: + +- 第一阶段兼容旧明文 +- 第二阶段升级为哈希存储与校验 + +### 问题 8:缺少统一的 `/api/auth/me` + +当前文档里已经定义了 `/api/auth/me`,但现有 controller 里还没有真正落地。 + +这会影响: + +- 前端初始化用户态 +- 角色/权限/地区统一回显 +- 登录后刷新恢复 + +--- + +## 3. 老系统可迁移数据结论 + +基于老项目 `docauditai` 的数据库快照和代码分析,当前可直接继承的核心表是: + +- `sso_users` +- `roles` +- `user_role` +- `permissions` +- `role_permissions` +- `sys_routes` +- `role_route` + +## 3.1 老系统 `sso_users` 关键字段 + +老系统 `sso_users` 目前至少包含: + +- `id` +- `sub` +- `username` +- `nick_name` +- `phone_number` +- `email` +- `ou_id` +- `ou_name` +- `status` +- `is_leader` +- `password` +- `try_count` +- `try_login_time` +- `area` +- `tenant_name` +- `dep_short_name` +- `dep_name` +- `mq_person_uuid` +- `mq_account_uuid` +- `mq_synced_at` +- `created_at` +- `updated_at` +- `deleted_at` + +### 迁移判断 + +这说明: + +- 新系统**完全没必要重新发明用户主表** +- 最优方案是: + - 继续沿用 `sso_users` + - 补必要索引/约束/注释 + - 修正其业务语义 + +## 3.2 老系统 `roles` 关键字段 + +老系统 `roles` 目前至少包含: + +- `id` +- `role_key` +- `role_name` +- `data_scope` +- `description` +- `parent_role_id` +- `priority` +- `is_system_role` +- `permissions_cache` +- `metadata` +- `created_at` +- `updated_at` + +### 迁移判断 + +建议: + +- 保留 `role_key` 作为机器标识 +- 保留 `role_name` 作为中文/展示名 +- 继续使用 `data_scope` +- `parent_role_id / permissions_cache / metadata` 可以先保留但不作为第一阶段核心依赖 + +## 3.3 老系统 `permissions` 关键字段 + +老系统 `permissions` 目前至少包含: + +- `id` +- `permission_key` +- `module` +- `resource` +- `action` +- `description` +- `display_name` +- `permission_type` +- `is_system` +- `metadata` +- `parent_id` +- `sort_order` +- `route_id` +- `api_path` +- `api_method` +- `created_at` +- `updated_at` + +### 迁移判断 + +建议: + +- 不要再新造 `permission_code` +- 直接沿用 `permission_key` +- 后续新系统全部按 `permission_key` 统一 + +## 3.4 老系统 `role_permissions` 关键字段 + +老系统 `role_permissions` 目前至少包含: + +- `role_id` +- `permission_id` +- `grant_type` +- `data_scope` +- `condition_filter` +- `metadata` +- `created_at` +- `updated_at` + +### 迁移判断 + +建议: + +- 继续保留 `grant_type` +- 第一阶段只用 `GRANT` +- `DENY` 能力先兼容不扩展 +- `condition_filter` 暂不作为第一阶段核心能力 + +--- + +## 4. 最终推荐表结构策略 + +这里不是从零建库,而是: + +- **以老系统实际表结构为准** +- 对新系统补齐缺失字段、索引、语义约束 +- 避免再造第二套平行用户权限模型 + +## 4.1 `sso_users` 目标策略 + +### 保留字段 + +- 保留老系统现有字段不动 +- 核心业务字段继续认:`area` + +### 必须加的约束 / 索引 + +建议检查并补: + +- `UNIQUE(sub)` +- `INDEX(area)` +- `INDEX(status)` +- `INDEX(deleted_at)` +- `INDEX(username)` + +### 字段语义正式化 + +- `sub`:统一身份唯一标识 +- `username`:登录名 / 工号 / 展示账号 +- `nick_name`:真实姓名 +- `area`:用户主地区,也是默认数据隔离依据 +- `password`:短期兼容旧值,后续迁移为哈希 + +## 4.2 `roles` 目标策略 + +继续沿用: + +- `role_key`:机器标识,例如 `provincial_admin` +- `role_name`:中文展示名,例如“省级管理员” +- `data_scope`:默认数据范围 + +建议正式保留的角色: + +- `provincial_admin` +- `admin` +- `common` +- `super_admin`(可选) + +## 4.3 `permissions` 目标策略 + +继续沿用: + +- `permission_key` +- `api_path` +- `api_method` +- `route_id` + +建议后续所有新权限都按下面格式命名: + +- `auth:me:read` +- `documents:list:read` +- `documents:upload:write` +- `audit:run:execute` +- `rules:binding:update` + +不要混用: + +- 一部分 `documents.list` +- 一部分 `document:read:all` + +必须统一成一种风格。 + +### 我建议 + +直接收口成老系统已有风格: + +- `module:resource:action` + +这样能和老数据完全对齐。 + +## 4.4 `role_permissions` 目标策略 + +继续沿用: + +- `grant_type` +- `data_scope` + +建议唯一约束明确为: + +- `(role_id, permission_id, grant_type, data_scope)` + +但在第一阶段实际配置时: + +- 尽量只放 `GRANT` +- `data_scope` 按权限点是否涉及数据决定 + +## 4.5 `user_role` 目标策略 + +继续沿用现有结构即可。 + +关键点不是表结构,而是后端逻辑必须支持: + +- 一个用户可以挂多个角色 +- 登录时不能 `LIMIT 1` +- 权限汇总和数据范围要支持多角色收敛 + +## 4.6 `sys_routes / role_route` 目标策略 + +继续沿用旧系统结构即可。 + +重点是: + +- 前端菜单权限可以继续从这里出 +- 但 **接口权限不能只依赖菜单权限** +- API 权限必须还是走 `permissions / role_permissions` + +--- + +## 5. 当前逻辑漏洞清单(必须按优先级修) + +## P0:必须先修 + +### 5.1 角色/权限字段命名统一 + +要统一到底使用: + +- `role_key` 还是 `role_name` +- `permission_key` 还是 `permission_code` + +当前结论: + +- 统一用 `role_key` +- 统一用 `permission_key` + +### 5.2 登录角色查询改成多角色 + +当前: + +- `LIMIT 1` + +必须改成: + +- 查询全部角色 +- JWT 带 `roles` +- 兼容保留一个 `user_role` 主角色字段给旧前端 + +### 5.3 OAuth 登录禁止直接覆盖 `area` + +必须改成: + +- 登录入参中的 `area` 不再直接写库 +- area 只能来自: + - 老库已存在值 + - 后台管理维护 + - 后续可信组织同步 + +### 5.4 新用户自动分配默认角色 + +OAuth 首登新用户创建后,必须补: + +- 自动赋 `common` + +### 5.5 `/api/auth/me` 落地 + +要把当前用户的: + +- 基本信息 +- `area` +- `roles` +- `permissions` + +统一返回。 + +## P1:第二阶段紧跟着修 + +### 5.6 数据范围注入 + +至少要先覆盖: + +- 文档列表 +- 文档详情 +- 用户列表 +- 评查状态 / 结果 + +### 5.7 密码校验升级 + +短期兼容旧明文,长期迁移到哈希: + +- 登录时支持“旧明文兼容 + 新哈希优先” +- 用户下次修改密码时写入哈希 + +### 5.8 权限缓存 / 权限聚合 + +后续可以考虑: + +- JWT 轻载 +- 服务端缓存权限聚合结果 + +但这不是 P0。 + +--- + +## 6. 老系统用户数据迁移策略 + +## 6.1 迁移原则 + +不是“把老用户复制一份到新表”,而是: + +- **尽可能复用原表结构** +- 用增量清洗 + 角色校正 + 地区校正的方式迁移 + +## 6.2 迁移对象 + +第一阶段只迁这几类数据: + +1. `sso_users` +2. `roles` +3. `user_role` +4. `permissions` +5. `role_permissions` +6. `sys_routes` +7. `role_route` + +## 6.3 `sso_users` 迁移规则 + +### 必迁字段 + +- `id` +- `sub` +- `username` +- `nick_name` +- `phone_number` +- `email` +- `ou_id` +- `ou_name` +- `status` +- `is_leader` +- `password` +- `area` +- `tenant_name` +- `dep_name` +- `dep_short_name` +- `created_at` +- `updated_at` +- `deleted_at` + +### 可选迁字段 + +- `mq_person_uuid` +- `mq_account_uuid` +- `mq_synced_at` +- `try_count` +- `try_login_time` + +### 迁移前校验 + +要先出统计: + +- `sub` 是否有重复 +- `username` 是否有重复 +- `area` 是否为空 +- `status != 0` 的禁用用户数量 +- `deleted_at is not null` 的软删除用户数量 + +## 6.4 角色映射规则 + +要先梳理老库中真实 `role_key` 分布。 + +预期映射: + +- `provincial_admin` → 保留 +- `admin` → 保留 +- `common` → 保留 +- 其他历史角色: + - 先做映射表 + - 无法归类的先落到 `common` + - 再人工复核 + +### 明确不再延续的角色 + +- `city_admin` +- `review_manager` +- `review_user` +- `rule_admin` + +如果老库真的还残留这些历史角色,不建议直接带入新系统业务角色定义,应该做映射清洗。 + +## 6.5 area 清洗规则 + +迁移前要先把 `sso_users.area` 跑一遍质量检查: + +- 是否为空 +- 是否存在历史别名 +- 是否存在前后空格 +- 是否存在同义不同写法 + +最后要统一成一套稳定值,例如: + +- `省局` +- `梅州` +- `云浮` +- `揭阳` +- `潮州` + +--- + +## 7. 开发分阶段 TaskList + +下面是正式开发顺序。 + +## 阶段 A:数据库基线收口 + +### A1. 先核对当前库真实表结构 + +目标: + +- 以当前数据库真实字段为准 +- 不再拿“理想 SQL”直接执行 + +动作: + +- 检查 `sso_users` +- 检查 `roles` +- 检查 `user_role` +- 检查 `permissions` +- 检查 `role_permissions` +- 检查 `sys_routes` +- 检查 `role_route` + +产出: + +- 一份“现网真实表结构快照” +- 一份“目标结构差异清单” + +### A2. 出正式迁移 SQL + +目标: + +- 不是重建表 +- 是补字段 / 补索引 / 补约束 / 补初始化数据 + +动作: + +- 修正角色初始化 SQL +- 修正权限初始化 SQL +- 修正菜单初始化 SQL +- 统一全部字段名到现有真实库 + +产出: + +- `用户权限_schema_patch.sql` +- `用户权限_seed.sql` + +## 阶段 B:登录与 JWT 收口 + +### B1. 修 `AuthServiceImpl` + +必须改: + +- 多角色查询 +- 新用户默认角色分配 +- OAuth 登录禁止外部覆盖 `area` +- 登录响应统一返回 `roles` +- 兼容返回单个 `user_role` + +### B2. 修 `JwtService` + +必须改: + +- token 中写入 `roles` +- 保留 `user_role` +- 写入 `area` +- 视情况写入精简 `permissions` + +### B3. 新增 `/api/auth/me` + +返回: + +- 用户基本信息 +- area +- roles +- permissions + +## 阶段 C:权限聚合与数据范围引擎 + +### C1. 做统一权限聚合服务 + +目标: + +- 给定 `user_id` +- 返回: + - 角色列表 + - 权限列表 + - 每个权限点最终 data_scope + +### C2. 做统一数据范围判定器 + +目标: + +- 输入:用户 + 权限点 + 业务资源 +- 输出: + - `ALL` + - `DEPT` + - `SELF` + +### C3. 先接到核心接口 + +优先改: + +- `/api/documents/list` +- `/api/audit/run/{RunId}` +- `/api/audit/result/{RunId}` +- `/api/users/list` + +## 阶段 D:老用户数据迁移 + +### D1. 出迁移前检查 SQL + +统计: + +- 用户数 +- 角色分布 +- area 分布 +- 无角色用户 +- 空 area 用户 +- 重复 sub / username + +### D2. 出迁移脚本 + +做法: + +- 先备份 +- 再清洗 area +- 再导入 / 映射角色 +- 再补默认角色 +- 再跑校验 + +### D3. 迁移后验收 + +验收项: + +- 登录正常 +- `/api/auth/me` 正常 +- 文档列表按地区隔离正常 +- admin 不能跨区 +- provincial_admin 能看全局 +- common 只能看自己 + +## 阶段 E:安全与运维收口 + +### E1. 密码升级方案 + +- 兼容旧明文 +- 新密码写哈希 +- 后续批量升级 + +### E2. 审计日志 + +至少记录: + +- 登录成功/失败 +- 角色变更 +- 用户地区变更 +- 权限分配变更 + +### E3. 文档留档 + +要把以下文档保持最新: + +- `docs/用户与地区权限完整设计方案.md` +- `docs/接口/用户权限与权限点清单.md` +- `docs/用户权限开发TaskList.md` + +--- + +## 8. 推荐的实际开发顺序 + +如果按“马上开始干”的节奏,建议严格按下面顺序: + +1. 先检查当前库真实 RBAC 表结构 +2. 再把初始化 SQL 改成真实可执行版 +3. 再改登录与 JWT +4. 再补 `/api/auth/me` +5. 再做权限聚合与数据范围注入 +6. 再做老用户迁移脚本 +7. 最后再做密码升级和审计收口 + +这个顺序的原因很简单: + +- 不先确认表结构,后面代码全会写偏 +- 不先收口登录态,前端无法稳定接入 +- 不先做数据范围,地区隔离只是纸面方案 +- 不最后做迁移,容易把脏数据带着跑 + +--- + +## 9. 我建议下一步立刻做什么 + +下一步最应该做的不是直接改 controller,而是: + +### 第一步 + +先把**当前数据库里 7 张核心表的真实结构**完整导出来: + +- `sso_users` +- `roles` +- `user_role` +- `permissions` +- `role_permissions` +- `sys_routes` +- `role_route` + +### 第二步 + +基于真实结构,产出两份正式 SQL: + +- schema patch SQL +- seed SQL + +### 第三步 + +再开始改: + +- `authServiceImpl.py` +- `jwtService.py` +- `/api/auth/me` + +也就是说,**下一阶段开发入口就是“数据库真相收口”**。 + diff --git a/docs/老系统_docauditai_用户权限架构深度分析.md b/docs/老系统_docauditai_用户权限架构深度分析.md new file mode 100644 index 0000000..f5aa4a0 --- /dev/null +++ b/docs/老系统_docauditai_用户权限架构深度分析.md @@ -0,0 +1,1047 @@ +# 老系统 `docauditai` 用户权限架构深度分析 + +本文档用于系统性梳理老项目: + +- `/home/wren-dev/Porject/docauditai` + +中的用户、认证、角色、路由、权限、地区、数据范围逻辑。 + +目标不是简单列表,而是明确回答: + +- 老系统真实的用户权限架构是什么 +- 它依赖哪些表和哪些业务规则 +- 新系统应该继承什么、舍弃什么、升级什么 + +--- + +## 1. 结论先行 + +老系统并不是一个“只有 6 张 RBAC 表”的简单权限系统。 + +它真实的架构是: + +- 统一登录入口:OAuth + 账密登录 +- 用户主表:`sso_users` +- 角色体系:`roles + user_role` +- 菜单路由:`sys_routes + role_route` +- 权限点:`permissions + role_permissions` +- 数据权限:`role_permissions.data_scope` +- 地区隔离:大量业务依赖 `sso_users.area` +- 组织同步:登录时通过 MQ / 组织表同步 `area / tenant / dep` + +也就是说,老系统本质上是: + +- `RBAC + area-based data scope` + +而不是纯粹的: + +- `用户 - 角色 - 菜单` + +--- + +## 2. 核心表与模块 + +从代码实际使用看,老系统核心不只是以下 6 张表: + +- `sys_routes` +- `sso_users` +- `roles` +- `role_route` +- `role_permissions` +- `user_role` + +还必须加上: + +- `permissions` +- `route_permission` +- 业务表中的 `area` 字段 +- `roles.data_scope` +- `role_permissions.data_scope` + +相关代码位置: + +- 认证:`app/routes/auth.py` +- JWT:`app/auth/auth.py` +- 路由权限:`app/rbac/route_permission.py` +- 权限检查:`app/rbac/permission_checker_v2.py` +- 数据范围:`app/rbac/data_scope_injector_v2.py` +- RBAC API:`app/routes/v3/rbac.py` + +--- + +## 3. 登录架构 + +## 3.1 统一登录入口 + +老系统登录主入口是: + +- `POST /auth/login` + +代码: + +- `app/routes/auth.py` + +统一登录入口自动识别两种模式: + +- OAuth 登录 +- 账号密码登录 + +识别规则: + +- 请求体包含 `userInfo.sub` → OAuth 登录 +- 请求体包含 `username + password` → 账密登录 + +对应代码: + +- `app/routes/auth.py:111` +- `app/routes/auth.py:154` +- `app/routes/auth.py:158` + +这说明老系统在登录入口设计上已经做到了统一。 + +--- + +## 3.2 OAuth 登录逻辑 + +OAuth 登录处理函数: + +- `app/routes/auth.py:_handle_oauth_login` + +主要流程: + +1. 读取前端传入的 `userInfo` +2. 通过 `sub` 查询 `sso_users` +3. 如果用户不存在,则自动创建用户 +4. 如果用户已存在,则更新用户基本资料 +5. 查询用户角色 +6. 生成 JWT +7. 返回统一登录响应 + +对应代码位置: + +- `app/routes/auth.py:412` +- `app/routes/auth.py:451` +- `app/routes/auth.py:518` +- `app/routes/auth.py:563` +- `app/routes/auth.py:601` +- `app/routes/auth.py:611` + +### OAuth 用户创建规则 + +当用户不存在时: + +- 自动插入 `sso_users` +- 自动分配默认角色 `common` + +对应代码: + +- `app/routes/auth.py:471` +- `app/routes/auth.py:512` + +这意味着: + +- 老系统支持 OAuth 首登自动建号 +- 并且会自动授予最低默认权限 + +--- + +## 3.3 账号密码登录逻辑 + +密码登录处理函数: + +- `app/routes/auth.py:_handle_password_login` + +主要流程: + +1. 根据 `username/sub` 查询 `sso_users` +2. 校验密码 +3. 校验状态 / 删除标记 +4. 同步组织信息(如果有) +5. 查询角色 +6. 生成 JWT +7. 返回统一响应 + +对应代码: + +- `app/routes/auth.py:655` +- `app/routes/auth.py:689` +- `app/routes/auth.py:723` +- `app/routes/auth.py:745` +- `app/routes/auth.py:756` +- `app/routes/auth.py:798` + +可以看出,老系统并没有把账密登录和 OAuth 登录做成两套完全不同的人群模型。 + +它们最终都归并到: + +- `sso_users` +- `user_role` +- `roles` +- JWT + +这是一个非常重要的架构特征。 + +--- + +## 4. JWT 模型 + +JWT 逻辑在: + +- `app/auth/auth.py` + +JWT Payload 中实际携带的关键字段有: + +- `user_id` +- `username` +- `user_role` +- `permissions`(可选) +- `sub` +- `nick_name` +- `email` +- `phone_number` +- `ou_id` +- `ou_name` +- `is_leader` +- `area` + +对应代码: + +- `app/auth/auth.py:61` +- `app/auth/auth.py:94` +- `app/auth/auth.py:101` +- `app/auth/auth.py:106` + +JWT 解码后,同样会恢复成: + +- `User` +- `TokenData` + +对应: + +- `app/auth/auth.py:24` +- `app/auth/auth.py:46` +- `app/auth/auth.py:119` +- `app/auth/auth.py:171` + +### 结论 + +老系统 JWT 不只是认证 token,它还承担了: + +- 用户身份 +- 用户角色 +- 用户地区 +- 组织基础信息 + +其中 `area` 是最关键的业务字段之一。 + +--- + +## 5. 用户地区到底怎么来的 + +这是老系统最关键的逻辑之一。 + +### 5.1 不是靠前端端口 + +老系统的地区不是通过前端访问端口推断。 + +它主要通过组织主数据反查得到。 + +### 5.2 OAuth 登录时的地区来源 + +OAuth 登录会调用: + +- `_get_user_org_info_from_mq` + +位置: + +- `app/routes/auth.py:280` + +查询流程: + +1. 用 `ou_id + nickname` 查询 `um_personinfo` +2. 获取: + - `tenant_uuid` + - `dep_uuid` + - `org_uuid` +3. 再查: + - `um_tenant` + - `um_department` +4. 最终组装: + - `area` + - `tenant_name` + - `dep_name` + - `dep_short_name` + - `ou_name` + +其中 `area` 的来源是: + +- `tenant_short_name` +- 或 `tenant_name` + +对应代码: + +- `app/routes/auth.py:206` +- `app/routes/auth.py:263` +- `app/routes/auth.py:280` +- `app/routes/auth.py:380` +- `app/routes/auth.py:435` + +### 5.3 用户首次创建时 + +新用户创建时,组织信息会直接写入 `sso_users`: + +- `area` +- `tenant_name` +- `dep_name` +- `dep_short_name` +- `ou_name` + +对应: + +- `app/routes/auth.py:471` +- `app/routes/auth.py:480` +- `app/routes/auth.py:512` + +### 5.4 老用户每次登录时 + +如果用户已存在,每次 OAuth 登录仍会同步这些字段: + +- `area` +- `tenant_name` +- `dep_name` +- `dep_short_name` +- `ou_name` + +对应: + +- `app/routes/auth.py:518` +- `app/routes/auth.py:543` +- `app/routes/auth.py:552` +- `app/routes/auth.py:593` + +### 结论 + +老系统对地区的真实定义是: + +- 地区是组织主数据的一部分 +- 地区由后端通过 MQ/组织系统同步 +- 登录时会把地区同步进 `sso_users.area` +- 之后大量业务依赖这个 `area` + +这说明: + +- 老系统是“后端主数据决定地区” +- 不是“前端端口决定地区” + +--- + +## 6. 用户主表 `sso_users` 的真实角色 + +从代码实际用法来看,`sso_users` 不只是登录表。 + +它承担了: + +- 统一身份映射表 +- 用户基础档案表 +- 地区信息存储表 +- 组织信息缓存表 +- 登录失败次数 / 锁定状态记录表 + +常见使用字段: + +- `id` +- `sub` +- `username` +- `nick_name` +- `phone_number` +- `email` +- `ou_id` +- `ou_name` +- `is_leader` +- `status` +- `deleted_at` +- `password` +- `try_count` +- `try_login_time` +- `area` +- `tenant_name` +- `dep_name` +- `dep_short_name` + +对应代码位置: + +- `app/routes/auth.py:451` +- `app/routes/auth.py:689` +- `app/routes/auth.py:914` + +### 结论 + +老系统把 `sso_users` 作为了: + +- 用户唯一主表 +- 认证与组织信息的汇聚中心 + +这一点新系统应该继承。 + +--- + +## 7. 角色体系是什么 + +角色相关表: + +- `roles` +- `user_role` + +### 7.1 用户和角色关系 + +一个用户可以拥有多个角色。 + +角色查询通常通过: + +- `user_role -> roles` + +对应代码: + +- `app/routes/auth.py:840` +- `app/routes/auth.py:852` +- `app/routes/auth.py:862` + +### 7.2 OAuth 新用户默认角色 + +新用户默认自动分配: + +- `common` + +对应代码: + +- `app/routes/auth.py:512` + +### 7.3 角色本身带数据范围 + +老系统 `roles` 表本身带: + +- `data_scope` + +在 RBAC API 中大量出现: + +- `RoleService.list_roles` +- `RoleService.get_role` + +对应代码: + +- `app/services/rbac/role_service.py:98` +- `app/services/rbac/role_service.py:171` +- `app/services/rbac/user_role_service.py:217` + +### 结论 + +老系统角色不只是页面访问角色,它还是: + +- 权限角色 +- 数据范围角色 + +也就是说: + +- 一个角色同时决定功能权限和数据权限 + +--- + +## 8. 菜单 / 路由权限模型 + +### 8.1 路由表和角色路由表 + +老系统页面菜单权限主要基于: + +- `sys_routes` +- `role_route` + +主逻辑在: + +- `app/rbac/route_permission.py` + +### 8.2 `get_user_routes(user_id)` 的真实行为 + +流程: + +1. 查用户所有角色 +2. 用角色查 `role_route` +3. join `sys_routes` +4. 只返回启用路由 +5. 组装成树形结构 +6. 还会把该路由相关的权限点附在 `permissions` 字段里 + +对应: + +- `app/rbac/route_permission.py:22` +- `app/rbac/route_permission.py:47` +- `app/rbac/route_permission.py:63` +- `app/rbac/route_permission.py:100` +- `app/rbac/route_permission.py:127` + +### 8.3 路由不仅是菜单,还带权限上下文 + +老系统会在返回路由树时,把页面对应的权限点也挂上去。 + +这意味着前端不是只拿“菜单”,而是拿: + +- 菜单结构 +- 页面权限上下文 + +这是很成熟的一种设计。 + +--- + +## 9. 功能权限模型 + +### 9.1 老系统不是只有 `role_permissions` + +真正的功能权限核心是: + +- `permissions` +- `role_permissions` + +其中: + +- `permissions`:权限定义表 +- `role_permissions`:角色授权表 + +### 9.2 权限检查器 V2 + +代码: + +- `app/rbac/permission_checker_v2.py` + +它的逻辑是: + +1. 查用户所有角色 +2. 通过 `role_permissions` 找到权限 +3. join `permissions` +4. 读 `permission_key` +5. 支持: + - 精确匹配 + - 通配符权限 + - `GRANT` + - `DENY` + +对应: + +- `app/rbac/permission_checker_v2.py:61` +- `app/rbac/permission_checker_v2.py:147` +- `app/rbac/permission_checker_v2.py:199` + +### 9.3 权限键格式 + +权限键采用: + +- `module:resource:action` + +例如: + +- `document:list:read` +- `document:delete:delete` +- `dify:dataset:read` +- `system:rbac:manage` + +这套结构非常清晰,推荐新系统继续保留。 + +### 结论 + +老系统功能权限的真实设计是: + +- `permissions.permission_key` 做标准权限定义 +- `role_permissions` 决定角色拥有哪些权限 +- 路由权限只是页面层,不能替代 `permissions` + +--- + +## 10. 数据权限模型 + +这是老系统最关键但最容易被忽略的一层。 + +### 10.1 数据权限来源 + +数据权限核心在: + +- `role_permissions.data_scope` +- `roles.data_scope` +- 用户 `area` + +### 10.2 数据范围注入器 + +代码: + +- `app/rbac/data_scope_injector_v2.py` + +定义了三种数据范围: + +- `ALL` +- `DEPT` +- `SELF` + +对应: + +- `app/rbac/data_scope_injector_v2.py:24` + +### 10.3 三种范围含义 + +#### `ALL` + +- 查看全部数据 +- 不加过滤条件 + +#### `DEPT` + +- 查看本地区数据 +- 实现方式是:按 `area` 过滤 + +#### `SELF` + +- 只能查看本人数据 +- 实现方式是:按 `user_id` 过滤 + +对应: + +- `app/rbac/data_scope_injector_v2.py:170` +- `app/rbac/data_scope_injector_v2.py:228` + +### 10.4 本地区的真实含义 + +老系统里 `DEPT` 虽然名称叫部门范围,但实际很多地方是: + +- 按地区 `area` 过滤 + +也就是说: + +- 它更像“本地市范围” +- 而不是严格的部门树范围 + +这说明老系统的数据权限其实是: + +- `ALL / 地区 / 本人` + +而不是严格组织树。 + +--- + +## 11. `area` 在老系统中的真实地位 + +### 11.1 `area` 是核心业务字段 + +老系统中,`area` 被用于: + +- 用户归属地区 +- 数据权限过滤条件 +- 业务记录写入默认地区 +- Dify 知识库选择 +- Dify 对话应用过滤 +- 评查点地区隔离 + +### 11.2 Dify 知识库访问 + +代码: + +- `app/routes/v3/dify_area_dataset.py` + +逻辑: + +- 普通用户:按 `current_user.area` 查本地区知识库 +- 省级管理员:可看全部 + +对应: + +- `app/routes/v3/dify_area_dataset.py:39` +- `app/routes/v3/dify_area_dataset.py:57` +- `app/routes/v3/dify_area_dataset.py:67` + +### 11.3 Dify 对话应用过滤 + +代码: + +- `app/routes/v3/dify_chat_apps.py` + +逻辑: + +- 根据 `current_user.area` 返回本地区应用 +- 同时可以返回省级应用 + +对应: + +- `app/routes/v3/dify_chat_apps.py:29` +- `app/routes/v3/dify_chat_apps.py:60` + +### 11.4 PostgREST 转发层的 area 注入 + +代码: + +- `app/exceptions/global_exc.py` + +老系统在某些写操作中,甚至会自动把用户 `area` 写进业务数据。 + +典型例子: + +- `evaluation_points` 表写入时自动填充 `area` + +并且: + +- `provincial_admin` 会被硬编码成 `省级` + +对应代码: + +- `app/exceptions/global_exc.py:250` +- `app/exceptions/global_exc.py:292` +- `app/exceptions/global_exc.py:307` + +### 结论 + +在老系统中: + +- `area` 不是附属字段 +- 而是整套业务隔离体系的核心字段之一 + +--- + +## 12. 中间件与鉴权行为 + +### 12.1 JWT 中间件 + +代码: + +- `app/middleware/jwt_auth.py` + +行为: + +- 白名单路径跳过 +- 其他请求必须有 Bearer Token +- 中间件先做基础校验 +- 然后将当前用户信息塞进: + - `request.state.current_user` + +塞入字段包括: + +- `user_id` +- `username` +- `nick_name` +- `email` +- `phone_number` +- `ou_id` +- `ou_name` +- `is_leader` +- `user_role` +- `area` + +对应: + +- `app/middleware/jwt_auth.py:49` +- `app/middleware/jwt_auth.py:75` +- `app/middleware/jwt_auth.py:88` +- `app/middleware/jwt_auth.py:89` + +### 12.2 路由层再做细粒度权限校验 + +真正功能权限由: + +- `require_permission_v2` + +处理,位置: + +- `app/rbac/decorators_v2.py` + +它会: + +- 从 `request.state.current_user` 取 `user_id` +- 调 `PermissionCheckerV2.check_permission` +- 无权限则 403 + +对应: + +- `app/rbac/decorators_v2.py:56` +- `app/rbac/decorators_v2.py:92` + +### 结论 + +老系统是两层鉴权: + +- 中间件:验 token +- 装饰器/依赖:验功能权限 + +这个分层设计是合理的。 + +--- + +## 13. 老系统角色体系的实际业务语义 + +根据代码表现,老系统大致存在以下角色层次: + +- `provincial_admin` +- `admin` +- `common` +- 以及其他业务角色 + +### 13.1 `provincial_admin` + +特点: + +- 高权限角色 +- 数据范围通常为 `ALL` +- 某些业务场景会被特殊处理为“省级” +- 可访问全部地区的数据 / 配置 + +体现位置: + +- `app/exceptions/global_exc.py:292` +- `app/routes/v3/dify_area_dataset.py:67` + +### 13.2 `admin` + +特点: + +- 更像市级管理员 +- 数据范围通常为本地区 +- 很多场景按 `area` 做限制 + +体现位置: + +- `app/services/rbac/user_role_service.py:91` +- `app/exceptions/global_exc.py:229` + +### 13.3 `common` + +特点: + +- 默认普通用户 +- 新用户自动分配 +- 权限最低 +- 数据范围通常为 `SELF` 或较弱的本地区权限 + +体现位置: + +- `app/routes/auth.py:512` +- `app/routes/auth.py:601` + +--- + +## 14. 老系统真实的架构图 + +如果按实际行为抽象,老系统可以概括为: + +### 14.1 用户身份层 + +- `sso_users` +- `sub` +- OAuth / 本地账密登录 + +### 14.2 角色层 + +- `roles` +- `user_role` + +### 14.3 页面菜单层 + +- `sys_routes` +- `role_route` + +### 14.4 功能权限层 + +- `permissions` +- `role_permissions` +- `permission_key` +- `grant_type` + +### 14.5 数据权限层 + +- `role_permissions.data_scope` +- `roles.data_scope` +- 用户 `area` +- 业务表 `area` + +### 14.6 组织归属层 + +- `ou_id` +- `ou_name` +- `tenant_name` +- `dep_name` +- `dep_short_name` +- 通过 MQ / 组织表同步 + +### 结论 + +老系统不是简单 RBAC,而是: + +- `RBAC + 地区数据隔离 + 组织同步` + +--- + +## 15. 老系统优点 + +### 15.1 登录入口统一 + +OAuth 和账密最终走同一条认证主链。 + +### 15.2 用户主数据集中 + +所有用户最终都汇总到: + +- `sso_users` + +### 15.3 路由权限设计成熟 + +- `sys_routes + role_route` +- 支持树形菜单 +- 支持隐藏路由 +- 支持角色路由缓存 + +### 15.4 功能权限设计成熟 + +- `permissions` +- `role_permissions` +- `grant / deny` +- 通配符匹配 + +### 15.5 数据范围是正式模型,不是临时过滤 + +虽然比较粗糙,但已经有: + +- `ALL` +- `DEPT` +- `SELF` + +### 15.6 地区来自后端主数据 + +老系统地区来源比“前端端口推断”可靠得多。 + +--- + +## 16. 老系统缺点 + +### 16.1 `area` 过载过重 + +`area` 同时承担: + +- 用户地区 +- 数据权限过滤条件 +- 业务默认地区 +- 规则隔离条件 +- 知识库分配条件 + +语义过重。 + +### 16.2 数据范围表达力不足 + +只有三档: + +- `ALL` +- `DEPT` +- `SELF` + +无法优雅表达: + +- 多地区访问 +- 指定地区集合 +- 复杂跨区权限 + +### 16.3 大量业务写死 `area` 逻辑 + +导致: + +- 架构耦合重 +- 后续改动成本高 +- 组织逻辑与业务逻辑混杂 + +### 16.4 存在角色硬编码 + +例如: + +- `provincial_admin -> 省级` + +这类逻辑写死在业务层,不够优雅。 + +### 16.5 `DEPT` 实际上更像地区范围,不是部门范围 + +命名容易误导。 + +--- + +## 17. 对新系统的启示 + +## 17.1 必须继承的部分 + +新系统应继承老系统的这些优点: + +- 统一登录入口 +- OAuth / 账密统一落主用户表 +- `sys_routes + role_route` +- `permissions + role_permissions` +- 标准 `permission_key` +- `grant / deny` +- 数据范围模型概念 +- 组织同步决定地区 + +## 17.2 必须升级的部分 + +新系统不能原样照搬老系统的缺点。 + +必须升级为: + +- 不再只有一个 `area` 字段承载所有数据权限语义 +- 把用户默认地区和可访问地区分开 +- 把数据权限从 `DEPT` 升级为正式 `region scope` +- 把组织同步保留,但不要在各业务层到处散写地区逻辑 + +--- + +## 18. 新系统设计应如何继承老系统 + +从老系统出发,新系统最合理的方向不是推翻,而是升级: + +### 18.1 保留骨架 + +- `sso_users` +- `roles` +- `user_role` +- `sys_routes` +- `role_route` +- `permissions` +- `role_permissions` + +### 18.2 替换数据权限实现 + +将老系统: + +- `ALL / DEPT / SELF` + +升级为: + +- `ALL` +- `SELF` +- `HOME_REGION` +- `CUSTOM_REGIONS` +- `PROVINCIAL` + +### 18.3 组织同步仍保留 + +地区来源仍然应该以后端组织主数据为准,而不是前端推断。 + +### 18.4 将 `area` 升级为正式 `region_code` + +业务表中的地区字段建议统一收口成稳定 code,而不是直接到处使用中文字符串。 + +--- + +## 19. 一句话总结 + +老系统 `docauditai` 的真实用户权限架构不是“6 张表的简单角色系统”,而是: + +- 以 `sso_users` 为用户主数据中心 +- 通过 OAuth / 账密统一登录 +- 通过组织系统同步 `area / tenant / dep` +- 用 `roles + user_role + sys_routes + role_route + permissions + role_permissions` + 管功能权限 +- 用 `data_scope + area` + 管数据权限 + +本质是: + +- `RBAC + 地区数据权限 + 组织同步` + +这就是新系统设计时真正应该继承的老逻辑骨架。 + diff --git a/fastapi_common/fastapi_common_security/jwtService.py b/fastapi_common/fastapi_common_security/jwtService.py index d93ae4d..5518650 100644 --- a/fastapi_common/fastapi_common_security/jwtService.py +++ b/fastapi_common/fastapi_common_security/jwtService.py @@ -18,7 +18,7 @@ import jwt from fastapi_common.fastapi_common_logger import logger from fastapi_admin.config import JWT_SECRET_KEY, JWT_ALGORITHM -ACCESS_TOKEN_EXPIRE_MINUTES = 15 +ACCESS_TOKEN_EXPIRE_MINUTES = 60 REFRESH_TOKEN_EXPIRE_DAYS = 7 JWT_AUDIENCE = "leaudit-platform" JWT_ISSUER = "leaudit-platform" @@ -39,6 +39,7 @@ class JwtService: ouId: str = "", ouName: str = "", roles: list[str] | None = None, + permissions: list[str] | None = None, area: str | None = None, userRole: str | None = None, deviceId: str | None = None, @@ -68,6 +69,7 @@ class JwtService: "ou_id": ouId, "ou_name": ouName, "roles": roles or [], + "permissions": permissions or [], "area": area, "user_role": userRole, "iat": now, diff --git a/fastapi_common/fastapi_common_security/security.py b/fastapi_common/fastapi_common_security/security.py index a96adbc..6960320 100644 --- a/fastapi_common/fastapi_common_security/security.py +++ b/fastapi_common/fastapi_common_security/security.py @@ -5,19 +5,40 @@ from __future__ import annotations from typing import Any import jwt -from fastapi import Request +from fastapi import HTTPException, Request, status -from fastapi_admin.config import JWT_SECRET_KEY, JWT_ALGORITHM +from fastapi_common.fastapi_common_security.jwtService import JwtService def verify_access_token(RequestObj: Request) -> dict[str, Any]: - """验证 JWT access token 并返回 payload。""" + """验证 JWT access token 并返回 payload。 + + 认证失败必须直接返回 401,不能静默放行为空 payload, + 否则受保护接口会在后续逻辑里变成“假鉴权”。 + """ auth = RequestObj.headers.get("Authorization", "") if not auth.startswith("Bearer "): - return {} + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="缺少有效的 Authorization Bearer Token", + ) + token = auth.removeprefix("Bearer ").strip() try: - payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + payload = JwtService.verify(token) + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的访问令牌类型", + ) return payload + except jwt.ExpiredSignatureError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="访问令牌已过期", + ) from exc except jwt.PyJWTError: - return {} + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="访问令牌无效", + ) diff --git a/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py b/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py index 310fc42..f5d57f3 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py @@ -4,18 +4,22 @@ POST /auth/login — 统一登录(OAuth + 密码自动检测) POST /auth/password_login — 账密登录 -响应格式按新项目规范使用 Result。 +前端当前统一按 ``success + message + data`` 解析登录结果, +这里显式对齐该契约,避免登录成功却被前端误判为失败。 """ -from fastapi import Request +from typing import Any + +from fastapi import Depends, Request from fastapi.responses import JSONResponse from fastapi_common.fastapi_common_web.controller import BaseController from fastapi_common.fastapi_common_web.domain.responses import Result +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_common.fastapi_common_logger import logger +from fastapi_common.fastapi_common_security.security import verify_access_token -from fastapi_modules.fastapi_leaudit.domian.Dto.auth.loginDto import PasswordLoginDTO, OAuthLoginDTO -from fastapi_modules.fastapi_leaudit.domian.vo.auth.loginTokenVo import LoginTokenVO +from fastapi_modules.fastapi_leaudit.domian.Dto.auth.loginDto import PasswordLoginDTO from fastapi_modules.fastapi_leaudit.services import IAuthService from fastapi_modules.fastapi_leaudit.services.impl.authServiceImpl import AuthServiceImpl @@ -60,10 +64,13 @@ class AuthController(BaseController): Password=requestData["password"], ) else: - return JSONResponse(status_code=400, content={"code": 400, "message": "无效的登录请求格式", "data": None}) + return JSONResponse( + status_code=400, + content={"success": False, "message": "无效的登录请求格式", "data": None}, + ) return JSONResponse(status_code=200, content={ - "code": 200, + "success": True, "message": "ok", "data": { "access_token": vo.access_token, @@ -74,10 +81,16 @@ class AuthController(BaseController): }, }) + except LeauditException as e: + logger.error(f"登录失败: {e.message}") + return JSONResponse( + status_code=e.statusCode, + content={"success": False, "message": e.message, "data": None}, + ) except Exception as e: logger.error(f"登录失败: {e}") return JSONResponse(status_code=401, content={ - "code": 401, "message": str(e), "data": None, + "success": False, "message": "登录失败,请稍后重试", "data": None, }) @self.router.post("/password_login") @@ -88,10 +101,22 @@ class AuthController(BaseController): dto = PasswordLoginDTO(**requestData) vo = await self.AuthService.PasswordLogin(Sub=dto.sub, Password=dto.password) return JSONResponse(status_code=200, content={ - "code": 200, "message": "ok", "data": vo.model_dump(), + "success": True, "message": "ok", "data": vo.model_dump(), }) + except LeauditException as e: + logger.error(f"密码登录失败: {e.message}") + return JSONResponse( + status_code=e.statusCode, + content={"success": False, "message": e.message, "data": None}, + ) except Exception as e: logger.error(f"密码登录失败: {e}") return JSONResponse(status_code=401, content={ - "code": 401, "message": str(e), "data": None, + "success": False, "message": "登录失败,请稍后重试", "data": None, }) + + @self.router.get("/me", response_model=Result[dict[str, Any]]) + async def GetCurrentUser(payload: dict[str, Any] = Depends(verify_access_token)): + """获取当前登录用户信息。""" + Data = await self.AuthService.GetCurrentUser(UserId=int(payload["user_id"])) + return Result.success(data=Data) diff --git a/fastapi_modules/fastapi_leaudit/services/authService.py b/fastapi_modules/fastapi_leaudit/services/authService.py index cffe158..5598ade 100644 --- a/fastapi_modules/fastapi_leaudit/services/authService.py +++ b/fastapi_modules/fastapi_leaudit/services/authService.py @@ -20,3 +20,8 @@ class IAuthService(ABC): IsLeader: bool | None, Area: str | None, ExpiresIn: int) -> LoginTokenVO: """OAuth 登录。""" ... + + @abstractmethod + async def GetCurrentUser(self, UserId: int) -> dict: + """获取当前登录用户信息。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py index e5ccc78..8cffea2 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py @@ -1,14 +1,15 @@ -"""认证服务实现。 +"""认证服务实现。""" -从旧项目 app/routes/auth.py 和 app/auth/auth.py 迁移,业务逻辑完全不变。 -仅重组为 Controller → Service(interface+impl) → Model 结构。 -""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any from fastapi_common.fastapi_common_logger import logger -from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_security.jwtService import JwtService -from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_modules.fastapi_leaudit.domian.vo.auth.loginTokenVo import LoginTokenVO from fastapi_modules.fastapi_leaudit.services.authService import IAuthService @@ -20,55 +21,73 @@ class AuthServiceImpl(IAuthService): async def PasswordLogin(self, Sub: str, Password: str) -> LoginTokenVO: """账密登录。 - 校验 sso_users 表:sub + password + status=0 + deleted_at IS NULL。 - 安全:统一错误提示"账号或密码错误",防止用户枚举。 + 现阶段仍兼容旧库明文密码,后续应迁移到哈希校验。 """ async with GetAsyncSession() as session: - from sqlalchemy import select, text + from sqlalchemy import text result = await session.execute( - text("SELECT id, sub, username, nick_name, phone_number, email, " - "ou_id, ou_name, is_leader, password, status, deleted_at, " - "try_count, try_login_time, area, tenant_name, dep_name, dep_short_name " - "FROM sso_users WHERE sub = :sub"), + text( + "SELECT id, sub, username, nick_name, phone_number, email, " + "ou_id, ou_name, is_leader, password, status, deleted_at, " + "try_count, try_login_time, area, tenant_name, dep_name, dep_short_name " + "FROM sso_users WHERE sub = :sub" + ), {"sub": Sub}, ) row = result.fetchone() if not row: - logger.warning(f"登录失败: 用户不存在 - sub={Sub}") + logger.warning("登录失败: 用户不存在 - sub=%s", Sub) raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误") user = dict(row._mapping) if user.get("deleted_at") is not None: - logger.warning(f"登录失败: 账号已删除 - sub={Sub}") + logger.warning("登录失败: 账号已删除 - sub=%s", Sub) raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误") if user.get("status") != 0: - logger.warning(f"登录失败: 账号已禁用 - sub={Sub}, status={user.get('status')}") + logger.warning("登录失败: 账号已禁用 - sub=%s, status=%s", Sub, user.get("status")) raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误") if user.get("password") != Password: - logger.warning(f"登录失败: 密码错误 - sub={Sub}") + logger.warning("登录失败: 密码错误 - sub=%s", Sub) raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误") + await self._ensure_default_role(session, user["id"]) return await self._buildLoginResponse(user, session) - async def OAuthLogin(self, Sub: str, Username: str | None, Nickname: str | None, - Email: str | None, PhoneNumber: str | None, - OuId: str | None, OuName: str | None, - IsLeader: bool | None, Area: str | None, ExpiresIn: int) -> LoginTokenVO: - """OAuth 登录。验证 sub 是否存在,不存在则自动创建用户。""" + async def OAuthLogin( + self, + Sub: str, + Username: str | None, + Nickname: str | None, + Email: str | None, + PhoneNumber: str | None, + OuId: str | None, + OuName: str | None, + IsLeader: bool | None, + Area: str | None, + ExpiresIn: int, + ) -> LoginTokenVO: + """OAuth 登录。 + + 当前阶段 area 不能被前端登录请求直接覆盖。 + 如果用户不存在,则仅创建基础账号信息,地区字段留待可信后台来源补齐。 + """ + del Area, ExpiresIn + async with GetAsyncSession() as session: - from sqlalchemy import select, text - from datetime import datetime, timezone + from sqlalchemy import text result = await session.execute( - text("SELECT id, sub, username, nick_name, phone_number, email, " - "ou_id, ou_name, is_leader, status, deleted_at, " - "area, tenant_name, dep_name, dep_short_name " - "FROM sso_users WHERE sub = :sub"), + text( + "SELECT id, sub, username, nick_name, phone_number, email, " + "ou_id, ou_name, is_leader, status, deleted_at, area, " + "tenant_name, dep_name, dep_short_name " + "FROM sso_users WHERE sub = :sub" + ), {"sub": Sub}, ) row = result.fetchone() @@ -77,86 +96,209 @@ class AuthServiceImpl(IAuthService): user = dict(row._mapping) if user.get("deleted_at") is not None or user.get("status") != 0: raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号已被禁用或删除") - # 更新最后登录信息 + await session.execute( - text("UPDATE sso_users SET username = :username, nick_name = :nick, " - "email = :email, phone_number = :phone, ou_id = :ou_id, " - "ou_name = :ou_name, is_leader = :is_leader, area = :area, " - "updated_at = :now WHERE id = :id"), - {"username": Username, "nick": Nickname, "email": Email, - "phone": PhoneNumber, "ou_id": OuId, "ou_name": OuName, - "is_leader": IsLeader, "area": Area, - "now": datetime.now(timezone.utc), "id": user["id"]}, + text( + "UPDATE sso_users SET username = :username, nick_name = :nick, " + "email = :email, phone_number = :phone, ou_id = :ou_id, " + "ou_name = :ou_name, is_leader = :is_leader, " + "updated_at = :now WHERE id = :id" + ), + { + "username": Username or user.get("username") or Sub, + "nick": Nickname or user.get("nick_name") or Username or Sub, + "email": Email, + "phone": PhoneNumber, + "ou_id": OuId or user.get("ou_id") or "", + "ou_name": OuName or user.get("ou_name") or "", + "is_leader": IsLeader if IsLeader is not None else user.get("is_leader"), + "now": datetime.now(timezone.utc), + "id": user["id"], + }, ) else: - # 自动创建用户 - await session.execute( - text("INSERT INTO sso_users (sub, username, nick_name, email, " - "phone_number, ou_id, ou_name, is_leader, area, status) " - "VALUES (:sub, :username, :nick, :email, :phone, :ou_id, " - ":ou_name, :is_leader, :area, 0)"), - {"sub": Sub, "username": Username, "nick": Nickname, "email": Email, - "phone": PhoneNumber, "ou_id": OuId, "ou_name": OuName, - "is_leader": IsLeader, "area": Area}, + created = await session.execute( + text( + "INSERT INTO sso_users (sub, username, nick_name, email, phone_number, " + "ou_id, ou_name, is_leader, status, created_at, updated_at) " + "VALUES (:sub, :username, :nick, :email, :phone, :ou_id, " + ":ou_name, :is_leader, 0, :now, :now) RETURNING id" + ), + { + "sub": Sub, + "username": Username or Sub, + "nick": Nickname or Username or Sub, + "email": Email, + "phone": PhoneNumber, + "ou_id": OuId or "", + "ou_name": OuName or "", + "is_leader": bool(IsLeader), + "now": datetime.now(timezone.utc), + }, ) - await session.commit() + user_id = created.scalar_one() + await self._ensure_default_role(session, user_id) - result = await session.execute( - text("SELECT id, sub, username, nick_name, phone_number, email, " - "ou_id, ou_name, is_leader, area, tenant_name, dep_name, dep_short_name " - "FROM sso_users WHERE sub = :sub"), - {"sub": Sub}, - ) - row = result.fetchone() - user = dict(row._mapping) if row else {} + result = await session.execute( + text( + "SELECT id, sub, username, nick_name, phone_number, email, " + "ou_id, ou_name, is_leader, area, tenant_name, dep_name, dep_short_name " + "FROM sso_users WHERE sub = :sub" + ), + {"sub": Sub}, + ) + row = result.fetchone() + user = dict(row._mapping) if row else {} if not user: raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "用户创建失败") + await self._ensure_default_role(session, user["id"]) return await self._buildLoginResponse(user, session) - async def _buildLoginResponse(self, user: dict, session) -> LoginTokenVO: - """组装登录响应:查询角色 → 签发 JWT → 返回 LoginTokenVO。""" - from sqlalchemy import text + async def GetCurrentUser(self, UserId: int) -> dict: + """获取当前登录用户信息。""" + async with GetAsyncSession() as session: + from sqlalchemy import text - # 查询用户角色 - roleResult = await session.execute( - text("SELECT r.role_key FROM user_role ur " - "JOIN roles r ON ur.role_id = r.id " - "WHERE ur.user_id = :uid LIMIT 1"), - {"uid": user["id"]}, - ) - roleRow = roleResult.fetchone() - userRole = roleRow[0] if roleRow else "common" + result = await session.execute( + text( + "SELECT id, sub, username, nick_name, phone_number, email, " + "ou_id, ou_name, is_leader, status, deleted_at, area, " + "tenant_name, dep_name, dep_short_name " + "FROM sso_users WHERE id = :uid" + ), + {"uid": UserId}, + ) + row = result.fetchone() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "用户不存在") + + user = dict(row._mapping) + if user.get("deleted_at") is not None or user.get("status") != 0: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号已被禁用或删除") + + await self._ensure_default_role(session, user["id"]) + identity = await self._loadUserIdentity(session, user["id"]) + return self._buildUserInfo(user, identity) + + async def _buildLoginResponse(self, user: dict[str, Any], session) -> LoginTokenVO: + """组装登录响应:查询角色/权限 → 签发 JWT。""" + identity = await self._loadUserIdentity(session, user["id"]) + user_info = self._buildUserInfo(user, identity) - # 签发 JWT - expiresIn = 3600 # 默认 1 小时 tokens = JwtService.generate( userId=user["id"], username=user.get("username") or user.get("sub", ""), nickName=user.get("nick_name") or "", ouId=user.get("ou_id") or "", ouName=user.get("ou_name") or "", + roles=identity["roles"], + permissions=identity["permissions"], area=user.get("area"), - userRole=userRole, + userRole=identity["primary_role"], ) return LoginTokenVO( access_token=tokens["access_token"], token_type="Bearer", - expires_in=expiresIn, + expires_in=tokens["expires_in"], issued_time=tokens.get("issued_time", ""), - user_info={ - "user_id": user["id"], - "sub": user.get("sub"), - "username": user.get("username"), - "nick_name": user.get("nick_name"), - "email": user.get("email"), - "phone_number": user.get("phone_number"), - "ou_id": user.get("ou_id"), - "ou_name": user.get("ou_name"), - "is_leader": user.get("is_leader"), - "area": user.get("area"), - "role": userRole, - }, + user_info=user_info, ) + + async def _ensure_default_role(self, session, user_id: int) -> None: + """确保用户至少拥有一个默认 common 角色。""" + from sqlalchemy import text + + role_count = await session.execute( + text("SELECT COUNT(*) FROM user_role WHERE user_id = :uid"), + {"uid": user_id}, + ) + if (role_count.scalar_one() or 0) > 0: + return + + common_role = await session.execute( + text("SELECT id FROM roles WHERE role_key = 'common' LIMIT 1") + ) + common_role_id = common_role.scalar_one_or_none() + if common_role_id is None: + logger.warning("默认角色 common 不存在,无法为用户 %s 自动分配角色", user_id) + return + + await session.execute( + text( + "INSERT INTO user_role (user_id, role_id, created_at, updated_at) " + "VALUES (:uid, :rid, :now, :now) " + "ON CONFLICT (user_id, role_id) DO NOTHING" + ), + {"uid": user_id, "rid": common_role_id, "now": datetime.now(timezone.utc)}, + ) + logger.info("已为用户 %s 自动补默认角色 common", user_id) + + async def _loadUserIdentity(self, session, user_id: int) -> dict[str, Any]: + """加载用户角色和权限聚合结果。""" + from sqlalchemy import text + + role_rows = await session.execute( + text( + "SELECT r.role_key, COALESCE(r.priority, 0) AS priority " + "FROM user_role ur " + "JOIN roles r ON ur.role_id = r.id " + "WHERE ur.user_id = :uid " + "ORDER BY COALESCE(r.priority, 0) DESC, r.id ASC" + ), + {"uid": user_id}, + ) + roles = [row[0] for row in role_rows.fetchall()] + if not roles: + roles = ["common"] + + perm_rows = await session.execute( + text( + "SELECT p.permission_key, rp.grant_type " + "FROM user_role ur " + "JOIN roles r ON ur.role_id = r.id " + "JOIN role_permissions rp ON r.id = rp.role_id " + "JOIN permissions p ON rp.permission_id = p.id " + "WHERE ur.user_id = :uid" + ), + {"uid": user_id}, + ) + + grants: set[str] = set() + denies: set[str] = set() + for permission_key, grant_type in perm_rows.fetchall(): + if grant_type == "DENY": + denies.add(permission_key) + else: + grants.add(permission_key) + + permissions = sorted(grants - denies) + return { + "roles": roles, + "primary_role": roles[0], + "permissions": permissions, + } + + @staticmethod + def _buildUserInfo(user: dict[str, Any], identity: dict[str, Any]) -> dict[str, Any]: + """组装统一用户信息。""" + return { + "user_id": user["id"], + "sub": user.get("sub"), + "username": user.get("username"), + "nick_name": user.get("nick_name"), + "email": user.get("email"), + "phone_number": user.get("phone_number"), + "ou_id": user.get("ou_id"), + "ou_name": user.get("ou_name"), + "is_leader": user.get("is_leader"), + "area": user.get("area"), + "user_role": identity["primary_role"], + "roles": identity["roles"], + "permissions": identity["permissions"], + "tenant_name": user.get("tenant_name"), + "dep_name": user.get("dep_name"), + "dep_short_name": user.get("dep_short_name"), + } diff --git a/scripts/user_rbac_comments_patch.sql b/scripts/user_rbac_comments_patch.sql new file mode 100644 index 0000000..a6a65de --- /dev/null +++ b/scripts/user_rbac_comments_patch.sql @@ -0,0 +1,137 @@ +-- ========================================================================== +-- LeAudit Platform RBAC / User 中文注释补丁 +-- 目标:补齐 sso_users / roles / user_role / permissions / role_permissions / +-- sys_routes / role_route 的表注释与列注释 +-- ============================================================================ + +BEGIN; + +-- -------------------------------------------------------------------------- +-- 1. sso_users +-- -------------------------------------------------------------------------- +COMMENT ON TABLE sso_users IS '用户主表:统一承载认证身份、组织信息、登录信息与单地区隔离核心字段'; +COMMENT ON COLUMN sso_users.id IS '主键ID'; +COMMENT ON COLUMN sso_users.sub IS '统一身份唯一标识,OAuth/SSO 主键'; +COMMENT ON COLUMN sso_users.username IS '登录名/工号/展示账号'; +COMMENT ON COLUMN sso_users.nick_name IS '用户真实姓名'; +COMMENT ON COLUMN sso_users.phone_number IS '手机号'; +COMMENT ON COLUMN sso_users.email IS '邮箱地址'; +COMMENT ON COLUMN sso_users.ou_id IS '所属组织单位ID/部门ID'; +COMMENT ON COLUMN sso_users.ou_name IS '所属组织单位名称/部门名称'; +COMMENT ON COLUMN sso_users.status IS '账户状态:0=正常,1=禁用'; +COMMENT ON COLUMN sso_users.is_leader IS '是否为负责人'; +COMMENT ON COLUMN sso_users.created_at IS '创建时间'; +COMMENT ON COLUMN sso_users.updated_at IS '更新时间'; +COMMENT ON COLUMN sso_users.deleted_at IS '软删除时间'; +COMMENT ON COLUMN sso_users.password IS '密码字段:当前阶段兼容旧值,后续应迁移为哈希'; +COMMENT ON COLUMN sso_users.try_count IS '尝试登录次数'; +COMMENT ON COLUMN sso_users.try_login_time IS '最近一次尝试登录时间'; +COMMENT ON COLUMN sso_users.area IS '用户主地区,当前系统唯一数据隔离字段'; +COMMENT ON COLUMN sso_users.mq_person_uuid IS '关联组织/人员同步系统中的人员UUID'; +COMMENT ON COLUMN sso_users.mq_account_uuid IS '关联组织/账号同步系统中的账号UUID'; +COMMENT ON COLUMN sso_users.mq_synced_at IS '最近一次组织/账号同步时间'; +COMMENT ON COLUMN sso_users.tenant_name IS '租户名称/管理单元名称'; +COMMENT ON COLUMN sso_users.dep_short_name IS '组织简称'; +COMMENT ON COLUMN sso_users.dep_name IS '组织名称'; + +-- -------------------------------------------------------------------------- +-- 2. roles +-- -------------------------------------------------------------------------- +COMMENT ON TABLE roles IS '角色表:定义系统角色及其默认数据范围,当前主业务角色为 provincial_admin/admin/common'; +COMMENT ON COLUMN roles.id IS '主键ID'; +COMMENT ON COLUMN roles.role_key IS '角色机器标识,例如 provincial_admin/admin/common'; +COMMENT ON COLUMN roles.role_name IS '角色展示名称'; +COMMENT ON COLUMN roles.data_scope IS '默认数据范围:ALL=全部,DEPT=同地区,SELF=仅自己,GROUP仅保留兼容'; +COMMENT ON COLUMN roles.description IS '角色描述'; +COMMENT ON COLUMN roles.created_at IS '创建时间'; +COMMENT ON COLUMN roles.updated_at IS '更新时间'; +COMMENT ON COLUMN roles.parent_role_id IS '父角色ID,用于角色继承'; +COMMENT ON COLUMN roles.priority IS '角色优先级,数值越大优先级越高'; +COMMENT ON COLUMN roles.is_system_role IS '是否系统内置角色'; +COMMENT ON COLUMN roles.permissions_cache IS '权限缓存JSON,当前阶段不作为核心依赖'; +COMMENT ON COLUMN roles.metadata IS '扩展元数据JSON'; + +-- -------------------------------------------------------------------------- +-- 3. user_role +-- -------------------------------------------------------------------------- +COMMENT ON TABLE user_role IS '用户角色关联表:一个用户可挂多个角色,用于聚合功能权限与数据范围'; +COMMENT ON COLUMN user_role.id IS '主键ID'; +COMMENT ON COLUMN user_role.user_id IS '用户ID,关联 sso_users.id'; +COMMENT ON COLUMN user_role.role_id IS '角色ID,关联 roles.id'; +COMMENT ON COLUMN user_role.created_at IS '创建时间'; +COMMENT ON COLUMN user_role.updated_at IS '更新时间'; + +-- -------------------------------------------------------------------------- +-- 4. permissions +-- -------------------------------------------------------------------------- +COMMENT ON TABLE permissions IS '权限点定义表:统一定义 API/UI/数据权限点,权限键采用 module:resource:action 风格'; +COMMENT ON COLUMN permissions.id IS '主键ID'; +COMMENT ON COLUMN permissions.permission_key IS '权限键,例如 documents:list:read、audit:run:execute'; +COMMENT ON COLUMN permissions.module IS '所属模块,例如 auth/documents/audit/rules/users/rbac'; +COMMENT ON COLUMN permissions.resource IS '资源名,例如 list/detail/upload/run'; +COMMENT ON COLUMN permissions.action IS '动作名,例如 read/write/delete/execute'; +COMMENT ON COLUMN permissions.description IS '权限描述'; +COMMENT ON COLUMN permissions.display_name IS '权限展示名称'; +COMMENT ON COLUMN permissions.permission_type IS '权限类型:API/UI/DATA/RPC'; +COMMENT ON COLUMN permissions.is_system IS '是否系统内置权限'; +COMMENT ON COLUMN permissions.metadata IS '扩展元数据JSON'; +COMMENT ON COLUMN permissions.created_at IS '创建时间'; +COMMENT ON COLUMN permissions.updated_at IS '更新时间'; +COMMENT ON COLUMN permissions.created_by IS '创建人用户ID'; +COMMENT ON COLUMN permissions.updated_by IS '更新人用户ID'; +COMMENT ON COLUMN permissions.parent_id IS '父权限ID,用于构建权限树'; +COMMENT ON COLUMN permissions.sort_order IS '排序顺序'; +COMMENT ON COLUMN permissions.route_id IS '主关联路由ID,关联 sys_routes.id'; +COMMENT ON COLUMN permissions.api_path IS '对应后端 API 路径'; +COMMENT ON COLUMN permissions.api_method IS '对应后端 HTTP 方法'; +COMMENT ON COLUMN permissions.related_routes IS '共享权限可关联的多个路由ID列表'; + +-- -------------------------------------------------------------------------- +-- 5. role_permissions +-- -------------------------------------------------------------------------- +COMMENT ON TABLE role_permissions IS '角色权限关联表:定义某角色拥有哪些权限点,以及该权限点对应的数据范围'; +COMMENT ON COLUMN role_permissions.id IS '主键ID'; +COMMENT ON COLUMN role_permissions.role_id IS '角色ID,关联 roles.id'; +COMMENT ON COLUMN role_permissions.permission_id IS '权限点ID,关联 permissions.id'; +COMMENT ON COLUMN role_permissions.grant_type IS '授权类型:GRANT=授予,DENY=拒绝'; +COMMENT ON COLUMN role_permissions.data_scope IS '数据范围:ALL=全部,DEPT=同地区,SELF=仅自己,GROUP仅保留兼容'; +COMMENT ON COLUMN role_permissions.condition_filter IS '高级条件过滤JSON,当前阶段不作为核心依赖'; +COMMENT ON COLUMN role_permissions.metadata IS '扩展元数据JSON'; +COMMENT ON COLUMN role_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN role_permissions.created_by IS '创建人用户ID'; +COMMENT ON COLUMN role_permissions.updated_at IS '更新时间'; +COMMENT ON COLUMN role_permissions.updated_by IS '更新人用户ID'; + +-- -------------------------------------------------------------------------- +-- 6. sys_routes +-- -------------------------------------------------------------------------- +COMMENT ON TABLE sys_routes IS '前端菜单/页面路由表:用于控制角色可见菜单,不替代 API 权限表'; +COMMENT ON COLUMN sys_routes.id IS '主键ID'; +COMMENT ON COLUMN sys_routes.route_path IS '前端路由路径'; +COMMENT ON COLUMN sys_routes.route_name IS '路由名称/内部标识'; +COMMENT ON COLUMN sys_routes.component IS '前端组件路径'; +COMMENT ON COLUMN sys_routes.parent_id IS '父路由ID'; +COMMENT ON COLUMN sys_routes.route_title IS '路由标题/菜单显示名'; +COMMENT ON COLUMN sys_routes.icon IS '菜单图标标识'; +COMMENT ON COLUMN sys_routes.sort_order IS '排序顺序'; +COMMENT ON COLUMN sys_routes.is_hidden IS '是否隐藏路由'; +COMMENT ON COLUMN sys_routes.is_cache IS '是否启用前端缓存'; +COMMENT ON COLUMN sys_routes.meta IS '路由扩展元数据JSON'; +COMMENT ON COLUMN sys_routes.status IS '状态:0=启用,1=禁用'; +COMMENT ON COLUMN sys_routes.created_at IS '创建时间'; +COMMENT ON COLUMN sys_routes.updated_at IS '更新时间'; +COMMENT ON COLUMN sys_routes.deleted_at IS '软删除时间'; + +-- -------------------------------------------------------------------------- +-- 7. role_route +-- -------------------------------------------------------------------------- +COMMENT ON TABLE role_route IS '角色路由关联表:定义某角色可访问哪些菜单/页面路由'; +COMMENT ON COLUMN role_route.id IS '主键ID'; +COMMENT ON COLUMN role_route.role_id IS '角色ID,关联 roles.id'; +COMMENT ON COLUMN role_route.route_id IS '路由ID,关联 sys_routes.id'; +COMMENT ON COLUMN role_route.permission IS '路由权限类型:R=读,W=写,RW=读写'; +COMMENT ON COLUMN role_route.created_at IS '创建时间'; +COMMENT ON COLUMN role_route.updated_at IS '更新时间'; +COMMENT ON COLUMN role_route.status IS '状态:0=禁用,1=启用'; + +COMMIT; diff --git a/scripts/user_rbac_migration_audit.sql b/scripts/user_rbac_migration_audit.sql new file mode 100644 index 0000000..3e95cba --- /dev/null +++ b/scripts/user_rbac_migration_audit.sql @@ -0,0 +1,85 @@ +-- ========================================================================== +-- 老系统用户权限迁移前审计 SQL +-- 目标库:docauditai +-- 用途:在正式迁移 sso_users / roles / user_role / permissions / role_permissions / sys_routes / role_route 前, +-- 先出质量统计,识别脏数据、空地区、无角色用户、历史脏角色。 +-- ============================================================================ + +-- 1. 基础行数 +SELECT 'sso_users' AS table_name, COUNT(*) AS total FROM sso_users +UNION ALL SELECT 'roles', COUNT(*) FROM roles +UNION ALL SELECT 'user_role', COUNT(*) FROM user_role +UNION ALL SELECT 'permissions', COUNT(*) FROM permissions +UNION ALL SELECT 'role_permissions', COUNT(*) FROM role_permissions +UNION ALL SELECT 'sys_routes', COUNT(*) FROM sys_routes +UNION ALL SELECT 'role_route', COUNT(*) FROM role_route; + +-- 2. 用户地区分布 +SELECT COALESCE(NULLIF(BTRIM(area), ''), '') AS area_value, COUNT(*) AS user_count +FROM sso_users +GROUP BY 1 +ORDER BY user_count DESC, area_value; + +-- 3. 重复 sub +SELECT sub, COUNT(*) AS dup_count +FROM sso_users +GROUP BY sub +HAVING COUNT(*) > 1 +ORDER BY dup_count DESC, sub; + +-- 4. 重复 username +SELECT username, COUNT(*) AS dup_count +FROM sso_users +GROUP BY username +HAVING COUNT(*) > 1 +ORDER BY dup_count DESC, username; + +-- 5. 空地区 / 禁用 / 软删除 用户统计 +SELECT + COUNT(*) FILTER (WHERE area IS NULL OR BTRIM(area) = '') AS empty_area_count, + COUNT(*) FILTER (WHERE status <> 0) AS disabled_count, + COUNT(*) FILTER (WHERE deleted_at IS NOT NULL) AS deleted_count +FROM sso_users; + +-- 6. 无角色用户 +SELECT u.id, u.sub, u.username, u.nick_name, u.area +FROM sso_users u +LEFT JOIN user_role ur ON ur.user_id = u.id +WHERE ur.id IS NULL +ORDER BY u.id; + +-- 7. 角色分布 +SELECT r.role_key, r.role_name, COUNT(ur.user_id) AS user_count +FROM roles r +LEFT JOIN user_role ur ON ur.role_id = r.id +GROUP BY r.id, r.role_key, r.role_name +ORDER BY user_count DESC, r.role_key; + +-- 8. 历史角色排查(重点看是否还有不应带入新系统的角色) +SELECT role_key, role_name, data_scope, description +FROM roles +ORDER BY role_key; + +-- 9. user_role 脏引用 +SELECT ur.* +FROM user_role ur +LEFT JOIN sso_users u ON u.id = ur.user_id +LEFT JOIN roles r ON r.id = ur.role_id +WHERE u.id IS NULL OR r.id IS NULL +ORDER BY ur.id; + +-- 10. role_permissions 脏引用 +SELECT rp.* +FROM role_permissions rp +LEFT JOIN roles r ON r.id = rp.role_id +LEFT JOIN permissions p ON p.id = rp.permission_id +WHERE r.id IS NULL OR p.id IS NULL +ORDER BY rp.id; + +-- 11. role_route 脏引用 +SELECT rr.* +FROM role_route rr +LEFT JOIN roles r ON r.id = rr.role_id +LEFT JOIN sys_routes sr ON sr.id = rr.route_id +WHERE r.id IS NULL OR sr.id IS NULL +ORDER BY rr.id; diff --git a/scripts/user_rbac_schema_patch.sql b/scripts/user_rbac_schema_patch.sql new file mode 100644 index 0000000..9fe2266 --- /dev/null +++ b/scripts/user_rbac_schema_patch.sql @@ -0,0 +1,287 @@ +-- ========================================================================== +-- LeAudit Platform RBAC / User Schema Patch +-- 目标:在当前 leaudit_platform 新库中补齐用户、角色、权限、路由核心表 +-- 设计原则: +-- 1. 兼容老系统 docauditai 的核心字段语义 +-- 2. 新系统只做单地区隔离,地区字段统一使用 sso_users.area +-- 3. 采用 bigint 主键,兼容新库现有 leaudit_* bigint 引用字段 +-- 4. 当前库里尚不存在 sso_users / roles / permissions / role_permissions / sys_routes / role_route / user_role +-- ============================================================================ + +BEGIN; + +-- -------------------------------------------------------------------------- +-- 1. 用户主表 sso_users +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sso_users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + sub VARCHAR(128) NOT NULL, + username VARCHAR(128) NOT NULL, + nick_name VARCHAR(128) NOT NULL, + phone_number VARCHAR(64), + email VARCHAR(256), + ou_id VARCHAR(128) NOT NULL, + ou_name VARCHAR(255) NOT NULL, + status SMALLINT NOT NULL DEFAULT 0, + is_leader BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITHOUT TIME ZONE, + password VARCHAR(255), + try_count INTEGER, + try_login_time TIMESTAMP WITHOUT TIME ZONE, + area VARCHAR(64), + mq_person_uuid VARCHAR(64), + mq_account_uuid VARCHAR(64), + mq_synced_at TIMESTAMP WITHOUT TIME ZONE, + tenant_name VARCHAR(255), + dep_short_name VARCHAR(255), + dep_name VARCHAR(255), + CONSTRAINT sso_users_sub_key UNIQUE (sub) +); + +CREATE INDEX IF NOT EXISTS idx_sso_users_sub ON sso_users(sub); +CREATE UNIQUE INDEX IF NOT EXISTS idx_sso_users_sub_unique ON sso_users(sub); +CREATE INDEX IF NOT EXISTS idx_sso_users_username ON sso_users(username); +CREATE INDEX IF NOT EXISTS idx_sso_users_area ON sso_users(area) WHERE area IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_sso_users_status ON sso_users(status); +CREATE INDEX IF NOT EXISTS idx_sso_users_deleted_at ON sso_users(deleted_at); +CREATE INDEX IF NOT EXISTS idx_sso_users_ou_id ON sso_users(ou_id); +CREATE INDEX IF NOT EXISTS idx_sso_users_is_leader ON sso_users(is_leader); +CREATE INDEX IF NOT EXISTS idx_sso_users_mq_person ON sso_users(mq_person_uuid); +CREATE INDEX IF NOT EXISTS idx_sso_users_mq_account ON sso_users(mq_account_uuid); + +COMMENT ON TABLE sso_users IS '用户主表:认证身份、组织信息、地区隔离基础字段统一沉淀在这里'; +COMMENT ON COLUMN sso_users.sub IS '统一身份唯一标识,OAuth / SSO 主键'; +COMMENT ON COLUMN sso_users.username IS '登录名/工号/展示账号'; +COMMENT ON COLUMN sso_users.nick_name IS '用户真实姓名'; +COMMENT ON COLUMN sso_users.area IS '用户主地区,当前系统唯一数据隔离字段'; +COMMENT ON COLUMN sso_users.password IS '密码字段:当前阶段兼容旧值,后续应迁移为哈希'; +COMMENT ON COLUMN sso_users.status IS '账户状态:0=正常,1=禁用'; +COMMENT ON COLUMN sso_users.deleted_at IS '软删除时间'; + +-- -------------------------------------------------------------------------- +-- 2. 角色表 roles +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS roles ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + role_key VARCHAR(64) NOT NULL, + role_name VARCHAR(128) NOT NULL, + data_scope VARCHAR(16) DEFAULT 'SELF', + description VARCHAR(255) DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + parent_role_id BIGINT, + priority INTEGER DEFAULT 0, + is_system_role BOOLEAN DEFAULT FALSE, + permissions_cache JSONB, + metadata JSONB, + CONSTRAINT roles_role_key_key UNIQUE (role_key), + CONSTRAINT fk_parent_role FOREIGN KEY (parent_role_id) REFERENCES roles(id) ON DELETE SET NULL, + CONSTRAINT chk_roles_data_scope CHECK (data_scope IN ('ALL', 'DEPT', 'SELF', 'GROUP')) +); + +CREATE INDEX IF NOT EXISTS idx_roles_parent_role_id ON roles(parent_role_id); +CREATE INDEX IF NOT EXISTS idx_roles_priority ON roles(priority DESC); +CREATE INDEX IF NOT EXISTS idx_roles_permissions_cache ON roles USING GIN (permissions_cache); + +COMMENT ON TABLE roles IS '角色表:当前主业务角色只使用 provincial_admin/admin/common,可选 super_admin'; +COMMENT ON COLUMN roles.role_key IS '角色机器标识,例如 provincial_admin/admin/common'; +COMMENT ON COLUMN roles.role_name IS '角色展示名称'; +COMMENT ON COLUMN roles.data_scope IS '默认数据范围:ALL/DEPT/SELF;GROUP 仅保留兼容'; +COMMENT ON COLUMN roles.priority IS '角色优先级,数值越大优先级越高'; + +-- -------------------------------------------------------------------------- +-- 3. 菜单/路由表 sys_routes +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sys_routes ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + route_path VARCHAR(255) NOT NULL, + route_name VARCHAR(128) NOT NULL, + component VARCHAR(255), + parent_id BIGINT, + route_title VARCHAR(255), + icon VARCHAR(128), + sort_order INTEGER DEFAULT 0, + is_hidden BOOLEAN DEFAULT FALSE, + is_cache BOOLEAN DEFAULT TRUE, + meta JSONB, + status INTEGER DEFAULT 0, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT fk_parent_route FOREIGN KEY (parent_id) REFERENCES sys_routes(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_routes_path ON sys_routes(route_path) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_routes_parent_id ON sys_routes(parent_id); +CREATE INDEX IF NOT EXISTS idx_routes_status ON sys_routes(status); + +COMMENT ON TABLE sys_routes IS '前端菜单/页面路由表:用于控制角色可见菜单,不替代 API 权限表'; +COMMENT ON COLUMN sys_routes.route_path IS '前端路由路径'; +COMMENT ON COLUMN sys_routes.status IS '状态:0=启用,1=禁用'; + +-- -------------------------------------------------------------------------- +-- 4. 角色路由关系表 role_route +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS role_route ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + role_id BIGINT NOT NULL, + route_id BIGINT NOT NULL, + permission VARCHAR(8) DEFAULT 'RW', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + status SMALLINT NOT NULL DEFAULT 1, + CONSTRAINT role_route_role_id_route_id_key UNIQUE (role_id, route_id), + CONSTRAINT fk_role_route_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + CONSTRAINT fk_role_route_route FOREIGN KEY (route_id) REFERENCES sys_routes(id) ON DELETE CASCADE, + CONSTRAINT chk_role_route_status CHECK (status IN (0, 1)), + CONSTRAINT chk_role_route_permission CHECK (permission IN ('R', 'W', 'RW')) +); + +CREATE INDEX IF NOT EXISTS idx_role_route_status ON role_route(status); +CREATE INDEX IF NOT EXISTS idx_role_route_role_status ON role_route(role_id, status); + +COMMENT ON TABLE role_route IS '角色与菜单路由关联表'; +COMMENT ON COLUMN role_route.permission IS '路由权限类型:R=读,W=写,RW=读写'; + +-- -------------------------------------------------------------------------- +-- 5. 权限点表 permissions +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS permissions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + permission_key VARCHAR(100) NOT NULL, + module VARCHAR(50) NOT NULL, + resource VARCHAR(50) NOT NULL, + action VARCHAR(50) NOT NULL, + description TEXT, + display_name VARCHAR(200), + permission_type VARCHAR(20) NOT NULL DEFAULT 'API', + is_system BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT, + updated_by BIGINT, + parent_id BIGINT, + sort_order INTEGER DEFAULT 0, + route_id BIGINT, + api_path VARCHAR(255), + api_method VARCHAR(16), + related_routes BIGINT[], + CONSTRAINT permissions_permission_key_key UNIQUE (permission_key), + CONSTRAINT permissions_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES permissions(id) ON DELETE SET NULL, + CONSTRAINT permissions_route_id_fkey FOREIGN KEY (route_id) REFERENCES sys_routes(id) ON DELETE SET NULL, + CONSTRAINT permissions_key_format_check CHECK (permission_key ~ '^[a-zA-Z0-9_*]+:[a-zA-Z0-9_*]+:[a-zA-Z0-9_*]+$') +); + +CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module); +CREATE INDEX IF NOT EXISTS idx_permissions_parent_id ON permissions(parent_id); +CREATE INDEX IF NOT EXISTS idx_permissions_route_id ON permissions(route_id); +CREATE INDEX IF NOT EXISTS idx_permissions_sort_order ON permissions(sort_order); +CREATE INDEX IF NOT EXISTS idx_permissions_system ON permissions(is_system); +CREATE INDEX IF NOT EXISTS idx_permissions_type ON permissions(permission_type); +CREATE INDEX IF NOT EXISTS idx_permissions_metadata ON permissions USING GIN (metadata); + +COMMENT ON TABLE permissions IS '权限点定义表:统一使用 module:resource:action 风格 permission_key'; +COMMENT ON COLUMN permissions.permission_key IS '权限键,例如 documents:list:read、audit:run:execute'; +COMMENT ON COLUMN permissions.related_routes IS '共享权限可关联多个路由 ID'; + +-- -------------------------------------------------------------------------- +-- 6. 角色权限关系表 role_permissions +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS role_permissions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + grant_type VARCHAR(10) NOT NULL DEFAULT 'GRANT', + data_scope VARCHAR(20), + condition_filter JSONB, + metadata JSONB, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT, + CONSTRAINT unique_role_permission UNIQUE (role_id, permission_id), + CONSTRAINT role_permissions_role_id_fkey FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + CONSTRAINT role_permissions_permission_id_fkey FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE, + CONSTRAINT chk_role_permissions_grant_type CHECK (grant_type IN ('GRANT', 'DENY')), + CONSTRAINT chk_role_permissions_data_scope CHECK (data_scope IS NULL OR data_scope IN ('ALL', 'DEPT', 'SELF', 'GROUP')) +); + +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_grant_type ON role_permissions(grant_type); +CREATE INDEX IF NOT EXISTS idx_role_permissions_data_scope ON role_permissions(data_scope); +CREATE INDEX IF NOT EXISTS idx_role_permissions_lookup ON role_permissions(role_id, grant_type); +CREATE INDEX IF NOT EXISTS idx_role_permissions_condition ON role_permissions USING GIN (condition_filter); + +COMMENT ON TABLE role_permissions IS '角色权限关联表:grant_type 兼容旧系统 DENY 语义,当前新系统默认只配置 GRANT'; +COMMENT ON COLUMN role_permissions.data_scope IS '数据范围:ALL/DEPT/SELF,GROUP 仅保留兼容'; + +-- -------------------------------------------------------------------------- +-- 7. 用户角色关系表 user_role +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS user_role ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT user_role_user_id_role_id_key UNIQUE (user_id, role_id), + CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) REFERENCES sso_users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_role_user_role ON user_role(user_id, role_id); + +COMMENT ON TABLE user_role IS '用户与角色关联表;一个用户允许挂多个角色'; + +-- -------------------------------------------------------------------------- +-- 8. 给现有 leaudit_* 业务表补用户外键 +-- 当前库中这些字段均为 bigint,且当前全为空,可安全补充外键。 +-- -------------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_leaudit_document_files_created_by' + ) THEN + ALTER TABLE leaudit_document_files + ADD CONSTRAINT fk_leaudit_document_files_created_by + FOREIGN KEY (created_by) REFERENCES sso_users(id) ON DELETE SET NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_leaudit_audit_runs_trigger_user_id' + ) THEN + ALTER TABLE leaudit_audit_runs + ADD CONSTRAINT fk_leaudit_audit_runs_trigger_user_id + FOREIGN KEY (trigger_user_id) REFERENCES sso_users(id) ON DELETE SET NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_leaudit_rule_sets_owner_user_id' + ) THEN + ALTER TABLE leaudit_rule_sets + ADD CONSTRAINT fk_leaudit_rule_sets_owner_user_id + FOREIGN KEY (owner_user_id) REFERENCES sso_users(id) ON DELETE SET NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_leaudit_rule_versions_editor_user_id' + ) THEN + ALTER TABLE leaudit_rule_versions + ADD CONSTRAINT fk_leaudit_rule_versions_editor_user_id + FOREIGN KEY (editor_user_id) REFERENCES sso_users(id) ON DELETE SET NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_leaudit_rule_versions_publisher_user_id' + ) THEN + ALTER TABLE leaudit_rule_versions + ADD CONSTRAINT fk_leaudit_rule_versions_publisher_user_id + FOREIGN KEY (publisher_user_id) REFERENCES sso_users(id) ON DELETE SET NULL; + END IF; +END $$; + +COMMIT; diff --git a/scripts/user_rbac_seed.sql b/scripts/user_rbac_seed.sql new file mode 100644 index 0000000..ff4d507 --- /dev/null +++ b/scripts/user_rbac_seed.sql @@ -0,0 +1,271 @@ +-- ========================================================================== +-- LeAudit Platform RBAC / User Seed +-- 说明: +-- 1. 本文件依赖 scripts/user_rbac_schema_patch.sql 已执行 +-- 2. 权限键统一采用 module:resource:action 风格 +-- 3. 当前只初始化新系统当前开发真正需要的最小权限集 +-- ============================================================================ + +BEGIN; + +-- -------------------------------------------------------------------------- +-- 1. 角色初始化 +-- -------------------------------------------------------------------------- +INSERT INTO roles (role_key, role_name, data_scope, description, priority, is_system_role, created_at, updated_at) +VALUES + ('super_admin', '系统超级管理员', 'ALL', '可选,仅系统维护/排障使用', 100, TRUE, NOW(), NOW()), + ('provincial_admin', '省级管理员', 'ALL', '查看全局数据并维护系统配置', 90, TRUE, NOW(), NOW()), + ('admin', '地区管理员', 'DEPT', '仅管理本地区数据', 50, TRUE, NOW(), NOW()), + ('common', '普通用户', 'SELF', '仅处理本人数据', 10, TRUE, NOW(), NOW()) +ON CONFLICT (role_key) DO UPDATE SET + role_name = EXCLUDED.role_name, + data_scope = EXCLUDED.data_scope, + description = EXCLUDED.description, + priority = EXCLUDED.priority, + is_system_role = EXCLUDED.is_system_role, + updated_at = NOW(); + +-- -------------------------------------------------------------------------- +-- 2. 路由初始化 +-- -------------------------------------------------------------------------- +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) +VALUES + ('/documents', 'documents', 'Layout', NULL, '文档管理', 'files', 10, FALSE, TRUE, '{"group":"documents"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/documents/list', 'documents.list', 'documents/list', NULL, '文档列表', 'table', 11, FALSE, TRUE, '{"group":"documents"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/audit', 'audit', 'Layout', NULL, '评查任务', 'audit', 20, FALSE, TRUE, '{"group":"audit"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/audit/runs', 'audit.runs', 'audit/runs', NULL, '评查运行', 'history', 21, FALSE, TRUE, '{"group":"audit"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/rules', 'rules', 'Layout', NULL, '规则管理', 'rule', 30, FALSE, TRUE, '{"group":"rules"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/rules/sets', 'rules.sets', 'rules/sets', NULL, '规则集管理', 'yaml', 31, FALSE, TRUE, '{"group":"rules"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/system', 'system', 'Layout', NULL, '系统管理', 'setting', 90, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/system/users', 'system.users', 'system/users', NULL, '用户管理', 'user', 91, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/system/roles', 'system.roles', 'system/roles', NULL, '角色权限', 'shield', 92, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL) +ON CONFLICT DO NOTHING; + +-- -------------------------------------------------------------------------- +-- 3. 权限点初始化 +-- -------------------------------------------------------------------------- +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 +) +VALUES + ('auth:me:read', 'auth', 'me', 'read', '查看当前登录用户信息', '当前用户信息', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 10, NULL, '/api/auth/me', 'GET', NULL), + + ('documents:upload:write', 'documents', 'upload', 'write', '上传文档', '上传文档', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 20, NULL, '/api/upload', 'POST', NULL), + ('documents:list:read', 'documents', 'list', 'read', '查看文档列表', '文档列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 21, NULL, '/api/documents/list', 'GET', NULL), + ('documents:detail:read', 'documents', 'detail', 'read', '查看文档详情', '文档详情', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 22, NULL, '/api/documents/{document_id}', 'GET', NULL), + ('documents:history:read', 'documents', 'history', 'read', '查看文档历史版本', '文档历史版本', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 23, NULL, '/api/documents/{document_id}/versions', 'GET', NULL), + ('documents:delete:delete', 'documents', 'delete', 'delete', '删除文档', '删除文档', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 24, NULL, '/api/documents/{document_id}', 'DELETE', NULL), + + ('audit:run:execute', 'audit', 'run', 'execute', '发起评查任务', '发起评查', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 30, NULL, '/api/audit/run', 'POST', NULL), + ('audit:status:read', 'audit', 'status', 'read', '查看评查运行状态', '评查状态', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 31, NULL, '/api/audit/run/{run_id}', 'GET', NULL), + ('audit:result:read', 'audit', 'result', 'read', '查看评查结果', '评查结果', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 32, NULL, '/api/audit/result/{run_id}', 'GET', NULL), + + ('rules:list:read', 'rules', 'list', 'read', '查看规则集列表', '规则集列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 40, NULL, '/api/rule-sets', 'GET', NULL), + ('rules:version_list:read', 'rules', 'version_list', 'read', '查看规则版本列表', '规则版本列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 41, NULL, '/api/rule-sets/{rule_type}/versions', 'GET', NULL), + ('rules:content:read', 'rules', 'content', 'read', '查看规则正文', '规则正文', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 42, NULL, '/api/rule-sets/versions/{version_id}/content', 'GET', NULL), + ('rules:validate:execute', 'rules', 'validate', 'execute', '校验规则 YAML', '规则校验', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 43, NULL, '/api/rule-sets/{rule_type}/validate', 'POST', NULL), + ('rules:version_create:write', 'rules', 'version_create', 'write', '创建规则版本', '创建规则版本', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 44, NULL, '/api/rule-sets/{rule_type}/versions', 'POST', NULL), + ('rules:publish:write', 'rules', 'publish', 'write', '发布规则版本', '发布规则', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 45, NULL, '/api/rule-sets/{rule_type}/publish', 'POST', NULL), + ('rules:rollback:write', 'rules', 'rollback', 'write', '回滚规则版本', '回滚规则', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 46, NULL, '/api/rule-sets/{rule_type}/rollback', 'POST', NULL), + ('rules:binding_list:read', 'rules', 'binding_list', 'read', '查看规则绑定列表', '规则绑定列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 47, NULL, '/api/rule-sets/bindings', 'GET', NULL), + ('rules:binding_create:write', 'rules', 'binding_create', 'write', '创建规则绑定', '创建规则绑定', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 48, NULL, '/api/rule-sets/{rule_type}/bindings', 'POST', NULL), + ('rules:binding_update:write', 'rules', 'binding_update', 'write', '更新规则绑定', '更新规则绑定', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 49, NULL, '/api/rule-sets/bindings/{binding_id}', 'PUT', NULL), + ('rules:binding_delete:delete', 'rules', 'binding_delete', 'delete', '删除规则绑定', '删除规则绑定', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 50, NULL, '/api/rule-sets/bindings/{binding_id}', 'DELETE', NULL), + + ('users:list:read', 'users', 'list', 'read', '查看用户列表', '用户列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 60, NULL, '/api/users/list', 'GET', NULL), + ('users:create:write', 'users', 'create', 'write', '创建用户', '创建用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 61, NULL, '/api/users', 'POST', NULL), + ('users:update:write', 'users', 'update', 'write', '更新用户', '更新用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 62, NULL, '/api/users/{user_id}', 'PUT', NULL), + ('users:disable:write', 'users', 'disable', 'write', '禁用/启用用户', '禁用用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 63, NULL, '/api/users/{user_id}/disable', 'PUT', NULL), + ('users:roles_assign:write', 'users', 'roles_assign', 'write', '分配用户角色', '分配用户角色', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 64, NULL, '/api/users/{user_id}/roles', 'POST', NULL), + + ('rbac:roles:read', 'rbac', 'roles', 'read', '查看角色列表', '角色列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 70, NULL, '/api/rbac/roles', 'GET', NULL), + ('rbac:roles:update', 'rbac', 'roles', 'update', '维护角色信息', '维护角色', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 71, NULL, '/api/rbac/roles/{role_id}', 'PUT', NULL), + ('rbac:permissions:read', 'rbac', 'permissions', 'read', '查看权限点列表', '权限点列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 72, NULL, '/api/rbac/permissions', 'GET', NULL), + ('rbac:role_permissions:write', 'rbac', 'role_permissions', 'write', '分配角色权限', '分配角色权限', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 73, NULL, '/api/rbac/roles/{role_id}/permissions', 'POST', NULL), + ('rbac:role_routes:write', 'rbac', 'role_routes', 'write', '分配角色菜单', '分配角色菜单', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 74, NULL, '/api/rbac/roles/{role_id}/routes', 'PUT', NULL) +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 = CURRENT_TIMESTAMP, + api_path = EXCLUDED.api_path, + api_method = EXCLUDED.api_method, + sort_order = EXCLUDED.sort_order; + +-- -------------------------------------------------------------------------- +-- 4. 角色菜单授权 +-- -------------------------------------------------------------------------- +WITH role_map AS ( + SELECT id, role_key FROM roles WHERE role_key IN ('super_admin', 'provincial_admin', 'admin', 'common') +), +route_map AS ( + SELECT id, route_path FROM sys_routes WHERE deleted_at IS NULL +), +seed(role_key, route_path, permission, status) AS ( + VALUES + ('super_admin', '/documents', 'RW', 1), + ('super_admin', '/documents/list', 'RW', 1), + ('super_admin', '/audit', 'RW', 1), + ('super_admin', '/audit/runs', 'RW', 1), + ('super_admin', '/rules', 'RW', 1), + ('super_admin', '/rules/sets', 'RW', 1), + ('super_admin', '/system', 'RW', 1), + ('super_admin', '/system/users', 'RW', 1), + ('super_admin', '/system/roles', 'RW', 1), + + ('provincial_admin', '/documents', 'RW', 1), + ('provincial_admin', '/documents/list', 'RW', 1), + ('provincial_admin', '/audit', 'RW', 1), + ('provincial_admin', '/audit/runs', 'RW', 1), + ('provincial_admin', '/rules', 'RW', 1), + ('provincial_admin', '/rules/sets', 'RW', 1), + ('provincial_admin', '/system', 'RW', 1), + ('provincial_admin', '/system/users', 'RW', 1), + ('provincial_admin', '/system/roles', 'RW', 1), + + ('admin', '/documents', 'RW', 1), + ('admin', '/documents/list', 'RW', 1), + ('admin', '/audit', 'RW', 1), + ('admin', '/audit/runs', 'RW', 1), + ('admin', '/rules', 'RW', 1), + ('admin', '/rules/sets', 'RW', 1), + ('admin', '/system', 'RW', 1), + ('admin', '/system/users', 'RW', 1), + + ('common', '/documents', 'R', 1), + ('common', '/documents/list', 'R', 1), + ('common', '/audit', 'R', 1), + ('common', '/audit/runs', '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(); + +-- -------------------------------------------------------------------------- +-- 5. 角色权限授权 +-- -------------------------------------------------------------------------- +WITH role_map AS ( + SELECT id, role_key FROM roles WHERE role_key IN ('super_admin', 'provincial_admin', 'admin', 'common') +), +perm_map AS ( + SELECT id, permission_key FROM permissions +), +seed(role_key, permission_key, grant_type, data_scope) AS ( + VALUES + ('super_admin', 'auth:me:read', 'GRANT', 'ALL'), + ('super_admin', 'documents:upload:write', 'GRANT', 'ALL'), + ('super_admin', 'documents:list:read', 'GRANT', 'ALL'), + ('super_admin', 'documents:detail:read', 'GRANT', 'ALL'), + ('super_admin', 'documents:history:read', 'GRANT', 'ALL'), + ('super_admin', 'documents:delete:delete', 'GRANT', 'ALL'), + ('super_admin', 'audit:run:execute', 'GRANT', 'ALL'), + ('super_admin', 'audit:status:read', 'GRANT', 'ALL'), + ('super_admin', 'audit:result:read', 'GRANT', 'ALL'), + ('super_admin', 'rules:list:read', 'GRANT', 'ALL'), + ('super_admin', 'rules:version_list:read', 'GRANT', 'ALL'), + ('super_admin', 'rules:content:read', 'GRANT', 'ALL'), + ('super_admin', 'rules:validate:execute', 'GRANT', 'ALL'), + ('super_admin', 'rules:version_create:write', 'GRANT', 'ALL'), + ('super_admin', 'rules:publish:write', 'GRANT', 'ALL'), + ('super_admin', 'rules:rollback:write', 'GRANT', 'ALL'), + ('super_admin', 'rules:binding_list:read', 'GRANT', 'ALL'), + ('super_admin', 'rules:binding_create:write', 'GRANT', 'ALL'), + ('super_admin', 'rules:binding_update:write', 'GRANT', 'ALL'), + ('super_admin', 'rules:binding_delete:delete', 'GRANT', 'ALL'), + ('super_admin', 'users:list:read', 'GRANT', 'ALL'), + ('super_admin', 'users:create:write', 'GRANT', 'ALL'), + ('super_admin', 'users:update:write', 'GRANT', 'ALL'), + ('super_admin', 'users:disable:write', 'GRANT', 'ALL'), + ('super_admin', 'users:roles_assign:write', 'GRANT', 'ALL'), + ('super_admin', 'rbac:roles:read', 'GRANT', 'ALL'), + ('super_admin', 'rbac:roles:update', 'GRANT', 'ALL'), + ('super_admin', 'rbac:permissions:read', 'GRANT', 'ALL'), + ('super_admin', 'rbac:role_permissions:write', 'GRANT', 'ALL'), + ('super_admin', 'rbac:role_routes:write', 'GRANT', 'ALL'), + + ('provincial_admin', 'auth:me:read', 'GRANT', 'ALL'), + ('provincial_admin', 'documents:upload:write', 'GRANT', 'ALL'), + ('provincial_admin', 'documents:list:read', 'GRANT', 'ALL'), + ('provincial_admin', 'documents:detail:read', 'GRANT', 'ALL'), + ('provincial_admin', 'documents:history:read', 'GRANT', 'ALL'), + ('provincial_admin', 'documents:delete:delete', 'GRANT', 'ALL'), + ('provincial_admin', 'audit:run:execute', 'GRANT', 'ALL'), + ('provincial_admin', 'audit:status:read', 'GRANT', 'ALL'), + ('provincial_admin', 'audit:result:read', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:list:read', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:version_list:read', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:content:read', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:validate:execute', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:version_create:write', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:publish:write', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:rollback:write', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:binding_list:read', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:binding_create:write', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:binding_update:write', 'GRANT', 'ALL'), + ('provincial_admin', 'rules:binding_delete:delete', 'GRANT', 'ALL'), + ('provincial_admin', 'users:list:read', 'GRANT', 'ALL'), + ('provincial_admin', 'users:create:write', 'GRANT', 'ALL'), + ('provincial_admin', 'users:update:write', 'GRANT', 'ALL'), + ('provincial_admin', 'users:disable:write', 'GRANT', 'ALL'), + ('provincial_admin', 'users:roles_assign:write', 'GRANT', 'ALL'), + ('provincial_admin', 'rbac:roles:read', 'GRANT', 'ALL'), + ('provincial_admin', 'rbac:roles:update', 'GRANT', 'ALL'), + ('provincial_admin', 'rbac:permissions:read', 'GRANT', 'ALL'), + ('provincial_admin', 'rbac:role_permissions:write', 'GRANT', 'ALL'), + ('provincial_admin', 'rbac:role_routes:write', 'GRANT', 'ALL'), + + ('admin', 'auth:me:read', 'GRANT', 'DEPT'), + ('admin', 'documents:upload:write', 'GRANT', 'DEPT'), + ('admin', 'documents:list:read', 'GRANT', 'DEPT'), + ('admin', 'documents:detail:read', 'GRANT', 'DEPT'), + ('admin', 'documents:history:read', 'GRANT', 'DEPT'), + ('admin', 'documents:delete:delete', 'GRANT', 'DEPT'), + ('admin', 'audit:run:execute', 'GRANT', 'DEPT'), + ('admin', 'audit:status:read', 'GRANT', 'DEPT'), + ('admin', 'audit:result:read', 'GRANT', 'DEPT'), + ('admin', 'rules:list:read', 'GRANT', 'DEPT'), + ('admin', 'rules:version_list:read', 'GRANT', 'DEPT'), + ('admin', 'rules:content:read', 'GRANT', 'DEPT'), + ('admin', 'rules:validate:execute', 'GRANT', 'DEPT'), + ('admin', 'rules:binding_list:read', 'GRANT', 'DEPT'), + ('admin', 'rules:binding_create:write', 'GRANT', 'DEPT'), + ('admin', 'rules:binding_update:write', 'GRANT', 'DEPT'), + ('admin', 'users:list:read', 'GRANT', 'DEPT'), + ('admin', 'users:update:write', 'GRANT', 'DEPT'), + + ('common', 'auth:me:read', 'GRANT', 'SELF'), + ('common', 'documents:upload:write', 'GRANT', 'SELF'), + ('common', 'documents:list:read', 'GRANT', 'SELF'), + ('common', 'documents:detail:read', 'GRANT', 'SELF'), + ('common', 'documents:history:read', 'GRANT', 'SELF'), + ('common', 'audit:run:execute', 'GRANT', 'SELF'), + ('common', 'audit:status:read', 'GRANT', 'SELF'), + ('common', 'audit:result:read', 'GRANT', 'SELF'), + ('common', 'rules:list:read', 'GRANT', 'DEPT'), + ('common', 'rules:version_list:read', 'GRANT', 'DEPT'), + ('common', 'rules:content:read', 'GRANT', 'DEPT'), + ('common', 'rules:binding_list:read', 'GRANT', 'DEPT') +) +INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at) +SELECT rm.id, pm.id, s.grant_type, s.data_scope, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM seed s +JOIN role_map rm ON rm.role_key = s.role_key +JOIN perm_map pm ON pm.permission_key = s.permission_key +ON CONFLICT (role_id, permission_id) DO UPDATE SET + grant_type = EXCLUDED.grant_type, + data_scope = EXCLUDED.data_scope, + updated_at = CURRENT_TIMESTAMP; + +COMMIT;