766666601e
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>
247 lines
8.6 KiB
Python
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
|