"""首页轮播 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 from app.repositories.user import is_default_nickname # 首页轮播数据源模式(存 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 稳定、刷新不变脸。 创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称, 按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。""" nick = (nickname or "").strip() if nick and not is_default_nickname(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 _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list: """洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。 rng=None → 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random)→ 排列确定(admin 稳定分页)。 减少同一用户连续出现;只有少数几个用户时 best-effort。""" rng = rng or _rng pool = list(rows) rng.shuffle(pool) result: list = [] while pool: prev_uid = result[-1][0] if result else None # 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个 idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0) result.append(pool.pop(idx)) return result def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]: """返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。 mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读 持久化的 marquee_feed_mode;非法值一律回退到持久化模式。 真实条:success 且 0 < saved ≤ 上限,**不按 user 去重**(打乱 + 去连簇:相邻尽量不同用户、减少单人连刷)后取前 limit。 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; 种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。 真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、 节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。 """ # 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置。 mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed items: list[dict] = [] used_names: set[str] = set() # 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前 # limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。 if mode != "seed": for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]: name = _mask_real(nick, uid) used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重) items.append({"masked_user": name, "saved_amount_cents": int(sc)}) # 种子补位 + 合成兜底(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 # ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) ===== _REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌) _BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部 def _seed_browse_row(seed: OpsMarqueeSeed) -> dict: """把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。 名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值。user_id=0(种子无真实用户)。""" r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id)) fixed = (seed.masked_user or "").strip() name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r) lo = max(0, int(seed.min_cents)) hi = max(lo, int(seed.max_cents)) amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2))) return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0} def list_real_records( db: Session, mode: str | None = None, offset: int = 0, limit: int = 8 ) -> tuple[list[dict], int]: """分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**): - 只真实:全部 success+省>0 的真实记录; - 只种子:每条启用种子按生成逻辑各出一行; - 混播:真实 + 种子 全部合在一起。 与 app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立、不算同 user);**固定种子** → 同批数据下排列恒定,翻页不跳、能翻遍全部。返回 (items, total);item.user_id=0 表示种子。""" mode = mode if mode in FEED_MODES else get_feed_mode(db) # pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇) pool: list[tuple[int, dict]] = [] if mode != "seed": rows = db.execute( select( ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname, ComparisonRecord.created_at, ) .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_BROWSE_CAP) ).all() for uid, sc, nick, ca in rows: pool.append(( int(uid), { "masked_user": _mask_real(nick, int(uid)), "saved_amount_cents": int(sc), "created_at": str(ca)[:16] if ca is not None else "", "user_id": int(uid), }, )) if mode != "real": seeds = ( db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))) .scalars() .all() ) for i, s in enumerate(seeds): pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇 total = len(pool) # 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。 ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED)) off = max(0, offset) items = [item for _key, item in ordered[off : off + limit]] return items, total # ===== 运营侧:种子 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)