From fff09a3d4d5174f2947d0036e25aa9686a4d6dbd Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Wed, 10 Jun 2026 20:48:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=A6=96=E9=A1=B5=E9=97=A8?= =?UTF-8?q?=E9=9D=A2=E6=95=B0=E5=AD=97=E4=BF=9D=E5=BA=95/=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F=20+=20=E8=BD=AE=E6=92=AD=E7=A7=8D=E5=AD=90=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=8C=96=E4=B8=8E=E6=89=B9=E9=87=8F=20+=20=E6=AF=94?= =?UTF-8?q?=E4=BB=B7=E8=AE=B0=E5=BD=95=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ops_stat: 真实值 offset→保底(max);初始基数过只增不减护栏 - ops_marquee: 脱敏名手机尾号+中文昵称混合;金额长尾;时间随机抖动;真实+种子不足兜底补满;真实记录查询 ~30s 缓存 - comparison_record: 复合索引 (status, created_at) + 迁移(PG CONCURRENTLY);修复 alembic 多 head(merge 改挂 onboarding_completion) - 轮播种子批量删除/启用接口 + ids 上限;docs 同步 - 附带并行会话 WIP:admin users / wallet Co-Authored-By: Claude Opus 4.8 (1M context) --- .../versions/comparison_status_created_idx.py | 48 +++++ ..._merge_comparison_idx_and_coupon_daily_.py | 26 +++ app/admin/routers/ops_marquee_seed.py | 36 ++++ app/admin/routers/users.py | 40 ++++ app/admin/schemas/ops_marquee_seed.py | 13 +- app/admin/schemas/ops_stat_config.py | 2 +- app/admin/schemas/user.py | 5 + app/models/comparison.py | 4 + app/models/ops_marquee_seed.py | 3 +- app/models/ops_stat_config.py | 3 +- app/repositories/ops_marquee.py | 202 ++++++++++++++---- app/repositories/ops_stat.py | 14 +- app/repositories/wallet.py | 31 +++ app/schemas/platform.py | 2 +- docs/api/admin-dashboard-display.md | 4 +- docs/api/admin-marquee-seeds.md | 12 +- docs/api/platform-savings-feed.md | 10 +- docs/api/platform-stats.md | 2 +- docs/database/ops_marquee_seed.md | 4 +- docs/database/ops_stat_config.md | 4 +- 20 files changed, 399 insertions(+), 66 deletions(-) create mode 100644 alembic/versions/comparison_status_created_idx.py create mode 100644 alembic/versions/d4b87c5847de_merge_comparison_idx_and_coupon_daily_.py diff --git a/alembic/versions/comparison_status_created_idx.py b/alembic/versions/comparison_status_created_idx.py new file mode 100644 index 0000000..9be8951 --- /dev/null +++ b/alembic/versions/comparison_status_created_idx.py @@ -0,0 +1,48 @@ +"""add composite index (status, created_at) on comparison_record + +首页轮播 feed(按 status='success' 过滤 + created_at 近期排序)与省钱战绩聚合 +都吃这个过滤+排序;复合索引避免随数据量增大退化成全表扫。 + +Revision ID: comparison_status_created_idx +Revises: a8c47fc4dc39 +Create Date: 2026-06-10 +""" + +from __future__ import annotations + +from alembic import op + +revision = "comparison_status_created_idx" +down_revision = "a8c47fc4dc39" +branch_labels = None +depends_on = None + +INDEX_NAME = "ix_comparison_status_created" + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + # PG 上 comparison_record 已有数据量, 普通 CREATE INDEX 持表写锁会阻塞线上写入; + # 用 CONCURRENTLY 不锁表(须脱离事务, autocommit_block 切到自动提交)。 + with op.get_context().autocommit_block(): + op.create_index( + INDEX_NAME, "comparison_record", ["status", "created_at"], + unique=False, postgresql_concurrently=True, + ) + else: + op.create_index( + INDEX_NAME, "comparison_record", ["status", "created_at"], unique=False + ) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + with op.get_context().autocommit_block(): + op.drop_index( + INDEX_NAME, table_name="comparison_record", + postgresql_concurrently=True, + ) + else: + op.drop_index(INDEX_NAME, table_name="comparison_record") diff --git a/alembic/versions/d4b87c5847de_merge_comparison_idx_and_coupon_daily_.py b/alembic/versions/d4b87c5847de_merge_comparison_idx_and_coupon_daily_.py new file mode 100644 index 0000000..6eb0aea --- /dev/null +++ b/alembic/versions/d4b87c5847de_merge_comparison_idx_and_coupon_daily_.py @@ -0,0 +1,26 @@ +"""merge comparison idx and coupon daily completion heads + +Revision ID: d4b87c5847de +Revises: comparison_status_created_idx, onboarding_completion +Create Date: 2026-06-10 16:45:50.285917 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4b87c5847de' +down_revision: Union[str, Sequence[str], None] = ('comparison_status_created_idx', 'onboarding_completion') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py index 53084ff..4c7c153 100644 --- a/app/admin/routers/ops_marquee_seed.py +++ b/app/admin/routers/ops_marquee_seed.py @@ -12,6 +12,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.schemas.ops_marquee_seed import ( + OpsMarqueeSeedBatchDelete, + OpsMarqueeSeedBatchEnable, OpsMarqueeSeedBulkCreate, OpsMarqueeSeedCreate, OpsMarqueeSeedOut, @@ -111,6 +113,40 @@ def bulk_generate( return [_out(s) for s in seeds] +@router.post("/batch-delete", summary="批量删除种子(带审计)") +def batch_delete_seeds( + body: OpsMarqueeSeedBatchDelete, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + n = ops_marquee.batch_delete(db, body.ids, commit=False) + write_audit( + db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed", + target_id=None, detail={"ids": body.ids, "deleted": n}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return {"deleted": n} + + +@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)") +def batch_enable_seeds( + body: OpsMarqueeSeedBatchEnable, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False) + write_audit( + db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed", + target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return {"updated": n} + + @router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)") def update_seed( seed_id: int, diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 6da09b9..c7bb408 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -12,6 +12,7 @@ from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.user import ( AdminUserListItem, AdminUserOverview, + GrantCashRequest, GrantCoinsRequest, SetDebugTraceRequest, SetUserStatusRequest, @@ -132,3 +133,42 @@ def grant_user_coins( ) db.commit() return OkResponse() + + +@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)") +def grant_user_cash( + user_id: int, + body: GrantCashRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("finance"))], + db: AdminDb, +) -> OkResponse: + """给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。""" + if body.amount_cents == 0: + raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + user = user_repo.get_user_by_id(db, user_id) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + # 负数扣减时不允许扣成负余额(运营误操作保护) + if body.amount_cents < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + if acc_now.cash_balance_cents + body.amount_cents < 0: + raise HTTPException( + status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + ) + biz_type = "admin_grant" if body.amount_cents > 0 else "admin_deduct" + # grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) + acc, _ = wallet_repo.grant_cash( + db, user_id, body.amount_cents, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + ) + write_audit( + db, admin, action="user.cash.grant", target_type="user", target_id=user_id, + detail={ + "amount_cents": body.amount_cents, + "balance_after_cents": acc.cash_balance_cents, + "reason": body.reason, + }, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() diff --git a/app/admin/schemas/ops_marquee_seed.py b/app/admin/schemas/ops_marquee_seed.py index 6095318..db8c56d 100644 --- a/app/admin/schemas/ops_marquee_seed.py +++ b/app/admin/schemas/ops_marquee_seed.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from pydantic import BaseModel +from pydantic import BaseModel, Field class OpsMarqueeSeedOut(BaseModel): @@ -42,6 +42,17 @@ class OpsMarqueeSeedBulkCreate(BaseModel): enabled: bool = True +class OpsMarqueeSeedBatchDelete(BaseModel): + """批量删除选中的种子。""" + ids: list[int] = Field(..., max_length=500) # 上限防超大列表(审计行/IN 子句过大) + + +class OpsMarqueeSeedBatchEnable(BaseModel): + """批量启用 / 停用选中的种子。""" + ids: list[int] = Field(..., max_length=500) + enabled: bool + + class OpsSavingsFeedPreviewItem(BaseModel): masked_user: str saved_amount_cents: int diff --git a/app/admin/schemas/ops_stat_config.py b/app/admin/schemas/ops_stat_config.py index 0a3a473..5172f3e 100644 --- a/app/admin/schemas/ops_stat_config.py +++ b/app/admin/schemas/ops_stat_config.py @@ -20,7 +20,7 @@ class OpsStatItemOut(BaseModel): random_kind: str # mult(×倍率) / add(+绝对增量) random_step_min: int # 绝对增量区间(基础单位;total_saved 为分) random_step_max: int - real_offset: int # 真实值模式基数偏移(基础单位) + real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减) random_current: int | None = None random_last_tick_at: str | None = None diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 304c8b7..0b49582 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -42,6 +42,11 @@ class GrantCoinsRequest(BaseModel): reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") +class GrantCashRequest(BaseModel): + amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)") + reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") + + class SetUserStatusRequest(BaseModel): status: Literal["active", "disabled"] = Field( ..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)" diff --git a/app/models/comparison.py b/app/models/comparison.py index 7a42a4f..0dbde5e 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -19,6 +19,7 @@ from sqlalchemy import ( Boolean, DateTime, ForeignKey, + Index, Integer, String, UniqueConstraint, @@ -39,6 +40,9 @@ class ComparisonRecord(Base): __table_args__ = ( # 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。 UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"), + # 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序; + # 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。 + Index("ix_comparison_status_created", "status", "created_at"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/app/models/ops_marquee_seed.py b/app/models/ops_marquee_seed.py index 2e64eb8..9eeb076 100644 --- a/app/models/ops_marquee_seed.py +++ b/app/models/ops_marquee_seed.py @@ -25,7 +25,8 @@ class OpsMarqueeSeed(Base): __tablename__ = "ops_marquee_seed" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - # 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名) + # 脱敏用户名(手机尾号 / 中文昵称风格,如「138****5678」「省钱**」);留空(NULL)或旧「用户****xxx」 + # 模板名 → feed 展示时随机合成混合风格名(避开撞名、自愈历史种子) masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True) # 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」 min_cents: Mapped[int] = mapped_column(Integer, nullable=False) diff --git a/app/models/ops_stat_config.py b/app/models/ops_stat_config.py index 1681de9..8ef17d5 100644 --- a/app/models/ops_stat_config.py +++ b/app/models/ops_stat_config.py @@ -53,7 +53,8 @@ class OpsStatConfig(Base): random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult") random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0) random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - # real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面) + # real 模式保底值(基础单位;total_saved 为分):展示值 = max(真实值, 保底值)。冷启动显示保底撑 + # 场面,真实值涨过保底后显示纯真实、零差距。列名沿用 real_offset(语义已从"偏移"改为"保底") real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束) allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 6eb0c27..688652e 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -5,14 +5,17 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条 种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 + +脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实 +异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机 +合成(自愈历史种子,无需迁移)。 """ from __future__ import annotations -import hashlib import random from datetime import datetime, timedelta -from sqlalchemy import func, select +from sqlalchemy import delete, func, select, update from sqlalchemy.orm import Session from app.core.rewards import CN_TZ @@ -22,34 +25,70 @@ 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 +# 真实记录查询缓存:首页人人都看、真实数据变化慢 → 缓存原始行 ~30s,省掉高频 DB 往返。 +# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。 +_REAL_ROWS_TTL_SECONDS = 30 +_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用 +_real_rows_cache: dict = {"at": None, "rows": None} + +# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)===== +_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x) +_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢" +_NICK_WORDS = [ + "省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭", + "美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生", + "佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子", +] +_NICK_PREFIX = ["小", "大", "老", "阿", "一只", "爱吃的", "快乐的"] +_NICK_SUFFIX = ["达人", "小能手", "侠", "党", "一族", "星人", "鸭", "酱"] +_LETTER_U = "ABCDEFGHJKLMNPQRSTUVWXYZ" +_LETTER_L = "abcdefghijkmnpqrstuvwxyz" + + +def _phone_name(rng: random.Random) -> str: + """手机尾号式:1[3-9]X****XXXX(合成、非真实号)。""" + head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9)) + return f"{head}****{rng.randint(0, 9999):04d}" + + +def _nickname(rng: random.Random) -> str: + """昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。""" + p = rng.randint(0, 11) + if p in (0, 1, 2): + return rng.choice(_NICK_WORDS) + "**" # 省钱** + if p in (3, 4): + return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 吃货**达人 + if p in (5, 6): + return rng.choice(_SURNAMES) + "**" # 张** + if p in (7, 8): + return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" # 小张** + if p in (9, 10): + return rng.choice(_NICK_WORDS) + rng.choice(_LETTER_U) + "**" # 中英混 省钱A** + return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) + + +def _realistic_name(rng: random.Random) -> str: + """~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。""" + return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng) + def _mask_user(user_id: int) -> str: - """真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。""" - suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX] - return f"用户********{suffix}" + """真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)。""" + return _realistic_name(random.Random(user_id)) -def _random_suffix(used: set[str]) -> str: - """随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。""" +def _unique_name(used: set[str]) -> str: + """随机合成一个不与 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" + n = _realistic_name(_rng) + if n not in used: + return n + return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到) def _validate_amount(min_cents: int, max_cents: int) -> None: @@ -61,17 +100,30 @@ def _validate_amount(min_cents: int, max_cents: int) -> None: 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 往前递减),保证轮播永远像刚发生 - (真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。 +def _skewed_amount(lo: int, hi: int) -> int: + """长尾金额:多数贴近下限、偶尔接近上限(比均匀随机更像真实省钱分布)。 + 用 u**2.2 把 [0,1) 均匀值压向 0 → 小额居多;偶尔 u 接近 1 给出大额。 """ - # 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。 + 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]]: + """近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。 + 返回纯元组(脱离 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) .where( @@ -80,18 +132,35 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS, ) .order_by(ComparisonRecord.created_at.desc()) - .limit(max(limit * 20, 100)) + .limit(_REAL_ROWS_FETCH_CAP) ).all() + out = [(int(uid), int(sc)) for uid, sc 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 往前**随机抖动**递减),保证轮播永远像刚发生、 + 节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。 + """ + # 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。 + rows = _recent_real_rows(db) items: list[dict] = [] - used_suffixes: set[str] = set() + used_names: 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:]) + used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受 items.append({"masked_user": name, "saved_amount_cents": int(sc)}) if len(items) >= limit: break @@ -105,22 +174,41 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: 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)。 + # 用户名:旧的「用户****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)) - cents = lo if lo >= hi else _rng.randint(lo, hi) - items.append({"masked_user": name, "saved_amount_cents": cents}) + items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)}) - # 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。 + # 兜底:真实 + 种子仍凑不满 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): - cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5)) + 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 @@ -244,3 +332,29 @@ def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool: 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) diff --git a/app/repositories/ops_stat.py b/app/repositories/ops_stat.py index 5dfd7f9..c4593b8 100644 --- a/app/repositories/ops_stat.py +++ b/app/repositories/ops_stat.py @@ -7,7 +7,8 @@ random 自增长支持两种方式(random_kind): - mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长 - add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增 -真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面。 +真实值(real)模式展示 = max(真实统计, real_offset 保底值):冷启动显示保底撑场面,真实值涨过 +保底后显示纯真实、零差距(real_offset 列名沿用,语义从"偏移"改为"保底")。 「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值, 保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。 @@ -116,8 +117,9 @@ def _current_target(db: Session, row: OpsStatConfig) -> int: if row.mode == "manual": return max(0, int(row.manual_value or 0)) if row.mode == "real": - # 真实值 + 基数偏移 - return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0)) + # 真实值保底:展示 = max(真实值, 保底值)。冷启动真实值小→显示保底撑场面; + # 真实值涨过保底后显示纯真实、零差距(real_offset 列名沿用,语义已从"偏移"改为"保底")。 + return max(0, int(_real_value(db, row.metric)), int(row.real_offset or 0)) # random:无显式初始基数时用真实值(纯真实,不含偏移)播种 return max(0, int(_real_value(db, row.metric))) @@ -302,7 +304,7 @@ def update_config( raise ValueError("增量上限不得小于下限") if real_offset is not None: if real_offset < 0: - raise ValueError("基数偏移需为非负整数") + raise ValueError("保底值需为非负整数") row.real_offset = real_offset if allow_decrease is not None: row.allow_decrease = allow_decrease @@ -311,7 +313,9 @@ def update_config( if random_initial is not None: if random_initial < 0: raise ValueError("初始基数需为非负整数") - row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束 + # 初始基数受「只增不减」护栏(与 manual/real 一致):未勾 allow_decrease 且低于 + # 当前展示值时保持现值。首次无值(random_current is None)则护栏放行、直接设。 + row.random_current = _monotonic(row, random_initial) row.random_last_tick_at = now elif apply_now: # 立即更新:不等钟点,马上刷新一次 diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 8d73dab..606e422 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -129,6 +129,37 @@ def grant_coins( return acc, txn +def grant_cash( + db: Session, + user_id: int, + amount_cents: int, + *, + biz_type: str, + ref_id: str | None = None, + remark: str | None = None, +) -> tuple[CoinAccount, CashTransaction]: + """现金变动入口(正数入账 / 负数出账)。更新现金余额 + 写流水,不 commit。 + + 与 [grant_coins] 同模式(运营手动调现金 / 测试发现金用)。返回 (account, transaction), + 调用方负责 commit。不在此校验扣成负——由调用方(admin router)按业务保护。 + """ + acc = get_or_create_account(db, user_id, commit=False) + acc.cash_balance_cents += amount_cents + + txn = CashTransaction( + user_id=user_id, + amount_cents=amount_cents, + balance_after_cents=acc.cash_balance_cents, + biz_type=biz_type, + ref_id=ref_id, + remark=remark, + created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock + ) + db.add(txn) + db.flush() + return acc, txn + + def list_coin_transactions( db: Session, user_id: int, diff --git a/app/schemas/platform.py b/app/schemas/platform.py index c49cd08..ec8619c 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -15,7 +15,7 @@ class PlatformStatsOut(BaseModel): class SavingsFeedItem(BaseModel): """首页轮播一条。time 为北京时间 HH:MM:SS。""" - masked_user: str # 脱敏用户名,如「用户********a52」 + masked_user: str # 脱敏用户名,手机尾号(138****5678)或中文昵称(省钱**)混合风格 saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」 time: str diff --git a/docs/api/admin-dashboard-display.md b/docs/api/admin-dashboard-display.md index 70463b2..93eec20 100644 --- a/docs/api/admin-dashboard-display.md +++ b/docs/api/admin-dashboard-display.md @@ -27,7 +27,7 @@ | `random_kind` | string | 自增长方式:`mult` ×倍率 / `add` +绝对增量 | | `random_step_min` | int | add 模式增量下限(基础单位) | | `random_step_max` | int | add 模式增量上限(基础单位) | -| `real_offset` | int | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 | +| `real_offset` | int | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。字段名沿用 real_offset(语义已改为"保底") | | `allow_decrease` | bool | 是否允许展示值下降(默认 false = 只增不减) | | `random_current` | int \| null | 当前展示值(所有模式,基础单位) | | `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO) | @@ -52,7 +52,7 @@ | `random_kind` | string | 自增长方式:`mult` / `add` | | `random_step_min` | int | add 增量下限(基础单位,≥0) | | `random_step_max` | int | add 增量上限(基础单位,≥下限) | -| `real_offset` | int | real 基数偏移(基础单位,≥0) | +| `real_offset` | int | real 保底值(基础单位,≥0):展示 = max(真实值, 保底值) | | `allow_decrease` | bool | 允许展示值下降(默认 false=只增不减) | | `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** | | `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) | diff --git a/docs/api/admin-marquee-seeds.md b/docs/api/admin-marquee-seeds.md index a0b9566..cc1e8dd 100644 --- a/docs/api/admin-marquee-seeds.md +++ b/docs/api/admin-marquee-seeds.md @@ -31,6 +31,16 @@ - 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents`、`max_cents`、`enabled`(默认 true) - 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。 +## POST /admin/api/marquee-seeds/batch-delete — 批量删除(带审计) +对选中的多条种子一次删除(一个事务 + 一条审计)。 +- 入参 `OpsMarqueeSeedBatchDelete`:`ids`(int 数组;空数组 = no-op;不存在的 id 自动跳过) +- 出参 `200`:`{"deleted": <实际删除条数>}` + +## POST /admin/api/marquee-seeds/batch-enable — 批量启用 / 停用(带审计) +对选中的多条种子一次置启用态。 +- 入参 `OpsMarqueeSeedBatchEnable`:`ids`(int 数组)、`enabled`(bool) +- 出参 `200`:`{"updated": <实际更新条数>}` + ## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计) 入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`。 - `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。 @@ -41,5 +51,5 @@ 出参 `200`:`{"deleted": true}`。`404`=种子不存在。 ## 说明 -- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。 +- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate` / `batch_delete` / `batch_enable`)。预览为只读,任意已登录 admin 可用。 - 改动客户端**进首页 / 回前台**重拉 feed 时生效。 diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md index 156fa1e..321dc69 100644 --- a/docs/api/platform-savings-feed.md +++ b/docs/api/platform-savings-feed.md @@ -15,13 +15,15 @@ | 字段 | 类型 | 说明 | |---|---|---| | `items` | list | 轮播条目数组(最多 `limit` 条) | -| `items[].masked_user` | string | 脱敏用户名,如 `用户********a52` | +| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 | | `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 | | `items[].time` | string | 北京时间 `HH:MM:SS` | ## 说明 -- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` 哈希脱敏(`用户********`+3 位)。 -- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空则按脱敏格式**随机合成并避开同屏撞名**,金额在 `[min_cents, max_cents]` 区间**随机取值**(固定金额则 min==max)。 -- **`time` 为合成的「最近」时间**(从当前北京时间往前递减,首条约 20 秒前,其余每条 2~6 分钟):社会证明轮播保证永远像刚发生,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 +- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` **确定性合成**手机尾号 / 中文昵称混合脱敏名(同用户恒定)。 +- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。 +- 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。 +- **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 - 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 +- **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。 - 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 diff --git a/docs/api/platform-stats.md b/docs/api/platform-stats.md index 25ced25..9ddaef6 100644 --- a/docs/api/platform-stats.md +++ b/docs/api/platform-stats.md @@ -18,7 +18,7 @@ ## 说明 三指标各自独立选模式,且**统一「定时刷新」**:客户端看到的展示值只在「更新时间(按更新间隔)」对齐的北京钟点(`anchor + k*间隔`)刷新一次,平时不变。每次刷新按模式算新值: -- **real**:重新快照查库 + 基数偏移。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和(**只计 `0 模型 `app/models/ops_marquee_seed.py` | 关联接口 [platform-savings-feed](../api/platform-savings-feed.md) / [admin-marquee-seeds](../api/admin-marquee-seeds.md) | [← 表索引](./README.md) -首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`。 +首页「XXX 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成手机尾号/中文昵称混合名)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明 | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,如 `用户********a52`(整串存)。**留空 → feed 展示时按脱敏格式随机合成**(`用户********`+3 位 hex,避开同屏撞名),风格与真实条一致、永不穿帮 | +| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)风格(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时随机合成混合风格名**(避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮 | | `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) | | `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | | `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | diff --git a/docs/database/ops_stat_config.md b/docs/database/ops_stat_config.md index fe738be..d719be4 100644 --- a/docs/database/ops_stat_config.md +++ b/docs/database/ops_stat_config.md @@ -21,7 +21,7 @@ | `random_kind` | String(8) | NOT NULL, default `mult` | 自增长方式:`mult` ×倍率 / `add` +绝对增量 | | `random_step_min` | Integer | NOT NULL, default 0 | add 模式增量下限(基础单位) | | `random_step_max` | Integer | NOT NULL, default 0 | add 模式增量上限(基础单位) | -| `real_offset` | Integer | NOT NULL, default 0 | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 | +| `real_offset` | Integer | NOT NULL, default 0 | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。列名沿用 real_offset(语义已从"偏移"改为"保底") | | `allow_decrease` | Boolean | NOT NULL, default false | 是否允许展示值下降(默认 false = 只增不减) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 | @@ -39,7 +39,7 @@ `random_current` 现是**所有模式**的「当前展示值」(不止 random),用户侧 `/stats` 直接返回它。 - 触发边界 = 北京时间 `anchor + k*interval`(interval=`random_tick_seconds`,anchor=`random_anchor_minutes*60`,sub-day 间隔取 `anchor % interval` 作相位)。例:每天 09:00 / 每小时 :30 / 每 5 分刻度。 - 读取(`get_display_values`/`get_config`)时跑 `_refresh`:统计 `random_last_tick_at`→`now` 跨过几个边界 N,N≥1 才刷新: - - **real** → `random_current` = 重新查库的真实值 + `real_offset`(跨多少边界都只取最新),**经只增不减护栏**。 + - **real** → `random_current` = `max(重新查库的真实值, real_offset 保底值)`(跨多少边界都只取最新),**经只增不减护栏**。 - **manual** → `random_current` = 当前 `manual_value`,**经只增不减护栏**。 - **random** → 按 `random_kind` 走 N 个周期:`mult` 连乘随机倍率(`randint(min,max)/1000`,恒 ≥1.0)/ `add` 连加随机增量(`randint(step_min,step_max)`,≥0)。天然只增。 - **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时若新目标值 < 当前 `random_current`,保持当前值不回退(门面忌缩水)。开 `allow_decrease` 才允许下降。 -- 2.52.0