From 0d3ac89f094024a52ab78306423ee8b4b7f07245 Mon Sep 17 00:00:00 2001 From: wxg0103 <727495428@qq.com> Date: Wed, 28 Oct 2026 16:14:52 +0800 Subject: [PATCH] fix: improve tree integrity check by validating parent-child tree_id consistency --- apps/folders/serializers/folder.py | 365 ++++++++++++++++++----------- 1 file changed, 223 insertions(+), 142 deletions(-) diff --git a/apps/folders/serializers/folder.py b/apps/folders/serializers/folder.py index 9d27b42a135..91dac3c7e65 100644 --- a/apps/folders/serializers/folder.py +++ b/apps/folders/serializers/folder.py @@ -2,7 +2,7 @@ import uuid_utils.compat as uuid from django.db import transaction -from django.db.models import QuerySet, Q, Func, F, TextField +from django.db.models import QuerySet, Q, Max, TextField from django.db.models.functions import Cast from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -32,12 +32,17 @@ def has_exact_permission_by_role(user_id: str, workspace_id: str, permission_id: role_permission_mapping_model = DatabaseModelManage.get_model("role_permission_mapping_model") is_x_pack_ee = workspace_user_role_mapping_model is not None and role_permission_mapping_model is not None if is_x_pack_ee: - return QuerySet(workspace_user_role_mapping_model).select_related('role', 'user').filter( - Q(role__rolepermission__permission_id=permission_id) | Q(role__internal=True), - workspace_id=workspace_id, - user_id=user_id, - role__type=role_type, - ).exists() + return ( + QuerySet(workspace_user_role_mapping_model) + .select_related("role", "user") + .filter( + Q(role__rolepermission__permission_id=permission_id) | Q(role__internal=True), + workspace_id=workspace_id, + user_id=user_id, + role__type=role_type, + ) + .exists() + ) return False @@ -97,7 +102,7 @@ def check_depth(source, parent_id, workspace_id, current_depth=0): # 验证层级深度 if depth + current_depth > FOLDER_DEPTH: - raise serializers.ValidationError(_('Folder depth cannot exceed 10000 levels')) + raise serializers.ValidationError(_("Folder depth cannot exceed 10000 levels")) def get_max_depth(current_node): @@ -111,7 +116,7 @@ def get_max_depth(current_node): return 0 # 获取最大深度 - max_level = descendants.order_by('-level').first().level + max_level = descendants.order_by("-level").first().level current_level = current_node.level max_depth = max_level - current_level @@ -119,147 +124,170 @@ def get_max_depth(current_node): def has_target_permission(workspace_id, source, user_id, target): - return QuerySet(WorkspaceUserResourcePermission).filter(workspace_id=workspace_id, user_id=user_id, - auth_target_type=source, target=target, - permission_list__contains=['MANAGE']).exists() + return ( + QuerySet(WorkspaceUserResourcePermission) + .filter( + workspace_id=workspace_id, + user_id=user_id, + auth_target_type=source, + target=target, + permission_list__contains=["MANAGE"], + ) + .exists() + ) class FolderSerializer(serializers.Serializer): - id = serializers.CharField(required=True, label=_('folder id')) - name = serializers.CharField(required=True, label=_('folder name')) - desc = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_('folder description')) - user_id = serializers.CharField(required=True, label=_('folder user id')) - workspace_id = serializers.CharField(required=False, label=_('workspace id')) - parent_id = serializers.CharField(required=False, label=_('parent id')) + id = serializers.CharField(required=True, label=_("folder id")) + name = serializers.CharField(required=True, label=_("folder name")) + desc = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("folder description")) + user_id = serializers.CharField(required=True, label=_("folder user id")) + workspace_id = serializers.CharField(required=False, label=_("workspace id")) + parent_id = serializers.CharField(required=False, label=_("parent id")) class Create(serializers.Serializer): - workspace_id = serializers.CharField(required=True, label=_('workspace id')) - user_id = serializers.UUIDField(required=True, label=_('user id')) - source = serializers.CharField(required=True, label=_('source')) + workspace_id = serializers.CharField(required=True, label=_("workspace id")) + user_id = serializers.UUIDField(required=True, label=_("user id")) + source = serializers.CharField(required=True, label=_("source")) def insert(self, instance, with_valid=True): if with_valid: self.is_valid(raise_exception=True) FolderCreateRequest(data=instance).is_valid(raise_exception=True) - workspace_id = self.data.get('workspace_id') + workspace_id = self.data.get("workspace_id") if not workspace_id: - workspace_id = 'default' - parent_id = instance.get('parent_id') + workspace_id = "default" + parent_id = instance.get("parent_id") if not parent_id: parent_id = workspace_id - name = instance.get('name') + name = instance.get("name") - Folder = get_folder_type(self.data.get('source')) # noqa + Folder = get_folder_type(self.data.get("source")) # noqa if QuerySet(Folder).filter(name=name, workspace_id=workspace_id, parent_id=parent_id).exists(): - raise AppApiException(500, _('Folder name already exists')) + raise AppApiException(500, _("Folder name already exists")) # Folder 不能超过3层 - check_depth(self.data.get('source'), parent_id, workspace_id) + check_depth(self.data.get("source"), parent_id, workspace_id) folder = Folder( id=uuid.uuid7(), - name=instance.get('name'), - desc=instance.get('desc'), - user_id=self.data.get('user_id'), + name=instance.get("name"), + desc=instance.get("desc"), + user_id=self.data.get("user_id"), workspace_id=workspace_id, - parent_id=parent_id + parent_id=parent_id, ) folder.save() - UserResourcePermissionSerializer(data={ - 'workspace_id': self.data.get('workspace_id'), - 'user_id': self.data.get('user_id'), - 'auth_target_type': self.data.get('source') - }).auth_resource(str(folder.id), is_folder=True) + UserResourcePermissionSerializer( + data={ + "workspace_id": self.data.get("workspace_id"), + "user_id": self.data.get("user_id"), + "auth_target_type": self.data.get("source"), + } + ).auth_resource(str(folder.id), is_folder=True) return FolderSerializer(folder).data class Operate(serializers.Serializer): - id = serializers.CharField(required=True, label=_('folder id')) - workspace_id = serializers.CharField(required=True, allow_null=True, allow_blank=True, label=_('workspace id')) - source = serializers.CharField(required=True, label=_('source')) - user_id = serializers.UUIDField(required=True, label=_('user id')) + id = serializers.CharField(required=True, label=_("folder id")) + workspace_id = serializers.CharField(required=True, allow_null=True, allow_blank=True, label=_("workspace id")) + source = serializers.CharField(required=True, label=_("source")) + user_id = serializers.UUIDField(required=True, label=_("user id")) def is_valid(self, *, raise_exception=False): super().is_valid(raise_exception=True) - Folder = get_folder_type(self.data.get('source')) # noqa - if Folder is None or not QuerySet(Folder).filter( - id=self.data.get('id'), workspace_id=self.data.get('workspace_id') - ).exists(): - raise serializers.ValidationError(_('Folder does not exist')) + Folder = get_folder_type(self.data.get("source")) # noqa + if ( + Folder is None + or not QuerySet(Folder) + .filter(id=self.data.get("id"), workspace_id=self.data.get("workspace_id")) + .exists() + ): + raise serializers.ValidationError(_("Folder does not exist")) @transaction.atomic def edit(self, instance): self.is_valid(raise_exception=True) - Folder = get_folder_type(self.data.get('source')) # noqa - current_id = self.data.get('id') - current_node = Folder.objects.get(id=current_id, workspace_id=self.data.get('workspace_id')) + Folder = get_folder_type(self.data.get("source")) # noqa + current_id = self.data.get("id") + current_node = Folder.objects.get(id=current_id, workspace_id=self.data.get("workspace_id")) if current_node is None: - raise serializers.ValidationError(_('Folder does not exist')) + raise serializers.ValidationError(_("Folder does not exist")) # 模块间的移动 - parent_id = instance.get('parent_id') + parent_id = instance.get("parent_id") if parent_id is None: parent_id = current_node.parent_id # 如果要修改文件夹名称,检查同级目录下是否存在同名文件夹 - new_name = instance.get('name') + new_name = instance.get("name") if new_name is not None and new_name != current_node.name: - if QuerySet(Folder).filter( - name=new_name, - parent_id=parent_id, - workspace_id=current_node.workspace_id - ).exclude(id=current_id).exists(): - raise AppApiException(500, _('Folder name already exists')) - - edit_field_list = ['name', 'desc'] - edit_dict = {field: instance.get(field) for field in edit_field_list if ( - field in instance and instance.get(field) is not None)} + if ( + QuerySet(Folder) + .filter(name=new_name, parent_id=parent_id, workspace_id=current_node.workspace_id) + .exclude(id=current_id) + .exists() + ): + raise AppApiException(500, _("Folder name already exists")) + + edit_field_list = ["name", "desc"] + edit_dict = { + field: instance.get(field) + for field in edit_field_list + if (field in instance and instance.get(field) is not None) + } QuerySet(Folder).filter(id=current_id).update(**edit_dict) current_node.refresh_from_db() - if parent_id is not None and current_id != current_node.workspace_id and current_node.parent_id != parent_id: - - source_type = self.data.get('source') - if has_target_permission(current_node.workspace_id, source_type, self.data.get('user_id'), - parent_id) or is_workspace_manage(self.data.get('user_id'), - current_node.workspace_id): + if ( + parent_id is not None + and current_id != current_node.workspace_id + and current_node.parent_id != parent_id + ): + source_type = self.data.get("source") + if has_target_permission( + current_node.workspace_id, source_type, self.data.get("user_id"), parent_id + ) or is_workspace_manage(self.data.get("user_id"), current_node.workspace_id): current_depth = get_max_depth(current_node) - check_depth(self.data.get('source'), parent_id, current_node.workspace_id, current_depth) + check_depth(self.data.get("source"), parent_id, current_node.workspace_id, current_depth) parent = Folder.objects.get(id=parent_id) - if QuerySet(Folder).filter(name=current_node.name, parent_id=parent_id, - workspace_id=current_node.workspace_id).exists(): - raise AppApiException(500, _('Folder name already exists')) + if ( + QuerySet(Folder) + .filter(name=current_node.name, parent_id=parent_id, workspace_id=current_node.workspace_id) + .exists() + ): + raise AppApiException(500, _("Folder name already exists")) current_node.parent = parent current_node.save() current_node.refresh_from_db() else: - raise AppApiException(403, _('No permission for the target folder')) + raise AppApiException(403, _("No permission for the target folder")) return self.one() def one(self): self.is_valid(raise_exception=True) - Folder = get_folder_type(self.data.get('source')) # noqa - folder = QuerySet(Folder).filter(id=self.data.get('id'), workspace_id=self.data.get('workspace_id')).first() + Folder = get_folder_type(self.data.get("source")) # noqa + folder = QuerySet(Folder).filter(id=self.data.get("id"), workspace_id=self.data.get("workspace_id")).first() return FolderSerializer(folder).data @transaction.atomic def delete(self): self.is_valid(raise_exception=True) - Folder = get_folder_type(self.data.get('source')) # noqa - Source = get_source_type(self.data.get('source')) # noqa - folder = Folder.objects.filter(id=self.data.get('id'), workspace_id=self.data.get('workspace_id')).first() + Folder = get_folder_type(self.data.get("source")) # noqa + Source = get_source_type(self.data.get("source")) # noqa + folder = Folder.objects.filter(id=self.data.get("id"), workspace_id=self.data.get("workspace_id")).first() if not folder: - raise serializers.ValidationError(_('Folder does not exist')) + raise serializers.ValidationError(_("Folder does not exist")) if folder.id == folder.workspace_id: - raise serializers.ValidationError(_('Cannot delete root folder')) + raise serializers.ValidationError(_("Cannot delete root folder")) # 工作空间管理员可以删除 - workspace_manage = is_workspace_manage(self.data.get('user_id'), self.data.get('workspace_id')) + workspace_manage = is_workspace_manage(self.data.get("user_id"), self.data.get("workspace_id")) if workspace_manage: - nodes = Folder.objects.filter(id=self.data.get('id')).get_descendants(include_self=True) + nodes = Folder.objects.filter(id=self.data.get("id")).get_descendants(include_self=True) for node in nodes: # print(node) # 删除相关的资源 @@ -268,93 +296,144 @@ def delete(self): node.delete() # 普通用户删除的文件夹内全部都得是自己有权限的资源 else: - nodes = Folder.objects.filter(id=self.data.get('id')).get_descendants(include_self=True) + nodes = Folder.objects.filter(id=self.data.get("id")).get_descendants(include_self=True) for node in nodes: # 删除相关的资源 - source_ids = (Source.objects.filter(folder_id=node.id) - .annotate(id_str=Cast('id', TextField())) - .values_list('id_str', flat=True)) + source_ids = ( + Source.objects.filter(folder_id=node.id) + .annotate(id_str=Cast("id", TextField())) + .values_list("id_str", flat=True) + ) # 检查文件夹是否存在未授权当前用户的资源 - auth_list = QuerySet(WorkspaceUserResourcePermission).filter( - Q(workspace_id=self.data.get('workspace_id')) & - Q(user_id=self.data.get('user_id')) & - Q(auth_target_type=self.data.get('source')) & - Q(target__in=source_ids) & - Q(permission_list__overlap=[ResourcePermissionConstants.MANAGE, ResourcePermissionConstants.ROLE]) - ).count() + auth_list = ( + QuerySet(WorkspaceUserResourcePermission) + .filter( + Q(workspace_id=self.data.get("workspace_id")) + & Q(user_id=self.data.get("user_id")) + & Q(auth_target_type=self.data.get("source")) + & Q(target__in=source_ids) + & Q( + permission_list__overlap=[ + ResourcePermissionConstants.MANAGE, + ResourcePermissionConstants.ROLE, + ] + ) + ) + .count() + ) if auth_list != len(source_ids): - raise AppApiException(500, _('This folder contains resources that you dont have permission')) + raise AppApiException(500, _("This folder contains resources that you dont have permission")) self.delete_source(node) node.delete() def delete_source(self, node): - Source = get_source_type(self.data.get('source')) # noqa - source_ids = Source.objects.filter(folder_id=node.id).values_list('id', flat=True) - source = self.data.get('source') + Source = get_source_type(self.data.get("source")) # noqa + source_ids = Source.objects.filter(folder_id=node.id).values_list("id", flat=True) + source = self.data.get("source") for source_id in source_ids: if source == Group.TOOL.name: - ToolSerializer.Operate(data={ - 'workspace_id': self.data.get('workspace_id'), - 'id': source_id, - }).delete() + ToolSerializer.Operate( + data={ + "workspace_id": self.data.get("workspace_id"), + "id": source_id, + } + ).delete() elif source == Group.APPLICATION.name: - ApplicationOperateSerializer(data={ - 'workspace_id': self.data.get('workspace_id'), - 'application_id': source_id, - 'user_id': self.data.get('user_id'), - }).delete() + ApplicationOperateSerializer( + data={ + "workspace_id": self.data.get("workspace_id"), + "application_id": source_id, + "user_id": self.data.get("user_id"), + } + ).delete() elif source == Group.KNOWLEDGE.name: - KnowledgeSerializer.Operate(data={ - 'workspace_id': self.data.get('workspace_id'), - 'knowledge_id': source_id, - 'user_id': self.data.get('user_id'), - }).delete() + KnowledgeSerializer.Operate( + data={ + "workspace_id": self.data.get("workspace_id"), + "knowledge_id": source_id, + "user_id": self.data.get("user_id"), + } + ).delete() class FolderTreeSerializer(serializers.Serializer): - workspace_id = serializers.CharField(required=True, allow_null=True, allow_blank=True, label=_('workspace id')) - source = serializers.CharField(required=True, label=_('source')) + workspace_id = serializers.CharField(required=True, allow_null=True, allow_blank=True, label=_("workspace id")) + source = serializers.CharField(required=True, label=_("source")) @staticmethod def _check_tree_integrity(queryset): """检查树结构完整性""" - for folder in queryset: + tree_id_attr = queryset.model._mptt_meta.tree_id_attr + folders = list(queryset) + tree_id_by_id = {folder.id: getattr(folder, tree_id_attr) for folder in folders} + parent_ids = {folder.parent_id for folder in folders} + for folder in folders: if folder.lft >= folder.rght: return True # 需要重建 - if folder.is_leaf_node() and folder.get_children().exists(): + if folder.is_leaf_node() and folder.id in parent_ids: return True # 需要重建 + parent_tree_id = tree_id_by_id.get(folder.parent_id) + if parent_tree_id is not None and parent_tree_id != getattr(folder, tree_id_attr): + return True # 子节点与父节点 tree_id 不一致,需要重建 return False + @staticmethod + def _rebuild_workspace_tree(Folder, workspace_id): + """只重建当前工作空间的文件夹树,避免重建整张表。 + + MPTT 的 rebuild 会把 tree_id 从 1 开始重新分配,直接按 workspace_id 过滤会与 + 其他工作空间已占用的 tree_id 冲突,因此复用本工作空间根节点的 tree_id;若该 + tree_id 已被其他工作空间占用,则分配一个新的未使用 tree_id。存在多个根节点 + (异常数据)时回退为整表重建,保证正确性。 + """ + tree_id_attr = Folder._mptt_meta.tree_id_attr + root_tree_ids = list( + Folder.objects.filter(workspace_id=workspace_id, parent__isnull=True).values_list(tree_id_attr, flat=True) + ) + if len(root_tree_ids) != 1: + Folder.objects.rebuild() + return + tree_id = root_tree_ids[0] + if Folder.objects.filter(**{tree_id_attr: tree_id}).exclude(workspace_id=workspace_id).exists(): + tree_id = (Folder.objects.aggregate(max_tree_id=Max(tree_id_attr)).get("max_tree_id") or 0) + 1 + with transaction.atomic(): + Folder.objects.filter(workspace_id=workspace_id).update(**{tree_id_attr: tree_id}) + Folder.objects.rebuild(workspace_id=workspace_id, tree_id=tree_id) + @staticmethod def _having_read_permission_by_role(user_id: str, workspace_id: str, source: str): workspace_user_role_mapping_model = DatabaseModelManage.get_model("workspace_user_role_mapping") role_permission_mapping_model = DatabaseModelManage.get_model("role_permission_mapping_model") is_x_pack_ee = workspace_user_role_mapping_model is not None and role_permission_mapping_model is not None if is_x_pack_ee: - return QuerySet(workspace_user_role_mapping_model).select_related('role', 'user').filter( - Q(role__rolepermission__permission_id=f"{source}_FOLDER:READ") | Q(role__internal=True), - workspace_id=workspace_id, - user_id=user_id, - role__type=RoleConstants.USER.value.__str__(), - ).exists() + return ( + QuerySet(workspace_user_role_mapping_model) + .select_related("role", "user") + .filter( + Q(role__rolepermission__permission_id=f"{source}_FOLDER:READ") | Q(role__internal=True), + workspace_id=workspace_id, + user_id=user_id, + role__type=RoleConstants.USER.value.__str__(), + ) + .exists() + ) return False - def get_folder_tree(self, - current_user, name=None): + def get_folder_tree(self, current_user, name=None): self.is_valid(raise_exception=True) user_id = current_user.id - workspace_id = self.data.get('workspace_id') - source = self.data.get('source') + workspace_id = self.data.get("workspace_id") + source = self.data.get("source") Folder = get_folder_type(source) # noqa # 检查特定工作空间的树结构完整性 workspace_folders = Folder.objects.filter(workspace_id=workspace_id) - # 如果发现数据不一致,重建整个表(这是 MPTT 的限制) + # 如果发现数据不一致,只重建当前工作空间的树 if self._check_tree_integrity(workspace_folders): - Folder.objects.rebuild() + self._rebuild_workspace_tree(Folder, workspace_id) workspace_manage = is_workspace_manage(user_id, workspace_id) @@ -363,25 +442,27 @@ def get_folder_tree(self, if name is not None: base_q &= Q(name__contains=name) if not workspace_manage: - having_read_permission_by_role = has_exact_permission_by_role(user_id, workspace_id, - f"{source}_FOLDER:READ", - RoleConstants.USER.value.__str__()) - permission_condition = ['VIEW'] + having_read_permission_by_role = has_exact_permission_by_role( + user_id, workspace_id, f"{source}_FOLDER:READ", RoleConstants.USER.value.__str__() + ) + permission_condition = ["VIEW"] if having_read_permission_by_role: - permission_condition = ['VIEW', 'ROLE'] + permission_condition = ["VIEW", "ROLE"] - base_q &= (Q(id__in=WorkspaceUserResourcePermission.objects.filter(user_id=current_user.id, - auth_target_type=self.data.get('source'), - workspace_id=self.data.get( - 'workspace_id'), - permission_list__overlap=permission_condition) - .values_list( - 'target', flat=True)) | Q(id=self.data.get('workspace_id'))) + base_q &= Q( + id__in=WorkspaceUserResourcePermission.objects.filter( + user_id=current_user.id, + auth_target_type=self.data.get("source"), + workspace_id=self.data.get("workspace_id"), + permission_list__overlap=permission_condition, + ).values_list("target", flat=True) + ) | Q(id=self.data.get("workspace_id")) nodes = Folder.objects.filter(base_q).get_cached_trees() - TreeSerializer = get_folder_tree_serializer(self.data.get('source')) # noqa + TreeSerializer = get_folder_tree_serializer(self.data.get("source")) # noqa serializer = TreeSerializer(nodes, many=True) - return [d for d in serializer.data if - d.get('id') == d.get('workspace_id')] if name is None else serializer.data # 这是可序列化的字典 + return ( + [d for d in serializer.data if d.get("id") == d.get("workspace_id")] if name is None else serializer.data + ) # 这是可序列化的字典