feat(platform): 首页门面三统计 + 运营后台展示模式配置

- 新表 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>
This commit is contained in:
OuYingJun1024
2026-06-06 22:45:07 +08:00
parent f5d1a1a20d
commit cfeacb4bab
16 changed files with 726 additions and 1 deletions
@@ -0,0 +1,30 @@
"""platform_stat_display 增加 random_anchor_minutes(自增长触发时刻对齐偏移)
Revision ID: pstat2anchor01
Revises: pstat1d2e3f4a
Create Date: 2026-06-06 13:00:00.000000
自增长 tick 从「启动时刻 + 间隔」改为「北京时间钟点对齐(anchor + k*interval)」,
新增列存对齐偏移(距 0 点分钟数)。已有行默认 0(= 对齐到自然边界:天→0点、小时→整点)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'pstat2anchor01'
down_revision: Union[str, Sequence[str], None] = 'pstat1d2e3f4a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'platform_stat_display',
sa.Column('random_anchor_minutes', sa.Integer(), nullable=False, server_default='0'),
)
def downgrade() -> None:
op.drop_column('platform_stat_display', 'random_anchor_minutes')
@@ -0,0 +1,59 @@
"""platform_stat_display table (首页三统计展示配置:每指标独立 real/manual/random)
Revision ID: pstat1d2e3f4a
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
播种 3 行:默认 manual + 客户端原写死门面值(12847 人 / 86532 次 / 37621.4 元),
保证上线前后展示一致,运营再按需切模式。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'pstat1d2e3f4a'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
table = op.create_table(
'platform_stat_display',
sa.Column('metric', sa.String(length=32), nullable=False),
sa.Column('mode', sa.String(length=16), nullable=False),
sa.Column('manual_value', sa.Integer(), nullable=True),
sa.Column('random_mult_min', sa.Integer(), nullable=False),
sa.Column('random_mult_max', sa.Integer(), nullable=False),
sa.Column('random_tick_seconds', sa.Integer(), nullable=False),
sa.Column('random_current', sa.Integer(), nullable=True),
sa.Column('random_last_tick_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_by_admin_id', sa.Integer(), nullable=True),
sa.Column(
'updated_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.PrimaryKeyConstraint('metric'),
)
_common = {
'mode': 'manual',
'random_mult_min': 1000, # 1.000(只增不减下限)
'random_mult_max': 1100, # 1.100
'random_tick_seconds': 86400, # 1 天
}
op.bulk_insert(
table,
[
{'metric': 'help_users', 'manual_value': 12847, **_common},
{'metric': 'total_compares', 'manual_value': 86532, **_common},
{'metric': 'total_saved', 'manual_value': 3762140, **_common}, # 37621.40 元
],
)
def downgrade() -> None:
op.drop_table('platform_stat_display')
+2
View File
@@ -18,6 +18,7 @@ from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
from app.admin.routers.config import router as config_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.dashboard_display import router as dashboard_display_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_router
@@ -73,6 +74,7 @@ def health() -> dict[str, str]:
admin_app.include_router(auth_router)
admin_app.include_router(dashboard_router)
admin_app.include_router(dashboard_display_router)
admin_app.include_router(users_router)
admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
+69
View File
@@ -0,0 +1,69 @@
"""admin 首页三统计展示配置:列出三指标配置(+ 真实值预览)+ 改某指标(带审计 + 校验)。
每指标可独立选 real/manual/random。计算逻辑见 app/repositories/platform_stat.py。
权限:operator 可改(运营调门面数字),super 恒可。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, 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.dashboard_display import (
PlatformStatItemOut,
PlatformStatUpdateRequest,
)
from app.models.admin import AdminUser
from app.repositories import platform_stat
router = APIRouter(
prefix="/admin/api/dashboard-display",
tags=["admin-dashboard-display"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=list[PlatformStatItemOut], summary="首页三统计配置 + 真实值预览")
def list_display(db: AdminDb) -> list[PlatformStatItemOut]:
return [PlatformStatItemOut(**item) for item in platform_stat.get_config(db)]
@router.patch(
"/{metric}", response_model=PlatformStatItemOut, summary="改某指标展示配置(带审计)"
)
def update_display(
metric: str,
body: PlatformStatUpdateRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> PlatformStatItemOut:
try:
before, after = platform_stat.update_config(
db,
metric,
mode=body.mode,
manual_value=body.manual_value,
random_mult_min=body.random_mult_min,
random_mult_max=body.random_mult_max,
random_tick_seconds=body.random_tick_seconds,
random_anchor_minutes=body.random_anchor_minutes,
random_initial=body.random_initial,
apply_now=body.apply_now,
admin_id=admin.id,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="dashboard_display.set", target_type="platform_stat",
target_id=metric, detail={"before": before, "after": after},
ip=get_client_ip(request), commit=False,
)
db.commit()
item = next(it for it in platform_stat.get_config(db) if it["metric"] == metric)
return PlatformStatItemOut(**item)
+38
View File
@@ -0,0 +1,38 @@
"""admin 首页三统计展示配置 schemas。
倍率用千分比整数(1.000→1000);total_saved 的 manual_value / random_current 单位是分。
"""
from __future__ import annotations
from pydantic import BaseModel
class PlatformStatItemOut(BaseModel):
metric: str # help_users / total_compares / total_saved
label: str # 帮助用户 / 完成比价 / 累计节省
unit: str # 人 / 次 / 分
mode: str # real / manual / random
manual_value: int | None = None
random_mult_min: int
random_mult_max: int
random_tick_seconds: int
random_anchor_minutes: int
random_current: int | None = None
random_last_tick_at: str | None = None
real_value: int # 当前真实值(给运营对比参考)
updated_at: str | None = None
class PlatformStatUpdateRequest(BaseModel):
"""改某指标配置(均可选,只改传了的字段)。"""
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 时的初始基数;不传则用当前真实值播种
random_initial: int | None = None
# 立即更新:不等更新钟点,保存后马上把展示值刷新一次(real 快照/manual 生效/random ×一档)
apply_now: bool = False
+30
View File
@@ -0,0 +1,30 @@
"""首页平台级展示数据 endpoint(全平台门面数字,**不鉴权**——登录前首页也要展示)。
路由前缀 `/api/v1/platform`:
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/platform_stat.py。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from app.api.deps import DbSession
from app.repositories import platform_stat as crud
from app.schemas.platform import PlatformStatsOut
logger = logging.getLogger("shagua.platform")
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
@router.get("/stats", response_model=PlatformStatsOut, summary="首页三统计(全平台门面数字)")
def stats(db: DbSession) -> PlatformStatsOut:
v = crud.get_display_values(db)
return PlatformStatsOut(
help_users=v["help_users"],
total_compares=v["total_compares"],
total_saved_cents=v["total_saved"],
)
+2
View File
@@ -24,6 +24,7 @@ from app.api.internal.price import router as internal_price_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.order import router as order_router
from app.api.v1.platform import router as platform_router
from app.api.v1.report import router as report_router
from app.api.v1.savings import router as savings_router
from app.api.v1.signin import router as signin_router
@@ -93,6 +94,7 @@ app.include_router(order_router)
app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(platform_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
+1
View File
@@ -7,6 +7,7 @@ from app.models.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.platform_stat import PlatformStatDisplay # noqa: F401
from app.models.price_observation import PriceObservation # noqa: F401
from app.models.price_report import PriceReport # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
+61
View File
@@ -0,0 +1,61 @@
"""首页三统计展示配置表(帮助用户 / 完成比价 / 累计节省)。
客户端首页顶部三个门面数字,每个指标可独立选 3 种展示模式(运营后台配):
- real : 实时查真实数据(help_users=去重成功比价用户数 / total_compares=成功比价数 /
total_saved=成功比价省额求和)
- manual : 直接用运营手填的固定值
- random : 只增不减的伪增长读取时惰性 tick:距上次 tick 超过 random_tick_seconds
就把 random_current 乘以一个 [min,max] 内的随机倍率(倍率恒 1.0 只增不减)
数值单位约定(三指标都用各自基础单位的整数存,避免浮点):
- help_users / total_compares : 个数( / )
- total_saved : (cents);客户端 ÷100 显示
倍率用千分比整数存:1.00010001.1001100,读时 factor = randint(min,max)/1000
一行一指标,主键 = metric初始由 migration 播种 3 (默认 manual + 当前写死门面值,
保证上线无视觉变化),运营再按需切模式
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PlatformStatDisplay(Base):
__tablename__ = "platform_stat_display"
# help_users / total_compares / total_saved
metric: Mapped[str] = mapped_column(String(32), primary_key=True)
# real / manual / random(每指标独立)
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="real")
# manual 模式固定值(基础单位整数;total_saved 为分)
manual_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
# random 模式:倍率区间(千分比,≥1000 保证只增不减)+ tick 周期 + 当前值 + 上次 tick 时刻
random_mult_min: Mapped[int] = mapped_column(Integer, nullable=False, default=1000)
random_mult_max: Mapped[int] = mapped_column(Integer, nullable=False, default=1100)
random_tick_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=86400)
# 触发时刻对齐偏移:距北京时间 0 点的分钟数。按单位:天=每日时刻(h*60+m)、小时=每小时第几分、
# 分钟=0。刷新在北京时间 (anchor + k*interval) 的钟点边界触发(见 repo _refresh)。
random_anchor_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
random_current: Mapped[int | None] = mapped_column(Integer, nullable=True)
random_last_tick_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
updated_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return f"<PlatformStatDisplay metric={self.metric} mode={self.mode}>"
+278
View File
@@ -0,0 +1,278 @@
"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。
三种模式见 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=300anchor=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)
+12
View File
@@ -0,0 +1,12 @@
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
from __future__ import annotations
from pydantic import BaseModel
class PlatformStatsOut(BaseModel):
"""首页三统计。total_saved 为分,客户端 ÷100 显示「元」。"""
help_users: int # 帮助用户(人)
total_compares: int # 完成比价(次)
total_saved_cents: int # 累计节省(分)
+4
View File
@@ -66,6 +66,8 @@
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
| **首页门面数据**(前缀 `/api/v1/platform`;全平台展示数字,登录前可读) |||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
| **静态资源**StaticFiles 挂载,见下方 `/media` 静态服务) |||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
@@ -88,6 +90,8 @@
| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+61
View File
@@ -0,0 +1,61 @@
# Admin 首页数据配置 — 三统计展示模式
> 所属:Admin 组(前缀 `/admin/api/dashboard-display` | 鉴权:Admin Bearer(改需 operator/super | [← 返回 API 索引](./README.md)
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [platform_stat_display](../database/platform_stat_display.md)。
> **单位约定**:倍率用**千分比整数**(1.000→`1000`、1.100→`1100`)。`total_saved``manual_value` / `random_current` / `random_initial` 单位是**分**(前端展示时 ÷100 取元)。
---
## GET /admin/api/dashboard-display — 三指标配置 + 真实值预览
### 出参
响应 `200`:`list[PlatformStatItemOut]`(按 help_users / total_compares / total_saved 顺序)
| 字段 | 类型 | 说明 |
|---|---|---|
| `metric` | string | `help_users` / `total_compares` / `total_saved` |
| `label` | string | 帮助用户 / 完成比价 / 累计节省 |
| `unit` | string | 人 / 次 / 分 |
| `mode` | string | `real` / `manual` / `random` |
| `manual_value` | int \| null | manual 固定值(基础单位) |
| `random_mult_min` | int | 倍率下限(千分比,≥1000) |
| `random_mult_max` | int | 倍率上限(千分比) |
| `random_tick_seconds` | int | 更新间隔(秒,默认 86400=1 天) |
| `random_anchor_minutes` | int | 触发时刻对齐偏移(距北京 0 点分钟数:天=每日时刻、小时=每小时第几分、分钟=0) |
| `random_current` | int \| null | random 当前累积值(基础单位) |
| `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO |
| `real_value` | int | 当前真实值(给运营对比参考,基础单位) |
| `updated_at` | string \| null | 上次修改时间 |
---
## PATCH /admin/api/dashboard-display/{metric} — 改某指标(带审计)
`metric` 路径参数取 `help_users` / `total_compares` / `total_saved`
### 入参 `PlatformStatUpdateRequest`(字段均可选,只改传了的)
| 字段 | 类型 | 说明 |
|---|---|---|
| `mode` | string | `real` / `manual` / `random` |
| `manual_value` | int | manual 固定值(基础单位,≥0) |
| `random_mult_min` | int | 倍率下限(千分比,1000~5000 |
| `random_mult_max` | int | 倍率上限(千分比,≥下限) |
| `random_tick_seconds` | int | 更新间隔(秒,≥60) |
| `random_anchor_minutes` | int | 触发时刻对齐偏移(分钟,需 `*60 < 更新间隔` |
| `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** |
### 出参
响应 `200`:更新后的 `PlatformStatItemOut`(同上)。
### 错误
- `400`:mode 非法 / 倍率越界(<1000 或 >5000/ 上限<下限 / tick<60 / 负值。
- `403`:角色不足(需 operator 或 super_admin)。
- `404`:无效 metric(不在三指标内,作为 400 文案返回)。
### 说明
- **统一定时刷新**:三模式的展示值(`random_current`)只在「更新时间(按更新间隔)」对齐的北京钟点刷新一次——real 快照查库 / manual 取固定值 / random ×倍率。`random_tick_seconds`+`random_anchor_minutes` 三模式通用。
- 改 mode/manual/倍率等到**下个更新钟点**才在客户端生效(展示值不立即变);`random_initial` 例外:立即设为该值并重置(自增长设起点)。首次无展示值时按当前模式播种。
- 每次改动写 `admin_audit_log`(action=`dashboard_display.set`,detail 含改前/改后快照)。
- 客户端在**进首页 / 回前台时**重拉 `/stats` 取最新展示值。
+27
View File
@@ -0,0 +1,27 @@
# GET /api/v1/platform/stats — 首页三统计(全平台门面数字)
> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无**(登录前首页也要展示) | [← 返回 API 索引](./README.md)
客户端首页顶部「帮助用户 / 完成比价 / 累计节省」三个平台级数字。每个指标的展示模式由运营后台配(见 [admin-dashboard-display](./admin-dashboard-display.md)),本接口只返回算好的结果值。
## 入参
无。
## 出参
响应 `200`:`PlatformStatsOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `help_users` | int | 帮助用户(人) |
| `total_compares` | int | 完成比价(次) |
| `total_saved_cents` | int | 累计节省(**分**),客户端 ÷100 显示「元」 |
## 说明
三指标各自独立选模式,且**统一「定时刷新」**:客户端看到的展示值只在「更新时间(按更新间隔)」对齐的北京钟点(`anchor + k*间隔`)刷新一次,平时不变。每次刷新按模式算新值:
- **real**:重新快照查库。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和。
- **manual**:取运营当前手填的固定值(改值到下个更新钟点生效)。
- **random**:在上次展示值上 ×一个 [min,max](≥1.0)的随机倍率,只增不减(跨几个钟点乘几次)。
计算逻辑见 `app/repositories/platform_stat.py`,模型见 `app/models/platform_stat.py`(表 [platform_stat_display](../database/platform_stat_display.md))。
上线初始播种为 manual + 客户端原写死门面值(12847 / 86532 / 37621.40 元),保证前后展示一致。
+6 -1
View File
@@ -9,7 +9,7 @@
---
## 表总览(19 张业务表 + `alembic_version` 框架表)
## 表总览(20 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -39,6 +39,11 @@
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
### 首页门面数据
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `platform_stat_display` | 首页三统计展示配置(real/manual/random) | `models/platform_stat.py` | [详情](./platform_stat_display.md) |
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
+46
View File
@@ -0,0 +1,46 @@
# platform_stat_display — 首页三统计展示配置
> 模型 `app/models/platform_stat.py` | 关联接口 [platform-stats](../api/platform-stats.md) / [admin-dashboard-display](../api/admin-dashboard-display.md) | [← 表索引](./README.md)
客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示配置。一行一指标(主键=`metric`),每指标可独立选 real/manual/random 三种模式。计算逻辑见 `app/repositories/platform_stat.py`
> **单位**:倍率用**千分比整数**(1.000→`1000`);`total_saved` 指标的 `manual_value` / `random_current` 单位是**分**,两个计数指标是**个数**。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `metric` | String(32) | PK | `help_users` / `total_compares` / `total_saved` |
| `mode` | String(16) | NOT NULL, default `real` | `real` 查库 / `manual` 手填 / `random` 随机增长 |
| `manual_value` | Integer | nullable | manual 模式固定值(基础单位) |
| `random_mult_min` | Integer | NOT NULL, default 1000 | 倍率下限(千分比,≥1000 保证只增不减) |
| `random_mult_max` | Integer | NOT NULL, default 1100 | 倍率上限(千分比) |
| `random_tick_seconds` | Integer | NOT NULL, default 86400 | 更新间隔(秒) |
| `random_anchor_minutes` | Integer | NOT NULL, default 0 | 触发时刻对齐偏移(距北京 0 点分钟数):天=每日时刻 h*60+m、小时=每小时第几分、分钟=0 |
| `random_current` | Integer | nullable | random 当前累积值(基础单位);惰性 tick 改写 |
| `random_last_tick_at` | DateTime(tz) | nullable | 上次 tick 时刻 |
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 |
## 初始数据(migration 播种)
三行,默认 `mode='manual'` + 客户端原写死门面值,保证上线前后展示一致:
| metric | manual_value | 含义 |
|---|---|---|
| `help_users` | 12847 | 12847 人 |
| `total_compares` | 86532 | 86532 次 |
| `total_saved` | 3762140 | 37621.40 元 |
## 统一「定时刷新」(北京钟点对齐)
`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` = 重新查库的真实值(跨多少边界都只取最新)。
- **manual**`random_current` = 当前 `manual_value`
- **random**`random_current` 连乘 N 次随机倍率(`randint(min,max)/1000`,恒 ≥1.0 只增不减)。
- 用边界索引比较,`random_last_tick_at` 直接记 `now`,不重复计。
- ⚠️ **接受刷新时刻的微小并发竞态**(不加行锁):纯门面数字,无业务后果。
- `random_current` 首次为空时按当前模式播种(`update_config``_refresh` 兜底);`random_initial` 可立即设起点。
## 说明
- real 口径仅统计 `comparison_record``status='success'` 的记录(去重用户数 / 记录数 / `saved_amount_cents` 求和)。
- 改配置走 admin `PATCH /admin/api/dashboard-display/{metric}`,带审计。