gongxiangp2/docs/department-isolation-recommendations.md
chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

9.9 KiB
Raw Blame History

部门隔离方案优化建议

📋 当前实现分析

现有功能

  1. 部门权限配置

    • 通过 DepartmentMaterialPermission 表配置部门对素材类型的权限
    • 支持:canView(可见)、canDownload(可下载)、canShare(可分享)
    • 支持权限继承(从父部门继承)
    • 支持权限关联(关联到其他部门)
  2. 部门层级结构

    • 支持父子部门关系(parentId)
    • 支持部门层级(level)
    • 支持部门管理员(managerId)
  3. 权限检查

    • 基于素材类型(materialType)的权限过滤
    • 用户只能看到有权限的素材类型

当前限制

  1. 数据隔离粒度

    • 当前是基于素材类型的隔离,不是基于素材本身的隔离
    • 同一素材类型下,所有有权限的部门都能看到所有素材
  2. 素材归属

    • 素材表(Material)没有直接的部门字段
    • 只能通过 userId → User.departmentId 间接关联
  3. 跨部门访问

    • 没有明确的跨部门访问控制
    • 共享功能可能绕过权限限制

🎯 优化建议

方案一:基于素材归属的严格隔离(推荐)

核心思想:每个素材明确归属于一个部门,用户只能看到自己部门(及有权限的子部门)的素材。

1.1 Schema 改进

model Material {
  // ... 现有字段 ...
  
  // 新增:素材归属部门(明确归属)
  ownerDepartmentId String?
  ownerDepartment   Department? @relation("MaterialOwnerDepartment", fields: [ownerDepartmentId], references: [id])
  
  // 新增:可访问部门(白名单,可选)
  // 如果为空,则只有归属部门及其子部门可访问
  accessibleDepartmentIds String[] // 部门ID数组(PostgreSQL 数组)
  
  @@index([ownerDepartmentId])
  @@index([accessibleDepartmentIds])
}

model Department {
  // ... 现有字段 ...
  
  // 新增关联
  ownedMaterials Material[] @relation("MaterialOwnerDepartment")
}

1.2 权限检查逻辑

// 伪代码
async function canUserAccessMaterial(userId: string, materialId: string): Promise<boolean> {
  const user = await getUser(userId);
  const material = await getMaterial(materialId);
  
  // 1. 超级管理员可以访问所有
  if (await isSuperAdmin(userId)) return true;
  
  // 2. 素材创建者可以访问自己的素材
  if (material.userId === userId) return true;
  
  // 3. 检查素材归属部门
  if (!material.ownerDepartmentId) {
    // 没有归属部门,使用创建者的部门
    const creatorDept = await getUserDepartment(material.userId);
    material.ownerDepartmentId = creatorDept?.departmentId;
  }
  
  // 4. 用户部门是否在可访问范围内
  const userDeptId = user.departmentId;
  if (!userDeptId) return false;
  
  // 5. 检查用户部门是否是归属部门或其子部门
  if (isDepartmentOrDescendant(material.ownerDepartmentId, userDeptId)) {
    // 6. 检查素材类型的权限
    return await checkMaterialTypePermission(userDeptId, material.materialType);
  }
  
  // 7. 检查是否在白名单中
  if (material.accessibleDepartmentIds?.includes(userDeptId)) {
    return await checkMaterialTypePermission(userDeptId, material.materialType);
  }
  
  return false;
}

1.3 查询过滤

// 在 GET /api/materials 中
async function getMaterialsForUser(userId: string, filters: any) {
  const user = await getUser(userId);
  
  if (await isSuperAdmin(userId)) {
    // 超级管理员看到所有
    return getAllMaterials(filters);
  }
  
  const userDeptId = user.departmentId;
  if (!userDeptId) return [];
  
  // 获取用户部门及其所有子部门
  const accessibleDeptIds = await getDepartmentAndDescendants(userDeptId);
  
  const where = {
    ...filters,
    OR: [
      // 1. 素材归属于用户部门或其子部门
      { ownerDepartmentId: { in: accessibleDeptIds } },
      // 2. 素材在白名单中包含用户部门
      { accessibleDepartmentIds: { has: userDeptId } },
      // 3. 用户自己创建的素材(兜底)
      { userId: userId }
    ],
    // 同时检查素材类型权限
    materialType: { in: await getUserViewableMaterialTypes(userDeptId) }
  };
  
  return prisma.material.findMany({ where });
}

优点

  • ✅ 数据隔离清晰,每个素材有明确的归属
  • ✅ 支持跨部门共享(通过白名单)
  • ✅ 支持部门层级权限继承
  • ✅ 性能好(有索引支持)

缺点

  • ⚠️ 需要数据迁移(为现有素材设置 ownerDepartmentId)
  • ⚠️ Schema 变更需要谨慎处理

方案二:基于用户部门的动态隔离

核心思想:保持现有 Schema,通过查询时动态过滤来实现隔离。

2.1 查询优化

