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/ARCHITECTURE.md b/ARCHITECTURE.md index b7c9c77f8..58188d97b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,7 +74,9 @@ Yuxi 只交付完整知识能力路径。API 始终注册 `external_kb`、`knowl - `composables` 封装请求排队、Run SSE、流式消息、审批、线程状态、提及和其他可组合逻辑。 - `utils` 放轻量转换和展示辅助;全局样式集中在 `assets/css`,颜色和基础规范优先复用 `base.css`。 -`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 对普通用户可见,知识库、工具和 MCP 管理能力仅管理员可见;Dashboard 仅超级管理员可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。 +`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 对普通用户可见,知识库对管理员及具备个人或团队知识业务能力的用户开放,工具和 MCP 管理能力仅管理员可见;Dashboard 仅超级管理员可访问。知识库页面按共享配置分为“团队/我的”:个人库复用创建者及空共享范围,仅所有者可访问;团队库按读取范围授权,辅导人员只读,业务管理员维护获授权范围内的库。后端权限检查始终是最终边界,前端守卫只负责页面体验。 + +`/students` 为辅导人员和业务管理员提供学生档案列表。业务管理员创建档案并指定初始负责人,负责人在详情中维护背景摘要与状态;部门和负责人可见性由后端档案接口执行。 ## 智能体运行链路 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 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/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 2be6e8cf6..df07bd70c 100644 --- a/backend/package/yuxi/permissions/__init__.py +++ b/backend/package/yuxi/permissions/__init__.py @@ -1,11 +1,21 @@ """跨资源权限能力。""" +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, SKILL_PERMISSION_POLICY, ResourcePermission, ResourcePermissionDenied, + is_personal_knowledge_base, normalize_permission_config, require_knowledge_base_permission, require_resource_permission, @@ -18,14 +28,22 @@ __all__ = [ "AGENT_PERMISSION_POLICY", + "BUSINESS_ROLE_CAPABILITIES", "KNOWLEDGE_BASE_PERMISSION_POLICY", "SKILL_PERMISSION_POLICY", + "BusinessCapability", + "BusinessRole", "ResourcePermission", "ResourcePermissionDenied", + "is_personal_knowledge_base", "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/permissions/resource_permission.py b/backend/package/yuxi/permissions/resource_permission.py index 6da0f8ae7..4ce5029ad 100644 --- a/backend/package/yuxi/permissions/resource_permission.py +++ b/backend/package/yuxi/permissions/resource_permission.py @@ -7,6 +7,8 @@ from enum import StrEnum from typing import Any, Protocol +from yuxi.permissions.business_roles import BusinessCapability, resolve_business_capabilities + class ResourcePermission(StrEnum): """资源权限等级,数值顺序用于判断权限是否足够。""" @@ -207,7 +209,31 @@ 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 + if _value(user, "role") in {"admin", "superadmin"} or ( + BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE in resolve_business_capabilities(user) + ): + return ResourcePermission.MANAGE + return ResourcePermission.NONE + + if _value(user, "role") == "user": + config = normalize_permission_config(_value(resource, "share_config")) + readable = scope_matches(user, config["read_scope"]) or ( + config["read_scope"] is None and scope_matches(user, config["manage_scope"]) + ) + if not readable: + return ResourcePermission.NONE + capabilities = resolve_business_capabilities(user) + if BusinessCapability.MANAGE_TEAM_KNOWLEDGE in capabilities: + return ResourcePermission.MANAGE + if BusinessCapability.READ_AUTHORIZED_TEAM_KNOWLEDGE in capabilities: + return ResourcePermission.READ + return ResourcePermission.NONE return resolve_resource_permission( user, @@ -216,6 +242,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/package/yuxi/repositories/counseling.py b/backend/package/yuxi/repositories/counseling.py new file mode 100644 index 000000000..4c5ac0f33 --- /dev/null +++ b/backend/package/yuxi/repositories/counseling.py @@ -0,0 +1,91 @@ +"""学生档案的归属查询与持久化边界。""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from yuxi.storage.postgres.models_business import User +from yuxi.storage.postgres.models_counseling import StudentRecord +from yuxi.utils.datetime_utils import utc_now_naive + + +class StudentRepository: + """在查询条件中落实部门与负责人隔离。""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def list_department_counselors(self, department_id: int) -> list[User]: + """读取本部门未删除的负责人候选。""" + result = await self.db.execute( + select(User) + .where(User.department_id == department_id, User.is_deleted == 0) + .order_by(User.username, User.id) + ) + return list(result.scalars().all()) + + async def eligible_counselor(self, counselor_id: int, department_id: int) -> User | None: + """锁定同部门且未删除的待分配用户。""" + result = await self.db.execute( + select(User) + .where(User.id == counselor_id, User.department_id == department_id, User.is_deleted == 0) + .with_for_update() + ) + return result.scalar_one_or_none() + + async def create(self, department_id: int, student_code: str, counselor_id: int) -> StudentRecord: + """创建空背景的档案并等待调用方提交。""" + record = StudentRecord(department_id=department_id, student_code=student_code, counselor_id=counselor_id) + self.db.add(record) + await self.db.flush() + return record + + async def list_for_owner(self, department_id: int, counselor_id: int) -> list[StudentRecord]: + """只读取当前负责人名下的档案。""" + result = await self.db.execute( + select(StudentRecord) + .where(StudentRecord.department_id == department_id, StudentRecord.counselor_id == counselor_id) + .order_by(StudentRecord.id) + ) + return list(result.scalars().all()) + + async def list_for_manager(self, department_id: int) -> list[StudentRecord]: + """读取部门内用于分配的档案元数据。""" + result = await self.db.execute( + select(StudentRecord.id, StudentRecord.student_code, StudentRecord.counselor_id, StudentRecord.status) + .where(StudentRecord.department_id == department_id) + .order_by(StudentRecord.id) + ) + return list(result.all()) + + async def get_for_owner(self, student_id: int, department_id: int, counselor_id: int) -> StudentRecord | None: + """只返回负责人能读取和修改的档案。""" + result = await self.db.execute( + select(StudentRecord).where( + StudentRecord.id == student_id, + StudentRecord.department_id == department_id, + StudentRecord.counselor_id == counselor_id, + ) + ) + return result.scalar_one_or_none() + + async def update_for_owner( + self, student_id: int, department_id: int, counselor_id: int, background_summary: str, status: str + ) -> StudentRecord | None: + """持锁更新负责人名下的背景和状态。""" + result = await self.db.execute( + select(StudentRecord) + .where( + StudentRecord.id == student_id, + StudentRecord.department_id == department_id, + StudentRecord.counselor_id == counselor_id, + ) + .with_for_update() + ) + record = result.scalar_one_or_none() + if record is None: + return None + record.background_summary = background_summary + record.status = status + record.updated_at = utc_now_naive() + await self.db.flush() + return record diff --git a/backend/package/yuxi/services/counseling.py b/backend/package/yuxi/services/counseling.py new file mode 100644 index 000000000..0e7b1bf12 --- /dev/null +++ b/backend/package/yuxi/services/counseling.py @@ -0,0 +1,88 @@ +"""最小学生档案用例与对外字段。""" + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from yuxi.permissions.business_roles import BusinessCapability, resolve_business_capabilities +from yuxi.repositories.counseling import StudentRepository +from yuxi.storage.postgres.models_business import User + + +def _metadata(record) -> dict: + return { + "id": record.id, + "student_code": record.student_code, + "counselor_id": record.counselor_id, + "status": record.status, + } + + +def _details(record) -> dict: + return {**_metadata(record), "background_summary": record.background_summary} + + +async def list_counselor_options(db: AsyncSession, actor: User) -> list[dict]: + """只向分配人员提供本部门可担任负责人的用户。""" + if BusinessCapability.ASSIGN_STUDENTS not in resolve_business_capabilities(actor): + raise PermissionError("需要学生分配权限") + users = await StudentRepository(db).list_department_counselors(actor.department_id) + return [ + {"id": user.id, "username": user.username} + for user in users + if BusinessCapability.MANAGE_ASSIGNED_STUDENTS in resolve_business_capabilities(user) + ] + + +async def create_student(db: AsyncSession, actor: User, student_code: str, counselor_id: int) -> dict: + """由同部门业务管理员为辅导人员创建空档案。""" + if BusinessCapability.ASSIGN_STUDENTS not in resolve_business_capabilities(actor): + raise PermissionError("需要学生分配权限") + repository = StudentRepository(db) + counselor = await repository.eligible_counselor(counselor_id, actor.department_id) + if counselor is None or BusinessCapability.MANAGE_ASSIGNED_STUDENTS not in resolve_business_capabilities(counselor): + raise ValueError("负责人必须是本部门在职辅导人员") + try: + record = await repository.create(actor.department_id, student_code, counselor_id) + result = _metadata(record) + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise FileExistsError("本部门学生编号已存在") from exc + return result + + +async def list_students(db: AsyncSession, actor: User) -> list[dict]: + """按角色读取本部门分配元数据或本人档案列表。""" + capabilities = resolve_business_capabilities(actor) + repository = StudentRepository(db) + if BusinessCapability.ASSIGN_STUDENTS in capabilities: + records = await repository.list_for_manager(actor.department_id) + elif BusinessCapability.MANAGE_ASSIGNED_STUDENTS in capabilities: + records = await repository.list_for_owner(actor.department_id, actor.id) + else: + raise PermissionError("需要学生档案权限") + return [_metadata(record) for record in records] + + +async def get_student(db: AsyncSession, actor: User, student_id: int) -> dict: + """仅负责人读取学生背景。""" + if BusinessCapability.MANAGE_ASSIGNED_STUDENTS not in resolve_business_capabilities(actor): + raise PermissionError("需要负责学生权限") + record = await StudentRepository(db).get_for_owner(student_id, actor.department_id, actor.id) + if record is None: + raise LookupError("学生档案不存在") + return _details(record) + + +async def update_student(db: AsyncSession, actor: User, student_id: int, background_summary: str, status: str) -> dict: + """仅负责人更新学生背景与状态。""" + if BusinessCapability.MANAGE_ASSIGNED_STUDENTS not in resolve_business_capabilities(actor): + raise PermissionError("需要负责学生权限") + record = await StudentRepository(db).update_for_owner( + student_id, actor.department_id, actor.id, background_summary, status + ) + if record is None: + raise LookupError("学生档案不存在") + result = _details(record) + await db.commit() + return result 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..1e38bbd61 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -18,14 +18,64 @@ UNVIEWED_RUN_MARKER, ) from yuxi.storage.postgres.models_business import Base as BusinessBase +from yuxi.storage.postgres import models_counseling # noqa: F401 - 注册学生表到业务 metadata from yuxi.storage.postgres.models_knowledge import Base as KnowledgeBase from yuxi.utils import logger 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 = 9 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 $$; + """, +) +COUNSELING_STUDENT_SCHEMA_STATEMENTS = ( + """ + CREATE TABLE IF NOT EXISTS counseling_students ( + id SERIAL PRIMARY KEY, + department_id INTEGER NOT NULL REFERENCES departments(id), + student_code VARCHAR(64) NOT NULL, + counselor_id INTEGER NOT NULL REFERENCES users(id), + background_summary TEXT NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT uq_counseling_students_department_code UNIQUE (department_id, student_code), + CONSTRAINT ck_counseling_students_status CHECK (status IN ('active', 'closed')) + ) + """, + "CREATE INDEX IF NOT EXISTS ix_counseling_students_owner ON counseling_students(department_id, counselor_id)", +) 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 +584,20 @@ 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 upgrade_business_schema_v8_to_v9(self) -> None: + """为既有业务数据库增加最小学生档案表。""" + self._check_initialized() + async with self.async_engine.begin() as conn: + for statement in COUNSELING_STUDENT_SCHEMA_STATEMENTS: + await conn.execute(text(statement)) + async def drop_tables(self): """删除所有表(慎用!)""" self._check_initialized() @@ -920,6 +984,8 @@ async def ensure_business_schema(self): """确保业务 schema 包含后续新增字段(运行时 schema 演进)。""" self._check_initialized() stmts = [ + *BUSINESS_ROLE_SCHEMA_STATEMENTS, + *COUNSELING_STUDENT_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/postgres/models_counseling.py b/backend/package/yuxi/storage/postgres/models_counseling.py new file mode 100644 index 000000000..fc7d61c2d --- /dev/null +++ b/backend/package/yuxi/storage/postgres/models_counseling.py @@ -0,0 +1,36 @@ +"""学生档案的最小 PostgreSQL 模型。""" + +from sqlalchemy import ( + CheckConstraint, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, +) + +from yuxi.storage.postgres.models_business import Base + + +class StudentRecord(Base): + """以部门编号和负责人限定的当前学生档案。""" + + __tablename__ = "counseling_students" + __table_args__ = ( + UniqueConstraint("department_id", "student_code", name="uq_counseling_students_department_code"), + CheckConstraint("status IN ('active', 'closed')", name="ck_counseling_students_status"), + Index("ix_counseling_students_owner", "department_id", "counselor_id"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + department_id = Column(Integer, ForeignKey("departments.id"), nullable=False) + student_code = Column(String(64), nullable=False) + counselor_id = Column(Integer, ForeignKey("users.id"), nullable=False) + background_summary = Column(Text, nullable=False, default="", server_default="") + status = Column(String(16), nullable=False, default="active", server_default="active") + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) diff --git a/backend/package/yuxi/storage_migration.py b/backend/package/yuxi/storage_migration.py index a529270c9..a9b3f1d5a 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, 8), ) knowledge_version = versions.get("knowledge") _require_supported_version( @@ -150,6 +150,13 @@ 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.upgrade_business_schema_v8_to_v9() + await pg_manager.record_schema_version("business", BUSINESS_SCHEMA_VERSION) + elif business_version == 8: + await pg_manager.upgrade_business_schema_v8_to_v9() + 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/__init__.py b/backend/server/routers/__init__.py index 899f20980..6ef050e45 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -7,6 +7,7 @@ from server.routers.auth_dept_router import department from server.routers.auth_router import auth from server.routers.chat_router import chat +from server.routers.counseling_router import counseling from server.routers.dashboard_router import dashboard from server.routers.external_kb_router import external_kb from server.routers.filesystem_router import filesystem_router @@ -36,6 +37,7 @@ router.include_router(agent_invocation_channel_router) # /api/agent-invocation/channel/* router.include_router(agent_invocation_eval_router) # /api/agent-invocation/eval/* router.include_router(chat) # /api/chat/* 对话线程、消息历史与附件 +router.include_router(counseling) # /api/counseling/students* 学生档案 router.include_router(projects) # /api/projects* 项目创建与选择 router.include_router(scheduled_agents) # /api/scheduled-tasks* 用户自建 Agent 定时任务 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/server/routers/counseling_router.py b/backend/server/routers/counseling_router.py new file mode 100644 index 000000000..30cd8c44f --- /dev/null +++ b/backend/server/routers/counseling_router.py @@ -0,0 +1,96 @@ +"""学生档案最小 HTTP 入口。""" + +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from server.utils.auth_middleware import get_db, get_required_user +from yuxi.services.counseling import create_student, get_student, list_counselor_options, list_students, update_student +from yuxi.storage.postgres.models_business import User + +counseling = APIRouter(prefix="/counseling/students", tags=["counseling"]) + + +class StudentCreate(BaseModel): + """创建档案时只提交部门内部编号和负责人。""" + + model_config = ConfigDict(extra="forbid") + student_code: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$") + counselor_id: int = Field(gt=0) + + +class StudentUpdate(BaseModel): + """负责人维护当前背景和状态。""" + + model_config = ConfigDict(extra="forbid") + background_summary: str = Field(max_length=10000) + status: Literal["active", "closed"] + + +def _raise_counseling_error(exc: Exception) -> None: + """把业务边界错误映射为稳定 HTTP 状态。""" + if isinstance(exc, PermissionError): + code = 403 + elif isinstance(exc, LookupError): + code = 404 + elif isinstance(exc, FileExistsError): + code = 409 + else: + code = 422 + raise HTTPException(status_code=code, detail=str(exc)) from exc + + +@counseling.post("", status_code=status.HTTP_201_CREATED) +async def create_student_route( + payload: StudentCreate, actor: User = Depends(get_required_user), db: AsyncSession = Depends(get_db) +): + """业务管理员在本部门指定初始负责人。""" + try: + return await create_student(db, actor, payload.student_code, payload.counselor_id) + except (PermissionError, ValueError, FileExistsError) as exc: + _raise_counseling_error(exc) + + +@counseling.get("") +async def list_students_route(actor: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)): + """按角色列出可见档案的最小元数据。""" + try: + return await list_students(db, actor) + except PermissionError as exc: + _raise_counseling_error(exc) + + +@counseling.get("/counselors") +async def list_counselors_route(actor: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)): + """获取当前部门可选的初始负责人。""" + try: + return await list_counselor_options(db, actor) + except PermissionError as exc: + _raise_counseling_error(exc) + + +@counseling.get("/{student_id}") +async def get_student_route( + student_id: int, actor: User = Depends(get_required_user), db: AsyncSession = Depends(get_db) +): + """只向负责人返回背景。""" + try: + return await get_student(db, actor, student_id) + except (PermissionError, LookupError) as exc: + _raise_counseling_error(exc) + + +@counseling.put("/{student_id}") +async def update_student_route( + student_id: int, + payload: StudentUpdate, + actor: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +): + """只允许负责人修改当前背景和状态。""" + try: + return await update_student(db, actor, student_id, payload.background_summary, payload.status) + except (PermissionError, LookupError) as exc: + _raise_counseling_error(exc) 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..33f1490aa 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, @@ -33,7 +34,10 @@ ) from yuxi.knowledge.utils.url_fetcher import fetch_url_content from yuxi.permissions import ( + BusinessCapability, ResourcePermission, + is_personal_knowledge_base, + resolve_business_capabilities, resolve_knowledge_base_permission, ) from yuxi.services.knowledge_folder_service import knowledge_folder_service @@ -49,6 +53,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 +161,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 +186,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 +208,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 +240,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 +258,33 @@ 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="辅导知识库仅支持本地文档知识库") + capabilities = resolve_business_capabilities(current_user) + personal = share_config is not None and is_personal_knowledge_base({"share_config": share_config}) + if share_config is None and BusinessCapability.MANAGE_TEAM_KNOWLEDGE not in capabilities: + personal = True + if personal: + if BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE not in capabilities: + raise HTTPException(status_code=403, detail="无权创建个人知识库") + share_config = {"version": 2, "read_scope": None, "manage_scope": None} + elif BusinessCapability.MANAGE_TEAM_KNOWLEDGE not in capabilities: + raise HTTPException(status_code=403, detail="无权创建团队知识库") + else: + if current_user.department_id is None: + raise HTTPException(status_code=400, detail="业务管理员需先加入部门") + team_config = { + "version": 2, + "read_scope": {"access_level": "department", "department_ids": [current_user.department_id]}, + "manage_scope": None, + } + if share_config is not None and share_config != team_config: + raise HTTPException(status_code=403, detail="业务管理员只能创建本部门团队知识库") + share_config = team_config 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 +448,13 @@ 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="个人知识库不能修改共享范围") + if current_user.role not in {"admin", "superadmin"} and data.share_config is not None: + if not is_personal_knowledge_base(database_info): + 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 +772,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 +814,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 +1595,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 +1659,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 +1731,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 +1779,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 +1792,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/routers/workspace_router.py b/backend/server/routers/workspace_router.py index dd28b7447..f9422faba 100644 --- a/backend/server/routers/workspace_router.py +++ b/backend/server/routers/workspace_router.py @@ -48,6 +48,7 @@ async def _ensure_knowledge_read_access(current_user: User, kb_id: str) -> None: "uid": current_user.uid, "role": current_user.role, "department_id": current_user.department_id, + "business_roles": current_user.business_roles, }, kb_id, ) diff --git a/backend/server/utils/knowledge_permissions.py b/backend/server/utils/knowledge_permissions.py index 3c7ac418c..7ee4e48fa 100644 --- a/backend/server/utils/knowledge_permissions.py +++ b/backend/server/utils/knowledge_permissions.py @@ -2,17 +2,29 @@ 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: + """允许管理员、辅导人员及业务管理员进入知识管理。""" + capabilities = resolve_business_capabilities(current_user) + if current_user.role not in {"admin", "superadmin"} and not capabilities.intersection( + {BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE, BusinessCapability.MANAGE_TEAM_KNOWLEDGE} + ): + raise HTTPException(status_code=403, detail="需要知识库业务权限") + return current_user + + async def ensure_knowledge_base_permission( kb_id: str, current_user: User, @@ -33,9 +45,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 +55,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_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/api/test_counseling_student_api.py b/backend/test/integration/api/test_counseling_student_api.py new file mode 100644 index 000000000..013979658 --- /dev/null +++ b/backend/test/integration/api/test_counseling_student_api.py @@ -0,0 +1,182 @@ +"""真实 HTTP、PostgreSQL 学生档案归属和迁移测试。""" + +import os +import uuid + +import asyncpg +import pytest + +from yuxi.storage.postgres.manager import pg_manager +from yuxi.utils.auth_utils import AuthUtils + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_student_owner_and_manager_access_are_isolated(test_client): + """不同负责人、部门及技术管理员不能读取或覆盖背景。""" + suffix = uuid.uuid4().hex[:10] + conn = await asyncpg.connect(os.environ["POSTGRES_URL"].replace("+asyncpg", "")) + departments = [] + users = [] + + async def actor(department_id, role, business_roles): + uid = f"student_{suffix}_{len(users)}" + user_id = await conn.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, + business_roles, + department_id, + ) + users.append(user_id) + return user_id, {"Authorization": f"Bearer {AuthUtils.create_access_token({'sub': str(user_id)})}"} + + try: + for number in (1, 2): + departments.append( + await conn.fetchval( + "INSERT INTO departments (name, description) VALUES ($1, 'test') RETURNING id", + f"student_dept_{suffix}_{number}", + ) + ) + manager_id, manager = await actor(departments[0], "admin", '["business_admin"]') + owner_id, owner = await actor(departments[0], "user", '["counselor"]') + other_id, other = await actor(departments[0], "user", '["counselor"]') + tech_id, tech = await actor(departments[0], "superadmin", '["technical_admin"]') + _, foreign_manager = await actor(departments[1], "admin", '["business_admin"]') + _, no_role = await actor(departments[0], "user", "[]") + + denied = await test_client.post( + "/api/counseling/students", + headers=manager, + json={"student_code": "S-001", "counselor_id": tech_id}, + ) + assert denied.status_code == 422, denied.text + denied = await test_client.post( + "/api/counseling/students", + headers=foreign_manager, + json={"student_code": "S-001", "counselor_id": owner_id}, + ) + assert denied.status_code == 422, denied.text + for headers in (owner, tech, no_role): + denied = await test_client.post( + "/api/counseling/students", + headers=headers, + json={"student_code": "S-001", "counselor_id": owner_id}, + ) + assert denied.status_code == 403, denied.text + + created = await test_client.post( + "/api/counseling/students", + headers=manager, + json={"student_code": "S-001", "counselor_id": owner_id}, + ) + assert created.status_code == 201, created.text + student_id = created.json()["id"] + assert "background_summary" not in created.json() + duplicate = await test_client.post( + "/api/counseling/students", + headers=manager, + json={"student_code": "S-001", "counselor_id": other_id}, + ) + assert duplicate.status_code == 409, duplicate.text + + updated = await test_client.put( + f"/api/counseling/students/{student_id}", + headers=owner, + json={"background_summary": "虚构背景,仅测试隔离", "status": "closed"}, + ) + assert updated.status_code == 200, updated.text + assert updated.json()["background_summary"] == "虚构背景,仅测试隔离" + persisted = await conn.fetchrow( + "SELECT department_id, student_code, counselor_id, background_summary, status " + "FROM counseling_students WHERE id = $1", + student_id, + ) + assert tuple(persisted.values()) == (departments[0], "S-001", owner_id, "虚构背景,仅测试隔离", "closed") + + for headers in (manager, other, tech, foreign_manager, no_role): + detail = await test_client.get(f"/api/counseling/students/{student_id}", headers=headers) + assert detail.status_code in {403, 404}, detail.text + assert "虚构背景" not in detail.text + write = await test_client.put( + f"/api/counseling/students/{student_id}", + headers=headers, + json={"background_summary": "越权覆盖", "status": "active"}, + ) + assert write.status_code in {403, 404}, write.text + + for headers in (manager, owner): + listed = await test_client.get("/api/counseling/students", headers=headers) + assert listed.status_code == 200, listed.text + assert listed.json() == [ + {"id": student_id, "student_code": "S-001", "counselor_id": owner_id, "status": "closed"} + ] + assert "background_summary" not in listed.text + for headers in (other, foreign_manager): + listed = await test_client.get("/api/counseling/students", headers=headers) + assert listed.status_code == 200 and listed.json() == [], listed.text + for headers in (tech, no_role): + listed = await test_client.get("/api/counseling/students", headers=headers) + assert listed.status_code == 403, listed.text + assert ( + await conn.fetchval("SELECT background_summary FROM counseling_students WHERE id = $1", student_id) + ) == "虚构背景,仅测试隔离" + finally: + await conn.execute("DELETE FROM counseling_students WHERE department_id = ANY($1::integer[])", departments) + await conn.execute("DELETE FROM users WHERE id = ANY($1::integer[])", users) + await conn.execute("DELETE FROM departments WHERE id = ANY($1::integer[])", departments) + await conn.close() + + +async def test_student_migration_is_idempotent_and_checks_status(): + """迁移可重复执行,数据库拒绝非法状态。""" + pg_manager.initialize() + await pg_manager.upgrade_business_schema_v8_to_v9() + await pg_manager.upgrade_business_schema_v8_to_v9() + conn = await asyncpg.connect(os.environ["POSTGRES_URL"].replace("+asyncpg", "")) + try: + columns = { + row["column_name"] + for row in await conn.fetch( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'counseling_students'" + ) + } + assert {"department_id", "student_code", "counselor_id", "background_summary", "status"} <= columns + constraints = { + row["conname"] + for row in await conn.fetch( + "SELECT conname FROM pg_constraint WHERE conrelid = 'counseling_students'::regclass" + ) + } + assert {"uq_counseling_students_department_code", "ck_counseling_students_status"} <= constraints + transaction = conn.transaction() + await transaction.start() + try: + dept = await conn.fetchval( + "INSERT INTO departments (name, description) VALUES ($1, 'test') RETURNING id", + f"migration_student_{uuid.uuid4().hex}", + ) + user = await conn.fetchval( + """INSERT INTO users (username, uid, password_hash, role, department_id, + login_failed_count, is_deleted, created_at) + VALUES ($1, $1, 'test', 'user', $2, 0, 0, NOW()) RETURNING id""", + f"migration_student_{uuid.uuid4().hex}", + dept, + ) + with pytest.raises(asyncpg.CheckViolationError): + await conn.execute( + """INSERT INTO counseling_students (department_id, student_code, counselor_id, status) + VALUES ($1, $2, $3, 'invalid')""", + dept, + "invalid-state", + user, + ) + finally: + await transaction.rollback() + finally: + await conn.close() 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/integration/api/test_team_knowledge_api.py b/backend/test/integration/api/test_team_knowledge_api.py new file mode 100644 index 000000000..cca589189 --- /dev/null +++ b/backend/test/integration/api/test_team_knowledge_api.py @@ -0,0 +1,240 @@ +"""通过真实 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_team_knowledge_read_and_manage_boundaries(test_client) -> None: + """同部门辅导人员只读,业务管理员维护,跨部门及个人资料隔离。""" + suffix = uuid.uuid4().hex[:10] + connection = await asyncpg.connect(os.environ["POSTGRES_URL"].replace("+asyncpg", "")) + departments: list[int] = [] + actors: dict[str, dict] = {} + databases: list[tuple[str, dict]] = [] + try: + for name in ("team", "other"): + department_id = await connection.fetchval( + "INSERT INTO departments (name, description) VALUES ($1, 'team knowledge test') RETURNING id", + f"pytest_team_{name}_{suffix}", + ) + departments.append(department_id) + + for name, role, roles, department_id in ( + ("counselor", "user", ["counselor"], departments[0]), + ("teammate", "user", ["counselor"], departments[0]), + ("outsider", "user", ["counselor"], departments[1]), + ("business", "user", ["business_admin"], departments[0]), + ("other_business", "user", ["business_admin"], departments[1]), + ("business_no_department", "user", ["business_admin"], None), + ("no_role", "user", [], departments[0]), + ("platform_admin", "admin", ["business_admin"], departments[0]), + ): + uid = f"team_{name}_{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[name] = { + "id": user_id, + "uid": uid, + "headers": {"Authorization": f"Bearer {AuthUtils.create_access_token({'sub': str(user_id)})}"}, + } + + payload = { + "database_name": f"pytest_team_knowledge_{suffix}", + "description": "team knowledge integration test", + "embedding_model_spec": "siliconflow-cn:Pro/BAAI/bge-m3", + "kb_type": "milvus", + } + for bad_share in ( + {"version": 2, "read_scope": {"access_level": "global"}, "manage_scope": None}, + { + "version": 2, + "read_scope": {"access_level": "department", "department_ids": [departments[1]]}, + "manage_scope": None, + }, + ): + denied = await test_client.post( + "/api/knowledge/databases", + json={**payload, "share_config": bad_share}, + headers=actors["business"]["headers"], + ) + assert denied.status_code == 403, denied.text + denied = await test_client.post( + "/api/knowledge/databases", + json={**payload, "share_config": {"version": 2, "read_scope": None, "manage_scope": None}}, + headers=actors["business"]["headers"], + ) + assert denied.status_code == 403, denied.text + denied = await test_client.post( + "/api/knowledge/databases", json=payload, headers=actors["business_no_department"]["headers"] + ) + assert denied.status_code == 400, denied.text + denied = await test_client.post( + "/api/knowledge/databases", + json={**payload, "database_name": f"pytest_personal_knowledge_{suffix}"}, + headers=actors["counselor"]["headers"], + ) + assert denied.status_code == 200, denied.text + personal_id = denied.json()["kb_id"] + databases.append((personal_id, actors["counselor"]["headers"])) + + team_created = await test_client.post( + "/api/knowledge/databases", json=payload, headers=actors["business"]["headers"] + ) + assert team_created.status_code == 200, team_created.text + team_id = team_created.json()["kb_id"] + databases.append((team_id, actors["business"]["headers"])) + team_config = { + "version": 2, + "read_scope": {"access_level": "department", "department_ids": [departments[0]], "user_uids": []}, + "manage_scope": None, + } + record = await connection.fetchrow( + "SELECT created_by, share_config FROM knowledge_bases WHERE kb_id = $1", team_id + ) + assert record["created_by"] == actors["business"]["uid"] + assert json.loads(record["share_config"]) == team_config + + for name, can_see_team, can_see_personal, can_manage_team in ( + ("counselor", True, True, False), + ("teammate", True, False, False), + ("outsider", False, False, False), + ("business", True, False, True), + ("other_business", False, False, False), + ("platform_admin", True, False, False), + ): + listed = await test_client.get("/api/knowledge/databases", headers=actors[name]["headers"]) + assert listed.status_code == 200, listed.text + visible = {item["kb_id"]: item for item in listed.json()["databases"]} + assert (team_id in visible) is can_see_team, name + assert (personal_id in visible) is can_see_personal, name + if can_see_team: + assert visible[team_id]["can_manage"] is can_manage_team + + for name in ("outsider", "other_business"): + denied = await test_client.get( + f"/api/knowledge/databases/{team_id}", headers=actors[name]["headers"] + ) + assert denied.status_code == 403, denied.text + denied = await test_client.get( + f"/api/knowledge/databases/{personal_id}", headers=actors["business"]["headers"] + ) + assert denied.status_code == 403, denied.text + denied = await test_client.get("/api/knowledge/databases", headers=actors["no_role"]["headers"]) + assert denied.status_code == 403, denied.text + for name in ("outsider", "other_business", "no_role"): + denied = await test_client.get( + "/api/workspace/knowledge/tree", + params={"kb_id": team_id}, + headers=actors[name]["headers"], + ) + assert denied.status_code == 403, f"{name}: {denied.status_code} {denied.text}" + + update = {"name": f"pytest_team_updated_{suffix}", "description": "maintained by business role"} + saved = await test_client.put( + f"/api/knowledge/databases/{team_id}", json=update, headers=actors["business"]["headers"] + ) + assert saved.status_code == 200, saved.text + assert ( + await connection.fetchval("SELECT description FROM knowledge_bases WHERE kb_id = $1", team_id) + == update["description"] + ) + content = f"team material {suffix}".encode() + uploaded = await test_client.post( + "/api/knowledge/files/upload", + params={"kb_id": team_id}, + files={"file": ("team.txt", content, "text/plain")}, + headers=actors["business"]["headers"], + ) + assert uploaded.status_code == 200, uploaded.text + source = uploaded.json()["file_path"] + content_hash = uploaded.json()["content_hash"] + added = await test_client.post( + f"/api/knowledge/databases/{team_id}/documents/add", + json={"items": [source], "params": {"content_hashes": {source: content_hash}}}, + headers=actors["business"]["headers"], + ) + assert added.status_code == 200 and added.json()["status"] == "success", added.text + file_id = added.json()["items"][0]["file_id"] + assert await connection.fetchval( + "SELECT created_by FROM knowledge_files WHERE file_id = $1 AND kb_id = $2", file_id, team_id + ) == actors["business"]["uid"] + for name in ("counselor", "teammate", "platform_admin"): + read = await test_client.get( + f"/api/knowledge/databases/{team_id}/documents/{file_id}/download", + headers=actors[name]["headers"], + ) + assert read.status_code == 200 and read.content == content + for name in ("outsider", "other_business"): + read = await test_client.get( + f"/api/knowledge/databases/{team_id}/documents/{file_id}/download", + headers=actors[name]["headers"], + ) + assert read.status_code == 403, read.text + + await connection.execute( + "UPDATE knowledge_bases SET created_by = $1 WHERE kb_id = $2", actors["counselor"]["uid"], team_id + ) + for name in ("counselor", "teammate", "outsider", "other_business"): + response = await test_client.put( + f"/api/knowledge/databases/{team_id}", json=update, headers=actors[name]["headers"] + ) + assert response.status_code == 403, f"{name}: {response.status_code} {response.text}" + for name in ("counselor", "teammate", "outsider", "other_business"): + response = await test_client.post( + "/api/knowledge/files/upload", + params={"kb_id": team_id}, + files={"file": ("blocked.txt", b"blocked", "text/plain")}, + headers=actors[name]["headers"], + ) + assert response.status_code == 403, f"{name}: {response.status_code} {response.text}" + forbidden_share = await test_client.put( + f"/api/knowledge/databases/{team_id}", + json={**update, "share_config": {"version": 2, "read_scope": {"access_level": "global"}}}, + headers=actors["business"]["headers"], + ) + assert forbidden_share.status_code == 403, forbidden_share.text + assert json.loads( + await connection.fetchval("SELECT share_config FROM knowledge_bases WHERE kb_id = $1", team_id) + ) == team_config + owner_read = await test_client.get( + f"/api/knowledge/databases/{team_id}", headers=actors["counselor"]["headers"] + ) + assert owner_read.status_code == 200 and owner_read.json()["can_manage"] is False + manager_read = await test_client.get( + f"/api/knowledge/databases/{team_id}", headers=actors["business"]["headers"] + ) + assert manager_read.status_code == 200 and manager_read.json()["can_manage"] is True + assert await connection.fetchval( + "SELECT COUNT(*) FROM knowledge_files WHERE kb_id = $1", team_id + ) == 1 + finally: + for kb_id, headers in reversed(databases): + deleted = await test_client.delete(f"/api/knowledge/databases/{kb_id}", headers=headers) + assert deleted.status_code == 200, deleted.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) + for department_id in departments: + await connection.execute("DELETE FROM departments WHERE id = $1", department_id) + 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..8fd6f6db6 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,68 @@ 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 == 9 + 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) + + +async def test_business_v8_to_v9_creates_student_schema_idempotently() -> None: + """既有业务库重复升级后具有完整学生档案约束与默认值。""" + schema, admin_engine, scoped_engine, manager = await _create_isolated_manager("pytest_student_records") + try: + await manager.create_business_tables() + async with scoped_engine.begin() as connection: + await connection.execute(text("DROP TABLE counseling_students")) + + await manager.upgrade_business_schema_v8_to_v9() + await manager.upgrade_business_schema_v8_to_v9() + async with scoped_engine.connect() as connection: + defaults = dict( + (await connection.execute(text( + "SELECT column_name, column_default FROM information_schema.columns " + "WHERE table_schema = :schema AND table_name = 'counseling_students' " + "AND column_name IN ('background_summary', 'status')" + ), {"schema": schema})).all() + ) + assert defaults == {"background_summary": "''::text", "status": "'active'::character varying"} finally: await _drop_isolated_schema(schema, admin_engine, scoped_engine) 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_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/permissions/test_resource_permission.py b/backend/test/unit/permissions/test_resource_permission.py index ccf219ba3..e3afe97fe 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(): @@ -121,6 +136,56 @@ def test_global_knowledge_base_share_remains_manage_for_admin(): assert resolve_knowledge_base_permission(_user(role="user"), resource) == ResourcePermission.READ +def test_team_knowledge_counselor_owner_is_read_only(): + resource = _resource( + created_by="counselor", + share_config={ + "version": 2, + "read_scope": {"access_level": "global"}, + "manage_scope": {"access_level": "global"}, + }, + ) + counselor = _user(uid="counselor") + counselor.business_roles = ["counselor"] + + assert resolve_knowledge_base_permission(counselor, resource) == ResourcePermission.READ + + +def test_team_knowledge_business_admin_manages_only_authorized_scope(): + resource = _resource( + share_config={ + "version": 2, + "read_scope": {"access_level": "department", "department_ids": [1]}, + "manage_scope": None, + } + ) + business_admin = _user(role="user") + business_admin.business_roles = ["business_admin"] + counselor = _user(role="user") + counselor.business_roles = ["counselor"] + no_capability = _user(role="user") + no_capability.business_roles = [] + + assert resolve_knowledge_base_permission(business_admin, resource) == ResourcePermission.MANAGE + assert resolve_knowledge_base_permission(counselor, resource) == ResourcePermission.READ + assert resolve_knowledge_base_permission(no_capability, resource) == ResourcePermission.NONE + business_admin.department_id = 2 + assert resolve_knowledge_base_permission(business_admin, resource) == ResourcePermission.NONE + counselor.department_id = 2 + assert resolve_knowledge_base_permission(counselor, resource) == ResourcePermission.NONE + + +def test_team_business_admin_cannot_read_another_personal_knowledge_base(): + resource = _resource( + created_by="counselor", + share_config={"version": 2, "read_scope": None, "manage_scope": None}, + ) + business_admin = _user(uid="business-admin", role="user") + business_admin.business_roles = ["business_admin"] + + assert resolve_knowledge_base_permission(business_admin, resource) == ResourcePermission.NONE + + def test_legacy_permission_config_is_rejected_at_runtime(): from yuxi.permissions import normalize_permission_config 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/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..077391689 100644 --- a/backend/test/unit/services/test_storage_migration.py +++ b/backend/test/unit/services/test_storage_migration.py @@ -281,6 +281,60 @@ async def session_context(): assert {"create_business", "checkpoint", "knowledge_schema"}.isdisjoint(calls) +@pytest.mark.asyncio +@pytest.mark.parametrize("starting_version", [7, 8]) +async def test_main_existing_business_schema_upgrades_to_student_records(monkeypatch, starting_version: int): + 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": starting_version, "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"), + upgrade_business_schema_v8_to_v9=lambda: _record(calls, "student_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) is (starting_version == 7) + assert "student_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/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/docs/develop-guides/decisions/implemented/2026-09-12-minimal-student-record.md b/docs/develop-guides/decisions/implemented/2026-09-12-minimal-student-record.md new file mode 100644 index 000000000..1454c02a7 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-12-minimal-student-record.md @@ -0,0 +1,25 @@ +# 最小学生档案与负责人边界 + +状态:implemented +类型:feature +Owner:backend/package/yuxi/repositories/counseling.py + +## 问题 + +辅导业务需要持久化学生编号、负责人、背景摘要和状态。平台技术管理身份不能自动获得学生背景的阅读权限。 + +## 决策 + +PostgreSQL 的 `counseling_students` 表保存部门、内部编号、负责人、背景摘要、状态与时间戳,部门内编号唯一,状态限制为 `active` 或 `closed`。业务管理员只在本人部门给具备辅导能力的在职用户创建空档案,并读取部门分配元数据。负责人读取和更新自己名下的背景摘要与状态。HTTP 入口只处理认证、输入和错误映射;业务能力在 service 检查,部门与负责人隔离在 repository 查询条件中执行。 + +## 替代方案 + +复用 Project 会改变工作目录与会话语义;将背景保存为知识文档会使个体信息进入检索范围;仅在前端过滤无法保护直接 API 调用。 + +## 后果 + +当前范围没有转派、删除、会话或工作材料关联。管理员只能看分配元数据;技术管理员无业务角色时不能查看档案。后续转派需要明确既有会话与文件的访问后果。学生摘要不发送给模型。 + +## 验证 + +真实 PostgreSQL 迁移可重复执行,表中存在部门编号唯一约束与状态约束,非法状态写入被拒。真实 HTTP 测试创建、更新并从 PostgreSQL 回读;重复编号、其他负责人、跨部门用户与仅有技术管理员身份的请求覆盖负向边界。提交前同时运行仓库的工程契约、基础单元测试和代码检查。 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/docs/develop-guides/decisions/implemented/2026-09-12-student-record-pages.md b/docs/develop-guides/decisions/implemented/2026-09-12-student-record-pages.md new file mode 100644 index 000000000..15695333c --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-12-student-record-pages.md @@ -0,0 +1,25 @@ +# 学生档案页面入口 + +状态:implemented +类型:feature +Owner:web/src/views/StudentRecordsView.vue + +## 问题 + +学生档案的持久化接口已有独立权限边界,业务人员需要从页面创建、查看和维护可访问的档案。 + +## 决策 + +侧边栏为具备辅导人员或业务管理员角色的用户显示“学生档案”。业务管理员在列表中读取部门分配元数据,创建档案时从本部门合格辅导人员中选择初始负责人。负责人从列表进入本人档案详情,维护背景摘要与状态。页面接口封装在 `web/src/apis/counseling_api.js`;候选人的权限筛选由 `backend/package/yuxi/services/counseling.py` 与 repository 执行。 + +## 替代方案 + +直接输入负责人数字 ID 难以核对目标用户,容易误分配。平台管理员用户列表不能服务仅具备业务管理员角色的用户,因此使用独立的最小候选接口。 + +## 后果 + +业务管理员不读取学生背景;只有本人同时担任负责人时才能进入详情。当前后端不提供转派,负责人分配仅发生于创建时。统计、材料模块页面操作与账号隔离验证不在当前页面范围。 + +## 验证 + +Web lint、329 项 unit 和 build、文档构建、工程契约检查通过;现有学生档案 HTTP integration、后端基础 unit 通过;后端相关文件 ruff check 和 format check 通过。真实登录页面重定向已观察到;由于缺少可用于页面验证的测试账号,登录后页面截图未验证。 diff --git a/docs/develop-guides/decisions/implemented/2026-09-12-team-knowledge-entry.md b/docs/develop-guides/decisions/implemented/2026-09-12-team-knowledge-entry.md new file mode 100644 index 000000000..a7108009d --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-12-team-knowledge-entry.md @@ -0,0 +1,38 @@ +# 团队知识库业务入口 + +状态:implemented +类型:feature +Owner:backend/package/yuxi/permissions/resource_permission.py + +## 问题 + +原知识库列表混排团队共享资料和个人资料,仅有业务管理员业务角色的普通账号也不能维护团队库。 + +## 决策 + +知识库沿用 `version 2` 共享配置:读取和管理范围均为空的是个人库,仅创建者可访问和维护;其余是团队库,读取范围决定可见人员。页面按“团队/我的”分类,分类决定列表、空态和新建库默认类型。后端返回的 `can_manage` 决定卡片与详情写入操作,直接访问接口仍独立鉴权。 + +普通账号中,辅导员只能读取获授权团队库,即使自己是旧团队库创建者;业务管理员可维护自己命中读取范围的团队库。没有相应业务能力的账号不能访问。业务管理员创建团队库时,后端固定为本部门可读且不允许自行扩大共享范围;没有部门的业务管理员不能创建。业务管理员不能修改团队库的共享配置,平台管理员保留原有共享权限。个人库仍仅所有者可访问,平台管理员的旧团队库权限保持兼容。 + +文件浏览、下载、上传及知识库详情使用同一业务角色和资源权限;工作区文件树必须传入完整业务角色,不能把无业务角色账号按旧默认辅导员处理。不新建资源表或持久字段。 + +## 替代方案 + +按创建者区分库会把旧团队库错误归为个人库。提高业务管理员的平台角色会一并开放系统管理。新建团队资源表则重复已有共享配置与文件归属。 + +## 后果 + +业务管理员仅能创建本部门团队库,跨部门共享范围需要平台管理员调整。旧团队库创建者如为辅导员,其写入权限收敛为只读。 + +## 验证 + +| 主张 | 正向证据 | 负向证据 | +|---|---|---| +| 团队与个人分类正确 | 真实页面以业务管理员和同部门辅导员切换分类、查看详情;前端分类单测 | 团队库创建者仍归团队,个人库不混入团队列表 | +| 辅导员只读获授权团队库 | 真实 HTTP 列表、详情及原文件下载 | 旧创建者更新和上传被拒;跨部门详情、文件及工作区树被拒 | +| 业务管理员维护团队库 | 真实 HTTP 创建、更新、上传及 PostgreSQL 回读 | 越范围访问、扩大共享范围、无部门创建被拒 | +| 个人库隔离与旧权限兼容 | 个人库集成测试、资源权限单测、平台管理员团队库下载 | 业务管理员读取他人个人库、无业务角色访问团队库被拒 | + +## 约束 + +本次验收覆盖知识库及原文件维护,不代表真实嵌入模型、索引和检索质量已接通或验证。团队库管理权限以业务管理员自身命中的读取范围为界,不跨部门接管。 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..739d959f7 --- /dev/null +++ b/docs/develop-guides/decisions/proposed/2026-09-12-counseling-minimal-release.md @@ -0,0 +1,68 @@ +# 知伴最小业务版分批实施 + +状态:proposed +类型:feature +Owner:web/src/router/index.js + +## 问题 + +面向实现者和验收人员,知伴需要把通用知识与对话能力连接到学生背景和持续跟进。原版服务已有运行记录,真实模型尚未接入;第一版先验证业务持久化与隔离,再进行真实模型联调。本文定义待实现范围,不证明业务已可用。 + +## 提案 + +产品展示名为“知伴 · 心理辅导工作台”,简称“知伴”。名称已由项目方选择,公开检索存在同名产品和商标线索,未取得商标可注册或无冲突结论。优先使用现有品牌配置,逐页替换名称、标题和欢迎语;正式 Logo 暂缓。保留许可证、上游版权及来源说明,保留核心包名、源码目录和现有运行拓扑。 + +第一版复用单前端、知识库、消息、任务、worker 和文件存储。业务入口保留学生档案;详情提供背景与跟进、关联会话,最近对话作为快捷入口。知识库提供团队与个人分类,图谱沿用原能力并遵守对应库权限。系统管理按授权显示现有智能体、模型、工具、账号、配置和监控能力。只显示已实现的入口。 + +辅导人员维护本人负责学生和个人知识库,读取授权团队库;业务管理员分配负责人并维护团队知识;技术管理员管理系统配置。固定角色可兼任,功能按授权合并。业务或技术管理身份不自动获得学生正文、关联资源或他人个人库访问权。后端负责最终校验,兼容现有管理账号,具体映射在实施前核实原权限机制。 + +原系统单值 `user/admin/superadmin` 继续作为平台兼容角色,既有接口不改写其含义。新增可组合业务角色 `counselor/business_admin/technical_admin`;迁移时分别按旧平台角色回填一个最小默认角色,不产生隐式角色继承。超级管理员负责显式分配组合角色,后续学生与知识业务只读取业务角色能力并在后端资源边界再次校验。 + +学生最小字段为内部编号、负责人、背景摘要、进行中或已结束状态,后续加入按日期跟进文本。每条会话固定绑定一名学生,保存人工确认的背景快照;每名学生可有多条会话。档案变化后提示重新确认,刷新只影响后续请求,历史消息及已发起请求维持原快照。模型分析不自动写回档案。 + +个人库默认仅创建者可见,团队库由业务管理员维护。个人库用于可复用参考资料,学生档案及跟进不自动进入知识库。会话可选择获准访问的团队库和个人库;保存后、每次请求执行时仍须校验访问权,并记录实际来源范围。后续真实模型联调时验证引用归属、知识更新与删除行为。 + +模型未配置时显示真实的未配置或失败原因,保留业务可操作部分。上传原始文件不等于完成解析或索引;嵌入模型缺失时不得标为索引完成。不制造模拟回答或伪造成功任务。 + +本版暂缓材料确认、当前方案、督导流程、经验回流业务、业务统计及图谱调优。文件沿用原能力。真实模型联调在最小业务版通过后执行,覆盖检索、引用、多轮连续性和成果文件。事实核查员代码保留,不默认增加自动核查链路。 + +### 提交边界 + +每批先确认最小差异和验证方式,检查及独立审查通过后提交本地 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 | +| 固定业务角色可兼任且不隐式继承 | 管理身份自动获得学生或个人库权限 | 用户业务角色、能力映射及认证接口 | 迁移回读、角色单测与真实 HTTP | 普通管理员显式提权被拒绝,未知角色被数据库拒绝 | Passed | +| 展示品牌且保留来源 | 全仓替换误改许可或包名 | 现有品牌配置、前端入口及 LICENSE | 页面验证、差异检查 | 检查许可证及核心名称无误改 | Passed | + +## 风险 + +原权限是否覆盖个人库创建、图谱和工具检索需要逐项核实,避免另建重复机制。学生权限要覆盖旧会话、任务、文件和搜索入口。各功能随权限一起交付。无模型验收仅证明业务与输入准备,无法证明索引、检索、引用质量或模型行为。 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 908ad2935..ee4f8b387 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) 提交建议。 ## 计划中的不兼容方向 diff --git a/docs/mechanisms/knowledge-base.md b/docs/mechanisms/knowledge-base.md index 4ff0cf66c..a75569df0 100644 --- a/docs/mechanisms/knowledge-base.md +++ b/docs/mechanisms/knowledge-base.md @@ -102,9 +102,11 @@ get_mindmap、search_file、download_kb_file 知识库的最终授权由后端依赖、Manager 可见性查询和具体工具目标校验共同完成: +- `version 2` 的空读取、管理范围表示个人库,仅创建者可访问;其他共享配置表示团队库,读取范围限定可见人员; +- 具有辅导员业务能力的普通用户按读取范围只读团队库,即使是旧团队库创建者也不能维护;具有业务管理员能力的普通用户可维护自己命中读取范围的团队库;原平台管理员沿用已有团队权限; - 读取、检索、打开和下载需要 read 权限; - 创建、更新、添加文件、解析、索引、删除和图谱写操作需要 manage 权限; -- 原文件上传入口要求管理员,并在传入 `kb_id` 时继续检查该知识库的 manage 权限; +- 辅导人员上传原文件必须指定自己可管理的知识库;业务管理员新建团队库默认仅本部门可读,团队共享范围由平台管理员调整; - 前端守卫、按钮隐藏、Agent 配置和提示词只控制呈现或缩小范围,不能授予权限。 Agent 的 `knowledges` 只能缩小用户已有权限。子智能体使用自己的配置,但仍沿用发起用户的身份。私有解析图片通过带知识库权限校验的 API 读取,MinIO 对象 URL 不是授权凭证。 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'], 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/apis/counseling_api.js b/web/src/apis/counseling_api.js new file mode 100644 index 000000000..dbae96a72 --- /dev/null +++ b/web/src/apis/counseling_api.js @@ -0,0 +1,11 @@ +import { apiGet, apiPost, apiPut } from './base' + +const root = '/api/counseling/students' + +export const counselingApi = { + listStudents: () => apiGet(root), + listCounselors: () => apiGet(`${root}/counselors`), + getStudent: (id) => apiGet(`${root}/${encodeURIComponent(id)}`), + createStudent: (payload) => apiPost(root, payload), + updateStudent: (id, payload) => apiPut(`${root}/${encodeURIComponent(id)}`, payload) +} 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/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/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 @@ + + + + diff --git a/web/test/unit/personalKnowledge.test.js b/web/test/unit/personalKnowledge.test.js new file mode 100644 index 000000000..daeead661 --- /dev/null +++ b/web/test/unit/personalKnowledge.test.js @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import { + createPersonalShareConfig, + buildDatabaseRequest +} from '../../src/utils/databaseCreateForm.js' +import { isPersonalKnowledgeConfig } from '../../src/utils/shareConfig.js' + +const source = (path) => readFileSync(new URL('../../src/' + path, import.meta.url), 'utf8') + +test('个人知识库请求固定为所有者私有,旧共享库保持可识别', () => { + const request = buildDatabaseRequest( + { name: '个人资料', kb_type: 'milvus', additional_params: {} }, + {}, + createPersonalShareConfig() + ) + assert.deepEqual(request.share_config, { version: 2, read_scope: null, manage_scope: null }) + assert.equal(isPersonalKnowledgeConfig(request.share_config), true) + assert.equal( + isPersonalKnowledgeConfig({ + version: 2, + read_scope: { access_level: 'global' }, + manage_scope: null + }), + false + ) + assert.equal(isPersonalKnowledgeConfig(undefined), false) +}) + +test('登录、刷新与退出同步业务角色,显式空角色不能继承上一账号能力', async () => { + const script = source('stores/user.js') + .replace(/^import .*$/gm, '') + .replaceAll('export ', '') + let profile = { id: 1, role: 'user', business_roles: ['counselor'] } + const user = runInNewContext(script + '\nuseUserStore()', { + defineStore: (_, setup) => setup, + ref: (value) => ({ value }), + computed: (getter) => ({ + get value() { + return getter() + } + }), + localStorage: { getItem: () => '', setItem() {}, removeItem() {} }, + authApi: { + login: async () => ({ ...profile, access_token: 'test', user_id: profile.id }), + getCurrentUser: async () => profile + }, + useAgentStore: () => ({ reset() {} }), + console + }) + await user.login({}) + assert.equal(user.canManagePersonalKnowledge.value, true) + profile = { id: 2, role: 'user', business_roles: [] } + await user.getCurrentUser() + assert.equal(user.canManagePersonalKnowledge.value, false) + profile = { id: 3, role: 'user', business_roles: ['counselor'] } + await user.getCurrentUser() + user.logout() + assert.equal(user.canManagePersonalKnowledge.value, false) +}) + +test('知识库与文件请求不要求平台管理员,下载保留blob响应,评估仍受限', async () => { + const calls = [] + const request = (...args) => { + calls.push(args) + return args + } + const script = source('apis/knowledge_api.js') + .replace(/^import [\s\S]*?from '\.\/base'\s*/, '') + .replaceAll('export ', '') + const apis = runInNewContext( + script + '\n;({ databaseApi, documentApi, fileApi, typeApi, evaluationApi })', + { + apiGet: request, + apiPost: request, + apiPut: request, + apiDelete: request, + apiRequest: request, + apiAdminGet: () => { + throw new Error('需要管理员权限') + }, + apiAdminPost: () => { + throw new Error('需要管理员权限') + }, + apiAdminDelete: () => { + throw new Error('需要管理员权限') + }, + buildQuery: () => '', + FormData + } + ) + await apis.databaseApi.getDatabases() + await apis.databaseApi.createDatabase({ share_config: createPersonalShareConfig() }) + await apis.databaseApi.updateDatabase('own', { name: '更新' }) + await apis.documentApi.listDocuments('own') + await apis.documentApi.downloadDocument('own', 'file') + assert.deepEqual(Array.from(calls.at(-1)), [ + '/api/knowledge/databases/own/documents/file/download', + calls.at(-1)[1], + true, + 'blob' + ]) + await apis.fileApi.getSupportedFileTypes() + await apis.typeApi.getKnowledgeBaseTypes() + await assert.rejects(apis.evaluationApi.listDatasets('own'), /管理员/) + await assert.rejects(apis.typeApi.getStatistics(), /管理员/) +}) + +test('辅导入口不开放工具和评估,个人库隐藏共享编辑并强制私有提交', () => { + assert.match(source('views/ExtensionsView.vue'), /userStore\.canManagePersonalKnowledge/) + assert.match(source('views/ExtensionsView.vue'), /userStore\.isAdmin && activeTab === 'tools'/) + const routes = source('router/index.js') + assert.match( + routes, + /name: 'ExtensionKnowledgeBaseDetail'[\s\S]*?requiresKnowledgeManagement: true/ + ) + assert.match(routes, /name: 'ExtensionEvaluationBenchmarkDetail'[\s\S]*?requiresAdmin: true/) + const create = source('components/knowledge/DatabaseCreateFlowModal.vue') + assert.match(create, /const isPersonal = computed\(\(\) => props\.defaultPersonal\)/) + assert.match(create, /isPersonal\.value \? createPersonalShareConfig\(\) : shareConfig\.value/) + assert.match(create, / { + const start = uploadScript.indexOf('const uploadModeOptions') + const end = uploadScript.indexOf('watch(uploadMode', start) + const setup = uploadScript.slice(start, end) + for (const admin of [false, true]) { + const options = runInNewContext(setup + '\n;uploadModeOptions.value', { + computed: (getter) => ({ value: getter() }), + uploadUserStore: { isAdmin: admin }, + h: () => ({}), + FileUp: {}, + FolderUp: {}, + Link: {}, + FolderOpen: {} + }) + const values = Array.from(options, (option) => option.value) + assert.equal(values.includes('url'), admin) + assert.equal(values.includes('file'), true) + assert.equal(values.includes('workspace'), true) + } +}) + +test('账号切换清除个人库缓存,前一账号迟到列表和详情不会重新显示', async () => { + const script = source('stores/database.js') + .replace(/^import .*$/gm, '') + .replaceAll('export ', '') + const user = { userId: 1, isAdmin: false, canManagePersonalKnowledge: true } + let clearSession + let resolveList + let resolveDetail + const store = runInNewContext(script + '\n;useDatabaseStore()', { + defineStore: (_, setup) => setup, + ref: (value) => ({ value }), + reactive: (value) => value, + watch: (_, callback) => { + clearSession = callback + }, + useRouter: () => ({}), + useTaskerStore: () => ({}), + useUserStore: () => user, + databaseApi: { + getDatabases: () => + new Promise((resolve) => { + resolveList = resolve + }), + getDatabaseInfo: () => + new Promise((resolve) => { + resolveDetail = resolve + }) + }, + documentApi: {}, + queryApi: {}, + message: { error() {} }, + Modal: {}, + console, + clearTimeout, + setTimeout + }) + store.databases.value = [{ name: '旧账号资料' }] + store.database.value = { kb_id: 'old', name: '旧账号资料' } + store.kbId.value = 'old' + const list = store.loadDatabases() + const detail = store.getDatabaseInfo('old', true) + user.userId = 2 + clearSession() + assert.equal(store.databases.value.length, 0) + assert.equal(store.database.value.name, undefined) + assert.equal(store.kbId.value, null) + resolveList({ databases: [{ name: '不应重新显示' }] }) + resolveDetail({ kb_id: 'old', name: '不应重新显示' }) + await Promise.all([list, detail]) + assert.equal(store.databases.value.length, 0) + assert.equal(store.database.value.name, undefined) +}) + +test('统计修复只渲染给管理员,辅导员不看到无动作按钮', () => { + const detail = source('views/DataBaseInfoView.vue') + const repairButtons = detail.match(/]*file-stat-repair[^>]*>/g) || [] + assert.equal(repairButtons.length, 2) + for (const button of repairButtons) { + assert.match(button, /v-if="canManageDatabase && userStore.isAdmin"/) + } +}) diff --git a/web/test/unit/teamKnowledge.test.js b/web/test/unit/teamKnowledge.test.js new file mode 100644 index 000000000..82e572f13 --- /dev/null +++ b/web/test/unit/teamKnowledge.test.js @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import { isKnowledgeBaseInScope } from '../../src/utils/shareConfig.js' + +const source = (path) => readFileSync(new URL('../../src/' + path, import.meta.url), 'utf8') + +test('团队与我的按共享配置分类,团队库创建者不会归入个人库', () => { + const team = { + created_by: 'current-user', + share_config: { + version: 2, + read_scope: { access_level: 'department', department_ids: [1] }, + manage_scope: null + } + } + const personal = { + created_by: 'current-user', + share_config: { version: 2, read_scope: null, manage_scope: null } + } + assert.equal(isKnowledgeBaseInScope(team, 'team'), true) + assert.equal(isKnowledgeBaseInScope(team, 'mine'), false) + assert.equal(isKnowledgeBaseInScope(personal, 'mine'), true) + assert.equal(isKnowledgeBaseInScope(personal, 'team'), false) +}) + +test('业务管理员身份与辅导员身份独立,空业务角色不能继承团队权限', async () => { + const script = source('stores/user.js') + .replace(/^import .*$/gm, '') + .replaceAll('export ', '') + let profile = { id: 1, role: 'user', business_roles: ['business_admin'] } + const user = runInNewContext(script + '\nuseUserStore()', { + defineStore: (_, setup) => setup, + ref: (value) => ({ value }), + computed: (getter) => ({ + get value() { + return getter() + } + }), + localStorage: { getItem: () => '', setItem() {}, removeItem() {} }, + authApi: { + login: async () => ({ ...profile, access_token: 'test', user_id: profile.id }), + getCurrentUser: async () => profile + }, + useAgentStore: () => ({ reset() {} }), + console + }) + await user.login({}) + assert.equal(user.canManageTeamKnowledge.value, true) + assert.equal(user.canManagePersonalKnowledge.value, false) + profile = { id: 2, role: 'user', business_roles: ['counselor'] } + await user.getCurrentUser() + assert.equal(user.canManageTeamKnowledge.value, false) + assert.equal(user.canManagePersonalKnowledge.value, true) + profile = { id: 3, role: 'user', business_roles: [] } + await user.getCurrentUser() + assert.equal(user.canManageTeamKnowledge.value, false) + assert.equal(user.canManagePersonalKnowledge.value, false) +}) + +test('当前分类控制创建入口及个人共享配置', () => { + const view = source('views/DataBaseView.vue') + assert.match(view, /label: '团队', value: 'team'/) + assert.match(view, /label: '我的', value: 'mine'/) + assert.match(view, /isKnowledgeBaseInScope\(database, knowledgeScope\.value\)/) + assert.match(view, /v-if="canCreateCurrentScope"/) + assert.match(view, /:default-personal="knowledgeScope === 'mine'"/) + const create = source('components/knowledge/DatabaseCreateFlowModal.vue') + assert.match(create, /props\.defaultPersonal/) + assert.match(create, /read_scope: \{ access_level: 'department', department_ids: \[userStore\.departmentId\] \}/) +})