mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-21 12:43:36 +08:00
feat:
修改删除本地人员流程 1 第一步找出用户的所有资产 2 第二步先找到用户的上级管理员,沿着组织机构向上找,如果没有合适的管理员,就用平台管理员 3 将用户的资产转给管理员 4 执行现有的用户删除操作 5 删除完成后,页面上提示转移资产信息 6 Linsight 资产随用户一并删除 7 多个上级管理员,默认取第一个 8 在回收站新建一个用户同名的文件夹,然后将用户个人库中的内容放到回收站的用户同名文件夹中
This commit is contained in:
@@ -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`
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "知識空間を作成",
|
||||
|
||||
@@ -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": "创建知识空间",
|
||||
|
||||
@@ -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<void>
|
||||
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<ConfirmParams | null>(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<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
await runOk()
|
||||
}
|
||||
|
||||
const handleContentKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
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 (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent onKeyDown={handleContentKeyDown}>
|
||||
<AlertDialogHeader className="relative">
|
||||
<div><TipIcon /></div>
|
||||
{showClose && <X onClick={close} className="absolute right-0 top-[-0.5rem] cursor-pointer text-gray-400 hover:text-gray-600"></X>}
|
||||
{showClose && !okLoading && (
|
||||
<X
|
||||
onClick={close}
|
||||
className="absolute right-0 top-[-0.5rem] cursor-pointer text-gray-400 hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-popover-foreground">
|
||||
{desc}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelClick} className="px-11">{canelTxt}</AlertDialogCancel>
|
||||
{!okHidden && <AlertDialogAction onClick={handleOkClick} disabled={okDisabled} className="px-11">{okTxt}</AlertDialogAction>}
|
||||
<AlertDialogCancel
|
||||
onClick={handleCancelClick}
|
||||
disabled={okLoading}
|
||||
className="px-11"
|
||||
>
|
||||
{canelTxt}
|
||||
</AlertDialogCancel>
|
||||
{!okHidden && (
|
||||
<AlertDialogAction
|
||||
onClick={(event) => void handleOkClick(event)}
|
||||
disabled={okDisabled || okLoading}
|
||||
className="px-11"
|
||||
>
|
||||
{okLoading && <LoadIcon className="mr-1" />}
|
||||
{okLoading ? okLoadingTxt || okTxt : okTxt}
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -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<string, number>
|
||||
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<string, number>
|
||||
transfer_log_ids: string[]
|
||||
}
|
||||
linsight_deleted: {
|
||||
performed: boolean
|
||||
deleted_count: number
|
||||
counts: Record<string, number>
|
||||
}
|
||||
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<LocalMemberDeletePreview> {
|
||||
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<void> {
|
||||
await axios.delete(
|
||||
): Promise<LocalMemberDeleteExecuteResult> {
|
||||
return await axios.delete(
|
||||
`/api/v1/departments/${depSeg(deptId)}/members/${userId}/local-account`
|
||||
)
|
||||
}
|
||||
|
||||
+50
-44
@@ -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({
|
||||
<DialogFooter className="flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
{form?.edit_mode === "local_primary" && (
|
||||
<Button
|
||||
<LoadButton
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={deleting}
|
||||
disabled={saving || loading}
|
||||
onClick={() => handleDeleteLocalMember()}
|
||||
onClick={() => void handleDeleteLocalMember()}
|
||||
>
|
||||
{t("bs:department.deleteLocalMember")}
|
||||
</Button>
|
||||
{deleting
|
||||
? t("bs:department.deleteLocalMemberChecking")
|
||||
: t("bs:department.deleteLocalMember")}
|
||||
</LoadButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
+128
@@ -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<string, number>,
|
||||
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 (
|
||||
<div className="space-y-3 text-left">
|
||||
<p>{t("bs:department.deleteLocalMemberConfirm")}</p>
|
||||
{preview.transfer_count > 0 && preview.proposed_receiver && transferLines.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("bs:department.deleteLocalMemberTransferSection", {
|
||||
receiver: formatLocalMemberDeleteReceiverLabel(preview.proposed_receiver, t),
|
||||
})}
|
||||
</p>
|
||||
<ul className="mt-1 list-inside list-disc text-sm">
|
||||
{transferLines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{preview.linsight_delete_count > 0 && linsightLines.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">{t("bs:department.deleteLocalMemberLinsightSection")}</p>
|
||||
<ul className="mt-1 list-inside list-disc text-sm">
|
||||
{linsightLines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(" ")
|
||||
}
|
||||
Reference in New Issue
Block a user