From dcd77c6b354a9636281fdf54aa24cbebd7e8c64a Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 7 Nov 2026 01:50:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E6=89=B9=E9=87=8F=E6=8A=8A?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E7=A7=BB=E5=88=B0=E7=9B=AE=E5=BD=95=E6=88=96?= =?UTF-8?q?=E5=85=B6=E5=AE=83=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/knowledge.py | 308 +++++++++++++++++++++++++++++++- backend/tests/test_knowledge.py | 224 +++++++++++++++++++++++ 2 files changed, 530 insertions(+), 2 deletions(-) diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index a012ffb..2ab87c3 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -22,13 +22,16 @@ from typing import Annotated, Any from fastapi import APIRouter, Query from pydantic import BaseModel, Field -from sqlalchemy import Select, and_, delete, func, or_, select, update +from sqlalchemy import Select, and_, delete, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from ..audit import record as audit_record +from ..db import bind_tenant from ..deps import MemberDep, SessionDep from ..domain.knowledge import can_publish_role, sidebar_fields, target_status from ..errors import AppError, ErrorCode +from ..models import Member, Workspace +from ..models_attachment import Attachment from ..models_knowledge import ( DOC_TYPES, KNOWLEDGE_STATUSES, @@ -37,7 +40,7 @@ from ..models_knowledge import ( KnowledgeCategory, KnowledgeDoc, ) -from ..models_task import Activity, Item, ItemTag, Tag +from ..models_task import Activity, Comment, Item, ItemTag, Tag from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok from ..serializers import item_out, tags_of from ..services.knowledge import create_draft_from_source, find_doc_by_source @@ -95,6 +98,14 @@ class StatusActionIn(BaseModel): note: str = "" +class BulkMoveIn(BaseModel): + item_ids: list[uuid.UUID] = Field(min_length=1, max_length=100) + # 不传 = 留在当前工作区;传了就必须是自己是成员的工作区 + target_workspace_id: uuid.UUID | None = None + # 不传 = 移动到"未分类" + category_id: uuid.UUID | None = None + + # ── 内部工具 ──────────────────────────────────────────────────────────── async def _load_doc( session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, *, include_deleted: bool = False @@ -164,6 +175,11 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]: } +def _row_values(row: Any) -> dict[str, Any]: + """把一行 ORM 对象摊平成可原样 INSERT 的字典(跨租户搬家时用)。""" + return {column.name: getattr(row, column.name) for column in row.__table__.columns} + + # ── 分类 ──────────────────────────────────────────────────────────────── _COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") @@ -653,6 +669,294 @@ async def search( return ok({"query": q, "mode": mode, "items": hits, "has_more": False}) +@router.post("/knowledge/bulk-move", summary="批量移动知识到目录 / 其它工作区") +async def bulk_move_knowledge(body: BulkMoveIn, ctx: MemberDep, session: SessionDep) -> dict: + """把多条知识一起搬到另一个目录,或搬到另一个工作区的某个目录。 + + 同一工作区只是改 `knowledge_doc.category_id`;跨工作区是"真的搬家": + RLS 下一条 UPDATE 没法既让源租户看得见旧行、又让目标租户放行新行, + 所以在同一个事务里先读完整快照、按 id 删掉源条目(外键级联清子表), + 再切到目标租户按同样的 id 写回。失败整体回滚,不会出现"搬了一半"。 + """ + ws_id = ctx.ws_id + target_ws = body.target_workspace_id or ws_id + item_ids = list(dict.fromkeys(body.item_ids)) + cross = target_ws != ws_id + + rows = ( + await session.execute( + select(Item, KnowledgeDoc) + .join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id) + .where( + Item.id.in_(item_ids), + Item.workspace_id == ws_id, + Item.kind == "knowledge", + Item.deleted_at.is_(None), + ) + ) + ).all() + found = {item.id for item, _doc in rows} + missing = [str(i) for i in item_ids if i not in found] + if missing: + raise AppError( + ErrorCode.KNOWLEDGE_NOT_FOUND, + "有知识不存在或已被删除", + details={"missing": missing}, + ) + + if cross: + # 移出工作区等于把它从团队手里拿走,和删除同一条权限线 + for item, _doc in rows: + if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"): + raise AppError( + ErrorCode.INSUFFICIENT_ROLE, + f"「{item.title}」只有作者本人或管理员能移出工作区", + ) + # member 表的策略允许看到"自己的成员记录",所以不用切租户就能确认目标身份 + target_member = ( + await session.execute( + select(Member).where( + Member.workspace_id == target_ws, + Member.user_id == ctx.user.id, + Member.status == "active", + ) + ) + ).scalar_one_or_none() + if target_member is None or target_member.role not in ("owner", "admin", "member"): + raise AppError(ErrorCode.INSUFFICIENT_ROLE, "你不是目标工作区的成员,不能移入") + # 目标租户的工作区和分类要切过去才看得见(RLS 只放行当前租户) + await bind_tenant(session, target_ws, ctx.user.id) + try: + target_ws_row = await session.get(Workspace, target_ws) + if target_ws_row is None or target_ws_row.deleted_at is not None: + raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "目标工作区不存在") + category = await _resolve_category(session, target_ws, body.category_id) + finally: + await bind_tenant(session, ws_id, ctx.user.id) + else: + category = await _resolve_category(session, ws_id, body.category_id) + + category_id = category.id if category else None + now = datetime.now(timezone.utc) + + if not cross: + await session.execute( + update(KnowledgeDoc) + .where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.item_id.in_(item_ids)) + .values(category_id=category_id) + ) + # 分类也算这条知识的"样子"变了,推进 version,免得别处拿着旧版本号撞 4009 + await session.execute( + update(Item) + .where(Item.workspace_id == ws_id, Item.id.in_(item_ids)) + .values(version=Item.version + 1, updated_at=now) + ) + for item, _doc in rows: + await _log( + session, + item, + ctx.user.id, + "knowledge_moved", + {"category_id": str(category_id) if category_id else None}, + ) + await audit_record( + session, + workspace_id=ws_id, + actor_user_id=ctx.user.id, + action="knowledge.moved", + target_type="knowledge_bulk", + target_id=str(len(item_ids)), + payload={ + "item_ids": [str(i) for i in item_ids], + "category_id": str(category_id) if category_id else None, + }, + ) + await session.flush() + return ok( + { + "moved": [str(i) for i in item_ids], + "workspace_id": str(ws_id), + "category_id": str(category_id) if category_id else None, + } + ) + + # ── 跨工作区:先快照,再删,再在目标租户写回 ────────────────────────── + item_values = [_row_values(item) for item, _doc in rows] + doc_values = [_row_values(doc) for _item, doc in rows] + moved = {item.id for item, _doc in rows} + + tag_pairs = ( + await session.execute( + select(ItemTag.item_id, Tag.name, Tag.color) + .join(Tag, Tag.id == ItemTag.tag_id) + .where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(moved)) + ) + ).all() + tags_by_item: dict[uuid.UUID, list[str]] = {} + tag_colors: dict[str, str] = {} + for item_id, name, color in tag_pairs: + tags_by_item.setdefault(item_id, []).append(name) + tag_colors.setdefault(name, color or "") + + comment_values = [ + _row_values(row) + for row in ( + await session.execute( + select(Comment).where(Comment.workspace_id == ws_id, Comment.item_id.in_(moved)) + ) + ).scalars() + ] + activity_values = [ + _row_values(row) + for row in ( + await session.execute( + select(Activity).where(Activity.workspace_id == ws_id, Activity.item_id.in_(moved)) + ) + ).scalars() + ] + attachment_values = [ + _row_values(row) + for row in ( + await session.execute( + select(Attachment).where( + Attachment.workspace_id == ws_id, Attachment.item_id.in_(moved) + ) + ) + ).scalars() + ] + chunk_values = [ + _row_values(row) + for row in ( + await session.execute( + select(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id.in_(moved)) + ) + ).scalars() + ] + + # 被搬走的知识如果还是本工作区别的知识的"来源",先把引用改成手动来源: + # 否则删除源条目时 FK SET NULL 会撞上 source_kind 的 CHECK 约束。 + await session.execute( + update(KnowledgeDoc) + .where( + KnowledgeDoc.workspace_id == ws_id, + KnowledgeDoc.source_item_id.in_(moved), + KnowledgeDoc.item_id.not_in(moved), + ) + .values(source_item_id=None, source_kind="manual") + ) + await session.execute(delete(Item).where(Item.workspace_id == ws_id, Item.id.in_(moved))) + session.expunge_all() + + await audit_record( + session, + workspace_id=ws_id, + actor_user_id=ctx.user.id, + action="knowledge.moved_out", + target_type="knowledge_bulk", + target_id=str(len(item_ids)), + payload={ + "item_ids": [str(i) for i in item_ids], + "target_workspace_id": str(target_ws), + "category_id": str(category_id) if category_id else None, + }, + ) + + await bind_tenant(session, target_ws, ctx.user.id) + existing_tags = { + tag.name: tag + for tag in ( + await session.execute(select(Tag).where(Tag.workspace_id == target_ws)) + ).scalars() + } + created_tags = [ + Tag(workspace_id=target_ws, name=name, color=color) + for name, color in tag_colors.items() + if name not in existing_tags + ] + if created_tags: + session.add_all(created_tags) + await session.flush() + tag_ids = {name: tag.id for name, tag in existing_tags.items()} + tag_ids.update({tag.name: tag.id for tag in created_tags}) + + for values in item_values: + values["workspace_id"] = target_ws + for values in doc_values: + values["workspace_id"] = target_ws + values["category_id"] = category_id + source_item_id = values.get("source_item_id") + if source_item_id is not None and source_item_id not in moved: + # 来源留在原工作区,目标工作区里不该留一条跨租户的外键 + values["source_item_id"] = None + values["source_kind"] = "manual" + + await session.execute(insert(Item), item_values) + await session.execute(insert(KnowledgeDoc), doc_values) + + item_tag_values = [ + { + "id": uuid.uuid4(), + "workspace_id": target_ws, + "item_id": item_id, + "tag_id": tag_ids[name], + "created_at": now, + "updated_at": now, + "version": 1, + } + for item_id, names in tags_by_item.items() + for name in names + ] + # 子表全部跟着换租户:RLS 的 WITH CHECK 只看新行,漏一个就整单回滚 + for child_values in (comment_values, activity_values, attachment_values, chunk_values): + for row in child_values: + row["workspace_id"] = target_ws + for table, values in ( + (ItemTag, item_tag_values), + (Comment, comment_values), + (Activity, activity_values), + (Attachment, attachment_values), + (Chunk, chunk_values), + ): + if values: + await session.execute(insert(table), values) + + for item_id in item_ids: + session.add( + Activity( + workspace_id=target_ws, + item_id=item_id, + actor_id=ctx.user.id, + action="knowledge_moved", + payload={ + "from_workspace_id": str(ws_id), + "category_id": str(category_id) if category_id else None, + }, + ) + ) + await session.flush() + await audit_record( + session, + workspace_id=target_ws, + actor_user_id=ctx.user.id, + action="knowledge.moved_in", + target_type="knowledge_bulk", + target_id=str(len(item_ids)), + payload={ + "item_ids": [str(i) for i in item_ids], + "from_workspace_id": str(ws_id), + "category_id": str(category_id) if category_id else None, + }, + ) + await bind_tenant(session, ws_id, ctx.user.id) + return ok( + { + "moved": [str(i) for i in item_ids], + "workspace_id": str(target_ws), + "category_id": str(category_id) if category_id else None, + } + ) + + @router.get("/knowledge/{item_id}", summary="知识详情") async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict: item, doc = await _load_doc(session, ctx.ws_id, item_id) diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index d9e9c62..d7f500a 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -538,3 +538,227 @@ async def test_tags_endpoint_counts_documents(client: AsyncClient) -> None: ) counts = {row["name"]: row["count"] for row in got["items"]} assert counts == {"发布": 2, "回滚": 1} + + +async def _category( + client: AsyncClient, user: dict[str, Any], ws_id: str, name: str +) -> dict[str, Any]: + return unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/knowledge/categories", + json={"name": name}, + headers=user["headers"], + ) + ) + + +async def _note( + client: AsyncClient, user: dict[str, Any], ws_id: str, title: str, **extra: Any +) -> dict[str, Any]: + return unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/knowledge", + json={"title": title, "doc_type": "note", **extra}, + headers=user["headers"], + ) + ) + + +async def _bulk_move( + client: AsyncClient, user: dict[str, Any], ws_id: str, body: dict[str, Any] +) -> dict[str, Any]: + return unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/knowledge/bulk-move", + json=body, + headers=user["headers"], + ) + ) + + +@pytest.mark.asyncio +async def test_bulk_move_into_category_of_same_workspace(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "整理知识") + cat = await _category(client, owner, ws["id"], "制度") + first = await _note(client, owner, ws["id"], "报销要贴发票原件") + second = await _note(client, owner, ws["id"], "请假提前一天提") + + moved = await _bulk_move( + client, + owner, + ws["id"], + {"item_ids": [first["id"], second["id"]], "category_id": cat["id"]}, + ) + assert sorted(moved["moved"]) == sorted([first["id"], second["id"]]) + assert moved["workspace_id"] == ws["id"] + assert moved["category_id"] == cat["id"] + + listed = unwrap( + await client.get( + f"{V1}/workspaces/{ws['id']}/knowledge", + params={"category_id": cat["id"]}, + headers=owner["headers"], + ) + ) + assert sorted(i["id"] for i in listed["items"]) == sorted([first["id"], second["id"]]) + assert all(i["category"]["id"] == cat["id"] for i in listed["items"]) + + # 不动目录也照样能移(移动到"未分类") + uncategorized = await _bulk_move(client, owner, ws["id"], {"item_ids": [first["id"]]}) + assert uncategorized["category_id"] is None + + +@pytest.mark.asyncio +async def test_bulk_move_across_workspaces_keeps_tags_and_children(client: AsyncClient) -> None: + owner = await login(client) + src = await create_workspace(client, owner, "来源库") + dst = await create_workspace(client, owner, "目标库") + cat = await _category(client, owner, dst["id"], "运维") + doc = await _note(client, owner, src["id"], "重启网关的顺序", tag_names=["运维", "网关"]) + + moved = await _bulk_move( + client, + owner, + src["id"], + { + "item_ids": [doc["id"]], + "target_workspace_id": dst["id"], + "category_id": cat["id"], + }, + ) + assert moved["workspace_id"] == dst["id"] + assert moved["category_id"] == cat["id"] + + gone = await client.get( + f"{V1}/workspaces/{src['id']}/knowledge/{doc['id']}", headers=owner["headers"] + ) + assert gone.json()["code"] == 7001 + + got = unwrap( + await client.get( + f"{V1}/workspaces/{dst['id']}/knowledge/{doc['id']}", headers=owner["headers"] + ) + ) + assert got["id"] == doc["id"] + assert got["knowledge"]["category_id"] == cat["id"] + assert sorted(t["name"] for t in got["tags"]) == ["网关", "运维"] + assert got["knowledge"]["source_kind"] == "manual" + + # 标签本身也在目标工作区里建好了,不是只在关联表里挂了个空壳 + tags = unwrap( + await client.get(f"{V1}/workspaces/{dst['id']}/knowledge/tags", headers=owner["headers"]) + ) + assert {row["name"] for row in tags["items"]} == {"运维", "网关"} + + # 历史事件也搬过来了,不然详情页会像凭空出现的一篇 + actions = {e["action"] for e in got["activities"]} + assert {"knowledge_created", "knowledge_moved"} <= actions + + +@pytest.mark.asyncio +async def test_bulk_move_ticket_source_becomes_manual(client: AsyncClient) -> None: + owner = await login(client) + src = await create_workspace(client, owner, "有来源的库") + dst = await create_workspace(client, owner, "接手的库") + helper = await _member(client, owner, src) + task = await _task(client, owner, src["id"], "网关老是超时") + ticket = await _finish_ticket(client, owner, src["id"], helper, task) + # 验收通过时系统已经自动沉淀了一份草稿,直接拿它 + listed = unwrap( + await client.get( + f"{V1}/workspaces/{src['id']}/knowledge", + params={"source_item_id": ticket["id"]}, + headers=owner["headers"], + ) + ) + doc = listed["items"][0] + + moved = await _bulk_move( + client, + owner, + src["id"], + {"item_ids": [doc["id"]], "target_workspace_id": dst["id"]}, + ) + assert moved["moved"] == [doc["id"]] + + got = unwrap( + await client.get( + f"{V1}/workspaces/{dst['id']}/knowledge/{doc['id']}", headers=owner["headers"] + ) + ) + # 目标库里没有那张工单,来源必须降级成手动,否则指向一个看不见的 id + assert got["knowledge"]["source_kind"] == "manual" + assert got["knowledge"]["source_item_id"] is None + assert got["source"] is None + + +@pytest.mark.asyncio +async def test_bulk_move_requires_membership_in_target(client: AsyncClient) -> None: + owner = await login(client) + src = await create_workspace(client, owner, "自己的库") + stranger = await login(client) + dst = await create_workspace(client, stranger, "别人的库") + doc = await _note(client, owner, src["id"], "我的笔记") + + resp = await client.post( + f"{V1}/workspaces/{src['id']}/knowledge/bulk-move", + json={"item_ids": [doc["id"]], "target_workspace_id": dst["id"]}, + headers=owner["headers"], + ) + assert resp.json()["code"] == 3004 + + +@pytest.mark.asyncio +async def test_bulk_move_rejects_foreign_category_and_missing_docs(client: AsyncClient) -> None: + owner = await login(client) + src = await create_workspace(client, owner, "甲库") + dst = await create_workspace(client, owner, "乙库") + src_cat = await _category(client, owner, src["id"], "甲的分类") + doc = await _note(client, owner, src["id"], "一篇") + + foreign = await client.post( + f"{V1}/workspaces/{src['id']}/knowledge/bulk-move", + json={ + "item_ids": [doc["id"]], + "target_workspace_id": dst["id"], + "category_id": src_cat["id"], + }, + headers=owner["headers"], + ) + assert foreign.json()["code"] == 7006 + + missing = await client.post( + f"{V1}/workspaces/{src['id']}/knowledge/bulk-move", + json={"item_ids": ["00000000-0000-0000-0000-000000000001"]}, + headers=owner["headers"], + ) + assert missing.json()["code"] == 7001 + assert missing.json()["data"]["missing"] == ["00000000-0000-0000-0000-000000000001"] + + # 移出后源库再移一次要报 7001,而不是静默成功 + await _bulk_move( + client, owner, src["id"], {"item_ids": [doc["id"]], "target_workspace_id": dst["id"]} + ) + again = await client.post( + f"{V1}/workspaces/{src['id']}/knowledge/bulk-move", + json={"item_ids": [doc["id"]]}, + headers=owner["headers"], + ) + assert again.json()["code"] == 7001 + + +@pytest.mark.asyncio +async def test_bulk_move_cannot_touch_another_tenants_knowledge(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "甲的工作区") + doc = await _note(client, owner, ws["id"], "甲的秘密") + outsider = await login(client) + own = await create_workspace(client, outsider, "乙的工作区") + + resp = await client.post( + f"{V1}/workspaces/{own['id']}/knowledge/bulk-move", + json={"item_ids": [doc["id"]]}, + headers=outsider["headers"], + ) + assert resp.json()["code"] == 7001