feat(task): 批量改状态与看板数据接口

- POST /items/batch-status:逐条成败,不做全回滚;拖 10 条里 1 条冲突不该让另 9 条白干
- GET /items/board:四列一次取全,收件箱不进看板
- 修:/items/board 被 /{item_id} 抢先匹配导致 422
- 新增向量库配置项(M3 用,独立实例,不动 fnsvr-01)
- 3 个新测试,全量 46 passed
This commit is contained in:
dadao
2026-10-28 04:05:35 +08:00
parent e25a1a4e31
commit c4868f04cf
6 changed files with 329 additions and 1 deletions
+7
View File
@@ -31,3 +31,10 @@ SMS_DEV_CODE=123456
SMS_THROTTLE_SECONDS=60 SMS_THROTTLE_SECONDS=60
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180 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
+133
View File
@@ -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 条事件)") @router.get("/{item_id}", summary="任务详情(含最近 50 条事件)")
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict: async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
item = await _load_item(session, ctx.ws_id, item_id) 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="已创建") 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="修改任务字段(乐观锁)") @router.patch("/{item_id}", summary="修改任务字段(乐观锁)")
async def update_item( async def update_item(
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
+12
View File
@@ -62,6 +62,14 @@ class Settings(BaseSettings):
idempotency_enabled: bool = True idempotency_enabled: bool = True
idempotency_ttl_seconds: int = 86_400 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_commit: str = "unknown"
build_time: str = "unknown" build_time: str = "unknown"
@@ -100,6 +108,10 @@ class Settings(BaseSettings):
return self.test_migration_database_url return self.test_migration_database_url
return self.migration_database_url return self.migration_database_url
def resolved_vector_database_url(self) -> str | None:
"""向量库连接串;没配就是 None,调用方必须显式处理"没有向量能力"。"""
return self.vector_database_url
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+90
View File
@@ -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())
+87
View File
@@ -270,3 +270,90 @@ async def test_cursor_pagination_does_not_repeat_rows(client: AsyncClient) -> No
assert ids_first.isdisjoint(ids_second) assert ids_first.isdisjoint(ids_second)
assert len(ids_second) == 1 assert len(ids_second) == 1
assert second["has_more"] is False 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())
-1
View File
@@ -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"}