mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-30 17:58:00 +08:00
Reduce denied-action noise in knowledge permissions
Knowledge pages were still rendering disabled action controls for users without edit/delete/manage grants, which made readonly states look broken and invited backend denials. Hide unavailable operation columns and buttons, keep busy copy/unpublished states visible, and retain the permission investigation artifacts that explain the surrounding migration context. Constraint: Existing knowledge permission APIs already compute action grants; this change only adjusts presentation around those grants. Rejected: Keep disabled controls with permission tooltips | readonly users still see actions they cannot perform, which obscures the actual relation-model behavior. Confidence: medium Scope-risk: moderate Directive: Do not reintroduce disabled mutation controls for users lacking the matching action permission without validating the readonly UX. Tested: src/backend/.venv/bin/pytest src/backend/test/test_permission_relation_bindings.py -q Tested: npm run build in src/frontend/platform Not-tested: Browser manual QA for every knowledge library/file/QA permission combination
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
# 个人提交代码质量与重构评估报告
|
||||
|
||||
日期:2026-04-27
|
||||
|
||||
统计范围:`upstream/2.5.0-PM..HEAD`
|
||||
|
||||
作者口径:仅统计 `Y1fe1Zh0u <zhouyifei210@gmail.com>` 的非 merge commit
|
||||
|
||||
评估目标:判断当前个人提交是否存在“史山化”风险,并给出量化依据、实现层判断和可执行重构建议。
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
当前代码不是典型“史山”,更准确的判断是:
|
||||
|
||||
> 这是一个高复杂度、多版本兼容、权限迁移驱动的系统改造。代码量大有客观原因,当前实现有测试保护和一定抽象边界,但 API 层和部分核心 service 已经变厚,需要做边界治理。
|
||||
|
||||
综合评分:`72 / 100`
|
||||
|
||||
等级判断:`可维护,但需要治理复杂度分布`
|
||||
|
||||
不是“立即需要推倒重写”的状态,也不建议大规模重写。更合理的方向是小步抽取:保持 API contract 不变,把 endpoint 中的领域规则、兼容逻辑、展示组装逻辑下沉到 domain/application service。
|
||||
|
||||
## 2. 代码量统计
|
||||
|
||||
### 2.1 Commit 统计
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 当前分支非 merge commits 总数 | 543 |
|
||||
| 个人非 merge commits | 205 |
|
||||
| 个人 commit 占比 | 37.8% |
|
||||
| 触达唯一文件数 | 372 |
|
||||
| 生产文件数 | 283 |
|
||||
| 测试文件数 | 89 |
|
||||
|
||||
### 2.2 行数统计
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 累计新增 | 47,531 行 |
|
||||
| 累计删除 | 6,499 行 |
|
||||
| 累计 churn | 54,030 行 |
|
||||
| 平均每 commit churn | 263.6 行 |
|
||||
| 平均每 commit 触达文件数 | 5.18 |
|
||||
|
||||
说明:这里的 churn 是 commit 历史累计工作量,不是最终 diff。它更适合衡量“个人实际改动压力”和“代码热点反复修改程度”。
|
||||
|
||||
### 2.3 按模块分类
|
||||
|
||||
| 分类 | 文件数 | 新增 | 删除 | Churn | Churn 占比 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 测试 | 89 | 18,336 | 807 | 19,143 | 35.4% |
|
||||
| 后端生产代码 | 118 | 11,633 | 2,315 | 13,948 | 25.8% |
|
||||
| Client 前端 | 61 | 6,858 | 1,949 | 8,807 | 16.3% |
|
||||
| Platform 前端 | 82 | 4,080 | 1,347 | 5,427 | 10.0% |
|
||||
| 文档/规格 | 16 | 4,954 | 4 | 4,958 | 9.2% |
|
||||
| 其他 | 5 | 1,566 | 15 | 1,581 | 2.9% |
|
||||
| 运维/工具 | 1 | 104 | 62 | 166 | 0.3% |
|
||||
|
||||
正向信号:
|
||||
|
||||
- 测试 churn 占比达到 `35.4%`,说明不是单纯堆业务代码,有较强回归保护意识。
|
||||
- 平均每 commit 触达 `5.18` 个文件,虽然不小,但不是单 commit 巨型倾倒。
|
||||
- 个人提交中大量 commit message 是行为意图型,例如 `Prevent...`、`Align...`、`Restore...`,可追溯性较好。
|
||||
|
||||
风险信号:
|
||||
|
||||
- 删除/新增比为 `6,499 / 47,531 = 13.7%`,说明主要是增量兼容和修复,清理不足。
|
||||
- 权限、知识空间、权限 UI 的热点集中度较高。
|
||||
- 存在重复 subject 的 commit,说明有 cherry-pick、回放、修复反复痕迹。
|
||||
|
||||
## 3. 时间分布
|
||||
|
||||
| 日期 | 个人 commits |
|
||||
|---|---:|
|
||||
| 2026-04-20 | 5 |
|
||||
| 2026-04-21 | 12 |
|
||||
| 2026-04-22 | 14 |
|
||||
| 2026-04-23 | 61 |
|
||||
| 2026-04-24 | 26 |
|
||||
| 2026-04-25 | 33 |
|
||||
| 2026-04-26 | 37 |
|
||||
| 2026-04-27 | 17 |
|
||||
|
||||
判断:
|
||||
|
||||
- `2026-04-23` 单日 61 个 commit,是明显的高压迁移/修复峰值。
|
||||
- 这种节奏下,代码出现兼容分支、兜底逻辑、重复修复痕迹是合理现象。
|
||||
- 后续应进入“收口和治理”阶段,而不是继续以同样方式堆补丁。
|
||||
|
||||
## 4. 热点文件统计
|
||||
|
||||
| Churn | 新增 | 删除 | 文件 |
|
||||
|---:|---:|---:|---|
|
||||
| 1,840 | 1,657 | 183 | `src/backend/test/test_knowledge_space_service.py` |
|
||||
| 1,546 | 1,546 | 0 | `docs/architecture/数据库表结构与关联说明.md` |
|
||||
| 1,454 | 1,424 | 30 | `src/backend/test/test_permission_api_integration.py` |
|
||||
| 1,320 | 724 | 596 | `src/frontend/client/src/pages/Subscription/ChannelShareDialog.tsx` |
|
||||
| 1,314 | 1,053 | 261 | `src/backend/bisheng/permission/api/endpoints/resource_permission.py` |
|
||||
| 1,163 | 964 | 199 | `src/backend/bisheng/permission/domain/services/permission_service.py` |
|
||||
| 1,076 | 942 | 134 | `src/backend/test/test_knowledge_service_rebac_bridge.py` |
|
||||
| 895 | 604 | 291 | `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py` |
|
||||
| 810 | 745 | 65 | `src/backend/bisheng/approval/domain/services/approval_service.py` |
|
||||
| 699 | 488 | 211 | `src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx` |
|
||||
|
||||
判断:
|
||||
|
||||
- 热点不是随机分布,而是集中在权限、知识空间、审批、权限 UI。
|
||||
- 这些是 v2.5 权限迁移的核心业务区域,热点集中有业务必然性。
|
||||
- 真正需要治理的是 `resource_permission.py`、`permission_service.py`、`knowledge_space_service.py`、前端权限组件双写。
|
||||
|
||||
## 5. 实现层质量评估
|
||||
|
||||
### 5.1 权限核心:不是乱写,但 facade 偏厚
|
||||
|
||||
文件:`src/backend/bisheng/permission/domain/services/permission_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 文件行数 | 1,706 |
|
||||
| 函数数 | 43 |
|
||||
| 超过 80 行函数 | 3 |
|
||||
| 超过 150 行函数 | 0 |
|
||||
| 最大函数 | 101 行 |
|
||||
|
||||
最大函数和关键函数:
|
||||
|
||||
| 函数 | 行数 | 分支数 | 判断 |
|
||||
|---|---:|---:|---|
|
||||
| `batch_write_tuples` | 101 | 19 | OpenFGA 写入保护逻辑较重,但可解释 |
|
||||
| `check` | 91 | 15 | 核心权限检查路径,复杂但未失控 |
|
||||
| `list_accessible_ids` | 82 | 15 | 列表可见性逻辑偏复杂 |
|
||||
|
||||
判断:
|
||||
|
||||
- 该文件不是“巨型函数式史山”。
|
||||
- 问题是类级职责较宽,同时承担 OpenFGA check、租户短路、owner fallback、legacy alias、implicit dept admin、tuple fallback、subject enrichment 等多种职责。
|
||||
- 适合保留 `PermissionService` 作为 facade,但把内部协作者逐步抽出。
|
||||
|
||||
### 5.2 细粒度权限:方向正确,但规则引擎需要阶段化
|
||||
|
||||
文件:`src/backend/bisheng/permission/domain/services/fine_grained_permission_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 文件行数 | 514 |
|
||||
| 函数数 | 17 |
|
||||
| 超过 80 行函数 | 1 |
|
||||
| 最大函数 | 120 行 |
|
||||
|
||||
核心函数:
|
||||
|
||||
| 函数 | 行数 | 分支数 |
|
||||
|---|---:|---:|
|
||||
| `get_effective_permission_ids_async` | 120 | 29 |
|
||||
|
||||
判断:
|
||||
|
||||
- 这是当前权限系统真正的“规则引擎”。
|
||||
- 它回答的是“用户对资源最终有哪些 action permission ids”。
|
||||
- 复杂度是业务必需的,不应简单按行数否定。
|
||||
- 但应该拆成阶段:lineage 构造、tuple 读取、binding 匹配、legacy 过滤、implicit/default 权限补偿。
|
||||
|
||||
### 5.3 API 层:当前最需要治理
|
||||
|
||||
文件:`src/backend/bisheng/permission/api/endpoints/resource_permission.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 文件行数 | 1,356 |
|
||||
| 函数数 | 54 |
|
||||
| 超过 80 行函数 | 2 |
|
||||
| 超过 150 行函数 | 1 |
|
||||
| 最大函数 | 180 行 |
|
||||
|
||||
核心函数:
|
||||
|
||||
| 函数 | 行数 | 分支数 |
|
||||
|---|---:|---:|
|
||||
| `authorize_resource` | 180 | 43 |
|
||||
| `_add_implicit_permission_entries` | 88 | 16 |
|
||||
| `_apply_binding_metadata_to_permissions` | 71 | 12 |
|
||||
| `get_resource_permissions` | 67 | 6 |
|
||||
|
||||
这里所谓“API 承载功能过多”,不是说 API 不该提供这些功能。
|
||||
|
||||
API 当然应该继续提供:
|
||||
|
||||
- 授权
|
||||
- 撤销
|
||||
- 查询权限列表
|
||||
- 查询可授权 relation models
|
||||
- 管理 relation model
|
||||
- 为前端返回完整展示数据
|
||||
- 兼容老数据展示
|
||||
|
||||
问题是这些能力的实现细节不应该主要长在 endpoint 文件里。
|
||||
|
||||
当前 API 文件承载的职责包括:
|
||||
|
||||
| 当前职责 | 更合适的位置 |
|
||||
|---|---|
|
||||
| relation/model 映射 | `RelationModelService` |
|
||||
| grant tier 校验 | `GrantPolicyService` |
|
||||
| 调用人是否能授权 | `GrantPolicyService` |
|
||||
| binding key / binding metadata | `PermissionBindingService` |
|
||||
| legacy knowledge_space 兼容 | `PermissionCompatibilityService` |
|
||||
| creator owner entry | `PermissionEntryAssembler` |
|
||||
| implicit permission entries | `PermissionEntryAssembler` |
|
||||
| HTTP 参数和 response | endpoint |
|
||||
|
||||
建议目标:
|
||||
|
||||
| 指标 | 当前 | 目标 |
|
||||
|---|---:|---:|
|
||||
| `authorize_resource` | 180 行 | 40-70 行 |
|
||||
| endpoint 文件 | 1,356 行 | 500-800 行 |
|
||||
| API contract | 不变 | 不变 |
|
||||
| 业务规则位置 | endpoint | domain/application service |
|
||||
|
||||
### 5.4 知识空间:服务偏大,但不是乱
|
||||
|
||||
文件:`src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 文件行数 | 2,425 |
|
||||
| 函数数 | 88 |
|
||||
| 超过 80 行函数 | 3 |
|
||||
| 超过 150 行函数 | 1 |
|
||||
|
||||
核心函数:
|
||||
|
||||
| 函数 | 行数 | 分支数 |
|
||||
|---|---:|---:|
|
||||
| `batch_download` | 157 | 34 |
|
||||
| `add_file` | 139 | 24 |
|
||||
| `_format_accessible_spaces` | 97 | 18 |
|
||||
| `create_knowledge_space` | 77 | 6 |
|
||||
| `search_space_children` | 70 | 12 |
|
||||
|
||||
判断:
|
||||
|
||||
- 不是“一个函数塞全部逻辑”的典型史山。
|
||||
- 风险是 service 职责太宽:空间列表、文件上传、下载、成员、订阅、审批、权限展示都在一个类里。
|
||||
- 推荐保留 facade,逐步拆内部服务。
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
```text
|
||||
KnowledgeSpaceQueryService
|
||||
KnowledgeSpaceFileService
|
||||
KnowledgeSpaceMemberService
|
||||
KnowledgeSpaceSubscriptionService
|
||||
KnowledgeSpaceDownloadService
|
||||
```
|
||||
|
||||
### 5.5 审批:当前不是主要风险
|
||||
|
||||
文件:`src/backend/bisheng/approval/domain/services/approval_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 文件行数 | 680 |
|
||||
| 函数数 | 24 |
|
||||
| 超过 80 行函数 | 1 |
|
||||
| 最大函数 | 86 行 |
|
||||
|
||||
判断:
|
||||
|
||||
- 审批服务复杂度可控。
|
||||
- 当前不属于优先重构对象。
|
||||
- 主要注意审批与权限的边界:审批服务负责状态和 reviewer,权限服务负责最终 action 判断。
|
||||
|
||||
### 5.6 应用/工具权限服务:相对健康
|
||||
|
||||
文件:`src/backend/bisheng/permission/domain/services/application_permission_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 函数数 | 14 |
|
||||
| 超过 80 行函数 | 0 |
|
||||
| 最大函数 | 49 行 |
|
||||
|
||||
文件:`src/backend/bisheng/permission/domain/services/tool_permission_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 函数数 | 12 |
|
||||
| 超过 80 行函数 | 0 |
|
||||
| 最大函数 | 48 行 |
|
||||
|
||||
判断:
|
||||
|
||||
- 这两个模块不是主要风险。
|
||||
- 说明按资源类型拆 permission service 是有效方向。
|
||||
- 后续知识库、应用、工具可以继续保持这种局部权限门面模式。
|
||||
|
||||
### 5.7 旧 RBAC 同步和 reconcile:保留优先,重构优先级低
|
||||
|
||||
文件:`src/backend/bisheng/permission/domain/services/legacy_rbac_sync_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 函数数 | 24 |
|
||||
| 超过 80 行函数 | 0 |
|
||||
| 最大函数 | 35 行 |
|
||||
|
||||
文件:`src/backend/bisheng/permission/migration/reconcile_role_access_fga.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 函数数 | 7 |
|
||||
| 超过 80 行函数 | 0 |
|
||||
| 最大函数 | 75 行 |
|
||||
|
||||
判断:
|
||||
|
||||
- 这部分是迁移保护代码。
|
||||
- 函数规模可控,不建议优先大改。
|
||||
- 更重要的是补充兼容说明:支持哪个版本、处理哪种历史数据、什么时候可以删除。
|
||||
|
||||
### 5.8 角色服务:局部偏重
|
||||
|
||||
文件:`src/backend/bisheng/role/domain/services/role_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 函数数 | 30 |
|
||||
| 超过 80 行函数 | 1 |
|
||||
| 最大函数 | 132 行 |
|
||||
|
||||
核心函数:
|
||||
|
||||
| 函数 | 行数 | 分支数 |
|
||||
|---|---:|---:|
|
||||
| `list_roles` | 132 | 18 |
|
||||
| `update_role_with_menu` | 58 | 15 |
|
||||
| `update_role` | 50 | 13 |
|
||||
|
||||
判断:
|
||||
|
||||
- 不是最高危,但角色列表查询已经混合创建者、部门 scope、权限等级、菜单、配额等多种逻辑。
|
||||
- 如果后续继续改角色列表,建议顺手拆 `RoleQueryService`、`RoleScopeResolver`、`RoleCreatorResolver`。
|
||||
|
||||
### 5.9 部门知识空间:健康
|
||||
|
||||
文件:`src/backend/bisheng/knowledge/domain/services/department_knowledge_space_service.py`
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 函数数 | 14 |
|
||||
| 超过 80 行函数 | 0 |
|
||||
| 最大函数 | 54 行 |
|
||||
|
||||
判断:
|
||||
|
||||
- 职责明确,函数规模健康。
|
||||
- 可以作为后续拆分知识空间大 service 的参考模式。
|
||||
|
||||
### 5.10 前端权限 UI:主要问题是双端重复
|
||||
|
||||
Client 文件:
|
||||
|
||||
| 文件 | 行数 | 状态 |
|
||||
|---|---:|---|
|
||||
| `PermissionListTab.tsx` | 597 | 偏大 |
|
||||
| `PermissionGrantTab.tsx` | 353 | 可接受 |
|
||||
| `SubjectSearchDepartment.tsx` | 289 | 可接受 |
|
||||
|
||||
Platform 文件:
|
||||
|
||||
| 文件 | 行数 | 状态 |
|
||||
|---|---:|---|
|
||||
| `PermissionListTab.tsx` | 563 | 偏大 |
|
||||
| `PermissionGrantTab.tsx` | 353 | 可接受 |
|
||||
| `SubjectSearchDepartment.tsx` | 343 | 可接受 |
|
||||
|
||||
判断:
|
||||
|
||||
- 单个文件不是失控级别。
|
||||
- 真正风险是 Client / Platform 双份相似权限逻辑。
|
||||
- 后续权限规则变动时,容易只改一端,导致行为漂移。
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
usePermissionListModel
|
||||
usePermissionGrantModel
|
||||
useSubjectDepartmentSearch
|
||||
```
|
||||
|
||||
UI 可以继续分开,业务规则、筛选、禁用、owner 保护、grantable model 处理不要双写。
|
||||
|
||||
## 6. “历史保护”到底是什么
|
||||
|
||||
当前系统中合理存在的历史保护包括:
|
||||
|
||||
| 历史保护 | 必要性 |
|
||||
|---|---|
|
||||
| 2.3/2.4 旧 RBAC 数据兼容 | 老客户不能一次性全量迁移 |
|
||||
| `knowledge_space` 到 `knowledge_library` 的兼容映射 | 旧权限对象和新权限对象不一致 |
|
||||
| legacy binding key | 旧 binding 缺少新 scope / include_children 信息 |
|
||||
| legacy subscription viewer tuple 过滤 | 防止旧订阅状态被误认为新授权 |
|
||||
| creator owner entry | 老资源缺 owner tuple 时仍能展示和管理 |
|
||||
| owner fallback | OpenFGA 写入延迟或迁移缺口时保护创建者 |
|
||||
| RBAC/ReBAC 同步 | 迁移期保证旧角色授权和新 FGA tuple 不漂移 |
|
||||
| reconcile 脚本 | 对账和修复历史数据 |
|
||||
|
||||
这些逻辑不能简单删除。
|
||||
|
||||
建议不是删除历史保护,而是把它们集中命名:
|
||||
|
||||
```text
|
||||
permission/domain/compat/
|
||||
permission_compatibility_service.py
|
||||
legacy_binding_adapter.py
|
||||
legacy_knowledge_resource_adapter.py
|
||||
```
|
||||
|
||||
每个兼容逻辑都要注明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| 支持版本 | 例如 `2.3/2.4 migrated customers` |
|
||||
| 历史数据形态 | 例如 `legacy role_access resource grants` |
|
||||
| 保护行为 | 例如 `map knowledge_space binding to knowledge_library` |
|
||||
| 删除条件 | 例如 `all active tenants migration_version >= 2.5.x` |
|
||||
| 对应测试 | 例如 `test_legacy_knowledge_library_binding` |
|
||||
|
||||
## 7. 为什么重构不一定减少代码量
|
||||
|
||||
在这个项目里,代码量大不是核心问题。
|
||||
|
||||
必须存在的代码包括:
|
||||
|
||||
| 代码类型 | 是否能删 |
|
||||
|---|---|
|
||||
| 2.3/2.4 兼容逻辑 | 不能马上删 |
|
||||
| ReBAC 新权限模型 | 不能删 |
|
||||
| RBAC/ReBAC 双写和同步 | 迁移期不能删 |
|
||||
| OpenFGA fallback / compensation | 不能删 |
|
||||
| 权限 UI 展示和授权操作 | 不能删 |
|
||||
| 迁移脚本 / reconcile 脚本 | 不能删 |
|
||||
| 回归测试 | 不该删 |
|
||||
|
||||
所以重构后的总代码量可能:
|
||||
|
||||
| 项目 | 预期 |
|
||||
|---|---|
|
||||
| 总 LOC | 基本不变,甚至短期增加 5%-15% |
|
||||
| 文件数量 | 增加 |
|
||||
| 单个核心文件 LOC | 降低 |
|
||||
| 单个函数 LOC | 降低 |
|
||||
| 单次改动理解成本 | 降低 |
|
||||
| 兼容逻辑删除成本 | 降低 |
|
||||
|
||||
重构目标不是“少写代码”,而是:
|
||||
|
||||
> 让必须存在的复杂度各就各位。
|
||||
|
||||
## 8. 重构优先级
|
||||
|
||||
### P0:不要推倒重写
|
||||
|
||||
原因:
|
||||
|
||||
- 多版本客户仍然存在。
|
||||
- 迁移数据量大。
|
||||
- 当前已有测试保护。
|
||||
- 大重写会破坏已经被测试锁住的兼容行为。
|
||||
|
||||
### P1:瘦 `resource_permission.py`
|
||||
|
||||
目标文件:
|
||||
|
||||
`src/backend/bisheng/permission/api/endpoints/resource_permission.py`
|
||||
|
||||
建议拆出:
|
||||
|
||||
```text
|
||||
ResourcePermissionApplicationService
|
||||
GrantPolicyService
|
||||
RelationModelService
|
||||
PermissionBindingService
|
||||
PermissionCompatibilityService
|
||||
PermissionEntryAssembler
|
||||
```
|
||||
|
||||
API contract 保持不变。
|
||||
|
||||
重构前:
|
||||
|
||||
```text
|
||||
endpoint = HTTP + 业务规则 + 兼容逻辑 + 展示组装
|
||||
```
|
||||
|
||||
重构后:
|
||||
|
||||
```text
|
||||
endpoint = HTTP 入参/出参
|
||||
application service = 用例编排
|
||||
policy service = 授权规则
|
||||
compatibility service = 旧版本兼容
|
||||
assembler = 前端展示结果组装
|
||||
```
|
||||
|
||||
### P2:阶段化 `FineGrainedPermissionService`
|
||||
|
||||
目标文件:
|
||||
|
||||
`src/backend/bisheng/permission/domain/services/fine_grained_permission_service.py`
|
||||
|
||||
当前核心函数:
|
||||
|
||||
```text
|
||||
get_effective_permission_ids_async: 120 行,29 个分支
|
||||
```
|
||||
|
||||
建议拆成:
|
||||
|
||||
```text
|
||||
build_resource_lineage
|
||||
read_permission_tuples
|
||||
match_relation_model_bindings
|
||||
filter_legacy_subscription_tuples
|
||||
apply_implicit_permissions
|
||||
resolve_effective_permission_ids
|
||||
```
|
||||
|
||||
### P3:拆 `KnowledgeSpaceService`,保留 facade
|
||||
|
||||
目标文件:
|
||||
|
||||
`src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py`
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
KnowledgeSpaceService # 保留 facade
|
||||
KnowledgeSpaceQueryService # 列表/广场/详情
|
||||
KnowledgeSpaceFileService # 上传/文件夹/文件管理
|
||||
KnowledgeSpaceDownloadService # 下载/批量下载
|
||||
KnowledgeSpaceMemberService # 成员/角色
|
||||
KnowledgeSpaceSubscriptionService # 订阅/申请
|
||||
```
|
||||
|
||||
### P4:前端抽 headless hooks
|
||||
|
||||
目标:
|
||||
|
||||
- Client 和 Platform UI 可以不同。
|
||||
- 权限业务规则不要重复实现。
|
||||
|
||||
建议抽:
|
||||
|
||||
```text
|
||||
usePermissionListModel
|
||||
usePermissionGrantModel
|
||||
useSubjectDepartmentSearch
|
||||
```
|
||||
|
||||
### P5:角色服务下次改动时顺手拆
|
||||
|
||||
目标文件:
|
||||
|
||||
`src/backend/bisheng/role/domain/services/role_service.py`
|
||||
|
||||
建议拆:
|
||||
|
||||
```text
|
||||
RoleQueryService
|
||||
RoleScopeResolver
|
||||
RoleCreatorResolver
|
||||
RoleMutationPolicy
|
||||
```
|
||||
|
||||
## 9. 后续质量红线
|
||||
|
||||
建议设置以下红线,不要求一次性全部达成,但作为后续增量开发标准。
|
||||
|
||||
| 指标 | 红线 |
|
||||
|---|---:|
|
||||
| API endpoint 单函数 | 不超过 70 行 |
|
||||
| domain service 单函数 | 不超过 100 行 |
|
||||
| 单个 service 文件 | 超过 1,500 行必须评估拆分 |
|
||||
| 新增兼容逻辑 | 必须注明支持版本和删除条件 |
|
||||
| 新增 fallback | 必须有测试覆盖 |
|
||||
| 前端权限判断 | 不允许 Client / Platform 双写新规则 |
|
||||
| 权限规则变更 | 必须覆盖 happy path + denied path |
|
||||
| 迁移逻辑 | 必须有 reconcile 或幂等保护 |
|
||||
|
||||
## 10. 最终判断
|
||||
|
||||
这批代码不应该简单评价为“史山”。
|
||||
|
||||
更准确的评价:
|
||||
|
||||
> 代码量大是业务和迁移复杂度导致的,测试投入较高,整体仍可维护。当前最主要风险不是 LOC,而是复杂度集中在少数 endpoint 和 facade service 中。如果继续以补 if、补 fallback 的方式推进,未来会滑向“准史山”;如果现在做分层治理,成本仍然可控。
|
||||
|
||||
最优先行动:
|
||||
|
||||
1. 不重写。
|
||||
2. 保持 API contract 不变。
|
||||
3. 先瘦 `resource_permission.py`。
|
||||
4. 把兼容逻辑集中命名并标注删除条件。
|
||||
5. 阶段化 `FineGrainedPermissionService` 的权限解析流程。
|
||||
6. 前端抽 headless 权限逻辑,避免 Client / Platform 漂移。
|
||||
|
||||
一句话总结:
|
||||
|
||||
> 重构不会让代码量明显减少,但会让每次改动需要理解的代码量明显减少。
|
||||
@@ -0,0 +1,199 @@
|
||||
# 权限迁移与数据丢失排查结论
|
||||
|
||||
日期:2026-04-27
|
||||
|
||||
## 结论
|
||||
|
||||
当前“丢了不少书籍”的主要表现更像是权限迁移后资源不可见,而不是 F006 权限迁移脚本批量删除了 MySQL 中的知识库或知识文件。
|
||||
|
||||
109 服务器上,`knowledge` 表没有发现备份后被删除的记录;`knowledgefile` 表只确认有 1 条备份中存在、当前库缺失的历史记录:`id=176 / PDF_TestPage.pdf / knowledge_id=49`。该记录在 `2026-04-21 14:57:12` 被删除,早于 F006 权限迁移执行时间。
|
||||
|
||||
权限层面存在明确问题:OpenFGA failed_tuple 队列中有知识空间相关的失败写入,集中在把 `department:X#member` 写成 `knowledge_space#owner`,OpenFGA 模型拒绝该 tuple。这类问题会造成资源仍在数据库中,但用户或部门看不到。
|
||||
|
||||
## 已确认的迁移执行记录
|
||||
|
||||
### 109 服务器
|
||||
|
||||
F006 在后端启动过程中自动触发,不是从 shell 手动执行。
|
||||
|
||||
- 执行时间:`2026-04-25 15:27:05`
|
||||
- checkpoint:`completed_step = 8`
|
||||
- checkpoint 时间:`2026-04-25T15:27:11.106508`
|
||||
- 统计:
|
||||
- Step 1 super_admin:3
|
||||
- Step 2 user_group:16
|
||||
- Step 3 role_access:0
|
||||
- Step 4 space_channel_member:115
|
||||
- Step 5 resource owners:126
|
||||
- Step 6 folder hierarchy:220
|
||||
- Step 7 user_department:22
|
||||
- Step 8 groupresource:137
|
||||
- 当前 Alembic 版本:`f032_workbench_subscription_web_menu_backfill`
|
||||
|
||||
### 114 服务器
|
||||
|
||||
114 上存在分阶段执行记录。
|
||||
|
||||
- `2026-04-22 19:55:50`:旧版本 F006 跑到 Step 6,写入 124 tuples
|
||||
- `2026-04-25 15:10:59`:checkpoint 显示 Step 8 完成
|
||||
- 当前 Alembic 版本:`f030_tenant_root_dept_id_backfill`
|
||||
|
||||
## MySQL 数据对账结果
|
||||
|
||||
### 109:4/21 备份 vs 当前库
|
||||
|
||||
备份文件:`/data/bisheng-mysql-recovery-20260421-113612.sql.gz`
|
||||
|
||||
`knowledge`:
|
||||
|
||||
- 备份:29 条,ID 范围 `1..50`
|
||||
- 当前:49 条,ID 范围 `1..70`
|
||||
- 备份中存在但当前缺失:0 条
|
||||
|
||||
`knowledgefile`:
|
||||
|
||||
- 备份:201 条,ID 范围 `1..244`
|
||||
- 当前:221 条,ID 范围 `1..270`
|
||||
- 备份中存在但当前缺失:1 条
|
||||
|
||||
缺失记录:
|
||||
|
||||
```text
|
||||
id=176
|
||||
knowledge_id=49
|
||||
file_name=PDF_TestPage.pdf
|
||||
status=3
|
||||
object_name=original/176.pdf
|
||||
create_time=2026-04-20 19:27:14
|
||||
```
|
||||
|
||||
### 109:binlog 删除记录
|
||||
|
||||
检查窗口:`2026-04-21 11:36:12` 到 `2026-04-27 23:59:59`
|
||||
|
||||
- `knowledge` DELETE:0
|
||||
- `knowledgefile` DELETE:6
|
||||
|
||||
6 条 `knowledgefile` DELETE 中,只有 `id=176` 是备份已有且当前缺失的记录;其余为备份后新建又删除的文件。`id=176` 的删除时间是 `2026-04-21 14:57:12`,早于 F006 在 `2026-04-25` 的执行时间。
|
||||
|
||||
`2026-04-24` 之后没有发现 `knowledge` 或 `knowledgefile` 的 DELETE。
|
||||
|
||||
### 114:binlog 检查
|
||||
|
||||
检查窗口:`2026-04-22 00:00:00` 到 `2026-04-27 23:59:59`
|
||||
|
||||
- `knowledge` DELETE:0
|
||||
- `knowledgefile` DELETE:0
|
||||
|
||||
114 未找到可用于做 ID 级对账的 SQL 备份文件。
|
||||
|
||||
## 权限层问题
|
||||
|
||||
### 109 failed_tuple
|
||||
|
||||
当前 `failed_tuple` 状态:
|
||||
|
||||
- `succeeded`:241
|
||||
- `dead`:41
|
||||
|
||||
dead 记录集中在:
|
||||
|
||||
- `owner -> knowledge_space:53`
|
||||
- `owner -> knowledge_space:55`
|
||||
- `owner -> knowledge_space:59`
|
||||
|
||||
错误类型:
|
||||
|
||||
```text
|
||||
department:X#member is not an allowed type restriction for knowledge_space#owner
|
||||
```
|
||||
|
||||
这说明迁移或后续权限写入曾尝试把部门成员关系写成知识空间 owner。该 tuple 不符合 OpenFGA authorization model,因此无法写入。结果是相关知识空间可能在 MySQL 中存在,但部门/用户侧不可见或权限不完整。
|
||||
|
||||
涉及的知识空间包括:
|
||||
|
||||
- `knowledge_space:53`:`yifeitest2`
|
||||
- `knowledge_space:55`:`yifeitest3`
|
||||
- `knowledge_space:59`:`毕昇测试环境SSO的知识空间`
|
||||
|
||||
### 114 failed_tuple
|
||||
|
||||
当前 `failed_tuple` 状态:
|
||||
|
||||
- `pending`:83
|
||||
- `succeeded`:70
|
||||
|
||||
pending 中有较多旧的 `FGAClient not available` 失败记录,也包含指向历史测试资源或已不存在资源的 tuple。114 需要先做有效性清理,再重放仍然有效的权限。
|
||||
|
||||
## 脚本风险点
|
||||
|
||||
### 1. checkpoint 与 Redis completed marker 状态不一致
|
||||
|
||||
F006 runner 使用两个完成信号:
|
||||
|
||||
- 文件 checkpoint:`/tmp/bisheng-permission-migration/migration_f006_checkpoint.json`
|
||||
- Redis marker:`migration:f006:completed`
|
||||
|
||||
现场看到 checkpoint 已经完成到 Step 8,但 Redis completed marker 之前检查为空。这样会导致“脚本看起来跑过”和“runner 判断是否完成”的状态来源不一致。
|
||||
|
||||
### 2. checkpoint 跳过粒度太粗
|
||||
|
||||
F006 每个 Step 成功后保存 checkpoint。后续如果发现某个 Step 的语义映射有问题,重启会直接跳过已完成 Step,不会自动补写或纠错。
|
||||
|
||||
### 3. FGAWriteError 中 `cannot write` 被当成成功
|
||||
|
||||
代码位置:
|
||||
|
||||
`src/backend/bisheng/permission/migration/migrate_rbac_to_rebac.py`
|
||||
|
||||
`_write_singles()` 中把包含 `already exists` 或 `cannot write` 的错误都算作成功:
|
||||
|
||||
```python
|
||||
if 'already exists' in err_lower or 'cannot write' in err_lower:
|
||||
written += 1
|
||||
```
|
||||
|
||||
`already exists` 可以视为幂等成功,但 `cannot write` 可能是真实模型不允许写入,不应该算作成功。线上 failed_tuple 中的知识空间 owner 错误就属于这类语义错误风险。
|
||||
|
||||
### 4. verify 覆盖不足
|
||||
|
||||
F006 的 verify 是抽样验证,并且主要比较 `can_read`。它无法证明所有知识库、知识空间、文件夹、知识文件的 owner/manager/editor/viewer/parent 关系都完整迁移。
|
||||
|
||||
## 本地测试结果
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
src/backend/.venv/bin/python -m pytest \
|
||||
src/backend/test/test_f006_permission_migration.py \
|
||||
src/backend/test/test_reconcile_role_access_fga_entrypoint.py
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
61 passed
|
||||
```
|
||||
|
||||
现有测试能覆盖 F006 映射、流程、checkpoint、基础幂等场景,但没有覆盖线上发现的这些关键风险:
|
||||
|
||||
- `cannot write` 不应被视为成功
|
||||
- failed_tuple 中无效 owner tuple 的修复策略
|
||||
- checkpoint 完成但 Redis marker 缺失
|
||||
- 全量资源可见性对账
|
||||
- 知识空间、文件夹、知识文件 parent 链路的端到端可见性
|
||||
|
||||
## 下一步修复建议
|
||||
|
||||
1. 增加只读对账脚本,从 MySQL 旧权限表和当前资源表生成“应有 OpenFGA tuple”,与当前 OpenFGA tuple 做 diff。
|
||||
2. 对 109 先处理 `knowledge_space:53/55/59` 的无效 owner failed_tuple,按正确 relation 重建权限,不要直接 retry 原 tuple。
|
||||
3. 对 114 先清理 pending failed_tuple:区分仍有效资源、已删除资源、历史测试资源,再重放有效 tuple。
|
||||
4. 修改 F006 `_write_singles()`,只把 `already exists` 视为幂等成功;`cannot write` 必须进入 failed_tuple 并让迁移失败或至少显式报警。
|
||||
5. 给 F006 增加全量 verify 模式,至少输出:
|
||||
- MySQL 资源总数
|
||||
- 应有 tuple 数
|
||||
- OpenFGA 实际 tuple 数
|
||||
- 缺失 tuple 明细
|
||||
- 无效/stale tuple 明细
|
||||
6. 明确 checkpoint 与 Redis completed marker 的权威来源,避免只完成 checkpoint 但没有 completed marker 的半完成状态。
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Regression tests for permission relation-model bindings."""
|
||||
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_admin_user():
|
||||
|
||||
@@ -372,6 +372,13 @@ export default function KnowledgeFile() {
|
||||
const canUseCopy = (el: any) => canCreateLibrary && canReadRow(el);
|
||||
const canManageKb = (el: any) =>
|
||||
KB_MANAGE_PERMISSION_IDS.some((permissionId) => hasPermissionId(permIds, el.id, permissionId));
|
||||
const isLibraryBusy = (el: any) =>
|
||||
[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state);
|
||||
const canCopy = (el: any) =>
|
||||
canUseCopy(el) && el.state === KnowledgeBaseStatus.Published;
|
||||
const hasRowActions = (el: any) =>
|
||||
canManageKb(el) || canCopy(el) || canEdit(el) || canDelete(el);
|
||||
const showOperationsColumn = visibleLibs.some((el: any) => isLibraryBusy(el) || hasRowActions(el));
|
||||
|
||||
// Enable polling during copying
|
||||
useEffect(() => {
|
||||
@@ -524,7 +531,9 @@ export default function KnowledgeFile() {
|
||||
<TableHead>{t('lib.libraryName', { ns: 'bs' })}</TableHead>
|
||||
<TableHead>{t('updateTime')}</TableHead>
|
||||
<TableHead>{t('lib.createUser', { ns: 'bs' })}</TableHead>
|
||||
<TableHead className="text-right">{t('operations')}</TableHead>
|
||||
{showOperationsColumn && (
|
||||
<TableHead className="text-right">{t('operations')}</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -575,13 +584,13 @@ export default function KnowledgeFile() {
|
||||
<div className="truncate-multiline text-[#5A5A5A]">{el.user_name || '--'}</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
{showOperationsColumn && <TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Select
|
||||
{(isLibraryBusy(el) || hasRowActions(el)) && <Select
|
||||
key={`${el.id}-${modalKey}`}
|
||||
open={selectOpenId === el.id}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (el.state === 2 || el.state === 0) return;
|
||||
if (isLibraryBusy(el) || !hasRowActions(el)) return;
|
||||
if (copyLoadingId !== el.id) {
|
||||
setSelectOpenId(isOpen ? el.id : null);
|
||||
} else if (!isOpen) {
|
||||
@@ -600,9 +609,7 @@ export default function KnowledgeFile() {
|
||||
setModalKey(prev => prev + 1);
|
||||
break;
|
||||
case 'copy':
|
||||
canUseCopy(el) &&
|
||||
el.state === KnowledgeBaseStatus.Published &&
|
||||
handleCopy(el);
|
||||
canCopy(el) && handleCopy(el);
|
||||
break;
|
||||
case 'set':
|
||||
canEdit(el) && handleOpenSettings(el);
|
||||
@@ -629,10 +636,10 @@ export default function KnowledgeFile() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
|
||||
hasRowActions(el) && <Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
{hasRowActions(el) && <SelectContent
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
@@ -646,28 +653,21 @@ export default function KnowledgeFile() {
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
<Tip content={!canUseCopy(el) && t('noOperationPermission')} side='top'>
|
||||
{canCopy(el) && (
|
||||
<SelectItem
|
||||
showIcon={false}
|
||||
value="copy"
|
||||
className="data-[disabled]:pointer-events-auto"
|
||||
disabled={
|
||||
!canUseCopy(el) ||
|
||||
el.state !== KnowledgeBaseStatus.Published ||
|
||||
copyLoadingId === el.id
|
||||
}
|
||||
disabled={copyLoadingId === el.id}
|
||||
>
|
||||
<div className="flex gap-2 items-center" >
|
||||
<Copy className="w-4 h-4" />
|
||||
{t('lib.copy', { ns: 'bs' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</Tip>
|
||||
<Tip content={!canEdit(el) && t('noOperationPermission')} side='top'>
|
||||
)}
|
||||
{canEdit(el) && (
|
||||
<SelectItem
|
||||
value="set"
|
||||
disabled={!canEdit(el)}
|
||||
className="data-[disabled]:pointer-events-auto"
|
||||
showIcon={false}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -675,24 +675,22 @@ export default function KnowledgeFile() {
|
||||
{t('settings')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</Tip>
|
||||
<Tip content={!canDelete(el) && t('noOperationPermission')} side='top'>
|
||||
)}
|
||||
{canDelete(el) && (
|
||||
<SelectItem
|
||||
value="delete"
|
||||
showIcon={false}
|
||||
className="data-[disabled]:pointer-events-auto"
|
||||
disabled={!canDelete(el)}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('delete')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</Tip>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</SelectContent>}
|
||||
</Select>}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -6,13 +6,11 @@ import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDial
|
||||
import { hasPermissionId, usePermissionIds } from "@/components/bs-comp/permission/usePermissionLevels";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
|
||||
import { Input, SearchInput, Textarea } from "@/components/bs-ui/input";
|
||||
import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge";
|
||||
import AutoPagination from "@/components/bs-ui/pagination/autoPagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/bs-ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/bs-ui/table";
|
||||
import { useToast } from "@/components/bs-ui/toast/use-toast";
|
||||
import { QuestionTooltip } from "@/components/bs-ui/tooltip";
|
||||
import Tip from "@/components/bs-ui/tooltip/tip";
|
||||
import { userContext } from "@/contexts/userContext";
|
||||
import { copyQaDatabase, createFileLib, deleteFileLib, readFileLibDatabase, updateKnowledge } from "@/controllers/API";
|
||||
import { getKnowledgeModelConfig } from "@/controllers/API/finetune";
|
||||
@@ -319,6 +317,13 @@ export default function KnowledgeQa(params) {
|
||||
const canUseCopy = (el: any) => canCreateLibrary && canReadRow(el);
|
||||
const canManageKb = (el: any) =>
|
||||
KB_MANAGE_PERMISSION_IDS.some((permissionId) => hasPermissionId(permIds, el.id, permissionId));
|
||||
const isLibraryBusy = (el: any) =>
|
||||
[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state);
|
||||
const canCopy = (el: any) =>
|
||||
canUseCopy(el) && el.state === KnowledgeBaseStatus.Published;
|
||||
const hasRowActions = (el: any) =>
|
||||
canManageKb(el) || canCopy(el) || canEdit(el) || canDelete(el);
|
||||
const showOperationsColumn = visibleLibs.some((el: any) => isLibraryBusy(el) || hasRowActions(el));
|
||||
|
||||
useEffect(() => {
|
||||
const todos = datalist.filter(lib => lib.state === KnowledgeBaseStatus.Copying);
|
||||
@@ -417,7 +422,9 @@ export default function KnowledgeQa(params) {
|
||||
<TableHead>{t('lib.libraryName', { ns: 'bs' })}</TableHead>
|
||||
<TableHead>{t('updateTime')}</TableHead>
|
||||
<TableHead>{t('lib.createUser', { ns: 'bs' })}</TableHead>
|
||||
<TableHead className="text-right">{t('operations')}</TableHead>
|
||||
{showOperationsColumn && (
|
||||
<TableHead className="text-right">{t('operations')}</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -440,7 +447,6 @@ export default function KnowledgeQa(params) {
|
||||
<div>
|
||||
<div className="truncate max-w-[500px] w-[264px] text-[14px] font-medium pt-2 flex items-center gap-2">
|
||||
{el.name}
|
||||
<PermissionBadge level={permLevels[String(el.id)]} />
|
||||
</div>
|
||||
<QuestionTooltip
|
||||
content={el.description || ''}
|
||||
@@ -460,13 +466,13 @@ export default function KnowledgeQa(params) {
|
||||
<TableCell className="max-w-[300px] break-all">
|
||||
<div className="truncate-multiline text-[#5A5A5A]">{el.user_name || '--'}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{showOperationsColumn && <TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Select
|
||||
{(isLibraryBusy(el) || hasRowActions(el)) && <Select
|
||||
key={`${el.id}-${modalKey}`}
|
||||
open={selectOpenId === el.id}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (el.state === 2 || el.state === 0) return;
|
||||
if (isLibraryBusy(el) || !hasRowActions(el)) return;
|
||||
if (copyLoadingId !== el.id) {
|
||||
setSelectOpenId(isOpen ? el.id : null);
|
||||
} else if (!isOpen) {
|
||||
@@ -482,9 +488,7 @@ export default function KnowledgeQa(params) {
|
||||
setModalKey(prev => prev + 1);
|
||||
break;
|
||||
case 'copy':
|
||||
canUseCopy(el) &&
|
||||
el.state === KnowledgeBaseStatus.Published &&
|
||||
handleCopy(el);
|
||||
canCopy(el) && handleCopy(el);
|
||||
break;
|
||||
case 'set':
|
||||
canEdit(el) && handleOpenSettings(el);
|
||||
@@ -509,10 +513,10 @@ export default function KnowledgeQa(params) {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
|
||||
hasRowActions(el) && <Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
{hasRowActions(el) && <SelectContent
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="z-50 overflow-visible"
|
||||
>
|
||||
@@ -524,26 +528,21 @@ export default function KnowledgeQa(params) {
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
<Tip content={!canUseCopy(el) && t('noPermission')} side='top'>
|
||||
{canCopy(el) && (
|
||||
<SelectItem
|
||||
showIcon={false}
|
||||
value="copy"
|
||||
disabled={
|
||||
!canUseCopy(el) ||
|
||||
el.state !== KnowledgeBaseStatus.Published ||
|
||||
copyLoadingId === el.id
|
||||
}
|
||||
disabled={copyLoadingId === el.id}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Copy className="w-4 h-4" />
|
||||
{t('lib.copy', { ns: 'bs' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</Tip>
|
||||
<Tip content={!canEdit(el) && t('noPermission')} side='top'>
|
||||
)}
|
||||
{canEdit(el) && (
|
||||
<SelectItem
|
||||
value="set"
|
||||
disabled={!canEdit(el)}
|
||||
showIcon={false}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -551,23 +550,22 @@ export default function KnowledgeQa(params) {
|
||||
{t('setting')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</Tip>
|
||||
<Tip content={!canDelete(el) && t('noPermission')} side='top'>
|
||||
)}
|
||||
{canDelete(el) && (
|
||||
<SelectItem
|
||||
value="delete"
|
||||
showIcon={false}
|
||||
disabled={!canDelete(el)}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('delete')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</Tip>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</SelectContent>}
|
||||
</Select>}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -246,6 +246,7 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
// Batch Download
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const canBatchEditTags = canEditKb && selectedFileObjs.length > 0 && selectedFileObjs.every(file => file.status === 2);
|
||||
const hasRowActions = canEditKb || canDeleteKb;
|
||||
const handleBatchDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
@@ -401,7 +402,8 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
{isDownloading ? <LoadingIcon className="h-4 w-4 mr-1" /> : <Download size={16} />}
|
||||
<span className="hidden sm:inline">{t('download', { ns: 'bs' })}</span>
|
||||
</Button>
|
||||
<Tip content={!canEditKb ? t('noOperationPermission') : !canBatchEditTags ? t('tagOperationRequiresCompletedFiles') : ''} side='bottom'>
|
||||
{canEditKb && (
|
||||
<Tip content={!canBatchEditTags ? t('tagOperationRequiresCompletedFiles') : ''} side='bottom'>
|
||||
<KnowledgeTagSelect
|
||||
knowledgeId={id}
|
||||
fileIds={selectedFileObjs.map(f => Number(f.id))}
|
||||
@@ -418,30 +420,27 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
<span className="hidden sm:inline">{t('tags')}</span>
|
||||
</Button>
|
||||
</KnowledgeTagSelect>
|
||||
</Tip>
|
||||
<Tip content={!canDeleteKb && t('noOperationPermission')} side='bottom'>
|
||||
</Tip>
|
||||
)}
|
||||
{canDeleteKb && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBatchDelete}
|
||||
disabled={!canDeleteKb}
|
||||
className="flex items-center gap-1 disabled:pointer-events-auto h-9 px-2 sm:px-4"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span className="hidden sm:inline">{t('delete')}</span>
|
||||
</Button>
|
||||
</Tip>
|
||||
{hasSelectedFailedFiles && (
|
||||
<Tip content={!canEditKb && t('noOperationPermission')} side='bottom'>
|
||||
)}
|
||||
{canEditKb && hasSelectedFailedFiles && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBatchRetry}
|
||||
disabled={!canEditKb}
|
||||
className="flex items-center gap-1 disabled:pointer-events-auto h-9 px-2 sm:px-4"
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
<span className="hidden sm:inline">{t('retry')}</span>
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -453,15 +452,16 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
setSelectedFileObjs([]);
|
||||
setIsAllSelected(false);
|
||||
}} />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setMetadataOpen(true)}
|
||||
disabled={!canEditKb}
|
||||
className="px-2 md:px-4 whitespace-nowrap h-9"
|
||||
>
|
||||
<ClipboardPenLine size={16} strokeWidth={1.5} className="mr-0 md:mr-1" />
|
||||
<span className="hidden md:inline">{t('metaData')}</span>
|
||||
</Button>
|
||||
{canEditKb && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setMetadataOpen(true)}
|
||||
className="px-2 md:px-4 whitespace-nowrap h-9"
|
||||
>
|
||||
<ClipboardPenLine size={16} strokeWidth={1.5} className="mr-0 md:mr-1" />
|
||||
<span className="hidden md:inline">{t('metaData')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{canEditKb && (
|
||||
<Link to={`/filelib/upload/${id}`}>
|
||||
<Button className="px-4 md:px-8 h-9">{t('uploadFile')}</Button>
|
||||
@@ -597,7 +597,9 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
|
||||
{hasRowActions && (
|
||||
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -660,14 +662,12 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
<TableCell>
|
||||
<StatusIndicator status={el.status} remark={el.remark} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{hasRowActions && <TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{el.status === 3 && (
|
||||
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
|
||||
{canEditKb && el.status === 3 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!canEditKb}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry([el]); // Single retry passes complete object
|
||||
@@ -677,13 +677,8 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<Tip
|
||||
content={!canDeleteKb && t('noOperationPermission')}
|
||||
side='top'
|
||||
styleClasses="-translate-x-6"
|
||||
>
|
||||
{canDeleteKb && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -692,14 +687,13 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
|
||||
e.stopPropagation();
|
||||
handleDelete(el.id);
|
||||
}}
|
||||
disabled={!canDeleteKb}
|
||||
title={t('delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Dialog, DialogContent } from '@/components/bs-ui/dialog';
|
||||
import { SearchInput } from '@/components/bs-ui/input';
|
||||
import AutoPagination from '@/components/bs-ui/pagination/autoPagination';
|
||||
import { toast } from "@/components/bs-ui/toast/use-toast";
|
||||
import Tip from "@/components/bs-ui/tooltip/tip";
|
||||
import ShadTooltip from "@/components/ShadTooltipComponent";
|
||||
import { addMetadata, delChunkApi, getFileBboxApi, getFilePathApi, getKnowledgeChunkApi, getKnowledgeDetailApi, getMetaFile, readFileByLibDatabase, saveUserMetadataApi, updateChunkApi } from '@/controllers/API';
|
||||
import { captureAndAlertRequestErrorHoc } from '@/controllers/request';
|
||||
@@ -830,23 +829,23 @@ export default function Paragraphs({ fileId, onBack, canEditKb = false, canDelet
|
||||
disabled={!selectedFileId}
|
||||
/>
|
||||
</div>
|
||||
{canEditKb && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleMetadataClick}
|
||||
disabled={!canEditKb}
|
||||
className="px-4 whitespace-nowrap disabled:pointer-events-auto"
|
||||
className="px-4 whitespace-nowrap"
|
||||
>
|
||||
<ClipboardPenLine size={16} strokeWidth={1.5} className="mr-1" />
|
||||
{t('metadialog.title')}
|
||||
</Button>
|
||||
<Tip content={!canEditKb && t('common.noPermission')} side='top'>
|
||||
)}
|
||||
{canEditKb && (
|
||||
<Button
|
||||
disabled={!canEditKb}
|
||||
onClick={handleAdjustSegmentation}
|
||||
className={`px-4 whitespace-nowrap disabled:pointer-events-auto`}>
|
||||
className={`px-4 whitespace-nowrap`}>
|
||||
{t('segment.adjustStrategy')}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Checkbox } from "@/components/bs-ui/checkBox";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
|
||||
import { Switch } from "@/components/bs-ui/switch";
|
||||
import { useToast } from "@/components/bs-ui/toast/use-toast";
|
||||
import Tip from "@/components/bs-ui/tooltip/tip";
|
||||
import { downloadFile, formatDate } from "@/util/utils";
|
||||
import { ArrowLeft, SquareCheckBig, SquareX, Trash2 } from "lucide-react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
@@ -244,6 +243,7 @@ export default function QasPage() {
|
||||
const { page, pageSize, data: datalist, total, loading, setPage, search, reload, refreshData } = useTable({}, (param) =>
|
||||
getQaList(id, param)
|
||||
)
|
||||
const hasSelectableActions = canEditKb || canDeleteKb;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -496,38 +496,36 @@ export default function QasPage() {
|
||||
<span className="text-gray-700 text-sm font-black pl-4 dark:text-white truncate max-w-80">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={selectedItems.length ? 'visible' : 'invisible'}>
|
||||
<Tip content={!canDeleteKb && t('noOperationPermission')} side='top'>
|
||||
<Button variant="outline" className="disabled:pointer-events-auto ml-2" disabled={!canDeleteKb} onClick={handleDeleteSelected}>
|
||||
{selectedItems.length > 0 && hasSelectableActions && (
|
||||
<div>
|
||||
{canDeleteKb && (
|
||||
<Button variant="outline" className="ml-2" onClick={handleDeleteSelected}>
|
||||
<Trash2 className="mr-2 h-4 w-4" ></Trash2> {t('delete')}
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
|
||||
<Button variant="outline" className="disabled:pointer-events-auto ml-2" disabled={!canEditKb} onClick={handleDisableSelected}>
|
||||
)}
|
||||
{canEditKb && (
|
||||
<Button variant="outline" className="ml-2" onClick={handleDisableSelected}>
|
||||
<SquareX className="mr-2 h-4 w-4" /> {t('disable')}
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
|
||||
<Button variant="outline" className="disabled:pointer-events-auto ml-2" disabled={!canEditKb} onClick={handleEnableSelected}>
|
||||
)}
|
||||
{canEditKb && (
|
||||
<Button variant="outline" className="ml-2" onClick={handleEnableSelected}>
|
||||
<SquareCheckBig className="mr-2 h-4 w-4" /> {t('enable')}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<SearchInput placeholder={t('qaContent')} onChange={(e) => search(e.target.value)}></SearchInput>
|
||||
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
|
||||
<Button variant="outline" disabled={!canEditKb} className="disabled:pointer-events-auto px-8" onClick={() => importRef.current.open()}>{t('import')}</Button>
|
||||
</Tip>
|
||||
<Button variant="outline" className="px-8" onClick={() => {
|
||||
{canEditKb && <Button variant="outline" className="px-8" onClick={() => importRef.current.open()}>{t('import')}</Button>}
|
||||
{canEditKb && <Button variant="outline" className="px-8" onClick={() => {
|
||||
getQaFile(id).then(res => {
|
||||
const fileUrl = res.file_list[0];
|
||||
downloadFile(checkSassUrl(fileUrl), `${title} ${formatDate(new Date(), 'yyyy-MM-dd')}.xlsx`);
|
||||
})
|
||||
}}>{t('export')}</Button>
|
||||
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
|
||||
<Button className="disabled:pointer-events-auto px-8" disabled={!canEditKb} onClick={() => editRef.current.open()}>{t('createQA')}</Button>
|
||||
</Tip>
|
||||
}}>{t('export')}</Button>}
|
||||
{canEditKb && <Button className="px-8" onClick={() => editRef.current.open()}>{t('createQA')}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,27 +533,29 @@ export default function QasPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
{hasSelectableActions && <TableHead className="w-8">
|
||||
<Checkbox checked={selectAll} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
</TableHead>}
|
||||
<TableHead className="w-[340px]">{t('question')}</TableHead>
|
||||
<TableHead className="w-[340px]">{t('answer')}</TableHead>
|
||||
<TableHead>{t('type')}</TableHead>
|
||||
<TableHead>{t('updateTime')}</TableHead>
|
||||
<TableHead>{t('createUser')}</TableHead>
|
||||
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
|
||||
{hasSelectableActions && (
|
||||
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{datalist.map(el => (
|
||||
<TableRow key={el.id} className={canEditKb ? "hover:bg-gray-100" : ""}>
|
||||
<TableCell className="font-medium" onClick={(e) => e.stopPropagation()}>
|
||||
{hasSelectableActions && <TableCell className="font-medium" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedItems.includes(el.id)}
|
||||
onCheckedChange={() => handleCheckboxChange(el.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
|
||||
<TableCell
|
||||
className="font-medium cursor-pointer"
|
||||
@@ -584,22 +584,15 @@ export default function QasPage() {
|
||||
</TableCell>
|
||||
<TableCell>{el.update_time.replace('T', ' ')}</TableCell>
|
||||
<TableCell>{el.user_name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{hasSelectableActions && <TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="flex items-center">
|
||||
{canEditKb && <div className="flex items-center">
|
||||
{el.status !== 2 && (
|
||||
<Tip
|
||||
content={!canEditKb && t('noOperationPermission')}
|
||||
side='top'>
|
||||
<div>
|
||||
<Switch
|
||||
checked={el.status === 1}
|
||||
disabled={!canEditKb}
|
||||
className="disabled:pointer-events-auto"
|
||||
onCheckedChange={(bln) => handleStatusClick(el.id, bln)}
|
||||
/>
|
||||
</div>
|
||||
</Tip>
|
||||
<Switch
|
||||
checked={el.status === 1}
|
||||
className="disabled:pointer-events-auto"
|
||||
onCheckedChange={(bln) => handleStatusClick(el.id, bln)}
|
||||
/>
|
||||
)}
|
||||
{el.status === 2 && (
|
||||
<span className="text-sm">{t('processing')}</span>
|
||||
@@ -607,16 +600,12 @@ export default function QasPage() {
|
||||
{el.status === 3 && (
|
||||
<span className="text-sm">{t('notEnabled')}</span>
|
||||
)}
|
||||
</div>
|
||||
<Tip
|
||||
content={!canDeleteKb && t('noOperationPermission')}
|
||||
styleClasses="-translate-x-6"
|
||||
side='top'>
|
||||
</div>}
|
||||
{canDeleteKb && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="disabled:pointer-events-auto"
|
||||
disabled={!canDeleteKb}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(el.id);
|
||||
@@ -624,9 +613,9 @@ export default function QasPage() {
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
Reference in New Issue
Block a user