feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s

知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用
  `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、
  RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE
  SET NULL。
- 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、
  跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树
  一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。
- 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带
  `category`(含 `path` / `full_path`)。
- PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞
  乐观锁 `4009`。

Web
- `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一
  加宽(`TaskDetail` / `Tickets` / `Knowledge`)。
- 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer`
  一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类
  与标签。
- 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。

安卓
- `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、
  列表带分类与标签筛选。
- 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表
  顶部按分类筛选,详情里能改分类与标签后单独保存。

文档与测试
- `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节,
  错误码补 `7006`/`7007`/`7008`。
- `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。
- 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、
  三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需
  `force`、跨租户隔离。
- 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次
  写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。

验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、
`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。
安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
This commit is contained in:
Steven
2026-10-30 03:51:43 +08:00
parent 91b3dde670
commit ed31af3f46
18 changed files with 1932 additions and 164 deletions
+280
View File
@@ -0,0 +1,280 @@
"""知识库的"备忘录"形态:分类树、正文、标签、筛选。
知识最容易做坏的地方,是把它做成"只填标题的任务"——那样的库三个月后没人会打开。
所以这里断言的是:一条知识能装下正文和标签、能被归到三级分类里、能按分类和标签找回来。
三条边界单独盯着:
1. **三级到底**。第四级不是"更细",是"找不到";越界必须当场拒绝(7007)。
2. **移动会带着子树一起动**。把一棵三层树挪到第二层下面,结果会是第五层,
这种操作要么重算层级、要么拒绝——这里选择拒绝,因为静默改层级同样会让人找不到。
3. **删分类不删知识**。有内容时必须显式 `force`,否则容易点一下就把别人的整理工作打散。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
def _code(resp: Any) -> int:
return int(resp.json().get("code", 0))
async def _ws(client: AsyncClient) -> tuple[dict[str, Any], str]:
user = await login(client)
ws = await create_workspace(client, user, "知识库")
return user, ws["id"]
async def _category(
client: AsyncClient, user: dict[str, Any], ws_id: str, name: str, parent_id: str | None = None
) -> dict[str, Any]:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/categories",
json={"name": name, "parent_id": parent_id},
headers=user["headers"],
)
)
async def _note(
client: AsyncClient,
user: dict[str, Any],
ws_id: str,
title: str,
*,
body: str = "",
tags: list[str] | None = None,
category_id: str | None = None,
) -> dict[str, Any]:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/knowledge",
json={
"title": title,
"body_md": body,
"doc_type": "note",
"tag_names": tags or [],
"category_id": category_id,
},
headers=user["headers"],
)
)
async def _list(
client: AsyncClient, user: dict[str, Any], ws_id: str, **params: Any
) -> list[dict[str, Any]]:
return unwrap(
await client.get(
f"{V1}/workspaces/{ws_id}/knowledge", params=params, headers=user["headers"]
)
)["items"]
@pytest.mark.asyncio
async def test_note_keeps_body_tags_and_category_path(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
root = await _category(client, user, ws_id, "研发")
child = await _category(client, user, ws_id, "部署", root["id"])
leaf = await _category(client, user, ws_id, "回滚", child["id"])
assert leaf["depth"] == 3
assert leaf["full_path"] == "研发 / 部署 / 回滚"
doc = await _note(
client,
user,
ws_id,
"回滚前先停掉灰度",
body="# 步骤\n\n1. 停灰度\n2. 回滚镜像\n",
tags=["发布", "事故"],
category_id=leaf["id"],
)
assert doc["category"]["id"] == leaf["id"]
assert doc["category"]["full_path"] == "研发 / 部署 / 回滚"
assert {t["name"] for t in doc["tags"]} == {"发布", "事故"}
detail = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"])
)
assert "停灰度" in detail["body_md"]
assert detail["category"]["full_path"] == "研发 / 部署 / 回滚"
@pytest.mark.asyncio
async def test_category_depth_stops_at_three(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
l1 = await _category(client, user, ws_id, "一级")
l2 = await _category(client, user, ws_id, "二级", l1["id"])
l3 = await _category(client, user, ws_id, "三级", l2["id"])
resp = await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/categories",
json={"name": "四级", "parent_id": l3["id"]},
headers=user["headers"],
)
assert resp.status_code == 422, resp.text
assert _code(resp) == 7007
@pytest.mark.asyncio
async def test_move_cannot_exceed_depth_or_loop(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
a = await _category(client, user, ws_id, "A")
b = await _category(client, user, ws_id, "B", a["id"])
c = await _category(client, user, ws_id, "C", b["id"])
other = await _category(client, user, ws_id, "X")
# 把 A(下面还有 B、C)移到 C 下面:自己是自己的祖先
resp = await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}",
json={"move": True, "parent_id": c["id"], "version": a["version"]},
headers=user["headers"],
)
assert resp.status_code in (409, 422), resp.text
assert _code(resp) == 7007
# 把 A 移到 X 下面:A 现在是一级,B、C 跟着下沉,C 会到第四级 -> 拒绝
resp = await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}",
json={"move": True, "parent_id": other["id"], "version": a["version"]},
headers=user["headers"],
)
assert _code(resp) == 7007
del c, b
@pytest.mark.asyncio
async def test_rename_conflict_and_move_to_root(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
a = await _category(client, user, ws_id, "研发")
b = await _category(client, user, ws_id, "市场")
dup = await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/categories",
json={"name": "研发"},
headers=user["headers"],
)
assert dup.status_code == 409, dup.text
assert _code(dup) == 7008
child = await _category(client, user, ws_id, "部署", a["id"])
moved = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{child['id']}",
json={"move": True, "parent_id": None, "version": child["version"]},
headers=user["headers"],
)
)
assert moved["parent_id"] is None
assert moved["depth"] == 1
assert moved["full_path"] == "部署"
del b
@pytest.mark.asyncio
async def test_list_filters_by_category_subtree_tag_and_search(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
root = await _category(client, user, ws_id, "研发")
sub = await _category(client, user, ws_id, "部署", root["id"])
other = await _category(client, user, ws_id, "市场")
deep = await _note(
client,
user,
ws_id,
"灰度发布流程",
body="先停灰度再回滚",
tags=["发布"],
category_id=sub["id"],
)
await _note(
client, user, ws_id, "季度预算", body="市场预算表", tags=["财务"], category_id=other["id"]
)
loose = await _note(client, user, ws_id, "散记", body="还没归类", tags=["草稿"])
by_category = await _list(client, user, ws_id, category_id=root["id"])
assert {d["id"] for d in by_category} == {deep["id"]}
by_none = await _list(client, user, ws_id, uncategorized="true")
assert {d["id"] for d in by_none} == {loose["id"]}
assert {d["id"] for d in await _list(client, user, ws_id, tag="发布")} == {deep["id"]}
assert {d["id"] for d in await _list(client, user, ws_id, q="回滚")} == {deep["id"]}
@pytest.mark.asyncio
async def test_delete_category_needs_force_when_docs_exist(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
cat = await _category(client, user, ws_id, "待清理")
doc = await _note(client, user, ws_id, "有主的东西", category_id=cat["id"])
blocked = await client.delete(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}", headers=user["headers"]
)
assert blocked.status_code == 409, blocked.text
assert _code(blocked) == 7008
forced = unwrap(
await client.delete(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}?force=true",
headers=user["headers"],
)
)
assert forced["uncategorized_docs"] == 1
detail = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"])
)
assert detail["category"] is None
@pytest.mark.asyncio
async def test_category_is_tenant_scoped(client: AsyncClient) -> None:
owner, ws_id = await _ws(client)
intruder = await login(client)
other_ws = await create_workspace(client, intruder, "别人的工作区")
foreign = await _category(client, intruder, other_ws["id"], "他们的分类")
listed = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/categories", headers=owner["headers"])
)
assert listed["items"] == []
# 用别的工作区的分类 id 建知识:应被当作"不存在"
resp = await client.post(
f"{V1}/workspaces/{ws_id}/knowledge",
json={"title": "越界", "category_id": foreign["id"]},
headers=owner["headers"],
)
assert resp.status_code == 404, resp.text
assert _code(resp) == 7006
@pytest.mark.asyncio
async def test_patch_knowledge_moves_and_clears_category(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
cat = await _category(client, user, ws_id, "研发")
doc = await _note(client, user, ws_id, "一篇知识", category_id=cat["id"])
moved = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
json={"category_id": cat["id"], "tag_names": ["运维"], "version": doc["version"]},
headers=user["headers"],
)
)
assert moved["category"]["name"] == "研发"
assert {t["name"] for t in moved["tags"]} == {"运维"}
cleared = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
json={"clear_category": True, "version": moved["version"]},
headers=user["headers"],
)
)
assert cleared["category"] is None