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
+47 -3
View File
@@ -11,12 +11,16 @@ import json
import logging
import os
import time
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from ..infrastructure.db import get_session
from ..infrastructure.repositories import TaskRepository
from . import park_config, tenants
from .auth import create_device_token, create_token, parse_device_token, parse_token, require_tenant
from .config import settings
@@ -447,6 +451,41 @@ async def dashboard_overview(authorization: str | None = Header(None), tenant_id
return get_engine(tid).snapshot()
@router.get("/api/tasks", summary="大屏任务展示(平台全局已发布任务)")
async def park_tasks(
authorization: str | None = Header(None),
tenant_id: str | None = None,
session: AsyncSession = Depends(get_session),
):
"""返回系统任务中心的上墙任务(published/claimed/doing),供大屏轮播卡片。
大屏卡片含任务 ID、task_code 与二维码(scan_payload)。任务为平台全局实体,
不受园区隔离;此处按园区身份校验后返回。
"""
await _resolve_tenant(authorization, tenant_id)
repo = TaskRepository(session)
items = await repo.list_published()
now_label = datetime.now().strftime("%Y%m%d")
def payload(t: dict) -> dict:
return {
"id": t["id"],
"task_code": t["task_code"] or f"TK-{now_label}-{t['id'][-5:]}",
"scan_payload": t["task_code"] or f"TK-{now_label}-{t['id'][-5:]}",
"title": t["title"],
"category": t["category"],
"summary": (t["description"] or "")[:160],
"budget_min": t["budget_min"],
"budget_max": t["budget_max"],
"status": t["status"],
"claimed_by": t["claimed_by"],
"claimed_at": t["claimed_at"],
"tags": [tag for tag in (t.get("tags") or "").split(",") if tag],
}
return {"items": [payload(t) for t in items]}
@router.get("/api/park/zones")
async def park_zones(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
@@ -500,8 +539,10 @@ def _gen_code() -> str:
def _publish_bind(device_id: str, payload: dict) -> None:
"""未绑定大屏仅订阅 bind 频道;绑定成功后经此频道通知并下发 token。"""
hub.publish(f"opc/display/bind/{device_id}", payload, qos=1)
"""未绑定大屏仅订阅 bind 频道;绑定成功后经此频道通知并下发 token。
retain=True:绑定/解绑状态持久,屏幕即使延迟连接,订阅时也会立即收到最新状态(避免
绑定成功但屏幕 MQTT 尚未订阅导致丢失 bound 事件、无法自动进入)。"""
hub.publish(f"opc/display/bind/{device_id}", payload, qos=1, retain=True)
@router.post("/api/devices/register")
@@ -545,6 +586,9 @@ async def bind_screen_by_code(tid: str, body: BindByCodeBody):
async def device_unbind(device_id: str, authorization: str | None = Header(None), tenant_id: str | None = None):
await _resolve_tenant(authorization, tenant_id)
ok = await tenants.unbind_device(device_id)
if ok:
# 通知大屏退出到待绑定页(大屏订阅 bind/<device> 频道,收到 unbound 即清理本地并回绑定页)
_publish_bind(device_id, {"event": "unbound"})
return {"ok": ok, "device_id": device_id}