diff --git a/app/models/comparison.py b/app/models/comparison.py index c351e94..a0e9165 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -83,7 +83,7 @@ class ComparisonRecord(Base): # ===== 明细(JSON,越详细越好)===== # 下单菜品 [{name, qty, specs?}] items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) - # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved}](price/coupon_saved 单位:元,原样存) + # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 目标平台未找到、跳过的菜名 skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index d431d46..b2bb4d0 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -37,6 +37,9 @@ class ComparisonResultIn(BaseModel): # 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。 # 各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠, 当前仅淘宝源)。见 pricebot 侧 比价红包额留痕-实现方案.md。 coupon_saved: float | None = None + # 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。 + # None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。 + coupon_name: str | None = None class ComparisonRecordIn(BaseModel): diff --git a/docs/api/compare-record-report.md b/docs/api/compare-record-report.md index 9f6dde0..162cf2b 100644 --- a/docs/api/compare-record-report.md +++ b/docs/api/compare-record-report.md @@ -31,9 +31,10 @@ **Item**:`{ name: string, qty: int=1, specs: string[]\|null }` -**Result(comparison_results 元素)**:`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null) }` +**Result(comparison_results 元素)**:`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null), coupon_name(string\|null) }` - `coupon_saved`:该平台本单**平台主优惠额**(元)——美团红包 / 淘宝平台红包 / 京东优惠券·百亿补贴,**只取那一笔**,不含配送费减免/共减总额。仅外卖目标平台带值,源平台/没用为 `null`,前端记录页据此展示**「已优惠 ¥X」**(null 不展示)。 +- `coupon_name`:优惠**来源名**(展示用,best-effort)——美团「外卖大额神券」/ 京东「百亿补贴」/ 淘宝「平台红包」。仅在该行有 `coupon_saved` 时带;`null` 时前端走通用「红包」。非用户选中的具体券名(选中态不可读)。 ## 服务端派生(客户端不用算) diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index d938618..ab58252 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -29,7 +29,7 @@ | `status` | String(16) | NOT NULL, default `success` | `success`(拿到有效对比)/ `failed`(出错/没采到目标价) | | `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) | | `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` | -| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」 | +| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved,coupon_name}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」;`coupon_name`=优惠来源名(展示用 best-effort,美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"),null 走前端通用"红包" | | `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 | | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 7ec7a19..1569d2f 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -33,7 +33,8 @@ def _food_payload(trace_id: str) -> dict: ], "comparison_results": [ {"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan", - "price": 123.50, "is_source": False, "rank": 1, "coupon_saved": 7.0}, + "price": 123.50, "is_source": False, "rank": 1, + "coupon_saved": 7.0, "coupon_name": "外卖大额神券"}, {"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao", "price": 128.50, "is_source": True, "rank": 2}, {"platform_id": "jd_waimai", "platform_name": "京东外卖", "package": "com.jingdong.app.mall", @@ -79,7 +80,7 @@ def test_report_and_derive(client) -> None: def test_coupon_saved_passthrough(client) -> None: - """红包优惠额 coupon_saved 上报→落库→读出;仅目标平台带值,源平台不带(null)。""" + """红包优惠额 coupon_saved + 来源名 coupon_name 上报→落库→读出;仅目标平台带值,源平台不带(null)。""" token = _login(client, "13800002010") rid = client.post( "/api/v1/compare/record", json=_food_payload("trace-coupon"), headers=_auth(token) @@ -87,7 +88,9 @@ def test_coupon_saved_passthrough(client) -> None: d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json() by_pid = {x["platform_id"]: x for x in d["comparison_results"]} assert by_pid["meituan"]["coupon_saved"] == 7.0 # 目标平台带值 + assert by_pid["meituan"]["coupon_name"] == "外卖大额神券" # 来源名透传(pricebot#38) assert by_pid["taobao_flash"].get("coupon_saved") is None # 源平台不带/为 null + assert by_pid["taobao_flash"].get("coupon_name") is None # 源平台无来源名 def test_source_is_cheapest_no_saving(client) -> None: diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 1803815..79743ba 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -180,6 +180,51 @@ def test_exchange_flow(client) -> None: assert page["items"][0]["biz_type"] == "exchange_in" +def test_exchange_min_floor_one_cent(client) -> None: + """锁定本次下调的兑换下限:正好兑下限额(MIN_EXCHANGE_COIN=100 金币=1 分)应成功。 + + 这是本 PR 的核心新能力(下限 10000→100,可兑 1 分起)。test_exchange_flow 兑的是 + 1 元(10000),在旧下限下也通过,证明不了新下限;本用例供款并兑换正好等于下限的 + 100 金币,断言到账 1 分。若有人把 MIN_EXCHANGE_COIN 改回 10000,100 会因低于下限被 + 判 400,本用例随之失败,从而把新下限值钉死。 + """ + phone = "13800001010" + token = _login(client, phone) + # 供款: 正好注入一个下限额度的金币(=COIN_PER_CENT=100) + with SessionLocal() as db: + user = get_user_by_phone(db, phone) + assert user is not None + crud_wallet.grant_coins( + db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款" + ) + db.commit() + + # 兑换下限额 100 金币 → 1 分 + r = client.post( + "/api/v1/wallet/exchange", + json={"coin_amount": MIN_EXCHANGE_COIN}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + res = r.json() + assert res["coin_amount"] == MIN_EXCHANGE_COIN + assert res["cash_added_cents"] == 1 # coins_to_cents(100) == 1 + assert res["coin_balance"] == 0 + assert res["cash_balance_cents"] == 1 + + # 金币流水有一笔 exchange_out(=-100) + r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) + out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out") + assert out_txn["amount"] == -MIN_EXCHANGE_COIN + + # 现金流水有且仅有一笔 exchange_in(=+1 分) + r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token)) + cash = r.json()["items"] + assert len(cash) == 1 + assert cash[0]["amount_cents"] == 1 + assert cash[0]["biz_type"] == "exchange_in" + + def test_exchange_insufficient_and_invalid(client) -> None: token = _login(client, "13800001006")