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
@@ -0,0 +1,59 @@
"""task claims and task display fields
Revision ID: f9f4eaef2e6f
Revises: 0007_user_classification
Create Date: 2026-08-25 18:23:09.123825
仅包含本次「任务中心 + 扫码接单」改动:tasks 增加展示/流转字段 + 新增 task_claims 流水表。
autogenerate 曾误带 training_* 删除与 users 列改动,已剔除,勿并入。)
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = 'f9f4eaef2e6f'
down_revision = '0007_user_classification'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ---- task_claims 接单流水表 ----
op.create_table('task_claims',
sa.Column('id', sa.String(), nullable=False),
sa.Column('task_id', sa.String(), nullable=False),
sa.Column('claimer_user_id', sa.String(), nullable=True),
sa.Column('claimer_name', sa.String(), nullable=False, server_default=''),
sa.Column('claim_source', sa.String(), nullable=False, server_default='scan'),
sa.Column('claimed_at', sa.String(), nullable=False, server_default=''),
sa.Column('status', sa.String(), nullable=False, server_default='claimed'),
sa.Column('created_at', sa.String(), nullable=False, server_default=''),
sa.Column('updated_at', sa.String(), nullable=False, server_default=''),
sa.ForeignKeyConstraint(['claimer_user_id'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_task_claims_task_id'), 'task_claims', ['task_id'], unique=False)
# ---- tasks 增加展示/流转字段 ----
op.add_column('tasks', sa.Column('task_code', sa.String(), nullable=True))
op.add_column('tasks', sa.Column('tags', sa.String(), nullable=True))
op.add_column('tasks', sa.Column('display_priority', sa.Integer(), nullable=True))
op.add_column('tasks', sa.Column('claimed_by', sa.String(), nullable=True))
op.add_column('tasks', sa.Column('claimed_at', sa.String(), nullable=True))
op.add_column('tasks', sa.Column('doing_at', sa.String(), nullable=True))
op.create_index(op.f('ix_tasks_task_code'), 'tasks', ['task_code'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_tasks_task_code'), table_name='tasks')
op.drop_column('tasks', 'doing_at')
op.drop_column('tasks', 'claimed_at')
op.drop_column('tasks', 'claimed_by')
op.drop_column('tasks', 'display_priority')
op.drop_column('tasks', 'tags')
op.drop_column('tasks', 'task_code')
op.drop_index(op.f('ix_task_claims_task_id'), table_name='task_claims')
op.drop_table('task_claims')
+49 -1
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from ..dependencies import get_db
from ..schemas.opc import BidRequest, ProfileUpdate, FinanceRecordCreate
from ..schemas.opc import BidRequest, ProfileUpdate, FinanceRecordCreate, TaskClaimRequest
from ...rbac import require_roles, write_audit
from ...infrastructure.repositories import Database, new_id, utcnow_iso
from ...infrastructure.models import FinanceRecord
@@ -132,6 +132,54 @@ async def opc_grab(
return updated
@router.post("/tasks/grab-by-code", summary="扫码/按短码接单")
async def opc_grab_by_code(
req: TaskClaimRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).claim(
(req.task_code or "").strip(), actor, source="scan",
)
await write_audit(db, action="task.claim", resource="task",
resource_id=updated["id"], detail=actor.get("username"),
user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/doing", summary="开始做单")
async def opc_task_doing(
task_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).start_doing(task_id, actor)
await write_audit(db, action="task.doing", resource="task", resource_id=task_id,
user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/complete", summary="完成做单")
async def opc_task_complete(
task_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).complete(task_id, actor)
await write_audit(db, action="task.complete", resource="task", resource_id=task_id,
user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/bid", summary="OPC 投标")
async def opc_bid(
task_id: str,
+66 -2
View File
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from ..dependencies import get_db
from ..schemas.operator import TaskCreateRequest, TaskStatusRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest
from ..schemas.operator import TaskCreateRequest, TaskUpdateRequest, TaskStatusRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityCreateRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest
from ...rbac import require_permission, require_roles, write_audit
from ...infrastructure.repositories import Database
from ...services import compute_client
@@ -36,12 +36,41 @@ async def create_task(
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:task.manage")),
):
task = await db.tasks.create(req.model_dump(exclude_none=True))
fields = req.model_dump(exclude_none=True)
if not fields.get("task_code"):
fields["task_code"] = _gen_task_code()
task = await db.tasks.create(fields)
await write_audit(db, action="task.create", resource="task", resource_id=task["id"],
detail=task["title"], user=actor, request=request)
return task
@router.patch("/tasks/{task_id}", summary="更新任务(增改)")
async def update_task(
task_id: str,
req: TaskUpdateRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:task.manage")),
):
if await db.tasks.get(task_id) is None:
raise HTTPException(status_code=404, detail="Task not found")
task = await db.tasks.update(task_id, req.model_dump(exclude_none=True))
await write_audit(db, action="task.update", resource="task", resource_id=task_id,
detail=(req.title or ""), user=actor, request=request)
return task
def _gen_task_code() -> str:
"""生成便于扫码展示的短码:TK-YYYYMMDD-XXXX(基于时间戳短采样)。"""
import time as _t
from datetime import datetime as _dt
stamp = _dt.now().strftime("%Y%m%d")
rand = f"{int(_t.time()) % 1000000:06d}"
return f"TK-{stamp}-{rand}"
@router.post("/tasks/{task_id}/status", summary="更新任务状态")
async def set_task_status(
task_id: str,
@@ -326,6 +355,19 @@ async def list_activities(
return training.list_activities(status=status)
@router.post("/activities", summary="创建活动")
async def create_activity(
req: ActivityCreateRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:activity.manage")),
):
activity = training.create_activity(req.model_dump(exclude_none=True))
await write_audit(db, action="activity.create", resource="activity", resource_id=activity["id"],
detail=activity["title"], user=actor, request=request)
return activity
@router.post("/activities/{event_id}/status", summary="更新活动状态")
async def set_activity_status(
event_id: str,
@@ -400,3 +442,25 @@ async def set_test_status(
if item is None:
raise HTTPException(status_code=404, detail="Test not found")
return item
# ── 培训业务 · 调研 / 政策 / 流程日志(落库结果只读)─────────────────────────
@router.get("/surveys", summary="调研结果列表")
async def list_surveys(
_u: dict = Depends(require_roles("operator")),
):
return training.list_surveys()
@router.get("/policies", summary="政策测评结果列表")
async def list_policies(
_u: dict = Depends(require_roles("operator")),
):
return training.list_policies()
@router.get("/plans", summary="流程启动日志列表")
async def list_plans(
_u: dict = Depends(require_roles("operator")),
):
return training.list_plans()
+5
View File
@@ -5,6 +5,11 @@ class BidRequest(BaseModel):
plan: str = ""
class TaskClaimRequest(BaseModel):
task_code: str = ""
task_id: str = ""
class ProfileUpdate(BaseModel):
nickname: str | None = None
+34 -1
View File
@@ -5,16 +5,33 @@ class TaskCreateRequest(BaseModel):
category: str = ""
sub_category: str = ""
description: str = ""
tags: str = ""
mode: str = "grab"
budget_min: int = 0
budget_max: int = 0
deadline: str = ""
task_code: str = ""
display_priority: int = 0
publisher_name: str = ""
class TaskUpdateRequest(BaseModel):
title: str | None = None
category: str | None = None
sub_category: str | None = None
description: str | None = None
tags: str | None = None
mode: str | None = None
budget_min: int | None = None
budget_max: int | None = None
deadline: str | None = None
task_code: str | None = None
display_priority: int | None = None
publisher_name: str | None = None
class TaskStatusRequest(BaseModel):
status: str # review/published/completed/cancelled
status: str # review/published/completed/cancelled/claimed/doing
@@ -81,6 +98,22 @@ class CourseStatusRequest(BaseModel):
status: str # draft/published/ended/offline
class ActivityCreateRequest(BaseModel):
title: str
type: str | None = None # free/salon
mode: str | None = None # online/offline
status: str | None = None # open/invite/pending/done/ended
subtitle: str | None = None
desc: str | None = None
location: str | None = None
host: str | None = None
image: str | None = None
link: str | None = None
start_at: str | None = None
duration_min: int | None = None
capacity: int | None = None
class ActivityStatusRequest(BaseModel):
status: str # open/invite/pending/done/ended
+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="财税服务",
+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}
+42 -4
View File
@@ -18,11 +18,49 @@ class TaskService:
self.db = db
async def grab(self, task_id: str, actor: dict) -> dict:
"""抢单:published + grab 模式可抢,抢后置 in_progress"""
"""抢单(兼容旧入口)published + grab → claimed,记录接单流水"""
return await self.claim_by_id(task_id, actor, source="grab")
async def claim(self, task_code: str, actor: dict, source: str = "scan") -> dict:
"""扫码/按短码接单:解析 task_code 后领单。"""
task = await self.db.tasks.get_by_code(task_code)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
return await self.claim_by_id(task["id"], actor, source=source)
async def claim_by_id(self, task_id: str, actor: dict, source: str) -> dict:
"""published + grab 模式 → claimedclaimed_by/claimed_at + TaskClaim 流水)。"""
task = await self.db.tasks.get(task_id)
if task is None or task["status"] != "published" or task["mode"] != "grab":
raise HTTPException(status_code=400, detail="任务不可抢单")
return await self.db.tasks.set_status(task_id, "in_progress")
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if task["status"] != "published":
raise HTTPException(status_code=400, detail="任务不可接单")
if task["mode"] != "grab":
raise HTTPException(status_code=400, detail="任务不支持扫码接单")
updated = await self.db.tasks.claim(task_id, actor["id"])
await self.db.task_claims.create(
task_id, actor["id"],
actor.get("nickname") or actor.get("username", ""), source,
)
return updated
async def start_doing(self, task_id: str, actor: dict) -> dict:
"""claimed → doing。"""
task = await self.db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if task["status"] != "claimed":
raise HTTPException(status_code=400, detail="任务未接单,无法开始")
return await self.db.tasks.set_doing(task_id)
async def complete(self, task_id: str, actor: dict) -> dict:
"""doing → completed。"""
task = await self.db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if task["status"] != "doing":
raise HTTPException(status_code=400, detail="任务未在进行中,无法完成")
return await self.db.tasks.set_status(task_id, "completed")
async def bid(self, task_id: str, actor: dict, quote: int, plan: str) -> dict:
"""投标:仅 published + bid 模式可投。"""
+76
View File
@@ -9,6 +9,7 @@
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from app.training import db as tdb
@@ -52,6 +53,16 @@ def list_activities(status: str | None = None) -> list[dict]:
return rows
def create_activity(data: dict) -> dict:
row = dict(data)
row["id"] = tdb.gen_id("E-")
row.setdefault("mode", "offline")
row.setdefault("status", "open")
row.setdefault("duration_min", 90)
tdb.insert("events", row)
return tdb.fetch_by_id("events", row["id"])
def set_activity_status(event_id: str, status: str) -> dict | None:
if tdb.fetch_by_id("events", event_id) is None:
return None
@@ -136,3 +147,68 @@ def create_test(data: dict) -> dict:
}
tdb.insert("tests", row)
return pack_test(tdb.fetch_by_id("tests", row["id"]))
def _parse_json(raw):
if raw is None:
return None
if isinstance(raw, (dict, list)):
return raw
try:
return json.loads(raw)
except (TypeError, ValueError):
return raw
# ── 调研(survey_logs 表)─────────────────────────────────────────────────
def list_surveys() -> list[dict]:
rows = tdb.list_all("survey_logs", "created_at DESC")
return [
{
"id": r.get("id"),
"username": r.get("username"),
"source": r.get("source"),
"answers": _parse_json(r.get("answers")),
"created_at": r.get("created_at"),
}
for r in rows
]
# ── 政策(policy_logs 表)─────────────────────────────────────────────────
def list_policies() -> list[dict]:
rows = tdb.list_all("policy_logs", "created_at DESC")
return [
{
"id": r.get("id"),
"username": r.get("username"),
"answers": _parse_json(r.get("answers")),
"policies_count": r.get("policies_count"),
"subsidies_count": r.get("subsidies_count"),
"loans_count": r.get("loans_count"),
"summary": r.get("summary"),
"created_at": r.get("created_at"),
}
for r in rows
]
# ── 流程(plan_logs 表)───────────────────────────────────────────────────
def list_plans() -> list[dict]:
rows = tdb.list_all("plan_logs", "created_at DESC")
return [
{
"id": r.get("id"),
"username": r.get("username"),
"region": r.get("region"),
"status": r.get("status"),
"need_park": r.get("need_park"),
"has_staff": r.get("has_staff"),
"steps_count": r.get("steps_count"),
"created_at": r.get("created_at"),
}
for r in rows
]
+63
View File
@@ -6,6 +6,7 @@
import asyncio
import json
import os
import secrets
import time
from typing import Optional
from fastapi import FastAPI, Header, Request, HTTPException, UploadFile, File
@@ -18,6 +19,12 @@ from .opc_engine import SECTIONS, AXIS_NAMES, ADAPT_LABELS, PROFILES
from contextlib import asynccontextmanager
# 平台域(任务/OPC 身份)访问:复用唯一总库 + 四层 Repository
from ..infrastructure.db import AsyncSessionLocal
from ..infrastructure.repositories import Database
from ..services.task_service import TaskService
from ..api.routers.auth import _ensure_opc_identity
@asynccontextmanager
async def lifespan(app):
@@ -202,6 +209,62 @@ async def login(req: Request):
return {"ok": True, "token": make_token(username), "username": username, "name": acct.get("name") or username}
# ============================= 扫码接单(任务中心)=============================
@app.post("/api/tasks/claim-by-code")
async def tasks_claim_by_code(request: Request):
"""扫码接单:task_code → 领单(published → claimed,记录 claimed_by/流水)。"""
body = await request.json()
code = str(body.get("task_code") or body.get("code") or "").strip()
if not code:
raise HTTPException(status_code=400, detail="缺少任务码")
auth = request.headers.get("Authorization", "")
async with AsyncSessionLocal() as session:
pdb = Database(session=session)
payload = require_auth(auth)
username = payload.get("username") or payload.get("sub") or ""
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username)
if not acct:
raise HTTPException(status_code=401, detail="请先登录")
key = (acct.get("phone") or "").strip() or username
user = await pdb.users.get_by_username(key)
if user is None:
user = await pdb.users.create(
key, password=secrets.token_hex(16),
phone=(acct.get("phone") or "").strip() or "",
nickname=(acct.get("name") or "").strip() or "",
role="opc_member", source="mini_program", auth_type="phone",
)
identity = await _ensure_opc_identity(pdb, user["id"])
actor = dict(user)
actor["id"] = user["id"]
actor["username"] = user["username"]
actor["nickname"] = user.get("nickname") or user.get("username")
actor["port"] = "opc"
actor["role"] = identity.get("role") or "opc_member"
task = await TaskService(pdb).claim(code, actor, source="scan")
return {"ok": True, "task": task}
@app.get("/api/tasks/my")
async def tasks_my_tasks(request: Request):
"""我的接单(扫码接单领取的任务)。"""
auth = request.headers.get("Authorization", "")
async with AsyncSessionLocal() as session:
pdb = Database(session=session)
payload = require_auth(auth)
username = payload.get("username") or payload.get("sub") or ""
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username)
if not acct:
raise HTTPException(status_code=401, detail="请先登录")
key = (acct.get("phone") or "").strip() or username
user = await pdb.users.get_by_username(key)
if user is None:
return {"items": []}
items = await pdb.tasks.list(status="claimed") + await pdb.tasks.list(status="doing")
mine = [t for t in items if t.get("claimed_by") == user["id"]]
return {"items": mine}
@app.post("/api/auth/send-code")
async def send_code(req: Request):
b = await req.json()
+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)
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""培训业务落库 + 管理端打通 自动化验证。
覆盖用户侧(小程序/网页共用)测评/调研/政策/流程流程「真实写库」,以及
运营端 /admin/* 管理面能否读到这些落库结果、能否创建活动。
环境说明:
- /api/* 由 app.training 子应用提供(生产经 dispatcher /api 前缀路由),
lifespan 为空操作,建表由 alembic/seed 非运行态完成 —— 这里把训练库指向临时
文件并执行 SCHEMA,实现完全隔离。
- 平台库(app.main)在测试中预置为空(Database.initialize 不在运行时建表/种子,
平台 RBAC 测试本身依赖外部迁移+种子,非本次范围)。因此管理端验证分两层:
1) 桥接函数 list_* / create_activity 直接对同一训练库读回,证明「落库→管理可见」;
2) 匿名访问 /admin/* 应 401/403 而非 404,证明路由已注册且受 RBAC 门卫。
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.services import training_admin_bridge as training
from app.training import db as tdb
from app.training.main import app as training_app
@pytest.fixture()
def training_db(tmp_path, monkeypatch, client):
"""训练库指向临时文件并建表;client 为 conftest 平台端(仅用于路由门卫校验)。"""
tmp_db = tmp_path / "train.db"
monkeypatch.setattr(tdb, "DB_PATH", str(tmp_db))
conn = tdb.get_conn()
conn.executescript(tdb.SCHEMA)
conn.commit()
conn.close()
with TestClient(training_app) as mp:
yield mp, client
def _assert_physical_row(table: str, rid: str):
row = tdb.fetch_by_id(table, rid)
assert row is not None, f"{table} 未落库"
def test_opc_test_persisted(training_db):
mp, _ = training_db
calc = mp.post("/api/tests/opc/calculate", json={"version": "quick", "answers": {}})
assert calc.status_code == 200, calc.text
r = calc.json()["result"]
report = mp.post("/api/tests", json={
"typeCode": r["typeCode"], "persona": (r["persona"] or {}).get("name", ""),
"adaptIndex": r["adaptIndex"], "adaptLevel": (r["adaptLevel"] or {}).get("level", ""),
"tracks": [t["code"] for t in (r["tracks"] or [])], "version": "quick", "username": "u_test",
})
assert report.status_code == 200, report.text
tid = report.json()["id"]
_assert_physical_row("tests", tid)
# 管理面(桥接)可读回
assert any(x.get("id") == tid for x in training.list_tests())
def test_survey_persisted(training_db):
mp, _ = training_db
res = mp.post("/api/survey/submit", json={"username": "u_survey", "source": "mp", "answers": {"q1": "A"}})
assert res.status_code == 200, res.text
sid = res.json()["id"]
_assert_physical_row("survey_logs", sid)
assert any(x.get("id") == sid for x in training.list_surveys())
def test_policy_persisted(training_db):
mp, _ = training_db
calc = mp.post("/api/policy/calculate", json={
"answers": {"status": "base", "region": "yn", "entity": "individual",
"capital": "self", "industry": ["tourism"]}})
assert calc.status_code == 200, calc.text
rr = calc.json()["result"]
res = mp.post("/api/policy-logs", json={
"username": "u_pol", "answers": {"status": "base"},
"policiesCount": len(rr["policies"]), "subsidiesCount": len(rr["subsidies"]),
"loansCount": len(rr["loans"]), "summary": "测试摘要",
})
assert res.status_code == 200, res.text
pid = res.json()["id"]
_assert_physical_row("policy_logs", pid)
assert any(x.get("id") == pid for x in training.list_policies())
def test_plan_persisted(training_db):
mp, _ = training_db
gen = mp.post("/api/plan/generate", json={"needPark": True, "needRegister": False, "hasStaff": False})
assert gen.status_code == 200, gen.text
steps = gen.json()["plan"]
res = mp.post("/api/plan-logs", json={
"username": "u_plan", "region": "yn", "status": "base",
"needPark": True, "hasStaff": False, "stepsCount": len(steps),
})
assert res.status_code == 200, res.text
lid = res.json()["id"]
_assert_physical_row("plan_logs", lid)
assert any(x.get("id") == lid for x in training.list_plans())
def test_admin_create_activity(training_db):
_mp, _ = training_db
item = training.create_activity({
"title": "新增公益课", "type": "free", "mode": "online",
"start_at": "2026-09-20T19:30:00+08:00", "capacity": 50,
})
assert item["id"]
_assert_physical_row("events", item["id"])
assert any(x.get("id") == item["id"] for x in training.list_activities())
def test_admin_routes_gated(training_db):
# /admin/surveys|policies|plans 应已注册且受 RBAC 门卫(匿名非 404)
_mp, admin = training_db
for path in ("/admin/surveys", "/admin/policies", "/admin/plans", "/admin/activities"):
res = admin.get(path)
assert res.status_code in (401, 403), f"{path} 未注册或无门卫: {res.status_code}"