diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 40100b5..9c8f55b 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession from app.repositories import comparison as crud_compare from app.schemas.compare_record import ( + CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, ComparisonRecordIn, @@ -52,6 +53,16 @@ def report_record( return ComparisonRecordCreatedOut(id=rec.id) +@router.get( + "/stats", + response_model=CompareStatsOut, + summary="比价口径战绩(完成比价数 + 累计发现可省)", +) +def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut: + count, saved = crud_compare.get_stats(db, user.id) + return CompareStatsOut(compare_count=count, discovered_saved_cents=saved) + + @router.get( "/records", response_model=ComparisonRecordPage, diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index f233c4f..acc0c66 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -163,6 +163,25 @@ def count_success(db: Session, user_id: int) -> int: ).scalar_one() +def get_stats(db: Session, user_id: int) -> tuple[int, int]: + """「我的」页省钱战绩卡的**比价口径**聚合:(成功比价数, 累计发现可省额/分)。 + + - 完成比价 = status='success' 的比价记录数(同 count_success)。 + - 累计发现可省 = 各成功比价 saved_amount_cents(源价−最低价)之和;null(没省/未识别)不计。 + 与「下单成交记账」savings_record 解耦:比过价就计,不要求下单(见 savings.py)。 + """ + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0), + ).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.status == "success", + ) + ).one() + return int(row[0]), int(row[1]) + + def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None: """取单条(限本人,避免越权读他人记录)。""" return db.execute( diff --git a/app/repositories/savings.py b/app/repositories/savings.py index dbbbfe5..7eb5fa4 100644 --- a/app/repositories/savings.py +++ b/app/repositories/savings.py @@ -1,14 +1,17 @@ -"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。 +"""省钱 CRUD:累计/战绩聚合 + 明细分页(纯真实比价上报数据)。 -设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自 -demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。 +设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),数据只来自真实比价 +下单上报(`source='compare'`,见 create_from_report)。 + +⚠️ 2026-06 起**停用 demo 兜底**:比价下单上报链路已通(Android `PriceBotService` → +`POST /api/v1/order/report`),新用户无真实记录时直接显示空/0(配合「我的」页省钱战绩 +卡的「未比价锁定态」)。历史遗留的 `source='demo'` 行不再被任何统计口径计入。 聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑—— created_at 带时区,统一转成北京时间的 date 再分组。 """ from __future__ import annotations -import random from dataclasses import dataclass from datetime import datetime, timedelta @@ -19,26 +22,6 @@ from app.core.rewards import CN_TZ, cn_today from app.models.savings import SavingsRecord from app.schemas.order import OrderReportRequest -# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单 -_DEMO_STREAK_DAYS = 9 -_DEMO_EXTRA_ORDERS = 14 - -# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。 -# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中; -# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。 -_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [ - ("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]), - ("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]), - ("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]), - ("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]), - ("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]), - ("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]), - ("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]), - ("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]), - ("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]), - ("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]), -] - @dataclass class SavingsSummary: @@ -52,7 +35,7 @@ class SavingsBattle: week_saved_cents: int # 本周(周一起)已省 beat_percent: int # 超过百分之多少用户 streak_days: int # 连续省钱天数 - compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底) + compare_count: int # 累计完成比价次数(= 真实 compare 上报记录数) def _local_date(dt: datetime): @@ -62,75 +45,20 @@ def _local_date(dt: datetime): return dt.astimezone(CN_TZ).date() -def _all_records(db: Session, user_id: int) -> list[SavingsRecord]: - stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) +def _compare_records(db: Session, user_id: int) -> list[SavingsRecord]: + """统计/明细口径:只用真实比价上报(`source='compare'`)记录。 + + 2026-06 起停用 demo 兜底:无真实记录直接返回空(新用户显示 0/锁定态)。 + 历史 `source='demo'` 行不计入。 + """ + stmt = select(SavingsRecord).where( + SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" + ) return list(db.execute(stmt).scalars().all()) -def _has_real(db: Session, user_id: int) -> bool: - """该用户是否已有真实比价上报(source='compare')记录。""" - return db.execute( - select(SavingsRecord.id) - .where(SavingsRecord.user_id == user_id, SavingsRecord.source == "compare") - .limit(1) - ).first() is not None - - -def _effective_records(db: Session, user_id: int) -> list[SavingsRecord]: - """统计口径:有真实(compare)记录就只用真实的;否则 seed 一批 demo 并用 demo 兜底。""" - if _has_real(db, user_id): - stmt = select(SavingsRecord).where( - SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" - ) - return list(db.execute(stmt).scalars().all()) - ensure_seeded(db, user_id) - return _all_records(db, user_id) - - -def ensure_seeded(db: Session, user_id: int) -> None: - """该用户没有任何省钱记录时,幂等灌一批 demo 数据。""" - exists = db.execute( - select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1) - ).first() - if exists is not None: - return - - rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致 - today = cn_today() - records: list[SavingsRecord] = [] - - def _mk(day) -> SavingsRecord: - hour = rng.randint(8, 21) - minute = rng.randint(0, 59) - ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ) - platform, shop, dishes = rng.choice(_DEMO_ORDERS) - # 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡 - saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元 - return SavingsRecord( - user_id=user_id, - order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价) - saved_amount_cents=saved, - platform=platform, - title=shop, - shop_name=shop, - dishes=dishes, - source="demo", - created_at=ts, - ) - - # 最近 N 天每天 1 单 → 连续省钱 N 天 - for d in range(_DEMO_STREAK_DAYS): - records.append(_mk(today - timedelta(days=d))) - # 历史散单(第 10~40 天) - for _ in range(_DEMO_EXTRA_ORDERS): - records.append(_mk(today - timedelta(days=rng.randint(10, 40)))) - - db.add_all(records) - db.commit() - - def get_summary(db: Session, user_id: int) -> SavingsSummary: - records = _effective_records(db, user_id) + records = _compare_records(db, user_id) total = sum(r.saved_amount_cents for r in records) count = len(records) avg = total // count if count else 0 @@ -150,7 +78,7 @@ def _streak_days(dates: set) -> int: def get_battle(db: Session, user_id: int) -> SavingsBattle: - records = _effective_records(db, user_id) + records = _compare_records(db, user_id) today = cn_today() week_start = today - timedelta(days=today.weekday()) # 本周一 @@ -172,7 +100,7 @@ def get_battle(db: Session, user_id: int) -> SavingsBattle: def _compute_beat_percent(db: Session, user_id: int) -> int: - """超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。 + """超过百分之多少用户:按"累计省下金额"在所有有真实(compare)记录的用户中做真实分位。 = 严格少于我的其他用户数 / 其他用户总数 * 100。 全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。 @@ -180,18 +108,12 @@ def _compute_beat_percent(db: Session, user_id: int) -> int: rows = db.execute( select( SavingsRecord.user_id, - SavingsRecord.source, func.sum(SavingsRecord.saved_amount_cents), - ).group_by(SavingsRecord.user_id, SavingsRecord.source) + ) + .where(SavingsRecord.source == "compare") + .group_by(SavingsRecord.user_id) ).all() - # 每用户口径与展示一致:有 compare 用 compare 之和,否则退回 demo 之和 - by_user: dict[int, dict[str, int]] = {} - for uid, source, total in rows: - by_user.setdefault(uid, {})[source] = total or 0 - totals = { - uid: (d["compare"] if "compare" in d else d.get("demo", 0)) - for uid, d in by_user.items() - } + totals = {uid: (total or 0) for uid, total in rows} others = {uid: t for uid, t in totals.items() if uid != user_id} if not others: return 0 @@ -207,12 +129,10 @@ def list_records( limit: int = 20, cursor: int | None = None, ) -> tuple[list[SavingsRecord], int | None]: - """省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底。""" - stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) - if _has_real(db, user_id): - stmt = stmt.where(SavingsRecord.source == "compare") - else: - ensure_seeded(db, user_id) + """省钱明细分页(按 id 倒序,游标式)。只列真实比价上报(`source='compare'`)记录。""" + stmt = select(SavingsRecord).where( + SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" + ) if cursor is not None: stmt = stmt.where(SavingsRecord.id < cursor) stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit) diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 0e0ace7..7543d4a 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -118,6 +118,13 @@ class ComparisonRecordCreatedOut(BaseModel): id: int = Field(..., description="写入(或已存在)的记录 id") +class CompareStatsOut(BaseModel): + """「我的」页省钱战绩卡(比价口径)聚合。""" + + compare_count: int = Field(..., description="累计完成比价次数(status='success' 记录数)") + discovered_saved_cents: int = Field(..., description="累计发现可省额(分;各成功比价 源价−最低价 之和)") + + # ===== 比价战绩里程碑(福利页「记录比价战绩」)===== class MilestoneStateOut(BaseModel): diff --git a/docs/api/README.md b/docs/api/README.md index 31c8a45..971f8a3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -30,6 +30,7 @@ | 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) | | 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) | | 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) | +| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) | | **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) ||| | 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) | | 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) | diff --git a/docs/api/compare-stats.md b/docs/api/compare-stats.md new file mode 100644 index 0000000..454f5a7 --- /dev/null +++ b/docs/api/compare-stats.md @@ -0,0 +1,25 @@ +# GET /api/v1/compare/stats — 比价口径战绩(「我的」页省钱战绩卡) + +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) + +## 入参 +无(用户由 token 确定)。 + +## 出参 +响应 `200`:`CompareStatsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `compare_count` | int | 累计完成比价次数(`comparison_record` 中 `status='success'` 的记录数) | +| `discovered_saved_cents` | int | 累计发现可省额(分;各成功比价 `saved_amount_cents`=源价−最低价 之和,null 不计) | + +## 说明 +「我的」页**省钱战绩卡**的数据源(**比价口径**): + +- 卡主额「累计发现可省 ¥X」← `discovered_saved_cents` +- 卡三列之「完成比价(次)」← `compare_count` +- 卡「未比价锁定态」判据:`compare_count == 0`(没比过价 → 盖锁定蒙层,引导「立即开始」去首页比价) + +> ⚠️ **与下单口径解耦**:本接口只看「比过价」(`comparison_record`),**不要求下单**。区别于 `savings_record`(下单成交记账,见 [savings-summary](./savings-summary.md) / [savings-records](./savings-records.md))——那是订单记录/省钱明细的源。两表互不喂数据。 + +> 卡另两项数据源:「赚取金币」← `GET /wallet/account.total_coin_earned`;「领取优惠券」后端暂无累计字段(前端先占位)。 diff --git a/docs/api/savings-battle.md b/docs/api/savings-battle.md index 91940ec..1911b99 100644 --- a/docs/api/savings-battle.md +++ b/docs/api/savings-battle.md @@ -13,7 +13,11 @@ | `week_saved_cents` | int | 本周已省(分) | | `beat_percent` | int | 超过百分之多少用户 | | `streak_days` | int | 连续省钱天数 | -| `compare_count` | int | 累计完成比价次数(= 比价上报记录数;有真实 `compare` 记录用真实数,否则 demo 兜底) | +| `compare_count` | int | 累计完成比价次数(= 真实 `compare` 上报记录数;无记录为 0) | ## 说明 -「我的」页「省钱战绩」卡数据源(三列:本周已省 / 完成比价 / 连续比价)。`compare_count` 与 `/summary` 的 `order_count` 同源(均 = 有效记录数),对应原型「完成比价(次)」列。接口已通但无真实业务写入时走 demo 兜底(seed 约 23 条)。 +下单成交口径(`savings_record` 源)的战绩字段。`compare_count` 与 `/summary` 的 `order_count` 同源(均 = 真实**下单上报**记录数)。 + +> ⚠️ **2026-06 起停用 demo 兜底**:只计真实比价下单上报(`source='compare'`)。下单上报链路已通(Android `PriceBotService` → `POST /api/v1/order/report`)。 + +> ⚠️ **「我的」页省钱战绩卡已不读本接口**:#23 起卡改为**比价口径**,「完成比价 / 累计发现可省」走 [`GET /api/v1/compare/stats`](./compare-stats.md)(`comparison_record`,比过价就计、不要求下单),「赚取金币」走 `/wallet/account.total_coin_earned`。本 `/battle`(及 `compare_count`/`week_saved_cents`/`beat_percent`/`streak_days`)现仅供订单口径/历史用途,不上战绩卡。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index c63b035..ba47da1 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -9,7 +9,7 @@ ## 用在哪 / 增删改查 - **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。 - **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。 -- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`report.py` 反查 `best_*`;admin 大盘/明细。 +- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | diff --git a/docs/database/savings_record.md b/docs/database/savings_record.md index 0e8daee..bf7814d 100644 --- a/docs/database/savings_record.md +++ b/docs/database/savings_record.md @@ -4,15 +4,15 @@ profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。是「记账闭环」的落库终点——客户端比价后点结果链接跳走付款,无障碍 watcher 捕获支付成功 + 金额归因(±1 元/5 分钟窗口)→ 上报 `POST /order/report`。 -> **两种来源(`source` 字段区分)**: +> **来源(`source` 字段)**: > - `source='compare'`:**真实下单上报**(经 `/order/report` 的 `create_from_report` 写,2026-06 已接通)。 -> - `source='demo'`:用户从无任何记录时,首次进省钱页**懒种子** `ensure_seeded` 按 user_id 幂等灌 ~23 条演示数据。 -> 统计口径:**只要有 ≥1 条 `compare` 记录,就只用 compare**(demo 全忽略);否则用 demo 兜底。聚合(SUM/分组/连续天数/分位)都是生产级真实计算。 +> - `source='demo'`:旧演示种子。⚠️ **2026-06 起停用** demo 兜底(`ensure_seeded` 已移除),不再产生 demo 行;历史遗留的 demo 行不再被任何统计计入。 +> 统计口径:**只用 `source='compare'`**。新用户无真实记录时 summary/battle/records 全空/0(配合「我的」页省钱战绩卡「未比价锁定态」)。聚合(SUM/分组/连续天数/分位)都是生产级真实计算。 ## 用在哪 / 增删改查 - **C(插入)**: - `POST /order/report`(`create_from_report`)→ 写一行 `source='compare'`,省额 = `original_price_cents − paid`(下限 0)。按 `(user_id, client_event_id)` 幂等,重复上报返回旧行不新增。 - - `ensure_seeded` → 用户无任何 savings 记录时,懒灌 demo(`source='demo'`),进 summary/battle/records 接口时触发。 + - ~~`ensure_seeded` demo 懒种子~~:**2026-06 移除**(不再灌 demo)。 - **U / D**:无。 - **R**:`GET /savings/summary`(累计省/单数/均省)、`/savings/battle`(本周省/连续天数/超过 X% 用户/比价次数)、`/savings/records`(明细分页);`comparison.py` 读本表 `shop_name`(source=compare)集合给比价记录打「已下单」。 @@ -47,4 +47,4 @@ profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据 ## 注意 - `dishes` 用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。 -- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位,每用户口径与展示一致(有 compare 用 compare 之和,否则 demo 之和)。 +- `beat_percent`(超过百分之多少用户)按各用户**真实(compare)**累计省下金额做真实分位(demo 不计)。 diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 1569d2f..85696f0 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -193,11 +193,38 @@ def test_detail_cross_user_404(client) -> None: assert r.status_code == 404 +def test_stats_compare_count_and_saved(client) -> None: + """比价口径战绩:完成比价数 + 累计发现可省(各成功比价 saved 之和)。""" + token = _login(client, "13800002011") + # 新用户:无记录 → 全 0(配合「我的」页未比价锁定态) + s0 = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s0["compare_count"] == 0 + assert s0["discovered_saved_cents"] == 0 + + # 报 2 条成功比价(各省 128.50−123.50=5.00 元=500 分) + client.post("/api/v1/compare/record", json=_food_payload("stat-1"), headers=_auth(token)) + client.post("/api/v1/compare/record", json=_food_payload("stat-2"), headers=_auth(token)) + s = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s["compare_count"] == 2 + assert s["discovered_saved_cents"] == 1000 + + # 失败帧(只有源平台)不计入成功比价数 + fail = _food_payload("stat-fail") + fail["comparison_results"] = [ + {"platform_id": "taobao_flash", "platform_name": "淘宝闪购", + "package": "com.taobao.taobao", "price": 128.50, "is_source": True, "rank": 1}, + ] + client.post("/api/v1/compare/record", json=fail, headers=_auth(token)) + s2 = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s2["compare_count"] == 2 # 仍 2(failed 不计) + + def test_requires_auth(client) -> None: """不带 token 统一 401。""" assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401 assert client.get("/api/v1/compare/records").status_code == 401 assert client.get("/api/v1/compare/records/1").status_code == 401 + assert client.get("/api/v1/compare/stats").status_code == 401 def test_trace_id_required(client) -> None: diff --git a/tests/test_order_savings.py b/tests/test_order_savings.py index d6b1226..9adc684 100644 --- a/tests/test_order_savings.py +++ b/tests/test_order_savings.py @@ -1,7 +1,7 @@ """记账测试:比价后下单上报 → 写 savings_record(source='compare')。 覆盖:上报落库 + 门店/菜品/原价随之入库、(user,client_event_id) 幂等、 -真实(compare)记录优先于 demo seeder、省额 = 源平台原价 − 实付。 +纯真实口径(demo 兜底已停用:新用户空、上报后才有数)、省额 = 源平台原价 − 实付。 """ from __future__ import annotations @@ -78,12 +78,21 @@ def test_order_report_idempotent(client) -> None: assert s["order_count"] == 1 -def test_real_overrides_demo_seed(client) -> None: - """先触发 demo seeder(多笔),再上报一笔真实 → 统计切换为只算真实那笔。""" +def test_fresh_user_empty_until_report(client) -> None: + """纯真实(demo 兜底已停用):新用户 summary/battle/records 全空,上报后才有数。""" token = _login(client, "13800002003") - demo = client.get("/api/v1/savings/summary", headers=_auth(token)).json() - assert demo["order_count"] > 1 # demo 是多笔 + # 未上报:无任何 demo,统计全 0、明细空 + s0 = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert s0["order_count"] == 0 + assert s0["total_saved_cents"] == 0 + b0 = client.get("/api/v1/savings/battle", headers=_auth(token)).json() + assert b0["compare_count"] == 0 + assert b0["streak_days"] == 0 + p0 = client.get("/api/v1/savings/records", headers=_auth(token)).json() + assert p0["items"] == [] + + # 上报一笔真实 → 统计只算这一笔 client.post( "/api/v1/order/report", json=_report_body( @@ -92,7 +101,7 @@ def test_real_overrides_demo_seed(client) -> None: headers=_auth(token), ) s = client.get("/api/v1/savings/summary", headers=_auth(token)).json() - assert s["order_count"] == 1 # demo 被忽略 + assert s["order_count"] == 1 assert s["total_saved_cents"] == 800 # 5000 − 4200 page = client.get("/api/v1/savings/records", headers=_auth(token)).json() diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 79743ba..6650efb 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -253,38 +253,62 @@ def test_exchange_insufficient_and_invalid(client) -> None: assert r.status_code == 400 +def _report_savings(client, token: str, n: int, *, original=5000, paid=4200) -> None: + """上报 n 笔真实比价记录(source='compare'),省额 = original − paid。""" + for i in range(n): + client.post( + "/api/v1/order/report", + json={ + "client_event_id": f"evt-{token[-6:]}-{i}", + "platform": "美团外卖", + "pay_channel": "wechat", + "compared_price_cents": paid, + "paid_amount_cents": paid, + "original_price_cents": original, + "shop_name": f"测试门店{i}", + "dishes": ["招牌菜"], + }, + headers=_auth(token), + ) + + def test_savings_summary_and_battle(client) -> None: - """首次访问触发 demo seeder,聚合接口返回真实算出的数字。""" + """聚合接口对真实上报记录返回真实算出的数字(demo 兜底已停用)。""" token = _login(client, "13800001007") + _report_savings(client, token, 2) # 2 笔,各省 5000−4200=800 r = client.get("/api/v1/savings/summary", headers=_auth(token)) assert r.status_code == 200, r.text s = r.json() - assert s["order_count"] > 0 - assert s["total_saved_cents"] > 0 + assert s["order_count"] == 2 + assert s["total_saved_cents"] == 1600 # 平均每单 = 总额 // 单数 assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"] r = client.get("/api/v1/savings/battle", headers=_auth(token)) assert r.status_code == 200, r.text b = r.json() - assert b["streak_days"] >= 1 + assert b["streak_days"] >= 1 # 今日上报 → 至少连续 1 天 assert b["week_saved_cents"] >= 0 assert 0 <= b["beat_percent"] <= 100 - # 完成比价次数与 summary 的 order_count 同源(均 = 有效记录数) + # 完成比价次数与 summary 的 order_count 同源(均 = 真实记录数) assert b["compare_count"] == s["order_count"] -def test_savings_seeder_idempotent(client) -> None: - """重复访问不应重复灌数据:两次 summary 完全一致。""" +def test_savings_empty_for_new_user(client) -> None: + """新用户无真实上报时全 0(demo 兜底已停用),两次读一致。""" token = _login(client, "13800001008") first = client.get("/api/v1/savings/summary", headers=_auth(token)).json() second = client.get("/api/v1/savings/summary", headers=_auth(token)).json() assert first == second + assert first["order_count"] == 0 + assert first["total_saved_cents"] == 0 def test_savings_records_pagination(client) -> None: token = _login(client, "13800001009") + _report_savings(client, token, 12) # 12 笔真实记录撑起分页 + # 第一页 r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token)) assert r.status_code == 200, r.text