Files
shaguabijia-app-server/app/repositories/ops_marquee.py
T
zzhyyyyy bce8cd9923 运营后台后端:RBAC 权限管理 + 系统配置下发修复 + 比价记录店/商品搜索
一、RBAC 权限管理(角色 → 可见页面)
- 新增 admin_role 表 + 权限目录 permissions.py:角色持有一组页面 key,登录后左侧只展示这些页
- 内建角色 管理员(super_admin 全权锁定)/运营/财务/技术,页集对齐原型;key 承重(require_role 用),另加中文 label 展示
- 角色 CRUD 端点(仅 super_admin)+ 目录端点;登录/me 下发当前角色有效可见页
- admins 加删除、角色存在性校验;可复看已确定登录密码:UI 建的账号留存明文 plain_password,脚本建的超管不留存

二、系统配置下发修复
- 首页数据「保存即生效」:显式保存(apply_now)对 real/manual 直接落配置目标值、绕过只增不减护栏(修「改了 app 端不变」),护栏仍管自动 tick
- 首页轮播新增数据源三选一:mixed(真实优先+种子)/real(只真实)/seed(只种子),get_feed 分支 + /marquee-seeds/mode 端点
- 福利页 Tab 隐藏 任务/里程碑,及看广告的单次金币/每轮次数/信息流开关:CONFIG_DEFS 加 hidden + list_config 过滤,业务读取默认值不受影响

三、比价记录店/商品搜索
- comparison_record 加 product_names 派生列(从下单 items 拼商品名),迁移建列并回填历史行
- admin 比价记录列表店/商品分列可搜:走 product_names 普通列 LIKE,规避 SQLite JSON 中文 ensure_ascii 转义搜不到的坑

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:52:42 +08:00

441 lines
20 KiB
Python

