merge: T15 报告编排骨架与失败退款(T-P3-07/09)

This commit is contained in:
xsl
2026-08-14 22:18:51 +08:00
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}
+175
View File
@@ -0,0 +1,175 @@
"""report 流水线编排的纯逻辑单测(不连库)。
覆盖任务文档 §4 的两条:assemble_stages 缺失即 CONFIG_ERROR 且列出缺失项;
run_pipeline 顺序执行、产物按键写入、第二阶段抛错 → failed 且已完成阶段
产物仍在。
"""
from __future__ import annotations
import json
import pytest
from packages.report.pipeline import (
ReportContext,
StageFailureError,
assemble_stages,
run_pipeline,
)
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import RpInput, RpReport
class FakeSession:
"""只满足 flush/commit/rollback 的最小会话——单测不连库。"""
def __init__(self) -> None:
self.flush_count = 0
self.commit_count = 0
async def flush(self) -> None:
self.flush_count += 1
async def commit(self) -> None:
self.commit_count += 1
async def rollback(self) -> None: ...
def _stage_settings(*paths: str) -> Settings:
"""按别名构造阶段配置——字段名带 alias 且未开 populate_by_name,
用字段名构造会被静默忽略、落回默认值(踩过坑,写下来防再踩)。"""
return Settings(REPORT_PIPELINE_STAGES_JSON=json.dumps(list(paths)))
def _make_ctx() -> tuple[ReportContext, FakeSession]:
# 瞬态 ORM 对象:default=dict 只在 INSERT 时生效,这里必须显式给 JSONB 列初值。
report = RpReport(
report_id=101,
user_id=1,
status="running",
result={},
model_info={},
)
report_input = RpInput(
input_id=11,
report_id=101,
questionnaire={},
photo_meta={},
derived_features={},
)
session = FakeSession()
ctx = ReportContext(
report=report,
report_input=report_input,
session=session, # type: ignore[arg-type]
settings=Settings(),
)
return ctx, session
class AlphaStage:
name = "alpha"
async def run(self, ctx: ReportContext) -> None:
ctx.report.result["alpha"] = {"value": 1}
ctx.report_input.derived_features["pose"] = {"kps": 3}
class BetaStage:
name = "beta"
async def run(self, ctx: ReportContext) -> None:
# 顺序契约:后一阶段能读到前一阶段的产物。
assert ctx.report.result.get("alpha") == {"value": 1}
ctx.report.result["beta"] = {"value": ctx.report.result["alpha"]["value"] + 1}
class BoomStage:
name = "boom"
async def run(self, ctx: ReportContext) -> None:
raise RuntimeError("爆了:模拟阶段失败")
async def test_run_pipeline_executes_stages_in_order_and_flushes_each() -> None:
ctx, session = _make_ctx()
await run_pipeline(ctx, [AlphaStage(), BetaStage()])
assert ctx.report.result == {"alpha": {"value": 1}, "beta": {"value": 2}}
assert ctx.report_input.derived_features == {"pose": {"kps": 3}}
# 每个阶段一次 flush:中间态逐段落库(T-P3-09)的结构保证。
assert session.flush_count == 2
async def test_run_pipeline_failure_keeps_completed_stage_and_marks_failed() -> None:
ctx, session = _make_ctx()
with pytest.raises(StageFailureError) as excinfo:
await run_pipeline(ctx, [AlphaStage(), BoomStage()])
assert excinfo.value.stage_name == "boom"
assert ctx.report.status == "failed"
# 已完成阶段的产物与视觉中间态都还在——重试/重生成不重跑的结构基础。
assert ctx.report.result["alpha"] == {"value": 1}
assert ctx.report_input.derived_features == {"pose": {"kps": 3}}
# 错误摘要写进 result,且失败结案在事务里先行提交(退款事务纪律)。
assert ctx.report.result["error"]["stage"] == "boom"
assert ctx.report.result["error"]["type"] == "RuntimeError"
assert session.commit_count == 1
def test_assemble_stages_lists_all_missing_paths() -> None:
settings = _stage_settings(
"packages.report.no_such_module:Nope",
"packages.report.pipeline:NotAClass",
)
with pytest.raises(AppError) as excinfo:
assemble_stages(settings)
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
assert excinfo.value.data == {
"missing": [
"packages.report.no_such_module:Nope",
"packages.report.pipeline:NotAClass",
]
}
def test_assemble_stages_rejects_empty_config() -> None:
settings = _stage_settings()
with pytest.raises(AppError) as excinfo:
assemble_stages(settings)
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
class _NotAStage:
"""存在但不满足 Stage 协议(没有 run)——必须被当成配置错误拦下。"""
name = "broken"
def test_assemble_stages_rejects_protocol_violations() -> None:
settings = _stage_settings(
"tests.unit.test_report_pipeline:AlphaStage",
"tests.unit.test_report_pipeline:_NotAStage",
)
with pytest.raises(AppError) as excinfo:
assemble_stages(settings)
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
assert excinfo.value.data == {"missing": ["tests.unit.test_report_pipeline:_NotAStage"]}
def test_assemble_stages_imports_from_any_module_path() -> None:
"""dotted path 装配对本模块的 Fake 阶段同样生效(importlib 动态导入路径)。"""
settings = _stage_settings("tests.unit.test_report_pipeline:AlphaStage")
stages = assemble_stages(settings)
assert [s.name for s in stages] == ["alpha"]