Files
shaguabijia-app-server/app/repositories/ops_marquee.py
T
ouzhou 766666601e feat(platform): 首页门面三统计 + 运营后台展示模式配置 (#22)
Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #22
Reviewed-by: marco <marco@wonderable.ai>
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-07 23:17:27 +08:00

247 lines
8.6 KiB
Python

"""首页轮播 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