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 项断言(沉淀 → 待核对 → 发布)
This commit is contained in:
dadao
2026-10-28 14:02:35 +08:00
parent 3471b07ac1
commit e9addeed25
17 changed files with 1957 additions and 1 deletions
+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"]