"""首页轮播 feed:全平台真实比价记录 + 种子兜底「混播」,以及种子表 CRUD。 feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 ops_marquee_seed 的种子补齐, 保证轮播永不空。真实够时种子不出现。 种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 """ from __future__ import annotations import hashlib import random from datetime import datetime, timedelta from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.rewards import CN_TZ from app.models.comparison import ComparisonRecord from app.models.ops_marquee_seed import OpsMarqueeSeed # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) _rng = random.Random() _SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐 _HEX = "0123456789abcdef" # 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮) _REAL_MAX_CENTS = 30000 # 种子金额防呆上限(1000 元):运营手滑填超大值时拦下 _SEED_MAX_CENTS = 100000 def _mask_user(user_id: int) -> str: """真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。""" suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX] return f"用户********{suffix}" def _random_suffix(used: set[str]) -> str: """随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。""" for _ in range(60): s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX)) if s not in used: used.add(s) return s # 兜底(几乎不会到):线性扫一个空位 for n in range(16 ** _SUFFIX_HEX): s = format(n, f"0{_SUFFIX_HEX}x") if s not in used: used.add(s) return s return "000" def _validate_amount(min_cents: int, max_cents: int) -> None: if min_cents < 0 or max_cents < 0: raise ValueError("金额需为非负整数(分)") if max_cents < min_cents: raise ValueError("金额上限不得小于下限") if max_cents > _SEED_MAX_CENTS: raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)") # ===== 用户侧:轮播 feed(真实 + 种子混播) ===== def get_feed(db: Session, limit: int = 8) -> list[dict]: """返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。 真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; 种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生 (真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。 """ # 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。 rows = db.execute( select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents) .where( ComparisonRecord.status == "success", ComparisonRecord.saved_amount_cents > 0, ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS, ) .order_by(ComparisonRecord.created_at.desc()) .limit(max(limit * 20, 100)) ).all() items: list[dict] = [] used_suffixes: set[str] = set() seen_users: set[int] = set() for uid, sc in rows: if uid in seen_users: continue seen_users.add(uid) name = _mask_user(uid) used_suffixes.add(name[-_SUFFIX_HEX:]) items.append({"masked_user": name, "saved_amount_cents": int(sc)}) if len(items) >= limit: break need = limit - len(items) if need > 0: seeds = db.execute( select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)) ).scalars().all() # 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。 chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds) _rng.shuffle(chosen) for s in chosen: # 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。 if s.masked_user and s.masked_user.strip(): name = s.masked_user.strip() used_suffixes.add(name[-_SUFFIX_HEX:]) else: name = f"用户********{_random_suffix(used_suffixes)}" # 金额:在 [min,max] 随机一个(固定金额则 min==max)。 lo = max(0, int(s.min_cents)) hi = max(lo, int(s.max_cents)) cents = lo if lo >= hi else _rng.randint(lo, hi) items.append({"masked_user": name, "saved_amount_cents": cents}) # 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。 cursor = datetime.now(CN_TZ) for i, it in enumerate(items): cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5)) it["time"] = cursor.strftime("%H:%M:%S") return items # ===== 运营侧:种子 CRUD ===== def list_seeds(db: Session) -> list[OpsMarqueeSeed]: return list( db.execute( select(OpsMarqueeSeed).order_by(OpsMarqueeSeed.sort_order, OpsMarqueeSeed.id) ).scalars().all() ) def create_seed( db: Session, *, masked_user: str | None = None, min_cents: int, max_cents: int, enabled: bool = True, sort_order: int = 0, commit: bool = True, ) -> OpsMarqueeSeed: _validate_amount(min_cents, max_cents) seed = OpsMarqueeSeed( masked_user=(masked_user.strip() or None) if masked_user else None, min_cents=min_cents, max_cents=max_cents, enabled=enabled, sort_order=sort_order, ) db.add(seed) if commit: db.commit() db.refresh(seed) else: db.flush() return seed def update_seed( db: Session, seed_id: int, *, masked_user: str | None = None, min_cents: int | None = None, max_cents: int | None = None, enabled: bool | None = None, sort_order: int | None = None, commit: bool = True, ) -> OpsMarqueeSeed: """改种子。masked_user 约定:不传(None)=不改;传空串=清空(改回随机合成);传值=固定该名。""" seed = db.get(OpsMarqueeSeed, seed_id) if seed is None: raise ValueError("种子不存在") if masked_user is not None: seed.masked_user = masked_user.strip() or None if min_cents is not None or max_cents is not None: new_min = min_cents if min_cents is not None else seed.min_cents new_max = max_cents if max_cents is not None else seed.max_cents _validate_amount(new_min, new_max) seed.min_cents = new_min seed.max_cents = new_max if enabled is not None: seed.enabled = enabled if sort_order is not None: seed.sort_order = sort_order if commit: db.commit() db.refresh(seed) else: db.flush() return seed def bulk_generate( db: Session, *, count: int, min_cents: int, max_cents: int, enabled: bool = True, commit: bool = True, ) -> list[OpsMarqueeSeed]: """批量生成 count 条「自动合成名」种子(masked_user=NULL),金额区间 [min,max]。 冷启动铺量用;sort_order 续在当前最大值之后。 """ if not (1 <= count <= 200): raise ValueError("数量需在 1~200 之间") _validate_amount(min_cents, max_cents) base = db.execute( select(func.coalesce(func.max(OpsMarqueeSeed.sort_order), 0)) ).scalar_one() created: list[OpsMarqueeSeed] = [] for i in range(1, count + 1): seed = OpsMarqueeSeed( masked_user=None, min_cents=min_cents, max_cents=max_cents, enabled=enabled, sort_order=base + i, ) db.add(seed) created.append(seed) if commit: db.commit() for s in created: db.refresh(s) else: db.flush() return created def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool: seed = db.get(OpsMarqueeSeed, seed_id) if seed is None: return False db.delete(seed) if commit: db.commit() else: db.flush() return True