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:
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.000→1000、1.100→1100,读时 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}>"
|
||||
@@ -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=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)
|
||||
@@ -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 # 累计节省(分)
|
||||
@@ -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` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
|
||||
@@ -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` 取最新展示值。
|
||||
@@ -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 元),保证前后展示一致。
|
||||
@@ -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/`,独立鉴权)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -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}`,带审计。
|
||||
Reference in New Issue
Block a user