- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
9.9 KiB
9.9 KiB
部门隔离方案优化建议
📋 当前实现分析
现有功能
-
部门权限配置
- 通过
DepartmentMaterialPermission表配置部门对素材类型的权限 - 支持:
canView(可见)、canDownload(可下载)、canShare(可分享) - 支持权限继承(从父部门继承)
- 支持权限关联(关联到其他部门)
- 通过
-
部门层级结构
- 支持父子部门关系(
parentId) - 支持部门层级(
level) - 支持部门管理员(
managerId)
- 支持父子部门关系(
-
权限检查
- 基于素材类型(
materialType)的权限过滤 - 用户只能看到有权限的素材类型
- 基于素材类型(
当前限制
-
数据隔离粒度
- 当前是基于素材类型的隔离,不是基于素材本身的隔离
- 同一素材类型下,所有有权限的部门都能看到所有素材
-
素材归属
- 素材表(
Material)没有直接的部门字段 - 只能通过
userId→User.departmentId间接关联
- 素材表(
-
跨部门访问
- 没有明确的跨部门访问控制
- 共享功能可能绕过权限限制
🎯 优化建议
方案一:基于素材归属的严格隔离(推荐)
核心思想:每个素材明确归属于一个部门,用户只能看到自己部门(及有权限的子部门)的素材。
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 迁移步骤
-
阶段一:实施方案二(基于用户部门的动态过滤)
- 无需 Schema 变更
- 快速上线
- 验证隔离效果
-
阶段二:添加
ownerDepartmentId字段(可选)- 新上传的素材自动设置
- 现有素材逐步迁移
- 保持向后兼容
-
阶段三:完全迁移到方案一
- 所有素材都有
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. 上传表单
自动填充部门信息:
- 显示当前用户部门
- 可选:允许选择归属部门(需权限)
⚠️ 注意事项
-
性能优化
- 为
ownerDepartmentId和accessibleDepartmentIds添加索引 - 考虑使用 Redis 缓存部门层级关系
- 批量查询时使用
include避免 N+1 问题
- 为
-
数据一致性
- 用户转部门时,需要考虑其创建的素材归属
- 部门删除时,需要处理素材归属(建议软删除)
-
权限边界
- 明确区分"访问权限"和"下载权限"
- 共享链接可能需要特殊处理(绕过部门隔离?)
-
审计日志
- 记录跨部门访问
- 记录权限变更操作
📝 总结
推荐方案:方案三(混合方案,渐进式迁移)
- 短期:实施方案二,快速实现部门隔离
- 中期:添加
ownerDepartmentId字段,逐步迁移 - 长期:完善权限控制,支持更灵活的数据共享
这样可以在不影响现有功能的情况下,逐步完善部门隔离机制。