diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a90312526a..5609122eb2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -86,7 +86,7 @@ services: backend: container_name: bisheng-backend - image: dataelement/bisheng-backend:v2.6.0-fix + image: dataelement/bisheng-backend:v2.6.0-fix2 ports: - "7860:7860" environment: @@ -128,7 +128,7 @@ services: backend_worker: container_name: bisheng-backend-worker - image: dataelement/bisheng-backend:v2.6.0-fix + image: dataelement/bisheng-backend:v2.6.0-fix2 environment: TZ: Asia/Shanghai BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422" @@ -161,7 +161,7 @@ services: frontend: container_name: bisheng-frontend - image: dataelement/bisheng-frontend:v2.6.0-fix + image: dataelement/bisheng-frontend:v2.6.0-fix2 ports: - "3001:3001" environment: diff --git a/docs/architecture/08-deployment.md b/docs/architecture/08-deployment.md index 93c7bef9c0..a92e33f0bd 100644 --- a/docs/architecture/08-deployment.md +++ b/docs/architecture/08-deployment.md @@ -223,11 +223,56 @@ npm start -- --host 0.0.0.0 # 端口 3001,API 代理到 localhost:7860 要点: -- **为什么 2/3 自动、4 手动**:2/3 是纯 DB、幂等、轻量的 backfill,失败只影响菜单 / 模型配置且可自愈,适合放进启动 lifespan;步骤 4 要写对象存储(MinIO 上的 `SKILL.md`)、数据量可能大、需人工核对迁移摘要,副作用重,故保持手动运维脚本(详见 `src/backend/CLAUDE.md`「Migration vs. script」与 PRD 决策)。 -- **步骤 4 说明**:需要完整 app context(写 MinIO 的 `SKILL.md`)。**不调用 LLM**——技能描述取 SOP 原描述,缺失时用 SOP 名称兜底(技能描述为必填,不会留空)。产出 JSON 迁移摘要(成功/跳过/失败,**运维产物,无管理页报告界面**),失败 / 超大 SOP 项需人工处理(拆分后经管理页重建)。`linsight_sop` 原表保留归档、不删。 +- **为什么 2/3 自动、4 手动**:2/3 是纯 DB、幂等、轻量的 backfill,失败只影响菜单 / 模型配置且可自愈,适合放进启动 lifespan;步骤 4 要写技能正文、数据量可能大、需人工核对迁移摘要,副作用重,故保持手动运维脚本(详见 `src/backend/CLAUDE.md`「Migration vs. script」与 PRD 决策)。 +- **步骤 4 说明**:需要完整 app context(写技能 bundle)。**不调用 LLM**——技能描述取 SOP 原描述,缺失时用 SOP 名称兜底(技能描述为必填,不会留空)。产出 JSON 迁移摘要(成功/跳过/失败,**运维产物,无管理页报告界面**),失败 / 超大 SOP 项需人工处理(拆分后经管理页重建)。`linsight_sop` 原表保留归档、不删。 - **幂等**:四步均可安全重跑。步骤 2/3 重复启动是 no-op;步骤 4 借 `metadata.sop-id` 识别已迁移项并覆盖自身 bundle,不会重复产生带后缀的技能。 - 步骤 4 单租户灰度可加 `--tenant-id `。 +### v3.0 · 灵思技能 bundle 迁至对象存储 + +技能正文/脚本/附件此前以**节点本地文件系统**(`SKILLS_ROOT`)为权威存储,DB 只存元数据。单机 +compose 下 `backend` 与 `backend_worker` 恰好 bind-mount 同一个 `/app/data` 才让它工作;一旦两者 +不在同一台宿主机,A 机上传的技能在 B 机 worker 上读不到,而失败是**静默**的——任务照跑,只是技能 +不生效。现改为对象存储(MinIO)为唯一权威 + 节点本地按内容哈希缓存。 + +| # | 步骤 | 触发方式 | 命令(从 `src/backend/`) | 不执行的后果 | +|---|------|---------|--------------------------|-------------| +| 1 | **加 `linsight_skill.content_hash` 列** | 🔧 部署流程 | `uv run alembic upgrade head` | 后端起不来(缺列) | +| 2 | **发布本机残留的技能 bundle** | ✅ 启动自动(窄) | `python scripts/migrate_skills_to_object_storage.py` → `--apply` | 存量的**用户自建/导入**技能不生效 | +| 3 | **内置技能重新发布** | ✅ 启动自动 | —(seeder 按内容哈希幂等) | 三个官方技能不生效 | + +要点: + +- **步骤 2 的启动自愈是刻意做窄的**:只发布本机确实持有、且字节数与 DB 记录一致的 bundle。多副本 + 同时启动时各自看到不同的本地盘,若谁都能发布自己那份,胜出者就是随机的——那正是本次要消除的 + 多节点不一致。凡不满足条件的,日志会**按技能名列出**,这就是「去持有该 bundle 的那台机器上跑一次 + 脚本」的信号。 +- **每台曾经跑过 API 的机器都要跑一次**步骤 2 的脚本。某台机器盘上没有的,它会报告出来。 +- **内置技能不需要迁移**:seeder 直接从镜像重新发布,比任何一台机器的磁盘都更权威。 +- **回滚**:新版本期间创建/编辑的技能只存在于对象存储中,旧代码只读本地盘,会**静默失效**。若确需 + 回滚,先在每台目标机器上跑 `python scripts/restore_skills_to_local.py --apply` 把 bundle 写回 + `SKILLS_ROOT`。 +- 配置项 `linsight.skills_root` 已降级为「迁移脚本读取本地遗留 bundle 的来源」,运行期不再使用; + 本地缓存目录由 `linsight.skills_cache_dir` 指定(留空 = 进程缓存目录下的 `linsight_skills`)。 + **不要**把缓存目录指向共享卷。 + +## 多节点部署 + +官方 `docker/docker-compose.yml` 是**单机单副本**编排,但组件本身按多节点设计(`entrypoint.sh` 中 +workflow worker 明确标注「支持多节点运行」,灵思 worker 用 hostname 级 `node_id` + 心跳 + 任务 +ownership)。横向扩容时按下表核对。 + +| 组件 | 可否多副本 | 注意事项 | +|------|-----------|---------| +| `backend`(FastAPI) | ✅ | 无状态。启动期 backfill/seeder 幂等,多副本同启安全 | +| `backend_worker`(Celery + 灵思 worker + Beat) | ⚠️ | Celery worker 可多节点,队列名需按 `entrypoint.sh` 注释约定;**Beat 只能有一个实例**,否则定时任务重复触发 | +| MySQL / Redis / MinIO / Milvus / ES / OpenFGA | — | 有状态,按各自方案做高可用 | + +**关键约束:不要用共享卷在节点间传递业务数据。** 权威存储只有 MySQL/DM8、Redis、MinIO 三处;节点 +本地磁盘(`/app/data`、进程缓存目录)一律视为可随时丢弃的缓存。这条已写入架构宪法 +[C8](../constitution.md#c8-no-shared-state-on-the-local-filesystem)。单机 compose 下 `backend` 与 +`backend_worker` 共享同一个 `/app/data`,会让「跨进程传文件」看起来能用——这是巧合,不是保证。 + ## 相关文档 - 系统架构总览 -- `docs/architecture/01-architecture-overview.md` diff --git a/docs/constitution.md b/docs/constitution.md index cad126860f..f6916feee8 100644 --- a/docs/constitution.md +++ b/docs/constitution.md @@ -6,7 +6,7 @@ > - `scripts/arch-guard.sh` is the **machine-enforcement arm** of this document: each RULE maps to a clause below (see the anchor table). > - Violations are reported as **BLOCKER** during `/sdd-review design`. > - **Change governance**: editing this file requires PR review (a law change affects every feature). If a RULE is involved, sync the "→ Cx" note in `arch-guard.sh`. -> - Last revised: 2026-08-06 (F048: lazy permission runtime and operational migration traffic control). +> - Last revised: 2026-08-14 (C8: no shared state on the local filesystem). ## Anchor Table (clause ↔ arch-guard RULE) @@ -19,6 +19,7 @@ | **C5** | Error-code convention | — (review) | — | | **C6** | No hardcoded secrets | RULE-7 | WARNING | | **C7** | Frontend store must not call HTTP directly | RULE-6 | WARNING | +| **C8** | No shared state on the local filesystem | — (review) | — | --- @@ -133,3 +134,30 @@ No `password` / `secret_key` / `api_key` / `access_token` literals in code. Use A frontend store must not call HTTP directly — go through `controllers/API/` (platform) or `api/` (client). All other frontend conventions (state library, UI library, path aliases, i18n, Toast, etc.) live in `.claude/rules/platform-frontend.md` and `.claude/rules/client-frontend.md` (see also `AGENTS.md §4`). + +## C8. No Shared State on the Local Filesystem ⚠️ + +**Multi-node is the default assumption, not an edge case.** The backend already runs as several +processes that need not share a machine: API replicas (`uvicorn --workers`), Celery workers, the +Linsight worker (`bisheng/linsight/worker.py`, hostname-derived `node_id` + heartbeats), and Beat. +Two processes agreeing today only because a single-host `docker compose` happens to bind-mount the +same `/app/data` is an accident, not a design. + +The authoritative store for anything read by more than one process is **MySQL/DM8, Redis, or MinIO**. +The local filesystem is a cache: disposable, rebuildable, never the source of truth. + +| ✅ Use | ❌ Never | +|--------|---------| +| Object storage for bytes + DB row for the pointer | A DB row whose payload only exists on the writer's disk | +| Content-addressed keys, local cache keyed by that hash | A mutable local path treated as the live copy | +| Startup work registered in **every** process role that needs it | Initialization only in `main.py`'s FastAPI lifespan | +| Fail loudly, or report the gap to the user | Log a warning and continue silently degraded | + +Reference implementations: `WorkspaceBackend` (MinIO truth + write-through cache) and `SkillStore` +(content-addressed objects + local materialization) in `bisheng/linsight/domain/services/`. + +Precedents that make this a law rather than advice: skill bundles shipped as node-local files and +were unreadable from any other host (fixed by moving them to object storage); the F048 resource +registry was installed only in the API process and had to be retrofitted into the background +workers (`02cbb921a`). Both failed **silently** — which is the real cost, and why the last row of +the table matters as much as the first. diff --git a/features/v2.6.0/035-linsight-task-mode/design.md b/features/v2.6.0/035-linsight-task-mode/design.md index 7380afc467..051062511c 100644 --- a/features/v2.6.0/035-linsight-task-mode/design.md +++ b/features/v2.6.0/035-linsight-task-mode/design.md @@ -577,7 +577,9 @@ SKILLS_ROOT/ # 配置项 linsight_conf.skills_root(v2 `FilesystemBackend(root_dir=SKILLS_ROOT, virtual_mode=True)`:`virtual_mode=True` 把所有路径约束在 `root_dir` 内,**防路径穿越**(`../` 逃逸被拦截)。租户自定义目录按 `tenant_id` 分片,配合 §6 自动注入实现隔离。 -> ⚠️ **多节点部署约束**:`worker.py` 的 `NodeManager` 为多节点设计(hostname 级 node_id + 心跳 + ownership),磁盘 Skill 要求 `SKILLS_ROOT` 为**所有 Worker 节点可见的同一卷** —— 须满足「灵思 Worker 单机部署」或「多机挂共享存储(NFS 等)」之一,否则 A 机新建/编辑的 Skill 在 B 机 worker 上读不到、CRUD 与执行不一致。元数据若入 DB 则跨节点一致;**正文一致性依赖共享卷**。运维须将此约束写入部署文档;启动期可加 `SKILLS_ROOT` 可写 + 共享性自检告警。若未来多机且不便共享盘,演进为 MinIO 正文 + 本地物化(`FilesystemBackend` 退化为物化后的本地只读层)。 +> ~~⚠️ **多节点部署约束**:`worker.py` 的 `NodeManager` 为多节点设计(hostname 级 node_id + 心跳 + ownership),磁盘 Skill 要求 `SKILLS_ROOT` 为**所有 Worker 节点可见的同一卷** —— 须满足「灵思 Worker 单机部署」或「多机挂共享存储(NFS 等)」之一,否则 A 机新建/编辑的 Skill 在 B 机 worker 上读不到、CRUD 与执行不一致。元数据若入 DB 则跨节点一致;**正文一致性依赖共享卷**。运维须将此约束写入部署文档;启动期可加 `SKILLS_ROOT` 可写 + 共享性自检告警。若未来多机且不便共享盘,演进为 MinIO 正文 + 本地物化(`FilesystemBackend` 退化为物化后的本地只读层)。~~ +> +> **已解决(v3.0,2026-08-14)**:本段末尾预留的演进路径已实施 —— Skill bundle 改为**对象存储内容寻址**(`linsight/skills/{tenant_id}/{name}/{content_hash}.zip`)为唯一权威,节点按内容哈希本地物化;共享卷不再是正确性前提。`SKILLS_ROOT` 降级为迁移脚本读取遗留 bundle 的来源。约束本身也已上升为架构宪法 C8(禁止用本地文件系统承载跨进程共享状态),并写入 `docs/architecture/08-deployment.md`「多节点部署」。**当时把它降级成一条"运维须知"而没有落地,是这次返工的根因**:契约写在设计文档里、部署文档零覆盖、启动自检也没做,等于没有约束。 > **两类技能的本质区别(对齐 PRD §4.5/§4.7)**:`built-in/` 是任务模式**内核能力**,所有租户共用同一份磁盘文件、随内核常驻加载,**不出现在技能选择器与管理页,也不经 `/skill` API**;`data/skills/{tenant_id}/` 才是前端可见、可管理的**租户自定义技能**(租户管理员从 0 新建 / 导入)。系统管理员不直接管 built-in,如需维护某租户的自定义技能须经 admin-scope 切入该租户。 diff --git a/features/v2.6.0/044-unified-permission-entry/design.md b/features/v2.6.0/044-unified-permission-entry/design.md new file mode 100644 index 0000000000..7ab8cf9203 --- /dev/null +++ b/features/v2.6.0/044-unified-permission-entry/design.md @@ -0,0 +1,357 @@ +# Design: 知识空间与频道统一权限设置入口 + +> **状态**:✅ 已确认并通过设计评审 +> **关联 Spec**:[spec.md](./spec.md) +> **版本契约**:[release-contract.md](../release-contract.md) +> **最后更新**:2026-08-07 +> **界面参考**:[Figma · BISHENG · node 13051:92477](https://www.figma.com/design/FNt6RR3OZtaJQH6x8enmaZ/BISHENG?node-id=13051-92477&m=dev) + +## 1. 目标与非目标 + +### 1.1 目标 + +- 将知识空间的新建/编辑、频道的新建/设置改为完整页面,在一个入口承载资源设置和权限设置。 +- 页面是否展示权限区域,服从服务端现有细粒度权限结果,不增加角色名称判断。 +- 新建时先在本地维护权限草稿;提交时随现有创建请求传入,由后端创建资源后调用现有批量授权服务落地。 +- 编辑时只提交用户实际修改的权限项,不用过期快照覆盖其他并发变更。 +- 分享转私密继续由后端原有更新事务清理全部非创建者授权。 + +### 1.2 非目标 + +- 不改 OpenFGA 模型、权限角色、细粒度权限 ID、授权对象范围或成员关系存储。 +- 不引入邀请确认、文件审批、中粮定制的部门空间强制分享规则。 +- 不把知识空间和频道的授权写接口合并成新的通用写接口。 +- 不合并“包含子部门”产生的多条底层授权记录。 + +## 2. 关键约束与 Constitution Check + +本设计遵循 [Architecture Constitution](../../../docs/constitution.md) C1–C7 和版本级 [release-contract.md](../release-contract.md),不在本文重抄全局铁律。功能特有约束如下: + +- 创建页面提交前没有资源 ID,禁止预创建资源;候选查询必须以“待创建资源”的能力和租户范围执行。 +- 资源数据库与 OpenFGA 不具备跨存储事务;初始授权失败沿用现有授权失败/重试语义,不回滚已创建资源。 +- 创建请求新增字段必须可选;未传 `initial_permissions` 的现有调用方请求与响应保持兼容。 +- 部门选择必须保持 F038 的逐层加载/服务端搜索,不恢复整棵部门树加载。 +- 本次只改 client SPA;不得把 client 的 Recoil/react-query v4 代码与 platform SPA 混用。 + +**Constitution Check:通过。** 候选查询从 API endpoint 下沉为共享 `GrantSubjectQueryService`,endpoint 不直接查询 ORM,授权仍经 `PermissionService` / F026 owner service;无 DDL、手写 tenant 条件、新错误码、密钥或 store HTTP 调用。 + +## 3. 关键决策 + +### D1. 使用完整页面替代现有抽屉 + +- **备选**:继续扩展 `CreateKnowledgeSpaceDrawer` / `CreateChannelDrawer`;使用独立权限弹窗;新建完整页面。 +- **选择**:新增完整页面路由,知识空间单栏、频道桌面双栏;窄屏统一堆叠为单栏。 +- **原因**:Figma 的信息密度和固定底部操作区已超出抽屉适用范围,也能真正取消独立权限入口。 +- **重议条件**:产品明确要求保留抽屉,且能给出权限区在窄宽度下的完整交互稿。 + +路由: + +- `/workspace/knowledge/create` +- `/workspace/knowledge/space/:spaceId/settings` +- `/workspace/channel/create` +- `/workspace/channel/:channelId/settings` + +### D2. 权限编辑采用受控本地草稿 + +- **备选**:复用当前点击即写入的 `PermissionListTab` / `PermissionGrantTab`;页面内维护草稿,保存时写入。 +- **选择**:抽取纯展示/选择组件并由页面持有 `PermissionDraft`;禁止选择控件直接调用授权接口。 +- **原因**:统一页面必须具备一致的保存/取消语义,新建阶段也没有资源 ID;即时写入无法取消。 +- **重议条件**:后端未来提供原子化“资源设置 + 权限集合”命令接口。 + +### D3. 扩展现有创建请求,由后端编排初始授权 + +- **备选**:新增无资源 ID 的创建上下文/候选接口后由前端分两步写;新增统一批量写 API;扩展两类资源现有创建请求。 +- **选择**:知识空间和频道创建请求各自增加可选 `initial_permissions.grants`;各自的 creation application service 编排资源创建,取得真实 ID 和创建者 owner 后调用该领域现有批量授权 service。编辑仍调用现有资源更新与授权接口。 +- **原因**:一次提交符合统一创建页语义;不新增权限写 API,也不把知识空间与频道不同的权限写模型强行合并。创建候选只补一个统一的只读查询路径,关系模型继续复用现有接口。 +- **重议条件**:两个领域形成共同的事务边界和统一 relation-model 语义。 + +### D4. 权限区可见性来自服务端有效能力 + +- **备选**:按 `admin/owner/editor` 等角色名称控制;按现有细粒度能力及可授予模型控制。 +- **选择**:编辑页使用现有 `manage_space_relation` / 频道授权管理能力;创建页按“创建者将获得的 owner 关系”对应的现有细粒度权限配置判断,并从现有 relation-model 配置筛出其可授予模型。创建接口在资源和 owner 建立后再次做权威校验。 +- **原因**:角色只是权限来源之一,硬编码会忽略用户级或自定义角色配置。 +- **重议条件**:权限中心提供新的、稳定的统一 capability ID,并完成存量迁移。 + +### D5. 分享转私密由后端权威清理 + +- **备选**:前端读取授权列表后逐条撤销;调用现有资源更新。 +- **选择**:只提交可见性为私密;知识空间 `KnowledgeSpaceService.update_knowledge_space` 和频道 `ChannelService.update_channel` 清理非创建者成员、FGA tuple 与 relation-model binding,并保留/重建创建者 owner。 +- **原因**:后端可覆盖隐藏记录和并发状态;前端列表不应成为授权真相。 +- **重议条件**:现有 update service 不再承诺该清理语义,届时须先建立新的服务端原子命令。 + +### D6. Figma 作为结构与交互基准,不复制生成代码 + +- **备选**:直接采用 Figma 生成代码和原始样式值;在现有 design system 上按结构重建。 +- **选择**:复用 `@bisheng/ui`、项目 token、既有表单控件、图标与 i18n;不引入原始 hex、外部 UI 库或 Figma 生成代码。 +- **原因**:client 强制使用 `@bisheng/ui` 与主题 token;复制生成代码会绕过品牌主题、i18n 和移动端排版规则。 +- **重议条件**:设计系统正式新增对应组件并替换现有组件。 + +## 4. 详细设计 + +### 4.0 现状调用链 + +- 知识空间:列表页打开 `CreateKnowledgeSpaceDrawer` 完成资源创建;资源详情中的独立 `KnowledgeSpaceShareDialog` 读取并即时写入授权。 +- 频道:订阅页打开 `CreateChannelDrawer` 完成资源创建;独立 `ChannelPermissionDialog` 经 F026 频道授权接口即时写入关系。 +- 两类资源的“分享转私密”均已在各自 update service 内清理非创建者授权;当前前端并不拥有这项清理规则。 + +目标态保留上述后端写链路,只把前端入口、页面布局和保存编排统一起来。 + +### 4.1 页面与布局 + +知识空间页面为约 648px 的居中单栏,依次展示“基础设置”“访问与分享”;频道桌面页为左右两栏,左侧保留全部频道业务设置,右侧展示“访问与分享”。两者底部均使用固定的取消/创建或取消/保存操作区。移动端按基础设置、业务设置、访问与分享顺序单栏排列,不保留独立权限弹窗。 + +分享区保留现有语义: + +| 资源 | 私密 | 分享下的加入方式 | 分享附加设置 | +|---|---|---|---| +| 知识空间 | private | approval / public | 空间广场等现有设置 | +| 频道 | private | review / public | 发布广场等现有设置 | + +选择私密后隐藏加入方式、广场和授权列表。私密切换为分享时,若本次页面尚未选择加入方式,则初始化为“需要审核”。 + +### 4.2 页面数据流 + +#### 新建 + +1. 页面从现有 relation-model 接口加载关系模型,并从 §4.5 的统一只读接口按需查询用户、部门和用户组;创建页只覆盖普通知识空间/频道,不套用管理后台部门空间的 F033 特殊候选范围。 +2. 页面根据 prospective owner 的现有细粒度权限配置决定是否展示授权区域;有权限时在本地编辑 `PermissionDraft`。 +3. 提交时将资源字段和 `initial_permissions.grants` 一并传给现有资源创建 API。 +4. 后端先校验授权对象属于当前租户、角色模型有效且部门/用户组不能成为 owner,再调用原资源 create service。创建者成员关系、owner tuple、审计和频道外部副作用只由原 create service 执行一次,creation application service 不重复写入。 +5. 获得真实资源 ID 后,后端调用从现有 authorize endpoint 下沉的知识空间 `ResourceAuthorizationService`,或 F026 `ChannelAuthorizationService` 批量授权;全部成功后返回资源及成功状态。 +6. 若资源已创建但批量授权失败,创建接口仍返回已创建资源 ID,并将 `initial_permission_result.status` 标为 `failed`、携带现有授权错误码;页面显示“资源已创建,权限未完全设置”,提供“仅重试权限设置”和“进入资源”。重试调用现有 authorize API,不得再次创建资源。 + +Owner tuple 和初始授权失败**维持现状**:资源创建继续调用 `OwnerService.write_owner_tuple()` 的默认 best-effort 模式,OpenFGA 写失败进入既有 `failed_tuples` 重试链路;初始批量授权继续使用当前知识空间/频道授权服务的校验、错误码和补偿行为。本 Feature 不把两次 OpenFGA 写改成数据库事务,也不新增同步重试循环。 + +#### 编辑 + +1. 并行读取资源详情、当前有效能力;仅当具备权限管理能力时读取权限列表和可授予模型。 +2. 将权限响应转换为 `baseline` 和可编辑草稿;创建者 owner 行只读且不可删除。 +3. 保存资源字段;若仍为分享,再按 `touchedKeys` 计算授权/撤销命令并调用现有授权 API。 +4. 若保存为私密,不发送任何后续授权写请求;清空本地草稿并重新读取服务端状态。 +5. 任一请求返回失权时展示通用错误并刷新能力/详情;业务组件不新增 403 分支。 + +资源设置与 OpenFGA 不具备跨存储原子事务,因此编辑保存结果按实际成功步骤反馈,不声称失败步骤已生效。再次加载始终以服务端状态为准。 + +### 4.3 权限草稿 + +```ts +type PermissionSubjectType = 'user' | 'department' | 'user_group'; +type PermissionRelation = 'owner' | 'manager' | 'editor' | 'viewer'; + +interface PermissionDraftRow { + subjectType: PermissionSubjectType; + subjectId: number; + subjectName: string; + relation: PermissionRelation; + modelId?: string; + includeChildren?: boolean; + immutableCreator?: boolean; +} + +interface PermissionDraft { + baseline: PermissionDraftRow[]; + rows: PermissionDraftRow[]; + touchedKeys: string[]; +} +``` + +`subjectType + subjectId + relation/modelId + includeChildren` 组成稳定比较键。仅对用户实际添加、改角色或删除的行生成写命令。未触碰的服务端授权不撤销,从而避免用旧页面快照覆盖并发新增;服务端仍负责最终合法性、重复和授权范围校验。 + +### 4.4 创建请求与结果契约 + +创建写入不新增 API 路径,扩展现有: + +- `POST /api/v1/knowledge/space` +- `POST /api/v1/channel/manager/create` + +两类请求均增加同形的可选字段;具体 grant item 分别复用 `AuthorizeGrantItem` / `ChannelGrantItem`,不允许传 `revokes`: + +```json +{ + "initial_permissions": { + "grants": [ + { + "subject_type": "user", + "subject_id": 123, + "relation": "editor", + "include_children": false, + "model_id": "editor" + } + ] + } +} +``` + +创建响应保留原资源字段,并增加可选结果: + +```json +{ + "id": "resource-id", + "initial_permission_result": { + "status": "success", + "error_code": null + } +} +``` + +`status` 仅为 `success | failed`。授权为空时不调用授权服务,结果可省略。批量授权失败不回滚或删除已创建资源,`error_code` 复用现有权限写错误码;响应不得返回成员名称或完整授权明细。编辑页继续使用现有资源 ID 版本的候选与 authorize 接口。 + +### 4.5 创建阶段候选查询 + +新增一个只读路径: + +- `GET /api/v1/permissions/creation-grant-subjects` + +查询参数: + +- `resource_type=knowledge_space|channel` +- `subject_type=user|department|user_group` +- `operation=list|children|search|path_tree` +- 按 operation 使用现有 `keyword/page/page_size/parent_id/department_id/limit` 参数。 + +响应继续使用现有 `resp_200(data)` envelope,`data` 按 `subject_type + operation` 保持现有形状: + +| 请求 | `data` 形状 | 关键字段 | +|---|---|---| +| `user + list` | `UserGrantSubject[]` | `user_id`, `user_name`, `external_id`, `primary_department_path` | +| `user_group + list` | `UserGroupGrantSubject[]` | `id`, `group_name` | +| `department + children` | `DepartmentGrantNode[]` | `id`, `dept_id`, `name`, `parent_id`, `path`, `sort_order`, `source`, `status`, `has_children`, `matched`, `children` | +| `department + search/path_tree` | `DepartmentGrantTree` | `roots`, `total_matches`, `truncated`;`roots` 中节点同 `DepartmentGrantNode` | + +空结果保持现有类型:用户、用户组和部门 children 返回 `[]`,部门 search/path_tree 返回 `{"roots":[],"total_matches":0,"truncated":false}`。无效 `resource_type/subject_type/operation` 使用现有参数/资源错误,无细粒度能力使用现有权限拒绝错误,不为本 Feature 新增错误码。 + +该接口复用现有 grant-subject helper 和 F038 懒加载结构。它校验登录态和当前 tenant scope,并按“创建者将获得的 owner 关系”的现有细粒度配置判断其是否具备权限管理能力;不新增 web-menu、角色名或另一套 create capability 门禁。不满足 prospective-owner 管理能力时拒绝返回候选。候选范围限定当前 tenant,禁止接受客户端 tenant_id。创建参数仍在后端逐项校验,候选结果不是授权依据。 + +创建页可授予模型不新增路径,扩展现有 `GET /api/v1/permissions/relation-models/grantable`:默认模式仍要求 `object_type + object_id`;`creation=true` 时只接受 `object_type`、禁止依赖不存在的资源 ID,按 prospective owner 的现有 permission IDs 过滤 relation models。原有编辑调用方与响应形状不变。 + +不能直接复用 `/api/v1/user/list`:该接口面向组织/用户组管理员,普通资源创建者可能无权访问,其数据范围也不是资源授权候选范围。编辑阶段仍使用带真实资源 ID 的现有候选接口。 + +现有 `_list_knowledge_space_grant_users`、`_grant_departments_*`、`_list_knowledge_space_grant_user_groups` 从 `permission/api/endpoints/resource_permission.py` 下沉为 `permission/domain/services/grant_subject_query_service.py` 的 `GrantSubjectQueryService` 能力。新接口、现有资源权限接口和 `ChannelAuthorizationService` 只调用该 Service,禁止 domain service 继续反向 import API endpoint;底层取数经 repository/既有 DAO,Service 不写 ORM 查询。两类 creation application service 在创建前调用该 Service 的批量校验能力,只验证当前 tenant 对象存在性、可授予模型与非用户 owner 约束;创建后仍由权威授权 Service 再次校验并写入,不把候选结果当作授权依据。 + +relation-model 配置读取从 endpoint 模块拆到 `permission/domain/services/relation_model_store.py`。`ResourceAuthorizationService`、`GrantSubjectQueryService` 与 endpoint 共用该纯读取能力,domain 层不得为复用配置而反向 import API endpoint。 + +同理,知识空间现有 `authorize_resource` endpoint 内的 grant-tier 校验、F033 scope 校验、tuple 写入、relation-model binding 持久化与通知编排下沉为 `permission/domain/services/resource_authorization_service.py` 的 `ResourceAuthorizationService.authorize(...)`。该方法成功返回 `None`,业务失败抛出现有 `BaseErrorCode`,tuple 写失败归一为现有 `PermissionTupleWriteError`;不返回 HTTP response 对象。原 endpoint 只负责 HTTP DTO/response 转换,知识空间 creation application service 调用同一 Service;创建前校验失败直接拒绝,资源已创建后的授权 `BaseErrorCode` 才折叠为 `initial_permission_result.failed`。禁止直接调用底层 `PermissionService.authorize()` 绕过 binding 和细粒度校验。频道仍使用 F026 `ChannelAuthorizationService`,不走通用 Service。 + +### 4.6 模块职责 + +| 模块 | 做什么 | 不做什么 | +|---|---|---| +| `client/pages/knowledge` | 知识空间 create/settings 路由、表单与保存编排 | 不直接调用 HTTP,不实现授权校验 | +| `client/pages/Subscription` | 频道 create/settings 路由、业务表单迁移与保存编排 | 不复制频道授权规则,不重放创建副作用 | +| `client/components/permission` | 无副作用的授权列表、候选选择、草稿 diff | 不发 HTTP,不保存服务端权限 | +| `client/api/permission.ts` | 创建候选、关系模型、知识空间权限 API adapter | 不持有页面状态,不吞业务错误 | +| `client/api/channels.ts` | 频道详情、创建/更新及 F026 授权 API adapter | 不推导权限角色,不处理 403 跳转 | +| `GrantSubjectQueryService` | 普通资源创建/编辑共用的用户、部门、用户组候选范围与创建前批量校验 | 不写授权、不接受客户端 tenant_id、不 import API endpoint | +| knowledge/channel creation application service | 编排原 create service 与初始授权 | 不重复写 owner/成员/审计/订阅/同步副作用,不删除授权失败后的资源 | +| `relation_model_store` | 读取并缓存 relation-model 配置,供 domain service 与 endpoint 共用 | 不接收 HTTP DTO,不依赖 API endpoint | +| `ResourceAuthorizationService` / `ChannelAuthorizationService` | 授权、撤销、模型绑定、租户与细粒度能力校验 | 不拥有资源基本信息写入,不绕过 `PermissionService` | +| `KnowledgeSpaceService` / `ChannelService` | 资源 CRUD、私密转换与非创建者权限清理 | 不接收完整权限快照,不根据前端列表清理授权 | + +旧的新建抽屉入口改为导航到 create 路由;旧“管理成员/授权管理”菜单项移除,原弹窗组件在无调用方后删除。编辑/频道设置菜单统一导航到 settings 路由,置顶、退出、删除/解散规则不变。 + +## 5. 已知坑与防护 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | 现有权限 Tab 是点击即写,不具备统一页面的取消语义 | 取消表单后权限已经生效,新建阶段还会因没有资源 ID 无法调用 | `client/components/permission/PermissionListTab.tsx`、`PermissionGrantTab.tsx`:拆纯 UI 与 mutation adapter | +| 2 | 创建前没有资源 ID,但普通创建者通常也无权调用组织管理 `/user/list` | 预创建会留下孤儿资源;复用管理接口会让非管理员无法选人或扩大数据范围 | `GrantSubjectQueryService` + `GET .../creation-grant-subjects` | +| 3 | 现有频道候选 Service 反向 import permission API endpoint helper | 继续复用会扩大 Constitution C1 违规,未来 endpoint 重构会直接破坏 domain service | `channel_authorization_service.py:list_grant_*` 改调 `grant_subject_query_service.py` | +| 3a | 知识空间 authorize 的模型校验/binding/通知目前编排在 endpoint,不等于只写 FGA tuple | 创建流程若直接调 `PermissionService.authorize()` 会丢 relation-model binding 并绕过细粒度 grant-tier 校验 | `resource_permission.py:authorize_resource` 下沉 `ResourceAuthorizationService.authorize`,endpoint 与 creation service 共用 | +| 4 | `OwnerService.write_owner_tuple()` 默认是 best-effort | OpenFGA 短暂失败时资源仍已创建;若误当成原子成功,会重复创建或错误回滚 | `owner_service.py:write_owner_tuple` + `failed_tuples` 既有重试;创建结果按 §4.2 反馈 | +| 5 | 分享转私密的清理权威在后端,不在前端授权列表 | 前端逐条 revoke 会漏掉未加载/并发授权,并可能遗留 FGA tuple 或 binding | `KnowledgeSpaceService.update_knowledge_space`、`ChannelService.update_channel` | +| 6 | 频道仍有无 relation-model binding 的历史成员 fallback | 删除 fallback 或按角色名控制新 UI 会改变存量用户能力 | `ChannelAuthorizationService._actor_grant_permissions`;新页面只消费有效 permission IDs | +| 7 | “包含子部门”的一项 UI 授权可能对应不同 scope/多条底层关系 | 只按 subjectId 去重会误撤销另一条授权 | `PermissionDraft` 比较键保留 relation/modelId/includeChildren;authorize service 最终校验 | +| 8 | 资源 DB 与 OpenFGA 不在同一事务 | 授权失败后自动删资源可能和审计、成员、外部副作用再次竞态 | creation application service 返回资源 ID;仅重试现有 authorize API | +| 9 | 频道创建可能先触发情报源订阅并保存知识同步配置 | 权限失败后重放创建会重复外部调用或生成重复频道 | `ChannelService.create_channel`;恢复入口只执行 `ChannelAuthorizationService.authorize_channel` | +| 10 | 页面加载成功不代表提交时仍有权限 | 本地能力缓存会让已失权用户看似保存成功 | 服务端每次写实时校验;`client/api/request.ts` 统一处理 403,业务组件不加 403 分支 | +| 11 | relation 为 owner 不等于该授权对象就是资源创建者 | 把所有 owner 行锁死会禁止调整普通 owner;频道若据此补写 `knowledge_sync` 还会覆盖非创建者更新 | 仅服务端明确的 creator 行设置 `immutableCreator`;频道更新只在详情已返回 `knowledge_sync` 时回传该字段 | + +## 6. 契约与依赖 + +### 6.1 Outgoing contracts + +| 契约 | 形式 | 消费者 | 兼容/风险 | +|---|---|---|---| +| `POST /api/v1/knowledge/space` | 现有 HTTP;可选 `initial_permissions.grants` / `initial_permission_result` | client 知识空间创建页、存量调用方 | 未传新字段时保持原行为;不得让授权失败触发客户端重建资源 | +| `POST /api/v1/channel/manager/create` | 现有 HTTP;同形可选字段 | client 频道创建页、存量调用方 | 频道 ID 为 string;不得重放情报源订阅副作用 | +| `GET /api/v1/permissions/creation-grant-subjects` | 新增只读 HTTP;参数见 §4.5 | client 创建页权限选择器 | 只能返回当前 tenant 且具备 prospective-owner 管理能力的候选 | +| 四个 `/workspace/.../create|settings` 路由 | client 页面路由 | 左侧导航、资源 action menu、移动端入口 | 删除独立权限入口后,所有调用点必须迁移,否则产生死链 | +| `GrantSubjectQueryService.query_creation_subjects(...)` | 内部 async Python Service | permission endpoint、knowledge/channel creation endpoint | 参数范围改变会同时影响新建和存量授权候选 | +| `ResourceAuthorizationService.authorize(...)` | 内部 async Python Service | 通用 authorize endpoint、知识空间 creation application service | 必须保持现有 tuple/binding/通知/错误码行为,频道不得调用 | +| knowledge/channel creation application service `create(...)` | 内部 async Python Service | 两个现有 create endpoint | 只调用一次原 create service,再编排初始授权;不得重复 owner/成员/审计/订阅/同步副作用或接管 F026 授权语义 | + +不新增数据库表、迁移、环境变量、Celery 任务、权限 ID、角色或错误码。 + +### 6.2 Incoming contracts + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| F026 `ChannelAuthorizationService` | 内部授权 owner service | relation model、租户校验或补偿语义变化会改变频道初始授权结果 | +| F033 部门空间范围 | 版本契约 | 本次创建页不覆盖管理后台部门空间;未来纳入时必须恢复绑定部门子树/user_group 禁用规则 | +| `PermissionService` / OpenFGA | `ResourceAuthorizationService` 和 F026 下层依赖 + 第三方服务 | OpenFGA 不可用时 owner/初始授权按现有失败队列和错误码处理,不能宣称跨存储原子成功 | +| relation-model 配置 | Config + FineGrainedPermissionService | 管理 permission ID 或 grant_tier 变化会改变权限区显隐和可授予模型,前端不得缓存角色名映射 | +| `KnowledgeSpaceService` / `ChannelService` create/update | 内部资源 Service | 返回 ID 类型不同;私密清理语义若变化必须重新评审 D5 | +| Bisheng Information | 频道创建的外部 HTTP 副作用 | 授权重试不得重放频道创建和信息源订阅 | +| `client/api/request.ts` | client 响应拦截器 | 403/业务错误管线变化会影响失权提示,页面不能自行复制处理分支 | + +上述归属和依赖登记到版本级 `release-contract.md`;本 Feature 只编排 owner service,不接管其领域写行为。 + +## 7. 验证策略 + +### 7.1 自动测试 + +- 前端单元测试:权限区能力显隐、私密/分享条件字段、默认审核、草稿 diff、创建者不可删除、移动端堆叠、部分失败恢复不重复创建。 +- API 测试:创建参数兼容、初始授权成功、跨租户对象拒绝、非法 owner 拒绝、频道 relation model 范围、资源已创建但授权失败的结构化结果。 +- 服务集成测试:分享转私密后仅保留创建者;再次转分享不恢复授权;提交时失权被拒绝。 +- E2E:覆盖 spec AC-01~AC-25,重点验证仅编辑者不可见权限区、创建后授权失败恢复和旧独立入口消失。 + +### 7.2 手动验证 + +1. 启动 API:`cd src/backend && uv run uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log`;启动 client:`cd src/frontend/client && pnpm dev`。 +2. 使用三类测试账号:A 具备资源创建+权限管理能力,B 仅 `edit_space`/频道编辑能力,C 只读;不在文档保存凭据。 +3. 访问 `/workspace/knowledge/create`、`/workspace/channel/create`;创建后访问对应 `/settings` 路由。A 可见权限区,B 只见可编辑业务字段,C 不见入口。 +4. 桌面端对照 Figma 检查知识空间单栏、频道双栏和固定底栏;浏览器宽度缩至 768px 以下检查单栏与完整能力。 +5. A 打开设置页后,由另一管理账号撤销 A 的管理能力,再提交;确认服务端拒绝且重新加载后权限区消失。 +6. 将含个人、部门、用户组授权的资源转私密,通过现有 `GET .../{resource_id}/permissions` 确认只剩创建者;再转分享确认不恢复。 + +实现后执行聚焦验证: + +- `cd src/backend && uv run pytest test/permission test/knowledge test/channel -k "initial_permission or unified_permission"` +- `cd src/frontend/client && pnpm test:ci -- --runInBand` +- `cd src/frontend/client && pnpm typecheck && pnpm build` + +### 7.3 可观测性 + +- 复用现有 API 错误日志与前端错误提示;新增创建后授权编排日志只记录 resource_type、resource_id、成功/失败数量和 request id,不记录成员名称或 ID 列表。 +- 部分失败提示必须携带已创建资源上下文,便于用户恢复和服务端排查。 + +### 7.4 Spec 追踪 + +| Spec AC | 设计落点 | +|---|---| +| AC-01~AC-05 | §3 D1、§4.1、§4.6 | +| AC-06~AC-10 | §3 D4、§4.2、§5.4/§5.8 | +| AC-11~AC-16 | §3 D2/D3、§4.2~§4.4、§5.6/§5.7 | +| AC-17~AC-22 | §3 D5、§4.1~§4.4、§5.2/§5.3/§5.5 | +| AC-23~AC-25 | §1.2、§3 D3、§4.6、§6 | + +## 8. 后续改进 / 不打算做的事 + +| 项目 | 本期不做的原因 | 重新考虑条件 | +|---|---|---| +| 资源设置与授权的跨存储原子事务 | DB 与 OpenFGA 无共同事务,强补偿会放大资源创建副作用 | 平台提供可靠 saga/outbox 且两个资源 owner 接受统一事务契约 | +| 邀请确认、待生效状态与审批通知 | 属 PRD §1.2,已由用户确认排除 | 单独立项并明确审批状态机、通知和超时规则 | +| 知识空间/频道授权写 API 统一 | F026 拥有频道 relation binding 与补偿语义,强行合并会越权 | 两领域形成统一 relation model、错误码和事务边界 | +| 部门授权多记录归并 | 底层现状包含 scope 差异,本期只统一 UI | 权限模型提供稳定的部门授权聚合 ID 与迁移方案 | +| 管理后台部门空间创建页改造 | 本期明确只覆盖 client 普通知识空间/频道 | 产品将管理后台创建流程正式纳入同一 Spec | + +## 9. 变更历史 + +| 日期 | 变更 | +|---|---| +| 2026-08-07 | 初版:确定完整页面、本地权限草稿、现有写链路与后端私密清理边界。 | +| 2026-08-07 | 设计确认前调整:现有创建请求携带 `initial_permissions.grants`,后端创建后批量授权;原六个只读路径收敛为一个创建阶段候选查询接口。 | +| 2026-08-07 | 评审修订:候选查询下沉共享 Service;明确现有失败语义;补齐坑、契约、风险、手动验证与延后原因。 | +| 2026-08-07 | tasks 拆解校正:补齐创建候选响应形状和创建前批量校验边界,不改变已确认外部契约。 | +| 2026-08-07 | 实施前契约冻结:原 create service 独占 owner/成员/副作用;授权 Service 使用 `BaseErrorCode`;扩展现有 grantable relation-model 路径的 creation 模式,不新增第二个只读路径。 | +| 2026-08-07 | 实现同步:relation-model 配置下沉为 domain 可复用读取能力;澄清 creation application service 不接管 owner;仅真实创建者行不可编辑,频道同步配置仅对真实创建者回传。 | diff --git a/features/v2.6.0/044-unified-permission-entry/e2e-checklist.md b/features/v2.6.0/044-unified-permission-entry/e2e-checklist.md new file mode 100644 index 0000000000..0225281f2e --- /dev/null +++ b/features/v2.6.0/044-unified-permission-entry/e2e-checklist.md @@ -0,0 +1,82 @@ +# E2E 验证清单:F044 统一权限设置入口 + +**测试环境**:Client `/workspace`;后端 `/api/v1`;使用隔离测试租户和 `f044-e2e--` 资源名前缀 +**账号要求**:A = 同时具备资源编辑与权限管理能力;B = 仅具备资源编辑能力;C = 只读用户。账号由测试环境管理员准备,本清单不记录凭据。 +**数据安全**:执行前后只清理本轮唯一前缀的空间、频道和测试用户;清理失败时记录资源类型、资源 ID、响应码和响应体摘要。 + +## 1. 知识空间统一页面 + +### AC-01 / AC-06 / AC-11 / AC-17~AC-19:新建与权限草稿 + +- [ ] A 从现有“新建知识空间”入口进入 `/workspace/knowledge/create`,刷新、前进和后退均可正常加载。 +- [ ] 页面同时展示基本信息、访问与分享、加入方式和成员授权;授权区不依据账号角色名称判断。 +- [ ] 新建页初始为分享且“加入需审核”;切为私密后隐藏加入方式和授权区,切回分享仍默认“加入需审核”。 +- [ ] 添加个人、部门、用户组并修改可授予角色;同一对象不重复出现,部门“包含子部门”语义保持不变,部门和用户组不能选所有者。 +- [ ] 创建后通过空间详情和权限查询确认名称、简介、广场发布、自动标签等字段以及所选授权均已保存。 + +### AC-03 / AC-07 / AC-10 / AC-14~AC-16:编辑与提交时校验 + +- [ ] A 从列表、卡片和详情进入同一个 `/workspace/knowledge/space//settings`,页面以服务端最新详情和权限初始化。 +- [ ] B 能进入同一路由并编辑允许的基本字段,但看不到可见范围、加入方式、授权列表和新增授权。 +- [ ] C 看不到新建或设置入口;直接访问设置 URL 或读取权限列表时被拒绝且不泄漏权限内容。 +- [ ] 页面打开后撤销 A 的编辑能力或权限管理能力,再提交相应变更;服务端按提交时权限拒绝,重新加载后原数据未被旧草稿覆盖。 +- [ ] 两个浏览器并发编辑权限:第二个页面只提交 touched grant/revoke,不撤销第一个页面新增且未触碰的授权。 + +### AC-13 / AC-20 / AC-21 / AC-23:失败恢复与转私密 + +- [ ] 注入初始授权写失败后提交创建:资源仍存在并显示“资源已创建但权限未完全设置”,失败授权不出现在有效权限列表。 +- [ ] 点击“重试权限”只调用授权接口,不再次创建空间;点击“进入空间”进入已创建资源。 +- [ ] 分享空间转私密时完成确认;保存后只保留创建者授权。 +- [ ] 再转分享后,被清理的授权不会自动恢复。 +- [ ] 空间名称、简介、广场发布、部门空间范围和自动标签等既有语义保持不变。 + +## 2. 频道统一页面 + +### AC-02 / AC-06 / AC-11 / AC-17~AC-19:新建与权限草稿 + +- [ ] A 从现有“新建频道”入口进入 `/workspace/channel/create`,刷新、前进和后退均可正常加载。 +- [ ] 桌面宽度下为业务设置与访问分享双栏,窄屏下按顺序变为单栏,底部操作区可用。 +- [ ] 信息源、简介、筛选规则、子频道和知识同步组件均可操作;授权区支持个人、部门和用户组以及现有可授予角色。 +- [ ] 私密隐藏加入方式、广场发布和授权区;切回分享默认“加入需审核”,也可切换为公开加入。 +- [ ] 创建后通过频道详情、权限列表及相关订阅/知识同步状态确认所有字段落库。 + +### AC-04 / AC-07 / AC-10 / AC-14~AC-16:编辑与提交时校验 + +- [ ] A 从文章区菜单和频道侧栏进入同一个 `/workspace/channel//settings`,并读取服务端最新详情和权限。 +- [ ] B 只看到其可编辑的频道业务字段;C 看不到入口且直接访问/读取权限被拒绝。 +- [ ] 页面打开后撤销编辑或权限管理能力,再提交时服务端拒绝对应失权操作。 +- [ ] 并发授权时只写 touched diff,不覆盖未触碰的并发新增授权。 + +### AC-13 / AC-20 / AC-21 / AC-24:失败恢复与转私密 + +- [ ] 注入初始授权失败后,频道 ID 仍返回且频道可查询;失败授权不显示为生效。 +- [ ] “重试权限”只调用频道授权,不重复创建频道,也不重复触发情报源订阅或知识同步;“进入频道”进入已创建频道。 +- [ ] 分享转私密后只保留创建者授权,再次分享不恢复旧授权。 +- [ ] 信息源、内容筛选、子频道和知识同步既有行为保持不变。 + +## 3. 入口合并与回归 + +### AC-05 / AC-08 / AC-25 + +- [ ] 知识空间列表、卡片和详情均不再出现独立“成员管理”入口,只保留统一“空间设置”。 +- [ ] 频道文章区和侧栏均不再出现独立“管理成员”入口,只保留统一“频道设置”。 +- [ ] 置顶、退出、删除空间、解散频道等操作的显隐和行为保持原规则。 +- [ ] 知识空间内文件和文件夹的通用权限弹窗仍可打开、授权和撤销。 +- [ ] 普通资源编辑模式的候选查询和授权接口保持原路径与响应;F033 部门空间范围、F038 部门懒加载和 F026 频道授权语义不变。 + +## 4. 跨租户与清理证据 + +### AC-09 / AC-22 / AC-25 + +- [ ] 租户 A 用户无法在创建候选中搜索到租户 B 的用户、部门或用户组。 +- [ ] 将租户 B 的 subject ID 直接提交给租户 A 的创建接口时,在创建资源前被拒绝。 +- [ ] 租户 B 用户无法读取或修改租户 A 空间/频道权限。 +- [ ] 执行前后记录 `f044-e2e--` 空间、频道、用户列表;清理后均为空。 +- [ ] 若任一清理请求失败,保存资源类型、ID、HTTP 状态、业务错误码和响应摘要,整体结果标为 PARTIAL/FAIL,不静默通过。 + +## 5. 最终结果 + +- [ ] API E2E 全部通过,或明确记录真实中间件/账号/权限配置阻塞。 +- [ ] 页面清单已由 A/B/C 三类账号分别签字确认。 +- [ ] 浏览器控制台无新增错误;桌面和移动布局均无内容遮挡。 +- [ ] 所有测试数据完成前缀限定清理,并附清理成功或失败证据。 diff --git a/features/v2.6.0/044-unified-permission-entry/spec.md b/features/v2.6.0/044-unified-permission-entry/spec.md new file mode 100644 index 0000000000..7369e7197c --- /dev/null +++ b/features/v2.6.0/044-unified-permission-entry/spec.md @@ -0,0 +1,130 @@ +# Feature: 知识空间与频道统一权限设置入口 + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。 +> 所有 How(架构决策、数据流、字段、API、Service、前端组件、文件清单)均由后续 [design.md](./design.md) 与 [tasks.md](./tasks.md) 承载。 + +**关联 PRD**: [0811 中粮需求响应 §1.1 统一权限设置入口](https://dataelem.feishu.cn/wiki/MWHZwS0mbihH7NkbBvpckvSRnwc) +**优先级**: P0(跨知识空间、频道与资源权限的统一交互契约) +**所属版本**: v2.6.0 +**依赖**: F026-channel-active-authorization、F033-department-space-member-scope + +> **范围边界** +> - **本次纳入**: +> - 知识空间的新建、编辑页面同时承载基本信息、可见范围、加入方式与成员权限设置。 +> - 频道的新建、频道设置页面同时承载现有频道业务设置与成员权限设置。 +> - 是否展示和允许操作授权管理区域,完全依据现有细粒度权限配置计算出的当前有效能力,不按角色名称另设判断规则。 +> - 新建时允许在提交前添加授权对象、选择或修改角色、移除待授权对象;统一提交成功后,资源与所选授权均生效。 +> - 编辑时展示服务端当前有效设置与权限,并在同一页面保存允许修改的内容。 +> - 保持知识空间和频道现有可见性语义、授权对象范围、权限角色及各自业务设置。 +> - 分享转私密时撤销所有非创建者授权;再次转为分享时不自动恢复。 +> - **本次明确排除**: +> - PRD §1.2「邀请个人用户需本人确认后生效」,包括确认任务、待生效状态、消息通知和审批流程。 +> - PRD §1.3 文件上传或删除审核,以及 PRD §1.4 上传审批前置方案。 +> - PRD §1.1.4 标注的中粮定制规则:集团/部门知识空间固定分享、前台私密选项禁用。 +> - 新增或重定义权限角色、授权对象、可见性状态、细粒度能力或底层权限模型。 +> - 将包含子部门的部门授权归并为一条底层授权记录;该行为保持现状。 +> - 管理后台创建集团/部门知识空间的流程改造。 + +--- + +## 1. 用户故事 + +作为 **具备知识空间权限管理能力的用户**, +我希望 **在新建或编辑空间时直接配置可见范围与成员权限**, +以便 **一次完成空间信息和权限设置,不再离开当前页面到独立入口补充配置**。 + +作为 **具备频道权限管理能力的用户**, +我希望 **在新建频道或进入频道设置时直接配置成员权限**, +以便 **频道业务设置与权限设置在同一入口完成**。 + +作为 **仅具有编辑能力的协作者**, +我希望 **继续通过同一设置入口维护我有权修改的基本信息和业务设置,同时看不到无权管理的权限内容**, +以便 **页面能力与我的实际授权一致**。 + +作为 **查看者**, +我希望 **看不到无权使用的新建或编辑入口,也不能读取或修改权限设置**, +以便 **资源权限不会因入口整合而被扩大**。 + +--- + +## 2. 验收标准 + +> AC-ID 在本特性内唯一;本特性按 P0 / 复杂 Feature 使用 EARS 句型。 + +### 2.1 统一入口与页面组织 + +- **AC-01** — WHEN 有权创建知识空间的用户进入现有新建入口, THE SYSTEM SHALL 在同一创建页面展示知识空间基本信息、可见范围、加入方式与成员权限设置。 +- **AC-02** — WHEN 有权创建频道的用户进入现有新建入口, THE SYSTEM SHALL 在同一创建页面展示频道现有业务设置与成员权限设置。 +- **AC-03** — WHEN 具备知识空间编辑能力的用户选择「编辑空间」, THE SYSTEM SHALL 在同一设置页面展示该用户有权查看和修改的空间设置区域。 +- **AC-04** — WHEN 具备频道编辑能力的用户选择「频道设置」, THE SYSTEM SHALL 在同一设置页面展示该用户有权查看和修改的频道设置区域。 +- **AC-05** — THE SYSTEM SHALL 不再为知识空间或频道提供仅用于成员权限管理的独立入口;置顶、退出、删除或解散等其他既有操作入口保持可用性规则不变。 + +### 2.2 角色能力与信息隔离 + +- **AC-06** — WHEN 用户进入知识空间或频道设置页, THE SYSTEM SHALL 根据现有细粒度权限配置计算出的当前有效能力决定是否展示授权管理区域,不得另行按角色名称硬编码展示条件。 +- **AC-07** — WHEN 仅具有编辑能力但不具有权限管理能力的用户进入设置页, THE SYSTEM SHALL 只展示其可编辑的基本信息和业务设置,并且不展示可见范围、加入方式、授权列表或新增授权操作。 +- **AC-08** — WHEN 不具有编辑能力的查看者查看资源操作入口, THE SYSTEM SHALL 不展示新建或编辑入口。 +- **AC-09** — IF 不具有权限管理能力的用户尝试读取或修改资源权限设置, THEN THE SYSTEM SHALL 拒绝该操作且不得返回权限列表或写入权限变更。 +- **AC-10** — IF 用户打开设置页后其编辑能力或权限管理能力被撤销, THEN THE SYSTEM SHALL 以提交时的当前有效能力拒绝其已失权的修改。 + +### 2.3 新建与编辑提交 + +- **AC-11** — WHEN 创建者在新建页面提交前配置成员权限, THE SYSTEM SHALL 允许其添加现有支持的授权对象、选择或修改可授予角色,以及移除尚未提交的授权对象。 +- **AC-12** — WHEN 创建者提交合法的知识空间或频道新建表单且资源创建与所选授权均成功, THE SYSTEM SHALL 返回创建成功,并使新资源及所选授权可被后续查询验证。 +- **AC-13** — IF 新资源已创建但任一所选授权未成功, THEN THE SYSTEM SHALL 不得把未成功的授权显示为已生效,并应向创建者明确反馈权限未全部设置成功。 +- **AC-14** — WHEN 有权用户打开知识空间或频道编辑页, THE SYSTEM SHALL 展示服务端当前有效的资源设置与权限设置,不以过期的本地列表数据作为最终状态。 +- **AC-15** — WHEN 有权用户在编辑页保存合法变更, THE SYSTEM SHALL 保存其有权修改的基本信息、业务设置与权限变更,并在重新打开页面后展示保存结果。 +- **AC-16** — IF 编辑提交期间资源设置或权限已被其他操作改变而导致当前提交不再有效, THEN THE SYSTEM SHALL 拒绝无效部分且不得以旧页面状态覆盖当前有效权限。 + +### 2.4 可见范围与授权规则 + +- **AC-17** — WHEN 具备相应细粒度权限管理能力的用户设置知识空间或频道可见范围, THE SYSTEM SHALL 提供现有「私密」与「分享」语义供其选择。 +- **AC-18** — WHILE 资源处于私密设置状态, THE SYSTEM SHALL 隐藏加入审核、广场发布和授权列表相关设置,并且不允许新增授权对象。 +- **AC-19** — WHEN 用户把资源从私密切换为分享, THE SYSTEM SHALL 默认采用「需要审核」的加入方式,并展示分享状态下适用的权限设置。 +- **AC-20** — WHEN 用户把资源从分享保存为私密, THE SYSTEM SHALL 撤销所有非资源创建者的已有授权,并保留资源创建者的权限。 +- **AC-21** — WHEN 已转为私密的资源再次切换为分享, THE SYSTEM SHALL 不自动恢复转私密前被撤销的授权。 +- **AC-22** — WHEN 具备相应细粒度权限管理能力的用户新增或修改授权, THE SYSTEM SHALL 沿用该资源现有支持的个人用户、部门和用户组候选范围及可授予角色,并且不允许向部门或用户组授予所有者角色。 + +### 2.5 两类资源的既有差异 + +- **AC-23** — WHEN 用户新建或编辑知识空间, THE SYSTEM SHALL 保留名称、简介、空间广场、部门空间范围及其他现有知识空间设置的业务语义。 +- **AC-24** — WHEN 用户新建或编辑频道, THE SYSTEM SHALL 保留信息源、频道简介、内容筛选、子频道、知识同步及其他现有频道设置的业务语义。 +- **AC-25** — THE SYSTEM SHALL 在统一入口调整后保持知识空间与频道现有授权对象、权限角色和有效权限判断规则不变。 + +--- + +## 3. 边界情况 + +- 创建资源成功但部分权限设置失败时,资源不得被显示为不存在,失败授权也不得被显示为已生效;创建者必须能识别「资源已创建、权限未完全设置」这一结果。 +- 用户停留在设置页期间被撤销权限时,页面初次加载成功不代表其仍可提交;最终结果必须服从提交时的有效权限。 +- 分享转私密会撤销所有非创建者授权;该操作应继续遵循现有确认交互,避免用户误操作。 +- 私密转分享只恢复可配置状态,不恢复历史授权。 +- 授权候选中同一对象不得因统一入口而被重复添加;包含子部门的部门授权在底层可能保留多条现有记录,不属于本次归并范围。 +- 仅编辑者看不到权限区域,但其仍可编辑的资源字段不得因权限区域隐藏而一并消失。 +- 统一入口不得扩大查看者、跨租户用户或其他无权用户对资源设置和权限列表的读取范围。 +- 移动端与桌面端若都提供相同新建或设置入口,其能力与范围必须一致,不得在移动端保留独立权限管理兜底入口。 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 页面合并方式、创建后授权编排及失败恢复方案 | design.md §3–§4 | +| 权限能力判定、知识空间与频道现有调用链 | design.md §4 | +| 部门授权多记录、服务端实时权限及分享转私密的已知坑 | design.md §5 | +| 对外接口、状态与依赖契约 | design.md §6 | +| 测试策略与手动验证入口 | design.md §7 | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md) +- 执行与落档: [tasks.md](./tasks.md) +- 版本契约: [../release-contract.md](../release-contract.md) +- 架构宪法: [../../../docs/constitution.md](../../../docs/constitution.md) +- PRD: [0811 中粮需求响应 §1.1 统一权限设置入口](https://dataelem.feishu.cn/wiki/MWHZwS0mbihH7NkbBvpckvSRnwc) +- 界面参考: [Figma · BISHENG · node 13051:92477](https://www.figma.com/design/FNt6RR3OZtaJQH6x8enmaZ/BISHENG?node-id=13051-92477&m=dev)(布局与交互基准;具体实现决策写入 design.md) diff --git a/features/v2.6.0/044-unified-permission-entry/tasks.md b/features/v2.6.0/044-unified-permission-entry/tasks.md new file mode 100644 index 0000000000..fc8a5c6508 --- /dev/null +++ b/features/v2.6.0/044-unified-permission-entry/tasks.md @@ -0,0 +1,362 @@ +# Tasks: F044-unified-permission-entry(知识空间与频道统一权限设置入口) + +**关联规格**: [spec.md](./spec.md) · **设计真相**: [design.md](./design.md)(接手第一入口) +**版本**: v2.6.0 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| spec.md | ✅ 已评审 | 用户确认范围、细粒度能力显隐与转私密语义 | +| design.md | ✅ 已评审 | `/sdd-review design` 通过;候选查询下沉 Service,创建接口携带初始授权 | +| tasks.md | ✅ 已拆解 | `/sdd-review tasks` 复审通过;45 项任务、6 个 Wave | +| 实现 | ✅ 已完成 | 45 / 45 完成;真实 E2E 环境不可用,验证结论明确记录为阻塞而非通过 | +| 契约登记 | ✅ 已完成 | `release-contract.md` 表 1 / 表 3 / 变更历史已登记 F044 | + +--- + +## 开发模式 + +- **后端 Test-First**:测试任务先红,再完成紧随其后的实现任务;测试放 `src/backend/test/{permission,knowledge,channel}/`。 +- **前端仅 Client**:只修改 `src/frontend/client/`,本 Feature 不涉及 platform;服务器状态用 react-query v4,本地草稿用 `useState`/hook,不新增 Recoil。 +- **无基础设施变更**:不新增 ORM、迁移、配置、错误码、Celery/Worker;因此无数据库回滚和 Worker tenant header 任务。 +- **共享文件防护**:`resource_permission.py`、`ChannelAuthorizationService` 的候选结果必须与改造前保持一致;Service 下沉只改变分层,不改变范围、排序、分页或 F038 懒加载形状。 +- **自包含**:每项内联文件、逻辑、AC、依赖;设计理由只引用 design,不复制。 +- **E2E 必做**:完成实现后调用 `/e2e-test features/v2.6.0/044-unified-permission-entry`,生成 API E2E 与页面手动清单。 + +--- + +## Tasks + +### Wave 1 — grant-subject 查询下沉(后端,可独立完成) + +- [x] **T001**: `GrantSubjectQueryService` 单元测试 + **文件**: `src/backend/test/permission/test_grant_subject_query_service.py` + **逻辑**: 以 mock repository 固化用户分页、用户组关键词、部门 children/search/path-tree、tenant scope、prospective-owner 无管理能力拒绝;固化创建前批量验证对跨 tenant 对象、非法 owner 的拒绝;断言不接受客户端 tenant_id。 + **测试**: `test_user_candidates_tenant_scoped`、`test_department_lazy_operations`、`test_creator_without_manage_permission_denied`、`test_validate_creation_grants_rejects_cross_tenant`、`test_group_owner_rejected` + **覆盖 AC**: AC-06, AC-09, AC-22, AC-25 + **依赖**: 无 + +- [x] **T002**: 候选查询 Repository + Service + **文件**: `src/backend/bisheng/permission/domain/repositories/grant_subject_query_repository.py`, `src/backend/bisheng/permission/domain/services/grant_subject_query_service.py` + **逻辑**: 将 endpoint 内 `_list_knowledge_space_grant_users`、`_grant_departments_*`、`_list_knowledge_space_grant_user_groups` 的取数下沉;Service 暴露资源/创建阶段查询与 `validate_creation_grants`,负责能力/tenant/scope/非用户 owner 编排,Repository 承担 ORM 和按 subject IDs 批量存在性查询;保持 F038 lazy shape 和 F033 可选 bound-department scope。 + **测试**: T001 全部通过 + **覆盖 AC**: AC-06, AC-09, AC-22, AC-25 + **依赖**: T001 + +- [x] **T003**: 资源权限候选 API 回归测试 + **文件**: `src/backend/test/permission/test_resource_grant_subject_api.py` + **逻辑**: 对真实 resource_id 固化 knowledge_space 的 users/departments/user-groups 响应与拒绝路径;增加 creation query 的 resource_type/subject_type/operation 参数、prospective-owner 管理能力和跨 tenant 断言;固化 `relation-models/grantable?creation=true` 无 object_id 的过滤与旧调用兼容。 + **测试**: 只写 HTTP/API 断言,不包含 Service 实现。 + **覆盖 AC**: AC-06, AC-09, AC-22, AC-25 + **依赖**: T002 + +- [x] **T004**: Permission API 改调 Service + 创建候选端点 + **文件**: `src/backend/bisheng/permission/api/endpoints/resource_permission.py` + **逻辑**: 删除 endpoint 内候选 ORM/helper 实现,现有 resource-id 候选端点改调 T002;新增 `GET /permissions/creation-grant-subjects`;扩展现有 `relation-models/grantable` 支持 `creation=true`、省略 object_id 并按 prospective owner permission IDs 过滤,默认编辑模式不变;authorize 路径本任务不改。 + **测试**: T003 全部通过 + **覆盖 AC**: AC-06, AC-09, AC-22, AC-25 + **依赖**: T002, T003 + +- [x] **T005**: 频道候选委托回归测试 + **文件**: `src/backend/test/channel/test_channel_grant_subject_delegation.py` + **逻辑**: mock `GrantSubjectQueryService`,逐项断言 channel users/departments children/search/path-tree/user-groups 使用 channel tenant 和现有权限门禁;禁止 import permission API endpoint。 + **测试**: 只验证委托、参数和拒绝传播。 + **覆盖 AC**: AC-06, AC-09, AC-22, AC-25 + **依赖**: T002 + +- [x] **T006**: `ChannelAuthorizationService` 改调候选 Service + **文件**: `src/backend/bisheng/channel/domain/services/channel_authorization_service.py` + **逻辑**: 移除对 `permission.api.endpoints.resource_permission` 的五处反向 import;`list_grant_*` 统一委托 T002,保留 `_require_manage_access`、channel tenant 解析与返回形状。 + **测试**: T005 全部通过 + **覆盖 AC**: AC-06, AC-09, AC-22, AC-25 + **依赖**: T002, T005 + +- [x] **T007**: 通用资源授权 Service 回归测试 + **文件**: `src/backend/test/permission/test_resource_authorization_service.py` + **逻辑**: 从现有 authorize endpoint 行为固化 grant-tier/管理 permission 校验、F033 scope、禁止部门/用户组 owner、自身权限与最后 owner 防护、tuple 写入、relation-model binding、通知、现有错误码;频道 resource_type 仍拒绝走通用 Service。 + **测试**: 只写 Service 行为测试,mock OpenFGA/Config/通知,不修改 endpoint。 + **覆盖 AC**: AC-09, AC-10, AC-12, AC-13, AC-16, AC-22, AC-25 + **依赖**: 无 + +- [x] **T008**: `ResourceAuthorizationService` 下沉 + endpoint 委托 + **文件**: `src/backend/bisheng/permission/domain/services/resource_authorization_service.py`, `src/backend/bisheng/permission/api/endpoints/resource_permission.py` + **逻辑**: 将现有 `authorize_resource` 的模型/能力/scope 校验、PermissionService tuple 写、binding 保存、通知编排下沉为 `authorize(...)`;成功返回 `None`,业务失败抛现有 `BaseErrorCode`,tuple 写失败归一为 `PermissionTupleWriteError`;endpoint 只做 DTO/response 转换。保留 relation-model/binding store 的兼容 wrapper,channel 继续拒绝并走 F026。 + **测试**: T007 与 T003 全部通过 + **覆盖 AC**: AC-09, AC-10, AC-12, AC-13, AC-16, AC-22, AC-25 + **依赖**: T004, T007 + +### Wave 2 — 创建请求携带初始授权(后端,知识空间与频道可并行) + +- [x] **T009**: 知识空间创建编排 Service 单元测试 + **文件**: `src/backend/test/knowledge/test_knowledge_space_creation_application_service.py` + **逻辑**: mock `KnowledgeSpaceService`、T002 `GrantSubjectQueryService` 与 T008 `ResourceAuthorizationService`,断言无 grants 完全保持旧行为;有 grants 时先校验再只调一次原 create、批量授权,不重复写 owner/成员/审计;授权 `BaseErrorCode` 保留 resource id、返回 failed + 现有 error code,不删除/重建资源;非法 owner/跨 tenant 在写资源前拒绝。 + **测试**: `test_create_without_initial_permissions_compatible`、`test_create_then_grant`、`test_grant_failure_keeps_resource`、`test_invalid_grant_rejected_before_create` + **覆盖 AC**: AC-11, AC-12, AC-13, AC-22, AC-23, AC-25 + **依赖**: T002, T008 + +- [x] **T010**: 知识空间初始授权 Schema + Application Service + **文件**: `src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py`, `src/backend/bisheng/knowledge/domain/services/knowledge_space_creation_application_service.py` + **逻辑**: `KnowledgeSpaceCreateReq` 增加可选 `initial_permissions.grants`,复用 `AuthorizeGrantItem` 且无 revokes;application service 先调 T002 批量校验,再只调一次原 create,最后调 T008;原 create 继续独占 owner best-effort、成员、审计等副作用。输出原资源字段 + 可选 `initial_permission_result(status,error_code)`,不得直接调底层 `PermissionService.authorize()`。 + **测试**: T009 全部通过 + **覆盖 AC**: AC-11, AC-12, AC-13, AC-22, AC-23, AC-25 + **依赖**: T002, T008, T009 + +- [x] **T011**: 知识空间创建端点 API 测试 + **文件**: `src/backend/test/knowledge/test_knowledge_space_create_initial_permissions_api.py` + **逻辑**: TestClient 覆盖旧请求响应兼容、新字段解析、成功结果、资源已创建但授权失败结果、用户组/部门 owner 拒绝;断言 endpoint 不直接写权限。 + **测试**: 只写 API 合约与依赖 mock。 + **覆盖 AC**: AC-01, AC-09, AC-12, AC-13, AC-22, AC-23 + **依赖**: T010 + +- [x] **T012**: 知识空间创建 Endpoint 接入编排 Service + **文件**: `src/backend/bisheng/knowledge/api/dependencies.py`, `src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py` + **逻辑**: 注入 T010 application service;`POST /knowledge/space` 从直接调用原 service 改为委托编排服务,保持 quota decorator、认证、响应 envelope 和旧字段兼容。 + **测试**: T011 全部通过 + **覆盖 AC**: AC-01, AC-09, AC-12, AC-13, AC-22, AC-23 + **依赖**: T010, T011 + +- [x] **T013**: 频道创建编排 Service 单元测试 + **文件**: `src/backend/test/channel/test_channel_creation_application_service.py` + **逻辑**: mock `ChannelService.create_channel`、T002 `GrantSubjectQueryService` 与 F026 `authorize_channel`,断言无 grants 旧行为、有 grants 先校验再创建后授权、授权失败不重放情报源订阅/知识同步、返回 resource id + failed、非法对象创建前拒绝。 + **测试**: `test_create_channel_without_grants_compatible`、`test_create_then_authorize_channel`、`test_authorize_failure_does_not_recreate_channel`、`test_invalid_subject_rejected` + **覆盖 AC**: AC-11, AC-12, AC-13, AC-22, AC-24, AC-25 + **依赖**: T002, T006 + +- [x] **T014**: 频道初始授权 Schema + Application Service + **文件**: `src/backend/bisheng/channel/domain/schemas/channel_manager_schema.py`, `src/backend/bisheng/channel/domain/services/channel_creation_application_service.py` + **逻辑**: `CreateChannelRequest` 增加可选 `initial_permissions.grants`,grant item 复用 F026 schema 且无 revokes;application service 先调 T002 批量校验、再只调一次原 create,最后调 `ChannelAuthorizationService.authorize_channel`;原 create 独占 owner/成员/订阅/知识同步副作用,授权失败不重放。 + **测试**: T013 全部通过 + **覆盖 AC**: AC-11, AC-12, AC-13, AC-22, AC-24, AC-25 + **依赖**: T002, T006, T013 + +- [x] **T015**: 频道创建端点 API 测试 + **文件**: `src/backend/test/channel/test_channel_create_initial_permissions_api.py` + **逻辑**: TestClient 覆盖 `POST /channel/manager/create` 旧请求兼容、initial grants 成功/失败、string channel id、非法 owner 与跨 tenant 拒绝;断言失败恢复不再次调用 create。 + **测试**: 只写 API 合约与依赖 mock。 + **覆盖 AC**: AC-02, AC-09, AC-12, AC-13, AC-22, AC-24 + **依赖**: T014 + +- [x] **T016**: 频道创建 Endpoint 接入编排 Service + **文件**: `src/backend/bisheng/channel/api/dependencies.py`, `src/backend/bisheng/channel/api/endpoints/channel_manager.py` + **逻辑**: 注入 T014 application service;现有 create endpoint 改为委托编排,保持认证、响应 envelope、审计与原 `ChannelService.create_channel` 副作用顺序。 + **测试**: T015 全部通过 + **覆盖 AC**: AC-02, AC-09, AC-12, AC-13, AC-22, AC-24 + **依赖**: T014, T015 + +- [x] **T017**: 编辑权限、并发与转私密回归测试 + **文件**: `src/backend/test/permission/test_unified_settings_permission_regression.py` + **逻辑**: 固化知识空间/频道提交时失权拒绝、非管理者不得读授权、touched grant/revoke 不覆盖未触碰并发授权、分享转私密只留创建者、再转分享不恢复、现有角色/permission ID 语义不变。 + **测试**: 仅补回归测试;本任务不修改生产实现,失败时先判断是基线缺陷还是本 Feature 引入。 + **覆盖 AC**: AC-09, AC-10, AC-15, AC-16, AC-17, AC-20, AC-21, AC-22, AC-25 + **依赖**: T012, T016 + +### Wave 3 — Client 共享权限草稿与 API Adapter + +- [x] **T018**: Client API 合约测试 + **文件**: `src/frontend/client/src/api/unifiedPermissionEntry.test.ts` + **逻辑**: mock request wrapper,断言创建候选、`relation-models/grantable?creation=true`、两类 initial grants 请求;固化 camelCase `InitialPermissionResult {status,errorCode}` 和资源 id 均不被 adapter 丢失;编辑继续调现有 resource-id authorize。 + **覆盖 AC**: AC-09, AC-11, AC-12, AC-13, AC-22 + **依赖**: T004, T012, T016 + +- [x] **T019**: 创建候选与权限 API Adapter + **文件**: `src/frontend/client/src/api/permission.ts` + **逻辑**: 增加 `getCreationGrantSubjects`、`getCreationGrantableRelationModels` 及 query/响应类型;后者复用现有 grantable 路径并传 `creation=true`;保留编辑态现有候选/authorize API;使用 wrapped request + `skip403Redirect` 现有约定,不增加业务 403 分支。 + **测试**: T018 中候选/权限断言通过 + **覆盖 AC**: AC-06, AC-09, AC-11, AC-22 + **依赖**: T004, T018 + +- [x] **T020**: 两类资源创建 API Adapter + **文件**: `src/frontend/client/src/api/knowledge.ts`, `src/frontend/client/src/api/channels.ts` + **逻辑**: create payload 增加可选 `initialPermissions.grants` 的 snake_case 映射;两类 adapter 统一导出 camelCase `InitialPermissionResult {status: "success"|"failed", errorCode: number|null}` 并在返回结果中保留资源 id,不得被现有 mapping 丢失;频道只用真实 `/channel/manager/create` adapter,更新/授权路径不变。 + **测试**: T018 中两类 create 断言通过 + **覆盖 AC**: AC-11, AC-12, AC-13, AC-23, AC-24 + **依赖**: T012, T016, T018 + +- [x] **T021**: `PermissionDraft` reducer/hook 单元测试 + **文件**: `src/frontend/client/src/components/permission/usePermissionDraft.test.ts` + **逻辑**: 覆盖 add/change/remove、创建者锁定、稳定 key 包含 relation/model/includeChildren、只生成 touched diff、baseline 并发新增不被撤销、reset/cancel 无写副作用。 + **覆盖 AC**: AC-11, AC-14, AC-16, AC-22, AC-25 + **依赖**: 无 + +- [x] **T022**: 权限草稿 Hook + 受控编辑器 + **文件**: `src/frontend/client/src/components/permission/usePermissionDraft.ts`, `src/frontend/client/src/components/permission/PermissionDraftEditor.tsx` + **逻辑**: 实现 design §4.3 草稿和 diff;编辑器只接收 value/onChange/capabilities,不发 HTTP;创建者行不可编辑/删除,部门和用户组不提供 owner;复用 `RelationSelect` 与现有 permission UI。 + **测试**: T021 全部通过 + **覆盖 AC**: AC-06, AC-07, AC-11, AC-14, AC-16, AC-22, AC-25 + **依赖**: T021 + +- [x] **T023**: 用户/用户组选择器支持创建阶段数据源 + **文件**: `src/frontend/client/src/components/permission/SubjectSearchUser.tsx`, `src/frontend/client/src/components/permission/SubjectSearchUserGroup.tsx` + **逻辑**: 增加显式 `mode=create|resource`/query adapter props;create 调 T019,edit 保持 resource-id API;候选去重仍由受控草稿负责,不放宽搜索范围。 + **覆盖 AC**: AC-11, AC-22, AC-25 + **手动验证**: 普通创建者可搜索 tenant 内允许的用户/组;跨 tenant 与不可见私有组不出现。 + **依赖**: T019, T022 + +- [x] **T024**: 部门选择器支持创建阶段懒加载 + **文件**: `src/frontend/client/src/components/permission/SubjectSearchDepartment.tsx`, `src/frontend/client/src/components/permission/useGrantDepartmentTree.ts` + **逻辑**: create 模式将 children/search/path-tree 映射到 T019 的单端点 operation;edit 模式不变;保持 F038 逐层展开、搜索裁剪树和 includeChildren scope。 + **覆盖 AC**: AC-11, AC-22, AC-25 + **手动验证**: 首屏只取根层,展开只取直接子层,搜索不加载整树;包含子部门选择保持现状。 + **依赖**: T019, T022 + +### Wave 4 — 知识空间统一页面与入口 + +- [x] **T025**: 知识空间 Settings 表单 Hook + Page + **文件**: `src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.ts`, `src/frontend/client/src/pages/knowledge/SpaceSettings/KnowledgeSpaceSettingsPage.tsx` + **逻辑**: named export 完整页面;create/edit 共用表单,加载服务端详情/permission IDs;管理能力决定访问与分享区,纯编辑者只见可编辑字段;private 隐藏授权,切 share 默认 approval;create 携 initial grants,失败提供仅重试授权/进入资源;edit 用 touched diff,转 private 后不再 authorize。 + **覆盖 AC**: AC-01, AC-03, AC-06, AC-07, AC-10, AC-11, AC-13, AC-14, AC-15, AC-17, AC-18, AC-19, AC-20, AC-21, AC-23, AC-25 + **手动验证**: A/B/C 三类账号分别验证 create/settings 显隐;权限失败不重复创建;private→share 不恢复成员。 + **依赖**: T019, T020, T022, T023, T024 + +- [x] **T026**: 知识空间列表 Action 合并为单一设置入口 + **文件**: `src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceItem.tsx`, `src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceCardItem.tsx` + **逻辑**: 删除独立“成员管理”菜单;当 `canEditSpace || canManageMembers` 时只展示一个“空间设置”并调用统一 settings callback;置顶、退出、删除规则不变。 + **覆盖 AC**: AC-01, AC-03, AC-05, AC-06, AC-07, AC-08 + **手动验证**: 仅编辑者、仅权限管理者都看到一个设置入口;只读者看不到;其他 action 不变。 + **依赖**: T025 + +- [x] **T027**: 知识空间侧边栏与列表容器接入 Settings 路由 + **文件**: `src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx`, `src/frontend/client/src/pages/knowledge/index.tsx` + **逻辑**: 移除 `onManageMembers` 独立传递链,新建与统一设置回调分别 navigate 到 create/settings;保留现有细粒度能力计算,仅合并入口不改权限语义。 + **覆盖 AC**: AC-01, AC-03, AC-05, AC-06, AC-07, AC-08, AC-25 + **手动验证**: 行列表、卡片列表和新建按钮均进入对应统一页面。 + **依赖**: T025, T026 + +- [x] **T028**: 知识空间详情移除独立权限入口 + **文件**: `src/frontend/client/src/pages/knowledge/SpaceDetail/index.tsx` + **逻辑**: 详情页编辑/原管理成员入口统一 navigate 到 settings;删除空间级 `KnowledgeSpaceShareDialog` state/render,保留文件/文件夹通用 `PermissionDialog`。 + **覆盖 AC**: AC-03, AC-05, AC-06, AC-07, AC-08 + **手动验证**: 详情页只进入统一设置页;文件与文件夹权限弹窗仍正常。 + **依赖**: T025 + +- [x] **T029**: 移除知识空间旧创建抽屉 + **文件**: `src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.tsx` + **逻辑**: 在 T027 无调用方且 `rg` 验证后删除;不得删除仍供文件/文件夹使用的通用权限 dialog。 + **覆盖 AC**: AC-01, AC-05 + **依赖**: T027 + +### Wave 5 — 频道统一页面、入口与旧组件清理 + +- [x] **T030**: 频道 Settings 表单 Hook + Page + **文件**: `src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.ts`, `src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelSettingsPage.tsx` + **逻辑**: named export 完整页面;复用 CreateChannel 下现有信息源、筛选、子频道、知识同步组件;桌面双栏/移动单栏;有效 permission IDs 控制权限区;private/share(review/public)、默认 review、initial grants、失败仅重试权限、edit touched diff 与 private 后禁 authorize。 + **覆盖 AC**: AC-02, AC-04, AC-06, AC-07, AC-10, AC-11, AC-13, AC-14, AC-15, AC-17, AC-18, AC-19, AC-20, AC-21, AC-24, AC-25 + **手动验证**: 创建保留全部频道业务字段;权限失败后不重复频道/情报源订阅;桌面双栏、窄屏单栏。 + **依赖**: T019, T020, T022, T023, T024 + +- [x] **T031**: 频道文章区 Action 合并为单一设置入口 + **文件**: `src/frontend/client/src/pages/Subscription/ArticleList/ChannelActionsMenu.tsx`, `src/frontend/client/src/pages/Subscription/ArticleList/ArticleList.tsx` + **逻辑**: 删除独立“管理成员”菜单;当 `canEditChannelSettings || canManageChannelPermissions` 时展示一个“频道设置”,回调统一传 channel;置顶、退出、删除/解散规则不变。 + **覆盖 AC**: AC-04, AC-05, AC-06, AC-07, AC-08 + **手动验证**: 仅编辑者、仅权限管理者都看到一个设置入口;只读者看不到;其他 action 不变。 + **依赖**: T030 + +- [x] **T032**: 频道侧栏 Action 合并为单一设置入口 + **文件**: `src/frontend/client/src/pages/Subscription/Sidebar/ChannelItem.tsx`, `src/frontend/client/src/pages/Subscription/Sidebar/ChannelSidebar.tsx` + **逻辑**: 与 T031 同口径移除侧栏独立管理成员入口;UI 只消费既有 helper 输出的有效 permission IDs,不新增 legacy role fallback 或角色名判断。 + **覆盖 AC**: AC-04, AC-05, AC-06, AC-07, AC-08, AC-25 + **手动验证**: 两种列表形态的菜单项、显隐和跳转一致。 + **依赖**: T030 + +- [x] **T033**: 频道容器接入 Settings 路由 + **文件**: `src/frontend/client/src/pages/Subscription/ChannelLayout.tsx`, `src/frontend/client/src/pages/Subscription/index.tsx` + **逻辑**: 新建/编辑/原管理成员回调统一 navigate create/settings;删除 permission dialog 和 create drawer state/render;向 ArticleList/Sidebar 仅传统一 settings callback;频道数据刷新在路由返回后读取服务端状态。 + **覆盖 AC**: AC-02, AC-04, AC-05, AC-06, AC-08, AC-14, AC-15 + **手动验证**: 新建、文章区、侧栏和详情入口均进入相同页面;返回后列表显示服务端最新值。 + **依赖**: T030, T031, T032 + +- [x] **T034**: 注册四个 client 页面路由 + **文件**: `src/frontend/client/src/routes/index.tsx` + **逻辑**: lazy import named page并注册 `knowledge/create`、`knowledge/space/:spaceId/settings`、`channel/create`、`channel/:channelId/settings`(最终 URL 含 `/workspace` base);沿用 knowledge/subscription plugin gate 与登录保护。 + **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-08 + **手动验证**: 四个 URL 刷新、前进/后退和无菜单权限访问行为正确。 + **依赖**: T025, T030, T033 + +- [x] **T035**: 移除频道独立权限弹窗 + **文件**: `src/frontend/client/src/pages/Subscription/ChannelPermissionDialog.tsx`, `src/frontend/client/src/pages/Subscription/ChannelShareDialog.tsx` + **逻辑**: T033 后 `rg` 确认无调用方再删除;不得影响知识文件/文件夹的通用权限 dialog。 + **覆盖 AC**: AC-05 + **依赖**: T033 + +- [x] **T036**: 迁移频道表单类型并移除旧创建抽屉 + **文件**: `src/frontend/client/src/pages/Subscription/channelUtils.ts`, `src/frontend/client/src/pages/Subscription/CreateChannel/CreateChannelDrawer.tsx` + **逻辑**: 将 `CreateChannelFormData` 移到并由 `channelUtils.ts` 导出,保持 payload builder 与 T030 settings hook 共用稳定类型;T030/T033 复用完子组件后删除无调用方 Drawer,保留 AddSource、Filter、SubChannel、KnowledgeSync 等组件。 + **覆盖 AC**: AC-02, AC-05, AC-24 + **依赖**: T030, T033 + +- [x] **T037**: 移除频道旧创建成功页 + **文件**: `src/frontend/client/src/pages/Subscription/CreateChannel/CreateChannelSuccess.tsx` + **逻辑**: T036 删除 Drawer 后使用 `rg` 确认无调用方再删除;统一页创建成功或初始授权失败的恢复动作均由 ChannelSettingsPage 承载。 + **覆盖 AC**: AC-02, AC-05, AC-13 + **依赖**: T036 + +- [x] **T038**: 统一页面 i18n(中英) + **文件**: `src/frontend/client/src/locales/zh-Hans/translation.json`, `src/frontend/client/src/locales/en/translation.json` + **逻辑**: 使用 `/i18n-localizer` 增加访问与分享、创建/保存、资源已创建但权限未完全设置、仅重试权限、进入资源等嵌套 key;复用已有基础设置/角色/私密/分享 key,禁止硬编码中文。 + **覆盖 AC**: AC-01, AC-02, AC-13, AC-17, AC-18, AC-19 + **依赖**: T025, T030 + +- [x] **T039**: 统一页面 i18n(日文) + **文件**: `src/frontend/client/src/locales/ja/translation.json` + **逻辑**: 与 T038 key 集完全一致并生成自然日语;运行 locale key 对齐检查,缺 key 视为任务失败。 + **覆盖 AC**: AC-01, AC-02, AC-13, AC-17, AC-18, AC-19 + **依赖**: T038 + +### Wave 6 — 自动化、E2E 与交付门禁 + +- [x] **T040**: 知识空间统一页面组件测试 + **文件**: `src/frontend/client/src/pages/knowledge/SpaceSettings/KnowledgeSpaceSettingsPage.test.tsx` + **逻辑**: mock API 覆盖 A/B/C 能力显隐、create/edit 服务端初始化、private 隐藏、share 默认 approval、initial auth failed 恢复、touched diff、转 private 不调用 authorize、既有字段保留。 + **覆盖 AC**: AC-01, AC-03, AC-06, AC-07, AC-08, AC-11, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-23, AC-25 + **依赖**: T025, T026, T027, T028, T029, T034, T038, T039 + +- [x] **T041**: 频道统一页面组件测试 + **文件**: `src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelSettingsPage.test.tsx` + **逻辑**: mock API 覆盖能力显隐、业务字段、private/review/public、initial auth failed 不重建、touched diff、转 private 不 authorize、桌面/移动布局关键 class/区域。 + **覆盖 AC**: AC-02, AC-04, AC-06, AC-07, AC-08, AC-11, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-24, AC-25 + **依赖**: T030, T031, T032, T033, T034, T038, T039 + +- [x] **T042**: 统一入口回归测试 + **文件**: `src/frontend/client/src/pages/unifiedPermissionEntryRoutes.test.tsx` + **逻辑**: 用 route/action mocks 断言知识空间与频道不再渲染独立权限入口,新建/设置指向四个路由;置顶、退出、删除/解散以及文件权限 dialog 仍存在。 + **覆盖 AC**: AC-05, AC-06, AC-07, AC-08, AC-25 + **依赖**: T026, T027, T028, T029, T031, T032, T033, T034, T035, T036, T037 + +- [x] **T043**: API E2E 测试 + **文件**: `src/backend/test/e2e/test_e2e_f044_unified_permission_entry.py` + **逻辑**: 调用 `/e2e-test` 生成并运行:两类 creation candidates、create+initial grants、授权失败资源保留、编辑失权、分享转私密清理、再分享不恢复、跨租户拒绝;所有资源名使用唯一 `f044-e2e-*` 前缀,`finally` 清理空间/频道/测试用户并记录清理失败证据;使用真实认证和服务端查询验证结果。 + **覆盖 AC**: AC-06, AC-09, AC-10, AC-11, AC-12, AC-13, AC-16, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25 + **依赖**: T017 + +- [x] **T044**: 页面 E2E 手动验证清单 + **文件**: `features/v2.6.0/044-unified-permission-entry/e2e-checklist.md` + **逻辑**: 由 `/e2e-test` 生成 A/B/C 账号矩阵、Figma 桌面/移动布局、创建/编辑/失败恢复、独立入口消失、文件权限不受影响、频道副作用不重放的逐步清单;不写账号凭据。 + **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25 + **依赖**: T040, T041, T042, T043 + +- [x] **T045**: 最终构建、架构与差异验证 + **文件**: 无(只执行验证,不改生产文件) + **逻辑**: 运行后端聚焦 pytest、client `test:ci/typecheck/build`、`scripts/arch-guard.sh`、`git diff --check`;`rg` 确认 domain 不 import permission endpoint、空间/频道独立 permission dialog 无调用、文件/文件夹 permission dialog 仍在;区分基线失败与本 Feature 引入失败并记录证据。 + **覆盖 AC**: AC-05, AC-09, AC-12, AC-13, AC-20, AC-21, AC-25 + **依赖**: T040, T041, T042, T043, T044 + +--- + +## 验证记录(2026-08-07) + +- **后端变更集**:F044 新增/关联的 10 个测试文件 `96 passed`;计划指定的聚合命令 `12 passed, 1440 deselected`;新增文件与关键私密清理路径 Ruff 通过。 +- **前端变更集**:统一入口 5 个 Jest suite `30 passed`;变更文件 ESLint、大小写 import 检查通过;三语 `com_unified_permission` 均为 21 个 key。 +- **架构与差异**:`scripts/arch-guard.sh`、`git diff --check` 通过;domain 未反向 import permission endpoint;空间/频道旧独立权限组件无生产调用,文件/文件夹权限弹窗仍在。 +- **真实 E2E(未通过)**:4 个用例因本机 API/中间件不可达而明确 `skipped`,均报告 `All connection attempts failed`;已生成独立 fixture、唯一资源名前缀、`finally` 清理和残留证据,等待可用环境复跑。 +- **仓库基线阻塞**:client `typecheck` 与 Vite build 均停在既有 `packages/ui/ErrorPage.tsx` 缺少 `qrcode.react`;全量 Jest 在注入本机 canvas 兼容层后为 `190 passed`、4 个既有 suite 失败(`filenamify`/`sse.js` ESM、历史结构断言与通知断言);未将这些结果标记为本 Feature 通过。 + +--- + +## 实际偏差记录 + +> 只留一行指针;论证回写 design.md。推翻已确认决策时先停下重新确认。 + +- 暂无。 diff --git a/features/v2.6.0/release-contract.md b/features/v2.6.0/release-contract.md index 4c04e0ad33..82cffc0416 100644 --- a/features/v2.6.0/release-contract.md +++ b/features/v2.6.0/release-contract.md @@ -28,6 +28,7 @@ | —(无新增) | F038-department-tree-lazy-load | 部门树整树加载改**按层懒加载 / 服务端搜索 / 按 id 定位** + 授权部门列表移除成员数统计;仅只读 `Department`,**不新增领域对象 / 表 / DAO 类**(新增取数方法在 `DepartmentDao` 既有职责内扩展,符合 C1);**推翻 F027 AC-16**(移除授权页部门列表 `member_count`,据 5 万实测其大 `.in_()` 计数在达梦约 66s);只读复用 F033 子树收敛与 F026 频道授权,不改其语义 | | —(无新增;新增对外 API `GET /channel/manager/{id}/unread-counts`) | F040-rebac-read-path-perf-rollout | 性能优化型;不引入新领域对象/表/DAO 入口。仅改读路径"怎么算/怎么取":频道详情拆出独立未读端点 + 上下文复用 + 文章总数 Redis 短 TTL 缓存;空间广场批量化权限检查(保持全返回);工作台/应用列表优先 cursor,遗留 `/chat/online`·`/workstation/app/uncategorized` 在兼容窗口内保留页码但内部 keyset 有界扫描;侧边栏权限懒加载;E 组按数据版本派生 key 缓存权限"名册"。只读既有 Service/DAO,不改 F026/F031/F033/F037 的授权·订阅语义 | | —(无新增领域对象;在 F029 拥有的 citation 链路上叠加 `accessScope` 分级) | F041-knowledge-space-select-flow-assistant | 4 入口(助手应用、工作流助手/知识库问答/知识库检索节点)知识库选择器新增「知识空间」tab + 检索;仅读取 / 调用现有 `KnowledgeSpaceService` / `KnowledgeSpaceChatService`(多态检索)/ F029 `KnowledgeFileVisibilityService`(`view_file` 双层过滤)/ 工作流节点 / 助手检索链路;两节点仅改**显示名**;在 F029 拥有的 `MessageCitation` / citation registry 上新增 `accessScope`(`per_user` / `shared`)字段与 resolve 分支(属 INV-7 例外的协同改动,经 F029 owner 认可);不新增领域对象 / 表 / DAO 入口 / 对外 API / 错误码 | +| —(无新增领域对象;统一资源设置与授权 UI 编排) | F044-unified-permission-entry | 知识空间与频道的新建/设置改为统一完整页面;扩展现有创建请求携带初始授权,资源创建后调用两类资源现有授权 Service 批量写入;新增一个创建阶段只读候选查询路径。频道授权写行为仍归 F026,部门空间授权范围仍归 F033;不新增权限写路径、角色、权限 ID、可见性状态、表、迁移或错误码 | **规则**: - 非 Owner Feature 的 AC 中不得出现其他对象的"创建/修改/删除"行为,只能"读取"或"调用" Owner 的 Service @@ -73,6 +74,7 @@ | F034-knowledge-space-file-move | F004/F008, F027, F039 | 文件/文件夹移动(同空间 + 跨空间)+ §5.5 文件夹上传;权限走 ReBAC 细粒度 `move_file`/`move_folder`(映射 can_edit,不改 OpenFGA 模型);列表刷新沿用 F027 cursor 协议(INV-6);跨空间移动依赖 F039 版本链「同空间」不变式做整链迁移;新增 180 段错误码 18033(无效移动目标)/ 18025(单次批量超 1000);新增对外 API `POST /knowledge/space/{id}/files/move` 与 `.../folders/upload`;未新增不变量 | | F040-rebac-read-path-perf-rollout | F004, F008, F027(cursor 协议 INV-6 + `common/cursor.py`), F036(细粒度评估范式), F037(频道权限上下文复用) | 性能优化型;收尾 F027/F036/F037 读路径主线。新增对外 API `GET /channel/manager/{id}/unread-counts`(未读从详情拆出)、`GET /channel/manager/{id}` 响应去 `sub_channel_unread_counts`;C 组列表复用 F027 cursor(不新增错误码);**援引 INV-6 例外**(见表 2)保持 `/space/joined`·`/space/department` 全返回,并给遗留 `/chat/online`·`/workstation/app/uncategorized` 登记页码兼容窗口(内部 keyset、不得 count/fetch-all);E 组按数据版本派生 key 缓存;不新增表/迁移/领域对象 | | F041-knowledge-space-select-flow-assistant | F029, F030 | 接盘 F029 明确排除的「工作流节点 / 运行用户身份」检索权限过滤遗留项;4 入口知识库选择器新增知识空间 tab、两节点仅改显示名;「用户知识库权限校验」开关(4 入口统一、默认关)**ON** 时按**运行使用者** `view_file` 双层过滤、**OFF** 时按**配置者** `view_file` 过滤(借用配置者可见范围、不越其边界;均复用 F029 `KnowledgeFileVisibilityService`);复用 F030 `type=3` 同表多态 + `row.type` 分派;**登记 INV-7 例外**(工作流/助手入口开关可选、默认关,含检索侧与溯源侧 `shared` 分级);在 F029 citation 链路新增 `accessScope` 分级 + resolve 分支;不新增领域对象 / 表 / 对外 API / 错误码 | +| F044-unified-permission-entry | F026, F033 | 统一知识空间与频道的新建/设置入口;现有创建请求增加可选初始授权参数,创建完成后复用 F026 频道授权写行为及现有知识空间授权服务;仅新增一个创建阶段只读候选查询路径,不新增权限写路径、领域对象、表、迁移、权限 ID、错误码或不变量 | --- @@ -87,7 +89,7 @@ | 120 | workstation | F028 沿用现有 `common/errcode/workstation.py`,会话导出 / 导入知识空间错误码段位 12060-12079,不得与既有 1204X / 1205X 冲突 | | 109 | knowledge | F030 沿用现有 `common/errcode/knowledge.py`,新增 `KnowledgeTypeNotSupportedError`(10962);复用 10900/10901/10991。180 (knowledge_space) 复用 18001/18010/18040 | | 109 | knowledge | F032 沿用现有 `common/errcode/knowledge.py`,新增 `OfdConvertError`(10917);不得与既有 10915/10916/10962 冲突 | -| 110 | linsight | F035 沿用现有 `common/errcode/linsight.py`,Skill 管理占用段位 **11050–11069**。实际落码:**11051** 校验失败 / **11052** 上传文件超 10MB / **11053** 不存在 / **11054** 无权限 / **11055** 重名 / **11056–11058** GitHub 导入 / **11059** 解压后内容超 100MB(2026-07-31 新增,与 11052 分离)。✅ 原占用段内/邻近的存量 SOP 检索·管理错误码(11010/11011/11050/11060/11070/11100/11110/11150/11160/11170/11171,design §8.6 计划下线)已随 SOP 残留代码移除(2026-06-17,见变更历史),段位腾空;Skill 码沿用已发布编号、不回迁腾空槽位(已发布编码不重编) | +| 110 | linsight | F035 沿用现有 `common/errcode/linsight.py`,Skill 管理占用段位 **11050–11069**。实际落码:**11051** 校验失败 / **11052** 超 10MB / **11053** 不存在 / **11054** 无权限 / **11055** 重名 / **11056–11058** GitHub 导入。✅ 原占用段内/邻近的存量 SOP 检索·管理错误码(11010/11011/11050/11060/11070/11100/11110/11150/11160/11170/11171,design §8.6 计划下线)已随 SOP 残留代码移除(2026-06-17,见变更历史),段位腾空;Skill 码沿用已发布编号、不回迁腾空槽位(已发布编码不重编) | | 180 | knowledge_space | F034 沿用现有 `common/errcode/knowledge_space.py`,新增 `SpaceMoveInvalidTargetError`(18033) / `SpaceFolderUploadCountExceededError`(18025);复用 18011(层级)/ 18012(文件夹重名)/ 18021 / 18024(容量)/ 18040 / 18041(跨租户);§5.5 文件夹上传租户容量超限复用 190 段 19403 | --- @@ -114,3 +116,4 @@ | 2026-06-29 | 登记 F038 部门树懒加载:表1 标"无新增领域对象"(部门树整树→按层懒加载/搜索/定位 + 授权部门列表移除成员数;仅只读 `Department`,新增取数在 `DepartmentDao` 既有职责内扩展,无 DDL)、表3 追加依赖 F027/F033/F026/F004/F008/F011-13;两端点族各自 scoping(admin 范围 vs 租户子树+F033)不统一;不新增表/对外 API 路径族之外错误码(复用 `DepartmentPermissionDeniedError`);**推翻 F027 AC-16**——据 5 万实测授权列表 member_count 的大 `.in_()` 计数在达梦约 66s(占 ~96%),移除之(已落地提交 `b8e481872`,69s→~3s);未新增不变量 | F038 / F027 | | 2026-07-01 | 登记 F041 工作流 / 助手应用支持选择知识空间(首钢合入需求):表 1 标注"无新增领域对象"(4 入口知识库选择器新增知识空间 tab、两工作流节点仅改显示名、复用 F029 双层 `view_file` 过滤 + F030 `type=3` 多态检索)、表 3 追加依赖 F029/F030;**INV-7 新增例外**——工作流 / 助手入口知识空间检索由「用户知识库权限校验」开关控制(4 入口统一、默认关,维持线上现状),开关 OFF 按**配置者** `view_file` 过滤(借用配置者可见范围、永不越其边界)、citation 标 `shared`(溯源不整条剔除、返回来源元数据,完整文件预览/下载仍按运行使用者 `view_file`),F029 直接入口 + 开关 ON(按运行使用者,`per_user`)维持强制;在 F029 拥有的 citation 链路新增 `accessScope` 分级 + resolve 分支(经 F029 owner 认可的协同改动);不新增表 / 对外 API / 错误码 / 领域对象 | F041(含 INV-7 例外 + F029 citation 链路协同改动) | | 2026-07-13 | F040 增补应用列表性能兼容窗口:`/chat/online` 与 `/workstation/app/uncategorized` 保留既有 `page/limit` 裸列表契约,内部改 DM8-safe keyset 有界扫描、权限预过滤 + 精确复核、页内装饰;INV-6 例外补充“不得 count/fetch-all、仅扫描到目标页可见前缀”;无表/迁移/API/错误码变化 | F040 | +| 2026-08-07 | 登记 F044 统一权限设置入口:表 1 标注无新增领域对象(统一 UI 编排,现有创建请求携带初始授权并在创建后调用两类资源现有授权 Service;新增一个创建阶段只读候选查询路径),表 3 追加依赖 F026/F033;资源 CRUD、频道授权、部门空间范围继续由原 owner 负责;不新增权限写路径、表、迁移、权限 ID、错误码或不变量 | F044 | diff --git a/features/v3.0.0-beta1/049-knowledge-space-children-read-optimization/design.md b/features/v3.0.0-beta1/049-knowledge-space-children-read-optimization/design.md new file mode 100644 index 0000000000..4fdafcf417 --- /dev/null +++ b/features/v3.0.0-beta1/049-knowledge-space-children-read-optimization/design.md @@ -0,0 +1,531 @@ +# Design: 知识空间目录与搜索读取优化 + +> **本文档定位 — 现状快照(Why this How)** +> +> - [spec.md](./spec.md) 回答做什么;本文回答为什么采用下面的实现。 +> - 本文覆盖当前实现完成后的目标状态;实现若改变关键决策,必须先按 SDD 偏差规则重新确认。 +> - 文件锚点以函数名为准,行号会随同分支改动漂移。 + +**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md)(design 确认后创建) +**版本**: v3.0.0-beta1 / F049 +**最后更新**: 2026-08-14 +**确认状态**: spec 已于 2026-08-14 确认;design 待确认 + +--- + +## 1. 目标与非目标 + +- **目标**:在不改变普通用户可见集、排序与分页结果的前提下,减少 `/children` 和 + `/search` 的重复权限门禁、固定过量候选读取和文件夹后代计数,并增加可定位慢阶段的结构化指标。 + ID 已知、tenant scope 已确定的访问路径同时支持 platform super admin 的系统身份策略。 +- **非目标**:不新增父级可见即放行子级的继承捷径;不修改 OpenFGA model、Grant、mode 或投影; + 不把搜索迁移为真实 cursor;不重做 ES 正文检索、文件下载、预览、RAG 或重试执行逻辑;不新增表、 + migration、错误码或第三方依赖。 + +--- + +## 2. 关键约束与 Constitution Check + +### 2.1 本功能特有约束 + +- client 文件列表当前固定请求 `page_size=80`,API 默认值仍为 20;候选窗口必须由请求页大小派生, + 但单次权限批次不得超过 OpenFGA BatchCheck 的 100 target 上限。 +- `/children` 已是四键 keyset cursor;返回 cursor 必须指向最后一个**已消费候选**,不能指向最后一个 + 可见项或数据库批次尾部,否则会重复或跳过权限过滤项。 +- `/search` 对外仍是 `page/page_size/has_more`;第 N 页需要重放前 N 页可见前缀。这是兼容窗口,不得在 + 本期用伪 cursor 改变其契约。 +- `visible` 的全局 facade 仍保持 F048 OQ-09:个人列表不因管理员身份扩权。F049 只在调用方已给出 + `space_id`、业务 Repository 已完成 tenant/resource scope 限定的目录与搜索路径中,识别 platform + super admin;不得把该策略扩到 `/joined`、`/department` 或其他个人枚举接口。 +- 普通用户候选可见性仍由 F048 统一执行面最终判定;SQL mode、创建者或父资源结果只能帮助业务验证, + 不能提供第二个 ALLOW。 +- 文件夹数字未展示,但两个派生状态仍有行为消费者:失败存在性控制文件夹/批量重试入口;处理中存在性 + 控制 5 秒状态轮询。因此可以删除数量,不能把两个状态一起静默删除。 +- 结构化指标不得包含 user/department/resource name、搜索词、token、Grant 主体或授权来源明细; + `emit_metric` 失败不得影响业务请求。 +- 116 环境数据仅是决策证据,不是 release-ready 性能门禁;正式验收仍需同版本、同 fixture 的前后对照。 + +### 2.2 Constitution Check + +全局架构铁律只引用 [docs/constitution.md](../../../docs/constitution.md) C1–C7,不在本文复制。 + +| 条款 | 结论 | 本设计的证据 | +|---|---|---| +| C1 DDD 分层 | PASS | Endpoint 只传参;编排在 `KnowledgeSpaceService`;新后代状态查询进入 `KnowledgeFileRepository`,不在 Service 新写 ORM;permission module 不读取知识空间父子表 | +| C2 MySQL + DM8 | PASS | 动态批次不依赖方言;后代状态使用 SQLAlchemy 相关 `EXISTS`,不使用 JSON、row tuple 或 MySQL 专有函数;DM8 在中央回归验证 | +| C3 多租户 | PASS | 所有业务候选仍由 tenant auto-filter 保护;super admin 只跳过权限判定,不开启 tenant bypass,不跨 tenant 枚举 | +| C4 权限统一入口 | PASS | 普通用户继续走 F048 `batch_check_business_visible`;super admin 是已确认的系统身份流程且仅限 ID-scoped 入口;不新增 SQL/继承 ALLOW 或 OpenFGA client 直连 | +| C5 错误码 | PASS | 保持既有不存在、拒绝、cursor 和权限故障错误;不新增错误码 | +| C6 安全 | PASS | 指标不记录搜索词、名称、主体或凭据;权限异常继续 fail closed | +| C7 前端边界 | PASS | client 继续通过 `~/api/knowledge.ts` 的 wrapped request;store 不发 HTTP;无新库 | + +--- + +## 3. 方案对比与选定 + +### 决策 1:普通候选继续 OpenFGA BatchCheck,不新增继承捷径 + +- **备选**: + - A. 父空间/文件夹可见且子资源没有 CUSTOM mode 时,由知识模块直接复用父结论。 + - B. 新增 `batch_check_visible_under_visible_parent(actor,parent,children)`,由权限模块理解父子与 mode。 + - C. 保持业务候选优先,普通用户每批候选继续走现有 F048 `batch_check_business_visible`。 +- **选定**:C。 +- **原因**:A 会让 SQL mode 和父级结果成为第二 PDP;B 会迫使权限模块理解知识空间业务树,破坏 + F048 的 verified-target 边界。2026-08-14 在 116 的隔离 store 串行基准中,BatchCheck 20/50/100 + 的 P95 约为 12.6/29.6/48.4ms,当前规模没有证明值得承担第二判定面的复杂度。用户已明确选择不新增 + 继承捷径。OpenFGA 不可用或投影非 CURRENT 时继续失败关闭。 +- **何时重新考虑**:同版本真实业务 trace 显示 `permission_elapsed_ms` 稳定占端到端 P95 的主要部分, + 且页级 BatchCheck P95 超过经评审阈值时,重新做 BENCH-01;即使重审,也应优先优化 OpenFGA model/ + datastore 或通用 permission facade,而不是在知识模块本地放行。 + +### 决策 2:platform super admin 使用窄 ID-scoped 系统身份路径,不修改全局 visible facade + +- **备选**: + - A. 修改 F048 `check_visible/batch_check_visible`,让 super admin 对所有 visible 调用全量 ALLOW。 + - B. 仅在 `/children`、`/search` 已知 `space_id` 的业务路径识别 `UserPayload.is_global_super`;仍加载并 + 校验空间、父文件夹和 tenant scope,之后跳过该路径的 visible Check/BatchCheck。 + - C. 只放行空间门禁,子候选仍做普通 visible BatchCheck。 +- **选定**:B。 +- **原因**:A 会推翻 F048 OQ-09,使“我加入的空间”等个人枚举扩成平台全量;C 会出现超管能打开空间 + 但列表为空的半授权状态。B 把系统管理能力限定在调用方已提供具体空间 ID 的路径,业务候选仍来自该 + tenant 的空间/文件表,不产生跨 tenant 或全平台枚举。它是 C4 系统身份策略,不是父级继承捷径。 +- **何时重新考虑**:若产品要求 super admin 的所有个人列表也展示全量资源,应修改 F048 OQ-09 并整体 + 重审,而不是继续扩大 F049 的局部判断;若以后新增正式“平台资源管理列表”,应使用独立管理入口。 + +### 决策 3:新增列表专用门禁编排,不修改 `_require_folder_action` 的全局安全语义 + +- **备选**: + - A. 删除 `_require_folder_action` 内部的空间检查,让所有调用方自行保证空间已校验。 + - B. 保持通用 helper 不变,为两个列表入口增加 `_require_space_listing_scope(space_id,parent_id)`:空间只 + 加载/校验一次,父文件夹使用“只加载并校验自身”的路径。 +- **选定**:B。 +- **原因**:A 会影响移动、上传、删除、聊天等大量既有调用方,任何漏补都会形成越权;B 将去重限定在 + 已审计的两个读入口。116 空间 3194 的真实搜索 trace 明确出现根目录两次 `/check`;文件夹路径按代码 + 会执行空间两次加文件夹一次。B 可把普通用户根目录收敛为 1 次空间门禁,文件夹路径为 1 次空间加 1 次 + 文件夹门禁;super admin 为 0 次权限 RPC,但仍做业务存在性/tenant 验证。 +- **何时重新考虑**:只有在全仓完成 `_require_folder_action` 调用点证明并建立统一 verified-scope 类型后, + 才考虑重构通用 helper;不能为减少一行调用直接改变其前置条件。 + +### 决策 4:候选窗口按 `page_size + 1` 派生,并以 100 为硬上限 + +- **备选**: + - A. 保持固定 100。 + - B. 每次只取当前还缺的可见条数,窗口可在同一搜索请求中变化。 + - C. 每个请求计算稳定窗口 `min(max(page_size + 1, 1), 100)`,同一请求的后续扫描沿用该窗口。 +- **选定**:C。 +- **原因**:A 对小页无条件过读;B 会让 `/search` 的 OFFSET 窗口宽度变化,批次 offset 无法稳定推导, + 也可能在只缺 1 个可见项时制造大量小 RPC。C 让 20 页读取 21、client 的 80 页读取 81,并为 + `has_more` 预留探针;超过 OpenFGA 单批上限时封顶 100。权限稀疏时仍逐批补取,直到填页或业务候选耗尽。 +- **何时重新考虑**:若新指标显示极低可见率下 round trip 数过多,可在不改变结果集的前提下设计基于 + scan amplification 的自适应放大,但必须保证搜索 OFFSET 的窗口/offset 稳定并增加等价测试。 + +### 决策 5:搜索保留页码兼容,内部只优化窗口与无效范围准备 + +- **备选**: + - A. 本期把 `/search` 与 F030 pseudo-cursor 一并迁移为真实 keyset cursor。 + - B. 保持 `page/page_size/has_more`,继续扫描到 `page × page_size + 1` 可见前缀;批次改为决策 4 的 + 页大小窗口,并避免在不需要时加载整个父文件夹后代对象。 +- **选定**:B。 +- **原因**:A 会修改 client `useFileManager`、F030 `asearch_space_children_cursor` 和既有消费者契约,超过 + 本次优化范围。B 可直接消除固定 100、重复空间门禁和无关键词/无必要交集时的后代全量加载,同时保持 + F040 已有的顺序与 `has_more` 等价测试。深页前缀重扫作为已知兼容成本进入指标,而不是被掩盖。 +- **何时重新考虑**:稳定出现 `page > 10`,或 `scanned_candidates / returned_items`、深页 P95 达到告警 + 阈值时,单独立项迁移真实 cursor,并一次性更新 HTTP 和 F030 wrapper。 + +### 决策 6:删除文件夹数量,改为一次批量存在性查询保留两个行为标记 + +- **备选**: + - A. 完全删除文件夹聚合和三个字段。 + - B. 保留现有每文件夹 `COUNT + GROUP BY status`,只不展示数字。 + - C. 在 `KnowledgeFileRepository` 用一条标准 SQLAlchemy 查询,为当前页文件夹分别计算“存在可重试 + 失败后代”和“存在处理中后代”;返回 `has_failed_files`、`has_processing_files` 两个布尔值,删除 + `success_file_num`、`processing_file_num`。 +- **选定**:C。 +- **原因**:截图和组件确认数字不展示;但 `has_failed_files` 控制文件夹/批量重试, + `processing_file_num > 0` 控制 5 秒轮询。A 会静默删除功能;B 仍为每页 N 个文件夹执行 N 次全状态计数。 + C 将返回量固定为每文件夹一行、每状态命中后即可停止,并把 N 次 DB 往返收敛为 1 次。查询进入已有 + Repository interface/implementation,避免延续 Service 内 ORM。 +- **何时重新考虑**:若 DM8 实测相关 `EXISTS` 计划不佳或 folder-heavy P95 仍由该阶段主导,基于真实 + EXPLAIN 评估状态物化;不得在无证据时新增冗余列或写路径维护。 + +### 决策 7:终端请求指标 + 权限扫描指标双层观测 + +- **备选**: + - A. 仅依赖 middleware `HTTP_ACCESS_METRIC` 总耗时。 + - B. 在每个步骤打印自由文本日志。 + - C. 保留/补齐 `permission_visible_list` 扫描指标,并新增每请求一次的 + `knowledge_space_read` 结构化终端指标;两者由同一个 trace 关联。 +- **选定**:C。 +- **原因**:A 无法回答慢在权限、ES、DB 还是 enrich;B 难聚合且容易泄露搜索词/名称。当前 children 的 + `permission_visible_list` 只有数量、没有阶段耗时,search 连该指标都没有。C 复用 F042 `emit_metric`, + 可聚合 P95,也能在失败时记录已完成阶段和 `failed_stage`;指标失败由现有 best-effort 机制隔离。 +- **何时重新考虑**:若后续接入正式 tracing/OTel,可把同字段迁入 span,但必须保留日志 pipeline 的兼容 + 窗口和字段语义,不能重新退化成只有总耗时。 + +--- + +## 4. 系统现状(实现后的目标快照) + +### 4.1 `/children` 数据流 + +```mermaid +flowchart TD + A["GET /knowledge/space/{space_id}/children"] --> B["加载空间并建立 listing scope"] + B --> C{"platform super admin?"} + C -- "是" --> D["校验可选父文件夹属于该空间"] + C -- "否" --> E["空间 visible 一次"] + E --> F{"有 parent_id?"} + F -- "是" --> G["加载父文件夹并校验 folder visible 一次"] + F -- "否" --> H["解码四键 cursor"] + G --> H + D --> H + H --> I["排除非主版本文件"] + I --> J["按 page_size 派生窗口读取业务候选"] + J --> K{"platform super admin?"} + K -- "是" --> L["候选直接进入可见页"] + K -- "否" --> M["按 folder/file 调统一 BatchCheck visible"] + M --> N{"已收集 page_size + 1 或候选耗尽?"} + L --> N + N -- "否" --> J + N -- "是" --> O["版本信息 + 文件标签/缩略图"] + O --> P["一次查询文件夹失败/处理中存在性"] + P --> Q["emit metrics + PageInfiniteCursorData"] +``` + +关键锚点: + +1. Endpoint:`knowledge/api/endpoints/knowledge_space.py:list_space_children`。 +2. 门禁:`KnowledgeSpaceService._require_space_listing_scope`(新增,两个接口共享)。 +3. 扫描:`_scan_visible_child_items`;窗口由 `_candidate_scan_batch_size(page_size)` 计算。 +4. 普通权限:`_filter_visible_child_items` → `batch_check_business_visible`;super admin 分支不调用它。 +5. enrichment:`_enrich_with_version_info` + 重构后的 `_handle_file_folder_extra_info`。 + +游标仍编码四键: + +```text +(file_type, extension_rank, update_time, id) +``` + +`next_cursor` 使用最后一个已消费候选的键;探测到第 `page_size + 1` 个可见项时,该探针本身不消费, +返回 cursor 仍停在上一已消费候选,从而下一页能够返回该探针项。 + +### 4.2 `/search` 数据流 + +```mermaid +flowchart TD + A["GET /knowledge/space/{space_id}/search"] --> B["共享 listing scope 门禁"] + B --> C["准备 parent/tag/status 过滤"] + C --> D{"有 keyword?"} + D -- "否" --> E["跳过文件总数与 ES"] + D -- "是" --> F["读取空间文件数,执行 ES 正文 document-id 聚合"] + E --> G["目标可见前缀 = page × page_size + 1"] + F --> G + G --> H["按 page_size 派生的稳定 OFFSET 窗口取候选"] + H --> I{"platform super admin?"} + I -- "是" --> J["保留业务候选"] + I -- "否" --> K["统一 BatchCheck visible"] + J --> L{"前缀已足够或候选耗尽?"} + K --> L + L -- "否" --> H + L -- "是" --> M["切出请求页并 enrichment"] + M --> N["emit metrics + page/has_more"] +``` + +搜索范围准备规则: + +- `parent_id` 总是先验证父文件夹;数据库候选始终带 `file_level_path` 范围条件。 +- 仅 parent/status 搜索不再为父文件夹加载全部后代对象。 +- tag-only 搜索把 tag resource IDs 直接交给数据库,并由 `space_id + file_level_path` 完成范围交集。 +- keyword 正文搜索若需要把 parent/tag 范围推进 ES,只读取必要的后代 ID 投影,不加载完整文件对象。 +- 文件名 `LIKE` 与 ES 正文 document IDs 的并集语义、10,000 terms ceiling 和截断 warning 保持不变。 +- 搜索窗口宽度在单请求内固定,DAO 继续使用 `id_tiebreaker=True`;第 N 页仍从第 1 窗口重放,这是已登记 + 的兼容成本。 + +### 4.3 文件夹状态与文件 enrichment + +当前页按类型分流: + +- 文件:保持 tags、thumbnail share link、`version_no`、`is_multi_version`、`has_similar`。 +- 文件夹:Repository 对本页 folder IDs 执行一次查询,每个文件夹返回: + - `has_failed_files: bool`:后代存在 `FAILED` 或 `VIOLATION` 文件; + - `has_processing_files: bool`:后代存在 `PROCESSING`、`WAITING` 或 `REBUILDING` 文件。 +- 删除:`success_file_num`、`processing_file_num`。前端不展示数字,且轮询改读 + `hasProcessingFiles`,重试继续读 `hasFailedFiles`。 + +### 4.4 候选扫描伪代码 + +```text +batch_size = min(max(page_size + 1, 1), 100) +target_visible = page_size + 1 # children +target_visible = page * page_size + 1 # search + +while visible_count < target_visible: + candidates = fetch_next_stable_batch(batch_size) + if candidates is empty: break + visible += candidates if system_scope else batch_check_visible(candidates) + if len(candidates) < batch_size: break + +return requested_slice, has_more, resume_position +``` + +普通用户一次业务候选批次最多产生两个 OpenFGA BatchCheck(folder、knowledge_file 各一);保持顺序执行, +本期不额外并发压 OpenFGA。super admin 的 `permission_batch_count=0`。 + +### 4.5 结构化指标契约 + +每个 `/children`、`/search` 请求最多各发一条终端指标: + +```text +BS_METRIC domain=knowledge_space_read + operation=children|search + scope=root|folder + outcome=success|error + system_scope=0|1 + page_size= + page= + candidate_batch_size= + candidate_count= + visible_count= + returned_count= + scan_batch_count= + permission_batch_count= + scan_amplification= + has_more=0|1 + gate_elapsed_ms= + scope_prepare_elapsed_ms= + search_engine_elapsed_ms= + candidate_db_elapsed_ms= + permission_elapsed_ms= + version_elapsed_ms= + folder_state_elapsed_ms= + file_enrich_elapsed_ms= + enrich_elapsed_ms= + total_elapsed_ms= + failed_stage= +``` + +`permission_visible_list` 同时覆盖 children/search 的扫描级数据,至少包含 `operation`、candidate/visible/ +returned、scan batches、permission batches、DB/FGA elapsed、amplification、stream completed、has_more。 +日志由现有 loguru context 自动带 `trace=`,不在业务指标重复提取请求头。 + +失败路径在 `finally` 发终端指标后原样抛出;`failed_stage` 只能取固定枚举: +`gate/scope_prepare/search_engine/candidate_scan/version_enrich/folder_state/file_enrich/response`,不能写异常消息。 + +### 4.6 对外字段契约 + +#### `/children` + +请求保持:`parent_id`、`file_ids[]`、`order_field`、`order_sort`、`file_status[]`、`page_size`、`cursor`、 +`file_type`。 + +响应保持: + +```json +{ + "data": [], + "page_size": 80, + "has_more": false, + "next_cursor": null +} +``` + +#### `/search` + +请求保持:`parent_id`、`page`、`page_size`、`order_field`、`order_sort`、`tag_ids[]`、`file_status[]`、 +`keyword`。 + +响应保持: + +```json +{ + "page": 1, + "page_size": 80, + "data": [], + "has_more": false +} +``` + +#### 文件夹条目字段变化 + +| 字段 | 目标状态 | 消费者 | +|---|---|---| +| `has_failed_files: bool` | 保留,始终明确返回 | `FileCard`、`FileTable`、`SpaceDetail` 重试入口 | +| `has_processing_files: bool` | 新增,始终明确返回 | `knowledgeUtils.isKnowledgeItemPending`,决定 5 秒轮询 | +| `success_file_num` | 删除 | 无展示消费者 | +| `processing_file_num` | 删除 | 被 `has_processing_files` 替代 | + +字段只对 folder 有意义;file 条目的现有字段不变。client TS 映射继续把 snake_case 转为 camelCase。 + +### 4.7 关键模块职责 + +| 模块 / 文件 | 做什么 | 不做什么 | +|---|---|---| +| `knowledge/api/endpoints/knowledge_space.py` | 保持两个 HTTP 路由和参数传递 | 不做权限、计时或查询编排 | +| `knowledge/domain/services/knowledge_space_service.py` | listing scope、扫描、权限编排、enrichment、终端指标 | 不新增 ORM;不读 mode 后本地放行;不直连 OpenFGA | +| `knowledge/domain/repositories/interfaces/knowledge_file_repository.py` | 声明批量文件夹后代状态和搜索范围 ID 投影契约 | 不做权限和响应拼装 | +| `.../implementations/knowledge_file_repository_impl.py` | 一次查询返回本页 folder 状态 flags;按需只投影搜索范围的后代 ID | 不决定谁可见;不加载不需要的完整文件对象;不统计成功/处理中数量 | +| `knowledge/api/dependencies.py` | 向 KnowledgeSpaceService 注入 file repository | 不创建第二 session 或跨请求缓存 | +| `open_endpoints/api/endpoints/filelib.py` | F030 `/filelib/file/list` 构造同一 Service 时注入 file repository | 不复制 children/search 编排;不改变 `writeable` 动作判断 | +| `permission/application/business_authorization.py` | 普通用户的 verified target + visible BatchCheck | 本期不新增 parent-aware API,不查询知识空间树 | +| `common/services/metric_log.py` | 格式化并 best-effort 发 `BS_METRIC` | 不聚合 P95,不持有业务字段;本期无需改动 | +| `client/src/api/knowledge.ts` | 响应字段映射与 TS contract | 不直接控制轮询/重试业务 | +| `client/.../knowledgeUtils.ts` | 用 `hasProcessingFiles` 判断 folder pending | 不恢复数量展示 | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | F048 `visible` facade 刻意不对管理员扩权;F049 的 super admin 是窄 ID-scoped 系统流程 | 直接改全局 facade 会让 `/joined` 等个人列表膨胀为平台全量 | `KnowledgeSpaceService._require_space_listing_scope` 和 scanner 的 `system_scope`;不改 `PermissionActionService.check_visible` | +| 2 | 只放行 super admin 的空间门禁不够;子候选仍按个人 visible 会让目录空白 | 出现“能进空间但看不到内容”的半授权 | listing scope 同时作用于门禁和候选过滤 | +| 3 | `_require_folder_action` 自己会再调用 `_require_read_permission` | 在外层先查空间再调它会重复空间 Check;全局删掉又会伤害其他调用方 | 两列表改用专用 listing scope;通用 helper 保持 | +| 4 | `processing_file_num` 不展示,但它驱动前端 5 秒自动轮询 | 直接删字段后,文件夹内任务状态不再自动刷新 | Repository 返回 `has_processing_files`;`knowledgeUtils.isKnowledgeItemPending` 改读布尔值 | +| 5 | `has_failed_files` 控制单文件夹和批量重试入口 | 把全部文件夹聚合一起删掉会让失败文件无法从文件夹入口重试 | 保留失败 EXISTS 语义;`FileCard/FileTable/SpaceDetail` 不改判断含义 | +| 6 | children cursor 必须停在最后“已消费”候选,而非最后 visible 或批次尾 | 否则不可见项被重复扫,或第 `page_size+1` 个可见探针被跳过 | `_scan_visible_child_items` 的 `resume_cursor` 更新顺序和回归测试 | +| 7 | search 的 cursor wrapper 只是 `[page_num]` pseudo-cursor | 误以为已有 keyset,会遗漏深页每次从头重扫的成本 | `asearch_space_children_cursor`、`_scan_visible_search_items` 指标记录 page/amplification | +| 8 | search OFFSET 分批要求同一请求窗口恒定且必须有 ID tie-breaker | 动态改变窗口宽度会造成 offset 重叠/空洞;同排序值会重复/漏项 | `_candidate_scan_batch_size` 每请求计算一次;DAO `id_tiebreaker=True` | +| 9 | parent search 当前会加载全部后代完整对象,即使没有 keyword | 大文件夹仅做 tag/status 搜索也先付出 O(subtree) 内存和 DB 成本 | scope prepare 按条件取必要 ID 投影;普通 SQL path 直接用 `file_level_path` | +| 10 | keyword 正文搜索先查空间文件总数,再让 ES terms 聚合最多返回 10,000 个 document IDs | 只看候选 DB/FGA 时间会错判 ES/范围准备瓶颈;大命中集仍可能截断 | 保持既有 warning;分别记录 scope/search engine 时间,列入 §8 | +| 11 | 一个候选批同时有 folder/file 时会产生两个权限批次 | 把 scan batch 数当 OpenFGA request 数会低估;盲目并发又会提高引擎峰值 | 指标分开记录 `scan_batch_count`、`permission_batch_count`,本期顺序执行 | +| 12 | `emit_metric` 的 trace 来自 loguru request context,指标函数本身不读 ContextVar | 手工重复写 trace 或直接打印自由文本会形成不一致字段 | 统一调用 `emit_metric("knowledge_space_read", ...)` | +| 13 | Service 目前已有历史 ORM,但 C1 禁止为新功能继续添加 | 为方便把 EXISTS 写回 `_handle_file_folder_extra_info` 会扩大分层债务 | 新查询进入 `KnowledgeFileRepository` 并由 DI 注入 | +| 14 | 116 的 `HTTP_ACCESS_METRIC` 只证明总耗时;当前 search scanner没有同 trace 的权限阶段 metric | 看到 101ms 无法判断是两个 Check、ES、BatchCheck 还是 enrich | 决策 7 的双层指标 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 / 风险点 | +|---|---|---| +| `GET /api/v1/knowledge/space/{space_id}/children` | HTTP cursor envelope | client `getSpaceChildrenApi`、状态轮询、空间广场预览;cursor/排序或字段变化会影响无限滚动 | +| `GET /api/v1/knowledge/space/{space_id}/search` | HTTP page/has_more envelope | client `searchSpaceChildrenApi`;F030 wrapper 依赖 `has_more`,不能恢复 exact total 或暗改 cursor | +| `GET /api/v2/filelib/file/list` 的知识空间分支 | HTTP cursor envelope + `writeable` | F030 调用 `list_space_children` 或 `asearch_space_children_cursor`;Repository 注入和 folder 字段变化必须同步覆盖 | +| `KnowledgeSpaceService.list_space_children()` | 内部 async API | HTTP endpoint、可能的内部构造调用;必须保持参数/返回 `PageInfiniteCursorData` | +| `KnowledgeSpaceService.search_space_children()` | 内部 async API | HTTP endpoint、`asearch_space_children_cursor`;early return 也必须带 `has_more` | +| `KnowledgeFileRepository.get_folder_descendant_state_flags()` | 内部 repository API(新增) | KnowledgeSpaceService enrichment;输入仅当前页 folder IDs,输出两个 bool map | +| `KnowledgeFileRepository.list_descendant_ids_for_search_scope()` | 内部 repository API(新增) | 仅 keyword + parent 等确需推进范围时投影 ID,不返回完整 KnowledgeFile | +| folder `has_failed_files/has_processing_files` | JSON 字段 | client 重试与轮询;缺失不能被解释为已确认 false | +| `BS_METRIC domain=knowledge_space_read` | logfmt 指标 | ELK/Loki/ES 聚合和 trace 排障;字段改名会破坏 dashboard/query | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| F048 `batch_check_business_visible` | permission application Python API | target resolution、CURRENT fence、OpenFGA 错误语义变化会影响普通用户;不得捕获后 fail-open | +| `UserPayload.is_global_super` | 认证阶段预解析 identity | 字段必须来自可信登录初始化;测试 fixture 未设置时按 false,不回查 RBAC/业务表 | +| OpenFGA BatchCheck ≤100 | 外部服务/API 契约 | 上限或 pinned version 变化需重跑 BENCH;不能把 HTTP 200 当完整语义以外的业务事实 | +| F027 `common/cursor.py` + keyset helper | 内部模块 | cursor context/key 数变化会使旧 token 失效;本期不改编码 | +| F040 search batch-scan contract | 内部算法 + tests | `id_tiebreaker`、fetch/filter/slice 等价和 early stop 不能倒退 | +| `KnowledgeDocumentVersionRepository` | 内部 Repository | 非主版本排除与版本字段;未注入时当前 warning 行为保持 | +| TagDao batch tags / tag resource IDs | DB API | tag ID 跨空间集合必须继续由业务 `space_id`/path 收窄 | +| ES `metadata.document_id` terms aggregation | Elasticsearch mapping | mapping 或 10k bucket 语义变化会影响正文命中集合;必须保留截断观测 | +| F042 `emit_metric` | 结构化日志 API | best-effort、未知 domain 默认启用;若监控开关策略改变要验证新 domain 仍采集 | +| client `useFileManager` | React 本地状态 | page size 当前 80;search page 和 children cursor 是两套状态机,不能混用 token | + +### 6.3 领域邻接声明 + +- F049 读取 Knowledge/KnowledgeFile 业务事实,不创建或修改 F048 Grant、mode、projection 或 OpenFGA tuple。 +- permission module 不接收 parent tree、space descendant 或 tag/tenant 业务查询;它只处理普通用户 verified targets。 +- super admin scope 不写权限事实、不伪造 Grant,不改变个人资源枚举;它只影响两个已知 ID 的知识空间读入口。 +- 文件夹重试执行仍归 `batch_retry_failed_files`;F049 只保留入口所需的状态标记,不重构任务展开与权限动作。 + +--- + +## 7. 测试与可观测 + +### 7.1 自动化策略 + +- **单元/服务测试**: + - 普通 root:空间 visible 恰好 1 次;普通 folder:空间 1 次 + folder 1 次;deny/error 不变。 + - platform super admin:仍验证空间/文件夹业务归属,权限 RPC 为 0;不能跨 tenant,不能影响个人列表。 + - children/search 的首批大小分别随 20、80、>100 页大小变化;稀疏可见时补批,候选耗尽正确结束。 + - children cursor 多页无重复/遗漏;search 各页结果与“全候选→权限过滤→切片”oracle 一致。 + - search keyword/tag/parent/status、ES+文件名并集、非主版本排除保持。 + - 成功、early return、权限异常、ES 异常均只产生一条终端 metric;错误原样传播且指标不含 keyword/name。 +- **Repository/双库契约测试**: + - 当前页多个文件夹只调用一次 Repository;失败/处理中/空后代的两个 bool 正确。 + - SQLAlchemy 表达式做 MySQL 编译测试;真实 DM8 由中央回归执行,重点检查相关 EXISTS 和 path LIKE。 +- **client 组件/纯函数测试**: + - folder `hasProcessingFiles=true` 时 pending、false 时不轮询;`hasFailedFiles` 重试入口保持。 + - raw mapper 不再依赖数值字段;children/search 两种 envelope 均映射新 bool。 +- **E2E**:按 `/e2e-test features/v3.0.0-beta1/049-knowledge-space-children-read-optimization` 生成并执行 API + 端到端覆盖;页面人工验证根目录、子文件夹、keyword/tag 搜索、无限加载、失败重试和处理中轮询。 + +### 7.2 确定性性能断言 + +| 场景 | 改造前 | 设计目标 | +|---|---:|---:| +| 普通 root 空结果的空间 visible | 2 次 Check | 1 次 Check | +| 普通 folder 门禁 | 空间 2 次 + folder 1 次 | 空间 1 次 + folder 1 次 | +| super admin listing 权限 RPC | 依赖个人 visible,可能拒绝/为空 | 0;但业务空间/tenant/folder 验证保留 | +| 首批候选,`page_size=20` | 100 | 21 | +| 首批候选,client `page_size=80` | 100 | 81 | +| 当前页 N 个文件夹后代状态 DB 往返 | N 次 COUNT/GROUP BY | 1 次 EXISTS flags 查询 | +| search 分段 metric | 无 | 1 terminal + permission scanner metric,同 trace | + +端到端不以单次请求作门禁。同一 fixture、同一镜像预热后各执行 3 次 warmup + 30 次采样,报告 P50/P95/P99、 +candidate/permission batch、DB/FGA/ES/enrich 分段。以 116 的 2026-08-14 只读观察作为基线参考:空间 3194 +`page_size=80&keyword=m` 约 101.1ms;两个无结果关键词约 79.2ms、68.4ms。该样本太小,不宣称正式 P95。 + +### 7.3 手动验证 + +1. 使用普通用户和 platform super admin 分别登录 client,在浏览器 Network 执行: + + ```text + GET /workspace/api/v1/knowledge/space/{space_id}/children?page_size=80 + GET /workspace/api/v1/knowledge/space/{space_id}/search?page=1&page_size=80&keyword= + ``` + +2. 验证普通用户结果不扩权;super admin 能浏览已知空间,但“我加入的空间”列表不扩大。 +3. 进入含失败文件的文件夹验证重试入口;进入含处理中后代的文件夹,观察每 5 秒刷新直到终态。 +4. 后端按 trace 查询: + + ```bash + docker logs --since 10m 2>&1 | grep -E "knowledge_space_read|permission_visible_list|HTTP_ACCESS_METRIC" + ``` + +5. 本地定向测试(cwd=`src/backend/`): + + ```bash + uv run pytest test/knowledge/test_file_visible_candidate_pagination.py \ + test/knowledge/test_f040_search_batch_scan.py \ + test/knowledge/test_knowledge_space_read_optimization.py + ``` + +6. client 定向测试和完整质量门从 `src/frontend/` 执行;最终命令由 tasks 按现有 package scripts 固化。 + +### 7.4 告警与排障顺序 + +- `outcome=error` 或 permission engine error:先按 `failed_stage` 与同 trace 的 permission decision 排查;保持 fail closed。 +- `scan_amplification > 10`:检查用户可见率、filter 选择性和 deep search page;不能直接扩大 batch 或跳权限。 +- `permission_elapsed_ms / total_elapsed_ms` 高:对照 OpenFGA metrics/request ID;必要时重跑 BENCH-01。 +- `search_engine_elapsed_ms` 高:检查 ES terms 命中量、10k 截断和 index;不要误判为 OpenFGA。 +- `folder_state_elapsed_ms` 高:检查 folder-heavy 页及相关 EXISTS plan,分别在 MySQL/DM8 EXPLAIN。 +- `candidate_db_elapsed_ms` 高:检查 path/status/filter 选择性和 search 深页,不通过新增未验证索引猜修。 + +--- + +## 8. 后续改进 / 本期不做 + +- **search 真实 cursor**:深页仍重放可见前缀;因需要同时迁移 HTTP、client 和 F030 wrapper,本期不做。 + 常态 `page>10` 或深页 scan amplification 告警后另立 feature。 +- **ES 正文候选流式化**:当前 terms 聚合仍可能生成最多 10k IDs,并先查文件总数。本期只拆指标和避免 + 不必要的 subtree 对象加载;若 `search_engine_elapsed_ms` 成为主瓶颈,再评估 composite agg/search_after。 +- **OpenFGA 继承捷径**:已否决;只有正式业务 trace 和 BENCH-01 证明必要时重审,不能直接恢复 + `batch_check_visible_under_visible_parent` 提案。 +- **folder 状态物化**:会把读成本转移到上传、重试、移动、删除等写路径并增加一致性负担;本期使用批量 + EXISTS。只有双库 EXPLAIN 和 P95 证明仍慢时再评估。 +- **并行 folder/file BatchCheck**:最多两个并发可能降低单请求延迟但提高 OpenFGA 峰值;先通过新指标确认 + 是否值得,并在并发压测后单独决定。 +- **通用 `_require_folder_action` 重构**:影响面远超两个接口;本期用专用 listing scope,不扩大改动。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-08-14 | 初版:children/search 共用窄 scope、页大小候选窗口、文件夹状态 flags、双层指标 | spec 确认后进入 design | diff --git a/features/v3.0.0-beta1/049-knowledge-space-children-read-optimization/spec.md b/features/v3.0.0-beta1/049-knowledge-space-children-read-optimization/spec.md new file mode 100644 index 0000000000..af336b9a64 --- /dev/null +++ b/features/v3.0.0-beta1/049-knowledge-space-children-read-optimization/spec.md @@ -0,0 +1,132 @@ +# Feature: 知识空间目录与搜索读取优化 + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> 本文档只定义 `/api/v1/knowledge/space/{space_id}/children` 与 +> `/api/v1/knowledge/space/{space_id}/search` 的目标行为、验收标准与范围边界。 +> 实现方案、调用链、日志字段与文件清单在后续 `design.md` / `tasks.md` 中维护。 + +**关联 PRD**: 用户于 2026-08-14 提出的知识空间目录与搜索性能优化要求 +**优先级**: P1 +**所属版本**: v3.0.0-beta1 +**依赖**: F027、F040、F048 + +> **范围边界** +> - **本次纳入**: +> - 指定知识空间目录列表和搜索中的超级管理员可见性行为; +> - 删除同一请求内重复的空间可见性判断; +> - 使候选读取规模跟随请求页大小,并继续满足权限过滤后的分页完整性; +> - 普通用户的候选文件和文件夹继续由统一权限执行面给出最终可见性结论; +> - 停止为目录列表和搜索结果计算未展示的文件夹后代成功数、处理中数量; +> - 保留文件夹失败文件存在性及其重试入口,但不再通过全量状态计数得到该布尔值; +> - 将目录搜索纳入相同的重复门禁、候选批次和分段耗时优化; +> - 增加可区分候选查询、搜索引擎查询、权限判断、数据补充和请求总耗时的结构化观测数据。 +> - **本次明确排除**: +> - 不新增“父资源已可见时直接放行 `INHERIT` 子资源”的权限捷径; +> - 不以数据库权限模式、创建者身份或其他本地事实替代 OpenFGA 的候选可见性结论; +> - 不修改 Grant、权限模式、OpenFGA 模型或权限投影数据; +> - 不把目录搜索从现有 `page` / `has_more` 契约迁移为真实 keyset cursor; +> - 不修改文件下载、预览、RAG 或具体变更动作的权限语义; +> - 不进行 OpenFGA 并发压测或生产数据写入。 + +--- + +## 1. 用户故事 + +### 1.1 目录访问者 + +作为有权访问知识空间的用户, +我希望目录列表与搜索结果在权限结果、排序和分页连续性不变的前提下减少无效查询, +以便更快地浏览大型知识空间。 + +### 1.2 超级管理员 + +作为平台超级管理员, +我希望能够打开一个已存在的指定知识空间并浏览或搜索其目录, +以便执行平台管理和问题排查,而不依赖该空间为我配置普通成员可见来源。 + +### 1.3 运维与研发人员 + +作为运维或研发人员, +我希望一次目录或搜索请求能够区分业务候选查询、搜索引擎查询、权限判断、结果补充和总耗时, +以便接口变慢时判断瓶颈是否来自 OpenFGA 或其他阶段。 + +--- + +## 2. 验收标准 + +### 2.1 权限语义 + +- **AC-01** — WHEN 已登录平台超级管理员访问一个已存在的指定知识空间目录或搜索接口, THE SYSTEM SHALL 在空间门禁和候选资源可见性中识别其系统身份,无需为该空间另配普通授权;该行为不得扩大“个人可见空间列表”或其他个人内容枚举结果。 +- **AC-02** — WHEN 普通用户访问知识空间根目录或执行全空间搜索, THE SYSTEM SHALL 对该空间执行一次且仅一次可见性判断,并保持既有拒绝行为不变。 +- **AC-03** — WHEN 普通用户访问某个文件夹的直接子节点或限定在该文件夹内搜索, THE SYSTEM SHALL 校验空间和当前文件夹的可见性,但不得重复校验同一个空间可见性。 +- **AC-04** — WHEN 系统为普通用户筛选候选文件或文件夹, THE SYSTEM SHALL 使用统一权限执行面的批量可见性结果作为最终结论;不得因子资源为继承模式而在应用层直接放行。平台超级管理员只适用已确认的系统身份策略。 +- **AC-05** — IF OpenFGA、授权模型或资源权限状态不可用或不可判定, THEN THE SYSTEM SHALL 保持既有 fail-closed 行为,不得因本次性能优化返回额外资源。 +- **AC-06** — WHEN 平台超级管理员访问指定空间目录或搜索接口, THE SYSTEM SHALL 仅应用已确认的系统身份策略;本特性不得把租户管理员或普通管理员扩展为平台超级管理员。 + +### 2.2 候选读取与分页 + +- **AC-07** — WHEN 客户端通过目录或搜索接口请求任意合法页大小的首屏或后续页, THE SYSTEM SHALL 使候选读取规模跟随该次请求仍需获取的可见条数且保持有界,不得无条件多读取固定数量的候选。 +- **AC-08** — WHEN 首批候选经统一权限执行面过滤后不足一页, THE SYSTEM SHALL 按稳定顺序继续读取后续候选,直到填满请求页或候选耗尽。 +- **AC-09** — WHEN 目录返回结果仍有后续可见记录, THE SYSTEM SHALL 返回可继续获取下一页的游标;不可见候选不得在后续页被重复扫描或导致可见记录重复、遗漏。 +- **AC-10** — THE SYSTEM SHALL 保持既有文件夹优先、文件扩展名优先、更新时间和 ID 稳定兜底的排序语义。 +- **AC-11** — THE SYSTEM SHALL 继续排除非主版本文件,并保持目录的 `file_ids`、`file_status`、`file_type` 与搜索的 `keyword`、`tag_ids`、`file_status`、范围和排序参数的既有过滤行为。 +- **AC-12** — WHEN 搜索接口返回后续页, THE SYSTEM SHALL 保持现有 `page` / `page_size` / `has_more` 响应契约以及可见结果的顺序、去重和完整性;本特性不要求把搜索改为真实 cursor。 +- **AC-13** — WHEN 搜索候选来自正文关键词匹配, THE SYSTEM SHALL 保持文件名匹配与正文匹配的并集语义,并分别记录搜索范围准备、正文检索和业务候选读取耗时。 + +### 2.3 文件夹统计与响应兼容 + +- **AC-14** — WHEN 目录列表或搜索结果返回文件夹, THE SYSTEM SHALL 不再计算或提供界面未展示的后代成功数和处理中数量。 +- **AC-15** — WHEN 前端消费目录列表或搜索结果中的文件夹, THE SYSTEM SHALL 保持既有失败文件提示、单文件夹重试和批量重试入口;`has_failed_files` 只表达是否存在可重试失败文件,不得依赖完整状态计数才能得出。 +- **AC-16** — THE SYSTEM SHALL 保持文件条目的标签、缩略图和版本信息等现有返回能力,不得把移除文件夹数量统计扩大为移除文件补充信息。 + +### 2.4 可观测性 + +- **AC-17** — WHEN 一次目录或搜索请求完成, THE SYSTEM SHALL 记录可关联到该请求的结构化性能数据,并能区分空间或文件夹门禁、筛选/搜索候选、候选权限判断、结果补充和请求总耗时。 +- **AC-18** — WHEN 候选权限判断完成, THE SYSTEM SHALL 记录候选数量、可见数量、扫描批次数、扫描放大、批量权限判断请求数量和权限判断耗时。 +- **AC-19** — IF 目录或搜索请求失败, THEN THE SYSTEM SHALL 记录已完成阶段的耗时、失败阶段和结果状态,同时不得记录凭证、授权主体明细、搜索词、文件名或其他敏感业务内容。 +- **AC-20** — THE SYSTEM SHALL 允许运维按统一结构化日志标记、接口类型和请求追踪标识检索这些数据,不依赖解析自由文本错误消息。 + +### 2.5 性能与回归 + +- **AC-21** — WHEN 使用代表性候选规模验证目录与搜索, THE SYSTEM SHALL 分别报告 OpenFGA 批量判断耗时和接口端到端耗时,不得只用单次引擎基准宣称接口优化完成。 +- **AC-22** — THE SYSTEM SHALL 保持 MySQL 与 DM8 查询兼容、多租户隔离、HTTP 200 业务响应包装以及目录游标和搜索页码的既有错误行为。 +- **AC-23** — THE SYSTEM SHALL 为超级管理员、普通用户、无权限用户、根目录、子目录、关键词/标签搜索、候选过滤不足一页、候选耗尽和后续分页提供自动化回归覆盖。 + +--- + +## 3. 边界情况 + +- 空间不存在、类型不是知识空间或不属于当前租户时,维持既有不存在或拒绝行为。 +- `parent_id` 不存在、不属于当前空间或不是文件夹时,维持既有文件夹不存在行为。 +- 空游标表示首屏;格式、版本、排序上下文或排序键不合法的游标继续返回既有游标业务错误。 +- 请求页大小很小、达到允许上限或候选可见率很低时,均不得恢复为无界扫描。 +- 同一批候选同时含文件夹和文件时,最终可见性仍按各自 canonical 资源类型由 OpenFGA 判断。 +- 权限过滤后的页面为空但候选已经耗尽时,返回正常空页和结束状态。 +- 文件夹成功数、处理中数量字段缺失时,前端不得恢复数字展示;失败存在性仍由明确的 `has_failed_files` 布尔值表达。 +- 搜索第 N 页继续遵守既有页码契约;本期允许为定位该契约导致的前缀重复扫描记录扫描放大,但不以伪游标冒充真实 keyset cursor。 +- 性能日志发送失败不得影响目录请求的业务结果。 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 为什么继续使用 OpenFGA BatchCheck | `design.md` §3 | +| 超级管理员指定资源访问与个人列表的边界 | `design.md` §2 / §4 | +| 候选批次、目录游标、搜索页码和权限过滤数据流 | `design.md` §4 | +| 文件夹统计移除和前端影响 | `design.md` §4 / §6 | +| 性能日志结构、阶段和阈值 | `design.md` §4 / §7 | +| 已知坑与回归风险 | `design.md` §5 | +| 文件清单、实现顺序与测试 | `tasks.md` | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(spec 确认后创建) +- 执行与落档: [tasks.md](./tasks.md)(design 确认后创建) +- 版本契约: [features/v3.0.0-beta1/release-contract.md](../release-contract.md) +- 前序特性: [F027](../../v2.6.0/027-rebac-list-perf-optim/spec.md) · [F040](../../v2.6.0/040-rebac-read-path-perf-rollout/spec.md) · [F048](../048-rebac-permission-model-grants/spec.md) +- 权限架构: `docs/architecture/10-permission-rbac.md` diff --git a/features/v3.0.0-beta1/050-unified-permission-settings/design.md b/features/v3.0.0-beta1/050-unified-permission-settings/design.md new file mode 100644 index 0000000000..8cedab6dba --- /dev/null +++ b/features/v3.0.0-beta1/050-unified-permission-settings/design.md @@ -0,0 +1,439 @@ +# Design: 知识空间与频道统一权限设置入口(F048 适配) + +> 本文是当前实现方案的唯一设计真相:[spec.md](./spec.md) 定义 What,本文定义 Why/How, +> [tasks.md](./tasks.md) 记录执行。实现变化必须覆盖更新本文档。 + +**版本**: v3.0.0-beta1 +**最后更新**: 2026-08-17 + +## 1. 目标与非目标 + +### 1.1 目标 + +- 将普通知识空间和频道的新建/编辑从抽屉与独立权限弹窗整合为完整设置页。 +- 创建阶段维护 F048 初始 Grant 草稿;资源与 protected owner 成功后,通过唯一 F048 runtime 应用普通 Grant。 +- 编辑阶段复用 F048 context、roster、grantable models 和 mutation,只提交用户触碰的变化,并反馈真实部分成功状态。 + +### 1.2 非目标 + +- 不恢复 F044 的 relation、permission_id、binding JSON、旧授权 Service/API 或旧 relation selector。 +- 不修改 F048 Catalog、Grant、投影、OpenFGA model、正式迁移和顶级资源固定 `CUSTOM` 的语义。 +- 不给知识空间/频道提供权限 mode 切换;不改管理后台部门空间;不纳入邀请确认和文件审批。 +- 不宣称业务数据库与 F048/OpenFGA 构成跨存储事务。 + +## 2. 关键约束与 Constitution Check + +遵循 `docs/constitution.md` C1–C8 和本版本 `release-contract.md`;本功能额外受以下约束: + +1. F048 是唯一资源权限执行面,业务代码不读取旧权限事实。 +2. 创建前没有资源,不得伪造 `VerifiedPermissionTarget` 或调用带资源 ID 的权限接口。 +3. `knowledge_space` 和 `channel` 固定 `CUSTOM`,页面不渲染 `ModeHeader`。 +4. 资源与 protected owner 先成功;普通 Grant 失败保留资源并允许前向重试。 +5. `private/review/public`、广场发布、加入审核与成员表由 knowledge/channel 业务域拥有。 +6. 创建候选不接受客户端 tenant_id;部门继续懒加载与服务端搜索。 +7. 重复创建必须持久幂等,不能只依赖浏览器按钮或 Redis 短锁。 +8. 只改 client SPA;不新增 Recoil、UI 或状态库。 +9. 新增可空请求键和唯一索引必须兼容 MySQL/DM8;Alembic 只做 DDL。 + +### 2.1 Constitution Check + +| 条款 | 结论与保证 | +|---|---| +| C1 | 通过。Endpoint 委托业务 Service;业务模块只依赖 `permission.application`;权限模块不查询业务 ORM | +| C2 | 通过。仅普通字符串列和组合唯一索引;无 JSON/MySQL 专属 SQL | +| C3 | 通过。tenant 自动注入;候选不接受 tenant_id;唯一键包含 tenant | +| C4 | 通过。protected owner、预览和普通 Grant 全走 F048;不恢复旧 relation fallback | +| C5/C6 | 通过。优先复用 250 模块错误;不新增密钥或明文配置 | +| C7 | 通过。页面经 client `api/` adapter;hook/组件不直接使用 request | +| C8 | 通过。幂等事实存业务数据库;不使用本地文件共享状态 | + +## 3. 方案对比与选定 + +### D1. 完整页面替代 Drawer 和资源专属权限弹窗 + +- **备选**:A. 扩展现有 Sheet;B. 新增完整 create/settings 路由。 +- **选定**:B。 +- **原因**:F044 UI 目标已确认;两个 Drawer 已包含复杂滚动、触摸穿透、嵌套弹层防护,继续扩展会放大移动端问题。 +- **何时重新考虑**:产品明确要求保留抽屉并提供完整窄屏权限交互稿。 + +路由: + +- `/workspace/knowledge/create` +- `/workspace/knowledge/space/:spaceId/settings` +- `/workspace/channel/create` +- `/workspace/channel/:channelId/settings` + +### D2. 页面级权限草稿,不复用即时写入行为 + +- **备选**:A. 嵌入当前 `PermissionDialog/PermissionGrantTab`,每次操作立即写入;B. 抽取无副作用 picker/roster,由页面统一保存。 +- **选定**:B。 +- **原因**:新建没有资源 ID;编辑要求取消不生效和并发反馈。即时 mutation 无法满足统一保存。 +- **何时重新考虑**:后端提供覆盖业务字段与 Grant 的单一事务命令,且产品取消统一保存/取消。 + +通用 `PermissionDialog` 继续服务其他资源类型;知识空间/频道移除独立入口,但共享展示组件不复制。 + +### D3. 业务域创建入口调用 F048 prospective-owner 协议 + +- **备选**:A. 预创建隐藏资源后复用资源 API,会留下孤儿和副作用;B. permission endpoint 仅按 resource_type 枚举,会绕过业务创建资格;C. knowledge/channel 先验证本域创建资格,再调用共享 application protocol。 +- **选定**:C。 +- **原因**:业务域拥有创建能力与配额事实,权限域拥有 Catalog、owner 可授予边界和主体 canonicalization,符合 C1/C4。 +- **何时重新考虑**:平台建立权威的统一资源创建 capability gateway。 + +业务入口返回同形契约: + +- `GET /api/v1/knowledge/space/creation-permission-context` +- `GET /api/v1/channel/manager/creation-permission-context` +- 各自同前缀的 `/creation-grant-subjects/users`、`user-groups`、`departments/children`、`departments/search`、`departments/{id}/path-tree` + +Endpoint 不查组织 ORM。业务 Service 校验创建资格后调用 `ProspectiveGrantApplicationPort`;后者以当前 tenant、资源类型和未来 protected owner model 返回 active grantable models,并复用 subject directory。 + +### D4. protected owner 后调用 F048 initial Grant application + +- **备选**:A. 前端创建后再请求公开 `grants:mutate`,网络中断下结果不完整;B.业务 Service 在 `authorize_created` 后调用共享 `InitialGrantApplicationPort`;C. 将普通 Grant 塞进 owner create projection,扩大资源生命周期事务。 +- **选定**:B。 +- **原因**:保持一次创建请求和唯一 F048 写路径,同时明确两阶段事实;普通 Grant 失败不回滚资源或 owner。 +- **何时重新考虑**:F048 提供正式的 lifecycle + ordinary grants 统一 durable command。 + +该协议只接受内部 verified target、actor、expected Catalog release 和 ADD-only 草稿;不接受 HTTP dict、MOVE/REMOVE,也不跳过真实资源上的 manage/grantable/version 校验。 + +### D5. 业务表保存创建幂等键 + +- **备选**:A. 仅禁用按钮,挡不住超时重试;B. Redis 短锁,业务提交后缓存前崩溃仍会重复;C. knowledge/channel 表保存可空请求键和 payload hash。 +- **选定**:C。 +- **原因**:资源本身是持久结果;重试可返回同一资源并恢复 owner/Grant,不依赖进程或 TTL。 +- **何时重新考虑**:平台建立通用持久 command/idempotency ledger。 + +统一页面生成 UUID 并在本页生命周期稳定复用;payload hash 覆盖业务创建字段和规范化后的初始权限草稿, +相同键但任一字段不同均返回冲突。旧调用不传键仍兼容;失败后要修改授权草稿时进入设置页产生新的 mutation, +不得用同一创建请求键改写创建命令。 + +### D6. 编辑按业务设置 → reload context → Grant mutation 串行保存 + +- **备选**:A. 并行,private 清理与 Grant 写入竞争;B. 权限先写,业务失败后留下意外授权;C. 业务先写,再按最新 context 写 touched mutations。 +- **选定**:C。 +- **原因**:private 转换由业务 Service 权威清除普通来源;串行执行可给出准确部分成功状态。 +- **何时重新考虑**:出现统一事务命令,或产品拆成两个明确保存按钮。 + +- 保存为 private:只提交业务更新;后端清理普通来源,前端丢弃权限草稿并 reload。 +- 其他情况:业务成功后重新取 context;仍有 manage 且版本有效才提交 mutation。 +- 业务失败不写 Grant;Grant 失败不伪报业务失败,提示部分成功并 reload。 + +### D7. private 复用现有 F048 source 清理 + +- **备选**:A. 前端按 roster 逐条 REMOVE,会遗漏分页外与并发来源;B. 复用 knowledge/channel Service 的 `remove_ordinary_sources`。 +- **选定**:B。 +- **原因**:当前后端已覆盖 direct、department、group、subscription 等普通来源,并保留 protected owner;投影提交后才清 membership。 +- **何时重新考虑**:产品要求 private 保留某类普通来源,且先明确该来源的 Owner 与保留规则。 + +### D8. 以 feat/2.6.0 实际页面为 UI 基线,将权限运行时替换为 F048 + +- **备选**:A. 整体保留 2.6 页面、旧 permission API 和后端实现;B. 不使用 2.6 实际页面,仅根据 Spec 在 beta1 重新设计;C. 先合入 `origin/feat/2.6.0@901fa1ada` 的完整页面与交互,保留其布局、组件、文案和移动端行为,再定点替换 F044 权限数据结构、API 和状态逻辑。 +- **选定**:C。 +- **原因**:A 会恢复已退役的 relation、`permission_id` 和 binding 运行时,已试合并会导致 7 个后端导入错误和 66 个前端类型错误;B 容易丢失 2.6 已落地的视觉、交互和窄屏细节。C 同时保证 UI 目标不漂移和 F048 作为唯一权限执行面。 +- **何时重新考虑**:产品提供了取代 2.6 页面的新交互稿,或 2.6 页面结构无法在不恢复旧权限运行时的前提下适配 F048。 + +合并冲突按下表裁决,不对整个文件统一选 `ours`/`theirs`: + +| 冲突内容 | 权威来源 | 处理原则 | +|---|---|---| +| 完整页面布局、操作区、文案、移动端交互 | `origin/feat/2.6.0` 的 F044 UI | 保留实际 JSX/样式/交互,只做 F048 接口适配所必需的改动 | +| relation、`permission_id`、binding、旧授权 API/Service | beta1 F048 | 删除 2.6 旧实现,改为 context/grants/grantable-models/`grants:mutate` | +| protected owner、多来源、version、Catalog release、投影 | beta1 F048 | 严格保留 F048 契约,禁止 UI 用旧四档关系推导 | +| 自动标签、频道知识同步、网站抓取队列 | beta1 与 2.6 都已存在的业务能力 | 不视为 beta1 独有;合并后按字段、副作用和页面交互逐项回归 | +| F045 个人邀请确认、F046 文件变更审批 | COFCO 专项分支 | 不在 `feat/2.6.0` 中,本特性不合入、不补开发 | + +## 4. 系统现状(接手必读) + +### 4.1 当前调用链 + +**知识空间创建**:`knowledge/index.tsx` → `CreateKnowledgeSpaceDrawer` → `createSpaceApi` → `POST /api/v1/knowledge/space` → `KnowledgeSpaceService.create_knowledge_space` → 保存资源 → F048 `authorize_created` 建 fixed CUSTOM + protected owner。 + +**知识空间编辑**:同一 Drawer → `updateSpaceApi` → `KnowledgeSpaceService.update_knowledge_space`。转 private 时 `clear_space_authorization_for_private/remove_ordinary_sources` 清普通来源,再清非创建者 membership。独立 `KnowledgeSpaceShareDialog/PermissionDialog` 即时管理 Grant。 + +**频道创建**:`Subscription/index.tsx` → `CreateChannelDrawer` → `createManagerChannelApi` → `ChannelService.create_channel` → 信息源订阅 → 保存频道 → F048 `authorize_created` → creator membership → 知识同步。 + +**频道编辑**:同一 Drawer → `updateChannelApi` → `ChannelService.update_channel`。转 private 时 F048 `remove_ordinary_sources` 后清 membership/通知。独立 `ChannelPermissionDialog` 包装 F048 `PermissionDialog`。 + +**F048 UI**:`PermissionDialog` 读取 context、roster/my-permissions;`PermissionGrantTab` 读取模型/候选并直接 mutate;候选 endpoint 必须先对真实资源检查 manage,不能用于创建前。顶级空间/频道固定 CUSTOM。 + +### 4.1.1 feat/2.6.0 UI 迁移基线 + +以 `origin/feat/2.6.0@901fa1ada` 为可追溯基线,合并后优先保留: + +- `pages/knowledge/SpaceSettings/KnowledgeSpaceSettingsPage.tsx` 及 `useKnowledgeSpaceSettingsForm.ts`:空间创建/编辑同页、自动标签区、固定操作区和部分失败页。 +- `pages/Subscription/ChannelSettings/ChannelSettingsPage.tsx`、`ChannelBusinessSettings.tsx` 及 `useChannelSettingsForm.ts`:频道完整页、抓取预览/队列、知识同步和部分失败页。 +- `components/permission/PermissionDraftPanel.tsx`、`PermissionDraftPickerDialog.tsx`、`UnifiedPermissionControls.tsx`:权限区的可视结构和交互原语。 + +上述文件中的 `RelationModel`、relation、`permission_ids`、旧 authorize API 和旧 `PermissionDraftRow` +只是待替换适配层,不是权限真相。实现时保留 UI 结构,将其输入/输出改接 F048 `PermissionDraft`。 + +### 4.1.2 必须保留的业务能力 + +| 能力 | 具体功能 | 主要代码/契约 | +|---|---|---| +| 知识空间自动标签 | 租户开关决定是否显示;创建/编辑可开启,选择标签库模式并预览标签,或使用自定义标签文本/文件;提交 `auto_tag_enabled`、`auto_tag_library_id`、`auto_tag_custom_tags` | beta1 `CreateKnowledgeSpaceDrawer.tsx`、`api/knowledge.ts`;2.6 已迁入 `KnowledgeSpaceSettingsPage` | +| 频道知识同步 | 配置主频道和子频道文章自动同步到指定知识空间,并随频道创建/更新提交 `knowledge_sync.main/subs` | `CreateChannel/KnowledgeSyncSection.tsx`、`SyncSpaceItem.tsx`、`api/channels.ts` | +| 网站抓取队列 | 在频道信息源中输入网址后异步抓取/预览,展示排队、进行中、成功/失败状态,支持取消、错误反馈和将成功结果加入信息源;抓取进行中阻止提交 | `hooks/useCrawlQueue.ts`、`CrawlQueuePanel.tsx`、`CrawlPreviewDialog.tsx` | + +### 4.2 目标创建数据流 + +```text +create route + → 业务创建资格 + prospective-owner context + → tenant-scoped candidates + → local PermissionDraft(无服务端写) + → POST create(resource fields, request id, initial grants) + → 幂等查找/创建业务资源 + → F048 authorize_created protected owner + → InitialGrantApplicationPort ADD ordinary grants + → success | resource_created_permission_failed + → 进入资源或设置页恢复 +``` + +重试步骤:按 tenant + creator + request ID 查资源;无记录才创建;唯一键竞争读取赢家;相同键但完整 payload hash 不同报冲突;相同载荷复用资源并以稳定派生 key 前向恢复 owner/initial Grant。 + +初始授权部分失败后只有两条明确路径: + +| 场景 | 调用 | 幂等/并发语义 | +|---|---|---| +| 失败页立即重试,且未修改原草稿 | 使用原 `creation_request_id` 和完全相同 payload 重放原创建 POST | 后端返回已创建资源,用稳定派生 key 重试未完成的 initial Grant,不创建第二个资源 | +| 进入设置页、刷新页面或修改授权草稿 | 重新 GET context/grants,以服务端基线生成新 `grants:mutate` 请求 | 使用新 mutation idempotency key 和当前 resource/assignee version;不得改写原创建命令 | + +不新增绕过 F048 的 `authorize`/“补权限”公开 endpoint。失败页如无法保证原 payload 规范化后完全相同,必须转入设置页走第二条路径。 + +### 4.3 目标编辑数据流 + +```text +settings route + → detail + F048 context + → if can_manage: paged roster + grantable models + candidates on demand + → business form + PermissionDraft + → save business + → if private: backend cleanup + reload + → else reload context + touched ADD/MOVE/REMOVE + → reload detail/context/roster + exact result +``` + +### 4.4 页面与草稿 + +| 页面 | 布局 | 内容 | +|---|---|---| +| Knowledge create/settings | 居中单栏,最大约 648px | 基本信息、访问分享、自动标签、成员权限、固定操作区 | +| Channel create/settings | 桌面双栏、窄屏单栏 | 左侧信息源/筛选/子频道/知识同步,右侧访问分享/成员权限 | + +使用 `@bisheng/ui`、语义字体/颜色 token 和主题类;禁止硬编码品牌/灰色、backdrop blur 和新 UI 库。 + +```ts +interface PermissionDraftAdd { + clientKey: string; + modelKey: string; + subject: PermissionGrantSubjectInput; + subjectName: string; +} + +interface PermissionDraft { + baselineResourceVersion: number | null; + baselineCatalogReleaseId: number; + existingChanges: Record; + additions: PermissionDraftAdd[]; +} +``` + +新建仅允许 additions;编辑变化必须携带 assignee version;protected/inherited/不可编辑项不能进 draft;未触碰 roster 不转换为 REMOVE;草稿只存页面内存,不进 Recoil/localStorage。 + +### 4.5 应用协议 + +`ProspectiveGrantApplicationPort`:从当前 Catalog 读取 owner 与可授予 active models;返回 release ID;按业务验证后的 tenant scope 查询候选;不创建 target、不 Check、不写 Grant。 + +`InitialGrantApplicationPort`:只接受 verified target;重新验证 grantable model、tenant/status/userset 与 Catalog;使用稳定 key 调 F048 durable mutation;返回实际 version/assignee。只有资源和 owner 已完成后的普通 Grant 错误可转换为部分成功;资源/owner 失败必须整体传播。 + +### 4.6 HTTP 契约 + +#### 4.6.1 创建前 context 与候选 + +| 资源 | Context | 候选路径前缀 | +|---|---|---| +| 知识空间 | `GET /api/v1/knowledge/space/creation-permission-context` | `/api/v1/knowledge/space/creation-grant-subjects` | +| 频道 | `GET /api/v1/channel/manager/creation-permission-context` | `/api/v1/channel/manager/creation-grant-subjects` | + +两类资源的候选 endpoint 同形: + +- `GET {prefix}/users?keyword=&page=1&page_size=50` → `{"data":[GrantUser],"total":number}`。 +- `GET {prefix}/user-groups?page=1&page_size=50` → 分页用户组。 +- `GET {prefix}/departments/children?parent_id=` → 当前层 `GrantDepartmentNode[]`。 +- `GET {prefix}/departments/search?keyword=&limit=50` → `{"roots":[],"total_matches":number,"truncated":boolean}`。 +- `GET {prefix}/departments/{id}/path-tree` → 用于定位已选部门的祖先路径树。 + +候选响应由 permission application 层按当前 tenant 和 active 状态 canonicalize;请求不接受 +`tenant_id`。部门浏览只返回一层,搜索有上限,不提供全树一次加载。 + +资源创建继续使用原 URL:知识空间 `POST /api/v1/knowledge/space`,频道 +`POST /api/v1/channel/manager/create`。以下字段是对原请求/响应的可选扩展,不替换原业务字段。 + +创建上下文: + +```json +{"catalog_release_id":42,"can_configure_initial_permissions":true,"grantable_models":[{"key":"viewer","name":"查看者","level":1,"active":true}]} +``` + +创建请求在原字段外增加: + +```json +{ + "creation_request_id":"uuid", + "initial_permissions":{ + "expected_catalog_release_id":42, + "grants":[{"model_key":"editor","subject":{"type":"department","id":"12","userset_relation":"subtree_member","include_children":true}}] + } +} +``` + +`initial_permissions` 可省略且只允许 ADD;禁止 tenant/source/protected/level/resource version/assignee ID。 + +创建响应保持原资源字段位于 `data` 顶层,只追加可选结果,避免破坏既有调用方: + +```json +{ + "id":"123", + "name":"示例资源", + "initial_permission_result":{"status":"success|failed","error_code":null,"resource_version":2} +} +``` + +无 grants 时结果可省略;failed 只表示本次原子普通 Grant mutation 未应用,resource + protected owner 已成功; +不回显完整主体或异常文本。后端保持旧 payload 和原资源响应形状;client adapter 只需读取新增可选字段。 + +#### 4.6.2 创建后 F048 编辑契约 + +资源路径统一为 `/api/v1/permissions/resources/{resource_type}/{resource_id}`: + +| 方法与后缀 | 用途 | 关键并发字段 | +|---|---|---| +| `GET /context` | 资源模式、当前能力、Catalog/resource version | `catalog_release_id`、`resource_version` | +| `GET /grants?cursor=&page_size=` | 分页 roster 与每条来源 | `assignee_id`、`version`、protected/source;不得按用户合并 | +| `GET /my-permissions` | 页面具体动作能力 | 不以角色名/relation 替代 | +| `GET /grantable-models` | 当前 actor 可授予的 active model | 与 context Catalog release 对齐 | +| `POST /grants:mutate` | 原子提交 touched ADD/MOVE/REMOVE | expected resource/catalog/assignee version + mutation idempotency key | +| `GET /grant-subjects/users` | 用户分页/搜索 | `keyword`、`page`、`page_size` | +| `GET /grant-subjects/user-groups` | 用户组分页 | `page`、`page_size` | +| `GET /grant-subjects/departments/children` | 部门懒加载 | `parent_id` 可省略 | +| `GET /grant-subjects/departments/search` | 部门服务端搜索 | `keyword`、`limit` | + +设置页不调用 mode-draft endpoint,因为知识空间和频道是 fixed `CUSTOM`。 + +### 4.7 数据库契约 + +| 表 | 新列 | 唯一范围 | +|---|---|---| +| knowledge | `creation_request_id VARCHAR(64) NULL`、`creation_payload_hash VARCHAR(64) NULL` | tenant + creator + type + request ID | +| channel | 同上 | tenant + creator + request ID | + +存量不回填。hash 覆盖规范化后的完整创建命令(含初始 Grant);初始 Grant 使用同一请求键派生的 projection +idempotency key保证相同网络重试不重复。修改失败草稿必须在设置页使用新的 mutation key。Alembic revision 只做 DDL。 + +### 4.8 模块职责 + +| 模块 | 做什么 | 不做什么 | +|---|---|---| +| create/settings pages | 路由、表单、部分成功恢复、保存编排 | 不直接 request,不鉴权或清权限 | +| `usePermissionDraft` | 本地草稿、touched mutation、只读防护 | 不 HTTP,不持久化 | +| permission views | roster、模型、主体选择共享展示 | 不决定创建资格,不自动提交 | +| client api adapters | F048 编辑 API、创建上下文/候选、envelope 映射 | 不持有页面状态 | +| knowledge/channel Service | 创建资格、CRUD、幂等、private 规则、初始权限编排 | 不查询 F048 SQL/OpenFGA,不构造 tuple | +| Prospective port | owner 策略预览与 tenant directory | 不加载业务资源,不写权限 | +| Initial Grant port | 真实 target 的 ADD-only F048 mutation | 不创建/删除资源,不返回 HTTP response | +| F048 runtime | Catalog、Grant、source、version、projection、OpenFGA | 不拥有业务字段 | + +## 5. 已知坑 / 反直觉事实 + +| # | 事实 | 不知道的后果 | 处理位置 | +|---|---|---|---| +| 1 | 空间/频道固定 CUSTOM | 错误显示 mode switch 并调用必失败 API | page 不渲染 ModeHeader;`FIXED_CUSTOM_TYPES` 不改 | +| 2 | PermissionGrantTab 当前即时写 | 取消后权限已生效;新建无 ID | presentational views + `usePermissionDraft` | +| 3 | 创建前无 verified target | 伪造 target 违反 C4,预创建产生孤儿 | D3 prospective protocol | +| 4 | owner 可授予范围随 Catalog 变 | 页面旧模型可能过期 | context 携带 release;提交再校验 | +| 5 | owner create/每次 mutation 推进 resource version | 使用列表快照稳定冲突 | 后端创建后加载 target;编辑业务保存后 reload context | +| 6 | private 后端已清所有 ordinary sources | 前端 mutation 会竞争或写回 | private 分支丢草稿并 reload | +| 7 | protected owner 与普通 owner 可并存 | 合并角色行会丢来源或误删 | roster 按 assignee/source,protected 锁定 | +| 8 | 同一用户可来自 direct/department/group | 按 user 去重会误判完全失权 | 按 assignee/source 操作 | +| 9 | 频道落库前可能订阅外部信息源 | 数据库 key 不能回滚外部调用 | 保留“已订阅则跳过”;异常真实失败 | +| 10 | beta1 和 2.6 都有自动标签、知识同步和抓取队列,但空间功能分别位于 Drawer 与完整页 | 整文件选 ours/theirs 会丢 UI 或字段/副作用 | D8 逐项裁决;§4.1.2 功能回归 | +| 11 | 403 由 interceptor 统一处理 | 页面分支形成双跳转 | 只处理 domain partial/conflict | +| 12 | 不稳定 localize/callback 曾触发表单重复初始化 | 请求循环、覆盖输入 | primitive effect deps、稳定 adapter、请求次数测试 | +| 13 | 部门必须懒加载 | 大租户冻结和越权放大 | children/search/path-tree | +| 14 | request ID 不替代 F048 projection idempotency | 资源不重复但 Grant 可能重复 | owner 与 initial mutation 各用稳定 key | + +## 6. 对外契约与依赖 + +### 6.1 本特性提供 + +| 契约 | 形式 | 消费方 | +|---|---|---| +| 四个 create/settings 路由 | client route | 空间列表/详情、频道订阅页/菜单 | +| 两类 creation context/candidates | Design §4.6.1 的 HTTP GET 与同形响应 | client 权限草稿 | +| 扩展后的两类创建请求/部分成功响应 | Design §4.6.1 的 HTTP POST/JSON | client 与既有调用方 | +| 失败后前向重试 | 原创建 POST 同键同 payload 重放,或 Design §4.6.2 F048 mutation | client 失败页/设置页 | +| `ProspectiveGrantApplicationPort` | Python protocol | knowledge/channel Service | +| `InitialGrantApplicationPort` | Python protocol | knowledge/channel Service | + +### 6.2 本特性依赖 + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| F048 active Catalog/owner | runtime | 未就绪必须 fail closed,不能伪造模型 | +| F048 `authorize_created` | Python protocol | 必须成功才能返回可恢复资源 | +| context/roster/grantable/mutate | HTTP/Python | resource/catalog/assignee version 不可丢 | +| subject directory | application port | tenant 与 active 状态必须 canonicalize | +| knowledge/channel create/update | business contract | 保留自动标签、信息源、筛选、同步、通知副作用 | +| bisheng-information | 第三方 HTTP | 频道创建外部订阅失败不能伪报资源成功 | +| MySQL/DM8 | 数据库 | 组合唯一索引与 nullable 行为需双库验证 | +| `@bisheng/ui`/i18n | frontend | 三语言同 PR,不新增库 | + +### 6.3 兼容性 + +- 原创建 URL 不变,新字段可选;旧调用不传 request ID/grants 继续成功。 +- 现有 F048 endpoint 不改;统一页面只是新消费者。 +- `PermissionDialog` 保留给其他资源;只删除无调用方的空间/频道 wrapper。 +- 新 i18n key 同时写 en、zh-Hans、ja;不手改 api_errors 生成物。 + +## 7. 测试与可观测 + +### 7.1 自动化 + +- 权限 application:prospective owner、tenant scope、ADD-only、Catalog/version、canonical subject。 +- 业务 Service/API:两类创建、重复 request/hash 冲突、owner 失败、普通 Grant 部分失败/重试、旧 payload。 +- 前端:草稿 ADD/MOVE/REMOVE、protected、多来源、无 manage 隐藏、冲突、部分成功、移动布局。 +- E2E:空间/频道创建并授权 user/department/group、初始授权失败、编辑并发、private、窄屏。 +- 门禁:backend ruff/pytest/arch-guard;frontend lint/typecheck/check-i18n;DM8 中央回归。 + +### 7.2 手动验证 + +启动 API 与 client 后访问: + +- `http://localhost:4001/workspace/knowledge/create` +- `http://localhost:4001/workspace/knowledge/space//settings` +- `http://localhost:4001/workspace/channel/create` +- `http://localhost:4001/workspace/channel//settings` + +分别使用创建者、仅 edit、仅 visible、manage 被撤销的同租户测试账号;再以另一租户账号验证候选隔离,不在文档中固化账号或密码。验证 direct/department/group、部分失败恢复、private 清理和 390px 布局,并回归自动标签、知识同步与抓取队列。开发命令以 `src/backend/AGENTS.md` 和 `src/frontend/client/AGENTS.md` 当前记载为准;真实 F048/OpenFGA 与 DM8 场景进入专用集成环境。 + +### 7.3 可观测性 + +结构化记录 `resource_type`、request ID、resource ID、resource_created、owner_projection_status、initial_grant_status、permission_error_code;不记录主体名称、完整草稿或异常正文。F048 operation/idempotency/projection 指标沿用现有观测,不建第二套成功口径。 + +## 8. 后续改进 / 不打算做的事 + +- 不抽象跨资源万能表单:两类业务差异大,只共享权限草稿与布局原语。 +- 不建立平台级通用 command ledger:两个业务表请求键是当前最小持久真相。 +- 不开放顶级资源 INHERIT;若需要必须先修改 F048 Spec/Contract。 +- 不保留旧独立权限入口作为 fallback,避免双权限 UI。 +- 不把创建草稿存浏览器;跨会话草稿需另起服务端实体设计。 + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-08-14 | 初版,以 F048 重建并保留 F044 UI 目标 | 用户确认 Discovery 与 Spec | +| 2026-08-14 | 改为先合入 2.6 实际 UI 再替换 F044 权限契约;补齐冲突裁决、HTTP 和失败重试契约 | Design 评审与用户确认实施顺序 | diff --git a/features/v3.0.0-beta1/050-unified-permission-settings/e2e-checklist.md b/features/v3.0.0-beta1/050-unified-permission-settings/e2e-checklist.md new file mode 100644 index 0000000000..2324d7fb18 --- /dev/null +++ b/features/v3.0.0-beta1/050-unified-permission-settings/e2e-checklist.md @@ -0,0 +1,38 @@ +# F050 页面 E2E 验收清单 + +**目标页面**: Client `/workspace/knowledge/create`、`/workspace/knowledge/space/:spaceId/settings`、`/workspace/channel/create`、`/workspace/channel/:channelId/settings` + +**前置条件**: F048 Catalog/Projection 就绪;准备具备 edit+manage、仅 edit、仅 view 三类账号;准备同租户用户/部门/用户组及一个可抓取网站。 + +## 知识空间 + +- [ ] AC-01/05/06:桌面与 390px 打开新建页,确认基本信息、可见性、加入方式、自动标签、初始授权和底部操作区完整;不存在独立成员权限入口。 +- [ ] AC-08/09:仅 edit 账号看不到名单/模型/新增授权;manage 账号可见来源、protected 和只读状态。 +- [ ] AC-13/18:添加用户、部门(含子部门)、用户组并切换模型;创建者 owner 始终不可删改。 +- [ ] AC-15/20/22:创建后重新进入设置页,以服务端 roster 为准;编辑只提交已触碰 ADD/MOVE/REMOVE。 +- [ ] AC-16/17/19:制造失效主体或 Catalog 版本变化,确认资源保留、显示部分失败,可进入设置页按最新状态重试。 +- [ ] AC-23/24/25:并发修改后旧页面保存被拒绝;protected/inherited 不可编辑;同主体多来源分行保留。 +- [ ] AC-26/28/29/31:切换 private/review/public,确认只清业务规则拥有的普通来源;自动标签库/自定义标签及广场设置不丢。 + +## 频道 + +- [ ] AC-02/05/06:桌面双栏与 390px 单栏展示信息源、筛选、子频道、知识同步、可见性和权限;不存在独立成员权限入口。 +- [ ] AC-08/09:仅 edit 与 manage 账号的信息隔离同知识空间。 +- [ ] AC-13/18:初始授权支持三类主体与模型调整,protected owner 不可删改。 +- [ ] AC-15/20/22:创建/编辑后刷新,信息源、筛选、子频道、知识同步及 F048 roster 均以服务端事实恢复。 +- [ ] AC-16/17/19:初始授权部分失败时不重复创建频道,可进入设置页重试。 +- [ ] AC-23/24/25:验证并发冲突、只读来源和同主体多来源。 +- [ ] AC-32/34:抓取任务排队、最多 3 并发、取消、失败、预览;进行中禁止提交,成功后信息源进入表单。 + +## 权限与租户回归 + +- [ ] AC-07/10/11:入口按最新 actions 显示;直达无权页面读取失败;页面打开后撤权,后续提交失败。 +- [ ] AC-12:候选列表不返回跨租户主体,手工提交跨租户主体被后端拒绝。 +- [ ] AC-27/33:顶级空间/频道不出现继承模式,网络请求中无 relation、permission_id 或旧 authorize API。 +- [ ] AC-30:不带 creation_request_id/initial_permissions 的旧创建请求仍成功并生成 protected owner。 + +## 回归与观察 + +- [ ] 浏览器控制台无错误;网络面板无重复创建请求或旧 F044 请求。 +- [ ] 置顶、退出、删除、解散、分享、文件/文件夹通用权限入口保持可用。 +- [ ] 页面与弹窗无 `backdrop-filter`,信创浏览器滚动和鼠标移动无明显卡顿。 diff --git a/features/v3.0.0-beta1/050-unified-permission-settings/e2e-report.md b/features/v3.0.0-beta1/050-unified-permission-settings/e2e-report.md new file mode 100644 index 0000000000..9029316b02 --- /dev/null +++ b/features/v3.0.0-beta1/050-unified-permission-settings/e2e-report.md @@ -0,0 +1,48 @@ +# F050 E2E 覆盖报告 + +## 当前结论 + +**状态: PARTIAL**。自动化代码、静态门禁与本地可执行测试已通过;本机 `localhost:7860` 未启动,且没有专用 F050 E2E 部署凭据,因此 live API 与浏览器验收尚未执行,T035–T037 不能标记完成。 + +## 自动化结果 + +| 范围 | 结果 | 证据 | +|---|---|---| +| Client F048 adapter / draft / 页面契约 / 路由 / 抓取队列 | PASS | Node 环境定向回归:10 suites,31 tests | +| Frontend workspace ESLint | PASS | Platform、Client、UI、file-viewers 全部通过 `pnpm lint` | +| Frontend workspace TypeScript | PASS | Platform、Client、file-viewers 全部通过 `pnpm typecheck` | +| i18n parity + backend error-code coverage | PASS | `pnpm check-i18n` | +| Client production build | PASS | Vite production build completed; generated locale artifacts are committed | +| Backend F050 focused regression | PASS | Permission/Knowledge/Channel 共 36 tests | +| Architecture guard | PASS | `scripts/arch-guard.sh` | +| Knowledge live E2E 收集 | SKIP | 3 tests;需 `F050_E2E=1` | +| Channel live E2E 收集 | SKIP | 3 tests;另需 `F050_E2E_CHANNEL_SOURCE_ID` | +| 新增 E2E Ruff | PASS | 2 files | + +## 合并基线证据 + +- 当前集成分支包含 `origin/feat/3.0.0-beta1@7b7adea602a57fea6bdd6db3216666a8e3866ca7`。 +- 当前集成分支包含 `origin/feat/2.6.0@3cd62f3909243bf787b47cd82e23f481eb01f42c`。 +- `git merge-base --is-ancestor` 对两个远端引用均返回成功;最新 2.6 增量合并提交为 `e9e110f758f3b73131cfbaf938f9acb155625fc6`。 + +## Live E2E 文件 + +- `src/backend/test/e2e/test_e2e_f050_knowledge_permission_settings.py` +- `src/backend/test/e2e/test_e2e_f050_channel_permission_settings.py` + +两个套件均使用至少 5 字符的 `e2e-f050-*` 前缀,并在模块前后只删除此前缀资源。默认安全跳过,不会连接或修改环境。 + +## 待执行命令 + +```bash +cd src/backend +F050_E2E=1 \ +E2E_API_BASE=http://:7860/api/v1 \ +E2E_ADMIN_PASSWORD='' \ +F050_E2E_CHANNEL_SOURCE_ID='' \ +uv run pytest \ + test/e2e/test_e2e_f050_knowledge_permission_settings.py \ + test/e2e/test_e2e_f050_channel_permission_settings.py -v +``` + +页面验收逐项记录在 `e2e-checklist.md`。live 环境执行完成并填写清单后,再更新本报告为 PASS 或 FAIL。 diff --git a/features/v3.0.0-beta1/050-unified-permission-settings/spec.md b/features/v3.0.0-beta1/050-unified-permission-settings/spec.md new file mode 100644 index 0000000000..ff6a27b8cc --- /dev/null +++ b/features/v3.0.0-beta1/050-unified-permission-settings/spec.md @@ -0,0 +1,150 @@ +# Feature: 知识空间与频道统一权限设置入口(F048 适配) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> 本文档只定义统一设置入口的目标体验、权限边界、失败行为和验收标准。 +> 实现决策、数据流、接口、组件与文件清单由后续 `design.md` / `tasks.md` 维护。 + +**关联需求**: v2.6.0 F044「知识空间与频道统一权限设置入口」迁移至 v3.0.0-beta1 F048 权限体系 +**优先级**: P0 +**所属版本**: v3.0.0-beta1 +**依赖**: v2.6.0 F044、v3.0.0-beta1 F048 + +> **范围边界** +> - **本次纳入**: +> - 将普通知识空间的新建、编辑以及频道的新建、设置改为完整页面; +> - 在同一入口承载资源基本信息、既有业务设置、可见范围和成员权限设置; +> - 新建时支持提交前维护成员权限草稿,并在资源创建后应用初始普通授权; +> - 编辑时按当前有效能力展示和保存业务设置与成员权限; +> - 全部权限读写、所有者保护和并发控制遵守 F048 当前语义; +> - 桌面端与移动端保持相同功能范围,按可用宽度调整页面布局; +> - 移除只用于知识空间或频道成员权限管理的独立入口。 +> - **本次明确排除**: +> - 不恢复旧 relation、permission_id、Config 权限模型或旧授权服务; +> - 不恢复旧权限选择器、旧独立权限弹窗或旧权限 API; +> - 不修改 F048 Catalog、标准/自定义模型、OpenFGA Authorization Model 或正式迁移流程; +> - 不把业务可见范围、加入方式与资源权限事实合并为同一个状态; +> - 不实现资源创建与初始普通授权的跨存储原子事务; +> - 不改造管理后台创建集团/部门知识空间的流程; +> - 不包含个人邀请确认、文件变更审批或其他审批中心需求。 + +--- + +## 1. 用户故事 + +### 1.1 资源创建者 + +作为知识空间或频道创建者, +我希望在一个完整页面中配置资源信息、业务选项和初始成员权限, +以便一次提交完成资源建立,无需创建后再寻找独立权限入口。 + +### 1.2 资源管理者 + +作为具备资源编辑和权限管理能力的协作者, +我希望在同一设置页读取并修改资源设置和成员权限, +以便基于当前服务端状态完成管理操作。 + +### 1.3 仅有编辑能力的协作者 + +作为只有业务编辑能力而没有权限管理能力的协作者, +我希望仍可使用统一设置页修改被授权的业务字段,但看不到成员权限信息, +以便权限入口整合不会扩大我的信息读取范围。 + +### 1.4 资源查看者 + +作为仅有查看能力的用户, +我希望看不到无权使用的新建或设置操作, +以便界面能力与服务端有效权限保持一致。 + +--- + +## 2. 验收标准 + +### 2.1 统一入口与页面组织 + +- **AC-01** — WHEN 有权创建普通知识空间的用户进入新建入口, THE SYSTEM SHALL 以完整页面同时展示基本信息、业务可见性、加入方式和可配置的初始成员权限。 +- **AC-02** — WHEN 有权创建频道的用户进入新建入口, THE SYSTEM SHALL 以完整页面同时展示频道信息源、内容筛选、子频道、知识同步、业务可见性和可配置的初始成员权限。 +- **AC-03** — WHEN 有权编辑知识空间的用户进入设置入口, THE SYSTEM SHALL 在同一完整页面展示其有权读取和修改的空间设置区域。 +- **AC-04** — WHEN 有权编辑频道的用户进入设置入口, THE SYSTEM SHALL 在同一完整页面展示其有权读取和修改的频道设置区域。 +- **AC-05** — THE SYSTEM SHALL 不再提供只用于知识空间或频道成员权限管理的独立操作入口;置顶、退出、删除、解散及其他既有操作继续遵循其原有能力规则。 +- **AC-06** — WHEN 统一设置页在窄屏设备打开, THE SYSTEM SHALL 保持与桌面端相同的字段、权限能力和提交结果,仅调整内容排列与操作区布局。 + +### 2.2 能力判断与信息隔离 + +- **AC-07** — WHEN 用户进入知识空间或频道编辑页, THE SYSTEM SHALL 根据服务端当前有效的具体动作权限决定其可见区域,不得按角色名称、历史 relation 或本地缓存结果扩大能力。 +- **AC-08** — WHEN 用户具备业务编辑能力但不具备权限管理能力, THE SYSTEM SHALL 允许其编辑获准的业务字段,但不展示成员名单、授权来源、可授予权限模型或新增授权操作。 +- **AC-09** — WHEN 用户具备权限管理能力, THE SYSTEM SHALL 展示有效成员授权、授权来源、保护状态及其可执行的授权操作。 +- **AC-10** — WHEN 用户不具备资源编辑能力, THE SYSTEM SHALL 不展示对应设置入口;IF 用户直接访问设置地址, THEN THE SYSTEM SHALL 拒绝读取或修改无权访问的设置。 +- **AC-11** — IF 用户打开页面后其编辑或权限管理能力被撤销, THEN THE SYSTEM SHALL 在后续读取或提交时按最新有效能力拒绝已失权操作。 +- **AC-12** — WHERE 多租户开启, THE SYSTEM SHALL 只允许选择、读取和授权当前租户范围内的用户、部门与用户组,不得返回或接受跨租户主体。 + +### 2.3 新建与初始授权 + +- **AC-13** — WHEN 创建者在提交前配置初始成员权限, THE SYSTEM SHALL 允许其添加支持的主体、选择可授予权限模型、调整尚未提交的模型以及移除草稿项。 +- **AC-14** — WHILE 新建表单尚未成功提交, THE SYSTEM SHALL 不创建资源、普通成员授权或可被其他请求观察到的临时权限事实。 +- **AC-15** — WHEN 创建者提交合法表单且资源与全部初始普通授权成功, THE SYSTEM SHALL 返回创建成功,并使资源及授权可通过后续服务端查询验证。 +- **AC-16** — IF 资源创建成功但任一初始普通授权失败, THEN THE SYSTEM SHALL 保留已创建资源及其受保护所有者权限,明确返回资源已创建但权限未完全设置的结果,且不得将失败授权显示为已生效。 +- **AC-17** — WHEN 创建者收到资源已创建但权限未完全设置的结果, THE SYSTEM SHALL 允许其进入该资源设置页,基于服务端最新权限状态重试未完成的授权,而不得重复创建资源。 +- **AC-18** — THE SYSTEM SHALL 不允许从创建草稿中删除、降级或替换系统建立的受保护创建者所有者权限。 +- **AC-19** — IF 初始授权提交时目标模型已停用、主体已失效、权限能力已撤销或版本上下文已变化, THEN THE SYSTEM SHALL 拒绝无效授权并返回可识别的部分失败结果,不得回退到旧权限路径继续写入。 + +### 2.4 编辑、并发与权限变更 + +- **AC-20** — WHEN 有权用户打开设置页, THE SYSTEM SHALL 从服务端读取当前资源详情、具体动作能力和权限状态,不得以列表页快照作为最终设置状态。 +- **AC-21** — WHEN 用户保存业务字段, THE SYSTEM SHALL 只修改其当前有权编辑的字段;权限区域隐藏或无权操作不得阻止其他合法业务字段保存。 +- **AC-22** — WHEN 有权用户新增、调整或移除成员授权, THE SYSTEM SHALL 只提交相对于当前服务端基线实际发生的授权变化,并保持未被本次操作触及的授权来源有效。 +- **AC-23** — IF 保存期间资源、权限目录或目标授权已被其他操作修改, THEN THE SYSTEM SHALL 拒绝过期变更并提示重新加载,不得用旧页面状态覆盖较新的有效状态。 +- **AC-24** — WHILE 某一成员授权被标记为受保护、继承或不可编辑, THE SYSTEM SHALL 展示其来源和只读状态,并禁止删除、降级或移动该授权。 +- **AC-25** — WHEN 同一主体通过直接授权、部门、用户组或其他来源获得权限, THE SYSTEM SHALL 分别展示和维护有效来源;撤销一个来源不得删除其他仍有效来源。 + +### 2.5 业务可见性与顶级资源权限 + +- **AC-26** — THE SYSTEM SHALL 将资源业务可见范围、加入方式和广场发布等业务设置,与成员权限分别展示和保存,不得把两者合并为一个枚举或互相推导全部状态。 +- **AC-27** — THE SYSTEM SHALL 将知识空间和频道作为顶级权限容器管理,不向用户提供从其他资源继承成员权限的选项。 +- **AC-28** — WHEN 用户修改业务可见范围或加入方式, THE SYSTEM SHALL 保持成员权限与业务设置的一致性,不得恢复已经退役的权限关系或无差别撤销来源不明的授权。 +- **AC-29** — IF 某项业务可见性变化需要撤销或建立权限事实, THEN THE SYSTEM SHALL 只处理该业务规则拥有的来源,并保留受保护所有者及其他独立有效来源。 + +### 2.6 兼容性与回归 + +- **AC-30** — THE SYSTEM SHALL 保持未携带初始权限草稿的既有资源创建调用仍可创建资源并建立受保护所有者权限。 +- **AC-31** — THE SYSTEM SHALL 保持知识空间名称、简介、空间广场、部门空间范围及其他既有空间业务语义。 +- **AC-32** — THE SYSTEM SHALL 保持频道信息源、简介、内容筛选、子频道、知识同步及其他既有频道业务语义。 +- **AC-33** — THE SYSTEM SHALL 不恢复任何已被 F048 退役的权限行为或形成第二套资源权限判定与写入结果。 +- **AC-34** — THE SYSTEM SHALL 为知识空间和频道的新建成功、初始授权部分失败、编辑、失权、并发冲突、多来源授权及移动端布局提供自动化回归覆盖。 + +--- + +## 3. 边界情况 + +- 创建请求重复提交时,不得产生第二个资源或重复初始授权;最终页面以服务端返回的实际资源和权限状态为准。 +- 资源创建后页面跳转、刷新或关闭时,已创建资源继续存在;未确认成功的普通授权不得由前端本地状态补写为成功。 +- 权限候选为空、权限模型停用或目标主体在提交前被删除时,页面应保留其他合法表单内容并准确反馈无效权限项。 +- 创建者同时通过其他直接或组织来源获得权限时,受保护所有者与其他来源应分别保留,不得合并成不可追溯的单行状态。 +- 编辑页部分业务保存成功、权限修改失败时,应按各步骤真实结果反馈;重新进入页面必须以服务端状态为准。 +- 私密、分享、加入审核和公开加入的组合不得改变知识空间与频道作为顶级权限容器的事实。 +- 系统身份或租户管理员短路只遵循 F048 已确认策略,不得因统一入口增加新的角色名放行。 +- 权限服务不可用、目录未就绪或权限结果不可判定时,权限相关读取和修改保持 fail-closed;不得回退到旧接口。 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 完整页面与移动端布局方案 | `design.md` §3 / §4 | +| 创建后初始授权编排与失败恢复 | `design.md` §3 / §4 / §5 | +| F048 权限上下文与成员授权变更契约 | `design.md` §4 / §6 | +| 业务可见性与顶级权限容器边界 | `design.md` §2 / §4 | +| 并发、幂等、多来源与 protected owner | `design.md` §2 / §5 | +| 文件清单、实现顺序与测试 | `tasks.md` | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(spec 确认后创建) +- 执行与落档: [tasks.md](./tasks.md)(design 确认后创建) +- 版本契约: [release-contract.md](../release-contract.md) +- 交互需求来源: Git ref `origin/feat/2.6.0:features/v2.6.0/044-unified-permission-entry/spec.md` +- 权限体系: [v3.0.0-beta1 F048](../048-rebac-permission-model-grants/spec.md) +- 架构宪法: [docs/constitution.md](../../../docs/constitution.md) diff --git a/features/v3.0.0-beta1/050-unified-permission-settings/tasks.md b/features/v3.0.0-beta1/050-unified-permission-settings/tasks.md new file mode 100644 index 0000000000..5e7ec6a373 --- /dev/null +++ b/features/v3.0.0-beta1/050-unified-permission-settings/tasks.md @@ -0,0 +1,278 @@ +# Tasks: 知识空间与频道统一权限设置入口(F048 适配) + +**关联规格**: [spec.md](./spec.md) +**关联设计**: [design.md](./design.md) +**版本**: v3.0.0-beta1 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| spec.md | ✅ 已评审 | 用户已确认 | +| design.md | ✅ 已评审 | 2026-08-17 用户确认;接手时第一入口 | +| tasks.md | ✅ 已拆解 | `/sdd-review tasks` 21 项评审 LGTM | +| 实现 | 🟡 进行中 | 34 / 37 完成;偏差见本文末尾 | + +--- + +## Wave 0:合并基线与数据库基础 + +- [x] **T001 合入 2.6 UI 迁移基线** + - **范围**: Git merge `origin/feat/2.6.0` → `feat/3.0.0-beta1-merge-2.6.0` + - **逻辑**: 按 Design D8 逐项解冲突;完整页面布局/文案/移动交互取 2.6,F044 relation/permission_id/binding 不作为运行时保留,F048 代码与契约取 beta1;排除 COFCO F045/F046。 + - **验证**: 无 unmerged path;`git diff --check`;记录冲突文件与裁决结果。 + - **依赖**: 无 + +- [x] **T002 Knowledge/Channel 创建幂等 ORM 字段** + - **文件**: `src/backend/bisheng/knowledge/domain/models/knowledge.py`, `src/backend/bisheng/channel/domain/models/channel.py` + - **逻辑**: 两表增加 nullable `creation_request_id VARCHAR(64)` 和 `creation_payload_hash VARCHAR(64)`;不回填存量,不把创建草稿存 JSON。 + - **影响**: 修改 Knowledge/Channel 已有对象,仅增加 F050 幂等事实,不改 F048 Grant 归属。 + - **依赖**: T001 + +- [x] **T003 双库 DDL 与唯一索引** + - **文件**: `src/backend/bisheng/core/database/alembic/versions/v3_0_0_beta1_f050_creation_idempotency.py` + - **逻辑**: 仅 DDL;Knowledge 唯一范围 tenant + creator + type + request ID,Channel 为 tenant + creator + request ID;MySQL/DM8 兼容的 nullable 列和索引长度。 + - **回滚**: downgrade 先删索引再删两列;不做 SELECT/UPDATE/回填/seed。 + - **依赖**: T002 + +--- + +## Wave 1:Permission Application Protocol(后端 Test-First) + +- [x] **T004 Prospective Grant 协议测试** + - **文件**: `src/backend/test/permission/test_prospective_grant_application.py` + - **逻辑**: 测试 owner 可授予 active models、Catalog release、tenant/active 主体 canonicalization、创建前不构造 target/不 Check/不写 Grant。 + - **覆盖 AC**: AC-01, AC-02, AC-07, AC-12, AC-13, AC-14, AC-18, AC-19, AC-27, AC-33 + - **依赖**: T003 + +- [x] **T005 Prospective Grant 协议实现** + - **文件**: `src/backend/bisheng/permission/application/prospective_grant.py`, `src/backend/bisheng/permission/application/ports.py` + - **逻辑**: 实现 `ProspectiveGrantApplicationPort`;只读 Catalog/subject directory,返回 release + grantable models,禁止业务 ORM 查询和权限写入。 + - **验证**: T004 全部通过。 + - **依赖**: T004 + +- [x] **T006 Initial Grant 协议测试** + - **文件**: `src/backend/test/permission/test_initial_grant_application.py` + - **逻辑**: verified target + ADD-only;重验 Catalog/model/subject/tenant/manage;原子普通 Grant mutation;稳定 idempotency key;拒绝 MOVE/REMOVE/protected/client source。 + - **覆盖 AC**: AC-13, AC-15, AC-16, AC-17, AC-18, AC-19, AC-23, AC-25, AC-33 + - **依赖**: T005 + +- [x] **T007 Initial Grant 协议实现** + - **文件**: `src/backend/bisheng/permission/application/initial_grant.py`, `src/backend/bisheng/permission/application/ports.py` + - **逻辑**: 实现 `InitialGrantApplicationPort`,只调 F048 durable mutation;返回真实 version/assignee/result,不创建或删除业务资源。 + - **验证**: T006 全部通过。 + - **依赖**: T006 + +--- + +## Wave 2:Knowledge/Channel 创建编排(后端 Test-First) + +- [x] **T008 Knowledge 创建幂等与初始授权测试** + - **文件**: `src/backend/test/knowledge/test_unified_permission_creation.py` + - **逻辑**: 旧 payload;新 payload;资源/owner 失败;Grant 部分失败;同 key 同 hash 前向重试;同 key 异 hash 冲突;唯一键竞争;自动标签字段不丢。 + - **覆盖 AC**: AC-01, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-30, AC-31, AC-33 + - **依赖**: T007 + +- [x] **T009 Knowledge 创建编排实现** + - **文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py`, `src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py` + - **逻辑**: 业务资源与 protected owner 先成功,再调 Initial Grant port;持久 request/hash;返回原资源形状 + 可选 result;保留自动标签及既有副作用。 + - **验证**: T008 全部通过。 + - **依赖**: T008 + +- [x] **T010 Channel 创建幂等与初始授权测试** + - **文件**: `src/backend/test/channel/test_unified_permission_creation.py` + - **逻辑**: 旧/新 payload、订阅外部信息源后重试不重复、owner/Grant 失败分界、同 key/hash 语义,保留 filter/subchannel/`knowledge_sync`。 + - **覆盖 AC**: AC-02, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-30, AC-32, AC-33 + - **依赖**: T007 + +- [x] **T011 Channel 创建编排实现** + - **文件**: `src/backend/bisheng/channel/domain/services/channel_service.py`, `src/backend/bisheng/channel/domain/schemas/channel_manager_schema.py` + - **逻辑**: 持久 request/hash;对已完成外部订阅的重试跳过重复副作用;资源 + owner 后调 Initial Grant port;保留知识同步/通知。 + - **验证**: T010 全部通过。 + - **依赖**: T010 + +- [x] **T012 创建 context/candidates API 测试** + - **文件**: `src/backend/test/knowledge/test_creation_permission_context_api.py`, `src/backend/test/channel/test_creation_permission_context_api.py` + - **逻辑**: 两域创建资格、同形 context、users/groups 分页、department children/search/path-tree、tenant 隔离、失权/fail-closed。 + - **覆盖 AC**: AC-01, AC-02, AC-07, AC-10, AC-11, AC-12, AC-13, AC-19, AC-27 + - **依赖**: T009, T011 + +- [x] **T013 创建 context/candidates API 实现** + - **文件**: `src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py`, `src/backend/bisheng/channel/api/endpoints/channel_manager.py` + - **逻辑**: 实现 Design §4.6.1 路由;Endpoint 只注入 actor 并委托业务 Service/Prospective port,不查组织 ORM,不接受 tenant_id。 + - **验证**: T012 全部通过。 + - **依赖**: T012 + +--- + +## Wave 3:编辑与 private 语义(后端 Test-First) + +- [x] **T014 Knowledge/Channel 编辑顺序与 private 测试** + - **文件**: `src/backend/test/knowledge/test_unified_permission_update.py`, `src/backend/test/channel/test_unified_permission_update.py` + - **逻辑**: 业务失败不写 Grant;private 只清 ordinary sources 且保留 protected/其他独立来源;编辑权与 manage 分离;失权、过期 version、多来源。 + - **覆盖 AC**: AC-03, AC-04, AC-07, AC-08, AC-09, AC-10, AC-11, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-31, AC-32, AC-33 + - **依赖**: T013 + +- [x] **T015 Knowledge/Channel 编辑顺序与 private 实现** + - **文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py`, `src/backend/bisheng/channel/domain/services/channel_service.py` + - **逻辑**: 保持业务可见性的 source ownership;private 复用 `remove_ordinary_sources`;投影成功后才清 membership/通知;不恢复旧 relation fallback。 + - **验证**: T014 全部通过。 + - **依赖**: T014 + +--- + +## Wave 4:Client API 与权限草稿 + +- [x] **T016 Client F048 adapter 契约测试** + - **文件**: `src/frontend/client/src/api/unifiedPermissionSettings.test.ts` + - **逻辑**: 创建 context/candidates 两域同形映射;创建可选字段/响应兼容;F048 context/grants/models/mutate 的 version 不丢失;AbortSignal 透传。 + - **覆盖 AC**: AC-01, AC-02, AC-07, AC-08, AC-09, AC-11, AC-12, AC-13, AC-16, AC-19, AC-20, AC-23, AC-30, AC-33 + - **依赖**: T013 + +- [x] **T017 Knowledge/Permission Client adapter 实现** + - **文件**: `src/frontend/client/src/api/knowledge.ts`, `src/frontend/client/src/api/permission.ts` + - **逻辑**: 使用 wrapped request;增加 Knowledge creation context/candidates 和创建可选契约;编辑复用 F048 adapter;不写 403 业务分支。 + - **验证**: T016 对应 Knowledge/F048 断言通过。 + - **依赖**: T016 + +- [x] **T018 Channel Client adapter 实现** + - **文件**: `src/frontend/client/src/api/channels.ts` + - **逻辑**: 增加 Channel creation context/candidates 和创建可选契约;保留 source/filter/subchannel/`knowledge_sync`;映射部分失败 result。 + - **验证**: T016 对应 Channel 断言通过。 + - **依赖**: T016 + +- [x] **T019 F048 PermissionDraft hook 测试** + - **文件**: `src/frontend/client/src/components/permission/usePermissionDraft.test.ts` + - **逻辑**: ADD/MOVE/REMOVE touched diff;protected/inherited/read-only 不入 draft;同主体多来源不合并;取消无写入;baseline resource/catalog/assignee version。 + - **覆盖 AC**: AC-08, AC-09, AC-13, AC-18, AC-20, AC-22, AC-23, AC-24, AC-25, AC-27, AC-33 + - **依赖**: T017, T018 + +- [x] **T020 F048 PermissionDraft hook 实现** + - **文件**: `src/frontend/client/src/components/permission/usePermissionDraft.ts` + - **逻辑**: 保留 2.6 hook 对页面的交互 API,内部改为 F048 modelKey/assignee/source/version;只存组件内存,不新增 Recoil/localStorage。 + - **验证**: T019 全部通过。 + - **依赖**: T019 + +- [x] **T021 权限草稿面板/选择器测试** + - **文件**: `src/frontend/client/src/components/permission/PermissionDraftPanel.test.tsx`, `src/frontend/client/src/components/permission/PermissionDraftPickerDialog.test.tsx` + - **逻辑**: 保留 2.6 布局/文案/选择流;模型、主体、多来源、protected 显示;部门懒加载/搜索;无 manage 不请求敏感数据。 + - **覆盖 AC**: AC-06, AC-08, AC-09, AC-12, AC-13, AC-18, AC-24, AC-25 + - **依赖**: T020 + +- [x] **T022 权限草稿面板/选择器适配** + - **文件**: `src/frontend/client/src/components/permission/PermissionDraftPanel.tsx`, `src/frontend/client/src/components/permission/PermissionDraftPickerDialog.tsx` + - **逻辑**: 保留 2.6 JSX/样式/移动交互;将 relation/modelId 输入改为 F048 model/subject;候选使用可注入 create/edit adapter。 + - **验证**: T021 全部通过。 + - **依赖**: T021 + +- [x] **T023 统一设置交互原语适配** + - **文件**: `src/frontend/client/src/components/permission/UnifiedPermissionControls.tsx`, `src/frontend/client/src/components/permission/PermissionLevelMenu.tsx` + - **逻辑**: 保留 2.6 访问范围行、章节标题、固定 footer 和模型菜单交互;不显示 ModeHeader;遵守语义 token/无 blur。 + - **覆盖 AC**: AC-05, AC-06, AC-26, AC-27 + - **依赖**: T022 + +--- + +## Wave 5:Knowledge 完整页 + +- [x] **T024 Knowledge 页面/表单测试** + - **文件**: `src/frontend/client/src/pages/knowledge/SpaceSettings/KnowledgeSpaceSettingsPage.test.tsx`, `src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.test.ts` + - **逻辑**: create/edit;自动标签库/自定义;edit 与 manage 区域隔离;同 payload 立即重试;进设置页后 F048 mutation;private;失权/冲突;390px。 + - **覆盖 AC**: AC-01, AC-03, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-13, AC-14, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-31, AC-33, AC-34 + - **依赖**: T023 + +- [x] **T025 Knowledge 完整页与表单适配** + - **文件**: `src/frontend/client/src/pages/knowledge/SpaceSettings/KnowledgeSpaceSettingsPage.tsx`, `src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.ts` + - **逻辑**: 保留 2.6 完整页 UI;保留自动标签全部业务字段;创建走 prospective + initial grants,编辑业务保存后 reload F048 再 mutate;部分成功真实反馈。 + - **验证**: T024 全部通过。 + - **依赖**: T024 + +- [x] **T026 Knowledge 路由与旧独立入口收敛测试** + - **文件**: `src/frontend/client/src/pages/unifiedPermissionEntryRoutes.test.tsx` + - **逻辑**: 空间/频道 create/settings 路由;列表/详情跳转;无 edit 不显示;直达失权;两类资源的旧独立权限弹窗入口消失。 + - **覆盖 AC**: AC-03, AC-04, AC-05, AC-10, AC-11 + - **依赖**: T025 + +- [x] **T027 Knowledge 路由与菜单收敛** + - **文件**: `src/frontend/client/src/routes/index.tsx`, `src/frontend/client/src/pages/knowledge/index.tsx` + - **逻辑**: 注册完整页路由,将创建/设置操作指向新页,移除知识空间独立权限入口;其他菜单能力不变。 + - **验证**: T026 对应断言通过。 + - **依赖**: T026 + +--- + +## Wave 6:Channel 完整页 + +- [x] **T028 Channel 表单 hook 测试** + - **文件**: `src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.test.ts` + - **逻辑**: source/filter/subchannel/knowledge sync 不丢;创建/编辑 F048 草稿;业务先保存 + reload + mutate;private/失权/部分成功/重试。 + - **覆盖 AC**: AC-02, AC-04, AC-07, AC-08, AC-09, AC-11, AC-13, AC-14, AC-16, AC-17, AC-19, AC-20, AC-21, AC-22, AC-23, AC-25, AC-26, AC-28, AC-29, AC-32, AC-33 + - **依赖**: T023 + +- [x] **T029 Channel 表单 hook 适配** + - **文件**: `src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.ts` + - **逻辑**: 保留 2.6 表单对页面的 API;删除 relation/permission_ids/authorizeChannel 逻辑;改接 prospective/F048 draft;保留 `knowledge_sync` 和业务可见性。 + - **验证**: T028 全部通过。 + - **依赖**: T028 + +- [x] **T030 Channel 页面与抓取队列测试** + - **文件**: `src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelSettingsPage.test.tsx`, `src/frontend/client/src/pages/Subscription/hooks/useCrawlQueue.test.ts` + - **逻辑**: 保留 2.6 双栏/390px 布局;抓取排队/取消/失败/预览/进行中禁提交;知识同步;manage 区隔离;protected/多来源。 + - **覆盖 AC**: AC-02, AC-04, AC-05, AC-06, AC-08, AC-09, AC-10, AC-18, AC-24, AC-25, AC-27, AC-32, AC-34 + - **依赖**: T029 + +- [x] **T031 Channel 完整页与业务区适配** + - **文件**: `src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelSettingsPage.tsx`, `src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelBusinessSettings.tsx` + - **逻辑**: 保留 2.6 JSX/布局/操作区;保留抓取队列和知识同步;权限区改接 F048 draft,无 manage 完全不渲染。 + - **验证**: T030 全部通过。 + - **依赖**: T030 + +- [x] **T032 Channel 路由与旧独立入口收敛** + - **文件**: `src/frontend/client/src/pages/Subscription/index.tsx`, `src/frontend/client/src/pages/Subscription/ArticleList/ChannelActionsMenu.tsx` + - **逻辑**: create/settings 指向完整页;移除 ChannelPermissionDialog/ShareDialog 的独立成员权限入口;保留置顶/退出/删除/解散能力。 + - **覆盖 AC**: AC-04, AC-05, AC-10 + - **依赖**: T031 + +--- + +## Wave 7:i18n、E2E 与总门禁 + +- [x] **T033 Client i18n 中英文** + - **文件**: `src/frontend/client/src/locales/zh-Hans/translation.json`, `src/frontend/client/src/locales/en/translation.json` + - **逻辑**: 补齐统一设置、F048 模型/来源/protected、部分成功/重试文案;保留 2.6 产品文案,不新增硬编码中文。 + - **覆盖 AC**: AC-05, AC-06, AC-09, AC-16, AC-17, AC-23, AC-24 + - **依赖**: T027, T032 + +- [x] **T034 Client i18n 日文与 parity** + - **文件**: `src/frontend/client/src/locales/ja/translation.json` + - **逻辑**: 对齐 T033 全部 key;运行 `pnpm check-i18n`,不手改 api_errors 生成物。 + - **覆盖 AC**: AC-05, AC-06, AC-09, AC-16, AC-17, AC-23, AC-24 + - **依赖**: T033 + +- [ ] **T035 Knowledge 统一设置 API E2E** + - **文件**: `src/backend/test/e2e/test_e2e_f050_knowledge_permission_settings.py` + - **逻辑**: 真实 API 覆盖空间创建、user/department/group、owner、部分失败/同键重试、edit/manage 分离、失权、并发、private、多来源、跨租户、旧 payload 和自动标签。 + - **覆盖 AC**: AC-01, AC-03, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-30, AC-31, AC-33, AC-34 + - **依赖**: T015, T034 + +- [ ] **T036 Channel 统一设置 API E2E** + - **文件**: `src/backend/test/e2e/test_e2e_f050_channel_permission_settings.py` + - **逻辑**: 真实 API 覆盖频道创建、user/department/group、owner、部分失败/同键重试、edit/manage 分离、失权、并发、private、多来源、跨租户、旧 payload、知识同步和外部订阅幂等。 + - **覆盖 AC**: AC-02, AC-04, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-30, AC-32, AC-33, AC-34 + - **依赖**: T015, T034 + +- [ ] **T037 页面验收与质量门禁** + - **文件**: `features/v3.0.0-beta1/050-unified-permission-settings/e2e-report.md` + - **逻辑**: 按 `/e2e-test` 执行 API E2E 与页面清单;对照 2.6 UI 验证桌面/390px;验证自动标签、知识同步、抓取队列;运行 backend focused pytest/ruff/arch-guard 与 frontend lint/typecheck/check-i18n。 + - **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-30, AC-31, AC-32, AC-33, AC-34 + - **依赖**: T035, T036 + +--- + +## 实际偏差记录 + +> 只记指向 Design 决策/已知坑的一行摘要。如需推翻用户已确认的 Spec/Design,先停止实现并重新确认。 + +- Client 页面组件测试在当前本机缺失 `canvas.node` 的 JSDOM 环境下改为 Node 源码契约测试;API adapter 与 PermissionDraft reducer 仍执行真实行为单测,页面交互留待 T037 浏览器验收。 diff --git a/features/v3.0.0-beta1/README.md b/features/v3.0.0-beta1/README.md index 8db514e5bb..36173a2d40 100644 --- a/features/v3.0.0-beta1/README.md +++ b/features/v3.0.0-beta1/README.md @@ -17,6 +17,8 @@ | F046 | [channel-source-link-failure-ux](./046-channel-source-link-failure-ux/) | P1 | Spec 已存在 | 无 | | F047 | [linsight-citation-traceability](./047-linsight-citation-traceability/) | P2 | Spec、Design 已存在 | F035, F029 | | F048 | [rebac-permission-model-grants](./048-rebac-permission-model-grants/) | P0 | ✅ 功能与迁移脚本开发完成;本地 E2E 经用户确认不执行 | F004, F006, F007, F008, F018, F027, F036, F040 | +| F049 | [knowledge-space-children-read-optimization](./049-knowledge-space-children-read-optimization/) | P1 | Spec 已存在 | F027, F040, F048 | +| F050 | [unified-permission-settings](./050-unified-permission-settings/) | P0 | Spec、Design 已确认,Tasks 已拆解,实现中 | v2.6.0 F044, F048 | --- @@ -58,3 +60,5 @@ | 2026-07-29 | 用户明确确认 Design ★,进入 tasks.md 编写与评审阶段;编码尚未开始。 | | 2026-07-29 | F048 tasks.md 完成 140 项原子拆解并通过 `/sdd-review tasks` 21 项评审;等待用户明确确认 Tasks。 | | 2026-07-30 | 用户明确确认 Tasks ★;完成 T001~T139 的实现、逐波回归和代码审查;随后明确本地不执行真实环境 E2E,T140 以范围决策和未执行证据报告收口,功能与迁移脚本开发完成。 | +| 2026-08-14 | 登记 F049 知识空间目录与搜索读取优化,以及 F050 统一权限设置入口的 F048 适配。 | +| 2026-08-17 | F050 Design 经用户确认;以 feat/2.6.0 实际 UI 为基线、F048 为唯一权限运行时,进入 Tasks 拆解。 | diff --git a/features/v3.0.0-beta1/release-contract.md b/features/v3.0.0-beta1/release-contract.md index bdbd4f06bc..47a08cf07c 100644 --- a/features/v3.0.0-beta1/release-contract.md +++ b/features/v3.0.0-beta1/release-contract.md @@ -6,7 +6,8 @@ > > F043~F046 来自 PRD《3.0.0-beta1 需求文档》§四 功能体验优化; > F047 为灵思任务模式引用溯源;F048 来自 PRD《3.0-beta1 ReBAC 逻辑优化》, -> 是本版本的 P0 权限架构升级 Feature。 +> 是本版本的 P0 权限架构升级 Feature;F049 在遵守 C4 系统身份策略与 OpenFGA 最终可见性语义的前提下, +> 优化知识空间目录与搜索读取的候选批次、冗余查询和可观测性。 --- @@ -35,6 +36,8 @@ | PermissionProjectionOperation / PermissionProjectionTuple | F048-rebac-permission-model-grants | tenant 级 Grant/mode/resource 到 OpenFGA 的幂等发布意图、分阶段 tuple、commit、补偿和失败关闭状态 | | AuthorizationModelRelease / PermissionMigrationRun / PermissionMigrationItem | F048-rebac-permission-model-grants | 现有 OpenFGA Store 中的新 Authorization Model 版本、唯一生产固定版本,以及由 `src/backend/scripts/` 专用数据迁移脚本写入的逐项映射、checkpoint、旧 tuple 退役、校验、启服和人工处置结果;不是 Alembic revision 状态 | | PermissionVisibleSourceProjection | F048-rebac-permission-model-grants | 原 F048 正式迁移和后续运行时从 canonical Grant assignee 生成的展平可见派生索引;随同一 PermissionMigrationRun/Item 追溯;system/public/shared 继续由各 Owner 事实与 system tuple 追溯;均不可独立编辑或参与数据库 ALLOW | +| —(无新增) | F049-knowledge-space-children-read-optimization | 只调整既有目录与搜索列表的读取、最终可见性批量判断、文件夹统计响应和性能观测;不新增领域对象、表、错误码、Grant、权限模式或 OpenFGA relation | +| —(无新增) | F050-unified-permission-settings | 统一知识空间/频道新建与设置页面;复用既有 Knowledge、Channel、F048 Grant/Assignee 与 protected owner,不建立第二套权限领域对象 | **规则**: - 非 Owner Feature 的 AC 中不得出现其他对象的"创建/修改/删除"行为,只能"读取"或"调用" Owner 的 Service @@ -69,6 +72,7 @@ | INV-25 | user-owned 资源创建仍必须通过 `PermissionService.authorize()` 为创建者建立受保护 owner Grant 并遵守失败补偿;一个资源可以同时有多个 owner,其他 owner 作为独立普通来源存在。F048 启服后不再要求继续写旧资源 `owner` tuple。只有经资源 adapter 代码 allowlist 与 canonical business predicate 双重确认的 platform system-owned 资源可以不伪造用户 owner,并继续只由 C4 system identity 管理。OQ-07 已选择 A:F048 启服时退役既有 F018 owner 交接 API,本期不实现 protected owner transfer;创建者 protected owner 不可通过普通成员接口删除或转让 | ProtectedPermissionAssignment, PermissionGrant | F048 | | INV-26 | F048 的 Alembic revision 只允许 MySQL/DM8 schema DDL,不得读取、转换、回填、去重、清理或 seed 旧权限数据,也不得访问 OpenFGA。所有旧 Config、业务事实和 tuple 数据迁移必须由运维人员在已启动但 F048 未就绪的 backend 容器内,通过 `src/backend/scripts/` 下的专用脚本于 schema upgrade 成功后显式执行;不得由 API、Celery 或应用启动钩子自动触发 | PermissionMigrationRun, PermissionMigrationItem | F048 | | INV-27 | 权限模型、Grant、Grant 主体和权限模式以规范化 MySQL/DM8 关系表为控制面真相;组织成员、系统身份和资源状态以各自 Owner 业务域的 canonical 事实为真相;OpenFGA 是这些事实发布后的唯一权限执行面。每条有效资源可见结果必须可追溯到至少一个当前有效来源;模型停用只禁止新增或变更授权,已有授权保持有效;模型删除前必须撤销或替换全部绑定,并在引用、来源投影和残留 tuple 清零后才允许删除。来源撤销只清除该来源贡献,并保证 Check 与可见资源枚举一致,不得删除其他仍有效来源的可见性 | PermissionModel, PermissionGrant, PermissionGrantAssignee, AuthorizationModelRelease | F048 | +| INV-28 | 知识空间和频道统一创建页只能在业务资源与 protected owner 成功后应用初始普通 Grant;普通 Grant 失败必须保留并返回真实资源、允许基于同一持久请求键前向重试且不得重复创建资源。创建前不得伪造 VerifiedPermissionTarget,旧 relation/permission_id 路径不得作为 fallback | Knowledge, Channel, ProtectedPermissionAssignment, PermissionGrant | F050 | (INV-1~7 为 v2.6.0 存量不变量,继续有效,见 `features/v2.6.0/release-contract.md`。) @@ -90,6 +94,8 @@ | F047-linsight-citation-traceability | F035, F029(均为 v2.6.0 存量,已上线) | 接线型;把灵思任务模式产物接入既有 citation 溯源子系统(Phase 1 应用内预览行内角标,覆盖 KB 文档 + Web 网页;Phase 2 下载 Word/PDF 烘焙可见 `[1]` 编号 + 参考资料,延后);不新增领域对象/表/对外 API/错误码/不变量/`MessageEventType` 枚举;无 alembic 迁移(复用 `message_citation`);不写 `LinsightExecuteTask.history`(避 DM8 写放大);角标解析复用 F029 `view_file` 过滤守 **INV-7**(见 v2.6.0 契约) | | F048-rebac-permission-model-grants | F004, F006, F007, F008 | 依赖既有 OpenFGA 核心、历史 ReBAC 迁移、资源权限界面和资源接入基线,并替换其中的四档静态关系与 Config 细粒度执行语义 | | F048-rebac-permission-model-grants | F027, F036, F040 | 依赖既有候选枚举、继承评估和列表性能基线;实现时必须保证分页与性能契约不倒退,并移除对旧 binding 第二次求值的依赖 | +| F049-knowledge-space-children-read-optimization | F027, F040, F048 | 沿用目录 cursor、搜索页码候选扫描与 F048 OpenFGA 唯一执行面;不得用 `INHERIT` 数据库模式绕过最终候选可见性判断 | +| F050-unified-permission-settings | v2.6.0 F044, F048 | 只继承 F044 的完整页面与统一入口目标;权限上下文、候选、protected owner、Grant mutation、版本和投影全部以 F048 为准 | --- @@ -103,6 +109,7 @@ | F008-resource-rebac-adaptation | 资源生命周期继续统一接入权限服务,但资源存在性/租户/状态/父级等业务校验留在各业务 Service;权限领域只接收已验证上下文并执行 F048 授权 | | F027/F036/F040 | cursor、批量候选和请求内性能约束继续有效;旧 Config binding / 第二 PDP 的优化路径在切换后退役 | | F013/F017 | `system`、`tenant`、`department`、`user_group`、`shared_with` 等系统关系继续保留;不得被误转为普通资源 Grant | +| F027/F040/F048 | F049 优化知识空间 `children` / `search` 的候选批次、重复门禁、文件夹统计和耗时观测;平台超级管理员遵守 C4 系统身份策略,普通用户继续执行有界 OpenFGA BatchCheck、稳定目录游标、既有搜索页码契约和 fail-closed,不改变个人可见空间枚举语义 | | F018-resource-owner-transfer | 当前实现先提交资源 `user_id`、再删除旧/写入新 owner tuple,失败依赖 `failed_tuple` 补写;同时不更新 knowledge_space/channel CREATOR membership,且无已接入前端。OQ-07 已选择 A:F048 启服时退役其 API/Service 调用路径,本期不重构 owner transfer;历史差异按 preservation-first 迁移 | --- @@ -135,3 +142,5 @@ | 2026-08-13 | 为 StreamedListObjects 未完整终止或超过业务容量上限分配 25014,禁止把枚举前缀作为成功全集返回 | F048 | | 2026-08-13 | 纠正 F048 可见投影迁移拓扑:F048 尚未上线,不新增旧 F048 到新 F048 model 的二次迁移;原 PermissionMigrationRun/Item 从旧 Config/四档关系和 Owner 事实直接生成最终单槽浅层 visible model、Grant/Assignee、PermissionVisibleSourceProjection 与 tuple | F048 | | 2026-08-13 | 明确模型停用/删除语义:停用只禁止新增或变更授权,已有 Grant 保持有效;删除必须先清零或替换全部绑定并完成残留投影对账。因停用不再触发批量撤权,F048 可见执行投影采用单槽浅层 `visible`,不引入 A/B 槽与运行时 switch | F048 | +| 2026-08-14 | 登记 F049 知识空间目录与搜索读取优化:指定资源的超级管理员按 C4 系统身份策略放行、去重空间鉴权、页大小驱动的有界候选扫描、移除未展示的文件夹数量统计并保留失败存在性、增加分段性能观测;普通用户候选最终可见性继续统一使用 OpenFGA BatchCheck,不新增继承捷径 | F049、F027、F040、F048 | +| 2026-08-14 | 登记 F050 统一权限设置入口:保留 v2.6.0 F044 页面目标,创建/编辑权限完全改接 F048,并增加创建后初始 Grant 部分失败与持久幂等约束 INV-28 | F050、F048 | diff --git a/src/backend/AGENTS.md b/src/backend/AGENTS.md index 798ac2bfdc..65fd8a1a6d 100644 --- a/src/backend/AGENTS.md +++ b/src/backend/AGENTS.md @@ -149,6 +149,7 @@ uv run alembic revision --autogenerate -m "msg" # autogen reflects MySQL only; - **The ruff PostToolUse hook deletes not-yet-used imports.** Every written `.py` gets `ruff check --fix`; an import added in one edit whose usage lands only in a later edit is removed as unused (F401) in between. Land import + usage in the same edit, or write the usage first. - **Celery Beat × multi-tenant.** A Beat schedule fires once; the task body iterates all active tenants — call `set_current_tenant_id()` per iteration, and wrap the cross-tenant enumeration query itself in `bypass_tenant_filter()` (`core/context/tenant.py`), otherwise queries fail on missing tenant context. - **DB config changes take up to 100s.** DB-layer config is cached in Redis with a 100s TTL — don't chase "config not applied" inside that window. +- **A single-host compose hides multi-node bugs (constitution C8).** `backend` and `backend_worker` bind-mount the *same* `/app/data`, and the Linsight worker runs *inside* the worker container — so cross-process file sharing appears to work locally and breaks the moment the two land on different hosts. Before persisting anything, ask: which process writes, which reads, and would they still agree on separate machines? Same trap for startup work: `main.py`'s lifespan runs in the API process only; a Celery or Linsight worker that needs it must register it too (precedent: `02cbb921a`). --- diff --git a/src/backend/bisheng/__init__.py b/src/backend/bisheng/__init__.py index 45a8c3fa21..976b7d1734 100644 --- a/src/backend/bisheng/__init__.py +++ b/src/backend/bisheng/__init__.py @@ -4,7 +4,7 @@ try: # SetujuciGo to automatic modification - __version__ = '2.6.0-fix' + __version__ = '2.6.0-fix2' except metadata.PackageNotFoundError: # Case where package metadata is not available. __version__ = '' diff --git a/src/backend/bisheng/channel/api/dependencies.py b/src/backend/bisheng/channel/api/dependencies.py index 13aa9dd7c6..85721d4d0d 100644 --- a/src/backend/bisheng/channel/api/dependencies.py +++ b/src/backend/bisheng/channel/api/dependencies.py @@ -63,6 +63,13 @@ async def get_channel_service( article_es_service = get_article_es_service() article_read_repository = await get_article_read_repository(session) message_service = await _get_message_service(session) + from bisheng.permission.application.access import get_f048_runtime + from bisheng.permission.application.initial_grant import InitialGrantApplication + from bisheng.permission.application.prospective_grant import ProspectiveGrantApplication + from bisheng.tenant.domain.services.f048_permission_subject import TenantPermissionSubjectDirectory + + runtime = await get_f048_runtime() + subject_directory = TenantPermissionSubjectDirectory() return ChannelService( channel_repository=channel_repository, @@ -71,4 +78,12 @@ async def get_channel_service( article_es_service=article_es_service, article_read_repository=article_read_repository, message_service=message_service, + initial_grant_application=InitialGrantApplication( + runtime=runtime, + subjects=subject_directory, + ), + prospective_grant_application=ProspectiveGrantApplication( + runtime=runtime, + subjects=subject_directory, + ), ) diff --git a/src/backend/bisheng/channel/api/endpoints/channel_manager.py b/src/backend/bisheng/channel/api/endpoints/channel_manager.py index 94e92319aa..4883492ee4 100644 --- a/src/backend/bisheng/channel/api/endpoints/channel_manager.py +++ b/src/backend/bisheng/channel/api/endpoints/channel_manager.py @@ -41,6 +41,91 @@ async def create_channel( return resp_200(data=channel) +@router.get("/creation-permission-context") +async def get_creation_permission_context( + login_user: UserPayload = Depends(UserPayload.get_login_user), + channel_service: "ChannelService" = Depends(get_channel_service), +): + return resp_200(data=await channel_service.get_creation_permission_context(login_user)) + + +@router.get("/creation-grant-subjects/users") +async def list_creation_grant_users( + keyword: str = "", + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + login_user: UserPayload = Depends(UserPayload.get_login_user), + channel_service: "ChannelService" = Depends(get_channel_service), +): + return resp_200( + data=await channel_service.list_creation_grant_users( + login_user, + keyword=keyword, + page=page, + page_size=page_size, + ) + ) + + +@router.get("/creation-grant-subjects/user-groups") +async def list_creation_grant_user_groups( + keyword: str = "", + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + login_user: UserPayload = Depends(UserPayload.get_login_user), + channel_service: "ChannelService" = Depends(get_channel_service), +): + return resp_200( + data=await channel_service.list_creation_grant_user_groups( + login_user, + keyword=keyword, + page=page, + page_size=page_size, + ) + ) + + +@router.get("/creation-grant-subjects/departments/children") +async def list_creation_grant_department_children( + parent_id: int | None = None, + login_user: UserPayload = Depends(UserPayload.get_login_user), + channel_service: "ChannelService" = Depends(get_channel_service), +): + return resp_200( + data=await channel_service.list_creation_grant_department_children( + login_user, + parent_id=parent_id, + ) + ) + + +@router.get("/creation-grant-subjects/departments/search") +async def search_creation_grant_departments( + keyword: str = "", + limit: int = Query(50, ge=1, le=200), + login_user: UserPayload = Depends(UserPayload.get_login_user), + channel_service: "ChannelService" = Depends(get_channel_service), +): + return resp_200( + data=await channel_service.search_creation_grant_departments( + login_user, + keyword=keyword, + limit=limit, + ) + ) + + +@router.get("/creation-grant-subjects/departments/{department_id}/path-tree") +async def get_creation_grant_department_path( + department_id: int, + login_user: UserPayload = Depends(UserPayload.get_login_user), + channel_service: "ChannelService" = Depends(get_channel_service), +): + return resp_200( + data=await channel_service.get_creation_grant_department_path(login_user, department_id) + ) + + @router.get("/list_sources") async def list_channel_information_sources( business_type: BusinessType = Query(..., description="Information source type: website / wechat"), diff --git a/src/backend/bisheng/channel/domain/models/channel.py b/src/backend/bisheng/channel/domain/models/channel.py index 416a273598..fafb0ad68b 100644 --- a/src/backend/bisheng/channel/domain/models/channel.py +++ b/src/backend/bisheng/channel/domain/models/channel.py @@ -1,17 +1,21 @@ import uuid from datetime import datetime from enum import Enum -from typing import List, Dict, Optional, Literal, Union, Annotated +from typing import Annotated, Literal, Union -from pydantic import BaseModel, Field as PydanticField, model_validator -from sqlalchemy import CHAR, Column, Integer, VARCHAR, Enum as SQLEnum, DateTime, Boolean, text, Text +from pydantic import BaseModel, model_validator +from pydantic import Field as PydanticField +from sqlalchemy import CHAR, VARCHAR, Boolean, Column, DateTime, Index, Integer, Text, text +from sqlalchemy import Enum as SQLEnum from sqlmodel import Field from bisheng.common.models.base import SQLModelSerializable -from bisheng.core.database.dialect_helpers import JsonType, UPDATE_TIME_SERVER_DEFAULT +from bisheng.core.database.dialect_helpers import UPDATE_TIME_SERVER_DEFAULT, JsonType + class ChannelVisibilityEnum(str, Enum): """Channel Visibility Enumeration""" + # Public PUBLIC = "public" # Private @@ -19,78 +23,115 @@ class ChannelVisibilityEnum(str, Enum): # Review required REVIEW = "review" + # 单一Rule class SingleRule(BaseModel): """Single Rule Model""" - type: Literal['single'] = 'single' - rule_type: Literal['include', 'exclude'] = PydanticField(..., description='Rule Type: include or exclude') - keywords: List[str] = PydanticField(..., description='List of keywords for the rule') + type: Literal["single"] = "single" + rule_type: Literal["include", "exclude"] = PydanticField(..., description="Rule Type: include or exclude") + keywords: list[str] = PydanticField(..., description="List of keywords for the rule") + # 多Rule组合 class MultiRule(BaseModel): """Multi Rule Model""" - type: Literal['multi'] = 'multi' - relation: Literal['and', 'or'] = PydanticField(..., description='Relationship between rules: and or or') - rules: List[SingleRule] = PydanticField(..., description='List of filter rules') + type: Literal["multi"] = "multi" + relation: Literal["and", "or"] = PydanticField(..., description="Relationship between rules: and or or") + rules: list[SingleRule] = PydanticField(..., description="List of filter rules") + class ChannelFilterRules(BaseModel): """Channel Filter Rules Model""" - relation: Literal['and', 'or'] = PydanticField(..., description='Relationship between rules: and or or') - rules: List[Annotated[Union[SingleRule, MultiRule], PydanticField(discriminator='type')]] = PydanticField(..., description='List of filter rules') - channel_type: Literal['main', 'sub'] = PydanticField(..., description='Channel type: main or sub') - name: Optional[str] = PydanticField(None, description='Filter name, required for sub channel') + relation: Literal["and", "or"] = PydanticField(..., description="Relationship between rules: and or or") + rules: list[Annotated[Union[SingleRule, MultiRule], PydanticField(discriminator="type")]] = PydanticField( + ..., description="List of filter rules" + ) + channel_type: Literal["main", "sub"] = PydanticField(..., description="Channel type: main or sub") + name: str | None = PydanticField(None, description="Filter name, required for sub channel") - @model_validator(mode='after') - def validate_sub_channel_name(self) -> 'ChannelFilterRules': - if self.channel_type == 'sub' and not self.name: - raise ValueError('Sub channel filter rules require a name') + @model_validator(mode="after") + def validate_sub_channel_name(self) -> "ChannelFilterRules": + if self.channel_type == "sub" and not self.name: + raise ValueError("Sub channel filter rules require a name") return self + class Channel(SQLModelSerializable, table=True): """ Channel Model """ - __tablename__ = 'channel' - - id: str = Field(default_factory=lambda: uuid.uuid4().hex, description='Channel ID', - sa_column=Column(CHAR(36), unique=True, nullable=False, primary_key=True)) - name: str = Field(..., description='Channel Name', sa_column=Column(VARCHAR(255), nullable=False)) - description: Optional[str] = Field(None, description='Channel Description/Brief', - sa_column=Column(Text, nullable=True)) - source_list: List[str] = Field(default_factory=list, description='Data Source List', - sa_column=Column(JsonType, nullable=False)) - visibility: ChannelVisibilityEnum = Field(..., sa_column=Column(SQLEnum(ChannelVisibilityEnum)), - description='Channel Visibility') - filter_rules: List[Dict] = Field(default_factory=list, description='Filter Conditions', - sa_column=Column(JsonType, nullable=False)) - user_id: int = Field(..., description='UsersID', foreign_key="user.user_id", nullable=False) - latest_article_update_time: datetime = Field(None, description='Latest Article Update Time', - sa_column=Column(DateTime, nullable=True)) - - is_pinned: bool = Field(default=False, description='Whether the channel is pinned', - sa_column=Column(Boolean, nullable=False)) - - is_released: bool = Field(default=False, description='Whether the channel is released', - sa_column=Column(Boolean, nullable=False)) + __tablename__ = "channel" + __table_args__ = ( + Index( + "uq_channel_creation_request", + "tenant_id", + "user_id", + "creation_request_id", + unique=True, + ), + ) + + id: str = Field( + default_factory=lambda: uuid.uuid4().hex, + description="Channel ID", + sa_column=Column(CHAR(36), unique=True, nullable=False, primary_key=True), + ) + name: str = Field(..., description="Channel Name", sa_column=Column(VARCHAR(255), nullable=False)) + description: str | None = Field( + None, description="Channel Description/Brief", sa_column=Column(Text, nullable=True) + ) + source_list: list[str] = Field( + default_factory=list, description="Data Source List", sa_column=Column(JsonType, nullable=False) + ) + visibility: ChannelVisibilityEnum = Field( + ..., sa_column=Column(SQLEnum(ChannelVisibilityEnum)), description="Channel Visibility" + ) + filter_rules: list[dict] = Field( + default_factory=list, description="Filter Conditions", sa_column=Column(JsonType, nullable=False) + ) + user_id: int = Field(..., description="UsersID", foreign_key="user.user_id", nullable=False) + latest_article_update_time: datetime = Field( + None, description="Latest Article Update Time", sa_column=Column(DateTime, nullable=True) + ) + + is_pinned: bool = Field( + default=False, description="Whether the channel is pinned", sa_column=Column(Boolean, nullable=False) + ) + + is_released: bool = Field( + default=False, description="Whether the channel is released", sa_column=Column(Boolean, nullable=False) + ) is_shared: bool = Field( default=False, - description='F017: Root channel shared to all children (mirrors FGA shared_with tuples)', - sa_column=Column(Boolean, nullable=False, server_default=text('0')), + description="F017: Root channel shared to all children (mirrors FGA shared_with tuples)", + sa_column=Column(Boolean, nullable=False, server_default=text("0")), ) - tenant_id: Optional[int] = Field( + tenant_id: int | None = Field( default=None, - sa_column=Column(Integer, nullable=False, server_default=text('1'), - index=True, comment='Tenant ID'), + sa_column=Column(Integer, nullable=False, server_default=text("1"), index=True, comment="Tenant ID"), ) - create_time: datetime = Field(default_factory=datetime.now, description='Creation Time', - sa_column=Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP'))) + creation_request_id: str | None = Field( + default=None, + sa_column=Column(VARCHAR(64), nullable=True), + ) + creation_payload_hash: str | None = Field( + default=None, + sa_column=Column(VARCHAR(64), nullable=True), + ) - update_time: Optional[datetime] = Field(default=None, sa_column=Column( - DateTime, nullable=True, server_default=UPDATE_TIME_SERVER_DEFAULT)) + create_time: datetime = Field( + default_factory=datetime.now, + description="Creation Time", + sa_column=Column(DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP")), + ) + + update_time: datetime | None = Field( + default=None, sa_column=Column(DateTime, nullable=True, server_default=UPDATE_TIME_SERVER_DEFAULT) + ) diff --git a/src/backend/bisheng/channel/domain/repositories/implementations/channel_repository_impl.py b/src/backend/bisheng/channel/domain/repositories/implementations/channel_repository_impl.py index 2793b19b30..9fd61e059c 100644 --- a/src/backend/bisheng/channel/domain/repositories/implementations/channel_repository_impl.py +++ b/src/backend/bisheng/channel/domain/repositories/implementations/channel_repository_impl.py @@ -2,6 +2,7 @@ from typing import Any from sqlalchemy import case, func, or_ +from sqlalchemy.exc import IntegrityError from sqlmodel import col, select, update from sqlmodel.ext.asyncio.session import AsyncSession @@ -22,6 +23,36 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository) def __init__(self, session: AsyncSession): super().__init__(session, Channel) + async def find_by_creation_request( + self, + *, + tenant_id: int, + user_id: int, + creation_request_id: str, + ) -> Channel | None: + query = select(Channel).where( + Channel.tenant_id == tenant_id, + Channel.user_id == user_id, + Channel.creation_request_id == creation_request_id, + ) + return (await self.session.exec(query)).first() + + async def save_creation(self, channel: Channel) -> tuple[Channel, bool]: + try: + return await self.save(channel), True + except IntegrityError: + await self.session.rollback() + if channel.creation_request_id is None or channel.tenant_id is None: + raise + existing = await self.find_by_creation_request( + tenant_id=int(channel.tenant_id), + user_id=channel.user_id, + creation_request_id=channel.creation_request_id, + ) + if existing is None: + raise + return existing, False + async def find_channels_by_ids(self, channel_ids: list[str]) -> list[Channel]: """Find channels by a list of channel IDs.""" if not channel_ids: diff --git a/src/backend/bisheng/channel/domain/repositories/interfaces/channel_repository.py b/src/backend/bisheng/channel/domain/repositories/interfaces/channel_repository.py index 534f39d4b0..1e22a14457 100644 --- a/src/backend/bisheng/channel/domain/repositories/interfaces/channel_repository.py +++ b/src/backend/bisheng/channel/domain/repositories/interfaces/channel_repository.py @@ -13,6 +13,22 @@ async def find_channels_by_ids(self, channel_ids: list[str]) -> list[Channel]: """Find channels by a list of channel IDs.""" pass + @abstractmethod + async def find_by_creation_request( + self, + *, + tenant_id: int, + user_id: int, + creation_request_id: str, + ) -> Channel | None: + """Find the durable result of one creator-scoped creation command.""" + pass + + @abstractmethod + async def save_creation(self, channel: Channel) -> tuple[Channel, bool]: + """Insert a creation command or recover its concurrent winner.""" + pass + @abstractmethod async def find_permission_candidates( self, diff --git a/src/backend/bisheng/channel/domain/schemas/channel_manager_schema.py b/src/backend/bisheng/channel/domain/schemas/channel_manager_schema.py index 9f298f4c5e..264f8867f0 100644 --- a/src/backend/bisheng/channel/domain/schemas/channel_manager_schema.py +++ b/src/backend/bisheng/channel/domain/schemas/channel_manager_schema.py @@ -2,9 +2,28 @@ from enum import StrEnum from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from bisheng.channel.domain.models.channel import ChannelFilterRules, ChannelVisibilityEnum +from bisheng.permission.domain.schemas import GrantSubjectInput + + +class ChannelInitialPermissionGrant(BaseModel): + model_key: str = Field(..., min_length=1, max_length=64) + subject: GrantSubjectInput + + +class ChannelInitialPermissionsRequest(BaseModel): + expected_catalog_release_id: int = Field(..., gt=0) + grants: list[ChannelInitialPermissionGrant] = Field(default_factory=list, max_length=50) + + +class ChannelInitialPermissionApplyResult(BaseModel): + status: Literal["succeeded", "failed"] + resource_version: int | None = None + assignee_ids: list[str] = Field(default_factory=list) + error_code: int | None = None + message: str | None = None class SubscriptionStatusEnum(StrEnum): @@ -57,6 +76,29 @@ class CreateChannelRequest(BaseModel): filter_rules: list[ChannelFilterRules] | None = Field(default_factory=list, description="Filter Conditions") is_released: bool = Field(default=False, description="Whether the channel is released") knowledge_sync: KnowledgeSyncConfig | None = Field(None, description="Knowledge space sync configuration") + creation_request_id: str | None = Field(default=None, min_length=1, max_length=64) + initial_permissions: ChannelInitialPermissionsRequest | None = None + + @model_validator(mode="after") + def require_request_id_for_initial_permissions(self): + if self.initial_permissions is not None and self.creation_request_id is None: + raise ValueError("initial_permissions requires creation_request_id") + return self + + +class CreateChannelResponse(BaseModel): + id: str + name: str + source_list: list[str] = Field(default_factory=list) + visibility: ChannelVisibilityEnum + description: str | None = None + filter_rules: list[dict] = Field(default_factory=list) + user_id: int + is_released: bool = False + tenant_id: int | None = None + create_time: datetime | None = None + update_time: datetime | None = None + initial_permission_result: ChannelInitialPermissionApplyResult | None = None class UpdateChannelRequest(BaseModel): diff --git a/src/backend/bisheng/channel/domain/services/channel_service.py b/src/backend/bisheng/channel/domain/services/channel_service.py index 002ca0ab3f..421f4cd9e3 100644 --- a/src/backend/bisheng/channel/domain/services/channel_service.py +++ b/src/backend/bisheng/channel/domain/services/channel_service.py @@ -1,6 +1,8 @@ import asyncio +import json import logging from datetime import datetime, timedelta +from hashlib import sha256 from typing import TYPE_CHECKING, Any from bisheng.approval.domain.schemas.approval_center_schema import ApprovalGateDecision, ApprovalGateRequest @@ -29,12 +31,14 @@ from bisheng.channel.domain.schemas.channel_manager_schema import ( AddArticlesToKnowledgeSpaceRequest, ChannelDetailResponse, + ChannelInitialPermissionApplyResult, ChannelItemResponse, ChannelMemberPageResponse, ChannelMemberResponse, ChannelSquareItemResponse, ChannelSquarePageResponse, CreateChannelRequest, + CreateChannelResponse, KnowledgeSyncConfig, KnowledgeSyncMainConfig, KnowledgeSyncSpaceItem, @@ -55,11 +59,13 @@ ChannelPermissionRecord, ) from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode import BaseErrorCode from bisheng.common.errcode.channel import ( ArticleSensitiveViolationError, ChannelAccessDeniedError, ChannelAdminLimitExceededError, ChannelCreateLimitExceededError, + ChannelCreationRequestConflictError, ChannelNotFoundError, ChannelOrganizationGrantUnsubscribeDeniedError, ) @@ -86,6 +92,12 @@ require_business_action, ) from bisheng.permission.application.identity import resolve_permission_actor +from bisheng.permission.application.initial_grant import ( + InitialGrantAddition, + InitialGrantApplication, + InitialGrantRequest, +) +from bisheng.permission.application.prospective_grant import ProspectiveGrantApplication from bisheng.permission.domain.services.permission_action_service import ( PermissionActor, ) @@ -199,6 +211,8 @@ def __init__( article_read_repository: "ArticleReadRepository" = None, message_service: MessageService | None = None, approval_gate: ApprovalGate | None = None, + initial_grant_application: InitialGrantApplication | None = None, + prospective_grant_application: ProspectiveGrantApplication | None = None, ): self.channel_repository = channel_repository self.space_channel_member_repository = space_channel_member_repository @@ -207,6 +221,8 @@ def __init__( self.article_read_repository = article_read_repository self.message_service = message_service self.approval_gate = approval_gate + self.initial_grant_application = initial_grant_application + self.prospective_grant_application = prospective_grant_application async def _get_channel_actions( self, @@ -428,8 +444,32 @@ async def reconcile_information_subscriptions(self) -> dict: return {"to_sub": len(to_sub), "to_unsub": len(to_unsub), "failed": failed} - async def create_channel(self, channel_data: CreateChannelRequest, login_user: UserPayload, request=None): + async def create_channel( + self, + channel_data: CreateChannelRequest, + login_user: UserPayload, + request=None, + ) -> Channel | CreateChannelResponse: """Create a new channel based on the provided data and the logged-in user.""" + tenant_id = self._current_tenant_id(login_user) + payload_hash = self._creation_payload_hash(channel_data) + existing = None + if channel_data.creation_request_id is not None: + existing = await self.channel_repository.find_by_creation_request( + tenant_id=tenant_id, + user_id=login_user.user_id, + creation_request_id=channel_data.creation_request_id, + ) + if existing is not None: + self._require_matching_creation(existing, payload_hash) + return await self._complete_channel_creation( + existing, + channel_data=channel_data, + login_user=login_user, + request=request, + created=False, + ) + # Check if the user has reached the role-configurable channel creation quota # (F005 quota: `channel`, default 10; admins/-1 = unlimited). effective already # folds in the tenant-chain cap, so this enforces both role and tenant limits. @@ -481,12 +521,38 @@ async def create_channel(self, channel_data: CreateChannelRequest, login_user: U filter_rules=[] if not channel_data.filter_rules else [f.model_dump() for f in channel_data.filter_rules], user_id=login_user.user_id, is_released=channel_data.is_released, + tenant_id=tenant_id, + creation_request_id=channel_data.creation_request_id, + creation_payload_hash=(payload_hash if channel_data.creation_request_id is not None else None), ) - channel_model = await self.channel_repository.save(channel_model) + created = True + if channel_data.creation_request_id is None: + channel_model = await self.channel_repository.save(channel_model) + else: + channel_model, created = await self.channel_repository.save_creation(channel_model) + self._require_matching_creation(channel_model, payload_hash) + + return await self._complete_channel_creation( + channel_model, + channel_data=channel_data, + login_user=login_user, + request=request, + created=created, + ) + async def _complete_channel_creation( + self, + channel_model: Channel, + *, + channel_data: CreateChannelRequest, + login_user: UserPayload, + request, + created: bool, + ) -> CreateChannelResponse: tenant_id = int(channel_model.tenant_id or self._current_tenant_id(login_user)) adapter = await get_f048_resource_adapter("channel") + actor = await resolve_permission_actor(login_user) await adapter.authorize_created( record=ChannelPermissionRecord( tenant_id=tenant_id, @@ -496,26 +562,39 @@ async def create_channel(self, channel_data: CreateChannelRequest, login_user: U permission_version=0, context_version=f"channel-create:{channel_model.id}"[:64], ), - actor=await resolve_permission_actor(login_user), + actor=actor, ) - # Add the creator as a member of the channel - await self.space_channel_member_repository.add_member( - business_id=channel_model.id, - business_type=BusinessTypeEnum.CHANNEL, - user_id=login_user.user_id, - role=UserRoleEnum.CREATOR, - relation=ChannelRelationEnum.OWNER, - grant_subject_type="self", - grant_subject_id=login_user.user_id, - grant_relation=ChannelRelationEnum.OWNER, - grant_model_id=ChannelRelationEnum.OWNER.value, - grant_binding_key=_self_channel_binding_key( - str(channel_model.id), - login_user.user_id, - ChannelRelationEnum.OWNER, - ), - ) + # The request-key path may resume after the row was committed. Ensure the + # legacy creator projection without duplicating it on retry. + creator_exists = False + if channel_data.creation_request_id is not None: + creator_exists = ( + await self.space_channel_member_repository.find_membership( + business_id=channel_model.id, + business_type=BusinessTypeEnum.CHANNEL, + user_id=login_user.user_id, + include_inactive=True, + ) + is not None + ) + if not creator_exists: + await self.space_channel_member_repository.add_member( + business_id=channel_model.id, + business_type=BusinessTypeEnum.CHANNEL, + user_id=login_user.user_id, + role=UserRoleEnum.CREATOR, + relation=ChannelRelationEnum.OWNER, + grant_subject_type="self", + grant_subject_id=login_user.user_id, + grant_relation=ChannelRelationEnum.OWNER, + grant_model_id=ChannelRelationEnum.OWNER.value, + grant_binding_key=_self_channel_binding_key( + str(channel_model.id), + login_user.user_id, + ChannelRelationEnum.OWNER, + ), + ) # Update latest_article_update_time for the new channel if channel_model.source_list: @@ -532,12 +611,204 @@ async def create_channel(self, channel_data: CreateChannelRequest, login_user: U # Audit log from bisheng.api.services.audit_log import AuditLogService - if request: + if request and created: await AuditLogService.create_channel( login_user, get_request_ip(request), str(channel_model.id), channel_model.name ) - return channel_model + permission_result = None + if channel_data.initial_permissions is not None and channel_data.initial_permissions.grants: + if self.initial_grant_application is None or channel_data.creation_request_id is None: + raise RuntimeError("F050 Initial Grant application is not configured") + try: + target = await adapter.resolve_permission_target( + resource_id=str(channel_model.id), + actor=actor, + action="manage_permission", + ) + initial_request = InitialGrantRequest( + command_key=channel_data.creation_request_id, + expected_catalog_release_id=channel_data.initial_permissions.expected_catalog_release_id, + additions=tuple( + InitialGrantAddition( + model_key=grant.model_key, + subject_type=grant.subject.type, + subject_id=grant.subject.id, + userset_relation=grant.subject.userset_relation, + include_children=grant.subject.include_children, + ) + for grant in channel_data.initial_permissions.grants + ), + ) + mutation = await self.initial_grant_application.apply( + actor=actor, + target=target, + request=initial_request, + ) + permission_result = ChannelInitialPermissionApplyResult( + status="succeeded", + resource_version=mutation.resource_version, + assignee_ids=[ + str(source.source_id) + for grant in mutation.grants + for source in grant.sources + if source.active and not source.protected + ], + ) + except Exception as exc: + # The Channel and protected owner are already durable; ordinary + # Grant failure is returned as an explicit partial success. + logger.exception("Initial Channel Grant mutation failed") + permission_result = ChannelInitialPermissionApplyResult( + status="failed", + error_code=exc.code if isinstance(exc, BaseErrorCode) else 500, + ) + + if channel_data.creation_request_id is None and channel_data.initial_permissions is None: + return channel_model + + return CreateChannelResponse.model_validate( + { + **channel_model.model_dump(), + "initial_permission_result": permission_result, + } + ) + + @staticmethod + def _require_matching_creation(channel: Channel, payload_hash: str) -> None: + if channel.creation_payload_hash != payload_hash: + raise ChannelCreationRequestConflictError() + + @staticmethod + def _creation_payload_hash(channel_data: CreateChannelRequest) -> str: + payload = channel_data.model_dump( + mode="json", + exclude={"creation_request_id"}, + ) + initial = payload.get("initial_permissions") + if initial is not None: + initial["grants"] = sorted( + initial["grants"], + key=lambda row: ( + row["model_key"].strip(), + row["subject"]["type"].strip().lower(), + row["subject"]["id"].strip(), + row["subject"].get("userset_relation") or "", + row["subject"].get("include_children", False), + ), + ) + canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return sha256(canonical.encode()).hexdigest() + + async def get_creation_permission_context(self, login_user: UserPayload) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access(login_user) + return await prospective.get_context(actor=actor, tenant_id=tenant_id, resource_type="channel") + + async def list_creation_grant_users( + self, + login_user: UserPayload, + *, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access(login_user) + return await prospective.list_users( + actor=actor, + tenant_id=tenant_id, + resource_type="channel", + keyword=keyword, + page=page, + page_size=page_size, + ) + + async def list_creation_grant_user_groups( + self, + login_user: UserPayload, + *, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access(login_user) + return await prospective.list_user_groups( + actor=actor, + tenant_id=tenant_id, + resource_type="channel", + keyword=keyword, + page=page, + page_size=page_size, + ) + + async def list_creation_grant_department_children( + self, + login_user: UserPayload, + *, + parent_id: int | None, + ) -> list[dict[str, object]]: + prospective, actor, tenant_id = await self._prospective_creation_access(login_user) + return await prospective.list_department_children( + actor=actor, + tenant_id=tenant_id, + resource_type="channel", + parent_id=parent_id, + ) + + async def search_creation_grant_departments( + self, + login_user: UserPayload, + *, + keyword: str, + limit: int, + ) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access(login_user) + return await prospective.search_departments( + actor=actor, + tenant_id=tenant_id, + resource_type="channel", + keyword=keyword, + limit=limit, + ) + + async def get_creation_grant_department_path( + self, + login_user: UserPayload, + department_id: int, + ) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access(login_user) + return await prospective.get_department_path( + actor=actor, + tenant_id=tenant_id, + resource_type="channel", + department_id=department_id, + ) + + async def _prospective_creation_access(self, login_user: UserPayload): + if self.prospective_grant_application is None: + raise RuntimeError("F050 Prospective Grant application is not configured") + effective = await QuotaService.get_effective_quota( + login_user.user_id, + QuotaResourceType.CHANNEL, + login_user.tenant_id, + login_user=login_user, + ) + if effective != -1: + memberships = await self.space_channel_member_repository.find_channel_memberships( + user_id=login_user.user_id, + roles=[UserRoleEnum.CREATOR], + statuses=[MembershipStatusEnum.ACTIVE], + ) + channel_ids = [membership.business_id for membership in memberships] + existing_channels = ( + await self.channel_repository.find_channels_by_ids(channel_ids) if channel_ids else [] + ) + if len(existing_channels) >= effective: + raise ChannelCreateLimitExceededError(quota=effective) + return ( + self.prospective_grant_application, + await resolve_permission_actor(login_user), + self._current_tenant_id(login_user), + ) async def get_my_channels( self, query_data: MyChannelQueryRequest, login_user: UserPayload @@ -1632,82 +1903,12 @@ async def update_channel(self, channel_id: str, req: UpdateChannelRequest, login channel.is_released = req.is_released if req.filter_rules is not None: channel.filter_rules = [f.model_dump() for f in req.filter_rules] + visibility_transition: tuple[ChannelVisibilityEnum, ChannelVisibilityEnum] | None = None if req.visibility is not None: new_visibility = ChannelVisibilityEnum(req.visibility) old_visibility = channel.visibility if old_visibility != new_visibility: - # When changing to PRIVATE (from PUBLIC or REVIEW), revoke every - # non-owner permission relation so the channel is only reachable - # by its owner(s): square subscribers, directly authorized users, - # and department/user_group grants alike. - if new_visibility == ChannelVisibilityEnum.PRIVATE: - adapter = await get_f048_resource_adapter("channel") - record = await adapter.load_permission_record(channel_id) - if record is None: - raise ChannelNotFoundError() - await adapter.remove_ordinary_sources( - record=record, - actor=await resolve_permission_actor(login_user), - ) - owners = await self.space_channel_member_repository.find_members_by_role( - channel_id, - UserRoleEnum.CREATOR, - ) - owner_user_ids = {owner.user_id for owner in owners} - # Capture active non-owner members before removal so we can - # notify everyone who loses access. - removed_user_ids = [] - if self.message_service: - existing_members = await self.space_channel_member_repository.find_all( - business_id=channel_id, - business_type=BusinessTypeEnum.CHANNEL, - ) - removed_user_ids = [ - member.user_id - for member in existing_members - if member.status == MembershipStatusEnum.ACTIVE and member.user_id not in owner_user_ids - ] - # Projection has committed, so business membership cleanup can - # proceed without leaving stale ALLOW tuples. - await self.space_channel_member_repository.remove_non_creator_members(channel_id) - if removed_user_ids and self.message_service: - final_removed_user_ids = [] - for user_id in removed_user_ids: - if not await self._user_can_read_channel(user_id, channel_id): - final_removed_user_ids.append(user_id) - await self._send_channel_event_notification( - action_code=CHANNEL_MADE_PRIVATE_MESSAGE, - operator_user_id=login_user.user_id, - operator_user_name=getattr(login_user, "user_name", None), - receiver_user_ids=final_removed_user_ids, - channel_id=channel_id, - channel_name=channel.name, - navigable=False, - ) - # When changing from REVIEW to PUBLIC, activate pending members and approve their messages - elif old_visibility == ChannelVisibilityEnum.REVIEW and new_visibility == ChannelVisibilityEnum.PUBLIC: - activated_members = await self.space_channel_member_repository.activate_pending_members(channel_id) - if activated_members: - logger.info( - "Activated %d pending members for channel_id=%s after visibility change from REVIEW to PUBLIC", - len(activated_members), - channel_id, - ) - # Mirror the newly-activated members into explicit ReBAC grants. - for member in activated_members: - await self.__class__.sync_direct_channel_user_permissions( - channel_id, - member.user_id, - member.user_role, - is_active=True, - operator_user_id=login_user.user_id, - ) - await self.space_channel_member_repository.remove_rejected_members(channel_id) - if self.message_service: - await self.message_service.batch_approve_channel_subscription_messages( - channel_id=channel_id, - operator_user_id=login_user.user_id, - ) + visibility_transition = (old_visibility, new_visibility) channel.visibility = new_visibility # Track if source_list changed for updating latest_article_update_time @@ -1739,6 +1940,14 @@ async def update_channel(self, channel_id: str, req: UpdateChannelRequest, login channel = await self.channel_repository.update(channel) + if visibility_transition is not None: + await self._apply_channel_visibility_transition( + channel, + old_visibility=visibility_transition[0], + new_visibility=visibility_transition[1], + login_user=login_user, + ) + # Update latest_article_update_time if source_list changed if source_list_changed: await self.update_channels_latest_article_time([channel]) @@ -1770,6 +1979,82 @@ async def update_channel(self, channel_id: str, req: UpdateChannelRequest, login return channel + async def _apply_channel_visibility_transition( + self, + channel: Channel, + *, + old_visibility: ChannelVisibilityEnum, + new_visibility: ChannelVisibilityEnum, + login_user: UserPayload, + ) -> None: + channel_id = str(channel.id) + if new_visibility == ChannelVisibilityEnum.PRIVATE: + adapter = await get_f048_resource_adapter("channel") + record = await adapter.load_permission_record(channel_id) + if record is None: + raise ChannelNotFoundError() + await adapter.remove_ordinary_sources( + record=record, + actor=await resolve_permission_actor(login_user), + ) + owners = await self.space_channel_member_repository.find_members_by_role( + channel_id, + UserRoleEnum.CREATOR, + ) + owner_user_ids = {owner.user_id for owner in owners} + removed_user_ids = [] + if self.message_service: + existing_members = await self.space_channel_member_repository.find_all( + business_id=channel_id, + business_type=BusinessTypeEnum.CHANNEL, + ) + removed_user_ids = [ + member.user_id + for member in existing_members + if member.status == MembershipStatusEnum.ACTIVE and member.user_id not in owner_user_ids + ] + # Projection has committed, so membership cleanup cannot leave stale + # ALLOW tuples if OpenFGA or SQL permission state rejects the change. + await self.space_channel_member_repository.remove_non_creator_members(channel_id) + if removed_user_ids and self.message_service: + final_removed_user_ids = [] + for user_id in removed_user_ids: + if not await self._user_can_read_channel(user_id, channel_id): + final_removed_user_ids.append(user_id) + await self._send_channel_event_notification( + action_code=CHANNEL_MADE_PRIVATE_MESSAGE, + operator_user_id=login_user.user_id, + operator_user_name=getattr(login_user, "user_name", None), + receiver_user_ids=final_removed_user_ids, + channel_id=channel_id, + channel_name=channel.name, + navigable=False, + ) + return + + if old_visibility == ChannelVisibilityEnum.REVIEW and new_visibility == ChannelVisibilityEnum.PUBLIC: + activated_members = await self.space_channel_member_repository.activate_pending_members(channel_id) + if activated_members: + logger.info( + "Activated %d pending members for channel_id=%s after visibility change from REVIEW to PUBLIC", + len(activated_members), + channel_id, + ) + for member in activated_members: + await self.__class__.sync_direct_channel_user_permissions( + channel_id, + member.user_id, + member.user_role, + is_active=True, + operator_user_id=login_user.user_id, + ) + await self.space_channel_member_repository.remove_rejected_members(channel_id) + if self.message_service: + await self.message_service.batch_approve_channel_subscription_messages( + channel_id=channel_id, + operator_user_id=login_user.user_id, + ) + async def get_channel_detail(self, channel_id: str, login_user: UserPayload) -> ChannelDetailResponse: """ Get channel detailed information including creator, subscriber count, and article count. diff --git a/src/backend/bisheng/common/chat/client.py b/src/backend/bisheng/common/chat/client.py index d88096f464..c8022bad2a 100644 --- a/src/backend/bisheng/common/chat/client.py +++ b/src/backend/bisheng/common/chat/client.py @@ -405,7 +405,7 @@ async def handle_gpts_message(self, message: Dict[any, any]): logger.info(f'gptsAgentOver assistant_id:{self.client_id} chat_id:{self.chat_id} question:{input_msg}') logger.info(f'gptsAgentOver assistant_id:{self.client_id} chat_id:{self.chat_id} answer:{answer}') - asyncio.create_task(self.generate_session_title(input_msg, answer)) + asyncio.create_task(self.generate_session_title(input_msg)) except BaseErrorCode as e: logger.exception('handle gpts message error: ') @@ -419,7 +419,7 @@ async def handle_gpts_message(self, message: Dict[any, any]): finally: await self.send_response('processing', 'close', '') - async def generate_session_title(self, question: str, answer: str = None): + async def generate_session_title(self, question: str): if not self.new_session: return if self.new_session.name: @@ -436,6 +436,6 @@ async def generate_session_title(self, question: str, answer: str = None): app_type=ApplicationTypeEnum.DAILY_CHAT, user_id=self.user_id ) - title = await generate_conversation_title_async(question=question, llm=llm, answer=answer) + title = await generate_conversation_title_async(question=question, llm=llm) await MessageSessionDao.update_session_name(self.new_session.chat_id, title) self.new_session.name = title diff --git a/src/backend/bisheng/common/errcode/channel.py b/src/backend/bisheng/common/errcode/channel.py index 2e271f3c74..9b300ce82a 100644 --- a/src/backend/bisheng/common/errcode/channel.py +++ b/src/backend/bisheng/common/errcode/channel.py @@ -118,3 +118,8 @@ class ChannelAuthorizationSyncError(BaseErrorCode): class ChannelOrganizationGrantUnsubscribeDeniedError(BaseErrorCode): Code: int = 19055 Msg: str = "本频道通过部门/用户组授权给你,暂无法取消订阅" + + +class ChannelCreationRequestConflictError(BaseErrorCode): + Code: int = 19056 + Msg: str = "Channel creation request conflicts with an earlier payload" diff --git a/src/backend/bisheng/common/errcode/knowledge_space.py b/src/backend/bisheng/common/errcode/knowledge_space.py index d727e067c4..6370d50da4 100644 --- a/src/backend/bisheng/common/errcode/knowledge_space.py +++ b/src/backend/bisheng/common/errcode/knowledge_space.py @@ -85,6 +85,11 @@ class SpaceOrganizationGrantExitDeniedError(BaseErrorCode): Msg: str = "本空间通过部门/用户组授权给你,暂无法退出" +class SpaceCreationRequestConflictError(BaseErrorCode): + Code: int = 18072 + Msg: str = "Knowledge Space creation request conflicts with an earlier payload" + + # ── Move (F034) ─────────────────────────────────────────────────────────────── diff --git a/src/backend/bisheng/common/services/config_service.py b/src/backend/bisheng/common/services/config_service.py index dd7056210a..736f4bc6c4 100644 --- a/src/backend/bisheng/common/services/config_service.py +++ b/src/backend/bisheng/common/services/config_service.py @@ -213,6 +213,28 @@ def _extract_block(lines: list[str], key: str, indent: str) -> list[str]: return lines[start:end] return [] + @staticmethod + def _section_child_indent(lines: list[str], anchor: int) -> str: + """Return the indentation already used by a stored section's children.""" + for line in lines[anchor + 1 :]: + if not line.strip() or line.lstrip().startswith("#"): + continue + leading = line[: len(line) - len(line.lstrip())] + if not leading: + break + return leading + return " " + + @staticmethod + def _reindent_block(block: list[str], source_indent: str, target_indent: str) -> list[str]: + """Move a raw YAML block while preserving its relative indentation.""" + if source_indent == target_indent: + return block + return [ + f"{target_indent}{line[len(source_indent) :]}" if line.startswith(source_indent) else line + for line in block + ] + @staticmethod def merge_missing_config(file_config: str, db_config: str) -> tuple[str, list[str]]: """Add keys that exist in the shipped yaml but not in the stored config. @@ -287,10 +309,11 @@ def merge_missing_config(file_config: str, db_config: str) -> tuple[str, list[st end -= 1 insert: list[str] = [] + target_indent = ConfigService._section_child_indent(out_lines, anchor) for sub in missing: block = ConfigService._extract_block(section, sub, " ") if block: - insert.extend(block) + insert.extend(ConfigService._reindent_block(block, " ", target_indent)) added.append(f"{key}.{sub}") if insert: out_lines[end:end] = insert diff --git a/src/backend/bisheng/common/services/llm_error_classifier.py b/src/backend/bisheng/common/services/llm_error_classifier.py index eb5602c441..4e0a2b80a5 100644 --- a/src/backend/bisheng/common/services/llm_error_classifier.py +++ b/src/backend/bisheng/common/services/llm_error_classifier.py @@ -85,6 +85,19 @@ class ErrorType(str, enum.Enum): "insufficient balance", "余额不足", "欠费", + # one-api / new-api style relay gateways (what kimi-k3 sits behind here). The + # gateway PRE-DEDUCTS an estimated cost before forwarding, so the body is + # "token quota is not enough, token remain quota: $0.073168, need quota: + # $0.173140" with HTTP 403 — money wording, but none of the strings above, and + # a 403 rather than the 429 this bucket was written around. It therefore fell + # through to the auth branch and told the user to "检查模型配置" when the only + # remedy was topping up (114, 2026-08-14). Both signals are specific to the + # pre-deduction path, so they cannot collide with TPM/TPS throttling wording. + # + # Note the amount is per-REQUEST: the same balance serves a fresh session fine + # and 403s a long-context one, because the estimate scales with input size. + "pre_consume_token_quota_failed", + "token quota is not enough", ) # Transient throttling — RPM (requests/min), TPM (tokens/min) and burst-rate diff --git a/src/backend/bisheng/common/utils/title_generator.py b/src/backend/bisheng/common/utils/title_generator.py index 3254b3994f..b8f9997296 100644 --- a/src/backend/bisheng/common/utils/title_generator.py +++ b/src/backend/bisheng/common/utils/title_generator.py @@ -4,7 +4,6 @@ """ import logging -from typing import Optional from langchain_core.language_models import BaseChatModel from langchain_core.messages import HumanMessage @@ -20,14 +19,17 @@ async def generate_conversation_title_async( question: str, llm: BaseChatModel, - answer: Optional[str] = None, ) -> str: - """Generate a conversation title asynchronously. + """Generate a conversation title asynchronously, from the QUESTION alone. + + The assistant's answer used to be fed in as well, which forced the whole + title step to wait for the round to finish. The question already carries the + topic, so dropping the answer lets a title be produced as soon as the user + submits — and removes a dependency on a reply that may never arrive. Args: question: The user's question content. llm: The BaseChatModel instance to use for generation. - answer: Optional assistant's answer content. Returns: Generated title string, or default title if generation fails. @@ -38,7 +40,6 @@ async def generate_conversation_title_async( "gen_title", "conversation_title", human=question or "", - assistant=answer or "", ) messages = [HumanMessage(content=prompt_obj.prompt)] @@ -55,14 +56,14 @@ async def generate_conversation_title_async( def generate_conversation_title_sync( question: str, llm: BaseChatModel, - answer: Optional[str] = None, ) -> str: - """Generate a conversation title synchronously. + """Generate a conversation title synchronously, from the QUESTION alone. + + See ``generate_conversation_title_async`` for why the answer is not used. Args: question: The user's question content. llm: The BaseChatModel instance to use for generation. - answer: Optional assistant's answer content. Returns: Generated title string, or default title if generation fails. @@ -73,7 +74,6 @@ def generate_conversation_title_sync( "gen_title", "conversation_title", human=question or "", - assistant=answer or "", ) messages = [HumanMessage(content=prompt_obj.prompt)] diff --git a/src/backend/bisheng/core/config/settings.py b/src/backend/bisheng/core/config/settings.py index 8333051813..8e655a0080 100644 --- a/src/backend/bisheng/core/config/settings.py +++ b/src/backend/bisheng/core/config/settings.py @@ -411,6 +411,19 @@ class LinsightConf(BaseModel): description="L3 tool-loop breaker: after this many consecutive same-tool failures, abort the task " "gracefully and salvage the intermediate result (instead of spinning to recursion_limit).", ) + tool_repeat_soft_limit: int = Field( + default=3, + description="L3 tool-loop breaker: after this many consecutive BYTE-IDENTICAL tool calls (same tool, " + "same arguments) that keep SUCCEEDING, append a counted corrective instruction to the next model " + "request. Distinct from tool_failure_soft_limit, which only counts errors. 0 disables the tier.", + ) + tool_repeat_hard_limit: int = Field( + default=8, + description="L3 tool-loop breaker: after this many consecutive byte-identical tool calls, abort the " + "task gracefully and salvage the intermediate result. Measured baseline: healthy runs that COMPLETED " + "reached at most 10 consecutive identical tool OUTPUTS (an upper bound on identical arguments), while " + "the 2026-08-14 incident hit 48. 0 disables the abort and leaves only the soft nudge.", + ) truncation_retry_limit: int = Field( default=2, description="L2 truncation guard: max times to retry a model call whose tool-call arguments were " @@ -447,9 +460,15 @@ class LinsightConf(BaseModel): ) skills_root: str = Field( default="data/linsight_skills", - description="Root directory of Linsight skills on disk (F035). Layout: built-in//SKILL.md for " - "kernel built-in skills; data/skills/{tenant_id}// for tenant custom skill bundles. " - "Multi-node deployments must mount this path on a shared volume (design §7.1).", + description="LEGACY: the pre-object-storage on-disk skill root. Skill bundles now live in object " + "storage; this path is only read by the one-off migration/restore scripts to find bundles left " + "on a node's local disk by an older release. Not used at runtime.", + ) + skills_cache_dir: str = Field( + default="", + description="Local cache root for materialized skill bundles. Empty = a 'linsight_skills' folder " + "under the process cache dir. Purely a cache: object storage is authoritative, entries are keyed " + "by content hash and are safe to delete at any time. Do NOT point this at a shared volume.", ) diff --git a/src/backend/bisheng/core/database/alembic/versions/v3_0_0_beta1_f050_creation_idempotency.py b/src/backend/bisheng/core/database/alembic/versions/v3_0_0_beta1_f050_creation_idempotency.py new file mode 100644 index 0000000000..f0bedbfb5b --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v3_0_0_beta1_f050_creation_idempotency.py @@ -0,0 +1,72 @@ +"""F050: add durable resource-creation idempotency keys. + +Revision ID: f050_creation_idempotency +Revises: f035_skill_content_hash +Create Date: 2026-08-17 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +from bisheng.core.database.alembic_helpers.online import column_exists, index_exists + +revision: str = "f050_creation_idempotency" +down_revision: str | Sequence[str] | None = "f035_skill_content_hash" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_KNOWLEDGE_TABLE = "knowledge" +_CHANNEL_TABLE = "channel" +_KNOWLEDGE_INDEX = "uq_knowledge_creation_request" +_CHANNEL_INDEX = "uq_channel_creation_request" + + +def _add_columns(table: str) -> None: + if not column_exists(table, "creation_request_id"): + op.add_column( + table, + sa.Column("creation_request_id", sa.String(length=64), nullable=True), + ) + if not column_exists(table, "creation_payload_hash"): + op.add_column( + table, + sa.Column("creation_payload_hash", sa.String(length=64), nullable=True), + ) + + +def upgrade() -> None: + _add_columns(_KNOWLEDGE_TABLE) + _add_columns(_CHANNEL_TABLE) + + if not index_exists(_KNOWLEDGE_TABLE, _KNOWLEDGE_INDEX): + op.create_index( + _KNOWLEDGE_INDEX, + _KNOWLEDGE_TABLE, + ["tenant_id", "user_id", "type", "creation_request_id"], + unique=True, + ) + if not index_exists(_CHANNEL_TABLE, _CHANNEL_INDEX): + op.create_index( + _CHANNEL_INDEX, + _CHANNEL_TABLE, + ["tenant_id", "user_id", "creation_request_id"], + unique=True, + ) + + +def downgrade() -> None: + if index_exists(_CHANNEL_TABLE, _CHANNEL_INDEX): + op.drop_index(_CHANNEL_INDEX, table_name=_CHANNEL_TABLE) + if index_exists(_KNOWLEDGE_TABLE, _KNOWLEDGE_INDEX): + op.drop_index(_KNOWLEDGE_INDEX, table_name=_KNOWLEDGE_TABLE) + + if column_exists(_CHANNEL_TABLE, "creation_payload_hash"): + op.drop_column(_CHANNEL_TABLE, "creation_payload_hash") + if column_exists(_CHANNEL_TABLE, "creation_request_id"): + op.drop_column(_CHANNEL_TABLE, "creation_request_id") + if column_exists(_KNOWLEDGE_TABLE, "creation_payload_hash"): + op.drop_column(_KNOWLEDGE_TABLE, "creation_payload_hash") + if column_exists(_KNOWLEDGE_TABLE, "creation_request_id"): + op.drop_column(_KNOWLEDGE_TABLE, "creation_request_id") diff --git a/src/backend/bisheng/core/database/alembic/versions/v3_0_0_f035_skill_content_hash.py b/src/backend/bisheng/core/database/alembic/versions/v3_0_0_f035_skill_content_hash.py new file mode 100644 index 0000000000..a29dddc97e --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v3_0_0_f035_skill_content_hash.py @@ -0,0 +1,60 @@ +"""F035: add linsight_skill.content_hash (skill bundles move to object storage). + +Bundle bytes used to live on the node's local filesystem, which made a +multi-node deployment inconsistent by construction: the API replica that +received an upload was the only host holding the files, while any Linsight +worker on another host read an empty directory and silently skipped the skill. +Bundles now live in object storage and ``content_hash`` is the pointer's version +component — the object key embeds it, so a key that exists always holds exactly +the bytes the row describes. + +The hash is computed over the bundle's *file mapping* +(``skill_store.bundle_content_hash``), not over the packed archive: zip bytes +carry timestamps and entry ordering, so hashing them would make every process +disagree about whether an unchanged bundle had changed. + +Empty string means "not migrated yet" and is left for the operational +backfill (``scripts/migrate_skills_to_object_storage.py``) to resolve — this +revision issues DDL only. + +Revision ID: f035_skill_content_hash +Revises: f048_visible_source_projection +Create Date: 2026-08-14 +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +from bisheng.core.database.alembic_helpers.online import column_exists + +revision: str = "f035_skill_content_hash" +down_revision: Union[str, Sequence[str], None] = "f048_visible_source_projection" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLE = "linsight_skill" +_COLUMN = "content_hash" + + +def upgrade() -> None: + if not column_exists(_TABLE, _COLUMN): + op.add_column( + _TABLE, + sa.Column( + _COLUMN, + sa.String(length=64), + nullable=False, + server_default=sa.text("''"), + comment="sha256 of the bundle's file mapping; '' = not yet on object storage", + ), + ) + + +def downgrade() -> None: + if column_exists(_TABLE, _COLUMN): + op.drop_column(_TABLE, _COLUMN) diff --git a/src/backend/bisheng/core/prompts/yaml/gen_title.yaml b/src/backend/bisheng/core/prompts/yaml/gen_title.yaml index 1194ce4720..b78603305d 100644 --- a/src/backend/bisheng/core/prompts/yaml/gen_title.yaml +++ b/src/backend/bisheng/core/prompts/yaml/gen_title.yaml @@ -30,12 +30,10 @@ prompts: ${USER_GOAL} conversation_title: - description: "会话标题生成" + description: "会话标题生成(仅依据用户问题,不使用回答)" type: prompt prompt: | - Please generate a concise, 5-word-or-less title for the conversation, using its same language, with no punctuation. Apply title case conventions appropriate for the language. Never directly mention the language name or the word "title" + Please generate a concise, 5-word-or-less title for the conversation, using its same language, with no punctuation. Apply title case conventions appropriate for the language. Never directly mention the language name or the word "title" ||>User: "${human}" - ||>Response: - "${assistant}" ||>Title: diff --git a/src/backend/bisheng/initdb_config.yaml b/src/backend/bisheng/initdb_config.yaml index 1716d4b55e..99595d0e36 100644 --- a/src/backend/bisheng/initdb_config.yaml +++ b/src/backend/bisheng/initdb_config.yaml @@ -155,4 +155,4 @@ mcp: knowledge_space: # 知识空间左侧目录树展示开关(仅前端读取,后端 API 不变) # 默认 true;中粮场内部署改为 false 走原 Tab 布局 - tree_structured_directory_display: true \ No newline at end of file + tree_structured_directory_display: true diff --git a/src/backend/bisheng/knowledge/api/dependencies.py b/src/backend/bisheng/knowledge/api/dependencies.py index d8ed49781e..6141ceef62 100644 --- a/src/backend/bisheng/knowledge/api/dependencies.py +++ b/src/backend/bisheng/knowledge/api/dependencies.py @@ -11,8 +11,9 @@ from bisheng.knowledge.domain.repositories.implementations.knowledge_document_version_repository_impl import ( KnowledgeDocumentVersionRepositoryImpl, ) -from bisheng.knowledge.domain.repositories.implementations.knowledge_file_repository_impl import \ - KnowledgeFileRepositoryImpl +from bisheng.knowledge.domain.repositories.implementations.knowledge_file_repository_impl import ( + KnowledgeFileRepositoryImpl, +) from bisheng.knowledge.domain.repositories.implementations.knowledge_repository_impl import KnowledgeRepositoryImpl from bisheng.knowledge.domain.repositories.interfaces.knowledge_document_repository import ( KnowledgeDocumentRepository, @@ -31,8 +32,8 @@ if TYPE_CHECKING: from bisheng.knowledge.domain.services.knowledge_file_service import KnowledgeFileService from bisheng.knowledge.domain.services.knowledge_service import KnowledgeService - from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService from bisheng.knowledge.domain.services.knowledge_space_chat_service import KnowledgeSpaceChatService + from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService from bisheng.knowledge.domain.services.knowledge_version_service import KnowledgeVersionService @@ -114,8 +115,26 @@ async def get_knowledge_space_service( ) -> 'KnowledgeSpaceService': """Get KnowledgeSpaceService instance, bound to the current request and login user""" from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService as _SvcClass + from bisheng.permission.application.access import get_f048_runtime + from bisheng.permission.application.initial_grant import InitialGrantApplication + from bisheng.permission.application.prospective_grant import ProspectiveGrantApplication + from bisheng.tenant.domain.services.f048_permission_subject import TenantPermissionSubjectDirectory + message_service = await _get_message_service(session) - service = _SvcClass(request=request, login_user=login_user) + runtime = await get_f048_runtime() + subject_directory = TenantPermissionSubjectDirectory() + service = _SvcClass( + request=request, + login_user=login_user, + initial_grant_application=InitialGrantApplication( + runtime=runtime, + subjects=subject_directory, + ), + prospective_grant_application=ProspectiveGrantApplication( + runtime=runtime, + subjects=subject_directory, + ), + ) service.message_service = message_service service.version_repo = version_repo service.doc_repo = doc_repo diff --git a/src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py b/src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py index d88de4e330..54fe4e9ac2 100644 --- a/src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py +++ b/src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py @@ -67,6 +67,8 @@ async def create_space( auto_tag_enabled=req.auto_tag_enabled, auto_tag_library_id=req.auto_tag_library_id, auto_tag_custom_tags=req.auto_tag_custom_tags, + creation_request_id=req.creation_request_id, + initial_permissions=req.initial_permissions, ) return resp_200(space) @@ -90,6 +92,62 @@ async def get_auto_tag_visibility( return resp_200({"visible": visible}) +@router.get("/creation-permission-context") +async def get_creation_permission_context( + svc: KnowledgeSpaceService = Depends(get_knowledge_space_service), +) -> Any: + return resp_200(await svc.get_creation_permission_context()) + + +@router.get("/creation-grant-subjects/users") +async def list_creation_grant_users( + keyword: str = "", + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + svc: KnowledgeSpaceService = Depends(get_knowledge_space_service), +) -> Any: + return resp_200( + await svc.list_creation_grant_users(keyword=keyword, page=page, page_size=page_size) + ) + + +@router.get("/creation-grant-subjects/user-groups") +async def list_creation_grant_user_groups( + keyword: str = "", + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + svc: KnowledgeSpaceService = Depends(get_knowledge_space_service), +) -> Any: + return resp_200( + await svc.list_creation_grant_user_groups(keyword=keyword, page=page, page_size=page_size) + ) + + +@router.get("/creation-grant-subjects/departments/children") +async def list_creation_grant_department_children( + parent_id: int | None = None, + svc: KnowledgeSpaceService = Depends(get_knowledge_space_service), +) -> Any: + return resp_200(await svc.list_creation_grant_department_children(parent_id=parent_id)) + + +@router.get("/creation-grant-subjects/departments/search") +async def search_creation_grant_departments( + keyword: str = "", + limit: int = Query(50, ge=1, le=200), + svc: KnowledgeSpaceService = Depends(get_knowledge_space_service), +) -> Any: + return resp_200(await svc.search_creation_grant_departments(keyword=keyword, limit=limit)) + + +@router.get("/creation-grant-subjects/departments/{department_id}/path-tree") +async def get_creation_grant_department_path( + department_id: int, + svc: KnowledgeSpaceService = Depends(get_knowledge_space_service), +) -> Any: + return resp_200(await svc.get_creation_grant_department_path(department_id)) + + @router.get("/{space_id}/info") async def get_space_info( space_id: int, diff --git a/src/backend/bisheng/knowledge/domain/models/knowledge.py b/src/backend/bisheng/knowledge/domain/models/knowledge.py index a9ace44dfa..5dc0081f90 100644 --- a/src/backend/bisheng/knowledge/domain/models/knowledge.py +++ b/src/backend/bisheng/knowledge/domain/models/knowledge.py @@ -4,7 +4,7 @@ from typing import Any, Optional, Union from pydantic import BaseModel, field_validator -from sqlalchemy import Boolean, Integer, String +from sqlalchemy import Boolean, Index, Integer, String from sqlmodel import Column, DateTime, Field, case, delete, func, or_, select, text, update from sqlmodel.sql.expression import Select, SelectOfScalar, col @@ -113,7 +113,26 @@ def convert_model(cls, v: Any) -> str: class Knowledge(KnowledgeBase, table=True): + __table_args__ = ( + Index( + "uq_knowledge_creation_request", + "tenant_id", + "user_id", + "type", + "creation_request_id", + unique=True, + ), + ) + id: int | None = Field(default=None, primary_key=True) + creation_request_id: str | None = Field( + default=None, + sa_column=Column(String(64), nullable=True), + ) + creation_payload_hash: str | None = Field( + default=None, + sa_column=Column(String(64), nullable=True), + ) class KnowledgeRead(KnowledgeBase): @@ -164,6 +183,24 @@ def convert_model(cls, v: Any) -> str: class KnowledgeDao(KnowledgeBase): + @classmethod + async def aget_by_creation_request( + cls, + *, + tenant_id: int, + user_id: int, + knowledge_type: int, + creation_request_id: str, + ) -> Knowledge | None: + async with get_async_db_session() as session: + statement = select(Knowledge).where( + Knowledge.tenant_id == tenant_id, + Knowledge.user_id == user_id, + Knowledge.type == knowledge_type, + Knowledge.creation_request_id == creation_request_id, + ) + return (await session.exec(statement)).first() + @classmethod def insert_one(cls, data: Knowledge) -> Knowledge: with get_sync_db_session() as session: diff --git a/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py b/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py index 4b52a99178..fe221e62b0 100644 --- a/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py +++ b/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py @@ -1,11 +1,30 @@ from enum import Enum from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from bisheng.common.models.space_channel_member import UserRoleEnum from bisheng.knowledge.domain.models.knowledge import AuthTypeEnum, KnowledgeBase from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileRead +from bisheng.permission.domain.schemas import GrantSubjectInput + + +class InitialPermissionGrant(BaseModel): + model_key: str = Field(..., min_length=1, max_length=64) + subject: GrantSubjectInput + + +class InitialPermissionsRequest(BaseModel): + expected_catalog_release_id: int = Field(..., gt=0) + grants: list[InitialPermissionGrant] = Field(default_factory=list, max_length=50) + + +class InitialPermissionApplyResult(BaseModel): + status: Literal["succeeded", "failed"] + resource_version: int | None = None + assignee_ids: list[str] = Field(default_factory=list) + error_code: int | None = None + message: str | None = None class SpaceSubscriptionStatusEnum(str, Enum): @@ -44,6 +63,19 @@ class KnowledgeSpaceCreateReq(BaseModel): "upserted server-side." ), ) + creation_request_id: str | None = Field(default=None, min_length=1, max_length=64) + initial_permissions: InitialPermissionsRequest | None = None + + @model_validator(mode="after") + def require_request_id_for_initial_permissions(self): + if self.initial_permissions is not None and self.creation_request_id is None: + raise ValueError("initial_permissions requires creation_request_id") + return self + + +class KnowledgeSpaceCreateResp(KnowledgeBase): + id: int + initial_permission_result: InitialPermissionApplyResult | None = None class KnowledgeSpaceInfoResp(KnowledgeBase): diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py index 753441ae3d..f903b37b24 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py @@ -311,7 +311,7 @@ async def generate_conversation(user_id: int, chat_id: str, question: str, answe app_type=ApplicationTypeEnum.DAILY_CHAT, user_id=user_id, ) - title = await generate_conversation_title_async(question=question, llm=llm, answer=answer) + title = await generate_conversation_title_async(question=question, llm=llm) await MessageSessionDao.update_session_name(chat_id, title) async def single_file_history( diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py index fb5f579b59..83734f069e 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py @@ -5,6 +5,7 @@ import tempfile from dataclasses import replace from datetime import datetime +from hashlib import sha256 from pathlib import Path from time import perf_counter from typing import TYPE_CHECKING @@ -12,6 +13,7 @@ from fastapi import Request from loguru import logger +from sqlalchemy.exc import IntegrityError from sqlmodel import select from bisheng.api.v1.schemas import ExcelRule, FileProcessBase, KnowledgeFileOne @@ -22,8 +24,10 @@ KnowledgeSpaceSubscribeScenarioHandler, ) from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode import BaseErrorCode from bisheng.common.errcode.knowledge import KnowledgeSpaceTagLibraryInvalidError from bisheng.common.errcode.knowledge_space import ( + SpaceCreationRequestConflictError, SpaceFileDuplicateError, SpaceFileExtensionError, SpaceFileNameDuplicateError, @@ -87,6 +91,9 @@ from bisheng.knowledge.domain.models.knowledge_space_user_pin import KnowledgeSpaceUserPinDao from bisheng.knowledge.domain.schemas.knowledge_space_schema import ( FolderUploadItem, + InitialPermissionApplyResult, + InitialPermissionsRequest, + KnowledgeSpaceCreateResp, KnowledgeSpaceFileResponse, KnowledgeSpaceInfoResp, KnowledgeSpaceListItemResp, @@ -120,6 +127,12 @@ check_business_action, ) from bisheng.permission.application.identity import resolve_permission_actor +from bisheng.permission.application.initial_grant import ( + InitialGrantAddition, + InitialGrantApplication, + InitialGrantRequest, +) +from bisheng.permission.application.prospective_grant import ProspectiveGrantApplication from bisheng.role.domain.services.quota_service import QuotaResourceType, QuotaService from bisheng.user.domain.models.user import UserDao from bisheng.utils import generate_uuid, get_request_ip @@ -207,6 +220,8 @@ def __init__( login_user: UserPayload, f048_permission_adapter=None, f048_file_delivery=None, + initial_grant_application: InitialGrantApplication | None = None, + prospective_grant_application: ProspectiveGrantApplication | None = None, ): self.request = request self.login_user = login_user @@ -214,6 +229,8 @@ def __init__( self.approval_gate: ApprovalGate | None = None self.f048_permission_adapter = f048_permission_adapter self.f048_file_delivery = f048_file_delivery + self.initial_grant_application = initial_grant_application + self.prospective_grant_application = prospective_grant_application # Injected by DI factory after construction (same pattern as message_service). # When set, list_space_children will exclude non-primary version files and # return version enrichment fields. @@ -1058,28 +1075,58 @@ async def create_knowledge_space( auto_tag_enabled: bool = False, auto_tag_library_id: int | None = None, auto_tag_custom_tags: list[str] | None = None, + creation_request_id: str | None = None, + initial_permissions: InitialPermissionsRequest | None = None, skip_user_limit: bool = False, - ) -> Knowledge: + ) -> KnowledgeSpaceCreateResp: """Create a new knowledge space (max 30 per user).""" - if not skip_user_limit: - count = await KnowledgeDao.async_count_spaces_by_user( - self.login_user.user_id, - exclude_department_spaces=True, - ) - if count >= _MAX_SPACE_PER_USER: - raise SpaceLimitError() + existing = await self._existing_creation(creation_request_id, None) + workbench_llm = None + if existing is None: + if not skip_user_limit: + count = await KnowledgeDao.async_count_spaces_by_user( + self.login_user.user_id, + exclude_department_spaces=True, + ) + if count >= _MAX_SPACE_PER_USER: + raise SpaceLimitError() - workbench_llm = await LLMService.get_workbench_llm() - if not workbench_llm or not workbench_llm.embedding_model: - raise WorkbenchEmbeddingError() + workbench_llm = await LLMService.get_workbench_llm() + if not workbench_llm or not workbench_llm.embedding_model: + raise WorkbenchEmbeddingError() # Defence-in-depth: a tenant with the feature flag off must not be able to - # configure auto-tag by hand-crafting requests. + # configure auto-tag by hand-crafting requests. Normalize before hashing + # so retries compare the effective business command. if not await self._is_auto_tag_feature_visible(): auto_tag_enabled = False auto_tag_library_id = None auto_tag_custom_tags = None + elif auto_tag_custom_tags is not None: + auto_tag_custom_tags = KnowledgeSpaceTagLibraryService.normalize_tags(auto_tag_custom_tags) + payload_hash = self._creation_payload_hash( + name=name, + description=description, + icon=icon, + auth_type=auth_type, + is_released=is_released, + auto_tag_enabled=auto_tag_enabled, + auto_tag_library_id=auto_tag_library_id, + auto_tag_custom_tags=auto_tag_custom_tags, + initial_permissions=initial_permissions, + ) + if existing is not None: + if existing.creation_payload_hash != payload_hash: + raise SpaceCreationRequestConflictError() + return await self._complete_knowledge_creation( + existing, + creation_request_id=creation_request_id, + initial_permissions=initial_permissions, + ) + if workbench_llm is None or workbench_llm.embedding_model is None: + raise WorkbenchEmbeddingError() + # Library-id needs the freshly minted knowledge.id when we are upserting # a private library, so defer the auto-tag fields until after insert. db_knowledge = Knowledge( @@ -1092,13 +1139,24 @@ async def create_knowledge_space( is_released=is_released, auto_tag_enabled=False, auto_tag_library_id=None, + creation_request_id=creation_request_id, + creation_payload_hash=payload_hash if creation_request_id is not None else None, ) - knowledge_space = KnowledgeService.create_knowledge_base( - self.request, self.login_user, db_knowledge, skip_hook=True - ) - - if auto_tag_enabled or auto_tag_library_id is not None or auto_tag_custom_tags is not None: + created = True + try: + knowledge_space = KnowledgeService.create_knowledge_base( + self.request, self.login_user, db_knowledge, skip_hook=True + ) + except IntegrityError: + # A concurrent request with the same durable key won the insert. + # Only that exact command may be resumed; a different hash conflicts. + knowledge_space = await self._existing_creation(creation_request_id, payload_hash) + if knowledge_space is None: + raise + created = False + + if created and (auto_tag_enabled or auto_tag_library_id is not None or auto_tag_custom_tags is not None): resolved_enabled, resolved_library_id = await self._apply_auto_tag_binding( knowledge=knowledge_space, auto_tag_enabled=auto_tag_enabled, @@ -1112,6 +1170,38 @@ async def create_knowledge_space( knowledge_space.auto_tag_library_id = resolved_library_id knowledge_space = await KnowledgeDao.async_update_space(knowledge_space) + result = await self._complete_knowledge_creation( + knowledge_space, + creation_request_id=creation_request_id, + initial_permissions=initial_permissions, + ) + + if not created: + return result + + member = SpaceChannelMember( + business_id=str(knowledge_space.id), + business_type=BusinessTypeEnum.SPACE, + user_id=self.login_user.user_id, + user_role=UserRoleEnum.CREATOR, + status=MembershipStatusEnum.ACTIVE, + ) + await SpaceChannelMemberDao.async_insert_member(member) + + # Audit log for knowledge space creation + await KnowledgeAuditTelemetryService.audit_create_knowledge_space( + self.login_user, self.request, knowledge_space + ) + + return result + + async def _complete_knowledge_creation( + self, + knowledge_space: Knowledge, + *, + creation_request_id: str | None, + initial_permissions: InitialPermissionsRequest | None, + ) -> KnowledgeSpaceCreateResp: container_adapter = await self._resource_adapter("knowledge_space") actor = await self._permission_actor() await container_adapter.authorize_created( @@ -1139,22 +1229,227 @@ async def create_knowledge_space( actor=actor, enabled=True, ) + permission_result = None + if initial_permissions is not None and initial_permissions.grants: + if self.initial_grant_application is None or creation_request_id is None: + raise RuntimeError("F050 Initial Grant application is not configured") + try: + target = await container_adapter.resolve_permission_target( + resource_type="knowledge_space", + resource_id=str(knowledge_space.id), + actor=actor, + action="manage_permission", + ) + request = InitialGrantRequest( + command_key=creation_request_id, + expected_catalog_release_id=initial_permissions.expected_catalog_release_id, + additions=tuple( + InitialGrantAddition( + model_key=grant.model_key, + subject_type=grant.subject.type, + subject_id=grant.subject.id, + userset_relation=grant.subject.userset_relation, + include_children=grant.subject.include_children, + ) + for grant in initial_permissions.grants + ), + ) + mutation = await self.initial_grant_application.apply( + actor=actor, + target=target, + request=request, + ) + permission_result = InitialPermissionApplyResult( + status="succeeded", + resource_version=mutation.resource_version, + assignee_ids=[ + str(source.source_id) + for grant in mutation.grants + for source in grant.sources + if source.active and not source.protected + ], + ) + except Exception as exc: + # Ordinary Grants are explicitly the partial-success phase: the + # business resource and protected owner are already durable. + logger.exception("Initial Knowledge Space Grant mutation failed") + permission_result = InitialPermissionApplyResult( + status="failed", + error_code=exc.code if isinstance(exc, BaseErrorCode) else 500, + ) + return KnowledgeSpaceCreateResp.model_validate( + { + **knowledge_space.model_dump(), + "initial_permission_result": permission_result, + } + ) - member = SpaceChannelMember( - business_id=str(knowledge_space.id), - business_type=BusinessTypeEnum.SPACE, - user_id=self.login_user.user_id, - user_role=UserRoleEnum.CREATOR, - status=MembershipStatusEnum.ACTIVE, + async def _existing_creation( + self, + creation_request_id: str | None, + payload_hash: str | None, + ) -> Knowledge | None: + if creation_request_id is None: + return None + existing = await KnowledgeDao.aget_by_creation_request( + tenant_id=int(self.login_user.tenant_id), + user_id=int(self.login_user.user_id), + knowledge_type=KnowledgeTypeEnum.SPACE.value, + creation_request_id=creation_request_id, ) - await SpaceChannelMemberDao.async_insert_member(member) + if existing is not None and payload_hash is not None and existing.creation_payload_hash != payload_hash: + raise SpaceCreationRequestConflictError() + return existing - # Audit log for knowledge space creation - await KnowledgeAuditTelemetryService.audit_create_knowledge_space( - self.login_user, self.request, knowledge_space + @staticmethod + def _creation_payload_hash( + *, + name: str, + description: str | None, + icon: str | None, + auth_type: AuthTypeEnum, + is_released: bool, + auto_tag_enabled: bool, + auto_tag_library_id: int | None, + auto_tag_custom_tags: list[str] | None, + initial_permissions: InitialPermissionsRequest | None, + ) -> str: + payload = { + "name": name, + "description": description, + "icon": icon, + "auth_type": auth_type.value, + "is_released": is_released, + "auto_tag_enabled": auto_tag_enabled, + "auto_tag_library_id": auto_tag_library_id, + "auto_tag_custom_tags": auto_tag_custom_tags, + "initial_permissions": None, + } + if initial_permissions is not None: + grants = sorted( + ( + { + "model_key": grant.model_key.strip(), + "subject": { + "type": grant.subject.type.strip().lower(), + "id": grant.subject.id.strip(), + "userset_relation": grant.subject.userset_relation, + "include_children": grant.subject.include_children, + }, + } + for grant in initial_permissions.grants + ), + key=lambda row: ( + row["model_key"], + row["subject"]["type"], + row["subject"]["id"], + row["subject"]["userset_relation"] or "", + row["subject"]["include_children"], + ), + ) + payload["initial_permissions"] = { + "expected_catalog_release_id": initial_permissions.expected_catalog_release_id, + "grants": grants, + } + canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return sha256(canonical.encode()).hexdigest() + + async def get_creation_permission_context(self) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access() + return await prospective.get_context( + actor=actor, + tenant_id=tenant_id, + resource_type="knowledge_space", + ) + + async def list_creation_grant_users( + self, + *, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access() + return await prospective.list_users( + actor=actor, + tenant_id=tenant_id, + resource_type="knowledge_space", + keyword=keyword, + page=page, + page_size=page_size, + ) + + async def list_creation_grant_user_groups( + self, + *, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access() + return await prospective.list_user_groups( + actor=actor, + tenant_id=tenant_id, + resource_type="knowledge_space", + keyword=keyword, + page=page, + page_size=page_size, ) - return knowledge_space + async def list_creation_grant_department_children( + self, + *, + parent_id: int | None, + ) -> list[dict[str, object]]: + prospective, actor, tenant_id = await self._prospective_creation_access() + return await prospective.list_department_children( + actor=actor, + tenant_id=tenant_id, + resource_type="knowledge_space", + parent_id=parent_id, + ) + + async def search_creation_grant_departments( + self, + *, + keyword: str, + limit: int, + ) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access() + return await prospective.search_departments( + actor=actor, + tenant_id=tenant_id, + resource_type="knowledge_space", + keyword=keyword, + limit=limit, + ) + + async def get_creation_grant_department_path(self, department_id: int) -> dict[str, object]: + prospective, actor, tenant_id = await self._prospective_creation_access() + return await prospective.get_department_path( + actor=actor, + tenant_id=tenant_id, + resource_type="knowledge_space", + department_id=department_id, + ) + + async def _prospective_creation_access(self): + if self.prospective_grant_application is None: + raise RuntimeError("F050 Prospective Grant application is not configured") + count = await KnowledgeDao.async_count_spaces_by_user( + self.login_user.user_id, + exclude_department_spaces=True, + ) + if count >= _MAX_SPACE_PER_USER: + raise SpaceLimitError() + workbench_llm = await LLMService.get_workbench_llm() + if not workbench_llm or not workbench_llm.embedding_model: + raise WorkbenchEmbeddingError() + return ( + self.prospective_grant_application, + await self._permission_actor(), + int(self.login_user.tenant_id), + ) async def get_space_info(self, space_id: int) -> KnowledgeSpaceInfoResp: from bisheng.worker import rebuild_knowledge_celery diff --git a/src/backend/bisheng/linsight/domain/models/linsight_skill.py b/src/backend/bisheng/linsight/domain/models/linsight_skill.py index 5c43e69f48..c752d88054 100644 --- a/src/backend/bisheng/linsight/domain/models/linsight_skill.py +++ b/src/backend/bisheng/linsight/domain/models/linsight_skill.py @@ -25,8 +25,11 @@ class LinsightSkillBase(SQLModelSerializable): """Tenant-scoped custom skill metadata (F035). - The skill body lives on disk under ``SKILLS_ROOT/data/skills/{tenant_id}//SKILL.md`` - (see design §7.1); this table only owns the metadata. Kernel built-in skills + The skill body lives in object storage, keyed by ``object_path`` (which embeds + ``content_hash``); this table only owns the metadata and the pointer. Nodes + materialize the bundle into a local cache on demand, so every API replica and + every Linsight worker resolves the same bytes regardless of which host wrote + them. Kernel built-in skills are seeded into this same table (``source='builtin'``) so they share one runtime path with uploaded ones — the picker, the enable/disable toggle and ``materialize_session_skills`` need no special case for them. @@ -65,12 +68,17 @@ class LinsightSkillBase(SQLModelSerializable): ) object_path: str = Field( ..., - description="Relative disk path under SKILLS_ROOT", - sa_column=Column(String(512), nullable=False, comment="SKILL.md relative path"), + description="Object-storage key of the bundle archive", + sa_column=Column(String(512), nullable=False, comment="Bundle object key"), + ) + content_hash: str = Field( + default="", + description="sha256 of the bundle's file mapping; '' means not yet on object storage", + sa_column=Column(String(64), nullable=False, server_default=text("''"), comment="Bundle content hash"), ) size: int | None = Field( default=0, - description="SKILL.md file size in bytes", + description="Total bundle size in bytes", sa_column=Column(Integer, nullable=False, server_default=text("0"), comment="File size in bytes"), ) created_by: int | None = Field( diff --git a/src/backend/bisheng/linsight/domain/services/agent_factory.py b/src/backend/bisheng/linsight/domain/services/agent_factory.py index a9ed2751dc..9c6a4ad2d8 100644 --- a/src/backend/bisheng/linsight/domain/services/agent_factory.py +++ b/src/backend/bisheng/linsight/domain/services/agent_factory.py @@ -157,11 +157,20 @@ - ask_user(reason, questions):第 0 步澄清;整个会话最多调用一次。 - write_todos(todos):维护有编号的待办清单;只翻转 status,不改写已有文案。 -__KB_TOOL_LINE__- write_file / read_file / edit_file / ls:工作区文件工具;交付物写 output/,中间产物写 scratch/(工具回显的 /output/x 与 output/x 是同一个文件)。 +__KB_TOOL_LINE__- write_file / read_file / edit_file / ls / glob / grep:工作区文件工具;交付物写 output/,中间产物写 scratch/(工具回显的 /output/x 与 output/x 是同一个文件)。read_file 支持 offset/limit 分块读取大文件;grep 可在整个工作区按关键字定位内容。 - export_docx(source_path, dest_path):把 output/ 下的 markdown 转 Word(.docx),必须在对应 .md 写好之后。 - export_pdf(source_path, dest_path):把 output/ 下的 markdown 转 PDF,必须在对应 .md 写好之后。 - task(description, subagent_type="general-purpose"):把独立、可隔离、较重的调研子任务委派给子代理。description 必须自包含——子代理看不到你的对话历史与上下文,只能读到 description,因此完成该子任务所需的全部背景、目标、约束与必要标识都要写进去;不得委派最终交付物撰写,也不得委派“问用户/澄清”。 +# 超大工具结果 + +某次工具结果过大时,系统会把它存入 /large_tool_results/,只在回复里保留开头片段。 +这表示**结果已经产生并保存好了**,不是执行失败、也不是输出被弄丢了。此时: + +- 用 read_file 读那个路径把内容取回来;文件很大就配合 offset/limit 分页读(该文件常常没有换行,会按字符分页,offset 数的是页不是行)。 +- 不知道确切路径时,用 grep 在 /large_tool_results/ 下按关键字定位。 +- **绝对不要重跑产生它的那次调用**:参数没变,结果一定一样,重跑只会再产生一份同样的大结果。 + __PATH_NAMESPACE_LINE__# 风格 - 简洁,工具调用之间不加多余解释性文字。 @@ -355,8 +364,14 @@ def _build_linsight_system_prompt( "- 反向同理:**不要**把代码里看到的宿主机绝对路径(形如 /root/.cache/…/<8位任务号>/output/a.png)" "传给 read_file / edit_file,去掉前缀只传 output/a.png。\n" "- 执行器写完会把本轮产出同步到工作区,随后 ls / glob 一般就能看到它们。" - "但**判定成功的依据是执行结果本身**:只要 exitcode 0 且日志显示写成功,就视为交付物已产出," - "继续下一步;不要反复 ls / glob 找它,更不要因为一时“找不到”而重新生成一遍。\n\n" + "但**判定成功的依据是执行结果本身**:只要 exitcode 0 且 file_list 里出现了目标文件," + "就视为交付物已产出,继续下一步,不要反复 ls / glob 找它。\n" + "- 若日志被截断、你看不到 file_list:可以 ls output/ **核实一次**;" + "若结果提示「Tool result too large … /large_tool_results/…」,用 read_file 读那个路径取回内容" + "(必要时配合 offset/limit 分页,或用 grep 在 /large_tool_results/ 下定位关键字)。" + "**任何情况下都不要把同一段代码原样再跑一遍**——相同的输入只会得到相同的结果。\n" + "- 不要一次 print 巨量内容:超长日志会被截断(中间省略),需要完整数据时把它写进 " + "scratch/ 下的文件,再用 read_file 分块读取。\n\n" ) return ( diff --git a/src/backend/bisheng/linsight/domain/services/builtin_skill_seeder.py b/src/backend/bisheng/linsight/domain/services/builtin_skill_seeder.py index f5320b4b8a..12cd90a9f8 100644 --- a/src/backend/bisheng/linsight/domain/services/builtin_skill_seeder.py +++ b/src/backend/bisheng/linsight/domain/services/builtin_skill_seeder.py @@ -1,20 +1,24 @@ """Install the kernel's built-in skill bundles into every tenant at startup. -A shipped skill has to reach the same place a user-uploaded one does — disk under -``SKILLS_ROOT/data/skills/{tenant_id}//`` plus a ``linsight_skill`` row — -because that is the only path the picker, the governance toggle and -``materialize_session_skills`` know about. Seeding there means an out-of-the-box -deployment shows the official skills with zero operator action, and every existing -capability (enable/disable, detail view, per-tenant isolation) works unchanged. +A shipped skill has to reach the same place a user-uploaded one does — a bundle +object in storage plus a ``linsight_skill`` row — because that is the only path +the picker, the governance toggle and ``materialize_session_skills`` know about. +Seeding there means an out-of-the-box deployment shows the official skills with +zero operator action, and every existing capability (enable/disable, detail view, +per-tenant isolation) works unchanged. + +Only the API process seeds. That is sufficient now that bundles live in object +storage: a Linsight worker on any host resolves the same objects. Under the old +local-disk layout it was a bug — the worker's filesystem was simply empty. Why here and not in a migration: project law says an Alembic revision does DDL only — any data seeding/backfill is a separate operational step. Doing it in the API lifespan keeps `docker compose up` a single command while staying out of the migration chain. -Idempotency is content-based: the bundle on disk is compared byte-for-byte with -the one shipped in the image, so an upgraded image updates the skill on the next -restart and an unchanged one costs a few file reads. A tenant that *edited* a +Idempotency is content-based: the shipped bundle's content hash is compared with +the one the row already points at, so an upgraded image updates the skill on the +next restart and an unchanged one costs one existence probe. A tenant that *edited* a built-in skill has its row flipped to ``manual`` by the update endpoints, and this seeder then leaves it alone forever — silently reverting a customer's edits on upgrade would be far worse than letting their copy drift. @@ -26,6 +30,7 @@ from pathlib import Path from loguru import logger +from sqlalchemy.exc import IntegrityError from bisheng.core.context.tenant import DEFAULT_TENANT_ID, current_tenant_id, set_current_tenant_id from bisheng.database.models.tenant import TenantDao @@ -38,6 +43,7 @@ DISPLAY_NAME_META_KEY, SKILL_MD, SkillStore, + bundle_content_hash, parse_skill_md, validate_skill_name, ) @@ -97,16 +103,21 @@ def discover_builtin_bundles() -> dict[str, tuple[dict, dict[str, bytes]]]: return found -def _installed_matches(store: SkillStore, tenant_id: int, name: str, files: dict[str, bytes]) -> bool: - """True when the on-disk bundle is byte-identical to the shipped one.""" +def _already_published(store: SkillStore, existing, tenant_id: int, name: str, shipped_hash: str) -> bool: + """True when this tenant's row already points at the shipped bundle *and* the object is there. + + The hash comparison alone would be cheaper still, but it would also drop a + property the previous byte-for-byte check had for free: if the stored bundle + is deleted or corrupted out-of-band, the next boot repairs it. Confirming the + object exists keeps that self-healing while staying O(1) per skill. + """ + if existing.content_hash != shipped_hash: + return False try: - installed = { - entry["path"]: store.read_bytes(tenant_id, name, entry["path"]) - for entry in store.list_files(tenant_id, name) - } - except Exception: # missing dir, unreadable file — treat as "needs rewrite" + return store.exists(tenant_id, name, shipped_hash) + except Exception: # storage hiccup — treat as "needs rewrite", the PUT is idempotent + logger.debug("built-in skill {!r} existence probe failed for tenant {}", name, tenant_id) return False - return installed == files def _display_name_of(meta: dict, name: str) -> str: @@ -125,18 +136,20 @@ async def _seed_one(store: SkillStore, tenant_id: int, name: str, meta: dict, fi if existing and existing.source != SKILL_SOURCE_BUILTIN: # The tenant forked it (any edit through the API flips source to manual). return "forked" - if existing and _installed_matches(store, tenant_id, name, files): + shipped_hash = bundle_content_hash(files) + if existing and _already_published(store, existing, tenant_id, name, shipped_hash): return "unchanged" - size = store.write_bundle(tenant_id, name, files) + ref = store.write_bundle(tenant_id, name, files) display_name = _display_name_of(meta, name) description = str(meta["description"]).strip() if existing: existing.display_name = display_name existing.description = description - existing.size = size - existing.object_path = store.object_path(tenant_id, name) + existing.size = ref.size + existing.content_hash = ref.content_hash + existing.object_path = ref.object_key await LinsightSkillDao.update(existing) return "updated" @@ -149,14 +162,17 @@ async def _seed_one(store: SkillStore, tenant_id: int, name: str, meta: dict, fi description=description, enabled=True, source=SKILL_SOURCE_BUILTIN, - object_path=store.object_path(tenant_id, name), - size=size, + object_path=ref.object_key, + content_hash=ref.content_hash, + size=ref.size, ) ) return "created" - except Exception: + except IntegrityError: # Several API replicas boot at once; uq_linsight_skill_tenant_name makes - # the loser's INSERT fail, and the winner already wrote the same bytes. + # the loser's INSERT fail, and the winner published the identical bytes. + # Only the uniqueness collision is benign — anything else (bad column, + # dead connection, DM8 dialect error) must not masquerade as a race. logger.debug("built-in skill {!r} insert lost a race for tenant {}", name, tenant_id) return "raced" diff --git a/src/backend/bisheng/linsight/domain/services/skill_bundle_backfill.py b/src/backend/bisheng/linsight/domain/services/skill_bundle_backfill.py new file mode 100644 index 0000000000..88e7bfdc63 --- /dev/null +++ b/src/backend/bisheng/linsight/domain/services/skill_bundle_backfill.py @@ -0,0 +1,106 @@ +"""Startup self-heal for skill bundles still sitting on this host's local disk. + +Before bundles moved to object storage, ``SKILLS_ROOT`` was authoritative — so an +upgraded deployment has rows whose ``content_hash`` is empty and whose bytes exist +only on whichever host wrote them. Those skills silently fail to load until the +bundle is published. + +This is deliberately **narrow**, and does far less than the operational script +(``scripts/migrate_skills_to_object_storage.py``): + +* it only publishes a bundle this host actually holds, whose byte count matches + what the row recorded — a partial or stale local copy is left alone rather than + becoming the version everyone gets; +* it never touches ``source='builtin'`` rows: those are republished from the + image by the seeder, which is a better source than any host's disk; +* anything it cannot resolve is logged **by name**, because that is the operator's + signal to run the script on the host that does hold it. + +Why not do the whole migration here: several API replicas boot at once, each +seeing a different local disk. Letting whichever replica wins publish its copy is +exactly the multi-node inconsistency this change exists to remove. The narrow rule +above is safe under concurrency — every replica that acts publishes byte-identical +content — while anything ambiguous is escalated to a human instead of guessed. +""" + +from __future__ import annotations + +from pathlib import Path + +from loguru import logger + +from bisheng.common.services.config_service import settings as bisheng_settings +from bisheng.core.context.tenant import bypass_tenant_filter, current_tenant_id, set_current_tenant_id +from bisheng.linsight.domain.models.linsight_skill import SKILL_SOURCE_BUILTIN, LinsightSkillDao +from bisheng.linsight.domain.services.skill_store import LEGACY_TENANT_SKILLS_DIR, SKILL_MD, SkillStore + +_SKIP_PARTS = {"__pycache__", ".git"} +_SKIP_NAMES = {".DS_Store"} + + +def read_legacy_bundle(legacy_root: Path, tenant_id: int, name: str) -> dict[str, bytes] | None: + """Read a bundle from the pre-object-storage on-disk layout, or None if absent.""" + base = legacy_root / LEGACY_TENANT_SKILLS_DIR / str(tenant_id) / name + if not (base / SKILL_MD).is_file(): + return None + files: dict[str, bytes] = {} + for path in sorted(base.rglob("*")): + if path.is_dir() or path.name in _SKIP_NAMES or _SKIP_PARTS & set(path.parts): + continue + files[path.relative_to(base).as_posix()] = path.read_bytes() + return files or None + + +async def backfill_skill_bundles_from_local_disk(*, store: SkillStore | None = None) -> dict: + """Publish what this host can prove it holds; name what it cannot. Returns counts.""" + store = store or SkillStore() + legacy_root = Path(bisheng_settings.get_linsight_conf().skills_root).resolve() + + with bypass_tenant_filter(): + rows, _ = await LinsightSkillDao.get_page(page=1, page_size=100000) + + stats = {"published": 0, "skipped_builtin": 0, "size_mismatch": 0, "elsewhere": 0} + unresolved: list[str] = [] + for row in rows: + if row.content_hash: + continue + if row.source == SKILL_SOURCE_BUILTIN: + stats["skipped_builtin"] += 1 + continue + + files = read_legacy_bundle(legacy_root, row.tenant_id, row.name) + if files is None: + stats["elsewhere"] += 1 + unresolved.append(f"{row.tenant_id}/{row.name}") + continue + if sum(len(c) for c in files.values()) != (row.size or 0): + # Local copy disagrees with what the row recorded — could be a partial + # write or an older revision. Publishing it would make a guess durable. + stats["size_mismatch"] += 1 + unresolved.append(f"{row.tenant_id}/{row.name}") + continue + + token = set_current_tenant_id(row.tenant_id) + try: + ref = store.write_bundle(row.tenant_id, row.name, files) + row.object_path, row.content_hash, row.size = ref.object_key, ref.content_hash, ref.size + await LinsightSkillDao.update(row) + stats["published"] += 1 + except Exception: + logger.exception("failed to publish local skill bundle {}/{}", row.tenant_id, row.name) + unresolved.append(f"{row.tenant_id}/{row.name}") + finally: + current_tenant_id.reset(token) + + if unresolved: + # Loud on purpose: these skills do not work until someone runs the + # migration script on the host holding their bundle. + logger.warning( + "{} skill bundle(s) are not on object storage and not on this host — run " + "scripts/migrate_skills_to_object_storage.py on the other API hosts: {}", + len(unresolved), + unresolved, + ) + if any(stats.values()): + logger.info("skill bundle backfill: {}", stats) + return stats diff --git a/src/backend/bisheng/linsight/domain/services/skill_middleware.py b/src/backend/bisheng/linsight/domain/services/skill_middleware.py deleted file mode 100644 index 52ccf41523..0000000000 --- a/src/backend/bisheng/linsight/domain/services/skill_middleware.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Skill loading + per-run whitelist middleware for the deepagents kernel. - -⚠️ DORMANT / SUPERSEDED (F035, 2026-06-24). This subclass is no longer wired into -``agent_factory.create_linsight_agent`` and ``make_skills_middleware`` has no -production caller. The Skill runtime was restored via **Fork X (copy-time gate)** -instead: at task startup ``skill_provisioning.materialize_session_skills`` copies -only the ``governance-enabled ∩ user-selected`` bundles into the session workspace -``/skills/`` subtree, then a plain ``deepagents.SkillsMiddleware`` (backed by a -``FilesystemBackend`` over the workspace cache) enumerates them. Because the copy -is the whitelist gate, the model physically cannot see an unselected skill — so -the per-run ``active_skills`` config key and the runtime ``_skill_allowed`` filter -below are NOT needed and NOT threaded. The whitelist *semantics* (built-in always -on; tenant skills need ``enabled`` + per-run selection; ``[]`` disables all) now -live in ``materialize_session_skills`` and are covered by its tests. - -This class is kept (not deleted) for reference and as a fallback should we ever -move filtering back to runtime. The Skill *management* CRUD/upload layer -(skill_service / skill_store / ``/api/v1/linsight/skill``) is live and orthogonal. - -F035 Track D (design §7.2, deviation D8): deepagents 0.6.x has no native -whitelist hook — ``SkillsMiddleware`` only loads sources. We subclass it and -filter the loaded ``skills_metadata`` in ``(a)before_agent``: - -- **built-in skills** (``SKILLS_ROOT/built-in/``) always pass — they are kernel - capabilities, never exposed to the UI and never constrained by the whitelist; -- **tenant custom skills** (``SKILLS_ROOT/data/skills/{tenant_id}/``) must be - enabled at governance level (``linsight_skill.enabled``, resolved at assembly - time) AND present in the per-run whitelist - ``config.configurable.active_skills`` (C3 contract: list of skill names; - ``[]`` disables all custom skills; a missing key counts as "no constraint" - for non-UI callers only — the product UI always sends an explicit list). - -The single-subclass shape replaces the design-§7.2 two-middleware split, which -removes the ordering hazard against other middlewares (recorded as deviation D8). -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from deepagents.backends.filesystem import FilesystemBackend -from deepagents.middleware.skills import SkillsMiddleware, SkillsState, SkillsStateUpdate -from langchain_core.runnables import RunnableConfig -from langgraph.runtime import Runtime - -from bisheng.linsight.domain.models.linsight_skill import LinsightSkillDao -from bisheng.linsight.domain.services.skill_store import BUILTIN_DIR, SkillStore - -if TYPE_CHECKING: - from deepagents.middleware.skills import SkillMetadata - -ACTIVE_SKILLS_CONFIG_KEY = "active_skills" - -_BUILTIN_SOURCE = f"/{BUILTIN_DIR}/" - - -class TenantSkillsMiddleware(SkillsMiddleware): - """SkillsMiddleware + tenant governance (enabled) + per-run whitelist filter.""" - - def __init__(self, tenant_id: int, enabled_names: set[str], store: SkillStore | None = None): - store = store or SkillStore() - store.builtin_dir().mkdir(parents=True, exist_ok=True) - store.tenant_dir(tenant_id).mkdir(parents=True, exist_ok=True) - # virtual_mode anchors all paths inside SKILLS_ROOT and blocks traversal (design §7.1). - backend = FilesystemBackend(root_dir=str(store.root), virtual_mode=True) - super().__init__( - backend=backend, - sources=[ - (_BUILTIN_SOURCE, "Built-in"), - (f"/data/skills/{tenant_id}/", "Tenant"), - ], - ) - self._tenant_id = tenant_id - self._enabled_names = set(enabled_names) - - # -- whitelist filtering ------------------------------------------------- - def _filter_update(self, update: SkillsStateUpdate | None, config: RunnableConfig) -> SkillsStateUpdate | None: - if not update or "skills_metadata" not in update: - return update - active = (config or {}).get("configurable", {}).get(ACTIVE_SKILLS_CONFIG_KEY) - active_set = set(active) if active is not None else None - update["skills_metadata"] = [ - skill for skill in update["skills_metadata"] if self._skill_allowed(skill, active_set) - ] - return update - - def _skill_allowed(self, skill: SkillMetadata, active_set: set[str] | None) -> bool: - if skill["path"].startswith(_BUILTIN_SOURCE): - return True - if skill["name"] not in self._enabled_names: - return False - # None = no per-run constraint (robustness fallback; UI always sends a list). - return active_set is None or skill["name"] in active_set - - def before_agent(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override] - return self._filter_update(super().before_agent(state, runtime, config), config) - - async def abefore_agent( - self, state: SkillsState, runtime: Runtime, config: RunnableConfig - ) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override] - return self._filter_update(await super().abefore_agent(state, runtime, config), config) - - -async def make_skills_middleware(tenant_id: int, store: SkillStore | None = None) -> TenantSkillsMiddleware: - """Assembly-time factory for Track A (`_create_agent` middleware list). - - Resolves the governance-enabled skill set from DB; the caller must already - hold the tenant context (worker re-establishes it before task execution, - design §6.5). - """ - enabled = {skill.name for skill in await LinsightSkillDao.list_enabled()} - return TenantSkillsMiddleware(tenant_id=tenant_id, enabled_names=enabled, store=store) diff --git a/src/backend/bisheng/linsight/domain/services/skill_provisioning.py b/src/backend/bisheng/linsight/domain/services/skill_provisioning.py index 069c87a5d6..01fd20fd27 100644 --- a/src/backend/bisheng/linsight/domain/services/skill_provisioning.py +++ b/src/backend/bisheng/linsight/domain/services/skill_provisioning.py @@ -7,8 +7,8 @@ * the session ``WorkspaceBackend`` lists MinIO recursively and returns only *file* entries (``is_dir=False``) — deepagents' native ``skills=`` param pointed at it would discover zero skills; - * skill bundles live in a separate ``SKILLS_ROOT`` the workspace ``read_file`` - cannot reach. + * skill bundles live in their own object-storage namespace the workspace + ``read_file`` cannot reach. So at task startup we copy the bundles this run is allowed to use into the workspace ``/skills/`` subtree. ``WorkspaceBackend.aupload_files`` write-throughs @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio +from typing import NamedTuple from loguru import logger @@ -36,11 +37,31 @@ """Workspace subtree the copied bundles live under (``/skills//...``).""" -def _collect_bundle_pairs(store: SkillStore, tenant_id: int, name: str) -> list[tuple[str, bytes]]: - """Read a bundle's files as ``(workspace_path, bytes)`` upload pairs (sync disk I/O).""" +class SkillProvisionResult(NamedTuple): + """Outcome of provisioning, split so the caller can tell silence from failure. + + ``failed`` holds skills the user explicitly picked and governance allowed, but + which could not be loaded. That case used to be indistinguishable from "no + skills requested" — a warning in the log and a task that ran on, quietly + missing the capability the user asked for. It is now reported to the user. + """ + + copied: list[str] + failed: list[str] + + +def _collect_bundle_pairs(store: SkillStore, tenant_id: int, name: str, content_hash: str) -> list[tuple[str, bytes]]: + """Read a bundle's files as ``(workspace_path, bytes)`` upload pairs. + + Blocking I/O: the first call for a given version fetches the object, later + ones are served from the node's local cache. + """ return [ - (f"/{WORKSPACE_SKILLS_DIR}/{name}/{entry['path']}", store.read_bytes(tenant_id, name, entry["path"])) - for entry in store.list_files(tenant_id, name) + ( + f"/{WORKSPACE_SKILLS_DIR}/{name}/{entry['path']}", + store.read_bytes(tenant_id, name, content_hash, entry["path"]), + ) + for entry in store.list_files(tenant_id, name, content_hash) ] @@ -49,22 +70,23 @@ async def materialize_session_skills( tenant_id: int, selected: list[str] | None, store: SkillStore | None = None, -) -> list[str]: +) -> SkillProvisionResult: """Copy allowed skill bundles into the workspace ``/skills/`` subtree. Args: backend: the session ``WorkspaceBackend`` (write-throughs to MinIO+cache). - tenant_id: owning tenant; scopes the on-disk bundle source path. + tenant_id: owning tenant; scopes the bundle object keys. selected: skill names picked for this run. Both ``None`` (field absent — a legacy row, or any client/caller that never sent it) and ``[]`` (the UI explicitly cleared the picker) mean "no skills this run": copy nothing. Only an explicit non-empty list opts in, and each name is still intersected with the tenant's governance-enabled set. - store: skill disk store (injectable for tests). + store: skill bundle store (injectable for tests). Returns: - The skill names actually materialized. Empty when nothing matched — the - caller then skips attaching the skills middleware entirely. + ``SkillProvisionResult(copied, failed)``. ``copied`` gates attaching the + skills middleware; a non-empty ``failed`` means the run is missing a + capability the user explicitly asked for and must be surfaced. """ # None ≡ [] ≡ "no skills for this run" — copy nothing. Treating a missing # field as "copy every enabled skill" was a footgun: any request that omitted @@ -72,43 +94,52 @@ async def materialize_session_skills( # loaded EVERY enabled skill, defeating the picker. Skills are strictly opt-in # via an explicit name list. if not selected: - return [] + return SkillProvisionResult([], []) store = store or SkillStore() # Governance gate, scoped to the current tenant (LinsightSkillDao.list_enabled # uses strict_tenant_filter); the worker has already restored tenant context. - enabled = {skill.name for skill in await LinsightSkillDao.list_enabled()} - wanted = {name for name in selected if name in enabled} + # Keep the whole row: it carries the content_hash that locates the bundle, so + # resolving one costs no extra query. + enabled = {skill.name: skill for skill in await LinsightSkillDao.list_enabled()} + wanted = sorted(name for name in selected if name in enabled) if not wanted: - return [] + return SkillProvisionResult([], []) copied: list[str] = [] - for name in sorted(wanted): + failed: list[str] = [] + for name in wanted: try: - # Bundle reads (rglob + per-file read_bytes) are sync disk I/O — run them - # off the worker's event loop so concurrent tasks aren't stalled. - pairs = await asyncio.to_thread(_collect_bundle_pairs, store, tenant_id, name) + # Reading a bundle is blocking I/O (a cache miss also fetches the + # object) — keep it off the worker's event loop so concurrent tasks + # aren't stalled. + pairs = await asyncio.to_thread(_collect_bundle_pairs, store, tenant_id, name, enabled[name].content_hash) if not pairs: - logger.warning("linsight skill {!r} (tenant {}) has no files on disk; skipping", name, tenant_id) + logger.warning("linsight skill {!r} (tenant {}) resolved to an empty bundle", name, tenant_id) + failed.append(name) continue responses = await backend.aupload_files(pairs) - failed = [r for r in responses if getattr(r, "error", None)] - if failed: - logger.warning("linsight skill {!r} copy had failures, not advertising: {}", name, failed) + upload_errors = [r for r in responses if getattr(r, "error", None)] + if upload_errors: + logger.warning("linsight skill {!r} copy had failures, not advertising: {}", name, upload_errors) + failed.append(name) continue copied.append(name) except Exception: - # Best-effort: one malformed/unreadable bundle must never abort the task. + # One broken bundle must never abort the task — but it must not be + # invisible either: the user picked this skill and will not get it. logger.exception("failed to materialize linsight skill {!r} (tenant {})", name, tenant_id) + failed.append(name) # loguru formats with str.format, NOT printf — printf placeholders print # literally and silently drop every arg (this line used to log a useless # "tenant=%s selected=%r ... -> materialized %s", hiding exactly the fact a # skill-provisioning investigation needs). logger.info( - "linsight skill provisioning: tenant={} selected={!r} enabled={} -> materialized {}", + "linsight skill provisioning: tenant={} selected={!r} enabled={} -> materialized {} failed {}", tenant_id, selected, sorted(enabled), copied, + failed, ) - return copied + return SkillProvisionResult(copied, failed) diff --git a/src/backend/bisheng/linsight/domain/services/skill_service.py b/src/backend/bisheng/linsight/domain/services/skill_service.py index c949c14285..3341d9e125 100644 --- a/src/backend/bisheng/linsight/domain/services/skill_service.py +++ b/src/backend/bisheng/linsight/domain/services/skill_service.py @@ -145,24 +145,25 @@ async def get_selectable(self) -> list[SkillSelectable]: async def get_detail(self, tenant_id: int, name: str) -> SkillDetail: skill = await self._get_or_404(name) try: - source_text = self.store.read_text(tenant_id, name) + source_text = self.store.read_text(tenant_id, name, skill.content_hash) _, body = parse_skill_md(source_text) except (FileNotFoundError, ValueError) as exc: - # Disk drifted from DB (shared-volume misconfig etc.) — surface, don't hide. - logger.warning("skill disk read failed for {}: {}", name, exc) + # Row exists but its bundle object doesn't (never migrated, or deleted + # out-of-band) — surface, don't hide. + logger.warning("skill bundle read failed for {}: {}", name, exc) source_text, body = "", "" detail = SkillDetail( **SkillBrief.from_model(skill).model_dump(), preview=body.strip(), source_text=source_text, - files=[SkillFileEntry(**e) for e in self.store.list_files(tenant_id, name)], + files=[SkillFileEntry(**e) for e in self.store.list_files(tenant_id, name, skill.content_hash)], ) return detail async def read_bundle_file(self, tenant_id: int, name: str, path: str) -> SkillFileContent: - await self._get_or_404(name) + skill = await self._get_or_404(name) try: - content = self.store.read_text(tenant_id, name, path) + content = self.store.read_text(tenant_id, name, skill.content_hash, path) except ValueError: raise SkillValidationError(msg=f"illegal file path: {path}") except FileNotFoundError: @@ -222,10 +223,11 @@ async def update_from_form(self, tenant_id: int, name: str, form: SkillCreateFor new_md = compose_skill_md( name=form.name, description=form.description, body=form.content, display_name=form.display_name ).encode("utf-8") - files = self._load_existing_bundle(tenant_id, name) + files = self._load_existing_bundle(tenant_id, name, skill.content_hash) files[SKILL_MD] = new_md - size = self.store.write_bundle(tenant_id, name, files) - skill.display_name, skill.description, skill.size = form.display_name, form.description, size + ref = self.store.write_bundle(tenant_id, name, files) + skill.display_name, skill.description = form.display_name, form.description + skill.size, skill.content_hash, skill.object_path = ref.size, ref.content_hash, ref.object_key self._mark_forked(skill) await LinsightSkillDao.update(skill) return await self.get_detail(tenant_id, name) @@ -237,8 +239,9 @@ async def update_from_upload(self, tenant_id: int, name: str, filename: str, dat if meta.name != name: raise SkillValidationError(msg=f"frontmatter name '{meta.name}' must equal skill ID '{name}'") await self._check_duplicate(name, meta.display_name, exclude_id=skill.id) - size = self.store.write_bundle(tenant_id, name, files) - skill.display_name, skill.description, skill.size = meta.display_name, meta.description, size + ref = self.store.write_bundle(tenant_id, name, files) + skill.display_name, skill.description = meta.display_name, meta.description + skill.size, skill.content_hash, skill.object_path = ref.size, ref.content_hash, ref.object_key self._mark_forked(skill) await LinsightSkillDao.update(skill) detail = await self.get_detail(tenant_id, name) @@ -253,7 +256,7 @@ async def delete(self, tenant_id: int, name: str) -> None: skill = await self._get_or_404(name) await LinsightSkillDao.delete_by_name(name) if not self.store.delete(tenant_id, name): - logger.warning("skill dir missing on delete: tenant={} name={}", tenant_id, skill.name) + logger.warning("skill bundle already absent on delete: tenant={} name={}", tenant_id, skill.name) # ----------------------------------------------------------- internals -- async def _get_or_404(self, name: str) -> LinsightSkill: @@ -332,12 +335,17 @@ async def _check_duplicate(self, name: str, display_name: str, exclude_id: int | if existing and existing.id != exclude_id: raise SkillNameDuplicateError() - def _load_existing_bundle(self, tenant_id: int, name: str) -> dict[str, bytes]: - base = self.store.skill_dir(tenant_id, name) - files: dict[str, bytes] = {} - for entry in self.store.list_files(tenant_id, name): - files[entry["path"]] = (base / entry["path"]).read_bytes() - return files + def _load_existing_bundle(self, tenant_id: int, name: str, content_hash: str) -> dict[str, bytes]: + """Read a stored bundle back as a file mapping (edit = read-modify-write). + + Goes through the store's reader rather than joining paths itself; the + bundle's authoritative copy is an object, and only the store knows how a + version is materialized locally. + """ + return { + entry["path"]: self.store.read_bytes(tenant_id, name, content_hash, entry["path"]) + for entry in self.store.list_files(tenant_id, name, content_hash) + } async def _create( self, @@ -350,7 +358,7 @@ async def _create( ) -> SkillDetail: self._validate_fields(name, display_name, description) await self._check_duplicate(name, display_name) - size = self.store.write_bundle(tenant_id, name, files) + ref = self.store.write_bundle(tenant_id, name, files) skill = LinsightSkill( tenant_id=tenant_id, name=name, @@ -358,8 +366,9 @@ async def _create( description=description, enabled=True, source=SKILL_SOURCE_MANUAL, - object_path=self.store.object_path(tenant_id, name), - size=size, + object_path=ref.object_key, + content_hash=ref.content_hash, + size=ref.size, created_by=user_id, ) skill = await LinsightSkillDao.create(skill) diff --git a/src/backend/bisheng/linsight/domain/services/skill_store.py b/src/backend/bisheng/linsight/domain/services/skill_store.py index 0acfd03d73..74170689a1 100644 --- a/src/backend/bisheng/linsight/domain/services/skill_store.py +++ b/src/backend/bisheng/linsight/domain/services/skill_store.py @@ -1,34 +1,58 @@ -"""Disk storage for Linsight skills (F035 Track D, design §7). - -Layout under SKILLS_ROOT (``linsight_conf.skills_root``):: - - built-in//SKILL.md # kernel built-in skills, loaded directly, never via API - data/skills/{tenant_id}// # tenant custom skill bundles (SKILL.md + optional assets) - -A skill is a directory ("bundle"): ``SKILL.md`` is mandatory and its frontmatter -``name`` must equal the directory name (deepagents hard constraint). The -human-facing ``display_name`` lives in ``metadata.display-name`` and in the -``linsight_skill`` table; it is the only name surfaced in UI. - -Multi-node deployments must mount SKILLS_ROOT on a shared volume (design §7.1). +"""Object-storage persistence for Linsight skill bundles (F035 Track D, design §7). + +A skill is a "bundle": a mapping of relative paths to bytes, with a mandatory +``SKILL.md`` at its root whose frontmatter ``name`` equals the skill name +(deepagents hard constraint). The human-facing ``display_name`` lives in +``metadata.display-name`` and in the ``linsight_skill`` table; it is the only +name surfaced in UI. + +**Object storage is the single source of truth**, keyed by content:: + + linsight/skills/{tenant_id}/{name}/{content_hash}.zip + +Nodes materialize a bundle into a local cache directory on demand. The cache is +disposable: the directory name *is* the content hash, so a present directory can +never hold stale bytes and no freshness probe is needed. + +Why content-addressed rather than a stable ``{name}.zip`` key: with a stable key +two concurrent edits race in two places at once — the object store keeps one +winner and the DB row keeps possibly the *other*. The row would then advertise a +hash the object no longer has, and nothing could ever detect it. Putting the hash +in the key means every write lands on its own object, so whichever UPDATE wins +still points at bytes that match it. Superseded objects become garbage, collected +out-of-band (see ``delete``) rather than by the writer — a writer that pruned +"other" versions would delete a concurrent writer's freshly published bundle. + +This replaces the previous local-filesystem layout, which made multi-node +deployments inconsistent by construction: only the node that received an upload +had the bytes, and a Linsight worker elsewhere silently skipped the skill. """ from __future__ import annotations +import hashlib import io +import os import re import shutil import zipfile from pathlib import Path, PurePosixPath +from typing import NamedTuple +from uuid import uuid4 import yaml from pypinyin import lazy_pinyin from bisheng.common.services.config_service import settings as bisheng_settings +from bisheng.core.cache.utils import CACHE_DIR +from bisheng.core.storage.minio.minio_manager import get_minio_storage_sync SKILL_MD = "SKILL.md" -BUILTIN_DIR = "built-in" -TENANT_SKILLS_DIR = "data/skills" +# Object-key namespace for skill bundles, sibling to the workspace's "workspace/". +SKILL_OBJECT_PREFIX = "linsight/skills" +# Legacy on-disk layout, still used by the one-off migration/restore scripts to +# find bundles a pre-object-storage release left on a node's local disk. +LEGACY_TENANT_SKILLS_DIR = "data/skills" # Upload payload limit: the .md / .zip / .skill bytes that arrive over HTTP. MAX_BUNDLE_SIZE = 10 * 1024 * 1024 @@ -48,6 +72,9 @@ _NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL) +# Earliest timestamp the zip format can represent. Fixed so packing is reproducible. +_ZIP_FIXED_MTIME = (1980, 1, 1, 0, 0, 0) + def slugify_pinyin(text: str, max_len: int = MAX_NAME_LEN) -> str: """Build a deepagents-legal skill name from arbitrary (Chinese) text. @@ -176,68 +203,139 @@ def _safe_rel_path(rel: str) -> PurePosixPath: return path -class SkillStore: - """Filesystem persistence for skill bundles. DB metadata stays in the DAO.""" +def bundle_content_hash(files: dict[str, bytes]) -> str: + """Content identity of a bundle: sha256 over the *file mapping*. - def __init__(self, root: str | Path | None = None): - if root is None: - root = bisheng_settings.get_linsight_conf().skills_root - self.root = Path(root).resolve() + Deliberately **not** the hash of the packed archive. ``zipfile`` stamps every + entry with ``time.localtime()`` and writes them in dict-iteration order, so + packing the same bundle twice produces different bytes. Hashing the archive + would make the built-in seeder see "changed" on every single startup and + rewrite every tenant's copy forever, and would grow one local cache directory + per boot. Hash the mapping and the identity is stable across processes, + machines and repacks. + """ + digest = hashlib.sha256() + for rel in sorted(files): + digest.update(rel.encode("utf-8")) + digest.update(b"\0") + digest.update(hashlib.sha256(files[rel]).digest()) + digest.update(b"\0") + return digest.hexdigest() - # ---- paths (also consumed by skill_middleware as SkillsMiddleware sources) ---- - def builtin_dir(self) -> Path: - return self.root / BUILTIN_DIR - def tenant_dir(self, tenant_id: int) -> Path: - return self.root / TENANT_SKILLS_DIR / str(tenant_id) +def pack_bundle_zip(files: dict[str, bytes]) -> bytes: + """Pack a bundle into a deterministic .zip — the object-storage transport form. - def skill_dir(self, tenant_id: int, name: str) -> Path: - return self.tenant_dir(tenant_id) / name + Sorted entries + a fixed timestamp keep the stored object reproducible, so the + same mapping never churns the object store. Bundle *identity* still comes from + ``bundle_content_hash``; this only avoids gratuitously different archive bytes. + """ + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for rel in sorted(files): + info = zipfile.ZipInfo(str(_safe_rel_path(rel)), date_time=_ZIP_FIXED_MTIME) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + archive.writestr(info, files[rel]) + return buffer.getvalue() - def object_path(self, tenant_id: int, name: str) -> str: - """Relative path stored in linsight_skill.object_path.""" - return f"{TENANT_SKILLS_DIR}/{tenant_id}/{name}" - # ---- bundle IO ---- - def exists(self, tenant_id: int, name: str) -> bool: - return (self.skill_dir(tenant_id, name) / SKILL_MD).is_file() +class BundleRef(NamedTuple): + """What a write produced: the row's pointer plus its total byte size.""" + + size: int + content_hash: str + object_key: str - def write_bundle(self, tenant_id: int, name: str, files: dict[str, bytes]) -> int: - """(Over)write a whole skill bundle; returns total size in bytes. - Writes into a sibling tmp dir first, then swaps — a crash mid-write - never leaves a half-bundle at the live path. +class SkillStore: + """Object-storage persistence for skill bundles, with a local materialize cache. + + ``content_hash`` identifies a bundle version and is required by every read — + callers already hold it on the ``linsight_skill`` row, so resolving a bundle + costs zero extra queries and zero metadata round-trips. + """ + + def __init__(self, root: str | Path | None = None, minio=None): + """``root`` is the *local cache* root (not authoritative storage).""" + if root is None: + conf = bisheng_settings.get_linsight_conf() + root = conf.skills_cache_dir or Path(CACHE_DIR) / "linsight_skills" + self.root = Path(root).resolve() + self._minio = minio + + @property + def minio(self): + """Resolved lazily: ``get_minio_storage_sync`` self-registers, so this works + in the API process, a Celery worker and the Linsight worker alike.""" + if self._minio is None: + self._minio = get_minio_storage_sync() + return self._minio + + def _bucket(self) -> str: + return self.minio.bucket + + # ---- keys & cache paths ---- + def object_key(self, tenant_id: int, name: str, content_hash: str) -> str: + """Object key stored in ``linsight_skill.object_path``. + + The tenant segment is the numeric ``tenant_id`` and is deliberately NOT + the F017 ``tenant_{code}/`` key-prefix convention. Skills are strictly + tenant-private (every DAO read runs under ``strict_tenant_filter``), and + keeping the key outside that convention means ``_translate_to_root_prefix`` + can never kick in — a Root-tenant fallback here would let a sub-tenant + read the Root tenant's skills. Do not "align" this with the F017 prefix. + """ + return f"{SKILL_OBJECT_PREFIX}/{tenant_id}/{name}/{content_hash}.zip" + + def _skill_prefix(self, tenant_id: int, name: str) -> str: + return f"{SKILL_OBJECT_PREFIX}/{tenant_id}/{name}/" + + def cache_dir(self, tenant_id: int, name: str, content_hash: str) -> Path: + """Local directory holding one materialized bundle version.""" + return self.root / str(tenant_id) / name / content_hash + + # ---- bundle IO ---- + def exists(self, tenant_id: int, name: str, content_hash: str) -> bool: + if (self.cache_dir(tenant_id, name, content_hash) / SKILL_MD).is_file(): + return True + return bool( + self.minio.object_exists_sync( + bucket_name=self._bucket(), object_name=self.object_key(tenant_id, name, content_hash) + ) + ) + + def write_bundle(self, tenant_id: int, name: str, files: dict[str, bytes]) -> BundleRef: + """Publish a bundle version and return its pointer. + + The object PUT is a single atomic operation, so there is no window in + which half a bundle is visible. Superseded versions are intentionally + left behind — see the module docstring. """ if SKILL_MD not in files: raise ValueError("bundle must contain SKILL.md") - dst = self.skill_dir(tenant_id, name) - tmp = dst.with_name(dst.name + ".tmp") - if tmp.exists(): - shutil.rmtree(tmp) total = 0 - try: - for rel, content in files.items(): - rel_path = _safe_rel_path(rel) - target = tmp / rel_path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(content) - total += len(content) - if dst.exists(): - shutil.rmtree(dst) - dst.parent.mkdir(parents=True, exist_ok=True) - tmp.replace(dst) - finally: - if tmp.exists(): - shutil.rmtree(tmp, ignore_errors=True) - return total - - def read_text(self, tenant_id: int, name: str, rel: str = SKILL_MD) -> str: - target = self.skill_dir(tenant_id, name) / _safe_rel_path(rel) + for rel, content in files.items(): + _safe_rel_path(rel) + total += len(content) + if total > MAX_UNPACKED_SIZE: + raise ValueError(f"bundle exceeds {MAX_UNPACKED_SIZE} bytes when unpacked") + + content_hash = bundle_content_hash(files) + key = self.object_key(tenant_id, name, content_hash) + self.minio.put_object_sync(bucket_name=self._bucket(), object_name=key, file=pack_bundle_zip(files)) + # Seed the local cache from what we already hold, so the writing node + # never round-trips to fetch back bytes it just uploaded. + self._install_cache(self.cache_dir(tenant_id, name, content_hash), files) + return BundleRef(size=total, content_hash=content_hash, object_key=key) + + def read_text(self, tenant_id: int, name: str, content_hash: str, rel: str = SKILL_MD) -> str: + target = self.materialize(tenant_id, name, content_hash) / _safe_rel_path(rel) if not target.is_file(): raise FileNotFoundError(str(target)) return target.read_text(encoding="utf-8", errors="replace") - def read_bytes(self, tenant_id: int, name: str, rel: str) -> bytes: + def read_bytes(self, tenant_id: int, name: str, content_hash: str, rel: str) -> bytes: """Read a bundle file as raw bytes (binary-safe). ``read_text`` decodes utf-8 with ``errors="replace"`` and is lossy for @@ -245,15 +343,20 @@ def read_bytes(self, tenant_id: int, name: str, rel: str) -> bytes: copy-into-workspace path (skill_provisioning) needs faithful bytes, so it reads through here instead. """ - target = self.skill_dir(tenant_id, name) / _safe_rel_path(rel) + target = self.materialize(tenant_id, name, content_hash) / _safe_rel_path(rel) if not target.is_file(): raise FileNotFoundError(str(target)) return target.read_bytes() - def list_files(self, tenant_id: int, name: str) -> list[dict]: - """Bundle file tree as [{path, size}], SKILL.md first, then sorted.""" - base = self.skill_dir(tenant_id, name) - if not base.is_dir(): + def list_files(self, tenant_id: int, name: str, content_hash: str) -> list[dict]: + """Bundle file tree as [{path, size}], SKILL.md first, then sorted. + + Returns ``[]`` when the bundle cannot be resolved, matching the previous + "missing directory" behaviour so callers keep degrading the same way. + """ + try: + base = self.materialize(tenant_id, name, content_hash) + except FileNotFoundError: return [] entries = [] for p in sorted(base.rglob("*")): @@ -263,8 +366,75 @@ def list_files(self, tenant_id: int, name: str) -> list[dict]: return entries def delete(self, tenant_id: int, name: str) -> bool: - dst = self.skill_dir(tenant_id, name) - if not dst.exists(): - return False - shutil.rmtree(dst) - return True + """Remove every stored version of a skill, plus its local cache. + + Deleting by prefix (rather than by a single hash) is what finally clears + superseded versions: the skill is gone, so no concurrent writer can be + publishing a version worth keeping. + """ + prefix = self._skill_prefix(tenant_id, name) + removed = False + for obj in self.minio.minio_client_sync.list_objects(self._bucket(), prefix=prefix, recursive=True): + self.minio.remove_object_sync(bucket_name=self._bucket(), object_name=obj.object_name) + removed = True + local = self.root / str(tenant_id) / name + if local.exists(): + shutil.rmtree(local, ignore_errors=True) + removed = True + return removed + + # ---- materialization ---- + def materialize(self, tenant_id: int, name: str, content_hash: str) -> Path: + """Return the local directory holding this bundle version, fetching it if absent. + + A cache hit performs no network I/O at all: the directory name is the + content hash, so its presence already proves the bytes are the right ones. + """ + dst = self.cache_dir(tenant_id, name, content_hash) + if (dst / SKILL_MD).is_file(): + return dst + key = self.object_key(tenant_id, name, content_hash) + data = self.minio.get_object_sync(bucket_name=self._bucket(), object_name=key) + if data is None: + raise FileNotFoundError(f"skill bundle object not found: {key}") + self._install_cache(dst, self._unpack_for_cache(unpack_zip_bytes(data), key)) + return dst + + @staticmethod + def _unpack_for_cache(files: dict[str, bytes], key: str) -> dict[str, bytes]: + """Re-apply the upload path's guards to bytes coming back from storage. + + Materialization is a *second* write-to-disk path: the size cap lives in + ``skill_service._parse_upload`` and the traversal check in the old + ``write_bundle``, so neither protects this one. A corrupted or tampered + object must not be able to fill the disk or escape the cache directory. + """ + total = 0 + for rel, content in files.items(): + _safe_rel_path(rel) + total += len(content) + if total > MAX_UNPACKED_SIZE: + raise ValueError(f"skill bundle {key} exceeds {MAX_UNPACKED_SIZE} bytes when unpacked") + return files + + def _install_cache(self, dst: Path, files: dict[str, bytes]) -> None: + """Atomically place a bundle's files at ``dst`` (a content-hash directory).""" + if (dst / SKILL_MD).is_file(): + return + tmp = dst.with_name(f"{dst.name}.tmp-{os.getpid()}-{uuid4().hex[:8]}") + try: + for rel, content in files.items(): + target = tmp / _safe_rel_path(rel) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + dst.parent.mkdir(parents=True, exist_ok=True) + try: + tmp.replace(dst) + except OSError: + # Someone materialized the same hash first. os.replace refuses a + # non-empty target rather than "winning", and that is fine: the + # directory name is the content hash, so their bytes equal ours. + pass + finally: + if tmp.exists(): + shutil.rmtree(tmp, ignore_errors=True) diff --git a/src/backend/bisheng/linsight/domain/services/tool_loop_middleware.py b/src/backend/bisheng/linsight/domain/services/tool_loop_middleware.py index 70fbe94b0f..76d21b7fa7 100644 --- a/src/backend/bisheng/linsight/domain/services/tool_loop_middleware.py +++ b/src/backend/bisheng/linsight/domain/services/tool_loop_middleware.py @@ -25,16 +25,27 @@ normal (COMPLETED) result with an apology preamble instead of a raw recursion error — the user still gets meaningful output. +A second, independent loop shape is bounded the same way (``reason="repeat"``): the +model re-submitting a BYTE-IDENTICAL tool call that keeps SUCCEEDING. The failure +tiers above are blind to it — they break on the first non-error result, and an +evicted "Tool result too large" message keeps its original ``status="success"``. +Its soft tier lives in ``wrap_model_call`` rather than ``wrap_tool_call``, because +deepagents' FilesystemMiddleware runs outside us and replaces evicted tool content +wholesale. See ``_identical_turn_run`` for the detection rules and the incident that +motivated them. + One instance per graph (main + researcher subagent), matching ``build_resilience_middleware`` — see ``build_tool_loop_breaker_middleware``. """ from __future__ import annotations +import hashlib +import json from typing import Any from langchain.agents.middleware.types import AgentMiddleware -from langchain_core.messages import ToolMessage +from langchain_core.messages import HumanMessage, ToolMessage from loguru import logger # The knowledge-search tool name (SearchKnowledgeBase). Kept as a local literal so @@ -48,20 +59,56 @@ _SALVAGE_KB_TOTAL_CAP = 4000 _TRUNCATION_NOTE = "\n\n…(内容较长,已截断)" +# Repeating these is protocol, not a loop. ``ask_user`` parks on a langgraph +# interrupt: resuming from a checkpoint replays a same-shaped call, and treating that +# as a loop would kill a task that is merely waiting for its user. +_REPEAT_EXEMPT_TOOLS = frozenset({"ask_user"}) + +# Cheap, read-only or state-only tools. Repeating them wastes a turn but destroys +# nothing, and a nudge usually lands — ``resilience_middleware`` measured 10 of one +# researcher's 29 calls being nothing but ``write_todos``. They still get a ceiling +# (the multiplier below): an unbounded ls-loop would otherwise burn the turn budget. +_REPEAT_LENIENT_TOOLS = frozenset({"write_todos", "ls", "glob"}) +_REPEAT_LENIENT_MULTIPLIER = 3 + +# The nudge carries the COUNT on purpose. With temperature=0 (the linsight default) +# and a tool_call_id that never changes, the offloaded ToolMessage is byte-identical +# every round, so context(n+1) is a deterministic function of context(n) — a fixed +# point that greedy decoding can never leave on its own. A monotonically changing +# line at the tail is what breaks the recurrence; restating the facts alone would not. +_REPEAT_HINT = ( + "⚠️ 你已经连续第 {count} 次提交**完全相同**的 {tools} 调用(参数逐字节一致)," + "得到的也是同一个结果。再提交一次仍然不会有任何变化,请立刻改变做法:\n" + "1) 若上一条工具结果提示「Tool result too large … saved in the filesystem at " + "/large_tool_results/…」,说明结果**已经产生**、只是没有直接展示:用 read_file 读取那个路径" + "取回内容,或用 grep 在 /large_tool_results/ 下按关键字定位。**不要重跑产生它的那次调用。**\n" + "2) 若是代码执行:不要再提交同一段脚本。减少 print 的输出量(只打印你真正需要的部分)," + "或把大段输出写进 scratch/ 下的文件,再用 read_file 分块读取。\n" + "3) 若这一步确实无法推进,就跳过它,用已经掌握的材料完成交付,并在收尾时说明这一点。" +) + class LinsightToolLoopError(Exception): - """Raised when one tool fails ``hard_limit`` times in a row. + """Raised when one tool fails ``hard_limit`` times in a row, or when the model + re-submits a byte-identical tool call ``repeat_hard_limit`` times in a row. Carries the salvaged intermediate result so ``task_exec`` can surface it as a meaningful (partial) deliverable instead of a raw recursion error. + + ``reason`` distinguishes the two causes so the user-facing preamble can state the + right one. They are NOT interchangeable: a failure run means the tool kept + erroring, a repeat run means it kept SUCCEEDING and the model ignored the result. + Telling a user "模型未能正确调用写入工具" about a repeat loop is simply false. """ - def __init__(self, *, tool_name: str | None, count: int, partial_result: str = "") -> None: + def __init__(self, *, tool_name: str | None, count: int, partial_result: str = "", reason: str = "failure") -> None: self.tool_name = tool_name self.count = count self.partial_result = partial_result or "" + self.reason = reason + verb = "returned an identical result for" if reason == "repeat" else "failed" super().__init__( - f"Tool '{tool_name}' failed {count} times consecutively; aborting the task with a salvaged partial result." + f"Tool '{tool_name}' {verb} {count} consecutive calls; aborting the task with a salvaged partial result." ) @@ -167,6 +214,101 @@ def _same_tool_streak(messages: list, tool_name: str | None) -> int: return run_count if run_tool == tool_name else 0 +# --------------------------------------------------------------------------- +# Identical-call detection (the "succeeded but got nowhere" loop) +# --------------------------------------------------------------------------- +# +# ``_trailing_tool_failure_run`` above only counts FAILURES. A tool that keeps +# succeeding while the model keeps re-sending the same arguments is invisible to it, +# and to every other guard in the stack: the turn budget bills each round as real +# work, and recursion_limit sits ~2500 steps away. Measured on 114, 2026-08-14: a +# kimi-k3 run re-sent a byte-identical bisheng_code_interpreter call 79 times over +# 78 minutes (13.8M input tokens) with zero todos advanced, and nothing stopped it. +# +# Why the whole turn and not just one call: parallel calls in one AIMessage are one +# unit of model intent. Comparing per-call would count a two-call turn as two. + + +def _args_digest(args: object) -> str: + """Stable digest of tool-call arguments; never raises.""" + try: + payload = json.dumps(args, sort_keys=True, ensure_ascii=False, default=str) + except (TypeError, ValueError): + payload = repr(args) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def _turn_fingerprint(message: object) -> tuple[tuple[str, str], ...] | None: + """``(tool_name, args_digest)`` for every tool call in one AIMessage, sorted. + + ``None`` when the message carries no tool calls — the model is talking, which + ends any repeat run. + + Deliberately EXCLUDES ``tool_call_id``. kimi-k3 (via tokenrouter) returns + ``:``, re-numbered per message and therefore + constant across turns, while deepseek returns a unique ``call_``. Folding + the id in would make this detector fire on one vendor and never on the other. + + A set, not a list: two identical parallel calls in one turn contribute one + element, so "twice in one turn" is not mistaken for "two turns". + """ + calls = _tool_calls_of(message) + if not calls: + return None + items: set[tuple[str, str]] = set() + for call in calls: + if isinstance(call, dict): + name, args = call.get("name"), call.get("args") + else: + name, args = getattr(call, "name", None), getattr(call, "args", None) + items.add((name or "", _args_digest(args if args is not None else {}))) + return tuple(sorted(items)) + + +def _identical_turn_run(messages: list) -> tuple[tuple[tuple[str, str], ...] | None, int]: + """``(fingerprint, count)`` of the trailing run of byte-identical model turns. + + ToolMessages are the CONSEQUENCE of a call and stay transparent. A human turn + (new user input) or a text-only AI turn (the model changed course) ends the run. + """ + latest: tuple[tuple[str, str], ...] | None = None + count = 0 + for m in reversed(messages): + role, _, _, _ = _msg_fields(m) + if role in ("human", "user"): + break + if role not in ("ai", "assistant"): + continue # tool results are transparent to the run + fingerprint = _turn_fingerprint(m) + if fingerprint is None: + break # the model produced text — no longer repeating + if latest is None: + latest, count = fingerprint, 1 + elif fingerprint == latest: + count += 1 + else: + break + if latest and any(name in _REPEAT_EXEMPT_TOOLS for name, _ in latest): + return None, 0 + if latest and _is_pure_failure_run(messages, count): + # A run where every result came back an error belongs to the FAILURE tier: it + # has its own limits and its own user-facing copy ("模型未能正确调用工具"). + # Claiming it here would abort earlier than that tier intends AND attribute it + # wrongly. This tier is for calls that SUCCEED and still get nowhere. + return None, 0 + return latest, count + + +def _is_pure_failure_run(messages: list, count: int) -> bool: + """True when the trailing identical run produced nothing but errors. + + ``count`` includes the newest AIMessage, whose tool has not run yet, so a fully + failing run has at most ``count - 1`` error results. + """ + _, failure_count = _trailing_tool_failure_run(messages) + return failure_count >= count - 1 + + # --------------------------------------------------------------------------- # Corrective hint + salvage assembly # --------------------------------------------------------------------------- @@ -235,13 +377,27 @@ def assemble_partial_result(messages: list) -> str: class LinsightToolLoopBreakerMiddleware(AgentMiddleware): - """Bound a same-tool consecutive-failure loop: soft nudge, then hard stop.""" - - def __init__(self, *, soft_limit: int = 3, hard_limit: int = 8, is_subagent: bool = False) -> None: + """Bound two shapes of tool loop: consecutive FAILURES, and byte-identical + REPEATS that keep succeeding. Both go soft nudge first, then hard stop.""" + + def __init__( + self, + *, + soft_limit: int = 3, + hard_limit: int = 8, + repeat_soft_limit: int = 3, + repeat_hard_limit: int = 8, + is_subagent: bool = False, + ) -> None: super().__init__() self.tools = [] # registers no extra tools self.soft_limit = max(1, soft_limit) self.hard_limit = max(self.soft_limit + 1, hard_limit) + # <= 0 disables that tier outright. This is the rollback switch: both values + # come from LinsightConf, so a bad rollout is a DB config change (<=100s) + # rather than a redeploy. + self.repeat_soft_limit = max(0, repeat_soft_limit) + self.repeat_hard_limit = 0 if repeat_hard_limit <= 0 else max(self.repeat_soft_limit + 1, repeat_hard_limit) self.is_subagent = is_subagent @property @@ -297,12 +453,86 @@ def _check_hard_limit(self, state) -> None: ) raise LinsightToolLoopError(tool_name=run_tool, count=run_count, partial_result=salvage) + # --- identical-repeat tier ------------------------------------------------ + + def _repeat_nudge(self, request): + """SOFT tier: append a counted corrective instruction to THIS request only. + + Why ``wrap_model_call`` and not ``wrap_tool_call``: deepagents' + ``FilesystemMiddleware`` sits OUTSIDE every bisheng middleware, and its + eviction path REPLACES a large ToolMessage's content wholesale. A hint + appended to the tool result would therefore be discarded in exactly the + case that needs it most. Appending to the request message list is ephemeral + (never enters graph state) and mirrors ``_with_wrap_up_nudge`` in + ``resilience_middleware``, which is already proven in production. It also + keeps the cached system-prompt prefix intact — writing this into the system + message would invalidate the prompt cache on every single turn. + """ + if not self.repeat_soft_limit: + return request + # Scan graph state, NOT ``request.messages``: the outer resilience middleware + # appends a wrap-up HumanMessage during soft landing, and a human turn ends a + # repeat run — reading the request list would silently disable this detector + # exactly while the run is already in trouble. + messages = _state_messages(getattr(request, "state", None)) or list(getattr(request, "messages", []) or []) + fingerprint, count = _identical_turn_run(messages) + if fingerprint is None or count < self.repeat_soft_limit: + return request + tools = sorted({name for name, _ in fingerprint}) + logger.warning( + "[linsight-toolloop] identical tool call repeated {}x ({}) ({}); injecting corrective nudge", + count, + ",".join(tools), + self.name, + ) + hint = _REPEAT_HINT.format(count=count, tools="、".join(tools)) + return request.override(messages=[*request.messages, HumanMessage(content=hint)]) + + async def awrap_model_call(self, request, handler): + return await handler(self._repeat_nudge(request)) + + def wrap_model_call(self, request, handler): + return handler(self._repeat_nudge(request)) + + def _check_repeat_limit(self, state) -> None: + """HARD tier for byte-identical repeats that keep SUCCEEDING. + + ``_check_hard_limit`` cannot see these: it breaks on the first non-error + result, and an evicted tool message keeps its original ``status="success"``. + """ + if not self.repeat_hard_limit: + return + messages = _state_messages(state) + fingerprint, count = _identical_turn_run(messages) + if fingerprint is None: + return # model is talking, or the run involves an exempt tool + names = {name for name, _ in fingerprint} + limit = self.repeat_hard_limit + if names <= _REPEAT_LENIENT_TOOLS: + limit *= _REPEAT_LENIENT_MULTIPLIER + if count < limit: + return + salvage = assemble_partial_result(messages) + logger.warning( + "[linsight-toolloop] identical tool call repeated {}x ({}) ({}); aborting with " + "{} chars of salvaged partial result", + count, + ",".join(sorted(names)), + self.name, + len(salvage), + ) + raise LinsightToolLoopError(tool_name=sorted(names)[0], count=count, partial_result=salvage, reason="repeat") + + def _check_limits(self, state) -> None: + self._check_hard_limit(state) # consecutive same-tool FAILURES + self._check_repeat_limit(state) # consecutive byte-identical calls + async def aafter_model(self, state, runtime): - self._check_hard_limit(state) + self._check_limits(state) return None def after_model(self, state, runtime): - self._check_hard_limit(state) + self._check_limits(state) return None @@ -311,5 +541,7 @@ def build_tool_loop_breaker_middleware(linsight_conf, *, is_subagent: bool) -> L return LinsightToolLoopBreakerMiddleware( soft_limit=getattr(linsight_conf, "tool_failure_soft_limit", 3), hard_limit=getattr(linsight_conf, "tool_failure_hard_limit", 8), + repeat_soft_limit=getattr(linsight_conf, "tool_repeat_soft_limit", 3), + repeat_hard_limit=getattr(linsight_conf, "tool_repeat_hard_limit", 8), is_subagent=is_subagent, ) diff --git a/src/backend/bisheng/linsight/domain/services/workbench_impl.py b/src/backend/bisheng/linsight/domain/services/workbench_impl.py index bf062c8cf3..4729fd96a9 100644 --- a/src/backend/bisheng/linsight/domain/services/workbench_impl.py +++ b/src/backend/bisheng/linsight/domain/services/workbench_impl.py @@ -1490,6 +1490,29 @@ async def task_title_generate(cls, question: str, chat_id: str, login_user: User return {"task_title": title, "chat_id": chat_id, "error_message": None} + except (GeneratorExit, asyncio.CancelledError): + # This runs INSIDE the submit SSE generator, so the client closing that + # stream (or any cancellation) lands here. Both are BaseException, NOT + # Exception, so the branch below never caught them: the fallback never + # ran and nothing was logged, leaving the session silently stuck on + # "New Chat" (114, 2026-08-14 — a session whose title-model call was + # cut off 9s in and never recovered). + # + # The write MUST be synchronous. Awaiting anything in a coroutine that + # is already being cancelled re-raises CancelledError at the first + # suspension point, so an async write would be dropped exactly as + # before. ``update_session_name_sync`` is a single UPDATE and does not + # touch the event loop. + logger.warning("Task title generation cancelled (client likely disconnected); writing fallback title") + try: + MessageSessionDao.update_session_name_sync(chat_id, cls._fallback_title(question)) + except Exception: + logger.exception("Failed to write fallback task title after cancellation") + # Cancellation must keep propagating: swallowing GeneratorExit makes + # Python raise "generator ignored GeneratorExit", and swallowing + # CancelledError breaks task cancellation semantics. + raise + except Exception as e: logger.exception("Failed to generate task title") # Even on hard failure, seed a question-based title so the session diff --git a/src/backend/bisheng/linsight/domain/services/workspace_backend.py b/src/backend/bisheng/linsight/domain/services/workspace_backend.py index 806b863d22..ca691af725 100644 --- a/src/backend/bisheng/linsight/domain/services/workspace_backend.py +++ b/src/backend/bisheng/linsight/domain/services/workspace_backend.py @@ -139,6 +139,18 @@ def _is_missing_key(exc: S3Error) -> bool: # aware hint; on its own the message still reads correctly to the model. BINARY_READ_ERROR_PREFIX = "[binary-file]" +# Character-page fallback for files with (almost) no line breaks — see +# ``_slice_workspace_text``. The size is a compromise: small enough that a default +# ``limit=100`` read stays a sane chunk, large enough that paging a 100KB file does +# not take dozens of calls. +_CHAR_PAGE_SIZE = 2000 +_CHAR_PAGE_MIN_CHARS = 8000 +_CHAR_PAGE_NOTICE = ( + "[This file has no line breaks, so it was paginated by CHARACTER: " + "{total} pages of {size} characters each. Use offset/limit to page through it — " + "offset counts pages, not source lines.]\n" +) + @dataclass class FileEntry: @@ -276,6 +288,45 @@ def _decode_workspace_text(data: bytes, rel_path: str = "") -> str | None: return text +def _slice_workspace_text(text: str, offset: int, limit: int | None) -> str: + """Apply ``offset``/``limit`` to text, falling back to CHARACTER pages when the + file has (almost) no line breaks. + + Line slicing silently breaks down on a file that is one enormous line, and the + single most likely such file is one deepagents itself produced: an offloaded + tool result. ToolNode serializes a dict result with ``json.dumps``, so every + newline becomes a literal ``\\n`` and the whole payload is one line. Reading it + back then had exactly two outcomes, both wrong: + + - ``offset=0`` returned the entire file (deepagents then clipped it to its own + 80000-char ceiling), and + - ``offset>=1`` returned ``""``, which upstream reports to the model as + *"File exists but has empty contents"* — worse than an error, because it + states as fact that there is nothing there. + + So the tail of such a file was unreachable by ANY call, while the offload notice + was busy telling the model to page through it with offset/limit. Paginating by + character makes that advice true. Only files that are both nearly line-free and + genuinely large are affected; ordinary text keeps byte-identical behaviour. + """ + lines = text.splitlines() + end = offset + limit if limit is not None else None + if not (len(lines) <= 2 and len(text) > _CHAR_PAGE_MIN_CHARS): + return "\n".join(lines[offset:end]) + + pages = [text[i : i + _CHAR_PAGE_SIZE] for i in range(0, len(text), _CHAR_PAGE_SIZE)] + selected = pages[offset:end] + if not selected: + return "" # past the end reads empty, exactly as line slicing would + if offset == 0 and len(selected) == len(pages): + # The whole file fits in this one read: hand back the ORIGINAL text. Joining + # the pages would splice in newlines that are not in the file, and a caller + # parsing the result (json.loads on a one-line payload) would get corrupt + # input — while the header would be noise it never asked for. + return text + return _CHAR_PAGE_NOTICE.format(total=len(pages), size=_CHAR_PAGE_SIZE) + "\n".join(selected) + + def _binary_read_result(rel_path: str, data: bytes) -> ReadResult: """Build the ``ReadResult`` for content that is not text. @@ -523,10 +574,7 @@ def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult # Binary: offset/limit are meaningless here (slicing bytes by "lines" # would corrupt the payload), so the whole object is decided on at once. return _binary_read_result(rel, data) - lines = text.splitlines() - start = offset - end = offset + limit if limit is not None else None - sliced = "\n".join(lines[start:end]) + sliced = _slice_workspace_text(text, offset, limit) return ReadResult(file_data=FileData(content=sliced, encoding="utf-8")) # -- ls (authoritative from MinIO) -------------------------------------- @@ -768,9 +816,8 @@ async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> Rea if text is None: # Same contract as the sync path: binary is decided whole, never sliced. return _binary_read_result(rel, data) - lines = text.splitlines() - end = offset + limit if limit is not None else None - return ReadResult(file_data=FileData(content="\n".join(lines[offset:end]), encoding="utf-8")) + # Same slicing contract as the sync path — they must not diverge. + return ReadResult(file_data=FileData(content=_slice_workspace_text(text, offset, limit), encoding="utf-8")) async def als(self, path: str = "") -> LsResult: return await asyncio.to_thread(self.ls, path) diff --git a/src/backend/bisheng/linsight/domain/task_exec.py b/src/backend/bisheng/linsight/domain/task_exec.py index 9b7793dab4..e6d3b9abc5 100644 --- a/src/backend/bisheng/linsight/domain/task_exec.py +++ b/src/backend/bisheng/linsight/domain/task_exec.py @@ -98,6 +98,12 @@ async def ensure_linsight_permission_runtime(manager=None) -> dict: "抱歉,在生成报告文件时遇到问题,模型未能正确调用写入工具。以下是已完成的分析内容:" ) _PARTIAL_RESULT_PREAMBLE_STEP_LIMIT = "抱歉,任务执行步骤数已达上限,未能完全收尾。以下是已完成的内容:" +# Third cause, same lesson as above: a repeat loop is NOT a failed tool call. The +# tool succeeded every time — the model kept re-sending identical arguments and +# ignoring the result. Saying "未能正确调用工具" here would be plainly false. +_PARTIAL_RESULT_PREAMBLE_REPEAT_LOOP = ( + "抱歉,模型在同一个步骤上重复提交了完全相同的调用且没有推进,任务已提前收尾。以下是已完成的分析内容:" +) # Friendly failure copy when the abort left nothing salvageable (no analysis text # and no captured answer) — still a classified friendly card, never a raw dump. @@ -105,6 +111,10 @@ async def ensure_linsight_permission_runtime(manager=None) -> dict: _PARTIAL_NO_SALVAGE_TOOL_LOOP = ( "任务未能完成:模型多次未能正确调用工具,且没有可供返回的中间结果。建议简化任务范围,或更换能力更强的模型后重试。" ) +_PARTIAL_NO_SALVAGE_REPEAT_LOOP = ( + "任务未能完成:模型在同一个步骤上反复提交完全相同的调用,没有取得进展,且没有可供返回的中间结果。" + "建议简化任务范围,或更换能力更强的模型后重试。" +) # Appended to a NORMAL (successful) result when the turn budget made the agent # wrap up ahead of schedule. Not an apology — the deliverables are real; the user # just deserves to know the content was closed out on the materials already @@ -133,6 +143,9 @@ async def ensure_linsight_permission_runtime(manager=None) -> dict: _INGEST_STEP_NAME = "ingest_uploads" # Phases that close the row (status="end"); anything else keeps it spinning. _INGEST_TERMINAL_PHASES = frozenset({"done", "failed", "aborted"}) +# Same contract, for the row saying a selected skill could not be loaded. Mirrored +# in the client's execTypes.ts alongside the ingest row. +_SKILL_LOAD_FAILED_STEP_NAME = "skill_load_failed" def _resolve_recursion_limit(linsight_conf) -> int: @@ -1013,9 +1026,11 @@ async def _create_agent(self, session_model: LinsightSessionVersion, tools: list # /skills/ subtree (governance-enabled ∩ user-selected — the copy IS the # whitelist gate). Re-runs harmlessly on resume/continue since this builds a # fresh agent each time. skills_present gates attaching the skills middleware. - copied_skills = await materialize_session_skills( + skills = await materialize_session_skills( backend, session_model.tenant_id, getattr(session_model, "skills", None) ) + if skills.failed: + await self._push_skill_load_failure(session_model.id, skills.failed) return await create_linsight_agent( session_model=session_model, tools=tools, @@ -1024,10 +1039,40 @@ async def _create_agent(self, session_model: LinsightSessionVersion, tools: list svid=session_model.id, checkpointer=checkpointer, backend=backend, - skills_present=bool(copied_skills), + skills_present=bool(skills.copied), turn_budget_sink=self._turn_budget, ) + async def _push_skill_load_failure(self, svid: str, names: list[str]) -> None: + """Tell the user a skill they picked is not available for this run. + + Silence here is what made the local-disk era so hard to diagnose: the + model just behaved as if the skill had never been selected. The task + still runs — one unavailable skill is not worth discarding the work — but + the gap is now on the timeline instead of only in a worker log. + + Carries only DATA (the skill names); wording lives in the client's locale + files, because this row is persisted and a backend-formatted string would + stay in the wrong language after a language switch. + """ + try: + step = ExecStep( + task_id=svid, + call_id=f"{svid}-skill-load-failed", + call_reason="", + name=_SKILL_LOAD_FAILED_STEP_NAME, + step_type="tool", + status="end", + output="", + extra_info={"skill_load_failed": {"names": names}}, + ) + await self._state_manager.add_execution_task_step(svid, step=step) + await self._state_manager.push_message( + MessageData(event_type=MessageEventType.TASK_EXECUTE_STEP, data=step.model_dump()) + ) + except Exception as e: + logger.warning(f"Failed to push skill-load-failure step for {svid}: {e}") + async def _seed_workspace_from_previous(self, session_model: LinsightSessionVersion) -> None: """Cross-turn continuity: copy the previous turn's deliverables/sources into this turn's workspace (跨轮工作区延续). @@ -1975,17 +2020,30 @@ async def _handle_task_partial(self, session_model: LinsightSessionVersion): ``_handle_direct_answer_completion``. If nothing is salvageable, degrade to a friendly classified failure (never a raw dump). """ - # Copy follows the REAL cause: only the tool-loop breaker means "the model - # kept calling a tool wrong"; a recursion ceiling means the step budget ran - # out, which has nothing to do with the write tools. + # Copy follows the REAL cause, three ways: a failure loop means "the model + # kept calling a tool wrong"; a REPEAT loop means the tool kept succeeding + # and the model kept ignoring the result (blaming the tool there is simply + # false); a recursion ceiling means the step budget ran out, which has + # nothing to do with the write tools. is_tool_loop = isinstance(self._partial_error, LinsightToolLoopError) + is_repeat_loop = is_tool_loop and getattr(self._partial_error, "reason", "failure") == "repeat" body = (self._partial_salvage or "").strip() or (self._last_assistant_text or "").strip() if not body: - no_salvage = _PARTIAL_NO_SALVAGE_TOOL_LOOP if is_tool_loop else _PARTIAL_NO_SALVAGE_STEP_LIMIT + if is_repeat_loop: + no_salvage = _PARTIAL_NO_SALVAGE_REPEAT_LOOP + elif is_tool_loop: + no_salvage = _PARTIAL_NO_SALVAGE_TOOL_LOOP + else: + no_salvage = _PARTIAL_NO_SALVAGE_STEP_LIMIT await self._handle_task_failure(session_model, no_salvage, exc=self._partial_error) return - preamble = _PARTIAL_RESULT_PREAMBLE_TOOL_LOOP if is_tool_loop else _PARTIAL_RESULT_PREAMBLE_STEP_LIMIT + if is_repeat_loop: + preamble = _PARTIAL_RESULT_PREAMBLE_REPEAT_LOOP + elif is_tool_loop: + preamble = _PARTIAL_RESULT_PREAMBLE_TOOL_LOOP + else: + preamble = _PARTIAL_RESULT_PREAMBLE_STEP_LIMIT answer = f"{preamble}\n\n{body}" session_model.status = SessionVersionStatusEnum.COMPLETED # Collect any output/ deliverable the model managed to write before looping; diff --git a/src/backend/bisheng/linsight/domain/utils.py b/src/backend/bisheng/linsight/domain/utils.py index 3720459c8e..914e1b057c 100644 --- a/src/backend/bisheng/linsight/domain/utils.py +++ b/src/backend/bisheng/linsight/domain/utils.py @@ -99,11 +99,25 @@ async def upload_file_to_minio(file_info: dict) -> dict | None: # uploads/ — the user's own source files # skills/ — skill bundles the platform copies in at task start # (skill_provisioning.WORKSPACE_SKILLS_DIR) +# +# The last two are written by deepagents itself, through the same WorkspaceBackend: +# large_tool_results/ — a tool result too big to inline (FilesystemMiddleware) +# conversation_history/ — history evicted by the summarization middleware +# They are context-overflow spill, not something the agent authored. Without them +# here, a run that ends with an empty output/ falls through to the baseline-diff +# branch of ``select_deliverables`` and hands the user a raw tool dump as its +# "deliverable" — and because an offloaded file is named after a tool_call_id it +# usually has NO extension, which sorts it AHEAD of any real .png chart. bisheng +# cannot stop deepagents writing these, so they are excluded here instead. OUTPUT_ZONE = "output" SCRATCH_ZONE = "scratch" UPLOADS_ZONE = "uploads" SKILLS_ZONE = "skills" -NON_DELIVERABLE_ZONES = frozenset({SCRATCH_ZONE, UPLOADS_ZONE, SKILLS_ZONE}) +LARGE_TOOL_RESULTS_ZONE = "large_tool_results" +CONVERSATION_HISTORY_ZONE = "conversation_history" +NON_DELIVERABLE_ZONES = frozenset( + {SCRATCH_ZONE, UPLOADS_ZONE, SKILLS_ZONE, LARGE_TOOL_RESULTS_ZONE, CONVERSATION_HISTORY_ZONE} +) def snapshot_file_paths(file_dir: str) -> set[str]: diff --git a/src/backend/bisheng/main.py b/src/backend/bisheng/main.py index 2bcfe26fbc..db74610384 100644 --- a/src/backend/bisheng/main.py +++ b/src/backend/bisheng/main.py @@ -101,9 +101,22 @@ async def lifespan(app: FastAPI): await backfill_linsight_default_model() except Exception: logger.exception("linsight default-model backfill failed; continuing startup") + # Skill bundles moved from node-local disk to object storage. Publish what + # this host still holds so an upgraded deployment heals itself; anything it + # cannot resolve is logged by name for the operator to run the migration + # script on the host that has it. Runs before seeding: built-in rows are + # left to the seeder, which republishes them from the image. + try: + from bisheng.linsight.domain.services.skill_bundle_backfill import ( + backfill_skill_bundles_from_local_disk, + ) + + await backfill_skill_bundles_from_local_disk() + except Exception: + logger.exception("linsight skill bundle backfill failed; continuing startup") # Ships the kernel's built-in skills into every tenant so a fresh deploy # has them without any operator step. Content-addressed and idempotent: - # an unchanged image costs a few file reads. + # an unchanged image costs one existence probe per skill. try: from bisheng.linsight.domain.services.builtin_skill_seeder import seed_builtin_skills diff --git a/src/backend/bisheng/permission/application/initial_grant.py b/src/backend/bisheng/permission/application/initial_grant.py new file mode 100644 index 0000000000..53d7084f86 --- /dev/null +++ b/src/backend/bisheng/permission/application/initial_grant.py @@ -0,0 +1,116 @@ +"""ADD-only ordinary Grant orchestration after F048 owner creation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 + +from bisheng.permission.application.ports import ( + InitialGrantRuntimePort, + InitialGrantSubjectDirectoryPort, +) +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_service import CanonicalGrantChange, GrantMutationResult +from bisheng.permission.domain.services.permission_action_service import PermissionActor + + +@dataclass(frozen=True, slots=True) +class InitialGrantAddition: + """One client-independent ordinary Grant requested at creation time.""" + + model_key: str + subject_type: str + subject_id: str + userset_relation: str | None = None + include_children: bool = False + + +@dataclass(frozen=True, slots=True) +class InitialGrantRequest: + """Internal command; mutation operations and source metadata are not exposed.""" + + command_key: str + expected_catalog_release_id: int + additions: tuple[InitialGrantAddition, ...] + + +class InitialGrantApplication: + """Canonicalize subjects and delegate all authorization to F048 mutation.""" + + def __init__( + self, + *, + runtime: InitialGrantRuntimePort, + subjects: InitialGrantSubjectDirectoryPort, + ) -> None: + self._runtime = runtime + self._subjects = subjects + + async def apply( + self, + *, + actor: PermissionActor, + target: VerifiedPermissionTarget, + request: InitialGrantRequest, + ) -> GrantMutationResult: + self._validate(target, request) + source_ids = iter(await self._runtime.allocate_source_ids(len(request.additions))) + changes: list[CanonicalGrantChange] = [] + for addition in request.additions: + source = await self._subjects.canonical_source( + tenant_id=target.tenant_id, + source_id=next(source_ids), + subject_type=addition.subject_type, + subject_id=addition.subject_id, + userset_relation=addition.userset_relation, + include_children=addition.include_children, + ) + if source.protected or source.source_type not in { + "DIRECT", + "DEPARTMENT", + "USER_GROUP", + }: + raise ValueError("initial Grants require a canonical ordinary source") + changes.append( + CanonicalGrantChange( + operation="ADD", + model_key=addition.model_key, + source=source, + ) + ) + return await self._runtime.mutate_grants( + actor=actor, + target=target, + changes=tuple(changes), + expected_resource_version=target.resource_version, + expected_catalog_release_id=request.expected_catalog_release_id, + idempotency_key=self._idempotency_key(target, request.command_key), + ) + + @staticmethod + def _validate(target: object, request: InitialGrantRequest) -> None: + if not isinstance(target, VerifiedPermissionTarget): + raise TypeError("Initial Grants require VerifiedPermissionTarget") + if not request.command_key.strip(): + raise ValueError("Initial Grant command_key must not be empty") + if request.expected_catalog_release_id <= 0: + raise ValueError("Initial Grant Catalog release must be positive") + if not 1 <= len(request.additions) <= 50: + raise ValueError("Initial Grants require between 1 and 50 additions") + if not all(isinstance(addition, InitialGrantAddition) for addition in request.additions): + raise TypeError("Initial Grants accept ADD-only InitialGrantAddition values") + if any(not addition.model_key.strip() for addition in request.additions): + raise ValueError("Initial Grant model_key must not be empty") + + @staticmethod + def _idempotency_key(target: VerifiedPermissionTarget, command_key: str) -> str: + canonical = "|".join( + ( + str(target.tenant_id), + target.resource_type, + target.resource_id, + command_key.strip(), + ) + ) + digest = sha256(canonical.encode()).hexdigest()[:43] + return f"f050:initial-grants:{digest}" diff --git a/src/backend/bisheng/permission/application/ports.py b/src/backend/bisheng/permission/application/ports.py new file mode 100644 index 0000000000..bd77a7fb87 --- /dev/null +++ b/src/backend/bisheng/permission/application/ports.py @@ -0,0 +1,118 @@ +"""Application ports shared by F050 permission-setting workflows.""" + +from __future__ import annotations + +from typing import Protocol + +from bisheng.permission.application.control_state import ( + RuntimeCatalogSnapshot, + RuntimeModelSnapshot, +) +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_service import ( + CanonicalGrantChange, + GrantMutationResult, +) +from bisheng.permission.domain.services.grant_source_service import GrantSourceRecord +from bisheng.permission.domain.services.permission_action_service import PermissionActor + + +class ProspectiveGrantRuntimePort(Protocol): + """Read the current Catalog through the initialized F048 runtime.""" + + async def prospective_owner_grantable_models( + self, + ) -> tuple[RuntimeCatalogSnapshot, tuple[RuntimeModelSnapshot, ...]]: ... + + +class ProspectiveGrantSubjectDirectoryPort(Protocol): + """List active candidates inside a business-verified tenant scope.""" + + async def list_users( + self, + *, + tenant_id: int, + resource_type: str, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: ... + + async def list_user_groups( + self, + *, + tenant_id: int, + resource_type: str, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: ... + + async def list_department_children( + self, + *, + tenant_id: int, + resource_type: str, + parent_id: int | None, + ) -> list[dict[str, object]]: ... + + async def search_departments( + self, + *, + tenant_id: int, + resource_type: str, + keyword: str, + limit: int, + ) -> dict[str, object]: ... + + async def get_department_path( + self, + *, + tenant_id: int, + resource_type: str, + department_id: int, + ) -> dict[str, object]: ... + + +class ProspectiveGrantApplicationPort(Protocol): + """Permission operations available before a business resource exists.""" + + async def get_context(self, **kwargs: object) -> dict[str, object]: ... + + +class InitialGrantRuntimePort(Protocol): + """Durable F048 operations used after owner creation.""" + + async def allocate_source_ids(self, count: int) -> tuple[int, ...]: ... + + async def mutate_grants( + self, + *, + actor: PermissionActor, + target: VerifiedPermissionTarget, + changes: tuple[CanonicalGrantChange, ...], + expected_resource_version: int, + expected_catalog_release_id: int, + idempotency_key: str, + ) -> GrantMutationResult: ... + + +class InitialGrantSubjectDirectoryPort(Protocol): + """Canonicalize active subjects in the verified target tenant.""" + + async def canonical_source( + self, + *, + tenant_id: int, + source_id: int, + subject_type: str, + subject_id: str, + userset_relation: str | None, + include_children: bool, + ) -> GrantSourceRecord: ... + + +class InitialGrantApplicationPort(Protocol): + """Apply ADD-only ordinary Grants to a newly authorized resource.""" + + async def apply(self, **kwargs: object) -> GrantMutationResult: ... diff --git a/src/backend/bisheng/permission/application/prospective_grant.py b/src/backend/bisheng/permission/application/prospective_grant.py new file mode 100644 index 0000000000..ced8387a0b --- /dev/null +++ b/src/backend/bisheng/permission/application/prospective_grant.py @@ -0,0 +1,143 @@ +"""F050 permission configuration before a business resource exists.""" + +from __future__ import annotations + +from bisheng.common.errcode.permission import PermissionDeniedError +from bisheng.permission.application.ports import ( + ProspectiveGrantRuntimePort, + ProspectiveGrantSubjectDirectoryPort, +) +from bisheng.permission.domain.services.permission_action_service import ( + PermissionActor, +) + + +class ProspectiveGrantApplication: + """Read owner grant policy and tenant candidates without inventing a target.""" + + def __init__( + self, + *, + runtime: ProspectiveGrantRuntimePort, + subjects: ProspectiveGrantSubjectDirectoryPort, + ) -> None: + self._runtime = runtime + self._subjects = subjects + + async def get_context( + self, + *, + actor: PermissionActor, + tenant_id: int, + resource_type: str, + ) -> dict[str, object]: + self._require_tenant_scope(actor, tenant_id) + del resource_type + catalog, models = await self._runtime.prospective_owner_grantable_models() + return { + "catalog_release_id": catalog.release_id, + "can_configure_initial_permissions": bool(models), + "grantable_models": [ + { + "key": model.snapshot.model_key, + "name": model.name, + "level": model.snapshot.derived_level, + "active": model.snapshot.active, + } + for model in models + ], + } + + async def list_users( + self, + *, + actor: PermissionActor, + tenant_id: int, + resource_type: str, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + self._require_tenant_scope(actor, tenant_id) + return await self._subjects.list_users( + tenant_id=tenant_id, + resource_type=resource_type, + keyword=keyword, + page=page, + page_size=page_size, + ) + + async def list_user_groups( + self, + *, + actor: PermissionActor, + tenant_id: int, + resource_type: str, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + self._require_tenant_scope(actor, tenant_id) + return await self._subjects.list_user_groups( + tenant_id=tenant_id, + resource_type=resource_type, + keyword=keyword, + page=page, + page_size=page_size, + ) + + async def list_department_children( + self, + *, + actor: PermissionActor, + tenant_id: int, + resource_type: str, + parent_id: int | None, + ) -> list[dict[str, object]]: + self._require_tenant_scope(actor, tenant_id) + return await self._subjects.list_department_children( + tenant_id=tenant_id, + resource_type=resource_type, + parent_id=parent_id, + ) + + async def search_departments( + self, + *, + actor: PermissionActor, + tenant_id: int, + resource_type: str, + keyword: str, + limit: int, + ) -> dict[str, object]: + self._require_tenant_scope(actor, tenant_id) + return await self._subjects.search_departments( + tenant_id=tenant_id, + resource_type=resource_type, + keyword=keyword, + limit=limit, + ) + + async def get_department_path( + self, + *, + actor: PermissionActor, + tenant_id: int, + resource_type: str, + department_id: int, + ) -> dict[str, object]: + self._require_tenant_scope(actor, tenant_id) + return await self._subjects.get_department_path( + tenant_id=tenant_id, + resource_type=resource_type, + department_id=department_id, + ) + + @staticmethod + def _require_tenant_scope(actor: PermissionActor, tenant_id: int) -> None: + if ( + tenant_id != actor.current_tenant_id + and not actor.super_admin + and tenant_id not in actor.tenant_admin_tenant_ids + ): + raise PermissionDeniedError() diff --git a/src/backend/bisheng/permission/application/runtime.py b/src/backend/bisheng/permission/application/runtime.py index b9f7454f5a..969109d570 100644 --- a/src/backend/bisheng/permission/application/runtime.py +++ b/src/backend/bisheng/permission/application/runtime.py @@ -18,6 +18,7 @@ from bisheng.core.openfga.client import FGAClient from bisheng.permission.application.control_state import ( RuntimeCatalogSnapshot, + RuntimeModelSnapshot, SqlGrantMutationState, SqlModeState, SqlOwnerProjectionState, @@ -154,6 +155,20 @@ async def batch_check_actions( async def current_catalog(self) -> RuntimeCatalogSnapshot: return await self._runtime_catalog() + async def prospective_owner_grantable_models( + self, + ) -> tuple[RuntimeCatalogSnapshot, tuple[RuntimeModelSnapshot, ...]]: + """Read the owner Grant policy without constructing a resource target.""" + + catalog = await self._runtime_catalog() + owner_model = require_owner_model(catalog) + grantable = self._grants.grantable_models_for_capabilities( + models=tuple(item.snapshot for item in catalog.models), + capabilities=(GrantCapability(model=owner_model, source_key="prospective-owner"),), + ) + keys = {model.model_key for model in grantable} + return catalog, tuple(item for item in catalog.models if item.snapshot.model_key in keys) + async def allocate_source_ids(self, count: int) -> tuple[int, ...]: return await self._state.allocate_source_ids(count) diff --git a/src/backend/bisheng/permission/domain/services/grant_service.py b/src/backend/bisheng/permission/domain/services/grant_service.py index 9e7c2932f4..a9e7772f47 100644 --- a/src/backend/bisheng/permission/domain/services/grant_service.py +++ b/src/backend/bisheng/permission/domain/services/grant_service.py @@ -153,10 +153,25 @@ def grantable_models( ) -> tuple[GrantModelSnapshot, ...]: """Return target models authorized by at least one complete source.""" + return self.grantable_models_for_capabilities( + models=context.models, + capabilities=context.capabilities, + system_authorized=context.system_authorized, + ) + + def grantable_models_for_capabilities( + self, + *, + models: tuple[GrantModelSnapshot, ...], + capabilities: tuple[GrantCapability, ...], + system_authorized: bool = False, + ) -> tuple[GrantModelSnapshot, ...]: + """Apply the Grant level policy without requiring a resource target.""" + available = tuple( - model for model in context.models if model.active and model.derived_level is not None and model.action_codes + model for model in models if model.active and model.derived_level is not None and model.action_codes ) - if context.system_authorized: + if system_authorized: return tuple( sorted( available, @@ -171,7 +186,7 @@ def grantable_models( ( model for model in available - if any(self._capability_allows(capability, model) for capability in context.capabilities) + if any(self._capability_allows(capability, model) for capability in capabilities) ), key=lambda model: ( int(model.derived_level), diff --git a/src/backend/bisheng/permission/domain/services/grant_subject_service.py b/src/backend/bisheng/permission/domain/services/grant_subject_service.py index 9c5526f4e4..2ba635d953 100644 --- a/src/backend/bisheng/permission/domain/services/grant_subject_service.py +++ b/src/backend/bisheng/permission/domain/services/grant_subject_service.py @@ -16,7 +16,7 @@ from dataclasses import dataclass -from sqlmodel import col, select +from sqlmodel import col, func, select from bisheng.core.context.tenant import bypass_tenant_filter from bisheng.core.database import get_async_db_session @@ -121,6 +121,36 @@ async def list_candidate_users( ] +async def count_candidate_users(scope: GrantSubjectScope, *, keyword: str) -> int: + with bypass_tenant_filter(): + async with get_async_db_session() as session: + in_tenant = ( + select(UserTenant.id) + .where( + UserTenant.user_id == User.user_id, + UserTenant.tenant_id == scope.tenant_id, + UserTenant.status == "active", + ) + .exists() + ) + statement = select(func.count(User.user_id)).where(User.delete == 0, in_tenant) + if scope.department_path is not None: + in_subtree = ( + select(UserDepartment.id) + .join(Department, Department.id == UserDepartment.department_id) + .where( + UserDepartment.user_id == User.user_id, + col(Department.path).like(f"{scope.department_path}%"), + Department.status == "active", + ) + .exists() + ) + statement = statement.where(in_subtree) + if keyword: + statement = statement.where(col(User.user_name).like(f"{keyword}%")) + return int((await session.exec(statement)).one()) + + async def _primary_department_paths(user_ids: list[int]) -> dict[int, str]: """Each user's primary department as a readable name chain, in one round trip.""" @@ -172,6 +202,15 @@ async def list_candidate_user_groups( return [{"id": int(row.id), "name": row.group_name} for row in rows] +async def count_candidate_user_groups(scope: GrantSubjectScope, *, keyword: str) -> int: + with bypass_tenant_filter(): + async with get_async_db_session() as session: + statement = select(func.count(Group.id)).where(Group.tenant_id == scope.tenant_id) + if keyword: + statement = statement.where(col(Group.group_name).like(f"{keyword}%")) + return int((await session.exec(statement)).one()) + + async def list_candidate_department_layer( scope: GrantSubjectScope, *, diff --git a/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py b/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py index 150feb597e..d1ece79811 100644 --- a/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py +++ b/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py @@ -10,10 +10,12 @@ from bisheng.database.models.group import GroupDao from bisheng.database.models.tenant import UserTenantDao from bisheng.database.models.user_group import UserGroupDao +from bisheng.permission.domain.services import grant_subject_service from bisheng.permission.domain.services.grant_source_service import ( GrantSourceRecord, GrantSourceService, ) +from bisheng.permission.domain.services.grant_subject_service import GrantSubjectScope from bisheng.permission.domain.services.permission_action_service import ( PermissionActor, ) @@ -26,6 +28,88 @@ class TenantPermissionSubjectDirectory: def __init__(self) -> None: self._sources = GrantSourceService() + @staticmethod + def _prospective_scope(tenant_id: int, resource_type: str) -> GrantSubjectScope: + if resource_type not in {"knowledge_space", "channel"}: + raise PermissionInvalidResourceError() + return GrantSubjectScope(tenant_id=tenant_id, department_path=None) + + async def list_users( + self, + *, + tenant_id: int, + resource_type: str, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + scope = self._prospective_scope(tenant_id, resource_type) + rows = await grant_subject_service.list_candidate_users( + scope, + keyword=keyword, + page=page, + page_size=page_size, + ) + total = await grant_subject_service.count_candidate_users(scope, keyword=keyword) + return {"data": rows, "total": total} + + async def list_user_groups( + self, + *, + tenant_id: int, + resource_type: str, + keyword: str, + page: int, + page_size: int, + ) -> dict[str, object]: + scope = self._prospective_scope(tenant_id, resource_type) + rows = await grant_subject_service.list_candidate_user_groups( + scope, + keyword=keyword, + page=page, + page_size=page_size, + ) + total = await grant_subject_service.count_candidate_user_groups(scope, keyword=keyword) + return {"data": rows, "total": total} + + async def list_department_children( + self, + *, + tenant_id: int, + resource_type: str, + parent_id: int | None, + ) -> list[dict[str, object]]: + return await grant_subject_service.list_candidate_department_layer( + self._prospective_scope(tenant_id, resource_type), + parent_id=parent_id, + ) + + async def search_departments( + self, + *, + tenant_id: int, + resource_type: str, + keyword: str, + limit: int, + ) -> dict[str, object]: + return await grant_subject_service.search_candidate_departments( + self._prospective_scope(tenant_id, resource_type), + keyword=keyword, + limit=limit, + ) + + async def get_department_path( + self, + *, + tenant_id: int, + resource_type: str, + department_id: int, + ) -> dict[str, object]: + return await grant_subject_service.get_candidate_department_path( + self._prospective_scope(tenant_id, resource_type), + dept_id=department_id, + ) + async def actor_projected_subjects( self, actor: PermissionActor, diff --git a/src/backend/bisheng/worker/workflow/redis_callback.py b/src/backend/bisheng/worker/workflow/redis_callback.py index 2c9de6f49c..95661b9b58 100644 --- a/src/backend/bisheng/worker/workflow/redis_callback.py +++ b/src/backend/bisheng/worker/workflow/redis_callback.py @@ -597,7 +597,7 @@ def save_chat_message( ) ) thread_pool.submit( - f"workflow_generate_title_{self.chat_id}", self.generate_session_title, message.message + f"workflow_generate_title_{self.chat_id}", self.generate_session_title ) # RecordTelemetryJournal @@ -643,7 +643,7 @@ def _extract_message_text(message: str | dict | list | None) -> str: return "" return json.dumps(message, ensure_ascii=False) - def generate_session_title(self, answer: str): + def generate_session_title(self): if not self.new_session: return if self.new_session.name: @@ -665,7 +665,7 @@ def generate_session_title(self, answer: str): app_type=ApplicationTypeEnum.DAILY_CHAT, user_id=self.user_id, ) - title = generate_conversation_title_sync(question=question, llm=llm, answer=answer) + title = generate_conversation_title_sync(question=question, llm=llm) MessageSessionDao.update_session_name_sync(self.new_session.chat_id, title) self.new_session.name = title diff --git a/src/backend/bisheng/workstation/domain/services/chat_helpers.py b/src/backend/bisheng/workstation/domain/services/chat_helpers.py index 36b74a1ea2..fcbe21cb95 100644 --- a/src/backend/bisheng/workstation/domain/services/chat_helpers.py +++ b/src/backend/bisheng/workstation/domain/services/chat_helpers.py @@ -109,13 +109,17 @@ async def final_message( async def gen_title( human: str, - assistant: str, llm: BaseChatModel, conversation_id: str, login_user: UserPayload, request: Request, ): - title = await generate_conversation_title_async(question=human, answer=assistant, llm=llm) + """Name a conversation from the user's question alone. + + The assistant's reply is deliberately NOT used: waiting for it is what tied + the title to the end of a round, and the question already states the topic. + """ + title = await generate_conversation_title_async(question=human, llm=llm) session = await MessageSessionDao.async_get_one(conversation_id) if session: await MessageSessionDao.update_session_name(chat_id=session.chat_id, name=title) diff --git a/src/backend/bisheng/workstation/domain/services/chat_service.py b/src/backend/bisheng/workstation/domain/services/chat_service.py index 222fe293b5..0981d1ca0a 100644 --- a/src/backend/bisheng/workstation/domain/services/chat_service.py +++ b/src/backend/bisheng/workstation/domain/services/chat_service.py @@ -2095,7 +2095,7 @@ def _serialize_message(m): # task has persisted a real name (slow models take >5s). if is_new_conv or (conversation.name in (None, "", "New Chat")): asyncio.create_task( - gen_title(data.text or "", final_msg, bisheng_llm, conversation_id, login_user, request) + gen_title(data.text or "", bisheng_llm, conversation_id, login_user, request) ) await log_telemetry_events(str(login_user.user_id), conversation_id, start_time) @@ -2242,7 +2242,6 @@ async def _handoff_event(): await asyncio.wait_for( gen_title( submit_obj.question or "", - "", title_llm, session_version.session_id, login_user, diff --git a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py index a2554b618a..7633d63e5d 100644 --- a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py +++ b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py @@ -142,6 +142,46 @@ def path_namespace_rules(include_skills: bool = True) -> str: "files to `scratch/` directly.\n" ) +# A SUCCESSFUL run's log is unbounded today, and nothing downstream bounds it either. +# Once the serialized result crosses ~80000 chars, the deepagents FilesystemMiddleware +# offloads it to /large_tool_results/ and replaces the content with a +# preview. That preview is computed PER LINE — but ToolNode serializes the dict result +# with json.dumps, which turns every newline into a literal \n, so the whole result is +# ONE line and the preview degrades to line[:1000] with no truncation marker at all. +# The model then sees `{"exitcode": 0, "log": "…` and nothing else: no file_list, no +# ending, and no sign that the rest exists elsewhere. Measured on 114, 2026-08-14: a +# 112744-byte result put a kimi-k3 run into a 79-turn / 78-minute / 13.8M-token loop +# re-sending the exact same script, because re-running was the only way it could think +# of to get the data back. +# +# Capping here keeps the output INSIDE the context (head + tail, ~30x more than the +# 1000 chars the offload path actually delivers) instead of behind a pointer the model +# does not follow. 30000 sits far above the measured p90 (~8000; only 11.7% of calls +# exceed 8000) so ordinary runs are untouched, and far below the eviction line even +# after file_list URLs are added. +MAX_SUCCESS_LOG_CHARS = 30000 +LOG_MIDDLE_TRUNCATED_NOTICE = ( + "\n[... {omitted} characters of output omitted from the middle. Re-running this " + "code will NOT return more — the output itself is too long. Print less (summarize, " + "or print only what you need), or write the bulk to a file under `scratch/` and read " + "it back in chunks with read_file ...]\n" +) + + +def clip_middle(logs: str, limit: int = MAX_SUCCESS_LOG_CHARS) -> str: + """Cap ``logs`` keeping BOTH ends, dropping the middle. + + Deliberately not ``LocalExecutor._tail``: that one keeps only the tail, which is + right for a FAILING run (a traceback states its cause on the last lines) and wrong + for a succeeding one — the head holds what the script actually printed (its + conclusions), and the tail holds the advisories the model must act on next turn. + """ + if len(logs) <= limit: + return logs + notice = LOG_MIDDLE_TRUNCATED_NOTICE.format(omitted=len(logs) - limit) + head_len = limit // 2 + return logs[:head_len] + notice + logs[-(limit - head_len) :] + class BaseExecutor(ABC): def __init__(self, minio: dict, **kwargs): diff --git a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/e2b_executor.py b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/e2b_executor.py index b97e7ced3f..56d0078cdd 100644 --- a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/e2b_executor.py +++ b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/e2b_executor.py @@ -8,7 +8,12 @@ from e2b_code_interpreter import Result, Sandbox from loguru import logger -from bisheng_langchain.gpts.tools.code_interpreter.base_executor import BaseExecutor, path_namespace_rules +from bisheng_langchain.gpts.tools.code_interpreter.base_executor import ( + MAX_SUCCESS_LOG_CHARS, + BaseExecutor, + clip_middle, + path_namespace_rules, +) # F035 TC-4: copy-in/copy-out thresholds. The sandbox cannot reach MinIO, so the # worker mediates all file transfer (design §9.3.9). @@ -23,6 +28,23 @@ _SANDBOX_ROOT = "/home/user/" +def _clip_log_lines(lines): + """Cap an E2B ``logs.stdout`` / ``logs.stderr`` list, preserving its type. + + E2B hands these back as ``List[str]`` and, unlike LocalExecutor's failure path, + nothing here bounds them at all. An unbounded log crosses the deepagents eviction + threshold and lands the run in the offload path whose preview is effectively + unreadable — see ``base_executor.MAX_SUCCESS_LOG_CHARS`` for the full story. + Over the limit the list collapses to a single clipped entry; the list type is kept + so callers that iterate keep working. + """ + if not isinstance(lines, list): + return clip_middle(lines) if isinstance(lines, str) else lines + if sum(len(str(x)) for x in lines) <= MAX_SUCCESS_LOG_CHARS: + return lines + return [clip_middle("".join(str(x) for x in lines))] + + class E2bCodeExecutor(BaseExecutor): def __init__( self, @@ -123,8 +145,8 @@ def run(self, code: str, required_files: list[str] = None): result = { "results": results, - "stdout": execution.logs.stdout, - "stderr": execution.logs.stderr, + "stdout": _clip_log_lines(execution.logs.stdout), + "stderr": _clip_log_lines(execution.logs.stderr), "error": execution.error, "file_list": file_list, "new_files": new_files, @@ -229,8 +251,8 @@ def run_code_with_one_sandbox(self, code: str) -> dict: results, file_list = self.parse_results(execution.results) return { "results": results, - "stdout": execution.logs.stdout, - "stderr": execution.logs.stderr, + "stdout": _clip_log_lines(execution.logs.stdout), + "stderr": _clip_log_lines(execution.logs.stderr), "error": execution.error, "file_list": file_list, } diff --git a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py index 0903e377c3..075199c1c4 100644 --- a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py +++ b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py @@ -18,6 +18,7 @@ from bisheng_langchain.gpts.tools.code_interpreter.base_executor import ( OUTPUT_DIR_NAME, BaseExecutor, + clip_middle, path_namespace_rules, ) @@ -440,6 +441,11 @@ def run(self, code: str) -> Any: return {"exitcode": exit_code, "log": self._tail(logs_all) + self.absolute_path_advisory(original_code)} all_file_list += file_list + # Clip BEFORE appending the advisory, same as the failure path above: the + # advisory is the one instruction the model must act on next turn, so the + # truncation must not be able to eat it. ``file_list`` is never clipped — + # not seeing it is exactly what makes the model conclude nothing was written. + logs_all = clip_middle(logs_all) # Deterministic safety net: if the script wrote a deliverable to an absolute # /output//scratch path it escaped the harvested working dir and silently # vanished (see base_executor). Append a corrective notice so the model diff --git a/src/backend/scripts/README.md b/src/backend/scripts/README.md index b8546bf5ce..6478d4ade1 100644 --- a/src/backend/scripts/README.md +++ b/src/backend/scripts/README.md @@ -365,7 +365,7 @@ F048 deployment. > the F035 upgrade checklist — see `docs/architecture/08-deployment.md` → 升级 checklist. One-shot migration of legacy `linsight_sop` rows into tenant custom skills -(`linsight_skill` + `SKILLS_ROOT/data/skills/{tenant_id}//SKILL.md`). +(a `linsight_skill` row + its bundle object in storage). `display_name` keeps the original (Chinese) SOP name; the skill ID is a pypinyin slug; `metadata.sop-id` makes re-runs idempotent. The skill description uses the SOP's own description, falling back to the SOP name when absent (no LLM @@ -383,6 +383,50 @@ bash scripts/migrate_sop_to_skill.sh --tenant-id 2 apply # single tenant Options: `--apply` (persist), `--tenant-id `, `--report-file ` (default `./migrate_sop_to_skill_report.json`). +### `migrate_skills_to_object_storage.py` + +> **Upgrade-required (→ v3.0) — run on EVERY host that has served the API.** +> Startup performs a deliberately narrow version of this automatically; the script +> is what resolves everything the self-heal refuses to guess at. See +> `docs/architecture/08-deployment.md` → 升级 checklist. + +Skill bundles used to be authoritative on the node's local disk, so an upgraded +deployment has rows whose `content_hash` is empty and whose bytes exist only on +whichever host wrote them — those skills silently fail to load. This publishes +them to object storage and repoints the row. + +The startup self-heal only publishes a bundle whose local byte count matches what +the row recorded, and skips `source='builtin'` rows (the seeder republishes those +from the image). Anything else — a size mismatch, or a bundle held by a different +host — is logged **by name** and left for this script. + +Idempotent: a row that already resolves is skipped; publishing the same bundle +twice writes the same content-addressed object. Dry-run by default. + +```bash +PYTHONPATH=./ .venv/bin/python scripts/migrate_skills_to_object_storage.py # dry-run +PYTHONPATH=./ .venv/bin/python scripts/migrate_skills_to_object_storage.py --apply +``` + +Options: `--apply`, `--tenant-id `, `--legacy-root ` (defaults to +`linsight_conf.skills_root`). Prints a JSON report; a non-empty `unresolved` list +means those bundles live on another host — run it there too. + +### `restore_skills_to_local.py` + +> **Rollback aid only.** Needed before rolling BACK to a release that reads skill +> bundles from `SKILLS_ROOT` instead of object storage. + +Writes every skill bundle back to the legacy on-disk layout. Skills created or +edited while the newer release was running exist only as objects; the older code +looks on local disk, finds nothing, and — because that path only warns — the skill +silently stops working. Run on every host that will serve the older release. + +```bash +PYTHONPATH=./ .venv/bin/python scripts/restore_skills_to_local.py # dry-run +PYTHONPATH=./ .venv/bin/python scripts/restore_skills_to_local.py --apply +``` + ### `backfill_linsight_task_mode_web_menu.py` > **F035 step 3 of 4 — AUTO at startup.** Runs automatically on service startup diff --git a/src/backend/scripts/migrate_skills_to_object_storage.py b/src/backend/scripts/migrate_skills_to_object_storage.py new file mode 100644 index 0000000000..14e6e6dad1 --- /dev/null +++ b/src/backend/scripts/migrate_skills_to_object_storage.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Publish skill bundles left on a node's local disk into object storage. + +Before this change ``SKILLS_ROOT`` was the authoritative store, so an existing +deployment's bundles live on whichever host wrote them. Rows created since the +upgrade already carry a ``content_hash``; rows with an empty one still point at +nothing and their skill will not load. This script walks those rows, finds the +bundle under the legacy on-disk layout and publishes it. + +Run it **on each host that ever served the API**. A host whose disk is empty +reports the rows it could not resolve — those bundles are on a different host, so +run it there too. (The API's startup self-heal covers the same ground but only +for what its own disk holds; see ``bisheng/main.py``.) + +Idempotent: a row that already resolves is skipped, and publishing the same +bundle twice writes the same content-addressed object. + +How to run (from src/backend/) +------------------------------ + cd src/backend/ + export config=config.yaml + PYTHONPATH=./ .venv/bin/python scripts/migrate_skills_to_object_storage.py # dry-run + PYTHONPATH=./ .venv/bin/python scripts/migrate_skills_to_object_storage.py --apply + PYTHONPATH=./ .venv/bin/python scripts/migrate_skills_to_object_storage.py --tenant-id 3 --apply +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path + +_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _BACKEND_ROOT not in sys.path: + sys.path.insert(0, _BACKEND_ROOT) + +from loguru import logger # noqa: E402 + +from bisheng.common.services.config_service import settings # noqa: E402 +from bisheng.core.context.manager import close_app_context, initialize_app_context # noqa: E402 +from bisheng.core.context.tenant import ( # noqa: E402 + bypass_tenant_filter, + current_tenant_id, + set_current_tenant_id, +) +from bisheng.linsight.domain.models.linsight_skill import LinsightSkillDao # noqa: E402 + +# Same reader the startup self-heal uses — two copies of "what a legacy bundle +# looks like on disk" would drift. +from bisheng.linsight.domain.services.skill_bundle_backfill import read_legacy_bundle # noqa: E402 +from bisheng.linsight.domain.services.skill_store import SkillStore # noqa: E402 + + +async def migrate(tenant_id: int | None, apply: bool, legacy_root: Path, store: SkillStore) -> dict: + report: dict[str, list] = {"published": [], "already_ok": [], "unresolved": []} + + with bypass_tenant_filter(): + rows, _ = await LinsightSkillDao.get_page(page=1, page_size=100000) + + for row in rows: + if tenant_id is not None and row.tenant_id != tenant_id: + continue + entry = {"tenant_id": row.tenant_id, "name": row.name, "display_name": row.display_name} + + if row.content_hash and store.exists(row.tenant_id, row.name, row.content_hash): + report["already_ok"].append(entry) + continue + + files = read_legacy_bundle(legacy_root, row.tenant_id, row.name) + if files is None: + # Not an error here: the bundle almost certainly sits on another host. + report["unresolved"].append(entry) + continue + + if apply: + # The DAO writes under the tenant ContextVar, so enter each row's + # tenant and restore the caller's on the way out. + token = set_current_tenant_id(row.tenant_id) + try: + ref = store.write_bundle(row.tenant_id, row.name, files) + row.object_path, row.content_hash, row.size = ref.object_key, ref.content_hash, ref.size + await LinsightSkillDao.update(row) + finally: + current_tenant_id.reset(token) + report["published"].append(entry) + + return report + + +async def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--apply", action="store_true", help="persist changes (default: dry-run)") + parser.add_argument("--tenant-id", type=int, default=None, help="limit to one tenant") + parser.add_argument( + "--legacy-root", + default=None, + help="legacy SKILLS_ROOT to read from (default: linsight_conf.skills_root)", + ) + args = parser.parse_args() + + await initialize_app_context(settings, instance_role="script") + try: + legacy_root = Path(args.legacy_root or settings.get_linsight_conf().skills_root).resolve() + store = SkillStore() + report = await migrate(args.tenant_id, args.apply, legacy_root, store) + + mode = "apply" if args.apply else "dry-run" + print(json.dumps({"mode": mode, "legacy_root": str(legacy_root), **report}, ensure_ascii=False, indent=2)) + if report["unresolved"]: + # The one outcome an operator must act on: these skills are broken + # until this script runs on the host that holds their bundle. + logger.warning( + "{} skill(s) have no bundle on this host — run this script on the other API hosts: {}", + len(report["unresolved"]), + [f"{e['tenant_id']}/{e['name']}" for e in report["unresolved"]], + ) + return 0 + finally: + await close_app_context() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/backend/scripts/migrate_sop_to_skill.py b/src/backend/scripts/migrate_sop_to_skill.py index 4fa6e676a0..eaab6833c1 100644 --- a/src/backend/scripts/migrate_sop_to_skill.py +++ b/src/backend/scripts/migrate_sop_to_skill.py @@ -121,7 +121,7 @@ async def _load_tenant_state(store: SkillStore, tenant_id: int) -> tuple[dict[st if row.source != SKILL_SOURCE_SOP_MIGRATED: continue try: - meta, _body = parse_skill_md(store.read_text(tenant_id, row.name)) + meta, _body = parse_skill_md(store.read_text(tenant_id, row.name, row.content_hash)) sop_id = str((meta.get("metadata") or {}).get(SOP_ID_META_KEY) or "") if sop_id: sop_map[sop_id] = row @@ -195,10 +195,10 @@ async def _migrate_tenant( ) if apply: - size = store.write_bundle(tenant_id, name, {SKILL_MD: skill_md.encode("utf-8")}) + ref = store.write_bundle(tenant_id, name, {SKILL_MD: skill_md.encode("utf-8")}) if existing: - existing.description, existing.size = description, size - existing.object_path = store.object_path(tenant_id, name) + existing.description, existing.size = description, ref.size + existing.object_path, existing.content_hash = ref.object_key, ref.content_hash await LinsightSkillDao.update(existing) else: await LinsightSkillDao.create( @@ -209,8 +209,9 @@ async def _migrate_tenant( description=description, enabled=True, source=SKILL_SOURCE_SOP_MIGRATED, - object_path=store.object_path(tenant_id, name), - size=size, + object_path=ref.object_key, + content_hash=ref.content_hash, + size=ref.size, created_by=sop.user_id, ) ) diff --git a/src/backend/scripts/reconcile_f048_visible_projection.py b/src/backend/scripts/reconcile_f048_visible_projection.py index 905072c6e4..702db77506 100644 --- a/src/backend/scripts/reconcile_f048_visible_projection.py +++ b/src/backend/scripts/reconcile_f048_visible_projection.py @@ -212,7 +212,7 @@ async def _load_current_release() -> CurrentRelease: async with get_async_db_session() as session: rows = list( ( - await session.execute( + await session.exec( select(PermissionCatalogRelease, AuthorizationModelRelease) .join( AuthorizationModelRelease, @@ -243,12 +243,12 @@ async def _assert_maintenance_window(*, apply: bool) -> None: async with get_async_db_session() as session: active_operations = int( ( - await session.execute( + await session.exec( select(func.count(PermissionProjectionOperation.id)).where( PermissionProjectionOperation.status.in_(ACTIVE_OPERATION_STATUSES) ) ) - ).scalar_one() + ).one() ) _require(active_operations == 0, f"{active_operations} permission projection operations are active") if apply: @@ -261,20 +261,18 @@ async def _load_canonical_grants() -> tuple[tuple[GrantSnapshot, ...], int]: async with get_async_db_session() as session: grant_rows = list( ( - await session.execute( + await session.exec( select(PermissionGrant) .where(PermissionGrant.state == "ACTIVE") .order_by(PermissionGrant.tenant_id, PermissionGrant.id) ) - ) - .scalars() - .all() + ).all() ) grant_ids = [int(row.id) for row in grant_rows if row.id is not None] assignee_rows = ( list( ( - await session.execute( + await session.exec( select(PermissionGrantAssignee) .where( col(PermissionGrantAssignee.grant_id).in_(grant_ids), @@ -282,9 +280,7 @@ async def _load_canonical_grants() -> tuple[tuple[GrantSnapshot, ...], int]: ) .order_by(PermissionGrantAssignee.tenant_id, PermissionGrantAssignee.id) ) - ) - .scalars() - .all() + ).all() ) if grant_ids else [] @@ -353,15 +349,13 @@ async def _load_persisted_sources() -> tuple[PermissionVisibleSourceProjection, async with get_async_db_session() as session: return tuple( ( - await session.execute( + await session.exec( select(PermissionVisibleSourceProjection).order_by( PermissionVisibleSourceProjection.tenant_id, PermissionVisibleSourceProjection.id, ) ) - ) - .scalars() - .all() + ).all() ) @@ -441,20 +435,16 @@ async def _apply_source_rows(upserts: tuple[Any, ...]) -> None: async with session.begin(): for source in upserts: existing = ( - ( - await session.execute( - select(PermissionVisibleSourceProjection) - .where( - PermissionVisibleSourceProjection.tenant_id == source.tenant_id, - PermissionVisibleSourceProjection.contribution_fingerprint - == source.contribution_fingerprint, - ) - .with_for_update() + await session.exec( + select(PermissionVisibleSourceProjection) + .where( + PermissionVisibleSourceProjection.tenant_id == source.tenant_id, + PermissionVisibleSourceProjection.contribution_fingerprint + == source.contribution_fingerprint, ) + .with_for_update() ) - .scalars() - .first() - ) + ).first() if existing is None: session.add( PermissionVisibleSourceProjection( @@ -519,17 +509,13 @@ async def _authorization_release_id(store_id: str, model_id: str) -> int: with bypass_tenant_filter(): async with get_async_db_session() as session: row = ( - ( - await session.execute( - select(AuthorizationModelRelease).where( - AuthorizationModelRelease.store_id == store_id, - AuthorizationModelRelease.model_id == model_id, - ) + await session.exec( + select(AuthorizationModelRelease).where( + AuthorizationModelRelease.store_id == store_id, + AuthorizationModelRelease.model_id == model_id, ) ) - .scalars() - .first() - ) + ).first() _require(row is not None and row.id is not None, "target Authorization Model release is missing") return int(row.id) @@ -544,14 +530,14 @@ async def _is_resumable_upgrade_draft( async with get_async_db_session() as session: count = int( ( - await session.execute( + await session.exec( select(func.count(PermissionCatalogRelease.id)).where( PermissionCatalogRelease.predecessor_id == current_catalog_id, PermissionCatalogRelease.idempotency_key == idempotency_key, PermissionCatalogRelease.status.in_(("DRAFT", "PROJECTING", "COMMITTED")), ) ) - ).scalar_one() + ).one() ) return count == 1 diff --git a/src/backend/scripts/restore_skills_to_local.py b/src/backend/scripts/restore_skills_to_local.py new file mode 100644 index 0000000000..69fad09f34 --- /dev/null +++ b/src/backend/scripts/restore_skills_to_local.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Write skill bundles back to the legacy on-disk layout (rollback aid). + +Only needed when rolling BACK to a release that reads skill bundles from +``SKILLS_ROOT`` instead of object storage. Skills created or edited while the +newer release was running exist only as objects; the older code looks for them on +local disk, finds nothing, and — because that path only logs a warning — the skill +silently stops working. + +Run this on every host that will serve the older release, BEFORE rolling back. +Bundles are written to ``SKILLS_ROOT/data/skills/{tenant_id}/{name}/``. + +How to run (from src/backend/) +------------------------------ + cd src/backend/ + export config=config.yaml + PYTHONPATH=./ .venv/bin/python scripts/restore_skills_to_local.py # dry-run + PYTHONPATH=./ .venv/bin/python scripts/restore_skills_to_local.py --apply +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path + +_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _BACKEND_ROOT not in sys.path: + sys.path.insert(0, _BACKEND_ROOT) + +from loguru import logger # noqa: E402 + +from bisheng.common.services.config_service import settings # noqa: E402 +from bisheng.core.context.manager import close_app_context, initialize_app_context # noqa: E402 +from bisheng.core.context.tenant import bypass_tenant_filter # noqa: E402 +from bisheng.linsight.domain.models.linsight_skill import LinsightSkillDao # noqa: E402 +from bisheng.linsight.domain.services.skill_store import LEGACY_TENANT_SKILLS_DIR, SkillStore # noqa: E402 + + +async def restore(apply: bool, legacy_root: Path, store: SkillStore) -> dict: + report: dict[str, list] = {"restored": [], "unavailable": []} + + with bypass_tenant_filter(): + rows, _ = await LinsightSkillDao.get_page(page=1, page_size=100000) + + for row in rows: + entry = {"tenant_id": row.tenant_id, "name": row.name} + try: + entries = store.list_files(row.tenant_id, row.name, row.content_hash) + if not entries: + raise FileNotFoundError(row.object_path) + files = {e["path"]: store.read_bytes(row.tenant_id, row.name, row.content_hash, e["path"]) for e in entries} + except Exception as exc: + logger.warning("cannot read bundle for {}/{}: {}", row.tenant_id, row.name, exc) + report["unavailable"].append(entry) + continue + + if apply: + base = legacy_root / LEGACY_TENANT_SKILLS_DIR / str(row.tenant_id) / row.name + for rel, content in files.items(): + target = base / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + report["restored"].append(entry) + + return report + + +async def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--apply", action="store_true", help="write files (default: dry-run)") + parser.add_argument("--legacy-root", default=None, help="target SKILLS_ROOT (default: linsight_conf.skills_root)") + args = parser.parse_args() + + await initialize_app_context(settings, instance_role="script") + try: + legacy_root = Path(args.legacy_root or settings.get_linsight_conf().skills_root).resolve() + report = await restore(args.apply, legacy_root, SkillStore()) + mode = "apply" if args.apply else "dry-run" + print(json.dumps({"mode": mode, "legacy_root": str(legacy_root), **report}, ensure_ascii=False, indent=2)) + return 0 + finally: + await close_app_context() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/backend/scripts/seed_overflow_skill.py b/src/backend/scripts/seed_overflow_skill.py index 9df0d933ec..cfa41bfb31 100644 --- a/src/backend/scripts/seed_overflow_skill.py +++ b/src/backend/scripts/seed_overflow_skill.py @@ -126,14 +126,14 @@ async def _run(apply: bool, remove: bool, tenant_id: int) -> int: print("[dry-run] 未写入 · 加 --apply 正式创建") return 0 - size = store.write_bundle(tenant_id, name, files) - object_path = store.object_path(tenant_id, name) + ref = store.write_bundle(tenant_id, name, files) if existing: existing.display_name = display_name existing.description = description existing.enabled = True - existing.object_path = object_path - existing.size = size + existing.object_path = ref.object_key + existing.content_hash = ref.content_hash + existing.size = ref.size await LinsightSkillDao.update(existing) print(f"[apply] updated existing skill row id={existing.id}") else: @@ -145,12 +145,13 @@ async def _run(apply: bool, remove: bool, tenant_id: int) -> int: description=description, enabled=True, source=SKILL_SOURCE_MANUAL, - object_path=object_path, - size=size, + object_path=ref.object_key, + content_hash=ref.content_hash, + size=ref.size, created_by=1, ) ) - print(f"[apply] created skill row id={created.id} object_path={object_path} size={size}") + print(f"[apply] created skill row id={created.id} object_path={ref.object_key} size={ref.size}") print("[done] 打开 构建-首页-技能管理,点开该技能即可体验详情抽屉超长内容排版") return 0 finally: diff --git a/src/backend/test/channel/test_creation_permission_context_api.py b/src/backend/test/channel/test_creation_permission_context_api.py new file mode 100644 index 0000000000..4fdb4a0dab --- /dev/null +++ b/src/backend/test/channel/test_creation_permission_context_api.py @@ -0,0 +1,125 @@ +"""Channel creation permission context and candidate API contracts.""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.channel.api.endpoints import channel_manager as endpoints +from bisheng.channel.domain.services.channel_service import ChannelService +from bisheng.common.errcode.channel import ChannelCreateLimitExceededError + + +class _Prospective: + def __init__(self) -> None: + self.calls = [] + + async def get_context(self, **kwargs): + self.calls.append(("context", kwargs)) + return {"catalog_release_id": 42, "can_configure_initial_permissions": True, "grantable_models": []} + + async def list_users(self, **kwargs): + self.calls.append(("users", kwargs)) + return {"data": [], "total": 0} + + async def list_user_groups(self, **kwargs): + self.calls.append(("groups", kwargs)) + return {"data": [], "total": 0} + + async def list_department_children(self, **kwargs): + self.calls.append(("children", kwargs)) + return [] + + async def search_departments(self, **kwargs): + self.calls.append(("search", kwargs)) + return {"roots": [], "total_matches": 0, "truncated": False} + + async def get_department_path(self, **kwargs): + self.calls.append(("path", kwargs)) + return {"roots": [], "total_matches": 0, "truncated": False} + + +def _service(prospective: _Prospective): + channels = SimpleNamespace(find_channels_by_ids=AsyncMock(return_value=[])) + members = SimpleNamespace(find_channel_memberships=AsyncMock(return_value=[])) + service = ChannelService( + channel_repository=channels, + space_channel_member_repository=members, + channel_info_source_repository=SimpleNamespace(), + prospective_grant_application=prospective, + ) + return service, channels, members + + +async def test_creation_context_and_candidates_use_server_tenant_and_same_shape() -> None: + prospective = _Prospective() + service, _, _ = _service(prospective) + login_user = SimpleNamespace(user_id=7, tenant_id=3) + with ( + patch( + "bisheng.channel.domain.services.channel_service.QuotaService.get_effective_quota", + new=AsyncMock(return_value=-1), + ), + patch( + "bisheng.channel.domain.services.channel_service.resolve_permission_actor", + new=AsyncMock(return_value=SimpleNamespace(user_id=7, current_tenant_id=3)), + ), + ): + context = await service.get_creation_permission_context(login_user) + users = await service.list_creation_grant_users(login_user, keyword="A", page=2, page_size=25) + await service.list_creation_grant_user_groups(login_user, keyword="G", page=1, page_size=20) + await service.list_creation_grant_department_children(login_user, parent_id=5) + await service.search_creation_grant_departments(login_user, keyword="R", limit=10) + await service.get_creation_grant_department_path(login_user, 9) + + assert context["catalog_release_id"] == 42 + assert users == {"data": [], "total": 0} + assert [name for name, _ in prospective.calls] == [ + "context", + "users", + "groups", + "children", + "search", + "path", + ] + assert all(call["tenant_id"] == 3 for _, call in prospective.calls) + assert all(call["resource_type"] == "channel" for _, call in prospective.calls) + + +async def test_channel_quota_fails_closed_before_permission_directory() -> None: + prospective = _Prospective() + service, channels, members = _service(prospective) + members.find_channel_memberships.return_value = [SimpleNamespace(business_id="channel-1")] + channels.find_channels_by_ids.return_value = [SimpleNamespace(id="channel-1")] + with patch( + "bisheng.channel.domain.services.channel_service.QuotaService.get_effective_quota", + new=AsyncMock(return_value=1), + ): + with pytest.raises(ChannelCreateLimitExceededError): + await service.get_creation_permission_context(SimpleNamespace(user_id=7, tenant_id=3)) + + assert prospective.calls == [] + + +def test_creation_routes_exist_and_do_not_accept_tenant_id() -> None: + paths = {route.path for route in endpoints.router.routes} + assert { + "/manager/creation-permission-context", + "/manager/creation-grant-subjects/users", + "/manager/creation-grant-subjects/user-groups", + "/manager/creation-grant-subjects/departments/children", + "/manager/creation-grant-subjects/departments/search", + "/manager/creation-grant-subjects/departments/{department_id}/path-tree", + } <= paths + for endpoint in ( + endpoints.get_creation_permission_context, + endpoints.list_creation_grant_users, + endpoints.list_creation_grant_user_groups, + endpoints.list_creation_grant_department_children, + endpoints.search_creation_grant_departments, + endpoints.get_creation_grant_department_path, + ): + assert "tenant_id" not in inspect.signature(endpoint).parameters diff --git a/src/backend/test/channel/test_unified_permission_creation.py b/src/backend/test/channel/test_unified_permission_creation.py new file mode 100644 index 0000000000..03460dfaa5 --- /dev/null +++ b/src/backend/test/channel/test_unified_permission_creation.py @@ -0,0 +1,285 @@ +"""F050 Channel creation, retry, business settings, and initial Grant contracts.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.channel.domain.models.channel import Channel +from bisheng.channel.domain.schemas.channel_manager_schema import CreateChannelRequest +from bisheng.channel.domain.services.channel_service import ChannelService +from bisheng.common.errcode.channel import ChannelCreationRequestConflictError +from bisheng.permission.domain.schemas import VerifiedPermissionTarget + +_CS = "bisheng.channel.domain.services.channel_service" + + +class _LoginUser: + user_id = 7 + user_name = "creator" + tenant_id = 3 + + +class _Adapter: + def __init__( + self, + owner_error: Exception | None = None, + resolve_error: Exception | None = None, + ) -> None: + self.owner_error = owner_error + self.resolve_error = resolve_error + self.authorized = 0 + + async def authorize_created(self, **kwargs): + self.authorized += 1 + if self.owner_error is not None: + raise self.owner_error + + async def resolve_permission_target(self, **kwargs): + if self.resolve_error is not None: + raise self.resolve_error + return VerifiedPermissionTarget.from_business_service( + tenant_id=3, + resource_type="channel", + resource_id=kwargs["resource_id"], + resource_version=1, + context_version="channel:v1", + ) + + +class _InitialGrants: + def __init__(self, error: Exception | None = None) -> None: + self.error = error + self.requests = [] + + async def apply(self, **kwargs): + self.requests.append(kwargs) + if self.error is not None: + raise self.error + source = SimpleNamespace(source_id=92, active=True, protected=False) + return SimpleNamespace(resource_version=2, grants=(SimpleNamespace(sources=(source,)),)) + + +def _request(*, request_id: str | None = "req-1") -> CreateChannelRequest: + return CreateChannelRequest.model_validate( + { + "name": "News", + "source_list": ["source-a"], + "visibility": "public", + "description": "Daily", + "filter_rules": [ + { + "relation": "and", + "rules": [{"type": "single", "rule_type": "include", "keywords": ["AI"]}], + "channel_type": "main", + } + ], + "knowledge_sync": { + "main": { + "enabled": True, + "spaces": [{"knowledge_space_id": "10", "folder_id": "20"}], + }, + "subs": [], + }, + "creation_request_id": request_id, + "initial_permissions": ( + { + "expected_catalog_release_id": 42, + "grants": [{"model_key": "viewer", "subject": {"type": "user", "id": "8"}}], + } + if request_id is not None + else None + ), + } + ) + + +def _channel(request: CreateChannelRequest, *, payload_hash: str | None = None) -> Channel: + return Channel( + id="channel-1", + name=request.name, + source_list=request.source_list, + visibility=request.visibility, + description=request.description, + filter_rules=[row.model_dump() for row in request.filter_rules or ()], + user_id=7, + tenant_id=3, + creation_request_id=request.creation_request_id, + creation_payload_hash=payload_hash, + ) + + +def _service(repository, adapter: _Adapter, grants: _InitialGrants | None = None): + members = SimpleNamespace( + find_channel_memberships=AsyncMock(return_value=[]), + find_membership=AsyncMock(return_value=None), + add_member=AsyncMock(), + ) + info_sources = SimpleNamespace(find_by_ids=AsyncMock(return_value=[SimpleNamespace(id="source-a")])) + service = ChannelService( + channel_repository=repository, + space_channel_member_repository=members, + channel_info_source_repository=info_sources, + article_es_service=SimpleNamespace(count_articles=AsyncMock(return_value=0)), + initial_grant_application=grants, + ) + service.update_channels_latest_article_time = AsyncMock() + service._save_knowledge_sync = AsyncMock() + return service, members, info_sources + + +@pytest.fixture(autouse=True) +def _runtime_stubs(): + information = SimpleNamespace(subscribe_information_source=AsyncMock()) + with ( + patch(f"{_CS}.QuotaService.get_effective_quota", new=AsyncMock(return_value=-1)), + patch(f"{_CS}.get_bisheng_information_client", new=AsyncMock(return_value=information)), + patch(f"{_CS}.resolve_permission_actor", new=AsyncMock(return_value=SimpleNamespace(user_id=7))), + ): + yield information + + +async def test_new_payload_preserves_filters_sync_and_applies_initial_grants() -> None: + request = _request() + adapter = _Adapter() + grants = _InitialGrants() + repository = SimpleNamespace( + find_by_creation_request=AsyncMock(return_value=None), + save_creation=AsyncMock(), + ) + created = _channel(request) + created.creation_payload_hash = ChannelService._creation_payload_hash(request) + repository.save_creation.return_value = (created, True) + service, members, _ = _service(repository, adapter, grants) + + with patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)): + result = await service.create_channel(request, _LoginUser()) + + saved = repository.save_creation.call_args.args[0] + assert saved.creation_request_id == "req-1" + assert len(saved.creation_payload_hash) == 64 + assert saved.filter_rules == [row.model_dump() for row in request.filter_rules] + service._save_knowledge_sync.assert_awaited_once_with( + channel_id="channel-1", + cfg=request.knowledge_sync, + user_id=7, + ) + assert result.initial_permission_result.status == "succeeded" + assert result.initial_permission_result.assignee_ids == ["92"] + assert grants.requests[0]["request"].expected_catalog_release_id == 42 + members.add_member.assert_awaited_once() + + +async def test_retry_skips_completed_external_subscription_and_resumes_durable_steps(_runtime_stubs) -> None: + request = _request() + existing = _channel(request, payload_hash=ChannelService._creation_payload_hash(request)) + repository = SimpleNamespace( + find_by_creation_request=AsyncMock(return_value=existing), + save_creation=AsyncMock(), + ) + adapter = _Adapter() + grants = _InitialGrants() + service, members, info_sources = _service(repository, adapter, grants) + members.find_membership.return_value = SimpleNamespace(id=1) + + with patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)): + result = await service.create_channel(request, _LoginUser()) + + assert result.id == "channel-1" + repository.save_creation.assert_not_awaited() + info_sources.find_by_ids.assert_not_awaited() + _runtime_stubs.subscribe_information_source.assert_not_awaited() + members.add_member.assert_not_awaited() + service._save_knowledge_sync.assert_awaited_once() + assert adapter.authorized == 1 + assert len(grants.requests) == 1 + + +async def test_owner_failure_propagates_before_initial_grants() -> None: + request = _request() + created = _channel(request, payload_hash=ChannelService._creation_payload_hash(request)) + repository = SimpleNamespace( + find_by_creation_request=AsyncMock(return_value=None), + save_creation=AsyncMock(return_value=(created, True)), + ) + grants = _InitialGrants() + adapter = _Adapter(owner_error=RuntimeError("owner failed")) + service, _, _ = _service(repository, adapter, grants) + + with patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)): + with pytest.raises(RuntimeError, match="owner failed"): + await service.create_channel(request, _LoginUser()) + + assert grants.requests == [] + + +async def test_initial_grant_failure_returns_partial_success() -> None: + request = _request() + created = _channel(request, payload_hash=ChannelService._creation_payload_hash(request)) + repository = SimpleNamespace( + find_by_creation_request=AsyncMock(return_value=None), + save_creation=AsyncMock(return_value=(created, True)), + ) + adapter = _Adapter() + service, _, _ = _service(repository, adapter, _InitialGrants(error=RuntimeError("grant failed"))) + + with patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)): + result = await service.create_channel(request, _LoginUser()) + + assert result.id == "channel-1" + assert result.initial_permission_result.status == "failed" + assert result.initial_permission_result.error_code == 500 + assert result.initial_permission_result.message is None + + +async def test_initial_target_resolution_failure_returns_partial_success() -> None: + request = _request() + created = _channel(request, payload_hash=ChannelService._creation_payload_hash(request)) + repository = SimpleNamespace( + find_by_creation_request=AsyncMock(return_value=None), + save_creation=AsyncMock(return_value=(created, True)), + ) + grants = _InitialGrants() + adapter = _Adapter(resolve_error=RuntimeError("target failed")) + service, _, _ = _service(repository, adapter, grants) + + with patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)): + result = await service.create_channel(request, _LoginUser()) + + assert result.id == "channel-1" + assert result.initial_permission_result.status == "failed" + assert result.initial_permission_result.error_code == 500 + assert grants.requests == [] + + +async def test_same_key_with_changed_filter_or_sync_conflicts_before_owner() -> None: + request = _request() + existing = _channel(request, payload_hash="x" * 64) + repository = SimpleNamespace(find_by_creation_request=AsyncMock(return_value=existing)) + adapter = _Adapter() + service, _, _ = _service(repository, adapter) + + with pytest.raises(ChannelCreationRequestConflictError): + await service.create_channel(request, _LoginUser()) + + assert adapter.authorized == 0 + + +async def test_unique_key_race_uses_winner_and_does_not_duplicate_creator() -> None: + request = _request() + existing = _channel(request, payload_hash=ChannelService._creation_payload_hash(request)) + repository = SimpleNamespace( + find_by_creation_request=AsyncMock(return_value=None), + save_creation=AsyncMock(return_value=(existing, False)), + ) + adapter = _Adapter() + service, members, _ = _service(repository, adapter, _InitialGrants()) + members.find_membership.return_value = SimpleNamespace(id=1) + + with patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)): + result = await service.create_channel(request, _LoginUser()) + + assert result.id == "channel-1" + members.add_member.assert_not_awaited() diff --git a/src/backend/test/channel/test_unified_permission_update.py b/src/backend/test/channel/test_unified_permission_update.py new file mode 100644 index 0000000000..1c16747ea9 --- /dev/null +++ b/src/backend/test/channel/test_unified_permission_update.py @@ -0,0 +1,125 @@ +"""F050 Channel settings save order and PRIVATE permission contracts.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.channel.domain.models.channel import Channel, ChannelVisibilityEnum +from bisheng.channel.domain.schemas.channel_manager_schema import UpdateChannelRequest +from bisheng.channel.domain.services.channel_service import ChannelService + +_CS = "bisheng.channel.domain.services.channel_service" + + +def _channel() -> Channel: + return Channel( + id="channel-1", + name="News", + source_list=[], + visibility=ChannelVisibilityEnum.PUBLIC, + user_id=7, + tenant_id=3, + ) + + +def _service(repository, members): + return ChannelService( + channel_repository=repository, + space_channel_member_repository=members, + channel_info_source_repository=SimpleNamespace(), + ) + + +async def test_business_save_failure_does_not_touch_grants_or_memberships() -> None: + repository = SimpleNamespace( + find_by_id=AsyncMock(return_value=_channel()), + update=AsyncMock(side_effect=RuntimeError("save failed")), + ) + members = SimpleNamespace(remove_non_creator_members=AsyncMock()) + service = _service(repository, members) + adapter_lookup = AsyncMock() + with ( + patch(f"{_CS}.require_business_action", new=AsyncMock()), + patch(f"{_CS}.get_bisheng_information_client", new=AsyncMock(return_value=SimpleNamespace())), + patch(f"{_CS}.get_f048_resource_adapter", new=adapter_lookup), + ): + with pytest.raises(RuntimeError, match="save failed"): + await service.update_channel( + "channel-1", + UpdateChannelRequest(visibility=ChannelVisibilityEnum.PRIVATE), + SimpleNamespace(user_id=7, user_name="creator", tenant_id=3), + ) + + adapter_lookup.assert_not_awaited() + members.remove_non_creator_members.assert_not_awaited() + + +async def test_private_projection_commits_before_membership_cleanup() -> None: + order = [] + channel = _channel() + + async def save(value): + order.append("business") + return value + + adapter = SimpleNamespace( + load_permission_record=AsyncMock(return_value=SimpleNamespace(resource_id="channel-1")), + remove_ordinary_sources=AsyncMock(side_effect=lambda **_: order.append("permission")), + ) + + async def remove_members(_channel_id): + order.append("membership") + + repository = SimpleNamespace(find_by_id=AsyncMock(return_value=channel), update=AsyncMock(side_effect=save)) + members = SimpleNamespace( + find_members_by_role=AsyncMock(return_value=[SimpleNamespace(user_id=7)]), + remove_non_creator_members=AsyncMock(side_effect=remove_members), + ) + service = _service(repository, members) + with ( + patch(f"{_CS}.require_business_action", new=AsyncMock()), + patch(f"{_CS}.get_bisheng_information_client", new=AsyncMock(return_value=SimpleNamespace())), + patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)), + patch(f"{_CS}.resolve_permission_actor", new=AsyncMock(return_value=SimpleNamespace(user_id=7))), + ): + result = await service.update_channel( + "channel-1", + UpdateChannelRequest(visibility=ChannelVisibilityEnum.PRIVATE), + SimpleNamespace(user_id=7, user_name="creator", tenant_id=3), + ) + + assert result.visibility == ChannelVisibilityEnum.PRIVATE + assert order == ["business", "permission", "membership"] + + +async def test_private_projection_failure_preserves_membership_rows() -> None: + repository = SimpleNamespace( + find_by_id=AsyncMock(return_value=_channel()), + update=AsyncMock(side_effect=lambda value: value), + ) + members = SimpleNamespace( + find_members_by_role=AsyncMock(return_value=[]), + remove_non_creator_members=AsyncMock(), + ) + adapter = SimpleNamespace( + load_permission_record=AsyncMock(return_value=SimpleNamespace(resource_id="channel-1")), + remove_ordinary_sources=AsyncMock(side_effect=RuntimeError("projection failed")), + ) + service = _service(repository, members) + with ( + patch(f"{_CS}.require_business_action", new=AsyncMock()), + patch(f"{_CS}.get_bisheng_information_client", new=AsyncMock(return_value=SimpleNamespace())), + patch(f"{_CS}.get_f048_resource_adapter", new=AsyncMock(return_value=adapter)), + patch(f"{_CS}.resolve_permission_actor", new=AsyncMock(return_value=SimpleNamespace(user_id=7))), + ): + with pytest.raises(RuntimeError, match="projection failed"): + await service.update_channel( + "channel-1", + UpdateChannelRequest(visibility=ChannelVisibilityEnum.PRIVATE), + SimpleNamespace(user_id=7, user_name="creator", tenant_id=3), + ) + + members.remove_non_creator_members.assert_not_awaited() diff --git a/src/backend/test/common/test_config_backfill.py b/src/backend/test/common/test_config_backfill.py index d26cf41862..d428c3581b 100644 --- a/src/backend/test/common/test_config_backfill.py +++ b/src/backend/test/common/test_config_backfill.py @@ -135,6 +135,42 @@ def test_result_stays_valid_yaml_with_correct_indentation(): } +def test_missing_child_inherits_legacy_four_space_section_indent(): + """Old ``merge_old_config`` rows may indent section children by four spaces. + + A new child copied verbatim from the two-space shipped YAML would prematurely + close that mapping and make the complete document invalid. + """ + file_cfg = """\ +system_login_method: + allow_multi_login: true + # Commercial SSO switch + gateway_login: false + +workflow: + timeout: 5 +""" + db_cfg = """\ +system_login_method: + allow_multi_login: true + admin_username: admin + +workflow: + timeout: 1 +""" + + merged, added = ConfigService.merge_missing_config(file_cfg, db_cfg) + cfg = yaml.safe_load(merged) + + assert added == ["system_login_method.gateway_login"] + assert cfg["system_login_method"] == { + "allow_multi_login": True, + "admin_username": "admin", + "gateway_login": False, + } + assert " # Commercial SSO switch\n gateway_login: false" in merged + + def test_empty_or_malformed_inputs_are_left_alone(): assert ConfigService.merge_missing_config("", DB_CONFIG) == (DB_CONFIG, []) # A scalar document is not a config tree — refuse rather than mangle it. diff --git a/src/backend/test/e2e/test_e2e_f050_channel_permission_settings.py b/src/backend/test/e2e/test_e2e_f050_channel_permission_settings.py new file mode 100644 index 0000000000..d99cc8bca9 --- /dev/null +++ b/src/backend/test/e2e/test_e2e_f050_channel_permission_settings.py @@ -0,0 +1,169 @@ +"""Live E2E coverage for F050 channel permission settings. + +Set ``F050_E2E=1`` and ``F050_E2E_CHANNEL_SOURCE_ID`` only for a dedicated +deployment. Cleanup is restricted to the ``e2e-f050-channel-`` prefix. +""" + +from __future__ import annotations + +import os +from uuid import uuid4 + +import httpx +import pytest + +from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error +from test.e2e.helpers.auth import auth_headers, get_admin_token + +PREFIX = "e2e-f050-channel-" +pytestmark = pytest.mark.skipif( + os.environ.get("F050_E2E") != "1", + reason="set F050_E2E=1 only against a dedicated F050 test deployment", +) + + +async def _cleanup(client: httpx.AsyncClient, token: str) -> None: + response = await client.get( + f"{API_BASE}/channel/manager/my_channels", + params={"query_type": "created", "sort_by": "latest_update"}, + headers=auth_headers(token), + ) + rows = assert_resp_200(response) + for item in rows if isinstance(rows, list) else []: + if str(item.get("name", "")).startswith(PREFIX): + deleted = await client.delete( + f"{API_BASE}/channel/manager/{item['id']}", + headers=auth_headers(token), + ) + assert_resp_200(deleted) + + +@pytest.fixture(scope="module") +async def client(): + async with httpx.AsyncClient(timeout=30.0) as value: + yield value + + +@pytest.fixture(scope="module") +async def admin_token(client: httpx.AsyncClient) -> str: + return await get_admin_token(client) + + +@pytest.fixture(scope="module", autouse=True) +async def prefix_cleanup(client: httpx.AsyncClient, admin_token: str): + await _cleanup(client, admin_token) + yield + await _cleanup(client, admin_token) + + +def _source_id() -> str: + value = os.environ.get("F050_E2E_CHANNEL_SOURCE_ID", "").strip() + if not value: + pytest.fail("F050_E2E_CHANNEL_SOURCE_ID is required when F050_E2E=1") + return value + + +class TestE2EF050ChannelPermissionSettings: + """F050 channel creation, idempotency, and F048 truth.""" + + async def test_ac02_ac12_creation_context_and_candidates( + self, + client: httpx.AsyncClient, + admin_token: str, + ) -> None: + """AC-02/12: prospective context and candidates are tenant scoped.""" + headers = auth_headers(admin_token) + context = assert_resp_200( + await client.get( + f"{API_BASE}/channel/manager/creation-permission-context", + headers=headers, + ) + ) + assert context["catalog_release_id"] > 0 + assert isinstance(context["can_configure_initial_permissions"], bool) + groups = assert_resp_200( + await client.get( + f"{API_BASE}/channel/manager/creation-grant-subjects/user-groups", + params={"page": 1, "page_size": 20}, + headers=headers, + ) + ) + assert "data" in groups and "total" in groups + + async def test_ac15_ac18_ac20_ac30_ac32_create_and_read_owner( + self, + client: httpx.AsyncClient, + admin_token: str, + ) -> None: + """AC-15/18/20/30/32: business fields persist with protected owner.""" + headers = auth_headers(admin_token) + name = f"{PREFIX}{uuid4().hex[:10]}" + payload = { + "name": name, + "source_list": [_source_id()], + "visibility": "private", + "filter_rules": [], + "knowledge_sync": {"main": {"enabled": False, "spaces": []}, "subs": []}, + } + created = assert_resp_200( + await client.post( + f"{API_BASE}/channel/manager/create", + json=payload, + headers=headers, + ) + ) + channel_id = str(created["id"]) + detail = assert_resp_200( + await client.get( + f"{API_BASE}/channel/manager/{channel_id}", + headers=headers, + ) + ) + assert detail["name"] == name + assert detail["source_list"] == payload["source_list"] + + roster = assert_resp_200( + await client.get( + f"{API_BASE}/permissions/resources/channel/{channel_id}/grants", + params={"page_size": 100}, + headers=headers, + ) + )["data"] + assert any(item["protected"] and item["model"]["key"] == "owner" for item in roster) + + async def test_ac16_ac17_ac19_ac30_same_request_reuses_resource( + self, + client: httpx.AsyncClient, + admin_token: str, + ) -> None: + """AC-16/17/19/30: same request resumes; changed payload is rejected.""" + headers = auth_headers(admin_token) + request_id = f"e2e-f050-{uuid4().hex}" + payload = { + "name": f"{PREFIX}{uuid4().hex[:10]}", + "source_list": [_source_id()], + "visibility": "private", + "filter_rules": [], + "creation_request_id": request_id, + } + first = assert_resp_200( + await client.post( + f"{API_BASE}/channel/manager/create", + json=payload, + headers=headers, + ) + ) + repeated = assert_resp_200( + await client.post( + f"{API_BASE}/channel/manager/create", + json=payload, + headers=headers, + ) + ) + assert repeated["id"] == first["id"] + conflict = await client.post( + f"{API_BASE}/channel/manager/create", + json={**payload, "description": "different"}, + headers=headers, + ) + assert_resp_error(conflict, 19056) diff --git a/src/backend/test/e2e/test_e2e_f050_knowledge_permission_settings.py b/src/backend/test/e2e/test_e2e_f050_knowledge_permission_settings.py new file mode 100644 index 0000000000..7da7e5e94e --- /dev/null +++ b/src/backend/test/e2e/test_e2e_f050_knowledge_permission_settings.py @@ -0,0 +1,164 @@ +"""Live E2E coverage for F050 knowledge-space permission settings. + +Set ``F050_E2E=1`` only for a dedicated test deployment. The suite creates and +deletes resources whose names start with ``e2e-f050-knowledge-``. +""" + +from __future__ import annotations + +import os +from uuid import uuid4 + +import httpx +import pytest + +from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error +from test.e2e.helpers.auth import auth_headers, get_admin_token + +PREFIX = "e2e-f050-knowledge-" +pytestmark = pytest.mark.skipif( + os.environ.get("F050_E2E") != "1", + reason="set F050_E2E=1 only against a dedicated F050 test deployment", +) + + +async def _cleanup(client: httpx.AsyncClient, token: str) -> None: + response = await client.get( + f"{API_BASE}/knowledge/space/list", + params={"page": 1, "page_size": 200}, + headers=auth_headers(token), + ) + payload = assert_resp_200(response) + rows = payload.get("data", payload) if isinstance(payload, dict) else payload + for item in rows if isinstance(rows, list) else []: + if str(item.get("name", "")).startswith(PREFIX): + deleted = await client.delete( + f"{API_BASE}/knowledge/space/{item['id']}", + headers=auth_headers(token), + ) + assert_resp_200(deleted) + + +@pytest.fixture(scope="module") +async def client(): + async with httpx.AsyncClient(timeout=30.0) as value: + yield value + + +@pytest.fixture(scope="module") +async def admin_token(client: httpx.AsyncClient) -> str: + return await get_admin_token(client) + + +@pytest.fixture(scope="module", autouse=True) +async def prefix_cleanup(client: httpx.AsyncClient, admin_token: str): + await _cleanup(client, admin_token) + yield + await _cleanup(client, admin_token) + + +class TestE2EF050KnowledgePermissionSettings: + """F050 knowledge-space creation, idempotency, and F048 truth.""" + + async def test_ac01_ac12_creation_context_and_candidates( + self, + client: httpx.AsyncClient, + admin_token: str, + ) -> None: + """AC-01/12: prospective context and candidates are tenant scoped.""" + headers = auth_headers(admin_token) + context = assert_resp_200( + await client.get( + f"{API_BASE}/knowledge/space/creation-permission-context", + headers=headers, + ) + ) + assert context["catalog_release_id"] > 0 + assert isinstance(context["can_configure_initial_permissions"], bool) + assert all(model["active"] for model in context["grantable_models"]) + + users = assert_resp_200( + await client.get( + f"{API_BASE}/knowledge/space/creation-grant-subjects/users", + params={"page": 1, "page_size": 20}, + headers=headers, + ) + ) + assert "data" in users and "total" in users + + async def test_ac15_ac18_ac20_ac30_create_and_read_f048_owner( + self, + client: httpx.AsyncClient, + admin_token: str, + ) -> None: + """AC-15/18/20/30: legacy create persists and exposes protected owner.""" + headers = auth_headers(admin_token) + name = f"{PREFIX}{uuid4().hex[:10]}" + created = assert_resp_200( + await client.post( + f"{API_BASE}/knowledge/space", + json={"name": name, "auth_type": "private", "auto_tag_enabled": False}, + headers=headers, + ) + ) + space_id = str(created["id"]) + info = assert_resp_200( + await client.get( + f"{API_BASE}/knowledge/space/{space_id}", + headers=headers, + ) + ) + assert info["name"] == name + + context = assert_resp_200( + await client.get( + f"{API_BASE}/permissions/resources/knowledge_space/{space_id}/context", + headers=headers, + ) + ) + roster = assert_resp_200( + await client.get( + f"{API_BASE}/permissions/resources/knowledge_space/{space_id}/grants", + params={"page_size": 100}, + headers=headers, + ) + )["data"] + assert context["mode"] == "CUSTOM" + assert any(item["protected"] and item["model"]["key"] == "owner" for item in roster) + + async def test_ac16_ac17_ac19_ac30_same_request_reuses_resource( + self, + client: httpx.AsyncClient, + admin_token: str, + ) -> None: + """AC-16/17/19/30: same request resumes; changed payload is rejected.""" + headers = auth_headers(admin_token) + request_id = f"e2e-f050-{uuid4().hex}" + name = f"{PREFIX}{uuid4().hex[:10]}" + payload = { + "name": name, + "auth_type": "private", + "creation_request_id": request_id, + } + first = assert_resp_200( + await client.post( + f"{API_BASE}/knowledge/space", + json=payload, + headers=headers, + ) + ) + repeated = assert_resp_200( + await client.post( + f"{API_BASE}/knowledge/space", + json=payload, + headers=headers, + ) + ) + assert repeated["id"] == first["id"] + + conflict = await client.post( + f"{API_BASE}/knowledge/space", + json={**payload, "description": "different"}, + headers=headers, + ) + assert_resp_error(conflict, 18072) diff --git a/src/backend/test/knowledge/test_creation_permission_context_api.py b/src/backend/test/knowledge/test_creation_permission_context_api.py new file mode 100644 index 0000000000..bfe64ac588 --- /dev/null +++ b/src/backend/test/knowledge/test_creation_permission_context_api.py @@ -0,0 +1,120 @@ +"""Knowledge creation permission context and candidate API contracts.""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bisheng.common.errcode.knowledge_space import SpaceLimitError +from bisheng.knowledge.api.endpoints import knowledge_space as endpoints +from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService + + +class _Prospective: + def __init__(self) -> None: + self.calls = [] + + async def get_context(self, **kwargs): + self.calls.append(("context", kwargs)) + return {"catalog_release_id": 42, "can_configure_initial_permissions": True, "grantable_models": []} + + async def list_users(self, **kwargs): + self.calls.append(("users", kwargs)) + return {"data": [], "total": 0} + + async def list_user_groups(self, **kwargs): + self.calls.append(("groups", kwargs)) + return {"data": [], "total": 0} + + async def list_department_children(self, **kwargs): + self.calls.append(("children", kwargs)) + return [] + + async def search_departments(self, **kwargs): + self.calls.append(("search", kwargs)) + return {"roots": [], "total_matches": 0, "truncated": False} + + async def get_department_path(self, **kwargs): + self.calls.append(("path", kwargs)) + return {"roots": [], "total_matches": 0, "truncated": False} + + +def _service(prospective: _Prospective) -> KnowledgeSpaceService: + service = KnowledgeSpaceService( + request=MagicMock(), + login_user=SimpleNamespace(user_id=11, tenant_id=7), + prospective_grant_application=prospective, + ) + service._permission_actor = AsyncMock(return_value=SimpleNamespace(user_id=11, current_tenant_id=7)) + return service + + +async def test_creation_context_and_candidates_use_server_tenant_and_same_shape() -> None: + prospective = _Prospective() + service = _service(prospective) + with ( + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_count_spaces_by_user", + new=AsyncMock(return_value=0), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.LLMService.get_workbench_llm", + new=AsyncMock(return_value=SimpleNamespace(embedding_model=SimpleNamespace(id=3))), + ), + ): + context = await service.get_creation_permission_context() + users = await service.list_creation_grant_users(keyword="A", page=2, page_size=25) + await service.list_creation_grant_user_groups(keyword="G", page=1, page_size=20) + await service.list_creation_grant_department_children(parent_id=5) + await service.search_creation_grant_departments(keyword="R", limit=10) + await service.get_creation_grant_department_path(9) + + assert context["catalog_release_id"] == 42 + assert users == {"data": [], "total": 0} + assert [name for name, _ in prospective.calls] == [ + "context", + "users", + "groups", + "children", + "search", + "path", + ] + assert all(call["tenant_id"] == 7 for _, call in prospective.calls) + assert all(call["resource_type"] == "knowledge_space" for _, call in prospective.calls) + + +async def test_creation_qualification_fails_closed_before_permission_directory() -> None: + prospective = _Prospective() + service = _service(prospective) + with patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_count_spaces_by_user", + new=AsyncMock(return_value=30), + ): + with pytest.raises(SpaceLimitError): + await service.get_creation_permission_context() + + assert prospective.calls == [] + + +def test_creation_routes_exist_and_do_not_accept_tenant_id() -> None: + paths = {route.path for route in endpoints.router.routes} + assert { + "/knowledge/space/creation-permission-context", + "/knowledge/space/creation-grant-subjects/users", + "/knowledge/space/creation-grant-subjects/user-groups", + "/knowledge/space/creation-grant-subjects/departments/children", + "/knowledge/space/creation-grant-subjects/departments/search", + "/knowledge/space/creation-grant-subjects/departments/{department_id}/path-tree", + } <= paths + for endpoint in ( + endpoints.get_creation_permission_context, + endpoints.list_creation_grant_users, + endpoints.list_creation_grant_user_groups, + endpoints.list_creation_grant_department_children, + endpoints.search_creation_grant_departments, + endpoints.get_creation_grant_department_path, + ): + assert "tenant_id" not in inspect.signature(endpoint).parameters diff --git a/src/backend/test/knowledge/test_unified_permission_creation.py b/src/backend/test/knowledge/test_unified_permission_creation.py new file mode 100644 index 0000000000..d3ed92ac9d --- /dev/null +++ b/src/backend/test/knowledge/test_unified_permission_creation.py @@ -0,0 +1,389 @@ +"""F050 Knowledge Space creation, idempotency, and initial Grant contracts.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy.exc import IntegrityError + +from bisheng.common.errcode.knowledge_space import SpaceCreationRequestConflictError +from bisheng.knowledge.domain.models.knowledge import AuthTypeEnum, Knowledge, KnowledgeState, KnowledgeTypeEnum +from bisheng.knowledge.domain.schemas.knowledge_space_schema import InitialPermissionsRequest +from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService +from bisheng.permission.domain.schemas import VerifiedPermissionTarget + + +class _Adapter: + def __init__( + self, + owner_error: Exception | None = None, + resolve_error: Exception | None = None, + ) -> None: + self.owner_error = owner_error + self.resolve_error = resolve_error + self.authorized = 0 + self.resolved = 0 + + async def authorize_created(self, **kwargs): + self.authorized += 1 + if self.owner_error is not None: + raise self.owner_error + + async def resolve_permission_target(self, **kwargs): + self.resolved += 1 + if self.resolve_error is not None: + raise self.resolve_error + return VerifiedPermissionTarget.from_business_service( + tenant_id=7, + resource_type="knowledge_space", + resource_id=kwargs["resource_id"], + resource_version=1, + context_version="created:v1", + ) + + +class _InitialGrants: + def __init__(self, error: Exception | None = None) -> None: + self.error = error + self.requests = [] + + async def apply(self, **kwargs): + self.requests.append(kwargs) + if self.error is not None: + raise self.error + source = SimpleNamespace(source_id=91, active=True, protected=False) + grant = SimpleNamespace(sources=(source,)) + return SimpleNamespace(resource_version=2, grants=(grant,)) + + +def _user(): + return SimpleNamespace(user_id=11, user_name="creator", tenant_id=7, role="user") + + +def _space(*, request_id: str | None = None, payload_hash: str | None = None) -> Knowledge: + return Knowledge( + id=101, + tenant_id=7, + user_id=11, + name="Space", + type=KnowledgeTypeEnum.SPACE.value, + state=KnowledgeState.PUBLISHED.value, + auth_type=AuthTypeEnum.PUBLIC, + model="3", + creation_request_id=request_id, + creation_payload_hash=payload_hash, + ) + + +def _initial() -> InitialPermissionsRequest: + return InitialPermissionsRequest.model_validate( + { + "expected_catalog_release_id": 42, + "grants": [{"model_key": "viewer", "subject": {"type": "user", "id": "8"}}], + } + ) + + +def _service(adapter: _Adapter, grants: _InitialGrants | None = None) -> KnowledgeSpaceService: + service = KnowledgeSpaceService( + request=MagicMock(), + login_user=_user(), + initial_grant_application=grants, + ) + service._resource_adapter = AsyncMock(return_value=adapter) + service._permission_actor = AsyncMock(return_value=SimpleNamespace(user_id=11, current_tenant_id=7)) + service._is_auto_tag_feature_visible = AsyncMock(return_value=True) + return service + + +def _creation_patches(space: Knowledge): + return ( + patch.object(KnowledgeSpaceService, "_is_square_preview_space", return_value=False), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_count_spaces_by_user", + new_callable=AsyncMock, + return_value=0, + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.LLMService.get_workbench_llm", + new_callable=AsyncMock, + return_value=SimpleNamespace(embedding_model=SimpleNamespace(id=3)), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeService.create_knowledge_base", + return_value=space, + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.SpaceChannelMemberDao.async_insert_member", + new_callable=AsyncMock, + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service." + "KnowledgeAuditTelemetryService.audit_create_knowledge_space", + new_callable=AsyncMock, + ), + ) + + +async def test_legacy_payload_preserves_original_response_and_side_effects() -> None: + adapter = _Adapter() + service = _service(adapter) + patches = _creation_patches(_space()) + with ( + patches[0], + patches[1], + patches[2], + patches[3] as create, + patches[4] as member, + patches[5] as audit, + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + ) as lookup, + ): + result = await service.create_knowledge_space(name="Space") + + assert result.id == 101 + assert result.initial_permission_result is None + assert create.call_args.args[2].creation_request_id is None + assert adapter.authorized == 1 + member.assert_awaited_once() + audit.assert_awaited_once() + lookup.assert_not_awaited() + + +async def test_new_payload_persists_hash_preserves_auto_tag_and_applies_grants() -> None: + adapter = _Adapter() + grants = _InitialGrants() + service = _service(adapter, grants) + inserted = _space(request_id="req-1") + patches = _creation_patches(inserted) + with ( + patches[0], + patches[1], + patches[2], + patches[3] as create, + patches[4], + patches[5], + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + return_value=None, + ), + patch.object( + service, + "_apply_auto_tag_binding", + new_callable=AsyncMock, + return_value=(True, 19), + ) as tags, + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_update_space", + new_callable=AsyncMock, + side_effect=lambda value: value, + ), + ): + result = await service.create_knowledge_space( + name="Space", + auto_tag_enabled=True, + auto_tag_custom_tags=["A", "B"], + creation_request_id="req-1", + initial_permissions=_initial(), + ) + + db_space = create.call_args.args[2] + assert db_space.creation_request_id == "req-1" + assert len(db_space.creation_payload_hash) == 64 + tags.assert_awaited_once() + assert inserted.auto_tag_enabled is True + assert inserted.auto_tag_library_id == 19 + assert result.initial_permission_result.status == "succeeded" + assert result.initial_permission_result.assignee_ids == ["91"] + assert grants.requests[0]["request"].command_key == "req-1" + + +async def test_owner_failure_propagates_without_attempting_initial_grants() -> None: + adapter = _Adapter(owner_error=RuntimeError("owner failed")) + grants = _InitialGrants() + service = _service(adapter, grants) + patches = _creation_patches(_space(request_id="req-1")) + with ( + patches[0], + patches[1], + patches[2], + patches[3], + patches[4], + patches[5], + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + return_value=None, + ), + ): + with pytest.raises(RuntimeError, match="owner failed"): + await service.create_knowledge_space( + name="Space", + creation_request_id="req-1", + initial_permissions=_initial(), + ) + + assert grants.requests == [] + + +async def test_initial_grant_failure_is_returned_as_partial_success() -> None: + adapter = _Adapter() + service = _service(adapter, _InitialGrants(error=RuntimeError("grant failed"))) + patches = _creation_patches(_space(request_id="req-1")) + with ( + patches[0], + patches[1], + patches[2], + patches[3], + patches[4], + patches[5], + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + return_value=None, + ), + ): + result = await service.create_knowledge_space( + name="Space", + creation_request_id="req-1", + initial_permissions=_initial(), + ) + + assert result.id == 101 + assert result.initial_permission_result.status == "failed" + assert result.initial_permission_result.error_code == 500 + assert result.initial_permission_result.message is None + + +async def test_initial_target_resolution_failure_is_returned_as_partial_success() -> None: + adapter = _Adapter(resolve_error=RuntimeError("target failed")) + grants = _InitialGrants() + service = _service(adapter, grants) + patches = _creation_patches(_space(request_id="req-1")) + with ( + patches[0], + patches[1], + patches[2], + patches[3], + patches[4], + patches[5], + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + return_value=None, + ), + ): + result = await service.create_knowledge_space( + name="Space", + creation_request_id="req-1", + initial_permissions=_initial(), + ) + + assert result.id == 101 + assert result.initial_permission_result.status == "failed" + assert result.initial_permission_result.error_code == 500 + assert grants.requests == [] + + +async def test_same_key_retry_resumes_permissions_without_business_side_effects() -> None: + adapter = _Adapter() + grants = _InitialGrants() + service = _service(adapter, grants) + payload_hash = service._creation_payload_hash( + name="Space", + description=None, + icon=None, + auth_type=AuthTypeEnum.PUBLIC, + is_released=False, + auto_tag_enabled=False, + auto_tag_library_id=None, + auto_tag_custom_tags=None, + initial_permissions=_initial(), + ) + existing = _space(request_id="req-1", payload_hash=payload_hash) + with ( + patch.object(KnowledgeSpaceService, "_is_square_preview_space", return_value=False), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + return_value=existing, + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeService.create_knowledge_base" + ) as create, + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.SpaceChannelMemberDao.async_insert_member", + new_callable=AsyncMock, + ) as member, + ): + result = await service.create_knowledge_space( + name="Space", + creation_request_id="req-1", + initial_permissions=_initial(), + ) + + assert result.id == 101 + assert adapter.authorized == 1 + assert len(grants.requests) == 1 + create.assert_not_called() + member.assert_not_awaited() + + +async def test_same_key_with_different_payload_conflicts_before_owner() -> None: + adapter = _Adapter() + service = _service(adapter, _InitialGrants()) + existing = _space(request_id="req-1", payload_hash="x" * 64) + with patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + return_value=existing, + ): + with pytest.raises(SpaceCreationRequestConflictError): + await service.create_knowledge_space(name="Different", creation_request_id="req-1") + + assert adapter.authorized == 0 + + +async def test_unique_key_race_loads_winner_and_skips_duplicate_side_effects() -> None: + adapter = _Adapter() + service = _service(adapter) + payload_hash = service._creation_payload_hash( + name="Space", + description=None, + icon=None, + auth_type=AuthTypeEnum.PUBLIC, + is_released=False, + auto_tag_enabled=False, + auto_tag_library_id=None, + auto_tag_custom_tags=None, + initial_permissions=None, + ) + inserted = _space(request_id="req-1", payload_hash=payload_hash) + patches = _creation_patches(inserted) + lookups = [None, inserted] + with ( + patches[0], + patches[1], + patches[2], + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeService.create_knowledge_base", + side_effect=IntegrityError("duplicate", None, Exception("duplicate")), + ), + patches[4] as member, + patches[5] as audit, + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_by_creation_request", + new_callable=AsyncMock, + side_effect=lookups, + ), + ): + result = await service.create_knowledge_space(name="Space", creation_request_id="req-1") + + assert result.id == 101 + member.assert_not_awaited() + audit.assert_not_awaited() diff --git a/src/backend/test/knowledge/test_unified_permission_update.py b/src/backend/test/knowledge/test_unified_permission_update.py new file mode 100644 index 0000000000..b8907b13ec --- /dev/null +++ b/src/backend/test/knowledge/test_unified_permission_update.py @@ -0,0 +1,138 @@ +"""F050 Knowledge settings save order and PRIVATE permission contracts.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bisheng.knowledge.domain.models.knowledge import AuthTypeEnum, Knowledge, KnowledgeState, KnowledgeTypeEnum +from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService + + +def _space() -> Knowledge: + return Knowledge( + id=101, + tenant_id=7, + user_id=11, + name="Space", + type=KnowledgeTypeEnum.SPACE.value, + state=KnowledgeState.PUBLISHED.value, + auth_type=AuthTypeEnum.PUBLIC, + model="3", + ) + + +def _service() -> KnowledgeSpaceService: + service = KnowledgeSpaceService( + request=MagicMock(), + login_user=SimpleNamespace(user_id=11, tenant_id=7), + ) + service._require_action = AsyncMock() + return service + + +async def test_business_save_failure_does_not_touch_grants_or_memberships() -> None: + service = _service() + clear = AsyncMock() + with ( + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aquery_by_id", + new=AsyncMock(return_value=_space()), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_update_space", + new=AsyncMock(side_effect=RuntimeError("save failed")), + ), + patch.object(KnowledgeSpaceService, "clear_space_authorization_for_private", new=clear), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service." + "SpaceChannelMemberDao.async_delete_non_creator_members", + new_callable=AsyncMock, + ) as remove_members, + ): + with pytest.raises(RuntimeError, match="save failed"): + await service.update_knowledge_space(101, auth_type=AuthTypeEnum.PRIVATE) + + clear.assert_not_awaited() + remove_members.assert_not_awaited() + service._require_action.assert_awaited_once_with("knowledge_space", 101, "edit") + + +async def test_private_projection_commits_before_membership_cleanup() -> None: + service = _service() + order = [] + space = _space() + + async def save(value): + order.append("business") + return value + + async def clear(**kwargs): + order.append("permission") + + async def remove_members(_space_id): + order.append("membership") + + service._authorized_space_user_ids = AsyncMock(return_value=set()) + service._list_space_child_resources = AsyncMock(return_value=[]) + service._send_space_event_notification = AsyncMock() + with ( + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aquery_by_id", + new=AsyncMock(return_value=space), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_update_space", + new=AsyncMock(side_effect=save), + ), + patch.object(KnowledgeSpaceService, "clear_space_authorization_for_private", new=clear), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.SpaceChannelMemberDao.async_get_members_by_space", + new=AsyncMock(return_value=[]), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service." + "SpaceChannelMemberDao.async_delete_non_creator_members", + new=AsyncMock(side_effect=remove_members), + ), + ): + result = await service.update_knowledge_space(101, auth_type=AuthTypeEnum.PRIVATE) + + assert result.auth_type == AuthTypeEnum.PRIVATE + assert order == ["business", "permission", "membership"] + + +async def test_private_projection_failure_preserves_membership_rows() -> None: + service = _service() + service._authorized_space_user_ids = AsyncMock(return_value=set()) + service._list_space_child_resources = AsyncMock(return_value=[]) + with ( + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aquery_by_id", + new=AsyncMock(return_value=_space()), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_update_space", + new=AsyncMock(side_effect=lambda value: value), + ), + patch.object( + KnowledgeSpaceService, + "clear_space_authorization_for_private", + new=AsyncMock(side_effect=RuntimeError("projection failed")), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.SpaceChannelMemberDao.async_get_members_by_space", + new=AsyncMock(return_value=[]), + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service." + "SpaceChannelMemberDao.async_delete_non_creator_members", + new_callable=AsyncMock, + ) as remove_members, + ): + with pytest.raises(RuntimeError, match="projection failed"): + await service.update_knowledge_space(101, auth_type=AuthTypeEnum.PRIVATE) + + remove_members.assert_not_awaited() diff --git a/src/backend/test/linsight/fixtures/fake_minio.py b/src/backend/test/linsight/fixtures/fake_minio.py new file mode 100644 index 0000000000..9250f71892 --- /dev/null +++ b/src/backend/test/linsight/fixtures/fake_minio.py @@ -0,0 +1,100 @@ +"""In-memory stand-in for ``MinioStorage``, shared by every object-store test. + +Lifted out of ``test_workspace_backend.py`` when the skill store moved from local +disk to object storage: both suites now need the same fake, and the skill store +additionally needs ``object_exists_sync`` / ``remove_object_sync``. + +Call counters (``get_calls`` / ``put_calls`` / ``exists_calls``) let a test assert +that a local cache hit performs **zero** network round-trips — the property that +makes per-task skill materialization cheap. +""" + +from __future__ import annotations + +import pytest + + +class FakeMinioStorage: + """Minimal in-memory stand-in for ``MinioStorage`` (sync + async surface).""" + + def __init__(self) -> None: + self.bucket = "bisheng" + self.tmp_bucket = "tmp-dir" + # store[(bucket, object_name)] = bytes + self.store: dict[tuple[str, str], bytes] = {} + self.minio_client_sync = _FakeRawClient(self.store, self.bucket) + self.get_calls = 0 + self.put_calls = 0 + self.exists_calls = 0 + + # async surface ---------------------------------------------------------- + async def put_object(self, *, bucket_name=None, object_name, file, **kwargs): + self.put_object_sync(bucket_name=bucket_name, object_name=object_name, file=file, **kwargs) + + async def get_object(self, bucket_name=None, object_name=None): + return self.get_object_sync(bucket_name=bucket_name, object_name=object_name) + + async def object_exists(self, bucket_name=None, object_name=None): + return self.object_exists_sync(bucket_name=bucket_name, object_name=object_name) + + async def remove_object(self, bucket_name=None, object_name=None): + self.remove_object_sync(bucket_name=bucket_name, object_name=object_name) + + # sync surface ----------------------------------------------------------- + def put_object_sync(self, *, bucket_name=None, object_name, file, **kwargs): + bucket = bucket_name or self.bucket + data = file if isinstance(file, bytes) else bytes(file) + self.store[(bucket, object_name)] = data + self.put_calls += 1 + + def get_object_sync(self, bucket_name=None, object_name=None): + bucket = bucket_name or self.bucket + self.get_calls += 1 + return self.store.get((bucket, object_name)) + + def object_exists_sync(self, bucket_name=None, object_name=None): + bucket = bucket_name or self.bucket + self.exists_calls += 1 + return (bucket, object_name) in self.store + + def remove_object_sync(self, bucket_name=None, object_name=None): + bucket = bucket_name or self.bucket + self.store.pop((bucket, object_name), None) + + # test helpers ----------------------------------------------------------- + def reset_counters(self) -> None: + self.get_calls = self.put_calls = self.exists_calls = 0 + + def keys(self, prefix: str = "") -> list[str]: + """Object names in the formal bucket, optionally filtered by prefix.""" + return sorted(name for (bucket, name) in self.store if bucket == self.bucket and name.startswith(prefix)) + + +class _FakeRawClient: + """Stands in for ``minio.Minio`` (only ``list_objects`` is used).""" + + def __init__(self, store: dict[tuple[str, str], bytes], bucket: str) -> None: + self._store = store + self._bucket = bucket + + def list_objects(self, bucket_name, prefix="", recursive=True): + for (bucket, name), data in sorted(self._store.items()): + if bucket != bucket_name: + continue + if prefix and not name.startswith(prefix): + continue + yield _FakeObject(name, len(data)) + + +class _FakeObject: + def __init__(self, object_name: str, size: int) -> None: + self.object_name = object_name + self.size = size + self.is_dir = False + self.last_modified = None + self.etag = "abc" + + +@pytest.fixture() +def fake_minio(): + return FakeMinioStorage() diff --git a/src/backend/test/linsight/test_builtin_skill_seeder.py b/src/backend/test/linsight/test_builtin_skill_seeder.py index 49012481a0..c2b4e06f38 100644 --- a/src/backend/test/linsight/test_builtin_skill_seeder.py +++ b/src/backend/test/linsight/test_builtin_skill_seeder.py @@ -13,6 +13,8 @@ from __future__ import annotations +import shutil + import pytest from bisheng.linsight.domain.models.linsight_skill import ( @@ -22,6 +24,7 @@ ) from bisheng.linsight.domain.services import builtin_skill_seeder as seeder from bisheng.linsight.domain.services.skill_store import SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage SKILL_MD_TEMPLATE = """--- name: {name} @@ -72,10 +75,20 @@ def env(tmp_path, monkeypatch): dao = _FakeDao() monkeypatch.setattr(seeder, "LinsightSkillDao", dao) - store = SkillStore(root=tmp_path / "skills_root") + store = SkillStore(root=tmp_path / "skills_root", minio=FakeMinioStorage()) return builtin, store, dao +def _is_stored(store, dao, tenant_id, name) -> bool: + """Bundle resolvable through the row's pointer — the way production reads it.""" + row = dao.rows.get((tenant_id, name)) + return bool(row) and store.exists(tenant_id, name, row.content_hash) + + +def _stored_bytes(store, dao, tenant_id, name, rel) -> bytes: + return store.read_bytes(tenant_id, name, dao.rows[(tenant_id, name)].content_hash, rel) + + def _write_bundle(builtin, name="demo-skill", description="演示技能描述", display="演示技能", extra=None): bundle = builtin / name (bundle / "scripts").mkdir(parents=True, exist_ok=True) @@ -146,7 +159,7 @@ async def test_first_seed_creates_the_row_and_writes_the_bundle(env): assert row.source == SKILL_SOURCE_BUILTIN assert row.display_name == "演示技能" assert row.enabled is True - assert store.exists(7, "demo-skill") + assert _is_stored(store, dao, 7, "demo-skill") async def test_second_run_is_a_noop_when_content_is_unchanged(env): @@ -160,6 +173,43 @@ async def test_second_run_is_a_noop_when_content_is_unchanged(env): assert dao.updates == 0 # nothing rewritten +async def test_missing_object_is_republished_even_when_the_hash_matches(env): + """Self-healing: comparing hashes alone would leave a deleted bundle broken forever. + + The byte-for-byte check this replaced repaired such a bundle on the next boot + as a side effect; confirming the object still exists keeps that property + without re-reading every file. + """ + builtin, store, dao = env + _write_bundle(builtin) + await seeder.seed_builtin_skills([7], store=store) + + # The object disappears (bucket lifecycle, operator error) while the row keeps + # advertising it, and no node has it cached. + store.minio.store.clear() + shutil.rmtree(store.root) + + stats = await seeder.seed_builtin_skills([7], store=store) + + assert stats == {"updated": 1} + assert _is_stored(store, dao, 7, "demo-skill") + + +async def test_a_non_uniqueness_db_error_is_not_swallowed_as_a_race(env, monkeypatch): + """Only the unique-key collision means "another replica won".""" + builtin, store, dao = env + _write_bundle(builtin) + + async def _boom(_skill): + raise RuntimeError("connection reset") + + monkeypatch.setattr(dao, "create", _boom) + + stats = await seeder.seed_builtin_skills([7], store=store) + + assert stats == {"failed": 1} # not "raced" + + async def test_changed_content_refreshes_the_bundle(env): """An upgraded image must update the installed skill on the next restart.""" builtin, store, dao = env @@ -171,7 +221,7 @@ async def test_changed_content_refreshes_the_bundle(env): assert stats == {"updated": 1} assert dao.rows[(7, "demo-skill")].description == "改过的描述" - assert store.read_bytes(7, "demo-skill", "scripts/helper.py") == b"print('v2')\n" + assert _stored_bytes(store, dao, 7, "demo-skill", "scripts/helper.py") == b"print('v2')\n" async def test_a_tenant_edit_opts_out_of_reseeding_forever(env): @@ -179,30 +229,33 @@ async def test_a_tenant_edit_opts_out_of_reseeding_forever(env): _write_bundle(builtin) await seeder.seed_builtin_skills([7], store=store) - # The tenant edits it through the API -> SkillService._mark_forked - dao.rows[(7, "demo-skill")].source = SKILL_SOURCE_MANUAL - store.write_bundle(7, "demo-skill", {"SKILL.md": b"---\nname: demo-skill\ndescription: mine\n---\n\nmine"}) + # The tenant edits it through the API -> SkillService._mark_forked, which also + # repoints the row at the new version. + edited = store.write_bundle(7, "demo-skill", {"SKILL.md": b"---\nname: demo-skill\ndescription: mine\n---\n\nmine"}) + row = dao.rows[(7, "demo-skill")] + row.source = SKILL_SOURCE_MANUAL + row.content_hash, row.object_path = edited.content_hash, edited.object_key _write_bundle(builtin, description="上游又改了", extra="print('v3')\n") stats = await seeder.seed_builtin_skills([7], store=store) assert stats == {"forked": 1} - assert b"mine" in store.read_bytes(7, "demo-skill", "SKILL.md") + assert b"mine" in _stored_bytes(store, dao, 7, "demo-skill", "SKILL.md") async def test_every_tenant_gets_its_own_copy(env): - builtin, store, _dao = env + builtin, store, dao = env _write_bundle(builtin) stats = await seeder.seed_builtin_skills([1, 2, 3], store=store) assert stats == {"created": 3} - assert store.exists(1, "demo-skill") and store.exists(2, "demo-skill") and store.exists(3, "demo-skill") + assert all(_is_stored(store, dao, t, "demo-skill") for t in (1, 2, 3)) async def test_falls_back_to_the_default_tenant_when_none_are_active(env, monkeypatch): """Single-tenant deployments have no rows in the tenant table.""" - builtin, store, _dao = env + builtin, store, dao = env _write_bundle(builtin) class _NoTenants: @@ -215,11 +268,11 @@ async def aget_active_ids(): stats = await seeder.seed_builtin_skills(store=store) assert stats == {"created": 1} - assert store.exists(1, "demo-skill") + assert _is_stored(store, dao, 1, "demo-skill") async def test_one_broken_bundle_does_not_stop_the_others(env, monkeypatch): - builtin, store, _dao = env + builtin, store, dao = env _write_bundle(builtin, name="good-skill", display="好技能") _write_bundle(builtin, name="bad-skill", display="坏技能") @@ -235,14 +288,14 @@ def _explode(tenant_id, name, files): stats = await seeder.seed_builtin_skills([7], store=store) assert stats == {"created": 1, "failed": 1} - assert store.exists(7, "good-skill") + assert _is_stored(store, dao, 7, "good-skill") async def test_tenant_context_is_restored_after_seeding(env): """Seeding runs inside per-tenant context; it must not leak into the caller's.""" from bisheng.core.context.tenant import current_tenant_id, set_current_tenant_id - builtin, store, _dao = env + builtin, store, dao = env _write_bundle(builtin) token = set_current_tenant_id(99) diff --git a/src/backend/test/linsight/test_code_interpreter_log_cap.py b/src/backend/test/linsight/test_code_interpreter_log_cap.py new file mode 100644 index 0000000000..b51c4533a3 --- /dev/null +++ b/src/backend/test/linsight/test_code_interpreter_log_cap.py @@ -0,0 +1,106 @@ +"""Success-path log capping in the code interpreter. + +The failure path has had a cap since it was written; the SUCCESS path had none, and +that is what started the 2026-08-14 incident: a 112744-byte result crossed deepagents' +eviction threshold, got replaced by a preview that (because ToolNode json.dumps'es the +dict into ONE line) showed only its first 1000 characters, and the model — unable to +see file_list or anything past the opening — re-sent the identical script 79 times. + +Capping keeps the output INSIDE the context instead of behind a pointer the model does +not follow. These tests pin the three properties that make that safe: both ends +survive, the advisory survives, and ordinary output is untouched. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +import json + +from bisheng_langchain.gpts.tools.code_interpreter.base_executor import ( + MAX_SUCCESS_LOG_CHARS, + clip_middle, +) + + +def _oversized(n=200_000): + return "HEAD-MARKER\n" + ("x" * n) + "\nTAIL-MARKER" + + +# --------------------------------------------------------------------------- clip_middle + + +def test_short_log_is_byte_identical(): + """Ordinary runs must not change at all — measured p90 is ~8000 chars.""" + log = "\n".join(f"row {i}" for i in range(200)) + assert clip_middle(log) == log + + +def test_exactly_at_limit_is_untouched(): + log = "y" * MAX_SUCCESS_LOG_CHARS + assert clip_middle(log) == log + + +def test_both_ends_survive(): + """Unlike the failure path's ``_tail``, the head must survive: for a succeeding + run it holds what the script printed, while the tail holds the advisories.""" + out = clip_middle(_oversized()) + assert out.startswith("HEAD-MARKER") + assert out.endswith("TAIL-MARKER") + + +def test_omission_is_stated_with_a_count_and_a_way_forward(): + out = clip_middle(_oversized()) + omitted = len(_oversized()) - MAX_SUCCESS_LOG_CHARS + assert str(omitted) in out + # Naming the cause is the point: a bare truncation reads as an infrastructure + # hiccup and invites a verbatim retry (same lesson as TIMEOUT_MSG in this package). + assert "Re-running this code will NOT return more" in out + assert "scratch/" in out + + +def test_clipped_length_is_bounded(): + out = clip_middle(_oversized()) + assert len(out) <= MAX_SUCCESS_LOG_CHARS + 400 # payload + the notice itself + + +# ------------------------------------------------------- the reason the cap exists + + +def test_capped_result_stays_below_the_deepagents_eviction_threshold(): + """THE regression pin. + + Reads the threshold from deepagents rather than hardcoding it, so this goes red + if upstream lowers its default or if someone raises MAX_SUCCESS_LOG_CHARS past + what the eviction path tolerates. Serialization mirrors langgraph's ToolNode. + """ + from inspect import signature + + from deepagents.middleware.filesystem import NUM_CHARS_PER_TOKEN, FilesystemMiddleware + + eviction_default = signature(FilesystemMiddleware.__init__).parameters["tool_token_limit_before_evict"].default + result = { + "exitcode": 0, + "log": clip_middle(_oversized()), + # file_list is never clipped, so budget for realistic MinIO URLs too. + "file_list": [f"http://minio.local/bisheng/workspace/{'a' * 32}/output/chart{i}.png" for i in range(20)], + } + serialized = json.dumps(result, ensure_ascii=False) + assert len(serialized) < NUM_CHARS_PER_TOKEN * eviction_default + + +def test_uncapped_result_would_have_been_evicted(): + """Proves the test above is actually load-bearing rather than trivially true.""" + from inspect import signature + + from deepagents.middleware.filesystem import NUM_CHARS_PER_TOKEN, FilesystemMiddleware + + eviction_default = signature(FilesystemMiddleware.__init__).parameters["tool_token_limit_before_evict"].default + raw = json.dumps({"exitcode": 0, "log": _oversized(), "file_list": []}, ensure_ascii=False) + assert len(raw) > NUM_CHARS_PER_TOKEN * eviction_default + + +def test_json_dumps_makes_the_result_a_single_line(): + """Pins the mechanism that made the upstream preview useless: newlines in the log + become literal ``\\n``, so the whole payload is one line and the head/tail preview + degrades to the first 1000 characters.""" + serialized = json.dumps({"exitcode": 0, "log": "a\nb\nc"}, ensure_ascii=False) + assert len(serialized.splitlines()) == 1 diff --git a/src/backend/test/linsight/test_final_result_selection.py b/src/backend/test/linsight/test_final_result_selection.py index 66d84a9e8a..1cd438cc33 100644 --- a/src/backend/test/linsight/test_final_result_selection.py +++ b/src/backend/test/linsight/test_final_result_selection.py @@ -58,6 +58,25 @@ def test_scratch_uploads_and_skills_are_never_deliverables(): assert select_deliverables(details, baseline_paths=baseline) == [] +def test_deepagents_spill_never_becomes_the_result(): + """Regression (114, 2026-08-14): deepagents writes its own overflow through the + same WorkspaceBackend — offloaded tool results into ``large_tool_results/`` and + evicted history into ``conversation_history/``. The looping session that day ended + with an empty ``output/``, so had it hit recursion_limit instead of being stopped + by hand, criterion 2 would have handed the user a raw tool dump as the deliverable. + Worse, an offloaded file is named after a tool_call_id and usually has NO + extension, which sorts it AHEAD of any real .png chart.""" + details = [ + _detail("large_tool_results/bisheng_code_interpreter:0", mtime=9.0), + _detail("conversation_history/db2b91b9d14d492da265e75715aa65bd.md", mtime=8.0), + ] + assert select_deliverables(details, baseline_paths=set()) == [] + + # a real chart alongside the spill wins, and wins alone + details.append(_detail("output/chart.png", mtime=1.0)) + assert _names(select_deliverables(details, baseline_paths=set())) == ["output/chart.png"] + + def test_provisioned_skill_bundles_never_become_the_result(): """Regression (114, 2026-07-28): skill bundles are copied into the workspace at task START — after the baseline snapshot — so criterion 2 saw ~100 SKILL.md / diff --git a/src/backend/test/linsight/test_github_skill_import.py b/src/backend/test/linsight/test_github_skill_import.py index d09e15f9c9..84a67ca4b3 100644 --- a/src/backend/test/linsight/test_github_skill_import.py +++ b/src/backend/test/linsight/test_github_skill_import.py @@ -25,6 +25,8 @@ fetch_skill_files, parse_github_url, ) +from test.linsight.fixtures.fake_minio import FakeMinioStorage + from bisheng.linsight.domain.services.skill_store import MAX_UNPACKED_SIZE, SKILL_MD, SkillStore TENANT = 1 @@ -249,7 +251,7 @@ def service(tmp_path, monkeypatch): monkeypatch.setattr(service_module, "LinsightSkillDao", _FakeSkillDao) owner_projection = SimpleNamespace(authorize_created=AsyncMock()) return service_module.SkillService( - store=SkillStore(root=tmp_path), + store=SkillStore(root=tmp_path, minio=FakeMinioStorage()), owner_projection=owner_projection, ) diff --git a/src/backend/test/linsight/test_llm_error_classifier.py b/src/backend/test/linsight/test_llm_error_classifier.py index d986d070be..f11744cc95 100644 --- a/src/backend/test/linsight/test_llm_error_classifier.py +++ b/src/backend/test/linsight/test_llm_error_classifier.py @@ -133,6 +133,55 @@ def test_quota_label(): assert label_error(exc) is ErrorType.QUOTA_EXHAUSTED +def _relay_pre_consume_403(): + """The verbatim 403 a one-api/new-api style relay returns when its PRE-DEDUCTED + cost estimate exceeds the remaining balance (114, 2026-08-14, kimi-k3).""" + return make_exc( + openai.PermissionDeniedError, + message=( + "Error code: 403 - {'error': {'message': 'token quota is not enough, " + "token remain quota: $0.073168, need quota: $0.173140 " + "(request id: 20260814123904962529180kpTepsN4)', 'type': 'api_error', " + "'param': '', 'code': 'pre_consume_token_quota_failed'}, 'id': 123}" + ), + code="pre_consume_token_quota_failed", + status_code=403, + ) + + +def test_relay_pre_consume_403_is_quota_not_auth(): + """Regression: this used to fall through to the auth branch, so the user was + told "凭证无效或没有权限 / 检查模型配置" when the only remedy was topping up. + + It is money wording but arrives as 403 (not the 429 this bucket was written + around) and matches none of the arrears/balance strings, which is exactly how + it slipped past. + """ + assert label_error(_relay_pre_consume_403()) is ErrorType.QUOTA_EXHAUSTED + + +def test_relay_pre_consume_403_still_fails_fast(): + """Behaviour must not change: retrying a balance shortfall is pointless.""" + assert classify_behavior(_relay_pre_consume_403()) is Behavior.FAIL_FAST + + +def test_relay_quota_signal_survives_in_body_only(): + """Some relays put the code only in the body — _exc_text folds body values in.""" + exc = make_exc( + openai.PermissionDeniedError, + message="request failed", + body={"error": {"code": "pre_consume_token_quota_failed"}}, + status_code=403, + ) + assert label_error(exc) is ErrorType.QUOTA_EXHAUSTED + + +def test_plain_403_is_still_auth_error(): + """The narrow signals must not swallow genuine credential/permission failures.""" + exc = make_exc(openai.PermissionDeniedError, message="invalid api key", status_code=403) + assert label_error(exc) is ErrorType.AUTH_ERROR + + def test_network_and_service_labels(): assert label_error(make_exc(openai.APITimeoutError, message="t")) is ErrorType.NETWORK_TIMEOUT assert label_error(make_exc(openai.InternalServerError, status_code=500)) is ErrorType.SERVICE_UNAVAILABLE diff --git a/src/backend/test/linsight/test_migrate_sop_to_skill.py b/src/backend/test/linsight/test_migrate_sop_to_skill.py index 74ddb6c098..5b93e78921 100644 --- a/src/backend/test/linsight/test_migrate_sop_to_skill.py +++ b/src/backend/test/linsight/test_migrate_sop_to_skill.py @@ -7,6 +7,8 @@ import pytest from bisheng.linsight.domain.models.linsight_sop import LinsightSOP +from test.linsight.fixtures.fake_minio import FakeMinioStorage + from bisheng.linsight.domain.services.skill_store import SkillStore, parse_skill_md from test.linsight.test_skill_service import FakeSkillDao @@ -38,7 +40,7 @@ def _report() -> dict: def env(tmp_path, monkeypatch): FakeSkillDao.reset() monkeypatch.setattr(script, "LinsightSkillDao", FakeSkillDao) - return SkillStore(root=tmp_path) + return SkillStore(root=tmp_path, minio=FakeMinioStorage()) async def _run_tenant(store, sops, apply=True): @@ -71,7 +73,7 @@ async def test_happy_path_chinese_name(self, env): assert entry["display_name"] == "标书撰写流程" row = FakeSkillDao.rows["biao-shu-zhuan-xie-liu-cheng"] assert row.source == "sop_migrated" and row.enabled - meta, body = parse_skill_md(env.read_text(TENANT, row.name)) + meta, body = parse_skill_md(env.read_text(TENANT, row.name, row.content_hash)) assert meta["metadata"]["sop-id"] == "17" assert meta["metadata"]["display-name"] == "标书撰写流程" assert body.strip() == "# SOP 正文" @@ -123,4 +125,4 @@ async def test_dry_run_writes_nothing(self, env): report = await _run_tenant(env, [_sop(17, "标书撰写流程")], apply=False) assert len(report["success"]) == 1 assert not FakeSkillDao.rows - assert not env.exists(TENANT, "biao-shu-zhuan-xie-liu-cheng") + assert env.minio.keys("linsight/skills/") == [] # dry-run wrote nothing diff --git a/src/backend/test/linsight/test_skill_api.py b/src/backend/test/linsight/test_skill_api.py index ab8d2573c0..2d0938466b 100644 --- a/src/backend/test/linsight/test_skill_api.py +++ b/src/backend/test/linsight/test_skill_api.py @@ -21,6 +21,8 @@ from bisheng.linsight.api.endpoints import skill as skill_endpoints from bisheng.linsight.domain.services import skill_service as service_module from bisheng.linsight.domain.services.skill_service import SkillService +from test.linsight.fixtures.fake_minio import FakeMinioStorage + from bisheng.linsight.domain.services.skill_store import MAX_BUNDLE_SIZE, SkillStore from test.linsight.test_skill_service import FakeSkillDao @@ -47,7 +49,7 @@ def client(tmp_path, monkeypatch): skill_endpoints, "SkillService", lambda: SkillService( - store=SkillStore(root=tmp_path), + store=SkillStore(root=tmp_path, minio=FakeMinioStorage()), owner_projection=owner_projection, ), ) diff --git a/src/backend/test/linsight/test_skill_bundle_backfill.py b/src/backend/test/linsight/test_skill_bundle_backfill.py new file mode 100644 index 0000000000..0e0c4793a6 --- /dev/null +++ b/src/backend/test/linsight/test_skill_bundle_backfill.py @@ -0,0 +1,139 @@ +"""Startup self-heal: publish bundles an upgraded host still holds locally. + +The rule under test is deliberately conservative. Several API replicas boot at +once, each with a different local disk; if any of them published whatever copy it +happened to have, the winner would be arbitrary — which is the very multi-node +inconsistency this migration exists to remove. So a bundle is published only when +this host can prove it matches what the row recorded, and everything else is +escalated to the operator by name. +""" + +from __future__ import annotations + +import pytest + +from bisheng.linsight.domain.models.linsight_skill import ( + SKILL_SOURCE_BUILTIN, + SKILL_SOURCE_MANUAL, + LinsightSkill, +) +from bisheng.linsight.domain.services import skill_bundle_backfill as backfill +from bisheng.linsight.domain.services.skill_store import LEGACY_TENANT_SKILLS_DIR, SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 1 +SKILL_BODY = b"---\nname: demo-skill\ndescription: d\n---\n\nbody" + + +class _FakeDao: + def __init__(self, rows): + self.rows = rows + self.updates = 0 + + async def get_page(self, page=1, page_size=100): + return list(self.rows), len(self.rows) + + async def update(self, skill): + self.updates += 1 + return skill + + +def _row(name="demo-skill", *, size=len(SKILL_BODY), source=SKILL_SOURCE_MANUAL, content_hash=""): + return LinsightSkill( + id=1, + tenant_id=TENANT, + name=name, + display_name=name, + description="d", + enabled=True, + source=source, + object_path="", + content_hash=content_hash, + size=size, + ) + + +def _write_legacy(root, tenant_id, name, body=SKILL_BODY): + base = root / LEGACY_TENANT_SKILLS_DIR / str(tenant_id) / name + base.mkdir(parents=True, exist_ok=True) + (base / "SKILL.md").write_bytes(body) + return base + + +@pytest.fixture +def env(tmp_path, monkeypatch): + legacy_root = tmp_path / "legacy" + + class _Conf: + skills_root = str(legacy_root) + + class _Settings: + @staticmethod + def get_linsight_conf(): + return _Conf() + + monkeypatch.setattr(backfill, "bisheng_settings", _Settings) + store = SkillStore(root=tmp_path / "cache", minio=FakeMinioStorage()) + return legacy_root, store + + +async def test_publishes_a_bundle_this_host_holds(env, monkeypatch): + legacy_root, store = env + _write_legacy(legacy_root, TENANT, "demo-skill") + row = _row() + dao = _FakeDao([row]) + monkeypatch.setattr(backfill, "LinsightSkillDao", dao) + + stats = await backfill.backfill_skill_bundles_from_local_disk(store=store) + + assert stats["published"] == 1 + assert row.content_hash and row.object_path.endswith(f"{row.content_hash}.zip") + assert store.read_bytes(TENANT, "demo-skill", row.content_hash, "SKILL.md") == SKILL_BODY + + +async def test_a_row_already_pointing_at_storage_is_untouched(env, monkeypatch): + legacy_root, store = env + _write_legacy(legacy_root, TENANT, "demo-skill") + dao = _FakeDao([_row(content_hash="deadbeef")]) + monkeypatch.setattr(backfill, "LinsightSkillDao", dao) + + stats = await backfill.backfill_skill_bundles_from_local_disk(store=store) + + assert stats["published"] == 0 and dao.updates == 0 + + +async def test_builtin_rows_are_left_to_the_seeder(env, monkeypatch): + """The image is a better source than any single host's disk.""" + legacy_root, store = env + _write_legacy(legacy_root, TENANT, "demo-skill") + dao = _FakeDao([_row(source=SKILL_SOURCE_BUILTIN)]) + monkeypatch.setattr(backfill, "LinsightSkillDao", dao) + + stats = await backfill.backfill_skill_bundles_from_local_disk(store=store) + + assert (stats["published"], stats["skipped_builtin"]) == (0, 1) + assert dao.updates == 0 + + +async def test_a_local_copy_that_disagrees_with_the_row_is_not_published(env, monkeypatch): + """Publishing a partial/stale copy would make an arbitrary guess durable.""" + legacy_root, store = env + _write_legacy(legacy_root, TENANT, "demo-skill", body=b"truncated") + dao = _FakeDao([_row(size=len(SKILL_BODY))]) # row remembers the full size + monkeypatch.setattr(backfill, "LinsightSkillDao", dao) + + stats = await backfill.backfill_skill_bundles_from_local_disk(store=store) + + assert (stats["published"], stats["size_mismatch"]) == (0, 1) + assert dao.updates == 0 + + +async def test_a_bundle_held_by_another_host_is_reported_not_invented(env, monkeypatch): + legacy_root, store = env # nothing written locally + dao = _FakeDao([_row()]) + monkeypatch.setattr(backfill, "LinsightSkillDao", dao) + + stats = await backfill.backfill_skill_bundles_from_local_disk(store=store) + + assert (stats["published"], stats["elsewhere"]) == (0, 1) + assert dao.updates == 0 diff --git a/src/backend/test/linsight/test_skill_middleware.py b/src/backend/test/linsight/test_skill_middleware.py deleted file mode 100644 index 63c17953b5..0000000000 --- a/src/backend/test/linsight/test_skill_middleware.py +++ /dev/null @@ -1,101 +0,0 @@ -"""F035 Track D — TenantSkillsMiddleware whitelist tests (TD-3, deviation D8). - -Real SKILL.md files on a tmp SKILLS_ROOT, real deepagents SkillsMiddleware -loading; only the DAO is bypassed (enabled_names passed explicitly). -Contract C3 semantics under test: - -- built-in skills always pass, regardless of active_skills; -- tenant custom skills require governance-enabled AND per-run whitelist; -- active_skills == [] disables every custom skill; -- missing active_skills (non-UI callers) keeps all enabled custom skills. -""" - -from unittest.mock import MagicMock - -import pytest - -from bisheng.linsight.domain.services import skill_middleware as mw_module -from bisheng.linsight.domain.services.skill_middleware import TenantSkillsMiddleware, make_skills_middleware -from bisheng.linsight.domain.services.skill_store import SkillStore - -TENANT = 1 - - -def _write_skill(base, name: str, display_name: str = ""): - d = base / name - d.mkdir(parents=True, exist_ok=True) - meta_block = f"metadata:\n display-name: {display_name}\n" if display_name else "" - (d / "SKILL.md").write_text( - f"---\nname: {name}\ndescription: desc of {name}\n{meta_block}---\n\n# {name}\n", - encoding="utf-8", - ) - - -@pytest.fixture -def store(tmp_path): - s = SkillStore(root=tmp_path) - _write_skill(s.builtin_dir(), "kernel-core") - _write_skill(s.tenant_dir(TENANT), "biao-shu-zhuan-xie", "标书撰写") - _write_skill(s.tenant_dir(TENANT), "he-tong-shen-yue", "合同审阅") - _write_skill(s.tenant_dir(TENANT), "ting-yong-ji-neng", "已停用技能") - return s - - -def _names(update) -> set[str]: - return {s["name"] for s in update["skills_metadata"]} - - -def _run(store, enabled_names, active_skills="__missing__") -> set[str]: - middleware = TenantSkillsMiddleware(tenant_id=TENANT, enabled_names=enabled_names, store=store) - configurable = {} if active_skills == "__missing__" else {"active_skills": active_skills} - update = middleware.before_agent({}, MagicMock(), {"configurable": configurable}) - return _names(update) - - -ENABLED = {"biao-shu-zhuan-xie", "he-tong-shen-yue"} # ting-yong-ji-neng disabled in DB - - -class TestWhitelist: - def test_whitelist_filters_custom_skills(self, store): - assert _run(store, ENABLED, ["biao-shu-zhuan-xie"]) == {"kernel-core", "biao-shu-zhuan-xie"} - - def test_empty_whitelist_disables_all_custom_but_keeps_builtin(self, store): - assert _run(store, ENABLED, []) == {"kernel-core"} - - def test_missing_whitelist_keeps_all_enabled(self, store): - assert _run(store, ENABLED) == {"kernel-core", *ENABLED} - - def test_db_disabled_skill_excluded_even_if_whitelisted(self, store): - assert _run(store, ENABLED, ["ting-yong-ji-neng", "biao-shu-zhuan-xie"]) == { - "kernel-core", - "biao-shu-zhuan-xie", - } - - def test_builtin_not_affected_by_whitelist_content(self, store): - # whitelisting the built-in name is a no-op: it always passes anyway - assert "kernel-core" in _run(store, ENABLED, ["kernel-core"]) - - def test_skip_when_state_already_loaded(self, store): - middleware = TenantSkillsMiddleware(tenant_id=TENANT, enabled_names=ENABLED, store=store) - # deepagents skips reloading when skills_metadata is already in state - update = middleware.before_agent({"skills_metadata": []}, MagicMock(), {"configurable": {}}) - assert update is None - - async def test_async_path_matches_sync(self, store): - middleware = TenantSkillsMiddleware(tenant_id=TENANT, enabled_names=ENABLED, store=store) - update = await middleware.abefore_agent({}, MagicMock(), {"configurable": {"active_skills": []}}) - assert _names(update) == {"kernel-core"} - - -class TestFactory: - async def test_make_skills_middleware_resolves_enabled_from_dao(self, store, monkeypatch): - row = MagicMock() - row.name = "biao-shu-zhuan-xie" - - async def fake_list_enabled(): - return [row] - - monkeypatch.setattr(mw_module.LinsightSkillDao, "list_enabled", fake_list_enabled) - middleware = await make_skills_middleware(TENANT, store=store) - update = middleware.before_agent({}, MagicMock(), {"configurable": {}}) - assert _names(update) == {"kernel-core", "biao-shu-zhuan-xie"} diff --git a/src/backend/test/linsight/test_skill_provisioning.py b/src/backend/test/linsight/test_skill_provisioning.py index 5df92afc7a..3b4bf5eaf7 100644 --- a/src/backend/test/linsight/test_skill_provisioning.py +++ b/src/backend/test/linsight/test_skill_provisioning.py @@ -20,6 +20,7 @@ from __future__ import annotations +import shutil from pathlib import Path from unittest.mock import MagicMock @@ -28,6 +29,7 @@ from bisheng.linsight.domain.services import skill_provisioning from bisheng.linsight.domain.services.skill_provisioning import WORKSPACE_SKILLS_DIR, materialize_session_skills from bisheng.linsight.domain.services.skill_store import SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage TENANT = 1 OTHER_TENANT = 2 @@ -36,17 +38,22 @@ BINARY_ASSET = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe\xfd\x00template" -def _write_skill(base: Path, name: str, *, assets: dict[str, bytes] | None = None) -> None: - d = base / name - d.mkdir(parents=True, exist_ok=True) - (d / "SKILL.md").write_text( - f"---\nname: {name}\ndescription: desc of {name}\n---\n\n# {name}\n", - encoding="utf-8", - ) - for rel, data in (assets or {}).items(): - target = d / rel - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(data) +def _write_skill(store: SkillStore, tenant_id: int, name: str, *, assets: dict[str, bytes] | None = None) -> str: + """Seed a bundle through the store's own writer; returns its content hash. + + Goes through ``write_bundle`` rather than poking storage directly, so these + tests keep exercising the real persistence path. The hash is also recorded on + the store so ``_patch_enabled`` can hand it back the way a DB row would. + """ + files: dict[str, bytes] = { + "SKILL.md": f"---\nname: {name}\ndescription: desc of {name}\n---\n\n# {name}\n".encode(), + } + files.update(assets or {}) + ref = store.write_bundle(tenant_id, name, files) + if not hasattr(store, "written_hashes"): + store.written_hashes = {} + store.written_hashes[name] = ref.content_hash + return ref.content_hash class _Resp: @@ -81,10 +88,10 @@ async def aupload_files(self, files): @pytest.fixture def store(tmp_path) -> SkillStore: - s = SkillStore(root=tmp_path / "skills_root") - _write_skill(s.tenant_dir(TENANT), "biao-shu-zhuan-xie") - _write_skill(s.tenant_dir(TENANT), "he-tong-shen-yue") - _write_skill(s.tenant_dir(TENANT), "ting-yong-ji-neng") # exists on disk but DB-disabled + s = SkillStore(root=tmp_path / "skills_root", minio=FakeMinioStorage()) + _write_skill(s, TENANT, "biao-shu-zhuan-xie") + _write_skill(s, TENANT, "he-tong-shen-yue") + _write_skill(s, TENANT, "ting-yong-ji-neng") # stored, but DB-disabled return s @@ -94,15 +101,23 @@ def backend(tmp_path) -> _CacheBackend: class _EnabledSkill: - """Stand-in for a LinsightSkill row (only ``.name`` is read).""" + """Stand-in for a LinsightSkill row (``.name`` + the pointer to its bundle).""" - def __init__(self, name: str): + def __init__(self, name: str, content_hash: str = ""): self.name = name + self.content_hash = content_hash -def _patch_enabled(monkeypatch, names: set[str]) -> None: +def _patch_enabled(monkeypatch, names: set[str], store: SkillStore | None = None) -> None: + """Fake the governance query, resolving each name's stored content hash. + + Provisioning locates a bundle by the hash on the row, so the stub has to carry + the same one the store wrote — exactly like the real DAO would. + """ + hashes = dict(getattr(store, "written_hashes", {})) if store is not None else {} + async def _fake_list_enabled(): - return [_EnabledSkill(n) for n in names] + return [_EnabledSkill(n, hashes.get(n, "")) for n in names] monkeypatch.setattr(skill_provisioning.LinsightSkillDao, "list_enabled", _fake_list_enabled) @@ -116,15 +131,15 @@ def _copied_rel_paths(backend: _CacheBackend) -> set[str]: class TestGate: async def test_selected_subset_copies_only_those(self, monkeypatch, store, backend): - _patch_enabled(monkeypatch, ENABLED) - copied = await materialize_session_skills(backend, TENANT, ["biao-shu-zhuan-xie"], store=store) - assert copied == ["biao-shu-zhuan-xie"] + _patch_enabled(monkeypatch, ENABLED, store) + result = await materialize_session_skills(backend, TENANT, ["biao-shu-zhuan-xie"], store=store) + assert result.copied == ["biao-shu-zhuan-xie"] assert _copied_rel_paths(backend) == {"/skills/biao-shu-zhuan-xie/SKILL.md"} async def test_empty_selection_copies_nothing(self, monkeypatch, store, backend): - _patch_enabled(monkeypatch, ENABLED) - copied = await materialize_session_skills(backend, TENANT, [], store=store) - assert copied == [] + _patch_enabled(monkeypatch, ENABLED, store) + result = await materialize_session_skills(backend, TENANT, [], store=store) + assert result.copied == [] assert backend.uploaded == [] async def test_none_selection_copies_nothing(self, monkeypatch, store, backend): @@ -132,32 +147,32 @@ async def test_none_selection_copies_nothing(self, monkeypatch, store, backend): # Regression guard: None used to mean "copy every enabled skill", which # silently loaded ALL skills for any request that omitted the field # (stale/cached client, non-UI caller, legacy row), defeating the picker. - _patch_enabled(monkeypatch, ENABLED) - copied = await materialize_session_skills(backend, TENANT, None, store=store) - assert copied == [] + _patch_enabled(monkeypatch, ENABLED, store) + result = await materialize_session_skills(backend, TENANT, None, store=store) + assert result.copied == [] assert backend.uploaded == [] async def test_db_disabled_skill_never_copied_even_if_selected(self, monkeypatch, store, backend): - _patch_enabled(monkeypatch, ENABLED) - copied = await materialize_session_skills( + _patch_enabled(monkeypatch, ENABLED, store) + result = await materialize_session_skills( backend, TENANT, ["ting-yong-ji-neng", "biao-shu-zhuan-xie"], store=store ) - assert copied == ["biao-shu-zhuan-xie"] + assert result.copied == ["biao-shu-zhuan-xie"] async def test_unknown_selected_name_ignored(self, monkeypatch, store, backend): - _patch_enabled(monkeypatch, ENABLED) - copied = await materialize_session_skills(backend, TENANT, ["does-not-exist"], store=store) - assert copied == [] + _patch_enabled(monkeypatch, ENABLED, store) + result = await materialize_session_skills(backend, TENANT, ["does-not-exist"], store=store) + assert result.copied == [] class TestByteFidelity: async def test_binary_asset_copied_losslessly(self, monkeypatch, tmp_path, backend): - store = SkillStore(root=tmp_path / "skills_root") - _write_skill(store.tenant_dir(TENANT), "with-asset", assets={"templates/logo.png": BINARY_ASSET}) - _patch_enabled(monkeypatch, {"with-asset"}) + store = SkillStore(root=tmp_path / "skills_root", minio=FakeMinioStorage()) + _write_skill(store, TENANT, "with-asset", assets={"templates/logo.png": BINARY_ASSET}) + _patch_enabled(monkeypatch, {"with-asset"}, store) - copied = await materialize_session_skills(backend, TENANT, ["with-asset"], store=store) - assert copied == ["with-asset"] + result = await materialize_session_skills(backend, TENANT, ["with-asset"], store=store) + assert result.copied == ["with-asset"] # The binary asset round-trips byte-identical (read_bytes, not lossy read_text). cached = backend.file_dir / "skills" / "with-asset" / "templates" / "logo.png" assert cached.read_bytes() == BINARY_ASSET @@ -166,14 +181,79 @@ async def test_binary_asset_copied_losslessly(self, monkeypatch, tmp_path, backe class TestCrossTenant: async def test_other_tenant_cannot_read_disk_bundle(self, monkeypatch, store, backend): # DAO gate is tenant-scoped in production (strict_tenant_filter); here even if - # the name were "enabled", the on-disk source path is keyed by tenant_id, so a - # different tenant resolves an empty bundle and copies nothing. - _patch_enabled(monkeypatch, {"biao-shu-zhuan-xie"}) - copied = await materialize_session_skills(backend, OTHER_TENANT, ["biao-shu-zhuan-xie"], store=store) - assert copied == [] + # the name were "enabled", the bundle's object key is scoped by tenant_id, so a + # different tenant resolves nothing and copies nothing. + _patch_enabled(monkeypatch, {"biao-shu-zhuan-xie"}, store) + result = await materialize_session_skills(backend, OTHER_TENANT, ["biao-shu-zhuan-xie"], store=store) + assert result.copied == [] assert backend.uploaded == [] +class TestAcrossNodes: + """The defect this migration exists to fix, reproduced at unit scale. + + Two SkillStore instances with *disjoint* local cache roots stand in for two + hosts. Under the previous local-disk storage the second one saw an empty + directory and skipped the skill; sharing only the object store must now be + enough. + """ + + async def test_a_worker_that_never_saw_the_upload_still_gets_the_skill(self, monkeypatch, tmp_path, backend, store): + # Same object storage, a cache root that has never held this bundle. + other_node = SkillStore(root=tmp_path / "worker-node", minio=store.minio) + other_node.written_hashes = store.written_hashes + _patch_enabled(monkeypatch, ENABLED, store) + + result = await materialize_session_skills(backend, TENANT, ["biao-shu-zhuan-xie"], store=other_node) + + assert result.copied == ["biao-shu-zhuan-xie"] + assert (backend.file_dir / "skills" / "biao-shu-zhuan-xie" / "SKILL.md").exists() + + +class TestFailureIsReported: + """A skill the user picked but that cannot load must not vanish silently. + + This is the failure mode the object-storage migration exists to kill: under + the old local-disk layout a worker on another host logged one warning and ran + the task without the skill, which is indistinguishable from "not selected". + """ + + async def test_unreachable_bundle_is_reported_not_swallowed(self, monkeypatch, store, backend): + _patch_enabled(monkeypatch, ENABLED, store) + # The row still points at a bundle, but the object is gone. + store.minio.store.clear() + shutil.rmtree(store.root) + + result = await materialize_session_skills(backend, TENANT, ["biao-shu-zhuan-xie"], store=store) + + assert result.copied == [] + assert result.failed == ["biao-shu-zhuan-xie"] + assert backend.uploaded == [] + + async def test_one_broken_skill_does_not_block_a_working_one(self, monkeypatch, store, backend): + _patch_enabled(monkeypatch, ENABLED, store) + broken_key = store.object_key(TENANT, "he-tong-shen-yue", store.written_hashes["he-tong-shen-yue"]) + store.minio.store.pop((store.minio.bucket, broken_key)) + shutil.rmtree(store.cache_dir(TENANT, "he-tong-shen-yue", store.written_hashes["he-tong-shen-yue"])) + + result = await materialize_session_skills( + backend, TENANT, ["biao-shu-zhuan-xie", "he-tong-shen-yue"], store=store + ) + + assert result.copied == ["biao-shu-zhuan-xie"] + assert result.failed == ["he-tong-shen-yue"] + + async def test_upload_error_counts_as_failure(self, monkeypatch, store, backend): + _patch_enabled(monkeypatch, ENABLED, store) + + async def _failing_upload(files): + return [_Resp(path, error="disk full") for path, _ in files] + + monkeypatch.setattr(backend, "aupload_files", _failing_upload) + result = await materialize_session_skills(backend, TENANT, ["biao-shu-zhuan-xie"], store=store) + assert (result.copied, result.failed) == ([], ["biao-shu-zhuan-xie"]) + + class TestEnumerationLoop: async def test_copied_skill_is_enumerated_and_path_resolves(self, monkeypatch, store, backend): """Full Fork X loop: copy → real SkillsMiddleware enumerates → path consistency.""" @@ -182,7 +262,7 @@ async def test_copied_skill_is_enumerated_and_path_resolves(self, monkeypatch, s from bisheng.linsight.domain.services.workspace_backend import normalize_workspace_path - _patch_enabled(monkeypatch, ENABLED) + _patch_enabled(monkeypatch, ENABLED, store) await materialize_session_skills(backend, TENANT, ["biao-shu-zhuan-xie"], store=store) # Enumerate via a FilesystemBackend over the same cache dir the copy wrote to — @@ -214,7 +294,7 @@ class TestProvisioningLog: async def test_summary_line_renders_its_arguments(self, monkeypatch, store, backend): from loguru import logger - _patch_enabled(monkeypatch, ENABLED) + _patch_enabled(monkeypatch, ENABLED, store) captured: list[str] = [] sink_id = logger.add(captured.append, level="INFO", format="{message}") try: diff --git a/src/backend/test/linsight/test_skill_service.py b/src/backend/test/linsight/test_skill_service.py index bdf4c7a5c5..d551ff28dc 100644 --- a/src/backend/test/linsight/test_skill_service.py +++ b/src/backend/test/linsight/test_skill_service.py @@ -20,6 +20,8 @@ from bisheng.linsight.domain.schemas.skill_schema import SkillCreateForm from bisheng.linsight.domain.services import skill_service as service_module from bisheng.linsight.domain.services.skill_service import SkillService +from test.linsight.fixtures.fake_minio import FakeMinioStorage + from bisheng.linsight.domain.services.skill_store import ( MAX_BUNDLE_SIZE, MAX_UNPACKED_SIZE, @@ -92,7 +94,7 @@ def service(tmp_path, monkeypatch): monkeypatch.setattr(service_module, "LinsightSkillDao", FakeSkillDao) owner_projection = SimpleNamespace(authorize_created=AsyncMock()) return SkillService( - store=SkillStore(root=tmp_path), + store=SkillStore(root=tmp_path, minio=FakeMinioStorage()), owner_projection=owner_projection, ) @@ -130,7 +132,7 @@ async def test_create_from_form(self, service): assert detail.enabled is True assert detail.source == "manual" # SKILL.md rendered with display-name metadata - text = service.store.read_text(TENANT, detail.name) + text = detail.source_text assert "display-name: 季度财报分析" in text # F048 protected owner is durably projected before returning. service._owner_projection.authorize_created.assert_awaited_once() @@ -250,7 +252,7 @@ async def test_update_form_keeps_assets(self, service): ) assert detail.display_name == "演示技能v2" assert {f.path for f in detail.files} == {SKILL_MD, "scripts/a.py"} - assert "# 新正文" in service.store.read_text(TENANT, "demo-skill") + assert "# 新正文" in detail.source_text async def test_update_form_cannot_change_id(self, service): await service.create_from_form(TENANT, USER, _form()) @@ -284,7 +286,7 @@ async def test_set_status_unknown_404(self, service): async def test_delete_removes_db_and_disk(self, service): await service.create_from_form(TENANT, USER, _form()) await service.delete(TENANT, "ji-du-cai-bao-fen-xi") - assert not service.store.exists(TENANT, "ji-du-cai-bao-fen-xi") + assert service.store.minio.keys("linsight/skills/") == [] # every version removed with pytest.raises(SkillNotFoundError): await service.get_detail(TENANT, "ji-du-cai-bao-fen-xi") @@ -306,7 +308,7 @@ async def test_uppercase_name_normalized_on_upload(self, service): assert detail.display_name == "Presentations" assert detail.normalized_from == "Presentations" # stored SKILL.md is rewritten so frontmatter name == bundle dir name - text = service.store.read_text(TENANT, "presentations") + text = detail.source_text assert "name: presentations" in text assert "display-name: Presentations" in text @@ -326,8 +328,8 @@ async def test_foreign_frontmatter_keys_survive_rewrite(self, service): b"allowed-tools: Bash, Read\n" b"---\n\nbody" ) - await service.create_from_upload(TENANT, USER, "s.md", md) - text = service.store.read_text(TENANT, "my-skill") + detail = await service.create_from_upload(TENANT, USER, "s.md", md) + text = detail.source_text assert "license: Apache-2.0" in text assert "allowed-tools: Bash, Read" in text @@ -342,7 +344,7 @@ async def test_existing_display_name_metadata_wins(self, service): ) detail = await service.create_from_upload(TENANT, USER, "s.md", md) assert detail.display_name == "演示技能" - text = service.store.read_text(TENANT, "presentations") + text = detail.source_text assert "display-name: 演示技能" in text assert "display-name: Presentations" not in text @@ -350,7 +352,7 @@ async def test_legal_name_bundle_untouched(self, service): raw = _md_bytes() detail = await service.create_from_upload(TENANT, USER, "demo-skill.md", raw) assert detail.normalized_from is None - assert service.store.read_text(TENANT, "demo-skill").encode() == raw + assert detail.source_text.encode() == raw async def test_unsalvageable_name_still_rejected(self, service): md = b"---\nname: '!!!'\ndescription: demo\n---\n\nbody" diff --git a/src/backend/test/linsight/test_skill_store.py b/src/backend/test/linsight/test_skill_store.py index 5ca24cd944..0914b2ef3d 100644 --- a/src/backend/test/linsight/test_skill_store.py +++ b/src/backend/test/linsight/test_skill_store.py @@ -5,15 +5,19 @@ import pytest +from bisheng.linsight.domain.services import skill_store as skill_store_module from bisheng.linsight.domain.services.skill_store import ( SKILL_MD, SkillStore, + bundle_content_hash, compose_skill_md, + pack_bundle_zip, parse_skill_md, slugify_pinyin, unpack_zip_bytes, validate_skill_name, ) +from test.linsight.fixtures.fake_minio import FakeMinioStorage def _zip_bytes(entries: dict[str, bytes]) -> bytes: @@ -120,30 +124,60 @@ def test_bad_zip_raises(self): class TestSkillStore: @pytest.fixture def store(self, tmp_path): - return SkillStore(root=tmp_path) + return SkillStore(root=tmp_path, minio=FakeMinioStorage()) def test_write_read_list_delete(self, store): - size = store.write_bundle(1, "demo-skill", {SKILL_MD: SKILL_MD_TEXT.encode(), "scripts/a.py": b"print(1)"}) - assert size == len(SKILL_MD_TEXT.encode()) + len(b"print(1)") - assert store.exists(1, "demo-skill") - assert store.read_text(1, "demo-skill").startswith("---") - files = store.list_files(1, "demo-skill") + ref = store.write_bundle(1, "demo-skill", {SKILL_MD: SKILL_MD_TEXT.encode(), "scripts/a.py": b"print(1)"}) + assert ref.size == len(SKILL_MD_TEXT.encode()) + len(b"print(1)") + assert ref.object_key == f"linsight/skills/1/demo-skill/{ref.content_hash}.zip" + assert store.exists(1, "demo-skill", ref.content_hash) + assert store.read_text(1, "demo-skill", ref.content_hash).startswith("---") + files = store.list_files(1, "demo-skill", ref.content_hash) assert files[0]["path"] == SKILL_MD # SKILL.md always first assert {f["path"] for f in files} == {SKILL_MD, "scripts/a.py"} - assert store.object_path(1, "demo-skill") == "data/skills/1/demo-skill" assert store.delete(1, "demo-skill") - assert not store.exists(1, "demo-skill") - - def test_overwrite_removes_stale_assets(self, store): - store.write_bundle(1, "demo-skill", {SKILL_MD: b"v1", "old.txt": b"stale"}) - store.write_bundle(1, "demo-skill", {SKILL_MD: b"v2"}) - assert {f["path"] for f in store.list_files(1, "demo-skill")} == {SKILL_MD} - assert store.read_text(1, "demo-skill") == "v2" - - def test_tenant_isolation_by_path(self, store): - store.write_bundle(1, "demo-skill", {SKILL_MD: b"t1"}) - assert not store.exists(2, "demo-skill") - assert store.list_files(2, "demo-skill") == [] + assert not store.exists(1, "demo-skill", ref.content_hash) + + def test_new_version_supersedes_without_touching_the_old_object(self, store): + """Each write is its own object, so a concurrent reader of v1 keeps working.""" + v1 = store.write_bundle(1, "demo-skill", {SKILL_MD: b"v1", "old.txt": b"stale"}) + v2 = store.write_bundle(1, "demo-skill", {SKILL_MD: b"v2"}) + assert v1.content_hash != v2.content_hash + assert {f["path"] for f in store.list_files(1, "demo-skill", v2.content_hash)} == {SKILL_MD} + assert store.read_text(1, "demo-skill", v2.content_hash) == "v2" + # v1 is superseded, not destroyed — pruning it in the writer would race + # with a concurrent writer publishing its own version. + assert store.read_text(1, "demo-skill", v1.content_hash) == "v1" + + def test_delete_removes_every_version(self, store): + v1 = store.write_bundle(1, "demo-skill", {SKILL_MD: b"v1"}) + v2 = store.write_bundle(1, "demo-skill", {SKILL_MD: b"v2"}) + assert store.delete(1, "demo-skill") + assert not store.exists(1, "demo-skill", v1.content_hash) + assert not store.exists(1, "demo-skill", v2.content_hash) + + def test_tenant_isolation_by_key(self, store): + ref = store.write_bundle(1, "demo-skill", {SKILL_MD: b"t1"}) + assert not store.exists(2, "demo-skill", ref.content_hash) + assert store.list_files(2, "demo-skill", ref.content_hash) == [] + + def test_cache_hit_does_no_network_io(self, store): + """The cache directory IS the content hash, so a hit needs no probe at all.""" + ref = store.write_bundle(1, "demo-skill", {SKILL_MD: b"x"}) + store.minio.reset_counters() + store.read_text(1, "demo-skill", ref.content_hash) + store.list_files(1, "demo-skill", ref.content_hash) + assert (store.minio.get_calls, store.minio.exists_calls) == (0, 0) + + def test_materializes_from_storage_when_cache_is_cold(self, store, tmp_path): + """A worker that never saw the write still resolves the bundle.""" + ref = store.write_bundle(1, "demo-skill", {SKILL_MD: b"x", "scripts/a.py": b"print(1)"}) + cold = SkillStore(root=tmp_path / "other-node", minio=store.minio) + assert cold.read_bytes(1, "demo-skill", ref.content_hash, "scripts/a.py") == b"print(1)" + + def test_missing_object_raises_not_found(self, store): + with pytest.raises(FileNotFoundError): + store.read_text(1, "demo-skill", "0" * 64) @pytest.mark.parametrize("evil", ["../evil.md", "/abs.md", "a/../../evil.md"]) def test_traversal_rejected_on_write(self, store, evil): @@ -151,10 +185,85 @@ def test_traversal_rejected_on_write(self, store, evil): store.write_bundle(1, "demo-skill", {SKILL_MD: b"x", evil: b"boom"}) def test_traversal_rejected_on_read(self, store): - store.write_bundle(1, "demo-skill", {SKILL_MD: b"x"}) + ref = store.write_bundle(1, "demo-skill", {SKILL_MD: b"x"}) with pytest.raises(ValueError, match="illegal bundle path"): - store.read_text(1, "demo-skill", "../../../etc/passwd") + store.read_text(1, "demo-skill", ref.content_hash, "../../../etc/passwd") + + def test_materializing_a_tampered_object_cannot_escape_the_cache_dir(self, store, tmp_path): + """Materialization is a second write-to-disk path and must re-check paths. + + The upload path's guards live in skill_service/_parse_upload and in + write_bundle; neither runs when bytes come back from storage. + """ + ref = store.write_bundle(1, "demo-skill", {SKILL_MD: b"x"}) + # Hand-crafted archive whose entry escapes — pack_bundle_zip would refuse + # to produce this, so it can only arrive from a corrupted/tampered object. + tampered = _zip_bytes({SKILL_MD: b"x", "../escaped.txt": b"boom"}) + store.minio.store[(store.minio.bucket, ref.object_key)] = tampered + cold = SkillStore(root=tmp_path / "cold", minio=store.minio) + with pytest.raises(ValueError, match="illegal bundle path"): + cold.read_text(1, "demo-skill", ref.content_hash) + assert not (tmp_path / "escaped.txt").exists() + + def test_materializing_an_oversized_object_is_refused(self, store, tmp_path, monkeypatch): + ref = store.write_bundle(1, "demo-skill", {SKILL_MD: b"x"}) + monkeypatch.setattr(skill_store_module, "MAX_UNPACKED_SIZE", 4) + store.minio.store[(store.minio.bucket, ref.object_key)] = _zip_bytes({SKILL_MD: b"0123456789"}) + cold = SkillStore(root=tmp_path / "cold2", minio=store.minio) + with pytest.raises(ValueError, match="exceeds"): + cold.read_text(1, "demo-skill", ref.content_hash) def test_bundle_requires_skill_md(self, store): with pytest.raises(ValueError, match="SKILL.md"): store.write_bundle(1, "demo-skill", {"other.md": b"x"}) + + +class TestBundleContentHash: + """Bundle identity must depend on content only — never on packing incidentals. + + This is the guard for a bug that would otherwise be invisible: if identity + tracked the packed archive's bytes, the built-in seeder would judge every + bundle "changed" on every startup and rewrite all tenants' copies forever. + """ + + BUNDLE = { + SKILL_MD: b"---\nname: demo-skill\ndescription: d\n---\n\nbody", + "scripts/run.py": b"print(1)\n", + "references/guide.md": b"# guide\n", + } + + def test_insertion_order_does_not_change_hash(self): + shuffled = dict(reversed(list(self.BUNDLE.items()))) + assert bundle_content_hash(self.BUNDLE) == bundle_content_hash(shuffled) + + def test_content_change_changes_hash(self): + changed = dict(self.BUNDLE, **{"scripts/run.py": b"print(2)\n"}) + assert bundle_content_hash(self.BUNDLE) != bundle_content_hash(changed) + + def test_renaming_a_file_changes_hash(self): + renamed = {k: v for k, v in self.BUNDLE.items() if k != "scripts/run.py"} + renamed["scripts/main.py"] = self.BUNDLE["scripts/run.py"] + assert bundle_content_hash(self.BUNDLE) != bundle_content_hash(renamed) + + def test_path_and_content_boundary_is_unambiguous(self): + """Concatenating path+content without a separator would collide these two.""" + a = {SKILL_MD: b"x", "ab": b"c"} + b = {SKILL_MD: b"x", "a": b"bc"} + assert bundle_content_hash(a) != bundle_content_hash(b) + + +class TestPackBundleZip: + def test_packing_is_reproducible(self): + """Same mapping, different insertion order and different wall-clock -> same bytes.""" + bundle = {SKILL_MD: b"x", "scripts/a.py": b"a", "b.txt": b"b"} + shuffled = dict(reversed(list(bundle.items()))) + assert pack_bundle_zip(bundle) == pack_bundle_zip(shuffled) + + def test_roundtrips_through_the_unpacker(self): + bundle = {SKILL_MD: b"x", "assets/logo.png": b"\x89PNG\r\n\x1a\n\xff\xfe"} + assert unpack_zip_bytes(pack_bundle_zip(bundle)) == bundle + + @pytest.mark.parametrize("evil", ["../evil.md", "/abs.md"]) + def test_traversal_rejected_when_packing(self, evil): + with pytest.raises(ValueError, match="illegal bundle path"): + pack_bundle_zip({SKILL_MD: b"x", evil: b"boom"}) diff --git a/src/backend/test/linsight/test_task_title.py b/src/backend/test/linsight/test_task_title.py index 8372507363..10f7eee096 100644 --- a/src/backend/test/linsight/test_task_title.py +++ b/src/backend/test/linsight/test_task_title.py @@ -8,11 +8,14 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import pytest from langchain_core.messages import AIMessage +from bisheng.database.models.session import MessageSessionDao from bisheng.linsight.domain.services.workbench_impl import LinsightWorkbenchImpl @@ -101,3 +104,56 @@ async def test_llm_failure_writes_question_fallback(self): assert result["task_title"] == "用户问题原文" assert result["error_message"] == "boom" upd.assert_awaited_once_with("c1", "用户问题原文") + + +class TestTaskTitleCancellation: + """This runs inside the submit SSE generator, so a client disconnect cancels it. + + GeneratorExit / CancelledError are BaseException, so the ``except Exception`` + fallback never saw them: the title was never written and nothing was logged — + the session stayed on "New Chat" forever (114, 2026-08-14). + """ + + @pytest.mark.parametrize("exc", [GeneratorExit, asyncio.CancelledError]) + async def test_cancellation_writes_fallback_synchronously(self, exc): + login_user = SimpleNamespace(user_id=1) + fake_llm = SimpleNamespace(ainvoke=AsyncMock(side_effect=exc())) + with ( + patch.object(LinsightWorkbenchImpl, "_get_llm", new=AsyncMock(return_value=(fake_llm, None))), + patch.object(LinsightWorkbenchImpl, "_generate_title_prompt", new=AsyncMock(return_value=[])), + patch.object(MessageSessionDao, "update_session_name_sync") as sync_upd, + patch.object(LinsightWorkbenchImpl, "_update_session_title", new=AsyncMock()) as async_upd, + ): + with pytest.raises(exc): + await LinsightWorkbenchImpl.task_title_generate( + question="卢旺达变压器参数抽取", chat_id="c1", login_user=login_user + ) + + # Written, and written SYNCHRONOUSLY: awaiting inside a cancelled coroutine + # re-raises at the first suspension point, so the async path would be lost. + sync_upd.assert_called_once_with("c1", "卢旺达变压器参数抽取") + async_upd.assert_not_awaited() + + async def test_cancellation_still_propagates(self): + """Swallowing GeneratorExit makes Python raise 'generator ignored + GeneratorExit'; swallowing CancelledError breaks cancellation semantics.""" + login_user = SimpleNamespace(user_id=1) + fake_llm = SimpleNamespace(ainvoke=AsyncMock(side_effect=asyncio.CancelledError())) + with ( + patch.object(LinsightWorkbenchImpl, "_get_llm", new=AsyncMock(return_value=(fake_llm, None))), + patch.object(LinsightWorkbenchImpl, "_generate_title_prompt", new=AsyncMock(return_value=[])), + patch.object(MessageSessionDao, "update_session_name_sync"), + ): + with pytest.raises(asyncio.CancelledError): + await LinsightWorkbenchImpl.task_title_generate(question="q", chat_id="c1", login_user=login_user) + + async def test_fallback_write_failure_does_not_mask_cancellation(self): + login_user = SimpleNamespace(user_id=1) + fake_llm = SimpleNamespace(ainvoke=AsyncMock(side_effect=GeneratorExit())) + with ( + patch.object(LinsightWorkbenchImpl, "_get_llm", new=AsyncMock(return_value=(fake_llm, None))), + patch.object(LinsightWorkbenchImpl, "_generate_title_prompt", new=AsyncMock(return_value=[])), + patch.object(MessageSessionDao, "update_session_name_sync", side_effect=RuntimeError("db down")), + ): + with pytest.raises(GeneratorExit): + await LinsightWorkbenchImpl.task_title_generate(question="q", chat_id="c1", login_user=login_user) diff --git a/src/backend/test/linsight/test_tool_repeat_loop.py b/src/backend/test/linsight/test_tool_repeat_loop.py new file mode 100644 index 0000000000..2c5991f116 --- /dev/null +++ b/src/backend/test/linsight/test_tool_repeat_loop.py @@ -0,0 +1,336 @@ +"""Unit tests for the identical-repeat tier of LinsightToolLoopBreakerMiddleware. + +The failure tier (``test_tool_loop_middleware.py``) only counts tool ERRORS. This +file covers the other loop shape: the model re-submitting a byte-identical tool call +that keeps SUCCEEDING. Measured on 114, 2026-08-14 — a kimi-k3 run re-sent the same +``bisheng_code_interpreter`` call 79 times over 78 minutes (13.8M input tokens) with +zero todos advanced, and every existing guard stayed silent. + +Three fixture details are deliberately faithful to that incident; getting them wrong +means testing something that cannot fail: +1. ``tool_call_id`` is CONSTANT across turns (``bisheng_code_interpreter:0`` — what + kimi-k3 returns via tokenrouter), not the unique ``call_`` other vendors send. +2. The offloaded tool result carries ``status="success"`` — that is precisely why the + failure counter never fired. +3. The oversized result is ONE line (``json.dumps`` output), which is what degrades the + upstream preview to its first 1000 characters. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +import json + +import pytest +from langchain.agents.middleware.types import ModelRequest +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +from bisheng.linsight.domain.services.tool_loop_middleware import ( + LinsightToolLoopBreakerMiddleware, + LinsightToolLoopError, + build_tool_loop_breaker_middleware, +) + +CI_TOOL = "bisheng_code_interpreter" +# Constant across turns — the whole point. See module docstring. +CI_CALL_ID = "bisheng_code_interpreter:0" +CI_ARGS = {"python_code": "import fitz\nprint('x')"} + + +# --------------------------------------------------------------------------- helpers + + +def _ai_call(tool=CI_TOOL, args=None, call_id=CI_CALL_ID, content=""): + return AIMessage( + content=content, + tool_calls=[{"name": tool, "args": args if args is not None else CI_ARGS, "id": call_id, "type": "tool_call"}], + ) + + +def _offloaded_tm(tool=CI_TOOL, call_id=CI_CALL_ID): + """The upstream 'result too large' replacement: SUCCESS status, one long line.""" + return ToolMessage( + content=( + f"Tool result too large, the result of this tool call {call_id} was saved in the " + f"filesystem at this path: /large_tool_results/{call_id}\n\n" + + json.dumps({"exitcode": 0, "log": "x" * 200}, ensure_ascii=False) + ), + tool_call_id=call_id, + name=tool, + status="success", + ) + + +def _repeat_turns(n, tool=CI_TOOL, args=None, call_id=CI_CALL_ID): + """n interleaved (identical AIMessage tool_call -> success ToolMessage) turns.""" + msgs = [] + for _ in range(n): + msgs.append(_ai_call(tool, args, call_id)) + msgs.append(_offloaded_tm(tool, call_id)) + return msgs + + +def _mw(*, repeat_soft=3, repeat_hard=8): + return LinsightToolLoopBreakerMiddleware(repeat_soft_limit=repeat_soft, repeat_hard_limit=repeat_hard) + + +def _request(messages, *, state_messages=None): + """A real ModelRequest so a langchain signature drift surfaces here, not in prod.""" + return ModelRequest( + model=GenericFakeChatModel(messages=iter([])), + messages=list(messages), + tools=[], + state={"messages": list(messages if state_messages is None else state_messages)}, + ) + + +async def _run_nudge(mw, request): + """Invoke the soft tier, returning the request the handler actually received.""" + seen = {} + + async def handler(req): + seen["request"] = req + return AIMessage(content="ok") + + await mw.awrap_model_call(request, handler) + return seen["request"] + + +# --------------------------------------------------------------------------- soft tier + + +async def test_below_soft_limit_leaves_request_untouched(): + mw = _mw(repeat_soft=3) + request = _request(_repeat_turns(2)) + received = await _run_nudge(mw, request) + assert len(received.messages) == len(request.messages) + + +async def test_soft_limit_injects_counted_nudge(): + mw = _mw(repeat_soft=3) + original = _repeat_turns(3) + request = _request(original) + received = await _run_nudge(mw, request) + + assert len(received.messages) == len(original) + 1 + tail = received.messages[-1] + assert isinstance(tail, HumanMessage) + # The COUNT is what breaks the temperature=0 fixed point: without a monotonically + # changing tail the next context is a byte-identical function of the previous one. + assert "3" in tail.content + assert "完全相同" in tail.content + assert "read_file" in tail.content + # The original request object must not be mutated — the nudge is ephemeral. + assert len(request.messages) == len(original) + + +async def test_nudge_reads_state_not_request_messages(): + """A wrap-up HumanMessage appended by the OUTER resilience middleware lives on + ``request.messages`` but not in graph state. Since a human turn ends a repeat run, + scanning the request list would silently disable this detector during soft landing. + """ + mw = _mw(repeat_soft=3) + state_messages = _repeat_turns(3) + request = _request([*state_messages, HumanMessage(content="⚠️ 预算即将耗尽")], state_messages=state_messages) + received = await _run_nudge(mw, request) + assert isinstance(received.messages[-1], HumanMessage) + assert "完全相同" in received.messages[-1].content + + +async def test_soft_tier_disabled_by_zero(): + mw = _mw(repeat_soft=0, repeat_hard=0) + request = _request(_repeat_turns(20)) + received = await _run_nudge(mw, request) + assert len(received.messages) == len(request.messages) + + +# --------------------------------------------------------------------------- hard tier + + +async def test_hard_limit_raises_with_repeat_reason(): + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = [AIMessage(content="我已经读完了 checklist 模板,共 109 项参数。"), *_repeat_turns(8)] + with pytest.raises(LinsightToolLoopError) as exc: + await mw.aafter_model({"messages": messages}, None) + assert exc.value.reason == "repeat" + assert exc.value.count == 8 + assert exc.value.tool_name == CI_TOOL + # Salvage must carry the model's earlier analysis so the user still gets output. + assert "109 项参数" in exc.value.partial_result + + +async def test_hard_tier_disabled_by_zero(): + mw = _mw(repeat_soft=3, repeat_hard=0) + await mw.aafter_model({"messages": _repeat_turns(50)}, None) # must not raise + + +async def test_tool_call_id_is_not_part_of_the_fingerprint(): + """Vendors that emit unique ids must be caught too — the loop is in the ARGUMENTS.""" + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = [] + for i in range(8): + messages.append(_ai_call(call_id=f"call_{i:024d}")) + messages.append(_offloaded_tm(call_id=f"call_{i:024d}")) + with pytest.raises(LinsightToolLoopError): + await mw.aafter_model({"messages": messages}, None) + + +async def test_parallel_identical_calls_count_as_one_turn(): + """Two identical calls in ONE AIMessage are one unit of model intent, not two.""" + mw = _mw(repeat_soft=3, repeat_hard=8) + turn = AIMessage( + content="", + tool_calls=[ + {"name": CI_TOOL, "args": CI_ARGS, "id": "bisheng_code_interpreter:0", "type": "tool_call"}, + {"name": CI_TOOL, "args": CI_ARGS, "id": "bisheng_code_interpreter:1", "type": "tool_call"}, + ], + ) + messages = [] + for _ in range(5): + messages.append(turn) + messages.append(_offloaded_tm(call_id="bisheng_code_interpreter:0")) + messages.append(_offloaded_tm(call_id="bisheng_code_interpreter:1")) + # 5 turns < hard 8: counting per-call would have reached 10 and aborted here. + await mw.aafter_model({"messages": messages}, None) + + +# --------------------------------------------------------------------------- false positives + + +async def test_paginated_reads_are_not_a_repeat(): + """The most important guard: paging through a big file is legitimate repetition. + + ``read_file`` with a moving ``offset`` differs in ARGUMENTS, so it must never count + — otherwise the fix would break the very recovery path it exists to enable. + """ + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = [] + for page in range(10): + messages.append( + _ai_call(tool="read_file", args={"file_path": "/large_tool_results/x", "offset": page * 100, "limit": 100}) + ) + messages.append(ToolMessage(content="chunk", tool_call_id="read_file:0", name="read_file")) + await mw.aafter_model({"messages": messages}, None) + received = await _run_nudge(mw, _request(messages)) + assert len(received.messages) == len(messages) + + +async def test_differing_query_is_not_a_repeat(): + """web_search legitimately runs long streaks — with a different query each time.""" + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = [] + for i in range(12): + messages.append(_ai_call(tool="web_search", args={"query": f"变压器 参数 {i}"})) + messages.append(ToolMessage(content="results", tool_call_id="web_search:0", name="web_search")) + await mw.aafter_model({"messages": messages}, None) + + +async def test_text_turn_breaks_the_run(): + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = [*_repeat_turns(7), AIMessage(content="换个思路,我改用 grep 定位。"), *_repeat_turns(1)] + await mw.aafter_model({"messages": messages}, None) + + +async def test_new_human_turn_resets_the_run(): + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = [*_repeat_turns(7), HumanMessage(content="继续"), *_repeat_turns(1)] + await mw.aafter_model({"messages": messages}, None) + + +async def test_ask_user_repeat_is_exempt(): + """ask_user parks on an interrupt; resuming replays a same-shaped call.""" + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = [] + for _ in range(15): + messages.append(_ai_call(tool="ask_user", args={"reason": "需要澄清", "questions": []})) + messages.append(ToolMessage(content="parked", tool_call_id="ask_user:0", name="ask_user")) + await mw.aafter_model({"messages": messages}, None) + received = await _run_nudge(mw, _request(messages)) + assert len(received.messages) == len(messages) + + +async def test_write_todos_nudges_but_tolerates_more_before_abort(): + mw = _mw(repeat_soft=3, repeat_hard=8) + lenient = [] + for _ in range(10): + lenient.append(_ai_call(tool="write_todos", args={"todos": [{"content": "a", "status": "pending"}]})) + lenient.append(ToolMessage(content="updated", tool_call_id="write_todos:0", name="write_todos")) + # Past the hard limit for a normal tool, still tolerated for a state-only one... + await mw.aafter_model({"messages": lenient}, None) + # ...but it is nudged, and it is NOT unbounded. + received = await _run_nudge(mw, _request(lenient)) + assert isinstance(received.messages[-1], HumanMessage) + for _ in range(15): + lenient.append(_ai_call(tool="write_todos", args={"todos": [{"content": "a", "status": "pending"}]})) + lenient.append(ToolMessage(content="updated", tool_call_id="write_todos:0", name="write_todos")) + with pytest.raises(LinsightToolLoopError): + await mw.aafter_model({"messages": lenient}, None) + + +# --------------------------------------------------------------------------- tier separation + + +async def test_pure_failure_run_is_left_to_the_failure_tier(): + """Identical calls that ALL error out are a failure loop, not a repeat loop. + + Claiming them here would abort earlier than ``tool_failure_hard_limit`` intends + and would label the abort "重复提交" when the honest cause is "调用一直失败". + """ + mw = LinsightToolLoopBreakerMiddleware(soft_limit=3, hard_limit=99, repeat_soft_limit=3, repeat_hard_limit=8) + messages = [] + for _ in range(20): + messages.append(_ai_call()) + messages.append(ToolMessage(content="boom", tool_call_id=CI_CALL_ID, name=CI_TOOL, status="error")) + await mw.aafter_model({"messages": messages}, None) # failure tier owns this run + received = await _run_nudge(mw, _request(messages)) + assert len(received.messages) == len(messages) + + +async def test_mixed_results_still_count_as_a_repeat(): + """A run that succeeded at first and only recently started erroring is still a + repeat loop — the model is re-sending identical arguments regardless.""" + mw = _mw(repeat_soft=3, repeat_hard=8) + messages = _repeat_turns(6) + for _ in range(2): + messages.append(_ai_call()) + messages.append(ToolMessage(content="boom", tool_call_id=CI_CALL_ID, name=CI_TOOL, status="error")) + with pytest.raises(LinsightToolLoopError) as exc: + await mw.aafter_model({"messages": messages}, None) + assert exc.value.reason == "repeat" + + +async def test_evicted_success_results_do_not_feed_the_failure_counter(): + """Pins WHY this tier had to exist: the failure counter breaks on the first + non-error result, and an evicted tool message keeps ``status="success"``. + """ + mw = _mw(repeat_soft=99, repeat_hard=0) # repeat tier fully disabled + await mw.aafter_model({"messages": _repeat_turns(30)}, None) # failure tier stays silent + + mw_enabled = _mw(repeat_soft=3, repeat_hard=8) + with pytest.raises(LinsightToolLoopError) as exc: + await mw_enabled.aafter_model({"messages": _repeat_turns(30)}, None) + assert exc.value.reason == "repeat" + + +def test_build_reads_repeat_limits_from_conf(): + class _Conf: + tool_failure_soft_limit = 3 + tool_failure_hard_limit = 8 + tool_repeat_soft_limit = 4 + tool_repeat_hard_limit = 9 + + mw = build_tool_loop_breaker_middleware(_Conf(), is_subagent=False) + assert mw.repeat_soft_limit == 4 + assert mw.repeat_hard_limit == 9 + + +def test_build_tolerates_conf_without_repeat_limits(): + """Existing deployments have no such keys in initdb_config; defaults must hold.""" + + class _OldConf: + tool_failure_soft_limit = 3 + tool_failure_hard_limit = 8 + + mw = build_tool_loop_breaker_middleware(_OldConf(), is_subagent=True) + assert mw.repeat_soft_limit == 3 + assert mw.repeat_hard_limit == 8 diff --git a/src/backend/test/linsight/test_workspace_backend.py b/src/backend/test/linsight/test_workspace_backend.py index 7a22a64a8f..4638bdb9c7 100644 --- a/src/backend/test/linsight/test_workspace_backend.py +++ b/src/backend/test/linsight/test_workspace_backend.py @@ -26,78 +26,14 @@ WorkspaceBackend, ) - -# --------------------------------------------------------------------------- -# Fake MinIO: in-memory object store keyed by (bucket, object_name) -# --------------------------------------------------------------------------- -class FakeMinioStorage: - """Minimal in-memory stand-in for ``MinioStorage`` (sync + async surface).""" - - def __init__(self) -> None: - self.bucket = "bisheng" - self.tmp_bucket = "tmp-dir" - # store[(bucket, object_name)] = bytes - self.store: dict[tuple[str, str], bytes] = {} - self.minio_client_sync = _FakeRawClient(self.store, self.bucket) - - # async surface used by WorkspaceBackend's a* methods -------------------- - async def put_object(self, *, bucket_name=None, object_name, file, **kwargs): - bucket = bucket_name or self.bucket - data = file if isinstance(file, bytes) else bytes(file) - self.store[(bucket, object_name)] = data - - async def get_object(self, bucket_name=None, object_name=None): - bucket = bucket_name or self.bucket - return self.store.get((bucket, object_name)) - - # sync surface ---------------------------------------------------------- - def put_object_sync(self, *, bucket_name=None, object_name, file, **kwargs): - bucket = bucket_name or self.bucket - data = file if isinstance(file, bytes) else bytes(file) - self.store[(bucket, object_name)] = data - - def get_object_sync(self, bucket_name=None, object_name=None): - bucket = bucket_name or self.bucket - return self.store.get((bucket, object_name)) - - async def object_exists(self, bucket_name=None, object_name=None): - bucket = bucket_name or self.bucket - return (bucket, object_name) in self.store - - -class _FakeRawClient: - """Stands in for ``minio.Minio`` (only ``list_objects`` is used).""" - - def __init__(self, store: dict[tuple[str, str], bytes], bucket: str) -> None: - self._store = store - self._bucket = bucket - - def list_objects(self, bucket_name, prefix="", recursive=True): - for (bucket, name), data in sorted(self._store.items()): - if bucket != bucket_name: - continue - if prefix and not name.startswith(prefix): - continue - yield _FakeObject(name, len(data)) - - -class _FakeObject: - def __init__(self, object_name: str, size: int) -> None: - self.object_name = object_name - self.size = size - self.is_dir = False - self.last_modified = None - self.etag = "abc" +# The in-memory MinIO fake lives in fixtures/ — the skill store suite needs the +# same one. ``fake_minio`` is re-exported so pytest still resolves the fixture. +from test.linsight.fixtures.fake_minio import FakeMinioStorage, fake_minio # noqa: F401 # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- -@pytest.fixture() -def fake_minio(): - return FakeMinioStorage() - - @pytest.fixture() def file_dir(): with tempfile.TemporaryDirectory() as d: diff --git a/src/backend/test/linsight/test_workspace_backend_single_line_paging.py b/src/backend/test/linsight/test_workspace_backend_single_line_paging.py new file mode 100644 index 0000000000..b302384626 --- /dev/null +++ b/src/backend/test/linsight/test_workspace_backend_single_line_paging.py @@ -0,0 +1,81 @@ +"""Character-page fallback in WorkspaceBackend.read / .aread. + +Line slicing collapses on a file with no line breaks, and the most common such file +is one deepagents produced itself: an offloaded tool result (ToolNode serializes dict +results with ``json.dumps``, so every newline becomes a literal ``\\n``). Before this +fallback, reading one back had two outcomes and both were wrong — ``offset=0`` handed +back the entire file, and ``offset>=1`` returned ``""``, which upstream reports to the +model as *"File exists but has empty contents"*. The tail was unreachable by any call +while the offload notice told the model to page through it with offset/limit. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +from bisheng.linsight.domain.services.workspace_backend import ( + _CHAR_PAGE_MIN_CHARS, + _CHAR_PAGE_SIZE, + _slice_workspace_text, +) + +# One line, comfortably over the threshold — the shape of an offloaded tool result. +_SINGLE_LINE = '{"exitcode": 0, "log": "' + ("x" * 40000) + '", "file_list": ["output/a.png"]}' + + +def test_single_line_tail_is_reachable(): + """The regression that mattered: page 1+ used to come back empty.""" + page = _slice_workspace_text(_SINGLE_LINE, offset=1, limit=1) + assert page.strip() + assert "file_list" not in page # a middle page, not the head + + +def test_pages_cover_the_whole_file(): + total_pages = -(-len(_SINGLE_LINE) // _CHAR_PAGE_SIZE) + seen = "".join( + _slice_workspace_text(_SINGLE_LINE, offset=i, limit=1).split("]\n", 1)[-1] for i in range(total_pages) + ) + # Every byte is retrievable; the notice only prefixes partial reads. + assert _SINGLE_LINE[-200:] in seen + assert _SINGLE_LINE[:200] in seen + + +def test_partial_read_is_annotated(): + page = _slice_workspace_text(_SINGLE_LINE, offset=0, limit=1) + assert "paginated by CHARACTER" in page + assert "offset counts pages" in page + + +def test_single_line_below_threshold_is_untouched(): + """A short one-liner (a JSON config, say) must come back byte-identical — no + prose header, or a caller doing json.loads on it would break.""" + small = '{"a": 1, "b": 2}' + assert _slice_workspace_text(small, offset=0, limit=100) == small + + +def test_full_single_line_read_is_not_annotated(): + """Fits in one request => not a partial read => no header.""" + text = "y" * (_CHAR_PAGE_MIN_CHARS + 100) + out = _slice_workspace_text(text, offset=0, limit=1000) + assert out == text + + +def test_multiline_files_keep_line_semantics(): + text = "\n".join(f"line {i}" for i in range(500)) + out = _slice_workspace_text(text, offset=10, limit=3) + assert out == "line 10\nline 11\nline 12" + assert "paginated by CHARACTER" not in out + + +def test_multiline_large_file_is_not_char_paged(): + """Size alone must not trigger the fallback — only the absence of line breaks.""" + text = "\n".join("z" * 100 for _ in range(2000)) # >> threshold, but 2000 lines + out = _slice_workspace_text(text, offset=0, limit=2) + assert out == "z" * 100 + "\n" + "z" * 100 + + +def test_limit_none_returns_everything(): + out = _slice_workspace_text(_SINGLE_LINE, offset=0, limit=None) + assert out.replace("\n", "").endswith('"file_list": ["output/a.png"]}') + + +def test_offset_past_the_end_is_empty_not_an_error(): + assert _slice_workspace_text(_SINGLE_LINE, offset=10_000, limit=10) == "" diff --git a/src/backend/test/permission/test_f048_linsight_runtime.py b/src/backend/test/permission/test_f048_linsight_runtime.py index c4457c0507..a79800d685 100644 --- a/src/backend/test/permission/test_f048_linsight_runtime.py +++ b/src/backend/test/permission/test_f048_linsight_runtime.py @@ -16,6 +16,8 @@ from bisheng.linsight.domain.schemas.skill_schema import SkillCreateForm from bisheng.linsight.domain.services import skill_service as skill_module from bisheng.linsight.domain.services.skill_service import SkillService +from test.linsight.fixtures.fake_minio import FakeMinioStorage + from bisheng.linsight.domain.services.skill_store import SkillStore from bisheng.linsight.worker import encode_queue_item, parse_queue_item @@ -176,7 +178,7 @@ async def test_skill_creation_waits_for_durable_owner_projection( monkeypatch.setattr(skill_module, "LinsightSkillDao", _SkillDao) owner = _OwnerProjection() service = SkillService( - store=SkillStore(root=tmp_path), + store=SkillStore(root=tmp_path, minio=FakeMinioStorage()), owner_projection=owner, ) @@ -204,7 +206,7 @@ async def test_skill_owner_projection_failure_is_not_best_effort( _SkillDao.row = None monkeypatch.setattr(skill_module, "LinsightSkillDao", _SkillDao) service = SkillService( - store=SkillStore(root=tmp_path), + store=SkillStore(root=tmp_path, minio=FakeMinioStorage()), owner_projection=_OwnerProjection(RuntimeError("projection failed")), ) diff --git a/src/backend/test/permission/test_f048_schema_contract.py b/src/backend/test/permission/test_f048_schema_contract.py index eeb6446020..e0c5f89368 100644 --- a/src/backend/test/permission/test_f048_schema_contract.py +++ b/src/backend/test/permission/test_f048_schema_contract.py @@ -35,9 +35,7 @@ MESSAGE_REVISION_PATH = ( BACKEND_ROOT / "bisheng/core/database/alembic/versions/v3_0_0_f048_migration_item_message_longtext.py" ) -VISIBLE_REVISION_PATH = ( - BACKEND_ROOT / "bisheng/core/database/alembic/versions/v3_0_0_f048_visible_source_projection.py" -) +VISIBLE_REVISION_PATH = BACKEND_ROOT / "bisheng/core/database/alembic/versions/v3_0_0_f048_visible_source_projection.py" F048_TABLES = { "authorization_model_release", @@ -143,13 +141,23 @@ def test_f048_unique_and_foreign_key_contract() -> None: } <= foreign_targets -def test_f048_revision_is_the_single_alembic_head() -> None: +def test_f048_revision_is_on_the_single_alembic_head_chain() -> None: + """One head, with the F048 revision on it. + + Asserts the property (no fork, F048 applied) rather than pinning the head to + F048 by name: every later migration legitimately becomes the new head, and a + name-pinned assertion would fail for each one while catching nothing extra. + """ config = Config(str(BACKEND_ROOT / "alembic.ini")) config.set_main_option( "script_location", str(BACKEND_ROOT / "bisheng/core/database/alembic"), ) - assert ScriptDirectory.from_config(config).get_heads() == ["f048_visible_source_projection"] + script = ScriptDirectory.from_config(config) + heads = script.get_heads() + assert len(heads) == 1, f"alembic graph forked: {heads}" + chain = {rev.revision for rev in script.walk_revisions("base", heads[0])} + assert "f048_visible_source_projection" in chain def test_f048_visible_projection_revision_is_static_ddl_only() -> None: diff --git a/src/backend/test/permission/test_initial_grant_application.py b/src/backend/test/permission/test_initial_grant_application.py new file mode 100644 index 0000000000..8211cdc807 --- /dev/null +++ b/src/backend/test/permission/test_initial_grant_application.py @@ -0,0 +1,158 @@ +"""Contracts for ordinary Grants immediately after resource creation.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from bisheng.permission.application.initial_grant import ( + InitialGrantAddition, + InitialGrantApplication, + InitialGrantRequest, +) +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_source_service import GrantSourceService +from bisheng.permission.domain.services.permission_action_service import PermissionActor + + +class _Runtime: + def __init__(self) -> None: + self.calls: list[tuple[str, object]] = [] + + async def allocate_source_ids(self, count: int): + self.calls.append(("allocate", count)) + return tuple(range(91, 91 + count)) + + async def mutate_grants(self, **kwargs): + self.calls.append(("mutate", kwargs)) + return SimpleNamespace(resource_version=2, grants=kwargs["changes"]) + + +class _Subjects: + def __init__(self) -> None: + self.calls: list[dict] = [] + self.sources = GrantSourceService() + + async def canonical_source(self, **kwargs): + self.calls.append(kwargs) + source_types = { + "user": "DIRECT", + "department": "DEPARTMENT", + "user_group": "USER_GROUP", + } + return self.sources.canonicalize_source( + source_id=kwargs["source_id"], + subject_type=kwargs["subject_type"], + subject_id=kwargs["subject_id"], + userset_relation=kwargs["userset_relation"], + include_children=kwargs["include_children"], + source_type=source_types[kwargs["subject_type"]], + ) + + +def _target() -> VerifiedPermissionTarget: + return VerifiedPermissionTarget.from_business_service( + tenant_id=7, + resource_type="knowledge_space", + resource_id="101", + resource_version=1, + context_version="knowledge-space:101:v1", + ) + + +def _actor() -> PermissionActor: + return PermissionActor(user_id=11, current_tenant_id=7) + + +async def test_additions_are_canonicalized_and_sent_to_f048_mutation() -> None: + runtime = _Runtime() + subjects = _Subjects() + service = InitialGrantApplication(runtime=runtime, subjects=subjects) + request = InitialGrantRequest( + command_key="create-request-1", + expected_catalog_release_id=42, + additions=( + InitialGrantAddition(model_key="viewer", subject_type="user", subject_id="8"), + InitialGrantAddition( + model_key="editor", + subject_type="department", + subject_id="5", + userset_relation="subtree_member", + include_children=True, + ), + ), + ) + + result = await service.apply(actor=_actor(), target=_target(), request=request) + + assert result.resource_version == 2 + assert runtime.calls[0] == ("allocate", 2) + mutation = runtime.calls[1][1] + assert mutation["actor"] == _actor() + assert mutation["target"] == _target() + assert mutation["expected_resource_version"] == 1 + assert mutation["expected_catalog_release_id"] == 42 + assert mutation["idempotency_key"].startswith("f050:initial-grants:") + assert len(mutation["idempotency_key"]) <= 64 + assert [change.operation for change in mutation["changes"]] == ["ADD", "ADD"] + assert [change.model_key for change in mutation["changes"]] == ["viewer", "editor"] + assert [change.source.source_type for change in mutation["changes"]] == [ + "DIRECT", + "DEPARTMENT", + ] + assert all(change.source.protected is False for change in mutation["changes"]) + assert [call["tenant_id"] for call in subjects.calls] == [7, 7] + + +async def test_command_key_derives_a_stable_target_scoped_idempotency_key() -> None: + runtime = _Runtime() + service = InitialGrantApplication(runtime=runtime, subjects=_Subjects()) + request = InitialGrantRequest( + command_key="create-request-1", + expected_catalog_release_id=42, + additions=(InitialGrantAddition(model_key="viewer", subject_type="user", subject_id="8"),), + ) + + await service.apply(actor=_actor(), target=_target(), request=request) + first = runtime.calls[-1][1]["idempotency_key"] + await service.apply(actor=_actor(), target=_target(), request=request) + second = runtime.calls[-1][1]["idempotency_key"] + + assert first == second + + +@pytest.mark.parametrize( + "initial_request", + [ + InitialGrantRequest(command_key="key", expected_catalog_release_id=42, additions=()), + InitialGrantRequest( + command_key="key", + expected_catalog_release_id=42, + additions=(object(),), + ), + ], +) +async def test_only_non_empty_typed_additions_are_accepted(initial_request) -> None: + runtime = _Runtime() + service = InitialGrantApplication(runtime=runtime, subjects=_Subjects()) + + with pytest.raises((TypeError, ValueError)): + await service.apply(actor=_actor(), target=_target(), request=initial_request) + + assert runtime.calls == [] + + +async def test_verified_target_is_mandatory() -> None: + runtime = _Runtime() + service = InitialGrantApplication(runtime=runtime, subjects=_Subjects()) + request = InitialGrantRequest( + command_key="key", + expected_catalog_release_id=42, + additions=(InitialGrantAddition(model_key="viewer", subject_type="user", subject_id="8"),), + ) + + with pytest.raises(TypeError, match="VerifiedPermissionTarget"): + await service.apply(actor=_actor(), target=object(), request=request) + + assert runtime.calls == [] diff --git a/src/backend/test/permission/test_prospective_grant_application.py b/src/backend/test/permission/test_prospective_grant_application.py new file mode 100644 index 0000000000..2ffe54f423 --- /dev/null +++ b/src/backend/test/permission/test_prospective_grant_application.py @@ -0,0 +1,210 @@ +"""Contracts for configuring ordinary Grants before a resource exists.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from bisheng.common.errcode.permission import PermissionDeniedError +from bisheng.permission.application.prospective_grant import ( + ProspectiveGrantApplication, +) +from bisheng.permission.domain.services.grant_source_service import ( + GrantModelSnapshot, +) +from bisheng.permission.domain.services.permission_action_service import ( + PermissionActor, +) + + +def _model(key: str, level: int, *, active: bool = True): + return SimpleNamespace( + snapshot=GrantModelSnapshot( + model_key=key, + active=active, + action_codes=("visible",), + derived_level=level, + ), + name=key.title(), + ) + + +class _Runtime: + def __init__(self) -> None: + self.calls: list[str] = [] + self.catalog = SimpleNamespace( + release_id=42, + models=( + _model("viewer", 1), + _model("editor", 2), + _model("retired", 3, active=False), + SimpleNamespace( + snapshot=GrantModelSnapshot( + model_key="owner", + active=True, + action_codes=("visible", "manage_permission"), + derived_level=4, + allow_same_level=False, + ), + name="Owner", + ), + ), + ) + + async def prospective_owner_grantable_models(self): + self.calls.append("prospective_owner_grantable_models") + return self.catalog, self.catalog.models[:2] + + def __getattr__(self, name: str): + if name in { + "allocate_source_ids", + "authorize_created", + "check_action", + "mutate_grants", + "require_manage_permission", + }: + raise AssertionError(f"prospective flow must not call {name}") + raise AttributeError(name) + + +class _Directory: + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + + async def list_users(self, **kwargs): + self.calls.append(("users", kwargs)) + return {"data": [{"user_id": 8, "user_name": "Ada"}], "total": 1} + + async def list_user_groups(self, **kwargs): + self.calls.append(("user_groups", kwargs)) + return {"data": [{"id": 3, "name": "Reviewers"}], "total": 1} + + async def list_department_children(self, **kwargs): + self.calls.append(("department_children", kwargs)) + return [{"id": 5, "name": "Research"}] + + async def search_departments(self, **kwargs): + self.calls.append(("department_search", kwargs)) + return {"roots": [], "total_matches": 0, "truncated": False} + + async def get_department_path(self, **kwargs): + self.calls.append(("department_path", kwargs)) + return {"roots": [{"id": 5}], "total_matches": 1, "truncated": False} + + +def _actor(tenant_id: int = 7) -> PermissionActor: + return PermissionActor(user_id=11, current_tenant_id=tenant_id) + + +@pytest.fixture +def prospective(): + runtime = _Runtime() + directory = _Directory() + return ProspectiveGrantApplication(runtime=runtime, subjects=directory), runtime, directory + + +async def test_context_uses_owner_policy_without_a_resource_target(prospective) -> None: + service, runtime, directory = prospective + + result = await service.get_context( + actor=_actor(), + tenant_id=7, + resource_type="knowledge_space", + ) + + assert result == { + "catalog_release_id": 42, + "can_configure_initial_permissions": True, + "grantable_models": [ + {"key": "viewer", "name": "Viewer", "level": 1, "active": True}, + {"key": "editor", "name": "Editor", "level": 2, "active": True}, + ], + } + assert runtime.calls == ["prospective_owner_grantable_models"] + assert directory.calls == [] + + +async def test_candidates_are_scoped_to_the_verified_tenant(prospective) -> None: + service, runtime, directory = prospective + + users = await service.list_users( + actor=_actor(), + tenant_id=7, + resource_type="channel", + keyword="Ad", + page=2, + page_size=25, + ) + groups = await service.list_user_groups( + actor=_actor(), + tenant_id=7, + resource_type="channel", + keyword="Rev", + page=1, + page_size=50, + ) + children = await service.list_department_children( + actor=_actor(), + tenant_id=7, + resource_type="channel", + parent_id=5, + ) + search = await service.search_departments( + actor=_actor(), + tenant_id=7, + resource_type="channel", + keyword="Res", + limit=20, + ) + path = await service.get_department_path( + actor=_actor(), + tenant_id=7, + resource_type="channel", + department_id=5, + ) + + assert users["total"] == groups["total"] == 1 + assert children == [{"id": 5, "name": "Research"}] + assert search["total_matches"] == 0 + assert path["total_matches"] == 1 + assert [name for name, _ in directory.calls] == [ + "users", + "user_groups", + "department_children", + "department_search", + "department_path", + ] + assert all(call["tenant_id"] == 7 for _, call in directory.calls) + assert all(call["resource_type"] == "channel" for _, call in directory.calls) + assert runtime.calls == [] + + +async def test_cross_tenant_scope_fails_before_catalog_or_directory_access(prospective) -> None: + service, runtime, directory = prospective + + with pytest.raises(PermissionDeniedError): + await service.get_context( + actor=_actor(tenant_id=7), + tenant_id=8, + resource_type="knowledge_space", + ) + + assert runtime.calls == [] + assert directory.calls == [] + + +async def test_super_admin_may_use_a_business_verified_cross_tenant_scope(prospective) -> None: + service, _, directory = prospective + actor = PermissionActor(user_id=1, current_tenant_id=1, super_admin=True) + + await service.list_users( + actor=actor, + tenant_id=8, + resource_type="knowledge_space", + keyword="", + page=1, + page_size=50, + ) + + assert directory.calls[0][1]["tenant_id"] == 8 diff --git a/src/frontend/client/AGENTS.md b/src/frontend/client/AGENTS.md index a0e9c50fe3..3eeda7f515 100644 --- a/src/frontend/client/AGENTS.md +++ b/src/frontend/client/AGENTS.md @@ -35,3 +35,6 @@ Vite 6 + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + **Recoil - Typography (new code): semantic classes `text-caption/body-sm/body/h4…h1` (auto-remap ≤768px) — not raw `text-sm/base` (基础-字体规范.md). - Neutral colors (new code): semantic tokens `text-text-1…4` / `bg-fill-1…4` / `border-border-base|-deep` / `success|warning|danger` — never `text-gray-*` or hex (基础-色彩规范.md). - Hover/touch: plain `hover:` classes ONLY (`hoverOnlyWhenSupported` disables them on touch app-wide) — **never invent hover variant prefixes**; touch press via `coarse-pointer:active:`; hover/active shade stays within the base color's own ramp (no cross-palette graying). + +## Known Pitfalls +- **`useLocalize()` return value is unstable**: `~/hooks/useLocalize.ts` returns a new arrow-function identity on every render (no memoization). Any `useCallback`/`useMemo` that lists `localize` in its deps is therefore also unstable every render. Never let such a callback sit in a `useEffect` dep array that's meant to run only when real data changes (e.g. a "hydrate form from server response" effect) — the effect will silently re-fire on every render and can reset in-progress user input/toggles on every keystroke. Found in `ChannelSettings/useChannelSettingsForm.ts` (`initBusinessFromChannel` dep), symptom: edit-page inputs/switches appeared unresponsive because the fetched detail was re-applied after every keystroke. Guard "run once when data arrives" effects with a ref/id check instead of relying on function-reference deps, or drop the `localize`-derived function from the dep array with a lint-justified comment. diff --git a/src/frontend/client/eslint-suppressions.json b/src/frontend/client/eslint-suppressions.json index e1e0861e06..343f64cafd 100644 --- a/src/frontend/client/eslint-suppressions.json +++ b/src/frontend/client/eslint-suppressions.json @@ -65,7 +65,7 @@ }, "src/api/chatApi.ts": { "@typescript-eslint/no-explicit-any": { - "count": 20 + "count": 16 } }, "src/api/index.ts": { @@ -75,7 +75,7 @@ }, "src/api/knowledge.ts": { "@typescript-eslint/no-explicit-any": { - "count": 67 + "count": 64 }, "@typescript-eslint/no-unused-vars": { "count": 1 @@ -115,11 +115,6 @@ "count": 4 } }, - "src/api/permission.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, "src/api/quota.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -376,7 +371,7 @@ "count": 11 }, "@typescript-eslint/no-unused-vars": { - "count": 6 + "count": 4 } }, "src/components/Chat/Input/Files/FileFormWrapper.tsx": { @@ -882,11 +877,6 @@ "count": 1 } }, - "src/components/FileListRow.tsx": { - "no-restricted-syntax": { - "count": 11 - } - }, "src/components/Files/ActionButton.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -2619,20 +2609,6 @@ "count": 6 } }, - "src/pages/Subscription/CreateChannel/CreateChannelDrawer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - }, - "@typescript-eslint/no-unused-vars": { - "count": 9 - }, - "no-restricted-syntax": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "src/pages/Subscription/CreateChannel/FilterConditionEditor.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 @@ -2664,11 +2640,6 @@ "count": 1 } }, - "src/pages/Subscription/channelUtils.ts": { - "no-restricted-syntax": { - "count": 3 - } - }, "src/pages/Subscription/hooks/useArticleShare.ts": { "react-hooks/exhaustive-deps": { "count": 1 @@ -2710,10 +2681,7 @@ }, "src/pages/Subscription/index.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 4 - }, - "@typescript-eslint/no-unused-vars": { - "count": 2 + "count": 3 }, "no-console": { "count": 2 @@ -3159,9 +3127,6 @@ "@typescript-eslint/no-unused-expressions": { "count": 1 }, - "no-case-declarations": { - "count": 9 - }, "no-console": { "count": 4 }, @@ -3245,11 +3210,6 @@ "count": 1 } }, - "src/pages/knowledge/CreateKnowledgeSpaceDrawer.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "src/pages/knowledge/FilePreview/DocumentViewer.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -3264,9 +3224,6 @@ } }, "src/pages/knowledge/FilePreview/index.tsx": { - "@typescript-eslint/ban-ts-comment": { - "count": 2 - }, "react-hooks/exhaustive-deps": { "count": 1 } @@ -3437,9 +3394,6 @@ } }, "src/pages/knowledge/hooks/useFileUpload.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - }, "@typescript-eslint/no-unused-vars": { "count": 1 }, @@ -3490,9 +3444,6 @@ "src/pages/knowledge/sidebar/KnowledgeSpaceItem.tsx": { "@typescript-eslint/no-unused-expressions": { "count": 1 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 } }, "src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx": { diff --git a/src/frontend/client/package.json b/src/frontend/client/package.json index 27f4671180..4681c5f7e4 100644 --- a/src/frontend/client/package.json +++ b/src/frontend/client/package.json @@ -67,7 +67,7 @@ "@tanstack/react-table": "^8.11.7", "@tanstack/react-virtual": "^3", "axios": "^1.8.4", - "bisheng-icons": "^0.2.28", + "bisheng-icons": "^0.2.30", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", "copy-to-clipboard": "^3.3.3", diff --git a/src/frontend/client/src/api/channels.ts b/src/frontend/client/src/api/channels.ts index de1b63b507..8a416407c8 100644 --- a/src/frontend/client/src/api/channels.ts +++ b/src/frontend/client/src/api/channels.ts @@ -1,4 +1,18 @@ import request from "./request"; +import { mapInitialPermissionResult } from "./permission"; +import type { + InitialPermissionResult, + InitialPermissionsPayload, + RawInitialPermissionResult, +} from "./permission"; + +function unwrapChannelPermissionPayload(response: any): T { + const statusCode = response?.status_code ?? response?.code ?? 200; + if (statusCode !== 200) { + throw new Error(response?.status_message || response?.message || `Channel request failed: ${statusCode}`); + } + return (response?.data ?? response) as T; +} // 排序方式 export enum SortType { @@ -443,6 +457,14 @@ export interface CreateManagerChannelPayload { is_released?: boolean; // 是否发布(可选) /** v2.5 Module D — saved atomically with the channel. */ knowledge_sync?: KnowledgeSyncConfig; + initialPermissions?: InitialPermissionsPayload; + creationRequestId?: string; +} + +export interface CreateManagerChannelResult { + id: string; + initialPermissionResult?: InitialPermissionResult; + [key: string]: unknown; } /** @@ -451,8 +473,34 @@ export interface CreateManagerChannelPayload { */ export async function createManagerChannelApi( data: CreateManagerChannelPayload -): Promise { - return await request.post(`/api/v1/channel/manager/create`, data, { showError: true } as any); +): Promise { + const { initialPermissions, creationRequestId, ...channelData } = data; + const body = { + ...channelData, + ...(creationRequestId ? { creation_request_id: creationRequestId } : {}), + ...(initialPermissions ? { initial_permissions: initialPermissions } : {}), + }; + const response = await request.post( + `/api/v1/channel/manager/create`, + body, + { showError: true } as any, + ); + const raw = unwrapChannelPermissionPayload< + Record & { + id?: string; + initial_permission_result?: RawInitialPermissionResult | null; + } + >(response); + if (!raw || raw.id === undefined || raw.id === null) { + throw new Error("createManagerChannelApi: missing data"); + } + const { initial_permission_result: rawPermissionResult, ...channel } = raw; + const initialPermissionResult = mapInitialPermissionResult(rawPermissionResult); + return { + ...channel, + id: String(raw.id), + ...(initialPermissionResult ? { initialPermissionResult } : {}), + }; } /** diff --git a/src/frontend/client/src/api/chatApi.ts b/src/frontend/client/src/api/chatApi.ts index c72660e021..91a6aaddcc 100644 --- a/src/frontend/client/src/api/chatApi.ts +++ b/src/frontend/client/src/api/chatApi.ts @@ -605,7 +605,7 @@ export async function getFolderSessions( spaceId: string | number, folderId?: string | number ): Promise { - const params: Record = {}; + const params: Record = {}; if (folderId != null && folderId !== "") params.folder_id = folderId; const res = await http.get( `/api/v1/knowledge/space/${spaceId}/chat/folder/session`, @@ -622,7 +622,7 @@ export async function createFolderSession( spaceId: string | number, folderId?: number ): Promise { - const body: Record = {}; + const body: Record = {}; if (folderId != null) body.folder_id = folderId; const res = await http.post( `/api/v1/knowledge/space/${spaceId}/chat/folder/session`, @@ -637,7 +637,7 @@ export async function deleteFolderSession( chatId: string, folderId?: number ): Promise { - const body: Record = { chat_id: chatId }; + const body: Record = { chat_id: chatId }; if (folderId != null) body.folder_id = folderId; await http.deleteWithOptions( `/api/v1/knowledge/space/${spaceId}/chat/folder/session`, @@ -665,7 +665,7 @@ export async function getFolderChatHistory( pageSize?: number; } ): Promise { - const queryParams: Record = {}; + const queryParams: Record = {}; if (params.folderId != null && params.folderId !== "") queryParams.folder_id = params.folderId; if (params.chatId) queryParams.chat_id = params.chatId; diff --git a/src/frontend/client/src/api/knowledge.ts b/src/frontend/client/src/api/knowledge.ts index d439029302..82479d6a36 100644 --- a/src/frontend/client/src/api/knowledge.ts +++ b/src/frontend/client/src/api/knowledge.ts @@ -1,6 +1,14 @@ // @ts-strict-ignore import request from "./request"; import { resolveKnowledgeParseFailureMessage } from "./knowledgeParseFailureMessage"; +import { mapInitialPermissionResult } from "./permission"; +import type { + InitialPermissionResult, + InitialPermissionsPayload, + RawInitialPermissionResult, +} from "./permission"; + +export type { InitialPermissionResult } from "./permission"; // Standard backend response wrapper interface ApiResponse { @@ -128,6 +136,8 @@ export interface KnowledgeSpace { departmentName?: string; approvalEnabled?: boolean; sensitiveCheckEnabled?: boolean; + actions?: string[]; + initialPermissionResult?: InitialPermissionResult; } export type SpaceSubscribeStatus = "subscribed" | "pending"; @@ -196,6 +206,12 @@ export interface KnowledgeFile { user_name?: string; // mapped from user_name — original uploader of this file // Transient UI-only fields isCreating?: boolean; + /** + * The unmapped server row, kept only for duplicate entries (status 3) so the + * retry API can echo it back verbatim. Set by the list mappers below; nothing + * reads its fields, so it travels as the raw shape it arrived in. + */ + _raw?: RawSpaceChild; } // ───────────────────────────────────────────── @@ -229,6 +245,8 @@ interface RawKnowledgeSpace { is_pending?: boolean; is_followed?: boolean; subscription_status?: string; + initial_permission_result?: RawInitialPermissionResult | null; + actions?: string[]; } export interface KnowledgeSpaceTagLibraryListItem { @@ -244,7 +262,7 @@ export interface KnowledgeSpaceTagLibraryPage { total: number; } -interface RawSpaceChild { +export interface RawSpaceChild { id: number; name: string; /** "folder" | "file" */ @@ -354,6 +372,7 @@ function mapSpace(raw: RawKnowledgeSpace): KnowledgeSpace { (raw as any).sensitive_check_enabled !== undefined ? Boolean((raw as any).sensitive_check_enabled) : undefined, + actions: Array.isArray(raw.actions) ? raw.actions : [], }; } @@ -962,7 +981,7 @@ export async function getSquareSpacesApi(params?: { /** * Create a new knowledge space */ -export async function createSpaceApi(data: { +export interface CreateSpacePayload { name: string; description?: string; icon?: string; @@ -971,8 +990,18 @@ export async function createSpaceApi(data: { auto_tag_enabled?: boolean; auto_tag_library_id?: number | null; auto_tag_custom_tags?: string[] | null; -}): Promise { - const res: any = await request.post(`/api/v1/knowledge/space`, data); + initialPermissions?: InitialPermissionsPayload; + creationRequestId?: string; +} + +export async function createSpaceApi(data: CreateSpacePayload): Promise { + const { initialPermissions, creationRequestId, ...spaceData } = data; + const body = { + ...spaceData, + ...(creationRequestId ? { creation_request_id: creationRequestId } : {}), + ...(initialPermissions ? { initial_permissions: initialPermissions } : {}), + }; + const res = await request.post(`/api/v1/knowledge/space`, body); const statusCode = res?.status_code ?? res?.code ?? 200; if (statusCode !== 200) { throw new Error(res?.status_message || res?.message || "createSpaceApi failed"); @@ -981,7 +1010,12 @@ export async function createSpaceApi(data: { if (!raw || raw?.id === undefined || raw?.id === null) { throw new Error("createSpaceApi: missing data"); } - return mapSpace({ ...raw, user_role: SpaceRole.CREATOR }); + const space = mapSpace({ ...raw, user_role: SpaceRole.CREATOR }); + const initialPermissionResult = mapInitialPermissionResult(raw.initial_permission_result); + return { + ...space, + ...(initialPermissionResult ? { initialPermissionResult } : {}), + }; } /** @@ -1477,7 +1511,7 @@ export async function addFilesApi( const file = mapChild(raw, space_id); // Preserve raw object for status 3 (duplicate) so retry API can use it if (raw?.status === 3) { - (file as any)._raw = raw; + file._raw = raw; } return file; }); @@ -1537,7 +1571,7 @@ export async function uploadFolderApi( const file = mapChild(raw, space_id); // Preserve raw object for status 3 (duplicate) so retry API can use it if (raw?.status === 3) { - (file as any)._raw = raw; + file._raw = raw; } return file; }); diff --git a/src/frontend/client/src/api/permission.ts b/src/frontend/client/src/api/permission.ts index 682b48b287..41e42b0889 100644 --- a/src/frontend/client/src/api/permission.ts +++ b/src/frontend/client/src/api/permission.ts @@ -23,6 +23,38 @@ export interface GrantablePermissionModel { active: boolean; } +export interface CreationPermissionContext { + catalog_release_id: number; + can_configure_initial_permissions: boolean; + grantable_models: GrantablePermissionModel[]; +} + +export interface InitialPermissionGrant { + model_key: string; + subject: PermissionGrantSubjectInput; +} + +export interface InitialPermissionsPayload { + expected_catalog_release_id: number; + grants: InitialPermissionGrant[]; +} + +export interface RawInitialPermissionResult { + status: "succeeded" | "failed"; + resource_version?: number | null; + assignee_ids?: string[]; + error_code?: number | null; + message?: string | null; +} + +export interface InitialPermissionResult { + status: "succeeded" | "failed"; + resourceVersion?: number; + assigneeIds: string[]; + errorCode: number | null; + message?: string; +} + export interface ResourcePermissionContext { mode: ResourcePermissionMode; parent_type: ResourceType | null; @@ -156,20 +188,29 @@ interface PermissionRequestConfig { // Client request layer returns the full backend envelope {status_code, status_message, data}. // All functions below unwrap .data so callers get the payload directly. -function assertSuccess(res: any) { - if (res && typeof res === "object" && "status_code" in res && res.status_code !== 200) { - throw new Error(res.status_message || `Permission request failed: ${res.status_code}`); +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" ? value as JsonRecord : null; +} + +function assertSuccess(res: unknown) { + const record = asRecord(res); + if (record && "status_code" in record && record.status_code !== 200) { + throw new Error(String(record.status_message || `Permission request failed: ${record.status_code}`)); } } -function unwrap(res: any): T { +function unwrap(res: unknown): T { assertSuccess(res); - return res?.data ?? res; + const record = asRecord(res); + return (record && "data" in record ? record.data : res) as T; } -function unwrapArray(res: any): T[] { - const data = unwrap(res); - const rows = data?.data ?? data?.list ?? data?.records ?? data; +function unwrapArray(res: unknown): T[] { + const data = unwrap(res); + const record = asRecord(data); + const rows = record?.data ?? record?.list ?? record?.records ?? data; return Array.isArray(rows) ? rows : []; } @@ -180,6 +221,117 @@ function withPermissionRequestOptions(config?: PermissionRequestConfig) { }; } +export function mapInitialPermissionResult( + result?: RawInitialPermissionResult | null, +): InitialPermissionResult | undefined { + if (!result) return undefined; + return { + status: result.status, + ...(result.resource_version == null + ? {} + : { resourceVersion: result.resource_version }), + assigneeIds: result.assignee_ids ?? [], + errorCode: result.error_code ?? null, + ...(result.message ? { message: result.message } : {}), + }; +} + +type CreationResourceType = "knowledge_space" | "channel"; + +function creationPermissionPath(resourceType: CreationResourceType): string { + return resourceType === "knowledge_space" + ? "/api/v1/knowledge/space" + : "/api/v1/channel/manager"; +} + +export async function getCreationPermissionContext( + resourceType: CreationResourceType, + config?: PermissionRequestConfig, +): Promise { + const res = await request.get( + `${creationPermissionPath(resourceType)}/creation-permission-context`, + withPermissionRequestOptions(config), + ); + return unwrap(res); +} + +export async function searchCreationUsers( + resourceType: CreationResourceType, + name: string, + params?: { page?: number; pageSize?: number }, + config?: PermissionRequestConfig, +): Promise<{ data: GrantUser[]; total: number }> { + const res = await request.get( + `${creationPermissionPath(resourceType)}/creation-grant-subjects/users`, + { + params: { + keyword: name, + page: params?.page ?? 1, + page_size: params?.pageSize ?? 50, + }, + ...withPermissionRequestOptions(config), + }, + ); + const data = unwrap(res); + const record = asRecord(data); + const rows = record?.data ?? data; + const list = Array.isArray(rows) ? rows : []; + return { data: list as GrantUser[], total: Number(record?.total ?? list.length) }; +} + +export async function getCreationDepartmentChildren( + resourceType: CreationResourceType, + parentId: number | null, + config?: PermissionRequestConfig, +): Promise { + const res = await request.get( + `${creationPermissionPath(resourceType)}/creation-grant-subjects/departments/children`, + { + params: { parent_id: parentId ?? undefined }, + ...withPermissionRequestOptions(config), + }, + ); + return unwrapArray(res); +} + +export async function searchCreationDepartments( + resourceType: CreationResourceType, + keyword: string, + limit = 50, + config?: PermissionRequestConfig, +): Promise { + const res = await request.get( + `${creationPermissionPath(resourceType)}/creation-grant-subjects/departments/search`, + { + params: { keyword, limit }, + ...withPermissionRequestOptions(config), + }, + ); + return unwrap(res); +} + +export async function getCreationUserGroups( + resourceType: CreationResourceType, + config?: PermissionRequestConfig, +): Promise<{ id: number; group_name: string }[]> { + const res = await request.get( + `${creationPermissionPath(resourceType)}/creation-grant-subjects/user-groups`, + { + params: { page: 1, page_size: 200 }, + ...withPermissionRequestOptions(config), + }, + ); + const data = unwrap(res); + const record = asRecord(data); + const rows = record?.data ?? data; + return Array.isArray(rows) + ? rows.map((row) => { + const item = asRecord(row) ?? {}; + return { id: Number(item.id), group_name: String(item.name ?? item.group_name ?? "") }; + }) + : []; +} + // ── Permission APIs ────────────────────────────────── function permissionResourcePath( @@ -220,6 +372,32 @@ export async function getResourcePermissionGrants( return unwrap(res); } +export async function getAllResourcePermissionGrants( + resourceType: ResourceType, + resourceId: string, + config?: PermissionRequestConfig, +): Promise { + const items: PermissionGrantAssignee[] = []; + const seenCursors = new Set(); + let cursor: string | null = null; + + for (;;) { + const page = await getResourcePermissionGrants( + resourceType, + resourceId, + { cursor, page_size: 200 }, + config, + ); + items.push(...page.data); + if (!page.has_more) return items; + if (!page.next_cursor || seenCursors.has(page.next_cursor)) { + throw new Error("Permission roster pagination returned an invalid cursor"); + } + seenCursors.add(page.next_cursor); + cursor = page.next_cursor; + } +} + export async function getMyResourcePermissions( resourceType: ResourceType, resourceId: string, @@ -335,10 +513,11 @@ export async function searchUsers( ...withPermissionRequestOptions(config), } ); - const data = unwrap(res); - const rows = data?.data ?? data; + const data = unwrap(res); + const record = asRecord(data); + const rows = record?.data ?? data; const list = Array.isArray(rows) ? rows : []; - return { data: list, total: Number(data?.total ?? list.length) }; + return { data: list as GrantUser[], total: Number(record?.total ?? list.length) }; } // ── Lazy organization-department tree ──────────────── @@ -424,9 +603,13 @@ export async function getUserGroups( ...withPermissionRequestOptions(config), } ); - const data = unwrap(res); - const rows = data?.data ?? data; + const data = unwrap(res); + const record = asRecord(data); + const rows = record?.data ?? data; return Array.isArray(rows) - ? rows.map((row: any) => ({ id: row.id, group_name: row.name ?? row.group_name })) + ? rows.map((row) => { + const item = asRecord(row) ?? {}; + return { id: Number(item.id), group_name: String(item.name ?? item.group_name ?? "") }; + }) : []; } diff --git a/src/frontend/client/src/api/unifiedPermissionSettings.test.ts b/src/frontend/client/src/api/unifiedPermissionSettings.test.ts new file mode 100644 index 0000000000..0b69ee8027 --- /dev/null +++ b/src/frontend/client/src/api/unifiedPermissionSettings.test.ts @@ -0,0 +1,224 @@ +/** @jest-environment node */ + +import request from "./request"; +import { + getCreationDepartmentChildren, + getCreationPermissionContext, + getAllResourcePermissionGrants, + getResourcePermissionContext, + mutateResourceGrants, + searchCreationUsers, +} from "./permission"; +import { createSpaceApi } from "./knowledge"; +import { createManagerChannelApi } from "./channels"; + +jest.mock("./request", () => ({ + __esModule: true, + default: { get: jest.fn(), post: jest.fn() }, +})); + +const mockedRequest = request as jest.Mocked; + +describe("F048 unified permission settings adapter", () => { + beforeEach(() => jest.clearAllMocks()); + + it.each([ + ["knowledge_space" as const, "/api/v1/knowledge/space"], + ["channel" as const, "/api/v1/channel/manager"], + ])("loads %s prospective context from its business domain", async (type, path) => { + const controller = new AbortController(); + const context = { + catalog_release_id: 42, + can_configure_initial_permissions: true, + grantable_models: [{ key: "viewer", name: "Viewer", level: 1, active: true }], + }; + mockedRequest.get.mockResolvedValueOnce({ status_code: 200, data: context }); + + await expect(getCreationPermissionContext(type, { signal: controller.signal })) + .resolves.toEqual(context); + expect(mockedRequest.get).toHaveBeenCalledWith( + `${path}/creation-permission-context`, + expect.objectContaining({ signal: controller.signal, skip403Redirect: true }), + ); + }); + + it("uses domain-scoped creation candidates and preserves abort signals", async () => { + const controller = new AbortController(); + mockedRequest.get + .mockResolvedValueOnce({ status_code: 200, data: { data: [{ user_id: 7 }], total: 1 } }) + .mockResolvedValueOnce({ status_code: 200, data: [{ id: 8, name: "Platform" }] }); + + await searchCreationUsers("knowledge_space", "Ada", { page: 2, pageSize: 20 }, { + signal: controller.signal, + }); + await getCreationDepartmentChildren("channel", null, { signal: controller.signal }); + + expect(mockedRequest.get).toHaveBeenNthCalledWith( + 1, + "/api/v1/knowledge/space/creation-grant-subjects/users", + expect.objectContaining({ + params: { keyword: "Ada", page: 2, page_size: 20 }, + signal: controller.signal, + }), + ); + expect(mockedRequest.get).toHaveBeenNthCalledWith( + 2, + "/api/v1/channel/manager/creation-grant-subjects/departments/children", + expect.objectContaining({ signal: controller.signal }), + ); + }); + + it("preserves F048 resource and catalog versions", async () => { + mockedRequest.get.mockResolvedValueOnce({ + status_code: 200, + data: { + mode: "CUSTOM", + parent_type: null, + parent_id: null, + resource_version: 9, + catalog_release_id: 42, + projection_state: "READY", + can_manage_permission: true, + }, + }); + mockedRequest.post.mockResolvedValueOnce({ + status_code: 200, + data: { resource_version: 10, items: [] }, + }); + + await expect(getResourcePermissionContext("channel", "c-1")) + .resolves.toMatchObject({ resource_version: 9, catalog_release_id: 42 }); + await expect(mutateResourceGrants("channel", "c-1", { + idempotency_key: "mutation-1", + expected_resource_version: 9, + expected_catalog_release_id: 42, + changes: [], + })).resolves.toMatchObject({ resource_version: 10 }); + }); + + it("loads every cursor page without merging assignee sources", async () => { + mockedRequest.get + .mockResolvedValueOnce({ + status_code: 200, + data: { + data: [{ assignee_id: "direct-1", source: { type: "DIRECT" } }], + page_size: 200, + has_more: true, + next_cursor: "cursor-2", + }, + }) + .mockResolvedValueOnce({ + status_code: 200, + data: { + data: [{ assignee_id: "department-1", source: { type: "DEPARTMENT" } }], + page_size: 200, + has_more: false, + next_cursor: null, + }, + }); + + await expect(getAllResourcePermissionGrants("knowledge_space", "space-1")) + .resolves.toMatchObject([ + { assignee_id: "direct-1", source: { type: "DIRECT" } }, + { assignee_id: "department-1", source: { type: "DEPARTMENT" } }, + ]); + expect(mockedRequest.get).toHaveBeenNthCalledWith( + 2, + "/api/v1/permissions/resources/knowledge_space/space-1/grants", + expect.objectContaining({ params: { cursor: "cursor-2", page_size: 200 } }), + ); + }); + + it("maps knowledge creation options and partial success", async () => { + mockedRequest.post.mockResolvedValueOnce({ + status_code: 200, + data: { + id: 42, + name: "Docs", + auth_type: "public", + initial_permission_result: { + status: "failed", + resource_version: 3, + assignee_ids: [], + error_code: 21009, + }, + }, + }); + const initialPermissions = { + expected_catalog_release_id: 42, + grants: [{ model_key: "viewer", subject: { type: "user" as const, id: "7" } }], + }; + const result = await createSpaceApi({ + name: "Docs", + auth_type: "public", + creationRequestId: "request-1", + initialPermissions, + }); + + expect(mockedRequest.post).toHaveBeenCalledWith("/api/v1/knowledge/space", { + name: "Docs", + auth_type: "public", + creation_request_id: "request-1", + initial_permissions: initialPermissions, + }); + expect(result.initialPermissionResult).toEqual({ + status: "failed", + resourceVersion: 3, + assigneeIds: [], + errorCode: 21009, + }); + }); + + it("keeps channel business fields while adding creation permissions", async () => { + mockedRequest.post.mockResolvedValueOnce({ + status_code: 200, + data: { id: "channel-1", name: "News", initial_permission_result: { status: "succeeded" } }, + }); + const initialPermissions = { + expected_catalog_release_id: 42, + grants: [{ + model_key: "editor", + subject: { type: "department" as const, id: "8", include_children: true }, + }], + }; + await createManagerChannelApi({ + name: "News", + source_list: ["source-1"], + visibility: "public", + filter_rules: [], + knowledge_sync: { main: { enabled: false, spaces: [] }, subs: [] }, + creationRequestId: "request-2", + initialPermissions, + }); + + expect(mockedRequest.post).toHaveBeenCalledWith( + "/api/v1/channel/manager/create", + expect.objectContaining({ + source_list: ["source-1"], + filter_rules: [], + knowledge_sync: { main: { enabled: false, spaces: [] }, subs: [] }, + creation_request_id: "request-2", + initial_permissions: initialPermissions, + }), + expect.objectContaining({ showError: true }), + ); + }); + + it("keeps legacy create bodies unchanged when optional fields are omitted", async () => { + mockedRequest.post + .mockResolvedValueOnce({ status_code: 200, data: { id: 1, name: "Legacy", auth_type: "private" } }) + .mockResolvedValueOnce({ status_code: 200, data: { id: "c-2", name: "Legacy channel" } }); + await createSpaceApi({ name: "Legacy", auth_type: "private" }); + await createManagerChannelApi({ + name: "Legacy channel", + source_list: [], + visibility: "private", + filter_rules: [], + }); + expect(mockedRequest.post).toHaveBeenNthCalledWith( + 1, + "/api/v1/knowledge/space", + { name: "Legacy", auth_type: "private" }, + ); + }); +}); diff --git a/src/frontend/client/src/components/ChannelMemberDialog.test.tsx b/src/frontend/client/src/components/ChannelMemberDialog.test.tsx deleted file mode 100644 index 0f21df2bdf..0000000000 --- a/src/frontend/client/src/components/ChannelMemberDialog.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import type { Channel } from "~/api/channels"; -import { ChannelPermissionDialog } from "~/pages/Subscription/ChannelPermissionDialog"; - -jest.mock("~/components/permission/PermissionDialog", () => ({ - PermissionDialog: ({ - resourceType, - resourceId, - resourceName, - }: { - resourceType: string; - resourceId: string; - resourceName: string; - }) => ( -
- {resourceType}:{resourceId}:{resourceName} -
- ), -})); - -const channel = { - id: "channel-1", - name: "Engineering", -} as Channel; - -describe("F048 Client channel permission entry", () => { - it("reuses the F048 channel dialog and stable resource identity", () => { - render( - , - ); - - expect( - screen.getByText("channel:channel-1:Engineering"), - ).toBeInTheDocument(); - }); - - it("contains no relation-model selector or relation payload adapter", () => { - const dialogSource = readFileSync( - resolve( - process.cwd(), - "src/pages/Subscription/ChannelPermissionDialog.tsx", - ), - "utf8", - ); - - expect(dialogSource).not.toMatch( - /RelationSelect|RelationModel|authorizeChannelApi|relation:/, - ); - - const apiSource = readFileSync( - resolve(process.cwd(), "src/api/channels.ts"), - "utf8", - ); - expect(apiSource).not.toMatch( - /ChannelRelation|ChannelUserRole|item\.relation/, - ); - }); -}); diff --git a/src/frontend/client/src/components/Chat/AiModelSelect.tsx b/src/frontend/client/src/components/Chat/AiModelSelect.tsx index f3ac1d3a72..883b1aed32 100644 --- a/src/frontend/client/src/components/Chat/AiModelSelect.tsx +++ b/src/frontend/client/src/components/Chat/AiModelSelect.tsx @@ -77,10 +77,14 @@ const AiModelSelect = memo( very long ones. `auto` (see SelectContent) keeps the popup from being forced to the trigger's width. No flash on open: the model list is already in memory via `options`. */} - + {uniqueOptions.map((opt) => ( - -
+ +
{opt.displayName} {opt.description && ( <> diff --git a/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx b/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx index 9754ca381d..839520f41c 100644 --- a/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx +++ b/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx @@ -47,8 +47,8 @@ interface Props { function iconForGroup(group: AvailableToolGroup) { const firstKey = group.children?.[0]?.tool_key; - if (firstKey === "web_search") return ; - return ; + if (firstKey === "web_search") return ; + return ; } export default function AgentToolSelector({ availableTools, disabled, compact = false }: Props) { @@ -130,7 +130,7 @@ export default function AgentToolSelector({ availableTools, disabled, compact = brand-blue once a tool is selected — mirrors the knowledge-space selector (ChatKnowledge) so both pickers signal an active selection. */}
- +
{/* Compact: collapse to icon + chevron only to save horizontal space. */} {!compact && ( @@ -140,9 +140,12 @@ export default function AgentToolSelector({ availableTools, disabled, compact = )}
- + {availableTools.map((group) => ( -
+
{iconForGroup(group)} (value: T, delay: number): T { return debouncedValue; } +/** Cap for the mobile popup / drill panels (further clamped to whatever space + * the viewport actually offers — see mobileDrillMaxH). */ const MAX_SUB_HEIGHT = 256; +/** Cap for the desktop 知识空间 panel. Its own value: the skill / org panels sit + * at 440, this one stays shorter so it still opens downward more often. */ +const KNOWLEDGE_PANEL_MAX_H = 320; const BOTTOM_GAP = 8; +/** Distance from the chat input box's outer edge to its toolbar: the box's + * `p-3` (12px) plus its 1px border (AiChatInput / TaskModeInput shell). */ +const INPUT_INNER_INSET = 13; /** 移动端:碰撞检测余量;底部勿过大,否则 flip/shift 会把整块菜单顶到视口上方导致裁切 */ const MOBILE_MENU_COLLISION = { top: 56, @@ -47,106 +52,6 @@ const MOBILE_MENU_COLLISION = { right: 12, } as const; -/** - * Compute an alignOffset so the sub-content top aligns with the parent menu top, - * and clamp maxH so the sub-content never overflows the viewport on either side. - * - * After the sub-content is rendered by Radix, we also observe its real position - * and re-clamp maxH based on the actual top (handles Radix collision shifting). - */ -function useSubMenuLayout(menuRef: React.RefObject, triggerKey: string, open: boolean) { - const [alignOffset, setAlignOffset] = useState(0); - const [maxH, setMaxH] = useState(MAX_SUB_HEIGHT); - const subContentRef = useRef(null); - - // Phase 1 — compute alignOffset & an initial maxH from parent menu rect - useLayoutEffect(() => { - if (!open) return; - - const update = () => { - const menuEl = menuRef.current; - if (!menuEl) return; - - const menuRect = menuEl.getBoundingClientRect(); - - const trigger = menuEl.querySelector(`[data-sub-key="${triggerKey}"]`); - if (trigger) { - const triggerRect = trigger.getBoundingClientRect(); - setAlignOffset(Math.round(menuRect.top - triggerRect.top)); - } else { - setAlignOffset(0); - } - - // Initial estimate — will be refined in Phase 2 - const spaceBelow = window.innerHeight - menuRect.top - BOTTOM_GAP; - const spaceAbove = menuRect.bottom - BOTTOM_GAP; - const available = Math.max(spaceBelow, spaceAbove); - setMaxH(Math.min(Math.max(available, 120), MAX_SUB_HEIGHT)); - }; - - requestAnimationFrame(update); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); - }, [open, menuRef, triggerKey]); - - // Phase 2 — once Radix renders the actual sub-content, observe its real - // position and clamp maxH so it stays within the viewport. - useEffect(() => { - if (!open) { - subContentRef.current = null; - return; - } - - // Radix renders sub-content in a portal; locate it by role + data attribute - const findSubContent = (): HTMLElement | null => { - // Look for the sub-content element associated with this trigger - const menuEl = menuRef.current; - if (!menuEl) return null; - - const trigger = menuEl.querySelector(`[data-sub-key="${triggerKey}"]`); - if (!trigger) return null; - - // The sub-content is rendered in a portal; we find it via the Radix - // data-state="open" attribute on [role="menu"] elements in the document - const allMenus = document.querySelectorAll('[role="menu"][data-state="open"]'); - // Pick the deepest nested one that is NOT the parent menu - for (const m of Array.from(allMenus)) { - if (m !== menuEl && !menuEl.contains(m)) { - return m; - } - } - return null; - }; - - const clampToViewport = () => { - const el = subContentRef.current || findSubContent(); - if (!el) return; - subContentRef.current = el; - - const rect = el.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.top - BOTTOM_GAP; - const finalH = Math.min(Math.max(spaceBelow, 120), MAX_SUB_HEIGHT); - setMaxH(finalH); - }; - - // Wait a tick for Radix portal to mount - const rafId = requestAnimationFrame(() => { - requestAnimationFrame(clampToViewport); - }); - - window.addEventListener('resize', clampToViewport); - window.addEventListener('scroll', clampToViewport, true); - - return () => { - cancelAnimationFrame(rafId); - window.removeEventListener('resize', clampToViewport); - window.removeEventListener('scroll', clampToViewport, true); - }; - }, [open, menuRef, triggerKey]); - - return { alignOffset, maxH }; -} - // --- main --- export const ChatKnowledge = ({ variant = 'plus', @@ -321,7 +226,6 @@ export const ChatKnowledge = ({ const isMobile = useMediaQuery('(max-width: 576px)'); const [mobilePanel, setMobilePanel] = useState<'root' | 'org' | 'skill'>('root'); const menuContentRef = useRef(null); - const orgLayout = useSubMenuLayout(menuContentRef, 'org', openSub === 'org'); const handleRootOpenChange = useCallback((open: boolean) => { setRootOpen(open); @@ -358,7 +262,7 @@ export const ChatKnowledge = ({ // otherwise open upward to avoid being clipped by the chat input area. const preferBottom = below >= 240 || below >= above; setMobileMenuSide(preferBottom ? 'bottom' : 'top'); - // Fixed cap aligned with the desktop popup (MAX_SUB_HEIGHT = 256), but + // Fixed cap, but // fall back to whatever space is actually available on the chosen side if // 256 wouldn't fit — keeps the popup from being clipped against the // viewport edge on smaller phones. @@ -403,7 +307,7 @@ export const ChatKnowledge = ({ aria-hidden className={cn( "block size-4", - selectedKnowledgeSpaces.length > 0 ? "bg-blue-500" : "bg-[#999999]" + selectedKnowledgeSpaces.length > 0 ? "bg-blue-500" : "bg-[#4E5969]" )} style={{ WebkitMaskImage: `url(${__APP_ENV__.BASE_URL || ''}/assets/channel/book-one.svg)`, @@ -447,18 +351,24 @@ export const ChatKnowledge = ({ e.preventDefault()} className={cn( - 'flex flex-col gap-0 rounded-lg border-0 shadow-[0_2px_16px_-2px_rgba(0,23,66,0.10)]', + 'flex flex-col gap-1 rounded-2xl border-0 shadow-[0_2px_16px_-2px_rgba(0,23,66,0.10)]', // variant-aware width/padding: the pill (knowledge) shows a list // directly, so it needs the wider list layout; the "+" menu stays - // compact for its short action items. + // compact for its short action items. Bottom padding is 0 on the list + // variant — the scroll list carries its own so rows can reach the edge. variant === 'knowledge' - ? 'w-[240px] overflow-hidden pt-2 px-2 pb-0' - : 'w-[160px] p-2', + ? 'w-[240px] overflow-hidden pt-3 px-3 pb-0' + : 'w-[160px] p-3', // Mobile width override only applies to the knowledge variant — the // "+" menu shows short action items and matches the desktop 160px // width on phones too. (knowledge needs more room for search + list) @@ -466,9 +376,10 @@ export const ChatKnowledge = ({ // skill / org lists) needs the wider width; 160px is fine only for // the compact root of the "+" menu (short action items). isMobile && mobileTallPanel && 'touch-mobile:w-[min(calc(100vw-24px),320px)]', - // Mobile knowledge popup only: replace `p-2` with `pt-2 px-2 pb-0` so - // the scroll list's own `pb-2` handles the last-item spacing. - isMobile && variant === 'knowledge' && 'touch-mobile:pt-2 touch-mobile:px-2 touch-mobile:pb-0', + // Any mobile list panel (knowledge popup, or the "+" menu drilled into + // org / skill): replace `p-3` with `pt-3 px-3 pb-0` so the scroll list's + // own bottom padding is the only gap under the last row. + isMobile && mobileTallPanel && 'touch-mobile:pt-3 touch-mobile:px-3 touch-mobile:pb-0', isMobile && mobileTallPanel && 'touch-mobile:min-h-0 touch-mobile:overflow-hidden', @@ -479,7 +390,7 @@ export const ChatKnowledge = ({ : // Desktop knowledge pill: cap height so the space list scrolls // internally instead of growing past the viewport. !isMobile && variant === 'knowledge' - ? { maxHeight: MAX_SUB_HEIGHT } + ? { maxHeight: KNOWLEDGE_PANEL_MAX_H } : undefined } > @@ -491,9 +402,9 @@ export const ChatKnowledge = ({ if (fileUploadDisabled) return; onFileUploadClick?.(); }} - className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40" + className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40" > - + {localize('com_ui_upload_files')} )} @@ -516,35 +427,19 @@ export const ChatKnowledge = ({ )} - {/* Knowledge pill (mobile): show the SPACES list directly — no drill. - Matches the desktop layout (title + list) so both surfaces feel the - same; only the outer width / position adapt to the smaller screen. */} - {variant === 'knowledge' && isMobile && ( -
-

- {localize('com_ui_knowledge_space')} -

- handleToggle(item, 'space')} - isFetching={spaceFetching} - hasMore={false} - onLoadMore={() => { }} - emptyText={localize('com_chat_knowledge_empty_no_spaces')} - /> -
- )} - - {/* Knowledge pill (desktop): show the SPACES list directly — no sub. */} - {variant === 'knowledge' && !isMobile && ( + {/* Knowledge pill: show the SPACES list directly — no drill, no sub. + Same layout on both surfaces; only the outer width / position adapt + to the smaller screen, plus the mobile-only heading below. */} + {variant === 'knowledge' && (
-

- {localize('com_ui_knowledge_space')} -

+ {/* Mobile keeps the heading: the narrow toolbar can collapse the pill + trigger to icon-only (`compact`), leaving this as the only place + the name shows. On desktop the trigger reads "知识空间" beside it. */} + {isMobile && ( +

+ {localize('com_ui_knowledge_space')} +

+ )}
{/* Icon turns brand-blue once an org KB is selected (no dot). */} - 0 ? "text-blue-500" : "text-[#999]"} /> + 0 ? "text-blue-500" : "text-[#4E5969]"} />
{localize('com_tools_org_knowledge')} @@ -587,22 +482,24 @@ export const ChatKnowledge = ({
+ {/* `align="center"` centers the panel vertically on the trigger row + (same placement rule as the skill panel below). */} -

- {localize('com_tools_org_knowledge')} -

+ {/* No heading here: this panel hangs off the "组织知识库" row, which + stays visible next to it. The mobile drill panel keeps its own + heading — there it is the back row's label. */}
{/* Icon turns brand-blue once an org KB is selected (no dot). */} - 0 ? "text-blue-500" : "text-[#999]"} /> + 0 ? "text-blue-500" : "text-[#4E5969]"} />
{localize('com_tools_org_knowledge')} @@ -643,10 +540,10 @@ export const ChatKnowledge = ({ {/* Org knowledge selector (mobile): drill panel. */} {variant === 'plus' && isMobile && mobilePanel === 'org' && config?.knowledgeBase?.enabled !== false && (
-
+
+ {localize('com_tools_org_knowledge')} @@ -688,9 +586,9 @@ export const ChatKnowledge = ({ setRootOpen(false); onEnterTaskMode?.(); }} - className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] outline-none" + className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none" > - + {localize('com_linsight_task_mode')} @@ -704,18 +602,24 @@ export const ChatKnowledge = ({
- + {localize('com_linsight_add_skill')}
- {/* Layout mirrors the knowledge panel shell (variant === 'knowledge' above). */} - + {/* Layout mirrors the knowledge panel shell (variant === 'knowledge' above). + `align="center"` centers the panel vertically on the trigger row + instead of aligning their top edges. */} + {renderSkillSubmenu(() => setRootOpen(false))}
@@ -725,10 +629,10 @@ export const ChatKnowledge = ({ e.preventDefault(); setMobilePanel('skill'); }} - className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-2 py-[5px] outline-none" + className="flex h-8 cursor-pointer items-center justify-between gap-2 rounded-lg px-2 outline-none" >
- + {localize('com_linsight_add_skill')} @@ -743,10 +647,10 @@ export const ChatKnowledge = ({ {/* 添加 Skill — 移动端下钻面板 */} {isMobile && mobilePanel === 'skill' && renderSkillSubmenu && (
-
+
+ {localize('com_linsight_add_skill')} @@ -766,4 +671,4 @@ export const ChatKnowledge = ({ ); -}; \ No newline at end of file +}; diff --git a/src/frontend/client/src/components/Chat/Input/KnowledgeListPanel.tsx b/src/frontend/client/src/components/Chat/Input/KnowledgeListPanel.tsx index 9fe037ce64..e3074d4f18 100644 --- a/src/frontend/client/src/components/Chat/Input/KnowledgeListPanel.tsx +++ b/src/frontend/client/src/components/Chat/Input/KnowledgeListPanel.tsx @@ -8,10 +8,12 @@ * 我加入的. Callers drop empty groups before passing them in, so a group * title never renders without rows underneath it. */ -import { Loader2, SearchIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Outlined } from "bisheng-icons"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { DropdownMenuItem, Input } from "~/components/ui"; import { Checkbox } from "~/components/ui/Checkbox"; +import { EmptyStateIllustration } from "~/components/illustrations"; import { useScrollRevealRef } from "~/hooks"; import { cn } from "~/utils"; import type { KnowledgeItem } from "./knowledgeTypes"; @@ -42,6 +44,10 @@ interface KnowledgeListPanelProps { hasMore: boolean; onLoadMore: () => void; emptyText: string; + /** Hold the list at its unfiltered height while a keyword is active, so a + * centre-aligned panel doesn't jump as results shrink. Opt-in: only the + * panels that are vertically centred on their trigger need it. */ + freezeHeightOnFilter?: boolean; } export const KnowledgeListPanel = ({ @@ -56,6 +62,7 @@ export const KnowledgeListPanel = ({ hasMore, onLoadMore, emptyText, + freezeHeightOnFilter = false, }: KnowledgeListPanelProps) => { const listScrollRevealRef = useScrollRevealRef(); // Direct ref to the scroll container so we can read scroll metrics for the @@ -81,6 +88,17 @@ export const KnowledgeListPanel = ({ [sections], ); + // Filtering shrinks the list; on a panel that is centred on its trigger that + // reads as the panel jumping. Sample the unfiltered height and hold it as a + // floor while a keyword is active. + const listAreaRef = useRef(null); + const [unfilteredHeight, setUnfilteredHeight] = useState(); + useLayoutEffect(() => { + if (!freezeHeightOnFilter || keyword) return; // only the unfiltered list is a valid sample + const el = listAreaRef.current; + if (el) setUnfilteredHeight(el.getBoundingClientRect().height); + }, [freezeHeightOnFilter, keyword, totalCount]); + // Edge shadows: visible only when there is content above / below the current // viewport. Shadows fade out at the top/bottom boundary. const [canScrollUp, setCanScrollUp] = useState(false); @@ -105,12 +123,16 @@ export const KnowledgeListPanel = ({ }; return ( -
+ // gap-2: 8px between the search box and the list below it, so the first row + // doesn't crowd the input's bottom border (matches the skill panel). +
{/* 搜索框 */}
- + {/* top nudged 1px past centre: the magnifier's ring sits above the glyph's + own box, so a mathematically centred icon reads high next to the text. */} + setKeyword(e.target.value)} @@ -121,7 +143,11 @@ export const KnowledgeListPanel = ({ {/* 滚动列表 — wrapped in a relative container so the top/bottom edge shadows can be absolutely positioned over the scroll viewport. */} -
+
{/* Top edge fade — solid popup-white fades to transparent so list content visually dissolves into the menu surface. */}
{sections.map((section) => ( @@ -163,7 +189,7 @@ export const KnowledgeListPanel = ({ e.preventDefault(); onToggle(item); }} - className="flex items-center gap-2 px-2 py-[5px] cursor-pointer rounded-md data-[highlighted]:bg-[#f2f3f5] focus:bg-[#f2f3f5] outline-none transition-colors" + className="flex h-8 items-center gap-2 px-2 cursor-pointer rounded-lg data-[highlighted]:bg-[#f2f3f5] focus:bg-[#f2f3f5] outline-none transition-colors" > )} {!isFetching && totalCount === 0 && ( -
{emptyText}
+ // Fills the scroll viewport so the copy sits in the panel's middle + // rather than stranded under the search box. +
+ +

{emptyText}

+
)}
diff --git a/src/frontend/client/src/components/Chat/MessageFeedbackButtons.tsx b/src/frontend/client/src/components/Chat/MessageFeedbackButtons.tsx index 73d1d76d8f..74bf831371 100644 --- a/src/frontend/client/src/components/Chat/MessageFeedbackButtons.tsx +++ b/src/frontend/client/src/components/Chat/MessageFeedbackButtons.tsx @@ -14,11 +14,18 @@ * itself is optional). Cancel/close discards the dislike entirely. Thumbs-up * and un-toggling persist immediately. `liked` seeds the initial highlight and * re-syncs when history reload delivers the stored value. + * + * Every persisted verdict (up, or a submitted dislike) confirms with a "thanks + * for your feedback" toast; un-toggling stays silent — the icon losing its + * highlight is feedback enough, and a toast there would read as if cancelling + * had itself been recorded. When `onLike` returns a promise the toast waits for + * it, so a failed request shows only the interceptor's error toast (never both). */ import { useEffect, useState } from "react"; import { Outlined } from "bisheng-icons"; import { CommentDialog } from "~/components"; import { useLocalize } from "~/hooks"; +import { useToastContext } from "~/Providers"; import { cn } from "~/utils"; // 0 = unrated / 1 = thumbs up / 2 = thumbs down (mirrors chatmessage.liked) @@ -30,8 +37,9 @@ const ACTION_BTN = interface MessageFeedbackButtonsProps { /** Initial / persisted verdict: 0 none, 1 up, 2 down. */ liked?: number; - /** Persist the new verdict (0/1/2). Dislike is only sent on dialog submit. */ - onLike: (liked: number) => void; + /** Persist the new verdict (0/1/2). Dislike is only sent on dialog submit. + Return the request promise to gate the confirmation toast on success. */ + onLike: (liked: number) => void | Promise; /** Persist the free-text reason when the user submits a non-empty dislike comment. */ onDislikeComment?: (comment: string) => void; className?: string; @@ -44,6 +52,7 @@ export function MessageFeedbackButtons({ className, }: MessageFeedbackButtonsProps) { const localize = useLocalize(); + const { showToast } = useToastContext(); const [state, setState] = useState(liked as ThumbsState); const [commentOpen, setCommentOpen] = useState(false); @@ -52,6 +61,20 @@ export function MessageFeedbackButtons({ setState(liked as ThumbsState); }, [liked]); + // Persist, then confirm. Un-toggling (next === 0) is silent. A rejected + // request is swallowed here: the response interceptor already toasted it. + const persist = (next: ThumbsState) => { + const pending = onLike(next); + if (next === 0) return; + const thanks = () => + showToast({ message: localize("com_feedback_thanks"), status: "success" }); + if (pending && typeof (pending as Promise).then === "function") { + (pending as Promise).then(thanks, () => void 0); + } else { + thanks(); + } + }; + const handleClick = (type: ThumbsState) => { // Newly disliking with a reason dialog available: defer — no persist, // no highlight until the dialog is submitted. @@ -61,12 +84,12 @@ export function MessageFeedbackButtons({ } const next: ThumbsState = state === type ? 0 : type; setState(next); - onLike(next); + persist(next); }; const handleSubmitComment = (comment: string) => { setState(2); - onLike(2); + persist(2); if (comment) onDislikeComment?.(comment); setCommentOpen(false); }; diff --git a/src/frontend/client/src/components/Conversations/Convo.tsx b/src/frontend/client/src/components/Conversations/Convo.tsx index 38d30bdc6c..529f89bd63 100644 --- a/src/frontend/client/src/components/Conversations/Convo.tsx +++ b/src/frontend/client/src/components/Conversations/Convo.tsx @@ -1,6 +1,5 @@ // @ts-strict-ignore import { Outlined } from "bisheng-icons"; -import { Check, X } from "lucide-react"; import type { FocusEvent, KeyboardEvent, MouseEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -96,7 +95,8 @@ export default function Conversation({ useEffect(() => { if (renaming && inputRef.current) { - inputRef.current.focus(); + // Select the whole title so typing replaces it directly. + inputRef.current.select(); } }, [renaming]); @@ -106,6 +106,11 @@ export default function Conversation({ ) => { e.preventDefault(); setRenaming(false); + // An empty (or whitespace-only) name silently restores the previous one. + if (!titleInput?.trim()) { + setTitleInput(title); + return; + } if (titleInput === title) { return; } @@ -165,15 +170,6 @@ export default function Conversation({ [onRename] ); - const cancelRename = useCallback( - (e: MouseEvent) => { - e.preventDefault(); - setTitleInput(title); - setRenaming(false); - }, - [title] - ); - const isActiveConvo: boolean = useMemo( () => currentConvoId === conversationId || @@ -191,7 +187,15 @@ export default function Conversation({ // Mobile rows: 8px left / 6px right, 8px vertical (desktop nav uses 12px/6px). isSmallScreen ? "pl-[8px] pr-[6px] py-[8px]" : "px-[12px] py-[6px]", isActiveConvo ? "bg-[#EEE]" : "hover:bg-[#f7f7f7]", - renaming ? "bg-[#EEE]" : "", + // Pin the hover fill while the row owns a transient UI — an open + // options menu or the rename input — since the pointer leaves the row + // (into the menu) long before the interaction is over. Never promoted + // to the active row's fill: this row is not the open conversation. + !isActiveConvo && (renaming || isPopoverActive) && "bg-[#f7f7f7]", + // Renaming chrome matches the chat input surface (AiChatInput): 1px + // #ECECEC hairline + soft drop shadow, both as shadows so the row + // keeps its borderless box size. + renaming && "shadow-[0_0_0_1px_#ECECEC,0_0_8px_rgba(3,7,117,0.05)]", )} > {renaming ? ( @@ -199,7 +203,7 @@ export default function Conversation({ setTitleInput(e.target.value)} onKeyDown={handleKeyDown} @@ -209,26 +213,17 @@ export default function Conversation({ )}`} />
-
@@ -268,6 +263,9 @@ export default function Conversation({
)} + {/* While renaming, drop the options slot entirely — an empty flex item + would still cost the row an extra 8px gap before the right padding. */} + {!renaming && (
- {!renaming && isTaskRunning && ( + {isTaskRunning && ( // Task-mode running indicator: a body-colored spinner that yields the // slot to the options menu on hover (or when its popover is open). )} - {!renaming && ( -
- -
- )} +
+ +
+ )}
); } diff --git a/src/frontend/client/src/components/FileListRow.tsx b/src/frontend/client/src/components/FileListRow.tsx deleted file mode 100644 index fd77d0035a..0000000000 --- a/src/frontend/client/src/components/FileListRow.tsx +++ /dev/null @@ -1,214 +0,0 @@ -// @ts-strict-ignore -import { useState } from "react"; -import { Folder, FileText, Download, MoreVertical, Circle, Edit, Tag } from "lucide-react"; -import { KnowledgeFile, FileType, FileStatus, SpaceRole } from "~/api/knowledge"; -import { formatFileSize, getFileTypeColor } from "~/mock/knowledge"; -import { Badge } from "~/components/ui/Badge"; -import { cn } from "~/utils"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "~/components/ui/DropdownMenu"; - -interface FileListRowProps { - file: KnowledgeFile; - userRole: SpaceRole; - isSelected: boolean; - onSelect: (selected: boolean) => void; - onDownload: () => void; - onRename: () => void; - onDelete: () => void; - onEditTags: () => void; - onRetry?: () => void; -} - -export function FileListRow({ - file, - userRole, - isSelected, - onSelect, - onDownload, - onRename, - onDelete, - onEditTags, - onRetry -}: FileListRowProps) { - const [hovered, setHovered] = useState(false); - const isAdmin = userRole === SpaceRole.CREATOR || userRole === SpaceRole.ADMIN; - const isFolder = file.type === FileType.FOLDER; - - const formatTime = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleString("zh-CN", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }).replace(/\//g, "-"); - }; - - const getFileTypeLabel = (type: FileType): string => { - const typeMap: Record = { - [FileType.FOLDER]: "文件夹", - [FileType.PDF]: "pdf", - [FileType.DOC]: "doc", - [FileType.DOCX]: "docx", - [FileType.XLS]: "xls", - [FileType.XLSX]: "xlsx", - [FileType.PPT]: "ppt", - [FileType.PPTX]: "pptx", - [FileType.JPG]: "jpg", - [FileType.JPEG]: "jpeg", - [FileType.PNG]: "png", - [FileType.OTHER]: "其他" - }; - return typeMap[type] || type; - }; - - const getStatusDisplay = () => { - if (isFolder) { - return 7/11; - } - - switch (file.status) { - case FileStatus.SUCCESS: - return 成功; - case FileStatus.PROCESSING: - return 处理中; - case FileStatus.FAILED: - return 失败; - case FileStatus.VIOLATION: - return 违规; - default: - return null; - } - }; - - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {/* 复选框 */} -
- onSelect(e.target.checked)} - className="size-4" - onClick={(e) => e.stopPropagation()} - /> -
- - {/* 文件名 */} -
- {isFolder ? ( - - ) : ( - - )} - - {file.name} - -
- - {/* 文件类型 */} -
- {getFileTypeLabel(file.type)} -
- - {/* 文件大小 */} -
- {file.size !== undefined ? formatFileSize(file.size) : "--"} -
- - {/* 标签 */} -
- {file.tags.length > 0 ? ( - <> - {file.tags.slice(0, 2).map((tag, index) => ( - - {tag} - - ))} - {file.tags.length > 2 && ( - +{file.tags.length - 2} - )} - - ) : ( - -- - )} -
- - {/* 更新时间 */} -
- {formatTime(file.updatedAt)} -
- - {/* 状态 */} -
- {getStatusDisplay()} -
- - {/* 操作 */} -
- {hovered && ( - <> - - - {isAdmin && ( - - - - - - - - 编辑标签 - - - - 重命名 - - {(file.status === FileStatus.FAILED || file.status === FileStatus.VIOLATION) && onRetry && ( - - - 重试 - - )} - - - 删除 - - - - )} - - )} -
-
- ); -} diff --git a/src/frontend/client/src/components/Linsight/Execution/DeepStepGroup.tsx b/src/frontend/client/src/components/Linsight/Execution/DeepStepGroup.tsx index fa0f6e94d0..3a7475ec55 100644 --- a/src/frontend/client/src/components/Linsight/Execution/DeepStepGroup.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/DeepStepGroup.tsx @@ -40,9 +40,10 @@ import { ACCENT, ACTIVITY_I18N, BODY, INK } from './execTokens'; // the single Accent (blue) highlight; the chevron is muted and darkens on hover; // the title + narration sit lighter as quiet meta. const NODE_TEXT = '#999999'; -import { readIngestProgress } from './execTypes'; +import { readIngestProgress, readSkillLoadFailure } from './execTypes'; import { GroupHeaderLabel } from './GroupHeaderLabel'; import { IngestPhaseRow } from './IngestPhaseRow'; +import { SkillLoadFailureRow } from './SkillLoadFailureRow'; import { KnowledgeRow } from './KnowledgeRow'; import { NarrationTicker } from './NarrationTicker'; import ToolRowLite from './ToolRowLite'; @@ -217,6 +218,9 @@ const DeepStepGroupBase: FC = ({ group, compact = false, sub if (readIngestProgress(seg.step)) { return ; } + if (readSkillLoadFailure(seg.step)) { + return ; + } return ; }); diff --git a/src/frontend/client/src/components/Linsight/Execution/ExecutionTimeline.tsx b/src/frontend/client/src/components/Linsight/Execution/ExecutionTimeline.tsx index d3fb6c2df9..e30bbf438f 100644 --- a/src/frontend/client/src/components/Linsight/Execution/ExecutionTimeline.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/ExecutionTimeline.tsx @@ -14,8 +14,9 @@ import { useMemo } from 'react'; import { DeepStepGroup } from './DeepStepGroup'; import { useExecutionLive } from './executionLive'; -import { readIngestProgress } from './execTypes'; +import { readIngestProgress, readSkillLoadFailure } from './execTypes'; import { IngestPhaseRow } from './IngestPhaseRow'; +import { SkillLoadFailureRow } from './SkillLoadFailureRow'; import { IntentRow } from './IntentRow'; import { KnowledgeRow } from './KnowledgeRow'; import { ToolRowLite } from './ToolRowLite'; @@ -106,6 +107,9 @@ export function ExecutionTimeline({ history }: ExecutionTimelineProps) { if (readIngestProgress(step)) { return ; } + if (readSkillLoadFailure(step)) { + return ; + } return ; })}
diff --git a/src/frontend/client/src/components/Linsight/Execution/ResultPanel.tsx b/src/frontend/client/src/components/Linsight/Execution/ResultPanel.tsx index 92acb448b6..8ad64e07c3 100644 --- a/src/frontend/client/src/components/Linsight/Execution/ResultPanel.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/ResultPanel.tsx @@ -61,7 +61,8 @@ export function ResultPanel({ children, messageId, liked, allowFeedback, onLiked liked={liked} onLike={(l) => { onLikedChange?.(l); - likeChatApi(messageId, l); + // returned so the confirmation toast waits for the request + return likeChatApi(messageId, l); }} onDislikeComment={(c) => disLikeCommentApi(messageId, c)} /> diff --git a/src/frontend/client/src/components/Linsight/Execution/SkillLoadFailureRow.tsx b/src/frontend/client/src/components/Linsight/Execution/SkillLoadFailureRow.tsx new file mode 100644 index 0000000000..44defd7278 --- /dev/null +++ b/src/frontend/client/src/components/Linsight/Execution/SkillLoadFailureRow.tsx @@ -0,0 +1,47 @@ +/** + * SkillLoadFailureRow — a skill the user picked was not available for this run. + * + * Skill bundles are fetched from object storage when a run starts. If that fails + * the task still proceeds, just without the skill; the model then behaves exactly + * as if the skill had never been selected. That silence is the actual defect this + * row exists to close — under the previous node-local storage, a worker on a + * different host found no bundle at all and the only trace was one warning in a + * log nobody reads. + * + * Renders as preparation (like the ingest row), not as an agent tool call: the + * agent did not do this, and it must not count toward the group's activity tally. + */ +import { Outlined } from 'bisheng-icons'; +import type { FC } from 'react'; +import { useLocalize } from '~/hooks'; +import { MUTED } from './execTokens'; +import { readSkillLoadFailure } from './execTypes'; +import type { MergedStep } from './stepUtils'; + +export interface SkillLoadFailureRowProps { + step: MergedStep; +} + +export const SkillLoadFailureRow: FC = ({ step }) => { + const localize = useLocalize(); + const names = readSkillLoadFailure(step); + // Defensive: an older worker can emit the name with no payload. + if (!names) return null; + + return ( +
+ + + +
+ + {localize('com_linsight_skill_load_failed', { 0: names.join('、') })} + +
+
+ ); +}; + +SkillLoadFailureRow.displayName = 'SkillLoadFailureRow'; + +export default SkillLoadFailureRow; diff --git a/src/frontend/client/src/components/Linsight/Execution/execTypes.ts b/src/frontend/client/src/components/Linsight/Execution/execTypes.ts index 3c31232f05..cf218d68e2 100644 --- a/src/frontend/client/src/components/Linsight/Execution/execTypes.ts +++ b/src/frontend/client/src/components/Linsight/Execution/execTypes.ts @@ -72,6 +72,33 @@ export function readIngestProgress(step: { }; } +/** + * A skill the user picked could not be loaded for this run. + * + * Skill bundles live in object storage and are materialized per run; when that + * fails the task still runs, just without the skill. Left unreported this is + * indistinguishable from never having selected it — which is exactly how the old + * local-disk storage hid a multi-node inconsistency for months. + * + * ⚠️ Contract with the backend: `_SKILL_LOAD_FAILED_STEP_NAME` in + * bisheng/linsight/domain/task_exec.py. + */ +export const SKILL_LOAD_FAILED_STEP_NAME = 'skill_load_failed'; + +/** Pull the failed skill names off a step, or null when it is not that row. */ +export function readSkillLoadFailure(step: { + name?: string; + extraInfo?: Record; +}): string[] | null { + if (step.name !== SKILL_LOAD_FAILED_STEP_NAME) return null; + const raw = step.extraInfo?.skill_load_failed; + if (!raw || typeof raw !== 'object') return null; + const names = (raw as Record).names; + if (!Array.isArray(names)) return null; + const cleaned = names.filter((n): n is string => typeof n === 'string' && n.length > 0); + return cleaned.length ? cleaned : null; +} + /** Raw `task_execute_step.data` frame (contract C1). */ export interface ExecStepEventData { call_id?: string; diff --git a/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx b/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx index 821c073ba2..b9529dde8b 100644 --- a/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx +++ b/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx @@ -65,18 +65,16 @@ export function PlusMenu({ - {/* Upload file (icon: shared daily-mode `link` asset) */} + {/* Upload file — same icon component as the daily-mode "+" menu: + the old `link.svg` asset bakes its own colour in and can't + follow the shared resting tint. */} onUploadFile()} - className="flex cursor-pointer items-center gap-3 rounded-xl px-2 py-1.5 outline-none" + className="flex h-8 cursor-pointer items-center gap-3 rounded-lg px-2 outline-none" > - + {localize('com_ui_upload_files')} @@ -102,9 +100,9 @@ export function PlusMenu({ {/* Task mode toggle */} onToggleTaskMode()} - className="flex cursor-pointer items-center gap-3 rounded-xl px-2 py-1.5 outline-none" + className="flex h-8 cursor-pointer items-center gap-3 rounded-lg px-2 outline-none" > - + @@ -129,7 +127,7 @@ export function PlusMenu({
0 ? 'text-blue-500' : 'text-slate-600')} + className={cn(selectedSkills.length > 0 ? 'text-blue-500' : 'text-[#4E5969]')} /> {selectedSkills.length > 0 && ( @@ -141,8 +139,13 @@ export function PlusMenu({
{/* ChevronRight is rendered by DropdownMenuSubTrigger itself */}
- {/* Layout mirrors the daily-mode knowledge panel shell (ChatKnowledge `variant === 'knowledge'`). */} - + {/* Layout mirrors the daily-mode knowledge panel shell (ChatKnowledge `variant === 'knowledge'`). + `align="center"` centers the panel vertically on the trigger row. */} + diff --git a/src/frontend/client/src/components/Linsight/Input/SkillSelector.tsx b/src/frontend/client/src/components/Linsight/Input/SkillSelector.tsx index 8fc593c332..0c938f9954 100644 --- a/src/frontend/client/src/components/Linsight/Input/SkillSelector.tsx +++ b/src/frontend/client/src/components/Linsight/Input/SkillSelector.tsx @@ -5,11 +5,14 @@ * textarea; only checked skills are sent with the submission. Supports keyword * search over display name + description. */ -import { Check, Loader2, SearchIcon } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Outlined } from 'bisheng-icons'; +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getSelectableSkills } from '~/api/linsight'; import { DropdownMenuItem, Input } from '~/components/ui'; +import { EmptyStateIllustration } from '~/components/illustrations'; +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/Tooltip2'; import { useLocalize } from '~/hooks'; import type { TaskModeSkill } from '~/store/linsight'; import { cn } from '~/utils'; @@ -19,6 +22,104 @@ interface SkillSelectorProps { onChange: (skills: TaskModeSkill[]) => void; } +interface SkillRowProps { + skill: TaskModeSkill; + isChecked: boolean; + onToggle: (skill: TaskModeSkill) => void; +} + +/** + * Whether a single-line element is actually cut off. Re-measures on resize, so + * it keeps up with panel-width changes. + */ +function useIsClipped(text?: string) { + const ref = useRef(null); + const [clipped, setClipped] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) { + setClipped(false); + return; + } + // +1 absorbs sub-pixel rounding, which otherwise reports a clip on text + // that fits exactly. + const measure = () => setClipped(el.scrollWidth > el.clientWidth + 1); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [text]); + + return [ref, clipped] as const; +} + +/** + * One selectable row. Name and description are each clamped to a single line; + * the full text moves into a tooltip — but only when something actually + * overflows, which we can only know by measuring, hence the per-row component. + */ +function SkillRow({ skill, isChecked, onToggle }: SkillRowProps) { + const [nameRef, nameClipped] = useIsClipped(skill.display_name); + const [descRef, descClipped] = useIsClipped(skill.description); + const showTooltip = nameClipped || descClipped; + + return ( + // Always wrapped: rendering the tooltip conditionally would instead swap + // the row's element type and remount it on every measure. + + + { + e.preventDefault(); + onToggle(skill); + }} + className={cn( + 'flex cursor-pointer items-start gap-2 rounded-lg px-2 py-[5px] outline-none transition-colors', + 'data-[highlighted]:bg-[#f2f3f5] focus:bg-[#f2f3f5]', + // Selected rows carry the state themselves (brand tint + a + // trailing check) now that the leading checkbox is gone. + isChecked && 'bg-blue-500/[0.07] data-[highlighted]:bg-blue-500/[0.07] focus:bg-blue-500/[0.07]', + )} + > + {/* Both lines are single-line; the full text lives in the tooltip. */} +
+

+ {skill.display_name} +

+ {skill.description && ( +

+ {skill.description} +

+ )} +
+ {isChecked && } +
+
+ {/* No content unless something is actually cut off — a tooltip that just + repeats what is already fully visible is noise. Once it does show, it + carries the whole row: a name-only tooltip next to a visible + description reads as if the description were missing. */} + {showTooltip && ( + +

{skill.display_name}

+ {skill.description && ( +

{skill.description}

+ )} +
+ )} +
+ ); +} + export function SkillSelector({ selected, onChange }: SkillSelectorProps) { const localize = useLocalize(); const [keyword, setKeyword] = useState(''); @@ -55,6 +156,17 @@ export function SkillSelector({ selected, onChange }: SkillSelectorProps) { updateScrollIndicators(); }, [filtered, updateScrollIndicators]); + // Filtering shrinks the list, and the panel is centred on its trigger, so a + // shrinking panel visibly jumps. Sample the unfiltered height and hold it as + // a floor while a keyword is active. + const listAreaRef = useRef(null); + const [unfilteredHeight, setUnfilteredHeight] = useState(); + useLayoutEffect(() => { + if (keyword) return; // only the unfiltered list is a valid sample + const el = listAreaRef.current; + if (el) setUnfilteredHeight(el.getBoundingClientRect().height); + }, [keyword, filtered.length]); + const handleToggle = (skill: TaskModeSkill) => { const exists = selected.some((s) => s.name === skill.name); onChange( @@ -65,17 +177,19 @@ export function SkillSelector({ selected, onChange }: SkillSelectorProps) { }; return ( -
- {/* Panel title — mirrors the knowledge panel header for visual consistency */} -

- {localize('com_linsight_skill_title')} -

- + // gap-2: 8px between the search box and the list below it, so the first + // row doesn't crowd the input's bottom border. +
+ {/* No panel heading: every surface that opens this list already + labels it — the desktop submenu hangs off the "添加技能" row, the + mobile drill panel has it in the back-navigation row. */} {/* Search — stopPropagation so typing isn't hijacked by the Radix menu's type-ahead */}
- + {/* top nudged 1px past centre: the magnifier's ring sits above the glyph's + own box, so a mathematically centred icon reads high next to the text. */} + setKeyword(e.target.value)} @@ -85,13 +199,24 @@ export function SkillSelector({ selected, onChange }: SkillSelectorProps) {
{/* List */} +
{isFetching && skills.length === 0 ? (
) : filtered.length === 0 ? ( -
- {localize('com_linsight_skill_empty')} + // Centred in whatever height the list area is holding (see + // unfilteredHeight), so searching to zero results doesn't leave + // the copy stranded at the top of an otherwise empty panel. +
+ +

+ {localize('com_linsight_skill_empty')} +

) : (
@@ -115,40 +240,26 @@ export function SkillSelector({ selected, onChange }: SkillSelectorProps) { />
- {filtered.map((skill) => { - const isChecked = selected.some((s) => s.name === skill.name); - return ( - { - e.preventDefault(); - handleToggle(skill); - }} - className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-[5px] outline-none transition-colors data-[highlighted]:bg-[#f2f3f5] focus:bg-[#f2f3f5]" - > -
- {isChecked && } -
-
-

{skill.display_name}

- {skill.description && ( -

{skill.description}

- )} -
-
- ); - })} + {filtered.map((skill, i) => ( + + {/* Hairline between rows — a standalone element rather than a + border on the row, so it sits centred in the 4px gutter and + never cuts across a selected row's tinted background. */} + {i > 0 &&
} + s.name === skill.name)} + onToggle={handleToggle} + /> + + ))}
)} +
); } diff --git a/src/frontend/client/src/components/Nav/HubModuleNavTabs.tsx b/src/frontend/client/src/components/Nav/HubModuleNavTabs.tsx index d1d569ed42..9b24b8e8ae 100644 --- a/src/frontend/client/src/components/Nav/HubModuleNavTabs.tsx +++ b/src/frontend/client/src/components/Nav/HubModuleNavTabs.tsx @@ -1,7 +1,7 @@ import type { ComponentType } from 'react'; import { useMemo } from 'react'; +import { Outlined } from 'bisheng-icons'; import { matchPath, NavLink, useLocation } from 'react-router-dom'; -import BookOpenIcon from '~/components/ui/icon/BookOpen'; import GlobeIcon from '~/components/ui/icon/Globe'; import HomeIcon from '~/components/ui/icon/Home'; import LinkIcon from '~/components/ui/icon/Link'; @@ -90,7 +90,7 @@ export function useHubModuleLinks(): HubModuleLink[] { { section: 'knowledge' as const, to: hasPlugin('knowledge_space') || !menuApprovalMode ? (lastSectionPaths.knowledge || '/knowledge') : '/menu-unavailable?plugin=knowledge_space', - icon: BookOpenIcon, + icon: Outlined.Book, label: menuNames.knowledge, isActive: pathname.startsWith('/knowledge'), closeDrawerOnNavigate: true, diff --git a/src/frontend/client/src/components/Nav/Nav.tsx b/src/frontend/client/src/components/Nav/Nav.tsx index c8ab86d5c7..024b5a4f1d 100644 --- a/src/frontend/client/src/components/Nav/Nav.tsx +++ b/src/frontend/client/src/components/Nav/Nav.tsx @@ -205,7 +205,9 @@ const Nav = ({ />
- {menuNames.home} + {menuNames.home}
{/* Create chat button */} diff --git a/src/frontend/client/src/components/permission/PermissionDraftEditor.tsx b/src/frontend/client/src/components/permission/PermissionDraftEditor.tsx new file mode 100644 index 0000000000..0e80b92312 --- /dev/null +++ b/src/frontend/client/src/components/permission/PermissionDraftEditor.tsx @@ -0,0 +1,115 @@ +import { useLocalize } from "~/hooks"; +import { PermissionLevelMenu } from "./PermissionLevelMenu"; +import type { RelationModelOption } from "./RelationSelect"; +import { + getPermissionDraftRowKey, +} from "./usePermissionDraft"; +import type { PermissionDraftRow } from "./usePermissionDraft"; +import { SourceBadge } from "./SourceBadge"; + +export interface PermissionDraftEditorCapabilities { + canChangeRelation: boolean; + canRemove: boolean; + relationModels: RelationModelOption[]; +} + +export interface PermissionDraftEditorProps { + value: PermissionDraftRow[]; + onChange: (value: PermissionDraftRow[]) => void; + capabilities: PermissionDraftEditorCapabilities; +} + +export function PermissionDraftEditor({ + value, + onChange, + capabilities, +}: PermissionDraftEditorProps) { + const localize = useLocalize(); + + const handleRelationChange = (row: PermissionDraftRow, modelId: string) => { + if (row.protected || row.editable === false || !capabilities.canChangeRelation) return; + const model = capabilities.relationModels.find((candidate) => candidate.id === modelId); + if (!model) return; + + const rowKey = getPermissionDraftRowKey(row); + onChange(value.map((candidate) => ( + getPermissionDraftRowKey(candidate) === rowKey + ? { ...candidate, modelKey: model.id, modelName: model.name, modelLevel: model.level } + : candidate + ))); + }; + + const handleRemove = (row: PermissionDraftRow) => { + if (row.protected || row.editable === false || !capabilities.canRemove) return; + const rowKey = getPermissionDraftRowKey(row); + onChange(value.filter((candidate) => getPermissionDraftRowKey(candidate) !== rowKey)); + }; + + return ( +
+ {value.map((row) => { + const rowKey = getPermissionDraftRowKey(row); + const relationModels = capabilities.relationModels; + const canChangeRelation = !row.protected && row.editable !== false + && capabilities.canChangeRelation + && relationModels.length > 0; + const canRemove = !row.protected && row.editable !== false && capabilities.canRemove; + const activeModelId = row.modelKey; + const relationLabel = + capabilities.relationModels.find((model) => model.id === activeModelId)?.name + ?? row.modelName + ?? row.modelKey; + + return ( +
+
+ + {row.subjectName.trim().slice(0, 1).toUpperCase()} + +
+
{row.subjectName}
+ {(row.sourceType || row.protected || row.scope === "INHERITED" || row.editable === false) && ( +
+ {row.sourceType && ( + + )} + {row.protected && ( + {localize("f048_permission.roster.protected")} + )} + {!row.protected && (row.scope === "INHERITED" || row.editable === false) && ( + {localize("f048_permission.roster.read_only")} + )} + {row.scope === "INHERITED" && row.inheritedFromName && ( + + {localize("f048_permission.roster.inherited_from")}: {row.inheritedFromName} + + )} +
+ )} +
+
+ {row.protected ? ( + + {relationLabel} + + ) : ( + handleRelationChange(row, modelId)} + onRemove={canRemove ? () => handleRemove(row) : undefined} + /> + )} +
+ ); + })} +
+ ); +} diff --git a/src/frontend/client/src/components/permission/PermissionDraftPanel.test.tsx b/src/frontend/client/src/components/permission/PermissionDraftPanel.test.tsx new file mode 100644 index 0000000000..78f0a732bf --- /dev/null +++ b/src/frontend/client/src/components/permission/PermissionDraftPanel.test.tsx @@ -0,0 +1,32 @@ +/** @jest-environment node */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +function source(file: string) { + return readFileSync(join(process.cwd(), "src/components/permission", file), "utf8"); +} + +describe("F050 permission draft presentation contract", () => { + it("keeps the 2.6 authorization tabs, list, and model menu", () => { + const panel = source("PermissionDraftPanel.tsx"); + const editor = source("PermissionDraftEditor.tsx"); + expect(panel).toContain('data-testid="authorization-list"'); + expect(panel).toContain('data-testid="authorization-list-body"'); + expect(panel).toContain('const SUBJECT_TYPES: SubjectType[] = ["user", "department", "user_group"]'); + expect(editor).toContain("PermissionLevelMenu"); + expect(editor).toContain("row.protected"); + expect(editor).toContain("row.editable === false"); + expect(editor).toContain("row.modelKey"); + expect(editor).not.toContain("row.relation"); + }); + + it("does not merge roster rows by subject", () => { + const panel = source("PermissionDraftPanel.tsx"); + const draft = source("usePermissionDraft.ts"); + expect(panel).toContain("getPermissionDraftRowKey"); + expect(draft).toContain("row.assigneeId ??"); + expect(draft).toContain("sourceType?: string"); + expect(draft).toContain('scope?: "LOCAL" | "INHERITED"'); + }); +}); diff --git a/src/frontend/client/src/components/permission/PermissionDraftPanel.tsx b/src/frontend/client/src/components/permission/PermissionDraftPanel.tsx new file mode 100644 index 0000000000..3e24ae50ef --- /dev/null +++ b/src/frontend/client/src/components/permission/PermissionDraftPanel.tsx @@ -0,0 +1,99 @@ +import { Button } from "@bisheng/ui"; +import type { SubjectType } from "~/api/permission"; +import { useLocalize } from "~/hooks"; +import { PermissionEmptyState } from "./PermissionEmptyState"; +import { PermissionDraftEditor, type PermissionDraftEditorCapabilities } from "./PermissionDraftEditor"; +import { + SUBJECT_TAB_BUTTON_ACTIVE_CLASS, + SUBJECT_TAB_BUTTON_CLASS, + SUBJECT_TAB_BUTTON_INACTIVE_CLASS, + SUBJECT_TAB_LIST_CLASS, +} from "./permissionDialogStyles"; +import { getPermissionDraftRowKey, type PermissionDraftRow } from "./usePermissionDraft"; + +const SUBJECT_TYPES: SubjectType[] = ["user", "department", "user_group"]; + +interface PermissionDraftPanelProps { + value: PermissionDraftRow[]; + onChange: (rows: PermissionDraftRow[]) => void; + capabilities: PermissionDraftEditorCapabilities; + activeSubjectType: SubjectType; + onActiveSubjectTypeChange: (type: SubjectType) => void; + onAddAuthorization: () => void; + canAddAuthorization?: boolean; +} + +export function PermissionDraftPanel({ + value, + onChange, + capabilities, + activeSubjectType, + onActiveSubjectTypeChange, + onAddAuthorization, + canAddAuthorization = true, +}: PermissionDraftPanelProps) { + const localize = useLocalize(); + const visibleRows = value.filter((row) => row.subjectType === activeSubjectType); + + const handleVisibleRowsChange = (nextVisibleRows: PermissionDraftRow[]) => { + const visibleKeys = new Set(visibleRows.map(getPermissionDraftRowKey)); + onChange([ + ...value.filter((row) => !visibleKeys.has(getPermissionDraftRowKey(row))), + ...nextVisibleRows, + ]); + }; + + return ( +
+
+ {localize("com_unified_permission.authorization")} +
+
+
+ {SUBJECT_TYPES.map((type) => ( + + ))} +
+ {canAddAuthorization && ( + + )} +
+
+ {visibleRows.length === 0 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/frontend/client/src/components/permission/PermissionDraftPickerDialog.test.tsx b/src/frontend/client/src/components/permission/PermissionDraftPickerDialog.test.tsx new file mode 100644 index 0000000000..4a6fb58d04 --- /dev/null +++ b/src/frontend/client/src/components/permission/PermissionDraftPickerDialog.test.tsx @@ -0,0 +1,29 @@ +/** @jest-environment node */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const source = readFileSync( + join(process.cwd(), "src/components/permission/PermissionDraftPickerDialog.tsx"), + "utf8", +); + +describe("F050 permission subject picker contract", () => { + it("preserves the 2.6 multi-subject selection flow", () => { + expect(source).toContain(' { + expect(source).toContain("searchApi?: PermissionDraftSearchApi"); + expect(source).toContain("usersApi={searchApi?.usersApi}"); + expect(source).toContain("departmentChildrenApi={searchApi?.departmentChildrenApi}"); + expect(source).toContain("userGroupsApi={searchApi?.userGroupsApi}"); + expect(source).toContain("modelKey: activeModel.id"); + expect(source).not.toContain("modelId:"); + expect(source).not.toContain("relation:"); + }); +}); diff --git a/src/frontend/client/src/components/permission/PermissionDraftPickerDialog.tsx b/src/frontend/client/src/components/permission/PermissionDraftPickerDialog.tsx new file mode 100644 index 0000000000..ff5c0ca4c4 --- /dev/null +++ b/src/frontend/client/src/components/permission/PermissionDraftPickerDialog.tsx @@ -0,0 +1,209 @@ +import { Button } from "@bisheng/ui"; +import type { ComponentProps } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { ResourceType, SelectedSubject, SubjectType } from "~/api/permission"; +import { Checkbox } from "~/components/ui/Checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "~/components/ui/Dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/Tabs"; +import { useLocalize } from "~/hooks"; +import { cn } from "~/utils"; +import { + INCLUDE_CHILDREN_CHECKBOX_CLASS, + INCLUDE_CHILDREN_LABEL_CLASS, + PERMISSION_DIALOG_CONTENT_CLASS, + PERMISSION_FOOTER_ACTIONS_CLASS, + PERMISSION_FOOTER_LABEL_CLASS, + SUBJECT_TAB_LIST_CLASS, + SUBJECT_TAB_TRIGGER_CLASS, +} from "./permissionDialogStyles"; +import { RelationSelect, type RelationModelOption } from "./RelationSelect"; +import { SubjectSearchDepartment } from "./SubjectSearchDepartment"; +import { SubjectSearchUser } from "./SubjectSearchUser"; +import { SubjectSearchUserGroup } from "./SubjectSearchUserGroup"; +import type { PermissionDraftRow } from "./usePermissionDraft"; + +export interface PermissionDraftSearchApi { + usersApi?: ComponentProps["usersApi"]; + departmentChildrenApi?: ComponentProps["departmentChildrenApi"]; + departmentSearchApi?: ComponentProps["departmentSearchApi"]; + userGroupsApi?: ComponentProps["userGroupsApi"]; +} + +export interface PermissionDraftPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "create" | "resource"; + resourceType: ResourceType; + resourceId?: string; + disabledIds: Record; + relationModels: RelationModelOption[]; + canAddNonUserSubjects: boolean; + onConfirm: (rows: PermissionDraftRow[]) => void; + searchApi?: PermissionDraftSearchApi; +} + +export function PermissionDraftPickerDialog({ + open, + onOpenChange, + mode, + resourceType, + resourceId, + disabledIds, + relationModels, + canAddNonUserSubjects, + onConfirm, + searchApi, +}: PermissionDraftPickerDialogProps) { + const localize = useLocalize(); + const [subjectType, setSubjectType] = useState("user"); + const [subjects, setSubjects] = useState([]); + const [includeChildren, setIncludeChildren] = useState(true); + const [selectedModelId, setSelectedModelId] = useState(""); + const selectableModels = useMemo(() => relationModels, [relationModels]); + const activeModel = selectableModels.find((model) => model.id === selectedModelId) + ?? selectableModels.find((model) => model.level === 1) + ?? selectableModels[0]; + + useEffect(() => { + if (!open) { + setSubjects([]); + setSubjectType("user"); + setIncludeChildren(true); + setSelectedModelId(""); + } + }, [open]); + + const handleSubjectTypeChange = (value: string) => { + setSubjectType(value as SubjectType); + setSubjects([]); + setIncludeChildren(true); + setSelectedModelId(""); + }; + + const handleConfirm = () => { + if (!activeModel || subjects.length === 0) return; + onConfirm(subjects.map((subject) => ({ + subjectType: subject.type, + subjectId: subject.id, + subjectName: subject.name, + modelKey: activeModel.id, + modelName: activeModel.name, + modelLevel: activeModel.level, + includeChildren: subject.type === "department" ? includeChildren : undefined, + }))); + onOpenChange(false); + }; + + const searchProps = { + mode, + resourceType, + resourceId: resourceId ?? "__creation__", + value: subjects, + onChange: setSubjects, + disabledIds: disabledIds[subjectType], + }; + + return ( + + + + + {localize("com_unified_permission.add_authorization")} + + + +
+ + + {localize("com_permission.subject_user")} + + + {localize("com_permission.subject_department")} + + + {localize("com_permission.subject_user_group")} + + + {subjectType === "department" && ( + + )} +
+ + + + + + + + + +
+ {/* Mobile stacks the relation picker above a full-width action pair, with + the divider directly above the buttons — matching the resource grant + dialog. Desktop keeps both on one bordered row. */} +
+
+ + {localize("com_permission.uniform_grant")} + + +
+
+ + +
+
+
+
+ ); +} diff --git a/src/frontend/client/src/components/permission/PermissionEmptyState.tsx b/src/frontend/client/src/components/permission/PermissionEmptyState.tsx new file mode 100644 index 0000000000..bd8492be33 --- /dev/null +++ b/src/frontend/client/src/components/permission/PermissionEmptyState.tsx @@ -0,0 +1,27 @@ +import { EmptyStateIllustration } from "~/components/illustrations"; +import { useLocalize } from "~/hooks"; + +interface PermissionEmptyStateProps { + /** Already-localized message. */ + message: string; +} + +/** + * Shared empty state for every permission list — the authorization panel, the + * member list, and the three subject pickers in the grant dialog. Illustration + * above the message, centered in whatever height the parent gives it. + */ +export function PermissionEmptyState({ message }: PermissionEmptyStateProps) { + const localize = useLocalize(); + + return ( +
+ +

{message}

+
+ ); +} diff --git a/src/frontend/client/src/components/permission/PermissionLevelMenu.tsx b/src/frontend/client/src/components/permission/PermissionLevelMenu.tsx new file mode 100644 index 0000000000..5176216f35 --- /dev/null +++ b/src/frontend/client/src/components/permission/PermissionLevelMenu.tsx @@ -0,0 +1,106 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/DropdownMenu"; +import { ChevronDown } from "lucide-react"; +import { useLocalize } from "~/hooks"; +import { cn } from "~/utils"; +import type { RelationModelOption } from "./RelationSelect"; + +interface PermissionLevelMenuProps { + /** Current level shown on the trigger. */ + label: string; + options: RelationModelOption[]; + activeId?: string; + /** When false the level items are hidden — the menu then only offers removal. */ + canChangeLevel: boolean; + onChange: (modelId: string) => void; + /** Renders the destructive "移除" item. Omit to hide it. */ + onRemove?: () => void; + className?: string; +} + +/** + * Level dropdown shared by the member-management list and the create-page + * authorization draft. Removal lives inside the menu — never as a separate + * icon button next to it. + */ +export function PermissionLevelMenu({ + label, + options, + activeId, + canChangeLevel, + onChange, + onRemove, + className, +}: PermissionLevelMenuProps) { + const localize = useLocalize(); + const showLevels = canChangeLevel && options.length > 0; + + if (!showLevels && !onRemove) { + return ( + + {label} + + ); + } + + return ( + + + + + + {showLevels && options.map((model) => { + const active = model.id === activeId; + return ( + onChange(model.id)} + > + {model.name} + + ); + })} + {showLevels && onRemove && ( + + )} + {onRemove && ( + onRemove()} + > + {localize("com_permission.remove")} + + )} + + + ); +} diff --git a/src/frontend/client/src/components/permission/RelationSelect.tsx b/src/frontend/client/src/components/permission/RelationSelect.tsx new file mode 100644 index 0000000000..20ca137040 --- /dev/null +++ b/src/frontend/client/src/components/permission/RelationSelect.tsx @@ -0,0 +1,59 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/Select"; +import { cn } from "~/utils"; + +export interface RelationModelOption { + id: string; + name: string; + level?: number | null; +} + +interface RelationSelectProps { + value: string; + onChange: (value: string) => void; + className?: string; + disabled?: boolean; + options: RelationModelOption[]; +} + +export function RelationSelect({ + value, + onChange, + className, + disabled, + options, +}: RelationSelectProps) { + return ( + + ); +} diff --git a/src/frontend/client/src/components/permission/SubjectSearchDepartment.tsx b/src/frontend/client/src/components/permission/SubjectSearchDepartment.tsx index 44d94be512..34d715925b 100644 --- a/src/frontend/client/src/components/permission/SubjectSearchDepartment.tsx +++ b/src/frontend/client/src/components/permission/SubjectSearchDepartment.tsx @@ -21,7 +21,7 @@ import { useGrantDepartmentTree } from "./useGrantDepartmentTree"; * (decision 10). No client-side subtree materialization. */ -interface SubjectSearchDepartmentProps { +export interface SubjectSearchDepartmentProps { value: SelectedSubject[]; onChange: (v: SelectedSubject[]) => void; resourceType: ResourceType; diff --git a/src/frontend/client/src/components/permission/SubjectSearchUser.tsx b/src/frontend/client/src/components/permission/SubjectSearchUser.tsx index 406747c29b..edd8d285a5 100644 --- a/src/frontend/client/src/components/permission/SubjectSearchUser.tsx +++ b/src/frontend/client/src/components/permission/SubjectSearchUser.tsx @@ -5,7 +5,7 @@ import { User as UserIcon, Search } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useLocalize } from "~/hooks"; -interface SubjectSearchUserProps { +export interface SubjectSearchUserProps { value: SelectedSubject[]; onChange: (v: SelectedSubject[]) => void; resourceType?: ResourceType; @@ -13,6 +13,7 @@ interface SubjectSearchUserProps { disabledIds?: number[]; /** subjectId -> the permission model(s) that subject already holds here. */ grantedLabels?: Record; + usersApi?: typeof searchUsers; } type UserRow = GrantUser; @@ -26,6 +27,7 @@ export function SubjectSearchUser({ resourceId, disabledIds = [], grantedLabels = {}, + usersApi, }: SubjectSearchUserProps) { const localize = useLocalize(); const [keyword, setKeyword] = useState(""); @@ -55,7 +57,7 @@ export function SubjectSearchUser({ signal: AbortSignal, ): Promise => { if (!resourceType || !resourceId) return []; - const res = await searchUsers( + const res = await (usersApi ?? searchUsers)( resourceType, resourceId, name, @@ -65,7 +67,7 @@ export function SubjectSearchUser({ if (signal.aborted) return []; return res.data || []; }, - [resourceId, resourceType], + [resourceId, resourceType, usersApi], ); const resetAndLoad = useCallback( diff --git a/src/frontend/client/src/components/permission/SubjectSearchUserGroup.tsx b/src/frontend/client/src/components/permission/SubjectSearchUserGroup.tsx index 6497f64fc9..c91d384f9f 100644 --- a/src/frontend/client/src/components/permission/SubjectSearchUserGroup.tsx +++ b/src/frontend/client/src/components/permission/SubjectSearchUserGroup.tsx @@ -10,7 +10,7 @@ interface UserGroup { group_name: string; } -interface SubjectSearchUserGroupProps { +export interface SubjectSearchUserGroupProps { value: SelectedSubject[]; onChange: (v: SelectedSubject[]) => void; resourceType?: ResourceType; @@ -18,6 +18,7 @@ interface SubjectSearchUserGroupProps { disabledIds?: number[]; /** subjectId -> the permission model(s) that subject already holds here. */ grantedLabels?: Record; + userGroupsApi?: typeof getUserGroups; } export function SubjectSearchUserGroup({ @@ -27,6 +28,7 @@ export function SubjectSearchUserGroup({ resourceId, disabledIds = [], grantedLabels = {}, + userGroupsApi, }: SubjectSearchUserGroupProps) { const localize = useLocalize(); const [groups, setGroups] = useState([]); @@ -36,7 +38,7 @@ export function SubjectSearchUserGroup({ useEffect(() => { const controller = new AbortController(); if (!resourceType || !resourceId) return; - const request = getUserGroups(resourceType, resourceId, { + const request = (userGroupsApi ?? getUserGroups)(resourceType, resourceId, { signal: controller.signal, }); @@ -52,7 +54,7 @@ export function SubjectSearchUserGroup({ }); return () => controller.abort(); - }, [resourceId, resourceType]); + }, [resourceId, resourceType, userGroupsApi]); const filtered = useMemo(() => { if (!keyword) return groups; diff --git a/src/frontend/client/src/components/permission/TruncatedTooltip.tsx b/src/frontend/client/src/components/permission/TruncatedTooltip.tsx new file mode 100644 index 0000000000..f2a523b812 --- /dev/null +++ b/src/frontend/client/src/components/permission/TruncatedTooltip.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/Tooltip2"; + +interface TruncatedTooltipProps { + /** Full text, shown only when the rendered element is actually clipped. */ + content: string; + className?: string; + as?: "span" | "p" | "div"; + children: ReactNode; +} + +/** + * Tooltip that stays silent unless the wrapped text is truncated — so rows that + * fit never fire a hover popup. + */ +export function TruncatedTooltip({ + content, + className, + as: Tag = "span", + children, +}: TruncatedTooltipProps) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- polymorphic `as`: no single element type covers span/p/div refs + const ref = useRef(null); + const [open, setOpen] = useState(false); + + const handleOpenChange = (next: boolean) => { + if (!next) { + setOpen(false); + return; + } + const el = ref.current; + if (el && (el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight)) { + setOpen(true); + } + }; + + return ( + + + {children} + + + {content} + + + ); +} diff --git a/src/frontend/client/src/components/permission/UnifiedPermissionControls.tsx b/src/frontend/client/src/components/permission/UnifiedPermissionControls.tsx new file mode 100644 index 0000000000..f673efdd33 --- /dev/null +++ b/src/frontend/client/src/components/permission/UnifiedPermissionControls.tsx @@ -0,0 +1,187 @@ +import { Button } from "@bisheng/ui"; +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { Layers3, Settings, ShieldCheck } from "lucide-react"; +import type { ComponentType, ReactNode } from "react"; +import { Switch } from "~/components/ui/Switch"; +import { TruncatedTooltip } from "./TruncatedTooltip"; + +export type SettingsSectionKind = "basic" | "advanced" | "permission"; + +interface SettingsSectionHeaderProps { + kind: SettingsSectionKind; + title: string; +} + +const SECTION_ICONS: Record< + SettingsSectionKind, + ComponentType<{ className?: string }> +> = { + basic: Layers3, + advanced: Settings, + permission: ShieldCheck, +}; + +export function SettingsSectionHeader({ + kind, + title, +}: SettingsSectionHeaderProps) { + const Icon = SECTION_ICONS[kind]; + return ( +
+ + {title} +
+ ); +} + +interface AccessModeSelectorProps { + value: "private" | "shared"; + onValueChange: (value: "private" | "shared") => void; + privateLabel: string; + privateDescription: string; + sharedLabel: string; + sharedDescription: string; + disabled?: boolean; +} + +export function AccessModeSelector({ + value, + onValueChange, + privateLabel, + privateDescription, + sharedLabel, + sharedDescription, + disabled, +}: AccessModeSelectorProps) { + const options = [ + { + value: "private" as const, + label: privateLabel, + description: privateDescription, + }, + { + value: "shared" as const, + label: sharedLabel, + description: sharedDescription, + }, + ]; + + return ( + onValueChange(next as "private" | "shared")} + className="grid grid-cols-2 gap-2 max-[560px]:grid-cols-1" + > + {options.map((option) => ( + + ))} + + ); +} + +interface SettingsSwitchRowProps { + label: string; + description?: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + required?: boolean; + children?: ReactNode; +} + +export function SettingsSwitchRow({ + label, + description, + checked, + onCheckedChange, + disabled, + required, + children, +}: SettingsSwitchRowProps) { + return ( +
+
+ + {required && *} + {label} + + {description && ( + {description} + )} + {children} +
+ +
+ ); +} + +interface SettingsFooterProps { + cancelLabel: string; + submitLabel: string; + onCancel: () => void; + onSubmit: () => void; + submitting?: boolean; + disabled?: boolean; + centered?: boolean; +} + +export function SettingsFooter({ + cancelLabel, + submitLabel, + onCancel, + onSubmit, + submitting, + disabled, + centered, +}: SettingsFooterProps) { + return ( +
+ + +
+ ); +} diff --git a/src/frontend/client/src/components/permission/permissionDialogStyles.ts b/src/frontend/client/src/components/permission/permissionDialogStyles.ts new file mode 100644 index 0000000000..d788ac8bef --- /dev/null +++ b/src/frontend/client/src/components/permission/permissionDialogStyles.ts @@ -0,0 +1,51 @@ +/** + * Shared chrome for the permission dialogs. + * + * Extracted verbatim from KnowledgeSpaceShareDialog, which shipped the original + * "新增授权" dialog. The unified-permission draft picker reuses these so both + * dialogs stay pixel-identical instead of drifting into two look-alikes. + */ + +/** Dialog shell: fixed 80vh card on desktop, full-screen sheet under 768px. */ +export const PERMISSION_DIALOG_CONTENT_CLASS = + "!flex h-[80vh] max-h-[800px] w-[calc(100vw-80px)] max-w-[800px] min-w-0 flex-col gap-0 overflow-hidden p-5 max-[768px]:fixed max-[768px]:inset-0 max-[768px]:h-[100dvh] max-[768px]:max-h-[100dvh] max-[768px]:w-full max-[768px]:max-w-none max-[768px]:translate-x-0 max-[768px]:translate-y-0 max-[768px]:rounded-none max-[768px]:p-4"; + +/** Subject-type switcher: bordered pill group, brand-tinted active segment. */ +export const SUBJECT_TAB_LIST_CLASS = + "w-fit shrink-0 rounded-md border border-[#ECECEC] bg-white p-[3px] shadow-none"; + +export const SUBJECT_TAB_TRIGGER_CLASS = + "min-w-0 rounded-[4px] px-3 py-0.5 text-[14px] font-normal leading-[22px] text-[#818181] shadow-none data-[state=active]:bg-[rgb(var(--brand-500)/0.15)] data-[state=active]:font-medium data-[state=active]:text-blue-500 data-[state=active]:shadow-none"; + +/** + * Same switcher rendered with plain buttons instead of Radix Tabs — used where + * the active segment is driven by external state. Wrap them in + * `inline-flex items-center justify-center ${SUBJECT_TAB_LIST_CLASS}`. + */ +export const SUBJECT_TAB_BUTTON_CLASS = + "min-w-0 rounded-[4px] px-3 py-0.5 text-[14px] leading-[22px] transition-colors"; + +export const SUBJECT_TAB_BUTTON_ACTIVE_CLASS = + "bg-[rgb(var(--brand-500)/0.15)] font-medium text-blue-500"; + +export const SUBJECT_TAB_BUTTON_INACTIVE_CLASS = "font-normal text-[#818181]"; + +/** "包含子部门" toggle sitting next to the tab group. */ +export const INCLUDE_CHILDREN_LABEL_CLASS = + "flex shrink-0 cursor-pointer items-center gap-2 text-[14px] leading-[22px] text-[#212121]"; + +export const INCLUDE_CHILDREN_CHECKBOX_CLASS = + "border-[#D9D9D9] data-[state=checked]:border-primary data-[state=indeterminate]:border-primary"; + +/** + * Footer action pair (cancel + confirm). Right-aligned at their natural width on + * desktop; under 768px — where the dialog becomes a full-screen sheet — the two + * tile across one full-width row, each taking half. Same breakpoint as the shell + * above so both permission dialogs bottom out identically on mobile. + */ +export const PERMISSION_FOOTER_ACTIONS_CLASS = + "flex shrink-0 gap-3 max-[768px]:[&>button]:flex-1 min-[769px]:justify-end"; + +/** Muted caption used by the footer labels ("已选用户:", "统一授权:"). */ +export const PERMISSION_FOOTER_LABEL_CLASS = + "shrink-0 text-[14px] font-normal leading-[22px] text-[#999999]"; diff --git a/src/frontend/client/src/components/permission/permissionI18n.test.ts b/src/frontend/client/src/components/permission/permissionI18n.test.ts index 65c54739ee..d8d43ff425 100644 --- a/src/frontend/client/src/components/permission/permissionI18n.test.ts +++ b/src/frontend/client/src/components/permission/permissionI18n.test.ts @@ -7,8 +7,11 @@ const COMPONENTS = [ "src/components/permission/PermissionDialog.tsx", "src/components/permission/PermissionGrantTab.tsx", "src/components/permission/PermissionListTab.tsx", + "src/components/permission/PermissionDraftEditor.tsx", + "src/components/permission/PermissionDraftPanel.tsx", "src/components/permission/SourceBadge.tsx", - "src/pages/Subscription/ChannelPermissionDialog.tsx", + "src/pages/Subscription/ChannelSettings/ChannelPermissionSettings.tsx", + "src/pages/knowledge/SpaceSettings/KnowledgeSpaceSettingsPage.tsx", ] as const; const REQUIRED_KEYS = [ diff --git a/src/frontend/client/src/components/permission/useGrantDepartmentTree.ts b/src/frontend/client/src/components/permission/useGrantDepartmentTree.ts index 54a6a33ef5..aa168e4c76 100644 --- a/src/frontend/client/src/components/permission/useGrantDepartmentTree.ts +++ b/src/frontend/client/src/components/permission/useGrantDepartmentTree.ts @@ -17,6 +17,10 @@ const SEARCH_DEBOUNCE_MS = 300; export interface GrantDepartmentTreeSource { fetchChildren: (parentId: number | null, signal?: AbortSignal) => Promise; fetchSearch: (keyword: string, signal?: AbortSignal) => Promise; + fetchPathTree?: ( + departmentId: number, + signal?: AbortSignal, + ) => Promise; } export interface GrantDepartmentTree { @@ -28,6 +32,8 @@ export interface GrantDepartmentTree { initialLoading: boolean; /** Expand/collapse a node, loading its child layer on first expand. */ toggle: (node: GrantDepartmentNode) => void; + /** Reveal a selected department without loading the complete organization tree. */ + reveal: (departmentId: number) => Promise; keyword: string; setKeyword: (kw: string) => void; searchMode: boolean; @@ -63,24 +69,32 @@ export function useGrantDepartmentTree(source: GrantDepartmentTreeSource): Grant setChildIds((prev) => ({ ...prev, [key]: layer.map((n) => n.id) })); }, []); - const loadChildren = useCallback( - async (parentId: number) => { - if (childIdsRef.current[parentId]) return; // already loaded - setLoadingIds((prev) => new Set(prev).add(parentId)); + const loadLayer = useCallback( + async (parentId: number | null) => { + const key = parentId ?? ROOT_KEY; + if (childIdsRef.current[key]) return; + if (parentId !== null) setLoadingIds((prev) => new Set(prev).add(parentId)); try { const layer = await sourceRef.current.fetchChildren(parentId); if (layer) storeLayer(parentId, layer); } finally { - setLoadingIds((prev) => { - const next = new Set(prev); - next.delete(parentId); - return next; - }); + if (parentId !== null) { + setLoadingIds((prev) => { + const next = new Set(prev); + next.delete(parentId); + return next; + }); + } } }, [storeLayer] ); + const loadChildren = useCallback( + async (parentId: number) => loadLayer(parentId), + [loadLayer], + ); + const toggle = useCallback( (node: GrantDepartmentNode) => { if (!node.has_children) return; @@ -98,6 +112,36 @@ export function useGrantDepartmentTree(source: GrantDepartmentTreeSource): Grant [loadChildren] ); + const reveal = useCallback(async (departmentId: number) => { + const fetchPathTree = sourceRef.current.fetchPathTree; + if (!fetchPathTree) return; + const pathTree = await fetchPathTree(departmentId); + + const findPath = ( + nodes: GrantDepartmentNode[], + path: GrantDepartmentNode[] = [], + ): GrantDepartmentNode[] | null => { + for (const node of nodes) { + const nextPath = [...path, node]; + if (node.id === departmentId) return nextPath; + const nested = findPath(node.children ?? [], nextPath); + if (nested) return nested; + } + return null; + }; + const path = findPath(pathTree.roots ?? []); + if (!path) return; + + await loadLayer(null); + for (const ancestor of path.slice(0, -1)) { + await loadLayer(ancestor.id); + setExpanded((prev) => new Set(prev).add(ancestor.id)); + } + }, [loadLayer]); + + const getNode = useCallback((id: number) => nodeMap[id], [nodeMap]); + const getChildIds = useCallback((id: number) => childIds[id], [childIds]); + // Root layer on mount. useEffect(() => { let cancelled = false; @@ -142,12 +186,13 @@ export function useGrantDepartmentTree(source: GrantDepartmentTreeSource): Grant return { rootIds: childIds[ROOT_KEY] ?? [], - getNode: (id) => nodeMap[id], - getChildIds: (id) => childIds[id], + getNode, + getChildIds, expanded, loadingIds, initialLoading, toggle, + reveal, keyword, setKeyword, searchMode: !!keyword.trim(), diff --git a/src/frontend/client/src/components/permission/usePermissionDraft.test.ts b/src/frontend/client/src/components/permission/usePermissionDraft.test.ts new file mode 100644 index 0000000000..6ebcc62198 --- /dev/null +++ b/src/frontend/client/src/components/permission/usePermissionDraft.test.ts @@ -0,0 +1,116 @@ +/** @jest-environment node */ + +import { + createPermissionDraft, + getPermissionDraftDiff, + getPermissionDraftRowKey, + permissionDraftReducer, + type PermissionDraftRow, +} from "./usePermissionDraft"; + +const localViewer: PermissionDraftRow = { + subjectType: "user", subjectId: 7, subjectName: "Ada", modelKey: "viewer", + assigneeId: "assignee-1", assigneeVersion: 3, sourceType: "DIRECT", + scope: "LOCAL", editable: true, +}; + +describe("F048 permission draft", () => { + it("builds ADD with canonical subject fields", () => { + const row: PermissionDraftRow = { + subjectType: "department", subjectId: 8, subjectName: "Platform", + modelKey: "editor", includeChildren: true, + }; + const draft = permissionDraftReducer(createPermissionDraft(), { type: "add", row }); + expect(getPermissionDraftDiff(draft).changes).toEqual([{ + op: "ADD", model_key: "editor", + subject: { + type: "department", id: "8", userset_relation: "subtree_member", + include_children: true, + }, + }]); + }); + + it("builds versioned MOVE and REMOVE changes", () => { + let draft = createPermissionDraft([localViewer], { resourceVersion: 9, catalogReleaseId: 42 }); + draft = permissionDraftReducer(draft, { + type: "change", key: getPermissionDraftRowKey(localViewer), changes: { modelKey: "editor" }, + }); + expect(getPermissionDraftDiff(draft).changes).toEqual([{ + op: "MOVE", assignee_id: "assignee-1", expected_assignee_version: 3, + target_model_key: "editor", + }]); + draft = permissionDraftReducer(draft, { + type: "remove", key: getPermissionDraftRowKey(draft.rows[0]), + }); + expect(getPermissionDraftDiff(draft).changes).toEqual([{ + op: "REMOVE", assignee_id: "assignee-1", expected_assignee_version: 3, + }]); + }); + + it("keeps the server baseline when the editor replaces visible rows", () => { + const baseline = createPermissionDraft([localViewer]); + const moved = permissionDraftReducer(baseline, { + type: "replace_rows", + rows: [{ ...localViewer, modelKey: "editor" }], + }); + expect(getPermissionDraftDiff(moved).changes).toEqual([{ + op: "MOVE", + assignee_id: "assignee-1", + expected_assignee_version: 3, + target_model_key: "editor", + }]); + + const removed = permissionDraftReducer(baseline, { + type: "replace_rows", + rows: [], + }); + expect(getPermissionDraftDiff(removed).changes).toEqual([{ + op: "REMOVE", + assignee_id: "assignee-1", + expected_assignee_version: 3, + }]); + }); + + it("keeps protected rows when the editor replaces a subject tab", () => { + const protectedOwner: PermissionDraftRow = { + ...localViewer, + assigneeId: "owner-1", + modelKey: "owner", + protected: true, + editable: false, + }; + const baseline = createPermissionDraft([protectedOwner, localViewer]); + const next = permissionDraftReducer(baseline, { + type: "replace_rows", + rows: [{ ...localViewer, modelKey: "editor" }], + }); + expect(next.rows).toContainEqual(protectedOwner); + expect(getPermissionDraftDiff(next).changes).toHaveLength(1); + }); + + it.each([ + { ...localViewer, protected: true }, + { ...localViewer, scope: "INHERITED" as const }, + { ...localViewer, editable: false }, + ])("does not mutate protected or read-only rows", (row) => { + const draft = createPermissionDraft([row]); + expect(permissionDraftReducer(draft, { + type: "remove", key: getPermissionDraftRowKey(row), + })).toBe(draft); + }); + + it("keeps the same subject as separate source assignees", () => { + const rows = [localViewer, { ...localViewer, assigneeId: "assignee-2", sourceType: "DEPARTMENT" }]; + expect(createPermissionDraft(rows).rows).toHaveLength(2); + }); + + it("cancel restores rows and clears changes", () => { + const baseline = createPermissionDraft([localViewer]); + const changed = permissionDraftReducer(baseline, { + type: "remove", key: getPermissionDraftRowKey(localViewer), + }); + const reset = permissionDraftReducer(changed, { type: "reset" }); + expect(reset.rows).toEqual([localViewer]); + expect(getPermissionDraftDiff(reset).changes).toEqual([]); + }); +}); diff --git a/src/frontend/client/src/components/permission/usePermissionDraft.ts b/src/frontend/client/src/components/permission/usePermissionDraft.ts new file mode 100644 index 0000000000..457eb20f08 --- /dev/null +++ b/src/frontend/client/src/components/permission/usePermissionDraft.ts @@ -0,0 +1,238 @@ +import { useCallback, useMemo, useReducer } from "react"; +import type { PermissionGrantMutationChange, SubjectType } from "~/api/permission"; + +export interface PermissionDraftRow { + subjectType: SubjectType; + subjectId: number; + subjectName: string; + modelKey: string; + modelName?: string; + modelLevel?: number | null; + includeChildren?: boolean; + assigneeId?: string; + assigneeVersion?: number; + sourceType?: string; + scope?: "LOCAL" | "INHERITED"; + inheritedFrom?: string | null; + inheritedFromName?: string | null; + protected?: boolean; + editable?: boolean; +} + +export interface PermissionDraftBaseline { + resourceVersion: number; + catalogReleaseId: number; +} + +export interface PermissionDraft { + baseline: PermissionDraftRow[]; + rows: PermissionDraftRow[]; + touchedAssigneeIds: string[]; + baselineVersion?: PermissionDraftBaseline; +} + +export interface PermissionDraftDiff { + changes: PermissionGrantMutationChange[]; +} + +export type PermissionDraftAction = + | { type: "add"; row: PermissionDraftRow } + | { type: "change"; key: string; changes: Partial } + | { type: "remove"; key: string } + | { type: "replace_rows"; rows: PermissionDraftRow[]; baselineVersion?: PermissionDraftBaseline } + | { type: "reset"; rows?: PermissionDraftRow[]; baselineVersion?: PermissionDraftBaseline }; + +const EMPTY_DIFF: PermissionDraftDiff = { changes: [] }; + +function cloneRows(rows: PermissionDraftRow[]): PermissionDraftRow[] { + return rows.map((row) => ({ ...row })); +} + +function isReadOnly(row: PermissionDraftRow): boolean { + return row.protected === true || row.scope === "INHERITED" || row.editable === false; +} + +export function getPermissionDraftRowKey(row: PermissionDraftRow): string { + return row.assigneeId ?? JSON.stringify([ + row.subjectType, + row.subjectId, + row.modelKey, + row.includeChildren ?? null, + ]); +} + +function uniqueRows(rows: PermissionDraftRow[]): PermissionDraftRow[] { + const seen = new Set(); + return rows.filter((row) => { + const key = getPermissionDraftRowKey(row); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function createPermissionDraft( + rows: PermissionDraftRow[] = [], + baselineVersion?: PermissionDraftBaseline, +): PermissionDraft { + const baseline = uniqueRows(cloneRows(rows)); + return { baseline, rows: cloneRows(baseline), touchedAssigneeIds: [], baselineVersion }; +} + +function touch(state: PermissionDraft, row: PermissionDraftRow): string[] { + const key = getPermissionDraftRowKey(row); + return state.touchedAssigneeIds.includes(key) + ? state.touchedAssigneeIds + : [...state.touchedAssigneeIds, key]; +} + +export function permissionDraftReducer( + state: PermissionDraft, + action: PermissionDraftAction, +): PermissionDraft { + if (action.type === "reset") { + return createPermissionDraft( + action.rows ?? state.baseline, + action.baselineVersion ?? state.baselineVersion, + ); + } + if (action.type === "replace_rows") { + const immutableBaseline = state.baseline.filter(isReadOnly); + const immutableKeys = new Set(immutableBaseline.map(getPermissionDraftRowKey)); + const rows = uniqueRows([ + ...immutableBaseline, + ...cloneRows(action.rows).filter( + (row) => !immutableKeys.has(getPermissionDraftRowKey(row)), + ), + ]); + const currentByKey = new Map(state.rows.map((row) => [getPermissionDraftRowKey(row), row])); + const nextByKey = new Map(rows.map((row) => [getPermissionDraftRowKey(row), row])); + const touched = [...state.touchedAssigneeIds]; + for (const baseline of state.baseline) { + if (isReadOnly(baseline)) continue; + const key = getPermissionDraftRowKey(baseline); + const previous = currentByKey.get(key); + const next = nextByKey.get(key); + if ( + previous?.modelKey !== next?.modelKey + || (previous !== undefined && next === undefined) + || (previous === undefined && next !== undefined) + ) { + if (!touched.includes(key)) touched.push(key); + } + } + return { + ...state, + rows, + touchedAssigneeIds: touched, + baselineVersion: action.baselineVersion ?? state.baselineVersion, + }; + } + if (action.type === "add") { + if (isReadOnly(action.row)) return state; + const key = getPermissionDraftRowKey(action.row); + if (state.rows.some((row) => getPermissionDraftRowKey(row) === key)) return state; + return { + ...state, + rows: [...state.rows, { ...action.row }], + touchedAssigneeIds: touch(state, action.row), + }; + } + const index = state.rows.findIndex((row) => getPermissionDraftRowKey(row) === action.key); + if (index < 0 || isReadOnly(state.rows[index])) return state; + const previous = state.rows[index]; + if (action.type === "remove") { + return { + ...state, + rows: state.rows.filter((_, rowIndex) => rowIndex !== index), + touchedAssigneeIds: touch(state, previous), + }; + } + const next = { ...previous, ...action.changes }; + if (next.modelKey === previous.modelKey) return state; + const rows = state.rows.slice(); + rows[index] = next; + return { ...state, rows, touchedAssigneeIds: touch(state, previous) }; +} + +export function getPermissionDraftDiff(draft: PermissionDraft): PermissionDraftDiff { + const baselineByKey = new Map(draft.baseline.map((row) => [getPermissionDraftRowKey(row), row])); + const rowsByKey = new Map(draft.rows.map((row) => [getPermissionDraftRowKey(row), row])); + const changes: PermissionGrantMutationChange[] = []; + for (const current of draft.rows) { + if (current.assigneeId || isReadOnly(current)) continue; + changes.push({ + op: "ADD", + model_key: current.modelKey, + subject: { + type: current.subjectType, + id: String(current.subjectId), + ...(current.subjectType === "department" + ? { + userset_relation: current.includeChildren ? "subtree_member" : null, + include_children: Boolean(current.includeChildren), + } + : {}), + }, + }); + } + if (draft.touchedAssigneeIds.length === 0) { + return changes.length === 0 ? EMPTY_DIFF : { changes }; + } + for (const key of draft.touchedAssigneeIds) { + const baseline = baselineByKey.get(key); + const current = rowsByKey.get(key); + if (baseline && !current && baseline.assigneeId != null && baseline.assigneeVersion != null) { + changes.push({ + op: "REMOVE", + assignee_id: baseline.assigneeId, + expected_assignee_version: baseline.assigneeVersion, + }); + } else if ( + baseline && current && baseline.modelKey !== current.modelKey + && baseline.assigneeId != null && baseline.assigneeVersion != null + ) { + changes.push({ + op: "MOVE", + assignee_id: baseline.assigneeId, + expected_assignee_version: baseline.assigneeVersion, + target_model_key: current.modelKey, + }); + } + } + return { changes }; +} + +export function usePermissionDraft(initialRows: PermissionDraftRow[] = []) { + const [draft, dispatch] = useReducer(permissionDraftReducer, initialRows, createPermissionDraft); + const diff = useMemo(() => getPermissionDraftDiff(draft), [draft]); + const addRow = useCallback((row: PermissionDraftRow) => dispatch({ type: "add", row }), []); + const addRows = useCallback((rows: PermissionDraftRow[]) => { + rows.forEach((row) => dispatch({ type: "add", row })); + }, []); + const changeRow = useCallback((key: string, changes: Partial) => { + dispatch({ type: "change", key, changes }); + }, []); + const removeRow = useCallback((key: string) => dispatch({ type: "remove", key }), []); + const replaceRows = useCallback((rows: PermissionDraftRow[], baselineVersion?: PermissionDraftBaseline) => { + dispatch({ type: "replace_rows", rows, baselineVersion }); + }, []); + const reset = useCallback((rows?: PermissionDraftRow[], baselineVersion?: PermissionDraftBaseline) => { + dispatch({ type: "reset", rows, baselineVersion }); + }, []); + const cancel = useCallback(() => dispatch({ type: "reset" }), []); + return { + draft, + rows: draft.rows, + diff, + hasChanges: diff.changes.length > 0, + addRow, + addRows, + changeRow, + updateRow: changeRow, + removeRow, + replaceRows, + reset, + cancel, + }; +} diff --git a/src/frontend/client/src/components/ui/DropdownMenu.tsx b/src/frontend/client/src/components/ui/DropdownMenu.tsx index 2e23894e60..718c31fb23 100644 --- a/src/frontend/client/src/components/ui/DropdownMenu.tsx +++ b/src/frontend/client/src/components/ui/DropdownMenu.tsx @@ -39,10 +39,20 @@ DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayNam const DropdownMenuSubContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', ...props }, ref) => ( + Omit, 'align'> & { + /** + * Cross-axis alignment against the sub-trigger: 'start' (default) lines up + * their top edges, 'center' centers the panel vertically on the trigger row. + * Radix types SubContent's align as 'start' | 'end' only, but the runtime + * hands the value straight to Popper — which does support 'center' — so we + * widen it here rather than hand-computing an alignOffset from panel height. + */ + align?: 'start' | 'center' | 'end'; + } +>(({ className = '', align = 'start', ...props }, ref) => ( , - React.ComponentPropsWithoutRef ->(({ className, children, headNode = null, footerNode = null, auto, position = "popper", ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + /** Padding etc. for the inner viewport — `className` lands on the surface, + * which cannot reach it. */ + viewportClassName?: string + } +>(({ className, children, headNode = null, footerNode = null, auto, viewportClassName, position = "popper", ...props }, ref) => ( {children} diff --git a/src/frontend/client/src/components/ui/icon/File/FileIcon.tsx b/src/frontend/client/src/components/ui/icon/File/FileIcon.tsx index d5c2f017c7..b7c869b248 100644 --- a/src/frontend/client/src/components/ui/icon/File/FileIcon.tsx +++ b/src/frontend/client/src/components/ui/icon/File/FileIcon.tsx @@ -1,3 +1,4 @@ +import { Outlined } from "bisheng-icons"; import { BookType, File, FileMinus, Heading, Image, Loader2, Table2 } from "lucide-react"; import React from "react"; @@ -135,6 +136,39 @@ const getSizeClass = (size: 'sm' | 'md' | 'lg') => { export const getFileTypebyFileName = (fileName: string) => { return fileName ? fileName.split('.').pop()?.toLocaleLowerCase() as FileType : ''; } +// Map an uploaded file's extension to a bisheng outlined file-type icon. +// Anything not listed falls back to the generic Outlined.File icon. +const FILE_TYPE_ICONS: Record = { + // FileExcel + xls: Outlined.FileExcel, + xlsx: Outlined.FileExcel, + csv: Outlined.FileExcel, + et: Outlined.FileExcel, + // FilePdf + pdf: Outlined.FilePdf, + ppt: Outlined.FilePdf, + dps: Outlined.FilePdf, + // FileTxt + txt: Outlined.FileTxt, + // FileWord + doc: Outlined.FileWord, + docx: Outlined.FileWord, + wps: Outlined.FileWord, + // FileImage + png: Outlined.FileImage, + jpg: Outlined.FileImage, + jpeg: Outlined.FileImage, + bmp: Outlined.FileImage, + // FileEditing + md: Outlined.FileEditing, + // File (generic) + html: Outlined.File, +}; + +/** Outlined icon component for a file name, generic `File` when unmapped. */ +export const getFileTypeIcon = (fileName: string): typeof Outlined.File => + FILE_TYPE_ICONS[getFileTypebyFileName(fileName)] ?? Outlined.File; + // Whether an attachment should render as a picture. Judged by filename rather // than any stored MIME type: older messages don't carry one, and the upload // endpoints disagree on where they put it — the name is always there, and this diff --git a/src/frontend/client/src/layouts/MainLayout.tsx b/src/frontend/client/src/layouts/MainLayout.tsx index 4868173a26..24bdc588f5 100644 --- a/src/frontend/client/src/layouts/MainLayout.tsx +++ b/src/frontend/client/src/layouts/MainLayout.tsx @@ -336,10 +336,15 @@ export default function MainLayout() { ); const isAppChatRoute = /^\/app(\/|$)/.test(pathname); const isChannelRoute = /^\/channel(\/|$)/.test(pathname); - /** 订阅 / 应用中心 / 应用对话:白卡片不滚动,把高度交给页面内层(含移动端 ≤767 与桌面窄窗) */ + const isKnowledgeSettingsRoute = Boolean( + matchPath({ path: '/knowledge/create', end: true }, pathForMatch) || + matchPath({ path: '/knowledge/space/:spaceId/settings', end: true }, pathForMatch), + ); + /** These full-height surfaces keep the white shell fixed and delegate scrolling to their page content. */ const innerScrollShell = /^\/(c|linsight)(\/|$)/.test(pathname) || isChannelRoute || + isKnowledgeSettingsRoute || isAppChatRoute || (isAppsArea && !isAppsExploreRoute); const isKnowledgeRoute = /^\/knowledge(\/|$)/.test(pathname); diff --git a/src/frontend/client/src/locales/en/api_errors.gen.json b/src/frontend/client/src/locales/en/api_errors.gen.json index e60b2445bc..33d31fd155 100644 --- a/src/frontend/client/src/locales/en/api_errors.gen.json +++ b/src/frontend/client/src/locales/en/api_errors.gen.json @@ -260,6 +260,7 @@ "18064": "Source file no longer exists or has been deleted.", "18070": "The page cursor has expired. Please refresh the list and try again.", "18071": "This space is granted to you through a department or user group and cannot be left for now.", + "18072": "This creation request ID was already used with different knowledge space settings. Refresh and try again.", "18100": "Approval request not found", "18101": "No permission to view or process this approval", "18102": "Approval task already processed", @@ -300,6 +301,7 @@ "19053": "Knowledge space LLM is not configured. Please configure it in workbench settings first", "19054": "Channel authorization sync failed. Please try again later.", "19055": "This channel is granted to you through a department or user group and cannot be unsubscribed for now.", + "19056": "This creation request ID was already used with different channel settings. Refresh and try again.", "19101": "Primary department change blocked: user still owns resources under the old tenant; transfer resources first or disable user_tenant_sync.enforce_transfer_before_relocate", "19102": "Failed to resolve leaf tenant: no primary department and no default tenant available", "19103": "JWT token_version does not match the current user state; please re-login", diff --git a/src/frontend/client/src/locales/en/translation.json b/src/frontend/client/src/locales/en/translation.json index 81f8133024..e00d848b09 100644 --- a/src/frontend/client/src/locales/en/translation.json +++ b/src/frontend/client/src/locales/en/translation.json @@ -327,6 +327,7 @@ "com_error_no_user_key": "No key found. Please provide a key and try again.", "com_error_retry": "Retry", "com_feedback_placeholder": "Share your suggestions — we'll keep improving (optional)", + "com_feedback_thanks": "Thanks for your feedback — we'll use it to improve our answers", "com_feedback_title": "Feedback", "com_file_content_exceed_tokens": "File content exceeds 30k tokens", "com_file_current_empty": "Current file is empty", @@ -383,7 +384,6 @@ "com_linsight_awaiting_reply": "Answer the question above to continue the task", "com_linsight_skill_empty": "No skills available", "com_linsight_skill_search": "Search skills", - "com_linsight_skill_title": "Skills", "com_linsight_task_mode": "Task mode", "com_linsight_voice_coming_soon": "Voice input is not available yet", "com_linsight_intent_confirmed": "User intent confirmed", @@ -391,6 +391,7 @@ "com_linsight_ingest_running": "Parsing uploaded files {{0}}/{{1}}", "com_linsight_ingest_done": "Uploaded files parsed {{0}}/{{1}}", "com_linsight_ingest_failed": "Failed to parse uploaded files", + "com_linsight_skill_load_failed": "These skills could not be loaded and are not in use for this run: {{0}}", "com_linsight_ingest_aborted": "Parsing stopped {{0}}/{{1}}", "com_linsight_planning": "Planning tasks", "com_linsight_executing": "Working", @@ -1870,6 +1871,38 @@ "wechat_article_link_label": "WeChat article link", "wechat_link_copy_tip": "Tap \"···\" in the top-right corner of the article and choose \"Copy Link\", or choose \"Open in Browser\" and copy the URL from the address bar." }, + "com_unified_permission": { + "access_and_share": "Access and sharing", + "add_authorization": "Add permission", + "advanced_settings": "Advanced settings", + "authorization": "Permissions", + "authorization_empty": "No authorized subjects for this type yet", + "basic_settings": "Basic settings", + "cancel": "Cancel", + "confirm_create": "Create", + "create": "Create", + "enter_channel": "Enter channel", + "enter_space": "Enter space", + "join_public": "Anyone can join", + "join_review": "Approval required", + "page_channel_create": "Create channel", + "page_channel_settings": "Channel settings", + "page_knowledge_create": "Create knowledge space", + "page_knowledge_settings": "Space settings", + "permission_section": "Member permissions", + "private": "Private", + "private_hint": "Others cannot subscribe directly", + "publish_channel_hint": "When enabled, the channel appears in the square and can be searched or viewed", + "publish_space_hint": "When enabled, the knowledge space appears in the square and can be searched or viewed", + "publish_to_square": "Publish to square", + "resource_created_permission_failed": "The resource was created, but some permissions were not applied", + "retry_permission": "Retry permissions", + "review_join": "Review requests", + "review_join_hint": "When enabled, an administrator must approve requests to join", + "save": "Save", + "shared": "Shared", + "shared_hint": "Others can subscribe through a link or the square" + }, "com_permission": { "action_revoke": "Revoke", "action_submit": "Submit", @@ -2157,7 +2190,6 @@ "copy_before": "You can also ", "copy_link": "copy the error details", "copy_after": ", or", - "copied": "Copied", "refresh": "Refresh and retry", "download": "Download error log", "screenshot_hint": "Please include this area in your screenshot", diff --git a/src/frontend/client/src/locales/ja/api_errors.gen.json b/src/frontend/client/src/locales/ja/api_errors.gen.json index b4aa6e4178..e830a29d0f 100644 --- a/src/frontend/client/src/locales/ja/api_errors.gen.json +++ b/src/frontend/client/src/locales/ja/api_errors.gen.json @@ -260,6 +260,7 @@ "18064": "ファイルは存在しないか、削除されています。", "18070": "ページネーションが無効になりました。リストを更新して再試行してください。", "18071": "このスペースは部門/ユーザーグループ経由で付与されているため、現在退出できません。", + "18072": "この作成リクエストIDは別のナレッジスペース設定ですでに使用されています。更新して再試行してください。", "18100": "審査申請が見つかりません", "18101": "この審査を閲覧または処理する権限がありません", "18102": "審査タスクは既に処理されています", @@ -300,6 +301,7 @@ "19053": "ナレッジスペースのLLMが設定されていません。先にワークベンチ設定で設定してください", "19054": "チャンネル権限の同期に失敗しました。しばらくしてから再試行してください。", "19055": "このチャンネルは部門/ユーザーグループ経由で付与されているため、現在購読解除できません。", + "19056": "この作成リクエストIDは別のチャンネル設定ですでに使用されています。更新して再試行してください。", "19101": "主所属部門の変更がブロックされました。旧テナントにリソースが残っています。先に移譲するか設定を確認してください。", "19102": "リーフテナントを解決できません。主所属部門もデフォルトテナントもありません。", "19103": "ログイン状態が無効です(token_versionの不一致)。再ログインしてください。", diff --git a/src/frontend/client/src/locales/ja/translation.json b/src/frontend/client/src/locales/ja/translation.json index b309f3675d..c32f67ed63 100644 --- a/src/frontend/client/src/locales/ja/translation.json +++ b/src/frontend/client/src/locales/ja/translation.json @@ -313,6 +313,7 @@ "com_error_no_user_key": "キーが見つかりません。キーを提供して再試行してください。", "com_error_retry": "再試行", "com_feedback_placeholder": "ご意見をお聞かせください。今後の改善に活かします(任意)", + "com_feedback_thanks": "フィードバックありがとうございます。回答の品質改善に活用します", "com_feedback_title": "フィードバック", "com_file_content_exceed_tokens": "ファイル内容が 3 万トークンを超えています", "com_file_current_empty": "現在のファイルは空です", @@ -368,7 +369,6 @@ "com_linsight_awaiting_reply": "上記の質問に回答してタスクを続行してください", "com_linsight_skill_empty": "利用可能なスキルはありません", "com_linsight_skill_search": "スキルを検索", - "com_linsight_skill_title": "スキル", "com_linsight_task_mode": "タスクモード", "com_linsight_voice_coming_soon": "音声入力はまだ利用できません", "com_linsight_intent_confirmed": "ユーザーの意図を確認しました", @@ -376,6 +376,7 @@ "com_linsight_ingest_running": "アップロードファイルを解析中 {{0}}/{{1}}", "com_linsight_ingest_done": "アップロードファイルの解析が完了 {{0}}/{{1}}", "com_linsight_ingest_failed": "アップロードファイルの解析に失敗しました", + "com_linsight_skill_load_failed": "次のスキルを読み込めなかったため、今回の実行では使用されません:{{0}}", "com_linsight_ingest_aborted": "解析を中断しました {{0}}/{{1}}", "com_linsight_planning": "タスクを計画中", "com_linsight_executing": "タスクを実行中", @@ -1793,6 +1794,38 @@ "wechat_article_link_label": "公式アカウント記事リンク", "wechat_link_copy_tip": "記事右上の「···」をタップして「リンクをコピー」を選ぶか、「ブラウザで開く」を選んでアドレスバーの URL をコピーしてください。" }, + "com_unified_permission": { + "access_and_share": "アクセスと共有", + "add_authorization": "権限を追加", + "advanced_settings": "詳細設定", + "authorization": "権限", + "authorization_empty": "現在のタイプに権限対象はまだありません", + "basic_settings": "基本設定", + "cancel": "キャンセル", + "confirm_create": "作成を確定", + "create": "作成", + "enter_channel": "チャンネルを開く", + "enter_space": "スペースを開く", + "join_public": "自由に参加", + "join_review": "参加には承認が必要", + "page_channel_create": "チャンネルを作成", + "page_channel_settings": "チャンネル設定", + "page_knowledge_create": "ナレッジスペースを作成", + "page_knowledge_settings": "スペース設定", + "permission_section": "メンバー権限", + "private": "プライベート", + "private_hint": "他のユーザーは直接購読できません", + "publish_channel_hint": "有効にすると、チャンネルが広場に表示され、検索または閲覧できます", + "publish_space_hint": "有効にすると、ナレッジスペースが広場に表示され、検索または閲覧できます", + "publish_to_square": "広場に公開", + "resource_created_permission_failed": "リソースは作成されましたが、一部の権限を設定できませんでした", + "retry_permission": "権限設定を再試行", + "review_join": "参加申請を審査", + "review_join_hint": "有効にすると、参加には管理者の承認が必要です", + "save": "保存", + "shared": "共有", + "shared_hint": "他のユーザーはリンクまたは広場から購読できます" + }, "com_permission": { "action_revoke": "解除", "action_submit": "送信", @@ -2080,7 +2113,6 @@ "copy_before": "または ", "copy_link": "エラー情報をコピー", "copy_after": " するか、", - "copied": "コピーしました", "refresh": "再読み込み", "download": "エラーログをダウンロード", "screenshot_hint": "スクリーンショットにはこの領域を含めてください", diff --git a/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json b/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json index 2a32666e5d..2fb60e242e 100644 --- a/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json +++ b/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json @@ -260,6 +260,7 @@ "18064": "文件不存在或已被删除。", "18070": "分页参数已失效,请刷新列表后重试", "18071": "本空间通过部门/用户组授权给你,暂无法退出", + "18072": "创建请求标识已被用于不同的知识空间配置,请刷新后重试", "18100": "审批申请不存在", "18101": "无权限查看或处理此审批", "18102": "审批任务已处理或无法重复操作", @@ -300,6 +301,7 @@ "19053": "知识空间LLM未配置。请先在工作台设置中进行配置", "19054": "频道授权同步失败,请稍后重试", "19055": "本频道通过部门/用户组授权给你,暂无法取消订阅", + "19056": "创建请求标识已被用于不同的频道配置,请刷新后重试", "19101": "主部门变更被阻止:用户在原租户下仍有资源,请先迁移资源或关闭 user_tenant_sync.enforce_transfer_before_relocate。", "19102": "无法解析叶子租户:无主部门且无可用默认租户。", "19103": "登录状态已失效(token_version 不匹配),请重新登录。", diff --git a/src/frontend/client/src/locales/zh-Hans/translation.json b/src/frontend/client/src/locales/zh-Hans/translation.json index 98afeec4d8..f2feac686d 100644 --- a/src/frontend/client/src/locales/zh-Hans/translation.json +++ b/src/frontend/client/src/locales/zh-Hans/translation.json @@ -316,6 +316,7 @@ "com_error_no_user_key": "没有找到密钥。请提供密钥后重试。", "com_error_retry": "重试", "com_feedback_placeholder": "欢迎留下你的建议,我们会持续改进(选填)", + "com_feedback_thanks": "感谢你的反馈,我们会用它来改进问答效果", "com_feedback_title": "反馈", "com_file_content_exceed_tokens": "文件内容超出3万token", "com_file_current_empty": "当前文件为空", @@ -371,7 +372,6 @@ "com_linsight_awaiting_reply": "请在上方回答问题后继续任务", "com_linsight_skill_empty": "暂无可用技能", "com_linsight_skill_search": "搜索技能", - "com_linsight_skill_title": "技能", "com_linsight_task_mode": "任务模式", "com_linsight_voice_coming_soon": "语音输入暂未开放", "com_linsight_intent_confirmed": "已经明确用户意图", @@ -379,6 +379,7 @@ "com_linsight_ingest_running": "正在解析上传文件 {{0}}/{{1}}", "com_linsight_ingest_done": "上传文件解析完成 {{0}}/{{1}}", "com_linsight_ingest_failed": "上传文件解析失败", + "com_linsight_skill_load_failed": "以下技能本次未能加载,运行将不使用它们:{{0}}", "com_linsight_ingest_aborted": "上传文件解析已中断 {{0}}/{{1}}", "com_linsight_planning": "正在规划任务", "com_linsight_executing": "正在执行任务", @@ -1799,6 +1800,38 @@ "wechat_article_link_label": "公众号文章链接", "wechat_link_copy_tip": "点击文章右上角「···」,选择「复制链接」,或选择「在浏览器打开」后复制地址栏中的链接。" }, + "com_unified_permission": { + "access_and_share": "访问与分享", + "add_authorization": "新增授权", + "advanced_settings": "高级设置", + "authorization": "授权", + "authorization_empty": "当前类型下暂无授权对象", + "basic_settings": "基础设置", + "cancel": "取消", + "confirm_create": "确认创建", + "create": "创建", + "enter_channel": "进入频道", + "enter_space": "进入空间", + "join_public": "公开加入", + "join_review": "加入需审核", + "page_channel_create": "创建频道", + "page_channel_settings": "频道设置", + "page_knowledge_create": "创建知识空间", + "page_knowledge_settings": "空间设置", + "permission_section": "成员权限", + "private": "私密", + "private_hint": "其他人不能主动订阅", + "publish_channel_hint": "开启后,该频道会展示在广场,他人可以搜索或查看", + "publish_space_hint": "开启后,该知识空间会展示在广场,他人可以搜索或查看", + "publish_to_square": "发布到广场", + "resource_created_permission_failed": "资源已创建,但权限未完全设置", + "retry_permission": "重试权限设置", + "review_join": "审核加入", + "review_join_hint": "开启后,需管理员确认后才可加入", + "save": "保存", + "shared": "分享", + "shared_hint": "其他人可通过链接或广场订阅" + }, "com_permission": { "action_revoke": "撤销", "action_submit": "提交", @@ -2086,7 +2119,6 @@ "copy_before": "也可以 ", "copy_link": "点击复制错误信息", "copy_after": ",或", - "copied": "已复制", "refresh": "刷新重试", "download": "下载错误日志", "screenshot_hint": "截图时请包含此区域", diff --git a/src/frontend/client/src/mock/knowledge.ts b/src/frontend/client/src/mock/knowledge.ts deleted file mode 100644 index 32cb4f6d46..0000000000 --- a/src/frontend/client/src/mock/knowledge.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { FileType } from "~/api/knowledge"; - -// Format a byte count into a human-readable size string. -export function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + sizes[i]; -} - -// File-type icon color. Folder/doc are brand blue; the rest are fixed -// semantic colors (red/green/orange/purple) that do not follow the theme. -export function getFileTypeColor(type: FileType): string { - switch (type) { - case FileType.FOLDER: - return "#165dff"; - case FileType.PDF: - return "#f53f3f"; - case FileType.DOC: - case FileType.DOCX: - return "#165dff"; - case FileType.XLS: - case FileType.XLSX: - return "#00b42a"; - case FileType.PPT: - case FileType.PPTX: - return "#ff7d00"; - case FileType.JPG: - case FileType.JPEG: - case FileType.PNG: - return "#722ed1"; - default: - return "#86909c"; - } -} diff --git a/src/frontend/client/src/pages/Subscription/ArticleList/ArticleList.tsx b/src/frontend/client/src/pages/Subscription/ArticleList/ArticleList.tsx index 4de495d057..f2279bc497 100644 --- a/src/frontend/client/src/pages/Subscription/ArticleList/ArticleList.tsx +++ b/src/frontend/client/src/pages/Subscription/ArticleList/ArticleList.tsx @@ -33,8 +33,6 @@ interface ArticleListProps { selectedArticleId?: string; /** PC:顶部标题下拉切换频道(替代左侧 ChannelSidebar) */ onChannelSelect?: (channel: Channel | null) => void; - /** PC:下拉内频道项管理操作 */ - onManageMembers?: (channel: Channel) => void; onChannelSettings?: (channel: Channel) => void; /** H5:打开「我的频道」侧栏(订阅页抽屉) */ onOpenChannelNav?: () => void; @@ -161,7 +159,6 @@ export function ArticleList({ selectedArticleId, onArticleSelect, onChannelSelect, - onManageMembers, onChannelSettings, onOpenChannelNav, onGoChannelSquare, @@ -492,7 +489,6 @@ export function ArticleList({ variant="mobile" channel={channel} onChannelSelect={onChannelSelect} - onManageMembers={onManageMembers} onChannelSettings={onChannelSettings} onShare={canOpenChannelShare ? handleMobileShare : undefined} onOpenSourceFilter={ @@ -778,7 +774,6 @@ export function ArticleList({ diff --git a/src/frontend/client/src/pages/Subscription/ArticleList/ChannelActionsMenu.tsx b/src/frontend/client/src/pages/Subscription/ArticleList/ChannelActionsMenu.tsx index 81ac4b3dd6..2c35a27146 100644 --- a/src/frontend/client/src/pages/Subscription/ArticleList/ChannelActionsMenu.tsx +++ b/src/frontend/client/src/pages/Subscription/ArticleList/ChannelActionsMenu.tsx @@ -17,7 +17,6 @@ interface ChannelActionsMenuProps { /** Currently active channel (the one being viewed). */ channel: Channel; onChannelSelect: (channel: Channel | null) => void; - onManageMembers?: (channel: Channel) => void; onChannelSettings?: (channel: Channel) => void; /** "default" = PC labels (频道设置/成员管理/解散频道). * "mobile" = H5 labels (编辑频道/权限管理/删除频道). */ @@ -41,7 +40,6 @@ interface ChannelActionsMenuProps { export function ChannelActionsMenu({ channel, onChannelSelect, - onManageMembers, onChannelSettings, variant = "default", onShare, @@ -108,8 +106,7 @@ export function ChannelActionsMenu({ const hasMenuItem = Boolean( (isMobile && onShare) || (isMobile && onOpenSourceFilter) - || (canEditSettings && onChannelSettings) - || (canManageMembers && onManageMembers) + || ((canEditSettings || canManageMembers) && onChannelSettings) || canDissolve || canUnsubscribe, ); @@ -152,7 +149,7 @@ export function ChannelActionsMenu({ {localize("com_subscription.source_filter")} ) : null} - {canEditSettings && onChannelSettings ? ( + {(canEditSettings || canManageMembers) && onChannelSettings ? ( onChannelSettings(liveChannel)}> {isMobile @@ -160,12 +157,6 @@ export function ChannelActionsMenu({ : localize("com_subscription.channel_settings")} ) : null} - {canManageMembers && onManageMembers ? ( - onManageMembers(liveChannel)}> - - {localize("com_subscription.permission_management")} - - ) : null} {canDissolve ? ( void; /** PC:顶部标题下拉切换频道 */ onChannelSelect?: (channel: Channel | null) => void; - /** PC:下拉内频道项管理操作 */ - onManageMembers?: (channel: Channel) => void; onChannelSettings?: (channel: Channel) => void; /** H5:打开左侧「我的频道」抽屉(由订阅页挂载) */ onOpenChannelNav?: () => void; @@ -54,7 +52,6 @@ export function ChannelLayout({ channel, onFullScreen, onChannelSelect, - onManageMembers, onChannelSettings, onOpenChannelNav, onGoChannelSquare, @@ -183,7 +180,6 @@ export function ChannelLayout({ onArticleSelect={handleArticleSelect} selectedArticleId={selectedArticle?.id} onChannelSelect={onChannelSelect} - onManageMembers={onManageMembers} onChannelSettings={onChannelSettings} onOpenChannelNav={onOpenChannelNav} onGoChannelSquare={onGoChannelSquare} diff --git a/src/frontend/client/src/pages/Subscription/ChannelPermissionDialog.tsx b/src/frontend/client/src/pages/Subscription/ChannelPermissionDialog.tsx deleted file mode 100644 index 84701be860..0000000000 --- a/src/frontend/client/src/pages/Subscription/ChannelPermissionDialog.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Channel } from "~/api/channels"; -import { PermissionDialog } from "~/components/permission/PermissionDialog"; - -interface ChannelPermissionDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - channel: Channel | null; -} - -export function ChannelPermissionDialog({ - open, - onOpenChange, - channel, -}: ChannelPermissionDialogProps) { - if (!channel) return null; - - return ( - - ); -} diff --git a/src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelBusinessSettings.tsx b/src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelBusinessSettings.tsx new file mode 100644 index 0000000000..7c633bb40f --- /dev/null +++ b/src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelBusinessSettings.tsx @@ -0,0 +1,253 @@ +// bisheng-icons has no boxed plus; SquarePlus matches the sibling "add condition" button. +import { SquarePlus } from "lucide-react"; +import type { ComponentProps } from "react"; +import { NotificationSeverity } from "~/common"; +import { + SettingsSectionHeader, + SettingsSwitchRow, +} from "~/components/permission/UnifiedPermissionControls"; +import { Input } from "~/components/ui/Input"; +import { Label } from "~/components/ui/Label"; +import { Textarea } from "~/components/ui/Textarea"; +import { useToastContext } from "~/Providers"; +import { getFullWidthLength, truncateByFullWidth } from "~/utils"; +import { AddSourceDropdown } from "../CreateChannel/AddSourceDropdown"; +import { CrawlQueuePanel } from "../CreateChannel/CrawlQueuePanel"; +import { FilterConditionEditor } from "../CreateChannel/FilterConditionEditor"; +import KnowledgeSyncSection from "../CreateChannel/KnowledgeSyncSection"; +import { SubChannelBlock } from "../CreateChannel/SubChannelBlock"; +import type { useCrawlQueue } from "../hooks/useCrawlQueue"; +import { normalizeUrlForSearch } from "../urlNormalize"; +import type { useChannelSettingsForm } from "./useChannelSettingsForm"; + +const MAX_CHANNEL_NAME = 10; +const MAX_CHANNEL_DESC = 100; +const MAX_SUB_CHANNELS = 10; + +interface ChannelBusinessSettingsProps { + settings: ReturnType; + crawlQueue: ReturnType; + knowledgePickerHostRef: ComponentProps< + typeof KnowledgeSyncSection + >["knowledgePickerHostRef"]; + onOpenPreview: (itemId: string) => void; + onOpenFeedback: () => void; +} + +export function ChannelBusinessSettings({ + settings, + crawlQueue, + knowledgePickerHostRef, + onOpenPreview, + onOpenFeedback, +}: ChannelBusinessSettingsProps) { + const { showToast } = useToastContext(); + const form = settings.business; + + const handleEnqueueCrawl = (url: string) => { + const normalized = normalizeUrlForSearch(url); + if (!normalized) return; + const duplicate = + crawlQueue.queue.some( + (item) => normalizeUrlForSearch(item.url) === normalized, + ) || + form.sources.some( + (source) => + source.url && normalizeUrlForSearch(source.url) === normalized, + ); + if (duplicate) { + showToast({ + message: settings.localize("com_subscription.url_already_in_queue"), + severity: NotificationSeverity.WARNING, + }); + return; + } + crawlQueue.enqueue(url); + crawlQueue.setPanelOpen(true); + }; + + return ( +
+
+ +
+
+
+ + +
+ +
+
+ +
+ + form.setChannelName( + truncateByFullWidth(event.target.value, MAX_CHANNEL_NAME), + ) + } + placeholder={settings.localize( + "com_subscription.enter_channel_name", + )} + className="h-8 rounded-md bg-white pr-14 placeholder:text-[#999999]" + /> + + {Math.ceil(getFullWidthLength(form.channelName))}/ + {MAX_CHANNEL_NAME} + +
+
+
+ +