Files
meida_backend/tests/integration/test_report_flow.py
T
xsl e8208d2972 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 入口归一。
2026-08-14 22:18:14 +08:00

426 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""报告域端到端(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}