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:
@@ -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
|
||||
@@ -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 保留 ----------------------------------
|
||||
|
||||
Reference in New Issue
Block a user