修改删除本地人员流程
1 第一步找出用户的所有资产
2 第二步先找到用户的上级管理员,沿着组织机构向上找,如果没有合适的管理员,就用平台管理员
3 将用户的资产转给管理员
4 执行现有的用户删除操作
5 删除完成后,页面上提示转移资产信息
6 Linsight 资产随用户一并删除
7 多个上级管理员,默认取第一个
8 在回收站新建一个用户同名的文件夹,然后将用户个人库中的内容放到回收站的用户同名文件夹中
This commit is contained in:
binfeng
2026-08-20 13:46:55 +08:00
parent 1a9b0c4d0e
commit 7cf59e8541
23 changed files with 2479 additions and 151 deletions
@@ -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: previewcounts / 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`
)
}
@@ -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">
@@ -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(" ")
}