You've already forked DataMate
feat(user-permission): 实现用户权限体系并修复所有问题
- 数据库层面: - 创建 RBAC 核心表(角色、菜单权限) - 扩展现有表支持数据共享 - 初始化基础数据 - 后端层面: - 实现 UserContext 用户上下文管理 - 实现数据集访问权限服务 - 实现菜单权限服务 - 添加数据集共享功能 - 修复前端命名不匹配问题(snake_case vs camelCase) - 修复请求头不匹配问题(X-User-Roles vs X-Role-Codes) - 修复 Mapper 方法未实现问题 - 修复共享设置持久化缺失问题 - 前端层面: - 创建菜单权限工具 - 更新 Redux Store 支持菜单过滤 - 创建数据集共享设置组件 - 添加用户信息到请求头 - 实现 Token 刷新逻辑 - 数据隔离: - 实现 MyBatis 查询权限检查 - 实现数据文件访问控制 参考: - Codex 生成的实施方案 - kimI-cli 实施结果 - Codex Review 审核报告 修复的问题: 1. 前端命名不匹配(is_shared -> isShared, shared_with -> sharedWith) 2. 请求头不匹配(X-User-Roles -> X-Role-Codes) 3. Mapper 方法未实现(添加 findFilesWithAccessCheck 等方法声明) 4. 共享设置持久化缺失(添加 isShared 和 sharedWith 字段到 UpdateDatasetRequest) 5. 用户上下文加载问题(实现 Token 刷新逻辑)
This commit is contained in:
864
DataMate-user-permission-implementation.md
Normal file
864
DataMate-user-permission-implementation.md
Normal file
@@ -0,0 +1,864 @@
|
||||
# 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<UserContext> 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<Dataset> getAccessibleDatasets(String userId) {
|
||||
return datasetRepository.selectList(new LambdaQueryWrapper<Dataset>()
|
||||
.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<String> 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<String> getAccessibleMenus(String userId) {
|
||||
// 获取用户角色
|
||||
Set<String> roleCodes = roleRepository.findByUserId(userId)
|
||||
.stream()
|
||||
.map(Role::getCode)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 获取角色对应的菜单权限
|
||||
return roleMenuPermissionRepository.findMenuCodesByRoleCodes(roleCodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有菜单访问权限
|
||||
*/
|
||||
public boolean hasMenuAccess(String userId, String menuCode) {
|
||||
Set<String> 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<Set<String>> getAccessibleMenus(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
Set<String> 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<Void> 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<List<Dataset>> getDatasets(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
// 使用 DatasetAccessService 获取可访问的数据集
|
||||
List<Dataset> 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 (
|
||||
<Menu theme="dark" mode="inline">
|
||||
{filteredMenus.map(menu => (
|
||||
<Menu.Item key={menu.menuCode} icon={<Icon icon={menu.icon} />}>
|
||||
{menu.parentCode ? (
|
||||
<SubMenu title={menu.menuName}>
|
||||
{getFilteredMenus(accessibleMenus)
|
||||
.filter(m => m.parentCode === menu.menuCode)
|
||||
.map(subMenu => (
|
||||
<Menu.Item key={subMenu.menuCode}>{subMenu.menuName}</Menu.Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
) : (
|
||||
<a href={menu.menuPath}>{menu.menuName}</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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<string[]>(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 && (
|
||||
<Button onClick={() => setIsOpen(true)}>共享设置</Button>
|
||||
)}
|
||||
|
||||
<Modal title="共享设置" open={isOpen} onCancel={() => setIsOpen(false)} footer={
|
||||
<Button type="primary" onClick={handleSave}>保存</Button>
|
||||
}>
|
||||
<Form>
|
||||
<Form.Item label="允许共享">
|
||||
<Switch checked={isShared} onChange={setIsShared} />
|
||||
</Form.Item>
|
||||
|
||||
{isShared && (
|
||||
<Form.Item label="共享给">
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={sharedWith}
|
||||
onChange={setSharedWith}
|
||||
placeholder="选择用户"
|
||||
options={[]} // 从用户列表获取
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 dataset.api.ts 更新
|
||||
|
||||
```typescript
|
||||
// frontend/src/pages/DataManagement/dataset.api.ts
|
||||
export const shareDataset = async (
|
||||
datasetId: string,
|
||||
isShared: boolean,
|
||||
sharedWith: string[]
|
||||
) => {
|
||||
return request.post(`/api/datasets/${datasetId}/share`, {
|
||||
is_shared: isShared,
|
||||
shared_with: sharedWith,
|
||||
});
|
||||
};
|
||||
|
||||
// 新增:获取可访问的菜单
|
||||
export const getAccessibleMenus = async () => {
|
||||
return request.get('/api/menu-permissions/accessible-menus');
|
||||
};
|
||||
```
|
||||
|
||||
### 3.5 Redux Store 更新
|
||||
|
||||
```typescript
|
||||
// frontend/src/store/slices/authSlice.ts
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
accessibleMenus: string[]; // 新增
|
||||
}
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
loginSuccess: (state, action) => {
|
||||
state.token = action.payload.token;
|
||||
state.user = action.payload.user;
|
||||
state.accessibleMenus = action.payload.accessibleMenus; // 新增
|
||||
state.isAuthenticated = true;
|
||||
},
|
||||
logout: (state) => {
|
||||
state.token = null;
|
||||
state.user = null;
|
||||
state.accessibleMenus = []; // 新增
|
||||
state.isAuthenticated = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3.6 request.ts 更新
|
||||
|
||||
```typescript
|
||||
// frontend/src/utils/request.ts
|
||||
import { UserContextHolder } from '@/utils/userContext'; // 需要实现
|
||||
|
||||
// 修改 request 拦截器,添加用户信息到 headers
|
||||
instance.interceptors.request.use(config => {
|
||||
const state = store.getState();
|
||||
const { user } = state.auth;
|
||||
|
||||
if (user) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers['X-User-Id'] = user.id;
|
||||
config.headers['X-User-Name'] = user.username;
|
||||
config.headers['X-User-Roles'] = user.roles?.join(',') || '';
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### 3.7 Sidebar.tsx 更新
|
||||
|
||||
```typescript
|
||||
// frontend/src/pages/Layout/Sidebar.tsx
|
||||
import { menuItems } from '@/utils/menu';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getFilteredMenus } from '@/utils/menu';
|
||||
|
||||
export default function Sidebar() {
|
||||
const accessibleMenus = useSelector((state: RootState) => state.auth.accessibleMenus);
|
||||
const filteredMenus = getFilteredMenus(accessibleMenus);
|
||||
|
||||
return (
|
||||
<Sider width={256} theme="dark">
|
||||
<Menu theme="dark" mode="inline">
|
||||
{filteredMenus.map(menu => (
|
||||
<Menu.Item key={menu.menuCode} icon={<Icon icon={menu.icon} />}>
|
||||
{menu.parentCode ? (
|
||||
<SubMenu title={menu.menuName}>
|
||||
{getFilteredMenus(accessibleMenus)
|
||||
.filter(m => m.parentCode === menu.menuCode)
|
||||
.map(subMenu => (
|
||||
<Menu.Item key={subMenu.menuCode}>{subMenu.menuName}</Menu.Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
) : (
|
||||
<a href={menu.menuPath}>{menu.menuName}</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
</Sider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据隔离的具体实现
|
||||
|
||||
### 4.1 MyBatis XML 更新
|
||||
|
||||
```xml
|
||||
<!-- backend/services/data-management-service/src/main/resources/mappers/DatasetMapper.xml -->
|
||||
<!-- 添加查询方法 -->
|
||||
<select id="findAccessibleDatasets" resultType="Dataset">
|
||||
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
|
||||
</select>
|
||||
|
||||
<select id="findByIdWithAccessCheck" resultType="Dataset">
|
||||
SELECT d.*
|
||||
FROM t_dm_datasets d
|
||||
WHERE d.id = #{datasetId}
|
||||
AND (
|
||||
d.owner_id = #{userId}
|
||||
OR (d.is_shared = 1 AND JSON_CONTAINS(d.shared_with, JSON_QUOTE(#{userId})))
|
||||
)
|
||||
</select>
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- backend/services/data-management-service/src/main/resources/mappers/DatasetFileMapper.xml -->
|
||||
<select id="findFilesWithAccessCheck" resultType="DatasetFile">
|
||||
SELECT f.*
|
||||
FROM t_dm_dataset_files f
|
||||
JOIN t_dm_datasets d ON d.id = f.dataset_id
|
||||
WHERE f.dataset_id = #{datasetId}
|
||||
AND (
|
||||
d.owner_id = #{userId}
|
||||
OR (d.is_shared = 1 AND JSON_CONTAINS(d.shared_with, JSON_QUOTE(#{userId})))
|
||||
)
|
||||
</select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 (集成)
|
||||
Reference in New Issue
Block a user