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