知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建 就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开 详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签 筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。 后端 - 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用 `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、 RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE SET NULL。 - 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、 跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树 一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。 - 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带 `category`(含 `path` / `full_path`)。 - PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞 乐观锁 `4009`。 Web - `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一 加宽(`TaskDetail` / `Tickets` / `Knowledge`)。 - 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer` 一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类 与标签。 - 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。 安卓 - `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、 列表带分类与标签筛选。 - 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表 顶部按分类筛选,详情里能改分类与标签后单独保存。 文档与测试 - `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节, 错误码补 `7006`/`7007`/`7008`。 - `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。 - 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、 三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需 `force`、跨租户隔离。 - 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次 写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。 验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、 `tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。 安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
This commit is contained in:
@@ -21,15 +21,21 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Select, and_, or_, select, update
|
||||
from sqlalchemy import Select, and_, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..audit import record as audit_record
|
||||
from ..deps import MemberDep, SessionDep
|
||||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc
|
||||
from ..models_task import Activity, Item
|
||||
from ..models_knowledge import (
|
||||
DOC_TYPES,
|
||||
KNOWLEDGE_STATUSES,
|
||||
MAX_CATEGORY_DEPTH,
|
||||
KnowledgeCategory,
|
||||
KnowledgeDoc,
|
||||
)
|
||||
from ..models_task import Activity, 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
|
||||
@@ -47,6 +53,7 @@ class KnowledgeIn(BaseModel):
|
||||
doc_type: str = "note"
|
||||
source_item_id: uuid.UUID | None = None
|
||||
tag_names: list[str] = Field(default_factory=list)
|
||||
category_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class KnowledgePatch(BaseModel):
|
||||
@@ -55,6 +62,24 @@ class KnowledgePatch(BaseModel):
|
||||
summary: str | None = None
|
||||
doc_type: str | None = None
|
||||
tag_names: list[str] | None = None
|
||||
category_id: uuid.UUID | None = None
|
||||
# 单独一个开关:`category_id=None` 既可能表示"没改",也可能表示"清空分类",
|
||||
# 靠 pydantic 的未传/传 null 区分太隐晦,不如显式给一个。
|
||||
clear_category: bool = False
|
||||
version: int
|
||||
|
||||
|
||||
class CategoryIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=40)
|
||||
parent_id: uuid.UUID | None = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CategoryPatch(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=40)
|
||||
parent_id: uuid.UUID | None = None
|
||||
move: bool = False
|
||||
sort_order: int | None = None
|
||||
version: int
|
||||
|
||||
|
||||
@@ -112,6 +137,7 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
"status_hint": doc.status_hint,
|
||||
"source_kind": doc.source_kind,
|
||||
"source_item_id": str(doc.source_item_id) if doc.source_item_id else None,
|
||||
"category_id": str(doc.category_id) if doc.category_id else None,
|
||||
"generated_by": doc.generated_by,
|
||||
"published_at": doc.published_at,
|
||||
"archived_at": doc.archived_at,
|
||||
@@ -120,12 +146,96 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ── 分类 ────────────────────────────────────────────────────────────────
|
||||
async def _categories(
|
||||
session: AsyncSession, ws_id: uuid.UUID
|
||||
) -> dict[uuid.UUID, KnowledgeCategory]:
|
||||
"""一次把分类全捞出来。分类是工作区级的小字典(几十条),比按需查祖先便宜。"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(KnowledgeCategory).where(KnowledgeCategory.workspace_id == ws_id)
|
||||
)
|
||||
).scalars()
|
||||
return {row.id: row for row in rows}
|
||||
|
||||
|
||||
def _category_payload(
|
||||
cat: KnowledgeCategory, by_id: dict[uuid.UUID, KnowledgeCategory]
|
||||
) -> dict[str, Any]:
|
||||
"""分类带上完整路径:`研发 / 部署 / 回滚`。客户端不用自己去拼。"""
|
||||
names: list[str] = []
|
||||
node: KnowledgeCategory | None = cat
|
||||
while node is not None and len(names) < MAX_CATEGORY_DEPTH:
|
||||
names.append(node.name)
|
||||
node = by_id.get(node.parent_id) if node.parent_id else None
|
||||
names.reverse()
|
||||
return {
|
||||
"id": str(cat.id),
|
||||
"parent_id": str(cat.parent_id) if cat.parent_id else None,
|
||||
"name": cat.name,
|
||||
"depth": cat.depth,
|
||||
"sort_order": cat.sort_order,
|
||||
"version": cat.version,
|
||||
"path": names,
|
||||
"full_path": " / ".join(names),
|
||||
}
|
||||
|
||||
|
||||
def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]:
|
||||
children: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||
for cat in by_id.values():
|
||||
if cat.parent_id:
|
||||
children.setdefault(cat.parent_id, []).append(cat.id)
|
||||
out = [root_id]
|
||||
stack = [root_id]
|
||||
while stack:
|
||||
for child in children.get(stack.pop(), []):
|
||||
out.append(child)
|
||||
stack.append(child)
|
||||
return out
|
||||
|
||||
|
||||
async def _resolve_category(
|
||||
session: AsyncSession, ws_id: uuid.UUID, category_id: uuid.UUID | None
|
||||
) -> KnowledgeCategory | None:
|
||||
if category_id is None:
|
||||
return None
|
||||
cat = await session.get(KnowledgeCategory, category_id)
|
||||
if cat is None or cat.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
return cat
|
||||
|
||||
|
||||
async def _category_name_taken(
|
||||
session: AsyncSession,
|
||||
ws_id: uuid.UUID,
|
||||
parent_id: uuid.UUID | None,
|
||||
name: str,
|
||||
exclude: uuid.UUID | None = None,
|
||||
) -> bool:
|
||||
stmt = select(KnowledgeCategory.id).where(
|
||||
KnowledgeCategory.workspace_id == ws_id,
|
||||
func.lower(KnowledgeCategory.name) == name.strip().lower(),
|
||||
)
|
||||
stmt = (
|
||||
stmt.where(KnowledgeCategory.parent_id.is_(None))
|
||||
if parent_id is None
|
||||
else stmt.where(KnowledgeCategory.parent_id == parent_id)
|
||||
)
|
||||
if exclude is not None:
|
||||
stmt = stmt.where(KnowledgeCategory.id != exclude)
|
||||
return (await session.execute(stmt)).first() is not None
|
||||
|
||||
|
||||
async def _knowledge_out(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
|
||||
) -> dict[str, Any]:
|
||||
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
|
||||
data = item_out(item, tags)
|
||||
data["knowledge"] = _doc_out(doc)
|
||||
by_id = await _categories(session, ws_id)
|
||||
cat = by_id.get(doc.category_id) if doc.category_id else None
|
||||
data["category"] = _category_payload(cat, by_id) if cat else None
|
||||
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||
data["attachments"] = await list_for_item(session, ws_id, item.id)
|
||||
data["attachment_count"] = len(data["attachments"])
|
||||
@@ -148,6 +258,9 @@ async def list_knowledge(
|
||||
status: Annotated[list[str] | None, Query()] = None,
|
||||
doc_type: Annotated[str | None, Query()] = None,
|
||||
source_item_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
category_id: Annotated[uuid.UUID | None, Query(description="含子分类")] = None,
|
||||
uncategorized: Annotated[bool, Query(description="只看未分类")] = False,
|
||||
tag: Annotated[str | None, Query(max_length=32)] = None,
|
||||
q: Annotated[str | None, Query(max_length=100)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
|
||||
cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None,
|
||||
@@ -165,6 +278,21 @@ async def list_knowledge(
|
||||
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
|
||||
if source_item_id:
|
||||
stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id)
|
||||
by_id: dict[uuid.UUID, KnowledgeCategory] = {}
|
||||
if uncategorized:
|
||||
stmt = stmt.where(KnowledgeDoc.category_id.is_(None))
|
||||
elif category_id is not None:
|
||||
by_id = await _categories(session, ws_id)
|
||||
cat = by_id.get(category_id)
|
||||
if cat is None:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
stmt = stmt.where(KnowledgeDoc.category_id.in_(_subtree_ids(category_id, by_id)))
|
||||
if tag:
|
||||
stmt = (
|
||||
stmt.join(ItemTag, ItemTag.item_id == Item.id)
|
||||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||||
.where(Tag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower())
|
||||
)
|
||||
if q:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
@@ -188,12 +316,16 @@ async def list_knowledge(
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
|
||||
if not by_id:
|
||||
by_id = await _categories(session, ws_id)
|
||||
|
||||
items = []
|
||||
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
|
||||
for item, doc in rows:
|
||||
data = item_out(item, tag_map.get(item.id, []))
|
||||
data["knowledge"] = _doc_out(doc)
|
||||
cat = by_id.get(doc.category_id) if doc.category_id else None
|
||||
data["category"] = _category_payload(cat, by_id) if cat else None
|
||||
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||
data["attachment_count"] = counts.get(str(item.id), 0)
|
||||
items.append(data)
|
||||
@@ -204,6 +336,207 @@ async def list_knowledge(
|
||||
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
|
||||
|
||||
|
||||
# ── 分类的增删改查 ──────────────────────────────────────────────────────
|
||||
# 路由顺序:`/knowledge/categories` 必须排在 `/knowledge/{item_id}` 之前,
|
||||
# 否则 "categories" 会被当成 uuid 解析,直接 422。
|
||||
@router.get("/knowledge/categories", summary="知识分类")
|
||||
async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
|
||||
by_id = await _categories(session, ctx.ws_id)
|
||||
counts = dict(
|
||||
(
|
||||
await session.execute(
|
||||
select(KnowledgeDoc.category_id, func.count())
|
||||
.join(Item, Item.id == KnowledgeDoc.item_id)
|
||||
.where(
|
||||
KnowledgeDoc.workspace_id == ctx.ws_id,
|
||||
KnowledgeDoc.category_id.is_not(None),
|
||||
KnowledgeDoc.status_hint != "archived",
|
||||
Item.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(KnowledgeDoc.category_id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
items = []
|
||||
for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)):
|
||||
data = _category_payload(cat, by_id)
|
||||
data["doc_count"] = counts.get(cat.id, 0)
|
||||
items.append(data)
|
||||
return ok({"items": items, "max_depth": MAX_CATEGORY_DEPTH})
|
||||
|
||||
|
||||
@router.post("/knowledge/categories", summary="新建分类")
|
||||
async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
depth = 1
|
||||
if body.parent_id is not None:
|
||||
parent = await _resolve_category(session, ws_id, body.parent_id)
|
||||
depth = parent.depth + 1
|
||||
if depth > MAX_CATEGORY_DEPTH:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
|
||||
f"分类最多 {MAX_CATEGORY_DEPTH} 级,「{parent.name}」下面不能再建了",
|
||||
details={"parent_depth": parent.depth},
|
||||
)
|
||||
name = body.name.strip()
|
||||
if await _category_name_taken(session, ws_id, body.parent_id, name):
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
|
||||
cat = KnowledgeCategory(
|
||||
workspace_id=ws_id,
|
||||
parent_id=body.parent_id,
|
||||
name=name,
|
||||
depth=depth,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
session.add(cat)
|
||||
await session.flush()
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.category.create",
|
||||
target_type="knowledge_category",
|
||||
target_id=str(cat.id),
|
||||
)
|
||||
by_id = await _categories(session, ws_id)
|
||||
return ok(_category_payload(cat, by_id))
|
||||
|
||||
|
||||
@router.patch("/knowledge/categories/{category_id}", summary="改名或移动分类")
|
||||
async def patch_category(
|
||||
category_id: uuid.UUID, body: CategoryPatch, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
cat = await session.get(KnowledgeCategory, category_id)
|
||||
if cat is None or cat.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
by_id = await _categories(session, ws_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
new_parent_id = cat.parent_id
|
||||
new_depth = cat.depth
|
||||
shift = 0
|
||||
subtree: list[uuid.UUID] = []
|
||||
if body.move:
|
||||
new_parent_id = body.parent_id
|
||||
if new_parent_id is not None and new_parent_id not in by_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
if new_parent_id == category_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类挂到自己下面")
|
||||
subtree = _subtree_ids(category_id, by_id)
|
||||
if new_parent_id is not None and new_parent_id in subtree:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类移到它自己的子分类下")
|
||||
new_depth = by_id[new_parent_id].depth + 1 if new_parent_id else 1
|
||||
deepest = max(by_id[cid].depth for cid in subtree)
|
||||
shift = new_depth - cat.depth
|
||||
if deepest + shift > MAX_CATEGORY_DEPTH:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
|
||||
f"移动后子树最深到第 {deepest + shift} 级,超过 {MAX_CATEGORY_DEPTH} 级",
|
||||
details={"deepest_after_move": deepest + shift},
|
||||
)
|
||||
|
||||
name = body.name.strip() if body.name is not None else cat.name
|
||||
if name != cat.name and await _category_name_taken(
|
||||
session, ws_id, new_parent_id, name, exclude=category_id
|
||||
):
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
|
||||
|
||||
values: dict[str, Any] = {"name": name, "updated_at": now}
|
||||
if body.move:
|
||||
values.update(parent_id=new_parent_id, depth=new_depth)
|
||||
if body.sort_order is not None:
|
||||
values["sort_order"] = body.sort_order
|
||||
|
||||
result = await session.execute(
|
||||
update(KnowledgeCategory)
|
||||
.where(
|
||||
KnowledgeCategory.id == category_id,
|
||||
KnowledgeCategory.workspace_id == ws_id,
|
||||
KnowledgeCategory.version == body.version,
|
||||
)
|
||||
.values(version=KnowledgeCategory.version + 1, **values)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
await session.refresh(cat)
|
||||
raise AppError(
|
||||
ErrorCode.VERSION_CONFLICT,
|
||||
"这个分类刚被改过",
|
||||
details={"current_version": cat.version},
|
||||
)
|
||||
|
||||
# 子树跟着整体升降;只改被移动的那棵子树以外的行会留下 depth 不一致
|
||||
if body.move and shift:
|
||||
for cid in subtree:
|
||||
if cid == category_id:
|
||||
continue
|
||||
await session.execute(
|
||||
update(KnowledgeCategory)
|
||||
.where(KnowledgeCategory.id == cid, KnowledgeCategory.workspace_id == ws_id)
|
||||
.values(
|
||||
depth=KnowledgeCategory.depth + shift,
|
||||
updated_at=now,
|
||||
version=KnowledgeCategory.version + 1,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
fresh = await _categories(session, ws_id)
|
||||
return ok(_category_payload(fresh[category_id], fresh))
|
||||
|
||||
|
||||
@router.delete("/knowledge/categories/{category_id}", summary="删除分类")
|
||||
async def delete_category(
|
||||
category_id: uuid.UUID,
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
force: Annotated[bool, Query(description="真删:里面的知识变成未分类")] = False,
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
cat = await session.get(KnowledgeCategory, category_id)
|
||||
if cat is None or cat.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
children = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(KnowledgeCategory)
|
||||
.where(
|
||||
KnowledgeCategory.workspace_id == ws_id,
|
||||
KnowledgeCategory.parent_id == category_id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
if children:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
|
||||
f"「{cat.name}」下面还有 {children} 个子分类,先把它们移走或删掉",
|
||||
details={"children": children},
|
||||
)
|
||||
docs = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(KnowledgeDoc)
|
||||
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.category_id == category_id)
|
||||
)
|
||||
).scalar_one()
|
||||
if docs and not force:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
|
||||
f"「{cat.name}」里还有 {docs} 条知识,先移走,或者带 force 删成未分类",
|
||||
details={"doc_count": docs},
|
||||
)
|
||||
await session.delete(cat)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.category.delete",
|
||||
target_type="knowledge_category",
|
||||
target_id=str(category_id),
|
||||
payload={"orphaned_docs": docs},
|
||||
)
|
||||
return ok({"deleted": str(category_id), "uncategorized_docs": docs})
|
||||
|
||||
|
||||
# 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
|
||||
# 否则 "search" 会被当成 uuid 解析,返回 422。
|
||||
@router.get("/knowledge/search", summary="检索已发布知识")
|
||||
@@ -257,6 +590,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
|
||||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
|
||||
"来源必须是与本知识同一工作区的任务或工单",
|
||||
)
|
||||
category = await _resolve_category(session, ws_id, body.category_id)
|
||||
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
@@ -278,6 +612,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
|
||||
doc_type=body.doc_type,
|
||||
status_hint="draft",
|
||||
summary=body.summary,
|
||||
category_id=category.id if category else None,
|
||||
generated_by="human",
|
||||
)
|
||||
session.add(doc)
|
||||
@@ -320,7 +655,10 @@ async def patch_knowledge(
|
||||
if body.doc_type is not None:
|
||||
values["doc_type"] = body.doc_type
|
||||
|
||||
if values:
|
||||
# 分类/标签也改的是这条知识的"样子",所以同样要推进 version:
|
||||
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
|
||||
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
|
||||
if values or meta_changed:
|
||||
values["updated_at"] = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
update(Item)
|
||||
@@ -344,6 +682,11 @@ async def patch_knowledge(
|
||||
doc.doc_type = body.doc_type
|
||||
if body.summary is not None:
|
||||
doc.summary = body.summary
|
||||
if body.clear_category:
|
||||
doc.category_id = None
|
||||
elif body.category_id is not None:
|
||||
category = await _resolve_category(session, ws_id, body.category_id)
|
||||
doc.category_id = category.id if category else None
|
||||
if body.tag_names is not None:
|
||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||
await session.flush()
|
||||
|
||||
Reference in New Issue
Block a user