# DataMate 用户权限体系 - 具体实现方案 ## 用户选择 1. **多租户架构**:否,只用 owner_id(不使用 tenant_id) 2. **权限粒度**:菜单级(粗粒度,控制页面访问) 3. **资源共享**:允许共享,但由创建人控制是否允许共享 --- ## 1. 数据库层面的具体实现 ### 1.1 RBAC 核心表 #### 菜单权限表 ```sql CREATE TABLE t_sys_menu_permissions ( id VARCHAR(36) PRIMARY KEY COMMENT 'UUID', menu_code VARCHAR(50) NOT NULL UNIQUE COMMENT '菜单编码,如 DATASET_MANAGEMENT', menu_name VARCHAR(100) NOT NULL COMMENT '菜单名称', menu_path VARCHAR(200) COMMENT '菜单路径,如 /data/management', parent_code VARCHAR(50) COMMENT '父菜单编码', icon VARCHAR(50) COMMENT '菜单图标', sort_order INT DEFAULT 0 COMMENT '排序', description VARCHAR(500) COMMENT '描述', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT='菜单权限表'; -- 初始化菜单数据 INSERT INTO t_sys_menu_permissions (id, menu_code, menu_name, menu_path, parent_code, icon, sort_order) VALUES ('1', 'HOME', '首页', '/', NULL, 'Home', 1), ('2', 'DATASET_MANAGEMENT', '数据管理', '/data/management', NULL, 'Database', 2), ('3', 'DATASET_CREATE', '数据管理-创建', '/data/management/create', 'DATASET_MANAGEMENT', 'Plus', 3), ('4', 'DATASET_VIEW', '数据管理-查看', '/data/management/view', 'DATASET_MANAGEMENT', 'Eye', 4), ('5', 'DATA_ANNOTATION', '数据标注', '/annotation', NULL, 'PenTool', 5), ('6', 'ANNOTATION_CREATE', '数据标注-创建', '/annotation/create', 'DATA_ANNOTATION', 'Plus', 6), ('7', 'OPERATOR_MARKET', '操作符市场', '/operator/market', NULL, 'ShoppingCart', 7); ``` #### 角色菜单权限关联表 ```sql CREATE TABLE t_sys_role_menu_permissions ( id VARCHAR(36) PRIMARY KEY COMMENT 'UUID', role_id VARCHAR(36) NOT NULL COMMENT '角色ID', menu_code VARCHAR(50) NOT NULL COMMENT '菜单编码', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_role_menu (role_id, menu_code), FOREIGN KEY fk_role (role_id) REFERENCES t_sys_roles(id) ON DELETE CASCADE, FOREIGN KEY fk_menu (menu_code) REFERENCES t_sys_menu_permissions(menu_code) ON DELETE CASCADE ) COMMENT='角色菜单权限关联表'; ``` #### 角色表(更新) ```sql CREATE TABLE IF NOT EXISTS t_sys_roles ( id VARCHAR(36) PRIMARY KEY, code VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description VARCHAR(500), is_system BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT='角色表'; -- 初始化角色 INSERT INTO t_sys_roles (id, code, name, description, is_system) VALUES ('R1', 'ADMIN', '系统管理员', '拥有所有权限', TRUE), ('R2', 'USER', '普通用户', '基础权限', FALSE); ``` ### 1.2 数据集表扩展(支持共享) ```sql -- 添加共享相关字段 ALTER TABLE t_dm_datasets ADD COLUMN owner_id VARCHAR(36) COMMENT '数据集所有者(用户ID)'; ALTER TABLE t_dm_datasets ADD COLUMN is_shared BOOLEAN DEFAULT FALSE COMMENT '是否允许共享'; ALTER TABLE t_dm_datasets ADD COLUMN shared_with JSON COMMENT '可共享的用户ID列表,JSON数组格式'; -- 迁移现有数据(设置 owner_id) UPDATE t_dm_datasets SET owner_id = created_by WHERE owner_id IS NULL; -- 创建索引 CREATE INDEX idx_owner_id ON t_dm_datasets(owner_id); CREATE INDEX idx_is_shared ON t_dm_datasets(is_shared); ``` ### 1.3 数据迁移脚本 ```sql -- 20260204_add_user_permissions.sql USE datamate; -- Step 1: 创建权限表 -- (上面的表定义) -- Step 2: 初始化管理员权限 INSERT INTO t_sys_role_menu_permissions (id, role_id, menu_code) SELECT REPLACE(UUID(), '-', '-'), r.id, m.menu_code FROM t_sys_roles r CROSS JOIN t_sys_menu_permissions m WHERE r.code = 'ADMIN'; -- Step 3: 初始化普通用户权限 INSERT INTO t_sys_role_menu_permissions (id, role_id, menu_code) SELECT REPLACE(UUID(), '-', '-'), r.id, m.menu_code FROM t_sys_roles r CROSS JOIN t_sys_menu_permissions m WHERE r.code = 'USER' AND m.menu_code IN ('HOME', 'DATASET_VIEW', 'DATA_ANNOTATION'); ``` --- ## 2. 后端层面的具体实现 ### 2.1 UserContext 拦截器 ```java // backend/shared/domain-common/src/main/java/com/datamate/common/security/UserContext.java package com.datamate.common.security; import lombok.AllArgsConstructor; import lombok.Data; /** * 用户上下文信息 */ @Data @AllArgsConstructor public class UserContext { private String userId; private String username; private String[] roleCodes; } ``` ```java // backend/shared/domain-common/src/main/java/com/datamate/common/security/UserContextHolder.java package com.datamate.common.security; /** * 用户上下文持有者(ThreadLocal) */ public class UserContextHolder { private static final ThreadLocal CONTEXT = new ThreadLocal<>(); public static void setContext(UserContext context) { CONTEXT.set(context); } public static UserContext getContext() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } } ``` ```java // backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/web/UserContextInterceptor.java package com.datamate.common.infrastructure.web; import com.datamate.common.security.UserContext; import com.datamate.common.security.UserContextHolder; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @Component public class UserContextInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId = request.getHeader("X-User-Id"); String username = request.getHeader("X-User-Name"); String roles = request.getHeader("X-User-Roles"); if (userId != null) { UserContext context = new UserContext(userId, username, roles.split(",")); UserContextHolder.setContext(context); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContextHolder.clear(); } } ``` ```java // backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/web/WebMvcConfig.java package com.datamate.common.infrastructure.web; import com.datamate.common.infrastructure.web.UserContextInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private UserContextInterceptor userContextInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(userContextInterceptor) .addPathPatterns("/**") .excludePathPatterns("/auth/**"); } } ``` ### 2.2 数据共享服务 ```java // backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetAccessService.java package com.datamate.datamanagement.application; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.datamate.datamanagement.domain.model.dataset.Dataset; import com.datamate.datamanagement.infrastructure.persistence.repository.DatasetRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class DatasetAccessService { private final DatasetRepository datasetRepository; /** * 检查用户是否可以访问数据集 */ public boolean canAccessDataset(String userId, String datasetId) { Dataset dataset = datasetRepository.selectById(datasetId); if (dataset == null) { return false; } // 自己创建的,可以直接访问 if (userId.equals(dataset.getOwnerId())) { return true; } // 共享的,检查是否在共享列表中 if (Boolean.TRUE.equals(dataset.getIsShared())) { return isUserInSharedList(userId, dataset.getSharedWith()); } return false; } /** * 检查用户是否在共享列表中 */ private boolean isUserInSharedList(String userId, String sharedWith) { if (sharedWith == null || sharedWith.isEmpty()) { return false; } // 解析 JSON 数组(sharedWith 格式为 ["user1", "user2", ...]) return sharedWith.contains(userId); } /** * 获取用户可访问的数据集列表 */ public List getAccessibleDatasets(String userId) { return datasetRepository.selectList(new LambdaQueryWrapper() .eq(Dataset::getOwnerId, userId) .or() .eq(Dataset::getIsShared, false) .and(wrapper -> wrapper .eq(Dataset::getIsShared, true) .apply("JSON_CONTAINS(shared_with, {0})", userId)) ) .orderByAsc(Dataset::getCreatedAt)); } } ``` ```java // backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/ShareDatasetRequest.java package com.datamate.datamanagement.interfaces.dto; import lombok.Data; import java.util.List; @Data public class ShareDatasetRequest { private Boolean isShared; private List sharedWith; } ``` ### 2.3 菜单权限服务 ```java // backend/services/main-application/src/main/java/com/datamate/main/application/MenuPermissionService.java package com.datamate.main.application; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.datamate.common.domain.model.role.Role; import com.datamate.common.infrastructure.persistence.repository.RoleRepository; import com.datamate.common.infrastructure.persistence.repository.RoleMenuPermissionRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.Set; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class MenuPermissionService { private final RoleRepository roleRepository; private final RoleMenuPermissionRepository roleMenuPermissionRepository; /** * 获取用户可访问的菜单列表 */ public Set getAccessibleMenus(String userId) { // 获取用户角色 Set roleCodes = roleRepository.findByUserId(userId) .stream() .map(Role::getCode) .collect(Collectors.toSet()); // 获取角色对应的菜单权限 return roleMenuPermissionRepository.findMenuCodesByRoleCodes(roleCodes); } /** * 检查用户是否有菜单访问权限 */ public boolean hasMenuAccess(String userId, String menuCode) { Set accessibleMenus = getAccessibleMenus(userId); return accessibleMenus.contains(menuCode); } } ``` ```java // backend/services/main-application/src/main/java/com/datamate/main/interfaces/rest/MenuPermissionController.java package com.datamate.main.interfaces.rest; import com.datamate.main.application.MenuPermissionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Set; @RestController @RequestMapping("/api/menu-permissions") @RequiredArgsConstructor public class MenuPermissionController { private final MenuPermissionService menuPermissionService; @GetMapping("/accessible-menus") public ResponseEntity> getAccessibleMenus( @RequestHeader("X-User-Id") String userId) { Set menus = menuPermissionService.getAccessibleMenus(userId); return ResponseEntity.ok(menus); } } ``` ### 2.4 DatasetController 更新 ```java // backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/DatasetController.java // 添加共享接口 @PostMapping("/{id}/share") public ResponseEntity shareDataset( @PathVariable String id, @RequestBody ShareDatasetRequest request, @RequestHeader("X-User-Id") String userId) { Dataset dataset = datasetService.findById(id); if (dataset == null) { return ResponseEntity.notFound().build(); } // 验证权限:只有所有者可以修改共享设置 if (!userId.equals(dataset.getOwnerId())) { return ResponseEntity.status(403).build(); } dataset.setIsShared(request.getIsShared()); dataset.setSharedWith(request.getSharedWith() != null ? JsonUtil.toJson(request.getSharedWith()) : null); datasetService.updateById(dataset); return ResponseEntity.ok().build(); } // 修改查询接口,添加权限检查 @GetMapping public ResponseEntity> getDatasets( @RequestHeader("X-User-Id") String userId) { // 使用 DatasetAccessService 获取可访问的数据集 List datasets = datasetAccessService.getAccessibleDatasets(userId); return ResponseEntity.ok(datasets); } ``` --- ## 3. 前端层面的具体实现 ### 3.1 menu.ts 工具 ```typescript // frontend/src/utils/menu.ts export interface MenuItem { menuCode: string; menuName: string; menuPath: string; icon?: string; parentCode?: string; } // 定义菜单项 export const menuItems: MenuItem[] = [ { menuCode: 'HOME', menuName: '首页', menuPath: '/', icon: 'Home', }, { menuCode: 'DATASET_MANAGEMENT', menuName: '数据管理', menuPath: '/data/management', icon: 'Database', }, { menuCode: 'DATASET_CREATE', menuName: '数据管理-创建', menuPath: '/data/management/create', parentCode: 'DATASET_MANAGEMENT', icon: 'Plus', }, { menuCode: 'DATASET_VIEW', menuName: '数据管理-查看', menuPath: '/data/management/view', parentCode: 'DATASET_MANAGEMENT', icon: 'Eye', }, { menuCode: 'DATA_ANNOTATION', menuName: '数据标注', menuPath: '/annotation', icon: 'PenTool', }, { menuCode: 'ANNOTATION_CREATE', menuName: '数据标注-创建', menuPath: '/annotation/create', parentCode: 'DATA_ANNOTATION', icon: 'Plus', }, { menuCode: 'OPERATOR_MARKET', menuName: '操作符市场', menuPath: '/operator/market', icon: 'ShoppingCart', }, ]; /** * 过滤菜单(基于权限) */ export const getFilteredMenus = (accessibleMenus: string[]) => { return menuItems.filter(item => accessibleMenus.includes(item.menuCode)); }; /** * 查找菜单 */ export const findMenuItem = (menuCode: string) => { return menuItems.find(item => item.menuCode === menuCode); }; ``` ### 3.2 menu.tsx 菜单组件 ```typescript // frontend/src/pages/Layout/menu.tsx import { menuItems, getFilteredMenus } from '@/utils/menu'; import { useSelector } from 'react-redux'; export default function AppMenu() { const accessibleMenus = useSelector((state: RootState) => state.auth.accessibleMenus); const filteredMenus = getFilteredMenus(accessibleMenus); return ( {filteredMenus.map(menu => ( }> {menu.parentCode ? ( {getFilteredMenus(accessibleMenus) .filter(m => m.parentCode === menu.menuCode) .map(subMenu => ( {subMenu.menuName} ))} ) : ( {menu.menuName} )} ))} ); } ``` ### 3.3 ShareSettings.tsx 共享设置组件 ```typescript // frontend/src/pages/DataManagement/Detail/components/ShareSettings.tsx import { useState } from 'react'; import { Modal, Form, Switch, Select, Button, message } from 'antd'; import { shareDataset } from '@/pages/DataManagement/dataset.api'; interface ShareSettingsProps { datasetId: string; ownerId: string; currentUserId: string; isShared: boolean; sharedWith: string[]; onSuccess: () => void; } export default function ShareSettings({ datasetId, ownerId, currentUserId, isShared: isSharedProp, sharedWith: sharedWithProp, onSuccess, }: ShareSettingsProps) { const [isOpen, setIsOpen] = useState(false); const [isShared, setIsShared] = useState(isSharedProp); const [sharedWith, setSharedWith] = useState(sharedWithProp || []); const handleSave = async () => { try { await shareDataset(datasetId, isShared, sharedWith); message.success('共享设置已更新'); setIsOpen(false); onSuccess(); } catch (error) { message.error('更新共享设置失败'); } }; // 只有所有者可以修改共享设置 const canEdit = currentUserId === ownerId; return ( <> {canEdit && ( )} setIsOpen(false)} footer={ }>
{isShared && ( SELECT d.id, d.parent_dataset_id, d.name, d.description, d.dataset_type, d.category, d.path, d.format, d.schema_info, d.size_bytes, d.file_count, d.record_count, d.retention_days, d.tags, d.metadata, d.status, d.is_public, d.is_featured, d.version, d.created_at, d.updated_at, d.created_by, d.updated_by, d.owner_id, d.is_shared, d.shared_with, CASE WHEN d.owner_id = #{userId} THEN 1 ELSE 0 END AS is_owner FROM t_dm_datasets d WHERE d.owner_id = #{userId} OR ( d.is_shared = 1 AND JSON_CONTAINS(d.shared_with, JSON_QUOTE(#{userId})) ) ORDER BY d.created_at DESC ``` ```xml ``` --- ## 5. 实施顺序建议 ### Phase 1:数据库迁移(1-2 天) 1. 执行数据库迁移脚本 `20260204_add_user_permissions.sql` 2. 验证表结构是否正确创建 3. 验证数据是否正确迁移 ### Phase 2:后端基础(3-5 天) 1. 创建 UserContext 相关类 2. 创建 UserContextInterceptor 拦截器 3. 配置 WebMvcConfig 4. 测试拦截器是否正常工作 ### Phase 3:后端服务(3-5 天) 1. 创建 DatasetAccessService 2. 创建 MenuPermissionService 3. 更新 DatasetController 添加共享接口 4. 创建 MenuPermissionController 5. 单元测试和集成测试 ### Phase 4:前端基础(2-3 天) 1. 创建 menu.ts 工具 2. 更新 Redux Store(添加 accessibleMenus) 3. 更新 request.ts(添加用户 headers) 4. 测试菜单过滤逻辑 ### Phase 5:前端 UI(2-3 天) 1. 创建 ShareSettings 组件 2. 更新 Sidebar 组件 3. 更新 dataset.api.ts 4. 集成到数据集详情页 5. UI 测试 ### Phase 6:数据隔离实现(2-3 天) 1. 更新 MyBatis XML 映射文件 2. 更新 Repository 查询方法 3. 测试数据隔离是否正确 4. 测试共享功能 --- ## 总结 ### 需要创建/修改的文件(共 20+ 个) **后端(11 个)**: 1. backend/shared/domain-common/src/main/java/com/datamate/common/security/UserContext.java 2. backend/shared/domain-common/src/main/java/com/datamate/common/security/UserContextHolder.java 3. backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/web/UserContextInterceptor.java 4. backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/web/WebMvcConfig.java 5. backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetAccessService.java 6. backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/ShareDatasetRequest.java 7. backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/DatasetController.java (更新) 8. backend/services/main-application/src/main/java/com/datamate/main/application/MenuPermissionService.java 9. backend/services/main-application/src/main/java/com/datamate/main/interfaces/rest/MenuPermissionController.java 10. backend/services/main-application/src/main/resources/mappers/MenuPermissionMapper.xml 11. scripts/db/20260204_add_user_permissions.sql (新建) **前端(9 个)**: 1. frontend/src/utils/menu.ts (新建) 2. frontend/src/pages/Layout/menu.tsx (新建/更新) 3. frontend/src/pages/Layout/Sidebar.tsx (更新) 4. frontend/src/pages/DataManagement/Detail/components/ShareSettings.tsx (新建) 5. frontend/src/pages/DataManagement/dataset.api.ts (更新) 6. frontend/src/pages/DataManagement/dataset.model.ts (更新) 7. frontend/src/store/slices/authSlice.ts (更新) 8. frontend/src/utils/request.ts (更新) 9. frontend/src/pages/DataManagement/Detail/components/DatasetDetail.tsx (集成)