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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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`;「领取优惠券」后端暂无累计字段(前端先占位)。
|
||||
@@ -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`)现仅供订单口径/历史用途,不上战绩卡。
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 不计)。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user