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:
xsl
2026-09-15 00:02:44 +08:00
parent a1d0171fc5
commit 5c4f1fc75a
16 changed files with 1491 additions and 69 deletions
+356
View File
@@ -0,0 +1,356 @@
"""深度诊断(tier=full)链路端到端:派生 → full_report 日配额 → full 流水线 → 拒绝路径。
与 test_report_flow 同一套基建:Fake 阶段经 ``REPORT_PIPELINE_STAGES_JSON`` /
``REPORT_FULL_PIPELINE_STAGES_JSON`` 动态装配(worker 按 report.tier 分流两
条阶段清单,两份 env 要同时给);不启真 celery,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",
),
]
_MODULE = "tests.integration.test_full_report_flow"
_BASIC_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeDiagnosisStage"])
_FULL_STAGES = json.dumps([f"{_MODULE}:FakeFullDiagnosisStage", f"{_MODULE}:FakeFullCopyStage"])
class FakeVisionStage:
"""与 test_report_flow 同一契约:derived_features 非空即跳过。"""
name = "vision"
async def run(self, ctx: Any) -> None:
features = ctx.report_input.derived_features
if not features:
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"] = {"dimensions": [], "coverage": {"total": 32}}
class FakeFullDiagnosisStage:
"""full 流水线的诊断阶段替身:前面没有 vision 阶段(full 不含视觉)。"""
name = "diagnosis"
async def run(self, ctx: Any) -> None:
assert "vision" not in ctx.report.result
ctx.report.result["diagnosis"] = {"dimensions": [], "coverage": {"total": 32}}
class FakeFullCopyStage:
"""full 文案阶段替身:copy 与 full 双写(结构与真实 FullCopyStage 一致)。"""
name = "copy"
async def run(self, ctx: Any) -> None:
assert "diagnosis" in ctx.report.result
ctx.report.result["copy"] = {
"summary": "深度总评",
"sections": [{"title": "风格", "paragraphs": ["段落"]}],
"model": "fake",
"prompt_version": "v1",
"dropped_keys": [],
}
ctx.report.result["full"] = {
"season": {
"code": "summer",
"detailed": "柔夏",
"name_cn": "柔夏型",
"analysis": "解析文案",
},
"palette": {
"best": [{"name": "雾霾蓝", "hex": "#8FA8BF"}],
"neutral": [{"name": "浅灰", "hex": "#D9D9D9"}],
"avoid": [{"name": "明黄", "hex": "#FFD93B"}],
},
"scenes": [
{
"key": "commute",
"title": "日常通勤",
"outfits": [{"name": "通勤 A", "points": ["要点一"]}],
}
],
"plan30": [{"week": 1, "title": "第 1 周", "tasks": ["任务一"]}],
}
# --- fixtures ---------------------------------------------------------------
@pytest.fixture
def no_enqueue(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("apps.api.routers.reports._enqueue_report", lambda task_id: None)
@contextmanager
def _stage_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
from packages.shared.config import get_settings
monkeypatch.setenv("REPORT_PIPELINE_STAGES_JSON", _BASIC_STAGES)
monkeypatch.setenv("REPORT_FULL_PIPELINE_STAGES_JSON", _FULL_STAGES)
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):
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]:
resp = await client.post(
"/api/v1/auth/dev-token", json={"device_key": f"full-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 _upload_photo(client: Any, headers: dict[str, str]) -> str:
resp = await client.post(
"/api/v1/uploads/direct?scene=report_photo",
headers={**headers, "Content-Type": "image/jpeg"},
content=b"\xff\xd8\xff\xe0" + b"\x00" * 64,
)
assert resp.status_code == 200
return str(resp.json()["data"]["object_key"])
async def _create_succeeded_basic(client: Any, headers: dict[str, str]) -> dict[str, Any]:
from apps.worker.report_tasks import _generate_report
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {"style": "通勤", "height_cm": 168},
"photo_object_key": await _upload_photo(client, headers),
"request_key": f"rk-{uuid.uuid4().hex}",
},
)
assert resp.status_code == 200
body = resp.json()["data"]
await _generate_report(int(body["task_id"]))
return body
async def _derive_full(
client: Any, headers: dict[str, str], report_id: str, *, request_key: str | None = None
) -> Any:
return await client.post(
f"/api/v1/reports/{report_id}/full",
headers=headers,
json={"request_key": request_key or f"rk-full-{uuid.uuid4().hex}"},
)
async def _fetch_report(report_id: int) -> Any:
from sqlalchemy import 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(RpReport).where(RpReport.report_id == report_id))
return result.scalar_one_or_none()
async def _fetch_input(report_id: int) -> Any:
from sqlalchemy import select
from packages.shared.db import session_scope
from packages.shared.models import RpInput
async with session_scope() as session:
result = await session.execute(select(RpInput).where(RpInput.report_id == report_id))
return result.scalar_one_or_none()
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:派生成功 + full 流水线 + 幂等重放 ----------------------------------
async def test_derive_full_success_runs_full_pipeline(fake_stages: None, client: Any) -> None:
from apps.worker.report_tasks import _generate_report
headers, user_id = await _login(client)
basic = await _create_succeeded_basic(client, headers)
request_key = f"rk-full-{uuid.uuid4().hex}"
resp = await _derive_full(client, headers, basic["report_id"], request_key=request_key)
assert resp.status_code == 200
data = resp.json()["data"]
full_report_id = int(data["report_id"])
assert full_report_id != int(basic["report_id"])
assert data["version"] == 1 # full 是独立谱系,版本从 1 起算
# full_report 日配额预扣(period_key d:YYYY-MM-DD)。
rows = await _ledger_rows(user_id, "full_report")
assert len(rows) == 1
assert rows[0].status == "reserved"
assert rows[0].period_key.startswith("d:")
# 输入三件套复用(derived_features 带着 basic 的视觉中间态,无视觉阶段)。
full_input = await _fetch_input(full_report_id)
assert full_input.derived_features == {"vision_runs": 1}
await _generate_report(int(data["task_id"]))
report = await _fetch_report(full_report_id)
assert report.status == "succeeded"
assert report.tier == "full"
# full 流水线:无 vision 键,copy + full + diagnosis 齐全。
assert set(report.result) == {"diagnosis", "copy", "full"}
full = report.result["full"]
assert full["season"]["name_cn"] == "柔夏型"
assert full["palette"]["best"][0]["hex"] == "#8FA8BF"
assert full["scenes"][0]["outfits"][0]["points"]
assert full["plan30"][0]["week"] == 1
rows = await _ledger_rows(user_id, "full_report")
assert [row.status for row in rows] == ["committed"]
# 详情视图透出 tier 与 full 结构(App 凭 tier/full 渲染深度区块)。
detail = (await client.get(f"/api/v1/reports/{full_report_id}", headers=headers)).json()["data"]
assert detail["tier"] == "full"
assert detail["result"]["full"]["season"]["detailed"] == "柔夏"
# 幂等重放:同 request_key 返回同任务同报告,不二次预扣。
replay = await _derive_full(client, headers, basic["report_id"], request_key=request_key)
assert replay.status_code == 200
assert replay.json()["data"]["report_id"] == str(full_report_id)
assert len(await _ledger_rows(user_id, "full_report")) == 1
# --- 场景 2:非 SUCCEEDED 源报告拒绝 --------------------------------------------
async def test_derive_full_rejects_pending_source(fake_stages: None, client: Any) -> None:
headers, _user_id = await _login(client)
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {"style": "通勤"},
"photo_object_key": await _upload_photo(client, headers),
"request_key": f"rk-{uuid.uuid4().hex}",
},
)
assert resp.status_code == 200
basic = resp.json()["data"] # pending,不跑 worker
derived = await _derive_full(client, headers, basic["report_id"])
assert derived.status_code == 409
assert derived.json()["code"] == 40900 # STATE_CONFLICT
# --- 场景 3:full 不能再派生 full(防套娃) --------------------------------------
async def test_derive_full_rejects_full_source(fake_stages: None, client: Any) -> None:
from apps.worker.report_tasks import _generate_report
headers, _user_id = await _login(client)
basic = await _create_succeeded_basic(client, headers)
derived = await _derive_full(client, headers, basic["report_id"])
assert derived.status_code == 200
full = derived.json()["data"]
await _generate_report(int(full["task_id"]))
nested = await _derive_full(client, headers, full["report_id"])
assert nested.status_code == 409
assert nested.json()["code"] == 40900
# --- 场景 4:full_report 配额耗尽 ------------------------------------------------
async def test_derive_full_blocked_when_quota_exhausted(fake_stages: None, client: Any) -> None:
"""开发库 full_report 规则行是测试期放宽值(base_limit=10);把本用例用户的
bl_quota 快照 limit_value 收敛到已用量,确定性断言 40301(同 test_report_flow
场景 4 的做法,只动本用户的快照行,不动全局规则)。"""
from sqlalchemy import update
from packages.shared.db import session_scope
from packages.shared.models import Quota
headers, user_id = await _login(client)
basic = await _create_succeeded_basic(client, headers)
first = await _derive_full(client, headers, basic["report_id"])
assert first.status_code == 200 # 首次派生占掉 1 次
async with session_scope() as session:
await session.execute(
update(Quota)
.where(Quota.user_id == user_id, Quota.quota_key == "full_report")
.values(limit_value=1)
)
second = await _derive_full(client, headers, basic["report_id"])
assert second.status_code == 403
assert second.json()["code"] == 40301 # QUOTA_EXHAUSTED
+29 -8
View File
@@ -331,29 +331,50 @@ async def _count_user_reports(user_id: int) -> int:
return int(result.scalar_one())
# --- 场景 4:首次资格 40920 ---------------------------------------------------
# --- 场景 4:重复生成不再 40920,由 report_basic 终身配额兜底 ---------------------
async def test_second_first_report_is_rejected_with_40920(fake_stages: None, client: Any) -> None:
async def test_second_report_create_is_blocked_by_lifetime_quota(
fake_stages: None, client: Any
) -> None:
"""产品决策变更:已生成过报告仍可再次提交,不再抛 40920(终身资格硬校验已删)。
重复生成由 report_basic 终身配额兜底(迁移 0003 SSOT:lifetime base_limit=1)。
本地开发库的规则行被人为放宽过(base_limit=10),这里把本用例用户的
bl_quota 快照 limit_value 收敛回 SSOT 值 1,确定性断言额度耗尽:
新 request_key 的第二次创建应返回 403 / QUOTA_EXHAUSTED 40301,
而不是旧的 40920 REPORT_ALREADY_EXISTS。
"""
from sqlalchemy import update
from apps.worker.report_tasks import _generate_report
from packages.shared.db import session_scope
from packages.shared.models import Quota
headers, _ = await _login(client)
headers, user_id = await _login(client)
data = await _create_report(client, headers)
await _generate_report(int(data["task_id"])) # 首单成功,终身资格已用掉
await _generate_report(int(data["task_id"])) # 首单成功,终身额度已用 1 次
# 收敛该用户的终身额度快照到 SSOT base_limit=1(限本用例用户,不动全局规则行)。
async with session_scope() as session:
await session.execute(
update(Quota)
.where(Quota.user_id == user_id, Quota.quota_key == "report_basic")
.values(limit_value=1)
)
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {},
"photo_object_key": "report_photo/u2.jpg",
"photo_object_key": await _upload_photo(client, headers), # 真实 key,先过存在性校验
"request_key": f"rk-{uuid.uuid4().hex}", # 新键:不是幂等重放
},
)
assert resp.status_code == 409
assert resp.status_code == 403
body = resp.json()
assert body["code"] == 40920
assert "30" in (body.get("hint") or "")
assert body["code"] == 40301 # QUOTA_EXHAUSTED,而非 40920
# --- 场景 5:失败退款 + derived_features 保留 ----------------------------------