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
+33 -2
View File
@@ -196,26 +196,57 @@ class Agent(Base):
# ── 运营端业务模型(平台运营/任务/服务商/内容/配置)────────────────────────
class Task(Base):
"""平台任务(运营端可审核/下架;后续各端口接入)。"""
"""平台任务(运营端可审核/下架;后续各端口接入)。
状态机(扩展支持扫码接单):draft/pending/review/published/claimed/doing/completed/cancelled。
扫码接单流转:published → claimed(claimed_by/claimed_at) → doing(doing_at) → completed。
历史接单流水见 TaskClaimclaim 记录 claimed_by/claimed_at/doing_at。
"""
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String, primary_key=True)
task_code: Mapped[str] = mapped_column(String, default="", index=True) # 展示/扫码短码(唯一)
title: Mapped[str] = mapped_column(String, default="")
category: Mapped[str] = mapped_column(String, default="") # 设计创意/技术开发/...
sub_category: Mapped[str] = mapped_column(String, default="")
description: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[str] = mapped_column(String, default="") # 逗号分隔展示标签
mode: Mapped[str] = mapped_column(String, default="grab") # grab/bid/designated/dispatch
budget_min: Mapped[int] = mapped_column(Integer, default=0)
budget_max: Mapped[int] = mapped_column(Integer, default=0)
deadline: Mapped[str] = mapped_column(String, default="")
status: Mapped[str] = mapped_column(String, default="draft") # draft/pending/review/published/completed/cancelled
display_priority: Mapped[int] = mapped_column(Integer, default=0) # 大屏展示排序(越大越前)
status: Mapped[str] = mapped_column(String, default="draft") # draft/pending/review/published/claimed/doing/completed/cancelled
claimed_by: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
claimed_at: Mapped[str] = mapped_column(String, default="")
doing_at: Mapped[str] = mapped_column(String, default="")
publisher_org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True)
publisher_name: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[str] = mapped_column(String, default="")
updated_at: Mapped[str] = mapped_column(String, default="")
class TaskClaim(Base):
"""任务接单流水(扫码/抢单/投标中标),rich 记录任务状态流转历史。"""
__tablename__ = "task_claims"
id: Mapped[str] = mapped_column(String, primary_key=True)
task_id: Mapped[str] = mapped_column(
ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, index=True,
)
claimer_user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
claimer_name: Mapped[str] = mapped_column(String, default="")
claim_source: Mapped[str] = mapped_column(String, default="scan") # scan/grab/bid/dispatch
claimed_at: Mapped[str] = mapped_column(String, default="")
status: Mapped[str] = mapped_column(String, default="claimed") # claimed/doing/completed/withdrawn
created_at: Mapped[str] = mapped_column(String, default="")
updated_at: Mapped[str] = mapped_column(String, default="")
class ServiceProvider(Base):
"""服务商(运营端审核/评级/考核)。"""
+111 -6
View File
@@ -46,6 +46,7 @@ from .models import (
SubsidyApplication,
SystemConfig,
Task,
TaskClaim,
TrainingEnrollment,
User,
UserIdentity,
@@ -895,10 +896,14 @@ class TaskRepository:
def _to_dict(self, t: Task) -> dict:
return {
"id": t.id, "title": t.title, "category": t.category,
"sub_category": t.sub_category, "description": t.description,
"id": t.id, "task_code": t.task_code, "title": t.title,
"category": t.category, "sub_category": t.sub_category,
"description": t.description, "tags": t.tags,
"mode": t.mode, "budget_min": t.budget_min, "budget_max": t.budget_max,
"deadline": t.deadline, "status": t.status,
"deadline": t.deadline, "display_priority": t.display_priority,
"status": t.status,
"claimed_by": t.claimed_by, "claimed_at": t.claimed_at,
"doing_at": t.doing_at,
"publisher_org_id": t.publisher_org_id, "publisher_name": t.publisher_name,
"created_at": t.created_at, "updated_at": t.updated_at,
}
@@ -909,17 +914,37 @@ class TaskRepository:
q = q.where(Task.status == status)
return [self._to_dict(t) for t in await self.session.scalars(q)]
async def list_published(self) -> list[dict]:
"""大屏/广场展示:published + claimed/doing 均可上墙,按 display_priority 降序。"""
q = (
select(Task)
.where(Task.status.in_(["published", "claimed", "doing"]))
.order_by(Task.display_priority.desc(), Task.updated_at.desc())
)
return [self._to_dict(t) for t in await self.session.scalars(q)]
async def get(self, task_id: str) -> dict | None:
t = await self.session.get(Task, task_id)
return self._to_dict(t) if t else None
async def get_by_code(self, task_code: str) -> dict | None:
if not task_code:
return None
q = select(Task).where(Task.task_code == task_code)
t = (await self.session.scalars(q)).first()
return self._to_dict(t) if t else None
async def create(self, fields: dict) -> dict:
now = utcnow_iso()
t = Task(id=new_id("task"), title=fields.get("title", ""),
t = Task(id=new_id("task"), task_code=fields.get("task_code", ""),
title=fields.get("title", ""),
category=fields.get("category", ""), sub_category=fields.get("sub_category", ""),
description=fields.get("description", ""), mode=fields.get("mode", "grab"),
description=fields.get("description", ""),
tags=fields.get("tags", ""), mode=fields.get("mode", "grab"),
budget_min=fields.get("budget_min", 0), budget_max=fields.get("budget_max", 0),
deadline=fields.get("deadline", ""), status=fields.get("status", "draft"),
deadline=fields.get("deadline", ""),
display_priority=fields.get("display_priority", 0),
status=fields.get("status", "draft"),
publisher_org_id=fields.get("publisher_org_id"),
publisher_name=fields.get("publisher_name", ""),
created_at=now, updated_at=now)
@@ -927,6 +952,21 @@ class TaskRepository:
await self.session.commit()
return self._to_dict(t)
async def update(self, task_id: str, fields: dict) -> dict | None:
t = await self.session.get(Task, task_id)
if t is None:
return None
for key in (
"task_code", "title", "category", "sub_category", "description",
"tags", "mode", "budget_min", "budget_max", "deadline",
"display_priority", "publisher_name",
):
if key in fields:
setattr(t, key, fields.get(key))
t.updated_at = utcnow_iso()
await self.session.commit()
return self._to_dict(t)
async def set_status(self, task_id: str, status: str) -> dict | None:
t = await self.session.get(Task, task_id)
if t is None:
@@ -936,6 +976,70 @@ class TaskRepository:
await self.session.commit()
return self._to_dict(t)
async def claim(self, task_id: str, user_id: str) -> dict | None:
"""published → claimed:记录 claimed_by / claimed_at。"""
t = await self.session.get(Task, task_id)
if t is None:
return None
t.status = "claimed"
t.claimed_by = user_id
t.claimed_at = t.claimed_at or utcnow_iso()
t.updated_at = utcnow_iso()
await self.session.commit()
return self._to_dict(t)
async def set_doing(self, task_id: str) -> dict | None:
"""claimed → doing:记录 doing_at。"""
t = await self.session.get(Task, task_id)
if t is None:
return None
t.status = "doing"
t.doing_at = t.doing_at or utcnow_iso()
t.updated_at = utcnow_iso()
await self.session.commit()
return self._to_dict(t)
class TaskClaimRepository:
def __init__(self, session: AsyncSession):
self.session = session
def _to_dict(self, c: TaskClaim) -> dict:
return {
"id": c.id, "task_id": c.task_id,
"claimer_user_id": c.claimer_user_id, "claimer_name": c.claimer_name,
"claim_source": c.claim_source, "claimed_at": c.claimed_at,
"status": c.status, "created_at": c.created_at, "updated_at": c.updated_at,
}
async def create(self, task_id: str, user_id: str, name: str,
source: str = "scan") -> dict:
now = utcnow_iso()
c = TaskClaim(id=new_id("claim"), task_id=task_id,
claimer_user_id=user_id, claimer_name=name,
claim_source=source, claimed_at=now,
status="claimed", created_at=now, updated_at=now)
self.session.add(c)
await self.session.commit()
return self._to_dict(c)
async def list_by_task(self, task_id: str) -> list[dict]:
q = select(TaskClaim).where(TaskClaim.task_id == task_id)
return [self._to_dict(c) for c in await self.session.scalars(q)]
async def list_by_claimer(self, user_id: str) -> list[dict]:
q = select(TaskClaim).where(TaskClaim.claimer_user_id == user_id)
return [self._to_dict(c) for c in await self.session.scalars(q)]
async def set_status(self, claim_id: str, status: str) -> dict | None:
c = await self.session.get(TaskClaim, claim_id)
if c is None:
return None
c.status = status
c.updated_at = utcnow_iso()
await self.session.commit()
return self._to_dict(c)
# ---------------------------------------------------------------------------
# 服务商
@@ -1916,6 +2020,7 @@ class Database:
self.regions = RegionRepository(self.session)
self.audit = AuditRepository(self.session)
self.tasks = TaskRepository(self.session)
self.task_claims = TaskClaimRepository(self.session)
self.providers = ProviderRepository(self.session)
self.content = ContentRepository(self.session)
self.config = ConfigRepository(self.session)
+22 -9
View File
@@ -355,21 +355,34 @@ async def _seed_operator_business(session: AsyncSession, now: str) -> None:
if await session.scalar(select(Task.id).limit(1)) is not None:
return
session.add_all([
Task(id="task_001", title="电商小程序首页 UI 设计", category="设计创意",
sub_category="UI设计", description="为电商小程序设计首页 UI,含 5 个页面",
mode="bid", budget_min=2000, budget_max=5000, deadline="2026-09-01",
Task(id="task_001", task_code="TK-001-UI", title="电商小程序首页 UI 设计",
category="设计创意", sub_category="UI设计",
description="为电商小程序设计首页 UI,含 5 个页面",
tags="UI,设计", mode="bid", budget_min=2000, budget_max=5000,
deadline="2026-09-01", display_priority=10,
status="published", publisher_org_id="o_ent_001",
publisher_name="云南某某甲方企业", created_at=now, updated_at=now),
Task(id="task_002", title="品牌文案写作(月度)", category="文案写作",
sub_category="内容写作", description="每月 8 篇品牌公众号文案",
mode="designated", budget_min=3000, budget_max=6000, deadline="2026-12-31",
Task(id="task_002", task_code="TK-002-CT", title="品牌文案写作(月度)",
category="文案写作", sub_category="内容写作",
description="每月 8 篇品牌公众号文案", tags="文案,内容",
mode="designated", budget_min=3000, budget_max=6000,
deadline="2026-12-31", display_priority=5,
status="review", publisher_org_id="o_ent_001",
publisher_name="云南某某甲方企业", created_at=now, updated_at=now),
Task(id="task_003", title="小程序前后端开发", category="技术开发",
sub_category="小程序开发", description="点餐小程序全栈开发,3 期交付",
mode="bid", budget_min=30000, budget_max=60000, deadline="2026-10-15",
Task(id="task_003", task_code="TK-003-DEV", title="小程序前后端开发",
category="技术开发", sub_category="小程序开发",
description="点餐小程序全栈开发,3 期交付", tags="开发",
mode="bid", budget_min=30000, budget_max=60000,
deadline="2026-10-15", display_priority=20,
status="pending", publisher_org_id="o_ent_001",
publisher_name="云南某某甲方企业", created_at=now, updated_at=now),
Task(id="task_004", task_code="TK-004-GRAB", title="短视频剪辑(扫码即领)",
category="技术开发", sub_category="视频剪辑",
description="为园区企业制作 3 条产品短视频,扫码即可接单,2 周内交付。",
tags="短视频,剪辑", mode="grab", budget_min=1500, budget_max=3000,
deadline="2026-11-30", display_priority=30,
status="published", publisher_org_id="o_ent_001",
publisher_name="园区运营方", created_at=now, updated_at=now),
])
session.add_all([
ServiceProvider(id="prov_001", name="云超财税服务所", category="财税服务",