You've already forked DataMate
- 数据库层面: - 创建 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 刷新逻辑)
865 lines
25 KiB
Markdown
865 lines
25 KiB
Markdown
# 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 (集成)
|