"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。 三种模式见 app/models/ops_stat_config.py。统一「定时刷新」:展示值 random_current 只在跨过 「北京钟点边界」时刷新一次(real 快照查库 / manual 取固定值 / random 增长)。 random 自增长支持两种方式(random_kind): - mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长 - add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增 真实值(real)模式展示 = max(真实统计, real_offset 保底值):冷启动显示保底撑场面,真实值涨过 保底后显示纯真实、零差距(real_offset 列名沿用,语义从"偏移"改为"保底")。 「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值, 保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。 纯门面数字,**接受 tick 时刻的微小并发竞态**(不加行锁):极端情况某次多走一档,无业务后果。 """ from __future__ import annotations import random from datetime import datetime, timezone from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.comparison import ComparisonRecord from app.models.ops_stat_config import OpsStatConfig # 指标顺序 + 展示元信息(label/unit 给运营后台用;total_saved 单位是分,客户端 ÷100 显示元) METRICS = ("help_users", "total_compares", "total_saved") _META = { "help_users": ("帮助用户", "人"), "total_compares": ("完成比价", "次"), "total_saved": ("累计节省", "分"), } # 初始播种值(= 客户端原写死门面数:12847 人 / 86532 次 / 37621.4 元)。 _SEED_VALUE = { "help_users": 12847, "total_compares": 86532, "total_saved": 3762140, # 37621.40 元 = 3762140 分 } # 防呆边界 _MULT_FLOOR = 1000 # 倍率千分比下限 = 1.000(只增不减) _MULT_CAP = 1500 # 倍率千分比上限 = 1.500(收紧:单次 ×1.5 已不小,>此一跳就假) _STEP_CAP = 10**12 # 绝对增量单次上限(基础单位防呆) _TICK_MIN_SECONDS = 60 # tick 周期下限 _MAX_CATCHUP_PERIODS = 3650 # 单次最多补走的周期数(防 last 远古时一次走爆) _BEIJING_OFFSET = 8 * 3600 # 北京时间 = UTC + 8h(钟点对齐按北京时间算) # 真实 total_saved 求和:单条 saved 超此值视为异常 / bug 值,不计入 # (与轮播口径一致,防一条 bug 大额配合「只增不减」永久撑高门面) _REAL_SAVED_CAP_CENTS = 30000 # 300 元 def _as_utc(dt: datetime | None) -> datetime | None: """把(可能朴素的 SQLite)datetime 归一成带时区 UTC;朴素值按 UTC 解释。""" if dt is None: return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) # ===== 真实数据(real 模式)===== def _real_value(db: Session, metric: str) -> int: """real 口径:仅统计 status='success' 的比价记录。 help_users=去重用户数 / total_compares=记录数 / total_saved=省额(分)求和(剔除单条异常大值)。 """ success = ComparisonRecord.status == "success" if metric == "help_users": return db.execute( select(func.count(func.distinct(ComparisonRecord.user_id))).where(success) ).scalar_one() if metric == "total_compares": return db.execute( select(func.count(ComparisonRecord.id)).where(success) ).scalar_one() if metric == "total_saved": # 只累加 0 < saved ≤ 上限 的条目,异常大值不计入(防 bug 值撑高门面) return db.execute( select(func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0)).where( success, ComparisonRecord.saved_amount_cents > 0, ComparisonRecord.saved_amount_cents <= _REAL_SAVED_CAP_CENTS, ) ).scalar_one() raise KeyError(f"unknown metric: {metric}") # ===== 行管理 ===== def _ensure_rows(db: Session) -> dict[str, OpsStatConfig]: """保证三指标行都存在(migration 已播种;这里是缺行兜底,默认 manual + 种子值)。""" rows = {r.metric: r for r in db.execute(select(OpsStatConfig)).scalars().all()} created = False for metric in METRICS: if metric not in rows: row = OpsStatConfig( metric=metric, mode="manual", manual_value=_SEED_VALUE[metric] ) db.add(row) rows[metric] = row created = True if created: db.commit() return rows # ===== random 惰性 tick(北京钟点对齐)===== def _boundary_index(epoch_utc: int, interval: int, anchor_sec: int) -> int: """该 UTC 时刻落在第几个「北京钟点边界」。边界 = 北京时间 (anchor + k*interval)。""" bj = epoch_utc + _BEIJING_OFFSET return (bj - anchor_sec) // interval def _current_target(db: Session, row: OpsStatConfig) -> int: """某模式此刻「应有的展示值」(用于播种 / real·manual 的定时快照)。""" if row.mode == "manual": return max(0, int(row.manual_value or 0)) if row.mode == "real": # 真实值保底:展示 = 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))) def _monotonic(row: OpsStatConfig, new_val: int) -> int: """只增不减护栏:未开 allow_decrease 且已有展示值时,新值不得低于当前值(否则保持当前)。""" if not row.allow_decrease and row.random_current is not None and new_val < row.random_current: return row.random_current return new_val def _grow(row: OpsStatConfig, val: int, periods: int) -> int: """random 自增长:按 random_kind 走 periods 个周期(add=逐周期加随机增量 / mult=逐周期乘随机倍率)。""" if row.random_kind == "add": lo = max(0, row.random_step_min) hi = max(lo, row.random_step_max) for _ in range(periods): val += random.randint(lo, hi) else: lo = max(_MULT_FLOOR, row.random_mult_min) hi = max(lo, row.random_mult_max) for _ in range(periods): val = val * random.randint(lo, hi) // 1000 return val def _refresh(db: Session, row: OpsStatConfig) -> int: """统一「定时刷新」:展示值 random_current 只在跨过「北京钟点边界」时刷新一次。 real→重新快照(真实+偏移,受只增不减护栏)/ manual→取固定值(受护栏)/ random→按方式增长。 无初值则按模式播种。返回当前展示值(基础单位)。 """ now = datetime.now(timezone.utc) if row.random_current is None or row.random_last_tick_at is None: row.random_current = _current_target(db, row) row.random_last_tick_at = now db.commit() return row.random_current interval = max(_TICK_MIN_SECONDS, row.random_tick_seconds) anchor_sec = (max(0, row.random_anchor_minutes or 0) * 60) % interval last = _as_utc(row.random_last_tick_at) periods = _boundary_index(int(now.timestamp()), interval, anchor_sec) - _boundary_index( int(last.timestamp()), interval, anchor_sec ) if periods <= 0: return row.random_current if row.mode == "random": row.random_current = _grow(row, row.random_current, min(periods, _MAX_CATCHUP_PERIODS)) else: # real / manual:跨多少边界都只取「此刻应有的值」快照一次,经只增不减护栏 row.random_current = _monotonic(row, _current_target(db, row)) row.random_last_tick_at = now # 用边界索引比较,直接记 now 不会重复计 db.commit() return row.random_current # ===== 用户侧:展示值 ===== def get_display_values(db: Session) -> dict[str, int]: """返回 {metric: 展示整数值}(total_saved 为分)。三模式统一走定时刷新。""" rows = _ensure_rows(db) return {metric: int(_refresh(db, rows[metric])) for metric in METRICS} # ===== 运营侧:配置读写 ===== def _snapshot(row: OpsStatConfig) -> dict: """审计 / 返回用的行快照。""" return { "mode": row.mode, "manual_value": row.manual_value, "random_mult_min": row.random_mult_min, "random_mult_max": row.random_mult_max, "random_tick_seconds": row.random_tick_seconds, "random_anchor_minutes": row.random_anchor_minutes, "random_kind": row.random_kind, "random_step_min": row.random_step_min, "random_step_max": row.random_step_max, "real_offset": row.real_offset, "allow_decrease": row.allow_decrease, "random_current": row.random_current, "random_last_tick_at": ( row.random_last_tick_at.isoformat() if row.random_last_tick_at else None ), } def get_config(db: Session) -> list[dict]: """三指标当前配置 + 真实值预览(给运营对比),按 METRICS 顺序。 先跑一次定时刷新,使「当前展示值」在后台轮询时也随钟点实时推进。 """ rows = _ensure_rows(db) out: list[dict] = [] for metric in METRICS: row = rows[metric] _refresh(db, row) label, unit = _META[metric] out.append( { "metric": metric, "label": label, "unit": unit, "real_value": _real_value(db, metric), # 纯真实(不含偏移),供运营对比 "updated_at": row.updated_at.isoformat() if row.updated_at else None, **_snapshot(row), } ) return out def update_config( db: Session, metric: str, *, mode: str | None = None, manual_value: int | None = None, random_mult_min: int | None = None, random_mult_max: int | None = None, random_tick_seconds: int | None = None, random_anchor_minutes: int | None = None, random_kind: str | None = None, random_step_min: int | None = None, random_step_max: int | None = None, real_offset: int | None = None, allow_decrease: bool | None = None, random_initial: int | None = None, apply_now: bool = False, admin_id: int, commit: bool = True, ) -> tuple[dict, dict]: """改某指标配置。返回 (before, after) 快照供审计。非法入参抛 ValueError(router 转 400)。 展示值 random_current 采用统一「定时刷新」:平时不动,到更新钟点才按模式刷新。 本函数只在「首次无值」时按模式播种;给了 random_initial 则立即设为该值并重置; apply_now=True 则立即刷新一次(random 走一档增长,real/manual 取应有值,均经只增不减护栏)。 """ if metric not in METRICS: raise ValueError(f"未知指标: {metric}") rows = _ensure_rows(db) row = rows[metric] before = _snapshot(row) if mode is not None: if mode not in ("real", "manual", "random"): raise ValueError("mode 需为 real/manual/random") row.mode = mode if manual_value is not None: if manual_value < 0: raise ValueError("manual_value 需为非负整数") row.manual_value = manual_value if random_mult_min is not None: if not (_MULT_FLOOR <= random_mult_min <= _MULT_CAP): raise ValueError(f"倍率下限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}") row.random_mult_min = random_mult_min if random_mult_max is not None: if not (_MULT_FLOOR <= random_mult_max <= _MULT_CAP): raise ValueError(f"倍率上限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}") row.random_mult_max = random_mult_max if row.random_mult_max < row.random_mult_min: raise ValueError("倍率上限不得小于下限") if random_tick_seconds is not None: if random_tick_seconds < _TICK_MIN_SECONDS: raise ValueError(f"更新间隔不得小于 {_TICK_MIN_SECONDS} 秒") row.random_tick_seconds = random_tick_seconds if random_anchor_minutes is not None: if not (0 <= random_anchor_minutes < 1440): raise ValueError("更新时间需在 00:00~23:59 之间") row.random_anchor_minutes = random_anchor_minutes if random_kind is not None: if random_kind not in ("mult", "add"): raise ValueError("增长方式需为 mult/add") row.random_kind = random_kind if random_step_min is not None: if not (0 <= random_step_min <= _STEP_CAP): raise ValueError("增量下限需为非负整数") row.random_step_min = random_step_min if random_step_max is not None: if not (0 <= random_step_max <= _STEP_CAP): raise ValueError("增量上限需为非负整数") row.random_step_max = random_step_max if row.random_step_max < row.random_step_min: raise ValueError("增量上限不得小于下限") if real_offset is not None: if real_offset < 0: raise ValueError("保底值需为非负整数") row.real_offset = real_offset if allow_decrease is not None: row.allow_decrease = allow_decrease now = datetime.now(timezone.utc) if random_initial is not None: if random_initial < 0: raise ValueError("初始基数需为非负整数") # 初始基数受「只增不减」护栏(与 manual/real 一致):未勾 allow_decrease 且低于 # 当前展示值时保持现值。首次无值(random_current is None)则护栏放行、直接设。 row.random_current = _monotonic(row, random_initial) row.random_last_tick_at = now elif apply_now: # 立即更新:不等钟点,马上刷新一次。 # random:走一档增长;real/manual:直接落到「配置目标值」(manual_value / max(真实,保底)), # **显式保存即所见即所得,绕过「只增不减」护栏**——运营手动改值就是要按配置显示(含调小)。 # (护栏仍作用于自动 tick 的 _refresh:防门面在两次保存之间被真实刷新/自增长悄悄缩水。) if row.mode == "random" and row.random_current is not None: row.random_current = _grow(row, row.random_current, 1) else: row.random_current = _current_target(db, row) row.random_last_tick_at = now elif row.random_current is None: # 首次无值:按当前模式播种,使配置后立即有合理展示值 row.random_current = _current_target(db, row) row.random_last_tick_at = now row.updated_by_admin_id = admin_id if commit: db.commit() db.refresh(row) else: db.flush() return before, _snapshot(row)