diff --git a/docs/PRD/删除本地人员资产转移/删除本地人员资产转移实现方案.md b/docs/PRD/删除本地人员资产转移/删除本地人员资产转移实现方案.md new file mode 100644 index 000000000..a4dafcec5 --- /dev/null +++ b/docs/PRD/删除本地人员资产转移/删除本地人员资产转移实现方案.md @@ -0,0 +1,363 @@ +# 删除本地人员 — 资产转移与 Linsight 清理实现方案 + +| 项 | 内容 | +|---|---| +| 版本 | v1.0 | +| 状态 | 已实现(P0 后端 + Platform 前端) | +| 适用范围 | 部门管理 → 编辑本地人员 → 删除人员 | +| 关联能力 | F018 资源 Owner 转移、`ResourceOwnershipService.transfer_owner` | + +--- + +## 1. 背景与目标 + +### 1.1 现状(改造前) + +部门管理页删除「本地人员」时,后端会先统计用户挂载的数据资产(知识空间、工作流、助手等)。若 `has_assets=true`,直接返回阻断错误,前端 toast 提示「无法删除」。 + +| 维度 | 改造前 | +|------|--------| +| 删除策略 | 软删 `user.delete=1`,移除组织关系 / 用户组 / 非 Admin 角色 | +| 资产处理 | 有资产则 **阻断删除** | +| Linsight | 未单独处理 | +| 账号禁用后续 | 部分路径未调用 `on_account_disabled` | +| 前端体验 | 仅展示三类 legacy 计数,无转移预览 | + +### 1.2 目标 + +删除本地人员时,改为 **先盘点 → 自动转移 F018 资产 → 删除 Linsight → 软删用户**,并在删除前向操作者展示转移预览与接收人。 + +### 1.3 非目标(本期不做) + +- 不改造 SSO / 组织同步等其它删用户路径 +- 不支持操作者手动选择资产接收人(自动解析) +- 不转移 Linsight 数据(随用户删除) +- 不物理删除 MySQL 用户行 +- Client 端(`/workspace`)不涉及 + +--- + +## 2. 核心业务规则 + +### 2.1 删除 eligibility(不变) + +仅允许删除同时满足以下条件的用户: + +1. 当前上下文部门内存在组织关系 +2. `user.source == "local"` +3. `user.delete == 0` +4. 非平台 AdminRole 持有者 +5. 操作者有部门管理权限;归档部门只读 + +### 2.2 资产盘点 + +| 类别 | 处理方式 | 资源类型 | +|------|----------|----------| +| F018 可转移资产 | 转移 Owner | `knowledge_space` / `folder` / `knowledge_file` / `workflow` / `assistant` / `tool` / `channel` | +| Linsight 资产 | **删除**(不转移) | `linsight_session_version` / `linsight_sop` / `linsight_sop_record` | + +盘点逻辑复用 `ResourceOwnershipService._resolve_resources`,按用户所属租户(`UserTenant`)逐租户扫描;无租户记录时回退操作者 `tenant_id` 或 `ROOT_TENANT_ID`。 + +批量转移单批上限 `MAX_BATCH=500`(与 F018 一致)。 + +**知识文件冗余字段同步:** F018 `transfer_owner` 在更新 `knowledgefile.user_id` 时,同步更新 `user_name`、`updater_id`、`updater_name`、`original_uploader_id`(当被删用户持有或为空时),避免列表仍显示已删用户姓名。 + +**个人知识空间不计入转移:** `knowledge_space_scope.level = personal` 或 `is_favorite = true` 的系统/个人库(如「我的收藏」「{用户名}的知识库」)不参与 F018 转移计数;删除用户时直接清理。仅团队/部门/公共等用户主动创建的业务空间会计入「知识空间 N 个」。 + +### 2.3 接收人解析 + +沿被删用户 **主部门**(无则当前上下文部门)的组织树 **向上** 查找: + +1. 每一层取 `DepartmentAdminGrant` 管理员列表 +2. 排除被删用户本人 +3. 过滤已软删用户 +4. 校验接收人对目标租户可见(`ResourceOwnershipService._check_receiver_visible`) +5. **多个候选管理员时取 `user_id` 升序第一个** + +若组织树各级均无可用部门管理员,回退到 **平台 AdminRole** 用户(同样取 `user_id` 升序第一个可见候选)。 + +若存在可转移资产但 **找不到任何接收人** → 抛错 `21022`,删除中止。 + +### 2.4 转移 operator 代理 + +部门管理员调用 `transfer_owner` 可能因权限不足失败(19601)。当 operator 非 global super / admin 时,自动回退到第一个有效 **AdminRole** 用户作为 transfer operator。 + +### 2.5 软删与账号禁用 + +软删步骤(与原逻辑一致,补充 hook): + +1. 删除 `UserDepartment` / `UserGroup` +2. 删除非 AdminRole 的 `UserRole` +3. `user.delete = 1` +4. `UserService.ainvalidate_jwt_after_account_disabled` +5. **`UserService.on_account_disabled`**(QA 转公开等) +6. `DepartmentChangeHandler.on_member_removed` 逐部门执行 + +--- + +## 3. 流程设计 + +```mermaid +sequenceDiagram + participant UI as Platform 编辑弹窗 + participant API as department_member API + participant Svc as LocalMemberDeleteService + participant Inv as AssetInventory + participant Rcv as TransferReceiver + participant F018 as ResourceOwnershipService + participant Lin as Linsight DELETE + + UI->>API: GET delete-check + API->>Svc: preview() + Svc->>Inv: build_local_member_asset_inventory + Svc->>Rcv: resolve_local_member_transfer_receiver + API-->>UI: preview(counts / receiver / linsight) + + UI->>UI: 确认框展示转移预览 + + UI->>API: DELETE local-account + API->>Svc: execute() + alt 有可转移资产 + Svc->>Rcv: resolve receiver + alt 无接收人 + Svc-->>API: 21022 + else + Svc->>F018: transfer_owner(分批) + end + end + alt 有 Linsight + Svc->>Lin: DELETE by user_id + end + Svc->>Svc: soft_delete_local_member_user + API-->>UI: execute 摘要 +``` + +--- + +## 4. API 设计 + +### 4.1 删除预检 + +``` +GET /api/v1/departments/{dept_id}/members/{user_id}/delete-check +``` + +**响应示例:** + +```json +{ + "has_assets": true, + "counts": { + "knowledge_space": 2, + "workflow": 1, + "assistant": 0, + "linsight_sop": 3 + }, + "transfer_count": 3, + "linsight_delete_count": 3, + "proposed_receiver": { + "user_id": 100, + "user_name": "张三", + "source": "department_admin", + "department_id": 20, + "department_name": "信息部" + } +} +``` + +`counts` 同时包含 F018 key 与 legacy key(兼容旧前端): + +| legacy key | 映射 | +|------------|------| +| `knowledge_spaces` | `knowledge_space` | +| `flows` | `workflow` | +| `assistants` | `assistant` | + +### 4.2 执行删除 + +``` +DELETE /api/v1/departments/{dept_id}/members/{user_id}/local-account +``` + +**响应示例:** + +```json +{ + "deleted_user_id": 42, + "transfer": { + "performed": true, + "receiver": { + "user_id": 100, + "user_name": "张三", + "source": "department_admin", + "department_id": 20, + "department_name": "信息部" + }, + "transferred_count": 3, + "counts_by_type": { "knowledge_space": 2, "workflow": 1 }, + "transfer_log_ids": ["uuid-1", "uuid-2"] + }, + "linsight_deleted": { + "performed": true, + "deleted_count": 3, + "counts": { "linsight_sop": 3 } + } +} +``` + +### 4.3 错误码 + +| Code | 类名 | 场景 | +|------|------|------| +| 21022 | `DepartmentMemberDeleteTransferReceiverNotFoundError` | 有可转移资产但无合格接收人 | + +其它既有错误码(非 local 用户、Admin 不可删、无权限等)保持不变。 + +--- + +## 5. 后端实现 + +### 5.1 模块划分 + +| 文件 | 职责 | +|------|------| +| `department/domain/schemas/local_member_delete_schema.py` | preview / execute DTO | +| `department/domain/services/local_member_asset_inventory.py` | F018 七类 + Linsight 计数与 batch 列表 | +| `department/domain/services/local_member_transfer_receiver.py` | 上级 first admin → platform admin fallback | +| `department/domain/services/local_member_asset_transfer.py` | `transfer_owner` + Linsight DELETE + operator 代理 | +| `department/domain/services/local_member_delete_service.py` | `preview` / `execute` / `soft_delete_local_member_user` 编排 | + +### 5.2 接入点 + +| 位置 | 改动 | +|------|------| +| `DepartmentService.acheck_local_member_delete` | 委托 `LocalMemberDeleteService.preview`,合并 legacy counts | +| `DepartmentService.adelete_local_organization_member` | 委托 `LocalMemberDeleteService.execute` | +| `DepartmentService._validate_local_member_deletable` | 共享校验;**移除** `_count_user_owned_data_assets` 阻断逻辑 | +| `department/api/endpoints/department_member.py` | `DELETE local-account` 返回 `result.model_dump()` | + +### 5.3 关键类 + +**LocalMemberDeleteService** + +- `preview()`:校验 → 盘点 → 解析 proposed_receiver +- `execute()`:校验 → 盘点 → 转移 → 删 Linsight → 软删 → 返回摘要 +- `_resolve_start_department_id()`:优先主部门,否则上下文部门 +- `soft_delete_local_member_user()`:软删 + JWT 失效 + `on_account_disabled` + +### 5.4 测试 + +``` +src/backend/test/department/test_local_member_delete_service.py +``` + +覆盖: + +1. 接收人解析(多管理员取 `user_id` 最小) +2. execute 完整流程(mock transfer / Linsight / soft delete) +3. 有资产无接收人抛 21022 + +--- + +## 6. 前端实现(Platform) + +### 6.1 涉及文件 + +| 文件 | 改动 | +|------|------| +| `src/controllers/API/department.ts` | 新增 `LocalMemberDeletePreview` / `LocalMemberDeleteExecuteResult` 类型 | +| `pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx` | 删除流程改造 | +| `pages/DepartmentPage/components/localMemberDeleteHelpers.tsx` | 确认框预览 / 成功 toast 文案构建 | +| `public/locales/{zh-Hans,en-US,ja}/bs.json` | i18n 文案 | + +### 6.2 交互流程 + +1. 点击「删除人员」→ 调用 `delete-check`(按钮 loading) +2. `transfer_count > 0` 且 `proposed_receiver == null` → toast 提示,不弹确认框 +3. 确认框展示: + - 基础删除说明 + - 待转移资产清单 + 接收人(部门管理员 / 平台管理员) + - 待删除 Linsight 清单(标注不转移) +4. 确认后 `DELETE local-account` +5. 成功 toast 展示转移与 Linsight 删除摘要 + +### 6.3 移除行为 + +- 不再使用 `deleteLocalMemberBlocked`(有资产直接阻断) +- 不再在确认后才调用 `delete-check`(改为确认前预检并展示预览) + +--- + +## 7. 与既有删除路径对比 + +| 路径 | 资产处理 | Linsight | 软删 | +|------|----------|----------|------| +| 部门管理删除本地人员(本方案) | F018 自动转移 | 删除 | ✅ | +| 组织同步 / SSO 删用户 | 各路径独立逻辑 | 视实现 | 视实现 | +| 系统用户管理删用户 | 可能阻断或独立处理 | 视实现 | ✅ | + +--- + +## 8. 验证清单 + +### 8.1 后端 + +```bash +cd src/backend +uv run pytest test/department/test_local_member_delete_service.py -q +``` + +### 8.2 前端(手动) + +| 场景 | 预期 | +|------|------| +| 无资产本地用户 | 确认框仅基础说明,删除成功 | +| 有工作流/知识空间 | 确认框展示转移清单与接收人,删除后 toast 含转移摘要 | +| 仅有 Linsight | 确认框展示 Linsight 删除清单,无转移段 | +| 有资产但无接收人 | 预检后 toast 21022 / 无接收人文案,不进入确认 | +| 部门管理员操作 | 转移成功(operator 代理到 AdminRole) | + +### 8.3 回归 + +- 非 local 用户、Admin 用户、归档部门 → 仍被正确拒绝 +- 删除后该用户无法登录,组织成员列表不再展示 + +--- + +## 9. 后续可选增强(非 P0) + +- E2E:`/e2e-test` 覆盖 delete-check + execute API +- 转移失败部分回滚策略(当前依赖 F018 单批原子性,全链路无分布式事务) +- 操作审计日志 UI 展示 `transfer_log_ids` +- 允许超管手动指定接收人 + +--- + +## 10. 变更文件索引 + +### 后端(新增) + +- `src/backend/bisheng/department/domain/schemas/local_member_delete_schema.py` +- `src/backend/bisheng/department/domain/services/local_member_asset_inventory.py` +- `src/backend/bisheng/department/domain/services/local_member_transfer_receiver.py` +- `src/backend/bisheng/department/domain/services/local_member_asset_transfer.py` +- `src/backend/bisheng/department/domain/services/local_member_delete_service.py` +- `src/backend/test/department/test_local_member_delete_service.py` + +### 后端(修改) + +- `src/backend/bisheng/department/domain/services/department_service.py` +- `src/backend/bisheng/department/api/endpoints/department_member.py` +- `src/backend/bisheng/common/errcode/department.py` + +### 前端(新增) + +- `src/frontend/platform/src/pages/DepartmentPage/components/localMemberDeleteHelpers.tsx` + +### 前端(修改) + +- `src/frontend/platform/src/controllers/API/department.ts` +- `src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx` +- `src/frontend/platform/public/locales/zh-Hans/bs.json` +- `src/frontend/platform/public/locales/en-US/bs.json` +- `src/frontend/platform/public/locales/ja/bs.json` diff --git a/src/backend/bisheng/common/errcode/department.py b/src/backend/bisheng/common/errcode/department.py index 61825f559..b413fc002 100644 --- a/src/backend/bisheng/common/errcode/department.py +++ b/src/backend/bisheng/common/errcode/department.py @@ -113,3 +113,8 @@ class DepartmentPersonIdDeletedAccountError(BaseErrorCode): class DepartmentDefaultRootMoveForbiddenError(BaseErrorCode): Code: int = 21021 Msg: str = "The default root organization cannot be moved or reparented" + + +class DepartmentMemberDeleteTransferReceiverNotFoundError(BaseErrorCode): + Code: int = 21022 + Msg: str = "No eligible administrator found to receive transferred assets" diff --git a/src/backend/bisheng/department/api/endpoints/department_member.py b/src/backend/bisheng/department/api/endpoints/department_member.py index 261c9a0c9..dd1f6c495 100644 --- a/src/backend/bisheng/department/api/endpoints/department_member.py +++ b/src/backend/bisheng/department/api/endpoints/department_member.py @@ -128,10 +128,10 @@ async def delete_local_organization_member( login_user: UserPayload = Depends(UserPayload.get_login_user), ): try: - await DepartmentService.adelete_local_organization_member( + result = await DepartmentService.adelete_local_organization_member( dept_id, user_id, login_user, ) - return resp_200() + return resp_200(result.model_dump()) except BaseErrorCode as e: return e.return_resp_instance() diff --git a/src/backend/bisheng/department/domain/schemas/local_member_delete_schema.py b/src/backend/bisheng/department/domain/schemas/local_member_delete_schema.py new file mode 100644 index 000000000..d35aeba2b --- /dev/null +++ b/src/backend/bisheng/department/domain/schemas/local_member_delete_schema.py @@ -0,0 +1,51 @@ +"""DTOs for local organization member delete with asset transfer.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class LocalMemberDeleteReceiverPreview(BaseModel): + user_id: int + user_name: str + source: str = Field(description="department_admin | platform_admin") + department_id: int | None = None + department_name: str | None = None + + +class LocalMemberDeletePreviewResponse(BaseModel): + has_assets: bool + counts: dict[str, int] = Field(default_factory=dict) + transfer_count: int = 0 + linsight_delete_count: int = 0 + proposed_receiver: LocalMemberDeleteReceiverPreview | None = None + + +class LocalMemberDeleteTransferSummary(BaseModel): + performed: bool = False + receiver: LocalMemberDeleteReceiverPreview | None = None + transferred_count: int = 0 + counts_by_type: dict[str, int] = Field(default_factory=dict) + transfer_log_ids: list[str] = Field(default_factory=list) + + +class LocalMemberDeleteLinsightSummary(BaseModel): + performed: bool = False + deleted_count: int = 0 + counts: dict[str, int] = Field(default_factory=dict) + + +class LocalMemberDeletePersonalRecycleSummary(BaseModel): + performed: bool = False + recycled_count: int = 0 + folder_name: str = "" + recycle_batch_id: str | None = None + + +class LocalMemberDeleteExecuteResponse(BaseModel): + deleted_user_id: int + transfer: LocalMemberDeleteTransferSummary + linsight_deleted: LocalMemberDeleteLinsightSummary + personal_recycled: LocalMemberDeletePersonalRecycleSummary = Field( + default_factory=LocalMemberDeletePersonalRecycleSummary, + ) diff --git a/src/backend/bisheng/department/domain/services/department_service.py b/src/backend/bisheng/department/domain/services/department_service.py index 34c7e4269..831e68a5e 100644 --- a/src/backend/bisheng/department/domain/services/department_service.py +++ b/src/backend/bisheng/department/domain/services/department_service.py @@ -20,7 +20,6 @@ from bisheng.common.errcode.department import ( DepartmentHasMembersError, DepartmentInvalidPasswordError, DepartmentInvalidRolesError, - DepartmentMemberDeleteBlockedError, DepartmentMemberDeleteForbiddenError, DepartmentMemberExistsError, DepartmentMemberNotFoundError, @@ -1819,42 +1818,13 @@ class DepartmentService: ) @classmethod - async def _count_user_owned_data_assets(cls, user_id: int) -> dict: - """删除人员前:统计用户作为创建者挂载的常见数据资产。""" - from bisheng.database.models.assistant import Assistant - from bisheng.database.models.flow import Flow - from bisheng.knowledge.domain.models.knowledge import Knowledge, KnowledgeTypeEnum - - async with get_async_db_session() as session: - k = await session.scalar( - select(func.count(Knowledge.id)).where( - Knowledge.user_id == user_id, - Knowledge.type == KnowledgeTypeEnum.SPACE.value, - ), - ) - f = await session.scalar( - select(func.count(Flow.id)).where(Flow.user_id == user_id), - ) - a = await session.scalar( - select(func.count(Assistant.id)).where( - Assistant.user_id == user_id, - Assistant.is_delete == 0, - ), - ) - return { - "knowledge_spaces": int(k or 0), - "flows": int(f or 0), - "assistants": int(a or 0), - } - - @classmethod - async def acheck_local_member_delete( + async def _validate_local_member_deletable( cls, dept_id: str, user_id: int, login_user, - ) -> dict: - """删除人员前预检:是否挂载数据资产。""" + ) -> None: + """Shared permission and eligibility checks for local member delete.""" from bisheng.database.constants import AdminRole from bisheng.user.domain.models.user import UserDao from bisheng.user.domain.models.user_role import UserRoleDao @@ -1884,9 +1854,34 @@ class DepartmentService: if any(int(r.role_id) == AdminRole for r in old_roles): raise DepartmentPermissionDeniedError() - counts = await cls._count_user_owned_data_assets(user_id) - total = sum(counts.values()) - return {"has_assets": total > 0, "counts": counts} + @classmethod + async def acheck_local_member_delete( + cls, + dept_id: str, + user_id: int, + login_user, + ) -> dict: + from bisheng.department.domain.services.local_member_delete_service import ( + LocalMemberDeleteService, + ) + + await cls._validate_local_member_deletable(dept_id, user_id, login_user) + preview = await LocalMemberDeleteService.preview( + dept_id=dept_id, + user_id=user_id, + login_user=login_user, + validate_member=cls._validate_local_member_deletable, + ) + legacy_counts = { + "knowledge_spaces": preview.counts.get("knowledge_space", 0), + "flows": preview.counts.get("workflow", 0), + "assistants": preview.counts.get("assistant", 0), + } + merged_counts = {**preview.counts, **legacy_counts} + return { + **preview.model_dump(), + "counts": merged_counts, + } @classmethod async def adelete_local_organization_member( @@ -1894,47 +1889,17 @@ class DepartmentService: dept_id: str, user_id: int, login_user, - ) -> None: - """删除本地人员账号:清部门关系、角色(保留超管)、用户组,软删用户。""" - from bisheng.database.constants import AdminRole - from bisheng.database.models.user_group import UserGroup - from bisheng.user.domain.models.user import User - from bisheng.user.domain.models.user_role import UserRole + ): + from bisheng.department.domain.services.local_member_delete_service import ( + LocalMemberDeleteService, + ) - await cls.acheck_local_member_delete(dept_id, user_id, login_user) - counts = await cls._count_user_owned_data_assets(user_id) - if sum(counts.values()) > 0: - raise DepartmentMemberDeleteBlockedError( - msg="User has data assets", - counts=counts, - ) - - uds = await UserDepartmentDao.aget_user_departments(user_id) - dept_ids = [int(u.department_id) for u in uds] - - async with get_async_db_session() as session: - await session.exec(delete(UserDepartment).where(UserDepartment.user_id == user_id)) - await session.exec( - delete(UserRole).where( - UserRole.user_id == user_id, - UserRole.role_id != AdminRole, - ), - ) - await session.exec(delete(UserGroup).where(UserGroup.user_id == user_id)) - db_user = (await session.exec(select(User).where(User.user_id == user_id))).first() - if db_user: - db_user.delete = 1 - session.add(db_user) - await session.commit() - - if db_user: - from bisheng.user.domain.services.user import UserService - - await UserService.ainvalidate_jwt_after_account_disabled(user_id) - - for did in dept_ids: - ops = DepartmentChangeHandler.on_member_removed(did, user_id) - await DepartmentChangeHandler.execute_async(ops) + return await LocalMemberDeleteService.execute( + dept_id=dept_id, + user_id=user_id, + login_user=login_user, + validate_member=cls._validate_local_member_deletable, + ) @classmethod async def aget_member_edit_form( diff --git a/src/backend/bisheng/department/domain/services/local_member_asset_inventory.py b/src/backend/bisheng/department/domain/services/local_member_asset_inventory.py new file mode 100644 index 000000000..012b3a7ce --- /dev/null +++ b/src/backend/bisheng/department/domain/services/local_member_asset_inventory.py @@ -0,0 +1,247 @@ +"""Inventory transferable and Linsight assets owned by a user.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field + +from sqlalchemy import func, select + +from bisheng.core.context.tenant import bypass_tenant_filter +from bisheng.core.database import get_async_db_session +from bisheng.database.models.tenant import ROOT_TENANT_ID, UserTenantDao +from bisheng.knowledge.domain.models.knowledge import Knowledge, KnowledgeTypeEnum +from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileDao +from bisheng.knowledge.domain.models.knowledge_space_scope import ( + KnowledgeSpaceLevelEnum, + KnowledgeSpaceScope, +) +from bisheng.linsight.domain.models.linsight_session_version import LinsightSessionVersion +from bisheng.linsight.domain.models.linsight_sop import LinsightSOP, LinsightSOPRecord +from bisheng.tenant.domain.services.resource_ownership_service import ResourceOwnershipService, ResourceRow +from bisheng.tenant.domain.services.resource_type_registry import SUPPORTED_TYPES + + +@dataclass +class AssetBatch: + tenant_id: int + resource_type: str + resource_ids: list[int | str] + + +@dataclass +class LocalMemberAssetInventory: + tenant_ids: list[int] = field(default_factory=list) + counts: dict[str, int] = field(default_factory=dict) + transfer_batches: list[AssetBatch] = field(default_factory=list) + linsight_counts: dict[str, int] = field(default_factory=dict) + personal_knowledge_space_ids: list[int] = field(default_factory=list) + + @property + def transfer_count(self) -> int: + return sum(len(batch.resource_ids) for batch in self.transfer_batches) + + @property + def linsight_delete_count(self) -> int: + return sum(self.linsight_counts.values()) + + @property + def personal_knowledge_space_count(self) -> int: + return len(self.personal_knowledge_space_ids) + + @property + def has_transferable_assets(self) -> bool: + return self.transfer_count > 0 + + +async def _resolve_user_tenant_ids(user_id: int, fallback_tenant_id: int | None) -> list[int]: + active = await UserTenantDao.aget_active_user_tenant(user_id) + if active is not None and active.tenant_id is not None: + return [int(active.tenant_id)] + + rows = await UserTenantDao.aget_user_tenants(user_id) + tenant_ids = sorted({int(row.tenant_id) for row in rows if row.tenant_id is not None}) + if tenant_ids: + return tenant_ids + if fallback_tenant_id is not None and int(fallback_tenant_id) > 0: + return [int(fallback_tenant_id)] + return [ROOT_TENANT_ID] + + +async def _find_personal_knowledge_space_ids(user_id: int) -> set[int]: + """System/personal spaces are recycled on delete, not transferred.""" + with bypass_tenant_filter(): + async with get_async_db_session() as session: + owned_space_ids = ( + await session.exec( + select(Knowledge.id).where( + Knowledge.user_id == user_id, + Knowledge.type == KnowledgeTypeEnum.SPACE.value, + ) + ) + ).all() + normalized_ids = sorted( + { + _extract_scalar_int(row) + for row in owned_space_ids + if row is not None + } + ) + if not normalized_ids: + return set() + + favorite_ids = { + _extract_scalar_int(row) + for row in ( + await session.exec( + select(Knowledge.id).where( + Knowledge.user_id == user_id, + Knowledge.type == KnowledgeTypeEnum.SPACE.value, + Knowledge.is_favorite == True, # noqa: E712 + ) + ) + ).all() + if row is not None + } + scoped_personal_ids = { + _extract_scalar_int(row) + for row in ( + await session.exec( + select(KnowledgeSpaceScope.space_id).where( + KnowledgeSpaceScope.space_id.in_(normalized_ids), + KnowledgeSpaceScope.level == KnowledgeSpaceLevelEnum.PERSONAL.value, + ) + ) + ).all() + if row is not None + } + return favorite_ids | scoped_personal_ids + + +async def _filter_resources_in_personal_spaces( + resources: list[ResourceRow], + personal_space_ids: set[int], +) -> list[ResourceRow]: + if not resources or not personal_space_ids: + return resources + + file_ids = [int(resource.id) for resource in resources] + files = await KnowledgeFileDao.aget_file_by_ids(file_ids) + allowed_ids = { + int(file.id) + for file in files + if int(file.knowledge_id) not in personal_space_ids + } + return [resource for resource in resources if int(resource.id) in allowed_ids] + + +async def _count_linsight_assets(user_id: int) -> dict[str, int]: + with bypass_tenant_filter(): + async with get_async_db_session() as session: + session_version_count = int( + await session.scalar( + select(func.count()).select_from(LinsightSessionVersion).where( + LinsightSessionVersion.user_id == user_id, + ), + ) + or 0 + ) + sop_count = int( + await session.scalar( + select(func.count()).select_from(LinsightSOP).where( + LinsightSOP.user_id == user_id, + ), + ) + or 0 + ) + sop_record_count = int( + await session.scalar( + select(func.count()).select_from(LinsightSOPRecord).where( + LinsightSOPRecord.user_id == user_id, + ), + ) + or 0 + ) + return { + "linsight_session_version": session_version_count, + "linsight_sop": sop_count, + "linsight_sop_record": sop_record_count, + } + + +def _chunks(values: list[int | str], size: int) -> list[list[int | str]]: + return [values[start : start + size] for start in range(0, len(values), size)] + + +def _extract_scalar_int(row: object) -> int: + """Normalize SQLModel/SQLAlchemy scalar row (int, Row, tuple) to int.""" + if isinstance(row, int): + return row + try: + return int(row) # type: ignore[arg-type] + except (TypeError, ValueError): + return int(row[0]) # type: ignore[index] + + +async def build_local_member_asset_inventory( + *, + user_id: int, + fallback_tenant_id: int | None, + batch_size: int, +) -> LocalMemberAssetInventory: + tenant_ids = await _resolve_user_tenant_ids(user_id, fallback_tenant_id) + personal_space_ids = await _find_personal_knowledge_space_ids(user_id) + counts: dict[str, int] = defaultdict(int) + transfer_batches: list[AssetBatch] = [] + + for tenant_id in tenant_ids: + for resource_type in SUPPORTED_TYPES: + resources = await ResourceOwnershipService._resolve_resources( + tenant_id=tenant_id, + from_user_id=user_id, + resource_types=[resource_type], + resource_ids=None, + ) + if not resources: + continue + + if resource_type == "knowledge_space": + resources = [ + resource + for resource in resources + if int(resource.id) not in personal_space_ids + ] + elif resource_type in {"folder", "knowledge_file"}: + resources = await _filter_resources_in_personal_spaces( + resources, + personal_space_ids, + ) + + if not resources: + continue + + counts[resource_type] += len(resources) + resource_ids = [resource.id for resource in resources] + for chunk in _chunks(resource_ids, batch_size): + transfer_batches.append( + AssetBatch( + tenant_id=tenant_id, + resource_type=resource_type, + resource_ids=chunk, + ), + ) + + if personal_space_ids: + counts["personal_knowledge_space"] = len(personal_space_ids) + + linsight_counts = await _count_linsight_assets(user_id) + for key, value in linsight_counts.items(): + counts[key] = value + + return LocalMemberAssetInventory( + tenant_ids=tenant_ids, + counts=dict(counts), + transfer_batches=transfer_batches, + linsight_counts=linsight_counts, + personal_knowledge_space_ids=sorted(personal_space_ids), + ) diff --git a/src/backend/bisheng/department/domain/services/local_member_asset_transfer.py b/src/backend/bisheng/department/domain/services/local_member_asset_transfer.py new file mode 100644 index 000000000..17519284d --- /dev/null +++ b/src/backend/bisheng/department/domain/services/local_member_asset_transfer.py @@ -0,0 +1,91 @@ +"""Transfer F018 assets and delete Linsight rows during local member delete.""" + +from __future__ import annotations + +from collections import defaultdict + +from sqlalchemy import delete + +from bisheng.core.context.tenant import bypass_tenant_filter +from bisheng.core.database import get_async_db_session +from bisheng.database.constants import AdminRole +from bisheng.department.domain.services.local_member_asset_inventory import ( + LocalMemberAssetInventory, +) +from bisheng.department.domain.services.local_member_transfer_receiver import ResolvedTransferReceiver +from bisheng.linsight.domain.models.linsight_session_version import LinsightSessionVersion +from bisheng.linsight.domain.models.linsight_sop import LinsightSOP, LinsightSOPRecord +from bisheng.tenant.domain.services.resource_ownership_service import ResourceOwnershipService +from bisheng.user.domain.models.user import UserDao +from bisheng.user.domain.models.user_role import UserRoleDao + + +async def _resolve_transfer_operator(operator: object) -> object: + is_global_super = getattr(operator, "is_global_super", None) + if callable(is_global_super) and is_global_super(): + return operator + is_admin = getattr(operator, "is_admin", None) + if callable(is_admin) and is_admin(): + return operator + + rows = await UserRoleDao.aget_roles_user([AdminRole]) + for row in sorted(rows, key=lambda item: int(item.user_id)): + user = await UserDao.aget_user(int(row.user_id)) + if user is None or int(getattr(user, "delete", 0) or 0) != 0: + continue + return user + return operator + + +async def transfer_local_member_assets( + *, + from_user_id: int, + inventory: LocalMemberAssetInventory, + receiver: ResolvedTransferReceiver, + operator: object, + reason: str, +) -> tuple[int, dict[str, int], list[str | None]]: + if not inventory.transfer_batches: + return 0, {}, [] + + transfer_operator = await _resolve_transfer_operator(operator) + transferred_count = 0 + counts_by_type: dict[str, int] = defaultdict(int) + transfer_log_ids: list[str | None] = [] + + for batch in inventory.transfer_batches: + result = await ResourceOwnershipService.transfer_owner( + tenant_id=batch.tenant_id, + from_user_id=from_user_id, + to_user_id=receiver.user_id, + resource_types=[batch.resource_type], + resource_ids=batch.resource_ids, + reason=reason, + operator=transfer_operator, + ) + batch_count = int(result.get("transferred_count", 0)) + transferred_count += batch_count + counts_by_type[batch.resource_type] += batch_count + transfer_log_ids.append(result.get("transfer_log_id")) + + return transferred_count, dict(counts_by_type), transfer_log_ids + + +async def delete_local_member_linsight_assets( + *, + user_id: int, + expected_counts: dict[str, int] | None = None, +) -> dict[str, int]: + with bypass_tenant_filter(): + async with get_async_db_session() as session: + await session.exec(delete(LinsightSessionVersion).where(LinsightSessionVersion.user_id == user_id)) + await session.exec(delete(LinsightSOP).where(LinsightSOP.user_id == user_id)) + await session.exec(delete(LinsightSOPRecord).where(LinsightSOPRecord.user_id == user_id)) + await session.commit() + if expected_counts is not None: + return dict(expected_counts) + return { + "linsight_session_version": 0, + "linsight_sop": 0, + "linsight_sop_record": 0, + } diff --git a/src/backend/bisheng/department/domain/services/local_member_delete_service.py b/src/backend/bisheng/department/domain/services/local_member_delete_service.py new file mode 100644 index 000000000..eac034627 --- /dev/null +++ b/src/backend/bisheng/department/domain/services/local_member_delete_service.py @@ -0,0 +1,198 @@ +"""Orchestrate local member delete with asset transfer and Linsight cleanup.""" + +from __future__ import annotations + +from sqlalchemy import delete +from sqlmodel import select + +from bisheng.common.errcode.department import DepartmentMemberDeleteTransferReceiverNotFoundError +from bisheng.core.database import get_async_db_session +from bisheng.database.constants import AdminRole +from bisheng.database.models.department import UserDepartment +from bisheng.database.models.user_group import UserGroup +from bisheng.department.domain.schemas.local_member_delete_schema import ( + LocalMemberDeleteExecuteResponse, + LocalMemberDeleteLinsightSummary, + LocalMemberDeletePersonalRecycleSummary, + LocalMemberDeletePreviewResponse, + LocalMemberDeleteTransferSummary, +) +from bisheng.department.domain.services.department_change_handler import DepartmentChangeHandler +from bisheng.department.domain.services.local_member_asset_inventory import ( + build_local_member_asset_inventory, +) +from bisheng.department.domain.services.local_member_asset_transfer import ( + delete_local_member_linsight_assets, + transfer_local_member_assets, +) +from bisheng.department.domain.services.local_member_personal_recycle import ( + recycle_local_member_personal_knowledge_spaces, +) +from bisheng.department.domain.services.local_member_transfer_receiver import ( + resolve_local_member_transfer_receiver, +) +from bisheng.tenant.domain.services.resource_ownership_service import MAX_BATCH +from bisheng.user.domain.models.user import User +from bisheng.user.domain.models.user_role import UserRole + + +class LocalMemberDeleteService: + TRANSFER_REASON = "local organization member delete auto transfer" + + @classmethod + async def preview( + cls, + *, + dept_id: str, + user_id: int, + login_user, + validate_member, + ) -> LocalMemberDeletePreviewResponse: + await validate_member(dept_id, user_id, login_user) + start_department_id = await cls._resolve_start_department_id(user_id, dept_id) + inventory = await build_local_member_asset_inventory( + user_id=user_id, + fallback_tenant_id=int(getattr(login_user, "tenant_id", 0) or 0) or None, + batch_size=MAX_BATCH, + ) + proposed_receiver = None + if inventory.has_transferable_assets: + receiver = await resolve_local_member_transfer_receiver( + user_id=user_id, + start_department_id=start_department_id, + tenant_ids=inventory.tenant_ids, + ) + proposed_receiver = receiver.to_preview() if receiver is not None else None + + return LocalMemberDeletePreviewResponse( + has_assets=( + inventory.transfer_count > 0 + or inventory.linsight_delete_count > 0 + or inventory.personal_knowledge_space_count > 0 + ), + counts=inventory.counts, + transfer_count=inventory.transfer_count, + linsight_delete_count=inventory.linsight_delete_count, + proposed_receiver=proposed_receiver, + ) + + @classmethod + async def execute( + cls, + *, + dept_id: str, + user_id: int, + login_user, + validate_member, + ) -> LocalMemberDeleteExecuteResponse: + await validate_member(dept_id, user_id, login_user) + start_department_id = await cls._resolve_start_department_id(user_id, dept_id) + inventory = await build_local_member_asset_inventory( + user_id=user_id, + fallback_tenant_id=int(getattr(login_user, "tenant_id", 0) or 0) or None, + batch_size=MAX_BATCH, + ) + + transfer_summary = LocalMemberDeleteTransferSummary(performed=False) + if inventory.has_transferable_assets: + receiver = await resolve_local_member_transfer_receiver( + user_id=user_id, + start_department_id=start_department_id, + tenant_ids=inventory.tenant_ids, + ) + if receiver is None: + raise DepartmentMemberDeleteTransferReceiverNotFoundError() + transferred_count, counts_by_type, transfer_log_ids = await transfer_local_member_assets( + from_user_id=user_id, + inventory=inventory, + receiver=receiver, + operator=login_user, + reason=cls.TRANSFER_REASON, + ) + transfer_summary = LocalMemberDeleteTransferSummary( + performed=True, + receiver=receiver.to_preview(), + transferred_count=transferred_count, + counts_by_type=counts_by_type, + transfer_log_ids=[log_id for log_id in transfer_log_ids if log_id], + ) + + personal_recycle_summary = LocalMemberDeletePersonalRecycleSummary() + if inventory.personal_knowledge_space_ids: + from bisheng.user.domain.models.user import UserDao + + deleted_user = await UserDao.aget_user(user_id) + recycle_result = await recycle_local_member_personal_knowledge_spaces( + user_id=user_id, + user_name=str(getattr(deleted_user, "user_name", "") or ""), + space_ids=inventory.personal_knowledge_space_ids, + operator=login_user, + ) + personal_recycle_summary = LocalMemberDeletePersonalRecycleSummary( + performed=recycle_result.performed, + recycled_count=recycle_result.recycled_count, + folder_name=recycle_result.folder_name, + recycle_batch_id=recycle_result.recycle_batch_id, + ) + + linsight_summary = LocalMemberDeleteLinsightSummary(performed=False) + if inventory.linsight_delete_count > 0: + linsight_counts = await delete_local_member_linsight_assets( + user_id=user_id, + expected_counts=inventory.linsight_counts, + ) + linsight_summary = LocalMemberDeleteLinsightSummary( + performed=True, + deleted_count=sum(linsight_counts.values()), + counts=linsight_counts, + ) + + await cls.soft_delete_local_member_user(user_id) + + return LocalMemberDeleteExecuteResponse( + deleted_user_id=user_id, + transfer=transfer_summary, + linsight_deleted=linsight_summary, + personal_recycled=personal_recycle_summary, + ) + + @classmethod + async def _resolve_start_department_id(cls, user_id: int, dept_id: str) -> int | None: + from bisheng.database.models.department import DepartmentDao, UserDepartmentDao + + primary = await UserDepartmentDao.aget_user_primary_department(user_id) + if primary is not None: + return int(primary.department_id) + department = await DepartmentDao.aget_by_dept_id(dept_id) + return int(department.id) if department is not None and department.id is not None else None + + @classmethod + async def soft_delete_local_member_user(cls, user_id: int) -> None: + from bisheng.database.models.department import UserDepartmentDao + from bisheng.user.domain.services.user import UserService + + uds = await UserDepartmentDao.aget_user_departments(user_id) + dept_ids = [int(item.department_id) for item in uds] + + async with get_async_db_session() as session: + await session.exec(delete(UserDepartment).where(UserDepartment.user_id == user_id)) + await session.exec( + delete(UserRole).where( + UserRole.user_id == user_id, + UserRole.role_id != AdminRole, + ), + ) + await session.exec(delete(UserGroup).where(UserGroup.user_id == user_id)) + db_user = (await session.exec(select(User).where(User.user_id == user_id))).first() + if db_user: + db_user.delete = 1 + session.add(db_user) + await session.commit() + + if db_user: + await UserService.ainvalidate_jwt_after_account_disabled(user_id) + await UserService.on_account_disabled(user_id) + + for department_id in dept_ids: + ops = DepartmentChangeHandler.on_member_removed(department_id, user_id) + await DepartmentChangeHandler.execute_async(ops) diff --git a/src/backend/bisheng/department/domain/services/local_member_personal_recycle.py b/src/backend/bisheng/department/domain/services/local_member_personal_recycle.py new file mode 100644 index 000000000..d635dfdc9 --- /dev/null +++ b/src/backend/bisheng/department/domain/services/local_member_personal_recycle.py @@ -0,0 +1,244 @@ +"""Move deleted member personal-library content into recycle bin.""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace + +from sqlalchemy import bindparam, func, select, text +from sqlmodel import col + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.core.context.tenant import bypass_tenant_filter +from bisheng.core.database import get_async_db_session +from bisheng.database.constants import AdminRole +from bisheng.department.domain.services.local_member_asset_transfer import _resolve_transfer_operator +from bisheng.knowledge.domain.models.knowledge import KnowledgeDao +from bisheng.knowledge.domain.models.knowledge_file import FileType, KnowledgeFile +from bisheng.knowledge.domain.models.knowledge_space_file import SpaceFileDao +from bisheng.knowledge.domain.services.knowledge_recycle_service import KnowledgeRecycleService +from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService + + +class _LocalMemberDeleteRequest: + state = SimpleNamespace() + + +@dataclass +class LocalMemberPersonalRecycleResult: + performed: bool = False + recycled_count: int = 0 + folder_name: str = "" + recycle_batch_id: str | None = None + host_space_id: int | None = None + + +async def _build_recycle_login_user(operator: object) -> UserPayload: + transfer_operator = await _resolve_transfer_operator(operator) + user_id = int(getattr(transfer_operator, "user_id", 0) or 0) + user_name = str(getattr(transfer_operator, "user_name", "") or "") + tenant_id = int(getattr(transfer_operator, "tenant_id", 1) or 1) + user_role = list(getattr(transfer_operator, "user_role", None) or []) + if AdminRole not in user_role: + user_role = [*user_role, AdminRole] + + if user_id > 0 and not user_name.strip(): + from bisheng.user.domain.models.user import UserDao + + db_user = await UserDao.aget_user(user_id) + if db_user is not None and getattr(db_user, "user_name", None): + user_name = str(db_user.user_name) + + return UserPayload( + user_id=user_id, + user_name=user_name or "admin", + tenant_id=tenant_id, + user_role=user_role, + ) + + +async def _count_active_files(space_id: int) -> int: + async with get_async_db_session() as session: + return int( + await session.scalar( + select(func.count()) + .select_from(KnowledgeFile) + .where( + KnowledgeFile.knowledge_id == space_id, + col(KnowledgeFile.deleted_at).is_(None), + ) + ) + or 0 + ) + + +async def _list_root_items(space_id: int) -> list[KnowledgeFile]: + """List active root-level files/folders in a personal space.""" + items: list[KnowledgeFile] = [] + page = 1 + page_size = 500 + while True: + batch = await SpaceFileDao.async_list_children( + int(space_id), + None, + page=page, + page_size=page_size, + ) + if not batch: + break + items.extend(batch) + if len(batch) < page_size: + break + page += 1 + return items + + +async def _pick_host_space_id(space_ids: list[int]) -> int: + best_id = space_ids[0] + best_score: tuple[int, int, int] | None = None + for space_id in space_ids: + space = await KnowledgeDao.aquery_by_id(space_id) + file_count = await _count_active_files(space_id) + is_favorite = bool(getattr(space, "is_favorite", False)) if space else False + score = (file_count, 0 if not is_favorite else -1, -int(space_id)) + if best_score is None or score > best_score: + best_score = score + best_id = int(space_id) + return best_id + + +def _resolve_folder_name(user_name: str, user_id: int) -> str: + cleaned = (user_name or "").strip() + return cleaned or f"user_{user_id}" + + +async def _sync_recycled_file_metadata( + *, + file_ids: list[int], + folder_ids: list[int], + from_user_id: int, + operator: UserPayload, +) -> None: + """Align denormalized uploader/updater columns with the delete operator (admin).""" + all_ids = list(dict.fromkeys([*file_ids, *folder_ids])) + if not all_ids: + return + + operator_id = int(operator.user_id) + operator_name = str(operator.user_name or operator_id) + stmt = text( + "UPDATE knowledgefile SET " + "user_id = :to_uid, " + "user_name = :to_user_name, " + "updater_id = :to_uid, " + "updater_name = :to_user_name, " + "original_uploader_id = CASE " + "WHEN original_uploader_id IS NULL OR original_uploader_id = :from_uid " + "THEN :to_uid ELSE original_uploader_id END " + "WHERE id IN :ids" + ).bindparams(bindparam("ids", expanding=True)) + + with bypass_tenant_filter(): + async with get_async_db_session() as session: + await session.execute( + stmt, + { + "to_uid": operator_id, + "to_user_name": operator_name, + "from_uid": int(from_user_id), + "ids": all_ids, + }, + ) + await session.commit() + + +async def _collect_recycle_ids(user_folder: KnowledgeFile) -> tuple[list[int], list[int]]: + space_id = int(user_folder.knowledge_id) + prefix = ( + f"{user_folder.file_level_path}/{user_folder.id}" + if user_folder.file_level_path + else f"/{user_folder.id}" + ) + children = await SpaceFileDao.get_children_by_prefix(space_id, prefix) + folder_ids = [int(user_folder.id)] + file_ids: list[int] = [] + for child in children: + if int(child.file_type) == FileType.DIR.value: + folder_ids.append(int(child.id)) + else: + file_ids.append(int(child.id)) + return file_ids, folder_ids + + +async def recycle_local_member_personal_knowledge_spaces( + *, + user_id: int, + user_name: str, + space_ids: list[int], + operator: object, +) -> LocalMemberPersonalRecycleResult: + """Create a user-named folder per personal space, move content in, then recycle.""" + if not space_ids: + return LocalMemberPersonalRecycleResult() + + folder_name = _resolve_folder_name(user_name, user_id) + login_user = await _build_recycle_login_user(operator) + space_service = KnowledgeSpaceService(_LocalMemberDeleteRequest(), login_user) + recycle_service = KnowledgeRecycleService(login_user) + + host_space_id = await _pick_host_space_id(space_ids) + host_user_folder_id: int | None = None + all_file_ids: list[int] = [] + all_folder_ids: list[int] = [] + + with bypass_tenant_filter(): + for space_id in space_ids: + if await _count_active_files(space_id) <= 0: + continue + + user_folder = await space_service.find_or_create_folder_for_file_sync( + int(space_id), + folder_name, + parent_id=None, + ) + if int(space_id) == host_space_id: + host_user_folder_id = int(user_folder.id) + + root_items = await _list_root_items(space_id) + for item in root_items: + item_id = int(item.id) + if item_id == int(user_folder.id): + continue + if int(item.file_type) == FileType.DIR.value: + await space_service.move_folder(space_id, item_id, int(user_folder.id)) + else: + await space_service.move_file_folder(space_id, item_id, int(user_folder.id)) + + file_ids, folder_ids = await _collect_recycle_ids(user_folder) + all_file_ids.extend(file_ids) + all_folder_ids.extend(folder_ids) + + if host_user_folder_id is None or not (all_file_ids or all_folder_ids): + return LocalMemberPersonalRecycleResult(folder_name=folder_name) + + await _sync_recycled_file_metadata( + file_ids=all_file_ids, + folder_ids=all_folder_ids, + from_user_id=user_id, + operator=login_user, + ) + + batch_id = await recycle_service.soft_delete_member_personal_batch( + recycle_root_id=host_user_folder_id, + file_ids=all_file_ids, + folder_ids=all_folder_ids, + list_entry_ids=[host_user_folder_id], + ) + + return LocalMemberPersonalRecycleResult( + performed=True, + recycled_count=len(set(all_file_ids + all_folder_ids)), + folder_name=folder_name, + recycle_batch_id=batch_id, + host_space_id=host_space_id, + ) diff --git a/src/backend/bisheng/department/domain/services/local_member_transfer_receiver.py b/src/backend/bisheng/department/domain/services/local_member_transfer_receiver.py new file mode 100644 index 000000000..e3159969e --- /dev/null +++ b/src/backend/bisheng/department/domain/services/local_member_transfer_receiver.py @@ -0,0 +1,150 @@ +"""Resolve the first eligible asset receiver for local member delete.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from bisheng.approval.domain.services.approver_resolver import _department_hierarchy_ids_from_path +from bisheng.common.errcode.resource_owner_transfer import ResourceTransferReceiverOutOfTenantError +from bisheng.database.constants import AdminRole +from bisheng.database.models.department import DepartmentDao +from bisheng.database.models.department_admin_grant import DepartmentAdminGrantDao +from bisheng.department.domain.schemas.local_member_delete_schema import ( + LocalMemberDeleteReceiverPreview, +) +from bisheng.tenant.domain.services.resource_ownership_service import ResourceOwnershipService +from bisheng.user.domain.models.user import UserDao +from bisheng.user.domain.models.user_role import UserRoleDao + + +@dataclass(frozen=True) +class ResolvedTransferReceiver: + user_id: int + user_name: str + source: str + department_id: int | None = None + department_name: str | None = None + + def to_preview(self) -> LocalMemberDeleteReceiverPreview: + return LocalMemberDeleteReceiverPreview( + user_id=self.user_id, + user_name=self.user_name, + source=self.source, + department_id=self.department_id, + department_name=self.department_name, + ) + + +async def _is_active_user(user_id: int) -> bool: + user = await UserDao.aget_user(user_id) + return user is not None and int(getattr(user, "delete", 0) or 0) == 0 + + +async def _receiver_visible_for_tenants(receiver_id: int, tenant_ids: list[int]) -> bool: + for tenant_id in tenant_ids: + try: + await ResourceOwnershipService._check_receiver_visible(receiver_id, tenant_id) + except ResourceTransferReceiverOutOfTenantError: + return False + return True + + +async def _pick_first_visible_candidate( + candidate_ids: list[int], + *, + excluded_user_id: int, + tenant_ids: list[int], +) -> int | None: + filtered = sorted( + { + int(candidate_id) + for candidate_id in candidate_ids + if int(candidate_id) != int(excluded_user_id) + } + ) + for candidate_id in filtered: + if not await _is_active_user(candidate_id): + continue + if await _receiver_visible_for_tenants(candidate_id, tenant_ids): + return candidate_id + return None + + +async def _resolve_department_admin_receiver( + *, + start_department_id: int, + excluded_user_id: int, + tenant_ids: list[int], +) -> ResolvedTransferReceiver | None: + dept = await DepartmentDao.aget_by_id(start_department_id) + if dept is None: + return None + + hierarchy_ids = _department_hierarchy_ids_from_path(getattr(dept, "path", None), start_department_id) + for candidate_dept_id in reversed(hierarchy_ids): + admin_ids = await DepartmentAdminGrantDao.aget_user_ids_by_department(candidate_dept_id) + if not admin_ids: + continue + receiver_id = await _pick_first_visible_candidate( + [int(uid) for uid in admin_ids], + excluded_user_id=excluded_user_id, + tenant_ids=tenant_ids, + ) + if receiver_id is None: + continue + receiver = await UserDao.aget_user(receiver_id) + candidate_dept = await DepartmentDao.aget_by_id(candidate_dept_id) + if receiver is None: + continue + return ResolvedTransferReceiver( + user_id=int(receiver.user_id), + user_name=str(receiver.user_name or ""), + source="department_admin", + department_id=int(candidate_dept_id), + department_name=str(getattr(candidate_dept, "name", "") or ""), + ) + return None + + +async def _resolve_platform_admin_receiver( + *, + excluded_user_id: int, + tenant_ids: list[int], +) -> ResolvedTransferReceiver | None: + rows = await UserRoleDao.aget_roles_user([AdminRole]) + candidate_ids = sorted({int(row.user_id) for row in rows}) + receiver_id = await _pick_first_visible_candidate( + candidate_ids, + excluded_user_id=excluded_user_id, + tenant_ids=tenant_ids, + ) + if receiver_id is None: + return None + receiver = await UserDao.aget_user(receiver_id) + if receiver is None: + return None + return ResolvedTransferReceiver( + user_id=int(receiver.user_id), + user_name=str(receiver.user_name or ""), + source="platform_admin", + ) + + +async def resolve_local_member_transfer_receiver( + *, + user_id: int, + start_department_id: int | None, + tenant_ids: list[int], +) -> ResolvedTransferReceiver | None: + if start_department_id is not None: + receiver = await _resolve_department_admin_receiver( + start_department_id=int(start_department_id), + excluded_user_id=user_id, + tenant_ids=tenant_ids, + ) + if receiver is not None: + return receiver + return await _resolve_platform_admin_receiver( + excluded_user_id=user_id, + tenant_ids=tenant_ids, + ) diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_recycle_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_recycle_service.py index 85a1437f8..8a7ef2822 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_recycle_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_recycle_service.py @@ -225,6 +225,143 @@ class KnowledgeRecycleService: await KnowledgeSpaceContentStat.enqueue_file_stat_async(file_ids) return batch_id + async def soft_delete_member_personal_batch( + self, + *, + recycle_root_id: int, + file_ids: Sequence[int], + folder_ids: Sequence[int], + list_entry_ids: Sequence[int], + ) -> str: + """Soft-delete personal-library items that may span multiple knowledge spaces. + + Unlike ``soft_delete_file_ids``, each snapshot row uses the file's actual + ``knowledge_id`` so restore/original-path logic stays correct when a deleted + member owned several personal spaces (e.g. default library + favorites). + """ + now = datetime.now() + retention_days = await self.get_retention_days() + expire_at = now + timedelta(days=retention_days) + batch_id = uuid.uuid4().hex + folder_ids = list(folder_ids or []) + all_ids = list(dict.fromkeys([*file_ids, *folder_ids])) + if not all_ids: + return batch_id + + list_entry_set = set(list_entry_ids or [recycle_root_id]) + records = await KnowledgeFileDao.aget_file_by_ids(all_ids) + by_id = {int(record.id): record for record in records if record} + records_by_space: dict[int, list[KnowledgeFile]] = {} + for record in records: + kid = int(record.knowledge_id) + records_by_space.setdefault(kid, []).append(record) + + space_cache: dict[int, tuple[Any, str, str]] = {} + folder_map_cache: dict[int, dict[int, str]] = {} + + async def _space_bundle(knowledge_id: int) -> tuple[Any, str, str, dict[int, str]]: + if knowledge_id not in space_cache: + space = await KnowledgeDao.aquery_by_id(knowledge_id) + space_level, space_level_label = await self._resolve_space_level(knowledge_id) + space_cache[knowledge_id] = (space, space_level, space_level_label) + space, space_level, space_level_label = space_cache[knowledge_id] + if knowledge_id not in folder_map_cache: + folder_map_cache[knowledge_id] = await self._build_folder_name_map( + knowledge_id, + records_by_space.get(knowledge_id, []), + ) + return space, space_level, space_level_label, folder_map_cache[knowledge_id] + + tags_by_file = await self._snapshot_tags_by_file_ids(all_ids) + items: list[KnowledgeRecycleItem] = [] + for fid in all_ids: + rec = by_id.get(int(fid)) + if not rec: + continue + knowledge_id = int(rec.knowledge_id) + space, space_level, space_level_label, folder_name_map = await _space_bundle(knowledge_id) + parent_id = self._parent_id_from_path(rec.file_level_path) + original_path = self._build_display_path(space.name if space else "", rec, folder_name_map) + fingerprint = self._path_fingerprint(rec.file_level_path) + biz = self._extract_business_domain(rec) + category = None + if rec.split_rule: + try: + import json + + rule = json.loads(rec.split_rule) if isinstance(rec.split_rule, str) else rec.split_rule + if isinstance(rule, dict): + category = rule.get("file_category_code") + biz = biz or rule.get("business_domain_code") + except Exception: + pass + + items.append( + KnowledgeRecycleItem( + tenant_id=getattr(space, "tenant_id", None) or self.login_user.tenant_id, + file_id=int(rec.id), + knowledge_id=knowledge_id, + file_type=self._coerce_file_type(rec.file_type), + is_list_entry=int(rec.id) in list_entry_set, + display_name=rec.file_name or "", + file_category_code=category, + file_subcategory_code=rec.file_subcategory_code, + business_domain_code=biz, + tags_snapshot=tags_by_file.get(int(rec.id), []), + file_encoding=rec.file_encoding, + file_size=rec.file_size, + md5=rec.md5, + space_level=space_level, + space_level_label=space_level_label, + original_knowledge_id=knowledge_id, + original_parent_id=parent_id, + original_path=original_path, + original_file_level_path=rec.file_level_path or "", + original_path_fingerprint=fingerprint, + deleted_by=self.login_user.user_id, + deleted_by_name=self.login_user.user_name, + deleted_at=now, + expire_at=expire_at, + recycle_batch_id=batch_id, + recycle_root_id=recycle_root_id, + document_id=None, + version_file_ids=None, + ) + ) + + async with get_async_db_session() as session: + await session.execute( + update(KnowledgeFile).where(col(KnowledgeFile.id).in_(all_ids)).values(deleted_at=now) + ) + for item in items: + session.add(item) + await request_file_delete_intents( + session, + [ + KnowledgeFulltextFileRef( + file_id=int(record.id), + knowledge_id=int(record.knowledge_id), + tenant_id=int(record.tenant_id or self.login_user.tenant_id), + ) + for record in records + if record.file_type == FileType.FILE.value + ], + trigger_type="recycle_soft_delete", + ) + await session.commit() + + logger.info( + "recycle soft-delete member-personal batch=%s root=%s count=%s by=%s", + batch_id, + recycle_root_id, + len(all_ids), + self.login_user.user_id, + ) + await KnowledgeSpaceContentStat.enqueue_file_stat_async( + [record.id for record in records if record.file_type == FileType.FILE.value] + ) + return batch_id + async def list_items( self, *, diff --git a/src/backend/bisheng/tenant/domain/services/resource_ownership_service.py b/src/backend/bisheng/tenant/domain/services/resource_ownership_service.py index f5ba7e97a..0c40ce9bc 100644 --- a/src/backend/bisheng/tenant/domain/services/resource_ownership_service.py +++ b/src/backend/bisheng/tenant/domain/services/resource_ownership_service.py @@ -127,7 +127,7 @@ class ResourceOwnershipService: # 5. Transactional flip: MySQL → OpenFGA (crash_safe) → audit transfer_log_id = cls._make_transfer_log_id() try: - await cls._bulk_update_user_ids(resources, to_user_id) + await cls._bulk_update_user_ids(resources, to_user_id, from_user_id) await cls._flip_fga_owner_tuples(resources, from_user_id, to_user_id) except Exception as exc: # noqa: BLE001 logger.error( @@ -325,7 +325,10 @@ class ResourceOwnershipService: @classmethod async def _bulk_update_user_ids( - cls, resources: List[ResourceRow], to_user_id: int, + cls, + resources: List[ResourceRow], + to_user_id: int, + from_user_id: int, ) -> None: """Per-table ``UPDATE SET user_id = :uid WHERE id IN (...)``. @@ -335,6 +338,9 @@ class ResourceOwnershipService: redundant (the ids already disambiguate) but keeps the update SQL symmetric with the select SQL and makes audits line up with per-type resource counts. + + For ``knowledgefile``, also sync denormalized uploader/updater + columns so UI lists show the new owner after transfer. """ by_type: Dict[str, List[Union[int, str]]] = {} for r in resources: @@ -343,13 +349,46 @@ class ResourceOwnershipService: with bypass_tenant_filter(): async with get_async_db_session() as session: try: + to_user_name = str(to_user_id) + name_row = ( + await session.execute( + text('SELECT user_name FROM user WHERE user_id = :uid'), + {'uid': to_user_id}, + ) + ).first() + if name_row and name_row[0]: + to_user_name = str(name_row[0]) + for rt, ids in by_type.items(): meta = get_meta(rt) - stmt = text( - f'UPDATE {meta.table} SET user_id = :uid ' - f'WHERE id IN :ids' - ).bindparams(bindparam('ids', expanding=True)) - await session.execute(stmt, {'uid': to_user_id, 'ids': ids}) + if meta.table == 'knowledgefile': + stmt = text( + f'UPDATE {meta.table} SET ' + f'user_id = :to_uid, ' + f'user_name = :to_user_name, ' + f'updater_id = :to_uid, ' + f'updater_name = :to_user_name, ' + f'original_uploader_id = CASE ' + f'WHEN original_uploader_id = :from_uid ' + f'OR original_uploader_id IS NULL THEN :to_uid ' + f'ELSE original_uploader_id END ' + f'WHERE id IN :ids' + ).bindparams(bindparam('ids', expanding=True)) + await session.execute( + stmt, + { + 'to_uid': to_user_id, + 'to_user_name': to_user_name, + 'from_uid': from_user_id, + 'ids': ids, + }, + ) + else: + stmt = text( + f'UPDATE {meta.table} SET user_id = :uid ' + f'WHERE id IN :ids' + ).bindparams(bindparam('ids', expanding=True)) + await session.execute(stmt, {'uid': to_user_id, 'ids': ids}) await session.commit() except Exception: await session.rollback() diff --git a/src/backend/test/department/test_local_member_asset_inventory.py b/src/backend/test/department/test_local_member_asset_inventory.py new file mode 100644 index 000000000..15c7acae2 --- /dev/null +++ b/src/backend/test/department/test_local_member_asset_inventory.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.department.domain.services.local_member_asset_inventory import ( + _extract_scalar_int, + build_local_member_asset_inventory, +) +from bisheng.tenant.domain.services.resource_ownership_service import ResourceRow + + +def test_extract_scalar_int_handles_sqlalchemy_row(): + class FakeRow: + def __init__(self, value): + self._value = value + + def __getitem__(self, index): + return self._value + + def __int__(self): + raise TypeError("not directly convertible") + + assert _extract_scalar_int(FakeRow(828)) == 828 + assert _extract_scalar_int(828) == 828 + assert _extract_scalar_int((828,)) == 828 + + +@pytest.mark.asyncio +async def test_inventory_excludes_personal_knowledge_spaces_from_transfer(): + personal_rows = [ + ResourceRow(resource_type="knowledge_space", id=11, user_id=42, tenant_id=1), + ResourceRow(resource_type="knowledge_space", id=12, user_id=42, tenant_id=1), + ] + team_rows = [ + ResourceRow(resource_type="knowledge_space", id=99, user_id=42, tenant_id=1), + ] + folder_in_personal = [ + ResourceRow(resource_type="folder", id=501, user_id=42, tenant_id=1), + ] + folder_in_team = [ + ResourceRow(resource_type="folder", id=502, user_id=42, tenant_id=1), + ] + + async def _resolve_resources( + tenant_id: int, + from_user_id: int, + resource_types: list[str], + resource_ids=None, + ): + resource_type = resource_types[0] + if resource_type == "knowledge_space": + return personal_rows + team_rows + if resource_type == "folder": + return folder_in_personal + folder_in_team + return [] + + with ( + patch( + "bisheng.department.domain.services.local_member_asset_inventory._resolve_user_tenant_ids", + AsyncMock(return_value=[1]), + ), + patch( + "bisheng.department.domain.services.local_member_asset_inventory._find_personal_knowledge_space_ids", + AsyncMock(return_value={11, 12}), + ), + patch( + "bisheng.department.domain.services.local_member_asset_inventory.ResourceOwnershipService._resolve_resources", + side_effect=_resolve_resources, + ), + patch( + "bisheng.department.domain.services.local_member_asset_inventory.KnowledgeFileDao.aget_file_by_ids", + AsyncMock( + return_value=[ + SimpleNamespace(id=501, knowledge_id=11), + SimpleNamespace(id=502, knowledge_id=99), + ] + ), + ), + patch( + "bisheng.department.domain.services.local_member_asset_inventory._count_linsight_assets", + AsyncMock(return_value={}), + ), + ): + inventory = await build_local_member_asset_inventory( + user_id=42, + fallback_tenant_id=1, + batch_size=500, + ) + + assert inventory.counts["knowledge_space"] == 1 + assert inventory.counts["personal_knowledge_space"] == 2 + assert inventory.counts.get("folder", 0) == 1 + assert inventory.personal_knowledge_space_ids == [11, 12] + assert inventory.transfer_count == 2 diff --git a/src/backend/test/department/test_local_member_delete_service.py b/src/backend/test/department/test_local_member_delete_service.py new file mode 100644 index 000000000..0a8c7a7dd --- /dev/null +++ b/src/backend/test/department/test_local_member_delete_service.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.common.errcode.department import DepartmentMemberDeleteTransferReceiverNotFoundError +from bisheng.department.domain.services.local_member_asset_inventory import LocalMemberAssetInventory +from bisheng.department.domain.services.local_member_delete_service import LocalMemberDeleteService +from bisheng.department.domain.services.local_member_transfer_receiver import ( + ResolvedTransferReceiver, + resolve_local_member_transfer_receiver, +) + + +@pytest.mark.asyncio +async def test_resolve_receiver_picks_first_active_department_admin(monkeypatch): + dept = SimpleNamespace(id=20, path="/1/20/", name="信息部") + admin_a = SimpleNamespace(user_id=100, user_name="AdminA", delete=0) + admin_b = SimpleNamespace(user_id=101, user_name="AdminB", delete=0) + + monkeypatch.setattr( + "bisheng.department.domain.services.local_member_transfer_receiver.DepartmentDao.aget_by_id", + AsyncMock(return_value=dept), + ) + monkeypatch.setattr( + "bisheng.department.domain.services.local_member_transfer_receiver.DepartmentAdminGrantDao.aget_user_ids_by_department", + AsyncMock(return_value=[101, 100]), + ) + + async def _aget_user(user_id: int): + return admin_a if user_id == 100 else admin_b + + monkeypatch.setattr( + "bisheng.department.domain.services.local_member_transfer_receiver.UserDao.aget_user", + _aget_user, + ) + monkeypatch.setattr( + "bisheng.department.domain.services.local_member_transfer_receiver.ResourceOwnershipService._check_receiver_visible", + AsyncMock(return_value=None), + ) + + receiver = await resolve_local_member_transfer_receiver( + user_id=42, + start_department_id=20, + tenant_ids=[1], + ) + + assert receiver is not None + assert receiver.user_id == 100 + assert receiver.source == "department_admin" + assert receiver.department_id == 20 + + +@pytest.mark.asyncio +async def test_execute_transfers_assets_deletes_linsight_and_soft_deletes_user(): + inventory = LocalMemberAssetInventory( + tenant_ids=[1], + counts={"workflow": 1}, + transfer_batches=[ + SimpleNamespace(tenant_id=1, resource_type="workflow", resource_ids=["flow-1"]), + ], + linsight_counts={"linsight_sop": 2}, + ) + receiver = ResolvedTransferReceiver(user_id=100, user_name="admin", source="platform_admin") + validate_member = AsyncMock() + soft_delete = AsyncMock() + + with ( + patch.object( + LocalMemberDeleteService, + "_resolve_start_department_id", + AsyncMock(return_value=20), + ), + patch( + "bisheng.department.domain.services.local_member_delete_service.build_local_member_asset_inventory", + AsyncMock(return_value=inventory), + ), + patch( + "bisheng.department.domain.services.local_member_delete_service.resolve_local_member_transfer_receiver", + AsyncMock(return_value=receiver), + ), + patch( + "bisheng.department.domain.services.local_member_delete_service.transfer_local_member_assets", + AsyncMock(return_value=(1, {"workflow": 1}, ["log-1"])), + ), + patch( + "bisheng.department.domain.services.local_member_delete_service.delete_local_member_linsight_assets", + AsyncMock(return_value={"linsight_sop": 2}), + ), + patch( + "bisheng.department.domain.services.local_member_delete_service.recycle_local_member_personal_knowledge_spaces", + AsyncMock(return_value=SimpleNamespace(performed=False, recycled_count=0, folder_name="", recycle_batch_id=None)), + ), + patch.object( + LocalMemberDeleteService, + "soft_delete_local_member_user", + soft_delete, + ), + ): + result = await LocalMemberDeleteService.execute( + dept_id="BS@test", + user_id=42, + login_user=SimpleNamespace(user_id=7, tenant_id=1), + validate_member=validate_member, + ) + + validate_member.assert_awaited_once() + soft_delete.assert_awaited_once_with(42) + assert result.deleted_user_id == 42 + assert result.transfer.performed is True + assert result.transfer.transferred_count == 1 + assert result.transfer.receiver.user_id == 100 + assert result.linsight_deleted.performed is True + assert result.linsight_deleted.deleted_count == 2 + + +@pytest.mark.asyncio +async def test_execute_raises_when_transfer_assets_exist_but_receiver_missing(): + inventory = LocalMemberAssetInventory( + tenant_ids=[1], + counts={"assistant": 1}, + transfer_batches=[ + SimpleNamespace(tenant_id=1, resource_type="assistant", resource_ids=["a-1"]), + ], + ) + + with ( + patch.object( + LocalMemberDeleteService, + "_resolve_start_department_id", + AsyncMock(return_value=20), + ), + patch( + "bisheng.department.domain.services.local_member_delete_service.build_local_member_asset_inventory", + AsyncMock(return_value=inventory), + ), + patch( + "bisheng.department.domain.services.local_member_delete_service.resolve_local_member_transfer_receiver", + AsyncMock(return_value=None), + ), + patch.object( + LocalMemberDeleteService, + "soft_delete_local_member_user", + AsyncMock(), + ), + ): + with pytest.raises(DepartmentMemberDeleteTransferReceiverNotFoundError): + await LocalMemberDeleteService.execute( + dept_id="BS@test", + user_id=42, + login_user=SimpleNamespace(user_id=7, tenant_id=1), + validate_member=AsyncMock(), + ) diff --git a/src/backend/test/department/test_local_member_personal_recycle.py b/src/backend/test/department/test_local_member_personal_recycle.py new file mode 100644 index 000000000..42a9225c4 --- /dev/null +++ b/src/backend/test/department/test_local_member_personal_recycle.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.department.domain.services.local_member_personal_recycle import ( + LocalMemberPersonalRecycleResult, + _pick_host_space_id, + _resolve_folder_name, + recycle_local_member_personal_knowledge_spaces, +) + + +def test_resolve_folder_name_uses_user_name_or_fallback(): + assert _resolve_folder_name("Alice", 42) == "Alice" + assert _resolve_folder_name(" ", 42) == "user_42" + + +@pytest.mark.asyncio +async def test_recycle_skips_when_no_content(): + with ( + patch( + "bisheng.department.domain.services.local_member_personal_recycle._pick_host_space_id", + AsyncMock(return_value=10), + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._build_recycle_login_user", + AsyncMock(return_value=SimpleNamespace(user_id=1, user_name="admin")), + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._count_active_files", + AsyncMock(return_value=0), + ), + ): + result = await recycle_local_member_personal_knowledge_spaces( + user_id=42, + user_name="Bob", + space_ids=[10, 11], + operator=SimpleNamespace(user_id=1), + ) + + assert result == LocalMemberPersonalRecycleResult(folder_name="Bob") + + +@pytest.mark.asyncio +async def test_recycle_moves_content_and_soft_deletes_batch(): + user_folder = SimpleNamespace(id=900, knowledge_id=10, file_level_path="") + moved_file = SimpleNamespace(id=901, file_type=1) + moved_folder = SimpleNamespace(id=902, file_type=0) + + space_service = SimpleNamespace( + find_or_create_folder_for_file_sync=AsyncMock(return_value=user_folder), + move_folder=AsyncMock(), + move_file_folder=AsyncMock(), + ) + recycle_service = SimpleNamespace( + soft_delete_member_personal_batch=AsyncMock(return_value="batch-1"), + ) + + async def _count_active_files(space_id: int) -> int: + return 2 if space_id == 10 else 0 + + async def _list_root_items(space_id: int): + assert space_id == 10 + return [moved_folder, moved_file] + + sync_metadata = AsyncMock() + with ( + patch( + "bisheng.department.domain.services.local_member_personal_recycle._pick_host_space_id", + AsyncMock(return_value=10), + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._build_recycle_login_user", + AsyncMock(return_value=SimpleNamespace(user_id=1, user_name="admin")), + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._count_active_files", + side_effect=_count_active_files, + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._list_root_items", + side_effect=_list_root_items, + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._collect_recycle_ids", + AsyncMock(return_value=([901], [900, 902])), + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._sync_recycled_file_metadata", + sync_metadata, + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle.KnowledgeSpaceService", + return_value=space_service, + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle.KnowledgeRecycleService", + return_value=recycle_service, + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle.bypass_tenant_filter", + nullcontext, + ), + ): + result = await recycle_local_member_personal_knowledge_spaces( + user_id=42, + user_name="Bob", + space_ids=[10, 11], + operator=SimpleNamespace(user_id=1), + ) + + space_service.move_folder.assert_awaited_once_with(10, 902, 900) + space_service.move_file_folder.assert_awaited_once_with(10, 901, 900) + sync_metadata.assert_awaited_once() + recycle_service.soft_delete_member_personal_batch.assert_awaited_once_with( + recycle_root_id=900, + file_ids=[901], + folder_ids=[900, 902], + list_entry_ids=[900], + ) + assert result.performed is True + assert result.recycled_count == 3 + assert result.folder_name == "Bob" + assert result.recycle_batch_id == "batch-1" + assert result.host_space_id == 10 + + +@pytest.mark.asyncio +async def test_pick_host_space_prefers_non_favorite_with_more_files(): + spaces = { + 10: SimpleNamespace(id=10, is_favorite=True), + 20: SimpleNamespace(id=20, is_favorite=False), + } + + async def _count_active_files(space_id: int) -> int: + return {10: 5, 20: 5}[space_id] + + with ( + patch( + "bisheng.department.domain.services.local_member_personal_recycle.KnowledgeDao.aquery_by_id", + AsyncMock(side_effect=lambda sid: spaces[int(sid)]), + ), + patch( + "bisheng.department.domain.services.local_member_personal_recycle._count_active_files", + side_effect=_count_active_files, + ), + ): + host_id = await _pick_host_space_id([10, 20]) + + assert host_id == 20 diff --git a/src/backend/test/test_resource_ownership_service.py b/src/backend/test/test_resource_ownership_service.py index 1ba106b1b..0ac306fbd 100644 --- a/src/backend/test/test_resource_ownership_service.py +++ b/src/backend/test/test_resource_ownership_service.py @@ -163,6 +163,52 @@ async def _seed_assistant(session, aid: str, user_id: int, tenant_id: int): ) +async def _seed_user(session, user_id: int, user_name: str): + await session.execute( + __import__('sqlalchemy').text( + 'INSERT INTO user (user_id, user_name, password) ' + 'VALUES (:uid, :name, :pwd)' + ), + {'uid': user_id, 'name': user_name, 'pwd': 'hashed'}, + ) + + +async def _seed_knowledge_file( + session, + file_id: int, + user_id: int, + tenant_id: int, + *, + user_name: str, + updater_id: int, + updater_name: str, + original_uploader_id: int, + knowledge_id: int = 10, +): + await session.execute( + __import__('sqlalchemy').text( + 'INSERT INTO knowledgefile (' + 'id, tenant_id, user_id, user_name, knowledge_id, file_name, file_type, ' + 'updater_id, updater_name, original_uploader_id' + ') VALUES (' + ':id, :tid, :uid, :user_name, :kid, :file_name, 1, ' + ':updater_id, :updater_name, :original_uploader_id' + ')' + ), + { + 'id': file_id, + 'tid': tenant_id, + 'uid': user_id, + 'user_name': user_name, + 'kid': knowledge_id, + 'file_name': f'file-{file_id}.txt', + 'updater_id': updater_id, + 'updater_name': updater_name, + 'original_uploader_id': original_uploader_id, + }, + ) + + # ========================================================================= # AC-03 / AC-08 / AC-08b / AC-08c / AC-08d / AD-03: validations # ========================================================================= @@ -448,6 +494,107 @@ class TestPendingTransfer: assert items == [] +# ========================================================================= +# knowledgefile denormalized uploader fields +# ========================================================================= + +@pytest.mark.asyncio +class TestKnowledgeFileDenormalizedFields: + + async def test_transfer_syncs_uploader_updater_original_uploader( + self, + tenant_admin, + patch_leaf_resolver, + sqlite_session, + patch_fga, + patch_audit, + ): + await _seed_user(sqlite_session, 100, 'DeletedUser') + await _seed_user(sqlite_session, 200, 'DeptAdmin') + await _seed_knowledge_file( + sqlite_session, + file_id=501, + user_id=100, + tenant_id=2, + user_name='DeletedUser', + updater_id=100, + updater_name='DeletedUser', + original_uploader_id=100, + ) + await sqlite_session.commit() + + with patch_leaf_resolver({200: 2}): + result = await ResourceOwnershipService.transfer_owner( + tenant_id=2, + from_user_id=100, + to_user_id=200, + resource_types=['knowledge_file'], + operator=tenant_admin, + ) + + assert result['transferred_count'] == 1 + row = ( + await sqlite_session.execute( + __import__('sqlalchemy').text( + 'SELECT user_id, user_name, updater_id, updater_name, original_uploader_id ' + 'FROM knowledgefile WHERE id = 501' + ) + ) + ).one() + assert row.user_id == 200 + assert row.user_name == 'DeptAdmin' + assert row.updater_id == 200 + assert row.updater_name == 'DeptAdmin' + assert row.original_uploader_id == 200 + + async def test_transfer_syncs_updater_even_when_updater_id_differs( + self, + tenant_admin, + patch_leaf_resolver, + sqlite_session, + patch_fga, + patch_audit, + ): + """Stale updater_name must not survive when updater_id does not match from_user.""" + await _seed_user(sqlite_session, 100, 'DeletedUser') + await _seed_user(sqlite_session, 200, 'DeptAdmin') + await _seed_knowledge_file( + sqlite_session, + file_id=502, + user_id=100, + tenant_id=2, + user_name='DeletedUser', + updater_id=999, + updater_name='DeletedUser', + original_uploader_id=100, + ) + await sqlite_session.commit() + + with patch_leaf_resolver({200: 2}): + result = await ResourceOwnershipService.transfer_owner( + tenant_id=2, + from_user_id=100, + to_user_id=200, + resource_types=['knowledge_file'], + operator=tenant_admin, + ) + + assert result['transferred_count'] == 1 + row = ( + await sqlite_session.execute( + __import__('sqlalchemy').text( + 'SELECT user_id, user_name, updater_id, updater_name, original_uploader_id ' + 'FROM knowledgefile WHERE id = 502' + ) + ) + ).one() + assert row.user_id == 200 + assert row.user_name == 'DeptAdmin' + assert row.updater_id == 200 + assert row.updater_name == 'DeptAdmin' + assert row.original_uploader_id == 200 + + # ========================================================================= # Minor invariants # ========================================================================= diff --git a/src/frontend/platform/public/locales/en-US/bs.json b/src/frontend/platform/public/locales/en-US/bs.json index f7e20ffea..c06603c5c 100644 --- a/src/frontend/platform/public/locales/en-US/bs.json +++ b/src/frontend/platform/public/locales/en-US/bs.json @@ -2633,9 +2633,28 @@ "department.memberEditPrimaryReadonly": "Primary department cannot be changed for third-party synced users.", "department.memberEditPrimaryPlaceholder": "Select primary department", "department.deleteLocalMember": "Delete member", + "department.deleteLocalMemberChecking": "Checking...", + "department.deleteLocalMemberProcessing": "Deleting...", "department.deleteLocalMemberConfirm": "Delete this local account? This removes organization memberships, user groups and non-admin roles, and disables login.", - "department.deleteLocalMemberBlocked": "This user still owns data assets and cannot be deleted: {{k}} knowledge space(s), {{f}} workflow(s), {{a}} assistant(s).", + "department.deleteLocalMemberTransferSection": "The following assets will be transferred to {{receiver}}:", + "department.deleteLocalMemberLinsightSection": "The following Linsight data will be deleted (not transferred):", + "department.deleteLocalMemberNoReceiver": "Transferable assets exist but no eligible receiver was found. Deletion is blocked.", + "department.deleteLocalMemberReceiverDepartmentAdmin": "{{userName}} ({{departmentName}} department admin)", + "department.deleteLocalMemberReceiverPlatformAdmin": "{{userName}} (platform admin)", "department.deleteLocalMemberDone": "Member account removed", + "department.deleteLocalMemberDoneTransferred": "Transferred {{count}} asset(s) to {{receiver}}", + "department.deleteLocalMemberDoneLinsight": "Deleted {{count}} Linsight item(s)", + "department.deleteLocalMemberDonePersonalRecycled": "Moved {{count}} personal library item(s) to recycle bin folder \"{{folderName}}\"", + "department.deleteLocalMemberAsset.knowledge_space": "{{count}} knowledge space(s)", + "department.deleteLocalMemberAsset.folder": "{{count}} folder(s)", + "department.deleteLocalMemberAsset.knowledge_file": "{{count}} knowledge file(s)", + "department.deleteLocalMemberAsset.workflow": "{{count}} workflow(s)", + "department.deleteLocalMemberAsset.assistant": "{{count}} assistant(s)", + "department.deleteLocalMemberAsset.tool": "{{count}} tool(s)", + "department.deleteLocalMemberAsset.channel": "{{count}} channel(s)", + "department.deleteLocalMemberAsset.linsight_session_version": "{{count}} Linsight session version(s)", + "department.deleteLocalMemberAsset.linsight_sop": "{{count}} Linsight SOP(s)", + "department.deleteLocalMemberAsset.linsight_sop_record": "{{count}} Linsight SOP record(s)", "department.memberEditAffiliateSection": "Affiliated departments (part-time)", "departmentSpace.configure": "Configure Department Spaces", "departmentSpace.createAction": "Create spaces", diff --git a/src/frontend/platform/public/locales/ja/bs.json b/src/frontend/platform/public/locales/ja/bs.json index 7bfdec0f0..5dc2f7bfe 100644 --- a/src/frontend/platform/public/locales/ja/bs.json +++ b/src/frontend/platform/public/locales/ja/bs.json @@ -2583,9 +2583,28 @@ "department.memberEditPrimaryReadonly": "サードパーティ同期ユーザーの主所属部門は変更できません。", "department.memberEditPrimaryPlaceholder": "主所属部門を選択", "department.deleteLocalMember": "メンバーを削除", + "department.deleteLocalMemberChecking": "確認中...", + "department.deleteLocalMemberProcessing": "削除中...", "department.deleteLocalMemberConfirm": "このローカルアカウントを削除しますか?組織所属・ユーザーグループ・一般ロールを解除し、ログインを無効化します。", - "department.deleteLocalMemberBlocked": "データ資産が残っているため削除できません:ナレッジスペース {{k}}、ワークフロー {{f}}、アシスタント {{a}}。", + "department.deleteLocalMemberTransferSection": "以下の資産は {{receiver}} に移管されます:", + "department.deleteLocalMemberLinsightSection": "以下の Linsight データは削除されます(移管しません):", + "department.deleteLocalMemberNoReceiver": "移管対象の資産がありますが、受け取り可能な担当者が見つかりません。削除できません。", + "department.deleteLocalMemberReceiverDepartmentAdmin": "{{userName}}({{departmentName}} 部門管理者)", + "department.deleteLocalMemberReceiverPlatformAdmin": "{{userName}}(プラットフォーム管理者)", "department.deleteLocalMemberDone": "メンバーアカウントを削除しました", + "department.deleteLocalMemberDoneTransferred": "{{count}} 件の資産を {{receiver}} に移管しました", + "department.deleteLocalMemberDoneLinsight": "{{count}} 件の Linsight データを削除しました", + "department.deleteLocalMemberDonePersonalRecycled": "個人ライブラリ {{count}} 件をごみ箱フォルダ「{{folderName}}」に移しました", + "department.deleteLocalMemberAsset.knowledge_space": "ナレッジスペース {{count}} 件", + "department.deleteLocalMemberAsset.folder": "フォルダ {{count}} 件", + "department.deleteLocalMemberAsset.knowledge_file": "ナレッジファイル {{count}} 件", + "department.deleteLocalMemberAsset.workflow": "ワークフロー {{count}} 件", + "department.deleteLocalMemberAsset.assistant": "アシスタント {{count}} 件", + "department.deleteLocalMemberAsset.tool": "ツール {{count}} 件", + "department.deleteLocalMemberAsset.channel": "チャンネル {{count}} 件", + "department.deleteLocalMemberAsset.linsight_session_version": "Linsight セッションバージョン {{count}} 件", + "department.deleteLocalMemberAsset.linsight_sop": "Linsight SOP {{count}} 件", + "department.deleteLocalMemberAsset.linsight_sop_record": "Linsight SOP 記録 {{count}} 件", "department.memberEditAffiliateSection": "兼務先部門", "departmentSpace.configure": "部門知識空間を設定", "departmentSpace.createAction": "知識空間を作成", diff --git a/src/frontend/platform/public/locales/zh-Hans/bs.json b/src/frontend/platform/public/locales/zh-Hans/bs.json index 95b33adcc..f70150cee 100644 --- a/src/frontend/platform/public/locales/zh-Hans/bs.json +++ b/src/frontend/platform/public/locales/zh-Hans/bs.json @@ -2583,9 +2583,28 @@ "department.memberEditPrimaryReadonly": "第三方同步人员主部门不可修改。", "department.memberEditPrimaryPlaceholder": "选择主部门", "department.deleteLocalMember": "删除人员", + "department.deleteLocalMemberChecking": "检查中...", + "department.deleteLocalMemberProcessing": "删除中...", "department.deleteLocalMemberConfirm": "确定删除该本地人员账号?将移除其组织关系、用户组与普通角色,并禁用登录。", - "department.deleteLocalMemberBlocked": "该用户仍挂载数据资产,无法删除:知识空间 {{k}} 个、工作流 {{f}} 个、助手 {{a}} 个。", + "department.deleteLocalMemberTransferSection": "以下资产将转移给 {{receiver}}:", + "department.deleteLocalMemberLinsightSection": "以下 Linsight 数据将被删除(不转移):", + "department.deleteLocalMemberNoReceiver": "存在可转移资产但未找到合适接收人,无法删除。", + "department.deleteLocalMemberReceiverDepartmentAdmin": "{{userName}}({{departmentName}} 部门管理员)", + "department.deleteLocalMemberReceiverPlatformAdmin": "{{userName}}(平台管理员)", "department.deleteLocalMemberDone": "已删除该人员账号", + "department.deleteLocalMemberDoneTransferred": "已转移 {{count}} 项资产至 {{receiver}}", + "department.deleteLocalMemberDoneLinsight": "已删除 {{count}} 项 Linsight 数据", + "department.deleteLocalMemberDonePersonalRecycled": "已将个人库 {{count}} 项内容放入回收站文件夹「{{folderName}}」", + "department.deleteLocalMemberAsset.knowledge_space": "知识空间 {{count}} 个", + "department.deleteLocalMemberAsset.folder": "文件夹 {{count}} 个", + "department.deleteLocalMemberAsset.knowledge_file": "知识文件 {{count}} 个", + "department.deleteLocalMemberAsset.workflow": "工作流 {{count}} 个", + "department.deleteLocalMemberAsset.assistant": "助手 {{count}} 个", + "department.deleteLocalMemberAsset.tool": "工具 {{count}} 个", + "department.deleteLocalMemberAsset.channel": "频道 {{count}} 个", + "department.deleteLocalMemberAsset.linsight_session_version": "Linsight 会话版本 {{count}} 个", + "department.deleteLocalMemberAsset.linsight_sop": "Linsight SOP {{count}} 个", + "department.deleteLocalMemberAsset.linsight_sop_record": "Linsight SOP 记录 {{count}} 个", "department.memberEditAffiliateSection": "附属部门(兼职)", "departmentSpace.configure": "配置部门知识空间", "departmentSpace.createAction": "创建知识空间", diff --git a/src/frontend/platform/src/components/bs-ui/alertDialog/useConfirm.tsx b/src/frontend/platform/src/components/bs-ui/alertDialog/useConfirm.tsx index 9e912ed6d..f4e43dda0 100644 --- a/src/frontend/platform/src/components/bs-ui/alertDialog/useConfirm.tsx +++ b/src/frontend/platform/src/components/bs-ui/alertDialog/useConfirm.tsx @@ -1,8 +1,9 @@ +import { LoadIcon } from "@/components/bs-icons" import { TipIcon } from "@/components/bs-icons/tip" import i18next from "i18next" import { X } from "lucide-react" -import { useRef, useState, type KeyboardEvent } from "react" +import { useRef, useState, type KeyboardEvent, type MouseEvent } from "react" import { createRoot } from "react-dom/client" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "." @@ -11,10 +12,11 @@ interface ConfirmParams { desc: string | React.ReactNode canelTxt?: string okTxt?: string + okLoadingTxt?: string showClose?: boolean onClose?: () => void onCancel?: () => void - onOk?: (next) => void + onOk?: (next: () => void) => void | Promise okDisabled?: boolean okHidden?: boolean } @@ -24,54 +26,111 @@ let openFn = (_: ConfirmParams) => { } function ConfirmWrapper() { const [open, setOpen] = useState(false) - const paramRef = useRef(null) + const [okLoading, setOkLoading] = useState(false) + const paramRef = useRef(null) openFn = (params: ConfirmParams) => { paramRef.current = params + setOkLoading(false) setOpen(true) } const close = () => { paramRef.current?.onClose?.() + setOkLoading(false) setOpen(false) } const handleCancelClick = () => { + if (okLoading) return paramRef.current?.onCancel?.() close() } - const handleOkClick = () => { - paramRef.current?.onOk - ? paramRef.current?.onOk?.(close) - : close() + const runOk = async () => { + if (okLoading || paramRef.current?.okDisabled) return + const onOk = paramRef.current?.onOk + if (!onOk) { + close() + return + } + setOkLoading(true) + try { + await Promise.resolve(onOk(close)) + } finally { + setOkLoading(false) + } + } + + const handleOkClick = async (event: MouseEvent) => { + event.preventDefault() + await runOk() } const handleContentKeyDown = (event: KeyboardEvent) => { + if (okLoading) return if (event.key !== "Enter" || event.nativeEvent.isComposing) { return } event.preventDefault() - handleOkClick() + void runOk() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && okLoading) return + setOpen(nextOpen) + if (!nextOpen) { + paramRef.current?.onClose?.() + setOkLoading(false) + } } if (!paramRef.current) return null - const { title, desc, okTxt, canelTxt, showClose = true, okDisabled = false, okHidden = false } = paramRef.current + const { + title, + desc, + okTxt, + okLoadingTxt, + canelTxt, + showClose = true, + okDisabled = false, + okHidden = false, + } = paramRef.current return ( - +
- {showClose && } + {showClose && !okLoading && ( + + )} {title} {desc}
- {canelTxt} - {!okHidden && {okTxt}} + + {canelTxt} + + {!okHidden && ( + void handleOkClick(event)} + disabled={okDisabled || okLoading} + className="px-11" + > + {okLoading && } + {okLoading ? okLoadingTxt || okTxt : okTxt} + + )}
diff --git a/src/frontend/platform/src/controllers/API/department.ts b/src/frontend/platform/src/controllers/API/department.ts index 2202886dc..530d89ded 100644 --- a/src/frontend/platform/src/controllers/API/department.ts +++ b/src/frontend/platform/src/controllers/API/department.ts @@ -247,13 +247,48 @@ export async function applyDepartmentMemberEditApi( ) } +export interface LocalMemberDeleteReceiverPreview { + user_id: number + user_name: string + source: "department_admin" | "platform_admin" + department_id?: number | null + department_name?: string | null +} + +export interface LocalMemberDeletePreview { + has_assets: boolean + counts: Record + transfer_count: number + linsight_delete_count: number + proposed_receiver: LocalMemberDeleteReceiverPreview | null +} + +export interface LocalMemberDeleteExecuteResult { + deleted_user_id: number + transfer: { + performed: boolean + receiver: LocalMemberDeleteReceiverPreview | null + transferred_count: number + counts_by_type: Record + transfer_log_ids: string[] + } + linsight_deleted: { + performed: boolean + deleted_count: number + counts: Record + } + personal_recycled: { + performed: boolean + recycled_count: number + folder_name: string + recycle_batch_id: string | null + } +} + export async function checkDepartmentMemberDeleteApi( deptId: string, userId: number -): Promise<{ - has_assets: boolean - counts: { knowledge_spaces: number; flows: number; assistants: number } -}> { +): Promise { return await axios.get( `/api/v1/departments/${depSeg(deptId)}/members/${userId}/delete-check` ) @@ -262,8 +297,8 @@ export async function checkDepartmentMemberDeleteApi( export async function deleteDepartmentLocalMemberApi( deptId: string, userId: number -): Promise { - await axios.delete( +): Promise { + return await axios.delete( `/api/v1/departments/${depSeg(deptId)}/members/${userId}/local-account` ) } diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx index cf344b880..63acff560 100644 --- a/src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx +++ b/src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx @@ -1,6 +1,6 @@ import { TreeDepartmentSelect } from "@/components/bs-comp/department/TreeDepartmentSelect" import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm" -import { Button } from "@/components/bs-ui/button" +import { Button, LoadButton } from "@/components/bs-ui/button" import { Dialog, DialogContent, @@ -32,6 +32,10 @@ import { captureAndAlertRequestErrorHoc } from "@/controllers/request" import type { DepartmentMember, DepartmentTreeNode } from "@/types/api/department" import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" +import { + buildLocalMemberDeleteConfirmDesc, + buildLocalMemberDeleteSuccessDescription, +} from "./localMemberDeleteHelpers" type Props = { open: boolean @@ -63,6 +67,7 @@ export function OrganizationMemberEditDialog({ const [primaryAssignableList, setPrimaryAssignableList] = useState< { id: number; role_name: string }[] >([]) + const [deleting, setDeleting] = useState(false) const resetLocal = useCallback(() => { setForm(null) @@ -168,49 +173,47 @@ export function OrganizationMemberEditDialog({ })) }, [form, primaryAssignableList]) - const handleDeleteLocalMember = useCallback(() => { + const handleDeleteLocalMember = useCallback(async () => { if (!member || !form) return - bsConfirm({ - desc: t("bs:department.deleteLocalMemberConfirm"), - onOk: async (close) => { - const chk = await captureAndAlertRequestErrorHoc( - checkDepartmentMemberDeleteApi(deptId, member.user_id) - ) - if (chk === false) { - close() - return - } - if (chk?.has_assets) { - const c = chk.counts - toast({ - title: t("prompt"), - variant: "warning", - description: t("bs:department.deleteLocalMemberBlocked", { - k: c.knowledge_spaces, - f: c.flows, - a: c.assistants, - }), - }) - close() - return - } - const delRes = await captureAndAlertRequestErrorHoc( - deleteDepartmentLocalMemberApi(deptId, member.user_id) - ) - if (delRes === false) { - close() - return - } + setDeleting(true) + try { + const preview = await captureAndAlertRequestErrorHoc( + checkDepartmentMemberDeleteApi(deptId, member.user_id) + ) + if (preview === false) return + + if (preview.transfer_count > 0 && !preview.proposed_receiver) { toast({ title: t("prompt"), - variant: "success", - description: t("bs:department.deleteLocalMemberDone"), + variant: "warning", + description: t("bs:department.deleteLocalMemberNoReceiver"), }) - onSaved() - onClose() - close() - }, - }) + return + } + + bsConfirm({ + desc: buildLocalMemberDeleteConfirmDesc(preview, t), + okLoadingTxt: t("bs:department.deleteLocalMemberProcessing"), + onOk: async (close) => { + const result = await captureAndAlertRequestErrorHoc( + deleteDepartmentLocalMemberApi(deptId, member.user_id) + ) + if (result === false) { + return + } + toast({ + title: t("prompt"), + variant: "success", + description: buildLocalMemberDeleteSuccessDescription(result, t), + }) + onSaved() + onClose() + close() + }, + }) + } finally { + setDeleting(false) + } }, [deptId, form, member, onClose, onSaved, t]) const title = useMemo(() => { @@ -455,14 +458,17 @@ export function OrganizationMemberEditDialog({
{form?.edit_mode === "local_primary" && ( - + {deleting + ? t("bs:department.deleteLocalMemberChecking") + : t("bs:department.deleteLocalMember")} + )}
diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/localMemberDeleteHelpers.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/localMemberDeleteHelpers.tsx new file mode 100644 index 000000000..bb60f38be --- /dev/null +++ b/src/frontend/platform/src/pages/DepartmentPage/components/localMemberDeleteHelpers.tsx @@ -0,0 +1,128 @@ +import type { + LocalMemberDeleteExecuteResult, + LocalMemberDeletePreview, + LocalMemberDeleteReceiverPreview, +} from "@/controllers/API/department" +import type { TFunction } from "i18next" +import type { ReactNode } from "react" + +const TRANSFER_ASSET_TYPES = [ + "knowledge_space", + "folder", + "knowledge_file", + "workflow", + "assistant", + "tool", + "channel", +] as const + +const LINSIGHT_ASSET_TYPES = [ + "linsight_session_version", + "linsight_sop", + "linsight_sop_record", +] as const + +function formatAssetLines( + counts: Record, + types: readonly string[], + t: TFunction +): string[] { + return types + .map((type) => ({ type, count: counts[type] ?? 0 })) + .filter(({ count }) => count > 0) + .map(({ type, count }) => + t(`bs:department.deleteLocalMemberAsset.${type}`, { count }) + ) +} + +export function formatLocalMemberDeleteReceiverLabel( + receiver: LocalMemberDeleteReceiverPreview, + t: TFunction +): string { + if (receiver.source === "department_admin") { + return t("bs:department.deleteLocalMemberReceiverDepartmentAdmin", { + userName: receiver.user_name, + departmentName: receiver.department_name ?? "-", + }) + } + return t("bs:department.deleteLocalMemberReceiverPlatformAdmin", { + userName: receiver.user_name, + }) +} + +export function buildLocalMemberDeleteConfirmDesc( + preview: LocalMemberDeletePreview, + t: TFunction +): ReactNode { + const transferLines = formatAssetLines(preview.counts, TRANSFER_ASSET_TYPES, t) + const linsightLines = formatAssetLines(preview.counts, LINSIGHT_ASSET_TYPES, t) + + return ( +
+

{t("bs:department.deleteLocalMemberConfirm")}

+ {preview.transfer_count > 0 && preview.proposed_receiver && transferLines.length > 0 && ( +
+

+ {t("bs:department.deleteLocalMemberTransferSection", { + receiver: formatLocalMemberDeleteReceiverLabel(preview.proposed_receiver, t), + })} +

+
    + {transferLines.map((line) => ( +
  • {line}
  • + ))} +
+
+ )} + {preview.linsight_delete_count > 0 && linsightLines.length > 0 && ( +
+

{t("bs:department.deleteLocalMemberLinsightSection")}

+
    + {linsightLines.map((line) => ( +
  • {line}
  • + ))} +
+
+ )} +
+ ) +} + +export function buildLocalMemberDeleteSuccessDescription( + result: LocalMemberDeleteExecuteResult, + t: TFunction +): string { + const parts = [t("bs:department.deleteLocalMemberDone")] + + if (result.transfer.performed && result.transfer.receiver) { + parts.push( + t("bs:department.deleteLocalMemberDoneTransferred", { + count: result.transfer.transferred_count, + receiver: formatLocalMemberDeleteReceiverLabel(result.transfer.receiver, t), + }) + ) + } + + if (result.linsight_deleted.performed && result.linsight_deleted.deleted_count > 0) { + parts.push( + t("bs:department.deleteLocalMemberDoneLinsight", { + count: result.linsight_deleted.deleted_count, + }) + ) + } + + if ( + result.personal_recycled?.performed && + result.personal_recycled.recycled_count > 0 && + result.personal_recycled.folder_name + ) { + parts.push( + t("bs:department.deleteLocalMemberDonePersonalRecycled", { + count: result.personal_recycled.recycled_count, + folderName: result.personal_recycled.folder_name, + }) + ) + } + + return parts.join(" ") +}