From c3e799d1ced1d2424c53e9a3720da58e01230077 Mon Sep 17 00:00:00 2001 From: lkk Date: Thu, 27 Aug 2026 21:02:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(knowledge):=20=E9=97=A8=E6=88=B7=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E5=BC=80=E5=85=B3=E4=BB=85=E7=B3=BB=E7=BB=9F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非 admin 提交 portal_discovery_enabled 整单拒绝且不写库;前端隐藏开关且保存不带该字段。 Co-authored-by: Cursor --- .../services/knowledge_space_service.py | 4 + .../test_portal_discovery_control.py | 132 +++++++++++++++++- .../CreateKnowledgeSpaceDrawer.test.tsx | 29 ++++ .../knowledge/CreateKnowledgeSpaceDrawer.tsx | 6 +- .../client/src/pages/knowledge/index.tsx | 1 + .../portal/PortalKnowledgeWorkbench.test.tsx | 4 +- .../portal/PortalKnowledgeWorkbench.tsx | 1 + .../portal/components/PortalDialogs.tsx | 4 + 8 files changed, 177 insertions(+), 4 deletions(-) diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py index fe571bfc0..cd893d856 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py @@ -12002,6 +12002,10 @@ class KnowledgeSpaceService(KnowledgeUtils): if getattr(space, "is_favorite", False): raise FavoriteSpaceProtectedError() + # 门户发现开关只允许平台系统管理员改;非 admin 只要带该字段就整单拒绝,避免其它字段先写库。 + if portal_discovery_enabled is not None and not self.login_user.is_admin(): + raise UnAuthorizedError(msg="仅系统管理员可以配置门户公开范围") + rebind_scope = None target_department = None old_department = None diff --git a/src/backend/test/knowledge/test_portal_discovery_control.py b/src/backend/test/knowledge/test_portal_discovery_control.py index 7b0d31db4..ec5a81b20 100644 --- a/src/backend/test/knowledge/test_portal_discovery_control.py +++ b/src/backend/test/knowledge/test_portal_discovery_control.py @@ -43,6 +43,14 @@ from bisheng.permission.domain.services.fine_grained_permission_service import ( ) +class _UnauthorizedTestError(Exception): + """conftest 会预 mock http_error,测试里用真实异常类替换服务模块上的 UnAuthorizedError。""" + + def __init__(self, msg: str = "", **_kwargs) -> None: + super().__init__(msg) + self.message = msg + + def test_scope_model_has_fail_closed_portal_discovery_column() -> None: column = KnowledgeSpaceScope.__table__.c.portal_discovery_enabled @@ -294,6 +302,25 @@ async def test_update_endpoint_forwards_switch_and_returns_authoritative_info() assert response.data["portal_discovery_enabled"] is True +@pytest.mark.asyncio +async def test_update_endpoint_does_not_reread_after_unauthorized_switch() -> None: + service = Mock() + service.update_knowledge_space = AsyncMock( + side_effect=_UnauthorizedTestError("仅系统管理员可以配置门户公开范围") + ) + service.get_space_info = AsyncMock() + + with pytest.raises(_UnauthorizedTestError) as exc_info: + await update_space( + space_id=10, + req=KnowledgeSpaceUpdateReq(portal_discovery_enabled=True), + svc=service, + ) + + assert "仅系统管理员可以配置门户公开范围" in exc_info.value.message + service.get_space_info.assert_not_awaited() + + @pytest.mark.parametrize( ("scope", "binding", "expected"), [ @@ -360,7 +387,7 @@ def test_portal_discovery_kind_uses_persisted_scope_and_binding( @pytest.mark.asyncio async def test_update_switch_reuses_edit_permission_and_persists_authoritative_value() -> None: login_user = Mock(user_id=7, user_name="管理员", tenant_id=1) - login_user.is_admin.return_value = False + login_user.is_admin.return_value = True service = KnowledgeSpaceService(request=Mock(headers={}), login_user=login_user) scope = KnowledgeSpaceScope( id=1, @@ -386,6 +413,10 @@ async def test_update_switch_reuses_edit_permission_and_persists_authoritative_v with ( patch.object(service, "_require_permission_id", new_callable=AsyncMock) as require_permission, + patch( + "bisheng.knowledge.domain.services.knowledge_space_service._require_not_write_frozen", + new_callable=AsyncMock, + ), patch( "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aquery_by_id", new_callable=AsyncMock, @@ -422,10 +453,103 @@ async def test_update_switch_reuses_edit_permission_and_persists_authoritative_v assert write_audit.await_args.kwargs["metadata"]["new_value"] is True +@pytest.mark.asyncio +async def test_non_admin_switch_update_is_rejected_and_scope_row_unchanged() -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as connection: + await connection.execute( + sa.text( + "CREATE TABLE knowledge_space_scope (" + "id INTEGER PRIMARY KEY, tenant_id INTEGER NOT NULL, space_id INTEGER NOT NULL, " + "level VARCHAR(32) NOT NULL, owner_type VARCHAR(64) NOT NULL, owner_id INTEGER NOT NULL, " + "created_by INTEGER NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " + "update_time DATETIME DEFAULT CURRENT_TIMESTAMP, " + "portal_discovery_enabled BOOLEAN NOT NULL DEFAULT 0)" + ) + ) + await connection.execute( + sa.text( + "INSERT INTO knowledge_space_scope " + "(id, tenant_id, space_id, level, owner_type, owner_id, portal_discovery_enabled) " + "VALUES (1, 1, 10, 'public', 'tenant_root_department', 1, 0)" + ) + ) + + login_user = Mock(user_id=8, user_name="空间编辑", tenant_id=1) + login_user.is_admin.return_value = False + space = Knowledge( + id=10, + name="公共知识库", + type=KnowledgeTypeEnum.SPACE.value, + user_id=8, + tenant_id=1, + ) + + async with AsyncSession(engine, expire_on_commit=False) as session: + service = KnowledgeSpaceService(request=Mock(headers={}), login_user=login_user) + service.knowledge_space_scope_repo = KnowledgeSpaceScopeRepositoryImpl(session) + + with ( + patch.object(service, "_require_permission_id", new_callable=AsyncMock) as require_permission, + patch( + "bisheng.knowledge.domain.services.knowledge_space_service._require_not_write_frozen", + new_callable=AsyncMock, + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.UnAuthorizedError", + _UnauthorizedTestError, + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aquery_by_id", + new_callable=AsyncMock, + return_value=space, + ), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.async_update_space", + new_callable=AsyncMock, + return_value=space, + ) as update_space, + ): + with pytest.raises(_UnauthorizedTestError) as exc_info: + await service.update_knowledge_space( + space_id=10, + description="本不该写入", + portal_discovery_enabled=True, + ) + + assert "仅系统管理员可以配置门户公开范围" in exc_info.value.message + require_permission.assert_not_awaited() + update_space.assert_not_awaited() + + enabled = ( + await session.execute( + sa.text( + "SELECT portal_discovery_enabled FROM knowledge_space_scope WHERE space_id = 10" + ) + ) + ).scalar_one() + assert int(enabled) == 0 + + await service.update_knowledge_space(space_id=10, description="其它设置") + require_permission.assert_awaited_once_with("knowledge_space", 10, "edit_space") + update_space.assert_awaited_once() + + enabled_again = ( + await session.execute( + sa.text( + "SELECT portal_discovery_enabled FROM knowledge_space_scope WHERE space_id = 10" + ) + ) + ).scalar_one() + assert int(enabled_again) == 0 + + await engine.dispose() + + @pytest.mark.asyncio async def test_update_switch_repository_failure_keeps_value_and_records_failed_audit() -> None: login_user = Mock(user_id=7, user_name="管理员", tenant_id=1) - login_user.is_admin.return_value = False + login_user.is_admin.return_value = True service = KnowledgeSpaceService( request=Mock(headers={"X-Request-ID": "request-1"}), login_user=login_user, @@ -453,6 +577,10 @@ async def test_update_switch_repository_failure_keeps_value_and_records_failed_a with ( patch.object(service, "_require_permission_id", new_callable=AsyncMock), + patch( + "bisheng.knowledge.domain.services.knowledge_space_service._require_not_write_frozen", + new_callable=AsyncMock, + ), patch( "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aquery_by_id", new_callable=AsyncMock, diff --git a/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.test.tsx b/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.test.tsx index d4946744a..d419a23f8 100644 --- a/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.test.tsx +++ b/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.test.tsx @@ -544,6 +544,7 @@ describe("CreateKnowledgeSpaceDrawer", () => { }; const { unmount } = renderDrawer({ mode: "edit", + isSystemAdmin: true, editingSpace: { ...base, id: "public-1", @@ -576,10 +577,38 @@ describe("CreateKnowledgeSpaceDrawer", () => { expect(screen.queryByText("公开用于首页知识库获取")).not.toBeInTheDocument(); }); + test("非系统管理员编辑公共知识库时不显示门户发现开关且保存不提交该字段", async () => { + const onConfirm = jest.fn().mockResolvedValue(true); + renderDrawer({ + mode: "edit", + isSystemAdmin: false, + editingSpace: { + id: "public-1", + name: "公共知识库", + description: "", + visibility: VisibilityType.PRIVATE, + isReleased: false, + spaceLevel: SpaceLevel.PUBLIC, + portalDiscoveryEnabled: true, + autoTagEnabled: false, + autoTagLibraryIds: [], + } as any, + onConfirm, + }); + + expect(screen.queryByText("公开用于首页知识库获取")).not.toBeInTheDocument(); + await selectDefaultTagLibrary(); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + expect(onConfirm.mock.calls[0][0]).not.toHaveProperty("portalDiscoveryEnabled"); + }); + test("编辑开关仅在保存成功后由权威响应更新", async () => { const onConfirm = jest.fn().mockResolvedValue(true); renderDrawer({ mode: "edit", + isSystemAdmin: true, editingSpace: { id: "clinic-1", name: "科室知识库", diff --git a/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.tsx b/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.tsx index baac34340..75804a669 100644 --- a/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.tsx +++ b/src/frontend/client/src/pages/knowledge/CreateKnowledgeSpaceDrawer.tsx @@ -157,6 +157,8 @@ interface CreateKnowledgeSpaceDrawerProps { initialSpaceLevel?: SpaceLevel; showApprovalReason?: boolean; canEditDepartmentBinding?: boolean; + /** 仅平台系统管理员可改门户发现开关;默认 false,漏传则隐藏且保存时不提交该字段。 */ + isSystemAdmin?: boolean; } /** @@ -207,6 +209,7 @@ export function CreateKnowledgeSpaceDrawer({ initialSpaceLevel, showApprovalReason = false, canEditDepartmentBinding = false, + isSystemAdmin = false, }: CreateKnowledgeSpaceDrawerProps) { const { showToast } = useToastContext(); const confirm = useConfirm(); @@ -366,7 +369,8 @@ export function CreateKnowledgeSpaceDrawer({ const shouldShowApprovalReason = mode === "create" && showApprovalReason && initialSpaceLevel === SpaceLevel.TEAM; - const shouldShowPortalDiscoverySwitch = mode === "edit" && Boolean( + // 开关仅系统管理员可见;隐藏时 payload 不含 portalDiscoveryEnabled,库里原值保持不变。 + const shouldShowPortalDiscoverySwitch = mode === "edit" && isSystemAdmin && Boolean( editingSpace?.spaceLevel === SpaceLevel.PUBLIC || editingSpace?.spaceLevel === SpaceLevel.DEPARTMENT || editingSpace?.isClinic, diff --git a/src/frontend/client/src/pages/knowledge/index.tsx b/src/frontend/client/src/pages/knowledge/index.tsx index 2a8934856..fca1e8564 100644 --- a/src/frontend/client/src/pages/knowledge/index.tsx +++ b/src/frontend/client/src/pages/knowledge/index.tsx @@ -903,6 +903,7 @@ export default function Knowledge() { ? user?.role === "admin" || Boolean(user?.is_department_admin) : user?.role === "admin" } + isSystemAdmin={user?.role === "admin"} onViewSpace={() => setShowCreateDrawer(false)} onManageMembers={() => { setShowCreateDrawer(false); diff --git a/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.test.tsx b/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.test.tsx index 8630bf85c..c422f8bdd 100644 --- a/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.test.tsx +++ b/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.test.tsx @@ -298,7 +298,7 @@ jest.mock("~/components/ui/icon/File", () => ({ })); jest.mock("../CreateKnowledgeSpaceDrawer", () => ({ - CreateKnowledgeSpaceDrawer: ({ open, initialSpaceLevel, mode = "create", editingSpace, showApprovalReason, showSuccessManageMembers, canEditDepartmentBinding, onConfirm }: any) => { + CreateKnowledgeSpaceDrawer: ({ open, initialSpaceLevel, mode = "create", editingSpace, showApprovalReason, showSuccessManageMembers, canEditDepartmentBinding, isSystemAdmin, onConfirm }: any) => { if (!open) return null; const successManageMembersVisible = typeof showSuccessManageMembers === "function" ? showSuccessManageMembers(initialSpaceLevel) @@ -312,6 +312,7 @@ jest.mock("../CreateKnowledgeSpaceDrawer", () => ({ approvalReason:{String(Boolean(showApprovalReason))} successManageMembers:{String(successManageMembersVisible)} canEditDepartmentBinding:{String(Boolean(canEditDepartmentBinding))} + isSystemAdmin:{String(Boolean(isSystemAdmin))}