feat(report): orchestration skeleton with staged pipeline and refund-on-failure

POST /reports 骨架:资格校验→终身配额预扣→tk_task→cpu 队列;动态装配
流水线(dotted-path 注册、缺阶段即 CONFIG_ERROR);中间态逐段落库、失败
退款、derived_features 跨重试/重生成复用(T-P3-07/09)。

实现:packages/report 的 pipeline.py(Stage 契约保持不变,新增 assemble_stages/
run_pipeline/StageFailureError,ReportContext 增可选 task_id 参数、向后兼容)、
repo.py、schemas.py、service.py(重放优先于资格校验、reserve+建行+建任务同事务、
提交后才投递、并发同键落败回滚);apps/api/routers/reports.py 填桩(send_task 按
任务名投递,不 import apps.worker);apps/worker/report_tasks.py(worker 侧自建
gateway 装配、状态归一 QUEUED→RUNNING、成功 commit 配额+成本聚合写 model_info、
基础设施异常重试一次、重试耗尽兜底失败退款)。测试:单测 6 个(装配缺失/空配置/
协议违约/顺序执行/失败保留产物),集成 6 场景(预扣 lifetime/PENDING、成功
committed+两阶段键、同 request_key 幂等不重复扣、二次首生成 40920、阶段失败
refund+derived_features 保留、regenerate 走 m: 月配额 version+1 复用视觉中间态)。

