0e42e96ddb
新增 admin 模块接口文档(auth login/me、admins CRUD、audit-logs、feedback 处理、stats 概览、user coins/detail/status、wallet 流水、withdraw 对账/刷新/列表) + admin_user/admin_audit_log 两张表文档,更新 api/database README 索引。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #12 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""省钱 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
|
|
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:
|
|
total_saved_cents: int
|
|
order_count: int
|
|
avg_saved_cents: int # 平均每单省(分)
|
|
|
|
|
|
@dataclass
|
|
class SavingsBattle:
|
|
week_saved_cents: int # 本周(周一起)已省
|
|
beat_percent: int # 超过百分之多少用户
|
|
streak_days: int # 连续省钱天数
|
|
compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底)
|
|
|
|
|
|
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 _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)
|
|
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:
|
|
records = _effective_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,
|
|
compare_count=len(records),
|
|
)
|
|
|
|
|
|
def _compute_beat_percent(db: Session, user_id: int) -> int:
|
|
"""超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。
|
|
|
|
= 严格少于我的其他用户数 / 其他用户总数 * 100。
|
|
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
|
|
"""
|
|
rows = db.execute(
|
|
select(
|
|
SavingsRecord.user_id,
|
|
SavingsRecord.source,
|
|
func.sum(SavingsRecord.saved_amount_cents),
|
|
).group_by(SavingsRecord.user_id, SavingsRecord.source)
|
|
).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()
|
|
}
|
|
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 倒序,游标式)。有真实(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)
|
|
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
|
|
|
|
|
|
def create_from_report(
|
|
db: Session, user_id: int, req: OrderReportRequest
|
|
) -> tuple[SavingsRecord, bool]:
|
|
"""真实比价上报 → 写入一条 savings_record(source='compare')。返回 (记录, 是否重复上报)。
|
|
|
|
省额 = 源平台原价 − 实付(下限 0);原价缺失则记 0。
|
|
幂等:同 (user_id, client_event_id) 已存在则直接返回旧记录,duplicated=True,不新增。
|
|
"""
|
|
existing = db.execute(
|
|
select(SavingsRecord).where(
|
|
SavingsRecord.user_id == user_id,
|
|
SavingsRecord.client_event_id == req.client_event_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
return existing, True
|
|
|
|
paid = req.paid_amount_cents
|
|
original = req.original_price_cents
|
|
saved = max(0, original - paid) if original is not None else 0
|
|
rec = SavingsRecord(
|
|
user_id=user_id,
|
|
order_amount_cents=paid,
|
|
saved_amount_cents=saved,
|
|
original_price_cents=original,
|
|
compared_price_cents=req.compared_price_cents,
|
|
platform=req.platform,
|
|
title=req.shop_name, # 明细卡标题用门店名
|
|
shop_name=req.shop_name,
|
|
dishes=req.dishes or [],
|
|
pay_channel=req.pay_channel,
|
|
platform_package=req.platform_package,
|
|
source_platform_name=req.source_platform_name,
|
|
source_deeplink=req.source_deeplink,
|
|
client_event_id=req.client_event_id,
|
|
device_id=req.device_id,
|
|
source="compare",
|
|
)
|
|
db.add(rec)
|
|
db.commit()
|
|
db.refresh(rec)
|
|
return rec, False
|