Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b51cd2d1b | |||
| bc10432f52 | |||
| ece41086cd | |||
| 766666601e |
@@ -40,6 +40,9 @@ MT_CPS_APP_KEY=
|
||||
MT_CPS_APP_SECRET=
|
||||
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
|
||||
MT_CPS_DEFAULT_SID=sgbjia
|
||||
# 美团调用走的代理。⚠️ 本机/内网开发直连美团会 SSL EOF,必须填本地代理(如 http://127.0.0.1:7897);
|
||||
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
|
||||
MT_CPS_PROXY=
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||
|
||||
+7
-1
@@ -23,7 +23,13 @@ dist/
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
data/
|
||||
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
|
||||
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
|
||||
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
|
||||
data/*
|
||||
!data/media/
|
||||
data/media/*
|
||||
!data/media/dl.html
|
||||
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge meituan_coupon 与 ops/price_observation 迁移头
|
||||
|
||||
Revision ID: 11a1d08c6f55
|
||||
Revises: meituan_coupon_table, opsrename01
|
||||
Create Date: 2026-06-08 00:06:01.633796
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '11a1d08c6f55'
|
||||
down_revision: Union[str, Sequence[str], None] = ('meituan_coupon_table', 'opsrename01')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -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')
|
||||
@@ -0,0 +1,54 @@
|
||||
"""user.invite_code + invite_relation table
|
||||
|
||||
Revision ID: invite_code_and_relation
|
||||
Revises: 11a1d08c6f55
|
||||
Create Date: 2026-06-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'invite_code_and_relation'
|
||||
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# user 加邀请码列(唯一索引;nullable 允许多个 NULL=未生成的用户)
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('invite_code', sa.String(length=16), nullable=True))
|
||||
batch_op.create_index('ix_user_invite_code', ['invite_code'], unique=True)
|
||||
|
||||
# 邀请关系表
|
||||
op.create_table(
|
||||
'invite_relation',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('invitee_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('channel', sa.String(length=16), nullable=False, server_default='clipboard'),
|
||||
sa.Column('status', sa.String(length=16), nullable=False, server_default='effective'),
|
||||
sa.Column('inviter_coin', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('invitee_coin', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
|
||||
sa.ForeignKeyConstraint(['invitee_user_id'], ['user.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_invite_relation_inviter_user_id', 'invite_relation', ['inviter_user_id'])
|
||||
op.create_index('ix_invite_relation_invitee_user_id', 'invite_relation', ['invitee_user_id'], unique=True)
|
||||
op.create_index('ix_invite_relation_created_at', 'invite_relation', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_invite_relation_created_at', table_name='invite_relation')
|
||||
op.drop_index('ix_invite_relation_invitee_user_id', table_name='invite_relation')
|
||||
op.drop_index('ix_invite_relation_inviter_user_id', table_name='invite_relation')
|
||||
op.drop_table('invite_relation')
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_user_invite_code')
|
||||
batch_op.drop_column('invite_code')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""marquee_seed 升级为「生成规则」:用户名可空 + 金额拆 min/max 区间
|
||||
|
||||
把种子从「死记录」升级:
|
||||
- masked_user 改 nullable(留空 → feed 随机合成脱敏用户名)
|
||||
- saved_amount_cents(单值)拆成 min_cents / max_cents(feed 在区间内随机取值;固定金额则相等)
|
||||
现有 6 条种子:min_cents = max_cents = 原 saved_amount_cents,masked_user 原样保留。
|
||||
|
||||
Revision ID: marquee_seed02
|
||||
Revises: marquee_seed01
|
||||
Create Date: 2026-06-07 11:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'marquee_seed02'
|
||||
down_revision: Union[str, Sequence[str], None] = 'marquee_seed01'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) 先加可空新列,使现有行合法
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('min_cents', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('max_cents', sa.Integer(), nullable=True))
|
||||
# 2) 用旧单值回填区间(固定金额)
|
||||
op.execute('UPDATE marquee_seed SET min_cents = saved_amount_cents, max_cents = saved_amount_cents')
|
||||
# 3) 收紧:区间非空、用户名可空、删旧列
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.alter_column('min_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('max_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=True)
|
||||
batch_op.drop_column('saved_amount_cents')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 反向:恢复单值列(取下限),用户名回非空
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('saved_amount_cents', sa.Integer(), nullable=True))
|
||||
op.execute('UPDATE marquee_seed SET saved_amount_cents = min_cents')
|
||||
# 自动合成名的种子 masked_user 为 NULL,降级前补占位符以满足非空
|
||||
op.execute("UPDATE marquee_seed SET masked_user = '用户********000' WHERE masked_user IS NULL")
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.alter_column('saved_amount_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=False)
|
||||
batch_op.drop_column('max_cents')
|
||||
batch_op.drop_column('min_cents')
|
||||
@@ -0,0 +1,52 @@
|
||||
"""marquee_seed table (首页轮播种子) + 合并 platform_stat / price_observation 两 head
|
||||
|
||||
Revision ID: marquee_seed01
|
||||
Revises: pstat2anchor01, price_observation_table
|
||||
Create Date: 2026-06-07 10:00:00.000000
|
||||
|
||||
兼并当前两个 head 为单 head,同时建 marquee_seed 表并播种客户端原写死的 6 条轮播
|
||||
(作为初始种子,保证上线即有内容)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'marquee_seed01'
|
||||
down_revision: Union[str, Sequence[str], None] = ('pstat2anchor01', 'price_observation_table')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = op.create_table(
|
||||
'marquee_seed',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('masked_user', sa.String(length=64), nullable=False),
|
||||
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# 初始种子 = 客户端原写死的 6 条(金额转成分)
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[
|
||||
{'masked_user': '用户********a52', 'saved_amount_cents': 1860, 'enabled': True, 'sort_order': 1},
|
||||
{'masked_user': '用户********k89', 'saved_amount_cents': 4240, 'enabled': True, 'sort_order': 2},
|
||||
{'masked_user': '用户********m71', 'saved_amount_cents': 790, 'enabled': True, 'sort_order': 3},
|
||||
{'masked_user': '用户********p46', 'saved_amount_cents': 15630, 'enabled': True, 'sort_order': 4},
|
||||
{'masked_user': '用户********r93', 'saved_amount_cents': 2350, 'enabled': True, 'sort_order': 5},
|
||||
{'masked_user': '用户********c28', 'saved_amount_cents': 8940, 'enabled': True, 'sort_order': 6},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('marquee_seed')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""meituan_coupon table(美团 CPS 券本地缓存,供销量/佣金排序从库里捞、本地排序)
|
||||
|
||||
Revision ID: meituan_coupon_table
|
||||
Revises: withdraw_review_ad_watch
|
||||
Create Date: 2026-06-06 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'meituan_coupon_table'
|
||||
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:
|
||||
op.create_table(
|
||||
'meituan_coupon',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('source', sa.String(length=16), nullable=False),
|
||||
sa.Column('platform', sa.Integer(), nullable=False),
|
||||
sa.Column('biz_line', sa.Integer(), nullable=True),
|
||||
sa.Column('city_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('product_view_sign', sa.String(length=128), nullable=False),
|
||||
sa.Column('sku_view_id', sa.String(length=128), nullable=True),
|
||||
sa.Column('name', sa.String(length=256), nullable=True),
|
||||
sa.Column('brand_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('sell_price_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('original_price_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('head_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('sale_volume', sa.String(length=32), nullable=True),
|
||||
sa.Column('sale_volume_num', sa.Integer(), nullable=True),
|
||||
sa.Column('commission_percent', sa.Float(), nullable=True),
|
||||
sa.Column('commission_amount_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('poi_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('available_poi_num', sa.Integer(), nullable=True),
|
||||
sa.Column('delivery_distance_m', sa.Float(), nullable=True),
|
||||
sa.Column('dedup_key', sa.String(length=64), nullable=False),
|
||||
sa.Column('raw', sa.JSON().with_variant(postgresql.JSONB(), 'postgresql'), nullable=False),
|
||||
sa.Column('first_seen', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('source', 'product_view_sign', name='uq_meituan_coupon_source_sign'),
|
||||
)
|
||||
op.create_index('ix_meituan_coupon_source', 'meituan_coupon', ['source'])
|
||||
op.create_index('ix_meituan_coupon_city_id', 'meituan_coupon', ['city_id'])
|
||||
op.create_index('ix_meituan_coupon_brand_name', 'meituan_coupon', ['brand_name'])
|
||||
op.create_index('ix_meituan_coupon_sale_volume_num', 'meituan_coupon', ['sale_volume_num'])
|
||||
op.create_index('ix_meituan_coupon_commission_percent', 'meituan_coupon', ['commission_percent'])
|
||||
op.create_index('ix_meituan_coupon_dedup_key', 'meituan_coupon', ['dedup_key'])
|
||||
op.create_index('ix_meituan_coupon_last_seen', 'meituan_coupon', ['last_seen'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('meituan_coupon')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
+39
-3
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""好友邀请 endpoint。
|
||||
|
||||
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据):
|
||||
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
|
||||
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码,注册即生效,双方各发金币
|
||||
|
||||
客户端两种调用 /bind:
|
||||
- 自动:首启读剪贴板拿到邀请码 → 登录后调本接口(channel=clipboard)
|
||||
- 手动:用户在邀请页输入好友邀请码(channel=manual)
|
||||
|
||||
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币)在 repositories/invite.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import invite as invite_repo
|
||||
from app.schemas.invite import BindInviteIn, BindInviteOut, InviteInfoOut
|
||||
|
||||
logger = logging.getLogger("shagua.invite")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
|
||||
|
||||
_BIND_MESSAGES = {
|
||||
"success": "邀请绑定成功,金币已到账",
|
||||
"already_bound": "你已绑定过邀请人",
|
||||
"invalid_code": "邀请码无效",
|
||||
"self_invite": "不能填写自己的邀请码",
|
||||
"not_eligible": "邀请仅对新注册用户生效",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
|
||||
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
||||
code = invite_repo.ensure_code(db, user)
|
||||
invited, coins = invite_repo.get_stats(db, user.id)
|
||||
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
|
||||
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
|
||||
return InviteInfoOut(
|
||||
invite_code=code,
|
||||
share_url=share_url,
|
||||
invited_count=invited,
|
||||
coins_earned=coins,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
|
||||
def bind_invite(req: BindInviteIn, user: CurrentUser, db: DbSession) -> BindInviteOut:
|
||||
result = invite_repo.bind(
|
||||
db, invitee=user, invite_code=req.invite_code, channel=req.channel,
|
||||
)
|
||||
logger.info(
|
||||
"invite bind invitee=%d code=%s channel=%s -> %s",
|
||||
user.id, req.invite_code, req.channel, result.status,
|
||||
)
|
||||
return BindInviteOut(
|
||||
status=result.status,
|
||||
coins_awarded=result.invitee_coin,
|
||||
message=_BIND_MESSAGES.get(result.status, result.status),
|
||||
)
|
||||
+75
-4
@@ -7,10 +7,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
|
||||
from app.models.meituan_coupon import MeituanCoupon
|
||||
from app.schemas.meituan import (
|
||||
CouponCard,
|
||||
CouponListRequest,
|
||||
@@ -19,6 +23,7 @@ from app.schemas.meituan import (
|
||||
FeedResponse,
|
||||
ReferralLinkRequest,
|
||||
ReferralLinkResponse,
|
||||
TopSalesRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.meituan")
|
||||
@@ -79,13 +84,21 @@ def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
|
||||
def _commission_pct(card: CouponCard) -> float:
|
||||
"""'1.4%' → 1.4;解析失败按 0(会被智能推荐过滤掉)。"""
|
||||
try:
|
||||
return float(card.commission_rate.rstrip("%"))
|
||||
except (ValueError, AttributeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
|
||||
def feed(req: FeedRequest) -> FeedResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||
page_idx = req.page - 1
|
||||
lon, lat = req.longitude, req.latitude
|
||||
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
|
||||
tab = (req.tab or "").strip()
|
||||
logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat)
|
||||
|
||||
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
|
||||
try:
|
||||
@@ -97,6 +110,28 @@ def feed(req: FeedRequest) -> FeedResponse:
|
||||
except MeituanCpsError:
|
||||
return []
|
||||
|
||||
# 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回。
|
||||
# (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。)
|
||||
if tab == "distance":
|
||||
with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool:
|
||||
futs = []
|
||||
for wm_topic, dd_topic in _TOPIC_ROUNDS:
|
||||
futs.append(pool.submit(_fetch_topic, 1, None, wm_topic))
|
||||
futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic))
|
||||
raws = [f.result() for f in futs]
|
||||
seen: set[str] = set()
|
||||
cards: list[CouponCard] = []
|
||||
for raw_list in raws:
|
||||
for it in raw_list:
|
||||
card = CouponCard.from_raw(it)
|
||||
if card.product_view_sign and card.product_view_sign not in seen:
|
||||
seen.add(card.product_view_sign)
|
||||
cards.append(card)
|
||||
cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf"))
|
||||
return FeedResponse(items=cards, has_next=False, page=1)
|
||||
|
||||
# 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。
|
||||
page_idx = req.page - 1
|
||||
if page_idx >= len(_TOPIC_ROUNDS):
|
||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||
|
||||
@@ -107,6 +142,8 @@ def feed(req: FeedRequest) -> FeedResponse:
|
||||
waimai, daodian = f_wm.result(), f_dd.result()
|
||||
|
||||
items = _interleave(waimai, daodian)
|
||||
if tab == "rec":
|
||||
items = [c for c in items if _commission_pct(c) >= 3.0]
|
||||
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
|
||||
return FeedResponse(items=items, has_next=has_next, page=req.page)
|
||||
|
||||
@@ -130,3 +167,37 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
|
||||
link_map = raw.get("referralLinkMap") or {}
|
||||
link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "")
|
||||
return ReferralLinkResponse(link=link, link_map=link_map)
|
||||
|
||||
|
||||
@router.post("/top-sales", response_model=CouponListResponse,
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
|
||||
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
|
||||
# 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序
|
||||
stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
|
||||
if req.platform is not None:
|
||||
stmt = stmt.where(MeituanCoupon.platform == req.platform)
|
||||
stmt = stmt.order_by(
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
)
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
|
||||
# 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。
|
||||
# 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。
|
||||
seen: set[str] = set()
|
||||
cards: list[CouponCard] = []
|
||||
for row in rows:
|
||||
if row.dedup_key in seen:
|
||||
continue
|
||||
seen.add(row.dedup_key)
|
||||
try:
|
||||
card = CouponCard.from_raw(row.raw or {})
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign:
|
||||
cards.append(card)
|
||||
|
||||
start = (req.page - 1) * req.page_size
|
||||
page_items = cards[start:start + req.page_size]
|
||||
has_next = start + req.page_size < len(cards)
|
||||
return CouponListResponse(items=page_items, has_next=has_next, search_id=None)
|
||||
|
||||
@@ -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])
|
||||
+32
-2
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -81,6 +81,9 @@ class Settings(BaseSettings):
|
||||
MT_CPS_HOST: str = "https://media.meituan.com"
|
||||
MT_CPS_TIMEOUT_SEC: int = 15
|
||||
MT_CPS_DEFAULT_SID: str = "sgbjia"
|
||||
# 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897;
|
||||
# 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。
|
||||
MT_CPS_PROXY: str = ""
|
||||
|
||||
@property
|
||||
def mt_cps_configured(self) -> bool:
|
||||
@@ -170,6 +173,11 @@ class Settings(BaseSettings):
|
||||
MEDIA_URL_PREFIX: str = "/media"
|
||||
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
|
||||
|
||||
# ===== 邀请好友 =====
|
||||
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
|
||||
# MVP 落地页就放 app-server 的 /media 静态目录下(dl.html)。
|
||||
INVITE_LANDING_URL: str = "https://app-api.shaguabijia.com/media/dl.html"
|
||||
|
||||
# ===== CORS =====
|
||||
CORS_ALLOW_ORIGINS: str = ""
|
||||
|
||||
|
||||
@@ -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 秒。",
|
||||
},
|
||||
}
|
||||
|
||||
+83
-19
@@ -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,73 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
return RECORD_MILESTONES[milestone - 1]
|
||||
|
||||
|
||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||
INVITE_INVITER_COINS: int = 10000
|
||||
INVITE_INVITEE_COINS: int = 10000
|
||||
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
|
||||
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
|
||||
INVITE_NEW_USER_WINDOW_HOURS: int = 72
|
||||
|
||||
|
||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||
# 金币数值体系约定: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 +153,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
|
||||
|
||||
@@ -62,8 +62,12 @@ def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
url = f"{settings.MT_CPS_HOST}{path}"
|
||||
# 美团调用走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
|
||||
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
|
||||
proxy = settings.MT_CPS_PROXY or None
|
||||
try:
|
||||
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
|
||||
with httpx.Client(proxy=proxy, trust_env=False, timeout=settings.MT_CPS_TIMEOUT_SEC) as client:
|
||||
resp = client.post(url, content=body, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception("[MT] http error calling %s", url)
|
||||
raise MeituanCpsError(f"meituan http error: {e}") from e
|
||||
|
||||
@@ -22,8 +22,10 @@ from app.api.v1.compare_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
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.invite import router as invite_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
|
||||
@@ -78,6 +80,7 @@ def health() -> dict[str, str]:
|
||||
app.include_router(auth_router)
|
||||
app.include_router(user_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(invite_router)
|
||||
app.include_router(coupon_router)
|
||||
app.include_router(compare_router)
|
||||
app.include_router(compare_record_router)
|
||||
@@ -93,6 +96,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)
|
||||
|
||||
@@ -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,14 @@ 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.invite import InviteRelation # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # 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
|
||||
|
||||
@@ -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"<AdFeedRewardRecord user_id={self.user_id} event={self.client_event_id} "
|
||||
f"{self.status} coin={self.coin}>"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""好友邀请关系表(注册即生效)。
|
||||
|
||||
一行 = 一次成功的邀请绑定。`invitee_user_id` 唯一 = 一个被邀请人只能被归因一次
|
||||
(天然幂等键,防重复发奖,仿 ad_reward.trans_id 思路)。`channel` 记归因来源
|
||||
(clipboard 自动 / manual 手动填),便于后续分析"剪贴板自动捕获率"。
|
||||
|
||||
邀请码本身是 `user.invite_code`(一人一稳定码,懒生成),不在这张表里。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InviteRelation(Base):
|
||||
__tablename__ = "invite_relation"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
inviter_user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 被邀请人唯一:一个人只能被邀一次(幂等键,防重复发奖)
|
||||
invitee_user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), unique=True, index=True, nullable=False
|
||||
)
|
||||
# 归因来源:clipboard(剪贴板自动) / manual(手动填码)
|
||||
channel: Mapped[str] = mapped_column(String(16), nullable=False, default="clipboard")
|
||||
# 状态:effective(注册即生效)。预留:以后改"完成首单才生效"时用 pending/effective
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="effective")
|
||||
# 本次给邀请人 / 被邀请人各发的金币(记账留痕,便于对账)
|
||||
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
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"<InviteRelation inviter={self.inviter_user_id} "
|
||||
f"invitee={self.invitee_user_id} {self.status}>"
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""美团 CPS 券本地缓存表(meituan_coupon)。
|
||||
|
||||
把美团联盟 CPS 的券定时抓进本地库,供「销量 / 佣金排序」等查询**直接从库里捞、本地排序**,
|
||||
不再每次实时打美团接口——美团搜索/供给对销量排序支持差(实测乱序)、且有 402 限流和召回上限。
|
||||
|
||||
数据来源(source,北京试点):
|
||||
- search_waimai : 到家/外卖, 搜索关键词「外卖」, 翻到尽头
|
||||
- search_meishi : 到家/外卖, 搜索关键词「美食」, 翻到尽头
|
||||
- store_supply : 到店, 多业务线供给(到餐+到综+酒店+门票), 翻到尽头
|
||||
|
||||
每 5min(本地测试)/ 1h(线上)全量抓一次,按 (source, product_view_sign) upsert 存最新态,
|
||||
last_seen 每轮刷新(可据此清理长期未再出现的陈旧券)。
|
||||
|
||||
去重说明:`product_view_sign` / `sku_view_id` 都是「按召回渠道生成」的,跨渠道(搜索 vs 供给)
|
||||
会变,**不能当商品全局唯一 id**。因此:
|
||||
- 存储:按 (source, product_view_sign) upsert(同源短周期内 sign 稳定)。
|
||||
- 查询:用 `dedup_key = md5(brand|name|price)` 跨源去重,再按 `sale_volume_num` / `commission_percent` 排序。
|
||||
|
||||
价格统一存「分」(cents),与 price_report / comparison_record 一致。`raw` 保留整条原始返回
|
||||
(字段越详细越好,避免后续漏字段还要重抓)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Float, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class MeituanCoupon(Base):
|
||||
__tablename__ = "meituan_coupon"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "product_view_sign", name="uq_meituan_coupon_source_sign"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# ===== 来源 / 分类 =====
|
||||
# source: search_waimai | search_meishi | store_supply
|
||||
source: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
|
||||
platform: Mapped[int] = mapped_column(Integer, nullable=False) # 1 到家/外卖, 2 到店
|
||||
biz_line: Mapped[int | None] = mapped_column(Integer, nullable=True) # 到店: 1到餐 2到综 3酒店 4门票
|
||||
city_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False)
|
||||
|
||||
# ===== 召回 id(按渠道生成,跨渠道会变;product_view_sign 用于换推广链) =====
|
||||
product_view_sign: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
sku_view_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# ===== 商品本体 =====
|
||||
name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
brand_name: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
sell_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
head_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# ===== 销量(美团只给粗档位:热销1w+;num=排序用的下界数值,如 1w+ → 10000) =====
|
||||
sale_volume: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
sale_volume_num: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
|
||||
# ===== 佣金(percent 如 1.4 表示 1.4%;amount 为分) =====
|
||||
commission_percent: Mapped[float | None] = mapped_column(Float, index=True, nullable=True)
|
||||
commission_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# ===== 门店 / 距离 =====
|
||||
poi_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
available_poi_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
delivery_distance_m: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# ===== 跨源去重键 + 原始返回 =====
|
||||
dedup_key: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
raw: Mapped[dict] = mapped_column(
|
||||
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=dict
|
||||
)
|
||||
|
||||
# ===== 抓取时间窗 =====
|
||||
first_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
last_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
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"<MeituanCoupon id={self.id} source={self.source} "
|
||||
f"name={self.name!r} sale={self.sale_volume} comm={self.commission_percent}>"
|
||||
)
|
||||
@@ -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"<OpsMarqueeSeed id={self.id} user={self.masked_user}>"
|
||||
@@ -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"<OpsStatConfig metric={self.metric} mode={self.mode}>"
|
||||
+30
-2
@@ -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"<SigninRecord user_id={self.user_id} date={self.signin_date} "
|
||||
f"day={self.cycle_day} streak={self.streak}>"
|
||||
)
|
||||
|
||||
|
||||
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"<SigninBoostRecord user_id={self.user_id} date={self.signin_date} coin={self.coin_awarded}>"
|
||||
|
||||
@@ -33,6 +33,12 @@ class User(Base):
|
||||
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# 邀请码:每个用户一个稳定短码(懒生成,见 repositories/invite.ensure_code),
|
||||
# 分享链接 / 二维码里带它。unique 允许多个 NULL(未生成的用户)。
|
||||
invite_code: Mapped[str | None] = mapped_column(
|
||||
String(16), unique=True, index=True, nullable=True
|
||||
)
|
||||
|
||||
# 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一;
|
||||
# unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。
|
||||
wechat_openid: Mapped[str | None] = mapped_column(
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
|
||||
|
||||
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
|
||||
1. invitee_user_id 唯一 → 一个被邀请人只能被绑定一次(幂等键)。
|
||||
2. 自邀屏蔽 → inviter == invitee 直接拒。
|
||||
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模。
|
||||
|
||||
发金币复用 wallet.grant_coins(grant 只 flush 不 commit),与建关系记录在**同一事务**
|
||||
commit,保证"建关系 + 双方加金币"原子。奖励额 = rewards.INVITE_INVITER_COINS /
|
||||
INVITE_INVITEE_COINS。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.invite import InviteRelation
|
||||
from app.models.user import User
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
|
||||
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
|
||||
_CODE_LEN = 6
|
||||
|
||||
|
||||
def _gen_code() -> str:
|
||||
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
|
||||
|
||||
|
||||
def ensure_code(db: Session, user: User) -> str:
|
||||
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
|
||||
|
||||
⚠️ 本函数会 db.commit() 整个 session(与 grant_coins"只 flush 不 commit"约定相反)。
|
||||
当前只在 GET /invite/me 里调(该请求此前无其它写,提交范围干净)。若将来在带其它未提交
|
||||
写的请求里复用本函数,会被它提前 commit——届时应改成 flush + 由调用方 commit。
|
||||
"""
|
||||
if user.invite_code:
|
||||
return user.invite_code
|
||||
for _ in range(8):
|
||||
user.invite_code = _gen_code()
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user.invite_code
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
user = db.get(User, user.id) # 重取(rollback 后实例已过期),继续换码
|
||||
raise RuntimeError("生成邀请码连续碰撞,请重试")
|
||||
|
||||
|
||||
def resolve_inviter(db: Session, invite_code: str) -> User | None:
|
||||
"""邀请码 → 邀请人(大小写不敏感)。"""
|
||||
code = (invite_code or "").strip().upper()
|
||||
if not code:
|
||||
return None
|
||||
return db.execute(
|
||||
select(User).where(User.invite_code == code)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _relation_of_invitee(db: Session, invitee_id: int) -> InviteRelation | None:
|
||||
return db.execute(
|
||||
select(InviteRelation).where(InviteRelation.invitee_user_id == invitee_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _is_new_user(user: User) -> bool:
|
||||
"""被邀请人是否为"新注册"(created_at 在 INVITE_NEW_USER_WINDOW_HOURS 窗口内)。
|
||||
|
||||
"新用户闸":只奖刚注册的人,挡存量老用户互相填码薅羊毛。兼容 PG(tz-aware)与
|
||||
SQLite(naive,按 UTC 解释)。
|
||||
"""
|
||||
created = user.created_at
|
||||
if created is None:
|
||||
return False
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
age = datetime.now(timezone.utc) - created
|
||||
return age <= timedelta(hours=rewards.INVITE_NEW_USER_WINDOW_HOURS)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BindResult:
|
||||
status: str # success / already_bound / invalid_code / self_invite / not_eligible
|
||||
relation: InviteRelation | None = None
|
||||
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
|
||||
|
||||
|
||||
def bind(
|
||||
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
|
||||
) -> BindResult:
|
||||
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币。
|
||||
|
||||
幂等:invitee 已被绑过 → already_bound(不重复发奖)。
|
||||
"""
|
||||
# 幂等:已绑过直接返回(不重复发奖)
|
||||
existing = _relation_of_invitee(db, invitee.id)
|
||||
if existing is not None:
|
||||
return BindResult("already_bound", existing)
|
||||
|
||||
inviter = resolve_inviter(db, invite_code)
|
||||
if inviter is None or inviter.status != "active":
|
||||
return BindResult("invalid_code")
|
||||
if inviter.id == invitee.id:
|
||||
return BindResult("self_invite")
|
||||
# 新用户闸:被邀请人必须是"新注册"(窗口内)才发奖,挡存量老用户互相填码薅羊毛
|
||||
if not _is_new_user(invitee):
|
||||
return BindResult("not_eligible")
|
||||
|
||||
inviter_coin = rewards.INVITE_INVITER_COINS
|
||||
invitee_coin = rewards.INVITE_INVITEE_COINS
|
||||
|
||||
rel = InviteRelation(
|
||||
inviter_user_id=inviter.id,
|
||||
invitee_user_id=invitee.id,
|
||||
channel=(channel or "clipboard")[:16],
|
||||
status="effective",
|
||||
inviter_coin=inviter_coin,
|
||||
invitee_coin=invitee_coin,
|
||||
)
|
||||
db.add(rel)
|
||||
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。
|
||||
crud_wallet.grant_coins(
|
||||
db, inviter.id, inviter_coin,
|
||||
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
|
||||
)
|
||||
crud_wallet.grant_coins(
|
||||
db, invitee.id, invitee_coin,
|
||||
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发:同一 invitee 另一个请求先建了关系 → 回滚返回已存在(幂等兜底)
|
||||
db.rollback()
|
||||
existing = _relation_of_invitee(db, invitee.id)
|
||||
if existing is not None:
|
||||
return BindResult("already_bound", existing)
|
||||
raise
|
||||
except Exception:
|
||||
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
|
||||
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
|
||||
db.rollback()
|
||||
raise
|
||||
db.refresh(rel)
|
||||
return BindResult("success", rel, invitee_coin)
|
||||
|
||||
|
||||
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
|
||||
count = db.execute(
|
||||
select(func.count())
|
||||
.select_from(InviteRelation)
|
||||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||
).scalar_one()
|
||||
coins = db.execute(
|
||||
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
|
||||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||
).scalar_one()
|
||||
return int(count), int(coins)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+28
-108
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-5
@@ -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="每日信息流展示次数上限")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""邀请相关 API 收发模型。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class InviteInfoOut(BaseModel):
|
||||
invite_code: str # 我的邀请码
|
||||
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
|
||||
invited_count: int # 已成功邀请人数
|
||||
coins_earned: int # 累计从邀请获得的金币
|
||||
|
||||
|
||||
class BindInviteIn(BaseModel):
|
||||
invite_code: str = Field(..., min_length=4, max_length=16, description="邀请人的邀请码")
|
||||
channel: str = Field(
|
||||
"clipboard", max_length=16,
|
||||
description="归因来源:clipboard(首启读剪贴板自动) / manual(用户手动填)",
|
||||
)
|
||||
|
||||
|
||||
class BindInviteOut(BaseModel):
|
||||
status: str # success / already_bound / invalid_code / self_invite / not_eligible
|
||||
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
|
||||
message: str # 给前端直接展示的文案
|
||||
@@ -134,6 +134,12 @@ class FeedRequest(BaseModel):
|
||||
latitude: float = Field(..., description="纬度")
|
||||
page: int = Field(1, ge=1)
|
||||
page_size: int = Field(20, ge=1, le=20)
|
||||
# 筛选/排序口径,后端据此处理后返回(前端不再自己筛/排):
|
||||
# rec = 智能推荐:榜单混合 feed 去掉佣金率 < 3%(分页)
|
||||
# distance = 距离最近:拉齐全部轮次后全局按距离由近及远(一次性返回, has_next=False)
|
||||
# 留空/其它 = 原混合 feed 不筛(老客户端兼容,新 app 会显式传 tab)
|
||||
# (销量最高 sales 走 /coupons 同城热销,不在本接口)
|
||||
tab: str = Field("", description="rec 智能推荐 / distance 距离最近 / 空=不筛(兼容)")
|
||||
|
||||
|
||||
class FeedResponse(BaseModel):
|
||||
@@ -142,6 +148,13 @@ class FeedResponse(BaseModel):
|
||||
page: int = 1
|
||||
|
||||
|
||||
class TopSalesRequest(BaseModel):
|
||||
"""销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。"""
|
||||
page: int = Field(1, ge=1)
|
||||
page_size: int = Field(20, ge=1, le=50)
|
||||
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(全城销量)")
|
||||
|
||||
|
||||
# ───────────────── 换链 请求 / 响应 ─────────────────
|
||||
|
||||
class ReferralLinkRequest(BaseModel):
|
||||
|
||||
@@ -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]
|
||||
+11
-1
@@ -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):
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<title>傻瓜比价 · 下载</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
||||
html,body { height:100%; }
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%);
|
||||
color:#fff; min-height:100%; display:flex; flex-direction:column;
|
||||
align-items:center; justify-content:center; padding:40px 26px; text-align:center;
|
||||
overflow-x:hidden;
|
||||
}
|
||||
.logo {
|
||||
width:104px; height:104px; border-radius:26px; background:#fff;
|
||||
display:flex; align-items:center; justify-content:center; font-size:52px;
|
||||
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px;
|
||||
}
|
||||
h1 { font-size:30px; font-weight:800; letter-spacing:1px; }
|
||||
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; }
|
||||
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; }
|
||||
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; }
|
||||
.feat b { font-weight:700; }
|
||||
.btn {
|
||||
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer;
|
||||
background:#fff; color:#FF3B30; font-size:19px; font-weight:800;
|
||||
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22);
|
||||
display:flex; align-items:center; justify-content:center; gap:9px;
|
||||
}
|
||||
.btn:active { transform:translateY(1px); opacity:.92; }
|
||||
.hint { margin-top:16px; font-size:13px; opacity:.85; }
|
||||
.foot { margin-top:30px; font-size:12px; opacity:.6; line-height:1.6; max-width:320px; }
|
||||
|
||||
/* 微信内"去浏览器打开"引导蒙层 */
|
||||
#wxmask {
|
||||
display:none; position:fixed; inset:0; z-index:9999;
|
||||
background:rgba(0,0,0,.86); padding:18px;
|
||||
}
|
||||
#wxmask.show { display:block; }
|
||||
.arrow { position:absolute; top:8px; right:14px; width:120px; }
|
||||
.wxtip { position:absolute; top:150px; right:18px; left:18px; text-align:right; }
|
||||
.wxtip .big { font-size:21px; font-weight:800; line-height:1.5; }
|
||||
.wxtip .big em { color:#FFD24D; font-style:normal; }
|
||||
.wxtip .sub { margin-top:14px; font-size:15px; line-height:1.8; opacity:.9; }
|
||||
.wxsteps { margin-top:26px; text-align:left; background:rgba(255,255,255,.1); border-radius:14px; padding:18px 18px; font-size:15px; line-height:2; }
|
||||
.wxsteps .n { display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; border-radius:50%; background:#FFD24D; color:#333; font-weight:800; font-size:13px; margin-right:8px; }
|
||||
.closebar { position:absolute; bottom:30px; left:0; right:0; text-align:center; font-size:14px; opacity:.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">🛒</div>
|
||||
<h1>傻瓜比价</h1>
|
||||
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div>
|
||||
|
||||
<div class="feats">
|
||||
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div>
|
||||
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div>
|
||||
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="dlbtn">⬇️ 下载安装包</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div>
|
||||
|
||||
<div class="foot">
|
||||
安装时如提示「未知来源」,请允许后继续安装。<br>
|
||||
本页为内部测试页。
|
||||
</div>
|
||||
|
||||
<!-- 微信内引导:跳出微信去浏览器 -->
|
||||
<div id="wxmask">
|
||||
<svg class="arrow" viewBox="0 0 120 130" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M30 120 C 30 70, 55 40, 95 28" stroke="#FFD24D" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="2 13"/>
|
||||
<path d="M95 28 L 78 30 M95 28 L 92 46" stroke="#FFD24D" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="wxtip">
|
||||
<div class="big">点击右上角 <em>···</em><br>选择「<em>在浏览器打开</em>」</div>
|
||||
<div class="sub">微信里无法直接下载安装包<br>需在系统浏览器中完成下载</div>
|
||||
<div class="wxsteps">
|
||||
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
|
||||
<div><span class="n">2</span>选择「在浏览器打开」</div>
|
||||
<div><span class="n">3</span>在浏览器里点「下载安装包」</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="closebar" id="wxclose">我知道了 ✕</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 →
|
||||
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP。
|
||||
var APK_URL = location.origin + "/media/shaguabijia.apk";
|
||||
var ua = navigator.userAgent || "";
|
||||
var isWeChat = /MicroMessenger/i.test(ua);
|
||||
var isIOS = /iPhone|iPad|iPod/i.test(ua);
|
||||
var isAndroid = /Android/i.test(ua);
|
||||
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
|
||||
|
||||
// 把邀请码写进剪贴板,APK 首启时读出来完成归因(deferred deeplink 的关键一步)。
|
||||
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
|
||||
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
|
||||
try {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||
document.execCommand("copy"); document.body.removeChild(ta);
|
||||
} catch (e) {}
|
||||
}
|
||||
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断)
|
||||
if (!ref) return Promise.resolve();
|
||||
var payload = "SGBJ_INVITE:" + ref;
|
||||
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。
|
||||
function startDownload() {
|
||||
var fired = false;
|
||||
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } }
|
||||
copyInviteCode().then(go);
|
||||
setTimeout(go, 400);
|
||||
}
|
||||
|
||||
var hint = document.getElementById("hint");
|
||||
if (isIOS) hint.textContent = "检测到 iPhone · iOS 版请前往 App Store";
|
||||
|
||||
var mask = document.getElementById("wxmask");
|
||||
function showMask(){ mask.classList.add("show"); }
|
||||
function hideMask(){ mask.classList.remove("show"); }
|
||||
document.getElementById("wxclose").addEventListener("click", hideMask);
|
||||
|
||||
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦)
|
||||
if (isWeChat) showMask();
|
||||
|
||||
document.getElementById("dlbtn").addEventListener("click", function(){
|
||||
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因)
|
||||
startDownload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,6 +10,7 @@
|
||||
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
|
||||
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
|
||||
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
|
||||
| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 |
|
||||
|
||||
## api/ 目录是怎么组织的(传送门式)
|
||||
|
||||
|
||||
+16
-2
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-06-04(+ 运营后台 Admin 子应用 A1–A18,见下方「运营后台 Admin」组)
|
||||
> 最后更新:2026-06-07(金币数值体系一期:签到膨胀 + 信息流广告结算)
|
||||
> 架构:`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/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
@@ -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`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -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`。
|
||||
@@ -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)。
|
||||
|
||||
@@ -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` 取最新展示值。
|
||||
@@ -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 时生效。
|
||||
@@ -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`;「领取优惠券」后端暂无累计字段(前端先占位)。
|
||||
@@ -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)。
|
||||
@@ -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<saved≤300 元` 的条目**,剔除 bug 异常大值);最终展示 = 真实值 + 运营配的 `real_offset`。
|
||||
- **manual**:取运营当前手填的固定值(改值到下个更新钟点生效)。
|
||||
- **random**:在上次展示值上按方式增长——`mult` ×一个 [1.0,1.5] 随机倍率 / `add` +一个 [下限,上限] 随机绝对增量(跨几个钟点走几次)。
|
||||
- **只增不减**:默认门面数字不回退(real/manual 新值低于当前则保持),除非运营开「允许下降」。
|
||||
|
||||
计算逻辑见 `app/repositories/ops_stat.py`,模型见 `app/models/ops_stat_config.py`(表 [ops_stat_config](../database/ops_stat_config.md))。
|
||||
|
||||
上线初始播种为 manual + 客户端原写死门面值(12847 / 86532 / 37621.40 元),保证前后展示一致。
|
||||
@@ -13,7 +13,11 @@
|
||||
| `week_saved_cents` | int | 本周已省(分) |
|
||||
| `beat_percent` | int | 超过百分之多少用户 |
|
||||
| `streak_days` | int | 连续省钱天数 |
|
||||
| `compare_count` | int | 累计完成比价次数(= 比价上报记录数;有真实 `compare` 记录用真实数,否则 demo 兜底) |
|
||||
| `compare_count` | int | 累计完成比价次数(= 真实 `compare` 上报记录数;无记录为 0) |
|
||||
|
||||
## 说明
|
||||
「我的」页「省钱战绩」卡数据源(三列:本周已省 / 完成比价 / 连续比价)。`compare_count` 与 `/summary` 的 `order_count` 同源(均 = 有效记录数),对应原型「完成比价(次)」列。接口已通但无真实业务写入时走 demo 兜底(seed 约 23 条)。
|
||||
下单成交口径(`savings_record` 源)的战绩字段。`compare_count` 与 `/summary` 的 `order_count` 同源(均 = 真实**下单上报**记录数)。
|
||||
|
||||
> ⚠️ **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`)现仅供订单口径/历史用途,不上战绩卡。
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 天。
|
||||
|
||||
@@ -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 天。
|
||||
|
||||
@@ -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_<id>`、`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_<key>`) | 同事务 |
|
||||
| 金币兑现金 `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_<key>` | `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:<reason>`) | + / − |
|
||||
|
||||
@@ -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` 对账兜底。
|
||||
|
||||
+12
-4
@@ -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/`,独立鉴权)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 回滚返回已存在那条(幂等兜底)。
|
||||
|
||||
@@ -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 不可靠)。
|
||||
|
||||
@@ -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() | 最后修改时间 |
|
||||
|
||||
|
||||
@@ -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_<key>`(如 `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:<reason>`) |
|
||||
| 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_<key>` / `ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 |
|
||||
| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `signin_boost` / `task_<key>` / `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:<reason>」) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一)。
|
||||
- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `task_<key>`→`user_task.task_key` / `ad_reward`→`ad_reward_record.trans_id` / 其余 null。
|
||||
- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→签到日 / `task_<key>`→`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 等)在**同一事务**写,不会只发币不留痕。
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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<saved≤300 元`,按 user 去重)优先,不足用本表启用种子**公平随机抽取**补齐(不再固定取前 N,所有种子都有机会露出)。种子用户名空则随机合成、金额区间随机取值。展示时间统一刷新成「相对现在的最近时刻」(详见 [platform-savings-feed](../api/platform-savings-feed.md))。
|
||||
- 改种子走 admin `/admin/api/marquee-seeds` CRUD + 批量生成(`/bulk`)+ 预览(`/preview`),写操作带审计。
|
||||
@@ -0,0 +1,54 @@
|
||||
# ops_stat_config — 首页三统计展示配置
|
||||
|
||||
> 模型 `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}`,带审计。
|
||||
@@ -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 不计)。
|
||||
|
||||
@@ -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`。
|
||||
@@ -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() | 时间 |
|
||||
|
||||
@@ -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 后台管理员,两套体系)。
|
||||
|
||||
## 索引与约束
|
||||
|
||||
+5
-5
@@ -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`)
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# 好友邀请(invite-mvp)— 实现原理与本地测试
|
||||
|
||||
> 跨 **shaguabijia-app-server**(后端)+ **shaguabijia-app-android**(前端)的好友邀请功能。
|
||||
> 本文给三件事:**做什么 / 怎么实现 / 怎么在本地内网测**(交接给同事用)。
|
||||
> 代码引用一律用文件路径 + 符号名(不写行号,重构后行号会失效)。
|
||||
|
||||
---
|
||||
|
||||
## 一、功能是什么
|
||||
|
||||
**注册即生效**:被邀请人(B)用邀请人(A)的邀请码完成绑定 → **A、B 各得 1 万金币(= 1 元,可提现)**。
|
||||
|
||||
---
|
||||
|
||||
## 二、实现原理(数据流)
|
||||
|
||||
归因(B 装的这次算不算 A 邀的)有**两条通道**:
|
||||
|
||||
- **剪贴板自动**(deferred deeplink,生产主路径):落地页把邀请码写进剪贴板,APK 首启读回。
|
||||
- **手动填码**(兜底):B 在邀请页直接输入 A 的邀请码。
|
||||
|
||||
剪贴板自动这条的完整跨库链路:
|
||||
|
||||
```
|
||||
A 端(已登录)
|
||||
└─ GET /api/v1/invite/me → 拿到 invite_code + share_url(=落地页?ref=<码>)
|
||||
└─ 分享 share_url(微信/任意渠道)
|
||||
↓
|
||||
B 端(系统浏览器打开 share_url = .../media/dl.html?ref=<码>)
|
||||
└─ 点「下载安装包」
|
||||
├─ dl.html 把 "SGBJ_INVITE:<码>" 写进剪贴板(execCommand)
|
||||
└─ 跳 APK_URL 下载安装包
|
||||
↓
|
||||
B 安装并首启 App
|
||||
└─ InviteCapture 读剪贴板,识别 "SGBJ_INVITE:" 前缀 → 暂存 pending 邀请码
|
||||
└─ B 登录成功 → AuthRepository 触发 InviteRepository.bindPendingFromClipboard()
|
||||
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
|
||||
↓
|
||||
后端 repositories/invite.py bind()
|
||||
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
|
||||
```
|
||||
|
||||
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。
|
||||
|
||||
---
|
||||
|
||||
## 三、具体实现
|
||||
|
||||
### 3.1 后端(shaguabijia-app-server)
|
||||
|
||||
| 关注点 | 位置 |
|
||||
|---|---|
|
||||
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
|
||||
| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
|
||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
|
||||
| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 |
|
||||
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
|
||||
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
|
||||
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
|
||||
|
||||
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
|
||||
|
||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
|
||||
2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。
|
||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`。
|
||||
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
|
||||
|
||||
发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。
|
||||
|
||||
### 3.2 前端(shaguabijia-app-android)
|
||||
|
||||
| 关注点 | 位置 |
|
||||
|---|---|
|
||||
| 网络层 | `data/api/InviteApi.kt` + `InviteDto.kt`:`me()` / `bind(BindInviteRequest)`。 |
|
||||
| 门面 | `data/invite/InviteRepository.kt`:`myInvite()` / `bindManual(code)` / `bindPendingFromClipboard()`。 |
|
||||
| 剪贴板捕获 | `util/InviteCapture.kt`:首启读剪贴板找 `SGBJ_INVITE:` 前缀(常量 `CLIP_PREFIX`),命中即暂存到 SP(`pending_invite_code`);**未命中不烧机会**,跨多次启动最多试 `MAX_ATTEMPTS`(5)次,之后靠手动填码兜底。 |
|
||||
| 自动绑定触发 | 登录成功后由 `data/auth/AuthRepository.kt` 调 `InviteRepository.bindPendingFromClipboard()`(best-effort,不抛异常)。 |
|
||||
| UI | `ui/invite/InviteScreen.kt` + `InviteViewModel.kt`:展示码 / 二维码(`util/QrCodeGen.kt`)/ 战绩 + 手动填码入口。 |
|
||||
|
||||
### 3.3 落地页 dl.html(`data/media/dl.html`,由后端 `/media` 静态托管)
|
||||
|
||||
- 读 URL 的 `?ref=<邀请码>`。
|
||||
- 点「下载安装包」时:`legacyCopy()` 用 `document.execCommand('copy')` **同步**把 `"SGBJ_INVITE:<码>"` 写进剪贴板(execCommand **不要求安全上下文**,内网 HTTP 也能写;`navigator.clipboard` 只是 HTTPS 下锦上添花、失败无妨),再跳 `APK_URL` 下载。
|
||||
- `APK_URL = location.origin + "/media/shaguabijia.apk"`:**跟随页面 host**——本地测试时页面由笔记本(LAN:8770)托管 → 下载也走 LAN;生产由 `app-api.shaguabijia.com` 托管 → 走生产域名。**无需按机器改 IP**。
|
||||
- 微信内显示「去浏览器打开」引导(微信内 execCommand / apk 下载都受限,必须跳系统浏览器)。
|
||||
|
||||
> dl.html 已纳入 git(见本仓 `.gitignore` 对 `data/media/dl.html` 的例外)。`data/media/shaguabijia.apk`(大二进制)、`avatars/` 等仍忽略。
|
||||
|
||||
---
|
||||
|
||||
## 四、本地内网测试方法(重点)
|
||||
|
||||
**拓扑**:同一 WiFi 下,1 台笔记本(起后端 + 托管落地页/APK)+ 2 台安卓真机(A 邀请人 / B 被邀请人)。落地页和 APK 都由笔记本 app-server 的 `/media` 在局域网托管,一处搞定。
|
||||
|
||||
### 4.1 起后端
|
||||
|
||||
1. 本机 PostgreSQL 起着;`conda activate price`。
|
||||
2. `.env` 改两项(**本地测试覆盖,测完改回**):
|
||||
- `SMS_MOCK=true` —— 任意手机号 + 任意 6 位码秒注册,方便建 A/B 两个号(生产是 `false` 走真极光)。
|
||||
- `INVITE_LANDING_URL=http://<笔记本LAN_IP>:8770/media/dl.html` —— 让 `/invite/me` 返回的 share_url 走内网。
|
||||
3. `alembic upgrade head` 建表。
|
||||
- ⚠️ **切分支后若报 `Can't locate revision`**(DB 版本指针停在别分支的迁移,例如先测过 coupon-daily-claim-gate,DB 停在 `coupon_state_tables`,而本分支没这个文件):用 `alembic stamp <共同祖先> --purge` 把指针重置到共同祖先(本分支祖先是 `11a1d08c6f55`)→ 再 `alembic upgrade head` 只跑 invite 这一条建表。**别删多余表**。详见 `docs/数据库迁移.md`。
|
||||
4. `./run.sh`(监听 `0.0.0.0:8770`)。**笔记本防火墙放行入站 8770**。
|
||||
|
||||
### 4.2 落地页 + APK(都由 `/media` 在 LAN 托管)
|
||||
|
||||
- **落地页**:`data/media/dl.html` 已在 git、host-agnostic,**无需改**(clone 出来就在)。
|
||||
- **APK**:编一个 debug 包放到 `data/media/shaguabijia.apk`:
|
||||
1. 前端 `local.properties` 填 `debug.api.host=<笔记本LAN_IP>` / `debug.api.port=8770`(debug 包的 `BASE_URL` 据此**编译期**烧入,见 `app/build.gradle.kts`;debug 包还放行明文 HTTP)。
|
||||
2. `./gradlew assembleDebug`。
|
||||
3. `cp app/build/outputs/apk/debug/app-debug.apk <app-server>/data/media/shaguabijia.apk`。
|
||||
|
||||
### 4.3 跑流程
|
||||
|
||||
1. A 真机装 debug 包 → 登录 → 邀请页拿到 6 位码 / share_url(= `http://<LAN>:8770/media/dl.html?ref=<码>`)。
|
||||
2. A 把 share_url 发出去 → B 真机**用系统浏览器**打开 → 点「下载」(写剪贴板 + 下 APK)→ 安装。
|
||||
3. B 首启 → `InviteCapture` 读剪贴板拿码(Android 12+ 会弹"已粘贴"提示,正常)→ B 用**新手机号**登录 → 自动 `bind(clipboard)` → **A、B 各 +1 万金币,`invite_relation` 入库**。
|
||||
4. **手动填码路径**:B 在邀请页直接输 A 的码(`channel=manual`),不依赖剪贴板——剪贴板失败时的兜底,值得单独测。
|
||||
|
||||
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
|
||||
|
||||
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
|
||||
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
|
||||
- **A ≠ B**:自邀被屏蔽。
|
||||
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
|
||||
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
|
||||
|
||||
---
|
||||
|
||||
## 五、上线前还差什么(本地测通 ≠ 可上线)
|
||||
|
||||
本地这一关证明的是"机制能跑通",离生产可上线还差:
|
||||
|
||||
1. ⚠️ **alembic 多 head(不修 100% 翻车)**:`coupon_state_tables` 和 `invite_code_and_relation` 都挂在 `11a1d08c6f55` 下、是**兄弟迁移**。invite 一合 main,main 上就有两个 head,prod `alembic upgrade head` 撞 `Multiple head revisions` → 服务起不来。**合 main 前 / 部署前必须建一个 merge 迁移**把两条链并起来(参考 repo 里已有的 `*_merge_*_heads.py`)。本地单分支单 head 测不出。
|
||||
2. **release 包 ≠ debug 包**:用户拿到的是 release(签名不同、强制 HTTPS、若开 minify 还有 R8 混淆),本地测的是 debug。上线前用 **release 包真机**验全流程。
|
||||
3. **风控 / 薅羊毛**:1 万金币可提现,**邀请人无总数上限**,接码平台批量注册新号绑同一个码即可刷。上线前加 **inviter 上限 + 基础风控**。
|
||||
4. **仅验了 happy path**:错误分支文案(`invalid_code` / `already_bound` / `self_invite` / `not_eligible`)、手动填码兜底、stats 展示更新、并发绑定都没真测。
|
||||
5. **配置回归**:`SMS_MOCK` / `INVITE_LANDING_URL` 是本地覆盖,生产走真极光 + 默认生产落地页。
|
||||
6. **(dl.html 纳入 git 后的部署细节)**:生产机上已有未跟踪的 `data/media/dl.html`(之前手动放的)。若走 git 部署,首次 pull 前需先移走生产那份,否则报 `untracked working tree files would be overwritten`。
|
||||
@@ -0,0 +1,331 @@
|
||||
"""美团 CPS 券定时抓取入库(北京试点)。
|
||||
|
||||
把 3 路券抓进 meituan_coupon 表,供「销量/佣金排序」从库里捞、本地排序,不再实时打美团:
|
||||
1. search_waimai : 到家/外卖, 搜「外卖」 翻到尽头
|
||||
2. search_meishi : 到家/外卖, 搜「美食」 翻到尽头
|
||||
3. store_supply : 到店, 多业务线供给(到餐+到综+酒店+门票) 翻到尽头
|
||||
|
||||
按 (source, product_view_sign) upsert 存最新态;last_seen 每轮刷新。带文件锁,防止
|
||||
上一轮没跑完下一轮又起(本地 5~10min、跨进程 cron 都安全)。
|
||||
|
||||
用法:
|
||||
# 单轮(打通验证 / 给 cron 用,线上每 1h 一次)
|
||||
python -m scripts.pull_meituan_coupons --once
|
||||
|
||||
# 本地循环(默认每 10min 一轮)
|
||||
python -m scripts.pull_meituan_coupons --loop --interval 600
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Windows 控制台按 UTF-8 输出中文/¥
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations.meituan import MeituanCpsError, _call
|
||||
from app.models.meituan_coupon import MeituanCoupon
|
||||
|
||||
CITY_BEIJING = "WKV2HMXUEK634WP64CUCUQGM64"
|
||||
QUERY_PATH = "/cps_open/common/api/v1/query_coupon"
|
||||
PAGE_SIZE = 20
|
||||
MAX_PAGES = 80 # 单路安全上限(搜索 ~52 页、供给 ~70 页)
|
||||
PAGE_SLEEP = 0.35 # 页间配速,缓解 402
|
||||
RETRY = 7
|
||||
LOCK_FILE = "data/.meituan_etl.lock"
|
||||
LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管
|
||||
|
||||
SOURCES = [
|
||||
{"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"},
|
||||
{"code": "search_meishi", "label": "外卖·搜美食", "kind": "search", "platform": 1, "keyword": "美食"},
|
||||
{"code": "store_supply", "label": "到店·多业务线供给", "kind": "supply",
|
||||
"platform": 2, "biz_lines": [1, 2, 3, 4]},
|
||||
]
|
||||
|
||||
|
||||
# ───────────────────────── 美团调用 ─────────────────────────
|
||||
|
||||
def _call_retry(body: dict) -> dict | None:
|
||||
"""打美团,402/频繁退避重试;其它错误打印并放弃本页。"""
|
||||
for a in range(RETRY):
|
||||
try:
|
||||
return _call(QUERY_PATH, body)
|
||||
except MeituanCpsError as e:
|
||||
msg = str(e)
|
||||
if "402" in msg or "频繁" in msg:
|
||||
time.sleep(2.5 * (a + 1))
|
||||
continue
|
||||
print(f" [warn] meituan: {msg[:80]}")
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" [warn] {type(e).__name__}: {str(e)[:60]}")
|
||||
time.sleep(2.0 * (a + 1))
|
||||
return None
|
||||
|
||||
|
||||
def _pull_search(platform: int, keyword: str) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
sid = None
|
||||
pg = 1
|
||||
while pg <= MAX_PAGES:
|
||||
body = {"platform": platform, "searchText": keyword, "cityId": CITY_BEIJING, "pageSize": PAGE_SIZE}
|
||||
if sid:
|
||||
body["searchId"] = sid
|
||||
else:
|
||||
body["pageNo"] = pg
|
||||
r = _call_retry(body)
|
||||
if not r:
|
||||
break
|
||||
sid = r.get("searchId")
|
||||
data = r.get("data") or []
|
||||
rows.extend(data)
|
||||
if not r.get("hasNext") or not data:
|
||||
break
|
||||
pg += 1
|
||||
time.sleep(PAGE_SLEEP)
|
||||
return rows
|
||||
|
||||
|
||||
def _pull_supply(platform: int, biz_lines: list[int]) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
sid = None
|
||||
biz_param = [{"bizLine": b} for b in biz_lines]
|
||||
for _ in range(MAX_PAGES):
|
||||
body = {
|
||||
"multipleSupplyList": [{"platform": platform, "bizLineParamList": biz_param}],
|
||||
"cityId": CITY_BEIJING,
|
||||
"sortField": 2, # 供给查询 sortField 必填;我们入库后本地再排,这里给个默认
|
||||
"pageSize": PAGE_SIZE,
|
||||
}
|
||||
if sid:
|
||||
body["searchId"] = sid
|
||||
r = _call_retry(body)
|
||||
if not r:
|
||||
break
|
||||
sid = r.get("searchId")
|
||||
data = r.get("data") or []
|
||||
rows.extend(data)
|
||||
if not r.get("hasNext") or not data:
|
||||
break
|
||||
time.sleep(PAGE_SLEEP)
|
||||
return rows
|
||||
|
||||
|
||||
# ───────────────────────── 解析 ─────────────────────────
|
||||
|
||||
_SV_RE = re.compile(r"(\d+(?:\.\d+)?)\s*(w|万|k)?", re.I)
|
||||
|
||||
|
||||
def _sale_volume_num(s: str | None) -> int | None:
|
||||
"""'热销1w+' → 10000, '热销500+' → 500(取下界,排序用)。"""
|
||||
if not s:
|
||||
return None
|
||||
m = _SV_RE.search(str(s))
|
||||
if not m:
|
||||
return None
|
||||
v = float(m.group(1))
|
||||
u = (m.group(2) or "").lower()
|
||||
if u in ("w", "万"):
|
||||
v *= 10000
|
||||
elif u == "k":
|
||||
v *= 1000
|
||||
return int(v)
|
||||
|
||||
|
||||
def _to_cents(yuan) -> int | None:
|
||||
if yuan in (None, "", "null"):
|
||||
return None
|
||||
try:
|
||||
return round(float(yuan) * 100)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_item(item: dict, source: dict) -> dict | None:
|
||||
cpd = item.get("couponPackDetail") or {}
|
||||
br = item.get("brandInfo") or {}
|
||||
ci = item.get("commissionInfo") or {}
|
||||
poi = item.get("availablePoiInfo") or {}
|
||||
dp = item.get("deliverablePoiInfo") or {}
|
||||
|
||||
sign = cpd.get("productViewSign") or cpd.get("skuViewId")
|
||||
if not sign:
|
||||
return None
|
||||
|
||||
name = cpd.get("name") or ""
|
||||
brand = br.get("brandName") or ""
|
||||
price_cents = _to_cents(cpd.get("sellPrice"))
|
||||
|
||||
comm_pct = None
|
||||
if ci.get("commissionPercent") is not None:
|
||||
try:
|
||||
comm_pct = float(ci["commissionPercent"]) / 100.0 # 140 → 1.4(%)
|
||||
except (TypeError, ValueError):
|
||||
comm_pct = None
|
||||
|
||||
dist = dp.get("deliveryDistance")
|
||||
try:
|
||||
dist = float(dist) if dist not in (None, "", "null") else None
|
||||
except (TypeError, ValueError):
|
||||
dist = None
|
||||
|
||||
dedup_raw = f"{brand}|{name}|{price_cents}"
|
||||
return {
|
||||
"source": source["code"],
|
||||
"platform": source["platform"],
|
||||
"biz_line": item.get("bizLine") or cpd.get("bizLine"),
|
||||
"city_id": CITY_BEIJING,
|
||||
"product_view_sign": str(sign)[:128],
|
||||
"sku_view_id": cpd.get("skuViewId"),
|
||||
"name": (name[:256] or None),
|
||||
"brand_name": (brand[:128] or None),
|
||||
"sell_price_cents": price_cents,
|
||||
"original_price_cents": _to_cents(cpd.get("originalPrice")),
|
||||
"head_url": ((cpd.get("headUrl") or "").split("@")[0][:512] or None),
|
||||
"sale_volume": cpd.get("saleVolume"),
|
||||
"sale_volume_num": _sale_volume_num(cpd.get("saleVolume")),
|
||||
"commission_percent": comm_pct,
|
||||
"commission_amount_cents": _to_cents(ci.get("commission")),
|
||||
"poi_name": ((dp.get("poiName") or "")[:128] or None),
|
||||
"available_poi_num": poi.get("availablePoiNum"),
|
||||
"delivery_distance_m": dist,
|
||||
"dedup_key": hashlib.md5(dedup_raw.encode("utf-8")).hexdigest(),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
# ───────────────────────── 入库(upsert) ─────────────────────────
|
||||
|
||||
def _upsert(db, rows: list[dict], now: datetime) -> tuple[int, int]:
|
||||
"""按 (source, product_view_sign) upsert。返回 (入库去重后条数, 本源内重复条数)。"""
|
||||
if not rows:
|
||||
return 0, 0
|
||||
dedup: dict[tuple, dict] = {}
|
||||
for r in rows:
|
||||
dedup[(r["source"], r["product_view_sign"])] = r # 同轮内同键保留最后一条
|
||||
payload = list(dedup.values())
|
||||
for r in payload:
|
||||
r["last_seen"] = now
|
||||
r["updated_at"] = now
|
||||
|
||||
chunk = 500
|
||||
for i in range(0, len(payload), chunk):
|
||||
part = payload[i:i + chunk]
|
||||
stmt = pg_insert(MeituanCoupon).values(part)
|
||||
update_cols = {
|
||||
c: getattr(stmt.excluded, c)
|
||||
for c in part[0]
|
||||
if c not in ("source", "product_view_sign", "first_seen")
|
||||
}
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint="uq_meituan_coupon_source_sign", set_=update_cols
|
||||
)
|
||||
db.execute(stmt)
|
||||
db.commit()
|
||||
return len(payload), len(rows) - len(payload)
|
||||
|
||||
|
||||
# ───────────────────────── 运行锁 ─────────────────────────
|
||||
|
||||
def _acquire_lock() -> bool:
|
||||
os.makedirs(os.path.dirname(LOCK_FILE) or ".", exist_ok=True)
|
||||
try:
|
||||
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, f"{os.getpid()} {time.time()}".encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
try:
|
||||
with open(LOCK_FILE) as f:
|
||||
parts = f.read().split()
|
||||
ts = float(parts[1]) if len(parts) > 1 else 0.0
|
||||
if time.time() - ts > LOCK_STALE_SEC:
|
||||
os.remove(LOCK_FILE)
|
||||
return _acquire_lock()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _release_lock() -> None:
|
||||
try:
|
||||
os.remove(LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ───────────────────────── 主流程 ─────────────────────────
|
||||
|
||||
def run_once(prune_hours: int = 24) -> None:
|
||||
if not _acquire_lock():
|
||||
print(f"[{datetime.now():%H:%M:%S}] 上一轮还在跑(锁占用),跳过本轮")
|
||||
return
|
||||
t0 = time.time()
|
||||
now = datetime.now(timezone.utc)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total = 0
|
||||
for src in SOURCES:
|
||||
ts = time.time()
|
||||
if src["kind"] == "search":
|
||||
items = _pull_search(src["platform"], src["keyword"])
|
||||
else:
|
||||
items = _pull_supply(src["platform"], src["biz_lines"])
|
||||
parsed = [p for p in (_parse_item(it, src) for it in items) if p]
|
||||
up, dup = _upsert(db, parsed, now)
|
||||
total += up
|
||||
print(f" {src['label']:18} 抓{len(items):5} 解析{len(parsed):5} "
|
||||
f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s")
|
||||
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限
|
||||
if prune_hours and prune_hours > 0:
|
||||
cutoff = now - timedelta(hours=prune_hours)
|
||||
pruned = db.execute(
|
||||
delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff)
|
||||
).rowcount
|
||||
db.commit()
|
||||
if pruned:
|
||||
print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned} 条")
|
||||
cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar()
|
||||
print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, "
|
||||
f"用时 {time.time() - t0:.0f}s")
|
||||
finally:
|
||||
db.close()
|
||||
_release_lock()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="美团 CPS 券定时抓取入库")
|
||||
ap.add_argument("--once", action="store_true", help="只跑一轮(默认)")
|
||||
ap.add_argument("--loop", action="store_true", help="循环跑")
|
||||
ap.add_argument("--interval", type=int, default=600, help="循环间隔秒(默认 600=10min)")
|
||||
ap.add_argument("--prune-hours", type=int, default=24,
|
||||
help="清理超过 N 小时未再出现的陈旧券(默认 24;0=不清理)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.loop:
|
||||
print(f"循环模式: 每 {args.interval}s 一轮 (Ctrl-C 退出)")
|
||||
while True:
|
||||
try:
|
||||
run_once(args.prune_hours)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[{datetime.now():%H:%M:%S}] 本轮异常: {type(e).__name__}: {e}")
|
||||
_release_lock()
|
||||
time.sleep(args.interval)
|
||||
else:
|
||||
run_once(args.prune_hours)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+62
-6
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽。
|
||||
|
||||
用 sms mock 登录拿 token(同 test_welfare),再跑邀请闭环。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.rewards import (
|
||||
INVITE_INVITEE_COINS,
|
||||
INVITE_INVITER_COINS,
|
||||
INVITE_NEW_USER_WINDOW_HOURS,
|
||||
)
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories.user import get_user_by_phone
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _coin_balance(client, token: str) -> int:
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["coin_balance"]
|
||||
|
||||
|
||||
def _my_code(client, token: str) -> str:
|
||||
r = client.get("/api/v1/invite/me", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["invite_code"]
|
||||
|
||||
|
||||
def test_invite_me_returns_stable_code(client) -> None:
|
||||
"""/invite/me 返回邀请码 + 分享链接;同一用户多次取码稳定。"""
|
||||
token = _login(client, "13800002001")
|
||||
r = client.get("/api/v1/invite/me", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["invite_code"] # 非空
|
||||
assert f"ref={body['invite_code']}" in body["share_url"]
|
||||
assert body["invited_count"] == 0
|
||||
assert body["coins_earned"] == 0
|
||||
# 再取一次,码不变(懒生成 + 持久化)
|
||||
assert _my_code(client, token) == body["invite_code"]
|
||||
|
||||
|
||||
def test_bind_flow_both_get_coins(client) -> None:
|
||||
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1。"""
|
||||
a = _login(client, "13800002002")
|
||||
b = _login(client, "13800002003")
|
||||
a_code = _my_code(client, a)
|
||||
|
||||
assert _coin_balance(client, a) == 0
|
||||
assert _coin_balance(client, b) == 0
|
||||
|
||||
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["status"] == "success"
|
||||
assert res["coins_awarded"] == INVITE_INVITEE_COINS
|
||||
|
||||
# 双方金币到账
|
||||
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
|
||||
assert _coin_balance(client, a) == INVITE_INVITER_COINS
|
||||
|
||||
# A 的战绩:已邀 1 人,累计获得 = 邀请人那份
|
||||
r = client.get("/api/v1/invite/me", headers=_auth(a))
|
||||
assert r.json()["invited_count"] == 1
|
||||
assert r.json()["coins_earned"] == INVITE_INVITER_COINS
|
||||
|
||||
|
||||
def test_bind_idempotent_no_double_reward(client) -> None:
|
||||
"""同一被邀请人二次绑定 → already_bound,不重复发奖。"""
|
||||
a = _login(client, "13800002004")
|
||||
b = _login(client, "13800002005")
|
||||
a_code = _my_code(client, a)
|
||||
|
||||
r1 = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
|
||||
assert r1.json()["status"] == "success"
|
||||
bal_b = _coin_balance(client, b)
|
||||
bal_a = _coin_balance(client, a)
|
||||
|
||||
# 再绑一次(甚至换个码也不行,已被绑过)
|
||||
c = _login(client, "13800002006")
|
||||
c_code = _my_code(client, c)
|
||||
r2 = client.post("/api/v1/invite/bind", json={"invite_code": c_code}, headers=_auth(b))
|
||||
assert r2.json()["status"] == "already_bound"
|
||||
assert r2.json()["coins_awarded"] == 0
|
||||
|
||||
# 余额没变(没二次发奖),C 也没拿到邀请奖励
|
||||
assert _coin_balance(client, b) == bal_b
|
||||
assert _coin_balance(client, a) == bal_a
|
||||
assert _coin_balance(client, c) == 0
|
||||
|
||||
|
||||
def test_self_invite_blocked(client) -> None:
|
||||
"""填自己的邀请码 → self_invite,不发奖。"""
|
||||
a = _login(client, "13800002007")
|
||||
a_code = _my_code(client, a)
|
||||
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(a))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "self_invite"
|
||||
assert _coin_balance(client, a) == 0
|
||||
|
||||
|
||||
def test_invalid_code(client) -> None:
|
||||
"""无效邀请码 → invalid_code,不发奖。"""
|
||||
b = _login(client, "13800002008")
|
||||
r = client.post("/api/v1/invite/bind", json={"invite_code": "ZZZZZZ"}, headers=_auth(b))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "invalid_code"
|
||||
assert _coin_balance(client, b) == 0
|
||||
|
||||
|
||||
def test_manual_channel_recorded(client) -> None:
|
||||
"""channel=manual 也能绑定(手动填码兜底路径)。"""
|
||||
a = _login(client, "13800002009")
|
||||
b = _login(client, "13800002010")
|
||||
a_code = _my_code(client, a)
|
||||
r = client.post(
|
||||
"/api/v1/invite/bind",
|
||||
json={"invite_code": a_code, "channel": "manual"},
|
||||
headers=_auth(b),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "success"
|
||||
|
||||
|
||||
def test_invite_requires_auth(client) -> None:
|
||||
"""不带 token → 401。"""
|
||||
assert client.get("/api/v1/invite/me").status_code == 401
|
||||
assert client.post("/api/v1/invite/bind", json={"invite_code": "ABCDEF"}).status_code == 401
|
||||
|
||||
|
||||
def test_old_user_not_eligible(client) -> None:
|
||||
"""新用户闸:被邀请人注册超过窗口 → not_eligible,双方都不发奖。"""
|
||||
a = _login(client, "13800002030")
|
||||
a_code = _my_code(client, a)
|
||||
b = _login(client, "13800002031")
|
||||
# 把 b 的注册时间改成超出窗口 → 变"老用户"
|
||||
with SessionLocal() as db:
|
||||
u = get_user_by_phone(db, "13800002031")
|
||||
assert u is not None
|
||||
u.created_at = datetime.now(timezone.utc) - timedelta(hours=INVITE_NEW_USER_WINDOW_HOURS + 1)
|
||||
db.commit()
|
||||
|
||||
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "not_eligible"
|
||||
assert r.json()["coins_awarded"] == 0
|
||||
assert _coin_balance(client, b) == 0
|
||||
assert _coin_balance(client, a) == 0
|
||||
@@ -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()
|
||||
|
||||
+57
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user