From 7dee829cfcfa9be2c4c5306241b3e00aa12b835b Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Sat, 1 Aug 2026 09:55:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E6=AF=94=E4=BB=B7?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=8A=B6=E6=80=81=E6=9E=9A=E4=B8=BE=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comparison_below_minimum_as_success.py | 22 ++++++++- app/admin/repositories/queries.py | 40 +++++++++++++--- app/admin/routers/comparison.py | 4 +- app/admin/schemas/comparison.py | 2 +- app/models/comparison.py | 2 +- app/repositories/comparison.py | 23 ++++++--- app/schemas/compare_record.py | 4 +- tests/test_compare_harvest.py | 21 +++++++++ tests/test_comparison_admin_summary.py | 47 ++++++++++++++----- ...test_comparison_below_minimum_migration.py | 18 ++++++- 10 files changed, 149 insertions(+), 34 deletions(-) diff --git a/alembic/versions/comparison_below_minimum_as_success.py b/alembic/versions/comparison_below_minimum_as_success.py index 26c49ce..e985fea 100644 --- a/alembic/versions/comparison_below_minimum_as_success.py +++ b/alembic/versions/comparison_below_minimum_as_success.py @@ -1,4 +1,4 @@ -"""count completed below-minimum comparisons as success +"""normalize granular comparison outcomes into terminal record statuses Revision ID: comparison_below_min_success Revises: limit_policy_global_bundle @@ -18,6 +18,13 @@ branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None _JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") +_FAILED_OUTCOMES = ( + "store_closed", + "store_not_found", + "items_not_found", + "no_delivery", + "unsupported", +) def _comparison_record() -> sa.TableClause: @@ -36,6 +43,11 @@ def upgrade() -> None: .where(comparison_record.c.status == "below_minimum") .values(status="success", fail_reason=None) ) + op.execute( + comparison_record.update() + .where(comparison_record.c.status.in_(_FAILED_OUTCOMES)) + .values(status="failed") + ) def downgrade() -> None: @@ -54,3 +66,11 @@ def downgrade() -> None: ) .values(status="below_minimum") ) + op.execute( + comparison_record.update() + .where( + comparison_record.c.status == "failed", + raw_outcome.in_(_FAILED_OUTCOMES), + ) + .values(status=raw_outcome) + ) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index a2158ee..c085445 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -39,6 +39,34 @@ from app.repositories import activity, ad_ecpm # 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") _NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") +# comparison_record historically persisted a few granular business outcomes as +# top-level statuses. Admin filters and metrics expose lifecycle buckets while +# retaining the raw values until the data migration has run everywhere. +_COMPARISON_STATUS_ALIASES = { + "success": ("success", "below_minimum"), + "failed": ( + "failed", + "store_closed", + "store_not_found", + "items_not_found", + "no_delivery", + "unsupported", + ), + "cancelled": ("cancelled",), + "running": ("running",), +} +_COMPARISON_SUCCESS_STATUSES = _COMPARISON_STATUS_ALIASES["success"] +_COMPARISON_FAILED_STATUSES = _COMPARISON_STATUS_ALIASES["failed"] +_COMPARISON_COMPLETED_STATUSES = ( + *_COMPARISON_SUCCESS_STATUSES, + *_COMPARISON_FAILED_STATUSES, +) + + +def _comparison_status_condition(status: str): + values = _COMPARISON_STATUS_ALIASES.get(status, (status,)) + return ComparisonRecord.status.in_(values) + # 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。 _FEED_SCENE_LABEL = { "comparison": "比价信息流", @@ -334,7 +362,7 @@ def _comparison_conditions( ) ) if status: - conditions.append(ComparisonRecord.status == status) + conditions.append(_comparison_status_condition(status)) if business_type: conditions.append(ComparisonRecord.business_type == business_type) if store: @@ -419,7 +447,7 @@ def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles ), ).where( *conditions, - ComparisonRecord.status == status, + _comparison_status_condition(status), ComparisonRecord.total_ms.is_not(None), ) @@ -444,7 +472,7 @@ def _comparison_duration_aggregates( select(ComparisonRecord.total_ms) .where( *conditions, - ComparisonRecord.status == status, + _comparison_status_condition(status), ComparisonRecord.total_ms.is_not(None), ) .order_by(ComparisonRecord.total_ms) @@ -474,11 +502,11 @@ def comparison_records_summary( row = db.execute( select( func.count(ComparisonRecord.id), - func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)), - func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + func.sum(case((ComparisonRecord.status.in_(_COMPARISON_COMPLETED_STATUSES), 1), else_=0)), + func.sum(case((ComparisonRecord.status.in_(_COMPARISON_SUCCESS_STATUSES), 1), else_=0)), func.avg(ComparisonRecord.llm_cost_yuan), func.sum(case(( - (ComparisonRecord.status == "success") + ComparisonRecord.status.in_(_COMPARISON_SUCCESS_STATUSES) & (ComparisonRecord.saved_amount_cents > 0), 1 ), else_=0)), func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py index 8876dc0..33e37f1 100644 --- a/app/admin/routers/comparison.py +++ b/app/admin/routers/comparison.py @@ -35,7 +35,7 @@ def list_comparison_records( db: AdminDb, user_id: Annotated[int | None, Query()] = None, phone: Annotated[str | None, Query(description="手机号前缀")] = None, - status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled|running)$")] = None, business_type: Annotated[str | None, Query()] = None, store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, @@ -66,7 +66,7 @@ def comparison_records_summary( db: AdminDb, user_id: Annotated[int | None, Query()] = None, phone: Annotated[str | None, Query(description="手机号前缀")] = None, - status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled|running)$")] = None, business_type: Annotated[str | None, Query()] = None, store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index c9f1d43..5060f8b 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -20,7 +20,7 @@ class AdminComparisonListItem(BaseModel): trace_id: str # admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled) trace_url: str | None = None - status: str + status: str # success / failed / cancelled / running;旧细分值由前端兼容映射 information: str | None = None store_name: str | None = None product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索) diff --git a/app/models/comparison.py b/app/models/comparison.py index b52c825..51956d4 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -97,7 +97,7 @@ class ComparisonRecord(Base): total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) - # success(流程正常完成,含 below_minimum)/ failed(技术异常或未形成可信结论) + # success(流程正常完成,含 below_minimum)/ failed(技术异常或未形成可比报价,含店铺打烊等) status: Mapped[str] = mapped_column(String(16), nullable=False, default="success") # done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…"; # 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。 diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index d6e881e..5af04f1 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -77,16 +77,27 @@ _BIZ_STATUS_PRIORITY = ( def _normalize_record_status(status: str | None) -> str | None: - """Map a completed below-minimum outcome onto persisted ``success``. + """Map granular business outcomes onto the record lifecycle status. ``record_status`` describes the business outcome, while ``comparison_record.status`` is also the completed-comparison flag used by - milestones, stats and idempotent rewards. Reaching the target cart and - conclusively reading that it is below the delivery minimum is a completed - comparison, not a technical failure. The granular outcome remains in - ``raw_payload`` and ``platform_results`` for result rendering. + milestones, stats and idempotent rewards. ``below_minimum`` is a completed + success because the target cart produced a trustworthy conclusion. Other + known target-side outcomes did not produce a comparable quote and belong + to the failed record bucket. The granular outcome remains in ``raw_payload`` + and ``platform_results`` for result rendering. """ - return "success" if status == "below_minimum" else status + if status == "below_minimum": + return "success" + if status in { + "store_closed", + "store_not_found", + "items_not_found", + "no_delivery", + "unsupported", + }: + return "failed" + return status def _store_closed_text(reason: str | None) -> str: diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 23a543e..0349458 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -112,8 +112,8 @@ class ComparisonRecordIn(BaseModel): # pricebot 定,server 只原样落库),前端读它、老记录空时回退 comparison_results。 platforms: list[dict] = Field(default_factory=list) # 记录级业务结局(pricebot 下发): success/below_minimum/store_closed/failed。 - # below_minimum 表示流程正常完成,持久化主状态归 success;原值仍随 raw_payload 落库, - # admin/记录页从 platform_results 展示"未满起送"的细分结论。 + # below_minimum 表示流程正常完成,持久化主状态归 success;store_closed/items_not_found 等 + # 已知无报价结局归 failed。原值仍随 raw_payload 落库,admin/记录页从 platform_results 展示细分结论。 record_status: str | None = None # 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/ # unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列), diff --git a/tests/test_compare_harvest.py b/tests/test_compare_harvest.py index 6963c4d..22adef2 100644 --- a/tests/test_compare_harvest.py +++ b/tests/test_compare_harvest.py @@ -13,6 +13,7 @@ import uuid from unittest.mock import MagicMock, patch import httpx +import pytest from sqlalchemy import select from app.db.session import SessionLocal @@ -25,6 +26,26 @@ def _tid() -> str: return uuid.uuid4().hex +@pytest.mark.parametrize( + ("raw_status", "record_status"), + [ + ("success", "success"), + ("below_minimum", "success"), + ("failed", "failed"), + ("store_closed", "failed"), + ("store_not_found", "failed"), + ("items_not_found", "failed"), + ("no_delivery", "failed"), + ("unsupported", "failed"), + ("cancelled", "cancelled"), + ("running", "running"), + (None, None), + ], +) +def test_record_status_normalization(raw_status, record_status) -> None: + assert crud._normalize_record_status(raw_status) == record_status + + def _done_params() -> dict: """一份典型 done 帧 params:美团 25 元 vs 源淘宝闪购 30 元 → 省 5 元、success。""" return { diff --git a/tests/test_comparison_admin_summary.py b/tests/test_comparison_admin_summary.py index 2f86e24..cbf43c4 100644 --- a/tests/test_comparison_admin_summary.py +++ b/tests/test_comparison_admin_summary.py @@ -23,7 +23,7 @@ def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None: ) assert sql.count("percentile_cont") == 4 - assert "comparison_record.status = 'success'" in sql + assert "comparison_record.status IN ('success', 'below_minimum')" in sql def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: @@ -34,6 +34,9 @@ def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: ("summary-success-b", "success", 3000, 2.0, 0), ("summary-failed", "failed", 100_000, 3.0, 0), ("summary-cancelled", "cancelled", 5000, 4.0, 0), + ("summary-below-minimum", "below_minimum", 5000, None, 0), + ("summary-store-closed", "store_closed", 200_000, None, 0), + ("summary-running", "running", 4000, None, 0), ] for trace_id, status, total_ms, cost, saved in rows: db.add(ComparisonRecord( @@ -56,26 +59,44 @@ def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) ) - assert summary["started"] == 4 - assert summary["completed"] == 3 - assert summary["success"] == 2 - assert summary["success_rate"] == pytest.approx(2 / 3) + assert summary["started"] == 7 + assert summary["completed"] == 5 + assert summary["success"] == 3 + assert summary["success_rate"] == pytest.approx(3 / 6) assert summary["avg_token_cost"] == pytest.approx(2.5) - assert summary["lower_price_rate"] == 0.5 - assert summary["avg_duration_ms"] == 2000 - assert summary["p5_duration_ms"] == 1100 - assert summary["p50_duration_ms"] == 2000 - assert summary["p95_duration_ms"] == 2900 - assert summary["p99_duration_ms"] == 2980 + assert summary["lower_price_rate"] == pytest.approx(1 / 3) + assert summary["avg_duration_ms"] == 3000 + assert summary["p5_duration_ms"] == 1200 + assert summary["p50_duration_ms"] == 3000 + assert summary["p95_duration_ms"] == 4800 + assert summary["p99_duration_ms"] == 4960 assert summary["cancelled"] == 1 - assert summary["cancelled_rate"] == 0.25 + assert summary["cancelled_rate"] == pytest.approx(1 / 7) assert summary["cancelled_p50_ms"] == 5000 items, _next_cursor, total = queries.list_comparison_records( db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20 ) - assert total == 4 + assert total == 7 assert {item.trace_id for item in items} == {row[0] for row in rows} + + success_items, _next_cursor, success_total = queries.list_comparison_records( + db, status="success", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) + ) + assert success_total == 3 + assert {item.status for item in success_items} == {"success", "below_minimum"} + + failed_items, _next_cursor, failed_total = queries.list_comparison_records( + db, status="failed", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) + ) + assert failed_total == 2 + assert {item.status for item in failed_items} == {"failed", "store_closed"} + + running_items, _next_cursor, running_total = queries.list_comparison_records( + db, status="running", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) + ) + assert running_total == 1 + assert running_items[0].status == "running" finally: db.rollback() db.close() diff --git a/tests/test_comparison_below_minimum_migration.py b/tests/test_comparison_below_minimum_migration.py index 27b75f8..9bb2322 100644 --- a/tests/test_comparison_below_minimum_migration.py +++ b/tests/test_comparison_below_minimum_migration.py @@ -1,4 +1,4 @@ -"""Regression coverage for the below-minimum status data migration.""" +"""Regression coverage for comparison terminal-status normalization.""" from __future__ import annotations import importlib.util @@ -23,7 +23,7 @@ def _migration_module(): return module -def test_below_minimum_migration_upgrade_and_downgrade() -> None: +def test_business_status_migration_upgrade_and_downgrade() -> None: engine = sa.create_engine("sqlite:///:memory:") metadata = sa.MetaData() records = sa.Table( @@ -56,6 +56,16 @@ def test_below_minimum_migration_upgrade_and_downgrade() -> None: "fail_reason": "技术异常", "raw_payload": {"status": "failed"}, }, + { + "status": "store_closed", + "fail_reason": "店铺打烊", + "raw_payload": {"record_status": "store_closed"}, + }, + { + "status": "items_not_found", + "fail_reason": "商品未找到", + "raw_payload": {"status": "items_not_found"}, + }, ], ) module.op = Operations(MigrationContext.configure(connection)) @@ -68,6 +78,8 @@ def test_below_minimum_migration_upgrade_and_downgrade() -> None: ("success", None), ("success", None), ("failed", "技术异常"), + ("failed", "店铺打烊"), + ("failed", "商品未找到"), ] module.downgrade() @@ -78,4 +90,6 @@ def test_below_minimum_migration_upgrade_and_downgrade() -> None: ("below_minimum", None), ("success", None), ("failed", "技术异常"), + ("store_closed", "店铺打烊"), + ("items_not_found", "商品未找到"), ]