Compare commits

...
2 Commits
Author SHA1 Message Date
dadao 07688881b4 docs(m3): 补知识接口契约、M3 清单与状态表
CI / backend (push) Canceled after 0s
- API.md 新增第 5 节:列表/详情/手记/改内容/状态动作/从事项沉淀 + 7xxx 错误码
- ROADMAP 增 M3 全节与 DOD,验收用例补 11–13,明确 3.8 向量索引未跑
- README 状态表:知识已通、pgvector 只有配置
- DEVELOPMENT 补三条 e2e 命令与「先想一条能失败的断言」
2026-10-28 14:03:10 +08:00
dadao e9addeed25 feat(knowledge): 工单验收自动生成草稿,核对后发布,来源可追
- 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 项断言(沉淀 → 待核对 → 发布)
2026-10-28 14:02:35 +08:00
21 changed files with 2093 additions and 11 deletions
+90 -2
View File
@@ -1,4 +1,4 @@
# 接口约定(M0–M2 已实现)
# 接口约定(M0–M3 已实现)
> 只写**已经在跑**的接口。写完没验证的东西不进这份文档。
> 前缀 `/api/v1`。所有响应都是同一个信封,**HTTP 200 不代表业务成功**。
@@ -301,7 +301,95 @@ canceled→ todo (恢复)
---
## 5. 健康与版本
## 5. 知识 `/workspaces/{ws_id}`
### 5.1 `GET /knowledge`
| 参数 | 说明 |
|---|---|
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
| `source_item_id` | 按来源事项筛 |
| `q` | 标题 / 正文 / 摘要子串 |
| `limit` | 1–100,默认 30 |
| `cursor` | 上一页的 `next_cursor` |
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`:
```json
{
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
"body_md": "…", "status": "published", "tags": [],
"knowledge": {
"doc_type": "howto", "summary": "…", "status_hint": "published",
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
"chunk_count": 0, "author_id": "…"
},
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
}
```
> `status` 与 `knowledge.status_hint` 是同一件事:前者在 `item` 上(复用事项表),
> 后者在 `knowledge_doc` 上(列表按知识口径筛时不必回连 `item`)。**两者必须同步**,
> 状态动作接口会一起写。
### 5.2 `GET /knowledge/{item_id}`
详情,附最近 50 条 `activities`。不存在或跨租户统一 `7001`。
### 5.3 `POST /knowledge` — 手记或指定来源
```json
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
"source_item_id": "可选的来源事项 id"}
```
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
- 新知识一律 `draft`,不会直接对外可见。
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`。
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
### 5.5 `POST /knowledge/{item_id}/status` — 状态动作
| action | 允许的前置状态 | 结果 | 谁能做 |
|---|---|---|---|
| `submit_review` | draft | in_review | 成员 |
| `withdraw` | in_review | draft | 成员 |
| `publish` | draft / in_review | published | owner / admin |
| `archive` | draft / in_review / published | archived | 成员 |
| `restore` | archived | draft | 成员 |
非法动作返回 `7003`,`details.allowed_from` 是动作要求的前置状态,`details.allowed_actions`
是当前状态下还能做什么——前端据此渲染按钮,不用自己维护一份状态机。
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
### 5.7 知识的错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 7001 | 404 | 知识不存在,或不是知识 |
| 7002 | 409 | 这个来源已经有知识了 |
| 7003 | 409 | 当前状态不允许这个动作 |
| 7004 | 403 | 权限不足(如非管理员发布) |
| 7005 | 409 | 来源事项无效或缺失 |
---
## 6. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
+6 -1
View File
@@ -84,11 +84,16 @@ ruff check . && ruff format --check .
```bash
cd web
node e2e/smoke.mjs # 登录→建任务→详情保存→看板拖拽→非法流转被拒,7 项断言
node e2e/smoke.mjs # 登录→建任务→详情保存→看板拖拽→非法流转被拒,7 项断言
node e2e/tickets.mjs # 双人:升级工单→受理→提交→验收→原任务回流,8 项断言
node e2e/knowledge.mjs # 沉淀成知识→待核对→发布,7 项断言
```
用本机缓存的 Chromium(`playwright-core`,不下载浏览器),截图落在 `web/e2e/artifacts/`。
> e2e 抓的都是"手动点几下看不出来"的问题:字段缺失导致整页白屏、请求竞态导致的空列表、
> 点错按钮的 404。加功能时**先想一条能失败的断言**,再动手写页面。
### 1000 条下的首屏耗时
```bash
+35 -4
View File
@@ -261,7 +261,36 @@
---
## 关键验收用例(M0–M2 必须通过)
## M3 · 知识库(第 7–8 周,10 人日)
| # | 任务 | 人日 | 状态 |
|---|---|---|---|
| 3.1 | `knowledge_doc`(1:1 于 item,`source_kind` + `source_item_id` 必填约束)+ 迁移 0004 + RLS | 1.0 | [x] 已跑通,隔离测试覆盖 |
| 3.2 | 状态机 `draft / in_review / published / archived`,发布要 owner/admin | 0.5 | [x] 5 个动作,非法动作回 `allowed_from` + `allowed_actions` |
| 3.3 | 结构化编辑:标题 / 正文 / 摘要 / 类型,乐观锁,已发布禁改 | 1.0 | [x] 含 `4009` 冲突用例 |
| 3.4 | **闭环沉淀**:工单验收通过自动生成草稿(`generated_by=rule`),同一来源只生成一次 | 1.5 | [x] 只转述不推断,正文带"请先核对再发布" |
| 3.5 | 手动沉淀:任务/工单详情里"沉淀成知识" | 0.5 | [x] 有 e2e |
| 3.6 | 手记:没有来源的知识(唯一允许 `source_kind=manual` 的入口) | 0.5 | [x] 手记页 + 列表 |
| 3.7 | 检索:标题/正文/摘要子串 + 类型/状态筛选 + 游标分页 | 1.0 | [x] trigram 索引在 0002 已建 |
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [ ] 见下方说明 |
| 3.9 | 安卓知识列表 | 1.0 | [ ] 与工单一起延后到编译链就绪 |
| 3.10 | 文档与 e2e | 1.0 | [x] `web/e2e/knowledge.mjs` 7 项断言绿 |
### M3 完成定义(DOD)
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
- [x] 非管理员发布被拒;跨租户看不到别人的知识
- [ ] 向量切片与语义检索(需要独立 pgvector 实例真正跑起来)
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
**关于 3.8 的说明**:本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展,
所以 `deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 只有配置,**没有跑过**。
按"写完不算完成"的规矩,它在真跑起来之前保持未勾。切片表设计要点已写进
`2.Design/PRODUCT-PLAN.md` 的 D8,落地时不要改 `EMBEDDING_DIM`(改了要重算全量)。
---
## 关键验收用例(M0–M3 必须通过)
| # | 用例 | 期望 | 现状 |
|---|---|---|---|
@@ -275,14 +304,16 @@
| 8 | 部署后 `/version` | commit 与仓库 HEAD 一致 | [~] 机制已有,等部署验证 |
| 9 | 办理人验收自己的工单 | 403 `6004`,不能自己给自己过 | ✅ 已覆盖 |
| 10 | 工单验收通过后原任务 | 自动从"被阻塞"回到"待办",事件流留痕 | ✅ 已覆盖(含 e2e) |
| 11 | 工单验收通过后知识库 | 自动出现一份草稿,且只生成一次 | ✅ 已覆盖 |
| 12 | 成员发布知识 | 403 `7004`;owner 发布成功且记录发布时间 | ✅ 已覆盖 |
| 13 | 知识有没有来源 | 非手记必须有 `source_item_id`,否则 7005 / 库级约束拒绝 | ✅ 已覆盖 |
---
## 下一步排期建议
```
已完成 0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同(含双人 e2e)
待补 0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑 / 安卓编译链
第7–8周 M3 知识:knowledge_doc + chunk、切片与索引、工单关闭生成草稿、带引用问答
已完成 0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同 + 3.1–3.7、3.10 知识库
待补 0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑 / 安卓编译链 / 3.8 向量索引
第9–10周 M4 AI:结构化录入、人物工作侧写、主动提醒、20 条黄金用例回归
```
+5 -3
View File
@@ -21,13 +21,15 @@
|---|---|
| 手机号验证码登录、刷新令牌、幂等请求 | ✅ 已实现并有测试 |
| 工作区 / 成员 / 邀请码加入,跨租户隔离(RLS) | ✅ 同上 |
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,57 个测试全绿 |
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通 |
| Web 列表 / 看板拖拽 / 详情侧栏编辑 / 键盘流 / 快速录入语法 | ✅ e2e 7 项断言全绿,构建通过 |
| 1000 条任务下的首屏性能 | ✅ 实测 P50 38–55ms(目标 < 300ms) |
| 安卓端任务列表与详情 | ⚠️ 代码已写,**本机无 JDK/Gradle,未编译验证** |
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web 已通,双人 e2e 8 项全绿 |
| 后端测试 | ✅ 57 passed,ruff 干净 |
| 知识库、LLM 能力 | ⬜ M3–M4 |
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web 已通,e2e 7 项全绿 |
| 后端测试 | ✅ 65 passed,ruff 干净 |
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
| 安卓端知识页 / LLM 能力 | ⬜ M3 收尾 / M4 |
## 快速开始
+118
View File
@@ -0,0 +1,118 @@
"""M3 知识域:knowledge_doc + 扩宽 item.status(加知识四态)。
三个需要注意的点:
1. **item.status 的 CHECK 再扩一次**。0003 里是"任务 ∪ 工单",知识的
`draft/in_review/published/archived` 会被直接拦下。这里改为三者并集;
"哪种 kind 只能用哪组状态"由 `app/domain/*.py` 的状态机保证,不靠数据库
(CHECK 不能跨行判断 kind 与 status 的组合)。
2. **知识的来源写在数据库里**。`source_kind <> 'manual'` 时 `source_item_id` 必填。
这条 CHECK 是 D3 的物理落点:LLM 自动归档出来的知识若没有来源,用户无法核对,
等于往库里塞断言。
3. **downgrade 会删掉所有知识**(kind='knowledge' 的 item 及其扩展),原因与 0003 相同:
旧约束容纳不下知识状态,不回删就回不到 0003。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
TICKET_STATUSES = ("open", "accepted", "in_progress", "pending_verify", "done", "canceled")
KNOWLEDGE_STATUSES = ("draft", "in_review", "published", "archived")
def upgrade() -> None:
# 1) item.status 放宽到 任务 ∪ 工单 ∪ 知识
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
statuses = ",".join(
f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES, *KNOWLEDGE_STATUSES)
)
op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))")
# 2) knowledge_doc
op.create_table(
"knowledge_doc",
sa.Column(
"item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"author_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"source_item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("source_kind", sa.String(16), nullable=False, server_default="manual"),
sa.Column("doc_type", sa.String(16), nullable=False, server_default="note"),
sa.Column("status_hint", sa.String(16), nullable=False, server_default="draft"),
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
sa.Column("generated_by", sa.String(16), nullable=False, server_default="human"),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("chunk_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.CheckConstraint(
"source_kind = 'manual' OR source_item_id IS NOT NULL",
name="ck_knowledge_source_required",
),
sa.CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"),
)
op.create_index("ix_knowledge_ws_status", "knowledge_doc", ["workspace_id", "status_hint"])
op.create_index("ix_knowledge_ws_source", "knowledge_doc", ["workspace_id", "source_item_id"])
# 3) RLS + 授权
op.execute("ALTER TABLE knowledge_doc ENABLE ROW LEVEL SECURITY")
op.execute(
"CREATE POLICY knowledge_doc_isolation ON knowledge_doc "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())"
)
op.execute(
f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0004)} TO {APP_ROLE}"
)
def downgrade() -> None:
# 旧约束容纳不下知识状态:先清掉知识 item,才能回到 0003 的约束
op.execute("DELETE FROM item WHERE kind = 'knowledge'")
op.execute("DROP POLICY IF EXISTS knowledge_doc_isolation ON knowledge_doc")
op.drop_index("ix_knowledge_ws_source", table_name="knowledge_doc")
op.drop_index("ix_knowledge_ws_status", table_name="knowledge_doc")
op.drop_table("knowledge_doc")
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
statuses = ",".join(f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES))
op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))")
+450
View File
@@ -0,0 +1,450 @@
"""知识域接口(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
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)
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 = []
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)
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))
+40
View File
@@ -288,6 +288,41 @@ async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session:
# -- 办理/验收 ----------------------------------------------------------
async def _draft_knowledge(
session: AsyncSession,
ctx: TenantCtx,
ticket: Item,
ext: TicketExt,
resolution_md: str,
now: datetime,
) -> None:
"""验收通过时自动整理一份知识草稿(M3 闭环沉淀)。
只落 draft 不发布:自动整理的东西得有人扫一眼才敢进团队检索。
同一张工单只沉淀一次(重复点验收、历史数据补跑都不会重复产出)。
"""
from ..services.knowledge import create_draft_from_source, find_doc_by_source
if await find_doc_by_source(session, ctx.ws_id, ticket.id) is not None:
return
doc_item, _doc = await create_draft_from_source(
session,
ws_id=ctx.ws_id,
source=ticket,
author_id=ctx.user.id,
generated_by="rule",
resolution_md=resolution_md or ext.resolution_md or "",
doc_type="postmortem",
)
await _log(
session,
doc_item,
ctx.user.id,
"knowledge_created",
{"source_item_id": str(ticket.id), "generated_by": "rule"},
)
async def _advance(
ctx: TenantCtx,
session: AsyncSession,
@@ -391,6 +426,11 @@ async def _advance(
body="它对应的工单已验收通过",
)
# 验收通过 -> 顺手整理一份知识草稿(闭环沉淀)。
# 只落草稿不发布:自动整理的东西得有人扫一眼才敢进团队检索。
if action == "approve":
await _draft_knowledge(session, ctx, ticket, ext, resolution_md, now)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
+187
View File
@@ -0,0 +1,187 @@
"""知识域状态机与"从事项生成草稿"的纯函数(M3)。
为什么单独放一个模块:这两件事都是纯逻辑,不该缠在 HTTP 层或 ORM 里。
状态机能被 5 行测试覆盖;草稿拼装能被快照测试比对。放进 API 模块就只能靠 e2e 兜。
两个刻意的设计:
1. **草稿默认落 `draft`,不自动发布**(用户拍板)。自动生成的东西一定会有"看着像那么回事、
其实漏了关键前提"的情况,人扫一眼的成本远低于让团队按错的结论办事。
2. **草稿正文如实转述来源**,不写"建议""应该"这类推断。LLM 阶段(M4)接进来时,
`generated_by` 会从 `rule` 变成 `llm`,但"只转述、不改写结论"这条不变。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from ..errors import AppError, ErrorCode
KNOWLEDGE_TRANSITIONS: dict[str, tuple[str, ...]] = {
"draft": ("in_review", "published", "archived"),
"in_review": ("draft", "published", "archived"),
"published": ("archived",),
"archived": ("draft",),
}
KNOWLEDGE_ACTIONS: dict[str, tuple[tuple[str, ...], str]] = {
# action: (允许的前置状态, 目标状态)
"submit_review": (("draft",), "in_review"),
"withdraw": (("in_review",), "draft"),
"publish": (("draft", "in_review"), "published"),
"archive": (("draft", "in_review", "published"), "archived"),
"restore": (("archived",), "draft"),
}
# 生成草稿时给读者的一句提示,防止有人把草稿当已确认的结论转发
DRAFT_BANNER = "> 这份草稿由系统根据来源自动整理,**请先核对再发布**;发布后才会进入团队检索。"
ACTION_LABEL: dict[str, str] = {
"created": "创建",
"status_changed": "状态变更",
"promoted_to_ticket": "升级成工单",
"ticket_created": "工单创建",
"ticket_accept": "受理",
"ticket_start": "开始办理",
"ticket_submit": "提交验收",
"ticket_approve": "验收通过",
"ticket_reject": "验收驳回",
"ticket_reassign": "改派",
"ticket_cancel": "取消",
"comment_added": "评论",
"knowledge_created": "沉淀为知识",
}
def target_status(action: str, current: str) -> str:
"""动作 → 目标状态。非法组合抛 7003,并把"现在还能做什么"带回去。"""
rule = KNOWLEDGE_ACTIONS.get(action)
if rule is None:
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
message=f"未知动作 {action}",
details={"action": action, "allowed_actions": sorted(KNOWLEDGE_ACTIONS)},
)
allowed_from, to_status = rule
if current not in allowed_from:
allowed_actions = [a for a, (src, _) in KNOWLEDGE_ACTIONS.items() if current in src]
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
message=f"当前状态({current})不能{_label(action)}",
details={
"action": action,
"from": current,
"allowed_from": list(allowed_from),
"allowed_actions": allowed_actions,
},
)
return to_status
def ensure_transition(current: str, target: str) -> None:
"""直接改状态(不经过动作)时的校验。"""
if target not in KNOWLEDGE_TRANSITIONS.get(current, ()):
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
message=f"不能从 {current} 直接变成 {target}",
details={
"from": current,
"to": target,
"allowed": list(KNOWLEDGE_TRANSITIONS.get(current, ())),
},
)
def sidebar_fields(target_status: str, now: datetime) -> dict[str, Any]:
"""状态跃迁时要顺手维护的时间戳。"""
if target_status == "published":
return {"published_at": now, "archived_at": None}
if target_status == "archived":
return {"archived_at": now}
return {}
def can_publish_role(role: str) -> bool:
"""发布是"团队背书",guest/member 只能提交审议。"""
return role in ("owner", "admin")
def _label(action: str) -> str:
return {
"submit_review": "提交审议",
"withdraw": "撤回审议",
"publish": "发布",
"archive": "归档",
"restore": "恢复",
}.get(action, action)
def render_draft(
*,
source_kind: str,
title: str,
body_md: str = "",
resolution_md: str = "",
created_at: datetime | None = None,
closed_at: datetime | None = None,
people: dict[str, str] | None = None,
timeline: list[dict[str, Any]] | None = None,
comments: list[dict[str, Any]] | None = None,
) -> str:
"""把一件事(任务或工单)整理成知识草稿的正文。
只做"结构化的转述":来源里有什么就写什么,没有的段落直接省略,
不补写"经验教训"这类需要人判断的话。
"""
people = people or {}
out: list[str] = [DRAFT_BANNER, ""]
when = _fmt(closed_at or created_at)
origin = "工单" if source_kind == "ticket" else "任务"
out.append(f"自动沉淀自{origin}「{title}」{(',' + when + ' 结束') if when else ''}。")
out.append("")
if people:
line = "、".join(f"{k}:{v}" for k, v in people.items() if v)
if line:
out.append(f"- {line}")
out.append("")
out.append("## 背景")
out.append("")
out.append(
body_md.strip() if body_md.strip() else "(来源里没有正文,补充一句这件事要解决什么。)"
)
out.append("")
rows = [r for r in (timeline or []) if r.get("action") in ACTION_LABEL]
if rows:
out.append("## 过程")
out.append("")
for r in rows:
out.append(f"- {_fmt(r.get('at'))} {ACTION_LABEL[r['action']]}")
out.append("")
if resolution_md.strip():
out.append("## 结论")
out.append("")
out.append(resolution_md.strip())
out.append("")
said = [c for c in (comments or []) if (c.get("body") or "").strip()]
if said:
out.append("## 讨论")
out.append("")
for c in said:
who = c.get("author") or "某人"
out.append(f"- **{who}**:{c['body'].strip()}")
out.append("")
return "\n".join(out).rstrip() + "\n"
def _fmt(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M")
return ""
+12
View File
@@ -50,6 +50,13 @@ class ErrorCode(IntEnum):
TICKET_FORBIDDEN = 6004
NOTIFICATION_NOT_FOUND = 6005
# 7xxx 知识域
KNOWLEDGE_NOT_FOUND = 7001
KNOWLEDGE_ALREADY_EXISTS = 7002
KNOWLEDGE_STATUS_INVALID = 7003
KNOWLEDGE_FORBIDDEN = 7004
KNOWLEDGE_SOURCE_REQUIRED = 7005
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
_HTTP_STATUS: dict[ErrorCode, int] = {
@@ -83,6 +90,11 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.TICKET_STATUS_INVALID: 409,
ErrorCode.TICKET_FORBIDDEN: 403,
ErrorCode.NOTIFICATION_NOT_FOUND: 404,
ErrorCode.KNOWLEDGE_NOT_FOUND: 404,
ErrorCode.KNOWLEDGE_ALREADY_EXISTS: 409,
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
}
+2 -1
View File
@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy import text
from starlette.exceptions import HTTPException as StarletteHTTPException
from .api import auth, items, tickets, workspaces
from .api import auth, items, knowledge, tickets, workspaces
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -104,6 +104,7 @@ app.include_router(items.router, prefix=settings.api_prefix)
app.include_router(items.tag_router, prefix=settings.api_prefix)
app.include_router(tickets.router, prefix=settings.api_prefix)
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
app.include_router(knowledge.router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+2
View File
@@ -135,6 +135,7 @@ class AuditLog(Base):
#
# 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base,
# 所以只能在文件末尾反向 import,否则循环导入。
from .models_knowledge import KNOWLEDGE_TENANT_TABLES # noqa: E402
from .models_task import TASK_TENANT_TABLES # noqa: E402
from .models_ticket import TICKET_TENANT_TABLES # noqa: E402
@@ -145,4 +146,5 @@ TENANT_TABLES: tuple[str, ...] = (
"audit_log",
*TASK_TENANT_TABLES,
*TICKET_TENANT_TABLES,
*KNOWLEDGE_TENANT_TABLES,
)
+79
View File
@@ -0,0 +1,79 @@
"""知识域模型(M3):knowledge_doc。
三条设计约束(对应 PRODUCT-PLAN 的 D1/D3):
1. **知识也是 `item`**。标题、正文、标签、评论、事件流全部复用;`knowledge_doc`
只补"从哪来的、是什么类型、什么时候被人认可过"。同一套列表、搜索、详情、权限对知识依然成立。
2. **知识必须有来源**(D3)。`source_kind` 为 `task` / `ticket` 时必须给出 `source_item_id`,
只有 `manual`(自己记的笔记)允许留空。这条写在数据库 CHECK 里,不靠 API 自律——
将来接 LLM 自动归档时,没有来源的知识等于"没人能核对的断言",比没有更糟。
3. **状态机是 `draft → in_review → published → archived`**。草稿可以随时改,发布的要有
人来点头(`published_at` 记时间),归档的不删只藏(历史结论不该消失,只该退出检索)。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Index,
String,
Text,
)
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column
from .models import Base, Timestamps
# 知识状态机(与任务/工单共用 item.status 一列,取值集合不同)
KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived")
DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision")
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
GENERATED_BY: tuple[str, ...] = ("human", "rule")
class KnowledgeDoc(Base, Timestamps):
"""知识的附加信息,与 item 一比一。"""
__tablename__ = "knowledge_doc"
__table_args__ = (
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
CheckConstraint(
"source_kind = 'manual' OR source_item_id IS NOT NULL",
name="ck_knowledge_source_required",
),
CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"),
)
# item_id 既是主键又是外键:一条 item 最多一条 knowledge_doc,天然 1:1
item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
author_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 知识的来源。NULL 只允许出现在 source_kind='manual' 上(见 CHECK)
source_item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
)
source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note")
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
chunk_count: Mapped[int] = mapped_column(nullable=False, default=0)
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc",)
View File
+136
View File
@@ -0,0 +1,136 @@
"""知识域的数据库配套动作(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 ""
+342
View File
@@ -0,0 +1,342 @@
"""M3 知识域测试:草稿、来源、审议发布、隔离。
重点不是"接口能通",而是这几条容易做错的:
1. **知识的来源不可丢**:从工单沉淀出来的知识必须指回那张工单(`source_kind='ticket'`),
否则半年后没人能核对这条结论从哪来;
2. **自动沉淀只落草稿**:验收通过后知识是 `draft`,不是 `published`;
3. **同一件事只沉淀一次**:重复点"沉淀成知识"要报 7002,而不是悄悄生成两份;
4. **发布要管理员**:普通成员的发布请求必须是 403(7004);
5. **跨租户不可见**:别人的知识列表为空、直接按 id 取是 7001。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
async def _member(
client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member"
) -> dict[str, Any]:
user = await login(client)
inv = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"]
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/join", json={"code": inv["code"]}, headers=user["headers"]
)
)
return user
async def _task(client: AsyncClient, user: dict[str, Any], ws_id: str, title: str) -> dict:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items",
json={"title": title, "status": "todo"},
headers=user["headers"],
)
)
async def _finish_ticket(
client: AsyncClient, owner: dict[str, Any], ws_id: str, helper: dict[str, Any], task: dict
) -> dict:
"""把任务升级成工单并走完到验收通过,返回工单。"""
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/accept", headers=helper["headers"]
)
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/start", headers=helper["headers"]
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/submit",
json={"note": "已经开了只读账号"},
headers=helper["headers"],
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/verify",
json={"approve": True, "resolution_md": "账号权限已开通,走的是 A 方案"},
headers=owner["headers"],
)
)
return ticket
@pytest.mark.asyncio
async def test_create_manual_knowledge_is_draft(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "知识手记")
data = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={
"title": "报销单要贴发票原件",
"body_md": "财务只认纸质发票,扫描件不行。",
"doc_type": "note",
"tag_names": ["财务"],
},
headers=owner["headers"],
)
)
assert data["kind"] == "knowledge"
assert data["status"] == "draft"
assert data["knowledge"]["source_kind"] == "manual"
assert data["knowledge"]["source_item_id"] is None
assert [t["name"] for t in data["tags"]] == ["财务"]
listed = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/knowledge", headers=owner["headers"])
)
assert [i["id"] for i in listed["items"]] == [data["id"]]
@pytest.mark.asyncio
async def test_ticket_approval_creates_draft_with_source(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "闭环沉淀")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "开通测试账号权限")
ticket = await _finish_ticket(client, owner, ws["id"], helper, task)
listed = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge",
params={"status": "draft"},
headers=owner["headers"],
)
)
assert listed["has_more"] is False
docs = [i for i in listed["items"] if i["knowledge"]["source_item_id"] == ticket["id"]]
assert len(docs) == 1, "验收通过应当恰好沉淀一份草稿"
doc = docs[0]
assert doc["status"] == "draft", "自动沉淀只能是草稿,不能直接发布"
assert doc["knowledge"]["source_kind"] == "ticket"
assert doc["knowledge"]["generated_by"] == "rule"
# 草稿正文必须带结论和"请先核对"的提示,否则用户会把它当已确认结论转发
assert "账号权限已开通" in doc["body_md"]
assert "请先核对再发布" in doc["body_md"]
assert doc["source"]["kind"] == "ticket"
@pytest.mark.asyncio
async def test_promote_twice_conflicts(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "重复沉淀")
task = await _task(client, owner, ws["id"], "写周报")
first = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge",
headers=owner["headers"],
)
)
assert first["knowledge"]["source_kind"] == "task"
assert first["knowledge"]["generated_by"] == "human"
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge", headers=owner["headers"]
)
assert resp.json()["code"] == 7002
@pytest.mark.asyncio
async def test_publish_requires_admin(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "发布权限")
member = await _member(client, owner, ws)
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": "值班手册", "body_md": "先重启再上报。"},
headers=member["headers"],
)
)
reviewed = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "submit_review", "version": doc["version"]},
headers=member["headers"],
)
)
assert reviewed["status"] == "in_review"
denied = await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "publish", "version": reviewed["version"]},
headers=member["headers"],
)
assert denied.json()["code"] == 7004
published = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "publish", "version": reviewed["version"]},
headers=owner["headers"],
)
)
assert published["status"] == "published"
assert published["knowledge"]["published_at"] is not None
# 已发布的内容不许直接改,先撤回
blocked = await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v2", "version": published["version"]},
headers=owner["headers"],
)
assert blocked.json()["code"] == 7003
archived = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "archive", "version": published["version"]},
headers=owner["headers"],
)
)
assert archived["status"] == "archived"
assert archived["knowledge"]["archived_at"] is not None
@pytest.mark.asyncio
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "非法动作")
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": "草稿"},
headers=owner["headers"],
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "archive", "version": doc["version"]},
headers=owner["headers"],
)
)
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "publish", "version": doc["version"]},
headers=owner["headers"],
)
body = resp.json()
assert body["code"] == 7003
assert body["data"]["allowed_from"] == ["draft", "in_review"]
assert "restore" in body["data"]["allowed_actions"]
@pytest.mark.asyncio
async def test_knowledge_is_tenant_scoped(client: AsyncClient) -> None:
alice = await login(client)
ws_a = await create_workspace(client, alice, "A 的知识")
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws_a['id']}/knowledge",
json={"title": "A 的内部结论"},
headers=alice["headers"],
)
)
bob = await login(client)
ws_b = await create_workspace(client, bob, "B 的工作区")
listed = unwrap(
await client.get(f"{V1}/workspaces/{ws_b['id']}/knowledge", headers=bob["headers"])
)
assert listed["items"] == []
cross = await client.get(
f"{V1}/workspaces/{ws_b['id']}/knowledge/{doc['id']}", headers=bob["headers"]
)
assert cross.json()["code"] == 7001
not_member = await client.get(f"{V1}/workspaces/{ws_a['id']}/knowledge", headers=bob["headers"])
assert not_member.json()["code"] == 3003
@pytest.mark.asyncio
async def test_search_and_filters(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "检索")
for title, doc_type in (
("周报模板", "howto"),
("客户投诉复盘", "postmortem"),
("周报提醒", "note"),
):
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": title, "body_md": f"{title} 的正文", "doc_type": doc_type},
headers=owner["headers"],
)
)
hit = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge",
params={"q": "周报"},
headers=owner["headers"],
)
)
assert sorted(i["title"] for i in hit["items"]) == ["周报提醒", "周报模板"]
by_type = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge",
params={"doc_type": "postmortem"},
headers=owner["headers"],
)
)
assert [i["title"] for i in by_type["items"]] == ["客户投诉复盘"]
@pytest.mark.asyncio
async def test_patch_uses_optimistic_lock(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "并发改")
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": "初稿", "version": 0},
headers=owner["headers"],
)
)
updated = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"body_md": "补一段", "version": doc["version"]},
headers=owner["headers"],
)
)
assert updated["version"] == doc["version"] + 1
stale = await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"body_md": "又一段", "version": doc["version"]},
headers=owner["headers"],
)
assert stale.json()["code"] == 4009
assert stale.json()["data"]["current_version"] == updated["version"]
+135
View File
@@ -0,0 +1,135 @@
/**
* 知识闭环 e2e:一件事 → 知识草稿 → 核对 → 发布。
*
* 走的是真实浏览器 + 真实后端(8100)。前面造数据用接口,
* 后面验证界面,这样脚本短、失败点也清楚。
*/
import { chromium } from "playwright-core";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const stamp = String(Date.now()).slice(-7);
const phone = `135${stamp}0`;
let failures = 0;
function check(name, ok, extra = "") {
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
if (!ok) failures += 1;
}
async function call(method, path, body, token) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
return json.data;
}
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const consoleErrors = [];
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push(m.text());
});
page.on("response", (r) => {
if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`);
});
try {
await call("POST", "/auth/sms/send", { phone });
const login = await call("POST", "/auth/login", { phone, code: "123456" });
const token = login.tokens.access_token;
const ws = await call("POST", "/workspaces", { name: `知识闭环 ${stamp}` }, token);
const task = await call(
"POST",
`/workspaces/${ws.id}/items`,
{ title: "发票原件必须一起寄", body_md: "电子发票打印件不算,财务退回过一次。" },
token,
);
const moved = await call(
"POST",
`/workspaces/${ws.id}/items/${task.id}/status`,
{ status: "todo", version: task.version },
token,
);
await call(
"POST",
`/workspaces/${ws.id}/items/${task.id}/status`,
{ status: "done", version: moved.version },
token,
);
await page.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, login.tokens.refresh_token],
);
await page.goto(`${WEB}/w/${ws.id}`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "已完成", exact: true }).click();
await page.getByText("发票原件必须一起寄", { exact: false }).first().click();
await page.getByRole("button", { name: "沉淀成知识", exact: true }).click();
await page.getByText("已生成知识草稿", { exact: false }).waitFor({ timeout: 10_000 });
check("任务详情能把这件事沉淀成草稿", true);
await page.getByRole("button", { name: "关闭", exact: true }).click();
await page.getByRole("link", { name: "知识", exact: true }).click();
const card = page.getByText("发票原件必须一起寄", { exact: false }).first();
await card.waitFor({ timeout: 10_000 });
const draftLabelVisible = await page.getByText("草稿", { exact: true }).first().isVisible();
check("草稿出现在「待核对」", draftLabelVisible);
await page.screenshot({ path: "e2e/artifacts/knowledge-draft.png", fullPage: false });
await page.getByRole("button", { name: "打开", exact: true }).first().click();
// 等这一个请求落地再断言,不然会和抽屉的刷新抢跑("已发布"这四个字在 tab 上也有一份)
const [publishResp] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes("/knowledge/") && r.url().endsWith("/status") && r.status() === 200,
),
page.getByRole("button", { name: "发布", exact: true }).click(),
]);
check("发布请求成功", publishResp.ok());
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
const published = await page.getByText(/已发布 · 版本 \d+/, { exact: false }).first().isVisible();
check("核对后可以发布", published);
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
headers: { authorization: `Bearer ${token}` },
}).then((r) => r.json());
// 同标题可能有多条(每次跑脚本都会新建工作区,但标题固定在两处),取已发布的那条
const doc = again.data.items.find(
(d) => d.title === "发票原件必须一起寄" && d.knowledge.status_hint === "published",
);
check(
"发布后接口里 published 且有发布时间",
doc?.knowledge.status_hint === "published" && Boolean(doc?.knowledge.published_at),
);
check("正文带「请先核对再发布」的提示", String(doc?.body_md ?? "").includes("请先核对再发布"));
await page.screenshot({ path: "e2e/artifacts/knowledge-published.png", fullPage: false });
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
} catch (err) {
failures += 1;
console.log(`FAIL 脚本异常 — ${err.message}`);
await page.screenshot({ path: "e2e/artifacts/knowledge-failure.png" }).catch(() => {});
console.log((await page.textContent("body"))?.slice(0, 600));
} finally {
await browser.close();
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
}
+2
View File
@@ -8,6 +8,7 @@ import { WorkspacePage } from "./pages/Workspace";
import { TasksPage } from "./pages/Tasks";
import { BoardPage } from "./pages/Board";
import { TicketsPage } from "./pages/Tickets";
import { KnowledgePage } from "./pages/Knowledge";
import { JoinPage } from "./pages/Join";
interface AuthState {
@@ -86,6 +87,7 @@ export function App() {
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+33
View File
@@ -201,6 +201,25 @@ export function TaskDetail({
}
}
/** 把这件事整理成知识草稿。草稿默认待核对,不直接发布。 */
async function toKnowledge() {
if (!item) return;
setBusy(true);
setError(null);
try {
await api.post(`${base}/knowledge?doc_type=${item.kind === "ticket" ? "postmortem" : "howto"}`);
setNotice("已生成知识草稿。它现在在「知识 → 待核对」里,核对无误后再发布,才会进入团队检索。");
} catch (err) {
if (err instanceof ApiError && err.code === 7002) {
setNotice("这件事已经有一份知识草稿了,去「知识」页找它。");
} else {
setError(err instanceof ApiError ? err.message : "生成失败");
}
} finally {
setBusy(false);
}
}
async function ticketAction(action: TicketAction, extra: Record<string, unknown> = {}) {
if (!item) return;
setBusy(true);
@@ -412,6 +431,20 @@ export function TaskDetail({
</div>
)}
{item.kind !== "knowledge" && item.status !== "canceled" && (
<div className="mt-4 rounded-lg border border-ink-100 p-3">
<p className="text-sm font-medium">这件事值得留下来?沉淀成知识</p>
<p className="mt-1 text-xs text-ink-500">
会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。
</p>
<div className="mt-2">
<button className="btn-ghost px-3 py-1 text-sm" disabled={busy} onClick={() => void toKnowledge()}>
{item.kind === "ticket" ? "沉淀成复盘" : "沉淀成知识"}
</button>
</div>
</div>
)}
{notice && <p className="mt-3 text-sm text-emerald-700">{notice}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{busy && <p className="mt-3 text-xs text-ink-500">处理中…</p>}
+1
View File
@@ -6,6 +6,7 @@ const TABS = [
{ path: "", label: "任务" },
{ path: "/board", label: "看板" },
{ path: "/tickets", label: "工单" },
{ path: "/knowledge", label: "知识" },
{ path: "/members", label: "成员" },
];
+28
View File
@@ -112,3 +112,31 @@ export interface Notification {
read_at: string | null;
created_at: string;
}
export type KnowledgeStatus = "draft" | "in_review" | "published" | "archived";
export interface KnowledgeInfo {
doc_type: string;
summary: string;
status_hint: KnowledgeStatus;
source_kind: "manual" | "task" | "ticket";
source_item_id: string | null;
generated_by: "human" | "rule";
published_at: string | null;
archived_at: string | null;
chunk_count: number;
author_id: string;
}
export interface KnowledgeSource {
id: string;
kind: string;
title: string;
status: string;
}
export interface Knowledge extends Item {
knowledge: KnowledgeInfo;
source: KnowledgeSource | null;
activities?: Activity[];
}
+390
View File
@@ -0,0 +1,390 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { ApiError, api } from "../lib/client";
import type { Knowledge, KnowledgeStatus } from "../lib/types";
const TABS = [
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
{ key: "published", label: "已发布", statuses: ["published"] },
{ key: "archived", label: "已归档", statuses: ["archived"] },
{ key: "all", label: "全部", statuses: [] },
] as const;
const DOC_TYPE_LABEL: Record<string, string> = {
note: "笔记",
howto: "怎么做",
faq: "常见问题",
postmortem: "复盘",
decision: "决策",
};
const STATUS_LABEL: Record<KnowledgeStatus, string> = {
draft: "草稿",
in_review: "待审议",
published: "已发布",
archived: "已归档",
};
const SOURCE_LABEL: Record<string, string> = {
manual: "手记",
task: "来自任务",
ticket: "来自工单",
};
const ACTION_LABEL: Record<string, string> = {
submit_review: "提交审议",
withdraw: "撤回审议",
publish: "发布",
archive: "归档",
restore: "恢复成草稿",
};
export function KnowledgePage() {
const { wsId = "" } = useParams();
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("draft");
const [q, setQ] = useState("");
const [docs, setDocs] = useState<Knowledge[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const reqId = useRef(0);
const load = useCallback(
async (nextCursor: string | null) => {
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
const mine = ++reqId.current;
setLoading(true);
setError(null);
try {
const qs = new URLSearchParams();
const statuses = TABS.find((t) => t.key === tab)!.statuses;
for (const s of statuses) qs.append("status", s);
if (q.trim()) qs.set("q", q.trim());
if (nextCursor) qs.set("cursor", nextCursor);
const page = await api.get<{
items: Knowledge[];
next_cursor: string | null;
has_more: boolean;
}>(`/workspaces/${wsId}/knowledge?${qs.toString()}`);
if (mine !== reqId.current) return;
setDocs((prev) => (nextCursor ? [...prev, ...page.items] : page.items));
setCursor(page.has_more ? page.next_cursor : null);
} catch (err) {
if (mine !== reqId.current) return;
setError(err instanceof ApiError ? err.message : "知识加载失败");
} finally {
if (mine === reqId.current) setLoading(false);
}
},
[wsId, tab, q],
);
useEffect(() => {
void load(null);
}, [load]);
async function addNote() {
const title = draftTitle.trim();
if (!title) return;
try {
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
title,
doc_type: "note",
});
setDraftTitle("");
setAdding(false);
setTab("draft");
setOpenId(doc.id);
void load(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "保存失败");
}
}
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">知识</h1>
<p className="mt-0.5 text-xs text-ink-500">
工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索
</p>
</div>
<WorkspaceNav wsId={wsId} />
</div>
<div className="mb-3 flex flex-wrap items-center gap-2">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
tab === t.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{t.label}
</button>
))}
<input
className="input ml-auto w-56"
placeholder="搜标题或正文…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<button className="btn-primary px-3 py-1 text-sm" onClick={() => setAdding((v) => !v)}>
记一条
</button>
</div>
{adding && (
<div className="card mb-3">
<p className="mb-2 text-xs text-ink-500">
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
</p>
<div className="flex gap-2">
<input
className="input flex-1"
placeholder="一句话标题,比如「报销单要贴发票原件」"
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addNote();
}}
/>
<button className="btn-primary px-3 py-1 text-sm" onClick={() => void addNote()}>
保存
</button>
</div>
</div>
)}
{error && <p className="mb-3 rounded-lg bg-red-50 p-2 text-sm text-red-700">{error}</p>}
{docs.length === 0 && !loading && (
<div className="card text-center text-sm text-ink-500">
{tab === "draft"
? "还没有待核对的知识。把工单验收通过,或者在这里「记一条」。"
: "这里还空着。"}
</div>
)}
<ul className="space-y-2">
{docs.map((d) => (
<li key={d.id} className="card">
<div className="flex items-start justify-between gap-3">
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
<p className="truncate text-sm font-medium">{d.title}</p>
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-ink-500">
<span className="rounded bg-ink-100 px-1.5 py-0.5">
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
</span>
<span>{STATUS_LABEL[d.knowledge.status_hint]}</span>
<span>{SOURCE_LABEL[d.knowledge.source_kind] ?? d.knowledge.source_kind}</span>
{d.source && <span className="truncate">「{d.source.title}」</span>}
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
</p>
</button>
<button className="btn-ghost shrink-0 px-2 py-1 text-xs" onClick={() => setOpenId(d.id)}>
打开
</button>
</div>
</li>
))}
</ul>
{cursor && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</div>
)}
{openId && (
<KnowledgeDrawer
wsId={wsId}
itemId={openId}
onClose={() => setOpenId(null)}
onChanged={() => void load(null)}
/>
)}
</Layout>
);
}
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
function KnowledgeDrawer({
wsId,
itemId,
onClose,
onChanged,
}: {
wsId: string;
itemId: string;
onClose: () => void;
onChanged: () => void;
}) {
const [doc, setDoc] = useState<Knowledge | null>(null);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
setDoc(data);
setTitle(data.title);
setBody(data.body_md);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
}, [wsId, itemId]);
useEffect(() => {
void load();
}, [load]);
async function save() {
if (!doc) return;
setBusy(true);
setError(null);
try {
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
title,
body_md: body,
version: doc.version,
});
setDoc(next);
onChanged();
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("刚被别人改过,已刷新为最新内容");
await load();
} else {
setError(err instanceof ApiError ? err.message : "保存失败");
}
} finally {
setBusy(false);
}
}
async function act(action: string) {
if (!doc) return;
setBusy(true);
setError(null);
try {
const next = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}/status`, {
action,
version: doc.version,
});
setDoc(next);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
await load();
} finally {
setBusy(false);
}
}
const status = doc?.knowledge.status_hint;
const canEdit = status === "draft" || status === "in_review";
const actions: { key: string; label: string; show: boolean }[] = [
{ key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" },
{ key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" },
{
key: "publish",
label: ACTION_LABEL.publish,
show: status === "draft" || status === "in_review",
},
{
key: "archive",
label: ACTION_LABEL.archive,
show: status === "draft" || status === "in_review" || status === "published",
},
{ key: "restore", label: ACTION_LABEL.restore, show: status === "archived" },
];
return (
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
<aside
className="flex h-full w-full max-w-xl flex-col overflow-y-auto bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{!doc ? (
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
) : (
<>
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
<span>
{DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} ·{" "}
{STATUS_LABEL[doc.knowledge.status_hint]} · 版本 {doc.version}
</span>
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
关闭
</button>
</div>
<div className="mb-4 rounded-lg border border-ink-100 bg-ink-50 p-3 text-xs text-ink-700">
{doc.source ? (
<p>
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「{doc.source.title}」
</p>
) : (
<p>来源:手记(没有关联的事项)</p>
)}
<p className="mt-1">
{doc.knowledge.generated_by === "rule"
? "这份草稿由系统整理,请核对后再发布。"
: "手动创建。"}
{doc.knowledge.published_at &&
` 发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}。`}
</p>
</div>
<input
className="input mb-3"
value={title}
disabled={!canEdit}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="input mb-3 min-h-[280px] font-mono text-xs"
value={body}
disabled={!canEdit}
onChange={(e) => setBody(e.target.value)}
/>
{canEdit && (
<button className="btn-primary mb-4 self-start" disabled={busy} onClick={() => void save()}>
{busy ? "保存中…" : "保存"}
</button>
)}
<div className="mb-2 flex flex-wrap gap-2">
{actions
.filter((a) => a.show)
.map((a) => (
<button
key={a.key}
className={a.key === "publish" ? "btn-primary px-3 py-1 text-sm" : "btn-ghost px-3 py-1 text-sm"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
</button>
))}
</div>
{error && <p className="text-sm text-red-700">{error}</p>}
</>
)}
</aside>
</div>
);
}