feat(report): 形象深度诊断全链路——POST /reports/{id}/full 派生 tier=full 报告,复用 basic 输入与 32 维结果;full 流水线(无视觉阶段)LLM 扩写四大块(十二季定位/规则色板/4场景穿搭/30天计划),三重校验+语义重试+规则兜底降级;删除终身一次硬校验(40920 下线,重复生成由配额兜底);validation 枚举一致性校验放宽(十二季词表);迁移 0003 SSOT 补 full_report 规则行
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
"""报告域服务:触发资格、配额预扣、版本化与幂等(T-P3-07 / T-P3-09)。
|
||||
"""报告域服务:配额预扣、版本化与幂等(T-P3-07 / T-P3-09)。
|
||||
|
||||
事务纪律:reserve 与建报告行、建 tk_task 在**同一事务**;commit 之后才投递
|
||||
队列——队列消息不能早于数据落库,否则 worker 可能读到一个不存在的报告。
|
||||
幂等是双保险:tk_task 的幂等键(= request_key)挡重复任务行,reserve 的
|
||||
幂等键挡重复预扣;服务层再加一次「重放优先」快捷路径,让首单成功后的
|
||||
同键重试拿到 200 而不是 40920。
|
||||
幂等键挡重复预扣;服务层再加一次「重放优先」快捷路径,让成功后的同键
|
||||
重试拿到 200 而不是重新创建。
|
||||
|
||||
已生成过报告的用户允许再次生成(产品决策变更):不再按「已有 succeeded
|
||||
报告」硬拒(原 40920 终身锁死),只由 report_basic 终身配额兜底——额度
|
||||
耗尽时报 40301 而非 40920。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +35,10 @@ logger = get_logger(__name__)
|
||||
#: 首次生成走终身配额(迁移 0003:lifetime / base 1),重新生成走月配额。
|
||||
QUOTA_KEY_BASIC = "report_basic"
|
||||
QUOTA_KEY_REGEN = "report_regen"
|
||||
#: 深度诊断(全面报告)走日配额 full_report(迁移 0003 SSOT)。
|
||||
QUOTA_KEY_FULL = "full_report"
|
||||
TIER_BASIC = "basic"
|
||||
TIER_FULL = "full"
|
||||
REPORT_REF_TYPE = "report"
|
||||
REPORT_ETA_SECONDS = 60
|
||||
|
||||
@@ -91,16 +99,14 @@ class ReportService:
|
||||
photo_object_key: str,
|
||||
request_key: str,
|
||||
) -> ReportSubmission:
|
||||
# 幂等重放最先判:首单成功后用同一 request_key 重试不是「再生成一次」,
|
||||
# 必须先于终身资格校验按原任务返回(否则会把幂等重试误判成 40920)。
|
||||
# 幂等重放最先判:成功后用同一 request_key 重试不是「再生成一次」,
|
||||
# 必须先于任何校验按原任务返回(否则会把幂等重试误判成新请求)。
|
||||
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)
|
||||
# 不再按「已有 succeeded 报告」终身锁死(产品决策变更):重复生成由
|
||||
# report_basic 终身配额兜底,额度耗尽由 BillingService 报 40301。
|
||||
|
||||
# 照片存在性校验(本地磁盘降级路线):key 必须落在
|
||||
# upload_dir/report_photo/<本人>/ 下且文件真实存在——模拟 key 与越界
|
||||
@@ -191,6 +197,62 @@ class ReportService:
|
||||
)
|
||||
return submission
|
||||
|
||||
# -- 深度诊断(全面报告) ------------------------------------------------
|
||||
|
||||
async def derive_full_report(
|
||||
self, *, user_id: int, report_id: int, request_key: str
|
||||
) -> ReportSubmission:
|
||||
"""基于一份 SUCCEEDED 的 basic 报告派生 tier=full 的深度诊断报告。
|
||||
|
||||
与 regenerate 同一派生模式(拷贝 rp_input 三件套,视觉阶段因
|
||||
derived_features 非空而天然缺席——full 流水线本就不含视觉阶段);
|
||||
差别在 tier 与配额键。full 报告是独立谱系,version 从 1 起算。
|
||||
"""
|
||||
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.tier != TIER_BASIC:
|
||||
# full→full 套娃直接拒绝:深度诊断只能以基础报告为源。
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="深度诊断报告请以基础报告为源生成")
|
||||
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=TIER_FULL,
|
||||
status="pending",
|
||||
version=1,
|
||||
)
|
||||
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_FULL,
|
||||
request_key=request_key,
|
||||
reason=f"full report from basic {report_id}",
|
||||
)
|
||||
logger.info(
|
||||
"full_report_derived",
|
||||
user_id=user_id,
|
||||
source_report_id=report_id,
|
||||
report_id=submission.report_id,
|
||||
task_id=submission.task_id,
|
||||
)
|
||||
return submission
|
||||
|
||||
# -- 查询 ---------------------------------------------------------------
|
||||
|
||||
async def get_report(self, *, user_id: int, report_id: int) -> RpReport:
|
||||
|
||||
Reference in New Issue
Block a user