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:
+121
-4
@@ -1,11 +1,128 @@
|
||||
"""报告路由(骨架,T15 填充实现)。
|
||||
"""报告路由(T15):触发、查询、重新生成。
|
||||
|
||||
POST /reports、GET /reports、GET /reports/{id}、POST /reports/{id}/regenerate;
|
||||
实现契约见 docs/开发任务包/T15-报告域骨架.md。
|
||||
投递方式:用 celery ``send_task`` 按任务名发消息,不 import apps.worker ——
|
||||
apps 互不依赖契约只给 tasks→worker 开了一个例外,报告的投递边靠「任务名
|
||||
约定」解耦,任务代码由 worker 进程加载(见 apps/worker/report_tasks.py)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
|
||||
from celery import Celery
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from apps.api.deps import CurrentUserId, SessionDep, SettingsDep
|
||||
from packages.report.schemas import (
|
||||
ReportCreateRequest,
|
||||
ReportItem,
|
||||
ReportRegenerateRequest,
|
||||
ReportSubmitResponse,
|
||||
)
|
||||
from packages.report.service import ReportService, ReportSubmission
|
||||
from packages.shared.config import get_settings
|
||||
from packages.shared.models import RpReport
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
REPORT_TASK_NAME = "meida.report.generate"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _producer() -> Celery:
|
||||
"""send_task 专用生产者:只发消息不加载任务代码,不需要 result backend。"""
|
||||
settings = get_settings()
|
||||
return Celery("meida-api", broker=settings.celery_broker_url)
|
||||
|
||||
|
||||
def _enqueue_report(task_id: int) -> None:
|
||||
"""投进 cpu 队列(task_routes 里 meida.report.* → cpu 已配好,显式声明双保险)。"""
|
||||
_producer().send_task(REPORT_TASK_NAME, args=[task_id], queue="cpu")
|
||||
|
||||
|
||||
def _serialize(report: RpReport) -> dict[str, object]:
|
||||
result = report.result or {}
|
||||
if report.status == "failed":
|
||||
# 失败报告只透错误摘要:半成品是流水线的中间态(给重试/重生成复用),
|
||||
# 不是给用户看的内容。
|
||||
result = {"error": result.get("error", {})}
|
||||
return ReportItem(
|
||||
report_id=str(report.report_id),
|
||||
tier=report.tier,
|
||||
status=report.status,
|
||||
version=report.version,
|
||||
result=result,
|
||||
created_at=report.created_at,
|
||||
completed_at=report.completed_at,
|
||||
).model_dump()
|
||||
|
||||
|
||||
def report_service_dep(session: SessionDep, settings: SettingsDep) -> ReportService:
|
||||
return ReportService(session, settings=settings, enqueue=_enqueue_report)
|
||||
|
||||
|
||||
ReportServiceDep = Annotated[ReportService, Depends(report_service_dep)]
|
||||
|
||||
|
||||
@router.post("", summary="生成第一份美学报告(终身一次,走 report_basic 配额)")
|
||||
async def create_report(
|
||||
payload: ReportCreateRequest,
|
||||
user_id: CurrentUserId,
|
||||
service: ReportServiceDep,
|
||||
) -> dict[str, object]:
|
||||
request_key = payload.request_key or f"report:{user_id}:{uuid.uuid4().hex}"
|
||||
submission = await service.create_report(
|
||||
user_id=user_id,
|
||||
questionnaire=payload.questionnaire,
|
||||
photo_object_key=payload.photo_object_key,
|
||||
request_key=request_key,
|
||||
)
|
||||
return ok(_submit_payload(submission))
|
||||
|
||||
|
||||
@router.get("", summary="我的报告列表(归属校验)")
|
||||
async def list_reports(
|
||||
user_id: CurrentUserId,
|
||||
service: ReportServiceDep,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
) -> dict[str, object]:
|
||||
rows = await service.list_reports(user_id=user_id, limit=limit)
|
||||
return ok([_serialize(row) for row in rows])
|
||||
|
||||
|
||||
@router.get("/{report_id}", summary="报告详情(归属校验)")
|
||||
async def get_report(
|
||||
report_id: int,
|
||||
user_id: CurrentUserId,
|
||||
service: ReportServiceDep,
|
||||
) -> dict[str, object]:
|
||||
report = await service.get_report(user_id=user_id, report_id=report_id)
|
||||
return ok(_serialize(report))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{report_id}/regenerate", summary="重新生成(走 report_regen 月配额,复用照片与视觉结果)"
|
||||
)
|
||||
async def regenerate_report(
|
||||
report_id: int,
|
||||
payload: ReportRegenerateRequest,
|
||||
user_id: CurrentUserId,
|
||||
service: ReportServiceDep,
|
||||
) -> dict[str, object]:
|
||||
request_key = payload.request_key or f"report-regen:{user_id}:{report_id}:{uuid.uuid4().hex}"
|
||||
submission = await service.regenerate(
|
||||
user_id=user_id, report_id=report_id, request_key=request_key
|
||||
)
|
||||
return ok(_submit_payload(submission))
|
||||
|
||||
|
||||
def _submit_payload(submission: ReportSubmission) -> dict[str, object]:
|
||||
return ReportSubmitResponse(
|
||||
task_id=str(submission.task_id),
|
||||
report_id=str(submission.report_id),
|
||||
status=submission.status,
|
||||
version=submission.version,
|
||||
).model_dump()
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""报告生成的 celery 任务(name=``meida.report.generate``,cpu 队列,T-P3-07/09)。
|
||||
|
||||
注册说明:``apps/worker/celery_app.py`` 的 include 目前只含 apps.worker.tasks
|
||||
(本批禁改文件),worker 进程要注册本模块需要把 include 补上——由协调者在
|
||||
合并阶段处理;API 侧用 send_task 按任务名投递,两侧只靠名字衔接,互不 import。
|
||||
|
||||
worker 侧 LLM 网关装配:apps.api.deps 属 HTTP 层,worker 不得 import apps.api
|
||||
(apps 互不依赖契约),这里按同一配方自建;改网关装配时两处同步。
|
||||
|
||||
成本归因约定:阶段经 gateway 调用时以 ``ref_type="report"``、
|
||||
``ref_id=str(report_id)`` 记账——成功结案按这个口径聚合写
|
||||
``rp_report.model_info`` / ``cost_cents``(ob_ai_call 是唯一数据源,架构 §13)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from apps.worker.celery_app import celery_app
|
||||
from packages.billing.service import BillingService
|
||||
from packages.domain.task.state import TERMINAL_STATUSES, TaskStatus
|
||||
from packages.llm.budget import DailyBudgetGuard, RedisCostCounter
|
||||
from packages.llm.circuit import CircuitBreakerRegistry
|
||||
from packages.llm.cost import (
|
||||
CompositeAICallRecorder,
|
||||
ModelRate,
|
||||
PeakAwareRateTable,
|
||||
RateTable,
|
||||
)
|
||||
from packages.llm.gateway import LLMGateway
|
||||
from packages.llm.prompts import PromptRegistry
|
||||
from packages.llm.providers.base import LLMProvider
|
||||
from packages.llm.providers.deepseek import DeepSeekProvider
|
||||
from packages.llm.providers.zhipu import ZhipuGLMProvider
|
||||
from packages.llm.recording import PrometheusAICallRecorder, SqlAlchemyAICallRecorder
|
||||
from packages.llm.routing import SceneRouter, build_scene_configs
|
||||
from packages.report import repo as report_repo
|
||||
from packages.report.pipeline import (
|
||||
ReportContext,
|
||||
StageFailureError,
|
||||
assemble_stages,
|
||||
load_reservation,
|
||||
run_pipeline,
|
||||
)
|
||||
from packages.shared.config import Settings, get_settings
|
||||
from packages.shared.context import bind_context
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.shared.models import AiCall, RpReport, Task
|
||||
from packages.shared.redis_client import get_redis
|
||||
from packages.task import repo as task_repo
|
||||
from packages.task.service import TaskService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def build_llm_gateway(settings: Settings) -> LLMGateway:
|
||||
"""worker 侧装配,与 apps.api.deps.build_llm_gateway 同构(见模块 docstring)。"""
|
||||
providers: dict[str, LLMProvider] = {
|
||||
"deepseek": DeepSeekProvider(
|
||||
api_key=settings.deepseek_api_key,
|
||||
base_url=settings.deepseek_base_url,
|
||||
default_model=settings.deepseek_model,
|
||||
),
|
||||
"zhipu": ZhipuGLMProvider(
|
||||
api_key=settings.zhipu_api_key,
|
||||
base_url=settings.zhipu_base_url,
|
||||
default_model=settings.zhipu_model,
|
||||
),
|
||||
}
|
||||
default_rate = ModelRate.from_mapping(settings.llm_default_cost_rate)
|
||||
rates = PeakAwareRateTable(
|
||||
RateTable(
|
||||
{
|
||||
model: ModelRate.from_mapping(rate)
|
||||
for model, rate in settings.llm_cost_rates.items()
|
||||
},
|
||||
default=default_rate,
|
||||
),
|
||||
RateTable(
|
||||
{
|
||||
model: ModelRate.from_mapping(rate)
|
||||
for model, rate in settings.llm_cost_rates_peak.items()
|
||||
},
|
||||
default=default_rate,
|
||||
)
|
||||
if settings.llm_cost_rates_peak
|
||||
else None,
|
||||
enabled=settings.llm_peak_pricing_enabled,
|
||||
)
|
||||
return LLMGateway(
|
||||
providers=providers,
|
||||
router=SceneRouter(
|
||||
build_scene_configs(
|
||||
primary_provider=settings.llm_primary_provider,
|
||||
backup_provider=settings.llm_backup_provider,
|
||||
structured_max_retries=settings.llm_structured_max_retries,
|
||||
overrides=settings.llm_scene_overrides,
|
||||
)
|
||||
),
|
||||
prompts=PromptRegistry(
|
||||
settings.prompts_dir, cache_prefix_mode=settings.llm_cache_prefix_mode
|
||||
),
|
||||
rates=rates,
|
||||
recorder=CompositeAICallRecorder(
|
||||
SqlAlchemyAICallRecorder(get_session_factory()),
|
||||
PrometheusAICallRecorder(),
|
||||
),
|
||||
budget=DailyBudgetGuard(
|
||||
RedisCostCounter(get_redis()),
|
||||
daily_budget_cents=Decimal(settings.llm_daily_budget_cents),
|
||||
enabled=settings.llm_daily_budget_enabled,
|
||||
tz_offset_hours=settings.llm_budget_timezone_offset_hours,
|
||||
tiers=list(settings.llm_budget_tiers),
|
||||
),
|
||||
breakers=CircuitBreakerRegistry(
|
||||
failure_threshold=settings.llm_circuit_failure_threshold,
|
||||
cooldown_seconds=settings.llm_circuit_cooldown_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _aggregate_cost(session: AsyncSession, report_id: int) -> tuple[Decimal, int]:
|
||||
"""聚合该报告的 AI 成本与调用次数(口径见模块 docstring 的归因约定)。"""
|
||||
result = await session.execute(
|
||||
select(func.coalesce(func.sum(AiCall.cost_cents), 0), func.count()).where(
|
||||
AiCall.ref_type == "report", AiCall.ref_id == str(report_id)
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
return Decimal(row[0]), int(row[1])
|
||||
|
||||
|
||||
async def _settle_success(
|
||||
session: AsyncSession,
|
||||
task: Task,
|
||||
report: RpReport,
|
||||
settings: Settings,
|
||||
stage_names: list[str],
|
||||
) -> None:
|
||||
"""成功结案:commit 配额(幂等)+ succeeded + completed_at + 成本聚合。"""
|
||||
cost_cents, ai_calls = await _aggregate_cost(session, report.report_id)
|
||||
report.status = "succeeded"
|
||||
report.completed_at = datetime.now(UTC)
|
||||
report.cost_cents = cost_cents
|
||||
report.model_info = {
|
||||
"stages": stage_names,
|
||||
"vision_provider": settings.report_vision_provider,
|
||||
"ai_calls": ai_calls,
|
||||
# Decimal 不能直接进 JSONB;字符串保留精度,展示层再格式化。
|
||||
"cost_cents": str(cost_cents),
|
||||
}
|
||||
reservation = await load_reservation(session, task.task_id)
|
||||
if reservation is not None:
|
||||
await BillingService(session).commit(reservation)
|
||||
# 任务中心视图同步收口(前端轮询的就是 tk_task,不是 rp_report)。
|
||||
await TaskService(session).transition(
|
||||
task_id=task.task_id,
|
||||
target=TaskStatus.SUCCEEDED,
|
||||
stage_text="已完成",
|
||||
result={"report_id": str(report.report_id), "version": report.version},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"report_succeeded",
|
||||
report_id=report.report_id,
|
||||
task_id=task.task_id,
|
||||
cost_cents=str(cost_cents),
|
||||
ai_calls=ai_calls,
|
||||
)
|
||||
|
||||
|
||||
async def _generate_report(task_id: int) -> dict[str, Any]:
|
||||
"""执行一条报告流水线(celery 包装层的内芯;集成测试直调的就是它)。"""
|
||||
settings = get_settings()
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
task = await task_repo.get_for_update(session, task_id)
|
||||
if task is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, message=f"任务不存在:{task_id}")
|
||||
current = TaskStatus(task.status)
|
||||
if current in TERMINAL_STATUSES:
|
||||
# 重复投递/幂等重放的守卫:已结案的任务不再执行,防重复扣配额。
|
||||
logger.info("report_task_replay_skipped", task_id=task_id, status=task.status)
|
||||
return {"task_id": str(task_id), "status": task.status, "skipped": True}
|
||||
|
||||
report_id_raw = task.payload.get("report_id")
|
||||
if not isinstance(report_id_raw, str | int):
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="任务缺少 report_id")
|
||||
report_id = int(report_id_raw)
|
||||
report = await report_repo.get_report(session, report_id)
|
||||
report_input = await report_repo.get_input(session, report_id)
|
||||
if report is None or report_input is None:
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="报告数据不完整")
|
||||
|
||||
service = TaskService(session)
|
||||
# 首跑是 PENDING;重试入站可能是 RUNNING(worker 崩溃重投)或 FAILED
|
||||
# (上一轮失败后重试),先归一到 QUEUED(状态机允许,且会维护
|
||||
# retry_count)再进 RUNNING。
|
||||
if current is not TaskStatus.QUEUED:
|
||||
await service.transition(task_id=task_id, target=TaskStatus.QUEUED, stage_text="排队中")
|
||||
await service.transition(task_id=task_id, target=TaskStatus.RUNNING, stage_text="分析中")
|
||||
report.status = "running"
|
||||
await session.commit() # RUNNING 先落库:前端进度与崩溃恢复都读它
|
||||
|
||||
gateway = build_llm_gateway(settings)
|
||||
try:
|
||||
stages = assemble_stages(settings)
|
||||
ctx = ReportContext(
|
||||
report=report,
|
||||
report_input=report_input,
|
||||
session=session,
|
||||
settings=settings,
|
||||
gateway=gateway,
|
||||
task_id=task_id,
|
||||
)
|
||||
await run_pipeline(ctx, stages)
|
||||
await _settle_success(session, task, report, settings, [s.name for s in stages])
|
||||
finally:
|
||||
await gateway.aclose()
|
||||
return {"task_id": str(task_id), "report_id": str(report_id), "status": "succeeded"}
|
||||
|
||||
|
||||
async def _fail_report(task_id: int, message: str) -> None:
|
||||
"""基础设施异常重试耗尽后的兜底结案:failed + 退款(尽力而为)。
|
||||
|
||||
与 run_pipeline 的失败结案不同,这里没有阶段名——异常发生在流水线之外
|
||||
(装配、DB 连接等),错误摘要以 worker 为责任方落库。结案失败只记日志:
|
||||
此时额度停在 reserved,靠流水对账兜底。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
try:
|
||||
async with factory() as session:
|
||||
task = await task_repo.get_for_update(session, task_id)
|
||||
if task is None or TaskStatus(task.status) in TERMINAL_STATUSES:
|
||||
return
|
||||
report_id_raw = task.payload.get("report_id")
|
||||
if isinstance(report_id_raw, str | int):
|
||||
report = await report_repo.get_report(session, int(report_id_raw))
|
||||
if report is not None and report.status != "succeeded":
|
||||
report.status = "failed"
|
||||
report.result["error"] = {
|
||||
"stage": "worker",
|
||||
"type": "infrastructure",
|
||||
"message": message[:200],
|
||||
}
|
||||
# JSONB 深变更要显式标脏,否则 flush 按值比较会跳过 UPDATE。
|
||||
flag_modified(report, "result")
|
||||
await TaskService(session).transition(
|
||||
task_id=task_id,
|
||||
target=TaskStatus.FAILED,
|
||||
stage_text="执行失败",
|
||||
error_message=message,
|
||||
)
|
||||
reservation = await load_reservation(session, task_id)
|
||||
if reservation is not None:
|
||||
await BillingService(session).refund(reservation, reason="report_worker_failed")
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("report_fail_settle_failed", task_id=task_id)
|
||||
|
||||
|
||||
# Celery 的 task 装饰器没有类型标注,会把被装饰函数擦成 untyped
|
||||
@celery_app.task(name="meida.report.generate", bind=True, max_retries=1) # type: ignore[untyped-decorator]
|
||||
def generate_report(self: Any, task_id: int) -> dict[str, Any]:
|
||||
"""cpu 队列的报告生成入口。
|
||||
|
||||
重试语义:网络/基础设施类异常重试一次——重跑时 ``derived_features``
|
||||
已落库,视觉阶段自然跳过(T-P3-09);阶段业务失败(StageFailureError)
|
||||
已在流水线内结案(退款已提交),重跑也不会好,不再重试。
|
||||
"""
|
||||
bind_context(task_id=task_id)
|
||||
try:
|
||||
return asyncio.run(_generate_report(task_id))
|
||||
except StageFailureError as exc:
|
||||
logger.warning(
|
||||
"report_stage_failed",
|
||||
task_id=task_id,
|
||||
stage=exc.stage_name,
|
||||
error=f"{type(exc.original).__name__}: {exc.original}",
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
if self.request.retries < self.max_retries:
|
||||
logger.warning("report_generate_retry", task_id=task_id, error=str(exc))
|
||||
raise self.retry(exc=exc, countdown=10) from exc
|
||||
logger.exception("report_generate_failed_after_retry", task_id=task_id)
|
||||
asyncio.run(_fail_report(task_id, f"{type(exc).__name__}: {exc}"))
|
||||
raise
|
||||
@@ -1 +1 @@
|
||||
"""report 域:报告触发判定、生成编排、版本管理、分享。占位。"""
|
||||
"""report 域:报告触发、流水线编排、版本管理与查询(分享 rp_share 归后续任务)。"""
|
||||
|
||||
+181
-6
@@ -1,9 +1,10 @@
|
||||
"""报告流水线的阶段契约(第四批 T15/T16/T17 的共同接口)。
|
||||
"""报告流水线的阶段契约与编排(第四批 T15/T16/T17 的共同接口)。
|
||||
|
||||
本文件由协调提交只定 **接口**;编排实现(assemble_stages / run_pipeline)
|
||||
归 T15。T16 提供 vision_stage / diagnosis_stage,T17 提供 copy_stage /
|
||||
validation_stage——三个任务互不改对方文件,靠 Settings.report_pipeline_stages
|
||||
里的 dotted path 动态装配。
|
||||
阶段契约(``ReportContext`` / ``Stage`` / ``DEFAULT_STAGE_PATHS``)由协调提交
|
||||
定死,T15 实现编排(``assemble_stages`` / ``run_pipeline``)。T16 提供
|
||||
vision_stage / diagnosis_stage,T17 提供 copy_stage / validation_stage——
|
||||
三个任务互不改对方文件,靠 Settings.report_pipeline_stages 里的 dotted
|
||||
path 动态装配。
|
||||
|
||||
设计动机(为什么动态 import):三个阶段模块由三个并行分支各自创建,
|
||||
静态 import 会在任一分支缺文件时炸掉编排;按配置动态装配让每个分支
|
||||
@@ -13,13 +14,24 @@ validation_stage——三个任务互不改对方文件,靠 Settings.report_pi
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from packages.billing import repo as billing_repo
|
||||
from packages.billing.service import BillingService, Reservation
|
||||
from packages.domain.task.state import TaskStatus
|
||||
from packages.llm.gateway import LLMGateway
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.models import RpInput, RpReport
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.shared.models import Ledger, RpInput, RpReport
|
||||
from packages.task import repo as task_repo
|
||||
from packages.task.service import TaskService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ReportContext:
|
||||
@@ -37,12 +49,16 @@ class ReportContext:
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
gateway: LLMGateway | None = None,
|
||||
task_id: int | None = None,
|
||||
) -> None:
|
||||
self.report = report
|
||||
self.report_input = report_input
|
||||
self.session = session
|
||||
self.settings = settings
|
||||
self.gateway = gateway
|
||||
# 编排层专用:失败结案要推动 tk_task。阶段实现不需要感知它,
|
||||
# 保持可选以保证旧构造调用(T16/T17 的测试)不受影响。
|
||||
self.task_id = task_id
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -70,3 +86,162 @@ DEFAULT_STAGE_PATHS: tuple[str, ...] = (
|
||||
def stage_names(result: dict[str, Any]) -> list[str]:
|
||||
"""result JSONB 里已出现的阶段键(诊断/调试用的小工具)。"""
|
||||
return [k for k in result if isinstance(result[k], dict)]
|
||||
|
||||
|
||||
class StageFailureError(Exception):
|
||||
"""阶段异常且已按失败结案(rp_report failed + tk_task failed + 退款已提交)。
|
||||
|
||||
celery 包装层据此区分两类异常:``StageFailureError`` 是业务性失败,重跑
|
||||
也不会好,不再重试;其它异常(DB/网络/装配)发生在结算之外,值得用
|
||||
重试换恢复。
|
||||
"""
|
||||
|
||||
def __init__(self, stage_name: str, original: BaseException) -> None:
|
||||
self.stage_name = stage_name
|
||||
self.original = original
|
||||
super().__init__(f"stage={stage_name} error={type(original).__name__}: {original}")
|
||||
|
||||
|
||||
def assemble_stages(settings: Settings) -> list[Stage]:
|
||||
"""按 ``settings.report_pipeline_stages`` 的 dotted path 动态装配阶段列表。
|
||||
|
||||
动态 import 是刻意的插件式装配(见模块 docstring):T16/T17 各自建阶段
|
||||
模块,不碰本文件;import-linter 看不到这条边,阶段模块自身的依赖仍受
|
||||
分层契约约束,这里只做装配不做业务。
|
||||
|
||||
模块/类不存在、实例化失败、或不符合 Stage 协议时,收集**全部**缺失项
|
||||
抛 ``CONFIG_ERROR`` —— 绝不静默跳过:静默等于流水线空跑还扣用户配额。
|
||||
"""
|
||||
if not settings.report_pipeline_stages:
|
||||
raise AppError(
|
||||
ErrorCode.CONFIG_ERROR,
|
||||
message="报告流水线阶段配置为空",
|
||||
data={"missing": list(DEFAULT_STAGE_PATHS)},
|
||||
)
|
||||
|
||||
missing: list[str] = []
|
||||
stages: list[Stage] = []
|
||||
for dotted in settings.report_pipeline_stages:
|
||||
module_path, sep, attr = dotted.partition(":")
|
||||
stage: Any = None
|
||||
if sep and module_path and attr:
|
||||
try:
|
||||
# 阶段构造约定无参。__init__ 抛错视同配置坏了(比如阶段自身
|
||||
# 校验它的依赖),归入 CONFIG_ERROR 而不是让 worker 崩溃。
|
||||
stage = getattr(importlib.import_module(module_path), attr)()
|
||||
except Exception:
|
||||
stage = None
|
||||
if stage is None or not isinstance(stage, Stage):
|
||||
missing.append(dotted)
|
||||
else:
|
||||
stages.append(stage)
|
||||
|
||||
if missing:
|
||||
raise AppError(
|
||||
ErrorCode.CONFIG_ERROR,
|
||||
message="报告流水线阶段装配失败:缺失或不可用",
|
||||
data={"missing": missing},
|
||||
)
|
||||
return stages
|
||||
|
||||
|
||||
def reservation_from_ledger(ledger: Ledger) -> Reservation:
|
||||
"""从 reserved 流水重建 Reservation(worker 成功 commit / 失败 refund 都要它)。"""
|
||||
return Reservation(
|
||||
tx_id=ledger.tx_id,
|
||||
user_id=ledger.user_id,
|
||||
quota_key=ledger.quota_key or "",
|
||||
period_key=ledger.period_key or "",
|
||||
amount=abs(ledger.delta),
|
||||
remaining=ledger.balance_after,
|
||||
idempotency_key=ledger.idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
async def load_reservation(session: AsyncSession, task_id: int) -> Reservation | None:
|
||||
"""按 ``tk_task.reserve_tx_id`` 取回预扣;无预扣或流水缺失返回 None。"""
|
||||
task = await task_repo.get(session, task_id)
|
||||
if task is None or task.reserve_tx_id is None:
|
||||
return None
|
||||
ledger = await billing_repo.get_ledger(session, task.reserve_tx_id)
|
||||
if ledger is None:
|
||||
return None
|
||||
return reservation_from_ledger(ledger)
|
||||
|
||||
|
||||
async def run_pipeline(ctx: ReportContext, stages: list[Stage]) -> None:
|
||||
"""逐阶段执行,每阶段产物即刻 flush 落库(T-P3-09 中间态持久化)。
|
||||
|
||||
中间态的意义:阶段半程失败或 worker 崩溃后重试,已完成阶段(尤其
|
||||
``rp_input.derived_features`` 里的视觉结果)不必重算——「失败不需重新
|
||||
上传照片」的结构保证就落在这几次 flush 里。
|
||||
|
||||
阶段异常 → ``_settle_stage_failure``(事务纪律见其 docstring)后包成
|
||||
:class:`StageFailureError` 上抛;正常返回即全部阶段成功,成功结案归调用方
|
||||
(worker:commit 配额 + succeeded + 成本聚合)。
|
||||
"""
|
||||
# 防御:default=dict 只在 INSERT 时生效,测试/手工构造的瞬态对象可能为 None。
|
||||
if ctx.report.result is None:
|
||||
ctx.report.result = {}
|
||||
if ctx.report_input.derived_features is None:
|
||||
ctx.report_input.derived_features = {}
|
||||
|
||||
for stage in stages:
|
||||
try:
|
||||
await stage.run(ctx)
|
||||
except Exception as exc:
|
||||
await _settle_stage_failure(ctx, stage.name, exc)
|
||||
raise StageFailureError(stage.name, exc) from exc
|
||||
# JSONB 列没有 Mutable 追踪:阶段对 dict 的深变更 ORM 看不见。且不能
|
||||
# 靠「重新赋一个相等的新 dict」——flush 按值比较会认为没变而跳过
|
||||
# UPDATE。flag_modified 强制该列进入下一条 UPDATE(中间态逐段落库
|
||||
# 的关键)。derived_features 同理。
|
||||
ctx.report.result.setdefault(stage.name, {})
|
||||
flag_modified(ctx.report, "result")
|
||||
flag_modified(ctx.report_input, "derived_features")
|
||||
await ctx.session.flush()
|
||||
logger.info(
|
||||
"report_stage_done",
|
||||
report_id=ctx.report.report_id,
|
||||
task_id=ctx.task_id,
|
||||
stage=stage.name,
|
||||
)
|
||||
|
||||
|
||||
async def _settle_stage_failure(ctx: ReportContext, stage_name: str, exc: Exception) -> None:
|
||||
"""阶段失败的结案:report failed + tk_task failed + 退款,提交后才返回。
|
||||
|
||||
事务纪律(对齐 AgentService):退款必须先于异常上抛提交,否则外层回滚
|
||||
会把退款一起卷走(FR-BILL-05)。结案本身失败只记日志、不吞原始异常——
|
||||
此时额度挂在 reserved 状态,靠流水对账兜底。
|
||||
"""
|
||||
summary = {"stage": stage_name, "type": type(exc).__name__, "message": str(exc)[:200]}
|
||||
try:
|
||||
ctx.report.status = "failed"
|
||||
ctx.report.result["error"] = summary
|
||||
flag_modified(ctx.report, "result") # 深变更必须显式标脏,见 run_pipeline 的注释
|
||||
if ctx.task_id is not None:
|
||||
await TaskService(ctx.session).transition(
|
||||
task_id=ctx.task_id,
|
||||
target=TaskStatus.FAILED,
|
||||
stage_text=f"阶段失败:{stage_name}",
|
||||
error_message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
reservation = await load_reservation(ctx.session, ctx.task_id)
|
||||
if reservation is not None:
|
||||
await BillingService(ctx.session).refund(
|
||||
reservation, reason=f"report_stage_failed:{stage_name}"
|
||||
)
|
||||
await ctx.session.commit()
|
||||
logger.warning(
|
||||
"report_stage_failed",
|
||||
report_id=ctx.report.report_id,
|
||||
task_id=ctx.task_id,
|
||||
stage=stage_name,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
except Exception:
|
||||
await ctx.session.rollback()
|
||||
logger.exception(
|
||||
"report_failure_settle_failed", report_id=ctx.report.report_id, stage=stage_name
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""rp_report / rp_input 的数据访问。**只有本模块可以触这两张表**(rp_share 归后续任务)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.shared.models import RpInput, RpReport
|
||||
|
||||
STATUS_SUCCEEDED = "succeeded"
|
||||
TIER_BASIC = "basic"
|
||||
|
||||
|
||||
async def get_report(session: AsyncSession, report_id: int) -> RpReport | None:
|
||||
return await session.get(RpReport, report_id)
|
||||
|
||||
|
||||
async def find_succeeded_basic(session: AsyncSession, *, user_id: int) -> int | None:
|
||||
"""首次终身资格:该用户是否已有 succeeded 的 basic 报告(需求 §2.1)。
|
||||
|
||||
只取 report_id 不取整行——资格判定不需要报告内容。
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(RpReport.report_id)
|
||||
.where(
|
||||
RpReport.user_id == user_id,
|
||||
RpReport.tier == TIER_BASIC,
|
||||
RpReport.status == STATUS_SUCCEEDED,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_reports(session: AsyncSession, *, user_id: int, limit: int = 20) -> list[RpReport]:
|
||||
result = await session.execute(
|
||||
select(RpReport)
|
||||
.where(RpReport.user_id == user_id)
|
||||
.order_by(RpReport.created_at.desc(), RpReport.report_id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_input(session: AsyncSession, report_id: int) -> RpInput | None:
|
||||
result = await session.execute(select(RpInput).where(RpInput.report_id == report_id))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""报告域的请求/响应模型。
|
||||
|
||||
Wire 响应统一走 ok() 信封(架构文档 §9.2),这里的模型只描述 ``data`` 部分;
|
||||
雪花 ID 一律以字符串返回,避免 JS 精度丢失(NFR-MAINT-01 契约先行)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ReportCreateRequest(BaseModel):
|
||||
questionnaire: dict[str, object] = Field(
|
||||
default_factory=dict, description="问卷答案;字段结构由产品定稿后在路由层收紧"
|
||||
)
|
||||
photo_object_key: str = Field(
|
||||
min_length=1, max_length=255, description="临时桶里已上传照片的对象键(report_photo 场景)"
|
||||
)
|
||||
request_key: str | None = Field(
|
||||
default=None, max_length=128, description="幂等键;相同键返回同一任务且不重复扣配额"
|
||||
)
|
||||
|
||||
|
||||
class ReportRegenerateRequest(BaseModel):
|
||||
request_key: str | None = Field(
|
||||
default=None, max_length=128, description="幂等键;相同键返回同一任务且不重复扣配额"
|
||||
)
|
||||
|
||||
|
||||
class ReportSubmitResponse(BaseModel):
|
||||
task_id: str
|
||||
report_id: str
|
||||
status: str = Field(description="tk_task 状态;报告本体状态与其同步推进")
|
||||
version: int
|
||||
|
||||
|
||||
class ReportItem(BaseModel):
|
||||
report_id: str
|
||||
tier: str
|
||||
status: str
|
||||
version: int
|
||||
result: dict[str, object] = Field(
|
||||
description="按阶段键组织的产物;failed 报告只透错误摘要(中间态留给流水线复用)"
|
||||
)
|
||||
created_at: datetime
|
||||
completed_at: datetime | None
|
||||
@@ -0,0 +1,266 @@
|
||||
"""报告域服务:触发资格、配额预扣、版本化与幂等(T-P3-07 / T-P3-09)。
|
||||
|
||||
事务纪律:reserve 与建报告行、建 tk_task 在**同一事务**;commit 之后才投递
|
||||
队列——队列消息不能早于数据落库,否则 worker 可能读到一个不存在的报告。
|
||||
幂等是双保险:tk_task 的幂等键(= request_key)挡重复任务行,reserve 的
|
||||
幂等键挡重复预扣;服务层再加一次「重放优先」快捷路径,让首单成功后的
|
||||
同键重试拿到 200 而不是 40920。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.billing.service import BillingService
|
||||
from packages.domain.ids import next_id
|
||||
from packages.domain.task.state import TaskStatus, TaskType
|
||||
from packages.report import repo
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.errors import AppError, ErrorCode, ForbiddenError
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.shared.models import RpInput, RpReport, Task
|
||||
from packages.task import repo as task_repo
|
||||
from packages.task.service import TaskService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: 首次生成走终身配额(迁移 0003:lifetime / base 1),重新生成走月配额。
|
||||
QUOTA_KEY_BASIC = "report_basic"
|
||||
QUOTA_KEY_REGEN = "report_regen"
|
||||
REPORT_REF_TYPE = "report"
|
||||
REPORT_ETA_SECONDS = 60
|
||||
|
||||
_TERMINAL_TASK_STATUSES = frozenset(
|
||||
{TaskStatus.SUCCEEDED.value, TaskStatus.FAILED.value, TaskStatus.CANCELED.value}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReportSubmission:
|
||||
"""一次提交(新建或重新生成)的结果;幂等重放返回同值。"""
|
||||
|
||||
task_id: int
|
||||
report_id: int
|
||||
status: str
|
||||
version: int
|
||||
|
||||
|
||||
def _submission_of(task: Task) -> ReportSubmission:
|
||||
"""从 tk_task 行重建提交结果(幂等重放与并发落败两条路径共用)。"""
|
||||
raw_report_id = task.payload.get("report_id")
|
||||
if not isinstance(raw_report_id, str) or not raw_report_id.isdigit():
|
||||
# 同键任务却没有 report_id:不是本域写进去的,按幂等冲突拒绝。
|
||||
raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT)
|
||||
return ReportSubmission(
|
||||
task_id=task.task_id,
|
||||
report_id=int(raw_report_id),
|
||||
status=task.status,
|
||||
version=int(task.payload.get("version", 1)),
|
||||
)
|
||||
|
||||
|
||||
class ReportService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
settings: Settings,
|
||||
enqueue: Callable[[int], None] | None = None,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._settings = settings
|
||||
# enqueue 注入投递动作(apps 层用 celery send_task);None 便于测试
|
||||
# 与未来非队列场景直调。投递时机由本服务统一保证在 commit 之后。
|
||||
self._enqueue = enqueue
|
||||
self._billing = BillingService(
|
||||
session, tz_offset_hours=settings.llm_budget_timezone_offset_hours
|
||||
)
|
||||
self._tasks = TaskService(session)
|
||||
|
||||
# -- 首次生成 -----------------------------------------------------------
|
||||
|
||||
async def create_report(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
questionnaire: dict[str, object],
|
||||
photo_object_key: str,
|
||||
request_key: str,
|
||||
) -> ReportSubmission:
|
||||
# 幂等重放最先判:首单成功后用同一 request_key 重试不是「再生成一次」,
|
||||
# 必须先于终身资格校验按原任务返回(否则会把幂等重试误判成 40920)。
|
||||
replay = await self._find_replay(request_key=request_key, user_id=user_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
# 首次资格(终身 1 次):已有 succeeded 的 basic 报告 → 40920。
|
||||
# 文案与 hint(「每 30 天可重新生成一次」)见 ErrorCode 定义,不含价格字样。
|
||||
if await repo.find_succeeded_basic(self._session, user_id=user_id) is not None:
|
||||
raise AppError(ErrorCode.REPORT_ALREADY_EXISTS)
|
||||
|
||||
report = RpReport(
|
||||
report_id=next_id(),
|
||||
user_id=user_id,
|
||||
tier=self._settings.report_tier,
|
||||
status="pending",
|
||||
version=1,
|
||||
)
|
||||
report_input = RpInput(
|
||||
input_id=next_id(),
|
||||
report_id=report.report_id,
|
||||
questionnaire=dict(questionnaire),
|
||||
photo_meta={"object_key": photo_object_key},
|
||||
derived_features={},
|
||||
)
|
||||
submission = await self._submit_and_deliver(
|
||||
user_id=user_id,
|
||||
report=report,
|
||||
report_input=report_input,
|
||||
quota_key=QUOTA_KEY_BASIC,
|
||||
request_key=request_key,
|
||||
reason="first basic report",
|
||||
)
|
||||
logger.info(
|
||||
"report_created",
|
||||
user_id=user_id,
|
||||
report_id=submission.report_id,
|
||||
task_id=submission.task_id,
|
||||
)
|
||||
return submission
|
||||
|
||||
# -- 重新生成 -----------------------------------------------------------
|
||||
|
||||
async def regenerate(
|
||||
self, *, user_id: int, report_id: int, request_key: str
|
||||
) -> ReportSubmission:
|
||||
replay = await self._find_replay(request_key=request_key, user_id=user_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
source = await self.get_report(user_id=user_id, report_id=report_id)
|
||||
if source.status != repo.STATUS_SUCCEEDED:
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="只有已完成的报告可以重新生成")
|
||||
source_input = await repo.get_input(self._session, report_id)
|
||||
if source_input is None:
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="报告输入缺失,无法重新生成")
|
||||
|
||||
report = RpReport(
|
||||
report_id=next_id(),
|
||||
user_id=user_id,
|
||||
tier=source.tier,
|
||||
status="pending",
|
||||
version=source.version + 1,
|
||||
)
|
||||
# 复用输入:questionnaire / photo_meta 原样带过来;derived_features 一并
|
||||
# 拷贝——新一轮流水线的视觉阶段看到非空即跳过(T-P3-09),这就是
|
||||
# 「重新生成 / 失败重试不需要重新上传照片」。
|
||||
report_input = RpInput(
|
||||
input_id=next_id(),
|
||||
report_id=report.report_id,
|
||||
questionnaire=source_input.questionnaire,
|
||||
photo_meta=source_input.photo_meta,
|
||||
derived_features=source_input.derived_features,
|
||||
)
|
||||
submission = await self._submit_and_deliver(
|
||||
user_id=user_id,
|
||||
report=report,
|
||||
report_input=report_input,
|
||||
quota_key=QUOTA_KEY_REGEN,
|
||||
request_key=request_key,
|
||||
reason=f"regenerate report v{source.version}",
|
||||
)
|
||||
logger.info(
|
||||
"report_regenerated",
|
||||
user_id=user_id,
|
||||
source_report_id=report_id,
|
||||
report_id=submission.report_id,
|
||||
task_id=submission.task_id,
|
||||
version=submission.version,
|
||||
)
|
||||
return submission
|
||||
|
||||
# -- 查询 ---------------------------------------------------------------
|
||||
|
||||
async def get_report(self, *, user_id: int, report_id: int) -> RpReport:
|
||||
report = await repo.get_report(self._session, report_id)
|
||||
if report is None:
|
||||
raise AppError(ErrorCode.REPORT_NOT_FOUND)
|
||||
if report.user_id != user_id:
|
||||
# 归属校验(架构 §11.1):不能只靠 ID 不可猜,必须显式比对。
|
||||
raise ForbiddenError()
|
||||
return report
|
||||
|
||||
async def list_reports(self, *, user_id: int, limit: int = 20) -> list[RpReport]:
|
||||
return await repo.list_reports(self._session, user_id=user_id, limit=limit)
|
||||
|
||||
# -- 内部 ---------------------------------------------------------------
|
||||
|
||||
async def _find_replay(self, *, request_key: str, user_id: int) -> ReportSubmission | None:
|
||||
"""同 request_key 的 REPORT 任务已存在时重建提交结果。"""
|
||||
existing = await task_repo.find_by_idempotency_key(self._session, request_key)
|
||||
if existing is None:
|
||||
return None
|
||||
if existing.user_id != user_id or existing.task_type != TaskType.REPORT.value:
|
||||
# 幂等键全局唯一,撞上了其它用户/其它功能的关键字:按幂等冲突拒绝,
|
||||
# 绝不把别人的任务当重放结果返回。
|
||||
raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT)
|
||||
return _submission_of(existing)
|
||||
|
||||
async def _submit_and_deliver(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
report: RpReport,
|
||||
report_input: RpInput,
|
||||
quota_key: str,
|
||||
request_key: str,
|
||||
reason: str,
|
||||
) -> ReportSubmission:
|
||||
"""预扣 + 建行 + 建任务 + 提交后投递——create 与 regenerate 的共用尾段。
|
||||
|
||||
并发同 request_key:reserve 幂等命中、tk_task 幂等键唯一,后到者回滚
|
||||
自己多建的报告行并按先到者的任务返回(不重复预扣、不留孤儿行)。
|
||||
"""
|
||||
reservation = await self._billing.reserve(
|
||||
user_id=user_id,
|
||||
quota_key=quota_key,
|
||||
request_key=request_key,
|
||||
amount=1,
|
||||
reason=reason,
|
||||
ref_type=REPORT_REF_TYPE,
|
||||
)
|
||||
# rp_input.report_id 有 FK 指向 rp_report,但两表之间没有 ORM
|
||||
# relationship,unit of work 不知道先后——必须显式先落报告行。
|
||||
self._session.add(report)
|
||||
await self._session.flush()
|
||||
self._session.add(report_input)
|
||||
task = await self._tasks.submit(
|
||||
user_id=user_id,
|
||||
task_type=TaskType.REPORT,
|
||||
idempotency_key=request_key,
|
||||
payload={"report_id": str(report.report_id), "version": report.version},
|
||||
quota_reserved=reservation.amount,
|
||||
reserve_tx_id=reservation.tx_id,
|
||||
eta_seconds=REPORT_ETA_SECONDS,
|
||||
)
|
||||
if task.payload.get("report_id") != str(report.report_id):
|
||||
# 另一个同键请求抢先建了任务(tk_task 唯一键),本请求的报告行作废。
|
||||
await self._session.rollback()
|
||||
winner = await task_repo.find_by_idempotency_key(self._session, request_key)
|
||||
if winner is None: # pragma: no cover - 唯一键刚被对手占住,不可达兜底
|
||||
raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT)
|
||||
return _submission_of(winner)
|
||||
|
||||
# 先提交再投递:worker 收到消息时报告行必须已经可见。
|
||||
await self._session.commit()
|
||||
self._deliver(task)
|
||||
return ReportSubmission(task.task_id, report.report_id, task.status, report.version)
|
||||
|
||||
def _deliver(self, task: Task) -> None:
|
||||
"""事务提交后投递(见类 docstring);终态任务不重复投递。"""
|
||||
if self._enqueue is None or task.status in _TERMINAL_TASK_STATUSES:
|
||||
return
|
||||
self._enqueue(task.task_id)
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user