bce8cd9923
一、RBAC 权限管理(角色 → 可见页面) - 新增 admin_role 表 + 权限目录 permissions.py:角色持有一组页面 key,登录后左侧只展示这些页 - 内建角色 管理员(super_admin 全权锁定)/运营/财务/技术,页集对齐原型;key 承重(require_role 用),另加中文 label 展示 - 角色 CRUD 端点(仅 super_admin)+ 目录端点;登录/me 下发当前角色有效可见页 - admins 加删除、角色存在性校验;可复看已确定登录密码:UI 建的账号留存明文 plain_password,脚本建的超管不留存 二、系统配置下发修复 - 首页数据「保存即生效」:显式保存(apply_now)对 real/manual 直接落配置目标值、绕过只增不减护栏(修「改了 app 端不变」),护栏仍管自动 tick - 首页轮播新增数据源三选一:mixed(真实优先+种子)/real(只真实)/seed(只种子),get_feed 分支 + /marquee-seeds/mode 端点 - 福利页 Tab 隐藏 任务/里程碑,及看广告的单次金币/每轮次数/信息流开关:CONFIG_DEFS 加 hidden + list_config 过滤,业务读取默认值不受影响 三、比价记录店/商品搜索 - comparison_record 加 product_names 派生列(从下单 items 拼商品名),迁移建列并回填历史行 - admin 比价记录列表店/商品分列可搜:走 product_names 普通列 LIKE,规避 SQLite JSON 中文 ensure_ascii 转义搜不到的坑 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
342 lines
15 KiB
Python
342 lines
15 KiB
Python
"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。
|
|
|
|
三种模式见 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)
|