diff --git a/alembic/versions/marquee_seed_range.py b/alembic/versions/marquee_seed_range.py new file mode 100644 index 0000000..5333ba8 --- /dev/null +++ b/alembic/versions/marquee_seed_range.py @@ -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') diff --git a/alembic/versions/marquee_seed_table.py b/alembic/versions/marquee_seed_table.py new file mode 100644 index 0000000..7eae916 --- /dev/null +++ b/alembic/versions/marquee_seed_table.py @@ -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') diff --git a/alembic/versions/platform_stat_growth_offset.py b/alembic/versions/platform_stat_growth_offset.py new file mode 100644 index 0000000..728776d --- /dev/null +++ b/alembic/versions/platform_stat_growth_offset.py @@ -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') diff --git a/app/admin/main.py b/app/admin/main.py index 3862566..2cdb21c 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -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) diff --git a/app/admin/routers/dashboard_display.py b/app/admin/routers/dashboard_display.py index 2f18dad..69ce72d 100644 --- a/app/admin/routers/dashboard_display.py +++ b/app/admin/routers/dashboard_display.py @@ -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, diff --git a/app/admin/routers/marquee_seed.py b/app/admin/routers/marquee_seed.py new file mode 100644 index 0000000..df4a6f8 --- /dev/null +++ b/app/admin/routers/marquee_seed.py @@ -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} diff --git a/app/admin/schemas/dashboard_display.py b/app/admin/schemas/dashboard_display.py index 1814553..7913135 100644 --- a/app/admin/schemas/dashboard_display.py +++ b/app/admin/schemas/dashboard_display.py @@ -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 diff --git a/app/admin/schemas/marquee_seed.py b/app/admin/schemas/marquee_seed.py new file mode 100644 index 0000000..b144020 --- /dev/null +++ b/app/admin/schemas/marquee_seed.py @@ -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] diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index 8ecfdb4..4d17bb5 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -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]) diff --git a/app/models/__init__.py b/app/models/__init__.py index b8055b0..9b70807 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.feedback import Feedback # noqa: F401 +from app.models.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 diff --git a/app/models/marquee_seed.py b/app/models/marquee_seed.py new file mode 100644 index 0000000..bbc7970 --- /dev/null +++ b/app/models/marquee_seed.py @@ -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"" diff --git a/app/models/platform_stat.py b/app/models/platform_stat.py index a9aa130..b7d5fcd 100644 --- a/app/models/platform_stat.py +++ b/app/models/platform_stat.py @@ -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), diff --git a/app/repositories/marquee.py b/app/repositories/marquee.py new file mode 100644 index 0000000..999a53c --- /dev/null +++ b/app/repositories/marquee.py @@ -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 diff --git a/app/repositories/platform_stat.py b/app/repositories/platform_stat.py index 567314f..3c537a0 100644 --- a/app/repositories/platform_stat.py +++ b/app/repositories/platform_stat.py @@ -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: # 首次无值:按当前模式播种,使配置后立即有合理展示值 diff --git a/app/schemas/platform.py b/app/schemas/platform.py index b978135..c49cd08 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -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] diff --git a/docs/api/README.md b/docs/api/README.md index 4f7e3da..31c8a45 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,7 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新: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/` | 无 | 用户头像;返回二进制图片 | | - | `GET /media/feedback/` | 无 | 反馈截图;返回二进制图片 | @@ -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` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/admin-dashboard-display.md b/docs/api/admin-dashboard-display.md index fbd9711..c753b03 100644 --- a/docs/api/admin-dashboard-display.md +++ b/docs/api/admin-dashboard-display.md @@ -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` 取最新展示值。 diff --git a/docs/api/admin-marquee-seeds.md b/docs/api/admin-marquee-seeds.md new file mode 100644 index 0000000..b130785 --- /dev/null +++ b/docs/api/admin-marquee-seeds.md @@ -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 时生效。 diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md new file mode 100644 index 0000000..2aeb544 --- /dev/null +++ b/docs/api/platform-savings-feed.md @@ -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)。 diff --git a/docs/api/platform-stats.md b/docs/api/platform-stats.md index 21aaf30..9c154f9 100644 --- a/docs/api/platform-stats.md +++ b/docs/api/platform-stats.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 模型 `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