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:
@@ -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 → claimed(claimed_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)
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user