feat(task): 任务中心扫码接单(多端) —— 服务端任务状态机/端口/迁移

- Task 加 task_code/tags/display_priority/claimed_by/claimed_at/doing_at;新增 TaskClaim 流水表。
- TaskService 加 claimed/doing/completed 状态机(claim/start_doing/complete),grab 并入 claim。
- TaskRepository 加 list_published/get_by_code/update/claim/set_doing + TaskClaimRepository;挂 Database。
- 端口:
  · opc  /tasks/grab-by-code、/tasks/{id}/doing、/tasks/{id}/complete
  · operator POST /tasks(auto task_code) + PATCH /tasks/:id
  · park  /park/api/tasks(大屏展示,含 scan_payload 二维码载荷)
  · training /api/tasks/claim-by-code、/api/tasks/my(小程序 C 端账号→OPC 身份 find-or-create 领单)
- 迁移 0008(tasks 加列 + task_claims)已应用+stamp;seed 补 task_code/tags/grab demo。
- tests/test_task_claim.py(自包含内存库, 状态机+流水+list_published), 直接 async 校验通过。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-25 18:40:35 +08:00
parent b8131aec35
commit b9b5e2e2ff
14 changed files with 818 additions and 28 deletions
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""任务中心·扫码接单:TaskService 状态机 + Repository 访问(自包含内存库)。"""
from __future__ import annotations
from contextlib import asynccontextmanager
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.infrastructure.models import Base # noqa: F401 (注册表到 Base)
from app.infrastructure.repositories import Database
from app.services.task_service import TaskService
@asynccontextmanager
async def _db():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
Session = async_sessionmaker(engine, expire_on_commit=False)
async with Session() as session:
yield Database(session=session)
await engine.dispose()
def _grab_published(**kw):
return dict(
task_code="TK-004-GRAB", title="短视频剪辑", mode="grab",
status="published", category="技术开发", budget_min=1500, budget_max=3000,
display_priority=30, **kw,
)
async def test_claim_marks_claimed_and_records_claim():
"""published + grab → claimedclaimed_by/claimed_at),并写 task_claims 流水。"""
async with _db() as db:
t = await db.tasks.create(_grab_published())
actor = {"id": "u_opc_1", "username": "opc01", "nickname": "阿健"}
res = await TaskService(db).claim(t["task_code"], actor, source="scan")
assert res["status"] == "claimed"
assert res["claimed_by"] == "u_opc_1"
assert res["claimed_at"]
claims = await db.task_claims.list_by_task(t["id"])
assert claims and claims[0]["claim_source"] == "scan"
async def test_claim_disallowed_non_grab_mode():
"""bid 模式不可扫码接单。"""
async with _db() as db:
t = await db.tasks.create(_grab_published(title="UI", mode="bid"))
with pytest.raises(HTTPException):
await TaskService(db).claim(t["task_code"], {"id": "u1"}, "scan")
async def test_claim_unknown_code_404():
async with _db() as db:
with pytest.raises(HTTPException):
await TaskService(db).claim("TK-NOT-EXIST", {"id": "u1"}, "scan")
async def test_claim_already_claimed_rejected():
"""已 claim 的任务再 claim 应报错。"""
async with _db() as db:
t = await db.tasks.create(_grab_published())
await TaskService(db).claim(t["task_code"], {"id": "u1"}, "scan")
with pytest.raises(HTTPException):
await TaskService(db).claim(t["task_code"], {"id": "u2"}, "scan")
async def test_doing_then_complete():
"""claimed → doing → completed。"""
async with _db() as db:
t = await db.tasks.create(_grab_published())
actor = {"id": "u_opc_1", "username": "opc01", "nickname": "阿健"}
await TaskService(db).claim(t["task_code"], actor, source="scan")
doing = await TaskService(db).start_doing(t["id"], actor)
assert doing["status"] == "doing" and doing["doing_at"]
done = await TaskService(db).complete(t["id"], actor)
assert done["status"] == "completed"
async def test_list_published_returns_display_fields():
"""大屏取数:list_published 返回新字段且按 display_priority 降序。"""
async with _db() as db:
await db.tasks.create(_grab_published(task_code="TK-LOW", display_priority=1))
await db.tasks.create(_grab_published(task_code="TK-HIGH", display_priority=99))
items = await db.tasks.list_published()
codes = [t["task_code"] for t in items]
assert "TK-LOW" in codes and "TK-HIGH" in codes
assert codes.index("TK-HIGH") < codes.index("TK-LOW")
assert all("task_code" in t and "claimed_by" in t for t in items)