- knowledge_doc 与 item 1:1,除 source_kind=manual 外必须有来源(库级约束) - 状态机 draft/in_review/published/archived,发布要 owner/admin;非法动作回 allowed_from 与 allowed_actions - 工单验收通过自动落 postmortem 草稿(generated_by=rule),同一来源只生成一次 - 接口:列表/详情/手记/改内容/状态动作/从事项沉淀;已发布禁改,改内容带乐观锁 - Web 知识页 + 详情抽屉;任务与工单详情里可「沉淀成知识」 - 8 个后端用例 + e2e 7 项断言(沉淀 → 待核对 → 发布)
137 lines
4.2 KiB
Python
137 lines
4.2 KiB
Python
"""知识域的数据库配套动作(M3)。
|
|
|
|
放在 services 而不是 api:两个入口都要用——用户手动点"沉淀成知识",
|
|
以及工单验收通过时自动落一份草稿。逻辑只有一份,行为才不会分叉。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..domain.knowledge import render_draft
|
|
from ..errors import AppError, ErrorCode
|
|
from ..models import AppUser
|
|
from ..models_knowledge import KnowledgeDoc
|
|
from ..models_task import Activity, Comment, Item
|
|
|
|
|
|
async def snapshot_source(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
|
|
"""把来源事项的"人、时间线、讨论"取成一份快照,供 render_draft 使用。"""
|
|
nicknames = dict(
|
|
(await session.execute(select(AppUser.id, AppUser.nickname))).all() # type: ignore[arg-type]
|
|
)
|
|
timeline = (
|
|
await session.execute(
|
|
select(Activity.action, Activity.created_at)
|
|
.where(Activity.workspace_id == ws_id, Activity.item_id == item.id)
|
|
.order_by(Activity.created_at)
|
|
)
|
|
).all()
|
|
comments = (
|
|
await session.execute(
|
|
select(Comment.body_md, Comment.author_id, Comment.created_at)
|
|
.where(
|
|
Comment.workspace_id == ws_id,
|
|
Comment.item_id == item.id,
|
|
Comment.deleted_at.is_(None),
|
|
)
|
|
.order_by(Comment.created_at)
|
|
)
|
|
).all()
|
|
return {
|
|
"people": {
|
|
"提出人": nicknames.get(item.creator_id, ""),
|
|
"办理人": nicknames.get(item.assignee_id, "") if item.assignee_id else "",
|
|
},
|
|
"timeline": [{"action": a, "at": at} for a, at in timeline],
|
|
"comments": [
|
|
{"body": body, "author": nicknames.get(author_id, ""), "at": at}
|
|
for body, author_id, at in comments
|
|
],
|
|
}
|
|
|
|
|
|
async def find_doc_by_source(
|
|
session: AsyncSession, ws_id: uuid.UUID, source_item_id: uuid.UUID
|
|
) -> KnowledgeDoc | None:
|
|
return (
|
|
await session.execute(
|
|
select(KnowledgeDoc).where(
|
|
KnowledgeDoc.workspace_id == ws_id,
|
|
KnowledgeDoc.source_item_id == source_item_id,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
async def create_draft_from_source(
|
|
session: AsyncSession,
|
|
*,
|
|
ws_id: uuid.UUID,
|
|
source: Item,
|
|
author_id: uuid.UUID,
|
|
generated_by: str = "rule",
|
|
resolution_md: str = "",
|
|
doc_type: str = "note",
|
|
title: str | None = None,
|
|
) -> tuple[Item, KnowledgeDoc]:
|
|
"""从一条任务/工单生成知识草稿(draft)。同一个来源只沉淀一次。"""
|
|
existing = await find_doc_by_source(session, ws_id, source.id)
|
|
if existing is not None:
|
|
raise AppError(
|
|
ErrorCode.KNOWLEDGE_ALREADY_EXISTS,
|
|
message="这件事已经沉淀过一份知识了",
|
|
details={"knowledge_id": str(existing.item_id)},
|
|
)
|
|
|
|
snap = await snapshot_source(session, ws_id, source)
|
|
body = render_draft(
|
|
source_kind=source.kind,
|
|
title=source.title,
|
|
body_md=source.body_md,
|
|
resolution_md=resolution_md,
|
|
created_at=source.created_at,
|
|
closed_at=source.completed_at,
|
|
people=snap["people"],
|
|
timeline=snap["timeline"],
|
|
comments=snap["comments"],
|
|
)
|
|
|
|
doc_item = Item(
|
|
workspace_id=ws_id,
|
|
kind="knowledge",
|
|
title=title or source.title,
|
|
body_md=body,
|
|
status="draft",
|
|
priority=0,
|
|
creator_id=author_id,
|
|
)
|
|
session.add(doc_item)
|
|
await session.flush()
|
|
|
|
doc = KnowledgeDoc(
|
|
item_id=doc_item.id,
|
|
workspace_id=ws_id,
|
|
author_id=author_id,
|
|
source_item_id=source.id,
|
|
source_kind="ticket" if source.kind == "ticket" else "task",
|
|
doc_type=doc_type,
|
|
status_hint="draft",
|
|
summary=_first_line(resolution_md) or source.title,
|
|
generated_by=generated_by,
|
|
)
|
|
session.add(doc)
|
|
await session.flush()
|
|
return doc_item, doc
|
|
|
|
|
|
def _first_line(text: str) -> str:
|
|
for line in text.strip().splitlines():
|
|
if line.strip():
|
|
return line.strip()[:200]
|
|
return ""
|