feat: update approval and knowledge flows

This commit is contained in:
Wenruli
2026-05-24 15:41:17 +08:00
parent 5d1807886e
commit 3fcaaeee17
8 changed files with 201 additions and 14 deletions
+1 -5
View File
@@ -179,13 +179,9 @@ steps:
cd /opt/code/bisheng
echo "======= reset local changes ======="
git reset --hard HEAD
echo "======= pull latest code ======="
git pull origin feat/2.5.0-sg
git pull
echo "======= restart backend ======="
@@ -11,7 +11,7 @@ from bisheng.knowledge.domain.models.knowledge_file import (
KnowledgeFileDao,
KnowledgeFileStatus,
)
from bisheng.knowledge.domain.models.knowledge_space_scope import KnowledgeSpaceLevelEnum
from bisheng.knowledge.domain.models.knowledge_space_scope import KnowledgeSpaceLevelEnum, KnowledgeSpaceScopeDao
KNOWLEDGE_SPACE_CREATE_SCENARIO = 'knowledge_space_create_request'
FILE_PUBLISH_SCENARIO = 'knowledge_space_file_publish_request'
@@ -197,6 +197,10 @@ class KnowledgeSpaceFilePublishApprovalHandler:
return file
return None
async def _space_level(self, space_id: int) -> KnowledgeSpaceLevelEnum:
scope = await KnowledgeSpaceScopeDao.aget_by_space_id(space_id)
return scope.level if scope else KnowledgeSpaceLevelEnum.PERSONAL
async def on_approved(self, instance_id: int, payload_snapshot: dict) -> dict:
from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService
@@ -235,6 +239,12 @@ class KnowledgeSpaceFilePublishApprovalHandler:
raise ValueError('target space not found')
if not source_file or source_file.knowledge_id != source_space_id:
raise ValueError('source file not found')
source_level = await self._space_level(source_space_id)
target_level = await self._space_level(target_space_id)
if source_level not in {KnowledgeSpaceLevelEnum.TEAM, KnowledgeSpaceLevelEnum.PERSONAL}:
raise ValueError('source space must be team or personal')
if target_level not in {KnowledgeSpaceLevelEnum.PUBLIC, KnowledgeSpaceLevelEnum.DEPARTMENT}:
raise ValueError('target space must be public or department')
if source_file.status != KnowledgeFileStatus.SUCCESS.value:
raise ValueError('source file is not parsed successfully')
@@ -28,7 +28,7 @@ from bisheng.approval.domain.services.shougang_approval_handler import (
KnowledgeSpaceFilePublishApprovalHandler,
)
from bisheng.database.models.department import DepartmentDao, UserDepartmentDao
from bisheng.knowledge.domain.models.knowledge import KnowledgeDao, KnowledgeTypeEnum
from bisheng.knowledge.domain.models.knowledge import AuthTypeEnum, KnowledgeDao, KnowledgeTypeEnum
from bisheng.knowledge.domain.models.knowledge_file import (
FileType,
KnowledgeFileDao,
@@ -69,6 +69,23 @@ class ShougangApprovalService:
return True
return bool(await DepartmentDao.aget_user_admin_departments(login_user.user_id))
@staticmethod
def _enum_value(value) -> Any:
return value.value if hasattr(value, 'value') else value
@classmethod
def _is_private_personal_space_create(cls, params: dict) -> bool:
return (
cls._enum_value(params.get('space_level')) == KnowledgeSpaceLevelEnum.PERSONAL.value
and cls._enum_value(params.get('auth_type')) == AuthTypeEnum.PRIVATE.value
and not bool(params.get('is_released'))
)
async def _requires_create_approval(self, *, login_user, params: dict) -> bool:
if self._is_private_personal_space_create(params):
return False
return not await self._is_create_approval_exempt(login_user)
async def _task_approver_user_ids(self, task_ids: list[int]) -> list[int]:
approver_user_ids: list[int] = []
seen: set[int] = set()
@@ -144,7 +161,7 @@ class ShougangApprovalService:
params = self._space_create_params(req)
await space_service.validate_knowledge_space_create(**params)
return ShougangKnowledgeSpaceCreateValidateResp(
approval_required=not await self._is_create_approval_exempt(login_user)
approval_required=await self._requires_create_approval(login_user=login_user, params=params)
)
async def submit_knowledge_space_create(
@@ -157,7 +174,7 @@ class ShougangApprovalService:
params = self._space_create_params(req)
await space_service.validate_knowledge_space_create(**params)
if await self._is_create_approval_exempt(login_user):
if not await self._requires_create_approval(login_user=login_user, params=params):
created = await space_service.create_knowledge_space(**params)
get_info = getattr(space_service, 'get_space_info', None)
space_info = await get_info(created.id) if get_info else created
@@ -189,6 +206,11 @@ class ShougangApprovalService:
'applicant_user_id': login_user.user_id,
'applicant_user_name': login_user.user_name,
'applicant_department_id': applicant_department_id,
'space_level': params.get('space_level'),
'auth_type': params.get('auth_type'),
'is_released': params.get('is_released'),
'department_id': params.get('department_id'),
'user_group_id': params.get('user_group_id'),
'create_params': params,
},
)
@@ -238,9 +260,16 @@ class ShougangApprovalService:
if target_level not in {KnowledgeSpaceLevelEnum.PUBLIC, KnowledgeSpaceLevelEnum.DEPARTMENT}:
raise HTTPException(status_code=400, detail='目标知识空间必须是公共或业务域知识库')
if space_service is not None:
await space_service._require_permission_id('knowledge_space', target_space_id, 'upload_file')
await space_service._require_permission_id('knowledge_space', target_space_id, 'view_space')
return target_space
async def _space_level_for_payload(self, space) -> str:
level = getattr(space, 'space_level', None)
if level is None:
scope = await KnowledgeSpaceScopeDao.aget_by_space_id(int(space.id))
level = scope.level if scope else KnowledgeSpaceLevelEnum.PERSONAL
return self._enum_value(level)
async def list_file_publish_target_spaces(self, *, space_service) -> ShougangFilePublishTargetSpacesResp:
grouped = await space_service.get_grouped_spaces()
spaces = list(grouped.public_spaces or []) + list(grouped.department_spaces or [])
@@ -333,6 +362,7 @@ class ShougangApprovalService:
space_service=space_service,
)
target_space = await self._ensure_publish_target_space(req.target_space_id, space_service=space_service)
target_level = await self._space_level_for_payload(target_space)
target_document_title = None
if req.target_document_id:
@@ -372,10 +402,12 @@ class ShougangApprovalService:
'applicant_department_id': applicant_department_id,
'source_space_id': int(source_space.id),
'source_space_name': source_space.name,
'source_space_level': self._enum_value(source_level),
'source_file_id': int(source_file.id),
'source_file_name': file_name,
'target_space_id': int(target_space.id),
'target_space_name': target_space.name,
'target_space_level': target_level,
'target_document_id': req.target_document_id,
'target_document_title': target_document_title,
},
@@ -64,6 +64,9 @@ async def test_knowledge_space_create_submit_requires_approval_without_creating(
approval_gate.request_or_pass.assert_awaited_once()
gate_req = approval_gate.request_or_pass.await_args.args[0]
assert gate_req.scenario_code == "knowledge_space_create_request"
assert gate_req.payload_snapshot["space_level"] == KnowledgeSpaceLevelEnum.TEAM.value
assert gate_req.payload_snapshot["auth_type"] == AuthTypeEnum.PUBLIC.value
assert gate_req.payload_snapshot["is_released"] is True
assert gate_req.payload_snapshot["create_params"]["name"] == "团队资料库"
assert gate_req.payload_snapshot["create_params"]["user_group_id"] == 7
assert result["decision"] == "pending"
@@ -109,6 +112,44 @@ async def test_knowledge_space_create_submit_exempt_user_creates_directly(monkey
assert result["space"]["id"] == 88
@pytest.mark.asyncio
async def test_personal_private_space_create_skips_approval_for_regular_user(monkeypatch):
from bisheng.approval.domain.schemas.shougang_approval_schema import (
ShougangKnowledgeSpaceCreateSubmitReq,
)
from bisheng.approval.domain.services.shougang_approval_service import (
ShougangApprovalService,
)
from bisheng.knowledge.domain.models.knowledge import AuthTypeEnum
from bisheng.knowledge.domain.models.knowledge_space_scope import KnowledgeSpaceLevelEnum
created_space = SimpleNamespace(id=89)
space_service = SimpleNamespace(
validate_knowledge_space_create=AsyncMock(return_value=None),
create_knowledge_space=AsyncMock(return_value=created_space),
get_space_info=AsyncMock(return_value={"id": 89, "name": "私人资料库"}),
)
approval_gate = SimpleNamespace(request_or_pass=AsyncMock())
service = ShougangApprovalService(approval_gate=approval_gate)
monkeypatch.setattr(service, "_is_create_approval_exempt", AsyncMock(return_value=False))
result = await service.submit_knowledge_space_create(
req=ShougangKnowledgeSpaceCreateSubmitReq(
name="私人资料库",
auth_type=AuthTypeEnum.PRIVATE,
is_released=False,
space_level=KnowledgeSpaceLevelEnum.PERSONAL,
),
login_user=SimpleNamespace(user_id=11, user_name="普通用户", tenant_id=1, is_admin=lambda: False),
space_service=space_service,
)
approval_gate.request_or_pass.assert_not_called()
space_service.create_knowledge_space.assert_awaited_once()
assert result["created"] is True
assert result["space"]["id"] == 89
@pytest.mark.asyncio
async def test_file_publish_source_requires_upload_file_permission():
from bisheng.approval.domain.services.shougang_approval_service import ShougangApprovalService
@@ -137,6 +178,27 @@ async def test_file_publish_source_requires_upload_file_permission():
)
@pytest.mark.asyncio
async def test_file_publish_target_space_requires_view_space_not_upload_file(monkeypatch):
from bisheng.approval.domain.services.shougang_approval_service import ShougangApprovalService
from bisheng.knowledge.domain.models.knowledge import KnowledgeTypeEnum
from bisheng.knowledge.domain.models.knowledge_space_scope import KnowledgeSpaceLevelEnum
monkeypatch.setattr(
"bisheng.approval.domain.services.shougang_approval_service.KnowledgeDao.aquery_by_id",
AsyncMock(return_value=SimpleNamespace(id=20, type=KnowledgeTypeEnum.SPACE.value, name="公共空间")),
)
monkeypatch.setattr(
"bisheng.approval.domain.services.shougang_approval_service.KnowledgeSpaceScopeDao.aget_by_space_id",
AsyncMock(return_value=SimpleNamespace(level=KnowledgeSpaceLevelEnum.PUBLIC)),
)
space_service = SimpleNamespace(_require_permission_id=AsyncMock(return_value=None))
await ShougangApprovalService()._ensure_publish_target_space(20, space_service=space_service)
space_service._require_permission_id.assert_awaited_once_with("knowledge_space", 20, "view_space")
@pytest.mark.asyncio
async def test_file_publish_submit_requires_team_or_personal_success_file(monkeypatch):
from bisheng.approval.domain.schemas.shougang_approval_schema import ShougangFilePublishSubmitReq
@@ -172,7 +234,11 @@ async def test_file_publish_submit_requires_team_or_personal_success_file(monkey
),
)
monkeypatch.setattr(service, "_ensure_can_publish_file", AsyncMock(return_value=None))
monkeypatch.setattr(service, "_ensure_publish_target_space", AsyncMock(return_value=SimpleNamespace(id=20, name="公共空间")))
monkeypatch.setattr(
service,
"_ensure_publish_target_space",
AsyncMock(return_value=SimpleNamespace(id=20, name="公共空间", space_level=KnowledgeSpaceLevelEnum.PUBLIC.value)),
)
monkeypatch.setattr(service, "_get_primary_department_id", AsyncMock(return_value=9))
monkeypatch.setattr(service, "_task_approver_user_ids", AsyncMock(return_value=[]))
@@ -189,6 +255,8 @@ async def test_file_publish_submit_requires_team_or_personal_success_file(monkey
gate_req = service.approval_gate.request_or_pass.await_args.args[0]
assert gate_req.scenario_code == "knowledge_space_file_publish_request"
assert gate_req.payload_snapshot["source_space_level"] == KnowledgeSpaceLevelEnum.TEAM.value
assert gate_req.payload_snapshot["target_space_level"] == KnowledgeSpaceLevelEnum.PUBLIC.value
assert gate_req.payload_snapshot["source_file_id"] == 100
assert gate_req.payload_snapshot["target_space_id"] == 20
assert result["created"] is False
@@ -214,7 +282,11 @@ async def test_file_publish_submit_rejects_invalid_target_document(monkeypatch):
),
)
monkeypatch.setattr(service, "_ensure_can_publish_file", AsyncMock(return_value=None))
monkeypatch.setattr(service, "_ensure_publish_target_space", AsyncMock(return_value=SimpleNamespace(id=20, name="公共空间")))
monkeypatch.setattr(
service,
"_ensure_publish_target_space",
AsyncMock(return_value=SimpleNamespace(id=20, name="公共空间", space_level=KnowledgeSpaceLevelEnum.PUBLIC.value)),
)
version_service = SimpleNamespace(search_version_sources=AsyncMock(return_value=[]))
with pytest.raises(HTTPException) as exc_info:
@@ -371,6 +443,58 @@ async def test_file_publish_handler_links_existing_file_version_once(monkeypatch
handler._copy_file.assert_not_called()
@pytest.mark.asyncio
async def test_file_publish_handler_revalidates_source_and_target_scope_before_copy(monkeypatch):
from bisheng.approval.domain.services.shougang_approval_handler import KnowledgeSpaceFilePublishApprovalHandler
from bisheng.knowledge.domain.models.knowledge import KnowledgeTypeEnum
from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileStatus
from bisheng.knowledge.domain.models.knowledge_space_scope import KnowledgeSpaceLevelEnum
handler = KnowledgeSpaceFilePublishApprovalHandler()
monkeypatch.setattr(
"bisheng.approval.domain.services.shougang_approval_handler.KnowledgeFileDao.aget_file_by_filters",
AsyncMock(return_value=[]),
)
monkeypatch.setattr(
"bisheng.approval.domain.services.shougang_approval_handler.KnowledgeDao.aquery_by_id",
AsyncMock(
side_effect=[
SimpleNamespace(id=10, type=KnowledgeTypeEnum.SPACE.value, name="团队空间"),
SimpleNamespace(id=20, type=KnowledgeTypeEnum.SPACE.value, name="个人空间"),
]
),
)
monkeypatch.setattr(
"bisheng.approval.domain.services.shougang_approval_handler.KnowledgeFileDao.query_by_id",
AsyncMock(return_value=SimpleNamespace(id=100, knowledge_id=10, status=KnowledgeFileStatus.SUCCESS.value)),
)
monkeypatch.setattr(
"bisheng.approval.domain.services.shougang_approval_handler.KnowledgeSpaceScopeDao.aget_by_space_id",
AsyncMock(
side_effect=[
SimpleNamespace(level=KnowledgeSpaceLevelEnum.TEAM),
SimpleNamespace(level=KnowledgeSpaceLevelEnum.PERSONAL),
]
),
)
monkeypatch.setattr(handler, "_copy_file", Mock())
with pytest.raises(ValueError, match="target space must be public or department"):
await handler.on_approved(
102,
{
"tenant_id": 1,
"applicant_user_id": 11,
"applicant_user_name": "申请人",
"source_space_id": 10,
"source_file_id": 100,
"target_space_id": 20,
},
)
handler._copy_file.assert_not_called()
def test_shougang_scenarios_registered_in_default_presets():
from bisheng.approval.domain.services.approval_registry import ApprovalRegistry
@@ -69,6 +69,7 @@ export type PublishToSquare = "yes" | "no";
export interface CreateKnowledgeSpaceFormData {
name: string;
description: string;
reason?: string;
joinPolicy: JoinPolicy;
publishToSquare: PublishToSquare;
spaceLevel: SpaceLevel;
@@ -89,6 +90,7 @@ interface CreateKnowledgeSpaceDrawerProps {
mode?: "create" | "edit";
editingSpace?: KnowledgeSpace | null;
initialSpaceLevel?: SpaceLevel;
showApprovalReason?: boolean;
}
export function CreateKnowledgeSpaceDrawer({
@@ -100,12 +102,14 @@ export function CreateKnowledgeSpaceDrawer({
mode = "create",
editingSpace,
initialSpaceLevel,
showApprovalReason = false,
}: CreateKnowledgeSpaceDrawerProps) {
const { showToast } = useToastContext();
const confirm = useConfirm();
const localize = useLocalize();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [reason, setReason] = useState("");
const [joinPolicy, setJoinPolicy] = useState<JoinPolicy>("review");
const [publishToSquare, setPublishToSquare] = useState<PublishToSquare>("yes");
const [spaceLevel, setSpaceLevel] = useState<SpaceLevel>(SpaceLevel.PERSONAL);
@@ -191,6 +195,7 @@ export function CreateKnowledgeSpaceDrawer({
const resetForm = () => {
setName("");
setDescription("");
setReason("");
setJoinPolicy("review");
setPublishToSquare("yes");
setSpaceLevel(SpaceLevel.PERSONAL);
@@ -419,6 +424,7 @@ export function CreateKnowledgeSpaceDrawer({
const payload: CreateKnowledgeSpaceFormData = {
name: name.trim(),
description: description.trim(),
reason: mode === "create" && showApprovalReason ? reason.trim() || undefined : undefined,
joinPolicy,
publishToSquare: needPublishOption ? publishToSquare : "no",
spaceLevel,
@@ -674,6 +680,20 @@ export function CreateKnowledgeSpaceDrawer({
</div>
</div>
{mode === "create" && showApprovalReason && (
<div className="space-y-2">
<Label className="text-sm text-[#1D2129] font-medium">
</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="请输入申请意见"
className="min-h-[88px] rounded-[6px] border-[#E5E6EB] bg-[#fff] text-[14px]"
/>
</div>
)}
{/* 权限设置 */}
<div className="space-y-3">
<Label className="text-sm text-[#1D2129] font-medium">
@@ -138,18 +138,20 @@ jest.mock("~/components/ui/icon/File", () => ({
}));
jest.mock("../CreateKnowledgeSpaceDrawer", () => ({
CreateKnowledgeSpaceDrawer: ({ open, initialSpaceLevel, mode = "create", editingSpace, onConfirm }: any) => {
CreateKnowledgeSpaceDrawer: ({ open, initialSpaceLevel, mode = "create", editingSpace, showApprovalReason, onConfirm }: any) => {
if (!open) return null;
return (
<div data-testid="create-space-drawer">
mode:{mode}
initial:{initialSpaceLevel}
editing:{editingSpace?.name || ""}
approvalReason:{String(Boolean(showApprovalReason))}
<button
type="button"
onClick={() => onConfirm?.({
name: "新空间",
description: "说明",
reason: "申请创建团队知识库",
joinPolicy: "review",
publishToSquare: "yes",
spaceLevel: initialSpaceLevel,
@@ -584,6 +586,7 @@ describe("PortalKnowledgeWorkbench", () => {
renderWorkbench();
fireEvent.click(await screen.findByRole("button", { name: "新增团队知识库知识空间" }));
expect(screen.getByTestId("create-space-drawer")).toHaveTextContent("approvalReason:true");
fireEvent.click(screen.getByRole("button", { name: "提交创建" }));
await waitFor(() => {
@@ -597,6 +600,7 @@ describe("PortalKnowledgeWorkbench", () => {
user_group_id: undefined,
auto_tag_enabled: false,
auto_tag_library_id: null,
reason: "申请创建团队知识库",
});
});
expect(createSpaceApi).not.toHaveBeenCalled();
@@ -1041,7 +1041,7 @@ export default function PortalKnowledgeWorkbench() {
department_id: form.departmentId,
user_group_id: form.userGroupId,
auto_tag_enabled: form.autoTagEnabled,
auto_tag_library_id: form.autoTagLibraryId,
auto_tag_library_id: form.autoTagLibraryId, reason: form.reason,
});
if (result.created && result.space) {
@@ -381,6 +381,7 @@ export function PortalDialogs({
mode={editingSpace ? "edit" : "create"}
editingSpace={editingSpace}
initialSpaceLevel={pendingCreateLevel}
showApprovalReason
onViewSpace={onViewCreatedSpace}
onManageMembers={onManageEditingSpaceMembers}
/>