diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py index 592dd56..d1d053c 100644 --- a/app/admin/routers/ops_marquee_seed.py +++ b/app/admin/routers/ops_marquee_seed.py @@ -57,9 +57,15 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]: def preview_feed( db: AdminDb, limit: Annotated[int, Query(ge=1, le=30)] = 8, + mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None, ) -> OpsSavingsFeedPreviewOut: - """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。""" - return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit)) + """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。 + + mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。 + """ + if mode is not None and mode not in ops_marquee.FEED_MODES: + raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed") + return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode)) # 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。 diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 2572b3d..abf2978 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -30,6 +30,7 @@ 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=只种子+合成兜底。 @@ -140,9 +141,12 @@ def _synth_masked_name(rng: random.Random) -> str: def _mask_real(nickname: str | None, user_id: int) -> str: """真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可); - 没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。""" + 没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。 + + 创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称, + 按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。""" nick = (nickname or "").strip() - if nick: + if nick and not is_default_nickname(nick): return _mask_nickname(nick) return _mask_anon(user_id) @@ -213,9 +217,11 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]: return out -def get_feed(db: Session, limit: int = 8) -> list[dict]: +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 去重(同一用户只取最新一条,避免单人刷屏)。 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; 种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。 @@ -223,7 +229,8 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、 节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。 """ - mode = get_feed_mode(db) # mixed / real / seed(运营可在「首页轮播种子」页切换) + # 预览可显式指定模式(所见=选中模式,不依赖 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() diff --git a/app/repositories/user.py b/app/repositories/user.py index 7397d44..7a560ef 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -42,6 +42,22 @@ def _gen_nickname() -> str: ) +def is_default_nickname(nickname: str | None) -> bool: + """是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。 + + 这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。 + 精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、 + 长度也不符)。用户改过昵称即不再匹配。""" + if not nickname: + return False + s = nickname.strip() + return ( + len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN + and s.startswith(_NICKNAME_PREFIX) + and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):]) + ) + + def get_user_by_username(db: Session, username: str) -> User | None: return db.execute( select(User).where(User.username == username)