Files
DadaoTask/backend/app/api/knowledge.py
T
dadao a24a493403 feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。
这次把「正文」这条路补全,思路是**存 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,全绿。
2026-10-29 01:53:46 +08:00

456 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识域接口(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))