feat(platform): 首页轮播 feed 接入真实数据 + 三统计配置增强

轮播 feed(marquee_seed,全新):
- 新增 marquee_seed 表 + admin CRUD/批量生成/预览;真实比价记录(success 且 0<省额≤300元、按 user 去重)优先,种子兜底混播
- 种子为「生成规则」:用户名可空(空则按脱敏格式随机合成、避开同屏撞名),金额改 [min,max] 区间随机,feed 公平随机抽取(不看 sort_order)
- 新增 GET /api/v1/platform/savings-feed(无鉴权);展示时间统一刷新为相对当前的最近时刻

三统计配置(platform_stat_display)增强:
- 自增长新增「绝对增量」方式(random_kind=add,每周期 +[step_min,step_max])
- 真实值模式加基数偏移(real_offset,展示=真实+偏移)
- 只增不减护栏(allow_decrease 默认关,real/manual 不回退防门面缩水)
- 累计节省 real 口径只计 0<单条≤300元防虚高;倍率上限 5.0→1.5;PATCH 支持 apply_now 立即更新

迁移:marquee_seed_table / marquee_seed_range / platform_stat_growth_offset(均可逆,已验证)
文档:API/DB 对应文档同步更新

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-06-07 12:05:17 +08:00
parent cfeacb4bab
commit c9578f5ba2
23 changed files with 930 additions and 62 deletions
+2
View File
@@ -20,6 +20,7 @@ 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.marquee_seed import router as 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,6 +76,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(marquee_seed_router)
admin_app.include_router(users_router)
admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
+5
View File
@@ -50,6 +50,11 @@ def update_display(
random_mult_max=body.random_mult_max,
random_tick_seconds=body.random_tick_seconds,
random_anchor_minutes=body.random_anchor_minutes,
random_kind=body.random_kind,
random_step_min=body.random_step_min,
random_step_max=body.random_step_max,
real_offset=body.real_offset,
allow_decrease=body.allow_decrease,
random_initial=body.random_initial,
apply_now=body.apply_now,
admin_id=admin.id,
+160
View File
@@ -0,0 +1,160 @@
"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。
种子用于首页轮播「真实+种子混播」的兜底(见 app/repositories/marquee.py)。
权限:operator 可改,super 恒可;预览为只读(任意已登录 admin)。
"""
from __future__ import annotations
from typing import Annotated
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.models.admin import AdminUser
from app.models.marquee_seed import MarqueeSeed
from app.repositories import marquee
router = APIRouter(
prefix="/admin/api/marquee-seeds",
tags=["admin-marquee-seeds"],
dependencies=[Depends(get_current_admin)],
)
def _out(seed: MarqueeSeed) -> MarqueeSeedOut:
return MarqueeSeedOut(
id=seed.id,
masked_user=seed.masked_user,
min_cents=seed.min_cents,
max_cents=seed.max_cents,
enabled=seed.enabled,
sort_order=seed.sort_order,
created_at=seed.created_at.isoformat() if seed.created_at else None,
)
@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("/preview", response_model=SavingsFeedPreviewOut, summary="预览实际混播 feed")
def preview_feed(
db: AdminDb,
limit: Annotated[int, Query(ge=1, le=30)] = 8,
) -> SavingsFeedPreviewOut:
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
return SavingsFeedPreviewOut(items=marquee.get_feed(db, limit=limit))
@router.post("", response_model=MarqueeSeedOut, summary="新增轮播种子(带审计)")
def create_seed(
body: MarqueeSeedCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> MarqueeSeedOut:
try:
seed = marquee.create_seed(
db,
masked_user=body.masked_user,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
sort_order=body.sort_order,
commit=False,
)
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",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(seed)
return _out(seed)
@router.post("/bulk", response_model=list[MarqueeSeedOut], summary="批量生成种子(带审计)")
def bulk_generate(
body: MarqueeSeedBulkCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> list[MarqueeSeedOut]:
try:
seeds = marquee.bulk_generate(
db,
count=body.count,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
commit=False,
)
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",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
for s in seeds:
db.refresh(s)
return [_out(s) for s in seeds]
@router.patch("/{seed_id}", response_model=MarqueeSeedOut, summary="改轮播种子(带审计)")
def update_seed(
seed_id: int,
body: MarqueeSeedUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> MarqueeSeedOut:
try:
seed = marquee.update_seed(
db,
seed_id,
masked_user=body.masked_user,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
sort_order=body.sort_order,
commit=False,
)
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",
target_id=seed_id, detail=body.model_dump(exclude_none=True),
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(seed)
return _out(seed)
@router.delete("/{seed_id}", summary="删轮播种子(带审计)")
def delete_seed(
seed_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
ok = 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",
target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False,
)
db.commit()
return {"deleted": True}
+12 -2
View File
@@ -17,9 +17,14 @@ class PlatformStatItemOut(BaseModel):
random_mult_max: int
random_tick_seconds: int
random_anchor_minutes: int
random_kind: str # mult(×倍率) / add(+绝对增量)
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
random_step_max: int
real_offset: int # 真实值模式基数偏移(基础单位)
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
random_current: int | None = None
random_last_tick_at: str | None = None
real_value: int # 当前真实值(给运营对比参考)
real_value: int # 当前真实值(纯真实、不含偏移;给运营对比参考)
updated_at: str | None = None
@@ -32,7 +37,12 @@ class PlatformStatUpdateRequest(BaseModel):
random_mult_max: int | None = None
random_tick_seconds: int | None = None
random_anchor_minutes: int | None = None
random_kind: str | None = None # mult / add
random_step_min: int | None = None
random_step_max: int | None = None
real_offset: int | None = None
allow_decrease: bool | None = None
# 切 random 时的初始基数;不传则用当前真实值播种
random_initial: int | None = None
# 立即更新:不等更新钟点,保存后马上把展示值刷新一次(real 快照/manual 生效/random ×一档)
# 立即更新:不等更新钟点,保存后马上把展示值刷新一次(real 快照/manual 生效/random 一档)
apply_now: bool = False
+53
View File
@@ -0,0 +1,53 @@
"""admin 首页轮播种子 schemas。金额单位:分(前端展示 ÷100 取元)。
种子 = 生成规则:masked_user 可空(空→feed 随机合成名)、金额是 [min_cents, max_cents] 区间。
"""
from __future__ import annotations
from pydantic import BaseModel
class MarqueeSeedOut(BaseModel):
id: int
masked_user: str | None = None
min_cents: int
max_cents: int
enabled: bool
sort_order: int
created_at: str | None = None
class MarqueeSeedCreate(BaseModel):
masked_user: str | None = None # 空 / 不传 → feed 随机合成脱敏名
min_cents: int
max_cents: int
enabled: bool = True
sort_order: int = 0
class MarqueeSeedUpdate(BaseModel):
# masked_user:不传=不改;传空串=清空(改回随机合成);传值=固定该名
masked_user: str | None = None
min_cents: int | None = None
max_cents: int | None = None
enabled: bool | None = None
sort_order: int | None = None
class MarqueeSeedBulkCreate(BaseModel):
"""批量生成 count 条自动合成名种子(冷启动铺量)。"""
count: int
min_cents: int
max_cents: int
enabled: bool = True
class SavingsFeedPreviewItem(BaseModel):
masked_user: str
saved_amount_cents: int
time: str
class SavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[SavingsFeedPreviewItem]