feat(knowledge): 批量把知识移到目录或其它工作区
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user