// 在 GET /api/materials 中
async function getMaterialsForUser(userId: string, filters: any) {
  const user = await getUserWithDepartment(userId);
  
  if (await isSuperAdmin(userId)) {
    return getAllMaterials(filters);
  }
  
  const userDeptId = user.departmentId;
  if (!userDeptId) return [];
  
  // 获取用户部门及其子部门的用户ID
  const accessibleDeptIds = await getDepartmentAndDescendants(userDeptId);
  const creatorUserIds = await prisma.user.findMany({
    where: { departmentId: { in: accessibleDeptIds } },
    select: { id: true }
  }).then(users => users.map(u => u.id));
  
  const where = {
    ...filters,
    userId: { in: creatorUserIds }, // 只显示这些用户创建的素材
    materialType: { in: await getUserViewableMaterialTypes(userDeptId) }
  };
  
  return prisma.material.findMany({ where });
}

优点

  • ✅ 无需 Schema 变更
  • ✅ 实现简单,改动小
  • ✅ 向后兼容

缺点

  • ⚠️ 查询性能可能较差(需要通过用户表关联)
  • ⚠️ 跨部门共享不够灵活
  • ⚠️ 逻辑不够直观

方案三:混合方案(推荐用于渐进式迁移)

核心思想:先使用方案二快速实现,逐步迁移到方案一。

3.1 迁移步骤

  1. 阶段一:实施方案二(基于用户部门的动态过滤)

    • 无需 Schema 变更
    • 快速上线
    • 验证隔离效果
  2. 阶段二:添加 ownerDepartmentId 字段(可选)

    • 新上传的素材自动设置
    • 现有素材逐步迁移
    • 保持向后兼容
  3. 阶段三:完全迁移到方案一

    • 所有素材都有 ownerDepartmentId
    • 优化查询性能
    • 支持更灵活的权限控制

🔐 权限控制细节

1. 上传时的部门归属

// 上传素材时
async function createMaterial(userId: string, materialData: any) {
  const user = await getUser(userId);
  
  return prisma.material.create({
    data: {
      ...materialData,
      userId,
      // 自动设置归属部门
      ownerDepartmentId: user.departmentId || null,
    }
  });
}

2. 跨部门共享

// 共享素材到其他部门
async function shareMaterialToDepartments(
  materialId: string,
  departmentIds: string[],
  userId: string // 操作人
) {
  // 权限检查:只有素材创建者或部门管理员可以共享
  const material = await getMaterial(materialId);
  const user = await getUser(userId);
  
  if (material.userId !== userId && !await isDepartmentManager(userId, material.ownerDepartmentId)) {
    throw new Error('无权限共享此素材');
  }
  
  // 更新可访问部门列表
  return prisma.material.update({
    where: { id: materialId },
    data: {
      accessibleDepartmentIds: departmentIds
    }
  });
}

3. 下载权限检查

// 下载时额外检查下载权限
async function canUserDownloadMaterial(userId: string, materialId: string): Promise<boolean> {
  // 1. 先检查访问权限
  if (!await canUserAccessMaterial(userId, materialId)) {
    return false;
  }
  
  // 2. 检查下载权限(素材类型级别)
  const material = await getMaterial(materialId);
  const user = await getUser(userId);
  const userDeptId = user.departmentId;
  
  const permission = await getDepartmentPermission(userDeptId, material.materialType);
  return permission?.canDownload ?? false;
}

📊 数据迁移建议

迁移脚本

// scripts/migrate-material-department.ts
async function migrateMaterialDepartments() {
  const materials = await prisma.material.findMany({
    include: { user: { select: { departmentId: true } } }
  });
  
  for (const material of materials) {
    if (!material.ownerDepartmentId && material.user.departmentId) {
      await prisma.material.update({
        where: { id: material.id },
        data: { ownerDepartmentId: material.user.departmentId }
      });
    }
  }
}

🎨 UI/UX 建议

1. 部门筛选器

在素材列表页面添加部门筛选:

  • 显示用户有权限的部门
  • 默认显示本部门及子部门
  • 支持多选

2. 素材详情页

显示素材归属信息:

  • 所属部门
  • 可访问部门(如果已共享)
  • 共享按钮(如果有权限)

3. 上传表单

自动填充部门信息:

  • 显示当前用户部门
  • 可选:允许选择归属部门(需权限)

⚠️ 注意事项

  1. 性能优化

    • 为 ownerDepartmentId 和 accessibleDepartmentIds 添加索引
    • 考虑使用 Redis 缓存部门层级关系
    • 批量查询时使用 include 避免 N+1 问题
  2. 数据一致性

    • 用户转部门时,需要考虑其创建的素材归属
    • 部门删除时,需要处理素材归属(建议软删除)
  3. 权限边界

    • 明确区分"访问权限"和"下载权限"
    • 共享链接可能需要特殊处理(绕过部门隔离?)
  4. 审计日志

    • 记录跨部门访问
    • 记录权限变更操作

📝 总结

推荐方案:方案三(混合方案,渐进式迁移)

  1. 短期:实施方案二,快速实现部门隔离
  2. 中期:添加 ownerDepartmentId 字段,逐步迁移
  3. 长期:完善权限控制,支持更灵活的数据共享

这样可以在不影响现有功能的情况下,逐步完善部门隔离机制。