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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 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 +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