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:
@@ -0,0 +1,50 @@
|
||||
"""marquee_seed 升级为「生成规则」:用户名可空 + 金额拆 min/max 区间
|
||||
|
||||
把种子从「死记录」升级:
|
||||
- masked_user 改 nullable(留空 → feed 随机合成脱敏用户名)
|
||||
- saved_amount_cents(单值)拆成 min_cents / max_cents(feed 在区间内随机取值;固定金额则相等)
|
||||
现有 6 条种子:min_cents = max_cents = 原 saved_amount_cents,masked_user 原样保留。
|
||||
|
||||
Revision ID: marquee_seed02
|
||||
Revises: marquee_seed01
|
||||
Create Date: 2026-06-07 11:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'marquee_seed02'
|
||||
down_revision: Union[str, Sequence[str], None] = 'marquee_seed01'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) 先加可空新列,使现有行合法
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('min_cents', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('max_cents', sa.Integer(), nullable=True))
|
||||
# 2) 用旧单值回填区间(固定金额)
|
||||
op.execute('UPDATE marquee_seed SET min_cents = saved_amount_cents, max_cents = saved_amount_cents')
|
||||
# 3) 收紧:区间非空、用户名可空、删旧列
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.alter_column('min_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('max_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=True)
|
||||
batch_op.drop_column('saved_amount_cents')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 反向:恢复单值列(取下限),用户名回非空
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('saved_amount_cents', sa.Integer(), nullable=True))
|
||||
op.execute('UPDATE marquee_seed SET saved_amount_cents = min_cents')
|
||||
# 自动合成名的种子 masked_user 为 NULL,降级前补占位符以满足非空
|
||||
op.execute("UPDATE marquee_seed SET masked_user = '用户********000' WHERE masked_user IS NULL")
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.alter_column('saved_amount_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=False)
|
||||
batch_op.drop_column('max_cents')
|
||||
batch_op.drop_column('min_cents')
|
||||
@@ -0,0 +1,52 @@
|
||||
"""marquee_seed table (首页轮播种子) + 合并 platform_stat / price_observation 两 head
|
||||
|
||||
Revision ID: marquee_seed01
|
||||
Revises: pstat2anchor01, price_observation_table
|
||||
Create Date: 2026-06-07 10:00:00.000000
|
||||
|
||||
兼并当前两个 head 为单 head,同时建 marquee_seed 表并播种客户端原写死的 6 条轮播
|
||||
(作为初始种子,保证上线即有内容)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'marquee_seed01'
|
||||
down_revision: Union[str, Sequence[str], None] = ('pstat2anchor01', 'price_observation_table')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = op.create_table(
|
||||
'marquee_seed',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('masked_user', sa.String(length=64), nullable=False),
|
||||
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# 初始种子 = 客户端原写死的 6 条(金额转成分)
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[
|
||||
{'masked_user': '用户********a52', 'saved_amount_cents': 1860, 'enabled': True, 'sort_order': 1},
|
||||
{'masked_user': '用户********k89', 'saved_amount_cents': 4240, 'enabled': True, 'sort_order': 2},
|
||||
{'masked_user': '用户********m71', 'saved_amount_cents': 790, 'enabled': True, 'sort_order': 3},
|
||||
{'masked_user': '用户********p46', 'saved_amount_cents': 15630, 'enabled': True, 'sort_order': 4},
|
||||
{'masked_user': '用户********r93', 'saved_amount_cents': 2350, 'enabled': True, 'sort_order': 5},
|
||||
{'masked_user': '用户********c28', 'saved_amount_cents': 8940, 'enabled': True, 'sort_order': 6},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('marquee_seed')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""platform_stat_display 增强:绝对增量增长 / 真实值偏移 / 只增不减护栏
|
||||
|
||||
给首页三统计配置加 5 列:
|
||||
- random_kind ('mult'/'add') + random_step_min/max:自增长支持「绝对增量」(每周期 +[a,b]),不只是 ×倍率
|
||||
- real_offset:真实值模式基数偏移(展示=真实+偏移,冷启动既真实又体面)
|
||||
- allow_decrease:门面数字默认「只增不减」,开此开关才允许展示值下降
|
||||
|
||||
Revision ID: pstat3growth
|
||||
Revises: marquee_seed02
|
||||
Create Date: 2026-06-07 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'pstat3growth'
|
||||
down_revision: Union[str, Sequence[str], None] = 'marquee_seed02'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('random_kind', sa.String(length=8), nullable=False, server_default='mult'))
|
||||
batch_op.add_column(sa.Column('random_step_min', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('random_step_max', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('real_offset', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('allow_decrease', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
|
||||
batch_op.drop_column('allow_decrease')
|
||||
batch_op.drop_column('real_offset')
|
||||
batch_op.drop_column('random_step_max')
|
||||
batch_op.drop_column('random_step_min')
|
||||
batch_op.drop_column('random_kind')
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -9,11 +9,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
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.schemas.platform import PlatformStatsOut
|
||||
from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut
|
||||
|
||||
logger = logging.getLogger("shagua.platform")
|
||||
|
||||
@@ -28,3 +29,9 @@ def stats(db: DbSession) -> PlatformStatsOut:
|
||||
total_compares=v["total_compares"],
|
||||
total_saved_cents=v["total_saved"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/savings-feed", response_model=SavingsFeedOut, summary="首页轮播 feed(真实+种子混播)")
|
||||
def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut:
|
||||
items = marquee_crud.get_feed(db, limit=limit)
|
||||
return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items])
|
||||
|
||||
@@ -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.marquee_seed import MarqueeSeed # 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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""首页轮播「种子」条目(运营可配的兜底假数据 = 假数据生成规则)。
|
||||
|
||||
客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条:数据源是全平台真实比价记录,
|
||||
但冷启动 / 低谷期真实记录不够 N 条时,用本表的种子条目补齐到 N 条「混播」,保证轮播永不空。
|
||||
运营后台可增删改本表。真实够时种子不出现。
|
||||
|
||||
种子是「规则」而非「死记录」:
|
||||
- masked_user 可空——留空则 feed 展示时按脱敏格式随机合成用户名(并避开同屏撞名),
|
||||
风格与真实条一致、运营零心智、永不穿帮;填了则固定用该名。
|
||||
- 金额是 [min_cents, max_cents] 区间,feed 每次在区间内随机取值(固定金额则 min==max),
|
||||
让同一种子每次展示的数字有浮动,更像真的。
|
||||
- sort_order 仅供后台列表排序;feed 是公平随机抽取(不看 sort_order),保证所有启用种子都有机会露出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class MarqueeSeed(Base):
|
||||
__tablename__ = "marquee_seed"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名)
|
||||
masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」
|
||||
min_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
max_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 停用的种子不参与混播
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
# 仅供后台列表排序(小在前);feed 是公平随机抽取,不看此字段
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<MarqueeSeed id={self.id} user={self.masked_user}>"
|
||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -49,6 +49,15 @@ class PlatformStatDisplay(Base):
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# random 增长方式:mult=×倍率(用 random_mult_*) / add=+绝对增量(用 random_step_*,基础单位整数)
|
||||
random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult")
|
||||
random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面)
|
||||
real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束)
|
||||
allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
updated_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""首页轮播 feed:全平台真实比价记录 + 种子兜底「混播」,以及种子表 CRUD。
|
||||
|
||||
feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 marquee_seed 的种子补齐,
|
||||
保证轮播永不空。真实够时种子不出现。
|
||||
|
||||
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
|
||||
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
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
|
||||
|
||||
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
|
||||
_rng = random.Random()
|
||||
|
||||
_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐
|
||||
_HEX = "0123456789abcdef"
|
||||
# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮)
|
||||
_REAL_MAX_CENTS = 30000
|
||||
# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下
|
||||
_SEED_MAX_CENTS = 100000
|
||||
|
||||
|
||||
def _mask_user(user_id: int) -> str:
|
||||
"""真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。"""
|
||||
suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX]
|
||||
return f"用户********{suffix}"
|
||||
|
||||
|
||||
def _random_suffix(used: set[str]) -> str:
|
||||
"""随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。"""
|
||||
for _ in range(60):
|
||||
s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX))
|
||||
if s not in used:
|
||||
used.add(s)
|
||||
return s
|
||||
# 兜底(几乎不会到):线性扫一个空位
|
||||
for n in range(16 ** _SUFFIX_HEX):
|
||||
s = format(n, f"0{_SUFFIX_HEX}x")
|
||||
if s not in used:
|
||||
used.add(s)
|
||||
return s
|
||||
return "000"
|
||||
|
||||
|
||||
def _validate_amount(min_cents: int, max_cents: int) -> None:
|
||||
if min_cents < 0 or max_cents < 0:
|
||||
raise ValueError("金额需为非负整数(分)")
|
||||
if max_cents < min_cents:
|
||||
raise ValueError("金额上限不得小于下限")
|
||||
if max_cents > _SEED_MAX_CENTS:
|
||||
raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)")
|
||||
|
||||
|
||||
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
|
||||
def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。
|
||||
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生
|
||||
(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。
|
||||
"""
|
||||
# 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。
|
||||
rows = db.execute(
|
||||
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents)
|
||||
.where(
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(max(limit * 20, 100))
|
||||
).all()
|
||||
|
||||
items: list[dict] = []
|
||||
used_suffixes: set[str] = set()
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
name = _mask_user(uid)
|
||||
used_suffixes.add(name[-_SUFFIX_HEX:])
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
|
||||
need = limit - len(items)
|
||||
if need > 0:
|
||||
seeds = db.execute(
|
||||
select(MarqueeSeed).where(MarqueeSeed.enabled.is_(True))
|
||||
).scalars().all()
|
||||
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
|
||||
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
|
||||
_rng.shuffle(chosen)
|
||||
for s in chosen:
|
||||
# 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。
|
||||
if s.masked_user and s.masked_user.strip():
|
||||
name = s.masked_user.strip()
|
||||
used_suffixes.add(name[-_SUFFIX_HEX:])
|
||||
else:
|
||||
name = f"用户********{_random_suffix(used_suffixes)}"
|
||||
# 金额:在 [min,max] 随机一个(固定金额则 min==max)。
|
||||
lo = max(0, int(s.min_cents))
|
||||
hi = max(lo, int(s.max_cents))
|
||||
cents = lo if lo >= hi else _rng.randint(lo, hi)
|
||||
items.append({"masked_user": name, "saved_amount_cents": cents})
|
||||
|
||||
# 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。
|
||||
cursor = datetime.now(CN_TZ)
|
||||
for i, it in enumerate(items):
|
||||
cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5))
|
||||
it["time"] = cursor.strftime("%H:%M:%S")
|
||||
return items
|
||||
|
||||
|
||||
# ===== 运营侧:种子 CRUD =====
|
||||
def list_seeds(db: Session) -> list[MarqueeSeed]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(MarqueeSeed).order_by(MarqueeSeed.sort_order, MarqueeSeed.id)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def create_seed(
|
||||
db: Session,
|
||||
*,
|
||||
masked_user: str | None = None,
|
||||
min_cents: int,
|
||||
max_cents: int,
|
||||
enabled: bool = True,
|
||||
sort_order: int = 0,
|
||||
commit: bool = True,
|
||||
) -> MarqueeSeed:
|
||||
_validate_amount(min_cents, max_cents)
|
||||
seed = MarqueeSeed(
|
||||
masked_user=(masked_user.strip() or None) if masked_user else None,
|
||||
min_cents=min_cents,
|
||||
max_cents=max_cents,
|
||||
enabled=enabled,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
db.add(seed)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(seed)
|
||||
else:
|
||||
db.flush()
|
||||
return seed
|
||||
|
||||
|
||||
def update_seed(
|
||||
db: Session,
|
||||
seed_id: int,
|
||||
*,
|
||||
masked_user: str | None = None,
|
||||
min_cents: int | None = None,
|
||||
max_cents: int | None = None,
|
||||
enabled: bool | None = None,
|
||||
sort_order: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> MarqueeSeed:
|
||||
"""改种子。masked_user 约定:不传(None)=不改;传空串=清空(改回随机合成);传值=固定该名。"""
|
||||
seed = db.get(MarqueeSeed, seed_id)
|
||||
if seed is None:
|
||||
raise ValueError("种子不存在")
|
||||
if masked_user is not None:
|
||||
seed.masked_user = masked_user.strip() or None
|
||||
if min_cents is not None or max_cents is not None:
|
||||
new_min = min_cents if min_cents is not None else seed.min_cents
|
||||
new_max = max_cents if max_cents is not None else seed.max_cents
|
||||
_validate_amount(new_min, new_max)
|
||||
seed.min_cents = new_min
|
||||
seed.max_cents = new_max
|
||||
if enabled is not None:
|
||||
seed.enabled = enabled
|
||||
if sort_order is not None:
|
||||
seed.sort_order = sort_order
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(seed)
|
||||
else:
|
||||
db.flush()
|
||||
return seed
|
||||
|
||||
|
||||
def bulk_generate(
|
||||
db: Session,
|
||||
*,
|
||||
count: int,
|
||||
min_cents: int,
|
||||
max_cents: int,
|
||||
enabled: bool = True,
|
||||
commit: bool = True,
|
||||
) -> list[MarqueeSeed]:
|
||||
"""批量生成 count 条「自动合成名」种子(masked_user=NULL),金额区间 [min,max]。
|
||||
冷启动铺量用;sort_order 续在当前最大值之后。
|
||||
"""
|
||||
if not (1 <= count <= 200):
|
||||
raise ValueError("数量需在 1~200 之间")
|
||||
_validate_amount(min_cents, max_cents)
|
||||
base = db.execute(
|
||||
select(func.coalesce(func.max(MarqueeSeed.sort_order), 0))
|
||||
).scalar_one()
|
||||
created: list[MarqueeSeed] = []
|
||||
for i in range(1, count + 1):
|
||||
seed = MarqueeSeed(
|
||||
masked_user=None,
|
||||
min_cents=min_cents,
|
||||
max_cents=max_cents,
|
||||
enabled=enabled,
|
||||
sort_order=base + i,
|
||||
)
|
||||
db.add(seed)
|
||||
created.append(seed)
|
||||
if commit:
|
||||
db.commit()
|
||||
for s in created:
|
||||
db.refresh(s)
|
||||
else:
|
||||
db.flush()
|
||||
return created
|
||||
|
||||
|
||||
def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool:
|
||||
seed = db.get(MarqueeSeed, seed_id)
|
||||
if seed is None:
|
||||
return False
|
||||
db.delete(seed)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return True
|
||||
@@ -1,9 +1,18 @@
|
||||
"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。
|
||||
|
||||
三种模式见 app/models/platform_stat.py。核心是 random「只增不减」的惰性 tick:
|
||||
读取时若距上次 tick 超过 tick 周期,就按经过的整数个周期各乘一次随机倍率([min,max]/1000)。
|
||||
倍率恒 ≥1.0 → 只增不减。纯门面数字,**接受 tick 时刻的微小并发竞态**(不加行锁):
|
||||
极端情况某次多乘一档,数字略偏高,无业务后果(见与用户的方案讨论)。
|
||||
三种模式见 app/models/platform_stat.py。统一「定时刷新」:展示值 random_current 只在跨过
|
||||
「北京钟点边界」时刷新一次(real 快照查库 / manual 取固定值 / random 增长)。
|
||||
|
||||
random 自增长支持两种方式(random_kind):
|
||||
- mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长
|
||||
- add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增
|
||||
|
||||
真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面。
|
||||
|
||||
「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值,
|
||||
保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。
|
||||
|
||||
纯门面数字,**接受 tick 时刻的微小并发竞态**(不加行锁):极端情况某次多走一档,无业务后果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,7 +33,6 @@ _META = {
|
||||
"total_saved": ("累计节省", "分"),
|
||||
}
|
||||
# 初始播种值(= 客户端原写死门面数:12847 人 / 86532 次 / 37621.4 元)。
|
||||
# migration 与 _ensure_rows 都用它,保证上线前后展示一致,运营再按需切模式。
|
||||
_SEED_VALUE = {
|
||||
"help_users": 12847,
|
||||
"total_compares": 86532,
|
||||
@@ -33,10 +41,14 @@ _SEED_VALUE = {
|
||||
|
||||
# 防呆边界
|
||||
_MULT_FLOOR = 1000 # 倍率千分比下限 = 1.000(只增不减)
|
||||
_MULT_CAP = 5000 # 倍率千分比上限 = 5.000(防误填)
|
||||
_MULT_CAP = 1500 # 倍率千分比上限 = 1.500(收紧:单次 ×1.5 已不小,>此一跳就假)
|
||||
_STEP_CAP = 10**12 # 绝对增量单次上限(基础单位防呆)
|
||||
_TICK_MIN_SECONDS = 60 # tick 周期下限
|
||||
_MAX_CATCHUP_PERIODS = 3650 # 单次最多补乘的周期数(防 last 远古时一次乘爆)
|
||||
_MAX_CATCHUP_PERIODS = 3650 # 单次最多补走的周期数(防 last 远古时一次走爆)
|
||||
_BEIJING_OFFSET = 8 * 3600 # 北京时间 = UTC + 8h(钟点对齐按北京时间算)
|
||||
# 真实 total_saved 求和:单条 saved 超此值视为异常 / bug 值,不计入
|
||||
# (与轮播口径一致,防一条 bug 大额配合「只增不减」永久撑高门面)
|
||||
_REAL_SAVED_CAP_CENTS = 30000 # 300 元
|
||||
|
||||
|
||||
def _as_utc(dt: datetime | None) -> datetime | None:
|
||||
@@ -51,7 +63,7 @@ def _as_utc(dt: datetime | None) -> datetime | None:
|
||||
# ===== 真实数据(real 模式)=====
|
||||
def _real_value(db: Session, metric: str) -> int:
|
||||
"""real 口径:仅统计 status='success' 的比价记录。
|
||||
help_users=去重用户数 / total_compares=记录数 / total_saved=省额(分)求和。
|
||||
help_users=去重用户数 / total_compares=记录数 / total_saved=省额(分)求和(剔除单条异常大值)。
|
||||
"""
|
||||
success = ComparisonRecord.status == "success"
|
||||
if metric == "help_users":
|
||||
@@ -63,8 +75,13 @@ def _real_value(db: Session, metric: str) -> int:
|
||||
select(func.count(ComparisonRecord.id)).where(success)
|
||||
).scalar_one()
|
||||
if metric == "total_saved":
|
||||
# 只累加 0 < saved ≤ 上限 的条目,异常大值不计入(防 bug 值撑高门面)
|
||||
return db.execute(
|
||||
select(func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0)).where(success)
|
||||
select(func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0)).where(
|
||||
success,
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
ComparisonRecord.saved_amount_cents <= _REAL_SAVED_CAP_CENTS,
|
||||
)
|
||||
).scalar_one()
|
||||
raise KeyError(f"unknown metric: {metric}")
|
||||
|
||||
@@ -89,11 +106,7 @@ def _ensure_rows(db: Session) -> dict[str, PlatformStatDisplay]:
|
||||
|
||||
# ===== 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 分钟刻度。
|
||||
"""
|
||||
"""该 UTC 时刻落在第几个「北京钟点边界」。边界 = 北京时间 (anchor + k*interval)。"""
|
||||
bj = epoch_utc + _BEIJING_OFFSET
|
||||
return (bj - anchor_sec) // interval
|
||||
|
||||
@@ -103,14 +116,37 @@ def _current_target(db: Session, row: PlatformStatDisplay) -> int:
|
||||
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)) + int(row.real_offset or 0))
|
||||
# random:无显式初始基数时用真实值(纯真实,不含偏移)播种
|
||||
return max(0, int(_real_value(db, row.metric)))
|
||||
|
||||
|
||||
def _monotonic(row: PlatformStatDisplay, 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:
|
||||
"""random 自增长:按 random_kind 走 periods 个周期(add=逐周期加随机增量 / mult=逐周期乘随机倍率)。"""
|
||||
if row.random_kind == "add":
|
||||
lo = max(0, row.random_step_min)
|
||||
hi = max(lo, row.random_step_max)
|
||||
for _ in range(periods):
|
||||
val += random.randint(lo, hi)
|
||||
else:
|
||||
lo = max(_MULT_FLOOR, row.random_mult_min)
|
||||
hi = max(lo, row.random_mult_max)
|
||||
for _ in range(periods):
|
||||
val = val * random.randint(lo, hi) // 1000
|
||||
return val
|
||||
|
||||
|
||||
def _refresh(db: Session, row: PlatformStatDisplay) -> int:
|
||||
"""统一「定时刷新」:展示值 random_current 只在跨过「北京钟点边界」时刷新一次。
|
||||
real→重新快照查库、manual→取当前固定值、random→×随机倍率(跨几个边界乘几次)。
|
||||
real→重新快照(真实+偏移,受只增不减护栏)/ manual→取固定值(受护栏)/ random→按方式增长。
|
||||
无初值则按模式播种。返回当前展示值(基础单位)。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -130,16 +166,10 @@ def _refresh(db: Session, row: PlatformStatDisplay) -> int:
|
||||
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
|
||||
row.random_current = _grow(row, row.random_current, min(periods, _MAX_CATCHUP_PERIODS))
|
||||
else:
|
||||
# real / manual:跨多少边界都只取「此刻应有的值」快照一次
|
||||
row.random_current = _current_target(db, row)
|
||||
# real / manual:跨多少边界都只取「此刻应有的值」快照一次,经只增不减护栏
|
||||
row.random_current = _monotonic(row, _current_target(db, row))
|
||||
row.random_last_tick_at = now # 用边界索引比较,直接记 now 不会重复计
|
||||
db.commit()
|
||||
return row.random_current
|
||||
@@ -162,6 +192,11 @@ def _snapshot(row: PlatformStatDisplay) -> dict:
|
||||
"random_mult_max": row.random_mult_max,
|
||||
"random_tick_seconds": row.random_tick_seconds,
|
||||
"random_anchor_minutes": row.random_anchor_minutes,
|
||||
"random_kind": row.random_kind,
|
||||
"random_step_min": row.random_step_min,
|
||||
"random_step_max": row.random_step_max,
|
||||
"real_offset": row.real_offset,
|
||||
"allow_decrease": row.allow_decrease,
|
||||
"random_current": row.random_current,
|
||||
"random_last_tick_at": (
|
||||
row.random_last_tick_at.isoformat() if row.random_last_tick_at else None
|
||||
@@ -172,8 +207,7 @@ def _snapshot(row: PlatformStatDisplay) -> dict:
|
||||
def get_config(db: Session) -> list[dict]:
|
||||
"""三指标当前配置 + 真实值预览(给运营对比),按 METRICS 顺序。
|
||||
|
||||
先跑一次定时刷新,使「当前展示值」在后台轮询时也随钟点实时推进
|
||||
(否则只有用户侧 /stats 被调用才会推进)。
|
||||
先跑一次定时刷新,使「当前展示值」在后台轮询时也随钟点实时推进。
|
||||
"""
|
||||
rows = _ensure_rows(db)
|
||||
out: list[dict] = []
|
||||
@@ -186,7 +220,7 @@ def get_config(db: Session) -> list[dict]:
|
||||
"metric": metric,
|
||||
"label": label,
|
||||
"unit": unit,
|
||||
"real_value": _real_value(db, metric),
|
||||
"real_value": _real_value(db, metric), # 纯真实(不含偏移),供运营对比
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
**_snapshot(row),
|
||||
}
|
||||
@@ -204,6 +238,11 @@ def update_config(
|
||||
random_mult_max: int | None = None,
|
||||
random_tick_seconds: int | None = None,
|
||||
random_anchor_minutes: int | None = None,
|
||||
random_kind: str | None = None,
|
||||
random_step_min: int | None = None,
|
||||
random_step_max: int | None = None,
|
||||
real_offset: int | None = None,
|
||||
allow_decrease: bool | None = None,
|
||||
random_initial: int | None = None,
|
||||
apply_now: bool = False,
|
||||
admin_id: int,
|
||||
@@ -211,9 +250,9 @@ def update_config(
|
||||
) -> tuple[dict, dict]:
|
||||
"""改某指标配置。返回 (before, after) 快照供审计。非法入参抛 ValueError(router 转 400)。
|
||||
|
||||
展示值 random_current 采用统一「定时刷新」:平时不动,到更新钟点才按模式刷新
|
||||
(real 快照查库 / manual 取固定值 / random ×倍率)。本函数只在「首次无值」时按模式播种;
|
||||
给了 random_initial 则立即设为该值并重置(用于自增长设起点)。其余改动到下个更新钟点才生效。
|
||||
展示值 random_current 采用统一「定时刷新」:平时不动,到更新钟点才按模式刷新。
|
||||
本函数只在「首次无值」时按模式播种;给了 random_initial 则立即设为该值并重置;
|
||||
apply_now=True 则立即刷新一次(random 走一档增长,real/manual 取应有值,均经只增不减护栏)。
|
||||
"""
|
||||
if metric not in METRICS:
|
||||
raise ValueError(f"未知指标: {metric}")
|
||||
@@ -244,25 +283,42 @@ def update_config(
|
||||
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
|
||||
if random_kind is not None:
|
||||
if random_kind not in ("mult", "add"):
|
||||
raise ValueError("增长方式需为 mult/add")
|
||||
row.random_kind = random_kind
|
||||
if random_step_min is not None:
|
||||
if not (0 <= random_step_min <= _STEP_CAP):
|
||||
raise ValueError("增量下限需为非负整数")
|
||||
row.random_step_min = random_step_min
|
||||
if random_step_max is not None:
|
||||
if not (0 <= random_step_max <= _STEP_CAP):
|
||||
raise ValueError("增量上限需为非负整数")
|
||||
row.random_step_max = random_step_max
|
||||
if row.random_step_max < row.random_step_min:
|
||||
raise ValueError("增量上限不得小于下限")
|
||||
if real_offset is not None:
|
||||
if real_offset < 0:
|
||||
raise ValueError("基数偏移需为非负整数")
|
||||
row.real_offset = real_offset
|
||||
if allow_decrease is not None:
|
||||
row.allow_decrease = allow_decrease
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if random_initial is not None:
|
||||
if random_initial < 0:
|
||||
raise ValueError("初始基数需为非负整数")
|
||||
row.random_current = random_initial
|
||||
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
|
||||
row.random_current = _grow(row, row.random_current, 1)
|
||||
else:
|
||||
row.random_current = _current_target(db, row)
|
||||
row.random_current = _monotonic(row, _current_target(db, row))
|
||||
row.random_last_tick_at = now
|
||||
elif row.random_current is None:
|
||||
# 首次无值:按当前模式播种,使配置后立即有合理展示值
|
||||
|
||||
@@ -10,3 +10,15 @@ class PlatformStatsOut(BaseModel):
|
||||
help_users: int # 帮助用户(人)
|
||||
total_compares: int # 完成比价(次)
|
||||
total_saved_cents: int # 累计节省(分)
|
||||
|
||||
|
||||
class SavingsFeedItem(BaseModel):
|
||||
"""首页轮播一条。time 为北京时间 HH:MM:SS。"""
|
||||
|
||||
masked_user: str # 脱敏用户名,如「用户********a52」
|
||||
saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」
|
||||
time: str
|
||||
|
||||
|
||||
class SavingsFeedOut(BaseModel):
|
||||
items: list[SavingsFeedItem]
|
||||
|
||||
+8
-1
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-06-04(+ 运营后台 Admin 子应用 A1–A18,见下方「运营后台 Admin」组)
|
||||
> 最后更新:2026-06-07(运营后台 Admin 子应用 A1–A26;首页门面数据 #39–#40 + 轮播种子 A21–A26)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -68,6 +68,7 @@
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| **首页门面数据**(前缀 `/api/v1/platform`;全平台展示数字,登录前可读) |||
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
@@ -92,6 +93,12 @@
|
||||
| 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) |
|
||||
| A21 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
|
||||
| A22 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A23 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 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 取元)。
|
||||
> **单位约定**:倍率用**千分比整数**(1.000→`1000`、1.100→`1100`)。`total_saved` 的 `manual_value` / `random_current` / `random_initial` / `random_step_min` / `random_step_max` / `real_offset` 单位都是**分**(前端展示时 ÷100 取元);两个计数指标是个数。
|
||||
|
||||
---
|
||||
|
||||
@@ -24,9 +24,14 @@
|
||||
| `random_mult_max` | int | 倍率上限(千分比) |
|
||||
| `random_tick_seconds` | int | 更新间隔(秒,默认 86400=1 天) |
|
||||
| `random_anchor_minutes` | int | 触发时刻对齐偏移(距北京 0 点分钟数:天=每日时刻、小时=每小时第几分、分钟=0) |
|
||||
| `random_current` | int \| null | random 当前累积值(基础单位) |
|
||||
| `random_kind` | string | 自增长方式:`mult` ×倍率 / `add` +绝对增量 |
|
||||
| `random_step_min` | int | add 模式增量下限(基础单位) |
|
||||
| `random_step_max` | int | add 模式增量上限(基础单位) |
|
||||
| `real_offset` | int | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 |
|
||||
| `allow_decrease` | bool | 是否允许展示值下降(默认 false = 只增不减) |
|
||||
| `random_current` | int \| null | 当前展示值(所有模式,基础单位) |
|
||||
| `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO) |
|
||||
| `real_value` | int | 当前真实值(给运营对比参考,基础单位) |
|
||||
| `real_value` | int | 当前真实值(纯真实、不含偏移;给运营对比参考,基础单位) |
|
||||
| `updated_at` | string \| null | 上次修改时间 |
|
||||
|
||||
---
|
||||
@@ -40,22 +45,29 @@
|
||||
|---|---|---|
|
||||
| `mode` | string | `real` / `manual` / `random` |
|
||||
| `manual_value` | int | manual 固定值(基础单位,≥0) |
|
||||
| `random_mult_min` | int | 倍率下限(千分比,1000~5000) |
|
||||
| `random_mult_max` | int | 倍率上限(千分比,≥下限) |
|
||||
| `random_mult_min` | int | 倍率下限(千分比,1000~1500) |
|
||||
| `random_mult_max` | int | 倍率上限(千分比,≥下限,≤1500) |
|
||||
| `random_tick_seconds` | int | 更新间隔(秒,≥60) |
|
||||
| `random_anchor_minutes` | int | 触发时刻对齐偏移(分钟,需 `*60 < 更新间隔`) |
|
||||
| `random_anchor_minutes` | int | 触发时刻对齐偏移(分钟,0~1439) |
|
||||
| `random_kind` | string | 自增长方式:`mult` / `add` |
|
||||
| `random_step_min` | int | add 增量下限(基础单位,≥0) |
|
||||
| `random_step_max` | int | add 增量上限(基础单位,≥下限) |
|
||||
| `real_offset` | int | real 基数偏移(基础单位,≥0) |
|
||||
| `allow_decrease` | bool | 允许展示值下降(默认 false=只增不减) |
|
||||
| `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** |
|
||||
| `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) |
|
||||
|
||||
### 出参
|
||||
响应 `200`:更新后的 `PlatformStatItemOut`(同上)。
|
||||
|
||||
### 错误
|
||||
- `400`:mode 非法 / 倍率越界(<1000 或 >5000)/ 上限<下限 / tick<60 / 负值。
|
||||
- `400`:mode/kind 非法 / 倍率越界(<1000 或 >1500)/ 上限<下限 / 增量负值或上限<下限 / 偏移负值 / tick<60 / 更新时间越界 / manual 负值。
|
||||
- `403`:角色不足(需 operator 或 super_admin)。
|
||||
- `404`:无效 metric(不在三指标内,作为 400 文案返回)。
|
||||
|
||||
### 说明
|
||||
- **统一定时刷新**:三模式的展示值(`random_current`)只在「更新时间(按更新间隔)」对齐的北京钟点刷新一次——real 快照查库 / manual 取固定值 / random ×倍率。`random_tick_seconds`+`random_anchor_minutes` 三模式通用。
|
||||
- 改 mode/manual/倍率等到**下个更新钟点**才在客户端生效(展示值不立即变);`random_initial` 例外:立即设为该值并重置(自增长设起点)。首次无展示值时按当前模式播种。
|
||||
- **统一定时刷新**:三模式的展示值(`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 含改前/改后快照)。
|
||||
- 客户端在**进首页 / 回前台时**重拉 `/stats` 取最新展示值。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Admin 首页轮播种子管理
|
||||
|
||||
> 所属: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 显示元)。
|
||||
|
||||
## 复用结构 MarqueeSeedOut
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | |
|
||||
| `masked_user` | string \| null | 脱敏用户名;`null` = feed 随机合成 |
|
||||
| `min_cents` | int | 金额区间下限(分) |
|
||||
| `max_cents` | int | 金额区间上限(分);固定金额则与下限相等 |
|
||||
| `enabled` | bool | 停用不参与混播 |
|
||||
| `sort_order` | int | 仅后台列表排序(小在前) |
|
||||
| `created_at` | string \| null | 创建时间(ISO) |
|
||||
|
||||
## GET /admin/api/marquee-seeds — 种子列表
|
||||
出参 `200`:`list[MarqueeSeedOut]`(按 `sort_order,id`)。
|
||||
|
||||
## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed
|
||||
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。
|
||||
- 入参:`limit`(query,1~30,默认 8)
|
||||
- 出参 `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`=金额非法。
|
||||
|
||||
## 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`=数量 / 金额非法。
|
||||
|
||||
## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计)
|
||||
入参 `MarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`。
|
||||
- `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。
|
||||
- 只改单边 `min_cents` / `max_cents` 时,与现有的另一边一起做「上限≥下限」校验。
|
||||
出参:更新后的 `MarqueeSeedOut`。`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 可用。
|
||||
- 改动客户端**进首页 / 回前台**重拉 feed 时生效。
|
||||
@@ -0,0 +1,27 @@
|
||||
# GET /api/v1/platform/savings-feed — 首页轮播 feed(真实+种子混播)
|
||||
|
||||
> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无** | [← 返回 API 索引](./README.md)
|
||||
|
||||
客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([marquee_seed](../database/marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。
|
||||
|
||||
## 入参
|
||||
| 参数 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `limit` | int, query | 返回条数,默认 8,范围 1~30 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`SavingsFeedOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `items` | list | 轮播条目数组(最多 `limit` 条) |
|
||||
| `items[].masked_user` | string | 脱敏用户名,如 `用户********a52` |
|
||||
| `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 |
|
||||
| `items[].time` | string | 北京时间 `HH:MM:SS` |
|
||||
|
||||
## 说明
|
||||
- 真实条:`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)。
|
||||
- **`time` 为合成的「最近」时间**(从当前北京时间往前递减,首条约 20 秒前,其余每条 2~6 分钟):社会证明轮播保证永远像刚发生,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。
|
||||
- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。
|
||||
- 逻辑见 `app/repositories/marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。
|
||||
@@ -18,9 +18,10 @@
|
||||
|
||||
## 说明
|
||||
三指标各自独立选模式,且**统一「定时刷新」**:客户端看到的展示值只在「更新时间(按更新间隔)」对齐的北京钟点(`anchor + k*间隔`)刷新一次,平时不变。每次刷新按模式算新值:
|
||||
- **real**:重新快照查库。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和。
|
||||
- **real**:重新快照查库 + 基数偏移。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和(**只计 `0<saved≤300 元` 的条目**,剔除 bug 异常大值);最终展示 = 真实值 + 运营配的 `real_offset`。
|
||||
- **manual**:取运营当前手填的固定值(改值到下个更新钟点生效)。
|
||||
- **random**:在上次展示值上 ×一个 [min,max](≥1.0)的随机倍率,只增不减(跨几个钟点乘几次)。
|
||||
- **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))。
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `platform_stat_display` | 首页三统计展示配置(real/manual/random) | `models/platform_stat.py` | [详情](./platform_stat_display.md) |
|
||||
| `marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/marquee_seed.py` | [详情](./marquee_seed.md) |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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)
|
||||
|
||||
首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/marquee.py`。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,如 `用户********a52`(整串存)。**留空 → feed 展示时按脱敏格式随机合成**(`用户********`+3 位 hex,避开同屏撞名),风格与真实条一致、永不穿帮 |
|
||||
| `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) |
|
||||
| `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** |
|
||||
| `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 |
|
||||
| `sort_order` | Integer | NOT NULL, default 0 | **仅后台列表排序**(小在前);feed 是公平随机抽取,不看此字段 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | 创建时间 |
|
||||
|
||||
## 初始数据(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` 改为可空。)
|
||||
|
||||
## 说明
|
||||
- feed 取真实(`comparison_record` success 且 `0<saved≤300 元`,按 user 去重)优先,不足用本表启用种子**公平随机抽取**补齐(不再固定取前 N,所有种子都有机会露出)。种子用户名空则随机合成、金额区间随机取值。展示时间统一刷新成「相对现在的最近时刻」(详见 [platform-savings-feed](../api/platform-savings-feed.md))。
|
||||
- 改种子走 admin `/admin/api/marquee-seeds` CRUD + 批量生成(`/bulk`)+ 预览(`/preview`),写操作带审计。
|
||||
@@ -16,8 +16,13 @@
|
||||
| `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_current` | Integer | nullable | 当前展示值(所有模式,基础单位);惰性 tick 改写 |
|
||||
| `random_last_tick_at` | DateTime(tz) | nullable | 上次 tick 时刻 |
|
||||
| `random_kind` | String(8) | NOT NULL, default `mult` | 自增长方式:`mult` ×倍率 / `add` +绝对增量 |
|
||||
| `random_step_min` | Integer | NOT NULL, default 0 | add 模式增量下限(基础单位) |
|
||||
| `random_step_max` | Integer | NOT NULL, default 0 | add 模式增量上限(基础单位) |
|
||||
| `real_offset` | Integer | NOT NULL, default 0 | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 |
|
||||
| `allow_decrease` | Boolean | NOT NULL, default false | 是否允许展示值下降(默认 false = 只增不减) |
|
||||
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 |
|
||||
|
||||
@@ -34,13 +39,15 @@
|
||||
`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 只增不减)。
|
||||
- **real** → `random_current` = 重新查库的真实值 + `real_offset`(跨多少边界都只取最新),**经只增不减护栏**。
|
||||
- **manual** → `random_current` = 当前 `manual_value`,**经只增不减护栏**。
|
||||
- **random** → 按 `random_kind` 走 N 个周期:`mult` 连乘随机倍率(`randint(min,max)/1000`,恒 ≥1.0)/ `add` 连加随机增量(`randint(step_min,step_max)`,≥0)。天然只增。
|
||||
- **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时若新目标值 < 当前 `random_current`,保持当前值不回退(门面忌缩水)。开 `allow_decrease` 才允许下降。
|
||||
- 用边界索引比较,`random_last_tick_at` 直接记 `now`,不重复计。
|
||||
- ⚠️ **接受刷新时刻的微小并发竞态**(不加行锁):纯门面数字,无业务后果。
|
||||
- `random_current` 首次为空时按当前模式播种(`update_config` 或 `_refresh` 兜底);`random_initial` 可立即设起点。
|
||||
- `random_current` 首次为空时按当前模式播种(`update_config` 或 `_refresh` 兜底);`random_initial` 可立即设起点(不受护栏约束)。
|
||||
|
||||
## 说明
|
||||
- real 口径仅统计 `comparison_record` 中 `status='success'` 的记录(去重用户数 / 记录数 / `saved_amount_cents` 求和)。
|
||||
- real 口径仅统计 `comparison_record` 中 `status='success'` 的记录(去重用户数 / 记录数)。`total_saved` 的求和**只计入 `0 < saved_amount_cents ≤ 300 元` 的条目**(剔除 bug 异常大值,与轮播口径一致,防一条异常配合「只增不减」永久撑高门面)。
|
||||
- `倍率上限收紧为 1.500`(单次 ×1.5,原 5.0 太大一跳就假)。
|
||||
- 改配置走 admin `PATCH /admin/api/dashboard-display/{metric}`,带审计。
|
||||
|
||||
Reference in New Issue
Block a user