Files
meida_backend/packages/report/service.py
T
xsl e8208d2972 feat(report): orchestration skeleton with staged pipeline and refund-on-failure
POST /reports 骨架:资格校验→终身配额预扣→tk_task→cpu 队列;动态装配
流水线(dotted-path 注册、缺阶段即 CONFIG_ERROR);中间态逐段落库、失败
退款、derived_features 跨重试/重生成复用(T-P3-07/09)。

实现:packages/report 的 pipeline.py(Stage 契约保持不变,新增 assemble_stages/
run_pipeline/StageFailureError,ReportContext 增可选 task_id 参数、向后兼容)、
repo.py、schemas.py、service.py(重放优先于资格校验、reserve+建行+建任务同事务、
提交后才投递、并发同键落败回滚);apps/api/routers/reports.py 填桩(send_task 按
任务名投递,不 import apps.worker);apps/worker/report_tasks.py(worker 侧自建
gateway 装配、状态归一 QUEUED→RUNNING、成功 commit 配额+成本聚合写 model_info、
基础设施异常重试一次、重试耗尽兜底失败退款)。测试:单测 6 个(装配缺失/空配置/
协议违约/顺序执行/失败保留产物),集成 6 场景(预扣 lifetime/PENDING、成功
committed+两阶段键、同 request_key 幂等不重复扣、二次首生成 40920、阶段失败
refund+derived_features 保留、regenerate 走 m: 月配额 version+1 复用视觉中间态)。

偏差说明:
1) apps/worker/celery_app.py 的 include 只含 apps.worker.tasks(禁改文件),真
worker 进程注册 meida.report.generate 需协调者合并时把 include 补上
apps.worker.report_tasks;API 侧已按任务名 send_task,两侧只靠名字衔接,测试
不受影响。2) ReportContext 增加可选 task_id 关键字参数(失败结案要推动
tk_task),Stage 协议/DEFAULT_STAGE_PATHS/stage_names 未动,T16/T17 兼容。
3) JSONB 深变更用 flag_modified 显式标脏(重新赋相等新 dict 会被 flush 的按值
比较跳过,集成测试抓到过该坑)。4) POST 后 tk_task 保持 PENDING(任务文档
§4 场景 1 口径),QUEUED→RUNNING 由 worker 入口归一。
2026-08-14 22:18:14 +08:00

267 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""报告域服务:触发资格、配额预扣、版本化与幂等(T-P3-07 / 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)