From cfeacb4bab0b9e2f6454e02ea28358ac43c1d141 Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Sat, 6 Jun 2026 22:45:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E9=A6=96=E9=A1=B5=E9=97=A8?= =?UTF-8?q?=E9=9D=A2=E4=B8=89=E7=BB=9F=E8=AE=A1=20+=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=BC=8F=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新表 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) --- .../versions/platform_stat_anchor_minutes.py | 30 ++ .../versions/platform_stat_display_table.py | 59 ++++ app/admin/main.py | 2 + app/admin/routers/dashboard_display.py | 69 +++++ app/admin/schemas/dashboard_display.py | 38 +++ app/api/v1/platform.py | 30 ++ app/main.py | 2 + app/models/__init__.py | 1 + app/models/platform_stat.py | 61 ++++ app/repositories/platform_stat.py | 278 ++++++++++++++++++ app/schemas/platform.py | 12 + docs/api/README.md | 4 + docs/api/admin-dashboard-display.md | 61 ++++ docs/api/platform-stats.md | 27 ++ docs/database/README.md | 7 +- docs/database/platform_stat_display.md | 46 +++ 16 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/platform_stat_anchor_minutes.py create mode 100644 alembic/versions/platform_stat_display_table.py create mode 100644 app/admin/routers/dashboard_display.py create mode 100644 app/admin/schemas/dashboard_display.py create mode 100644 app/api/v1/platform.py create mode 100644 app/models/platform_stat.py create mode 100644 app/repositories/platform_stat.py create mode 100644 app/schemas/platform.py create mode 100644 docs/api/admin-dashboard-display.md create mode 100644 docs/api/platform-stats.md create mode 100644 docs/database/platform_stat_display.md diff --git a/alembic/versions/platform_stat_anchor_minutes.py b/alembic/versions/platform_stat_anchor_minutes.py new file mode 100644 index 0000000..be07da4 --- /dev/null +++ b/alembic/versions/platform_stat_anchor_minutes.py @@ -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') diff --git a/alembic/versions/platform_stat_display_table.py b/alembic/versions/platform_stat_display_table.py new file mode 100644 index 0000000..4a640fe --- /dev/null +++ b/alembic/versions/platform_stat_display_table.py @@ -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') diff --git a/app/admin/main.py b/app/admin/main.py index c5fda29..3862566 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -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) diff --git a/app/admin/routers/dashboard_display.py b/app/admin/routers/dashboard_display.py new file mode 100644 index 0000000..2f18dad --- /dev/null +++ b/app/admin/routers/dashboard_display.py @@ -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) diff --git a/app/admin/schemas/dashboard_display.py b/app/admin/schemas/dashboard_display.py new file mode 100644 index 0000000..1814553 --- /dev/null +++ b/app/admin/schemas/dashboard_display.py @@ -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 diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py new file mode 100644 index 0000000..8ecfdb4 --- /dev/null +++ b/app/api/v1/platform.py @@ -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"], + ) diff --git a/app/main.py b/app/main.py index b718e3c..751a5d8 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index b0f2bba..b8055b0 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/platform_stat.py b/app/models/platform_stat.py new file mode 100644 index 0000000..a9aa130 --- /dev/null +++ b/app/models/platform_stat.py @@ -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"" diff --git a/app/repositories/platform_stat.py b/app/repositories/platform_stat.py new file mode 100644 index 0000000..567314f --- /dev/null +++ b/app/repositories/platform_stat.py @@ -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) diff --git a/app/schemas/platform.py b/app/schemas/platform.py new file mode 100644 index 0000000..b978135 --- /dev/null +++ b/app/schemas/platform.py @@ -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 # 累计节省(分) diff --git a/docs/api/README.md b/docs/api/README.md index 8a97204..4f7e3da 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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/` | 无 | 用户头像;返回二进制图片 | | - | `GET /media/feedback/` | 无 | 反馈截图;返回二进制图片 | @@ -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` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/admin-dashboard-display.md b/docs/api/admin-dashboard-display.md new file mode 100644 index 0000000..fbd9711 --- /dev/null +++ b/docs/api/admin-dashboard-display.md @@ -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` 取最新展示值。 diff --git a/docs/api/platform-stats.md b/docs/api/platform-stats.md new file mode 100644 index 0000000..21aaf30 --- /dev/null +++ b/docs/api/platform-stats.md @@ -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 元),保证前后展示一致。 diff --git a/docs/database/README.md b/docs/database/README.md index af93c25..1b43569 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -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/`,独立鉴权) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/platform_stat_display.md b/docs/database/platform_stat_display.md new file mode 100644 index 0000000..7928eb2 --- /dev/null +++ b/docs/database/platform_stat_display.md @@ -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}`,带审计。