"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。 设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自 demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。 聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑—— created_at 带时区,统一转成北京时间的 date 再分组。 """ from __future__ import annotations import random from dataclasses import dataclass from datetime import datetime, timedelta from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.rewards import CN_TZ, cn_today from app.models.savings import SavingsRecord # 演示数据规模:最近 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: total_saved_cents: int order_count: int avg_saved_cents: int # 平均每单省(分) @dataclass class SavingsBattle: week_saved_cents: int # 本周(周一起)已省 beat_percent: int # 超过百分之多少用户 streak_days: int # 连续省钱天数 def _local_date(dt: datetime): """把(可能带时区的)created_at 转成北京时间的 date。""" if dt.tzinfo is None: return dt.date() 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) return list(db.execute(stmt).scalars().all()) 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: ensure_seeded(db, user_id) records = _all_records(db, user_id) total = sum(r.saved_amount_cents for r in records) count = len(records) avg = total // count if count else 0 return SavingsSummary(total_saved_cents=total, order_count=count, avg_saved_cents=avg) def _streak_days(dates: set) -> int: """从最近活跃日往前数连续有省钱记录的天数。""" if not dates: return 0 cur = max(dates) streak = 0 while cur in dates: streak += 1 cur = cur - timedelta(days=1) return streak def get_battle(db: Session, user_id: int) -> SavingsBattle: ensure_seeded(db, user_id) records = _all_records(db, user_id) today = cn_today() week_start = today - timedelta(days=today.weekday()) # 本周一 week_saved = sum( r.saved_amount_cents for r in records if _local_date(r.created_at) >= week_start ) dates = {_local_date(r.created_at) for r in records} streak = _streak_days(dates) beat_percent = _compute_beat_percent(db, user_id) return SavingsBattle( week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak ) def _compute_beat_percent(db: Session, user_id: int) -> int: """超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。 = 严格少于我的其他用户数 / 其他用户总数 * 100。 全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。 """ rows = db.execute( select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents)) .group_by(SavingsRecord.user_id) ).all() 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 my_total = totals.get(user_id, 0) beaten = sum(1 for t in others.values() if t < my_total) return round(100 * beaten / len(others)) def list_records( db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None, ) -> tuple[list[SavingsRecord], int | None]: """省钱明细分页(按 id 倒序,游标式)。""" ensure_seeded(db, user_id) stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) if cursor is not None: stmt = stmt.where(SavingsRecord.id < cursor) stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit) items = list(db.execute(stmt).scalars().all()) next_cursor = items[-1].id if len(items) == limit else None return items, next_cursor