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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user