"""报告域服务:触发资格、配额预扣、版本化与幂等(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)