From c089acc3075fedb40721e76f08b3bb674834ab73 Mon Sep 17 00:00:00 2001 From: wang <17003863663@qq.com> Date: Fri, 11 Sep 2026 23:18:23 +0800 Subject: [PATCH 1/9] =?UTF-8?q?chore:=20=E5=A2=9E=E5=8A=A0=20WSL=20?= =?UTF-8?q?=E4=B8=8E=20VS=20Code=20=E5=BC=80=E5=8F=91=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 2 ++ .gitignore | 3 ++ .vscode/tasks.json | 35 ++++++++++++++++++ docker-compose.wsl.yml | 9 +++++ docs/develop-guides/contributing.md | 12 +++++++ .../2026-09-11-wsl-vscode-development.md | 36 +++++++++++++++++++ web/eslint.config.js | 9 +++++ 7 files changed, 106 insertions(+) create mode 100644 .gitattributes create mode 100644 .vscode/tasks.json create mode 100644 docker-compose.wsl.yml create mode 100644 docs/develop-guides/decisions/implemented/2026-09-11-wsl-vscode-development.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..70ba3d79b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Linux 容器和 WSL 直接执行的脚本必须保持 LF,避免 Windows 检出后出现 ^M。 +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index eee65f5e9..6b1002744 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ cache ### IDE .vscode +!/.vscode/ +/.vscode/* +!/.vscode/tasks.json .idea .vibe .qoder diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..b1a753497 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,35 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "语析:启动开发服务(WSL)", + "type": "process", + "command": "wsl.exe", + "args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "up", "--build", "-d"], + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" } + }, + { + "label": "语析:查看服务状态(WSL)", + "type": "process", + "command": "wsl.exe", + "args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "ps"], + "problemMatcher": [] + }, + { + "label": "语析:查看日志(WSL)", + "type": "process", + "command": "wsl.exe", + "args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "logs", "--tail=100", "-f", "api", "worker", "web"], + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" } + }, + { + "label": "语析:停止服务并保留数据(WSL)", + "type": "process", + "command": "wsl.exe", + "args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "stop"], + "problemMatcher": [] + } + ] +} diff --git a/docker-compose.wsl.yml b/docker-compose.wsl.yml new file mode 100644 index 000000000..e0dbfefcf --- /dev/null +++ b/docker-compose.wsl.yml @@ -0,0 +1,9 @@ +# Windows 源码目录通过 WSL 开发时,PostgreSQL 使用 Linux 命名卷。 +# Windows 文件系统不支持 PostgreSQL 初始化所需的 chmod/chown 语义。 +services: + postgres: + volumes: + - postgres-wsl-data:/var/lib/postgresql/data + +volumes: + postgres-wsl-data: diff --git a/docs/develop-guides/contributing.md b/docs/develop-guides/contributing.md index 4ad7c2f58..e198cb9b6 100644 --- a/docs/develop-guides/contributing.md +++ b/docs/develop-guides/contributing.md @@ -51,6 +51,18 @@ docker compose logs --tail=100 api `api` 和 `web` 服务默认支持热重载。容器名由 Compose project 生成;使用 `docker compose logs api web` 查看当前槽位日志。修改本地代码后通常不需要手动重启。 +### Windows + VS Code + WSL + +源码位于 Windows 盘并通过 WSL Ubuntu 运行 Docker 时,使用仓库提供的覆盖文件,避免 PostgreSQL 在 Windows 挂载目录初始化时因权限语义不同而失败: + +```bash +docker compose -f docker-compose.yml -f docker-compose.wsl.yml up --build -d +``` + +Windows VS Code 可以直接打开仓库,通过“终端 → 运行任务”调用 `.vscode/tasks.json` 中的启动、状态、日志和停止任务。任务通过 `wsl.exe --cd` 把当前 `${workspaceFolder}` 作为工作目录,默认使用名为 `Ubuntu` 的 WSL 发行版;发行版名称不同的开发者应在个人任务中调整。覆盖文件只改变 PostgreSQL 数据卷,源码仍由原 Compose 挂载并保持热重载。 + +该命令创建 Docker 命名卷保存 PostgreSQL 数据。停止服务使用 `docker compose -f docker-compose.yml -f docker-compose.wsl.yml stop`;不要对需要保留的数据执行带 `-v` 的 `down`。 + ## 3. 实现原则 - 用满足验收标准的最小实现,保持主路径线性可读。 diff --git a/docs/develop-guides/decisions/implemented/2026-09-11-wsl-vscode-development.md b/docs/develop-guides/decisions/implemented/2026-09-11-wsl-vscode-development.md new file mode 100644 index 000000000..0681bf29e --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-11-wsl-vscode-development.md @@ -0,0 +1,36 @@ +# Windows VS Code 通过 WSL 运行开发环境 + +状态:implemented +类型:process +Owner:docker-compose.wsl.yml + +## 问题 + +仓库位于 Windows 文件系统时,默认 Compose 将 PostgreSQL 数据目录绑定到 Windows 路径。PostgreSQL 初始化需要修改目录权限,Windows 挂载不提供相同的 chmod/chown 语义,导致容器持续重启。Windows 自动换行还可能使容器入口脚本无法执行。 + +## 决策 + +保留默认 Compose 作为开发拓扑,通过 `docker-compose.wsl.yml` 仅将 PostgreSQL 数据目录替换为 Docker 命名卷。仓库跟踪一组 VS Code 任务,从 Windows 调用 WSL Ubuntu 中的 Docker Compose。`.gitattributes` 固定 shell 脚本使用 LF。前端 `test/` 目录单独声明 Node.js 全局变量,使固定基线中的 Node 单元测试可由 ESLint 正确检查。 + +该覆盖不改变 API、worker、前端和知识服务的运行方式,也不复制源码或创建另一套应用配置。 + +## 替代方案 + +- 将整个仓库复制到 WSL 文件系统:能够提供完整 Linux 语义,但会产生第二份源码,不符合当前单工作区开发方式。 +- 在 Windows 原生安装所有依赖:需要维护与上游 Docker 拓扑不同的运行方式。 +- 继续把 PostgreSQL 数据写入 Windows 挂载目录:初始化权限失败,不能作为可用方案。 + +## 后果 + +- Windows VS Code 可编辑原目录,容器继续通过挂载获得热重载。 +- PostgreSQL 开发数据保存在 Docker 命名卷中,不直接出现在源码目录。 +- 仓库内任务通过 `wsl.exe --cd` 使用 VS Code 当前工作区,但仍默认使用名为 `Ubuntu` 的发行版;其他发行版需要在个人配置中调整。 +- 停止或重建应用不能使用会删除命名卷的命令,除非明确要清空开发数据。 + +## 验证 + +- `docker compose -f docker-compose.yml -f docker-compose.wsl.yml config --quiet` +- 使用覆盖文件启动后,PostgreSQL 健康检查通过,storage-migrator 以退出码 0 完成。 +- `/api/system/ready` 返回 `status=ready` 且 `degraded=false`。 +- `http://localhost:5173` 返回 HTTP 200。 +- `.vscode/tasks.json` 可被 JSON 解析,且启动、状态、日志和停止任务均显式使用相同的两个 Compose 文件。 diff --git a/web/eslint.config.js b/web/eslint.config.js index fd51c2db2..26c35eb3a 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -20,6 +20,15 @@ export default defineConfig([ }, }, + { + files: ['test/**/*.{js,mjs,jsx}'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + js.configs.recommended, ...pluginVue.configs['flat/essential'], From 459c561fe34fb9a84387fbd28c504173ca582277 Mon Sep 17 00:00:00 2001 From: wang <17003863663@qq.com> Date: Sat, 12 Sep 2026 11:25:26 +0800 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=E7=9F=A5?= =?UTF-8?q?=E4=BC=B4=E6=9C=80=E5=B0=8F=E7=89=88=E5=AE=9E=E6=96=BD=E8=8C=83?= =?UTF-8?q?=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-12-counseling-minimal-release.md | 65 +++++++++++++++++++ docs/develop-guides/roadmap.md | 4 ++ 2 files changed, 69 insertions(+) create mode 100644 docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md diff --git a/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md b/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md new file mode 100644 index 000000000..14010690a --- /dev/null +++ b/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md @@ -0,0 +1,65 @@ +# 知伴最小业务版分批实施 + +状态:proposed +类型:feature +Owner:web/src/router/index.js + +## 问题 + +面向实现者和验收人员,知伴需要把通用知识与对话能力连接到学生背景和持续跟进。原版服务已有运行记录,真实模型尚未接入;第一版先验证业务持久化与隔离,再进行真实模型联调。本文定义待实现范围,不证明业务已可用。 + +## 提案 + +产品展示名为“知伴 · 心理辅导工作台”,简称“知伴”。名称已由项目方选择,公开检索存在同名产品和商标线索,未取得商标可注册或无冲突结论。优先使用现有品牌配置,逐页替换名称、标题和欢迎语;正式 Logo 暂缓。保留许可证、上游版权及来源说明,保留核心包名、源码目录和现有运行拓扑。 + +第一版复用单前端、知识库、消息、任务、worker 和文件存储。业务入口保留学生档案;详情提供背景与跟进、关联会话,最近对话作为快捷入口。知识库提供团队与个人分类,图谱沿用原能力并遵守对应库权限。系统管理按授权显示现有智能体、模型、工具、账号、配置和监控能力。只显示已实现的入口。 + +辅导人员维护本人负责学生和个人知识库,读取授权团队库;业务管理员分配负责人并维护团队知识;技术管理员管理系统配置。固定角色可兼任,功能按授权合并。业务或技术管理身份不自动获得学生正文、关联资源或他人个人库访问权。后端负责最终校验,兼容现有管理账号,具体映射在实施前核实原权限机制。 + +学生最小字段为内部编号、负责人、背景摘要、进行中或已结束状态,后续加入按日期跟进文本。每条会话固定绑定一名学生,保存人工确认的背景快照;每名学生可有多条会话。档案变化后提示重新确认,刷新只影响后续请求,历史消息及已发起请求维持原快照。模型分析不自动写回档案。 + +个人库默认仅创建者可见,团队库由业务管理员维护。个人库用于可复用参考资料,学生档案及跟进不自动进入知识库。会话可选择获准访问的团队库和个人库;保存后、每次请求执行时仍须校验访问权,并记录实际来源范围。后续真实模型联调时验证引用归属、知识更新与删除行为。 + +模型未配置时显示真实的未配置或失败原因,保留业务可操作部分。上传原始文件不等于完成解析或索引;嵌入模型缺失时不得标为索引完成。不制造模拟回答或伪造成功任务。 + +本版暂缓材料确认、当前方案、督导流程、经验回流业务、业务统计及图谱调优。文件沿用原能力。真实模型联调在最小业务版通过后执行,覆盖检索、引用、多轮连续性和成果文件。事实核查员代码保留,不默认增加自动核查链路。 + +### 提交边界 + +每批先确认最小差异和验证方式,检查及独立审查通过后提交本地 Git,不自动推送。档案存储、页面、会话关联可分批提交,三者一起达到学生辅导业务增量的验收标准。 + +| 批次 | 改动目的及范围 | 最低验收 | +| --- | --- | --- | +| 1 | 固定最小版范围,保存本提案及规划入口 | 文档链接、构建和仓库提交门禁 | +| 2 | 基础品牌替换,优先现有配置 | 名称、标题、欢迎文案的页面验证 | +| 3 | 固定角色映射与兼任基础 | 授权、越权及原管理账号可用 | +| 4 | 开放个人库创建和维护,复用原归属机制 | 两账号库及文件隔离 | +| 5 | 团队与个人分类,团队知识只读及维护入口 | 列表、详情及写入权限 | +| 6 | 最小学生存储、迁移、接口和负责人校验 | 真实数据库及接口验证 | +| 7 | 学生列表、详情及初次分配入口 | 页面维护及账号隔离 | +| 8 | 学生会话关联、确认背景快照及未配模型提示 | 新建、重开、切换学生及归属 | +| 9 | 保存会话知识来源并校验 | 保存恢复、越权选库及权限撤回 | +| 10 | 跟进记录、背景刷新和最小版收敛 | 两账号完整无模型流程 | + +## 替代方案 + +- 先接真实模型:能先验证生成质量,但当前选择先交付最小业务并延后模型联调,接受生成质量尚未验证的限制。 +- 一次完成材料、督导与业务大屏:增加开发和权限检查范围;按实际使用需要另行提出后续增量。 +- 只改菜单及提示词:无法形成学生关联、背景快照和资源隔离,需要薄业务层及持久化约束。 +- 重命名核心包或复制工程:带来兼容与维护成本,采用现有工程渐进扩展。 + +## 验收标准 + +运行时事实由现有路由、service、repository、数据库约束及执行器拥有;新增学生逻辑按原分层补充。验证命令由[测试规范](../../testing-guidelines.md)维护,权限及持久化采用真实接口与数据库证据。 + +| 验收主张 | 失败面 | 语义 Owner | 直接证据 / 命令 | 负向案例 | 当前结果 | +| --- | --- | --- | --- | --- | --- | +| 学生与关联会话保持负责人隔离 | 替换 ID、历史入口绕过权限 | 新增业务持久化及现有会话访问边界 | 两辅导账号、三虚构学生的 HTTP 与页面验证 | 非负责人访问档案、会话或任务被拒绝 | Not run | +| 个人库隔离,团队库按权限使用 | 只隐藏入口、工具越权检索 | 知识 repository、路由与工具执行边界 | 实际列表、文件、检索权限验证 | 猜测库 ID、撤权后沿用选择被拒绝 | Not run | +| 背景及知识来源绑定请求 | 档案更新或页面切换改变输入 | 对话关联记录及请求接入 service | 回读快照、请求和会话归属 | 晚到结果、历史会话重新绑定学生被拒绝 | Not run | +| 无模型时不伪造成功 | 文件上传被标为索引成功 | 模型状态、知识任务及对话页面 | 未配置模型的 HTTP 与页面结果 | 缺少嵌入模型仍显示索引未完成 | Not run | +| 展示品牌且保留来源 | 全仓替换误改许可或包名 | 现有品牌配置、前端入口及 LICENSE | 页面验证、差异检查 | 检查许可证及核心名称无误改 | Not run | + +## 风险 + +原权限是否覆盖个人库创建、图谱和工具检索需要逐项核实,避免另建重复机制。学生权限要覆盖旧会话、任务、文件和搜索入口。各功能随权限一起交付。无模型验收仅证明业务与输入准备,无法证明索引、检索、引用质量或模型行为。 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 908ad2935..9168d8528 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -1,5 +1,9 @@ # 开发路线图 +## 知伴二次开发方向 + +知伴最小业务版先补学生档案、角色权限、团队与个人知识库及会话背景关联,真实模型联调安排在业务版验收之后。范围、取舍和分批验收见[实施提案](./decisions/proposed/2026-09-12-counseling-minimal-release.md),当前仅完成规划整理,业务能力尚未实现。 + 这里记录尚未完成的方向,不代表已经实现或一定会在某个版本发布。需求和优先级会根据 Issue、讨论和维护资源调整;欢迎在 [GitHub Issues](https://github.com/xerrors/Yuxi/issues) 提交建议。 ## 计划中的不兼容方向 From cf6a86f2d9644af096e130d384df56f6f1212296 Mon Sep 17 00:00:00 2001 From: wang <17003863663@qq.com> Date: Sat, 12 Sep 2026 11:44:48 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E7=9F=A5?= =?UTF-8?q?=E4=BC=B4=E5=9F=BA=E7=A1=80=E5=93=81=E7=89=8C=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/config/static/info.template.yaml | 17 ++++++++--------- .../2026-09-12-counseling-minimal-release.md | 2 +- docs/develop-guides/roadmap.md | 2 +- web/index.html | 2 +- web/src/components/AgentChatComponent.vue | 10 +++++----- web/src/layouts/AppLayout.vue | 2 +- web/src/views/LoginView.vue | 4 ++-- 7 files changed, 19 insertions(+), 20 deletions(-) diff --git a/backend/package/yuxi/config/static/info.template.yaml b/backend/package/yuxi/config/static/info.template.yaml index 3a09a71aa..3af2d07b4 100644 --- a/backend/package/yuxi/config/static/info.template.yaml +++ b/backend/package/yuxi/config/static/info.template.yaml @@ -3,24 +3,23 @@ # 组织信息 organization: - name: "语析" # 完整组织名称 + name: "知伴" # 完整组织名称 logo: "/favicon.svg" # Logo文件路径(放在 web/public 目录下) avatar: "/favicon.svg" # 头像文件路径(放在 web/public 目录下) login_bg: "/login-bg.jpg" # 登录背景图片路径(放在 web/public 目录下) # 项目信息 branding: - name: "Yuxi" - title: "让团队知识可连接,让智能体可行动" # 系统标题 - subtitle: "知识库 × 知识图谱 × 智能体 Harness,开源且可私有部署" # 副标题(subtitles 为空时使用) + name: "知伴" + title: "知伴 · 心理辅导工作台" # 系统标题 + subtitle: "从了解情况开始,让知识支持每一次辅导" # 副标题(subtitles 为空时使用) subtitles: - - "知识与智能体真正协同,每个回答都可溯源" - - "从回答问题到交付任务,工具与沙盒一站完成" - - "多租户、权限与模型治理,面向团队而非 Demo" - - "Docker Compose 一键部署,数据不出你的内网" + - "从了解情况开始,让知识支持每一次辅导" + - "整理已有信息,逐步形成清晰的工作思路" + - "沉淀团队经验,为持续辅导提供参考" # 页脚信息 footer: - copyright: "© 语析 2026 v{{YUXI_VERSION}}" + copyright: "© 知伴 2026 v{{YUXI_VERSION}}" user_agreement_url: "/protocols/user-agreement.template.html" privacy_policy_url: "/protocols/privacy-policy.template.html" diff --git a/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md b/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md index 14010690a..2521469dd 100644 --- a/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md +++ b/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md @@ -58,7 +58,7 @@ Owner:web/src/router/index.js | 个人库隔离,团队库按权限使用 | 只隐藏入口、工具越权检索 | 知识 repository、路由与工具执行边界 | 实际列表、文件、检索权限验证 | 猜测库 ID、撤权后沿用选择被拒绝 | Not run | | 背景及知识来源绑定请求 | 档案更新或页面切换改变输入 | 对话关联记录及请求接入 service | 回读快照、请求和会话归属 | 晚到结果、历史会话重新绑定学生被拒绝 | Not run | | 无模型时不伪造成功 | 文件上传被标为索引成功 | 模型状态、知识任务及对话页面 | 未配置模型的 HTTP 与页面结果 | 缺少嵌入模型仍显示索引未完成 | Not run | -| 展示品牌且保留来源 | 全仓替换误改许可或包名 | 现有品牌配置、前端入口及 LICENSE | 页面验证、差异检查 | 检查许可证及核心名称无误改 | Not run | +| 展示品牌且保留来源 | 全仓替换误改许可或包名 | 现有品牌配置、前端入口及 LICENSE | 页面验证、差异检查 | 检查许可证及核心名称无误改 | Passed | ## 风险 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 9168d8528..34048a113 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -2,7 +2,7 @@ ## 知伴二次开发方向 -知伴最小业务版先补学生档案、角色权限、团队与个人知识库及会话背景关联,真实模型联调安排在业务版验收之后。范围、取舍和分批验收见[实施提案](./decisions/proposed/2026-09-12-counseling-minimal-release.md),当前仅完成规划整理,业务能力尚未实现。 +知伴最小业务版先补学生档案、角色权限、团队与个人知识库及会话背景关联,真实模型联调安排在业务版验收之后。范围、取舍和分批验收见[实施提案](./decisions/proposed/2026-09-12-counseling-minimal-release.md)。当前已完成规划整理和基础品牌展示,业务能力尚未实现。 这里记录尚未完成的方向,不代表已经实现或一定会在某个版本发布。需求和优先级会根据 Issue、讨论和维护资源调整;欢迎在 [GitHub Issues](https://github.com/xerrors/Yuxi/issues) 提交建议。 diff --git a/web/index.html b/web/index.html index e23f29ede..c485b6752 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - 语析 - Knowledge Management + 知伴 · 心理辅导工作台
diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 0a74fa09a..fd736129f 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -965,11 +965,11 @@ const steeringRequestIds = reactive(new Set()) let sendCooldownTimer = null // 预设的打招呼文本 const greetingMessages = [ - '语析,析万物之语', - '语析,与知识对话', - '答案藏在知识里,我来找', - '与知识对话,与答案相遇', - '你负责提问,我负责寻找' + '欢迎使用知伴', + '从了解情况开始,逐步梳理辅导思路', + '先核对已有信息,再讨论下一步', + '整理已有资料,为辅导工作提供参考', + '请从你当前最需要梳理的问题开始' ] // 随机选择一个打招呼文本 diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index 270817b17..364617699 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -154,7 +154,7 @@ const activeConversationThreadId = computed(() => { return route.path.startsWith('/agent') ? currentThreadId.value : null }) const organizationName = computed(() => { - return infoStore.organization.name || infoStore.branding.name || 'Yuxi' + return infoStore.organization.name || infoStore.branding.name || '知伴' }) // 下面是导航菜单部分,添加智能体项 diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index f79e59db0..723834da4 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -42,7 +42,7 @@