"""首页轮播 feed:全平台真实比价记录 + 种子兜底「混播」,以及种子表 CRUD。
feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 ops_marquee_seed 的种子补齐,
保证轮播永不空。真实够时种子不出现。
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
脱敏名规则(对齐 PRD「用户标识打码规则」,按字符数算、中英文皆适用):
- 有昵称 → 昵称脱敏:n≥5「首+***+末」(AAA省钱小能手→A***手 / Micky_ 毛→M***毛),
n=4「首+**+末」(你好不好→你**好),n≤3「首+**」(小确呀→小** / 阿强→阿** / 喵→喵**);
- 没昵称(真实用户)→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定。
真实条按上述 PRD 规则脱敏。种子 / 兜底无真实用户(无昵称/无 id,套不上「用户*****+id后2位」),
仍本地合成多样假名再走同一套昵称脱敏(避开同屏撞名),让横条不至清一色;种子留空 / 旧「用户****xxx」
模板名一律重新合成(自愈历史种子,无需迁移)。
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户
每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。
"""
from __future__ import annotations
import random
from datetime import datetime, timedelta
from sqlalchemy import delete, func, select, update
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
from app.models.user import User
from app.repositories import app_config
# 首页轮播数据源模式(存 app_config.marquee_feed_mode):
# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。
FEED_MODES = ("mixed", "real", "seed")
def get_feed_mode(db: Session) -> str:
"""读首页轮播数据源模式;非法/未配置回退 mixed(= 原行为)。"""
mode = app_config.get_value(db, "marquee_feed_mode")
return mode if mode in FEED_MODES else "mixed"
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random()
# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮)
_REAL_MAX_CENTS = 30000
# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下
_SEED_MAX_CENTS = 100000
# 真实记录查询缓存:首页人人都看、真实数据变化慢 → 缓存原始行 ~30s,省掉高频 DB 往返。
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
_REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None}
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
# 没昵称(真实用户)→「用户」+5星+id 后 2 位(用户*****08)。
# 种子/兜底没有真实用户(无昵称/无 id),本地用姓/名字池组合真名 + 网络昵称语料(中/英)合成假名,
# 再走同一套昵称脱敏,让横条像真实异质用户群、不清一色(PRD 只约束真实用户,种子是运营填充)。
_ANON_STARS = "*" * 5 # 「用户」匿名串固定 5 星(用户*****xx,对齐 PRD)
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
_SURNAME_ZH = list(
"王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁"
"魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段钱汤尹黎"
"易常武乔贺赖龚文施洪丰房邢"
)
_GIVEN_ZH = list(
"伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超霞平刚桂兰华健世广义兴良海山仁波宁贵福生龙元全国胜学祥"
"才发新利清飞彬富顺信子昌成康星光天达安岩中茂进有坚和彪博诚先敬震振壮会思群豪心邦承乐绍功松善"
"厚庆民友裕河哲亮政谦亨奇之轮翰朗伯宏言若鸣朋斌栋维启克伦翔旭鹏泽晨辰士以建家致树炎德行时泰盛雄"
)
# 英文名池:与英文昵称互补,偶尔带数字尾(jay87)更像注册用户名(替代原 Faker en_US)。
_FIRST_EN = [
"Alex", "Chris", "Sam", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Jessie",
"Robin", "Drew", "Lee", "Max", "Kim", "Ray", "Dana", "Pat", "Terry", "Jay",
"Kelly", "Sky", "Ash", "Nico", "Remy", "Quinn", "Reese", "Sage", "Toni", "Val",
]
# 网络昵称语料:补一份「省钱小能手 / SaveKing」式网络昵称更像真实 app 用户。想更丰富往里加即可。
_NICK_ZH = [
"省钱小能手", "薅羊毛专业户", "吃货一枚", "干饭人", "奶茶续命", "精打细算过日子",
"会过日子的人", "捡漏王", "外卖救星", "养生少年", "持家有道", "比价狂魔",
"月光族逆袭", "摸鱼达人", "暴富锦鲤", "美食猎人", "折扣猎手", "隐形贫困人口",
"打工不打烊", "佛系青年", "元气满满", "一只小馋猫", "爱吃的小朋友", "省钱才是硬道理",
]
_NICK_EN = [
"SaveKing", "DealHunter", "FoodieLife", "BudgetBoss", "CouponQueen", "ThriftyOne",
"SnackLover", "BargainHero", "PennyWise", "SmartShopper", "DiscountNinja", "HungryPanda",
"MoneyMaster", "CheapEats", "SaverPro", "FrugalFox", "NoodleKing", "BubbleTeaFan",
"GoldenSaver", "ValueHunter", "SnackAttack", "LazyFoodie",
]
def _mask_nickname(nick: str) -> str:
"""昵称脱敏(按字符数,中英文皆可,对齐 PRD「用户标识打码规则」):
n≥5 → 首+***+末(AAA省钱小能手→A***手 / Micky_ 毛→M***毛);
n=4 → 首+**+末(你好不好→你**好 / Lady→L**y);
n≤3 → 首+**(小确呀→小** / 阿强→阿** / 喵→喵** / Lay→L**)。
调用方需保证 nick 已 strip 且非空。"""
n = len(nick)
if n >= 5:
return nick[0] + "***" + nick[-1]
if n == 4:
return nick[0] + "**" + nick[-1]
return nick[0] + "**"
def _mask_anon(user_id: int) -> str:
"""没昵称(真实用户,对齐 PRD):「用户」+ 5 星 + id 后 2 位(不足 2 位前补 0):用户*****08。
注:user_id 为整数,「id 后 2 位」取末两位数字(PRD 示例「用户*****x8」的 x 为占位符)。"""
return "用户" + _ANON_STARS + f"{user_id % 100:02d}"
def _synth_full_name(rng: random.Random) -> str:
"""合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,
中文为主(~70%)英文为辅(~30%)。**全程用传入 rng** → 同种子复现、不同种子各异、组合空间上万。"""
r = rng.random()
if r < 0.35:
# 中文真名:姓 + 1~2 个名字字(60% 概率两字),脱敏后露「姓*」或「姓*末」
given = rng.choice(_GIVEN_ZH) + (rng.choice(_GIVEN_ZH) if rng.random() < 0.6 else "")
return rng.choice(_SURNAME_ZH) + given # 王伟 / 李秀兰
if r < 0.70:
return rng.choice(_NICK_ZH) # 中文网络昵称:省钱小能手
if r < 0.85:
return rng.choice(_NICK_EN) # 英文昵称:SaveKing
base = rng.choice(_FIRST_EN) # 英文名,半数带数字尾:Jay87 / Alex
return base + (str(rng.randint(1, 99)) if rng.random() < 0.5 else "")
def _synth_masked_name(rng: random.Random) -> str:
"""合成一条「已脱敏」假名(供种子 / 兜底用,真实用户已改走 PRD 规则)。用传入 rng 决定一切随机。"""
full = _synth_full_name(rng).strip()
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
nick = (nickname or "").strip()
if nick:
return _mask_nickname(nick)
return _mask_anon(user_id)
def _synth_name(rng: random.Random) -> str:
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 2 位(用户*****08)。"""
if rng.random() < 0.15:
return "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
return _synth_masked_name(rng)
def _unique_name(used: set[str]) -> str:
"""随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。"""
for _ in range(60):
n = _synth_name(_rng)
if n not in used:
return n
return "用户" + _ANON_STARS + f"{_rng.randint(0, 99):02d}" # 兜底:数字尾号天然好去重
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} 元)")
def _skewed_amount(lo: int, hi: int) -> int:
"""长尾金额:多数贴近下限、偶尔接近上限(比均匀随机更像真实省钱分布)。
用 u**2.2 把 [0,1) 均匀值压向 0 → 小额居多;偶尔 u 接近 1 给出大额。
"""
if hi <= lo:
return max(0, lo)
u = _rng.random() ** 2.2
return lo + int(round((hi - lo) * u))
# 兜底金额区间(真实 + 种子都凑不满 limit 时,内置合成条用):3~35 元
_FALLBACK_MIN_CENTS = 300
_FALLBACK_MAX_CENTS = 3500
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
"""近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。
join user 取真实昵称用于脱敏(无昵称则展示层走「用户+id 后 3 位」)。
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
"""
now = datetime.now(CN_TZ)
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
return cached
rows = db.execute(
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
.join(User, User.id == ComparisonRecord.user_id)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_ROWS_FETCH_CAP)
).all()
out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
return out
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),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
"""
mode = get_feed_mode(db) # mixed / real / seed(运营可在「首页轮播种子」页切换)
items: list[dict] = []
used_names: set[str] = set()
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
if mode != "seed":
rows = _recent_real_rows(db)
seen_users: set[int] = set()
for uid, sc, nick in rows:
if uid in seen_users:
continue
seen_users.add(uid)
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
if mode != "real":
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:
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
# 仅运营手动设的非模板真名才原样用。
fixed = (s.masked_user or "").strip()
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
used_names.add(name)
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
while len(items) < limit:
name = _unique_name(used_names)
used_names.add(name)
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
cursor = datetime.now(CN_TZ)
for i, it in enumerate(items):
if i == 0:
gap = _rng.randint(8, 40)
else:
r = _rng.random()
if r < 0.2:
gap = _rng.randint(20, 55) # 偶尔扎堆
elif r < 0.85:
gap = _rng.randint(60, 300) # 常规 1~5 分钟
else:
gap = _rng.randint(300, 540) # 偶尔较长 5~9 分钟
cursor = cursor - timedelta(seconds=gap)
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
def batch_delete(db: Session, ids: list[int], *, commit: bool = True) -> int:
"""批量删除选中的种子,返回实际删除条数(不存在的 id 自动跳过)。"""
if not ids:
return 0
n = db.execute(delete(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids))).rowcount
if commit:
db.commit()
else:
db.flush()
return int(n or 0)
def batch_set_enabled(db: Session, ids: list[int], enabled: bool, *, commit: bool = True) -> int:
"""批量启用 / 停用选中的种子,返回实际更新条数。"""
if not ids:
return 0
n = db.execute(
update(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids)).values(enabled=enabled)
).rowcount
if commit:
db.commit()
else:
db.flush()
return int(n or 0)