refactor(ops): 运营可配代码统一 ops_ 前缀(模型/表/repo/admin schema·router)

把首页门面数据的「运营可配」部分代码统一加 ops_ 前缀,与运营语义对齐:
- 模型 PlatformStatDisplay→OpsStatConfig(表 platform_stat_display→ops_stat_config)、
  MarqueeSeed→OpsMarqueeSeed(表 marquee_seed→ops_marquee_seed)
- repo platform_stat→ops_stat、marquee→ops_marquee
- admin schema/router dashboard_display→ops_stat_config、marquee_seed→ops_marquee_seed(schema 类 Ops*)
- 审计 action/target_type 同步成 ops_*
- 引用方(models/__init__、admin/main、api/v1/platform)更 import;platform 用户侧门面保留命名
- 迁移 opsrename01 仅改表名(可逆);路由 URL 与 API 文档文件名不变(前端/客户端无感)
- 文档:db 文档重命名 + API 文档/索引同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-06-07 23:07:53 +08:00
parent 356d6e52b9
commit 179303d875
19 changed files with 153 additions and 124 deletions
+28
View File
@@ -0,0 +1,28 @@
"""运营可配表加 ops_ 前缀:platform_stat_display→ops_stat_config / marquee_seed→ops_marquee_seed
把首页门面数据的两张「运营可配」表重命名,与代码侧 ops_ 前缀(模型 OpsStatConfig/OpsMarqueeSeed、
repo ops_stat/ops_marquee、admin ops_stat_config/ops_marquee_seed)对齐。仅改表名,不动列与数据。
Revision ID: opsrename01
Revises: coin_reward_phase1
Create Date: 2026-06-07 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = 'opsrename01'
down_revision: Union[str, Sequence[str], None] = 'coin_reward_phase1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.rename_table('platform_stat_display', 'ops_stat_config')
op.rename_table('marquee_seed', 'ops_marquee_seed')
def downgrade() -> None:
op.rename_table('ops_stat_config', 'platform_stat_display')
op.rename_table('ops_marquee_seed', 'marquee_seed')
+4 -4
View File
@@ -18,9 +18,9 @@ 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.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.marquee_seed import router as marquee_seed_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_router
@@ -75,8 +75,8 @@ 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(marquee_seed_router)
admin_app.include_router(ops_stat_config_router)
admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
@@ -1,6 +1,6 @@
"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。
种子用于首页轮播真实+种子混播的兜底( app/repositories/marquee.py)
种子用于首页轮播真实+种子混播的兜底( app/repositories/ops_marquee.py)
权限:operator 可改,super 恒可;预览为只读(任意已登录 admin)
"""
from __future__ import annotations
@@ -11,16 +11,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query, 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.marquee_seed import (
MarqueeSeedBulkCreate,
MarqueeSeedCreate,
MarqueeSeedOut,
MarqueeSeedUpdate,
SavingsFeedPreviewOut,
from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedBulkCreate,
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
OpsMarqueeSeedUpdate,
OpsSavingsFeedPreviewOut,
)
from app.models.admin import AdminUser
from app.models.marquee_seed import MarqueeSeed
from app.repositories import marquee
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.repositories import ops_marquee
router = APIRouter(
prefix="/admin/api/marquee-seeds",
@@ -29,8 +29,8 @@ router = APIRouter(
)
def _out(seed: MarqueeSeed) -> MarqueeSeedOut:
return MarqueeSeedOut(
def _out(seed: OpsMarqueeSeed) -> OpsMarqueeSeedOut:
return OpsMarqueeSeedOut(
id=seed.id,
masked_user=seed.masked_user,
min_cents=seed.min_cents,
@@ -41,29 +41,29 @@ def _out(seed: MarqueeSeed) -> MarqueeSeedOut:
)
@router.get("", response_model=list[MarqueeSeedOut], summary="轮播种子列表")
def list_seeds(db: AdminDb) -> list[MarqueeSeedOut]:
return [_out(s) for s in marquee.list_seeds(db)]
@router.get("", response_model=list[OpsMarqueeSeedOut], summary="轮播种子列表")
def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
return [_out(s) for s in ops_marquee.list_seeds(db)]
@router.get("/preview", response_model=SavingsFeedPreviewOut, summary="预览实际混播 feed")
@router.get("/preview", response_model=OpsSavingsFeedPreviewOut, summary="预览实际混播 feed")
def preview_feed(
db: AdminDb,
limit: Annotated[int, Query(ge=1, le=30)] = 8,
) -> SavingsFeedPreviewOut:
) -> OpsSavingsFeedPreviewOut:
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
return SavingsFeedPreviewOut(items=marquee.get_feed(db, limit=limit))
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
@router.post("", response_model=MarqueeSeedOut, summary="新增轮播种子(带审计)")
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
def create_seed(
body: MarqueeSeedCreate,
body: OpsMarqueeSeedCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> MarqueeSeedOut:
) -> OpsMarqueeSeedOut:
try:
seed = marquee.create_seed(
seed = ops_marquee.create_seed(
db,
masked_user=body.masked_user,
min_cents=body.min_cents,
@@ -75,7 +75,7 @@ def create_seed(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="marquee_seed.create", target_type="marquee_seed",
db, admin, action="ops_marquee_seed.create", target_type="ops_marquee_seed",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
@@ -83,15 +83,15 @@ def create_seed(
return _out(seed)
@router.post("/bulk", response_model=list[MarqueeSeedOut], summary="批量生成种子(带审计)")
@router.post("/bulk", response_model=list[OpsMarqueeSeedOut], summary="批量生成种子(带审计)")
def bulk_generate(
body: MarqueeSeedBulkCreate,
body: OpsMarqueeSeedBulkCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> list[MarqueeSeedOut]:
) -> list[OpsMarqueeSeedOut]:
try:
seeds = marquee.bulk_generate(
seeds = ops_marquee.bulk_generate(
db,
count=body.count,
min_cents=body.min_cents,
@@ -102,7 +102,7 @@ def bulk_generate(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="marquee_seed.bulk_generate", target_type="marquee_seed",
db, admin, action="ops_marquee_seed.bulk_generate", target_type="ops_marquee_seed",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
@@ -111,16 +111,16 @@ def bulk_generate(
return [_out(s) for s in seeds]
@router.patch("/{seed_id}", response_model=MarqueeSeedOut, summary="改轮播种子(带审计)")
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
def update_seed(
seed_id: int,
body: MarqueeSeedUpdate,
body: OpsMarqueeSeedUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> MarqueeSeedOut:
) -> OpsMarqueeSeedOut:
try:
seed = marquee.update_seed(
seed = ops_marquee.update_seed(
db,
seed_id,
masked_user=body.masked_user,
@@ -133,7 +133,7 @@ def update_seed(
except ValueError as e:
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e)) from e
write_audit(
db, admin, action="marquee_seed.update", target_type="marquee_seed",
db, admin, action="ops_marquee_seed.update", target_type="ops_marquee_seed",
target_id=seed_id, detail=body.model_dump(exclude_none=True),
ip=get_client_ip(request), commit=False,
)
@@ -149,11 +149,11 @@ def delete_seed(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
ok = marquee.delete_seed(db, seed_id, commit=False)
ok = ops_marquee.delete_seed(db, seed_id, commit=False)
if not ok:
raise HTTPException(status_code=404, detail="种子不存在")
write_audit(
db, admin, action="marquee_seed.delete", target_type="marquee_seed",
db, admin, action="ops_marquee_seed.delete", target_type="ops_marquee_seed",
target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False,
)
db.commit()
@@ -1,6 +1,6 @@
"""admin 首页三统计展示配置:列出三指标配置(+ 真实值预览)+ 改某指标(带审计 + 校验)。
每指标可独立选 real/manual/random计算逻辑见 app/repositories/platform_stat.py
每指标可独立选 real/manual/random计算逻辑见 app/repositories/ops_stat.py
权限:operator 可改(运营调门面数字),super 恒可
"""
from __future__ import annotations
@@ -11,12 +11,12 @@ 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.admin.schemas.ops_stat_config import (
OpsStatItemOut,
OpsStatUpdateRequest,
)
from app.models.admin import AdminUser
from app.repositories import platform_stat
from app.repositories import ops_stat
router = APIRouter(
prefix="/admin/api/dashboard-display",
@@ -25,23 +25,23 @@ router = APIRouter(
)
@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.get("", response_model=list[OpsStatItemOut], summary="首页三统计配置 + 真实值预览")
def list_display(db: AdminDb) -> list[OpsStatItemOut]:
return [OpsStatItemOut(**item) for item in ops_stat.get_config(db)]
@router.patch(
"/{metric}", response_model=PlatformStatItemOut, summary="改某指标展示配置(带审计)"
"/{metric}", response_model=OpsStatItemOut, summary="改某指标展示配置(带审计)"
)
def update_display(
metric: str,
body: PlatformStatUpdateRequest,
body: OpsStatUpdateRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> PlatformStatItemOut:
) -> OpsStatItemOut:
try:
before, after = platform_stat.update_config(
before, after = ops_stat.update_config(
db,
metric,
mode=body.mode,
@@ -64,11 +64,11 @@ def update_display(
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="dashboard_display.set", target_type="platform_stat",
db, admin, action="ops_stat_config.set", target_type="ops_stat_config",
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)
item = next(it for it in ops_stat.get_config(db) if it["metric"] == metric)
return OpsStatItemOut(**item)
@@ -7,7 +7,7 @@ from __future__ import annotations
from pydantic import BaseModel
class MarqueeSeedOut(BaseModel):
class OpsMarqueeSeedOut(BaseModel):
id: int
masked_user: str | None = None
min_cents: int
@@ -17,7 +17,7 @@ class MarqueeSeedOut(BaseModel):
created_at: str | None = None
class MarqueeSeedCreate(BaseModel):
class OpsMarqueeSeedCreate(BaseModel):
masked_user: str | None = None # 空 / 不传 → feed 随机合成脱敏名
min_cents: int
max_cents: int
@@ -25,7 +25,7 @@ class MarqueeSeedCreate(BaseModel):
sort_order: int = 0
class MarqueeSeedUpdate(BaseModel):
class OpsMarqueeSeedUpdate(BaseModel):
# masked_user:不传=不改;传空串=清空(改回随机合成);传值=固定该名
masked_user: str | None = None
min_cents: int | None = None
@@ -34,7 +34,7 @@ class MarqueeSeedUpdate(BaseModel):
sort_order: int | None = None
class MarqueeSeedBulkCreate(BaseModel):
class OpsMarqueeSeedBulkCreate(BaseModel):
"""批量生成 count 条自动合成名种子(冷启动铺量)。"""
count: int
min_cents: int
@@ -42,12 +42,12 @@ class MarqueeSeedBulkCreate(BaseModel):
enabled: bool = True
class SavingsFeedPreviewItem(BaseModel):
class OpsSavingsFeedPreviewItem(BaseModel):
masked_user: str
saved_amount_cents: int
time: str
class SavingsFeedPreviewOut(BaseModel):
class OpsSavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[SavingsFeedPreviewItem]
items: list[OpsSavingsFeedPreviewItem]
@@ -7,7 +7,7 @@ from __future__ import annotations
from pydantic import BaseModel
class PlatformStatItemOut(BaseModel):
class OpsStatItemOut(BaseModel):
metric: str # help_users / total_compares / total_saved
label: str # 帮助用户 / 完成比价 / 累计节省
unit: str # 人 / 次 / 分
@@ -28,7 +28,7 @@ class PlatformStatItemOut(BaseModel):
updated_at: str | None = None
class PlatformStatUpdateRequest(BaseModel):
class OpsStatUpdateRequest(BaseModel):
"""改某指标配置(均可选,只改传了的字段)。"""
mode: str | None = None
+3 -3
View File
@@ -3,7 +3,7 @@
路由前缀 `/api/v1/platform`:
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/platform_stat.py。
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
"""
from __future__ import annotations
@@ -12,8 +12,8 @@ import logging
from fastapi import APIRouter, Query
from app.api.deps import DbSession
from app.repositories import marquee as marquee_crud
from app.repositories import platform_stat as crud
from app.repositories import ops_marquee as marquee_crud
from app.repositories import ops_stat as crud
from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut
logger = logging.getLogger("shagua.platform")
+2 -2
View File
@@ -8,8 +8,8 @@ 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.marquee_seed import MarqueeSeed # noqa: F401
from app.models.platform_stat import PlatformStatDisplay # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # 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
@@ -21,8 +21,8 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class MarqueeSeed(Base):
__tablename__ = "marquee_seed"
class OpsMarqueeSeed(Base):
__tablename__ = "ops_marquee_seed"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名)
@@ -39,4 +39,4 @@ class MarqueeSeed(Base):
)
def __repr__(self) -> str: # pragma: no cover
return f"<MarqueeSeed id={self.id} user={self.masked_user}>"
return f"<OpsMarqueeSeed id={self.id} user={self.masked_user}>"
@@ -26,8 +26,8 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PlatformStatDisplay(Base):
__tablename__ = "platform_stat_display"
class OpsStatConfig(Base):
__tablename__ = "ops_stat_config"
# help_users / total_compares / total_saved
metric: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -67,4 +67,4 @@ class PlatformStatDisplay(Base):
)
def __repr__(self) -> str: # pragma: no cover
return f"<PlatformStatDisplay metric={self.metric} mode={self.mode}>"
return f"<OpsStatConfig metric={self.metric} mode={self.mode}>"
@@ -1,6 +1,6 @@
"""首页轮播 feed:全平台真实比价记录 + 种子兜底「混播」,以及种子表 CRUD。
feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 marquee_seed 的种子补齐,
feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 ops_marquee_seed 的种子补齐,
保证轮播永不空真实够时种子不出现
种子是生成规则:用户名可空(随机合成脱敏名,避开同屏撞名)金额是区间(每次随机取值)
@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.marquee_seed import MarqueeSeed
from app.models.ops_marquee_seed import OpsMarqueeSeed
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random()
@@ -99,7 +99,7 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
need = limit - len(items)
if need > 0:
seeds = db.execute(
select(MarqueeSeed).where(MarqueeSeed.enabled.is_(True))
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
).scalars().all()
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
@@ -126,10 +126,10 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
# ===== 运营侧:种子 CRUD =====
def list_seeds(db: Session) -> list[MarqueeSeed]:
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
return list(
db.execute(
select(MarqueeSeed).order_by(MarqueeSeed.sort_order, MarqueeSeed.id)
select(OpsMarqueeSeed).order_by(OpsMarqueeSeed.sort_order, OpsMarqueeSeed.id)
).scalars().all()
)
@@ -143,9 +143,9 @@ def create_seed(
enabled: bool = True,
sort_order: int = 0,
commit: bool = True,
) -> MarqueeSeed:
) -> OpsMarqueeSeed:
_validate_amount(min_cents, max_cents)
seed = MarqueeSeed(
seed = OpsMarqueeSeed(
masked_user=(masked_user.strip() or None) if masked_user else None,
min_cents=min_cents,
max_cents=max_cents,
@@ -171,9 +171,9 @@ def update_seed(
enabled: bool | None = None,
sort_order: int | None = None,
commit: bool = True,
) -> MarqueeSeed:
) -> OpsMarqueeSeed:
"""改种子。masked_user 约定:不传(None)=不改;传空串=清空(改回随机合成);传值=固定该名。"""
seed = db.get(MarqueeSeed, seed_id)
seed = db.get(OpsMarqueeSeed, seed_id)
if seed is None:
raise ValueError("种子不存在")
if masked_user is not None:
@@ -204,7 +204,7 @@ def bulk_generate(
max_cents: int,
enabled: bool = True,
commit: bool = True,
) -> list[MarqueeSeed]:
) -> list[OpsMarqueeSeed]:
"""批量生成 count 条「自动合成名」种子(masked_user=NULL),金额区间 [min,max]。
冷启动铺量用;sort_order 续在当前最大值之后
"""
@@ -212,11 +212,11 @@ def bulk_generate(
raise ValueError("数量需在 1~200 之间")
_validate_amount(min_cents, max_cents)
base = db.execute(
select(func.coalesce(func.max(MarqueeSeed.sort_order), 0))
select(func.coalesce(func.max(OpsMarqueeSeed.sort_order), 0))
).scalar_one()
created: list[MarqueeSeed] = []
created: list[OpsMarqueeSeed] = []
for i in range(1, count + 1):
seed = MarqueeSeed(
seed = OpsMarqueeSeed(
masked_user=None,
min_cents=min_cents,
max_cents=max_cents,
@@ -235,7 +235,7 @@ def bulk_generate(
def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool:
seed = db.get(MarqueeSeed, seed_id)
seed = db.get(OpsMarqueeSeed, seed_id)
if seed is None:
return False
db.delete(seed)
@@ -1,6 +1,6 @@
"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。
三种模式见 app/models/platform_stat.py统一定时刷新:展示值 random_current 只在跨过
三种模式见 app/models/ops_stat_config.py统一定时刷新:展示值 random_current 只在跨过
北京钟点边界时刷新一次(real 快照查库 / manual 取固定值 / random 增长)
random 自增长支持两种方式(random_kind):
@@ -23,7 +23,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.comparison import ComparisonRecord
from app.models.platform_stat import PlatformStatDisplay
from app.models.ops_stat_config import OpsStatConfig
# 指标顺序 + 展示元信息(label/unit 给运营后台用;total_saved 单位是分,客户端 ÷100 显示元)
METRICS = ("help_users", "total_compares", "total_saved")
@@ -87,13 +87,13 @@ def _real_value(db: Session, metric: str) -> int:
# ===== 行管理 =====
def _ensure_rows(db: Session) -> dict[str, PlatformStatDisplay]:
def _ensure_rows(db: Session) -> dict[str, OpsStatConfig]:
"""保证三指标行都存在(migration 已播种;这里是缺行兜底,默认 manual + 种子值)。"""
rows = {r.metric: r for r in db.execute(select(PlatformStatDisplay)).scalars().all()}
rows = {r.metric: r for r in db.execute(select(OpsStatConfig)).scalars().all()}
created = False
for metric in METRICS:
if metric not in rows:
row = PlatformStatDisplay(
row = OpsStatConfig(
metric=metric, mode="manual", manual_value=_SEED_VALUE[metric]
)
db.add(row)
@@ -111,7 +111,7 @@ def _boundary_index(epoch_utc: int, interval: int, anchor_sec: int) -> int:
return (bj - anchor_sec) // interval
def _current_target(db: Session, row: PlatformStatDisplay) -> int:
def _current_target(db: Session, row: OpsStatConfig) -> int:
"""某模式此刻「应有的展示值」(用于播种 / real·manual 的定时快照)。"""
if row.mode == "manual":
return max(0, int(row.manual_value or 0))
@@ -122,14 +122,14 @@ def _current_target(db: Session, row: PlatformStatDisplay) -> int:
return max(0, int(_real_value(db, row.metric)))
def _monotonic(row: PlatformStatDisplay, new_val: int) -> int:
def _monotonic(row: OpsStatConfig, new_val: int) -> int:
"""只增不减护栏:未开 allow_decrease 且已有展示值时,新值不得低于当前值(否则保持当前)。"""
if not row.allow_decrease and row.random_current is not None and new_val < row.random_current:
return row.random_current
return new_val
def _grow(row: PlatformStatDisplay, val: int, periods: int) -> int:
def _grow(row: OpsStatConfig, val: int, periods: int) -> int:
"""random 自增长:按 random_kind 走 periods 个周期(add=逐周期加随机增量 / mult=逐周期乘随机倍率)。"""
if row.random_kind == "add":
lo = max(0, row.random_step_min)
@@ -144,7 +144,7 @@ def _grow(row: PlatformStatDisplay, val: int, periods: int) -> int:
return val
def _refresh(db: Session, row: PlatformStatDisplay) -> int:
def _refresh(db: Session, row: OpsStatConfig) -> int:
"""统一「定时刷新」:展示值 random_current 只在跨过「北京钟点边界」时刷新一次。
real重新快照(真实+偏移,受只增不减护栏)/ manual取固定值(受护栏)/ random按方式增长
无初值则按模式播种返回当前展示值(基础单位)
@@ -183,7 +183,7 @@ def get_display_values(db: Session) -> dict[str, int]:
# ===== 运营侧:配置读写 =====
def _snapshot(row: PlatformStatDisplay) -> dict:
def _snapshot(row: OpsStatConfig) -> dict:
"""审计 / 返回用的行快照。"""
return {
"mode": row.mode,
+5 -5
View File
@@ -2,7 +2,7 @@
> 所属: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)。
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。
> **单位约定**:倍率用**千分比整数**(1.000→`1000`、1.100→`1100`)。`total_saved` 的 `manual_value` / `random_current` / `random_initial` / `random_step_min` / `random_step_max` / `real_offset` 单位都是**分**(前端展示时 ÷100 取元);两个计数指标是个数。
@@ -11,7 +11,7 @@
## GET /admin/api/dashboard-display — 三指标配置 + 真实值预览
### 出参
响应 `200`:`list[PlatformStatItemOut]`(按 help_users / total_compares / total_saved 顺序)
响应 `200`:`list[OpsStatItemOut]`(按 help_users / total_compares / total_saved 顺序)
| 字段 | 类型 | 说明 |
|---|---|---|
@@ -40,7 +40,7 @@
`metric` 路径参数取 `help_users` / `total_compares` / `total_saved`
### 入参 `PlatformStatUpdateRequest`(字段均可选,只改传了的)
### 入参 `OpsStatUpdateRequest`(字段均可选,只改传了的)
| 字段 | 类型 | 说明 |
|---|---|---|
| `mode` | string | `real` / `manual` / `random` |
@@ -58,7 +58,7 @@
| `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) |
### 出参
响应 `200`:更新后的 `PlatformStatItemOut`(同上)。
响应 `200`:更新后的 `OpsStatItemOut`(同上)。
### 错误
- `400`:mode/kind 非法 / 倍率越界(<1000 或 >1500/ 上限<下限 / 增量负值或上限<下限 / 偏移负值 / tick<60 / 更新时间越界 / manual 负值。
@@ -69,5 +69,5 @@
- **统一定时刷新**:三模式的展示值(`random_current`)只在「更新时间(按更新间隔)」对齐的北京钟点刷新一次——real 快照查库(+偏移)/ manual 取固定值 / random 按 `random_kind` 走一档(`mult` ×倍率 / `add` +增量)。`random_tick_seconds`+`random_anchor_minutes` 三模式通用。
- **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时新目标值低于当前展示值则保持不降;要下调须显式传 `allow_decrease=true`
- 改 mode/manual/倍率/增量/偏移等到**下个更新钟点**才在客户端生效(展示值不立即变);`random_initial` 立即设起点并重置;`apply_now=true` 立即刷新一次。首次无展示值时按当前模式播种。
- 每次改动写 `admin_audit_log`(action=`dashboard_display.set`,detail 含改前/改后快照)。
- 每次改动写 `admin_audit_log`(action=`ops_stat_config.set`,detail 含改前/改后快照)。
- 客户端在**进首页 / 回前台时**重拉 `/stats` 取最新展示值。
+9 -9
View File
@@ -2,9 +2,9 @@
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds` | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md)
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [marquee_seed](../database/marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
## 复用结构 MarqueeSeedOut
## 复用结构 OpsMarqueeSeedOut
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | |
@@ -16,7 +16,7 @@
| `created_at` | string \| null | 创建时间(ISO) |
## GET /admin/api/marquee-seeds — 种子列表
出参 `200`:`list[MarqueeSeedOut]`(按 `sort_order,id`)。
出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。
## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。
@@ -24,22 +24,22 @@
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md))
## POST /admin/api/marquee-seeds — 新增(带审计)
入参 `MarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `MarqueeSeedOut``400`=金额非法。
入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut``400`=金额非法。
## POST /admin/api/marquee-seeds/bulk — 批量生成(带审计)
冷启动铺量:一次生成 `count` 条「自动合成名」(masked_user=null)种子,金额均为给定区间,`sort_order` 续在当前最大值之后。
- 入参 `MarqueeSeedBulkCreate`:`count`(1~200)、`min_cents``max_cents``enabled`(默认 true)
- 出参 `200`:`list[MarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。
- 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents``max_cents``enabled`(默认 true)
- 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。
## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计)
入参 `MarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`
入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`
- `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。
- 只改单边 `min_cents` / `max_cents` 时,与现有的另一边一起做「上限≥下限」校验。
出参:更新后的 `MarqueeSeedOut``404`=种子不存在;`400`=金额非法。
出参:更新后的 `OpsMarqueeSeedOut``404`=种子不存在;`400`=金额非法。
## DELETE /admin/api/marquee-seeds/{seed_id} — 删(带审计)
出参 `200`:`{"deleted": true}``404`=种子不存在。
## 说明
- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。
- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。
- 改动客户端**进首页 / 回前台**重拉 feed 时生效。
+3 -3
View File
@@ -2,7 +2,7 @@
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:**无** | [← 返回 API 索引](./README.md)
客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([marquee_seed](../database/marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。
客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。
## 入参
| 参数 | 类型 | 说明 |
@@ -21,7 +21,7 @@
## 说明
- 真实条:`comparison_record``status='success'``0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` 哈希脱敏(`用户********`+3 位)。
- 种子条:`marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空则按脱敏格式**随机合成并避开同屏撞名**,金额在 `[min_cents, max_cents]` 区间**随机取值**(固定金额则 min==max)。
- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空则按脱敏格式**随机合成并避开同屏撞名**,金额在 `[min_cents, max_cents]` 区间**随机取值**(固定金额则 min==max)。
- **`time` 为合成的「最近」时间**(从当前北京时间往前递减,首条约 20 秒前,其余每条 2~6 分钟):社会证明轮播保证永远像刚发生,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。
- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。
- 逻辑见 `app/repositories/marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。
- 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。
+1 -1
View File
@@ -23,6 +23,6 @@
- **random**:在上次展示值上按方式增长——`mult` ×一个 [1.0,1.5] 随机倍率 / `add` +一个 [下限,上限] 随机绝对增量(跨几个钟点走几次)。
- **只增不减**:默认门面数字不回退(real/manual 新值低于当前则保持),除非运营开「允许下降」。
计算逻辑见 `app/repositories/platform_stat.py`,模型见 `app/models/platform_stat.py`(表 [platform_stat_display](../database/platform_stat_display.md))。
计算逻辑见 `app/repositories/ops_stat.py`,模型见 `app/models/ops_stat_config.py`(表 [ops_stat_config](../database/ops_stat_config.md))。
上线初始播种为 manual + 客户端原写死门面值(12847 / 86532 / 37621.40 元),保证前后展示一致。
+2 -2
View File
@@ -44,8 +44,8 @@
### 首页门面数据
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `platform_stat_display` | 首页三统计展示配置(real/manual/random) | `models/platform_stat.py` | [详情](./platform_stat_display.md) |
| `marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/marquee_seed.py` | [详情](./marquee_seed.md) |
| `ops_stat_config` | 首页三统计展示配置(real/manual/random) | `models/ops_stat_config.py` | [详情](./ops_stat_config.md) |
| `ops_marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/ops_marquee_seed.py` | [详情](./ops_marquee_seed.md) |
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
| 表 | 用途 | 模型 | 文档 |
@@ -1,8 +1,8 @@
# marquee_seed — 首页轮播种子(生成规则)
# ops_marquee_seed — 首页轮播种子(生成规则)
> 模型 `app/models/marquee_seed.py` | 关联接口 [platform-savings-feed](../api/platform-savings-feed.md) / [admin-marquee-seeds](../api/admin-marquee-seeds.md) | [← 表索引](./README.md)
> 模型 `app/models/ops_marquee_seed.py` | 关联接口 [platform-savings-feed](../api/platform-savings-feed.md) / [admin-marquee-seeds](../api/admin-marquee-seeds.md) | [← 表索引](./README.md)
首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/marquee.py`
首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
@@ -17,7 +17,7 @@
## 初始数据(migration 播种)
6 条 = 客户端原写死的轮播条目(金额转分、`min==max` 固定值):a52/18.60、k89/42.40、m71/7.90、p46/156.30、r93/23.50、c28/89.40。
(`marquee_seed02` 迁移把旧单值列 `saved_amount_cents` 拆成 `min_cents=max_cents`,并把 `masked_user` 改为可空。)
(`marquee_seed02` 迁移把旧单值列 `saved_amount_cents` 拆成 `min_cents=max_cents`,并把 `masked_user` 改为可空;`opsrename01` 迁移把表名由 `marquee_seed` 改为 `ops_marquee_seed`,与代码 ops_ 前缀对齐,路由 `/admin/api/marquee-seeds` 不变。)
## 说明
- feed 取真实(`comparison_record` success 且 `0<saved≤300 元`,按 user 去重)优先,不足用本表启用种子**公平随机抽取**补齐(不再固定取前 N,所有种子都有机会露出)。种子用户名空则随机合成、金额区间随机取值。展示时间统一刷新成「相对现在的最近时刻」(详见 [platform-savings-feed](../api/platform-savings-feed.md))。
@@ -1,8 +1,8 @@
# platform_stat_display — 首页三统计展示配置
# ops_stat_config — 首页三统计展示配置
> 模型 `app/models/platform_stat.py` | 关联接口 [platform-stats](../api/platform-stats.md) / [admin-dashboard-display](../api/admin-dashboard-display.md) | [← 表索引](./README.md)
> 模型 `app/models/ops_stat_config.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`
客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示配置。一行一指标(主键=`metric`),每指标可独立选 real/manual/random 三种模式。计算逻辑见 `app/repositories/ops_stat.py`
> **单位**:倍率用**千分比整数**(1.000→`1000`);`total_saved` 指标的 `manual_value` / `random_current` 单位是**分**,两个计数指标是**个数**。
@@ -50,4 +50,5 @@
## 说明
- real 口径仅统计 `comparison_record``status='success'` 的记录(去重用户数 / 记录数)。`total_saved` 的求和**只计入 `0 < saved_amount_cents ≤ 300 元` 的条目**(剔除 bug 异常大值,与轮播口径一致,防一条异常配合「只增不减」永久撑高门面)。
- `倍率上限收紧为 1.500`(单次 ×1.5,原 5.0 太大一跳就假)。
- 表名 `ops_stat_config`(`opsrename01` 迁移由 `platform_stat_display` 改名,与代码 ops_ 前缀对齐;路由 `/admin/api/dashboard-display` 不变)。
- 改配置走 admin `PATCH /admin/api/dashboard-display/{metric}`,带审计。