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
+121 -4
View File
@@ -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()
+297
View File
@@ -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
View File
@@ -1 +1 @@
"""report 域:报告触发判定、生成编排、版本管理、分享。占位。"""
"""report 域:报告触发、流水线编排、版本管理与查询(分享 rp_share 归后续任务)。"""
+181 -6
View File
@@ -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
)
+47
View File
@@ -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()
+48
View File
@@ -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
+266
View File
@@ -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)
+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"]