feat(savings): 省钱战绩卡比价口径 + 关停 savings demo 兜底

- 新增 GET /api/v1/compare/stats(comparison_record: status=success 计数 + saved_amount_cents 求和),供「我的」页省钱战绩卡比价口径(完成比价 + 累计发现可省),不要求下单
- savings.py 停用 demo seeder(ensure_seeded 等移除),只计真实 source='compare';简化 beat_percent 为 compare-only
- 测试: compare/stats 用例 + savings/welfare 改纯真实语义
- 文档: 新增 compare-stats.md;更新 savings-battle.md / savings_record.md / comparison_record.md / API README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-06-07 20:36:44 +08:00
parent c9578f5ba2
commit 4faf0b8e16
12 changed files with 176 additions and 129 deletions
+11
View File
@@ -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,
+19
View File
@@ -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(
+28 -108
View File
@@ -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)
+7
View File
@@ -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):
+1
View File
@@ -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) |
+25
View File
@@ -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`;「领取优惠券」后端暂无累计字段(前端先占位)。
+6 -2
View File
@@ -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`)现仅供订单口径/历史用途,不上战绩卡。
+1 -1
View File
@@ -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) |
+5 -5
View File
@@ -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 不计)。
+27
View File
@@ -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.50123.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:
+15 -6
View File
@@ -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()
+31 -7
View File
@@ -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 笔,各省 50004200=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