diff --git a/alembic/versions/coin_reward_points_phase1.py b/alembic/versions/coin_reward_points_phase1.py new file mode 100644 index 0000000..7293a0e --- /dev/null +++ b/alembic/versions/coin_reward_points_phase1.py @@ -0,0 +1,76 @@ +"""coin reward points phase 1 tables + +Revision ID: coin_reward_phase1 +Revises: pstat3growth +Create Date: 2026-06-07 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'coin_reward_phase1' +down_revision: Union[str, Sequence[str], None] = 'pstat3growth' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'signin_boost_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('signin_date', sa.Date(), nullable=False), + sa.Column('coin_awarded', sa.Integer(), nullable=False), + sa.Column('ad_ref_id', sa.String(length=64), nullable=True), + sa.Column( + 'created_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False, + ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_boost_user_date'), + ) + with op.batch_alter_table('signin_boost_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_signin_boost_record_user_id'), ['user_id'], unique=False) + + op.create_table( + 'ad_feed_reward_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('client_event_id', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('reward_date', sa.String(length=10), nullable=False), + sa.Column('duration_seconds', sa.Integer(), nullable=False), + sa.Column('unit_count', sa.Integer(), nullable=False), + sa.Column('ecpm_raw', sa.String(length=32), nullable=False), + sa.Column('adn', sa.String(length=32), nullable=True), + sa.Column('slot_id', sa.String(length=64), nullable=True), + sa.Column('coin', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column( + 'created_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False, + ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('client_event_id', name='uq_ad_feed_reward_client_event'), + ) + with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_reward_date'), ['reward_date'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_created_at'), ['created_at'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_created_at')) + batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_reward_date')) + batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_user_id')) + op.drop_table('ad_feed_reward_record') + + with op.batch_alter_table('signin_boost_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_signin_boost_record_user_id')) + op.drop_table('signin_boost_record') 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/ops_rename_tables.py b/alembic/versions/ops_rename_tables.py new file mode 100644 index 0000000..bb19023 --- /dev/null +++ b/alembic/versions/ops_rename_tables.py @@ -0,0 +1,28 @@ +"""运营可配表加 ops_ 前缀:platform_stat_display→ops_stat_config / marquee_seed→ops_marquee_seed + +把首页门面数据的两张「运营可配」表重命名,与代码侧 ops_ 前缀(模型 OpsStatConfig/OpsMarqueeSeed、 +repo ops_stat/ops_marquee、admin ops_stat_config/ops_marquee_seed)对齐。仅改表名,不动列与数据。 + +Revision ID: opsrename01 +Revises: coin_reward_phase1 +Create Date: 2026-06-07 16:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op + + +revision: str = 'opsrename01' +down_revision: Union[str, Sequence[str], None] = 'coin_reward_phase1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.rename_table('platform_stat_display', 'ops_stat_config') + op.rename_table('marquee_seed', 'ops_marquee_seed') + + +def downgrade() -> None: + op.rename_table('ops_stat_config', 'platform_stat_display') + op.rename_table('ops_marquee_seed', 'marquee_seed') diff --git a/alembic/versions/platform_stat_anchor_minutes.py b/alembic/versions/platform_stat_anchor_minutes.py new file mode 100644 index 0000000..be07da4 --- /dev/null +++ b/alembic/versions/platform_stat_anchor_minutes.py @@ -0,0 +1,30 @@ +"""platform_stat_display 增加 random_anchor_minutes(自增长触发时刻对齐偏移) + +Revision ID: pstat2anchor01 +Revises: pstat1d2e3f4a +Create Date: 2026-06-06 13:00:00.000000 + +自增长 tick 从「启动时刻 + 间隔」改为「北京时间钟点对齐(anchor + k*interval)」, +新增列存对齐偏移(距 0 点分钟数)。已有行默认 0(= 对齐到自然边界:天→0点、小时→整点)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'pstat2anchor01' +down_revision: Union[str, Sequence[str], None] = 'pstat1d2e3f4a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'platform_stat_display', + sa.Column('random_anchor_minutes', sa.Integer(), nullable=False, server_default='0'), + ) + + +def downgrade() -> None: + op.drop_column('platform_stat_display', 'random_anchor_minutes') diff --git a/alembic/versions/platform_stat_display_table.py b/alembic/versions/platform_stat_display_table.py new file mode 100644 index 0000000..4a640fe --- /dev/null +++ b/alembic/versions/platform_stat_display_table.py @@ -0,0 +1,59 @@ +"""platform_stat_display table (首页三统计展示配置:每指标独立 real/manual/random) + +Revision ID: pstat1d2e3f4a +Revises: withdraw_review_ad_watch +Create Date: 2026-06-06 12:00:00.000000 + +播种 3 行:默认 manual + 客户端原写死门面值(12847 人 / 86532 次 / 37621.4 元), +保证上线前后展示一致,运营再按需切模式。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'pstat1d2e3f4a' +down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + table = op.create_table( + 'platform_stat_display', + sa.Column('metric', sa.String(length=32), nullable=False), + sa.Column('mode', sa.String(length=16), nullable=False), + sa.Column('manual_value', sa.Integer(), nullable=True), + sa.Column('random_mult_min', sa.Integer(), nullable=False), + sa.Column('random_mult_max', sa.Integer(), nullable=False), + sa.Column('random_tick_seconds', sa.Integer(), nullable=False), + sa.Column('random_current', sa.Integer(), nullable=True), + sa.Column('random_last_tick_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_by_admin_id', sa.Integer(), nullable=True), + sa.Column( + 'updated_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False, + ), + sa.PrimaryKeyConstraint('metric'), + ) + + _common = { + 'mode': 'manual', + 'random_mult_min': 1000, # 1.000(只增不减下限) + 'random_mult_max': 1100, # 1.100 + 'random_tick_seconds': 86400, # 1 天 + } + op.bulk_insert( + table, + [ + {'metric': 'help_users', 'manual_value': 12847, **_common}, + {'metric': 'total_compares', 'manual_value': 86532, **_common}, + {'metric': 'total_saved', 'manual_value': 3762140, **_common}, # 37621.40 元 + ], + ) + + +def downgrade() -> None: + op.drop_table('platform_stat_display') diff --git a/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 c5fda29..74a06bb 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -18,7 +18,9 @@ from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router from app.admin.routers.config import router as config_router from app.admin.routers.dashboard import router as dashboard_router +from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.feedback import router as feedback_router +from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router @@ -73,6 +75,8 @@ def health() -> dict[str, str]: admin_app.include_router(auth_router) admin_app.include_router(dashboard_router) +admin_app.include_router(ops_stat_config_router) +admin_app.include_router(ops_marquee_seed_router) admin_app.include_router(users_router) admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 494da35..6ab6c41 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -34,8 +34,8 @@ def _validate(key: str, value: Any) -> None: raise ValueError("需为非空整数列表") if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value): raise ValueError("列表元素需为非负整数") - if key == "signin_rewards" and len(value) != 7: - raise ValueError("签到档位必须正好 7 个(对应 7 天循环)") + if key == "signin_rewards" and len(value) != 14: + raise ValueError("签到档位必须正好 14 个(对应 14 天循环)") elif t == "dict_str_int": if not isinstance(value, dict) or not all( isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool) diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py new file mode 100644 index 0000000..53084ff --- /dev/null +++ b/app/admin/routers/ops_marquee_seed.py @@ -0,0 +1,160 @@ +"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。 + +种子用于首页轮播「真实+种子混播」的兜底(见 app/repositories/ops_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.ops_marquee_seed import ( + OpsMarqueeSeedBulkCreate, + OpsMarqueeSeedCreate, + OpsMarqueeSeedOut, + OpsMarqueeSeedUpdate, + OpsSavingsFeedPreviewOut, +) +from app.models.admin import AdminUser +from app.models.ops_marquee_seed import OpsMarqueeSeed +from app.repositories import ops_marquee + +router = APIRouter( + prefix="/admin/api/marquee-seeds", + tags=["admin-marquee-seeds"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(seed: OpsMarqueeSeed) -> OpsMarqueeSeedOut: + return OpsMarqueeSeedOut( + 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[OpsMarqueeSeedOut], summary="轮播种子列表") +def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]: + return [_out(s) for s in ops_marquee.list_seeds(db)] + + +@router.get("/preview", response_model=OpsSavingsFeedPreviewOut, summary="预览实际混播 feed") +def preview_feed( + db: AdminDb, + limit: Annotated[int, Query(ge=1, le=30)] = 8, +) -> OpsSavingsFeedPreviewOut: + """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。""" + return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit)) + + +@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)") +def create_seed( + body: OpsMarqueeSeedCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OpsMarqueeSeedOut: + try: + seed = ops_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="ops_marquee_seed.create", target_type="ops_marquee_seed", + target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(seed) + return _out(seed) + + +@router.post("/bulk", response_model=list[OpsMarqueeSeedOut], summary="批量生成种子(带审计)") +def bulk_generate( + body: OpsMarqueeSeedBulkCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> list[OpsMarqueeSeedOut]: + try: + seeds = ops_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="ops_marquee_seed.bulk_generate", target_type="ops_marquee_seed", + target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False, + ) + db.commit() + for s in seeds: + db.refresh(s) + return [_out(s) for s in seeds] + + +@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)") +def update_seed( + seed_id: int, + body: OpsMarqueeSeedUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OpsMarqueeSeedOut: + try: + seed = ops_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="ops_marquee_seed.update", target_type="ops_marquee_seed", + target_id=seed_id, detail=body.model_dump(exclude_none=True), + ip=get_client_ip(request), commit=False, + ) + 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 = ops_marquee.delete_seed(db, seed_id, commit=False) + if not ok: + raise HTTPException(status_code=404, detail="种子不存在") + write_audit( + db, admin, action="ops_marquee_seed.delete", target_type="ops_marquee_seed", + target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False, + ) + db.commit() + return {"deleted": True} diff --git a/app/admin/routers/ops_stat_config.py b/app/admin/routers/ops_stat_config.py new file mode 100644 index 0000000..f422ae6 --- /dev/null +++ b/app/admin/routers/ops_stat_config.py @@ -0,0 +1,74 @@ +"""admin 首页三统计展示配置:列出三指标配置(+ 真实值预览)+ 改某指标(带审计 + 校验)。 + +每指标可独立选 real/manual/random。计算逻辑见 app/repositories/ops_stat.py。 +权限:operator 可改(运营调门面数字),super 恒可。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.ops_stat_config import ( + OpsStatItemOut, + OpsStatUpdateRequest, +) +from app.models.admin import AdminUser +from app.repositories import ops_stat + +router = APIRouter( + prefix="/admin/api/dashboard-display", + tags=["admin-dashboard-display"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=list[OpsStatItemOut], summary="首页三统计配置 + 真实值预览") +def list_display(db: AdminDb) -> list[OpsStatItemOut]: + return [OpsStatItemOut(**item) for item in ops_stat.get_config(db)] + + +@router.patch( + "/{metric}", response_model=OpsStatItemOut, summary="改某指标展示配置(带审计)" +) +def update_display( + metric: str, + body: OpsStatUpdateRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OpsStatItemOut: + try: + before, after = ops_stat.update_config( + db, + metric, + mode=body.mode, + manual_value=body.manual_value, + random_mult_min=body.random_mult_min, + random_mult_max=body.random_mult_max, + random_tick_seconds=body.random_tick_seconds, + random_anchor_minutes=body.random_anchor_minutes, + random_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, + commit=False, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + write_audit( + db, admin, action="ops_stat_config.set", target_type="ops_stat_config", + target_id=metric, detail={"before": before, "after": after}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + + item = next(it for it in ops_stat.get_config(db) if it["metric"] == metric) + return OpsStatItemOut(**item) diff --git a/app/admin/schemas/ops_marquee_seed.py b/app/admin/schemas/ops_marquee_seed.py new file mode 100644 index 0000000..6095318 --- /dev/null +++ b/app/admin/schemas/ops_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 OpsMarqueeSeedOut(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 OpsMarqueeSeedCreate(BaseModel): + masked_user: str | None = None # 空 / 不传 → feed 随机合成脱敏名 + min_cents: int + max_cents: int + enabled: bool = True + sort_order: int = 0 + + +class OpsMarqueeSeedUpdate(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 OpsMarqueeSeedBulkCreate(BaseModel): + """批量生成 count 条自动合成名种子(冷启动铺量)。""" + count: int + min_cents: int + max_cents: int + enabled: bool = True + + +class OpsSavingsFeedPreviewItem(BaseModel): + masked_user: str + saved_amount_cents: int + time: str + + +class OpsSavingsFeedPreviewOut(BaseModel): + """运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。""" + items: list[OpsSavingsFeedPreviewItem] diff --git a/app/admin/schemas/ops_stat_config.py b/app/admin/schemas/ops_stat_config.py new file mode 100644 index 0000000..0a3a473 --- /dev/null +++ b/app/admin/schemas/ops_stat_config.py @@ -0,0 +1,48 @@ +"""admin 首页三统计展示配置 schemas。 + +倍率用千分比整数(1.000→1000);total_saved 的 manual_value / random_current 单位是分。 +""" +from __future__ import annotations + +from pydantic import BaseModel + + +class OpsStatItemOut(BaseModel): + metric: str # help_users / total_compares / total_saved + label: str # 帮助用户 / 完成比价 / 累计节省 + unit: str # 人 / 次 / 分 + mode: str # real / manual / random + manual_value: int | None = None + random_mult_min: int + random_mult_max: int + random_tick_seconds: int + random_anchor_minutes: int + random_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 # 当前真实值(纯真实、不含偏移;给运营对比参考) + updated_at: str | None = None + + +class OpsStatUpdateRequest(BaseModel): + """改某指标配置(均可选,只改传了的字段)。""" + + mode: str | None = None + manual_value: int | None = None + random_mult_min: int | None = None + random_mult_max: int | None = None + random_tick_seconds: int | None = None + random_anchor_minutes: int | None = None + random_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 走一档) + apply_now: bool = False diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index ecff3b6..d6e6b99 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -20,12 +20,15 @@ from app.core.config import settings from app.integrations import pangle from app.core.ratelimit import rate_limit from app.repositories import ad_ecpm as crud_ecpm +from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch from app.schemas.ad import ( AdRewardStatusOut, EcpmReportIn, EcpmReportOut, + FeedRewardIn, + FeedRewardOut, PangleCallbackOut, TestGrantOut, WatchReportIn, @@ -112,15 +115,15 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: @router.post( "/watch-report", response_model=WatchReportOut, - summary="上报激励视频观看时长(每日 50 分钟防刷计时)", + summary="上报激励视频观看时长(旧客户端兼容字段)", dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))], ) def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut: """客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。 Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。 - 当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0, - 客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。 + 当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口 + 仍保留用于旧客户端兼容和排查观看时长。 """ total = crud_watch.add_watch_seconds(db, user.id, payload.seconds) limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT @@ -198,3 +201,36 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut: round_count=round_count, cooldown_until=cooldown_until, ) + + +@router.post( + "/feed-reward", + response_model=FeedRewardOut, + summary="信息流广告完成后结算金币", + dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))], +) +def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut: + """点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。 + + 当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。 + """ + rec = crud_feed.grant_feed_reward( + db, + user.id, + client_event_id=payload.client_event_id, + ecpm=payload.ecpm, + duration_seconds=payload.duration_seconds, + adn=payload.adn, + slot_id=payload.slot_id, + ) + logger.info( + "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", + user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin, + ) + return FeedRewardOut( + granted=(rec.status == "granted"), + status=rec.status, + coin=rec.coin, + unit_count=rec.unit_count, + daily_limit=rewards.get_ad_daily_limit(db), + ) diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 40100b5..9c8f55b 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession from app.repositories import comparison as crud_compare from app.schemas.compare_record import ( + CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, ComparisonRecordIn, @@ -52,6 +53,16 @@ def report_record( return ComparisonRecordCreatedOut(id=rec.id) +@router.get( + "/stats", + response_model=CompareStatsOut, + summary="比价口径战绩(完成比价数 + 累计发现可省)", +) +def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut: + count, saved = crud_compare.get_stats(db, user.id) + return CompareStatsOut(compare_count=count, discovered_saved_cents=saved) + + @router.get( "/records", response_model=ComparisonRecordPage, diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py new file mode 100644 index 0000000..5c3c9d1 --- /dev/null +++ b/app/api/v1/platform.py @@ -0,0 +1,37 @@ +"""首页平台级展示数据 endpoint(全平台门面数字,**不鉴权**——登录前首页也要展示)。 + +路由前缀 `/api/v1/platform`: + GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。 + +展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Query + +from app.api.deps import DbSession +from app.repositories import ops_marquee as marquee_crud +from app.repositories import ops_stat as crud +from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut + +logger = logging.getLogger("shagua.platform") + +router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) + + +@router.get("/stats", response_model=PlatformStatsOut, summary="首页三统计(全平台门面数字)") +def stats(db: DbSession) -> PlatformStatsOut: + v = crud.get_display_values(db) + return PlatformStatsOut( + help_users=v["help_users"], + total_compares=v["total_compares"], + total_saved_cents=v["total_saved"], + ) + + +@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/api/v1/signin.py b/app/api/v1/signin.py index 3bd440a..001b09d 100644 --- a/app/api/v1/signin.py +++ b/app/api/v1/signin.py @@ -1,8 +1,9 @@ """签到 endpoint。 路由前缀 `/api/v1/signin`: - GET /status 今日签到状态 + 7 天档位 + GET /status 今日签到状态 + 14 天档位 POST / 执行今日签到 + POST /boost 签到后看广告膨胀金币 """ from __future__ import annotations @@ -12,7 +13,12 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import CurrentUser, DbSession from app.repositories import signin as crud_signin -from app.schemas.welfare import SigninResultOut, SigninStatusOut +from app.schemas.welfare import ( + SigninBoostRequest, + SigninBoostResultOut, + SigninResultOut, + SigninStatusOut, +) logger = logging.getLogger("shagua.signin") @@ -42,3 +48,27 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut: streak=record.streak, coin_balance=balance, ) + + +@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币") +def boost_signin( + payload: SigninBoostRequest, user: CurrentUser, db: DbSession +) -> SigninBoostResultOut: + try: + record, balance = crud_signin.boost_today_signin( + db, user.id, ad_ref_id=payload.ad_ref_id + ) + except crud_signin.NotSignedTodayError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="not signed today") from e + except crud_signin.AlreadyBoostedError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already boosted today") from e + + logger.info( + "signin boost ok user_id=%d date=%s coin=%d", + user.id, record.signin_date, record.coin_awarded, + ) + return SigninBoostResultOut( + coin_awarded=record.coin_awarded, + coin_balance=balance, + signin_date=record.signin_date.isoformat(), + ) diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 9fefc6e..ab6d4c8 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -14,9 +14,9 @@ from app.core import rewards as r # type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { - "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", + "default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位", "group": "签到", "type": "int_list", - "help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。", + "help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14。", }, "min_exchange_coin": { "default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币", @@ -46,7 +46,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "ad_daily_limit": { "default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)", - "group": "看广告", "type": "int", + "group": "看广告", "type": "int", "help": "福利页激励视频每日可发奖次数上限,默认 500。", }, "ad_max_coin": { "default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限", @@ -54,10 +54,10 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "ad_round_count": { "default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数", - "group": "看广告", "type": "int", "help": "看完一轮触发冷却。", + "group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。", }, "ad_cooldown_sec": { - "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "每轮冷却(秒)", - "group": "看广告", "type": "int", + "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", + "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。", }, } diff --git a/app/core/rewards.py b/app/core/rewards.py index 8f1b745..2d06c31 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -17,9 +17,12 @@ def cn_today() -> date: return datetime.now(CN_TZ).date() -# ===== 签到:7 天循环,断签重置回第 1 天 ===== -# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引。 -SIGNIN_REWARDS: tuple[int, ...] = (10, 20, 30, 50, 80, 120, 200) +# ===== 签到:14 天循环,断签重置回第 1 天 ===== +# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。 +SIGNIN_REWARDS: tuple[int, ...] = ( + 200, 120, 150, 180, 200, 250, 500, + 200, 250, 300, 350, 400, 500, 3000, +) SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS) @@ -74,6 +77,64 @@ def record_milestone_reward(milestone: int) -> int: return RECORD_MILESTONES[milestone - 1] +# ===== 看激励视频 / 信息流广告发金币 ===== +# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。 +AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( + (0.1, 0, 100), + (0.3, 101, 200), + (0.4, 201, 400), + (0.6, 401, None), +) +AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( + (2.0, 1, 1), + (1.5, 2, 2), + (1.3, 3, 3), + (1.1, 4, 10), + (1.0, 11, None), +) + + +def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: + """解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。""" + if ecpm is None: + return 0.0 + try: + value = float(str(ecpm).strip()) + except (TypeError, ValueError): + return 0.0 + return max(0.0, value) + + +def ad_ecpm_factor(ecpm_yuan: float) -> float: + """eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。""" + if ecpm_yuan > 400: + return 0.6 + if ecpm_yuan > 200: + return 0.4 + if ecpm_yuan > 100: + return 0.3 + return 0.1 + + +def ad_lt_factor(today_count_after_this: int) -> float: + """LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。""" + count = max(1, today_count_after_this) + for factor, lo, hi in AD_LT_FACTOR_TABLE: + if count >= lo and (hi is None or count <= hi): + return factor + return 1.0 + + +def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int: + """按金币数值体系计算单份广告奖励金币。 + + 单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。 + """ + ecpm_yuan = parse_ecpm_yuan(ecpm) + yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) + return max(0, round(yuan * COIN_PER_YUAN)) + + # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== # 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)。 # 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"; @@ -83,25 +144,19 @@ def record_milestone_reward(milestone: int) -> int: AD_REWARD_COIN: int = 666 # 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。 MAX_AD_REWARD_COIN: int = 1000 -# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)。 -# 主闸是 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟);次数兜底要 ≥ 时长闸内的"合理最大次数", -# 否则会比主闸先掐、误伤看大量短广告的正常用户:50 分钟里若广告各 ~15s,理论可看 ~200 次, -# 故取 200(≈时长闸的次数等价),正常用户先撞 50 分钟时长闸,仅前端时长全报 0 等异常时本闸才兜住。 -# 不要为"更严"而下调到 120 之类(会在 ~30 分钟就掐短广告用户);要更严应在客户端如实上报时长。 -DAILY_AD_REWARD_LIMIT: int = 200 +# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。 +DAILY_AD_REWARD_LIMIT: int = 500 -# ===== 看激励视频每日总时长上限(防刷主闸)===== -# 用户每天最多看 50 分钟激励视频,超了不发奖 / 不给看。时长由前端 onAdClose 上报真实观看 -# 秒数累计(POST /api/v1/ad/watch-report → ad_watch_log 表 SUM)。前端不可信,故配合上面 -# DAILY_AD_REWARD_LIMIT 次数兜底一起防刷。要调上限直接改这里。 -DAILY_AD_WATCH_SECONDS_LIMIT: int = 50 * 60 # 3000 秒 = 50 分钟 +# ===== 看激励视频每日总时长上限(已停用)===== +# 旧版本用 50 分钟时长闸;当前产品只保留每日 500 次上限。保留字段返回给旧客户端, +# 但值为 0,客户端按 limit<=0 视为未启用。 +DAILY_AD_WATCH_SECONDS_LIMIT: int = 0 # 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。 MAX_SINGLE_WATCH_SECONDS: int = 120 -# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端, -# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit, -# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。 -VIDEO_ROUND_REQUIRED_COUNT: int = 3 -VIDEO_ROUND_COOLDOWN_SECONDS: int = 600 +# 每次广告关闭后 3 秒内不允许再次点击观看。沿用 round_count/cooldown_until 字段表达: +# round_size=1 表示每次成功发奖后都会派生一个 3 秒 cooldown_until。 +VIDEO_ROUND_REQUIRED_COUNT: int = 1 +VIDEO_ROUND_COOLDOWN_SECONDS: int = 3 def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001 diff --git a/app/main.py b/app/main.py index b718e3c..751a5d8 100644 --- a/app/main.py +++ b/app/main.py @@ -24,6 +24,7 @@ from app.api.internal.price import router as internal_price_router from app.api.v1.feedback import router as feedback_router from app.api.v1.meituan import router as meituan_router from app.api.v1.order import router as order_router +from app.api.v1.platform import router as platform_router from app.api.v1.report import router as report_router from app.api.v1.savings import router as savings_router from app.api.v1.signin import router as signin_router @@ -93,6 +94,7 @@ app.include_router(order_router) app.include_router(report_router) # 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。 app.include_router(internal_price_router) +app.include_router(platform_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 _media_root = Path(settings.MEDIA_ROOT) diff --git a/app/models/__init__.py b/app/models/__init__.py index b0f2bba..ca2d6d1 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ """所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" from app.models.ad_ecpm import AdEcpmRecord # noqa: F401 +from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 @@ -7,10 +8,12 @@ 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.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 +from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 from app.models.price_report import PriceReport # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 -from app.models.signin import SigninRecord # noqa: F401 +from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401 from app.models.task import UserTask # noqa: F401 from app.models.user import User # noqa: F401 from app.models.wallet import ( # noqa: F401 diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py new file mode 100644 index 0000000..c434312 --- /dev/null +++ b/app/models/ad_feed_reward.py @@ -0,0 +1,43 @@ +"""信息流广告奖励记录。 + +点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AdFeedRewardRecord(Base): + __tablename__ = "ad_feed_reward_record" + __table_args__ = ( + UniqueConstraint("client_event_id", name="uq_ad_feed_reward_client_event"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + client_event_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) + duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + unit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) + adn: Mapped[str | None] = mapped_column(String(32), nullable=True) + slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/models/ad_watch_log.py b/app/models/ad_watch_log.py index 5eb848c..7a9d5ed 100644 --- a/app/models/ad_watch_log.py +++ b/app/models/ad_watch_log.py @@ -1,15 +1,14 @@ -"""看激励视频观看时长记录(每日总时长防刷)。 +"""看激励视频观看时长记录(旧版每日总时长字段)。 每条 = 客户端 onAdClose 上报的一次激励视频实际观看秒数。按 (user_id, watch_date) 聚合 -SUM(watch_seconds) 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(超了不再发奖 / -不给看,见 core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT)。 +SUM(watch_seconds) 得当日总观看时长。当前产品只保留每日 500 次上限, +core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸;本表保留用于旧客户端兼容和排查。 这是看广告的第三条数据流,与另两条并列、互不关联: - ad_reward(AdRewardRecord):穿山甲 S2S 回调发金币(后端,有 trans_id); - ad_ecpm(AdEcpmRecord):客户端上报展示收益(前端,有 ecpm); - ad_watch_log(本表):客户端上报观看时长(前端,有 watch_seconds)。 -前端上报的时长不可信 → 时长是防刷"主闸",配合 ad_reward 的每日次数上限(DAILY_AD_REWARD_LIMIT) -做"次数兜底",前端少报时长也刷不过次数闸。 +前端上报的时长不可信,不能作为唯一发奖依据;当前发奖闸口以 ad_reward 的每日次数上限为准。 """ from __future__ import annotations diff --git a/app/models/ops_marquee_seed.py b/app/models/ops_marquee_seed.py new file mode 100644 index 0000000..2e64eb8 --- /dev/null +++ b/app/models/ops_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 OpsMarqueeSeed(Base): + __tablename__ = "ops_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/ops_stat_config.py b/app/models/ops_stat_config.py new file mode 100644 index 0000000..1681de9 --- /dev/null +++ b/app/models/ops_stat_config.py @@ -0,0 +1,70 @@ +"""首页三统计展示配置表(帮助用户 / 完成比价 / 累计节省)。 + +客户端首页顶部三个门面数字,每个指标可独立选 3 种展示模式(运营后台配): + - real : 实时查真实数据(help_users=去重成功比价用户数 / total_compares=成功比价数 / + total_saved=成功比价省额求和)。 + - manual : 直接用运营手填的固定值。 + - random : 「只增不减」的伪增长——读取时惰性 tick:距上次 tick 超过 random_tick_seconds + 就把 random_current 乘以一个 [min,max] 内的随机倍率(倍率恒 ≥1.0 → 只增不减)。 + +数值单位约定(三指标都用各自「基础单位」的整数存,避免浮点): + - help_users / total_compares : 个数(人 / 次) + - total_saved : 分(cents);客户端 ÷100 显示「元」 + +倍率用千分比整数存:1.000→1000、1.100→1100,读时 factor = randint(min,max)/1000。 + +一行一指标,主键 = metric。初始由 migration 播种 3 行(默认 manual + 当前写死门面值, +保证上线无视觉变化),运营再按需切模式。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class OpsStatConfig(Base): + __tablename__ = "ops_stat_config" + + # help_users / total_compares / total_saved + metric: Mapped[str] = mapped_column(String(32), primary_key=True) + # real / manual / random(每指标独立) + mode: Mapped[str] = mapped_column(String(16), nullable=False, default="real") + + # manual 模式固定值(基础单位整数;total_saved 为分) + manual_value: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # random 模式:倍率区间(千分比,≥1000 保证只增不减)+ tick 周期 + 当前值 + 上次 tick 时刻 + random_mult_min: Mapped[int] = mapped_column(Integer, nullable=False, default=1000) + random_mult_max: Mapped[int] = mapped_column(Integer, nullable=False, default=1100) + random_tick_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=86400) + # 触发时刻对齐偏移:距北京时间 0 点的分钟数。按单位:天=每日时刻(h*60+m)、小时=每小时第几分、 + # 分钟=0。刷新在北京时间 (anchor + k*interval) 的钟点边界触发(见 repo _refresh)。 + random_anchor_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + random_current: Mapped[int | None] = mapped_column(Integer, nullable=True) + random_last_tick_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # 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), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/signin.py b/app/models/signin.py index f23724a..63a9043 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,14 +1,14 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 - - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 + - cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 """ from __future__ import annotations from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -39,3 +39,31 @@ class SigninRecord(Base): f"" ) + + +class SigninBoostRecord(Base): + """签到后看广告膨胀记录。 + + 一天最多膨胀一次,补发金额等于当天签到原始奖励。独立表用于防并发重复补发, + 后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号。 + """ + + __tablename__ = "signin_boost_record" + __table_args__ = ( + UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + signin_date: Mapped[date] = mapped_column(Date, nullable=False) + coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False) + ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + + 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/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py new file mode 100644 index 0000000..1ab8994 --- /dev/null +++ b/app/repositories/ad_feed_reward.py @@ -0,0 +1,126 @@ +"""信息流广告奖励 CRUD。 + +点位 2:每展示满 10 秒累计一份奖励,视频完成后一次性入账。当前一期由客户端在 +完成回调后上报;后续若 SDK/S2S 能提供更强确认信号,可继续复用本表的幂等键。 +""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core import rewards +from app.core.rewards import cn_today +from app.models.ad_feed_reward import AdFeedRewardRecord +from app.repositories import wallet as crud_wallet + + +FEED_REWARD_UNIT_SECONDS = 10 + + +def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None: + return db.execute( + select(AdFeedRewardRecord).where( + AdFeedRewardRecord.client_event_id == client_event_id + ) + ).scalar_one_or_none() + + +def _granted_today(db: Session, user_id: int, reward_date: str) -> int: + return db.execute( + select(func.count()) + .select_from(AdFeedRewardRecord) + .where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.reward_date == reward_date, + AdFeedRewardRecord.status == "granted", + ) + ).scalar_one() + + +def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int: + """按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。""" + if unit_count <= 0: + return 0 + existing_units = db.execute( + select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) + .where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.reward_date == today, + AdFeedRewardRecord.status == "granted", + ) + ).scalar_one() + total = 0 + for offset in range(1, unit_count + 1): + total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset) + return total + + +def grant_feed_reward( + db: Session, + user_id: int, + *, + client_event_id: str, + ecpm: str, + duration_seconds: int, + adn: str | None = None, + slot_id: str | None = None, +) -> AdFeedRewardRecord: + """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。""" + existing = _find_by_event(db, client_event_id) + if existing is not None: + return existing + + today = cn_today().isoformat() + safe_duration = max(0, min(duration_seconds, 24 * 60 * 60)) + unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + + if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + coin=0, + status="capped", + ) + return _commit_record(db, rec, client_event_id) + + coin = _unit_reward_total(db, user_id, ecpm, unit_count, today) + if coin > 0: + crud_wallet.grant_coins( + db, user_id, coin, + biz_type="feed_ad_reward", ref_id=client_event_id, + remark=f"信息流广告奖励 {unit_count}份", + ) + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + coin=coin, + status="granted", + ) + return _commit_record(db, rec, client_event_id) + + +def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord: + db.add(rec) + try: + db.commit() + except IntegrityError: + db.rollback() + existing = _find_by_event(db, client_event_id) + if existing is not None: + return existing + raise + db.refresh(rec) + return rec diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index 77bee4b..ded6d73 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -72,10 +72,12 @@ def grant_ad_reward( today = cn_today().isoformat() - # #3 每日上限:观看时长(主闸,50 分钟) 或 发奖次数(兜底) 任一到顶 → 记 capped 不发金币, - # 让审计能看到"今天到顶了"。时长由前端 watch-report 累计(防刷主闸),次数防前端少报时长 - # 绕过;次数上限走 app_config(运营后台可改,默认 rewards.DAILY_AD_REWARD_LIMIT)。 - over_time = watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT + # #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段, + # 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。 + over_time = ( + DAILY_AD_WATCH_SECONDS_LIMIT > 0 + and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT + ) over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db) if over_time or over_count: rec = AdRewardRecord( @@ -137,8 +139,8 @@ def today_status( 返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC), 今日已观看总秒数, 每日观看总时长上限(秒))。 次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看 - 几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);时长维度(50 分钟 - 主闸)查 ad_watch_log 当日累计——前端据此展示"今日已看 X/50 分钟"+ 满则不给看。 + 几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);观看时长字段保留给 + 旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸。 """ today = cn_today().isoformat() granted_desc = _granted_times_today_desc(db, user_id, today) diff --git a/app/repositories/ad_watch.py b/app/repositories/ad_watch.py index c98ed69..75b859c 100644 --- a/app/repositories/ad_watch.py +++ b/app/repositories/ad_watch.py @@ -1,7 +1,7 @@ -"""看激励视频观看时长 CRUD(每日总时长防刷)。 +"""看激励视频观看时长 CRUD(旧版每日总时长字段)。 前端 onAdClose 上报本次观看秒数 → add_watch_seconds 落库 + 返回当日累计; -watched_seconds_today 给"时长闸"(发奖 / 展示前)判断当天是否已达 50 分钟上限。 +当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 时不再用本累计做发奖拦截。 鉴权接口已确保 user 存在(JWT),故不做 UnknownUser 校验。 """ from __future__ import annotations diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index f233c4f..acc0c66 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -163,6 +163,25 @@ def count_success(db: Session, user_id: int) -> int: ).scalar_one() +def get_stats(db: Session, user_id: int) -> tuple[int, int]: + """「我的」页省钱战绩卡的**比价口径**聚合:(成功比价数, 累计发现可省额/分)。 + + - 完成比价 = status='success' 的比价记录数(同 count_success)。 + - 累计发现可省 = 各成功比价 saved_amount_cents(源价−最低价)之和;null(没省/未识别)不计。 + 与「下单成交记账」savings_record 解耦:比过价就计,不要求下单(见 savings.py)。 + """ + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0), + ).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.status == "success", + ) + ).one() + return int(row[0]), int(row[1]) + + def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None: """取单条(限本人,避免越权读他人记录)。""" return db.execute( diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py new file mode 100644 index 0000000..6eb0c27 --- /dev/null +++ b/app/repositories/ops_marquee.py @@ -0,0 +1,246 @@ +"""首页轮播 feed:全平台真实比价记录 + 种子兜底「混播」,以及种子表 CRUD。 + +feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 ops_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.ops_marquee_seed import OpsMarqueeSeed + +# 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(OpsMarqueeSeed).where(OpsMarqueeSeed.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[OpsMarqueeSeed]: + return list( + db.execute( + select(OpsMarqueeSeed).order_by(OpsMarqueeSeed.sort_order, OpsMarqueeSeed.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, +) -> OpsMarqueeSeed: + _validate_amount(min_cents, max_cents) + seed = OpsMarqueeSeed( + 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, +) -> OpsMarqueeSeed: + """改种子。masked_user 约定:不传(None)=不改;传空串=清空(改回随机合成);传值=固定该名。""" + seed = db.get(OpsMarqueeSeed, 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[OpsMarqueeSeed]: + """批量生成 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(OpsMarqueeSeed.sort_order), 0)) + ).scalar_one() + created: list[OpsMarqueeSeed] = [] + for i in range(1, count + 1): + seed = OpsMarqueeSeed( + 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(OpsMarqueeSeed, 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/ops_stat.py b/app/repositories/ops_stat.py new file mode 100644 index 0000000..5dfd7f9 --- /dev/null +++ b/app/repositories/ops_stat.py @@ -0,0 +1,334 @@ +"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。 + +三种模式见 app/models/ops_stat_config.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 + +import random +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.comparison import ComparisonRecord +from app.models.ops_stat_config import OpsStatConfig + +# 指标顺序 + 展示元信息(label/unit 给运营后台用;total_saved 单位是分,客户端 ÷100 显示元) +METRICS = ("help_users", "total_compares", "total_saved") +_META = { + "help_users": ("帮助用户", "人"), + "total_compares": ("完成比价", "次"), + "total_saved": ("累计节省", "分"), +} +# 初始播种值(= 客户端原写死门面数:12847 人 / 86532 次 / 37621.4 元)。 +_SEED_VALUE = { + "help_users": 12847, + "total_compares": 86532, + "total_saved": 3762140, # 37621.40 元 = 3762140 分 +} + +# 防呆边界 +_MULT_FLOOR = 1000 # 倍率千分比下限 = 1.000(只增不减) +_MULT_CAP = 1500 # 倍率千分比上限 = 1.500(收紧:单次 ×1.5 已不小,>此一跳就假) +_STEP_CAP = 10**12 # 绝对增量单次上限(基础单位防呆) +_TICK_MIN_SECONDS = 60 # tick 周期下限 +_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: + """把(可能朴素的 SQLite)datetime 归一成带时区 UTC;朴素值按 UTC 解释。""" + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +# ===== 真实数据(real 模式)===== +def _real_value(db: Session, metric: str) -> int: + """real 口径:仅统计 status='success' 的比价记录。 + help_users=去重用户数 / total_compares=记录数 / total_saved=省额(分)求和(剔除单条异常大值)。 + """ + success = ComparisonRecord.status == "success" + if metric == "help_users": + return db.execute( + select(func.count(func.distinct(ComparisonRecord.user_id))).where(success) + ).scalar_one() + if metric == "total_compares": + return db.execute( + select(func.count(ComparisonRecord.id)).where(success) + ).scalar_one() + if metric == "total_saved": + # 只累加 0 < saved ≤ 上限 的条目,异常大值不计入(防 bug 值撑高门面) + return db.execute( + 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}") + + +# ===== 行管理 ===== +def _ensure_rows(db: Session) -> dict[str, OpsStatConfig]: + """保证三指标行都存在(migration 已播种;这里是缺行兜底,默认 manual + 种子值)。""" + rows = {r.metric: r for r in db.execute(select(OpsStatConfig)).scalars().all()} + created = False + for metric in METRICS: + if metric not in rows: + row = OpsStatConfig( + metric=metric, mode="manual", manual_value=_SEED_VALUE[metric] + ) + db.add(row) + rows[metric] = row + created = True + if created: + db.commit() + return rows + + +# ===== random 惰性 tick(北京钟点对齐)===== +def _boundary_index(epoch_utc: int, interval: int, anchor_sec: int) -> int: + """该 UTC 时刻落在第几个「北京钟点边界」。边界 = 北京时间 (anchor + k*interval)。""" + bj = epoch_utc + _BEIJING_OFFSET + return (bj - anchor_sec) // interval + + +def _current_target(db: Session, row: OpsStatConfig) -> int: + """某模式此刻「应有的展示值」(用于播种 / real·manual 的定时快照)。""" + if row.mode == "manual": + return max(0, int(row.manual_value or 0)) + if row.mode == "real": + # 真实值 + 基数偏移 + return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0)) + # random:无显式初始基数时用真实值(纯真实,不含偏移)播种 + return max(0, int(_real_value(db, row.metric))) + + +def _monotonic(row: OpsStatConfig, new_val: int) -> int: + """只增不减护栏:未开 allow_decrease 且已有展示值时,新值不得低于当前值(否则保持当前)。""" + if not row.allow_decrease and row.random_current is not None and new_val < row.random_current: + return row.random_current + return new_val + + +def _grow(row: OpsStatConfig, 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: OpsStatConfig) -> int: + """统一「定时刷新」:展示值 random_current 只在跨过「北京钟点边界」时刷新一次。 + real→重新快照(真实+偏移,受只增不减护栏)/ manual→取固定值(受护栏)/ random→按方式增长。 + 无初值则按模式播种。返回当前展示值(基础单位)。 + """ + now = datetime.now(timezone.utc) + if row.random_current is None or row.random_last_tick_at is None: + row.random_current = _current_target(db, row) + row.random_last_tick_at = now + db.commit() + return row.random_current + + interval = max(_TICK_MIN_SECONDS, row.random_tick_seconds) + anchor_sec = (max(0, row.random_anchor_minutes or 0) * 60) % interval + last = _as_utc(row.random_last_tick_at) + periods = _boundary_index(int(now.timestamp()), interval, anchor_sec) - _boundary_index( + int(last.timestamp()), interval, anchor_sec + ) + if periods <= 0: + return row.random_current + + if row.mode == "random": + row.random_current = _grow(row, row.random_current, min(periods, _MAX_CATCHUP_PERIODS)) + else: + # real / manual:跨多少边界都只取「此刻应有的值」快照一次,经只增不减护栏 + row.random_current = _monotonic(row, _current_target(db, row)) + row.random_last_tick_at = now # 用边界索引比较,直接记 now 不会重复计 + db.commit() + return row.random_current + + +# ===== 用户侧:展示值 ===== +def get_display_values(db: Session) -> dict[str, int]: + """返回 {metric: 展示整数值}(total_saved 为分)。三模式统一走定时刷新。""" + rows = _ensure_rows(db) + return {metric: int(_refresh(db, rows[metric])) for metric in METRICS} + + +# ===== 运营侧:配置读写 ===== +def _snapshot(row: OpsStatConfig) -> dict: + """审计 / 返回用的行快照。""" + return { + "mode": row.mode, + "manual_value": row.manual_value, + "random_mult_min": row.random_mult_min, + "random_mult_max": row.random_mult_max, + "random_tick_seconds": row.random_tick_seconds, + "random_anchor_minutes": row.random_anchor_minutes, + "random_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 + ), + } + + +def get_config(db: Session) -> list[dict]: + """三指标当前配置 + 真实值预览(给运营对比),按 METRICS 顺序。 + + 先跑一次定时刷新,使「当前展示值」在后台轮询时也随钟点实时推进。 + """ + rows = _ensure_rows(db) + out: list[dict] = [] + for metric in METRICS: + row = rows[metric] + _refresh(db, row) + label, unit = _META[metric] + out.append( + { + "metric": metric, + "label": label, + "unit": unit, + "real_value": _real_value(db, metric), # 纯真实(不含偏移),供运营对比 + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + **_snapshot(row), + } + ) + return out + + +def update_config( + db: Session, + metric: str, + *, + mode: str | None = None, + manual_value: int | None = None, + random_mult_min: int | None = None, + random_mult_max: int | None = None, + random_tick_seconds: int | None = None, + random_anchor_minutes: int | None = None, + random_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, + commit: bool = True, +) -> tuple[dict, dict]: + """改某指标配置。返回 (before, after) 快照供审计。非法入参抛 ValueError(router 转 400)。 + + 展示值 random_current 采用统一「定时刷新」:平时不动,到更新钟点才按模式刷新。 + 本函数只在「首次无值」时按模式播种;给了 random_initial 则立即设为该值并重置; + apply_now=True 则立即刷新一次(random 走一档增长,real/manual 取应有值,均经只增不减护栏)。 + """ + if metric not in METRICS: + raise ValueError(f"未知指标: {metric}") + rows = _ensure_rows(db) + row = rows[metric] + before = _snapshot(row) + + if mode is not None: + if mode not in ("real", "manual", "random"): + raise ValueError("mode 需为 real/manual/random") + row.mode = mode + if manual_value is not None: + if manual_value < 0: + raise ValueError("manual_value 需为非负整数") + row.manual_value = manual_value + if random_mult_min is not None: + if not (_MULT_FLOOR <= random_mult_min <= _MULT_CAP): + raise ValueError(f"倍率下限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}") + row.random_mult_min = random_mult_min + if random_mult_max is not None: + if not (_MULT_FLOOR <= random_mult_max <= _MULT_CAP): + raise ValueError(f"倍率上限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}") + row.random_mult_max = random_mult_max + if row.random_mult_max < row.random_mult_min: + raise ValueError("倍率上限不得小于下限") + if random_tick_seconds is not None: + if random_tick_seconds < _TICK_MIN_SECONDS: + raise ValueError(f"更新间隔不得小于 {_TICK_MIN_SECONDS} 秒") + row.random_tick_seconds = random_tick_seconds + if random_anchor_minutes is not None: + 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_last_tick_at = now + elif apply_now: + # 立即更新:不等钟点,马上刷新一次 + if row.mode == "random" and row.random_current is not None: + row.random_current = _grow(row, row.random_current, 1) + else: + row.random_current = _monotonic(row, _current_target(db, row)) + row.random_last_tick_at = now + elif row.random_current is None: + # 首次无值:按当前模式播种,使配置后立即有合理展示值 + row.random_current = _current_target(db, row) + row.random_last_tick_at = now + + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return before, _snapshot(row) diff --git a/app/repositories/savings.py b/app/repositories/savings.py index dbbbfe5..7eb5fa4 100644 --- a/app/repositories/savings.py +++ b/app/repositories/savings.py @@ -1,14 +1,17 @@ -"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。 +"""省钱 CRUD:累计/战绩聚合 + 明细分页(纯真实比价上报数据)。 -设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自 -demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。 +设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),数据只来自真实比价 +下单上报(`source='compare'`,见 create_from_report)。 + +⚠️ 2026-06 起**停用 demo 兜底**:比价下单上报链路已通(Android `PriceBotService` → +`POST /api/v1/order/report`),新用户无真实记录时直接显示空/0(配合「我的」页省钱战绩 +卡的「未比价锁定态」)。历史遗留的 `source='demo'` 行不再被任何统计口径计入。 聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑—— created_at 带时区,统一转成北京时间的 date 再分组。 """ from __future__ import annotations -import random from dataclasses import dataclass from datetime import datetime, timedelta @@ -19,26 +22,6 @@ from app.core.rewards import CN_TZ, cn_today from app.models.savings import SavingsRecord from app.schemas.order import OrderReportRequest -# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单 -_DEMO_STREAK_DAYS = 9 -_DEMO_EXTRA_ORDERS = 14 - -# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。 -# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中; -# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。 -_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [ - ("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]), - ("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]), - ("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]), - ("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]), - ("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]), - ("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]), - ("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]), - ("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]), - ("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]), - ("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]), -] - @dataclass class SavingsSummary: @@ -52,7 +35,7 @@ class SavingsBattle: week_saved_cents: int # 本周(周一起)已省 beat_percent: int # 超过百分之多少用户 streak_days: int # 连续省钱天数 - compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底) + compare_count: int # 累计完成比价次数(= 真实 compare 上报记录数) def _local_date(dt: datetime): @@ -62,75 +45,20 @@ def _local_date(dt: datetime): return dt.astimezone(CN_TZ).date() -def _all_records(db: Session, user_id: int) -> list[SavingsRecord]: - stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) +def _compare_records(db: Session, user_id: int) -> list[SavingsRecord]: + """统计/明细口径:只用真实比价上报(`source='compare'`)记录。 + + 2026-06 起停用 demo 兜底:无真实记录直接返回空(新用户显示 0/锁定态)。 + 历史 `source='demo'` 行不计入。 + """ + stmt = select(SavingsRecord).where( + SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" + ) return list(db.execute(stmt).scalars().all()) -def _has_real(db: Session, user_id: int) -> bool: - """该用户是否已有真实比价上报(source='compare')记录。""" - return db.execute( - select(SavingsRecord.id) - .where(SavingsRecord.user_id == user_id, SavingsRecord.source == "compare") - .limit(1) - ).first() is not None - - -def _effective_records(db: Session, user_id: int) -> list[SavingsRecord]: - """统计口径:有真实(compare)记录就只用真实的;否则 seed 一批 demo 并用 demo 兜底。""" - if _has_real(db, user_id): - stmt = select(SavingsRecord).where( - SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" - ) - return list(db.execute(stmt).scalars().all()) - ensure_seeded(db, user_id) - return _all_records(db, user_id) - - -def ensure_seeded(db: Session, user_id: int) -> None: - """该用户没有任何省钱记录时,幂等灌一批 demo 数据。""" - exists = db.execute( - select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1) - ).first() - if exists is not None: - return - - rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致 - today = cn_today() - records: list[SavingsRecord] = [] - - def _mk(day) -> SavingsRecord: - hour = rng.randint(8, 21) - minute = rng.randint(0, 59) - ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ) - platform, shop, dishes = rng.choice(_DEMO_ORDERS) - # 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡 - saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元 - return SavingsRecord( - user_id=user_id, - order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价) - saved_amount_cents=saved, - platform=platform, - title=shop, - shop_name=shop, - dishes=dishes, - source="demo", - created_at=ts, - ) - - # 最近 N 天每天 1 单 → 连续省钱 N 天 - for d in range(_DEMO_STREAK_DAYS): - records.append(_mk(today - timedelta(days=d))) - # 历史散单(第 10~40 天) - for _ in range(_DEMO_EXTRA_ORDERS): - records.append(_mk(today - timedelta(days=rng.randint(10, 40)))) - - db.add_all(records) - db.commit() - - def get_summary(db: Session, user_id: int) -> SavingsSummary: - records = _effective_records(db, user_id) + records = _compare_records(db, user_id) total = sum(r.saved_amount_cents for r in records) count = len(records) avg = total // count if count else 0 @@ -150,7 +78,7 @@ def _streak_days(dates: set) -> int: def get_battle(db: Session, user_id: int) -> SavingsBattle: - records = _effective_records(db, user_id) + records = _compare_records(db, user_id) today = cn_today() week_start = today - timedelta(days=today.weekday()) # 本周一 @@ -172,7 +100,7 @@ def get_battle(db: Session, user_id: int) -> SavingsBattle: def _compute_beat_percent(db: Session, user_id: int) -> int: - """超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。 + """超过百分之多少用户:按"累计省下金额"在所有有真实(compare)记录的用户中做真实分位。 = 严格少于我的其他用户数 / 其他用户总数 * 100。 全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。 @@ -180,18 +108,12 @@ def _compute_beat_percent(db: Session, user_id: int) -> int: rows = db.execute( select( SavingsRecord.user_id, - SavingsRecord.source, func.sum(SavingsRecord.saved_amount_cents), - ).group_by(SavingsRecord.user_id, SavingsRecord.source) + ) + .where(SavingsRecord.source == "compare") + .group_by(SavingsRecord.user_id) ).all() - # 每用户口径与展示一致:有 compare 用 compare 之和,否则退回 demo 之和 - by_user: dict[int, dict[str, int]] = {} - for uid, source, total in rows: - by_user.setdefault(uid, {})[source] = total or 0 - totals = { - uid: (d["compare"] if "compare" in d else d.get("demo", 0)) - for uid, d in by_user.items() - } + totals = {uid: (total or 0) for uid, total in rows} others = {uid: t for uid, t in totals.items() if uid != user_id} if not others: return 0 @@ -207,12 +129,10 @@ def list_records( limit: int = 20, cursor: int | None = None, ) -> tuple[list[SavingsRecord], int | None]: - """省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底。""" - stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) - if _has_real(db, user_id): - stmt = stmt.where(SavingsRecord.source == "compare") - else: - ensure_seeded(db, user_id) + """省钱明细分页(按 id 倒序,游标式)。只列真实比价上报(`source='compare'`)记录。""" + stmt = select(SavingsRecord).where( + SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" + ) if cursor is not None: stmt = stmt.where(SavingsRecord.id < cursor) stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit) diff --git a/app/repositories/signin.py b/app/repositories/signin.py index 04f894a..747233a 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -1,7 +1,7 @@ """签到 CRUD:状态查询 + 执行签到。 -7 天循环规则: - - 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续) +14 天循环规则: + - 昨天签过 → 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续) - 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置) 今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。 """ @@ -11,11 +11,12 @@ from dataclasses import dataclass from datetime import timedelta from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core import rewards from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today -from app.models.signin import SigninRecord +from app.models.signin import SigninBoostRecord, SigninRecord from app.repositories import wallet as crud_wallet @@ -23,9 +24,17 @@ class AlreadySignedError(Exception): """今天已经签过了。""" +class NotSignedTodayError(Exception): + """今天尚未签到,不能膨胀。""" + + +class AlreadyBoostedError(Exception): + """今天签到奖励已经膨胀过。""" + + @dataclass class SigninStep: - day: int # 1..7 + day: int # 1..14 coin: int status: str # claimed / today / locked @@ -34,7 +43,7 @@ class SigninStep: class SigninStatus: today_signed: bool consecutive_days: int # 当前已确认的连续签到天数 - today_cycle_day: int # 今天落在循环的第几档(1..7) + today_cycle_day: int # 今天落在循环的第几档(1..14) today_coin: int # 今天这一档的金币 can_claim: bool # 现在能否签到(= not today_signed) steps: list[SigninStep] @@ -51,7 +60,7 @@ def _latest_record(db: Session, user_id: int) -> SigninRecord | None: def _next_cycle_day(last: SigninRecord | None, today) -> int: - """若现在签到,今天会落在第几档(1..7)。""" + """若现在签到,今天会落在第几档(1..14)。""" if last is not None and last.signin_date == today - timedelta(days=1): return last.cycle_day % SIGNIN_CYCLE_LEN + 1 return 1 @@ -127,3 +136,52 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]: db.commit() db.refresh(record) return record, acc.coin_balance + + +def _today_record(db: Session, user_id: int) -> SigninRecord | None: + today = cn_today() + return db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user_id, + SigninRecord.signin_date == today, + ) + ).scalar_one_or_none() + + +def boost_today_signin( + db: Session, user_id: int, *, ad_ref_id: str | None = None +) -> tuple[SigninBoostRecord, int]: + """签到后看广告膨胀:补发一笔等额签到金币。返回 (膨胀记录, 补发后余额)。""" + record = _today_record(db, user_id) + if record is None: + raise NotSignedTodayError + + today = record.signin_date + existing = db.execute( + select(SigninBoostRecord).where( + SigninBoostRecord.user_id == user_id, + SigninBoostRecord.signin_date == today, + ) + ).scalar_one_or_none() + if existing is not None: + raise AlreadyBoostedError + + boost = SigninBoostRecord( + user_id=user_id, + signin_date=today, + coin_awarded=record.coin_awarded, + ad_ref_id=ad_ref_id, + ) + db.add(boost) + try: + acc, _ = crud_wallet.grant_coins( + db, user_id, record.coin_awarded, + biz_type="signin_boost", ref_id=today.isoformat(), + remark=f"签到膨胀 第{record.cycle_day}天", + ) + db.commit() + except IntegrityError as e: + db.rollback() + raise AlreadyBoostedError from e + db.refresh(boost) + return boost, acc.coin_balance diff --git a/app/schemas/ad.py b/app/schemas/ad.py index a6cdb65..4a4ddd9 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -30,17 +30,17 @@ class AdRewardStatusOut(BaseModel): remaining: int = Field(..., description="今日剩余可领次数") coin_per_ad: int = Field(..., description="看完一个激励视频发的金币") round_count: int = Field( - ..., description="本轮(3 次一组)已看次数,0..N-1。客户端可由它判断'刚刚看完一轮'" + ..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却" ) cooldown_until: datetime | None = Field( None, - description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。" + description="广告关闭后 3 秒短冷却结束时间(UTC ISO),null 表示不在冷却。" "冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点", ) - # ===== 看广告每日总时长(50 分钟防刷主闸):客户端据此展示"今日已看 X/50 分钟"、满则不给看 ===== + # ===== 旧版每日观看总时长字段:当前产品仅保留每日 500 次上限,limit=0 表示未启用时长闸 ===== watched_seconds_today: int = Field(0, description="今日已观看激励视频总秒数(前端累计上报)") - watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒,默认 3000=50 分钟)") - watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数(<=0 时客户端不应再展示广告)") + watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒);0 表示当前未启用") + watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截") class EcpmReportIn(BaseModel): @@ -92,3 +92,24 @@ class TestGrantOut(BaseModel): cooldown_until: datetime | None = Field( None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut" ) + + +class FeedRewardIn(BaseModel): + """信息流广告完成后结算奖励。 + + 每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。 + """ + + client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") + ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理") + duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") + adn: str | None = Field(None, description="实际投放 ADN") + slot_id: str | None = Field(None, description="实际展示代码位") + + +class FeedRewardOut(BaseModel): + granted: bool = Field(..., description="本次是否入账。达上限时 false") + status: str = Field(..., description="granted / capped") + coin: int = Field(..., description="本次发放金币") + unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") + daily_limit: int = Field(..., description="每日信息流展示次数上限") diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 0e0ace7..7543d4a 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -118,6 +118,13 @@ class ComparisonRecordCreatedOut(BaseModel): id: int = Field(..., description="写入(或已存在)的记录 id") +class CompareStatsOut(BaseModel): + """「我的」页省钱战绩卡(比价口径)聚合。""" + + compare_count: int = Field(..., description="累计完成比价次数(status='success' 记录数)") + discovered_saved_cents: int = Field(..., description="累计发现可省额(分;各成功比价 源价−最低价 之和)") + + # ===== 比价战绩里程碑(福利页「记录比价战绩」)===== class MilestoneStateOut(BaseModel): diff --git a/app/schemas/platform.py b/app/schemas/platform.py new file mode 100644 index 0000000..c49cd08 --- /dev/null +++ b/app/schemas/platform.py @@ -0,0 +1,24 @@ +"""首页平台级展示数据 schemas(客户端首页门面数字)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class PlatformStatsOut(BaseModel): + """首页三统计。total_saved 为分,客户端 ÷100 显示「元」。""" + + help_users: int # 帮助用户(人) + total_compares: int # 完成比价(次) + total_saved_cents: int # 累计节省(分) + + +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/app/schemas/welfare.py b/app/schemas/welfare.py index a48c65f..8977faf 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -172,7 +172,7 @@ class WithdrawOrderPage(BaseModel): # ===== 签到 ===== class SigninStepOut(BaseModel): - day: int = Field(..., description="循环内第几天 1..7") + day: int = Field(..., description="循环内第几天 1..14") coin: int status: str = Field(..., description="claimed / today / locked") @@ -193,6 +193,16 @@ class SigninResultOut(BaseModel): coin_balance: int = Field(..., description="签到后金币余额") +class SigninBoostRequest(BaseModel): + ad_ref_id: str | None = Field(None, description="广告会话/交易号。当前开发期可空,后续接 S2S 时回填") + + +class SigninBoostResultOut(BaseModel): + coin_awarded: int = Field(..., description="本次膨胀补发金币") + coin_balance: int = Field(..., description="膨胀补发后金币余额") + signin_date: str = Field(..., description="被膨胀的签到日期 YYYY-MM-DD") + + # ===== 任务 ===== class TaskOut(BaseModel): diff --git a/docs/api/README.md b/docs/api/README.md index 8a97204..6556d39 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(金币数值体系一期:签到膨胀 + 信息流广告结算) > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -30,6 +30,7 @@ | 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) | | 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) | | 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) | +| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) | | **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) ||| | 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) | | 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) | @@ -48,6 +49,7 @@ | **签到**(前缀 `/api/v1/signin`) ||| | 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) | | 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) | +| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin-boost.md) | | **任务**(前缀 `/api/v1/tasks`) ||| | 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) | | 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) | @@ -60,12 +62,16 @@ | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | +| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | | 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) | | **帮助与反馈**(前缀 `/api/v1/feedback`) ||| | 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) | +| **首页门面数据**(前缀 `/api/v1/platform`;全平台展示数字,登录前可读) ||| +| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) | +| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) | | **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) ||| | - | `GET /media/avatars/` | 无 | 用户头像;返回二进制图片 | | - | `GET /media/feedback/` | 无 | 反馈截图;返回二进制图片 | @@ -88,11 +94,19 @@ | A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | | A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | | A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | +| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | +| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | +| 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` 允许客户端传值覆盖默认渠道——见各接口"备注"。 > `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。 -> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。 +> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。 > 金额字段一律以**分**为单位(`*_cents`)。 --- diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md new file mode 100644 index 0000000..477390d --- /dev/null +++ b/docs/api/ad-feed-reward.md @@ -0,0 +1,40 @@ +# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 + +点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 + +## 鉴权 + +需要 Bearer token。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | +| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 | +| `duration_seconds` | int | 是 | 实际展示/播放秒数 | +| `adn` | string\|null | 否 | 实际投放 ADN | +| `slot_id` | string\|null | 否 | 实际展示代码位 | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `granted` | bool | 本次是否入账;达上限时为 `false` | +| `status` | string | `granted` / `capped` | +| `coin` | int | 本次发放金币 | +| `unit_count` | int | 按 10 秒折算出的奖励份数 | +| `daily_limit` | int | 每日信息流展示次数上限,默认 500 | + +## 计算口径 + +- 奖励份数:`duration_seconds // 10`。 +- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 +- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。 +- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 + +## 数据写入 + +- `ad_feed_reward_record` 新增一行。 +- 入账时 `coin_account` 增加余额。 +- 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`。 diff --git a/docs/api/ad-reward-status.md b/docs/api/ad-reward-status.md index 5486edc..57968af 100644 --- a/docs/api/ad-reward-status.md +++ b/docs/api/ad-reward-status.md @@ -14,15 +14,18 @@ | `daily_limit` | int | 每日发奖次数上限 | | `remaining` | int | 今日剩余可领次数 | | `coin_per_ad` | int | 看完一个激励视频发的金币 | -| `round_count` | int | 本轮(3 次一组)已看次数,0..N-1。客户端可据「拿到 round_count==0 && cooldown_until!=null」判定刚刚看完一轮 | -| `cooldown_until` | datetime\|null | 本轮 10 分钟冷却结束时间(UTC ISO);null 表示不在冷却 | +| `round_count` | int | 本轮已看次数;当前 `round_size=1`,广告关闭后进入短冷却 | +| `cooldown_until` | datetime\|null | 3 秒短冷却结束时间(UTC ISO);null 表示不在冷却 | +| `watched_seconds_today` | int | 今日已上报的激励视频观看秒数;当前仅兼容/排查用 | +| `watch_seconds_limit` | int | 每日观看总时长上限;当前默认 0 表示未启用时长闸 | +| `watch_seconds_remaining` | int | 今日剩余可观看秒数;`watch_seconds_limit=0` 时客户端不据此拦截 | ## 说明 福利页「看视频赚金币」用: - 展示「今日还能看 N 次」「看一次得 M 金币」(`remaining`/`coin_per_ad`) - 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取") -- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`round_count==0 && cooldown_until!=null` 显示「本轮视频已看完,10分钟后再来」 +- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`cooldown_until!=null` 显示「广告冷却中,3秒后再来」 -冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(每 N=3 条算一轮)末尾那次的 `created_at`,加 10 分钟。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。 +冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(当前每 N=1 条算一轮)末尾那次的 `created_at`,加 3 秒。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。 真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。 diff --git a/docs/api/admin-dashboard-display.md b/docs/api/admin-dashboard-display.md new file mode 100644 index 0000000..70463b2 --- /dev/null +++ b/docs/api/admin-dashboard-display.md @@ -0,0 +1,73 @@ +# Admin 首页数据配置 — 三统计展示模式 + +> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md) + +配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。 + +> **单位约定**:倍率用**千分比整数**(1.000→`1000`、1.100→`1100`)。`total_saved` 的 `manual_value` / `random_current` / `random_initial` / `random_step_min` / `random_step_max` / `real_offset` 单位都是**分**(前端展示时 ÷100 取元);两个计数指标是个数。 + +--- + +## GET /admin/api/dashboard-display — 三指标配置 + 真实值预览 + +### 出参 +响应 `200`:`list[OpsStatItemOut]`(按 help_users / total_compares / total_saved 顺序) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `metric` | string | `help_users` / `total_compares` / `total_saved` | +| `label` | string | 帮助用户 / 完成比价 / 累计节省 | +| `unit` | string | 人 / 次 / 分 | +| `mode` | string | `real` / `manual` / `random` | +| `manual_value` | int \| null | manual 固定值(基础单位) | +| `random_mult_min` | int | 倍率下限(千分比,≥1000) | +| `random_mult_max` | int | 倍率上限(千分比) | +| `random_tick_seconds` | int | 更新间隔(秒,默认 86400=1 天) | +| `random_anchor_minutes` | int | 触发时刻对齐偏移(距北京 0 点分钟数:天=每日时刻、小时=每小时第几分、分钟=0) | +| `random_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 | 当前真实值(纯真实、不含偏移;给运营对比参考,基础单位) | +| `updated_at` | string \| null | 上次修改时间 | + +--- + +## PATCH /admin/api/dashboard-display/{metric} — 改某指标(带审计) + +`metric` 路径参数取 `help_users` / `total_compares` / `total_saved`。 + +### 入参 `OpsStatUpdateRequest`(字段均可选,只改传了的) +| 字段 | 类型 | 说明 | +|---|---|---| +| `mode` | string | `real` / `manual` / `random` | +| `manual_value` | int | manual 固定值(基础单位,≥0) | +| `random_mult_min` | int | 倍率下限(千分比,1000~1500) | +| `random_mult_max` | int | 倍率上限(千分比,≥下限,≤1500) | +| `random_tick_seconds` | 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`:更新后的 `OpsStatItemOut`(同上)。 + +### 错误 +- `400`:mode/kind 非法 / 倍率越界(<1000 或 >1500)/ 上限<下限 / 增量负值或上限<下限 / 偏移负值 / tick<60 / 更新时间越界 / manual 负值。 +- `403`:角色不足(需 operator 或 super_admin)。 +- `404`:无效 metric(不在三指标内,作为 400 文案返回)。 + +### 说明 +- **统一定时刷新**:三模式的展示值(`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=`ops_stat_config.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..a0b9566 --- /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);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。 + +## 复用结构 OpsMarqueeSeedOut +| 字段 | 类型 | 说明 | +|---|---|---| +| `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[OpsMarqueeSeedOut]`(按 `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 — 新增(带审计) +入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut`。`400`=金额非法。 + +## POST /admin/api/marquee-seeds/bulk — 批量生成(带审计) +冷启动铺量:一次生成 `count` 条「自动合成名」(masked_user=null)种子,金额均为给定区间,`sort_order` 续在当前最大值之后。 +- 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents`、`max_cents`、`enabled`(默认 true) +- 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。 + +## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计) +入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`。 +- `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。 +- 只改单边 `min_cents` / `max_cents` 时,与现有的另一边一起做「上限≥下限」校验。 +出参:更新后的 `OpsMarqueeSeedOut`。`404`=种子不存在;`400`=金额非法。 + +## DELETE /admin/api/marquee-seeds/{seed_id} — 删(带审计) +出参 `200`:`{"deleted": true}`。`404`=种子不存在。 + +## 说明 +- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。 +- 改动客户端**进首页 / 回前台**重拉 feed 时生效。 diff --git a/docs/api/compare-stats.md b/docs/api/compare-stats.md new file mode 100644 index 0000000..454f5a7 --- /dev/null +++ b/docs/api/compare-stats.md @@ -0,0 +1,25 @@ +# GET /api/v1/compare/stats — 比价口径战绩(「我的」页省钱战绩卡) + +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) + +## 入参 +无(用户由 token 确定)。 + +## 出参 +响应 `200`:`CompareStatsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `compare_count` | int | 累计完成比价次数(`comparison_record` 中 `status='success'` 的记录数) | +| `discovered_saved_cents` | int | 累计发现可省额(分;各成功比价 `saved_amount_cents`=源价−最低价 之和,null 不计) | + +## 说明 +「我的」页**省钱战绩卡**的数据源(**比价口径**): + +- 卡主额「累计发现可省 ¥X」← `discovered_saved_cents` +- 卡三列之「完成比价(次)」← `compare_count` +- 卡「未比价锁定态」判据:`compare_count == 0`(没比过价 → 盖锁定蒙层,引导「立即开始」去首页比价) + +> ⚠️ **与下单口径解耦**:本接口只看「比过价」(`comparison_record`),**不要求下单**。区别于 `savings_record`(下单成交记账,见 [savings-summary](./savings-summary.md) / [savings-records](./savings-records.md))——那是订单记录/省钱明细的源。两表互不喂数据。 + +> 卡另两项数据源:「赚取金币」← `GET /wallet/account.total_coin_earned`;「领取优惠券」后端暂无累计字段(前端先占位)。 diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md new file mode 100644 index 0000000..156fa1e --- /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 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_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 位)。 +- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空则按脱敏格式**随机合成并避开同屏撞名**,金额在 `[min_cents, max_cents]` 区间**随机取值**(固定金额则 min==max)。 +- **`time` 为合成的「最近」时间**(从当前北京时间往前递减,首条约 20 秒前,其余每条 2~6 分钟):社会证明轮播保证永远像刚发生,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 +- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 +- 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 diff --git a/docs/api/platform-stats.md b/docs/api/platform-stats.md new file mode 100644 index 0000000..25ced25 --- /dev/null +++ b/docs/api/platform-stats.md @@ -0,0 +1,28 @@ +# GET /api/v1/platform/stats — 首页三统计(全平台门面数字) + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无**(登录前首页也要展示) | [← 返回 API 索引](./README.md) + +客户端首页顶部「帮助用户 / 完成比价 / 累计节省」三个平台级数字。每个指标的展示模式由运营后台配(见 [admin-dashboard-display](./admin-dashboard-display.md)),本接口只返回算好的结果值。 + +## 入参 +无。 + +## 出参 +响应 `200`:`PlatformStatsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `help_users` | int | 帮助用户(人) | +| `total_compares` | int | 完成比价(次) | +| `total_saved_cents` | int | 累计节省(**分**),客户端 ÷100 显示「元」 | + +## 说明 +三指标各自独立选模式,且**统一「定时刷新」**:客户端看到的展示值只在「更新时间(按更新间隔)」对齐的北京钟点(`anchor + k*间隔`)刷新一次,平时不变。每次刷新按模式算新值: +- **real**:重新快照查库 + 基数偏移。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和(**只计 `0 ⚠️ **2026-06 起停用 demo 兜底**:只计真实比价下单上报(`source='compare'`)。下单上报链路已通(Android `PriceBotService` → `POST /api/v1/order/report`)。 + +> ⚠️ **「我的」页省钱战绩卡已不读本接口**:#23 起卡改为**比价口径**,「完成比价 / 累计发现可省」走 [`GET /api/v1/compare/stats`](./compare-stats.md)(`comparison_record`,比过价就计、不要求下单),「赚取金币」走 `/wallet/account.total_coin_earned`。本 `/battle`(及 `compare_count`/`week_saved_cents`/`beat_percent`/`streak_days`)现仅供订单口径/历史用途,不上战绩卡。 diff --git a/docs/api/signin-boost.md b/docs/api/signin-boost.md new file mode 100644 index 0000000..3085338 --- /dev/null +++ b/docs/api/signin-boost.md @@ -0,0 +1,32 @@ +# POST /api/v1/signin/boost — 签到后看广告膨胀金币 + +用户当天已签到后,看完一条激励视频,补发一笔等额签到金币。 + +## 鉴权 + +需要 Bearer token。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `ad_ref_id` | string\|null | 否 | 广告会话/交易号。当前开发期可空,后续接 S2S 后可回填 | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `coin_awarded` | int | 本次膨胀补发金币 | +| `coin_balance` | int | 补发后的金币余额 | +| `signin_date` | string | 被膨胀的签到日期,格式 `YYYY-MM-DD` | + +## 错误 + +- `401`: 未登录 +- `409`: 当天未签到,或当天已经膨胀过 + +## 数据写入 + +- `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复。 +- `coin_account` 增加余额。 +- `coin_transaction` 写入 `biz_type=signin_boost`。 diff --git a/docs/api/signin-do.md b/docs/api/signin-do.md index 513ba60..3bb6504 100644 --- a/docs/api/signin-do.md +++ b/docs/api/signin-do.md @@ -11,7 +11,7 @@ | 字段 | 类型 | 说明 | |---|---|---| | `coin_awarded` | int | 本次签到发放金币 | -| `cycle_day` | int | 本次签到落在循环第几天 | +| `cycle_day` | int | 本次签到落在 14 天循环第几天 | | `streak` | int | 签到后的连续天数 | | `coin_balance` | int | 签到后金币余额 | @@ -20,3 +20,4 @@ ## 说明 发金币已计入 [wallet-account](./wallet-account.md) 的余额(客户端就地刷新即可,不必另叠)。 +断签后从第 1 天重新开始;第 15 天回到第 1 天。 diff --git a/docs/api/signin-status.md b/docs/api/signin-status.md index 8e09a54..541d6c8 100644 --- a/docs/api/signin-status.md +++ b/docs/api/signin-status.md @@ -1,4 +1,4 @@ -# GET /api/v1/signin/status — 今日签到状态 + 7 天档位 +# GET /api/v1/signin/status — 今日签到状态 + 14 天档位 > 所属:Signin 组(前缀 `/api/v1/signin`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) @@ -12,18 +12,18 @@ |---|---|---| | `today_signed` | bool | 今日是否已签 | | `consecutive_days` | int | 当前连续签到天数 | -| `today_cycle_day` | int | 今日处于循环内第几天(1..7) | +| `today_cycle_day` | int | 今日处于循环内第几天(1..14) | | `today_coin` | int | 今日签到可得金币 | | `can_claim` | bool | 今日是否可领(= 未签) | -| `steps` | SigninStepOut[] | 7 天档位 | +| `steps` | SigninStepOut[] | 14 天档位 | **SigninStepOut** | 字段 | 类型 | 说明 | |---|---|---| -| `day` | int | 循环内第几天 1..7 | +| `day` | int | 循环内第几天 1..14 | | `coin` | int | 该档金币 | | `status` | string | `claimed`(已领) / `today`(今日待领) / `locked`(未到) | ## 说明 -福利页签到行 + 签到弹窗 7 档 timeline 数据源。 +福利页签到行 + 签到弹窗 14 档 timeline 数据源。断签后从第 1 天重新开始,第 15 天回到第 1 天。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 6050ad4..65605ac 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -2,7 +2,7 @@ > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **19 张业务表** + `alembic_version`(框架的迁移版本指针)。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。 --- @@ -23,9 +23,10 @@ | 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 | | 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 | | 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) | -| 每日签到 | [`signin_record`](./signin_record.md) | 7 天循环发币 | +| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 14 天循环发币;签到后看广告可膨胀一次 | | 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 | -| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | **三条独立数据流**:发奖 / 时长闸 / 收益对账 | +| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 | +| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 | | 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 | | 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 | | 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 | @@ -58,6 +59,7 @@ | 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_`、`status=deleted`) | | 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U | | 签到 `POST /signin/do` | `signin_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin`) | 同事务 | +| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 | | 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_`) | 同事务 | | 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` −) + `cash_transaction`(C `exchange_in` +) | 同事务 | | 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` −) | 同事务,**不打款** | @@ -65,6 +67,7 @@ | 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `ad_reward`) | `trans_id` 幂等 | | 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | | | 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | | +| 信息流广告结算 `POST /ad/feed-reward` | `ad_feed_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `feed_ad_reward`) | `client_event_id` 幂等 | | 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 | | 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) | | 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 | @@ -90,7 +93,7 @@ ## 三、表间关系 & Join Key ### 硬外键(数据库 FK 约束) -- **15 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`price_report`、`feedback`。 +- **17 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`。 - `admin_audit_log.admin_id` → `admin_user.id`。 - `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。 @@ -100,8 +103,10 @@ | biz_type | ref_id 指向 | amount 符号 | |---|---|---| | `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | + | `signin_boost` | 当天日期串(= `signin_boost_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | | `task_` | `user_task.task_key` | + | | `ad_reward` | `ad_reward_record.trans_id` | + | + | `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + | | `exchange_out` | null(兑现金,无单据) | − | | `admin_grant` / `admin_deduct` | null(原因记在 `remark`=`admin:`) | + / − | @@ -113,7 +118,7 @@ | `exchange_in` | null | + | - **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 -- **三条广告流互不关联**:`ad_reward_record` / `ad_watch_log` / `ad_ecpm_record` 之间**无公共键**,各自只按 `(user_id, 日期串)` 聚合。别试图 join 它们逐条对应。 +- **广告流互不关联**:`ad_reward_record` / `ad_watch_log` / `ad_ecpm_record` / `ad_feed_reward_record` 之间**无公共键**,各自只按 `(user_id, 日期串)` 聚合。别试图 join 它们逐条对应。 - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 ### ER 关系(文字版) @@ -121,8 +126,8 @@ user ─1:1─ coin_account user ─1:1─ wechat_transfer_authorization user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record, - user_task, comparison_record, comparison_milestone_claim, - savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, + signin_boost_record, user_task, comparison_record, comparison_milestone_claim, + savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, price_report, feedback } comparison_record ─1:N─ price_report (comparison_record_id, 可空) admin_user ─1:N─ admin_audit_log @@ -135,7 +140,7 @@ app_config (独立, 无外键, key 为主键) 1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。 2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。 -3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / task / ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。 +3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。 - **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。 - **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款都写 `cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。 diff --git a/docs/database/README.md b/docs/database/README.md index af93c25..9220687 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,13 +3,13 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 -> 最后更新:2026-06-06(补全 19 张表 + 新增 [OVERVIEW 总览](./OVERVIEW.md)) +> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md)) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(19 张业务表 + `alembic_version` 框架表) +## 表总览(22 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | @@ -25,11 +25,13 @@ | `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) | | `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) | | `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) | -| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) | +| `signin_record` | 签到记录(14 天循环) | `models/signin.py` | [详情](./signin_record.md) | +| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) | | `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) | | `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) | -| `ad_watch_log` | 看广告观看时长(每日 50min 防刷主闸) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) | +| `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) | | `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) | +| `ad_feed_reward_record` | 信息流广告结算记录(10 秒一份,client_event_id 幂等) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) | ### 比价 / 省钱 | 表 | 用途 | 模型 | 文档 | @@ -39,6 +41,12 @@ | `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) | | `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) | +### 首页门面数据 +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `ops_stat_config` | 首页三统计展示配置(real/manual/random) | `models/ops_stat_config.py` | [详情](./ops_stat_config.md) | +| `ops_marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/ops_marquee_seed.py` | [详情](./ops_marquee_seed.md) | + ### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md new file mode 100644 index 0000000..ecea866 --- /dev/null +++ b/docs/database/ad_feed_reward_record.md @@ -0,0 +1,28 @@ +# ad_feed_reward_record — 信息流广告奖励记录 + +点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。 + +## 字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `id` | Integer | PK | 自增主键 | +| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id | +| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | +| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | +| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 | +| `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 | +| `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 | +| `adn` | String(32) | nullable | 实际投放 ADN | +| `slot_id` | String(64) | nullable | 实际展示代码位 | +| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 | +| `status` | String(16) | NOT NULL | `granted` / `capped` | +| `created_at` | DateTime | index, NOT NULL | 创建时间 | + +## 约束 + +- `UNIQUE(client_event_id)`:同一完成事件重试不重复发奖。 + +## 关联 + +- 入账时写 `coin_transaction.biz_type=feed_ad_reward`,`ref_id=client_event_id`。 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 3b7c766..16ac5c3 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -5,7 +5,7 @@ 每条 = 穿山甲一次**服务端发奖回调**(用户看完激励视频)。是看广告三条数据流之一(**发奖**;另两条:`ad_watch_log` 时长、`ad_ecpm_record` 收益,三者无公共键)。`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward`)或 `POST /ad/test-grant`(本地联调)。三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`)或观看总时长(`DAILY_AD_WATCH_SECONDS_LIMIT`=50min,查 `ad_watch_log`)任一到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则 `granted` + `grant_coins(biz_type='ad_reward', ref_id=trans_id)` 加币,同事务。 +- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward`)或 `POST /ad/test-grant`(本地联调)。三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。旧的观看总时长闸仅在 `DAILY_AD_WATCH_SECONDS_LIMIT > 0` 时启用,当前默认停用。否则 `granted` + `grant_coins(biz_type='ad_reward', ref_id=trans_id)` 加币,同事务。 - **U / D**:无。 - **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 @@ -16,7 +16,7 @@ | `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=ad_reward) | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | | `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped` 时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数或时长超限,记录但不发) | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限,记录但不发) | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | @@ -25,11 +25,11 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 - `trans_id` ← 被 `coin_transaction.ref_id` 引用(`granted` 那条发币流水);`capped` 行不发币、无对应流水。 -- 与 `ad_watch_log` / `ad_ecpm_record` **无公共键**,仅在发奖时读 `ad_watch_log` 当日时长做闸。 +- 与 `ad_watch_log` / `ad_ecpm_record` **无公共键**;当前不逐条关联 eCPM 和发奖回调。 ## 索引与约束 - PK `id`;UNIQUE+index `trans_id`;index `user_id`、`reward_date`、`created_at`。 ## 注意 -- 实发金币以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改)。 +- 当前公网回调尚未接入前,激励视频精确 eCPM 绑定发奖先搁置;实发金币仍以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改)。 - 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。 diff --git a/docs/database/ad_watch_log.md b/docs/database/ad_watch_log.md index 9b6db33..7f783bb 100644 --- a/docs/database/ad_watch_log.md +++ b/docs/database/ad_watch_log.md @@ -1,15 +1,15 @@ -# ad_watch_log — 看激励视频观看时长(每日防刷主闸) +# ad_watch_log — 看激励视频观看时长(旧版兼容字段) > 模型 `app/models/ad_watch_log.py` · 仓库 `app/repositories/ad_watch.py` · 接口 `POST /api/v1/ad/watch-report`(上报)、读取面 [ad-reward-status](../api/ad-reward-status.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每条 = 客户端 `onAdClose` 上报的一次激励视频**实际观看秒数**。按 `(user_id, watch_date)` 聚合 `SUM(watch_seconds)` 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(`rewards.DAILY_AD_WATCH_SECONDS_LIMIT`)。是看广告三条数据流之一(**时长闸**),与 `ad_reward`(发奖)、`ad_ecpm`(收益)并列、**互不关联**。 +每条 = 客户端 `onAdClose` 上报的一次激励视频**实际观看秒数**。按 `(user_id, watch_date)` 聚合 `SUM(watch_seconds)` 得当日总观看时长。当前产品只保留每日 500 次上限,`rewards.DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示不启用时长闸;本表保留用于旧客户端兼容和排查。 -> 防刷设计:前端上报时长不可信 → 时长是**主闸**;配合 `ad_reward_record` 的每日**次数兜底**(`DAILY_AD_REWARD_LIMIT`,默认 200),前端少报时长也刷不过次数闸。 +> 防刷设计:前端上报时长不可信,不能作为唯一发奖依据;当前发奖闸口以 `ad_reward_record` 的每日次数上限(`DAILY_AD_REWARD_LIMIT`,默认 500)为准。 ## 用在哪 / 增删改查 - **C(插入)**:`POST /ad/watch-report`(`add_watch_seconds`)。每次看完激励视频上报一次 → 写一行,`watch_seconds` 服务端夹 `[0, MAX_SINGLE_WATCH_SECONDS=120]`(防异常大值)。鉴权接口已确保 user 存在,不校验 UnknownUser。 - **U / D**:无。当日多次观看 = 多行,靠 SUM 聚合。 -- **R**:`watched_seconds_today` 当日累计 —— 被 `ad_reward.grant_ad_reward`(发奖前的时长闸)和 `GET /ad/reward-status`(展示"今日已看 X/50 分钟" + 满则不给看)调用。 +- **R**:`watched_seconds_today` 当日累计 —— 被 `ad_reward.grant_ad_reward` 和 `GET /ad/reward-status` 读取;当 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 时不触发 capped。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -22,11 +22,11 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- 与 `ad_reward_record` / `ad_ecpm_record` **无公共键**:不做逐条关联,只按 `(user_id, watch_date)` 聚合。发奖时 `ad_reward` 读本表当日累计判时长闸。 +- 与 `ad_reward_record` / `ad_ecpm_record` **无公共键**:不做逐条关联,只按 `(user_id, watch_date)` 聚合。 ## 索引与约束 - PK `id`;index `user_id`、`watch_date`、`created_at`。 ## 注意 -- 上限走常量 `DAILY_AD_WATCH_SECONDS_LIMIT=3000`(50min)/ `MAX_SINGLE_WATCH_SECONDS=120`(暂未挂 app_config,改值改 `core/rewards.py`)。 +- 当前 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示时长闸停用;`MAX_SINGLE_WATCH_SECONDS=120` 仍用于夹单次上报秒数。 - 按日期串等值查,不在 SQL 跨时区比 date(SQLite 不可靠)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index e061c41..947831c 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -15,7 +15,7 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` | -| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 7 档 / `dict_str_int` 如 task_rewards) | +| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index c036d2c..0beefe8 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -10,8 +10,10 @@ | 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 | |---|---|---|---| | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | + | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) | | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | `user_task.task_key` | | 看广告发奖回调 `POST /ad/pangle-callback` | `ad_reward` | + | `ad_reward_record.trans_id` | + | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | | 金币兑现金 `POST /wallet/exchange` | `exchange_out` | − | null(配套 `cash_transaction.exchange_in`) | | admin 手动加金币 | `admin_grant` | + | null(`remark`=`admin:`) | | admin 手动扣金币 | `admin_deduct` | − | null | @@ -27,17 +29,17 @@ | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `amount` | Integer | NOT NULL | 本笔变动金币;**正=入账(赚),负=出账(花/兑换)** | | `balance_after` | Integer | NOT NULL | 本笔后金币余额(= 当时 `coin_account.coin_balance`,对账用) | -| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `task_` / `ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 | +| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `signin_boost` / `task_` / `ad_reward` / `feed_ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 | | `ref_id` | String(64) | nullable | **关联业务键,指向随 `biz_type` 变(见上表)**;无关联时为 null | | `remark` | String(128) | nullable | 备注(如签到「每日签到 第N天」、admin「admin:」) | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `task_`→`user_task.task_key` / `ad_reward`→`ad_reward_record.trans_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→签到日 / `task_`→`user_task.task_key` / `ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 ## 注意 -- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward)在**同一事务**写,不会只发币不留痕。 +- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward/feed_ad_reward 等)在**同一事务**写,不会只发币不留痕。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index c63b035..ba47da1 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -9,7 +9,7 @@ ## 用在哪 / 增删改查 - **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。 - **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。 -- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`report.py` 反查 `best_*`;admin 大盘/明细。 +- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | diff --git a/docs/database/ops_marquee_seed.md b/docs/database/ops_marquee_seed.md new file mode 100644 index 0000000..639ec2e --- /dev/null +++ b/docs/database/ops_marquee_seed.md @@ -0,0 +1,24 @@ +# ops_marquee_seed — 首页轮播种子(生成规则) + +> 模型 `app/models/ops_marquee_seed.py` | 关联接口 [platform-savings-feed](../api/platform-savings-feed.md) / [admin-marquee-seeds](../api/admin-marquee-seeds.md) | [← 表索引](./README.md) + +首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_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` 改为可空;`opsrename01` 迁移把表名由 `marquee_seed` 改为 `ops_marquee_seed`,与代码 ops_ 前缀对齐,路由 `/admin/api/marquee-seeds` 不变。) + +## 说明 +- feed 取真实(`comparison_record` success 且 `0 模型 `app/models/ops_stat_config.py` | 关联接口 [platform-stats](../api/platform-stats.md) / [admin-dashboard-display](../api/admin-dashboard-display.md) | [← 表索引](./README.md) + +客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示配置。一行一指标(主键=`metric`),每指标可独立选 real/manual/random 三种模式。计算逻辑见 `app/repositories/ops_stat.py`。 + +> **单位**:倍率用**千分比整数**(1.000→`1000`);`total_saved` 指标的 `manual_value` / `random_current` 单位是**分**,两个计数指标是**个数**。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `metric` | String(32) | PK | `help_users` / `total_compares` / `total_saved` | +| `mode` | String(16) | NOT NULL, default `real` | `real` 查库 / `manual` 手填 / `random` 随机增长 | +| `manual_value` | Integer | nullable | manual 模式固定值(基础单位) | +| `random_mult_min` | Integer | NOT NULL, default 1000 | 倍率下限(千分比,≥1000 保证只增不减) | +| `random_mult_max` | Integer | NOT NULL, default 1100 | 倍率上限(千分比) | +| `random_tick_seconds` | Integer | NOT NULL, default 86400 | 更新间隔(秒) | +| `random_anchor_minutes` | Integer | NOT NULL, default 0 | 触发时刻对齐偏移(距北京 0 点分钟数):天=每日时刻 h*60+m、小时=每小时第几分、分钟=0 | +| `random_current` | Integer | nullable | 当前展示值(所有模式,基础单位);惰性 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() | 更新时间 | + +## 初始数据(migration 播种) +三行,默认 `mode='manual'` + 客户端原写死门面值,保证上线前后展示一致: + +| metric | manual_value | 含义 | +|---|---|---| +| `help_users` | 12847 | 12847 人 | +| `total_compares` | 86532 | 86532 次 | +| `total_saved` | 3762140 | 37621.40 元 | + +## 统一「定时刷新」(北京钟点对齐) +`random_current` 现是**所有模式**的「当前展示值」(不止 random),用户侧 `/stats` 直接返回它。 +- 触发边界 = 北京时间 `anchor + k*interval`(interval=`random_tick_seconds`,anchor=`random_anchor_minutes*60`,sub-day 间隔取 `anchor % interval` 作相位)。例:每天 09:00 / 每小时 :30 / 每 5 分刻度。 +- 读取(`get_display_values`/`get_config`)时跑 `_refresh`:统计 `random_last_tick_at`→`now` 跨过几个边界 N,N≥1 才刷新: + - **real** → `random_current` = 重新查库的真实值 + `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` 可立即设起点(不受护栏约束)。 + +## 说明 +- real 口径仅统计 `comparison_record` 中 `status='success'` 的记录(去重用户数 / 记录数)。`total_saved` 的求和**只计入 `0 < saved_amount_cents ≤ 300 元` 的条目**(剔除 bug 异常大值,与轮播口径一致,防一条异常配合「只增不减」永久撑高门面)。 +- `倍率上限收紧为 1.500`(单次 ×1.5,原 5.0 太大一跳就假)。 +- 表名 `ops_stat_config`(`opsrename01` 迁移由 `platform_stat_display` 改名,与代码 ops_ 前缀对齐;路由 `/admin/api/dashboard-display` 不变)。 +- 改配置走 admin `PATCH /admin/api/dashboard-display/{metric}`,带审计。 diff --git a/docs/database/savings_record.md b/docs/database/savings_record.md index 0e8daee..bf7814d 100644 --- a/docs/database/savings_record.md +++ b/docs/database/savings_record.md @@ -4,15 +4,15 @@ profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。是「记账闭环」的落库终点——客户端比价后点结果链接跳走付款,无障碍 watcher 捕获支付成功 + 金额归因(±1 元/5 分钟窗口)→ 上报 `POST /order/report`。 -> **两种来源(`source` 字段区分)**: +> **来源(`source` 字段)**: > - `source='compare'`:**真实下单上报**(经 `/order/report` 的 `create_from_report` 写,2026-06 已接通)。 -> - `source='demo'`:用户从无任何记录时,首次进省钱页**懒种子** `ensure_seeded` 按 user_id 幂等灌 ~23 条演示数据。 -> 统计口径:**只要有 ≥1 条 `compare` 记录,就只用 compare**(demo 全忽略);否则用 demo 兜底。聚合(SUM/分组/连续天数/分位)都是生产级真实计算。 +> - `source='demo'`:旧演示种子。⚠️ **2026-06 起停用** demo 兜底(`ensure_seeded` 已移除),不再产生 demo 行;历史遗留的 demo 行不再被任何统计计入。 +> 统计口径:**只用 `source='compare'`**。新用户无真实记录时 summary/battle/records 全空/0(配合「我的」页省钱战绩卡「未比价锁定态」)。聚合(SUM/分组/连续天数/分位)都是生产级真实计算。 ## 用在哪 / 增删改查 - **C(插入)**: - `POST /order/report`(`create_from_report`)→ 写一行 `source='compare'`,省额 = `original_price_cents − paid`(下限 0)。按 `(user_id, client_event_id)` 幂等,重复上报返回旧行不新增。 - - `ensure_seeded` → 用户无任何 savings 记录时,懒灌 demo(`source='demo'`),进 summary/battle/records 接口时触发。 + - ~~`ensure_seeded` demo 懒种子~~:**2026-06 移除**(不再灌 demo)。 - **U / D**:无。 - **R**:`GET /savings/summary`(累计省/单数/均省)、`/savings/battle`(本周省/连续天数/超过 X% 用户/比价次数)、`/savings/records`(明细分页);`comparison.py` 读本表 `shop_name`(source=compare)集合给比价记录打「已下单」。 @@ -47,4 +47,4 @@ profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据 ## 注意 - `dishes` 用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。 -- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位,每用户口径与展示一致(有 compare 用 compare 之和,否则 demo 之和)。 +- `beat_percent`(超过百分之多少用户)按各用户**真实(compare)**累计省下金额做真实分位(demo 不计)。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md new file mode 100644 index 0000000..e7174a1 --- /dev/null +++ b/docs/database/signin_boost_record.md @@ -0,0 +1,22 @@ +# signin_boost_record — 签到膨胀记录 + +App 用户当天签到后,看完激励视频可把签到奖励膨胀一次。本表记录膨胀动作,并用唯一约束防重复补发。 + +## 字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `id` | Integer | PK | 自增主键 | +| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | +| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | +| `coin_awarded` | Integer | NOT NULL | 本次补发金币 | +| `ad_ref_id` | String(64) | nullable | 广告会话/交易号,开发期可空 | +| `created_at` | DateTime | NOT NULL | 创建时间 | + +## 约束 + +- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。 + +## 关联 + +- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id=signin_date`。 diff --git a/docs/database/signin_record.md b/docs/database/signin_record.md index 23b5249..786beab 100644 --- a/docs/database/signin_record.md +++ b/docs/database/signin_record.md @@ -1,13 +1,13 @@ -# signin_record — 签到记录(7 天循环) +# signin_record — 签到记录(14 天循环) > 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(10,20,30,50,80,120,200)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。 +App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..14)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,120,150,180,200,250,500,200,250,300,350,400,500,3000)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 15 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。 ## 用在哪 / 增删改查 - **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。 - **U / D**:无。每天一行,历史不改。 -- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 +- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天 | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) | -| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 | +| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 | | `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 | | `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) | | `created_at` | DateTime(tz) | server_default now() | 时间 | diff --git a/docs/database/user.md b/docs/database/user.md index faae054..04b5adb 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -26,7 +26,7 @@ | `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) | ## 关系 / Join Key -- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`。 +- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`。 - 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。 ## 索引与约束 diff --git a/docs/看广告赚金币上线清单.md b/docs/看广告赚金币上线清单.md index c410817..3f0ccd7 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/看广告赚金币上线清单.md @@ -8,7 +8,7 @@ - **发奖只走服务端**:激励视频播完,穿山甲服务器 S2S 回调后端 `/api/v1/ad/pangle-callback` 发金币。 客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。 - 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。 -- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)、`VIDEO_ROUND_REQUIRED_COUNT=3`、`VIDEO_ROUND_COOLDOWN_SECONDS=600`(本轮 3 次后冷却 10 分钟)。 +- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=500`、`VIDEO_ROUND_REQUIRED_COUNT=1`、`VIDEO_ROUND_COOLDOWN_SECONDS=3`(每次广告关闭后 3 秒短冷却)。旧的观看时长闸 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示停用。 - **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。 --- @@ -57,7 +57,7 @@ ## C. 部署 + 包名 - [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代) -- [ ] **跑迁移**:`alembic upgrade head`(建 `ad_reward_record` 表,迁移 `c8d9e0f1a2b3`) +- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`signin_boost_record`、`ad_feed_reward_record` 等表) - [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换 - 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释) - [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善 @@ -74,9 +74,9 @@ - [ ] 真机看完一条**真实**激励视频 → 穿山甲 S2S 回调 → 后端发奖 → 客户端余额真到账 - [ ] **幂等**:同一 `trans_id` 不重复发 - [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」(Capped 态) -- [ ] **本轮冷却**(PR #5):连看 3 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 10 分钟后自动恢复 Normal -- [ ] **弹窗 limit note**:`round_count==0 && cooldown_until!=null` → "本轮视频已看完,10分钟后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来" -- [ ] **跨设备/杀进程一致**:在手机 A 看完 3 张进入冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地) +- [ ] **短冷却**:看完 1 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 3 秒后自动恢复 Normal +- [ ] **弹窗 limit note**:`cooldown_until!=null` → "广告冷却中,3秒后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来" +- [ ] **跨设备/杀进程一致**:在手机 A 看完 1 张进入短冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地) - [ ] 中途退出广告 → 客户端提示「未看完视频,本次没有奖励哦」,且不发奖 - [ ] 收益明细显示「看广告奖励」(biz_type=`ad_reward`) diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index c72bb39..2b30f3e 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -15,6 +15,7 @@ from app.core.rewards import ( MAX_AD_REWARD_COIN, VIDEO_ROUND_COOLDOWN_SECONDS, VIDEO_ROUND_REQUIRED_COUNT, + calculate_ad_reward_coin, ) from app.db.session import SessionLocal from app.models.ad_reward import AdRewardRecord @@ -204,19 +205,19 @@ def test_reward_status_initial_round_state(client) -> None: def test_reward_status_mid_round(client) -> None: - """看 1 次(未到一轮)→ round_count=1, cooldown_until 仍 None。""" + """当前 round_size=1:看 1 次 → round_count=0, cooldown_until 进入 3 秒短冷却。""" phone = "13800003102" token = _login(client, phone) uid = _user_id(phone) _callback(client, _signed(uid, "trans_mid_1")) st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() assert st["used_today"] == 1 - assert st["round_count"] == 1 - assert st["cooldown_until"] is None + assert st["round_count"] == 0 + assert st["cooldown_until"] is not None def test_reward_status_round_complete_enters_cooldown(client) -> None: - """刚看完一轮 N 次 → round_count=0(下一轮起点) + cooldown_until 是未来 ~10 分钟。""" + """刚看完一条广告 → round_count=0(下一轮起点) + cooldown_until 是未来 ~3 秒。""" from datetime import datetime, timezone phone = "13800003103" @@ -234,12 +235,12 @@ def test_reward_status_round_complete_enters_cooldown(client) -> None: cd = cd.replace(tzinfo=timezone.utc) now = datetime.now(timezone.utc) delta = (cd - now).total_seconds() - # 配置 600s,允许 ±30s 容差(本机 / CI 慢 IO) + # 配置 3s,允许 ±30s 容差(本机 / CI 慢 IO) assert 0 < delta <= VIDEO_ROUND_COOLDOWN_SECONDS + 30 def test_reward_status_cooldown_expired(client) -> None: - """看完一轮后冷却已过(手动改 created_at 到 11 分钟前)→ cooldown_until 回到 None。""" + """看完一条广告后冷却已过(手动改 created_at 到冷却前)→ cooldown_until 回到 None。""" from datetime import datetime, timedelta, timezone from sqlalchemy import select, update @@ -268,6 +269,61 @@ def test_reward_status_cooldown_expired(client) -> None: assert st["cooldown_until"] is None +def test_feed_reward_grants_by_10_second_units(client) -> None: + """信息流广告完成后:每满 10 秒一份奖励,同一 client_event_id 幂等。""" + phone = "13800003301" + token = _login(client, phone) + + payload = { + "client_event_id": "feed_evt_0001", + "ecpm": "200", + "duration_seconds": 30, + "adn": "pangle", + "slot_id": "slot_feed", + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4)) + assert body["granted"] is True + assert body["status"] == "granted" + assert body["unit_count"] == 3 + assert body["coin"] == expected + assert _coin_balance(client, token) == expected + + # 重试同一个事件不重复发 + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["coin"] == expected + assert _coin_balance(client, token) == expected + + +def test_feed_reward_daily_display_cap(client, monkeypatch) -> None: + """信息流展示次数到每日上限后继续完成 → capped,不发金币。""" + monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 1) + phone = "13800003302" + token = _login(client, phone) + + first = { + "client_event_id": "feed_evt_cap_1", + "ecpm": "201", + "duration_seconds": 10, + } + second = { + "client_event_id": "feed_evt_cap_2", + "ecpm": "201", + "duration_seconds": 10, + } + assert client.post("/api/v1/ad/feed-reward", json=first, headers=_auth(token)).status_code == 200 + before = _coin_balance(client, token) + r = client.post("/api/v1/ad/feed-reward", json=second, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["granted"] is False + assert r.json()["status"] == "capped" + assert r.json()["coin"] == 0 + assert _coin_balance(client, token) == before + + def test_callback_disabled_returns_503(client, monkeypatch) -> None: """未配置回调(开关关)时 → 503。""" monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False) diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 1a5fead..12797bc 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -69,14 +69,17 @@ def test_list_config(admin_client: TestClient, token: str) -> None: assert r.status_code == 200, r.text items = {i["key"]: i for i in r.json()} assert "signin_rewards" in items and "ad_daily_limit" in items - assert items["signin_rewards"]["value"] == [10, 20, 30, 50, 80, 120, 200] + assert items["signin_rewards"]["value"] == [ + 200, 120, 150, 180, 200, 250, 500, + 200, 250, 300, 350, 400, 500, 3000, + ] assert items["signin_rewards"]["overridden"] is False def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None: r = admin_client.patch( "/admin/api/config/signin_rewards", - json={"value": [100, 200, 300, 400, 500, 600, 700]}, + json={"value": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400]}, headers=_auth(token), ) assert r.status_code == 200, r.text @@ -116,7 +119,7 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N def test_config_validation(admin_client: TestClient, token: str) -> None: - # 签到档位长度≠7 + # 签到档位长度≠14 assert admin_client.patch( "/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token) ).status_code == 400 diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 1569d2f..85696f0 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -193,11 +193,38 @@ def test_detail_cross_user_404(client) -> None: assert r.status_code == 404 +def test_stats_compare_count_and_saved(client) -> None: + """比价口径战绩:完成比价数 + 累计发现可省(各成功比价 saved 之和)。""" + token = _login(client, "13800002011") + # 新用户:无记录 → 全 0(配合「我的」页未比价锁定态) + s0 = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s0["compare_count"] == 0 + assert s0["discovered_saved_cents"] == 0 + + # 报 2 条成功比价(各省 128.50−123.50=5.00 元=500 分) + client.post("/api/v1/compare/record", json=_food_payload("stat-1"), headers=_auth(token)) + client.post("/api/v1/compare/record", json=_food_payload("stat-2"), headers=_auth(token)) + s = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s["compare_count"] == 2 + assert s["discovered_saved_cents"] == 1000 + + # 失败帧(只有源平台)不计入成功比价数 + fail = _food_payload("stat-fail") + fail["comparison_results"] = [ + {"platform_id": "taobao_flash", "platform_name": "淘宝闪购", + "package": "com.taobao.taobao", "price": 128.50, "is_source": True, "rank": 1}, + ] + client.post("/api/v1/compare/record", json=fail, headers=_auth(token)) + s2 = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s2["compare_count"] == 2 # 仍 2(failed 不计) + + def test_requires_auth(client) -> None: """不带 token 统一 401。""" assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401 assert client.get("/api/v1/compare/records").status_code == 401 assert client.get("/api/v1/compare/records/1").status_code == 401 + assert client.get("/api/v1/compare/stats").status_code == 401 def test_trace_id_required(client) -> None: diff --git a/tests/test_order_savings.py b/tests/test_order_savings.py index d6b1226..9adc684 100644 --- a/tests/test_order_savings.py +++ b/tests/test_order_savings.py @@ -1,7 +1,7 @@ """记账测试:比价后下单上报 → 写 savings_record(source='compare')。 覆盖:上报落库 + 门店/菜品/原价随之入库、(user,client_event_id) 幂等、 -真实(compare)记录优先于 demo seeder、省额 = 源平台原价 − 实付。 +纯真实口径(demo 兜底已停用:新用户空、上报后才有数)、省额 = 源平台原价 − 实付。 """ from __future__ import annotations @@ -78,12 +78,21 @@ def test_order_report_idempotent(client) -> None: assert s["order_count"] == 1 -def test_real_overrides_demo_seed(client) -> None: - """先触发 demo seeder(多笔),再上报一笔真实 → 统计切换为只算真实那笔。""" +def test_fresh_user_empty_until_report(client) -> None: + """纯真实(demo 兜底已停用):新用户 summary/battle/records 全空,上报后才有数。""" token = _login(client, "13800002003") - demo = client.get("/api/v1/savings/summary", headers=_auth(token)).json() - assert demo["order_count"] > 1 # demo 是多笔 + # 未上报:无任何 demo,统计全 0、明细空 + s0 = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert s0["order_count"] == 0 + assert s0["total_saved_cents"] == 0 + b0 = client.get("/api/v1/savings/battle", headers=_auth(token)).json() + assert b0["compare_count"] == 0 + assert b0["streak_days"] == 0 + p0 = client.get("/api/v1/savings/records", headers=_auth(token)).json() + assert p0["items"] == [] + + # 上报一笔真实 → 统计只算这一笔 client.post( "/api/v1/order/report", json=_report_body( @@ -92,7 +101,7 @@ def test_real_overrides_demo_seed(client) -> None: headers=_auth(token), ) s = client.get("/api/v1/savings/summary", headers=_auth(token)).json() - assert s["order_count"] == 1 # demo 被忽略 + assert s["order_count"] == 1 assert s["total_saved_cents"] == 800 # 5000 − 4200 page = client.get("/api/v1/savings/records", headers=_auth(token)).json() diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 79743ba..ffe0331 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -90,6 +90,32 @@ def test_signin_flow(client) -> None: assert txn["balance_after"] == SIGNIN_REWARDS[0] +def test_signin_boost_flow(client) -> None: + """签到后看广告膨胀 → 补发一笔等额签到金币,每天只能膨胀一次。""" + token = _login(client, "13800001011") + + r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token)) + assert r.status_code == 409 + + r = client.post("/api/v1/signin", headers=_auth(token)) + assert r.status_code == 200, r.text + first_coin = r.json()["coin_awarded"] + + r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["coin_awarded"] == first_coin + assert body["coin_balance"] == first_coin * 2 + + r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token)) + assert r.status_code == 409 + + r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) + types = [t["biz_type"] for t in r.json()["items"]] + assert "signin" in types + assert "signin_boost" in types + + def test_task_claim_flow(client) -> None: """任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。""" token = _login(client, "13800001003") @@ -253,38 +279,62 @@ def test_exchange_insufficient_and_invalid(client) -> None: assert r.status_code == 400 +def _report_savings(client, token: str, n: int, *, original=5000, paid=4200) -> None: + """上报 n 笔真实比价记录(source='compare'),省额 = original − paid。""" + for i in range(n): + client.post( + "/api/v1/order/report", + json={ + "client_event_id": f"evt-{token[-6:]}-{i}", + "platform": "美团外卖", + "pay_channel": "wechat", + "compared_price_cents": paid, + "paid_amount_cents": paid, + "original_price_cents": original, + "shop_name": f"测试门店{i}", + "dishes": ["招牌菜"], + }, + headers=_auth(token), + ) + + def test_savings_summary_and_battle(client) -> None: - """首次访问触发 demo seeder,聚合接口返回真实算出的数字。""" + """聚合接口对真实上报记录返回真实算出的数字(demo 兜底已停用)。""" token = _login(client, "13800001007") + _report_savings(client, token, 2) # 2 笔,各省 5000−4200=800 r = client.get("/api/v1/savings/summary", headers=_auth(token)) assert r.status_code == 200, r.text s = r.json() - assert s["order_count"] > 0 - assert s["total_saved_cents"] > 0 + assert s["order_count"] == 2 + assert s["total_saved_cents"] == 1600 # 平均每单 = 总额 // 单数 assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"] r = client.get("/api/v1/savings/battle", headers=_auth(token)) assert r.status_code == 200, r.text b = r.json() - assert b["streak_days"] >= 1 + assert b["streak_days"] >= 1 # 今日上报 → 至少连续 1 天 assert b["week_saved_cents"] >= 0 assert 0 <= b["beat_percent"] <= 100 - # 完成比价次数与 summary 的 order_count 同源(均 = 有效记录数) + # 完成比价次数与 summary 的 order_count 同源(均 = 真实记录数) assert b["compare_count"] == s["order_count"] -def test_savings_seeder_idempotent(client) -> None: - """重复访问不应重复灌数据:两次 summary 完全一致。""" +def test_savings_empty_for_new_user(client) -> None: + """新用户无真实上报时全 0(demo 兜底已停用),两次读一致。""" token = _login(client, "13800001008") first = client.get("/api/v1/savings/summary", headers=_auth(token)).json() second = client.get("/api/v1/savings/summary", headers=_auth(token)).json() assert first == second + assert first["order_count"] == 0 + assert first["total_saved_cents"] == 0 def test_savings_records_pagination(client) -> None: token = _login(client, "13800001009") + _report_savings(client, token, 12) # 12 笔真实记录撑起分页 + # 第一页 r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token)) assert r.status_code == 200, r.text