cfeacb4bab
- 新表 platform_stat_display:每个指标可选 real/manual/random 三种 展示模式,含建表 + anchor_minutes 两个 alembic migration - 公开接口 GET /api/v1/platform/stats(无鉴权门面数字,登录前可读) - 运营后台 GET/PATCH /admin/api/dashboard-display 配置展示模式 - 配套 model/repository/schema,注册 router(app/main+admin/main), 导出 model,补 docs(api/database 索引及 3 篇详情) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。
|
|
|
|
三种模式见 app/models/platform_stat.py。核心是 random「只增不减」的惰性 tick:
|
|
读取时若距上次 tick 超过 tick 周期,就按经过的整数个周期各乘一次随机倍率([min,max]/1000)。
|
|
倍率恒 ≥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.platform_stat import PlatformStatDisplay
|
|
|
|
# 指标顺序 + 展示元信息(label/unit 给运营后台用;total_saved 单位是分,客户端 ÷100 显示元)
|
|
METRICS = ("help_users", "total_compares", "total_saved")
|
|
_META = {
|
|
"help_users": ("帮助用户", "人"),
|
|
"total_compares": ("完成比价", "次"),
|
|
"total_saved": ("累计节省", "分"),
|
|
}
|
|
# 初始播种值(= 客户端原写死门面数:12847 人 / 86532 次 / 37621.4 元)。
|
|
# migration 与 _ensure_rows 都用它,保证上线前后展示一致,运营再按需切模式。
|
|
_SEED_VALUE = {
|
|
"help_users": 12847,
|
|
"total_compares": 86532,
|
|
"total_saved": 3762140, # 37621.40 元 = 3762140 分
|
|
}
|
|
|
|
# 防呆边界
|
|
_MULT_FLOOR = 1000 # 倍率千分比下限 = 1.000(只增不减)
|
|
_MULT_CAP = 5000 # 倍率千分比上限 = 5.000(防误填)
|
|
_TICK_MIN_SECONDS = 60 # tick 周期下限
|
|
_MAX_CATCHUP_PERIODS = 3650 # 单次最多补乘的周期数(防 last 远古时一次乘爆)
|
|
_BEIJING_OFFSET = 8 * 3600 # 北京时间 = UTC + 8h(钟点对齐按北京时间算)
|
|
|
|
|
|
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":
|
|
return db.execute(
|
|
select(func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0)).where(success)
|
|
).scalar_one()
|
|
raise KeyError(f"unknown metric: {metric}")
|
|
|
|
|
|
# ===== 行管理 =====
|
|
def _ensure_rows(db: Session) -> dict[str, PlatformStatDisplay]:
|
|
"""保证三指标行都存在(migration 已播种;这里是缺行兜底,默认 manual + 种子值)。"""
|
|
rows = {r.metric: r for r in db.execute(select(PlatformStatDisplay)).scalars().all()}
|
|
created = False
|
|
for metric in METRICS:
|
|
if metric not in rows:
|
|
row = PlatformStatDisplay(
|
|
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)。
|
|
|
|
例:interval=86400(天)、anchor=32400(9h) → 边界在每天北京 09:00;
|
|
interval=3600(时)、anchor=1800(30min) → 每小时 :30;interval=300、anchor=0 → 每 5 分钟刻度。
|
|
"""
|
|
bj = epoch_utc + _BEIJING_OFFSET
|
|
return (bj - anchor_sec) // interval
|
|
|
|
|
|
def _current_target(db: Session, row: PlatformStatDisplay) -> int:
|
|
"""某模式此刻「应有的展示值」(用于播种 / real·manual 的定时快照)。"""
|
|
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)))
|
|
# random:无显式初始基数时用真实值播种
|
|
return max(0, int(_real_value(db, row.metric)))
|
|
|
|
|
|
def _refresh(db: Session, row: PlatformStatDisplay) -> 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":
|
|
periods = min(periods, _MAX_CATCHUP_PERIODS)
|
|
lo = max(_MULT_FLOOR, row.random_mult_min)
|
|
hi = max(lo, row.random_mult_max)
|
|
val = row.random_current
|
|
for _ in range(periods):
|
|
val = val * random.randint(lo, hi) // 1000
|
|
row.random_current = val
|
|
else:
|
|
# real / manual:跨多少边界都只取「此刻应有的值」快照一次
|
|
row.random_current = _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: PlatformStatDisplay) -> 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_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 顺序。
|
|
|
|
先跑一次定时刷新,使「当前展示值」在后台轮询时也随钟点实时推进
|
|
(否则只有用户侧 /stats 被调用才会推进)。
|
|
"""
|
|
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_initial: int | None = None,
|
|
apply_now: bool = False,
|
|
admin_id: int,
|
|
commit: bool = True,
|
|
) -> tuple[dict, dict]:
|
|
"""改某指标配置。返回 (before, after) 快照供审计。非法入参抛 ValueError(router 转 400)。
|
|
|
|
展示值 random_current 采用统一「定时刷新」:平时不动,到更新钟点才按模式刷新
|
|
(real 快照查库 / manual 取固定值 / random ×倍率)。本函数只在「首次无值」时按模式播种;
|
|
给了 random_initial 则立即设为该值并重置(用于自增长设起点)。其余改动到下个更新钟点才生效。
|
|
"""
|
|
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:
|
|
# 更新时间 = 每日时刻(距 0 点分钟数,0~1439);sub-day 间隔取 mod 间隔作为相位(见 _refresh)
|
|
if not (0 <= random_anchor_minutes < 1440):
|
|
raise ValueError("更新时间需在 00:00~23:59 之间")
|
|
row.random_anchor_minutes = random_anchor_minutes
|
|
|
|
now = datetime.now(timezone.utc)
|
|
if random_initial is not None:
|
|
if random_initial < 0:
|
|
raise ValueError("初始基数需为非负整数")
|
|
row.random_current = random_initial
|
|
row.random_last_tick_at = now
|
|
elif apply_now:
|
|
# 立即更新:不等钟点,马上把展示值刷新一次。random 在现值上 ×一档(无值则播种),real/manual 取此刻应有值。
|
|
if row.mode == "random" and row.random_current is not None:
|
|
lo = max(_MULT_FLOOR, row.random_mult_min)
|
|
hi = max(lo, row.random_mult_max)
|
|
row.random_current = row.random_current * random.randint(lo, hi) // 1000
|
|
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)
|