写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。 这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**—— 能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。 后端 - attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定, 换对象存储只改 services/storage.py 一个文件 - 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头 - 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求 - raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员, 非成员一律 8001 不区分「不存在」与「没权限」 - 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权 前端 - 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单 - RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用 - AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob - 任务备注、工单说明与验收结论、知识正文、评论全部接入 顺手修的 - TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的) 71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB; e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
456 lines
17 KiB
Python
456 lines
17 KiB
Python
"""知识域接口(M3):草稿、审议、发布、归档,以及"从事项沉淀"。
|
||
|
||
一条知识的一生:
|
||
|
||
工单验收通过(或你手动点"沉淀成知识")
|
||
-> draft 草稿:系统整理好了,等你核对
|
||
-> in_review 提交审议:让同事看一遍(可选)
|
||
-> published 发布:进团队检索、能被问答引用
|
||
-> archived 归档:不删,退出检索(历史结论不该消失)
|
||
|
||
为什么要 `draft` 这一步:自动整理的东西一定有"看着像那么回事、其实漏了前提"的情况,
|
||
让人扫一眼的成本远低于让整个团队按错的结论办事。发布权收紧到 owner/admin 也是同理——
|
||
发布等于团队背书。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
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.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 ..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
|
||
from .attachments import count_for_items, list_for_item
|
||
|
||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
|
||
|
||
|
||
# ── 入参 ────────────────────────────────────────────────────────────────
|
||
class KnowledgeIn(BaseModel):
|
||
title: str = Field(min_length=1, max_length=200)
|
||
body_md: str = ""
|
||
summary: str = Field(default="", max_length=2000)
|
||
doc_type: str = "note"
|
||
source_item_id: uuid.UUID | None = None
|
||
tag_names: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class KnowledgePatch(BaseModel):
|
||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||
body_md: str | None = None
|
||
summary: str | None = None
|
||
doc_type: str | None = None
|
||
tag_names: list[str] | None = None
|
||
version: int
|
||
|
||
|
||
class StatusActionIn(BaseModel):
|
||
action: str
|
||
version: int
|
||
note: str = ""
|
||
|
||
|
||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||
async def _load_doc(
|
||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
|
||
) -> tuple[Item, KnowledgeDoc]:
|
||
item = await session.get(Item, item_id)
|
||
doc = await session.get(KnowledgeDoc, item_id)
|
||
if item is None or doc is None or item.workspace_id != ws_id or item.kind != "knowledge":
|
||
raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在")
|
||
return item, doc
|
||
|
||
|
||
async def _log(
|
||
session: AsyncSession, item: Item, actor_id: uuid.UUID, action: str, payload: dict | None = None
|
||
) -> None:
|
||
session.add(
|
||
Activity(
|
||
workspace_id=item.workspace_id,
|
||
item_id=item.id,
|
||
actor_id=actor_id,
|
||
action=action,
|
||
payload=payload or {},
|
||
)
|
||
)
|
||
|
||
|
||
async def _source_out(
|
||
session: AsyncSession, ws_id: uuid.UUID, source_item_id: uuid.UUID | None
|
||
) -> dict[str, Any] | None:
|
||
if source_item_id is None:
|
||
return None
|
||
source = await session.get(Item, source_item_id)
|
||
if source is None or source.workspace_id != ws_id:
|
||
return None
|
||
return {
|
||
"id": str(source.id),
|
||
"kind": source.kind,
|
||
"title": source.title,
|
||
"status": source.status,
|
||
}
|
||
|
||
|
||
def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||
return {
|
||
"doc_type": doc.doc_type,
|
||
"summary": doc.summary,
|
||
"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,
|
||
"generated_by": doc.generated_by,
|
||
"published_at": doc.published_at,
|
||
"archived_at": doc.archived_at,
|
||
"chunk_count": doc.chunk_count,
|
||
"author_id": str(doc.author_id),
|
||
}
|
||
|
||
|
||
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)
|
||
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"])
|
||
return data
|
||
|
||
|
||
async def _set_tags_for(
|
||
session: AsyncSession, ws_id: uuid.UUID, item: Item, names: list[str]
|
||
) -> None:
|
||
from .items import _set_tags
|
||
|
||
await _set_tags(session, ws_id, item, names)
|
||
|
||
|
||
# ── 列表 / 详情 ─────────────────────────────────────────────────────────
|
||
@router.get("/knowledge", summary="知识列表")
|
||
async def list_knowledge(
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
status: Annotated[list[str] | None, Query()] = None,
|
||
doc_type: Annotated[str | None, Query()] = None,
|
||
source_item_id: Annotated[uuid.UUID | None, Query()] = 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,
|
||
) -> dict:
|
||
ws_id = ctx.ws_id
|
||
stmt: Select = (
|
||
select(Item, KnowledgeDoc)
|
||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||
.where(Item.workspace_id == ws_id, Item.kind == "knowledge", Item.deleted_at.is_(None))
|
||
)
|
||
wanted = [s for s in (status or []) if s in KNOWLEDGE_STATUSES]
|
||
if wanted:
|
||
stmt = stmt.where(KnowledgeDoc.status_hint.in_(wanted))
|
||
if doc_type:
|
||
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
|
||
if source_item_id:
|
||
stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id)
|
||
if q:
|
||
like = f"%{q.strip()}%"
|
||
stmt = stmt.where(
|
||
or_(Item.title.ilike(like), Item.body_md.ilike(like), KnowledgeDoc.summary.ilike(like))
|
||
)
|
||
if cursor:
|
||
cur = decode_cursor(cursor)
|
||
stmt = stmt.where(
|
||
or_(
|
||
Item.updated_at < cur.created_at,
|
||
and_(Item.updated_at == cur.created_at, Item.id < cur.item_id),
|
||
)
|
||
)
|
||
|
||
limit = clamp_limit(limit)
|
||
rows = (
|
||
await session.execute(
|
||
stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
|
||
)
|
||
).all()
|
||
has_more = len(rows) > limit
|
||
rows = rows[:limit]
|
||
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
|
||
|
||
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)
|
||
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||
data["attachment_count"] = counts.get(str(item.id), 0)
|
||
items.append(data)
|
||
|
||
next_cursor = (
|
||
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
|
||
)
|
||
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
|
||
|
||
|
||
@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)
|
||
data = await _knowledge_out(session, ctx.ws_id, item, doc)
|
||
events = (
|
||
await session.execute(
|
||
select(Activity)
|
||
.where(Activity.workspace_id == ctx.ws_id, Activity.item_id == item.id)
|
||
.order_by(Activity.created_at.desc())
|
||
.limit(50)
|
||
)
|
||
).scalars()
|
||
data["activities"] = [
|
||
{
|
||
"action": e.action,
|
||
"payload": e.payload,
|
||
"actor_id": str(e.actor_id) if e.actor_id else None,
|
||
"created_at": e.created_at,
|
||
}
|
||
for e in events
|
||
]
|
||
return ok(data)
|
||
|
||
|
||
# ── 创建 / 修改 ─────────────────────────────────────────────────────────
|
||
@router.post("/knowledge", summary="新建知识(手记或引用来源)")
|
||
async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
ws_id = ctx.ws_id
|
||
if body.doc_type not in DOC_TYPES:
|
||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||
|
||
source: Item | None = None
|
||
if body.source_item_id is not None:
|
||
source = await session.get(Item, body.source_item_id)
|
||
if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"):
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
|
||
"来源必须是与本知识同一工作区的任务或工单",
|
||
)
|
||
|
||
item = Item(
|
||
workspace_id=ws_id,
|
||
kind="knowledge",
|
||
title=body.title.strip(),
|
||
body_md=body.body_md,
|
||
status="draft",
|
||
creator_id=ctx.user.id,
|
||
)
|
||
session.add(item)
|
||
await session.flush()
|
||
|
||
doc = KnowledgeDoc(
|
||
item_id=item.id,
|
||
workspace_id=ws_id,
|
||
author_id=ctx.user.id,
|
||
source_item_id=source.id if source else None,
|
||
source_kind="manual" if source is None else source.kind,
|
||
doc_type=body.doc_type,
|
||
status_hint="draft",
|
||
summary=body.summary,
|
||
generated_by="human",
|
||
)
|
||
session.add(doc)
|
||
await session.flush()
|
||
await _log(session, item, ctx.user.id, "knowledge_created", {"generated_by": "human"})
|
||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.create",
|
||
target_type="item",
|
||
target_id=str(item.id),
|
||
)
|
||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||
|
||
|
||
@router.patch("/knowledge/{item_id}", summary="修改知识内容")
|
||
async def patch_knowledge(
|
||
item_id: uuid.UUID, body: KnowledgePatch, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
ws_id = ctx.ws_id
|
||
item, doc = await _load_doc(session, ws_id, item_id)
|
||
if item.status == "published":
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
||
"已发布的知识不能直接改内容,先撤回成草稿",
|
||
details={"from": item.status, "allowed_actions": ["archive"]},
|
||
)
|
||
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||
|
||
values: dict[str, Any] = {}
|
||
if body.title is not None:
|
||
values["title"] = body.title.strip()
|
||
if body.body_md is not None:
|
||
values["body_md"] = body.body_md
|
||
if body.summary is not None:
|
||
values["summary"] = body.summary
|
||
if body.doc_type is not None:
|
||
values["doc_type"] = body.doc_type
|
||
|
||
if values:
|
||
values["updated_at"] = datetime.now(timezone.utc)
|
||
result = await session.execute(
|
||
update(Item)
|
||
.where(
|
||
Item.id == item.id,
|
||
Item.workspace_id == ws_id,
|
||
Item.version == body.version,
|
||
Item.deleted_at.is_(None),
|
||
)
|
||
.values(version=Item.version + 1, **values)
|
||
)
|
||
if result.rowcount == 0:
|
||
await session.refresh(item)
|
||
raise AppError(
|
||
ErrorCode.VERSION_CONFLICT,
|
||
"这条内容刚被别人改过",
|
||
details={"current_version": item.version},
|
||
)
|
||
await session.refresh(item)
|
||
if body.doc_type is not None:
|
||
doc.doc_type = body.doc_type
|
||
if body.summary is not None:
|
||
doc.summary = body.summary
|
||
if body.tag_names is not None:
|
||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||
await session.flush()
|
||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||
|
||
|
||
# ── 状态动作 ────────────────────────────────────────────────────────────
|
||
@router.post("/knowledge/{item_id}/status", summary="提交审议 / 发布 / 归档 / 恢复")
|
||
async def change_status(
|
||
item_id: uuid.UUID, body: StatusActionIn, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
ws_id = ctx.ws_id
|
||
item, doc = await _load_doc(session, ws_id, item_id)
|
||
to_status = target_status(body.action, item.status)
|
||
|
||
# 发布等于团队背书;归档/恢复作者自己就能做
|
||
if body.action == "publish" and not can_publish_role(ctx.role):
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_FORBIDDEN,
|
||
"只有管理员可以发布知识",
|
||
details={"role": ctx.role},
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
result = await session.execute(
|
||
update(Item)
|
||
.where(
|
||
Item.id == item.id,
|
||
Item.workspace_id == ws_id,
|
||
Item.version == body.version,
|
||
Item.deleted_at.is_(None),
|
||
)
|
||
.values(version=Item.version + 1, status=to_status, updated_at=now)
|
||
)
|
||
if result.rowcount == 0:
|
||
await session.refresh(item)
|
||
raise AppError(
|
||
ErrorCode.VERSION_CONFLICT,
|
||
"这条内容刚被别人改过",
|
||
details={"current_version": item.version},
|
||
)
|
||
await session.refresh(item)
|
||
# 发布时间/归档时间记在 knowledge_doc 上(item 只有 status)
|
||
doc.status_hint = to_status
|
||
stamps = sidebar_fields(to_status, now)
|
||
if "published_at" in stamps:
|
||
doc.published_at = stamps["published_at"]
|
||
doc.archived_at = None
|
||
if "archived_at" in stamps:
|
||
doc.archived_at = stamps["archived_at"]
|
||
await _log(
|
||
session,
|
||
item,
|
||
ctx.user.id,
|
||
"knowledge_status_changed",
|
||
{"action": body.action, "to": to_status, "note": body.note},
|
||
)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action=f"knowledge.{body.action}",
|
||
target_type="item",
|
||
target_id=str(item.id),
|
||
payload={"to": to_status},
|
||
)
|
||
await session.flush()
|
||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||
|
||
|
||
# ── 从任务/工单沉淀 ─────────────────────────────────────────────────────
|
||
@router.post("/items/{item_id}/knowledge", summary="把这件事沉淀成知识草稿")
|
||
async def promote_to_knowledge(
|
||
item_id: uuid.UUID,
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
doc_type: Annotated[str, Query()] = "postmortem",
|
||
) -> dict:
|
||
ws_id = ctx.ws_id
|
||
source = await session.get(Item, item_id)
|
||
if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"):
|
||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
|
||
if doc_type not in DOC_TYPES:
|
||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{doc_type}")
|
||
|
||
existing = await find_doc_by_source(session, ws_id, source.id)
|
||
if existing is not None:
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_ALREADY_EXISTS,
|
||
"这件事已经沉淀过了",
|
||
details={"knowledge_id": str(existing.item_id)},
|
||
)
|
||
|
||
resolution = ""
|
||
if source.kind == "ticket":
|
||
from ..models_ticket import TicketExt
|
||
|
||
ext = await session.get(TicketExt, source.id)
|
||
if ext is not None and ext.resolution_md:
|
||
resolution = ext.resolution_md
|
||
|
||
doc_item, doc = await create_draft_from_source(
|
||
session,
|
||
ws_id=ws_id,
|
||
source=source,
|
||
author_id=ctx.user.id,
|
||
generated_by="rule" if source.kind == "ticket" else "human",
|
||
resolution_md=resolution,
|
||
doc_type=doc_type,
|
||
)
|
||
await _log(
|
||
session, doc_item, ctx.user.id, "knowledge_created", {"source_item_id": str(source.id)}
|
||
)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.promote",
|
||
target_type="item",
|
||
target_id=str(doc_item.id),
|
||
payload={"source_item_id": str(source.id)},
|
||
)
|
||
# 与"新建知识"返回同一种形状:知识本体 + source,前端不用记两套
|
||
return ok(await _knowledge_out(session, ws_id, doc_item, doc))
|