From 4aadb28c3cae2f94be4313b3887f69237d9719cf Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 29 Jul 2026 01:33:49 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=AF=94=E4=BB=B7=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E9=A1=B5=E5=8F=96=E6=95=B0=E6=8D=AE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/comparison_platforms_col.py | 47 ++++++++++ app/models/comparison.py | 4 + app/repositories/comparison.py | 90 ++++++++++++++++++-- app/schemas/compare_record.py | 10 +++ 4 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/comparison_platforms_col.py diff --git a/alembic/versions/comparison_platforms_col.py b/alembic/versions/comparison_platforms_col.py new file mode 100644 index 0000000..3b3d82e --- /dev/null +++ b/alembic/versions/comparison_platforms_col.py @@ -0,0 +1,47 @@ +"""add platforms unified array column to comparison_record + +展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带 +status/is_best/display, 记录页据此直接渲染, 不再靠 comparison_results + 客户端合并 + 前端派生。 +纯新增列, 老记录为空 → 前端回退老 comparison_results。 + +Revision ID: comparison_platforms_col +Revises: user_manual_risk_fields +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "comparison_platforms_col" +down_revision: str | None = "user_manual_risk_fields" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + # 幂等: 线上为了提前给历史数据补 platforms(2026-07-29), 已手动 + # `ALTER TABLE comparison_record ADD COLUMN IF NOT EXISTS platforms jsonb + # NOT NULL DEFAULT '[]'::jsonb`(与本 migration 定义一致)。列已存在时跳过, + # 否则上线 alembic upgrade head 会撞 DuplicateColumn 直接部署失败。 + bind = op.get_bind() + cols = {c["name"] for c in sa.inspect(bind).get_columns("comparison_record")} + if "platforms" in cols: + return + with op.batch_alter_table("comparison_record") as batch_op: + batch_op.add_column( + sa.Column( + "platforms", _JSON, nullable=False, + server_default=sa.text("'[]'"), + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("comparison_record") as batch_op: + batch_op.drop_column("platforms") diff --git a/app/models/comparison.py b/app/models/comparison.py index 3628438..2a2b328 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -108,6 +108,10 @@ class ComparisonRecord(Base): items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) + # 展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带 + # status/is_best/display/display_order, 记录页据此直接渲染, 不再靠 comparison_results + # + 客户端合并 + 前端派生。老记录/旧客户端为空 → 前端回退老 comparison_results 渲染。 + platforms: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 目标平台未找到、跳过的菜名 skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底 diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index eca4a33..41eb749 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -89,8 +89,9 @@ def _derive(payload: ComparisonRecordIn) -> dict: is_source_best = best.is_source if best is not None else None - # status:客户端显式给了就用;否则有"非源且有价"的结果=success,否则 failed - status = payload.status + # status:优先 pricebot record_status(区分 below_minimum/store_closed) → 客户端显式 status + # → 兜底"非源且有价"=success/否则 failed。record_status 让"未满起送"不再塌缩成 failed。 + status = payload.record_status or payload.status if status is None: has_valid_target = any( (not r.is_source) and r.price is not None for r in results @@ -117,7 +118,12 @@ def upsert_record( 灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile; 新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。 """ - derived = _derive(payload) + # 单源派生: 与 harvest_done 一致, payload 带 platforms 时从它派生(唯一真相源 + # _derive_from_platforms), 老客户端不带 platforms 时回退 _derive(从 comparison_results)。 + if payload.platforms: + derived = _derive_from_platforms(payload.platforms, payload.record_status) + else: + derived = _derive(payload) items = [it.model_dump(exclude_none=True) for it in payload.items] fields = dict( device_id=payload.device_id, @@ -134,6 +140,7 @@ def upsert_record( skipped_dish_count=payload.skipped_dish_count, items=items, comparison_results=[r.model_dump() for r in payload.comparison_results], + platforms=list(payload.platforms or []), skipped_dish_names=list(payload.skipped_dish_names), # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) device_model=payload.device_model, @@ -206,7 +213,8 @@ def upsert_record( def _derive_from_results( - results: list[dict], platform_results: dict | None = None + results: list[dict], platform_results: dict | None = None, + record_status: str | None = None, ) -> dict: """从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。 等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。 @@ -254,7 +262,53 @@ def _derive_from_results( "saved_amount_cents": saved_amount_cents, "is_source_best": best.get("is_source") if best else None, "store_name": (src_row or {}).get("store_name") or None, - "status": "success" if has_valid_target else "failed", + # 记录级结局: 优先用 pricebot 下发的 record_status(区分 below_minimum/store_closed, + # 不再把"未满起送"塌缩成 failed → 记录页不再误报"网络开小差"); 旧 pricebot 未下发时 + # 回退老的 success/failed 二态派生, 向后兼容。 + "status": record_status or ("success" if has_valid_target else "failed"), + } + + +def _derive_from_platforms( + platforms: list, record_status: str | None = None, +) -> dict: + """从 done 帧 platforms(每平台一行、渲染就绪)派生结构化列——**单一真相源**。 + + best_* 直接取 platforms 里 is_best 的那一行、source_* 取 role=source 行,与前端读的 + platforms 天然一致(不再像 _derive_from_results 那样从 comparison_results 二次评最优, + 消除"标量列 vs platforms"双源不一致)。platforms 非空时优先走这里;老 pricebot 无 + platforms 时调用方回退 _derive_from_results(向后兼容)。""" + rows = [p for p in (platforms or []) if isinstance(p, dict)] + src = next((p for p in rows if p.get("role") == "source"), None) + best = next((p for p in rows if p.get("is_best")), None) + source_price_cents = _yuan_to_cents(src.get("price")) if src else None + best_price_cents = _yuan_to_cents(best.get("price")) if best else None + saved_amount_cents = None + if source_price_cents is not None and best_price_cents is not None: + saved_amount_cents = source_price_cents - best_price_cents + has_valid_target = any( + p.get("role") != "source" and p.get("price") is not None for p in rows + ) + # store_name: 优先源行; recompare 场景源平台自己当目标、源行被目标覆盖(pricebot + # _build_platform_rows 有意去重, platforms 无 role=source 行)→ 回退 best 行 → 首个有店名 + # 的行(显示现场实际比到的店), 免得记录页店名空掉兜底显示成"比价"。正常比价有源行不走回退。 + store_name = ( + (src or {}).get("store_name") + or (best or {}).get("store_name") + or next((p.get("store_name") for p in rows if p.get("store_name")), None) + ) + return { + "source_platform_id": (src or {}).get("platform_id"), + "source_platform_name": (src or {}).get("platform_name"), + "source_package": (src or {}).get("package"), + "source_price_cents": source_price_cents, + "best_platform_id": (best or {}).get("platform_id"), + "best_platform_name": (best or {}).get("platform_name"), + "best_price_cents": best_price_cents, + "saved_amount_cents": saved_amount_cents, + "is_source_best": (best.get("role") == "source") if best else None, + "store_name": store_name or None, + "status": record_status or ("success" if has_valid_target else "failed"), } @@ -413,9 +467,28 @@ def harvest_done( 返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。 行不存在(理论上帧0已建;防御)则新建。""" results = done_params.get("comparison_results") or [] - derived = _derive_from_results(results, done_params.get("platform_results")) - # 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items - items = next((r.get("items") or [] for r in results if r.get("is_source")), []) + # 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。 + # record_status: 记录级结局(success/below_minimum/store_closed/failed), 覆盖老二态派生。 + platforms = done_params.get("platforms") or [] + record_status = done_params.get("record_status") + # 单源派生: platforms(含 pricebot 权威 is_best)是唯一真相源, best_*/source_*/saved/status + # 全从它取 → 与前端读的 platforms 天然一致; 菜品也取 platforms 源行。老 pricebot 无 + # platforms 时回退从 comparison_results 派生(向后兼容)。 + if platforms: + derived = _derive_from_platforms(platforms, record_status) + # 菜品优先源行; recompare 无源行 → 回退 best 行 → 首个有菜品的行(同 store_name 回退) + _item_row = ( + next((p for p in platforms if isinstance(p, dict) and p.get("role") == "source"), None) + or next((p for p in platforms if isinstance(p, dict) and p.get("is_best")), None) + or next((p for p in platforms if isinstance(p, dict) and p.get("items")), None) + ) + items = (_item_row or {}).get("items") or [] + else: + derived = _derive_from_results( + results, done_params.get("platform_results"), record_status + ) + # pricebot 已把源单菜品塞进 comparison_results[源行].items + items = next((r.get("items") or [] for r in results if r.get("is_source")), []) fields = dict( business_type=business_type or "food", information=done_params.get("information") or None, @@ -426,6 +499,7 @@ def harvest_done( skipped_dish_count=done_params.get("skipped_dish_count"), skipped_dish_names=list(done_params.get("skipped_dish_names") or []), comparison_results=results, + platforms=platforms, items=items, product_names=_product_names_from_items(items), raw_payload=done_params, diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 0c16e52..760a3a5 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -107,6 +107,13 @@ class ComparisonRecordIn(BaseModel): # 明细 items: list[ComparisonItemIn] = Field(default_factory=list) comparison_results: list[ComparisonResultIn] = Field(default_factory=list) + # 展示模型统一数组(pricebot done.params.platforms 原样透传): 每平台一行、自带 + # status/is_best/display/display_order,记录页据此直渲染。宽松 list[dict] 存(结构由 + # pricebot 定,server 只原样落库),前端读它、老记录空时回退 comparison_results。 + platforms: list[dict] = Field(default_factory=list) + # 记录级结局(pricebot 下发): success/below_minimum/store_closed/failed。让"未满起送"不再 + # 被塌缩成 failed。_derive 优先用它、其次客户端 status、再兜底二态派生。 + record_status: str | None = None # 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/ # unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列), # admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是 @@ -175,6 +182,9 @@ class ComparisonRecordOut(BaseModel): information: str | None = None items: list = [] comparison_results: list = [] + # 展示模型统一数组(每平台一行、自带 status/is_best/display/display_order): 记录页据此 + # 直渲染, 不再靠 comparison_results + 前端派生。老记录为空 → 前端回退 comparison_results。 + platforms: list = [] skipped_dish_names: list = [] total_ms: int | None = None # 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。 -- 2.52.0