From c4868f04cf4da4eceb49ccf270833dd1e6733237 Mon Sep 17 00:00:00 2001 From: dadao Date: Wed, 28 Oct 2026 04:05:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(task):=20=E6=89=B9=E9=87=8F=E6=94=B9?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=E7=9C=8B=E6=9D=BF=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /items/batch-status:逐条成败,不做全回滚;拖 10 条里 1 条冲突不该让另 9 条白干 - GET /items/board:四列一次取全,收件箱不进看板 - 修:/items/board 被 /{item_id} 抢先匹配导致 422 - 新增向量库配置项(M3 用,独立实例,不动 fnsvr-01) - 3 个新测试,全量 46 passed --- backend/.env.example | 7 ++ backend/app/api/items.py | 133 +++++++++++++++++++++++++++++++++ backend/app/config.py | 12 +++ backend/scripts/bench_views.py | 90 ++++++++++++++++++++++ backend/tests/test_items.py | 87 +++++++++++++++++++++ web/tsconfig.tsbuildinfo | 1 - 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 backend/scripts/bench_views.py delete mode 100644 web/tsconfig.tsbuildinfo diff --git a/backend/.env.example b/backend/.env.example index c65ef89..f975055 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,3 +31,10 @@ SMS_DEV_CODE=123456 SMS_THROTTLE_SECONDS=60 CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180 + +# ── 知识库向量库(独立的 PG + pgvector,M3 起用)────────────────────── +# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。 +# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。 +# VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/dadao_knowledge +# EMBEDDING_DIM=1024 +# EMBEDDING_MODEL=BAAI/bge-m3 diff --git a/backend/app/api/items.py b/backend/app/api/items.py index fed8406..4ce3a28 100644 --- a/backend/app/api/items.py +++ b/backend/app/api/items.py @@ -361,6 +361,60 @@ async def list_items( ) +@router.get("/board", summary="看板:按状态分列的最近任务") +async def board( + ctx: MemberDep, + session: SessionDep, + per_column: Annotated[int, Query(ge=1, le=100)] = 30, +) -> dict: + """一屏拖动需要的数据,一次拿全。 + + 每列按"截止时间早的在前、没有截止时间的靠后"排,其次按更新时间。 + 只取 `per_column` 条:看板是"手上正在做的",不是全量列表。 + """ + columns: dict[str, list[dict[str, Any]]] = {} + statuses = ("todo", "doing", "blocked", "done") + rows = ( + ( + await session.execute( + select(Item) + .where( + Item.workspace_id == ctx.ws_id, + Item.status.in_(statuses), + Item.deleted_at.is_(None), + ) + .order_by( + Item.due_at.asc().nulls_last(), + Item.updated_at.desc(), + Item.id.desc(), + ) + .limit(per_column * len(statuses)) + ) + ) + .scalars() + .all() + ) + + by_id = {row.id: row for row in rows} + tag_map = await _tags_of(session, ctx.ws_id, list(by_id)) + for status in statuses: + columns[status] = [ + _item_out(row, tag_map.get(row.id)) for row in rows if row.status == status + ][:per_column] + return ok( + { + "columns": columns, + "order": list(statuses), + "labels": { + "todo": "待办", + "doing": "进行中", + "blocked": "被阻塞", + "done": "已完成", + }, + } + ) + + @router.get("/{item_id}", summary="任务详情(含最近 50 条事件)") async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict: item = await _load_item(session, ctx.ws_id, item_id) @@ -423,6 +477,85 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) - return ok(_item_out(item, tags.get(item.id)), message="已创建") +class BatchStatusItemIn(BaseModel): + id: uuid.UUID + version: int + + +class BatchStatusIn(BaseModel): + status: str + items: list[BatchStatusItemIn] = Field(min_length=1, max_length=50) + + +@router.post("/batch-status", summary="批量改状态(逐条返回成败)") +async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep) -> dict: + """看板多选拖拽用。 + + 不做"全成功或全回滚":拖 10 条里有 1 条被同事改过,其余 9 条应当照样生效, + 失败的那条单独告诉前端原因。全回滚会让用户白干一次,而且更难解释。 + """ + if body.status not in ALL_STATUSES: + raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}") + + now = datetime.now(timezone.utc) + updated: list[dict[str, Any]] = [] + failed: list[dict[str, Any]] = [] + + for entry in body.items: + try: + item = await _load_item(session, ctx.ws_id, entry.id) + except AppError as exc: + failed.append({"id": str(entry.id), "code": int(exc.code), "message": exc.message}) + continue + + if item.status == body.status: + updated.append({"id": str(item.id), "version": item.version, "unchanged": True}) + continue + + try: + ensure_transition(item.status, body.status) + except AppError as exc: + failed.append( + { + "id": str(entry.id), + "code": int(exc.code), + "message": exc.message, + "allowed": (exc.details or {}).get("allowed"), + } + ) + continue + + previous = item.status + fields: dict[str, Any] = {"status": body.status, "completed_at": None} + apply_status(fields, body.status, now=now) + try: + item = await _apply_update( + session, + item, + version=entry.version, + values={"status": fields["status"], "completed_at": fields["completed_at"]}, + actor_id=ctx.user.id, + action="status_changed", + payload={"from": previous, "to": body.status, "batch": True}, + ) + except AppError as exc: + failed.append( + { + "id": str(entry.id), + "code": int(exc.code), + "message": exc.message, + "current_version": (exc.details or {}).get("current_version"), + } + ) + continue + updated.append({"id": str(item.id), "version": item.version, "unchanged": False}) + + return ok( + {"updated": updated, "failed": failed}, + message=f"已更新 {len(updated)} 条,{len(failed)} 条未生效", + ) + + @router.patch("/{item_id}", summary="修改任务字段(乐观锁)") async def update_item( item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep diff --git a/backend/app/config.py b/backend/app/config.py index f2a86be..cb01f0b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,6 +62,14 @@ class Settings(BaseSettings): idempotency_enabled: bool = True idempotency_ttl_seconds: int = 86_400 + # ── 知识库向量库(独立实例,M3 起用)─────────────────── + # 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它; + # 向量检索吃内存,和任务库挤在同一实例会互相拖累。 + # 未配置时所有向量相关能力(M3 才启用)直接不可用,不会静默降级成假结果。 + vector_database_url: str | None = None + embedding_dim: int = 1024 + embedding_model: str = "BAAI/bge-m3" + # ── 构建标记(部署校验用)────────────────────────────── build_commit: str = "unknown" build_time: str = "unknown" @@ -100,6 +108,10 @@ class Settings(BaseSettings): return self.test_migration_database_url return self.migration_database_url + def resolved_vector_database_url(self) -> str | None: + """向量库连接串;没配就是 None,调用方必须显式处理"没有向量能力"。""" + return self.vector_database_url + @lru_cache def get_settings() -> Settings: diff --git a/backend/scripts/bench_views.py b/backend/scripts/bench_views.py new file mode 100644 index 0000000..5eedd13 --- /dev/null +++ b/backend/scripts/bench_views.py @@ -0,0 +1,90 @@ +"""四视图 + 看板的首屏耗时实测(1000 条任务)。 + +不是压测工具,只回答一个问题:**1000 条之后,点开还会不会卡**。 +跑之前先起后端:`uvicorn app.main:app --port 8100`(连开发库)。 + + python scripts/bench_views.py +""" + +from __future__ import annotations + +import asyncio +import statistics +import time +import uuid +from datetime import datetime, timedelta, timezone + +import httpx + +BASE = "http://127.0.0.1:8100/api/v1" +TOTAL = 1000 +REPEAT = 15 + + +async def login(client: httpx.AsyncClient) -> tuple[str, dict[str, str]]: + phone = f"137{uuid.uuid4().int % 100_000_000:08d}" + sent = await client.post(f"{BASE}/auth/sms/send", json={"phone": phone}) + code = sent.json()["data"]["dev_code"] + out = await client.post(f"{BASE}/auth/login", json={"phone": phone, "code": code}) + token = out.json()["data"]["tokens"]["access_token"] + return token, {"Authorization": f"Bearer {token}"} + + +async def main() -> None: + async with httpx.AsyncClient(timeout=30.0) as client: + _, headers = await login(client) + ws = await client.post(f"{BASE}/workspaces", json={"name": "性能实测"}, headers=headers) + ws_id = ws.json()["data"]["id"] + + now = datetime.now(timezone.utc) + statuses = ["todo", "doing", "blocked", "done", "inbox"] + print(f"造 {TOTAL} 条任务……") + started = time.perf_counter() + for index in range(TOTAL): + payload = { + "title": f"测试任务 {index:04d} " + ("周报" if index % 7 == 0 else "跟进"), + "status": statuses[index % len(statuses)], + "priority": index % 4, + "due_at": (now + timedelta(days=(index % 10) - 5)).isoformat(), + "tag_names": ["批量", f"组{index % 5}"], + } + await client.post(f"{BASE}/workspaces/{ws_id}/items", json=payload, headers=headers) + print(f"写入耗时 {time.perf_counter() - started:.1f}s") + + targets = { + "今天": f"/workspaces/{ws_id}/items?view=today&limit=30", + "进行中": f"/workspaces/{ws_id}/items?view=active&limit=30", + "收件箱": f"/workspaces/{ws_id}/items?view=inbox&limit=30", + "全部": f"/workspaces/{ws_id}/items?view=all&limit=30", + "搜索(周报)": f"/workspaces/{ws_id}/items?view=all&q=%E5%91%A8%E6%8A%A5&limit=30", + "看板": f"/workspaces/{ws_id}/items/board", + } + + print("\n| 视图 | 首次 | P50 | P95 | 返回条数 |") + print("|---|---|---|---|---|") + for label, path in targets.items(): + samples: list[float] = [] + count = 0 + for attempt in range(REPEAT): + t0 = time.perf_counter() + resp = await client.get(f"{BASE}{path}", headers=headers) + samples.append((time.perf_counter() - t0) * 1000) + if attempt == 0: + body = resp.json()["data"] + if "columns" in body: + count = sum(len(v) for v in body["columns"].values()) + else: + count = len(body["items"]) + samples.sort() + p50 = statistics.median(samples) + p95 = samples[min(len(samples) - 1, int(len(samples) * 0.95))] + print(f"| {label} | {samples[0]:.1f} ms | {p50:.1f} ms | {p95:.1f} ms | {count} |") + + print( + "\n注:含网络往返与鉴权中间件;本机 127.0.0.1,所以这是**下限**," + "真实用户还要加速度。" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py index 6e80cec..ab19ba4 100644 --- a/backend/tests/test_items.py +++ b/backend/tests/test_items.py @@ -270,3 +270,90 @@ async def test_cursor_pagination_does_not_repeat_rows(client: AsyncClient) -> No assert ids_first.isdisjoint(ids_second) assert len(ids_second) == 1 assert second["has_more"] is False + + +async def test_batch_status_partial_success(client: AsyncClient) -> None: + """拖 3 条进去:2 条该成功,1 条因为版本过期该失败,且失败不影响另外两条。""" + user = await login(client) + ws = await create_workspace(client, user, "个人") + a = await _new_item(client, user, ws["id"], title="A", status="todo") + b = await _new_item(client, user, ws["id"], title="B", status="todo") + stale = await _new_item(client, user, ws["id"], title="C", status="todo") + + # 让 C 的版本先过期:另一个人(同一用户)改了一次 + unwrap( + await client.patch( + f"{items_url(ws['id'])}/{stale['id']}", + json={"title": "C 改过", "version": stale["version"]}, + headers=user["headers"], + ) + ) + + body = unwrap( + await client.post( + f"{items_url(ws['id'])}/batch-status", + json={ + "status": "done", + "items": [ + {"id": a["id"], "version": a["version"]}, + {"id": b["id"], "version": b["version"]}, + {"id": stale["id"], "version": stale["version"]}, + ], + }, + headers=user["headers"], + ) + ) + assert len(body["updated"]) == 2 + assert len(body["failed"]) == 1 + assert body["failed"][0]["id"] == stale["id"] + assert body["failed"][0]["code"] == 4009 + assert body["failed"][0]["current_version"] == stale["version"] + 1 + + detail_a = unwrap(await client.get(f"{items_url(ws['id'])}/{a['id']}", headers=user["headers"])) + assert detail_a["status"] == "done" + assert detail_a["completed_at"] is not None + + +async def test_batch_status_reports_illegal_transition(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + inbox = await _new_item(client, user, ws["id"], title="还在收件箱") + + body = unwrap( + await client.post( + f"{items_url(ws['id'])}/batch-status", + json={"status": "done", "items": [{"id": inbox["id"], "version": inbox["version"]}]}, + headers=user["headers"], + ) + ) + assert body["updated"] == [] + assert body["failed"][0]["code"] == 5002 + assert "todo" in body["failed"][0]["allowed"] + + +async def test_board_groups_by_status_and_hides_others(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + await _new_item(client, user, ws["id"], title="待办1", status="todo") + await _new_item(client, user, ws["id"], title="在做1", status="doing") + await _new_item(client, user, ws["id"], title="卡住1", status="blocked") + await _new_item(client, user, ws["id"], title="收件箱的", status="inbox") + + board = unwrap(await client.get(f"{items_url(ws['id'])}/board", headers=user["headers"])) + columns = board["columns"] + assert board["order"] == ["todo", "doing", "blocked", "done"] + assert [i["title"] for i in columns["todo"]] == ["待办1"] + assert [i["title"] for i in columns["doing"]] == ["在做1"] + assert [i["title"] for i in columns["blocked"]] == ["卡住1"] + assert columns["done"] == [] + # 收件箱不属于看板任何一列 + all_titles = [i["title"] for col in columns.values() for i in col] + assert "收件箱的" not in all_titles + + # 别人的工作区看板为空 + other_user = await login(client) + other_ws = await create_workspace(client, other_user, "别人") + empty = unwrap( + await client.get(f"{items_url(other_ws['id'])}/board", headers=other_user["headers"]) + ) + assert all(len(col) == 0 for col in empty["columns"].values()) diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo deleted file mode 100644 index fed686b..0000000 --- a/web/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/lib/client.ts","./src/lib/types.ts","./src/pages/home.tsx","./src/pages/join.tsx","./src/pages/login.tsx","./src/pages/placeholder.tsx","./src/pages/tasks.tsx","./src/pages/workspace.tsx"],"version":"5.9.3"} \ No newline at end of file