系统初始化,请创建超级管理员

-

欢迎登录

+

欢迎登录知伴

@@ -314,7 +314,7 @@ const brandOrgName = computed(() => { }) const brandName = computed(() => { const orgName = brandOrgName.value - const brandNameRaw = infoStore.branding?.name?.trim() || 'Yuxi' + const brandNameRaw = infoStore.branding?.name?.trim() || '知伴' if (orgName && brandNameRaw && orgName !== brandNameRaw) { return brandNameRaw From 4a5acca8a8086add9c894322ddd4c2660b74d79b Mon Sep 17 00:00:00 2001 From: wang <17003863663@qq.com> Date: Sat, 12 Sep 2026 11:54:10 +0800 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20CLAUDE=20?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 - 1 file changed, 1 deletion(-) delete mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file From 208cd2b60bf3af1540aed1d8ec41f5b5387059ea Mon Sep 17 00:00:00 2001 From: wang <17003863663@qq.com> Date: Sat, 12 Sep 2026 12:38:57 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E7=9F=A5?= =?UTF-8?q?=E4=BC=B4=E4=B8=9A=E5=8A=A1=E8=A7=92=E8=89=B2=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package/yuxi/permissions/__init__.py | 16 +++ .../yuxi/permissions/business_roles.py | 93 ++++++++++++++++ .../yuxi/services/identity_admin_service.py | 3 + backend/package/yuxi/services/oidc_service.py | 6 + .../package/yuxi/storage/postgres/manager.py | 42 ++++++- .../yuxi/storage/postgres/models_business.py | 4 +- backend/package/yuxi/storage_migration.py | 5 +- backend/server/routers/auth_router.py | 37 +++++++ .../api/test_business_roles_api.py | 104 ++++++++++++++++++ .../services/test_schema_migration_version.py | 41 ++++++- .../unit/permissions/test_business_roles.py | 51 +++++++++ .../test/unit/services/test_oidc_service.py | 37 ++++++- .../unit/services/test_storage_migration.py | 51 +++++++++ .../2026-09-12-counseling-minimal-release.md | 3 + docs/develop-guides/roadmap.md | 2 +- 15 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 backend/package/yuxi/permissions/business_roles.py create mode 100644 backend/test/integration/api/test_business_roles_api.py create mode 100644 backend/test/unit/permissions/test_business_roles.py diff --git a/backend/package/yuxi/permissions/__init__.py b/backend/package/yuxi/permissions/__init__.py index 2be6e8cf6..b4aabe0a0 100644 --- a/backend/package/yuxi/permissions/__init__.py +++ b/backend/package/yuxi/permissions/__init__.py @@ -1,5 +1,14 @@ """跨资源权限能力。""" +from yuxi.permissions.business_roles import ( + BUSINESS_ROLE_CAPABILITIES, + BusinessCapability, + BusinessRole, + default_business_roles_for_platform_role, + normalize_business_roles, + resolve_business_capabilities, + resolve_business_roles, +) from yuxi.permissions.resource_permission import ( AGENT_PERMISSION_POLICY, KNOWLEDGE_BASE_PERMISSION_POLICY, @@ -18,14 +27,21 @@ __all__ = [ "AGENT_PERMISSION_POLICY", + "BUSINESS_ROLE_CAPABILITIES", "KNOWLEDGE_BASE_PERMISSION_POLICY", "SKILL_PERMISSION_POLICY", + "BusinessCapability", + "BusinessRole", "ResourcePermission", "ResourcePermissionDenied", "normalize_permission_config", + "default_business_roles_for_platform_role", + "normalize_business_roles", "require_knowledge_base_permission", "require_resource_permission", "resolve_agent_permission", + "resolve_business_capabilities", + "resolve_business_roles", "resolve_knowledge_base_permission", "resolve_resource_permission", "resolve_skill_permission", diff --git a/backend/package/yuxi/permissions/business_roles.py b/backend/package/yuxi/permissions/business_roles.py new file mode 100644 index 000000000..f3d78b3ac --- /dev/null +++ b/backend/package/yuxi/permissions/business_roles.py @@ -0,0 +1,93 @@ +"""知伴业务角色及其能力映射。""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from enum import StrEnum +from typing import Any + + +class BusinessRole(StrEnum): + """第一版固定业务角色。""" + + COUNSELOR = "counselor" + BUSINESS_ADMIN = "business_admin" + TECHNICAL_ADMIN = "technical_admin" + + +class BusinessCapability(StrEnum): + """后续业务入口使用的最小授权能力。""" + + MANAGE_ASSIGNED_STUDENTS = "students.manage_assigned" + ASSIGN_STUDENTS = "students.assign" + MANAGE_PERSONAL_KNOWLEDGE = "knowledge.personal.manage" + READ_AUTHORIZED_TEAM_KNOWLEDGE = "knowledge.team.read_authorized" + MANAGE_TEAM_KNOWLEDGE = "knowledge.team.manage" + MANAGE_SYSTEM = "system.manage" + + +BUSINESS_ROLE_ORDER = ( + BusinessRole.COUNSELOR, + BusinessRole.BUSINESS_ADMIN, + BusinessRole.TECHNICAL_ADMIN, +) + +BUSINESS_ROLE_CAPABILITIES = { + BusinessRole.COUNSELOR: frozenset( + { + BusinessCapability.MANAGE_ASSIGNED_STUDENTS, + BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE, + BusinessCapability.READ_AUTHORIZED_TEAM_KNOWLEDGE, + } + ), + BusinessRole.BUSINESS_ADMIN: frozenset( + { + BusinessCapability.ASSIGN_STUDENTS, + BusinessCapability.MANAGE_TEAM_KNOWLEDGE, + } + ), + BusinessRole.TECHNICAL_ADMIN: frozenset({BusinessCapability.MANAGE_SYSTEM}), +} + +LEGACY_PLATFORM_ROLE_DEFAULTS = { + "user": (BusinessRole.COUNSELOR,), + "admin": (BusinessRole.BUSINESS_ADMIN,), + "superadmin": (BusinessRole.TECHNICAL_ADMIN,), +} + + +def normalize_business_roles(values: Iterable[str | BusinessRole]) -> tuple[BusinessRole, ...]: + """校验业务角色、去重并按固定顺序返回。""" + + roles = {BusinessRole(value) for value in values} + return tuple(role for role in BUSINESS_ROLE_ORDER if role in roles) + + +def default_business_roles_for_platform_role(platform_role: str) -> tuple[BusinessRole, ...]: + """为旧平台角色提供不扩大数据访问权的迁移默认值。""" + + return LEGACY_PLATFORM_ROLE_DEFAULTS.get(platform_role, ()) + + +def resolve_business_roles(user: Any) -> tuple[BusinessRole, ...]: + """读取持久业务角色;旧对象缺少字段时按平台角色兼容。""" + + if isinstance(user, Mapping): + stored_roles = user.get("business_roles") + platform_role = str(user.get("role", "")) + else: + stored_roles = getattr(user, "business_roles", None) + platform_role = str(getattr(user, "role", "")) + + if stored_roles is None: + return default_business_roles_for_platform_role(platform_role) + return normalize_business_roles(stored_roles) + + +def resolve_business_capabilities(user: Any) -> frozenset[BusinessCapability]: + """合并用户兼任角色的能力,不引入角色继承。""" + + capabilities: set[BusinessCapability] = set() + for role in resolve_business_roles(user): + capabilities.update(BUSINESS_ROLE_CAPABILITIES[role]) + return frozenset(capabilities) diff --git a/backend/package/yuxi/services/identity_admin_service.py b/backend/package/yuxi/services/identity_admin_service.py index eaecc69ed..92be49fd1 100644 --- a/backend/package/yuxi/services/identity_admin_service.py +++ b/backend/package/yuxi/services/identity_admin_service.py @@ -6,6 +6,7 @@ from sqlalchemy import text from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.permissions import BusinessRole from yuxi.repositories.department_repository import DepartmentRepository from yuxi.repositories.user_repository import UserRepository from yuxi.services.operation_log_service import log_operation @@ -91,6 +92,7 @@ async def create_department_with_admin( "phone_number": admin_phone, "password_hash": password_hash, "role": "admin", + "business_roles": [BusinessRole.BUSINESS_ADMIN], "department_id": department.id, } ) @@ -144,6 +146,7 @@ async def initialize_system_admin( "avatar": None, "password_hash": password_hash, "role": "superadmin", + "business_roles": [BusinessRole.TECHNICAL_ADMIN], "department_id": department.id, "last_login": utc_now_naive(), } diff --git a/backend/package/yuxi/services/oidc_service.py b/backend/package/yuxi/services/oidc_service.py index d1263c85b..a1ccf54bb 100644 --- a/backend/package/yuxi/services/oidc_service.py +++ b/backend/package/yuxi/services/oidc_service.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.exc import IntegrityError +from yuxi.permissions.business_roles import default_business_roles_for_platform_role from yuxi.repositories.user_repository import UserRepository from yuxi.services.operation_log_service import log_operation from yuxi.storage.postgres.models_business import Department, User @@ -572,6 +573,7 @@ async def _create_oidc_binding_placeholder(db, sub: str, target_user: User) -> N avatar=None, password_hash=password_hash, role=target_user.role, + business_roles=list(target_user.business_roles or []), department_id=target_user.department_id, is_deleted=1, # 标记为deleted,不参与实际登录 last_login=utc_now_naive(), @@ -670,6 +672,9 @@ async def create_oidc_user(db, user_info: dict, department_id: int | None = None "avatar": None, "password_hash": password_hash, "role": oidc_config.default_role, + "business_roles": [ + role.value for role in default_business_roles_for_platform_role(oidc_config.default_role) + ], "department_id": department_id, "last_login": utc_now_naive(), } @@ -858,6 +863,7 @@ async def oidc_callback_handler(code: str, state: str, db, request: Request | No "phone_number": user.phone_number, "avatar": user.avatar, "role": user.role, + "business_roles": list(user.business_roles or []), "department_id": user.department_id, "department_name": department_name, } diff --git a/backend/package/yuxi/storage/postgres/manager.py b/backend/package/yuxi/storage/postgres/manager.py index aca2c4778..70ea582d1 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -23,9 +23,41 @@ from yuxi.utils.singleton import SingletonMeta AGENT_RUN_TERMINAL_STATUS_SQL = ", ".join(f"'{status}'" for status in AGENT_RUN_TERMINAL_STATUSES) -BUSINESS_SCHEMA_VERSION = 7 +BUSINESS_SCHEMA_VERSION = 8 KNOWLEDGE_SCHEMA_VERSION = 2 SCHEMA_VERSION_TABLE = "yuxi_schema_migrations" +BUSINESS_ROLE_SCHEMA_STATEMENTS = ( + "ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS business_roles JSONB", + """ + UPDATE users + SET business_roles = CASE role + WHEN 'user' THEN '["counselor"]'::jsonb + WHEN 'admin' THEN '["business_admin"]'::jsonb + WHEN 'superadmin' THEN '["technical_admin"]'::jsonb + ELSE '[]'::jsonb + END + WHERE business_roles IS NULL + """, + "ALTER TABLE IF EXISTS users ALTER COLUMN business_roles SET DEFAULT '[]'::jsonb", + "ALTER TABLE IF EXISTS users ALTER COLUMN business_roles SET NOT NULL", + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ck_users_business_roles' + AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT ck_users_business_roles + CHECK ( + jsonb_typeof(business_roles) = 'array' + AND business_roles <@ '["counselor", "business_admin", "technical_admin"]'::jsonb + ); + END IF; + END $$; + """, +) AGENT_RUN_LEASE_SCHEMA_STATEMENTS = ( "ALTER TABLE IF EXISTS agent_runs ADD COLUMN IF NOT EXISTS worker_id VARCHAR(128)", "ALTER TABLE IF EXISTS agent_runs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMP WITHOUT TIME ZONE", @@ -534,6 +566,13 @@ async def upgrade_knowledge_schema_v1_to_v2(self) -> None: for statement in KNOWLEDGE_FILE_TASK_OWNER_SCHEMA_STATEMENTS: await conn.execute(text(statement)) + async def upgrade_business_schema_v7_to_v8(self) -> None: + """增加可组合业务角色,并按旧平台角色回填最小默认值。""" + self._check_initialized() + async with self.async_engine.begin() as conn: + for statement in BUSINESS_ROLE_SCHEMA_STATEMENTS: + await conn.execute(text(statement)) + async def drop_tables(self): """删除所有表(慎用!)""" self._check_initialized() @@ -920,6 +959,7 @@ async def ensure_business_schema(self): """确保业务 schema 包含后续新增字段(运行时 schema 演进)。""" self._check_initialized() stmts = [ + *BUSINESS_ROLE_SCHEMA_STATEMENTS, "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb", diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index 43e50cc09..4932fc6c4 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -173,7 +173,8 @@ class User(Base): phone_number = Column(String, nullable=True, unique=True, index=True) # 手机号 avatar = Column(String, nullable=True) # 头像URL password_hash = Column(String, nullable=False) - role = Column(String, nullable=False, default="user") # 角色: superadmin, admin, user + role = Column(String, nullable=False, default="user") # 平台角色: superadmin, admin, user + business_roles = Column(JSON_VALUE, nullable=False, default=list, server_default="[]") department_id = Column(Integer, ForeignKey("departments.id"), nullable=True) # 部门ID created_at = Column(DateTime, default=utc_now_naive) last_login = Column(DateTime, nullable=True) @@ -207,6 +208,7 @@ def to_dict(self, include_password: bool = False) -> dict[str, Any]: "phone_number": self.phone_number, "avatar": normalize_public_minio_url(self.avatar), "role": self.role, + "business_roles": list(self.business_roles or []), "department_id": self.department_id, "created_at": format_utc_datetime(self.created_at), "last_login": format_utc_datetime(self.last_login), diff --git a/backend/package/yuxi/storage_migration.py b/backend/package/yuxi/storage_migration.py index a529270c9..02321cb6d 100644 --- a/backend/package/yuxi/storage_migration.py +++ b/backend/package/yuxi/storage_migration.py @@ -125,7 +125,7 @@ async def main() -> None: "business", business_version, BUSINESS_SCHEMA_VERSION, - upgrade_from=(2,), + upgrade_from=(2, 7), ) knowledge_version = versions.get("knowledge") _require_supported_version( @@ -150,6 +150,9 @@ async def main() -> None: if business_version is None: await pg_manager.setup_langgraph_checkpointer() await pg_manager.record_schema_version("business", BUSINESS_SCHEMA_VERSION) + elif business_version == 7: + await pg_manager.upgrade_business_schema_v7_to_v8() + await pg_manager.record_schema_version("business", BUSINESS_SCHEMA_VERSION) if knowledge_version is None: await pg_manager.create_knowledge_tables() diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index ff5648cfb..666984135 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -42,6 +42,11 @@ from yuxi.storage.postgres.models_business import User from yuxi.repositories.department_repository import DepartmentRepository from yuxi.repositories.user_repository import UserRepository +from yuxi.permissions import ( + BusinessRole, + default_business_roles_for_platform_role, + normalize_business_roles, +) from yuxi.utils import logger from yuxi.utils.auth_utils import AuthUtils from yuxi.utils.datetime_utils import utc_now_naive @@ -68,6 +73,7 @@ class Token(BaseModel): phone_number: str | None = None avatar: str | None = None role: str + business_roles: list[BusinessRole] department_id: int | None = None department_name: str | None = None @@ -76,6 +82,7 @@ class UserCreate(BaseModel): username: str password: str = Field(min_length=8) role: str = "user" + business_roles: list[BusinessRole] | None = None phone_number: str | None = None department_id: int | None = None @@ -88,6 +95,7 @@ class UserUpdate(BaseModel): phone_number: str | None = None avatar: str | None = None department_id: int | None = None + business_roles: list[BusinessRole] | None = None class UserProfileUpdate(BaseModel): @@ -102,6 +110,7 @@ class UserResponse(BaseModel): phone_number: str | None = None avatar: str | None = None role: str + business_roles: list[BusinessRole] department_id: int | None = None department_name: str | None = None # 部门名称 created_at: str @@ -119,6 +128,7 @@ class UserAccessOption(BaseModel): uid: str username: str role: str + business_roles: list[BusinessRole] department_id: int | None = None department_name: str | None = None @@ -158,6 +168,7 @@ class OIDCLoginResponse(BaseModel): phone_number: str | None = None avatar: str | None = None role: str + business_roles: list[BusinessRole] department_id: int | None = None department_name: str | None = None @@ -324,6 +335,7 @@ async def login_for_access_token( "phone_number": user.phone_number, "avatar": normalize_public_minio_url(user.avatar), "role": user.role, + "business_roles": user.business_roles, "department_id": user.department_id, "department_name": department_name, } @@ -434,6 +446,7 @@ async def initialize_admin(admin_data: InitializeAdmin, db: AsyncSession = Depen "phone_number": new_admin.phone_number, "avatar": new_admin.avatar, "role": new_admin.role, + "business_roles": new_admin.business_roles, } @@ -574,6 +587,18 @@ async def create_user( detail="管理员只能创建普通用户账户", ) + if user_data.business_roles is not None and current_user.role != "superadmin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有超级管理员才能指定业务角色", + ) + + requested_business_roles = ( + normalize_business_roles(user_data.business_roles) + if user_data.business_roles is not None + else default_business_roles_for_platform_role(user_data.role) + ) + # 部门分配逻辑 if current_user.role == "superadmin": # 超级管理员创建用户时,使用指定的部门或默认部门 @@ -606,6 +631,7 @@ async def create_user( "phone_number": user_data.phone_number, "password_hash": hashed_password, "role": user_data.role, + "business_roles": [role.value for role in requested_business_roles], "department_id": department_id, } ) @@ -699,6 +725,7 @@ async def read_user_access_options( "uid": user.uid, "username": user.username, "role": user.role, + "business_roles": user.business_roles, "department_id": user.department_id, "department_name": dept_name, } @@ -759,6 +786,15 @@ async def update_user( # 更新信息 update_details = [] + if user_data.business_roles is not None: + if current_user.role != "superadmin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有超级管理员才能修改业务角色", + ) + user.business_roles = [role.value for role in normalize_business_roles(user_data.business_roles)] + update_details.append(f"业务角色: {', '.join(user.business_roles) or '无'}") + if user_data.username is not None: # 检查用户名是否已被其他用户使用 existing_user = await user_repository.get_by_username(user_data.username, exclude_user_id=user_id) @@ -999,6 +1035,7 @@ async def impersonate_user( "phone_number": target_user.phone_number, "avatar": normalize_public_minio_url(target_user.avatar), "role": target_user.role, + "business_roles": target_user.business_roles, "department_id": target_user.department_id, "department_name": department_name, } diff --git a/backend/test/integration/api/test_business_roles_api.py b/backend/test/integration/api/test_business_roles_api.py new file mode 100644 index 000000000..a5ea9545e --- /dev/null +++ b/backend/test/integration/api/test_business_roles_api.py @@ -0,0 +1,104 @@ +"""业务角色的真实 HTTP 授权测试。""" + +from __future__ import annotations + +import json +import os +import uuid + +import asyncpg +import pytest + +from yuxi.utils.auth_utils import AuthUtils + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_business_roles_compose_without_expanding_legacy_admin_access(test_client) -> None: + """超级管理员可组合角色,普通管理员只能沿用辅导人员默认值。""" + + suffix = uuid.uuid4().hex[:10] + department_name = f"pytest_business_roles_{suffix}" + super_uid = f"roles_super_{suffix}" + admin_uid = f"roles_admin_{suffix}" + created_uids = {super_uid, admin_uid} + connection = await asyncpg.connect(os.environ["POSTGRES_URL"].replace("+asyncpg", "")) + try: + department_id = await connection.fetchval( + "INSERT INTO departments (name, description) VALUES ($1, $2) RETURNING id", + department_name, + "business role authorization test", + ) + super_id = await connection.fetchval( + """ + INSERT INTO users + (username, uid, password_hash, role, business_roles, department_id, + login_failed_count, is_deleted, created_at) + VALUES ($1, $1, 'test', 'superadmin', '["technical_admin"]'::jsonb, $2, 0, 0, NOW()) + RETURNING id + """, + super_uid, + department_id, + ) + admin_id = await connection.fetchval( + """ + INSERT INTO users + (username, uid, password_hash, role, business_roles, department_id, + login_failed_count, is_deleted, created_at) + VALUES ($1, $1, 'test', 'admin', '["business_admin"]'::jsonb, $2, 0, 0, NOW()) + RETURNING id + """, + admin_uid, + department_id, + ) + super_headers = {"Authorization": f"Bearer {AuthUtils.create_access_token({'sub': str(super_id)})}"} + admin_headers = {"Authorization": f"Bearer {AuthUtils.create_access_token({'sub': str(admin_id)})}"} + + profile_response = await test_client.get("/api/auth/me", headers=super_headers) + assert profile_response.status_code == 200, profile_response.text + assert profile_response.json()["business_roles"] == ["technical_admin"] + + forbidden_response = await test_client.post( + "/api/auth/users", + json={ + "username": f"forbidden_{suffix}", + "password": "routerTest123!", + "role": "user", + "business_roles": ["business_admin"], + }, + headers=admin_headers, + ) + assert forbidden_response.status_code == 403, forbidden_response.text + assert forbidden_response.json()["detail"] == "只有超级管理员才能指定业务角色" + + legacy_admin_response = await test_client.post( + "/api/auth/users", + json={"username": f"counselor_{suffix}", "password": "routerTest123!", "role": "user"}, + headers=admin_headers, + ) + assert legacy_admin_response.status_code == 200, legacy_admin_response.text + counselor = legacy_admin_response.json() + created_uids.add(counselor["uid"]) + assert counselor["business_roles"] == ["counselor"] + + update_response = await test_client.put( + f"/api/auth/users/{counselor['id']}", + json={"business_roles": ["business_admin", "counselor", "counselor"]}, + headers=super_headers, + ) + assert update_response.status_code == 200, update_response.text + assert update_response.json()["business_roles"] == ["counselor", "business_admin"] + + persisted_roles = await connection.fetchval( + "SELECT business_roles FROM users WHERE id = $1", + counselor["id"], + ) + assert json.loads(persisted_roles) == ["counselor", "business_admin"] + finally: + user_ids = await connection.fetch("SELECT id FROM users WHERE uid = ANY($1::varchar[])", list(created_uids)) + ids = [row["id"] for row in user_ids] + if ids: + await connection.execute("DELETE FROM operation_logs WHERE user_id = ANY($1::integer[])", ids) + await connection.execute("DELETE FROM users WHERE id = ANY($1::integer[])", ids) + await connection.execute("DELETE FROM departments WHERE name = $1", department_name) + await connection.close() diff --git a/backend/test/integration/services/test_schema_migration_version.py b/backend/test/integration/services/test_schema_migration_version.py index cfe75df04..80c58cc4d 100644 --- a/backend/test/integration/services/test_schema_migration_version.py +++ b/backend/test/integration/services/test_schema_migration_version.py @@ -8,6 +8,7 @@ import pytest from sqlalchemy import text +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from yuxi.storage.postgres.manager import BUSINESS_SCHEMA_VERSION, KNOWLEDGE_SCHEMA_VERSION, PostgresManager @@ -268,7 +269,45 @@ async def test_v072_business_converges_current_schema_idempotently() -> None: "ix_scheduled_agent_runs_job_created", "ix_scheduled_agent_runs_dispatching", }.issubset(scheduled_indexes) - assert BUSINESS_SCHEMA_VERSION == 7 + assert BUSINESS_SCHEMA_VERSION == 8 + finally: + await _drop_isolated_schema(schema, admin_engine, scoped_engine) + + +async def test_business_v7_to_v8_backfills_fixed_roles_and_rejects_unknown_values() -> None: + """v7 用户按旧平台角色获得最小默认业务角色,数据库拒绝未知角色。""" + schema, admin_engine, scoped_engine, manager = await _create_isolated_manager("pytest_business_roles") + try: + await manager.create_business_tables() + async with scoped_engine.begin() as connection: + await connection.execute(text("ALTER TABLE users DROP COLUMN business_roles")) + await connection.execute( + text( + "INSERT INTO users (username, uid, password_hash, role, login_failed_count, is_deleted) VALUES " + "('counselor', 'counselor', 'x', 'user', 0, 0), " + "('business', 'business', 'x', 'admin', 0, 0), " + "('technical', 'technical', 'x', 'superadmin', 0, 0)" + ) + ) + + await manager.upgrade_business_schema_v7_to_v8() + async with scoped_engine.begin() as connection: + await connection.execute(text("UPDATE users SET business_roles = '[]'::jsonb WHERE uid = 'counselor'")) + await manager.upgrade_business_schema_v7_to_v8() + + async with scoped_engine.connect() as connection: + rows = (await connection.execute(text("SELECT uid, business_roles FROM users ORDER BY uid"))).all() + assert {row.uid: row.business_roles for row in rows} == { + "business": ["business_admin"], + "counselor": [], + "technical": ["technical_admin"], + } + + with pytest.raises(IntegrityError, match="ck_users_business_roles"): + async with scoped_engine.begin() as connection: + await connection.execute( + text("UPDATE users SET business_roles = '[\"unknown\"]'::jsonb WHERE uid = 'counselor'") + ) finally: await _drop_isolated_schema(schema, admin_engine, scoped_engine) diff --git a/backend/test/unit/permissions/test_business_roles.py b/backend/test/unit/permissions/test_business_roles.py new file mode 100644 index 000000000..6d423e8c2 --- /dev/null +++ b/backend/test/unit/permissions/test_business_roles.py @@ -0,0 +1,51 @@ +from types import SimpleNamespace + +import pytest + +from yuxi.permissions import ( + BusinessCapability, + BusinessRole, + normalize_business_roles, + resolve_business_capabilities, + resolve_business_roles, +) + + +def test_legacy_platform_roles_map_without_expanding_student_access(): + assert resolve_business_roles(SimpleNamespace(role="user")) == (BusinessRole.COUNSELOR,) + assert resolve_business_roles(SimpleNamespace(role="admin")) == (BusinessRole.BUSINESS_ADMIN,) + assert resolve_business_roles(SimpleNamespace(role="superadmin")) == (BusinessRole.TECHNICAL_ADMIN,) + + admin_capabilities = resolve_business_capabilities(SimpleNamespace(role="admin")) + technical_capabilities = resolve_business_capabilities(SimpleNamespace(role="superadmin")) + assert BusinessCapability.MANAGE_ASSIGNED_STUDENTS not in admin_capabilities + assert BusinessCapability.MANAGE_ASSIGNED_STUDENTS not in technical_capabilities + + +def test_multiple_business_roles_merge_capabilities_without_role_inheritance(): + user = SimpleNamespace( + role="user", + business_roles=[BusinessRole.TECHNICAL_ADMIN, BusinessRole.COUNSELOR, BusinessRole.COUNSELOR], + ) + + assert resolve_business_roles(user) == (BusinessRole.COUNSELOR, BusinessRole.TECHNICAL_ADMIN) + assert resolve_business_capabilities(user) == frozenset( + { + BusinessCapability.MANAGE_ASSIGNED_STUDENTS, + BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE, + BusinessCapability.READ_AUTHORIZED_TEAM_KNOWLEDGE, + BusinessCapability.MANAGE_SYSTEM, + } + ) + + +def test_persisted_empty_roles_do_not_fall_back_to_legacy_role(): + user = SimpleNamespace(role="superadmin", business_roles=[]) + + assert resolve_business_roles(user) == () + assert resolve_business_capabilities(user) == frozenset() + + +def test_unknown_business_role_fails_closed(): + with pytest.raises(ValueError, match="not a valid BusinessRole"): + normalize_business_roles(["unknown"]) diff --git a/backend/test/unit/services/test_oidc_service.py b/backend/test/unit/services/test_oidc_service.py index f423143bc..b91ce5c54 100644 --- a/backend/test/unit/services/test_oidc_service.py +++ b/backend/test/unit/services/test_oidc_service.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from urllib.parse import unquote +from urllib.parse import parse_qs, unquote, urlparse import pytest import pytest_asyncio @@ -37,6 +37,31 @@ async def _create_user(session, uid: str = "alice") -> User: return user +async def test_create_oidc_user_assigns_default_business_role(oidc_session, monkeypatch): + monkeypatch.setattr(oidc_service.oidc_config, "use_raw_username", False) + monkeypatch.setattr(oidc_service.oidc_config, "default_role", "user") + + created_data = None + + class FakeUserRepository: + async def create(self, data): + nonlocal created_data + created_data = data + return User(**data) + + monkeypatch.setattr(oidc_service, "UserRepository", FakeUserRepository) + + user = await oidc_service.create_oidc_user( + oidc_session, + {"sub": "new-user", "name": "New User", "username": "new-user"}, + ) + + assert user.role == "user" + assert user.business_roles == ["counselor"] + assert created_data is not None + assert created_data["business_roles"] == ["counselor"] + + async def test_find_user_by_oidc_sub_resolves_placeholder_when_sub_contains_colon(oidc_session): user = await _create_user(oidc_session) @@ -67,6 +92,8 @@ async def test_find_deleted_oidc_user_by_sub_resolves_deleted_target_when_sub_co async def test_oidc_callback_allows_existing_binding_when_sub_contains_colon(oidc_session, monkeypatch): user = await _create_user(oidc_session) + user.business_roles = ["counselor"] + await oidc_session.commit() await oidc_service._create_oidc_binding_placeholder(oidc_session, "tenant:user", user) monkeypatch.setattr(oidc_service.oidc_config, "enabled", True) @@ -100,4 +127,10 @@ async def fake_log_operation(db, user_id, operation, request=None): response = await oidc_service.oidc_callback_handler("dummy-code", "dummy-state", oidc_session) assert response.status_code == 302 - assert unquote(response.headers["location"]).startswith("/auth/oidc/callback?code=") + callback_url = unquote(response.headers["location"]) + assert callback_url.startswith("/auth/oidc/callback?code=") + + exchange_code = parse_qs(urlparse(callback_url).query)["code"][0] + login_response = await oidc_service.oidc_exchange_code_handler(exchange_code) + + assert login_response["business_roles"] == ["counselor"] diff --git a/backend/test/unit/services/test_storage_migration.py b/backend/test/unit/services/test_storage_migration.py index e546e1c44..138cc297a 100644 --- a/backend/test/unit/services/test_storage_migration.py +++ b/backend/test/unit/services/test_storage_migration.py @@ -281,6 +281,57 @@ async def session_context(): assert {"create_business", "checkpoint", "knowledge_schema"}.isdisjoint(calls) +@pytest.mark.asyncio +async def test_main_v7_business_schema_runs_only_role_upgrade(monkeypatch): + calls: list[str] = [] + sessions = [_Session(), _Session(), _Session()] + + @asynccontextmanager + async def session_context(): + yield sessions.pop(0) + + manager = SimpleNamespace( + initialize=lambda: calls.append("initialize"), + schema_migration_lock=lambda: _async_context(calls, "schema_lock"), + create_schema_version_table=lambda: _record(calls, "create_schema_version_table"), + get_schema_versions=lambda: _async_value( + {"business": 7, "knowledge": storage_migration.KNOWLEDGE_SCHEMA_VERSION} + ), + record_schema_version=lambda domain, version: _record(calls, f"version:{domain}:{version}"), + create_business_tables=lambda: _record(calls, "create_business"), + create_knowledge_tables=lambda: _record(calls, "create_knowledge"), + ensure_business_schema=lambda: _record(calls, "business_schema"), + upgrade_business_schema_v7_to_v8=lambda: _record(calls, "business_roles_schema"), + ensure_knowledge_schema=lambda: _record(calls, "knowledge_schema"), + setup_langgraph_checkpointer=lambda: _record(calls, "checkpoint"), + get_async_session_context=session_context, + close=lambda: _record(calls, "close"), + ) + monkeypatch.setattr(storage_migration, "pg_manager", manager) + monkeypatch.setattr( + storage_migration, + "read_v071_workdir_plan", + lambda _db: _async_value(V071WorkdirMigrationPlan(False, (), ())), + ) + monkeypatch.setattr(storage_migration, "_legacy_skill_roots_exist", lambda: False) + monkeypatch.setattr(storage_migration, "_legacy_system_config_exists", lambda: False) + monkeypatch.setattr(storage_migration, "runtime_storage_requires_quiescence", lambda: False) + monkeypatch.setattr( + storage_migration, + "_converge_database_state", + lambda *, fail_nonterminal_runs: _record(calls, f"converge:{fail_nonterminal_runs}"), + ) + monkeypatch.setattr(storage_migration, "migrate_shared_skills", lambda _db: _record(calls, "skills")) + monkeypatch.setattr(storage_migration, "mark_v071_skills_migrated", lambda: calls.append("mark_skills")) + monkeypatch.setattr(storage_migration, "migrate_runtime_storage_identity", lambda: calls.append("runtime_identity")) + + await storage_migration.main() + + assert "business_roles_schema" in calls + assert f"version:business:{storage_migration.BUSINESS_SCHEMA_VERSION}" in calls + assert {"create_business", "business_schema", "checkpoint", "knowledge_schema"}.isdisjoint(calls) + + @pytest.mark.asyncio async def test_failed_business_migration_does_not_record_version(monkeypatch): calls: list[str] = [] diff --git a/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md b/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md index 2521469dd..739d959f7 100644 --- a/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md +++ b/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md @@ -16,6 +16,8 @@ Owner:web/src/router/index.js 辅导人员维护本人负责学生和个人知识库,读取授权团队库;业务管理员分配负责人并维护团队知识;技术管理员管理系统配置。固定角色可兼任,功能按授权合并。业务或技术管理身份不自动获得学生正文、关联资源或他人个人库访问权。后端负责最终校验,兼容现有管理账号,具体映射在实施前核实原权限机制。 +原系统单值 `user/admin/superadmin` 继续作为平台兼容角色,既有接口不改写其含义。新增可组合业务角色 `counselor/business_admin/technical_admin`;迁移时分别按旧平台角色回填一个最小默认角色,不产生隐式角色继承。超级管理员负责显式分配组合角色,后续学生与知识业务只读取业务角色能力并在后端资源边界再次校验。 + 学生最小字段为内部编号、负责人、背景摘要、进行中或已结束状态,后续加入按日期跟进文本。每条会话固定绑定一名学生,保存人工确认的背景快照;每名学生可有多条会话。档案变化后提示重新确认,刷新只影响后续请求,历史消息及已发起请求维持原快照。模型分析不自动写回档案。 个人库默认仅创建者可见,团队库由业务管理员维护。个人库用于可复用参考资料,学生档案及跟进不自动进入知识库。会话可选择获准访问的团队库和个人库;保存后、每次请求执行时仍须校验访问权,并记录实际来源范围。后续真实模型联调时验证引用归属、知识更新与删除行为。 @@ -58,6 +60,7 @@ Owner:web/src/router/index.js | 个人库隔离,团队库按权限使用 | 只隐藏入口、工具越权检索 | 知识 repository、路由与工具执行边界 | 实际列表、文件、检索权限验证 | 猜测库 ID、撤权后沿用选择被拒绝 | Not run | | 背景及知识来源绑定请求 | 档案更新或页面切换改变输入 | 对话关联记录及请求接入 service | 回读快照、请求和会话归属 | 晚到结果、历史会话重新绑定学生被拒绝 | Not run | | 无模型时不伪造成功 | 文件上传被标为索引成功 | 模型状态、知识任务及对话页面 | 未配置模型的 HTTP 与页面结果 | 缺少嵌入模型仍显示索引未完成 | Not run | +| 固定业务角色可兼任且不隐式继承 | 管理身份自动获得学生或个人库权限 | 用户业务角色、能力映射及认证接口 | 迁移回读、角色单测与真实 HTTP | 普通管理员显式提权被拒绝,未知角色被数据库拒绝 | Passed | | 展示品牌且保留来源 | 全仓替换误改许可或包名 | 现有品牌配置、前端入口及 LICENSE | 页面验证、差异检查 | 检查许可证及核心名称无误改 | Passed | ## 风险 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 34048a113..7d4fd2b6b 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -2,7 +2,7 @@ ## 知伴二次开发方向 -知伴最小业务版先补学生档案、角色权限、团队与个人知识库及会话背景关联,真实模型联调安排在业务版验收之后。范围、取舍和分批验收见[实施提案](./decisions/proposed/2026-09-12-counseling-minimal-release.md)。当前已完成规划整理和基础品牌展示,业务能力尚未实现。 +知伴最小业务版先补学生档案、角色权限、团队与个人知识库及会话背景关联,真实模型联调安排在业务版验收之后。范围、取舍和分批验收见[实施提案](./decisions/proposed/2026-09-12-counseling-minimal-release.md)。当前已完成规划整理、基础品牌展示和可兼任业务角色基础,学生及知识业务能力尚未实现。 这里记录尚未完成的方向,不代表已经实现或一定会在某个版本发布。需求和优先级会根据 Issue、讨论和维护资源调整;欢迎在 [GitHub Issues](https://github.com/xerrors/Yuxi/issues) 提交建议。 From 99309db35461d78c0389e185f06640e178bf0644 Mon Sep 17 00:00:00 2001 From: wang <17003863663@qq.com> Date: Sat, 12 Sep 2026 13:24:51 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=BE=85?= =?UTF-8?q?=E5=AF=BC=E4=BA=BA=E5=91=98=E7=AE=A1=E7=90=86=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 2 +- backend/package/yuxi/knowledge/manager.py | 9 +- .../package/yuxi/knowledge/utils/kb_utils.py | 23 ++ backend/package/yuxi/permissions/__init__.py | 2 + .../yuxi/permissions/resource_permission.py | 26 ++- .../server/routers/knowledge_eval_router.py | 2 +- backend/server/routers/knowledge_router.py | 51 +++-- backend/server/utils/knowledge_permissions.py | 21 +- .../api/test_personal_knowledge_api.py | 213 ++++++++++++++++++ backend/test/unit/knowledge/test_kb_utils.py | 38 ++++ .../permissions/test_resource_permission.py | 19 +- .../routers/test_knowledge_router_cleanup.py | 42 +++- .../test_knowledge_workspace_import.py | 40 +++- .../2026-09-12-personal-knowledge.md | 33 +++ web/src/apis/knowledge_api.js | 96 ++++---- web/src/components/EmbeddingModelSelector.vue | 4 +- web/src/components/FileUploadModal.vue | 64 +++--- .../knowledge/DatabaseCreateFlowModal.vue | 24 +- web/src/router/index.js | 9 +- web/src/stores/database.js | 33 ++- web/src/stores/user.js | 8 + web/src/utils/databaseCreateForm.js | 7 + web/src/utils/shareConfig.js | 7 +- web/src/views/DataBaseInfoView.vue | 34 ++- web/src/views/DataBaseView.vue | 8 +- web/src/views/ExtensionsView.vue | 15 +- web/test/unit/personalKnowledge.test.js | 211 +++++++++++++++++ 27 files changed, 904 insertions(+), 137 deletions(-) create mode 100644 backend/test/integration/api/test_personal_knowledge_api.py create mode 100644 docs/develop-guides/decisions/implemented/2026-09-12-personal-knowledge.md create mode 100644 web/test/unit/personalKnowledge.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b7c9c77f8..b55020c45 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,7 +74,7 @@ Yuxi 只交付完整知识能力路径。API 始终注册 `external_kb`、`knowl - `composables` 封装请求排队、Run SSE、流式消息、审批、线程状态、提及和其他可组合逻辑。 - `utils` 放轻量转换和展示辅助;全局样式集中在 `assets/css`,颜色和基础规范优先复用 `base.css`。 -`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 对普通用户可见,知识库、工具和 MCP 管理能力仅管理员可见;Dashboard 仅超级管理员可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。 +`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 对普通用户可见,知识库对管理员及具备个人知识管理能力的辅导人员开放,工具和 MCP 管理能力仅管理员可见;Dashboard 仅超级管理员可访问。个人知识库复用创建者及空共享范围,仅所有者可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。 ## 智能体运行链路 diff --git a/backend/package/yuxi/knowledge/manager.py b/backend/package/yuxi/knowledge/manager.py index 795042090..bbb20ec91 100644 --- a/backend/package/yuxi/knowledge/manager.py +++ b/backend/package/yuxi/knowledge/manager.py @@ -207,8 +207,8 @@ def _normalize_share_config( share_config, strict=user_uid is not None or department_id is not None, ) - if normalized["read_scope"] is None and (user_uid is not None or department_id is not None): - raise ValueError("知识库必须设置读取范围") + if normalized["read_scope"] is None and normalized["manage_scope"] is not None: + raise ValueError("共享知识库必须设置读取范围") read_scope = normalized["read_scope"] if read_scope and read_scope["access_level"] == "department" and department_id is not None: read_scope["department_ids"] = sorted({*read_scope["department_ids"], int(department_id)}) @@ -335,10 +335,6 @@ async def check_accessible(self, user: dict, kb_id: str) -> bool: Returns: bool: 是否有权限 """ - # 超级管理员有权访问所有 - if user.get("role") == "superadmin": - return True - from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository kb_repo = KnowledgeBaseRepository() @@ -397,6 +393,7 @@ async def get_databases_by_user(self, user: User | dict) -> list[KnowledgeBaseSu "uid": user.uid, "role": user.role, "department_id": user.department_id, + "business_roles": user.business_roles, } user_role = user_info.get("role") diff --git a/backend/package/yuxi/knowledge/utils/kb_utils.py b/backend/package/yuxi/knowledge/utils/kb_utils.py index 852917d46..39b10e888 100644 --- a/backend/package/yuxi/knowledge/utils/kb_utils.py +++ b/backend/package/yuxi/knowledge/utils/kb_utils.py @@ -68,6 +68,10 @@ async def prepare_item_metadata(item: str, content_type: str, kb_id: str, params kb_id: 数据库ID params: 处理参数,可选 """ + if content_type != "file": + raise ValueError(f"Unsupported content_type: {content_type}") + validate_uploaded_document_source(item, kb_id) + # 检查是否有预处理信息 (针对 URL 转 HTML 文件的情况) if params and "_preprocessed_map" in params and item in params["_preprocessed_map"]: pre_info = params["_preprocessed_map"][item] @@ -83,6 +87,7 @@ async def prepare_item_metadata(item: str, content_type: str, kb_id: str, params file_type = "html" # 强制转换为 html 类型,以便后续作为文件处理 item_path = pre_info["path"] # MinIO path + validate_uploaded_document_source(item_path, kb_id) content_hash = pre_info["content_hash"] # 使用 item(url) 生成 ID,保证同一 URL 即使多次添加 ID 也不同(配合 time) @@ -168,6 +173,24 @@ async def prepare_item_metadata(item: str, content_type: str, kb_id: str, params return metadata +def validate_uploaded_document_source(source: str, kb_id: str) -> None: + """在文件元数据落库前限制来源为当前知识库的上传对象。""" + from yuxi.storage.minio.client import MinIOClient + + if not isinstance(source, str) or not is_minio_url(source): + raise ValueError("File source must be a MinIO URL") + bucket_name, object_name = parse_minio_url(source) + prefix = f"{kb_id}/upload/" + if ( + bucket_name != MinIOClient.KB_BUCKETS["documents"] + or not object_name.startswith(prefix) + or not object_name.removeprefix(prefix) + or "\\" in object_name + or any(part in {".", "..", ""} for part in object_name.split("/")) + ): + raise ValueError("文件来源必须属于当前知识库的上传目录") + + def _normalize_source_path(value: object) -> str | None: """归一化客户端传入的上传源路径,仅用于知识库文件树中的展示文件名。 diff --git a/backend/package/yuxi/permissions/__init__.py b/backend/package/yuxi/permissions/__init__.py index b4aabe0a0..df07bd70c 100644 --- a/backend/package/yuxi/permissions/__init__.py +++ b/backend/package/yuxi/permissions/__init__.py @@ -15,6 +15,7 @@ SKILL_PERMISSION_POLICY, ResourcePermission, ResourcePermissionDenied, + is_personal_knowledge_base, normalize_permission_config, require_knowledge_base_permission, require_resource_permission, @@ -34,6 +35,7 @@ "BusinessRole", "ResourcePermission", "ResourcePermissionDenied", + "is_personal_knowledge_base", "normalize_permission_config", "default_business_roles_for_platform_role", "normalize_business_roles", diff --git a/backend/package/yuxi/permissions/resource_permission.py b/backend/package/yuxi/permissions/resource_permission.py index 6da0f8ae7..3fdce02cf 100644 --- a/backend/package/yuxi/permissions/resource_permission.py +++ b/backend/package/yuxi/permissions/resource_permission.py @@ -207,7 +207,19 @@ def require_resource_permission( def resolve_knowledge_base_permission(user: Any, resource: ShareableResource) -> ResourcePermission: - """解析知识库权限,普通用户最多只能获得只读权限。""" + """个人库仅所有者可访问,共享库继续使用既有角色上限。""" + + if is_personal_knowledge_base(resource): + owner = str(_value(resource, "created_by", "") or "") + if not owner or owner != str(_value(user, "uid", "") or ""): + return ResourcePermission.NONE + from yuxi.permissions.business_roles import BusinessCapability, resolve_business_capabilities + + if _value(user, "role") in {"admin", "superadmin"} or ( + BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE in resolve_business_capabilities(user) + ): + return ResourcePermission.MANAGE + return ResourcePermission.NONE return resolve_resource_permission( user, @@ -216,6 +228,18 @@ def resolve_knowledge_base_permission(user: Any, resource: ShareableResource) -> ) +def is_personal_knowledge_base(resource: ShareableResource) -> bool: + """用既有空共享范围识别仅所有者的个人知识库。""" + + config = _value(resource, "share_config") + return ( + isinstance(config, dict) + and config.get("version") == 2 + and config.get("read_scope") is None + and config.get("manage_scope") is None + ) + + def require_knowledge_base_permission( user: Any, resource: ShareableResource, diff --git a/backend/server/routers/knowledge_eval_router.py b/backend/server/routers/knowledge_eval_router.py index eed8726f6..0b9ca123b 100644 --- a/backend/server/routers/knowledge_eval_router.py +++ b/backend/server/routers/knowledge_eval_router.py @@ -21,7 +21,7 @@ from yuxi.utils import logger -evaluation = APIRouter(prefix="/evaluation", tags=["evaluation"]) +evaluation = APIRouter(prefix="/evaluation", tags=["evaluation"], dependencies=[Depends(get_admin_user)]) class GenerateDatasetRequest(BaseModel): diff --git a/backend/server/routers/knowledge_router.py b/backend/server/routers/knowledge_router.py index 2cc001c89..f70dda92e 100644 --- a/backend/server/routers/knowledge_router.py +++ b/backend/server/routers/knowledge_router.py @@ -18,6 +18,7 @@ from yuxi.knowledge.parser.capabilities import SUPPORTED_FILE_EXTENSIONS, is_supported_file_extension from yuxi.knowledge.runtime import knowledge_base from yuxi.knowledge.utils import calculate_content_hash, is_minio_url, params_for_uploaded_document, parse_minio_url +from yuxi.knowledge.utils.kb_utils import validate_uploaded_document_source from yuxi.knowledge.utils.mindmap_utils import ( batch_remove_files_from_mindmap, generate_database_mindmap, @@ -34,6 +35,7 @@ from yuxi.knowledge.utils.url_fetcher import fetch_url_content from yuxi.permissions import ( ResourcePermission, + is_personal_knowledge_base, resolve_knowledge_base_permission, ) from yuxi.services.knowledge_folder_service import knowledge_folder_service @@ -49,6 +51,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from server.utils.knowledge_response import serialize_knowledge_base, serialize_knowledge_base_list from server.utils.knowledge_permissions import ( + get_knowledge_user, ensure_knowledge_base_permission as _ensure_database_permission, require_knowledge_base_manage, require_knowledge_base_read, @@ -156,8 +159,10 @@ async def _delete_document_storage_objects(kb_id: str, doc_id: str, file_path: s async def _require_manage_permission_if_kb_id(kb_id: str | None, current_user: User) -> None: - """当请求携带 kb_id 时,校验当前用户对该知识库的管理权限。""" - if kb_id and getattr(current_user, "role", None): + """辅导人员上传必须指定知识库,指定后统一校验管理权限。""" + if not kb_id and current_user.role not in {"admin", "superadmin"}: + raise HTTPException(status_code=400, detail="请选择目标知识库") + if kb_id: await _ensure_database_permission(kb_id, current_user, ResourcePermission.MANAGE) @@ -179,7 +184,7 @@ def _ensure_document_params(params: dict | None) -> dict: return params -def _validate_uploaded_document_items(items: list[str], params: dict) -> None: +def _validate_uploaded_document_items(items: list[str], params: dict, kb_id: str) -> None: if not items: raise HTTPException(status_code=400, detail="items must not be empty") @@ -201,6 +206,14 @@ def _validate_uploaded_document_items(items: list[str], params: dict) -> None: if not is_minio_url(item): raise HTTPException(status_code=400, detail="File source must be a MinIO URL") + try: + validate_uploaded_document_source(item, kb_id) + preprocessed = preprocessed_map.get(item) if isinstance(preprocessed_map, dict) else None + if isinstance(preprocessed, dict) and "path" in preprocessed: + validate_uploaded_document_source(preprocessed["path"], kb_id) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + has_content_hash = isinstance(content_hashes, dict) and bool(content_hashes.get(item)) preprocessed = preprocessed_map.get(item) if isinstance(preprocessed_map, dict) else None has_preprocessed_hash = isinstance(preprocessed, dict) and bool(preprocessed.get("content_hash")) @@ -225,7 +238,7 @@ async def _has_running_graph_build_task(kb_id: str) -> bool: @knowledge.get("/databases") -async def get_databases(current_user: User = Depends(get_admin_user)): +async def get_databases(current_user: User = Depends(get_knowledge_user)): """获取所有知识库(根据用户权限过滤)""" try: return serialize_knowledge_base_list(await knowledge_base.get_databases_by_uid(current_user.uid)) @@ -243,9 +256,15 @@ async def create_database( additional_params: dict | None = Body(None), llm_model_spec: str | None = Body(None), share_config: dict | None = Body(None), - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_knowledge_user), ): """创建知识库""" + if current_user.role not in {"admin", "superadmin"}: + if kb_type != "milvus": + raise HTTPException(status_code=403, detail="个人知识库仅支持本地文档知识库") + if share_config is not None and not is_personal_knowledge_base({"share_config": share_config}): + raise HTTPException(status_code=403, detail="个人知识库仅本人可见") + share_config = {"version": 2, "read_scope": None, "manage_scope": None} logger.debug( f"Create database {database_name} with kb_type {kb_type}, " f"additional_params {additional_params}, llm_model_spec {llm_model_spec}, " @@ -409,6 +428,10 @@ async def update_database_info( current_user: User = Depends(require_knowledge_base_manage), ): """更新知识库信息""" + database_info = await knowledge_base.get_database_info(kb_id) + if is_personal_knowledge_base(database_info) and data.share_config is not None: + if not is_personal_knowledge_base({"share_config": data.share_config}): + raise HTTPException(status_code=403, detail="个人知识库不能修改共享范围") logger.debug( f"[update_database_info] 接收到的参数: name={data.name}, llm_model_spec={data.llm_model_spec}, " f"additional_params={data.additional_params}, share_config={data.share_config}" @@ -726,7 +749,7 @@ async def add_documents( if content_type != "file": raise HTTPException(status_code=400, detail=f"Unsupported content_type: {content_type}") - _validate_uploaded_document_items(items, params) + _validate_uploaded_document_items(items, params, kb_id) try: database = await knowledge_base.get_database_info(kb_id) @@ -768,7 +791,7 @@ async def add_uploaded_documents( if content_type != "file": raise HTTPException(status_code=400, detail=f"Unsupported content_type: {content_type}") - _validate_uploaded_document_items(payload.items, params) + _validate_uploaded_document_items(payload.items, params, kb_id) added_items: list[dict] = [] failed_items: list[dict] = [] @@ -1549,7 +1572,7 @@ async def fetch_url( @knowledge.post("/files/import-workspace") async def import_workspace_files( payload: WorkspaceImportRequest, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_knowledge_user), ): """将当前用户工作区文件导入 MinIO,返回与普通文件上传一致的预处理结果。""" kb_id = payload.kb_id.strip() @@ -1613,14 +1636,14 @@ async def import_workspace_files( async def upload_file( file: UploadFile = File(...), kb_id: str | None = Query(None), - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_knowledge_user), ): """上传文件""" if not file.filename: raise HTTPException(status_code=400, detail="No selected file") + await _require_manage_permission_if_kb_id(kb_id, current_user) if kb_id: - await _require_manage_permission_if_kb_id(kb_id, current_user) await _ensure_database_supports_documents(kb_id, "文档上传") logger.debug(f"Received upload file with filename: {file.filename}") @@ -1685,7 +1708,7 @@ async def upload_file( @knowledge.get("/files/supported-types") -async def get_supported_file_types(current_user: User = Depends(get_admin_user)): +async def get_supported_file_types(current_user: User = Depends(get_knowledge_user)): """获取当前支持的文件类型""" return {"message": "success", "file_types": sorted(SUPPORTED_FILE_EXTENSIONS)} @@ -1733,10 +1756,12 @@ async def mark_it_down(file: UploadFile = File(...), current_user: User = Depend @knowledge.get("/types") -async def get_knowledge_base_types(current_user: User = Depends(get_admin_user)): +async def get_knowledge_base_types(current_user: User = Depends(get_knowledge_user)): """获取支持的知识库类型""" try: kb_types = knowledge_base.get_supported_kb_types() + if current_user.role not in {"admin", "superadmin"}: + kb_types = {key: value for key, value in kb_types.items() if key == "milvus"} return {"kb_types": kb_types, "message": "success"} except Exception as e: logger.error(f"获取知识库类型失败 {e}, {traceback.format_exc()}") @@ -1744,7 +1769,7 @@ async def get_knowledge_base_types(current_user: User = Depends(get_admin_user)) @knowledge.get("/chunk-presets") -async def get_knowledge_chunk_presets(current_user: User = Depends(get_admin_user)): +async def get_knowledge_chunk_presets(current_user: User = Depends(get_knowledge_user)): """获取支持的知识库分块策略""" return {"chunk_presets": get_chunk_preset_options(), "message": "success"} diff --git a/backend/server/utils/knowledge_permissions.py b/backend/server/utils/knowledge_permissions.py index 3c7ac418c..007897557 100644 --- a/backend/server/utils/knowledge_permissions.py +++ b/backend/server/utils/knowledge_permissions.py @@ -2,17 +2,28 @@ from fastapi import Depends, HTTPException -from server.utils.auth_middleware import get_admin_user +from server.utils.auth_middleware import get_required_user from yuxi.knowledge.read_models import KnowledgeBaseDetail from yuxi.knowledge.runtime import knowledge_base from yuxi.permissions import ( ResourcePermission, + BusinessCapability, + resolve_business_capabilities, ResourcePermissionDenied, require_knowledge_base_permission, ) from yuxi.storage.postgres.models_business import User +async def get_knowledge_user(current_user: User = Depends(get_required_user)) -> User: + """允许既有管理员及具备个人知识管理能力的辅导人员进入知识管理。""" + if current_user.role not in {"admin", "superadmin"} and ( + BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE not in resolve_business_capabilities(current_user) + ): + raise HTTPException(status_code=403, detail="需要个人知识库管理权限") + return current_user + + async def ensure_knowledge_base_permission( kb_id: str, current_user: User, @@ -33,9 +44,9 @@ async def ensure_knowledge_base_permission( async def require_knowledge_base_read( kb_id: str, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_knowledge_user), ) -> User: - """校验管理员对指定知识库的读取权限。""" + """校验当前用户对指定知识库的读取权限。""" await ensure_knowledge_base_permission(kb_id, current_user, ResourcePermission.READ) return current_user @@ -43,9 +54,9 @@ async def require_knowledge_base_read( async def require_knowledge_base_manage( kb_id: str, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_knowledge_user), ) -> User: - """校验管理员对指定知识库的管理权限。""" + """校验当前用户对指定知识库的管理权限。""" await ensure_knowledge_base_permission(kb_id, current_user, ResourcePermission.MANAGE) return current_user diff --git a/backend/test/integration/api/test_personal_knowledge_api.py b/backend/test/integration/api/test_personal_knowledge_api.py new file mode 100644 index 000000000..05850a2f4 --- /dev/null +++ b/backend/test/integration/api/test_personal_knowledge_api.py @@ -0,0 +1,213 @@ +"""通过真实 HTTP、PostgreSQL 与对象存储验证个人知识库隔离。""" + +from __future__ import annotations + +import json +import os +import uuid + +import asyncpg +import pytest + +from yuxi.utils.auth_utils import AuthUtils + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_personal_knowledge_two_users_and_files_are_isolated(test_client) -> None: + """辅导人员可维护私库,其他账号和伪造文件来源均不能越权。""" + suffix = uuid.uuid4().hex[:10] + department_name = f"pytest_personal_kb_{suffix}" + connection = await asyncpg.connect(os.environ["POSTGRES_URL"].replace("+asyncpg", "")) + actors = {} + databases = [] + try: + department_id = await connection.fetchval( + "INSERT INTO departments (name, description) VALUES ($1, 'personal knowledge test') RETURNING id", + department_name, + ) + for actor, role, roles in ( + ("a", "user", ["counselor"]), + ("b", "user", ["counselor"]), + ("technical", "superadmin", ["technical_admin"]), + ("no_capability", "user", []), + ): + uid = f"personal_{actor}_{suffix}" + user_id = await connection.fetchval( + """ + INSERT INTO users + (username, uid, password_hash, role, business_roles, department_id, + login_failed_count, is_deleted, created_at) + VALUES ($1, $1, 'test', $2, $3::jsonb, $4, 0, 0, NOW()) RETURNING id + """, + uid, + role, + json.dumps(roles), + department_id, + ) + actors[actor] = { + "id": user_id, + "uid": uid, + "headers": {"Authorization": f"Bearer {AuthUtils.create_access_token({'sub': str(user_id)})}"}, + } + + payload = { + "database_name": f"pytest_personal_denied_{suffix}", + "description": "private knowledge integration test", + "embedding_model_spec": "siliconflow-cn:Pro/BAAI/bge-m3", + "kb_type": "milvus", + } + denied = await test_client.post( + "/api/knowledge/databases", json=payload, headers=actors["no_capability"]["headers"] + ) + assert denied.status_code == 403, denied.text + for extra in ( + {"share_config": {"version": 2, "read_scope": {"access_level": "global"}, "manage_scope": None}}, + {"kb_type": "dify"}, + ): + denied = await test_client.post( + "/api/knowledge/databases", json={**payload, **extra}, headers=actors["a"]["headers"] + ) + assert denied.status_code == 403, denied.text + assert await connection.fetchval( + "SELECT COUNT(*) FROM knowledge_bases WHERE name = $1", payload["database_name"] + ) == 0 + for actor in ("a", "b"): + response = await test_client.post( + "/api/knowledge/databases", + json={**payload, "database_name": f"pytest_personal_{actor}_{suffix}"}, + headers=actors[actor]["headers"], + ) + assert response.status_code == 200, response.text + kb_id = response.json()["kb_id"] + actors[actor]["kb_id"] = kb_id + databases.append((kb_id, actors[actor]["headers"])) + record = await connection.fetchrow( + "SELECT created_by, share_config FROM knowledge_bases WHERE kb_id = $1", kb_id + ) + assert record["created_by"] == actors[actor]["uid"] + assert json.loads(record["share_config"]) == {"version": 2, "read_scope": None, "manage_scope": None} + + a_id, b_id = actors["a"]["kb_id"], actors["b"]["kb_id"] + a_headers, b_headers = actors["a"]["headers"], actors["b"]["headers"] + update = {"name": f"pytest_personal_updated_{suffix}", "description": "updated personal notes"} + response = await test_client.put(f"/api/knowledge/databases/{a_id}", json=update, headers=a_headers) + assert response.status_code == 200, response.text + assert ( + await connection.fetchval("SELECT description FROM knowledge_bases WHERE kb_id = $1", a_id) + == update["description"] + ) + content = f"private counselor A notes {suffix}".encode() + uploaded = await test_client.post( + "/api/knowledge/files/upload", + params={"kb_id": a_id}, + files={"file": ("private.txt", content, "text/plain")}, + headers=a_headers, + ) + assert uploaded.status_code == 200, uploaded.text + source = uploaded.json()["file_path"] + content_hash = uploaded.json()["content_hash"] + document_payload = {"items": [source], "params": {"content_hashes": {source: content_hash}}} + added = await test_client.post( + f"/api/knowledge/databases/{a_id}/documents/add", json=document_payload, headers=a_headers + ) + assert added.status_code == 200 and added.json()["status"] == "success", added.text + file_id = added.json()["items"][0]["file_id"] + persisted_file = await connection.fetchrow( + "SELECT kb_id, created_by, path, status FROM knowledge_files WHERE file_id = $1", file_id + ) + assert dict(persisted_file) == { + "kb_id": a_id, + "created_by": actors["a"]["uid"], + "path": source, + "status": "uploaded", + } + downloaded = await test_client.get( + f"/api/knowledge/databases/{a_id}/documents/{file_id}/download", headers=a_headers + ) + assert downloaded.status_code == 200 and downloaded.content == content + + swapped_base = f"/api/knowledge/databases/{b_id}/documents/{file_id}" + swapped_basic = await test_client.get(f"{swapped_base}/basic", headers=b_headers) + assert swapped_basic.status_code == 200 and swapped_basic.json() == { + "message": "Failed to get file basic info", "status": "failed" + }, swapped_basic.text + swapped_download = await test_client.get(f"{swapped_base}/download", headers=b_headers) + assert swapped_download.status_code == 500, swapped_download.text + assert "not found" in swapped_download.json()["detail"] + swapped_delete = await test_client.delete(swapped_base, headers=b_headers) + assert swapped_delete.status_code == 400, swapped_delete.text + assert "not found" in swapped_delete.json()["detail"] + assert await connection.fetchval("SELECT kb_id FROM knowledge_files WHERE file_id = $1", file_id) == a_id + downloaded = await test_client.get( + f"/api/knowledge/databases/{a_id}/documents/{file_id}/download", headers=a_headers + ) + assert downloaded.status_code == 200 and downloaded.content == content + + for actor in ("a", "b", "technical"): + listed = await test_client.get("/api/knowledge/databases", headers=actors[actor]["headers"]) + assert listed.status_code == 200, listed.text + visible = {row["kb_id"] for row in listed.json()["databases"]} + assert (a_id in visible) == (actor == "a") + assert (b_id in visible) == (actor == "b") + + forbidden_reads = [ + f"/api/knowledge/databases/{a_id}", + f"/api/knowledge/databases/{a_id}/documents/{file_id}/basic", + f"/api/knowledge/databases/{a_id}/documents/{file_id}/download", + f"/api/graph/subgraph?kb_id={a_id}", + f"/api/workspace/knowledge/tree?kb_id={a_id}", + f"/api/workspace/knowledge/file?kb_id={a_id}&file_id={file_id}", + f"/api/workspace/knowledge/download?kb_id={a_id}&file_id={file_id}", + ] + for actor in ("b", "technical"): + for endpoint in forbidden_reads: + response = await test_client.get(endpoint, headers=actors[actor]["headers"]) + assert response.status_code == 403, f"{actor} {endpoint}: {response.status_code} {response.text}" + response = await test_client.put(f"/api/knowledge/databases/{a_id}", json=update, headers=b_headers) + assert response.status_code == 403, response.text + response = await test_client.post( + f"/api/knowledge/databases/{a_id}/query", json={"query": "notes", "meta": {}}, headers=b_headers + ) + assert response.status_code == 403, response.text + response = await test_client.delete(f"/api/knowledge/databases/{a_id}", headers=b_headers) + assert response.status_code == 403, response.text + response = await test_client.put( + f"/api/knowledge/databases/{a_id}", + headers=a_headers, + json={ + **update, + "share_config": {"version": 2, "read_scope": {"access_level": "global"}, "manage_scope": None}, + }, + ) + assert response.status_code == 403, response.text + + for route in ("documents", "documents/add"): + response = await test_client.post( + f"/api/knowledge/databases/{b_id}/{route}", json=document_payload, headers=b_headers + ) + assert response.status_code == 400, response.text + fake_source = f"minio://knowledgebases/{b_id}/upload/fake.html" + response = await test_client.post( + f"/api/knowledge/databases/{b_id}/{route}", + headers=b_headers, + json={ + "items": [fake_source], + "params": {"_preprocessed_map": {fake_source: {"path": source, "content_hash": content_hash}}}, + }, + ) + assert response.status_code == 400, response.text + assert await connection.fetchval("SELECT COUNT(*) FROM knowledge_files WHERE kb_id = $1", b_id) == 0 + assert json.loads( + await connection.fetchval("SELECT share_config FROM knowledge_bases WHERE kb_id = $1", a_id) + ) == {"version": 2, "read_scope": None, "manage_scope": None} + finally: + for kb_id, headers in reversed(databases): + response = await test_client.delete(f"/api/knowledge/databases/{kb_id}", headers=headers) + assert response.status_code == 200, response.text + ids = [actor["id"] for actor in actors.values()] + if ids: + await connection.execute("DELETE FROM operation_logs WHERE user_id = ANY($1::integer[])", ids) + await connection.execute("DELETE FROM users WHERE id = ANY($1::integer[])", ids) + await connection.execute("DELETE FROM departments WHERE name = $1", department_name) + await connection.close() diff --git a/backend/test/unit/knowledge/test_kb_utils.py b/backend/test/unit/knowledge/test_kb_utils.py index e2c1b4f79..b930cbbae 100644 --- a/backend/test/unit/knowledge/test_kb_utils.py +++ b/backend/test/unit/knowledge/test_kb_utils.py @@ -70,3 +70,41 @@ async def test_prepare_item_metadata_preserves_preprocessed_file_size(): async def test_prepare_item_metadata_rejects_direct_url_content_type(): with pytest.raises(ValueError, match="Unsupported content_type"): await prepare_item_metadata("https://example.com", "url", "db") + + +@pytest.mark.parametrize( + "source", + [ + "minio://knowledgebases/other/upload/private.txt", + "minio://knowledgebases/db-other/upload/private.txt", + "minio://kb-images/db/upload/private.txt", + "minio://knowledgebases/db/parsed/private.txt", + "minio://knowledgebases/db/upload/%2e%2e/other.txt", + "http://minio:9000/knowledgebases/other/upload/private.txt", + ], +) +async def test_prepare_item_metadata_rejects_foreign_document_sources(source): + with pytest.raises(ValueError, match="当前知识库的上传目录"): + await prepare_item_metadata(source, "file", "db", params={"content_hashes": {source: "hash"}}) + + +async def test_prepare_item_metadata_rejects_preprocessed_foreign_path(): + item = "minio://knowledgebases/db/upload/page.html" + params = { + "_preprocessed_map": { + item: { + "path": "minio://knowledgebases/other/upload/private.html", + "content_hash": "hash", + } + } + } + with pytest.raises(ValueError, match="当前知识库的上传目录"): + await prepare_item_metadata(item, "file", "db", params=params) + + +async def test_prepare_item_metadata_accepts_http_uploaded_document_source(): + item = "http://minio:9000/knowledgebases/db/upload/my%20notes.md" + metadata = await prepare_item_metadata(item, "file", "db", params={"content_hashes": {item: "hash"}}) + assert metadata["path"] == item + assert metadata["kb_id"] == "db" + assert metadata["filename"] == "my notes.md" diff --git a/backend/test/unit/permissions/test_resource_permission.py b/backend/test/unit/permissions/test_resource_permission.py index ccf219ba3..25e72e01e 100644 --- a/backend/test/unit/permissions/test_resource_permission.py +++ b/backend/test/unit/permissions/test_resource_permission.py @@ -100,12 +100,27 @@ def test_user_agent_and_skill_scope_preserves_user_management(): assert resolve_skill_permission(_user(), resource) == ResourcePermission.MANAGE -def test_knowledge_base_owner_and_superadmin_can_manage(): +def test_personal_knowledge_base_only_owner_can_manage(): resource = _resource(created_by="owner", share_config={"version": 2}) assert resolve_knowledge_base_permission(_user(uid="owner"), resource) == ResourcePermission.MANAGE assert resolve_knowledge_base_permission(_user(uid="owner", role="admin"), resource) == ResourcePermission.MANAGE - assert resolve_knowledge_base_permission(_user(role="superadmin"), resource) == ResourcePermission.MANAGE + assert resolve_knowledge_base_permission(_user(role="superadmin"), resource) == ResourcePermission.NONE + assert resolve_knowledge_base_permission(_user(uid="other"), resource) == ResourcePermission.NONE + + +def test_personal_knowledge_base_requires_owner_business_capability(): + resource = _resource(created_by="owner", share_config={"version": 2, "read_scope": None, "manage_scope": None}) + owner = _user(uid="owner") + owner.business_roles = [] + assert resolve_knowledge_base_permission(owner, resource) == ResourcePermission.NONE + owner.business_roles = ["counselor"] + assert resolve_knowledge_base_permission(owner, resource) == ResourcePermission.MANAGE + + +def test_personal_knowledge_base_without_owner_is_inaccessible(): + resource = _resource(created_by="", share_config={"version": 2}) + assert resolve_knowledge_base_permission(_user(uid=""), resource) == ResourcePermission.NONE def test_global_knowledge_base_share_remains_manage_for_admin(): diff --git a/backend/test/unit/routers/test_knowledge_router_cleanup.py b/backend/test/unit/routers/test_knowledge_router_cleanup.py index 2ead39262..447c33233 100644 --- a/backend/test/unit/routers/test_knowledge_router_cleanup.py +++ b/backend/test/unit/routers/test_knowledge_router_cleanup.py @@ -8,10 +8,21 @@ from yuxi.knowledge.read_models import KnowledgeBaseDetail from yuxi.services import knowledge_task_service from yuxi.services.task_registry import get_task_definition +from yuxi.permissions import ResourcePermission pytestmark = pytest.mark.asyncio +@pytest.fixture +def authorized_database_manager(monkeypatch): + """隔离上传内容验证测试所不涉及的知识库授权查询。""" + async def allow_manage(kb_id, current_user, required): + assert required == ResourcePermission.MANAGE + return _database_detail() + + monkeypatch.setattr(knowledge_router, "_ensure_database_permission", allow_manage) + + def _database_detail(**stats) -> KnowledgeBaseDetail: return KnowledgeBaseDetail( kb_id="kb_1", @@ -108,7 +119,7 @@ async def test_upload_file_rejects_jsonl_uploads(): upload = UploadFile(filename="dataset.jsonl", file=BytesIO(b'{"query":"hello"}\n')) with pytest.raises(HTTPException) as exc_info: - await knowledge_router.upload_file(upload, kb_id=None, current_user=SimpleNamespace(uid="user_1")) + await knowledge_router.upload_file(upload, kb_id=None, current_user=SimpleNamespace(uid="user_1", role="admin")) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "Unsupported file type: .jsonl" @@ -122,7 +133,7 @@ async def test_upload_file_rejects_jsonl_uploads(): ], ids=["upload_file", "mark_it_down"], ) -async def test_rejects_oversized_file(monkeypatch, call_upload): +async def test_rejects_oversized_file(monkeypatch, call_upload, authorized_database_manager): monkeypatch.setattr(knowledge_router, "MAX_UPLOAD_SIZE_BYTES", 5) async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None: @@ -150,7 +161,9 @@ async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> ("readonly", 400, "只支持检索,不支持文档上传"), ], ) -async def test_upload_file_fails_before_read_or_minio(monkeypatch, kb_id, status_code, error_detail): +async def test_upload_file_fails_before_read_or_minio( + monkeypatch, kb_id, status_code, error_detail, authorized_database_manager +): calls = {"read": 0, "upload": 0} async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None: @@ -181,6 +194,29 @@ async def fake_upload_to_minio(*_args, **_kwargs) -> str: assert calls == {"read": 0, "upload": 0} +@pytest.mark.parametrize("kb_id", [None, "private_other"]) +async def test_counselor_upload_rejects_missing_or_foreign_kb_before_read(monkeypatch, kb_id): + """没有目标库或无所有权时,不能读取上传内容及写入对象存储。""" + async def deny_manage(target, current_user, required): + assert target == "private_other" + assert required == ResourcePermission.MANAGE + raise HTTPException(status_code=403, detail="无权操作该知识库") + + async def forbidden_side_effect(*args, **kwargs): + pytest.fail("授权拒绝后不得读取文件或写入对象存储") + + monkeypatch.setattr(knowledge_router, "_ensure_database_permission", deny_manage) + monkeypatch.setattr(knowledge_router, "read_upload_with_limit", forbidden_side_effect) + monkeypatch.setattr(knowledge_router, "aupload_file_to_minio", forbidden_side_effect) + with pytest.raises(HTTPException) as exc_info: + await knowledge_router.upload_file( + UploadFile(filename="notes.txt", file=BytesIO(b"private")), + kb_id=kb_id, + current_user=SimpleNamespace(uid="counselor", role="user", business_roles=["counselor"]), + ) + assert exc_info.value.status_code == (400 if kb_id is None else 403) + + async def test_index_documents_uses_uid_for_operator(monkeypatch): captured = {} diff --git a/backend/test/unit/routers/test_knowledge_workspace_import.py b/backend/test/unit/routers/test_knowledge_workspace_import.py index bf2e1596b..223c898a1 100644 --- a/backend/test/unit/routers/test_knowledge_workspace_import.py +++ b/backend/test/unit/routers/test_knowledge_workspace_import.py @@ -6,11 +6,22 @@ from fastapi import HTTPException from server.routers import knowledge_router +from yuxi.permissions import ResourcePermission pytestmark = pytest.mark.asyncio -async def test_import_workspace_files_uploads_workspace_file_to_minio(monkeypatch): +@pytest.fixture +def authorized_database_manager(monkeypatch): + """隔离工作区文件读取测试所不涉及的知识库授权查询。""" + async def allow_manage(kb_id, current_user, required): + assert kb_id == "db_1" + assert required == ResourcePermission.MANAGE + + monkeypatch.setattr(knowledge_router, "_ensure_database_permission", allow_manage) + + +async def test_import_workspace_files_uploads_workspace_file_to_minio(monkeypatch, authorized_database_manager): async def fake_ensure_database_supports_documents(slug: str, operation: str) -> None: assert slug == "db_1" assert "文档添加" in operation @@ -37,7 +48,7 @@ async def fake_upload(bucket_name: str, file_name: str, data: bytes) -> str: fake_ensure_database_supports_documents, ) - user = SimpleNamespace(id="user_1") + user = SimpleNamespace(id="user_1", uid="user_1", role="admin") async def fake_read_workspace_file_bytes(*, path, current_user): assert path == "/note.md" @@ -66,7 +77,7 @@ async def fake_read_workspace_file_bytes(*, path, current_user): assert item["workspace_path"] == "/note.md" -async def test_import_workspace_files_rejects_directory(monkeypatch): +async def test_import_workspace_files_rejects_directory(monkeypatch, authorized_database_manager): async def fake_ensure_database_supports_documents(slug: str, operation: str) -> None: return None @@ -83,8 +94,29 @@ async def fake_read_workspace_file_bytes(**_kwargs): with pytest.raises(HTTPException) as exc_info: await knowledge_router.import_workspace_files( knowledge_router.WorkspaceImportRequest(kb_id="db_1", paths=["/folder"]), - current_user=SimpleNamespace(id="user_1"), + current_user=SimpleNamespace(id="user_1", uid="user_1", role="admin"), ) assert exc_info.value.status_code == 400 assert "不是文件" in exc_info.value.detail + + +async def test_import_workspace_files_checks_owner_before_file_access(monkeypatch): + """他人知识库不能通过工作区导入入口产生文件读取或写入。""" + async def deny_manage(kb_id, current_user, required): + assert kb_id == "private_other" + assert required == ResourcePermission.MANAGE + raise HTTPException(status_code=403, detail="无权操作该知识库") + + async def forbidden_side_effect(*args, **kwargs): + pytest.fail("授权拒绝后不得读取工作区或写入对象存储") + + monkeypatch.setattr(knowledge_router, "_ensure_database_permission", deny_manage) + monkeypatch.setattr(knowledge_router, "read_workspace_file_bytes", forbidden_side_effect) + monkeypatch.setattr(knowledge_router, "aupload_file_to_minio", forbidden_side_effect) + with pytest.raises(HTTPException) as exc_info: + await knowledge_router.import_workspace_files( + knowledge_router.WorkspaceImportRequest(kb_id="private_other", paths=["/note.md"]), + current_user=SimpleNamespace(uid="counselor", role="user", business_roles=["counselor"]), + ) + assert exc_info.value.status_code == 403 diff --git a/docs/develop-guides/decisions/implemented/2026-09-12-personal-knowledge.md b/docs/develop-guides/decisions/implemented/2026-09-12-personal-knowledge.md new file mode 100644 index 000000000..63b0dff43 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-12-personal-knowledge.md @@ -0,0 +1,33 @@ +# 辅导人员个人知识库 + +状态:implemented +类型:feature +Owner:backend/package/yuxi/permissions/resource_permission.py + +## 问题 + +辅导人员已有个人知识管理业务能力,但知识页面及 HTTP 依赖要求平台管理员。创建者归属已经持久化,文件维护及检索复用同一知识库权限解析。 + +## 决策 + +复用 created_by 与 version 2 的空读取、管理范围表示仅所有者的个人库,不新增表。具有 knowledge.personal.manage 能力的用户可创建和维护个人库;既有管理员共享库流程保留。个人库禁止改为共享,其他账号包含超级管理员无法读取。普通用户不得通过创建参数发布共享库。开放必要页面和资源接口,保留全局统计及无归属处理接口的管理限制。上传资料与挂载文件均验证目标知识库归属。 + +## 替代方案 + +另建个人库表会重复知识与文件机制;仅移除前端管理员限制无法闭合 HTTP 授权;提升全部普通用户的管理权限会扩大既有共享库权限。 + +## 后果 + +原仅所有者配置的知识库也执行严格所有者隔离。索引及模型问答不在无真实模型验收范围;本批验证文件上传、持久化和授权,不用模拟回答替代生成验证。 + +普通辅导人员创建入口仅开放 Milvus 文档库。评估、全局统计修复、URL 抓取及无归属文档转换保持管理员限制。前端在账号切换时清空知识库缓存,并丢弃旧身份的在途响应。 + +## 验证 + +真实 HTTP 测试 `test/integration/api/test_personal_knowledge_api.py` 通过。两个辅导账号分别创建个人库,PostgreSQL 回读创建者、私有配置及文件记录,对象下载内容与上传原文一致;无业务能力创建、发布共享配置、非 Milvus 创建、他人读写、技术管理员读取、修改共享、跨库文件地址及预处理路径均被拒绝。使用本人库 ID 搭配他人文件 ID 的查看、下载和删除失败,原文件保持完整。 + +临时辅导账号的浏览器验收完成知识库入口、创建、详情、描述更新和保存后回读;权限页截图显示“仅本人可见”,没有共享编辑控件。管理员专用统计修复按钮不向辅导人员渲染。测试账号及所属测试资料在验收后清理。 + +后端 `uv run --group test pytest test/unit -m "not slow"` 通过:1953 passed、53 skipped。工程契约检查及脚本自测通过(62 tests);前端 lint、build 和个人库专项测试通过(7 tests);文档使用已安装的 `vitepress build` 构建通过。 + +完整前端 `pnpm run test:unit` 执行 325 项,324 通过;Dashboard 搜索参数案例因扫描图标目录时 `ENOMEM` 失败,使用 `node --test --test-name-pattern getConversations test/unit/dashboard_thread_stats.test.js` 定向复跑通过。全量执行结果不记为全绿,失败案例复跑及个人库专项提供补充证据。 diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index 08ea9c058..c7e71dc0d 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -1,8 +1,10 @@ import { apiGet, + apiPost, + apiPut, + apiDelete, apiAdminGet, apiAdminPost, - apiAdminPut, apiAdminDelete, apiRequest, buildQuery @@ -23,7 +25,7 @@ export const databaseApi = { * @returns {Promise} - 知识库列表 */ getDatabases: async () => { - return apiAdminGet('/api/knowledge/databases') + return apiGet('/api/knowledge/databases') }, /** @@ -32,7 +34,7 @@ export const databaseApi = { * @returns {Promise} - 创建结果 */ createDatabase: async (databaseData) => { - return apiAdminPost('/api/knowledge/databases', databaseData) + return apiPost('/api/knowledge/databases', databaseData) }, /** @@ -41,7 +43,7 @@ export const databaseApi = { * @returns {Promise} - 知识库信息 */ getDatabaseInfo: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}`) + return apiGet(`/api/knowledge/databases/${kbId}`) }, /** @@ -76,7 +78,7 @@ export const databaseApi = { * @returns {Promise} - 更新结果 */ updateDatabase: async (kbId, updateData) => { - return apiAdminPut(`/api/knowledge/databases/${kbId}`, updateData) + return apiPut(`/api/knowledge/databases/${kbId}`, updateData) }, /** @@ -85,7 +87,7 @@ export const databaseApi = { * @returns {Promise} - 删除结果 */ deleteDatabase: async (kbId) => { - return apiAdminDelete(`/api/knowledge/databases/${kbId}`) + return apiDelete(`/api/knowledge/databases/${kbId}`) }, /** @@ -125,14 +127,12 @@ export const documentApi = { */ listDocuments: async (kbId, params = {}) => { const query = buildQuery(params) - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents${query ? `?${query}` : ''}`) + return apiGet(`/api/knowledge/databases/${kbId}/documents${query ? `?${query}` : ''}`) }, searchDocuments: async (kbId, params = {}) => { const query = buildQuery(params) - return apiAdminGet( - `/api/knowledge/databases/${kbId}/documents/search${query ? `?${query}` : ''}` - ) + return apiGet(`/api/knowledge/databases/${kbId}/documents/search${query ? `?${query}` : ''}`) }, /** @@ -143,7 +143,7 @@ export const documentApi = { */ documentExists: async (kbId, filename) => { const query = buildQuery({ filename }) - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/exists?${query}`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/exists?${query}`) }, /** @@ -154,20 +154,20 @@ export const documentApi = { * @returns {Promise} - 创建结果 */ createFolder: async (kbId, folderName, parentId = null) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/folders`, { + return apiPost(`/api/knowledge/databases/${kbId}/folders`, { folder_name: folderName, parent_id: parentId }) }, renameFolder: async (kbId, folderId, folderName) => { - return apiAdminPut(`/api/knowledge/databases/${kbId}/folders/${folderId}/rename`, { + return apiPut(`/api/knowledge/databases/${kbId}/folders/${folderId}/rename`, { folder_name: folderName }) }, moveDocument: async (kbId, documentId, newParentId) => { - return apiAdminPut(`/api/knowledge/databases/${kbId}/documents/${documentId}/move`, { + return apiPut(`/api/knowledge/databases/${kbId}/documents/${documentId}/move`, { new_parent_id: newParentId }) }, @@ -180,7 +180,7 @@ export const documentApi = { * @returns {Promise} - 添加结果 */ addDocuments: async (kbId, items, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents`, { items, params }) @@ -194,7 +194,7 @@ export const documentApi = { * @returns {Promise} - 添加结果 */ addUploadedDocuments: async (kbId, items, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/add`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/add`, { items, params }) @@ -207,7 +207,7 @@ export const documentApi = { * @returns {Promise} - 文档信息 */ getDocumentInfo: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}`) }, /** @@ -217,7 +217,7 @@ export const documentApi = { * @returns {Promise} - 文档基本信息 */ getDocumentBasicInfo: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/basic`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}/basic`) }, /** @@ -227,7 +227,7 @@ export const documentApi = { * @returns {Promise} - 文档内容信息 */ getDocumentContent: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/content`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}/content`) }, /** @@ -237,7 +237,7 @@ export const documentApi = { * @returns {Promise} - 删除结果 */ deleteDocument: async (kbId, docId) => { - return apiAdminDelete(`/api/knowledge/databases/${kbId}/documents/${docId}`) + return apiDelete(`/api/knowledge/databases/${kbId}/documents/${docId}`) }, /** @@ -268,7 +268,7 @@ export const documentApi = { * @returns {Promise} - Response对象 */ downloadDocument: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/download`, {}, 'blob') + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}/download`, {}, true, 'blob') }, /** @@ -279,7 +279,7 @@ export const documentApi = { * @returns {Promise} - 解析任务结果 */ parseDocuments: async (kbId, fileIds, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/parse`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/parse`, { file_ids: fileIds, params }) @@ -292,7 +292,7 @@ export const documentApi = { * @returns {Promise} - 解析任务结果 */ parsePendingDocuments: async (kbId, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/parse-pending`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/parse-pending`, { params }) }, @@ -305,7 +305,7 @@ export const documentApi = { * @returns {Promise} - 入库任务结果 */ indexDocuments: async (kbId, fileIds, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/index`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/index`, { file_ids: fileIds, params }) @@ -318,7 +318,7 @@ export const documentApi = { * @returns {Promise} - 入库任务结果 */ indexPendingDocuments: async (kbId, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/index-pending`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/index-pending`, { params }) } @@ -334,27 +334,27 @@ function graphBuildUrl(kbId, action) { export const graphBuildApi = { getStatus: async (kbId) => { - return apiAdminGet(graphBuildUrl(kbId, 'status')) + return apiGet(graphBuildUrl(kbId, 'status')) }, getFailedChunks: async (kbId, limit = 10) => { - return apiAdminGet(`${graphBuildUrl(kbId, 'failed-chunks')}?limit=${limit}`) + return apiGet(`${graphBuildUrl(kbId, 'failed-chunks')}?limit=${limit}`) }, configure: async (kbId, data) => { - return apiAdminPost(graphBuildUrl(kbId, 'config'), data) + return apiPost(graphBuildUrl(kbId, 'config'), data) }, startIndex: async (kbId) => { - return apiAdminPost(graphBuildUrl(kbId, 'index'), {}) + return apiPost(graphBuildUrl(kbId, 'index'), {}) }, reset: async (kbId, data) => { - return apiAdminPost(graphBuildUrl(kbId, 'reset'), data) + return apiPost(graphBuildUrl(kbId, 'reset'), data) }, reconcile: async (kbId, mode = 'failed') => { - return apiAdminPost(graphBuildUrl(kbId, 'reconcile'), { mode }) + return apiPost(graphBuildUrl(kbId, 'reconcile'), { mode }) } } @@ -364,15 +364,15 @@ export const graphBuildApi = { export const mindmapApi = { getDatabases: async () => { - return apiAdminGet('/api/knowledge/mindmap/databases') + return apiGet('/api/knowledge/mindmap/databases') }, getDatabaseFiles: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap/files`) + return apiGet(`/api/knowledge/databases/${kbId}/mindmap/files`) }, generateMindmap: async (kbId, fileIds = [], userPrompt = '', incremental = false) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/mindmap/generate`, { + return apiPost(`/api/knowledge/databases/${kbId}/mindmap/generate`, { file_ids: fileIds, user_prompt: userPrompt, incremental @@ -380,11 +380,11 @@ export const mindmapApi = { }, getByDatabase: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap`) + return apiGet(`/api/knowledge/databases/${kbId}/mindmap`) }, getDiff: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap/diff`) + return apiGet(`/api/knowledge/databases/${kbId}/mindmap/diff`) } } @@ -401,7 +401,7 @@ export const queryApi = { * @returns {Promise} - 查询结果 */ queryKnowledgeBase: async (kbId, query, meta = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/query`, { + return apiPost(`/api/knowledge/databases/${kbId}/query`, { query, meta }) @@ -415,7 +415,7 @@ export const queryApi = { * @returns {Promise} - 测试结果 */ queryTest: async (kbId, query, meta = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/query-test`, { + return apiPost(`/api/knowledge/databases/${kbId}/query-test`, { query, meta }) @@ -427,7 +427,7 @@ export const queryApi = { * @returns {Promise} - 查询参数 */ getKnowledgeBaseQueryParams: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/query-params`) + return apiGet(`/api/knowledge/databases/${kbId}/query-params`) }, /** @@ -437,7 +437,7 @@ export const queryApi = { * @returns {Promise} - 更新结果 */ updateKnowledgeBaseQueryParams: async (kbId, params) => { - return apiAdminPut(`/api/knowledge/databases/${kbId}/query-params`, params) + return apiPut(`/api/knowledge/databases/${kbId}/query-params`, params) }, /** @@ -447,7 +447,7 @@ export const queryApi = { * @returns {Promise} - 生成的问题列表 */ generateSampleQuestions: async (kbId, count = 10) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/sample-questions`, { + return apiPost(`/api/knowledge/databases/${kbId}/sample-questions`, { count }) }, @@ -458,7 +458,7 @@ export const queryApi = { * @returns {Promise} - 问题列表 */ getSampleQuestions: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/sample-questions`) + return apiGet(`/api/knowledge/databases/${kbId}/sample-questions`) } } @@ -499,7 +499,7 @@ export const fileApi = { * @returns {Promise} - 导入结果 */ importWorkspaceFiles: async (kbId, paths) => { - return apiAdminPost('/api/knowledge/files/import-workspace', { + return apiPost('/api/knowledge/files/import-workspace', { kb_id: kbId, paths }) @@ -515,7 +515,7 @@ export const fileApi = { const formData = new FormData() formData.append('file', file) - return apiAdminPost(fileApi.getUploadUrl(kbId), formData) + return apiPost(fileApi.getUploadUrl(kbId), formData) }, /** @@ -523,7 +523,7 @@ export const fileApi = { * @returns {Promise} - 文件类型列表 */ getSupportedFileTypes: async () => { - return apiAdminGet('/api/knowledge/files/supported-types') + return apiGet('/api/knowledge/files/supported-types') }, /** @@ -558,7 +558,7 @@ export const fileApi = { * @returns {Promise} - 处理任务结果 */ processFolder: async ({ file_path, kb_id, content_hash }) => { - return apiAdminPost('/api/knowledge/files/process-folder', { + return apiPost('/api/knowledge/files/process-folder', { file_path, kb_id, content_hash @@ -576,7 +576,7 @@ export const typeApi = { * @returns {Promise} - 知识库类型列表 */ getKnowledgeBaseTypes: async () => { - return apiAdminGet('/api/knowledge/types') + return apiGet('/api/knowledge/types') }, /** @@ -584,7 +584,7 @@ export const typeApi = { * @returns {Promise} - 分块策略列表 */ getChunkPresets: async () => { - return apiAdminGet('/api/knowledge/chunk-presets') + return apiGet('/api/knowledge/chunk-presets') }, /** diff --git a/web/src/components/EmbeddingModelSelector.vue b/web/src/components/EmbeddingModelSelector.vue index b2f1a2ab6..8424a9279 100644 --- a/web/src/components/EmbeddingModelSelector.vue +++ b/web/src/components/EmbeddingModelSelector.vue @@ -42,6 +42,7 @@ + + + +