偏差说明:
1) apps/worker/celery_app.py 的 include 只含 apps.worker.tasks(禁改文件),真
worker 进程注册 meida.report.generate 需协调者合并时把 include 补上
apps.worker.report_tasks;API 侧已按任务名 send_task,两侧只靠名字衔接,测试
不受影响。2) ReportContext 增加可选 task_id 关键字参数(失败结案要推动
tk_task),Stage 协议/DEFAULT_STAGE_PATHS/stage_names 未动,T16/T17 兼容。
3) JSONB 深变更用 flag_modified 显式标脏(重新赋相等新 dict 会被 flush 的按值
比较跳过,集成测试抓到过该坑)。4) POST 后 tk_task 保持 PENDING(任务文档
§4 场景 1 口径),QUEUED→RUNNING 由 worker 入口归一。
This commit is contained in:
xsl
2026-08-14 22:18:14 +08:00
parent d974526904
commit e8208d2972
9 changed files with 1561 additions and 11 deletions
+425
View File
@@ -0,0 +1,425 @@
"""报告域端到端(T-P3-07/09):触发→配额→流水线→失败退款→重生成。
Fake 阶段定义在本文件里,通过 ``REPORT_PIPELINE_STAGES_JSON`` 让
assemble_stages 按 dotted path 动态装配——与 T16/T17 的真实阶段走完全相同
的装配路径。不启真 celery:worker 内芯函数直调;API 投递钩子替换为 no-op,
避免测试消息进真 Redis 队列被开发环境的 worker 消费掉。
"""
from __future__ import annotations
import json
import os
import uuid
from collections.abc import AsyncIterator, Iterator
from contextlib import contextmanager
from typing import Any
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
# Fake 阶段走 importlib 动态装配,dotted path 必须可导入(本文件自身)。
_MODULE = "tests.integration.test_report_flow"
_SUCCESS_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeDiagnosisStage"])
_FAILING_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeBoomStage"])
class FakeVisionStage:
"""视觉阶段替身:计算次数写进 derived_features,用例据此断言「已存在即跳过」。"""
name = "vision"
async def run(self, ctx: Any) -> None:
features = ctx.report_input.derived_features
if not features:
# 与 T16 真实视觉阶段同一契约(T-P3-09):中间态非空则不重算。
features["vision_runs"] = 1
ctx.report.result["vision"] = {"features": sorted(features)}
class FakeDiagnosisStage:
name = "diagnosis"
async def run(self, ctx: Any) -> None:
# 顺序契约:诊断能读到视觉阶段的产物。
assert "vision" in ctx.report.result
ctx.report.result["diagnosis"] = {"summary": "肩宽腰细", "source": "vision"}
class FakeBoomStage:
name = "boom"
async def run(self, ctx: Any) -> None:
raise RuntimeError("爆了:模拟阶段失败")
# --- fixtures ---------------------------------------------------------------
@pytest.fixture
def no_enqueue(monkeypatch: pytest.MonkeyPatch) -> None:
"""不向真 Redis 投递:worker 由用例直调(celery 注册/消费不在本用例范围)。"""
monkeypatch.setattr("apps.api.routers.reports._enqueue_report", lambda task_id: None)
@contextmanager
def _stage_config(monkeypatch: pytest.MonkeyPatch, payload: str) -> Iterator[None]:
"""把阶段配置换成 Fake 阶段(setenv + cache_clear,结束时恢复)。
API 进程与 worker 内芯读的是同一个 get_settings 单例,cache_clear 之后
两边都会按 env 重建。
"""
from packages.shared.config import get_settings
monkeypatch.setenv("REPORT_PIPELINE_STAGES_JSON", payload)
get_settings.cache_clear()
try:
yield
finally:
monkeypatch.undo()
get_settings.cache_clear()
@pytest.fixture
def fake_stages(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
with _stage_config(monkeypatch, _SUCCESS_STAGES):
yield
@pytest.fixture
def failing_stages(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
with _stage_config(monkeypatch, _FAILING_STAGES):
yield
@pytest.fixture
async def client(no_enqueue: None) -> AsyncIterator[Any]:
import httpx
from apps.api.main import create_app
from packages.shared.config import get_settings
app = create_app(get_settings())
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
# --- helpers ----------------------------------------------------------------
async def _login(client: Any) -> tuple[dict[str, str], int]:
"""dev-token:每个用例独立 device_key,拿到的就是全新用户。"""
resp = await client.post(
"/api/v1/auth/dev-token", json={"device_key": f"report-it-{uuid.uuid4().hex[:8]}"}
)
assert resp.json()["code"] == 0
data = resp.json()["data"]
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
async def _create_report(
client: Any, headers: dict[str, str], *, request_key: str | None = None
) -> dict[str, Any]:
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {"style": "通勤", "height_cm": 168},
"photo_object_key": "report_photo/u1.jpg",
"request_key": request_key or f"rk-{uuid.uuid4().hex}",
},
)
assert resp.status_code == 200
body = resp.json()["data"]
assert body["status"] == "pending"
return body
async def _fetch_one(statement: Any) -> Any:
from packages.shared.db import session_scope
async with session_scope() as session:
result = await session.execute(statement)
return result.scalar_one_or_none()
async def _fetch_report(report_id: int) -> Any:
from sqlalchemy import select
from packages.shared.models import RpReport
return await _fetch_one(select(RpReport).where(RpReport.report_id == report_id))
async def _fetch_input(report_id: int) -> Any:
from sqlalchemy import select
from packages.shared.models import RpInput
return await _fetch_one(select(RpInput).where(RpInput.report_id == report_id))
async def _fetch_task(task_id: int) -> Any:
from packages.shared.db import session_scope
from packages.task import repo as task_repo
async with session_scope() as session:
task = await task_repo.get(session, task_id)
assert task is not None
# 脱离会话前把要断言的 JSONB 读出来(expire_on_commit=False,够用)。
return task
async def _ledger_rows(user_id: int, quota_key: str) -> list[Any]:
from sqlalchemy import select
from packages.shared.db import session_scope
from packages.shared.models import Ledger
async with session_scope() as session:
result = await session.execute(
select(Ledger)
.where(Ledger.user_id == user_id, Ledger.quota_key == quota_key)
.order_by(Ledger.created_at.asc(), Ledger.tx_id.asc())
)
return list(result.scalars().all())
# --- 场景 1:触发与预扣 -------------------------------------------------------
async def test_create_reserves_lifetime_quota_and_creates_pending_task(
fake_stages: None, client: Any
) -> None:
headers, user_id = await _login(client)
data = await _create_report(client, headers)
task_id, report_id = int(data["task_id"]), int(data["report_id"])
assert data["version"] == 1
task = await _fetch_task(task_id)
assert task.status == "pending"
assert task.task_type == "report"
assert task.payload["report_id"] == str(report_id)
assert task.quota_reserved == 1
assert task.reserve_tx_id is not None
report = await _fetch_report(report_id)
assert report.status == "pending"
rp_input = await _fetch_input(report_id)
assert rp_input.photo_meta == {"object_key": "report_photo/u1.jpg"}
assert rp_input.derived_features == {}
rows = await _ledger_rows(user_id, "report_basic")
assert len(rows) == 1
assert rows[0].tx_type == "consume"
assert rows[0].status == "reserved"
assert rows[0].period_key == "life" # report_basic 是终身配额(迁移 0003)
# --- 场景 2:worker 成功 ------------------------------------------------------
async def test_worker_success_commits_quota_and_writes_stage_keys(
fake_stages: None, client: Any
) -> None:
from apps.worker.report_tasks import _generate_report
headers, user_id = await _login(client)
data = await _create_report(client, headers)
task_id, report_id = int(data["task_id"]), int(data["report_id"])
result = await _generate_report(task_id)
assert result["status"] == "succeeded"
report = await _fetch_report(report_id)
assert report.status == "succeeded"
assert report.completed_at is not None
assert set(report.result) == {"vision", "diagnosis"}
assert report.result["vision"] == {"features": ["vision_runs"]}
assert "stages" in report.model_info
task = await _fetch_task(task_id)
assert task.status == "succeeded"
assert task.progress == 100
rows = await _ledger_rows(user_id, "report_basic")
assert [row.status for row in rows] == ["committed"]
# 列表与详情视图透出同样的产物。
listing = (await client.get("/api/v1/reports", headers=headers)).json()["data"]
assert [item["report_id"] for item in listing] == [str(report_id)]
assert listing[0]["result"]["diagnosis"]["summary"] == "肩宽腰细"
# --- 场景 3:幂等重提交 -------------------------------------------------------
async def test_duplicate_request_key_returns_same_task_without_second_reserve(
fake_stages: None, client: Any
) -> None:
from apps.worker.report_tasks import _generate_report
headers, user_id = await _login(client)
request_key = f"rk-{uuid.uuid4().hex}"
first = await _create_report(client, headers, request_key=request_key)
await _generate_report(int(first["task_id"])) # 先让首单成功
again = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {"style": "通勤"},
"photo_object_key": "report_photo/u1.jpg",
"request_key": request_key,
},
)
assert again.status_code == 200
replay = again.json()["data"]
assert replay["task_id"] == first["task_id"]
assert replay["report_id"] == first["report_id"]
assert replay["status"] == "succeeded"
# tk_task 幂等键 + reserve 幂等键双保险:只有一笔 reserve。
rows = await _ledger_rows(user_id, "report_basic")
assert len(rows) == 1
assert rows[0].status == "committed"
assert await _count_user_reports(user_id) == 1 # 重放不建新报告行
async def _count_user_reports(user_id: int) -> int:
from sqlalchemy import func, select
from packages.shared.db import session_scope
from packages.shared.models import RpReport
async with session_scope() as session:
result = await session.execute(
select(func.count()).select_from(RpReport).where(RpReport.user_id == user_id)
)
return int(result.scalar_one())
# --- 场景 4:首次资格 40920 ---------------------------------------------------
async def test_second_first_report_is_rejected_with_40920(fake_stages: None, client: Any) -> None:
from apps.worker.report_tasks import _generate_report
headers, _ = await _login(client)
data = await _create_report(client, headers)
await _generate_report(int(data["task_id"])) # 首单成功,终身资格已用掉
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {},
"photo_object_key": "report_photo/u2.jpg",
"request_key": f"rk-{uuid.uuid4().hex}", # 新键:不是幂等重放
},
)
assert resp.status_code == 409
body = resp.json()
assert body["code"] == 40920
assert "30" in (body.get("hint") or "")
# --- 场景 5:失败退款 + derived_features 保留 ----------------------------------
async def test_stage_failure_refunds_and_preserves_derived_features(
failing_stages: None, client: Any
) -> None:
from apps.worker.report_tasks import _generate_report
from packages.report.pipeline import StageFailureError
headers, user_id = await _login(client)
data = await _create_report(client, headers)
task_id, report_id = int(data["task_id"]), int(data["report_id"])
with pytest.raises(StageFailureError):
await _generate_report(task_id)
report = await _fetch_report(report_id)
assert report.status == "failed"
# 已完成阶段的产物仍在 + 错误摘要写进 result。
assert report.result["vision"] == {"features": ["vision_runs"]}
assert report.result["error"]["stage"] == "boom"
# T-P3-09 核心:视觉中间态保留——重试/重生成不需要重新上传照片。
rp_input = await _fetch_input(report_id)
assert rp_input.derived_features == {"vision_runs": 1}
task = await _fetch_task(task_id)
assert task.status == "failed"
# 退款:consume 流水转 refunded,反向 refund 流水 committed。
rows = await _ledger_rows(user_id, "report_basic")
assert [(row.tx_type, row.status) for row in rows] == [
("consume", "refunded"),
("refund", "committed"),
]
# failed 报告的详情只透错误摘要,不透半成品。
detail = (await client.get(f"/api/v1/reports/{report_id}", headers=headers)).json()["data"]
assert set(detail["result"]) == {"error"}
assert detail["result"]["error"]["stage"] == "boom"
# --- 场景 6:regenerate 走月配额并复用视觉中间态 -------------------------------
async def test_regenerate_uses_month_quota_and_reuses_derived_features(
fake_stages: None, client: Any
) -> None:
from apps.worker.report_tasks import _generate_report
headers, user_id = await _login(client)
first = await _create_report(client, headers)
await _generate_report(int(first["task_id"]))
regen = await client.post(
f"/api/v1/reports/{first['report_id']}/regenerate",
headers=headers,
json={"request_key": f"rk-regen-{uuid.uuid4().hex}"},
)
assert regen.status_code == 200
data = regen.json()["data"]
assert data["version"] == 2
new_report_id = int(data["report_id"])
assert new_report_id != int(first["report_id"])
# 重新生成走 report_regen 月配额(m:YYYY-MM),不复用终身额度。
rows = await _ledger_rows(user_id, "report_regen")
assert len(rows) == 1
assert rows[0].status == "reserved"
assert rows[0].period_key.startswith("m:")
# 新输入行直接带着上一版的视觉中间态(无需重新上传照片)。
new_input = await _fetch_input(new_report_id)
assert new_input.derived_features == {"vision_runs": 1}
await _generate_report(int(data["task_id"]))
report_v2 = await _fetch_report(new_report_id)
assert report_v2.status == "succeeded"
assert report_v2.version == 2
# 成功后视觉仍未重算:vision_runs 计数保持 1(Fake 阶段非空即跳过)。
input_after = await _fetch_input(new_report_id)
assert input_after.derived_features == {"vision_runs": 1}