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
+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)