fix(knowledge): 门户发现开关仅系统管理员可配置 (#2392)

## Summary
- 知识库空间设置里「公开用于首页知识库获取」仅平台系统管理员(`admin`)可见。
- 后端:非 admin 请求只要带 `portal_discovery_enabled` 即整单拒绝,且不改
`knowledge_space_scope`。
- 非 admin 保存其它设置不提交该字段,库里原值不变。

## Test plan
- [x] pytest `test_portal_discovery_control.py`:开关成功写入、非 admin 拒绝且
SELECT 仍为 0、再保存其它字段开关不变
- [x] Jest `CreateKnowledgeSpaceDrawer.test.tsx`:admin 可见开关;非 admin 不显示且
payload 不含 `portalDiscoveryEnabled`
- [x] Jest `PortalKnowledgeWorkbench.test.tsx`:系统管理员透传
`isSystemAdmin:true`
- [ ] 联调:系统管理员编辑公共/部门/科室库可见并保存开关;空间管理员编辑时看不到开关,保存后库值不变


Made with [Cursor](https://cursor.com)
This commit is contained in:
Mr.kai
2026-08-27 06:07:05 -07:00
committed by GitHub
8 changed files with 177 additions and 4 deletions
@@ -12222,6 +12222,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
@@ -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,
@@ -562,6 +562,7 @@ describe("CreateKnowledgeSpaceDrawer", () => {
};
const { unmount } = renderDrawer({
mode: "edit",
isSystemAdmin: true,
editingSpace: {
...base,
id: "public-1",
@@ -594,10 +595,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: "科室知识库",
@@ -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();
@@ -367,7 +370,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,
@@ -909,6 +909,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);
@@ -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))}
<button
type="button"
onClick={async () => {
@@ -1877,6 +1878,7 @@ describe("PortalKnowledgeWorkbench", () => {
expect(await screen.findByTestId("create-space-drawer")).toHaveTextContent(
"canEditDepartmentBinding:true",
);
expect(screen.getByTestId("create-space-drawer")).toHaveTextContent("isSystemAdmin:true");
fireEvent.click(screen.getByRole("button", { name: "提交创建" }));
await waitFor(() => expect(updateSpaceApi).toHaveBeenCalledTimes(1));
@@ -2992,6 +2992,7 @@ export default function PortalKnowledgeWorkbench() {
? false
: isSystemAdmin
}
isSystemAdmin={isSystemAdmin}
onViewCreatedSpace={() => setCreateDrawerOpen(false)}
onManageEditingSpaceMembers={() => {
setCreateDrawerOpen(false);
@@ -49,6 +49,8 @@ type PortalDialogsProps = {
pendingCreateLevel: SpaceLevel;
showSuccessManageMembers?: boolean | ((spaceLevel: SpaceLevel) => boolean);
canEditDepartmentBinding?: boolean;
/** 平台系统管理员;透传给创建/编辑抽屉,控制门户发现开关。 */
isSystemAdmin?: boolean;
onViewCreatedSpace: () => void;
onManageEditingSpaceMembers: () => void;
uploadDialogProps: ComponentProps<typeof PortalUploadDialog>;
@@ -93,6 +95,7 @@ export function PortalDialogs({
pendingCreateLevel,
showSuccessManageMembers,
canEditDepartmentBinding = false,
isSystemAdmin = false,
onViewCreatedSpace,
onManageEditingSpaceMembers,
uploadDialogProps,
@@ -202,6 +205,7 @@ export function PortalDialogs({
showApprovalReason
showSuccessManageMembers={showSuccessManageMembers}
canEditDepartmentBinding={canEditDepartmentBinding}
isSystemAdmin={isSystemAdmin}
onViewSpace={onViewCreatedSpace}
onManageMembers={onManageEditingSpaceMembers}
/>