Files
DataMate/DataMate-user-permission-implementation.md
Jerry Yan 4765349fd7 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 刷新逻辑)
2026-02-04 05:31:26 +00:00

25 KiB

DataMate 用户权限体系 - 具体实现方案

用户选择

  1. 多租户架构:否,只用 owner_id(不使用 tenant_id)
  2. 权限粒度:菜单级(粗粒度,控制页面访问)
  3. 资源共享:允许共享,但由创建人控制是否允许共享

1. 数据库层面的具体实现

1.1 RBAC 核心表

菜单权限表

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);

角色菜单权限关联表

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='角色菜单权限关联表';

角色表(更新)

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 数据集表扩展(支持共享)

-- 添加共享相关字段
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 数据迁移脚本

-- 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 拦截器

// 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;
}
// 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();
    }
}
// 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();
    }
}
// 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 数据共享服务

// 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));
    }
}
// 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 菜单权限服务

// 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);
    }
}
// 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 更新

// 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 工具

// 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 菜单组件

// 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 共享设置组件

// 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 更新

// 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 更新

// 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 更新

// 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 更新

// 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 更新

<!-- 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>
<!-- 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 (集成)