diff --git a/.env.example b/.env.example index c469c8c..20ef954 100644 --- a/.env.example +++ b/.env.example @@ -44,12 +44,25 @@ MT_CPS_DEFAULT_SID=sgbjia # 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。 MT_CPS_PROXY= -# ===== Pricebot 上游 (领券业务透传目标) ===== -# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step。 +# ===== Pricebot 上游 (领券/比价业务透传目标) ===== +# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。 # 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。 PRICEBOT_BASE_URL=http://localhost:8000 +# 【多实例】单机多进程部署时,填逗号分隔的实例列表(端口与 pricebot 集群对齐),透传层按 +# trace_id 一致性 hash 选实例 → 同一比价所有帧落同一进程(进程内维护 state,无需 Redis)。 +# 留空 = 单实例(用上面的 PRICEBOT_BASE_URL)。详见 pricebot-backend/docs/并发部署设计.md +# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003,http://127.0.0.1:8004,http://127.0.0.1:8005,http://127.0.0.1:8006 # 领券单帧最多 wait 6s,加网络往返,30s 兜底 PRICEBOT_REQUEST_TIMEOUT_SEC=30 +# 比价(intent/recognize + price/step)透传超时:大上下文 LLM + 逐帧 LLM,给 60s +PRICEBOT_COMPARE_TIMEOUT_SEC=60 + +# ===== 内部端点 (server→server, pricebot 上报价格观测) ===== +# pricebot 比价 done 后把各平台到手价 POST 到本服务 /internal/price-observation 落库 +# (price_observation 表,比价资产沉淀层),靠共享密钥头 X-Internal-Secret 校验。 +# 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。 +# 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))" +INTERNAL_API_SECRET= # ===== CORS ===== # 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类 diff --git a/alembic/versions/11a1d08c6f55_merge_meituan_coupon_与_ops_price_.py b/alembic/versions/11a1d08c6f55_merge_meituan_coupon_与_ops_price_.py new file mode 100644 index 0000000..f445fda --- /dev/null +++ b/alembic/versions/11a1d08c6f55_merge_meituan_coupon_与_ops_price_.py @@ -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 diff --git a/alembic/versions/coin_reward_points_phase1.py b/alembic/versions/coin_reward_points_phase1.py new file mode 100644 index 0000000..7293a0e --- /dev/null +++ b/alembic/versions/coin_reward_points_phase1.py @@ -0,0 +1,76 @@ +"""coin reward points phase 1 tables + +Revision ID: coin_reward_phase1 +Revises: pstat3growth +Create Date: 2026-06-07 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'coin_reward_phase1' +down_revision: Union[str, Sequence[str], None] = 'pstat3growth' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'signin_boost_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('signin_date', sa.Date(), nullable=False), + sa.Column('coin_awarded', sa.Integer(), nullable=False), + sa.Column('ad_ref_id', sa.String(length=64), nullable=True), + sa.Column( + 'created_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False, + ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_boost_user_date'), + ) + with op.batch_alter_table('signin_boost_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_signin_boost_record_user_id'), ['user_id'], unique=False) + + op.create_table( + 'ad_feed_reward_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('client_event_id', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('reward_date', sa.String(length=10), nullable=False), + sa.Column('duration_seconds', sa.Integer(), nullable=False), + sa.Column('unit_count', sa.Integer(), nullable=False), + sa.Column('ecpm_raw', sa.String(length=32), nullable=False), + sa.Column('adn', sa.String(length=32), nullable=True), + sa.Column('slot_id', sa.String(length=64), nullable=True), + sa.Column('coin', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column( + 'created_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False, + ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('client_event_id', name='uq_ad_feed_reward_client_event'), + ) + with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_reward_date'), ['reward_date'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_created_at'), ['created_at'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_created_at')) + batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_reward_date')) + batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_user_id')) + op.drop_table('ad_feed_reward_record') + + with op.batch_alter_table('signin_boost_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_signin_boost_record_user_id')) + op.drop_table('signin_boost_record') diff --git a/alembic/versions/marquee_seed_range.py b/alembic/versions/marquee_seed_range.py new file mode 100644 index 0000000..5333ba8 --- /dev/null +++ b/alembic/versions/marquee_seed_range.py @@ -0,0 +1,50 @@ +"""marquee_seed 升级为「生成规则」:用户名可空 + 金额拆 min/max 区间 + +把种子从「死记录」升级: +- masked_user 改 nullable(留空 → feed 随机合成脱敏用户名) +- saved_amount_cents(单值)拆成 min_cents / max_cents(feed 在区间内随机取值;固定金额则相等) +现有 6 条种子:min_cents = max_cents = 原 saved_amount_cents,masked_user 原样保留。 + +Revision ID: marquee_seed02 +Revises: marquee_seed01 +Create Date: 2026-06-07 11:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'marquee_seed02' +down_revision: Union[str, Sequence[str], None] = 'marquee_seed01' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1) 先加可空新列,使现有行合法 + with op.batch_alter_table('marquee_seed', schema=None) as batch_op: + batch_op.add_column(sa.Column('min_cents', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('max_cents', sa.Integer(), nullable=True)) + # 2) 用旧单值回填区间(固定金额) + op.execute('UPDATE marquee_seed SET min_cents = saved_amount_cents, max_cents = saved_amount_cents') + # 3) 收紧:区间非空、用户名可空、删旧列 + with op.batch_alter_table('marquee_seed', schema=None) as batch_op: + batch_op.alter_column('min_cents', existing_type=sa.Integer(), nullable=False) + batch_op.alter_column('max_cents', existing_type=sa.Integer(), nullable=False) + batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=True) + batch_op.drop_column('saved_amount_cents') + + +def downgrade() -> None: + # 反向:恢复单值列(取下限),用户名回非空 + with op.batch_alter_table('marquee_seed', schema=None) as batch_op: + batch_op.add_column(sa.Column('saved_amount_cents', sa.Integer(), nullable=True)) + op.execute('UPDATE marquee_seed SET saved_amount_cents = min_cents') + # 自动合成名的种子 masked_user 为 NULL,降级前补占位符以满足非空 + op.execute("UPDATE marquee_seed SET masked_user = '用户********000' WHERE masked_user IS NULL") + with op.batch_alter_table('marquee_seed', schema=None) as batch_op: + batch_op.alter_column('saved_amount_cents', existing_type=sa.Integer(), nullable=False) + batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=False) + batch_op.drop_column('max_cents') + batch_op.drop_column('min_cents') diff --git a/alembic/versions/marquee_seed_table.py b/alembic/versions/marquee_seed_table.py new file mode 100644 index 0000000..7eae916 --- /dev/null +++ b/alembic/versions/marquee_seed_table.py @@ -0,0 +1,52 @@ +"""marquee_seed table (首页轮播种子) + 合并 platform_stat / price_observation 两 head + +Revision ID: marquee_seed01 +Revises: pstat2anchor01, price_observation_table +Create Date: 2026-06-07 10:00:00.000000 + +兼并当前两个 head 为单 head,同时建 marquee_seed 表并播种客户端原写死的 6 条轮播 +(作为初始种子,保证上线即有内容)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'marquee_seed01' +down_revision: Union[str, Sequence[str], None] = ('pstat2anchor01', 'price_observation_table') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + table = op.create_table( + 'marquee_seed', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('masked_user', sa.String(length=64), nullable=False), + sa.Column('saved_amount_cents', sa.Integer(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column( + 'created_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False, + ), + sa.PrimaryKeyConstraint('id'), + ) + + # 初始种子 = 客户端原写死的 6 条(金额转成分) + op.bulk_insert( + table, + [ + {'masked_user': '用户********a52', 'saved_amount_cents': 1860, 'enabled': True, 'sort_order': 1}, + {'masked_user': '用户********k89', 'saved_amount_cents': 4240, 'enabled': True, 'sort_order': 2}, + {'masked_user': '用户********m71', 'saved_amount_cents': 790, 'enabled': True, 'sort_order': 3}, + {'masked_user': '用户********p46', 'saved_amount_cents': 15630, 'enabled': True, 'sort_order': 4}, + {'masked_user': '用户********r93', 'saved_amount_cents': 2350, 'enabled': True, 'sort_order': 5}, + {'masked_user': '用户********c28', 'saved_amount_cents': 8940, 'enabled': True, 'sort_order': 6}, + ], + ) + + +def downgrade() -> None: + op.drop_table('marquee_seed') diff --git a/alembic/versions/ops_rename_tables.py b/alembic/versions/ops_rename_tables.py new file mode 100644 index 0000000..bb19023 --- /dev/null +++ b/alembic/versions/ops_rename_tables.py @@ -0,0 +1,28 @@ +"""运营可配表加 ops_ 前缀:platform_stat_display→ops_stat_config / marquee_seed→ops_marquee_seed + +把首页门面数据的两张「运营可配」表重命名,与代码侧 ops_ 前缀(模型 OpsStatConfig/OpsMarqueeSeed、 +repo ops_stat/ops_marquee、admin ops_stat_config/ops_marquee_seed)对齐。仅改表名,不动列与数据。 + +Revision ID: opsrename01 +Revises: coin_reward_phase1 +Create Date: 2026-06-07 16:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op + + +revision: str = 'opsrename01' +down_revision: Union[str, Sequence[str], None] = 'coin_reward_phase1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.rename_table('platform_stat_display', 'ops_stat_config') + op.rename_table('marquee_seed', 'ops_marquee_seed') + + +def downgrade() -> None: + op.rename_table('ops_stat_config', 'platform_stat_display') + op.rename_table('ops_marquee_seed', 'marquee_seed') diff --git a/alembic/versions/platform_stat_anchor_minutes.py b/alembic/versions/platform_stat_anchor_minutes.py new file mode 100644 index 0000000..be07da4 --- /dev/null +++ b/alembic/versions/platform_stat_anchor_minutes.py @@ -0,0 +1,30 @@ +"""platform_stat_display 增加 random_anchor_minutes(自增长触发时刻对齐偏移) + +Revision ID: pstat2anchor01 +Revises: pstat1d2e3f4a +Create Date: 2026-06-06 13:00:00.000000 + +自增长 tick 从「启动时刻 + 间隔」改为「北京时间钟点对齐(anchor + k*interval)」, +新增列存对齐偏移(距 0 点分钟数)。已有行默认 0(= 对齐到自然边界:天→0点、小时→整点)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'pstat2anchor01' +down_revision: Union[str, Sequence[str], None] = 'pstat1d2e3f4a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'platform_stat_display', + sa.Column('random_anchor_minutes', sa.Integer(), nullable=False, server_default='0'), + ) + + +def downgrade() -> None: + op.drop_column('platform_stat_display', 'random_anchor_minutes') diff --git a/alembic/versions/platform_stat_display_table.py b/alembic/versions/platform_stat_display_table.py new file mode 100644 index 0000000..4a640fe --- /dev/null +++ b/alembic/versions/platform_stat_display_table.py @@ -0,0 +1,59 @@ +"""platform_stat_display table (首页三统计展示配置:每指标独立 real/manual/random) + +Revision ID: pstat1d2e3f4a +Revises: withdraw_review_ad_watch +Create Date: 2026-06-06 12:00:00.000000 + +播种 3 行:默认 manual + 客户端原写死门面值(12847 人 / 86532 次 / 37621.4 元), +保证上线前后展示一致,运营再按需切模式。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'pstat1d2e3f4a' +down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + table = op.create_table( + 'platform_stat_display', + sa.Column('metric', sa.String(length=32), nullable=False), + sa.Column('mode', sa.String(length=16), nullable=False), + sa.Column('manual_value', sa.Integer(), nullable=True), + sa.Column('random_mult_min', sa.Integer(), nullable=False), + sa.Column('random_mult_max', sa.Integer(), nullable=False), + sa.Column('random_tick_seconds', sa.Integer(), nullable=False), + sa.Column('random_current', sa.Integer(), nullable=True), + sa.Column('random_last_tick_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_by_admin_id', sa.Integer(), nullable=True), + sa.Column( + 'updated_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False, + ), + sa.PrimaryKeyConstraint('metric'), + ) + + _common = { + 'mode': 'manual', + 'random_mult_min': 1000, # 1.000(只增不减下限) + 'random_mult_max': 1100, # 1.100 + 'random_tick_seconds': 86400, # 1 天 + } + op.bulk_insert( + table, + [ + {'metric': 'help_users', 'manual_value': 12847, **_common}, + {'metric': 'total_compares', 'manual_value': 86532, **_common}, + {'metric': 'total_saved', 'manual_value': 3762140, **_common}, # 37621.40 元 + ], + ) + + +def downgrade() -> None: + op.drop_table('platform_stat_display') diff --git a/alembic/versions/platform_stat_growth_offset.py b/alembic/versions/platform_stat_growth_offset.py new file mode 100644 index 0000000..728776d --- /dev/null +++ b/alembic/versions/platform_stat_growth_offset.py @@ -0,0 +1,39 @@ +"""platform_stat_display 增强:绝对增量增长 / 真实值偏移 / 只增不减护栏 + +给首页三统计配置加 5 列: +- random_kind ('mult'/'add') + random_step_min/max:自增长支持「绝对增量」(每周期 +[a,b]),不只是 ×倍率 +- real_offset:真实值模式基数偏移(展示=真实+偏移,冷启动既真实又体面) +- allow_decrease:门面数字默认「只增不减」,开此开关才允许展示值下降 + +Revision ID: pstat3growth +Revises: marquee_seed02 +Create Date: 2026-06-07 12:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'pstat3growth' +down_revision: Union[str, Sequence[str], None] = 'marquee_seed02' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('platform_stat_display', schema=None) as batch_op: + batch_op.add_column(sa.Column('random_kind', sa.String(length=8), nullable=False, server_default='mult')) + batch_op.add_column(sa.Column('random_step_min', sa.Integer(), nullable=False, server_default='0')) + batch_op.add_column(sa.Column('random_step_max', sa.Integer(), nullable=False, server_default='0')) + batch_op.add_column(sa.Column('real_offset', sa.Integer(), nullable=False, server_default='0')) + batch_op.add_column(sa.Column('allow_decrease', sa.Boolean(), nullable=False, server_default=sa.false())) + + +def downgrade() -> None: + with op.batch_alter_table('platform_stat_display', schema=None) as batch_op: + batch_op.drop_column('allow_decrease') + batch_op.drop_column('real_offset') + batch_op.drop_column('random_step_max') + batch_op.drop_column('random_step_min') + batch_op.drop_column('random_kind') diff --git a/alembic/versions/price_observation_table.py b/alembic/versions/price_observation_table.py new file mode 100644 index 0000000..ca7d4a3 --- /dev/null +++ b/alembic/versions/price_observation_table.py @@ -0,0 +1,74 @@ +"""price_observation table (价格观测:比价沉淀的价格资产层,server 侧无条件落库) + +Revision ID: price_observation_table +Revises: wx_transfer_auth +Create Date: 2026-06-07 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'price_observation_table' +down_revision: Union[str, Sequence[str], None] = 'wx_transfer_auth' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'price_observation', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('observed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('trace_id', sa.String(length=64), nullable=False), + sa.Column('business_type', sa.String(length=16), nullable=False), + sa.Column('platform', sa.String(length=32), nullable=False), + sa.Column('platform_store_id', sa.String(length=64), nullable=True), + sa.Column('store_name', sa.String(length=128), nullable=True), + sa.Column('city', sa.String(length=64), nullable=True), + sa.Column('geohash', sa.String(length=16), nullable=True), + sa.Column('lng', sa.Float(), nullable=True), + sa.Column('lat', sa.Float(), nullable=True), + sa.Column('is_source', sa.Boolean(), nullable=False), + sa.Column('scope', sa.String(length=16), nullable=False), + sa.Column('price_cents', sa.Integer(), nullable=True), + sa.Column('coupon_saved_cents', sa.Integer(), nullable=True), + sa.Column('coupon_name', sa.String(length=64), nullable=True), + sa.Column('store_closed', sa.String(length=32), nullable=True), + sa.Column('rank', sa.Integer(), nullable=True), + # PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐 + sa.Column('dishes', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True), + sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True), + sa.Column('source_device_id', sa.String(length=64), nullable=True), + sa.Column('source_user_id', sa.Integer(), nullable=True), + sa.Column('confidence', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('trace_id', 'platform', 'scope', name='uq_price_obs_trace_platform_scope'), + ) + with op.batch_alter_table('price_observation', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_price_observation_observed_at'), ['observed_at'], unique=False) + batch_op.create_index(batch_op.f('ix_price_observation_trace_id'), ['trace_id'], unique=False) + batch_op.create_index(batch_op.f('ix_price_observation_business_type'), ['business_type'], unique=False) + batch_op.create_index(batch_op.f('ix_price_observation_platform'), ['platform'], unique=False) + batch_op.create_index(batch_op.f('ix_price_observation_store_name'), ['store_name'], unique=False) + batch_op.create_index(batch_op.f('ix_price_observation_geohash'), ['geohash'], unique=False) + batch_op.create_index(batch_op.f('ix_price_observation_source_device_id'), ['source_device_id'], unique=False) + batch_op.create_index(batch_op.f('ix_price_observation_source_user_id'), ['source_user_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('price_observation', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_price_observation_source_user_id')) + batch_op.drop_index(batch_op.f('ix_price_observation_source_device_id')) + batch_op.drop_index(batch_op.f('ix_price_observation_geohash')) + batch_op.drop_index(batch_op.f('ix_price_observation_store_name')) + batch_op.drop_index(batch_op.f('ix_price_observation_platform')) + batch_op.drop_index(batch_op.f('ix_price_observation_business_type')) + batch_op.drop_index(batch_op.f('ix_price_observation_trace_id')) + batch_op.drop_index(batch_op.f('ix_price_observation_observed_at')) + + op.drop_table('price_observation') diff --git a/alembic/versions/wechat_transfer_authorization.py b/alembic/versions/wechat_transfer_authorization.py new file mode 100644 index 0000000..398a401 --- /dev/null +++ b/alembic/versions/wechat_transfer_authorization.py @@ -0,0 +1,48 @@ +"""wechat_transfer_authorization 表(免确认收款授权) + +商家转账「用户授权免确认收款模式」:用户授权一次后,后续提现免逐笔确认直接到账。 +一个用户一条(user_id 主键)。out_authorization_no 我方生成,authorization_id 微信 active 后返回。 + +Revision ID: wx_transfer_auth +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 + + +# revision identifiers, used by Alembic. +revision: str = 'wx_transfer_auth' +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( + 'wechat_transfer_authorization', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('openid', sa.String(length=64), nullable=False), + sa.Column('out_authorization_no', sa.String(length=64), nullable=False), + sa.Column('authorization_id', sa.String(length=64), nullable=True), + sa.Column('state', sa.String(length=16), server_default='pending', nullable=False), + sa.Column('created_at', 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.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('user_id'), + ) + with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'), + ['out_authorization_no'], + unique=True, + ) + + +def downgrade() -> None: + with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_wechat_transfer_authorization_out_authorization_no')) + op.drop_table('wechat_transfer_authorization') diff --git a/app/admin/main.py b/app/admin/main.py index c5fda29..74a06bb 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -18,7 +18,9 @@ from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router from app.admin.routers.config import router as config_router from app.admin.routers.dashboard import router as dashboard_router +from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.feedback import router as feedback_router +from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router @@ -73,6 +75,8 @@ def health() -> dict[str, str]: admin_app.include_router(auth_router) admin_app.include_router(dashboard_router) +admin_app.include_router(ops_stat_config_router) +admin_app.include_router(ops_marquee_seed_router) admin_app.include_router(users_router) admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 494da35..6ab6c41 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -34,8 +34,8 @@ def _validate(key: str, value: Any) -> None: raise ValueError("需为非空整数列表") if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value): raise ValueError("列表元素需为非负整数") - if key == "signin_rewards" and len(value) != 7: - raise ValueError("签到档位必须正好 7 个(对应 7 天循环)") + if key == "signin_rewards" and len(value) != 14: + raise ValueError("签到档位必须正好 14 个(对应 14 天循环)") elif t == "dict_str_int": if not isinstance(value, dict) or not all( isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool) diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py new file mode 100644 index 0000000..53084ff --- /dev/null +++ b/app/admin/routers/ops_marquee_seed.py @@ -0,0 +1,160 @@ +"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。 + +种子用于首页轮播「真实+种子混播」的兜底(见 app/repositories/ops_marquee.py)。 +权限:operator 可改,super 恒可;预览为只读(任意已登录 admin)。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.ops_marquee_seed import ( + OpsMarqueeSeedBulkCreate, + OpsMarqueeSeedCreate, + OpsMarqueeSeedOut, + OpsMarqueeSeedUpdate, + OpsSavingsFeedPreviewOut, +) +from app.models.admin import AdminUser +from app.models.ops_marquee_seed import OpsMarqueeSeed +from app.repositories import ops_marquee + +router = APIRouter( + prefix="/admin/api/marquee-seeds", + tags=["admin-marquee-seeds"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(seed: OpsMarqueeSeed) -> OpsMarqueeSeedOut: + return OpsMarqueeSeedOut( + id=seed.id, + masked_user=seed.masked_user, + min_cents=seed.min_cents, + max_cents=seed.max_cents, + enabled=seed.enabled, + sort_order=seed.sort_order, + created_at=seed.created_at.isoformat() if seed.created_at else None, + ) + + +@router.get("", response_model=list[OpsMarqueeSeedOut], summary="轮播种子列表") +def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]: + return [_out(s) for s in ops_marquee.list_seeds(db)] + + +@router.get("/preview", response_model=OpsSavingsFeedPreviewOut, summary="预览实际混播 feed") +def preview_feed( + db: AdminDb, + limit: Annotated[int, Query(ge=1, le=30)] = 8, +) -> OpsSavingsFeedPreviewOut: + """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。""" + return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit)) + + +@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)") +def create_seed( + body: OpsMarqueeSeedCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OpsMarqueeSeedOut: + try: + seed = ops_marquee.create_seed( + db, + masked_user=body.masked_user, + min_cents=body.min_cents, + max_cents=body.max_cents, + enabled=body.enabled, + sort_order=body.sort_order, + commit=False, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + write_audit( + db, admin, action="ops_marquee_seed.create", target_type="ops_marquee_seed", + target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(seed) + return _out(seed) + + +@router.post("/bulk", response_model=list[OpsMarqueeSeedOut], summary="批量生成种子(带审计)") +def bulk_generate( + body: OpsMarqueeSeedBulkCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> list[OpsMarqueeSeedOut]: + try: + seeds = ops_marquee.bulk_generate( + db, + count=body.count, + min_cents=body.min_cents, + max_cents=body.max_cents, + enabled=body.enabled, + commit=False, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + write_audit( + db, admin, action="ops_marquee_seed.bulk_generate", target_type="ops_marquee_seed", + target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False, + ) + db.commit() + for s in seeds: + db.refresh(s) + return [_out(s) for s in seeds] + + +@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)") +def update_seed( + seed_id: int, + body: OpsMarqueeSeedUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OpsMarqueeSeedOut: + try: + seed = ops_marquee.update_seed( + db, + seed_id, + masked_user=body.masked_user, + min_cents=body.min_cents, + max_cents=body.max_cents, + enabled=body.enabled, + sort_order=body.sort_order, + commit=False, + ) + except ValueError as e: + raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e)) from e + write_audit( + db, admin, action="ops_marquee_seed.update", target_type="ops_marquee_seed", + target_id=seed_id, detail=body.model_dump(exclude_none=True), + ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(seed) + return _out(seed) + + +@router.delete("/{seed_id}", summary="删轮播种子(带审计)") +def delete_seed( + seed_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + ok = ops_marquee.delete_seed(db, seed_id, commit=False) + if not ok: + raise HTTPException(status_code=404, detail="种子不存在") + write_audit( + db, admin, action="ops_marquee_seed.delete", target_type="ops_marquee_seed", + target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False, + ) + db.commit() + return {"deleted": True} diff --git a/app/admin/routers/ops_stat_config.py b/app/admin/routers/ops_stat_config.py new file mode 100644 index 0000000..f422ae6 --- /dev/null +++ b/app/admin/routers/ops_stat_config.py @@ -0,0 +1,74 @@ +"""admin 首页三统计展示配置:列出三指标配置(+ 真实值预览)+ 改某指标(带审计 + 校验)。 + +每指标可独立选 real/manual/random。计算逻辑见 app/repositories/ops_stat.py。 +权限:operator 可改(运营调门面数字),super 恒可。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.ops_stat_config import ( + OpsStatItemOut, + OpsStatUpdateRequest, +) +from app.models.admin import AdminUser +from app.repositories import ops_stat + +router = APIRouter( + prefix="/admin/api/dashboard-display", + tags=["admin-dashboard-display"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=list[OpsStatItemOut], summary="首页三统计配置 + 真实值预览") +def list_display(db: AdminDb) -> list[OpsStatItemOut]: + return [OpsStatItemOut(**item) for item in ops_stat.get_config(db)] + + +@router.patch( + "/{metric}", response_model=OpsStatItemOut, summary="改某指标展示配置(带审计)" +) +def update_display( + metric: str, + body: OpsStatUpdateRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OpsStatItemOut: + try: + before, after = ops_stat.update_config( + db, + metric, + mode=body.mode, + manual_value=body.manual_value, + random_mult_min=body.random_mult_min, + random_mult_max=body.random_mult_max, + random_tick_seconds=body.random_tick_seconds, + random_anchor_minutes=body.random_anchor_minutes, + random_kind=body.random_kind, + random_step_min=body.random_step_min, + random_step_max=body.random_step_max, + real_offset=body.real_offset, + allow_decrease=body.allow_decrease, + random_initial=body.random_initial, + apply_now=body.apply_now, + admin_id=admin.id, + commit=False, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + write_audit( + db, admin, action="ops_stat_config.set", target_type="ops_stat_config", + target_id=metric, detail={"before": before, "after": after}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + + item = next(it for it in ops_stat.get_config(db) if it["metric"] == metric) + return OpsStatItemOut(**item) diff --git a/app/admin/schemas/ops_marquee_seed.py b/app/admin/schemas/ops_marquee_seed.py new file mode 100644 index 0000000..6095318 --- /dev/null +++ b/app/admin/schemas/ops_marquee_seed.py @@ -0,0 +1,53 @@ +"""admin 首页轮播种子 schemas。金额单位:分(前端展示 ÷100 取元)。 + +种子 = 生成规则:masked_user 可空(空→feed 随机合成名)、金额是 [min_cents, max_cents] 区间。 +""" +from __future__ import annotations + +from pydantic import BaseModel + + +class OpsMarqueeSeedOut(BaseModel): + id: int + masked_user: str | None = None + min_cents: int + max_cents: int + enabled: bool + sort_order: int + created_at: str | None = None + + +class OpsMarqueeSeedCreate(BaseModel): + masked_user: str | None = None # 空 / 不传 → feed 随机合成脱敏名 + min_cents: int + max_cents: int + enabled: bool = True + sort_order: int = 0 + + +class OpsMarqueeSeedUpdate(BaseModel): + # masked_user:不传=不改;传空串=清空(改回随机合成);传值=固定该名 + masked_user: str | None = None + min_cents: int | None = None + max_cents: int | None = None + enabled: bool | None = None + sort_order: int | None = None + + +class OpsMarqueeSeedBulkCreate(BaseModel): + """批量生成 count 条自动合成名种子(冷启动铺量)。""" + count: int + min_cents: int + max_cents: int + enabled: bool = True + + +class OpsSavingsFeedPreviewItem(BaseModel): + masked_user: str + saved_amount_cents: int + time: str + + +class OpsSavingsFeedPreviewOut(BaseModel): + """运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。""" + items: list[OpsSavingsFeedPreviewItem] diff --git a/app/admin/schemas/ops_stat_config.py b/app/admin/schemas/ops_stat_config.py new file mode 100644 index 0000000..0a3a473 --- /dev/null +++ b/app/admin/schemas/ops_stat_config.py @@ -0,0 +1,48 @@ +"""admin 首页三统计展示配置 schemas。 + +倍率用千分比整数(1.000→1000);total_saved 的 manual_value / random_current 单位是分。 +""" +from __future__ import annotations + +from pydantic import BaseModel + + +class OpsStatItemOut(BaseModel): + metric: str # help_users / total_compares / total_saved + label: str # 帮助用户 / 完成比价 / 累计节省 + unit: str # 人 / 次 / 分 + mode: str # real / manual / random + manual_value: int | None = None + random_mult_min: int + random_mult_max: int + random_tick_seconds: int + random_anchor_minutes: int + random_kind: str # mult(×倍率) / add(+绝对增量) + random_step_min: int # 绝对增量区间(基础单位;total_saved 为分) + random_step_max: int + real_offset: int # 真实值模式基数偏移(基础单位) + allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减) + random_current: int | None = None + random_last_tick_at: str | None = None + real_value: int # 当前真实值(纯真实、不含偏移;给运营对比参考) + updated_at: str | None = None + + +class OpsStatUpdateRequest(BaseModel): + """改某指标配置(均可选,只改传了的字段)。""" + + mode: str | None = None + manual_value: int | None = None + random_mult_min: int | None = None + random_mult_max: int | None = None + random_tick_seconds: int | None = None + random_anchor_minutes: int | None = None + random_kind: str | None = None # mult / add + random_step_min: int | None = None + random_step_max: int | None = None + real_offset: int | None = None + allow_decrease: bool | None = None + # 切 random 时的初始基数;不传则用当前真实值播种 + random_initial: int | None = None + # 立即更新:不等更新钟点,保存后马上把展示值刷新一次(real 快照/manual 生效/random 走一档) + apply_now: bool = False diff --git a/app/api/internal/__init__.py b/app/api/internal/__init__.py new file mode 100644 index 0000000..6278e2a --- /dev/null +++ b/app/api/internal/__init__.py @@ -0,0 +1 @@ +"""内部(server→server)端点。不对客户端开放,靠共享密钥头校验,不走用户 JWT。""" diff --git a/app/api/internal/price.py b/app/api/internal/price.py new file mode 100644 index 0000000..5f654a7 --- /dev/null +++ b/app/api/internal/price.py @@ -0,0 +1,63 @@ +"""价格观测内部上报端点(pricebot → app-server)。 + +pricebot 在比价 done 帧把整批价格事实 POST 到这里落库。**不是给客户端的接口**: +不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。 +密钥未配置(默认空)时直接 503,避免裸奔的写端点。 + +与不鉴权的比价透传(compare.py / coupon.py)无关:那两个是客户端→server 的透传壳, +这个是 server→server 的内部写库。 +""" +from __future__ import annotations + +import hmac +import logging +from typing import Annotated + +from fastapi import APIRouter, Header, HTTPException, status + +from app.api.deps import DbSession +from app.core.config import settings +from app.repositories import price_observation as repo +from app.schemas.price_observation import ( + PriceObservationBatchIn, + PriceObservationBatchOut, +) + +logger = logging.getLogger("shagua.internal.price") + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +def _check_secret(x_internal_secret: str | None) -> None: + """共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。""" + configured = settings.INTERNAL_API_SECRET + if not configured: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="internal api not configured", + ) + if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid internal secret", + ) + + +@router.post( + "/price-observation", + response_model=PriceObservationBatchOut, + summary="价格观测内部上报(pricebot→app-server,落 price_observation)", +) +def report_price_observation( + payload: PriceObservationBatchIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> PriceObservationBatchOut: + _check_secret(x_internal_secret) + inserted, skipped = repo.insert_batch(db, payload) + logger.info( + "price_observation trace=%s inserted=%d skipped=%d device=%s user=%s", + payload.trace_id, inserted, skipped, + payload.source_device_id, payload.source_user_id, + ) + return PriceObservationBatchOut(inserted=inserted, skipped=skipped) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index ecff3b6..d6e6b99 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -20,12 +20,15 @@ from app.core.config import settings from app.integrations import pangle from app.core.ratelimit import rate_limit from app.repositories import ad_ecpm as crud_ecpm +from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch from app.schemas.ad import ( AdRewardStatusOut, EcpmReportIn, EcpmReportOut, + FeedRewardIn, + FeedRewardOut, PangleCallbackOut, TestGrantOut, WatchReportIn, @@ -112,15 +115,15 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: @router.post( "/watch-report", response_model=WatchReportOut, - summary="上报激励视频观看时长(每日 50 分钟防刷计时)", + summary="上报激励视频观看时长(旧客户端兼容字段)", dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))], ) def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut: """客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。 Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。 - 当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0, - 客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。 + 当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口 + 仍保留用于旧客户端兼容和排查观看时长。 """ total = crud_watch.add_watch_seconds(db, user.id, payload.seconds) limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT @@ -198,3 +201,36 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut: round_count=round_count, cooldown_until=cooldown_until, ) + + +@router.post( + "/feed-reward", + response_model=FeedRewardOut, + summary="信息流广告完成后结算金币", + dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))], +) +def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut: + """点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。 + + 当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。 + """ + rec = crud_feed.grant_feed_reward( + db, + user.id, + client_event_id=payload.client_event_id, + ecpm=payload.ecpm, + duration_seconds=payload.duration_seconds, + adn=payload.adn, + slot_id=payload.slot_id, + ) + logger.info( + "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", + user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin, + ) + return FeedRewardOut( + granted=(rec.status == "granted"), + status=rec.status, + coin=rec.coin, + unit_count=rec.unit_count, + daily_limit=rewards.get_ad_daily_limit(db), + ) diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 2a3b5f3..42bcddd 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -18,6 +18,7 @@ pricebot 协议文档: """ from __future__ import annotations +import json import logging from typing import Any @@ -25,6 +26,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from app.core.config import settings +from app.core.pricebot_router import pick_pricebot logger = logging.getLogger("shagua.compare") @@ -38,25 +40,35 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: 打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s, 比领券的 30s 长)。 """ + # 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server + # 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时 + # 直接发原始 bytes(content=raw),不重新 dumps。 + raw = await request.body() try: - body = await request.json() + meta = json.loads(raw) except Exception as e: raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e + if not isinstance(meta, dict): + meta = {} - url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}{upstream_path}" + # 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state) + base = pick_pricebot(meta.get("trace_id")) + url = f"{base.rstrip('/')}{upstream_path}" timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC logger.info( "compare %s device_id=%s trace_id=%s step=%s", upstream_path, - body.get("device_id"), - body.get("trace_id"), - body.get("step"), + meta.get("device_id"), + meta.get("trace_id"), + meta.get("step"), ) try: async with httpx.AsyncClient(timeout=timeout) as client: - resp = await client.post(url, json=body) + resp = await client.post( + url, content=raw, headers={"Content-Type": "application/json"} + ) except httpx.RequestError as e: logger.error("[pricebot] request failed: %s", e) raise HTTPException( diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 40100b5..9c8f55b 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession from app.repositories import comparison as crud_compare from app.schemas.compare_record import ( + CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, ComparisonRecordIn, @@ -52,6 +53,16 @@ def report_record( return ComparisonRecordCreatedOut(id=rec.id) +@router.get( + "/stats", + response_model=CompareStatsOut, + summary="比价口径战绩(完成比价数 + 累计发现可省)", +) +def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut: + count, saved = crud_compare.get_stats(db, user.id) + return CompareStatsOut(compare_count=count, discovered_saved_cents=saved) + + @router.get( "/records", response_model=ComparisonRecordPage, diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 29ba79c..0ed84ef 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -10,6 +10,7 @@ pricebot 协议文档: """ from __future__ import annotations +import json import logging from typing import Any @@ -17,6 +18,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from app.core.config import settings +from app.core.pricebot_router import pick_pricebot logger = logging.getLogger("shagua.coupon") @@ -33,24 +35,33 @@ async def coupon_step( - 透传: 不做 schema 校验,pricebot 自己校验 - 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message """ + # 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿 + # trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。 + raw = await request.body() try: - body = await request.json() + meta = json.loads(raw) except Exception as e: raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e + if not isinstance(meta, dict): + meta = {} - url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step" + # 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程) + base = pick_pricebot(meta.get("trace_id")) + url = f"{base.rstrip('/')}/api/coupon/step" timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC logger.info( "coupon_step device_id=%s trace_id=%s step=%s", - body.get("device_id"), - body.get("trace_id"), - body.get("step"), + meta.get("device_id"), + meta.get("trace_id"), + meta.get("step"), ) try: async with httpx.AsyncClient(timeout=timeout) as client: - resp = await client.post(url, json=body) + resp = await client.post( + url, content=raw, headers={"Content-Type": "application/json"} + ) except httpx.RequestError as e: logger.error("[pricebot] request failed: %s", e) raise HTTPException( diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py new file mode 100644 index 0000000..5c3c9d1 --- /dev/null +++ b/app/api/v1/platform.py @@ -0,0 +1,37 @@ +"""首页平台级展示数据 endpoint(全平台门面数字,**不鉴权**——登录前首页也要展示)。 + +路由前缀 `/api/v1/platform`: + GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。 + +展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Query + +from app.api.deps import DbSession +from app.repositories import ops_marquee as marquee_crud +from app.repositories import ops_stat as crud +from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut + +logger = logging.getLogger("shagua.platform") + +router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) + + +@router.get("/stats", response_model=PlatformStatsOut, summary="首页三统计(全平台门面数字)") +def stats(db: DbSession) -> PlatformStatsOut: + v = crud.get_display_values(db) + return PlatformStatsOut( + help_users=v["help_users"], + total_compares=v["total_compares"], + total_saved_cents=v["total_saved"], + ) + + +@router.get("/savings-feed", response_model=SavingsFeedOut, summary="首页轮播 feed(真实+种子混播)") +def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut: + items = marquee_crud.get_feed(db, limit=limit) + return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items]) diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py index 3bd440a..001b09d 100644 --- a/app/api/v1/signin.py +++ b/app/api/v1/signin.py @@ -1,8 +1,9 @@ """签到 endpoint。 路由前缀 `/api/v1/signin`: - GET /status 今日签到状态 + 7 天档位 + GET /status 今日签到状态 + 14 天档位 POST / 执行今日签到 + POST /boost 签到后看广告膨胀金币 """ from __future__ import annotations @@ -12,7 +13,12 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import CurrentUser, DbSession from app.repositories import signin as crud_signin -from app.schemas.welfare import SigninResultOut, SigninStatusOut +from app.schemas.welfare import ( + SigninBoostRequest, + SigninBoostResultOut, + SigninResultOut, + SigninStatusOut, +) logger = logging.getLogger("shagua.signin") @@ -42,3 +48,27 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut: streak=record.streak, coin_balance=balance, ) + + +@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币") +def boost_signin( + payload: SigninBoostRequest, user: CurrentUser, db: DbSession +) -> SigninBoostResultOut: + try: + record, balance = crud_signin.boost_today_signin( + db, user.id, ad_ref_id=payload.ad_ref_id + ) + except crud_signin.NotSignedTodayError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="not signed today") from e + except crud_signin.AlreadyBoostedError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already boosted today") from e + + logger.info( + "signin boost ok user_id=%d date=%s coin=%d", + user.id, record.signin_date, record.coin_awarded, + ) + return SigninBoostResultOut( + coin_awarded=record.coin_awarded, + coin_balance=balance, + signin_date=record.signin_date.isoformat(), + ) diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 3cd3c9c..c0bc017 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -33,6 +33,8 @@ from app.schemas.welfare import ( ExchangeInfoOut, ExchangeRequest, ExchangeResultOut, + TransferAuthResultOut, + TransferAuthStatusOut, UnbindWechatResultOut, WithdrawInfoOut, WithdrawOrderOut, @@ -155,15 +157,18 @@ def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut: return UnbindWechatResultOut(bound=False) -@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态") +@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关") def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: u = db.get(User, user.id) + # 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时 + auth = crud_wallet.sync_transfer_auth(db, user.id) return WithdrawInfoOut( min_cents=WITHDRAW_MIN_CENTS, max_cents=WITHDRAW_MAX_CENTS, wechat_bound=bool(u and u.wechat_openid), wechat_nickname=u.wechat_nickname if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None, + transfer_auth_enabled=bool(auth and auth.state == "active"), ) @@ -191,9 +196,19 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw except crud_wallet.InsufficientCashError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e + # 调试直发(非生产):skip_review=true 时跳过人工审核,立即发起微信转账(等价 admin approve)。 + # 双闸保护——客户端仅 debug 包下发此 flag,服务端仅非 prod 才认;任一道闸拦住即恢复正常审核, + # 生产绝不会被客户端 flag 绕过审核。用于本地联调"提现→微信转账/免确认到账"全链路。 + if req.skip_review and not settings.is_prod and order.status == "reviewing": + logger.warning( + "withdraw skip_review(非prod调试直发,跳过人工审核立即打款) user_id=%d bill=%s", + user.id, order.out_bill_no, + ) + order = crud_wallet.execute_withdraw_transfer(db, order) + acc = crud_wallet.get_or_create_account(db, user.id) logger.info( - "withdraw submitted user_id=%d cents=%d bill=%s status=%s(待审核)", + "withdraw submitted user_id=%d cents=%d bill=%s status=%s", user.id, req.amount_cents, order.out_bill_no, order.status, ) # 此刻 status=reviewing,尚未打款 → 无 package_info;App 据 status 提示"已提交,等待审核"。 @@ -246,3 +261,51 @@ def withdraw_orders( items=[WithdrawOrderOut.model_validate(it) for it in items], next_cursor=next_cursor, ) + + +# ===== 免确认收款授权(用户授权免确认模式)===== +# 开启一次后,后续提现走免确认转账直接到账,不再跳微信确认。绑定 openid 是前提。 + + +@router.post( + "/transfer-auth", + response_model=TransferAuthResultOut, + summary="开启免确认到账(申请授权,返回拉起微信授权页的 package)", + dependencies=[Depends(rate_limit(10, 60, "transfer-auth"))], +) +def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOut: + if not settings.wxpay_auth_configured: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="wechat transfer auth not configured", + ) + try: + info = crud_wallet.apply_transfer_auth(db, user.id) + except crud_wallet.WechatNotBoundError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e + except crud_wallet.WithdrawTransferError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e + logger.info("open transfer-auth user_id=%d already_active=%s", user.id, info["already_active"]) + return TransferAuthResultOut(**info) + + +@router.get( + "/transfer-auth/status", + response_model=TransferAuthStatusOut, + summary="查免确认授权状态(从微信授权页返回后轮询)", +) +def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut: + auth = crud_wallet.sync_transfer_auth(db, user.id) + state = auth.state if auth else "none" + return TransferAuthStatusOut(state=state, enabled=(state == "active")) + + +@router.post( + "/transfer-auth/close", + response_model=TransferAuthStatusOut, + summary="关闭免确认到账(解除授权)", +) +def close_transfer_auth_endpoint(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut: + crud_wallet.close_transfer_auth(db, user.id) + logger.info("close transfer-auth user_id=%d", user.id) + return TransferAuthStatusOut(state="closed", enabled=False) diff --git a/app/api/v1/wxpay.py b/app/api/v1/wxpay.py new file mode 100644 index 0000000..8844cae --- /dev/null +++ b/app/api/v1/wxpay.py @@ -0,0 +1,30 @@ +"""微信支付相关回调(一期:免确认收款授权结果通知 stub)。 + +⚠️ 一期不处理回调内容、不验签:授权状态以主动查询(query_transfer_authorization)为准。 +本端点仅向微信回 200 避免重试风暴,**绝不依据回调内容改账**。二期接入时必须先补 +V3 平台证书/公钥验签(Wechatpay-Signature) + APIv3 密钥 AEAD 解密,验签通过后方可信任并处理。 + +授权回调地址通过 settings.WXPAY_AUTH_NOTIFY_URL 配置,需指向本端点的公网地址。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request + +logger = logging.getLogger("shagua.wxpay") + +router = APIRouter(prefix="/api/v1/wxpay", tags=["wxpay"]) + + +@router.post("/transfer-auth-notify", summary="免确认收款授权结果通知(一期 stub:仅应答,不处理)") +async def transfer_auth_notify(request: Request) -> dict: + # 一期:不验签、不解密、不改账。仅记录 + 应答成功;真实授权状态靠 /transfer-auth/status 查询兜底。 + try: + body = await request.json() + logger.info( + "transfer-auth notify id=%s type=%s", body.get("id"), body.get("event_type") + ) + except Exception: # noqa: BLE001 — body 解析失败也照常应答 200,避免微信重试 + logger.info("transfer-auth notify (unparseable body)") + return {"code": "SUCCESS", "message": "OK"} diff --git a/app/core/config.py b/app/core/config.py index 1d4474c..c6eff5d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -102,6 +102,10 @@ class Settings(BaseSettings): WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥 WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销) WXPAY_REQUEST_TIMEOUT_SEC: int = 10 + # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 + # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 + # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 + WXPAY_AUTH_NOTIFY_URL: str = "" @property def wxpay_configured(self) -> bool: @@ -113,6 +117,11 @@ class Settings(BaseSettings): and self.WXPAY_PUBLIC_KEY_ID ) + @property + def wxpay_auth_configured(self) -> bool: + """免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。""" + return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL) + # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 # PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。 @@ -129,15 +138,34 @@ class Settings(BaseSettings): """回调开关打开且验签密钥已配,才接受发奖回调。""" return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET) - # ===== Pricebot 上游 (领券业务透传目标) ===== + # ===== Pricebot 上游 (领券/比价业务透传目标) ===== # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step PRICEBOT_BASE_URL: str = "http://localhost:8000" + # 多实例(单机多进程)时填逗号分隔的实例列表,例如: + # PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003 + # 透传层按 trace_id 一致性 hash 选实例(见 app/core/pricebot_router.py),保证同一次 + # 比价/领券的所有帧落同一 pricebot 进程,从而用进程内内存维护 session/coordinator, + # 无需 Redis。留空 → 退回单实例 [PRICEBOT_BASE_URL],零改动兼容。 + PRICEBOT_INSTANCES: str = "" # 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳 PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30 # 比价(intent/recognize + price/step)透传超时:意图识别是大上下文 LLM、 # price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。 PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60 + @property + def pricebot_instances(self) -> list[str]: + """pricebot 上游实例列表。空 → 单实例兜底 [PRICEBOT_BASE_URL]。""" + if not self.PRICEBOT_INSTANCES.strip(): + return [self.PRICEBOT_BASE_URL] + return [u.strip() for u in self.PRICEBOT_INSTANCES.split(",") if u.strip()] + + # ===== 内部(server→server)端点密钥 ===== + # pricebot 比价 done 后把价格观测 POST 到 /internal/price-observation 落库, + # 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。 + # 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。 + INTERNAL_API_SECRET: str = "" + # ===== 媒体文件(用户头像上传)===== # 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。 # 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。 diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 9fefc6e..ab6d4c8 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -14,9 +14,9 @@ from app.core import rewards as r # type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { - "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", + "default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位", "group": "签到", "type": "int_list", - "help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。", + "help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14。", }, "min_exchange_coin": { "default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币", @@ -46,7 +46,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "ad_daily_limit": { "default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)", - "group": "看广告", "type": "int", + "group": "看广告", "type": "int", "help": "福利页激励视频每日可发奖次数上限,默认 500。", }, "ad_max_coin": { "default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限", @@ -54,10 +54,10 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "ad_round_count": { "default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数", - "group": "看广告", "type": "int", "help": "看完一轮触发冷却。", + "group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。", }, "ad_cooldown_sec": { - "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "每轮冷却(秒)", - "group": "看广告", "type": "int", + "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", + "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。", }, } diff --git a/app/core/logging.py b/app/core/logging.py index 0981021..56b856b 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -1,20 +1,91 @@ """统一日志配置。 -业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可,本模块在 main.py 启动时调一次。 +业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。 + +- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。 +- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则); + 异常栈作为字段内嵌不换行 → 每条日志一行。 +- 环境变量: + - LOG_JSON_CONSOLE=1 控制台也输出 JSON + - LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log) + ⚠️ admin 子进程(app.admin.main, 独立进程)应设不同 LOG_FILE, 避免与主进程争抢同一轮转文件 + - LOG_SERVICE_NAME JSON 里的 service 字段(默认 app-server) """ from __future__ import annotations +import json import logging +import os import sys +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +class JsonFormatter(logging.Formatter): + """把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。""" + def __init__(self, service: str = "app-server"): + super().__init__() + self.service = service + + def format(self, record: logging.LogRecord) -> str: + dt = datetime.fromtimestamp(record.created) + data = { + "time": dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(record.msecs):03d}", + "level": record.levelname, + "service": self.service, + "logger": record.name, + "func": record.funcName, + "line": record.lineno, + "message": record.getMessage(), + } + if record.exc_info: + data["exception"] = self.formatException(record.exc_info) + if record.stack_info: + data["stack"] = self.formatStack(record.stack_info) + return json.dumps(data, ensure_ascii=False, default=str) + + +_CONFIGURED = False def setup_logging(debug: bool = False) -> None: + global _CONFIGURED + if _CONFIGURED: + return + level = logging.DEBUG if debug else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - stream=sys.stdout, + service = os.getenv("LOG_SERVICE_NAME", "app-server") + + root = logging.getLogger() + root.setLevel(level) + # 清掉 basicConfig/uvicorn 可能预置的 root handler, 避免重复输出 + for h in list(root.handlers): + root.removeHandler(h) + + # 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON + console = logging.StreamHandler(sys.stdout) + if os.getenv("LOG_JSON_CONSOLE") == "1": + console.setFormatter(JsonFormatter(service)) + else: + console.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + ) + root.addHandler(console) + + # 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个) + log_file = os.getenv("LOG_FILE") or str( + Path(os.getenv("LOG_DIR", "logs")) / "app-server.log" ) + Path(log_file).parent.mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler( + log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", + ) + file_handler.setFormatter(JsonFormatter(service)) + root.addHandler(file_handler) + # 第三方库降噪 logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) + + _CONFIGURED = True diff --git a/app/core/pricebot_router.py b/app/core/pricebot_router.py new file mode 100644 index 0000000..1dc8f52 --- /dev/null +++ b/app/core/pricebot_router.py @@ -0,0 +1,82 @@ +"""pricebot 上游实例选择:按 trace_id 一致性 hash(ketama 风格)选实例。 + +单机多进程下,保证同一 trace_id(一次比价/领券的所有帧)落同一 pricebot 进程, +从而用进程内内存维护 session/coordinator,不需要 Redis。 + +为什么用一致性 hash 而非简单取模(crc32 % N): + 加/减实例时,取模会让几乎所有 trace 重新映射(扩缩容全量中断进行中的比价); + 一致性 hash(虚拟节点)只重映射约 1/N 的 trace,扩缩容对存量冲击最小。 + ⚠️ 但内存态下,被重映射的那 1/N trace 仍会丢失 session(状态没外置), + 所以扩缩容仍建议挑低峰。 + +hash 用 md5(纯函数),app-server 多 worker / 多实例算出的环一致,亲和不会因 +app-server 自身水平扩展而被破坏。 +""" +from __future__ import annotations + +import bisect +import hashlib +from typing import Dict, List, Optional + +from app.core.config import settings + +# 每个真实实例在 hash 环上的虚拟节点数。越多分布越均匀。实例数少时(6~10)需要更多 +# 虚拟节点才均匀:实测 150→负载偏差~16%、1000→~6%。环只在启动/实例变更时构建一次, +# 1000×N 个点的构建与 bisect 查找开销都可忽略。 +_VNODES_PER_NODE = 1000 + + +def _hash(s: str) -> int: + """取 md5 前 8 hex(32-bit)做环坐标。确定性、跨进程一致。""" + return int(hashlib.md5(s.encode("utf-8")).hexdigest()[:8], 16) + + +class _HashRing: + """ketama 风格一致性 hash 环。不可变,实例列表变化时整体重建(见 _get_ring)。""" + + def __init__(self, nodes: List[str]): + self._keys: List[int] = [] # 升序的环坐标 + self._key_to_node: Dict[int, str] = {} + for node in nodes: + for v in range(_VNODES_PER_NODE): + h = _hash(f"{node}#{v}") + # 极小概率撞坐标,撞了跳过该虚拟节点(不影响正确性,仅少一个 vnode) + if h not in self._key_to_node: + self._key_to_node[h] = node + self._keys.append(h) + self._keys.sort() + + def pick(self, key: str) -> str: + h = _hash(key) + idx = bisect.bisect(self._keys, h) + if idx == len(self._keys): + idx = 0 # 环回绕 + return self._key_to_node[self._keys[idx]] + + +# 按实例列表缓存环,列表变了(改 PRICEBOT_INSTANCES + 重启)才重建。 +_ring: Optional[_HashRing] = None +_ring_nodes: tuple = () + + +def _get_ring(nodes: List[str]) -> _HashRing: + global _ring, _ring_nodes + key = tuple(nodes) + if _ring is None or key != _ring_nodes: + _ring = _HashRing(nodes) + _ring_nodes = key + return _ring + + +def pick_pricebot(trace_id: Optional[str]) -> str: + """按 trace_id 选 pricebot 实例 base url。 + + - 实例列表来自 settings.pricebot_instances(空则单实例兜底 [PRICEBOT_BASE_URL]) + - trace_id 缺失/空 或 单实例 → 直接返回第一个,不进环 + """ + nodes = settings.pricebot_instances + if len(nodes) == 1 or not trace_id: + return nodes[0] + # str() 防御:协议保证 trace_id 是 str,但万一传入非 str(如 int)也不至于在 + # _hash 的 .encode() 处炸,确定性地选到实例。 + return _get_ring(nodes).pick(str(trace_id)) diff --git a/app/core/rewards.py b/app/core/rewards.py index 8f1b745..2d06c31 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -17,9 +17,12 @@ def cn_today() -> date: return datetime.now(CN_TZ).date() -# ===== 签到:7 天循环,断签重置回第 1 天 ===== -# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引。 -SIGNIN_REWARDS: tuple[int, ...] = (10, 20, 30, 50, 80, 120, 200) +# ===== 签到:14 天循环,断签重置回第 1 天 ===== +# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。 +SIGNIN_REWARDS: tuple[int, ...] = ( + 200, 120, 150, 180, 200, 250, 500, + 200, 250, 300, 350, 400, 500, 3000, +) SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS) @@ -74,6 +77,64 @@ def record_milestone_reward(milestone: int) -> int: return RECORD_MILESTONES[milestone - 1] +# ===== 看激励视频 / 信息流广告发金币 ===== +# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。 +AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( + (0.1, 0, 100), + (0.3, 101, 200), + (0.4, 201, 400), + (0.6, 401, None), +) +AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( + (2.0, 1, 1), + (1.5, 2, 2), + (1.3, 3, 3), + (1.1, 4, 10), + (1.0, 11, None), +) + + +def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: + """解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。""" + if ecpm is None: + return 0.0 + try: + value = float(str(ecpm).strip()) + except (TypeError, ValueError): + return 0.0 + return max(0.0, value) + + +def ad_ecpm_factor(ecpm_yuan: float) -> float: + """eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。""" + if ecpm_yuan > 400: + return 0.6 + if ecpm_yuan > 200: + return 0.4 + if ecpm_yuan > 100: + return 0.3 + return 0.1 + + +def ad_lt_factor(today_count_after_this: int) -> float: + """LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。""" + count = max(1, today_count_after_this) + for factor, lo, hi in AD_LT_FACTOR_TABLE: + if count >= lo and (hi is None or count <= hi): + return factor + return 1.0 + + +def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int: + """按金币数值体系计算单份广告奖励金币。 + + 单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。 + """ + ecpm_yuan = parse_ecpm_yuan(ecpm) + yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) + return max(0, round(yuan * COIN_PER_YUAN)) + + # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== # 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)。 # 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"; @@ -83,25 +144,19 @@ def record_milestone_reward(milestone: int) -> int: AD_REWARD_COIN: int = 666 # 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。 MAX_AD_REWARD_COIN: int = 1000 -# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)。 -# 主闸是 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟);次数兜底要 ≥ 时长闸内的"合理最大次数", -# 否则会比主闸先掐、误伤看大量短广告的正常用户:50 分钟里若广告各 ~15s,理论可看 ~200 次, -# 故取 200(≈时长闸的次数等价),正常用户先撞 50 分钟时长闸,仅前端时长全报 0 等异常时本闸才兜住。 -# 不要为"更严"而下调到 120 之类(会在 ~30 分钟就掐短广告用户);要更严应在客户端如实上报时长。 -DAILY_AD_REWARD_LIMIT: int = 200 +# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。 +DAILY_AD_REWARD_LIMIT: int = 500 -# ===== 看激励视频每日总时长上限(防刷主闸)===== -# 用户每天最多看 50 分钟激励视频,超了不发奖 / 不给看。时长由前端 onAdClose 上报真实观看 -# 秒数累计(POST /api/v1/ad/watch-report → ad_watch_log 表 SUM)。前端不可信,故配合上面 -# DAILY_AD_REWARD_LIMIT 次数兜底一起防刷。要调上限直接改这里。 -DAILY_AD_WATCH_SECONDS_LIMIT: int = 50 * 60 # 3000 秒 = 50 分钟 +# ===== 看激励视频每日总时长上限(已停用)===== +# 旧版本用 50 分钟时长闸;当前产品只保留每日 500 次上限。保留字段返回给旧客户端, +# 但值为 0,客户端按 limit<=0 视为未启用。 +DAILY_AD_WATCH_SECONDS_LIMIT: int = 0 # 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。 MAX_SINGLE_WATCH_SECONDS: int = 120 -# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端, -# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit, -# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。 -VIDEO_ROUND_REQUIRED_COUNT: int = 3 -VIDEO_ROUND_COOLDOWN_SECONDS: int = 600 +# 每次广告关闭后 3 秒内不允许再次点击观看。沿用 round_count/cooldown_until 字段表达: +# round_size=1 表示每次成功发奖后都会派生一个 3 秒 cooldown_until。 +VIDEO_ROUND_REQUIRED_COUNT: int = 1 +VIDEO_ROUND_COOLDOWN_SECONDS: int = 3 def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001 diff --git a/app/integrations/wxpay.py b/app/integrations/wxpay.py index d52be17..06e0915 100644 --- a/app/integrations/wxpay.py +++ b/app/integrations/wxpay.py @@ -24,6 +24,10 @@ from app.core.config import settings _API_HOST = "https://api.mch.weixin.qq.com" _TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills" +# 免确认收款授权(用户授权免确认模式) +_AUTH_PATH = "/v3/fund-app/mch-transfer/user-confirm-authorization" +_PRE_TRANSFER_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/pre-transfer-with-authorization" +_TRANSFER_WITH_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/transfer" # 懒加载缓存 _private_key: RSAPrivateKey | None = None @@ -199,3 +203,164 @@ def code_to_userinfo(code: str) -> dict: pass return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw} + + +# ===== 免确认收款授权(用户授权免确认模式)===== +# 用户授权一次后,后续转账走 transfer_with_authorization 免逐笔确认直接到账。 +# 复用上面同一套 V3 签名(_build_authorization)、敏感字段加密(encrypt_sensitive)、懒加载密钥。 + + +def _transfer_report_infos() -> list[dict]: + """现金营销(scene 1000)转账场景报备信息,与 create_transfer 同口径。""" + return [ + {"info_type": "活动名称", "info_content": "比价返现"}, + {"info_type": "奖励说明", "info_content": "现金提现到微信零钱"}, + ] + + +def apply_transfer_authorization( + out_authorization_no: str, + openid: str, + user_display_name: str, + notify_url: str, + *, + scene_info: dict | None = None, +) -> dict: + """发起免确认收款授权(方式二:不转账,仅申请授权)。返回 {status_code, data}。 + 成功(200 + state=WAIT_USER_CONFIRM)时 data 带 package_info,供 App 拉起微信授权页。""" + body: dict = { + "out_authorization_no": out_authorization_no, + "appid": settings.WECHAT_APP_ID, + "openid": openid, + "transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID, + "user_display_name": user_display_name, + "authorization_notify_url": notify_url, + } + if scene_info: + body["scene_info"] = scene_info + body_str = json.dumps(body, ensure_ascii=False) + headers = { + "Authorization": _build_authorization("POST", _AUTH_PATH, body_str), + "Accept": "application/json", + "Content-Type": "application/json", + } + with httpx.Client() as client: + resp = client.post( + f"{_API_HOST}{_AUTH_PATH}", + content=body_str, + headers=headers, + timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC, + ) + return {"status_code": resp.status_code, "data": resp.json()} + + +def query_transfer_authorization(out_authorization_no: str) -> dict: + """按商户授权单号查授权结果。返回 {status_code, data}。 + data.state: WAIT_USER_CONFIRM / TAKING_EFFECT(已生效) / CLOSED;TAKING_EFFECT 时带 authorization_id。""" + path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}" + headers = { + "Authorization": _build_authorization("GET", path, ""), + "Accept": "application/json", + } + with httpx.Client() as client: + resp = client.get( + f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC + ) + return {"status_code": resp.status_code, "data": resp.json()} + + +def close_transfer_authorization(out_authorization_no: str) -> dict: + """解除免确认收款授权。返回 {status_code, data}(成功 data.state=CLOSED)。""" + path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}/close" + headers = { + "Authorization": _build_authorization("POST", path, ""), + "Accept": "application/json", + "Content-Type": "application/json", + } + with httpx.Client() as client: + resp = client.post( + f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC + ) + return {"status_code": resp.status_code, "data": resp.json()} + + +def pre_transfer_with_authorization( + openid: str, + amount_fen: int, + out_bill_no: str, + out_authorization_no: str, + user_display_name: str, + notify_url: str, + user_name: str | None = None, +) -> dict: + """方式一:发起转账并同时申请免确认收款授权(用户在确认这笔收款时一并完成授权)。 + 返回 {status_code, data};成功 state=WAIT_USER_CONFIRM 时带 package_info(拉确认+授权页)。""" + body: dict = { + "appid": settings.WECHAT_APP_ID, + "out_bill_no": out_bill_no, + "transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID, + "openid": openid, + "transfer_amount": amount_fen, + "transfer_remark": "金币提现", + "transfer_scene_report_infos": _transfer_report_infos(), + "authorization_info": { + "user_display_name": user_display_name, + "out_authorization_no": out_authorization_no, + "authorization_notify_url": notify_url, + }, + } + if user_name and amount_fen >= 30: + body["user_name"] = encrypt_sensitive(user_name) + + body_str = json.dumps(body, ensure_ascii=False) + headers = { + "Authorization": _build_authorization("POST", _PRE_TRANSFER_AUTH_PATH, body_str), + "Accept": "application/json", + "Content-Type": "application/json", + "Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID, + } + with httpx.Client() as client: + resp = client.post( + f"{_API_HOST}{_PRE_TRANSFER_AUTH_PATH}", + content=body_str, + headers=headers, + timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC, + ) + return {"status_code": resp.status_code, "data": resp.json()} + + +def transfer_with_authorization( + authorization_id: str, + amount_fen: int, + out_bill_no: str, + user_name: str | None = None, +) -> dict: + """方式二 / 二次起:用户已授权后免确认转账(无需用户逐笔确认,直接到账)。返回 {status_code, data}。 + 成功 state ∈ ACCEPTED/PROCESSING/TRANSFERING/SUCCESS(无 WAIT_USER_CONFIRM、无 package_info)。""" + body: dict = { + "appid": settings.WECHAT_APP_ID, + "out_bill_no": out_bill_no, + "transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID, + "transfer_amount": amount_fen, + "transfer_remark": "金币提现", + "transfer_scene_report_infos": _transfer_report_infos(), + "authorization_id": authorization_id, + } + if user_name and amount_fen >= 30: + body["user_name"] = encrypt_sensitive(user_name) + + body_str = json.dumps(body, ensure_ascii=False) + headers = { + "Authorization": _build_authorization("POST", _TRANSFER_WITH_AUTH_PATH, body_str), + "Accept": "application/json", + "Content-Type": "application/json", + "Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID, + } + with httpx.Client() as client: + resp = client.post( + f"{_API_HOST}{_TRANSFER_WITH_AUTH_PATH}", + content=body_str, + headers=headers, + timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC, + ) + return {"status_code": resp.status_code, "data": resp.json()} diff --git a/app/main.py b/app/main.py index 43d83f1..751a5d8 100644 --- a/app/main.py +++ b/app/main.py @@ -20,15 +20,18 @@ from app.api.v1.compare import router as compare_router from app.api.v1.compare_milestone import router as compare_milestone_router 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.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 from app.api.v1.tasks import router as tasks_router from app.api.v1.user import router as user_router from app.api.v1.wallet import router as wallet_router +from app.api.v1.wxpay import router as wxpay_router from app.core.config import settings from app.core.logging import setup_logging @@ -82,12 +85,16 @@ app.include_router(compare_record_router) app.include_router(compare_milestone_router) app.include_router(meituan_router) app.include_router(wallet_router) +app.include_router(wxpay_router) app.include_router(signin_router) app.include_router(tasks_router) app.include_router(savings_router) app.include_router(ad_router) app.include_router(order_router) app.include_router(report_router) +# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。 +app.include_router(internal_price_router) +app.include_router(platform_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 _media_root = Path(settings.MEDIA_ROOT) diff --git a/app/models/__init__.py b/app/models/__init__.py index 67649a2..2f95779 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ """所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" from app.models.ad_ecpm import AdEcpmRecord # noqa: F401 +from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 @@ -8,9 +9,12 @@ 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.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 diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py new file mode 100644 index 0000000..c434312 --- /dev/null +++ b/app/models/ad_feed_reward.py @@ -0,0 +1,43 @@ +"""信息流广告奖励记录。 + +点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AdFeedRewardRecord(Base): + __tablename__ = "ad_feed_reward_record" + __table_args__ = ( + UniqueConstraint("client_event_id", name="uq_ad_feed_reward_client_event"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + client_event_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) + duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + unit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) + adn: Mapped[str | None] = mapped_column(String(32), nullable=True) + slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/models/ad_watch_log.py b/app/models/ad_watch_log.py index 5eb848c..7a9d5ed 100644 --- a/app/models/ad_watch_log.py +++ b/app/models/ad_watch_log.py @@ -1,15 +1,14 @@ -"""看激励视频观看时长记录(每日总时长防刷)。 +"""看激励视频观看时长记录(旧版每日总时长字段)。 每条 = 客户端 onAdClose 上报的一次激励视频实际观看秒数。按 (user_id, watch_date) 聚合 -SUM(watch_seconds) 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(超了不再发奖 / -不给看,见 core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT)。 +SUM(watch_seconds) 得当日总观看时长。当前产品只保留每日 500 次上限, +core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸;本表保留用于旧客户端兼容和排查。 这是看广告的第三条数据流,与另两条并列、互不关联: - ad_reward(AdRewardRecord):穿山甲 S2S 回调发金币(后端,有 trans_id); - ad_ecpm(AdEcpmRecord):客户端上报展示收益(前端,有 ecpm); - ad_watch_log(本表):客户端上报观看时长(前端,有 watch_seconds)。 -前端上报的时长不可信 → 时长是防刷"主闸",配合 ad_reward 的每日次数上限(DAILY_AD_REWARD_LIMIT) -做"次数兜底",前端少报时长也刷不过次数闸。 +前端上报的时长不可信,不能作为唯一发奖依据;当前发奖闸口以 ad_reward 的每日次数上限为准。 """ from __future__ import annotations diff --git a/app/models/ops_marquee_seed.py b/app/models/ops_marquee_seed.py new file mode 100644 index 0000000..2e64eb8 --- /dev/null +++ b/app/models/ops_marquee_seed.py @@ -0,0 +1,42 @@ +"""首页轮播「种子」条目(运营可配的兜底假数据 = 假数据生成规则)。 + +客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条:数据源是全平台真实比价记录, +但冷启动 / 低谷期真实记录不够 N 条时,用本表的种子条目补齐到 N 条「混播」,保证轮播永不空。 +运营后台可增删改本表。真实够时种子不出现。 + +种子是「规则」而非「死记录」: +- masked_user 可空——留空则 feed 展示时按脱敏格式随机合成用户名(并避开同屏撞名), + 风格与真实条一致、运营零心智、永不穿帮;填了则固定用该名。 +- 金额是 [min_cents, max_cents] 区间,feed 每次在区间内随机取值(固定金额则 min==max), + 让同一种子每次展示的数字有浮动,更像真的。 +- sort_order 仅供后台列表排序;feed 是公平随机抽取(不看 sort_order),保证所有启用种子都有机会露出。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class OpsMarqueeSeed(Base): + __tablename__ = "ops_marquee_seed" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名) + masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」 + min_cents: Mapped[int] = mapped_column(Integer, nullable=False) + max_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # 停用的种子不参与混播 + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # 仅供后台列表排序(小在前);feed 是公平随机抽取,不看此字段 + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/ops_stat_config.py b/app/models/ops_stat_config.py new file mode 100644 index 0000000..1681de9 --- /dev/null +++ b/app/models/ops_stat_config.py @@ -0,0 +1,70 @@ +"""首页三统计展示配置表(帮助用户 / 完成比价 / 累计节省)。 + +客户端首页顶部三个门面数字,每个指标可独立选 3 种展示模式(运营后台配): + - real : 实时查真实数据(help_users=去重成功比价用户数 / total_compares=成功比价数 / + total_saved=成功比价省额求和)。 + - manual : 直接用运营手填的固定值。 + - random : 「只增不减」的伪增长——读取时惰性 tick:距上次 tick 超过 random_tick_seconds + 就把 random_current 乘以一个 [min,max] 内的随机倍率(倍率恒 ≥1.0 → 只增不减)。 + +数值单位约定(三指标都用各自「基础单位」的整数存,避免浮点): + - help_users / total_compares : 个数(人 / 次) + - total_saved : 分(cents);客户端 ÷100 显示「元」 + +倍率用千分比整数存:1.000→1000、1.100→1100,读时 factor = randint(min,max)/1000。 + +一行一指标,主键 = metric。初始由 migration 播种 3 行(默认 manual + 当前写死门面值, +保证上线无视觉变化),运营再按需切模式。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class OpsStatConfig(Base): + __tablename__ = "ops_stat_config" + + # help_users / total_compares / total_saved + metric: Mapped[str] = mapped_column(String(32), primary_key=True) + # real / manual / random(每指标独立) + mode: Mapped[str] = mapped_column(String(16), nullable=False, default="real") + + # manual 模式固定值(基础单位整数;total_saved 为分) + manual_value: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # random 模式:倍率区间(千分比,≥1000 保证只增不减)+ tick 周期 + 当前值 + 上次 tick 时刻 + random_mult_min: Mapped[int] = mapped_column(Integer, nullable=False, default=1000) + random_mult_max: Mapped[int] = mapped_column(Integer, nullable=False, default=1100) + random_tick_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=86400) + # 触发时刻对齐偏移:距北京时间 0 点的分钟数。按单位:天=每日时刻(h*60+m)、小时=每小时第几分、 + # 分钟=0。刷新在北京时间 (anchor + k*interval) 的钟点边界触发(见 repo _refresh)。 + random_anchor_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + random_current: Mapped[int | None] = mapped_column(Integer, nullable=True) + random_last_tick_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # random 增长方式:mult=×倍率(用 random_mult_*) / add=+绝对增量(用 random_step_*,基础单位整数) + random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult") + random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面) + real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束) + allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + updated_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/price_observation.py b/app/models/price_observation.py new file mode 100644 index 0000000..84f9e2c --- /dev/null +++ b/app/models/price_observation.py @@ -0,0 +1,111 @@ +"""价格观测表(price_observation)—— 比价沉淀的价格资产层。 + +每完成一次比价,pricebot 在 done 帧把"在某平台某店,这一篮菜/这单到手价多少钱" +按平台逐条算出来,server→server 内部上报落这里。**与登录无关、不依赖客户端上报**—— +比价透传链路当前不鉴权,只要比价跑到 done 就记,匿名用户也记(来源记 device_id, +user_id 客户端带上时一并记)。 + +与 comparison_record 的区别: +- comparison_record:用户视角的「我的比价记录」,客户端登录后主动上报,按 user_id 存。 +- price_observation:**平台/门店视角的价格事实**,server 侧无条件沉淀,按 (平台,门店,菜,时间) + 组织,是未来"别人查过同店→直接秒回价格"大数据的源头。两表独立,互不影响。 + +「先存下来、用法后说」:结构化列给将来按 平台/门店/菜/时间/地理 查询聚合用;dishes / attrs +(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema。raw trace(逐 step 全过程) +另存 pricebot 本地 jsonl.gz,本表只存提炼后的价格事实(本表可从 raw 重算)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + Boolean, + 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 + +# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 comparison_record)。 +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class PriceObservation(Base): + __tablename__ = "price_observation" + __table_args__ = ( + # 一次比价(trace)里,同一平台、同一口径(scope)只记一条:pricebot 重试 / 客户端 + # replay 重复上报时幂等去重(insert-or-ignore)。一次外卖比价 = 源 + N 个目标平台 + # 各一条 scope='order'。 + UniqueConstraint( + "trace_id", "platform", "scope", + name="uq_price_obs_trace_platform_scope", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # 观测时刻(≈ 比价 done 时;服务端落库时取 now)。按时间查"最近有效价"用,建索引。 + observed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + # pricebot 侧 trace_id:回指 A 层原始 trace(能溯源 / 重算)+ 幂等去重键 + trace_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + # 业务类型:food(外卖,当前唯一接通)/ ecom(电商,二期)。预留扩展。 + business_type: Mapped[str] = mapped_column( + String(16), nullable=False, default="food", index=True + ) + + # ===== 门店维度(先 denormalize 成文本,不建维度表;实体归一二期再做)===== + platform: Mapped[str] = mapped_column(String(32), index=True, nullable=False) + # 平台内门店 ID(抓得到就存,将来归一最稳的 key;现在多为空) + platform_store_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + store_name: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True) + city: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 地理(现在拿不到 → 留空;二期把 device 经纬度透传进比价请求后回填) + geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) + lng: Mapped[float | None] = mapped_column(Float, nullable=True) + lat: Mapped[float | None] = mapped_column(Float, nullable=True) + + # ===== 价格事实 ===== + # 是否源平台(发起比价那家)。源平台价也是真实观测,照记。 + is_source: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # 口径:order(整单到手价,外卖当前唯一)/ dish(单菜单价,引擎暂不逐菜读,二期) + scope: Mapped[str] = mapped_column(String(16), nullable=False, default="order") + # 该口径的到手价(分);None = 该平台采集失败 / 门店打烊,无有效价 + price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + coupon_saved_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + coupon_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 该平台目标店打烊原因(非空时 price_cents 为 None) + store_closed: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 本次比价里的全局名次(1=最便宜;按到手价升序,源/目标统一排) + rank: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # ===== 明细(JSON)===== + # 菜篮 [{name, qty, subtotal?}](外卖)。dish 级单价二期从这里 + 引擎增强推。 + dishes: Mapped[list | None] = mapped_column(_JSON, nullable=True) + # 灵活字段兜底(配送费 / 起送 / 活动名 / 跳过菜数 等),免得加字段就迁移。 + attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + + # ===== 来源(溯源 / 去重 / 置信)===== + source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。 + source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/models/signin.py b/app/models/signin.py index f23724a..63a9043 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,14 +1,14 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 - - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 + - cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 """ from __future__ import annotations from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -39,3 +39,31 @@ class SigninRecord(Base): f"" ) + + +class SigninBoostRecord(Base): + """签到后看广告膨胀记录。 + + 一天最多膨胀一次,补发金额等于当天签到原始奖励。独立表用于防并发重复补发, + 后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号。 + """ + + __tablename__ = "signin_boost_record" + __table_args__ = ( + UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + signin_date: Mapped[date] = mapped_column(Date, nullable=False) + coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False) + ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/wallet.py b/app/models/wallet.py index 9698758..9aff2ea 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -107,6 +107,43 @@ class WithdrawOrder(Base): return f"" +class WechatTransferAuthorization(Base): + """微信商家转账「免确认收款授权」(用户授权免确认模式)。一个用户一条(user_id 主键)。 + + 状态机: + (无) →(申请 / 首单转账顺带申请)→ pending(微信 WAIT_USER_CONFIRM,待用户在微信确认授权) + pending →(用户确认授权)→ active(微信 TAKING_EFFECT,此后转账免用户逐笔确认) + pending / active →(用户在微信关 / 商户解除 / 风控)→ closed(终态,需重新开启) + out_authorization_no 我方生成(查授权 / 发起授权用,一个用户一条稳定值,重开时换新); + authorization_id 微信在 active 后返回,免确认转账(transfer_with_authorization)时必传。 + """ + + __tablename__ = "wechat_transfer_authorization" + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), primary_key=True + ) + openid: Mapped[str] = mapped_column(String(64), nullable=False) + # 商户侧授权单号(我方生成),唯一 + out_authorization_no: Mapped[str] = mapped_column( + String(64), unique=True, index=True, nullable=False + ) + # 微信侧授权单号(active 后返回,免确认转账要用) + authorization_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # pending(待用户确认) / active(已生效可免确认) / closed(已关闭需重开) + state: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), 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"" + + class CashTransaction(Base): """现金流水(单位:分)。金币兑现金、提现都记在这里。""" diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py new file mode 100644 index 0000000..1ab8994 --- /dev/null +++ b/app/repositories/ad_feed_reward.py @@ -0,0 +1,126 @@ +"""信息流广告奖励 CRUD。 + +点位 2:每展示满 10 秒累计一份奖励,视频完成后一次性入账。当前一期由客户端在 +完成回调后上报;后续若 SDK/S2S 能提供更强确认信号,可继续复用本表的幂等键。 +""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core import rewards +from app.core.rewards import cn_today +from app.models.ad_feed_reward import AdFeedRewardRecord +from app.repositories import wallet as crud_wallet + + +FEED_REWARD_UNIT_SECONDS = 10 + + +def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None: + return db.execute( + select(AdFeedRewardRecord).where( + AdFeedRewardRecord.client_event_id == client_event_id + ) + ).scalar_one_or_none() + + +def _granted_today(db: Session, user_id: int, reward_date: str) -> int: + return db.execute( + select(func.count()) + .select_from(AdFeedRewardRecord) + .where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.reward_date == reward_date, + AdFeedRewardRecord.status == "granted", + ) + ).scalar_one() + + +def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int: + """按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。""" + if unit_count <= 0: + return 0 + existing_units = db.execute( + select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) + .where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.reward_date == today, + AdFeedRewardRecord.status == "granted", + ) + ).scalar_one() + total = 0 + for offset in range(1, unit_count + 1): + total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset) + return total + + +def grant_feed_reward( + db: Session, + user_id: int, + *, + client_event_id: str, + ecpm: str, + duration_seconds: int, + adn: str | None = None, + slot_id: str | None = None, +) -> AdFeedRewardRecord: + """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。""" + existing = _find_by_event(db, client_event_id) + if existing is not None: + return existing + + today = cn_today().isoformat() + safe_duration = max(0, min(duration_seconds, 24 * 60 * 60)) + unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + + if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + coin=0, + status="capped", + ) + return _commit_record(db, rec, client_event_id) + + coin = _unit_reward_total(db, user_id, ecpm, unit_count, today) + if coin > 0: + crud_wallet.grant_coins( + db, user_id, coin, + biz_type="feed_ad_reward", ref_id=client_event_id, + remark=f"信息流广告奖励 {unit_count}份", + ) + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + coin=coin, + status="granted", + ) + return _commit_record(db, rec, client_event_id) + + +def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord: + db.add(rec) + try: + db.commit() + except IntegrityError: + db.rollback() + existing = _find_by_event(db, client_event_id) + if existing is not None: + return existing + raise + db.refresh(rec) + return rec diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index 77bee4b..ded6d73 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -72,10 +72,12 @@ def grant_ad_reward( today = cn_today().isoformat() - # #3 每日上限:观看时长(主闸,50 分钟) 或 发奖次数(兜底) 任一到顶 → 记 capped 不发金币, - # 让审计能看到"今天到顶了"。时长由前端 watch-report 累计(防刷主闸),次数防前端少报时长 - # 绕过;次数上限走 app_config(运营后台可改,默认 rewards.DAILY_AD_REWARD_LIMIT)。 - over_time = watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT + # #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段, + # 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。 + over_time = ( + DAILY_AD_WATCH_SECONDS_LIMIT > 0 + and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT + ) over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db) if over_time or over_count: rec = AdRewardRecord( @@ -137,8 +139,8 @@ def today_status( 返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC), 今日已观看总秒数, 每日观看总时长上限(秒))。 次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看 - 几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);时长维度(50 分钟 - 主闸)查 ad_watch_log 当日累计——前端据此展示"今日已看 X/50 分钟"+ 满则不给看。 + 几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);观看时长字段保留给 + 旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸。 """ today = cn_today().isoformat() granted_desc = _granted_times_today_desc(db, user_id, today) diff --git a/app/repositories/ad_watch.py b/app/repositories/ad_watch.py index c98ed69..75b859c 100644 --- a/app/repositories/ad_watch.py +++ b/app/repositories/ad_watch.py @@ -1,7 +1,7 @@ -"""看激励视频观看时长 CRUD(每日总时长防刷)。 +"""看激励视频观看时长 CRUD(旧版每日总时长字段)。 前端 onAdClose 上报本次观看秒数 → add_watch_seconds 落库 + 返回当日累计; -watched_seconds_today 给"时长闸"(发奖 / 展示前)判断当天是否已达 50 分钟上限。 +当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 时不再用本累计做发奖拦截。 鉴权接口已确保 user 存在(JWT),故不做 UnknownUser 校验。 """ from __future__ import annotations diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index f233c4f..acc0c66 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -163,6 +163,25 @@ def count_success(db: Session, user_id: int) -> int: ).scalar_one() +def get_stats(db: Session, user_id: int) -> tuple[int, int]: + """「我的」页省钱战绩卡的**比价口径**聚合:(成功比价数, 累计发现可省额/分)。 + + - 完成比价 = status='success' 的比价记录数(同 count_success)。 + - 累计发现可省 = 各成功比价 saved_amount_cents(源价−最低价)之和;null(没省/未识别)不计。 + 与「下单成交记账」savings_record 解耦:比过价就计,不要求下单(见 savings.py)。 + """ + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0), + ).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.status == "success", + ) + ).one() + return int(row[0]), int(row[1]) + + def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None: """取单条(限本人,避免越权读他人记录)。""" return db.execute( diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py new file mode 100644 index 0000000..6eb0c27 --- /dev/null +++ b/app/repositories/ops_marquee.py @@ -0,0 +1,246 @@ +"""首页轮播 feed:全平台真实比价记录 + 种子兜底「混播」,以及种子表 CRUD。 + +feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 ops_marquee_seed 的种子补齐, +保证轮播永不空。真实够时种子不出现。 + +种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 +feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 +""" +from __future__ import annotations + +import hashlib +import random +from datetime import datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ +from app.models.comparison import ComparisonRecord +from app.models.ops_marquee_seed import OpsMarqueeSeed + +# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) +_rng = random.Random() + +_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐 +_HEX = "0123456789abcdef" +# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮) +_REAL_MAX_CENTS = 30000 +# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下 +_SEED_MAX_CENTS = 100000 + + +def _mask_user(user_id: int) -> str: + """真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。""" + suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX] + return f"用户********{suffix}" + + +def _random_suffix(used: set[str]) -> str: + """随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。""" + for _ in range(60): + s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX)) + if s not in used: + used.add(s) + return s + # 兜底(几乎不会到):线性扫一个空位 + for n in range(16 ** _SUFFIX_HEX): + s = format(n, f"0{_SUFFIX_HEX}x") + if s not in used: + used.add(s) + return s + return "000" + + +def _validate_amount(min_cents: int, max_cents: int) -> None: + if min_cents < 0 or max_cents < 0: + raise ValueError("金额需为非负整数(分)") + if max_cents < min_cents: + raise ValueError("金额上限不得小于下限") + if max_cents > _SEED_MAX_CENTS: + raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)") + + +# ===== 用户侧:轮播 feed(真实 + 种子混播) ===== +def get_feed(db: Session, limit: int = 8) -> list[dict]: + """返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。 + + 真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。 + 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; + 种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。 + 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生 + (真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。 + """ + # 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。 + rows = db.execute( + select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents) + .where( + ComparisonRecord.status == "success", + ComparisonRecord.saved_amount_cents > 0, + ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS, + ) + .order_by(ComparisonRecord.created_at.desc()) + .limit(max(limit * 20, 100)) + ).all() + + items: list[dict] = [] + used_suffixes: set[str] = set() + seen_users: set[int] = set() + for uid, sc in rows: + if uid in seen_users: + continue + seen_users.add(uid) + name = _mask_user(uid) + used_suffixes.add(name[-_SUFFIX_HEX:]) + items.append({"masked_user": name, "saved_amount_cents": int(sc)}) + if len(items) >= limit: + break + + need = limit - len(items) + if need > 0: + seeds = db.execute( + select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)) + ).scalars().all() + # 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。 + chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds) + _rng.shuffle(chosen) + for s in chosen: + # 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。 + if s.masked_user and s.masked_user.strip(): + name = s.masked_user.strip() + used_suffixes.add(name[-_SUFFIX_HEX:]) + else: + name = f"用户********{_random_suffix(used_suffixes)}" + # 金额:在 [min,max] 随机一个(固定金额则 min==max)。 + lo = max(0, int(s.min_cents)) + hi = max(lo, int(s.max_cents)) + cents = lo if lo >= hi else _rng.randint(lo, hi) + items.append({"masked_user": name, "saved_amount_cents": cents}) + + # 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。 + cursor = datetime.now(CN_TZ) + for i, it in enumerate(items): + cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5)) + it["time"] = cursor.strftime("%H:%M:%S") + return items + + +# ===== 运营侧:种子 CRUD ===== +def list_seeds(db: Session) -> list[OpsMarqueeSeed]: + return list( + db.execute( + select(OpsMarqueeSeed).order_by(OpsMarqueeSeed.sort_order, OpsMarqueeSeed.id) + ).scalars().all() + ) + + +def create_seed( + db: Session, + *, + masked_user: str | None = None, + min_cents: int, + max_cents: int, + enabled: bool = True, + sort_order: int = 0, + commit: bool = True, +) -> OpsMarqueeSeed: + _validate_amount(min_cents, max_cents) + seed = OpsMarqueeSeed( + masked_user=(masked_user.strip() or None) if masked_user else None, + min_cents=min_cents, + max_cents=max_cents, + enabled=enabled, + sort_order=sort_order, + ) + db.add(seed) + if commit: + db.commit() + db.refresh(seed) + else: + db.flush() + return seed + + +def update_seed( + db: Session, + seed_id: int, + *, + masked_user: str | None = None, + min_cents: int | None = None, + max_cents: int | None = None, + enabled: bool | None = None, + sort_order: int | None = None, + commit: bool = True, +) -> OpsMarqueeSeed: + """改种子。masked_user 约定:不传(None)=不改;传空串=清空(改回随机合成);传值=固定该名。""" + seed = db.get(OpsMarqueeSeed, seed_id) + if seed is None: + raise ValueError("种子不存在") + if masked_user is not None: + seed.masked_user = masked_user.strip() or None + if min_cents is not None or max_cents is not None: + new_min = min_cents if min_cents is not None else seed.min_cents + new_max = max_cents if max_cents is not None else seed.max_cents + _validate_amount(new_min, new_max) + seed.min_cents = new_min + seed.max_cents = new_max + if enabled is not None: + seed.enabled = enabled + if sort_order is not None: + seed.sort_order = sort_order + if commit: + db.commit() + db.refresh(seed) + else: + db.flush() + return seed + + +def bulk_generate( + db: Session, + *, + count: int, + min_cents: int, + max_cents: int, + enabled: bool = True, + commit: bool = True, +) -> list[OpsMarqueeSeed]: + """批量生成 count 条「自动合成名」种子(masked_user=NULL),金额区间 [min,max]。 + 冷启动铺量用;sort_order 续在当前最大值之后。 + """ + if not (1 <= count <= 200): + raise ValueError("数量需在 1~200 之间") + _validate_amount(min_cents, max_cents) + base = db.execute( + select(func.coalesce(func.max(OpsMarqueeSeed.sort_order), 0)) + ).scalar_one() + created: list[OpsMarqueeSeed] = [] + for i in range(1, count + 1): + seed = OpsMarqueeSeed( + masked_user=None, + min_cents=min_cents, + max_cents=max_cents, + enabled=enabled, + sort_order=base + i, + ) + db.add(seed) + created.append(seed) + if commit: + db.commit() + for s in created: + db.refresh(s) + else: + db.flush() + return created + + +def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool: + seed = db.get(OpsMarqueeSeed, seed_id) + if seed is None: + return False + db.delete(seed) + if commit: + db.commit() + else: + db.flush() + return True diff --git a/app/repositories/ops_stat.py b/app/repositories/ops_stat.py new file mode 100644 index 0000000..5dfd7f9 --- /dev/null +++ b/app/repositories/ops_stat.py @@ -0,0 +1,334 @@ +"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。 + +三种模式见 app/models/ops_stat_config.py。统一「定时刷新」:展示值 random_current 只在跨过 +「北京钟点边界」时刷新一次(real 快照查库 / manual 取固定值 / random 增长)。 + +random 自增长支持两种方式(random_kind): + - mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长 + - add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增 + +真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面。 + +「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值, +保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。 + +纯门面数字,**接受 tick 时刻的微小并发竞态**(不加行锁):极端情况某次多走一档,无业务后果。 +""" +from __future__ import annotations + +import random +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.comparison import ComparisonRecord +from app.models.ops_stat_config import OpsStatConfig + +# 指标顺序 + 展示元信息(label/unit 给运营后台用;total_saved 单位是分,客户端 ÷100 显示元) +METRICS = ("help_users", "total_compares", "total_saved") +_META = { + "help_users": ("帮助用户", "人"), + "total_compares": ("完成比价", "次"), + "total_saved": ("累计节省", "分"), +} +# 初始播种值(= 客户端原写死门面数:12847 人 / 86532 次 / 37621.4 元)。 +_SEED_VALUE = { + "help_users": 12847, + "total_compares": 86532, + "total_saved": 3762140, # 37621.40 元 = 3762140 分 +} + +# 防呆边界 +_MULT_FLOOR = 1000 # 倍率千分比下限 = 1.000(只增不减) +_MULT_CAP = 1500 # 倍率千分比上限 = 1.500(收紧:单次 ×1.5 已不小,>此一跳就假) +_STEP_CAP = 10**12 # 绝对增量单次上限(基础单位防呆) +_TICK_MIN_SECONDS = 60 # tick 周期下限 +_MAX_CATCHUP_PERIODS = 3650 # 单次最多补走的周期数(防 last 远古时一次走爆) +_BEIJING_OFFSET = 8 * 3600 # 北京时间 = UTC + 8h(钟点对齐按北京时间算) +# 真实 total_saved 求和:单条 saved 超此值视为异常 / bug 值,不计入 +# (与轮播口径一致,防一条 bug 大额配合「只增不减」永久撑高门面) +_REAL_SAVED_CAP_CENTS = 30000 # 300 元 + + +def _as_utc(dt: datetime | None) -> datetime | None: + """把(可能朴素的 SQLite)datetime 归一成带时区 UTC;朴素值按 UTC 解释。""" + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +# ===== 真实数据(real 模式)===== +def _real_value(db: Session, metric: str) -> int: + """real 口径:仅统计 status='success' 的比价记录。 + help_users=去重用户数 / total_compares=记录数 / total_saved=省额(分)求和(剔除单条异常大值)。 + """ + success = ComparisonRecord.status == "success" + if metric == "help_users": + return db.execute( + select(func.count(func.distinct(ComparisonRecord.user_id))).where(success) + ).scalar_one() + if metric == "total_compares": + return db.execute( + select(func.count(ComparisonRecord.id)).where(success) + ).scalar_one() + if metric == "total_saved": + # 只累加 0 < saved ≤ 上限 的条目,异常大值不计入(防 bug 值撑高门面) + return db.execute( + select(func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0)).where( + success, + ComparisonRecord.saved_amount_cents > 0, + ComparisonRecord.saved_amount_cents <= _REAL_SAVED_CAP_CENTS, + ) + ).scalar_one() + raise KeyError(f"unknown metric: {metric}") + + +# ===== 行管理 ===== +def _ensure_rows(db: Session) -> dict[str, OpsStatConfig]: + """保证三指标行都存在(migration 已播种;这里是缺行兜底,默认 manual + 种子值)。""" + rows = {r.metric: r for r in db.execute(select(OpsStatConfig)).scalars().all()} + created = False + for metric in METRICS: + if metric not in rows: + row = OpsStatConfig( + metric=metric, mode="manual", manual_value=_SEED_VALUE[metric] + ) + db.add(row) + rows[metric] = row + created = True + if created: + db.commit() + return rows + + +# ===== random 惰性 tick(北京钟点对齐)===== +def _boundary_index(epoch_utc: int, interval: int, anchor_sec: int) -> int: + """该 UTC 时刻落在第几个「北京钟点边界」。边界 = 北京时间 (anchor + k*interval)。""" + bj = epoch_utc + _BEIJING_OFFSET + return (bj - anchor_sec) // interval + + +def _current_target(db: Session, row: OpsStatConfig) -> int: + """某模式此刻「应有的展示值」(用于播种 / real·manual 的定时快照)。""" + if row.mode == "manual": + return max(0, int(row.manual_value or 0)) + if row.mode == "real": + # 真实值 + 基数偏移 + return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0)) + # random:无显式初始基数时用真实值(纯真实,不含偏移)播种 + return max(0, int(_real_value(db, row.metric))) + + +def _monotonic(row: OpsStatConfig, new_val: int) -> int: + """只增不减护栏:未开 allow_decrease 且已有展示值时,新值不得低于当前值(否则保持当前)。""" + if not row.allow_decrease and row.random_current is not None and new_val < row.random_current: + return row.random_current + return new_val + + +def _grow(row: OpsStatConfig, val: int, periods: int) -> int: + """random 自增长:按 random_kind 走 periods 个周期(add=逐周期加随机增量 / mult=逐周期乘随机倍率)。""" + if row.random_kind == "add": + lo = max(0, row.random_step_min) + hi = max(lo, row.random_step_max) + for _ in range(periods): + val += random.randint(lo, hi) + else: + lo = max(_MULT_FLOOR, row.random_mult_min) + hi = max(lo, row.random_mult_max) + for _ in range(periods): + val = val * random.randint(lo, hi) // 1000 + return val + + +def _refresh(db: Session, row: OpsStatConfig) -> int: + """统一「定时刷新」:展示值 random_current 只在跨过「北京钟点边界」时刷新一次。 + real→重新快照(真实+偏移,受只增不减护栏)/ manual→取固定值(受护栏)/ random→按方式增长。 + 无初值则按模式播种。返回当前展示值(基础单位)。 + """ + now = datetime.now(timezone.utc) + if row.random_current is None or row.random_last_tick_at is None: + row.random_current = _current_target(db, row) + row.random_last_tick_at = now + db.commit() + return row.random_current + + interval = max(_TICK_MIN_SECONDS, row.random_tick_seconds) + anchor_sec = (max(0, row.random_anchor_minutes or 0) * 60) % interval + last = _as_utc(row.random_last_tick_at) + periods = _boundary_index(int(now.timestamp()), interval, anchor_sec) - _boundary_index( + int(last.timestamp()), interval, anchor_sec + ) + if periods <= 0: + return row.random_current + + if row.mode == "random": + row.random_current = _grow(row, row.random_current, min(periods, _MAX_CATCHUP_PERIODS)) + else: + # real / manual:跨多少边界都只取「此刻应有的值」快照一次,经只增不减护栏 + row.random_current = _monotonic(row, _current_target(db, row)) + row.random_last_tick_at = now # 用边界索引比较,直接记 now 不会重复计 + db.commit() + return row.random_current + + +# ===== 用户侧:展示值 ===== +def get_display_values(db: Session) -> dict[str, int]: + """返回 {metric: 展示整数值}(total_saved 为分)。三模式统一走定时刷新。""" + rows = _ensure_rows(db) + return {metric: int(_refresh(db, rows[metric])) for metric in METRICS} + + +# ===== 运营侧:配置读写 ===== +def _snapshot(row: OpsStatConfig) -> dict: + """审计 / 返回用的行快照。""" + return { + "mode": row.mode, + "manual_value": row.manual_value, + "random_mult_min": row.random_mult_min, + "random_mult_max": row.random_mult_max, + "random_tick_seconds": row.random_tick_seconds, + "random_anchor_minutes": row.random_anchor_minutes, + "random_kind": row.random_kind, + "random_step_min": row.random_step_min, + "random_step_max": row.random_step_max, + "real_offset": row.real_offset, + "allow_decrease": row.allow_decrease, + "random_current": row.random_current, + "random_last_tick_at": ( + row.random_last_tick_at.isoformat() if row.random_last_tick_at else None + ), + } + + +def get_config(db: Session) -> list[dict]: + """三指标当前配置 + 真实值预览(给运营对比),按 METRICS 顺序。 + + 先跑一次定时刷新,使「当前展示值」在后台轮询时也随钟点实时推进。 + """ + rows = _ensure_rows(db) + out: list[dict] = [] + for metric in METRICS: + row = rows[metric] + _refresh(db, row) + label, unit = _META[metric] + out.append( + { + "metric": metric, + "label": label, + "unit": unit, + "real_value": _real_value(db, metric), # 纯真实(不含偏移),供运营对比 + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + **_snapshot(row), + } + ) + return out + + +def update_config( + db: Session, + metric: str, + *, + mode: str | None = None, + manual_value: int | None = None, + random_mult_min: int | None = None, + random_mult_max: int | None = None, + random_tick_seconds: int | None = None, + random_anchor_minutes: int | None = None, + random_kind: str | None = None, + random_step_min: int | None = None, + random_step_max: int | None = None, + real_offset: int | None = None, + allow_decrease: bool | None = None, + random_initial: int | None = None, + apply_now: bool = False, + admin_id: int, + commit: bool = True, +) -> tuple[dict, dict]: + """改某指标配置。返回 (before, after) 快照供审计。非法入参抛 ValueError(router 转 400)。 + + 展示值 random_current 采用统一「定时刷新」:平时不动,到更新钟点才按模式刷新。 + 本函数只在「首次无值」时按模式播种;给了 random_initial 则立即设为该值并重置; + apply_now=True 则立即刷新一次(random 走一档增长,real/manual 取应有值,均经只增不减护栏)。 + """ + if metric not in METRICS: + raise ValueError(f"未知指标: {metric}") + rows = _ensure_rows(db) + row = rows[metric] + before = _snapshot(row) + + if mode is not None: + if mode not in ("real", "manual", "random"): + raise ValueError("mode 需为 real/manual/random") + row.mode = mode + if manual_value is not None: + if manual_value < 0: + raise ValueError("manual_value 需为非负整数") + row.manual_value = manual_value + if random_mult_min is not None: + if not (_MULT_FLOOR <= random_mult_min <= _MULT_CAP): + raise ValueError(f"倍率下限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}") + row.random_mult_min = random_mult_min + if random_mult_max is not None: + if not (_MULT_FLOOR <= random_mult_max <= _MULT_CAP): + raise ValueError(f"倍率上限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}") + row.random_mult_max = random_mult_max + if row.random_mult_max < row.random_mult_min: + raise ValueError("倍率上限不得小于下限") + if random_tick_seconds is not None: + if random_tick_seconds < _TICK_MIN_SECONDS: + raise ValueError(f"更新间隔不得小于 {_TICK_MIN_SECONDS} 秒") + row.random_tick_seconds = random_tick_seconds + if random_anchor_minutes is not None: + if not (0 <= random_anchor_minutes < 1440): + raise ValueError("更新时间需在 00:00~23:59 之间") + row.random_anchor_minutes = random_anchor_minutes + if random_kind is not None: + if random_kind not in ("mult", "add"): + raise ValueError("增长方式需为 mult/add") + row.random_kind = random_kind + if random_step_min is not None: + if not (0 <= random_step_min <= _STEP_CAP): + raise ValueError("增量下限需为非负整数") + row.random_step_min = random_step_min + if random_step_max is not None: + if not (0 <= random_step_max <= _STEP_CAP): + raise ValueError("增量上限需为非负整数") + row.random_step_max = random_step_max + if row.random_step_max < row.random_step_min: + raise ValueError("增量上限不得小于下限") + if real_offset is not None: + if real_offset < 0: + raise ValueError("基数偏移需为非负整数") + row.real_offset = real_offset + if allow_decrease is not None: + row.allow_decrease = allow_decrease + + now = datetime.now(timezone.utc) + if random_initial is not None: + if random_initial < 0: + raise ValueError("初始基数需为非负整数") + row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束 + row.random_last_tick_at = now + elif apply_now: + # 立即更新:不等钟点,马上刷新一次 + if row.mode == "random" and row.random_current is not None: + row.random_current = _grow(row, row.random_current, 1) + else: + row.random_current = _monotonic(row, _current_target(db, row)) + row.random_last_tick_at = now + elif row.random_current is None: + # 首次无值:按当前模式播种,使配置后立即有合理展示值 + row.random_current = _current_target(db, row) + row.random_last_tick_at = now + + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return before, _snapshot(row) diff --git a/app/repositories/price_observation.py b/app/repositories/price_observation.py new file mode 100644 index 0000000..0450d7f --- /dev/null +++ b/app/repositories/price_observation.py @@ -0,0 +1,86 @@ +"""价格观测落库:一次比价的整批观测幂等写入。 + +幂等键 = (trace_id, platform, scope)。pricebot 重试 / replay 会重复上报同一 trace, +这里先查该 trace 已存在的 (platform, scope),只插新的——跨方言(PG / SQLite dev)都安全, +不依赖 ON CONFLICT。极端并发下若仍撞唯一约束,IntegrityError 回滚后忽略(本批本就幂等)。 +""" +from __future__ import annotations + +import logging + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.price_observation import PriceObservation +from app.schemas.price_observation import PriceObservationBatchIn + +logger = logging.getLogger("shagua.price_obs") + + +def insert_batch(db: Session, payload: PriceObservationBatchIn) -> tuple[int, int]: + """写入一次比价的整批观测,返回 (inserted, skipped)。 + + skipped = 因 (trace_id, platform, scope) 已存在而幂等跳过的条数。 + """ + if not payload.observations: + return 0, 0 + + # 该 trace 已有的 (platform, scope),用于幂等过滤 + existing: set[tuple[str, str]] = set( + db.execute( + select(PriceObservation.platform, PriceObservation.scope).where( + PriceObservation.trace_id == payload.trace_id + ) + ).all() + ) + + inserted = 0 + skipped = 0 + for obs in payload.observations: + key = (obs.platform, obs.scope) + if key in existing: + skipped += 1 + continue + existing.add(key) # 同批内去重(防 pricebot 同帧给重复平台) + db.add( + PriceObservation( + trace_id=payload.trace_id, + business_type=payload.business_type, + platform=obs.platform, + platform_store_id=obs.platform_store_id, + store_name=payload.store_name, + city=payload.city, + geohash=payload.geohash, + lng=payload.lng, + lat=payload.lat, + is_source=obs.is_source, + scope=obs.scope, + price_cents=obs.price_cents, + coupon_saved_cents=obs.coupon_saved_cents, + coupon_name=obs.coupon_name, + store_closed=obs.store_closed, + rank=obs.rank, + dishes=payload.dishes, + attrs=obs.attrs, + source_device_id=payload.source_device_id, + source_user_id=payload.source_user_id, + ) + ) + inserted += 1 + + if inserted == 0: + return 0, skipped + + try: + db.commit() + except IntegrityError: + # 并发下另一个请求刚插了同 (trace,platform,scope) → 唯一约束撞了。 + # 本批本就幂等,回滚后当作全跳过(不重试,失败就失败,见 P0 约定)。 + db.rollback() + logger.warning( + "price_observation 并发幂等冲突 trace=%s,本批回滚跳过", payload.trace_id + ) + return 0, skipped + inserted + + return inserted, skipped diff --git a/app/repositories/savings.py b/app/repositories/savings.py index dbbbfe5..7eb5fa4 100644 --- a/app/repositories/savings.py +++ b/app/repositories/savings.py @@ -1,14 +1,17 @@ -"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。 +"""省钱 CRUD:累计/战绩聚合 + 明细分页(纯真实比价上报数据)。 -设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自 -demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。 +设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),数据只来自真实比价 +下单上报(`source='compare'`,见 create_from_report)。 + +⚠️ 2026-06 起**停用 demo 兜底**:比价下单上报链路已通(Android `PriceBotService` → +`POST /api/v1/order/report`),新用户无真实记录时直接显示空/0(配合「我的」页省钱战绩 +卡的「未比价锁定态」)。历史遗留的 `source='demo'` 行不再被任何统计口径计入。 聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑—— created_at 带时区,统一转成北京时间的 date 再分组。 """ from __future__ import annotations -import random from dataclasses import dataclass from datetime import datetime, timedelta @@ -19,26 +22,6 @@ from app.core.rewards import CN_TZ, cn_today from app.models.savings import SavingsRecord from app.schemas.order import OrderReportRequest -# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单 -_DEMO_STREAK_DAYS = 9 -_DEMO_EXTRA_ORDERS = 14 - -# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。 -# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中; -# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。 -_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [ - ("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]), - ("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]), - ("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]), - ("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]), - ("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]), - ("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]), - ("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]), - ("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]), - ("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]), - ("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]), -] - @dataclass class SavingsSummary: @@ -52,7 +35,7 @@ class SavingsBattle: week_saved_cents: int # 本周(周一起)已省 beat_percent: int # 超过百分之多少用户 streak_days: int # 连续省钱天数 - compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底) + compare_count: int # 累计完成比价次数(= 真实 compare 上报记录数) def _local_date(dt: datetime): @@ -62,75 +45,20 @@ def _local_date(dt: datetime): return dt.astimezone(CN_TZ).date() -def _all_records(db: Session, user_id: int) -> list[SavingsRecord]: - stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) +def _compare_records(db: Session, user_id: int) -> list[SavingsRecord]: + """统计/明细口径:只用真实比价上报(`source='compare'`)记录。 + + 2026-06 起停用 demo 兜底:无真实记录直接返回空(新用户显示 0/锁定态)。 + 历史 `source='demo'` 行不计入。 + """ + stmt = select(SavingsRecord).where( + SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" + ) return list(db.execute(stmt).scalars().all()) -def _has_real(db: Session, user_id: int) -> bool: - """该用户是否已有真实比价上报(source='compare')记录。""" - return db.execute( - select(SavingsRecord.id) - .where(SavingsRecord.user_id == user_id, SavingsRecord.source == "compare") - .limit(1) - ).first() is not None - - -def _effective_records(db: Session, user_id: int) -> list[SavingsRecord]: - """统计口径:有真实(compare)记录就只用真实的;否则 seed 一批 demo 并用 demo 兜底。""" - if _has_real(db, user_id): - stmt = select(SavingsRecord).where( - SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" - ) - return list(db.execute(stmt).scalars().all()) - ensure_seeded(db, user_id) - return _all_records(db, user_id) - - -def ensure_seeded(db: Session, user_id: int) -> None: - """该用户没有任何省钱记录时,幂等灌一批 demo 数据。""" - exists = db.execute( - select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1) - ).first() - if exists is not None: - return - - rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致 - today = cn_today() - records: list[SavingsRecord] = [] - - def _mk(day) -> SavingsRecord: - hour = rng.randint(8, 21) - minute = rng.randint(0, 59) - ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ) - platform, shop, dishes = rng.choice(_DEMO_ORDERS) - # 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡 - saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元 - return SavingsRecord( - user_id=user_id, - order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价) - saved_amount_cents=saved, - platform=platform, - title=shop, - shop_name=shop, - dishes=dishes, - source="demo", - created_at=ts, - ) - - # 最近 N 天每天 1 单 → 连续省钱 N 天 - for d in range(_DEMO_STREAK_DAYS): - records.append(_mk(today - timedelta(days=d))) - # 历史散单(第 10~40 天) - for _ in range(_DEMO_EXTRA_ORDERS): - records.append(_mk(today - timedelta(days=rng.randint(10, 40)))) - - db.add_all(records) - db.commit() - - def get_summary(db: Session, user_id: int) -> SavingsSummary: - records = _effective_records(db, user_id) + records = _compare_records(db, user_id) total = sum(r.saved_amount_cents for r in records) count = len(records) avg = total // count if count else 0 @@ -150,7 +78,7 @@ def _streak_days(dates: set) -> int: def get_battle(db: Session, user_id: int) -> SavingsBattle: - records = _effective_records(db, user_id) + records = _compare_records(db, user_id) today = cn_today() week_start = today - timedelta(days=today.weekday()) # 本周一 @@ -172,7 +100,7 @@ def get_battle(db: Session, user_id: int) -> SavingsBattle: def _compute_beat_percent(db: Session, user_id: int) -> int: - """超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。 + """超过百分之多少用户:按"累计省下金额"在所有有真实(compare)记录的用户中做真实分位。 = 严格少于我的其他用户数 / 其他用户总数 * 100。 全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。 @@ -180,18 +108,12 @@ def _compute_beat_percent(db: Session, user_id: int) -> int: rows = db.execute( select( SavingsRecord.user_id, - SavingsRecord.source, func.sum(SavingsRecord.saved_amount_cents), - ).group_by(SavingsRecord.user_id, SavingsRecord.source) + ) + .where(SavingsRecord.source == "compare") + .group_by(SavingsRecord.user_id) ).all() - # 每用户口径与展示一致:有 compare 用 compare 之和,否则退回 demo 之和 - by_user: dict[int, dict[str, int]] = {} - for uid, source, total in rows: - by_user.setdefault(uid, {})[source] = total or 0 - totals = { - uid: (d["compare"] if "compare" in d else d.get("demo", 0)) - for uid, d in by_user.items() - } + totals = {uid: (total or 0) for uid, total in rows} others = {uid: t for uid, t in totals.items() if uid != user_id} if not others: return 0 @@ -207,12 +129,10 @@ def list_records( limit: int = 20, cursor: int | None = None, ) -> tuple[list[SavingsRecord], int | None]: - """省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底。""" - stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) - if _has_real(db, user_id): - stmt = stmt.where(SavingsRecord.source == "compare") - else: - ensure_seeded(db, user_id) + """省钱明细分页(按 id 倒序,游标式)。只列真实比价上报(`source='compare'`)记录。""" + stmt = select(SavingsRecord).where( + SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" + ) if cursor is not None: stmt = stmt.where(SavingsRecord.id < cursor) stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit) diff --git a/app/repositories/signin.py b/app/repositories/signin.py index 04f894a..747233a 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -1,7 +1,7 @@ """签到 CRUD:状态查询 + 执行签到。 -7 天循环规则: - - 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续) +14 天循环规则: + - 昨天签过 → 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续) - 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置) 今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。 """ @@ -11,11 +11,12 @@ from dataclasses import dataclass from datetime import timedelta from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core import rewards from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today -from app.models.signin import SigninRecord +from app.models.signin import SigninBoostRecord, SigninRecord from app.repositories import wallet as crud_wallet @@ -23,9 +24,17 @@ class AlreadySignedError(Exception): """今天已经签过了。""" +class NotSignedTodayError(Exception): + """今天尚未签到,不能膨胀。""" + + +class AlreadyBoostedError(Exception): + """今天签到奖励已经膨胀过。""" + + @dataclass class SigninStep: - day: int # 1..7 + day: int # 1..14 coin: int status: str # claimed / today / locked @@ -34,7 +43,7 @@ class SigninStep: class SigninStatus: today_signed: bool consecutive_days: int # 当前已确认的连续签到天数 - today_cycle_day: int # 今天落在循环的第几档(1..7) + today_cycle_day: int # 今天落在循环的第几档(1..14) today_coin: int # 今天这一档的金币 can_claim: bool # 现在能否签到(= not today_signed) steps: list[SigninStep] @@ -51,7 +60,7 @@ def _latest_record(db: Session, user_id: int) -> SigninRecord | None: def _next_cycle_day(last: SigninRecord | None, today) -> int: - """若现在签到,今天会落在第几档(1..7)。""" + """若现在签到,今天会落在第几档(1..14)。""" if last is not None and last.signin_date == today - timedelta(days=1): return last.cycle_day % SIGNIN_CYCLE_LEN + 1 return 1 @@ -127,3 +136,52 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]: db.commit() db.refresh(record) return record, acc.coin_balance + + +def _today_record(db: Session, user_id: int) -> SigninRecord | None: + today = cn_today() + return db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user_id, + SigninRecord.signin_date == today, + ) + ).scalar_one_or_none() + + +def boost_today_signin( + db: Session, user_id: int, *, ad_ref_id: str | None = None +) -> tuple[SigninBoostRecord, int]: + """签到后看广告膨胀:补发一笔等额签到金币。返回 (膨胀记录, 补发后余额)。""" + record = _today_record(db, user_id) + if record is None: + raise NotSignedTodayError + + today = record.signin_date + existing = db.execute( + select(SigninBoostRecord).where( + SigninBoostRecord.user_id == user_id, + SigninBoostRecord.signin_date == today, + ) + ).scalar_one_or_none() + if existing is not None: + raise AlreadyBoostedError + + boost = SigninBoostRecord( + user_id=user_id, + signin_date=today, + coin_awarded=record.coin_awarded, + ad_ref_id=ad_ref_id, + ) + db.add(boost) + try: + acc, _ = crud_wallet.grant_coins( + db, user_id, record.coin_awarded, + biz_type="signin_boost", ref_id=today.isoformat(), + remark=f"签到膨胀 第{record.cycle_day}天", + ) + db.commit() + except IntegrityError as e: + db.rollback() + raise AlreadyBoostedError from e + db.refresh(boost) + return boost, acc.coin_balance diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 09f7c0c..a0db153 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -7,6 +7,7 @@ from __future__ import annotations import re +import unicodedata import uuid from datetime import datetime, timedelta, timezone @@ -14,15 +15,25 @@ from sqlalchemy import select, update from sqlalchemy.orm import Session from app.core import rewards +from app.core.config import settings from app.core.rewards import COIN_PER_CENT, coins_to_cents from app.integrations import wxpay from app.models.user import User -from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +from app.models.wallet import ( + CashTransaction, + CoinAccount, + CoinTransaction, + WechatTransferAuthorization, + WithdrawOrder, +) # 微信转账终态:成功 / 失败(失败/取消/关闭都退款) _WX_STATE_SUCCESS = "SUCCESS" _WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"} _WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认 +# 免确认收款授权状态 +_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账 +_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启 class InvalidExchangeAmountError(Exception): @@ -405,13 +416,175 @@ def create_withdraw( return order # 待管理员审核;**不在此处打款** -def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder: - """审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。 +# ===== 免确认收款授权(用户授权免确认模式)===== +# 用户授权一次后,后续提现走 transfer_with_authorization 免逐笔确认直接到账。 +# WechatTransferAuthorization 一个用户一条;out_authorization_no 我方生成,authorization_id 微信生效后返回。 - 流程:reviewing → 置 pending → 调微信转账 → SUCCESS=success / 失败/取消=退款 failed / - 结果不明=先查单再决定。**不抛异常**(admin 调用方按 order.status 判断结果)。 - 用户在审核期间解绑微信 → 退款 failed(打不了款)。 - 可能返回 package_info(WAIT_USER_CONFIRM 场景:需用户在 App 端确认页确认后才真正到账)。 + +def _auth_display_name(user: User | None) -> str: + """微信授权页展示的"开通账号"昵称(≤32,utf8)。 + + 微信对 user_display_name 校验极严:**连空格和标点(. 等)都算"控制字符"拒收**——实测 + 'wonderable ai'(带空格)被 400 拒、'wonderableai'/'周周'/'傻瓜比价用户8888' 才过。 + 故只保留 文字(Unicode L*,中英文/CJK)与数字(N*),其余(emoji/符号/空格/标点/控制符/ + 零宽连接符/变体选择符/星形面字符)一律剔除;清空则兜底手机号尾号。 + """ + raw = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip() + kept = [ + ch for ch in raw + if ord(ch) <= 0xFFFF and unicodedata.category(ch)[0] in ("L", "N") + ] + name = "".join(kept).strip() + if not name and user and user.phone: + tail = user.phone[-4:] if len(user.phone) >= 4 else user.phone + name = f"傻瓜比价用户{tail}" + return (name or "傻瓜比价用户")[:32] + + +def _refresh_active_auth(db: Session, user_id: int) -> None: + """免确认转账失败后回查授权有效性(权威判定,不靠猜错误码): + 微信侧明确非生效(用户在微信关闭 / 风控 / 无此单)→ 标 closed,下次提现自动回退方式一重新授权; + 仍生效(失败实为商户余额不足等与授权无关的原因)→ 保持 active,下次免确认重试。 + 仅对本地 active 记录查询;查询本身失败(5xx/网络)不改状态,下次再核,避免误关有效授权。""" + auth = db.get(WechatTransferAuthorization, user_id) + if auth is None or auth.state != "active" or not settings.wxpay_configured: + return + try: + res = wxpay.query_transfer_authorization(auth.out_authorization_no) + except Exception: # noqa: BLE001 — 查询失败保持 active,下次再核 + return + if res["status_code"] == 200: + if res["data"].get("state") != _WX_AUTH_ACTIVE: # CLOSED / 其他非生效 → 失效 + auth.state = "closed" + db.commit() + elif _wx_not_found(res): # 明确无此授权单 → 失效 + auth.state = "closed" + db.commit() + # 其他查询错误(5xx 等):不改状态,保持 active,下次再核 + + +def sync_transfer_auth(db: Session, user_id: int) -> WechatTransferAuthorization | None: + """同步待确认授权最新状态(仅 pending 时查微信): + TAKING_EFFECT → active + 落 authorization_id;CLOSED/超期无此单 → closed。返回最新记录(或 None)。 + 非 pending(active/closed)直接返回不查;查询异常或缺凭证则保持原状。""" + auth = db.get(WechatTransferAuthorization, user_id) + if auth is None or auth.state != "pending" or not settings.wxpay_configured: + return auth + try: + res = wxpay.query_transfer_authorization(auth.out_authorization_no) + except Exception: # noqa: BLE001 — 查询失败保持 pending,下次再同步 + return auth + if res["status_code"] != 200: + if _wx_not_found(res): # 超期未确认被关闭 → closed;其他错误保持 pending + auth.state = "closed" + db.commit() + return auth + state = res["data"].get("state", "") + if state == _WX_AUTH_ACTIVE: + auth.state = "active" + auth.authorization_id = res["data"].get("authorization_id") or auth.authorization_id + db.commit() + elif state == _WX_AUTH_CLOSED: + auth.state = "closed" + db.commit() + # WAIT_USER_CONFIRM:保持 pending,等用户确认 + return auth + + +def _ensure_pending_auth_no(db: Session, user_id: int, openid: str) -> str: + """为"方式一(转账顺带授权)"准备待确认授权单号: + 已有 pending → 复用其 out_authorization_no(避免重复申请刷满"同场景≤5"上限); + 无 / 已 closed → 生成新单号并 upsert 成 pending。返回 out_authorization_no。""" + auth = db.get(WechatTransferAuthorization, user_id) + if auth is not None and auth.state == "pending": + if auth.openid != openid: # 换绑过 → 刷新快照 + auth.openid = openid + db.commit() + return auth.out_authorization_no + new_no = uuid.uuid4().hex # 32 位,符合 [0-9A-Za-z_-]{8,32} + if auth is None: + db.add( + WechatTransferAuthorization( + user_id=user_id, openid=openid, out_authorization_no=new_no, + authorization_id=None, state="pending", + ) + ) + else: # closed → 重新开启,换新单号 + auth.openid = openid + auth.out_authorization_no = new_no + auth.authorization_id = None + auth.state = "pending" + db.commit() + return new_no + + +def apply_transfer_auth(db: Session, user_id: int) -> dict: + """方式二:显式开启免确认收款(申请授权,不转账)。 + 返回 {already_active, package_info, mch_id, app_id};already_active=True 表示已开启无需再授权。 + 未绑微信抛 WechatNotBoundError;微信返回非 200 抛 WithdrawTransferError。""" + user = db.get(User, user_id) + openid = user.wechat_openid if user else None + if not openid: + raise WechatNotBoundError + + auth = db.get(WechatTransferAuthorization, user_id) + if auth is not None and auth.state == "active" and auth.authorization_id: + return { + "already_active": True, "package_info": None, + "mch_id": settings.WXPAY_MCH_ID, "app_id": settings.WECHAT_APP_ID, + } + + out_auth_no = _ensure_pending_auth_no(db, user_id, openid) + result = wxpay.apply_transfer_authorization( + out_auth_no, openid, _auth_display_name(user), settings.WXPAY_AUTH_NOTIFY_URL + ) + if result["status_code"] != 200: + raise WithdrawTransferError(str(result["data"].get("message") or result["data"])) + return { + "already_active": False, + "package_info": result["data"].get("package_info"), + "mch_id": settings.WXPAY_MCH_ID, + "app_id": settings.WECHAT_APP_ID, + } + + +def close_transfer_auth(db: Session, user_id: int) -> None: + """关闭免确认收款(解除授权)。best-effort 调微信解除 + 本地置 closed。""" + auth = db.get(WechatTransferAuthorization, user_id) + if auth is None: + return + if auth.state != "closed" and settings.wxpay_configured: + try: + wxpay.close_transfer_authorization(auth.out_authorization_no) + except Exception: # noqa: BLE001 — 微信解除失败不阻塞本地置 closed(用户也可在微信侧自行关闭) + pass + auth.state = "closed" + db.commit() + + +def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> WithdrawOrder: + """落微信转账应答到提现单:记 state/转账单号/package_info,SUCCESS 即 success。""" + order.wechat_state = data.get("state") + order.transfer_bill_no = data.get("transfer_bill_no") + order.package_info = data.get("package_info") # 免确认转账无此字段(None);确认模式带它供拉确认页 + if data.get("state") == _WX_STATE_SUCCESS: + order.status = "success" + db.commit() + db.refresh(order) + return order + + +def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder: + """审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。**不抛异常**。 + + 免确认收款(用户授权免确认模式)分叉: + ① 已有生效授权(active)→ transfer_with_authorization 免确认转账,直接到账,无 package_info。 + ② 无生效授权: + - 已配 WXPAY_AUTH_NOTIFY_URL → pre_transfer_with_authorization(方式一):转账 + 顺带申请授权, + 返回 WAIT_USER_CONFIRM + package_info,用户在确认这笔收款时一并授权,下次起免确认。 + - 未配回调地址(未启用免确认)→ 退化为原 create_transfer 确认模式,行为同改造前。 + 免确认转账失败 → 先 _settle_after_ambiguous 保金额安全,再 _refresh_active_auth 回查授权,失效则标 closed(下次回退方式一)。 + 用户审核期间解绑微信 → 退款 failed。结果不明(超时/非200)→ 先查单再决定,绝不盲目退款。 """ user = db.get(User, order.user_id) openid = user.wechat_openid if user else None @@ -420,33 +593,57 @@ def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrde db.refresh(order) return order + # 先同步待确认授权:捕获首单确认后微信已生效的 authorization_id(pending→active),或失效(→closed) + auth = sync_transfer_auth(db, order.user_id) + order.status = "pending" # 进入打款在途(转账调用前崩溃留 pending,交对账兜底) db.commit() + # ① 有生效授权 → 免确认转账(无需用户确认,直接到账) + if auth is not None and auth.state == "active" and auth.authorization_id: + try: + result = wxpay.transfer_with_authorization( + auth.authorization_id, order.amount_cents, order.out_bill_no, user_name=order.user_name + ) + except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认 + _settle_after_ambiguous(db, order, reason=f"免确认转账调用异常: {e}") + db.refresh(order) + return order + if result["status_code"] != 200: + # 金额安全:查转账单后定夺,绝不盲退(未创建→退款,已创建→按真实状态) + _settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"])) + # 授权有效性:回查授权单,微信侧已失效(用户关闭/风控)→标 closed,下次提现自动回退方式一重新授权 + _refresh_active_auth(db, order.user_id) + db.refresh(order) + return order + return _apply_transfer_result(db, order, result["data"]) + + # ② 无生效授权 → 启用免确认则转账+顺带授权(方式一),否则退化为原确认模式 try: - result = wxpay.create_transfer( - openid, order.amount_cents, order.out_bill_no, user_name=order.user_name - ) + if settings.WXPAY_AUTH_NOTIFY_URL: + out_auth_no = _ensure_pending_auth_no(db, order.user_id, openid) + result = wxpay.pre_transfer_with_authorization( + openid, order.amount_cents, order.out_bill_no, + out_authorization_no=out_auth_no, + user_display_name=_auth_display_name(user), + notify_url=settings.WXPAY_AUTH_NOTIFY_URL, + user_name=order.user_name, + ) + else: + result = wxpay.create_transfer( + openid, order.amount_cents, order.out_bill_no, user_name=order.user_name + ) except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认 _settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}") db.refresh(order) return order if result["status_code"] != 200: - msg = str(result["data"].get("message") or result["data"]) - _settle_after_ambiguous(db, order, reason=msg) + _settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"])) db.refresh(order) return order - data = result["data"] - order.wechat_state = data.get("state") - order.transfer_bill_no = data.get("transfer_bill_no") - order.package_info = data.get("package_info") - if data.get("state") == _WX_STATE_SUCCESS: - order.status = "success" - db.commit() - db.refresh(order) - return order + return _apply_transfer_result(db, order, result["data"]) def _get_withdraw_or_raise(db: Session, out_bill_no: str) -> WithdrawOrder: diff --git a/app/schemas/ad.py b/app/schemas/ad.py index a6cdb65..4a4ddd9 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -30,17 +30,17 @@ class AdRewardStatusOut(BaseModel): remaining: int = Field(..., description="今日剩余可领次数") coin_per_ad: int = Field(..., description="看完一个激励视频发的金币") round_count: int = Field( - ..., description="本轮(3 次一组)已看次数,0..N-1。客户端可由它判断'刚刚看完一轮'" + ..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却" ) cooldown_until: datetime | None = Field( None, - description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。" + description="广告关闭后 3 秒短冷却结束时间(UTC ISO),null 表示不在冷却。" "冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点", ) - # ===== 看广告每日总时长(50 分钟防刷主闸):客户端据此展示"今日已看 X/50 分钟"、满则不给看 ===== + # ===== 旧版每日观看总时长字段:当前产品仅保留每日 500 次上限,limit=0 表示未启用时长闸 ===== watched_seconds_today: int = Field(0, description="今日已观看激励视频总秒数(前端累计上报)") - watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒,默认 3000=50 分钟)") - watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数(<=0 时客户端不应再展示广告)") + watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒);0 表示当前未启用") + watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截") class EcpmReportIn(BaseModel): @@ -92,3 +92,24 @@ class TestGrantOut(BaseModel): cooldown_until: datetime | None = Field( None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut" ) + + +class FeedRewardIn(BaseModel): + """信息流广告完成后结算奖励。 + + 每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。 + """ + + client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") + ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理") + duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") + adn: str | None = Field(None, description="实际投放 ADN") + slot_id: str | None = Field(None, description="实际展示代码位") + + +class FeedRewardOut(BaseModel): + granted: bool = Field(..., description="本次是否入账。达上限时 false") + status: str = Field(..., description="granted / capped") + coin: int = Field(..., description="本次发放金币") + unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") + daily_limit: int = Field(..., description="每日信息流展示次数上限") diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 0e0ace7..7543d4a 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -118,6 +118,13 @@ class ComparisonRecordCreatedOut(BaseModel): id: int = Field(..., description="写入(或已存在)的记录 id") +class CompareStatsOut(BaseModel): + """「我的」页省钱战绩卡(比价口径)聚合。""" + + compare_count: int = Field(..., description="累计完成比价次数(status='success' 记录数)") + discovered_saved_cents: int = Field(..., description="累计发现可省额(分;各成功比价 源价−最低价 之和)") + + # ===== 比价战绩里程碑(福利页「记录比价战绩」)===== class MilestoneStateOut(BaseModel): diff --git a/app/schemas/platform.py b/app/schemas/platform.py new file mode 100644 index 0000000..c49cd08 --- /dev/null +++ b/app/schemas/platform.py @@ -0,0 +1,24 @@ +"""首页平台级展示数据 schemas(客户端首页门面数字)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class PlatformStatsOut(BaseModel): + """首页三统计。total_saved 为分,客户端 ÷100 显示「元」。""" + + help_users: int # 帮助用户(人) + total_compares: int # 完成比价(次) + total_saved_cents: int # 累计节省(分) + + +class SavingsFeedItem(BaseModel): + """首页轮播一条。time 为北京时间 HH:MM:SS。""" + + masked_user: str # 脱敏用户名,如「用户********a52」 + saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」 + time: str + + +class SavingsFeedOut(BaseModel): + items: list[SavingsFeedItem] diff --git a/app/schemas/price_observation.py b/app/schemas/price_observation.py new file mode 100644 index 0000000..f595dd0 --- /dev/null +++ b/app/schemas/price_observation.py @@ -0,0 +1,49 @@ +"""价格观测内部上报的收发模型。 + +pricebot 在比价 done 帧 server→server POST 一批观测(一次比价 = 源 + N 目标平台各一条)。 +批级字段(门店 / 菜篮 / 地理 / 来源)所有观测共享,放外层;逐平台字段放 observations[]。 +""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class PriceObservationItem(BaseModel): + """单个平台的价格观测(一次比价里的一行)。""" + + platform: str + is_source: bool = False + scope: str = "order" # order / dish + price_cents: int | None = None # 到手价(分);None=该平台失败/打烊 + coupon_saved_cents: int | None = None + coupon_name: str | None = None + store_closed: str | None = None # 打烊原因(非空时 price_cents=None) + rank: int | None = None # 全局名次(1=最便宜) + platform_store_id: str | None = None + attrs: dict | None = None # 该平台的灵活明细兜底 + + +class PriceObservationBatchIn(BaseModel): + """一次比价的整批价格观测上报体。""" + + trace_id: str + business_type: str = "food" + # 门店 / 菜篮 / 地理 —— 一次比价同一份,批级共享 + store_name: str | None = None + city: str | None = None + geohash: str | None = None + lng: float | None = None + lat: float | None = None + dishes: list | None = None # [{name, qty, subtotal?}] + # 来源 + source_device_id: str | None = None + source_user_id: int | None = None + # 逐平台观测(源 + 各目标) + observations: list[PriceObservationItem] = Field(default_factory=list) + + +class PriceObservationBatchOut(BaseModel): + """上报结果:本批新写入条数 / 因幂等跳过条数。""" + + inserted: int + skipped: int diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index 102f27e..8977faf 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -80,6 +80,23 @@ class WithdrawInfoOut(BaseModel): wechat_bound: bool = Field(..., description="当前用户是否已绑定微信") wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)") wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)") + transfer_auth_enabled: bool = Field( + False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)" + ) + + +# ===== 免确认收款授权(用户授权免确认模式)===== + +class TransferAuthResultOut(BaseModel): + already_active: bool = Field(False, description="是否已是开启状态(无需再授权)") + package_info: str | None = Field(None, description="拉起微信授权页的 package(已开启时为空)") + mch_id: str | None = None + app_id: str | None = None + + +class TransferAuthStatusOut(BaseModel): + state: str = Field(..., description="none(未开启)/pending(待确认)/active(已开启)/closed(已关闭)") + enabled: bool = Field(..., description="是否已开启免确认到账") class BindWechatRequest(BaseModel): @@ -102,6 +119,13 @@ class WithdrawRequest(BaseModel): out_bill_no: str | None = Field( None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成" ) + skip_review: bool = Field( + False, + description=( + "调试直发:跳过人工审核立即打款。仅非生产(APP_ENV!=prod)生效," + "客户端仅 debug 包下发(开发设置开关)。生产恒走人工审核。" + ), + ) class WithdrawResultOut(BaseModel): @@ -148,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") @@ -169,6 +193,16 @@ class SigninResultOut(BaseModel): coin_balance: int = Field(..., description="签到后金币余额") +class SigninBoostRequest(BaseModel): + ad_ref_id: str | None = Field(None, description="广告会话/交易号。当前开发期可空,后续接 S2S 时回填") + + +class SigninBoostResultOut(BaseModel): + coin_awarded: int = Field(..., description="本次膨胀补发金币") + coin_balance: int = Field(..., description="膨胀补发后金币余额") + signin_date: str = Field(..., description="被膨胀的签到日期 YYYY-MM-DD") + + # ===== 任务 ===== class TaskOut(BaseModel): diff --git a/docs/api/README.md b/docs/api/README.md index 8a97204..6556d39 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,7 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新:2026-06-04(+ 运营后台 Admin 子应用 A1–A18,见下方「运营后台 Admin」组) +> 最后更新:2026-06-07(金币数值体系一期:签到膨胀 + 信息流广告结算) > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -30,6 +30,7 @@ | 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) | | 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) | | 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) | +| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) | | **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) ||| | 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) | | 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) | @@ -48,6 +49,7 @@ | **签到**(前缀 `/api/v1/signin`) ||| | 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) | | 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) | +| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin-boost.md) | | **任务**(前缀 `/api/v1/tasks`) ||| | 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) | | 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) | @@ -60,12 +62,16 @@ | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | +| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | | 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) | | **帮助与反馈**(前缀 `/api/v1/feedback`) ||| | 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) | +| **首页门面数据**(前缀 `/api/v1/platform`;全平台展示数字,登录前可读) ||| +| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) | +| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) | | **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) ||| | - | `GET /media/avatars/` | 无 | 用户头像;返回二进制图片 | | - | `GET /media/feedback/` | 无 | 反馈截图;返回二进制图片 | @@ -88,11 +94,19 @@ | A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | | A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | | A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | +| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | +| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | +| A21 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | +| A22 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | +| A23 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | +| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 > `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。 -> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。 +> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。 > 金额字段一律以**分**为单位(`*_cents`)。 --- diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md new file mode 100644 index 0000000..477390d --- /dev/null +++ b/docs/api/ad-feed-reward.md @@ -0,0 +1,40 @@ +# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 + +点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 + +## 鉴权 + +需要 Bearer token。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | +| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 | +| `duration_seconds` | int | 是 | 实际展示/播放秒数 | +| `adn` | string\|null | 否 | 实际投放 ADN | +| `slot_id` | string\|null | 否 | 实际展示代码位 | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `granted` | bool | 本次是否入账;达上限时为 `false` | +| `status` | string | `granted` / `capped` | +| `coin` | int | 本次发放金币 | +| `unit_count` | int | 按 10 秒折算出的奖励份数 | +| `daily_limit` | int | 每日信息流展示次数上限,默认 500 | + +## 计算口径 + +- 奖励份数:`duration_seconds // 10`。 +- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 +- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。 +- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 + +## 数据写入 + +- `ad_feed_reward_record` 新增一行。 +- 入账时 `coin_account` 增加余额。 +- 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`。 diff --git a/docs/api/ad-reward-status.md b/docs/api/ad-reward-status.md index 5486edc..57968af 100644 --- a/docs/api/ad-reward-status.md +++ b/docs/api/ad-reward-status.md @@ -14,15 +14,18 @@ | `daily_limit` | int | 每日发奖次数上限 | | `remaining` | int | 今日剩余可领次数 | | `coin_per_ad` | int | 看完一个激励视频发的金币 | -| `round_count` | int | 本轮(3 次一组)已看次数,0..N-1。客户端可据「拿到 round_count==0 && cooldown_until!=null」判定刚刚看完一轮 | -| `cooldown_until` | datetime\|null | 本轮 10 分钟冷却结束时间(UTC ISO);null 表示不在冷却 | +| `round_count` | int | 本轮已看次数;当前 `round_size=1`,广告关闭后进入短冷却 | +| `cooldown_until` | datetime\|null | 3 秒短冷却结束时间(UTC ISO);null 表示不在冷却 | +| `watched_seconds_today` | int | 今日已上报的激励视频观看秒数;当前仅兼容/排查用 | +| `watch_seconds_limit` | int | 每日观看总时长上限;当前默认 0 表示未启用时长闸 | +| `watch_seconds_remaining` | int | 今日剩余可观看秒数;`watch_seconds_limit=0` 时客户端不据此拦截 | ## 说明 福利页「看视频赚金币」用: - 展示「今日还能看 N 次」「看一次得 M 金币」(`remaining`/`coin_per_ad`) - 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取") -- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`round_count==0 && cooldown_until!=null` 显示「本轮视频已看完,10分钟后再来」 +- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`cooldown_until!=null` 显示「广告冷却中,3秒后再来」 -冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(每 N=3 条算一轮)末尾那次的 `created_at`,加 10 分钟。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。 +冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(当前每 N=1 条算一轮)末尾那次的 `created_at`,加 3 秒。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。 真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。 diff --git a/docs/api/admin-dashboard-display.md b/docs/api/admin-dashboard-display.md new file mode 100644 index 0000000..70463b2 --- /dev/null +++ b/docs/api/admin-dashboard-display.md @@ -0,0 +1,73 @@ +# Admin 首页数据配置 — 三统计展示模式 + +> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md) + +配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。 + +> **单位约定**:倍率用**千分比整数**(1.000→`1000`、1.100→`1100`)。`total_saved` 的 `manual_value` / `random_current` / `random_initial` / `random_step_min` / `random_step_max` / `real_offset` 单位都是**分**(前端展示时 ÷100 取元);两个计数指标是个数。 + +--- + +## GET /admin/api/dashboard-display — 三指标配置 + 真实值预览 + +### 出参 +响应 `200`:`list[OpsStatItemOut]`(按 help_users / total_compares / total_saved 顺序) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `metric` | string | `help_users` / `total_compares` / `total_saved` | +| `label` | string | 帮助用户 / 完成比价 / 累计节省 | +| `unit` | string | 人 / 次 / 分 | +| `mode` | string | `real` / `manual` / `random` | +| `manual_value` | int \| null | manual 固定值(基础单位) | +| `random_mult_min` | int | 倍率下限(千分比,≥1000) | +| `random_mult_max` | int | 倍率上限(千分比) | +| `random_tick_seconds` | int | 更新间隔(秒,默认 86400=1 天) | +| `random_anchor_minutes` | int | 触发时刻对齐偏移(距北京 0 点分钟数:天=每日时刻、小时=每小时第几分、分钟=0) | +| `random_kind` | string | 自增长方式:`mult` ×倍率 / `add` +绝对增量 | +| `random_step_min` | int | add 模式增量下限(基础单位) | +| `random_step_max` | int | add 模式增量上限(基础单位) | +| `real_offset` | int | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 | +| `allow_decrease` | bool | 是否允许展示值下降(默认 false = 只增不减) | +| `random_current` | int \| null | 当前展示值(所有模式,基础单位) | +| `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO) | +| `real_value` | int | 当前真实值(纯真实、不含偏移;给运营对比参考,基础单位) | +| `updated_at` | string \| null | 上次修改时间 | + +--- + +## PATCH /admin/api/dashboard-display/{metric} — 改某指标(带审计) + +`metric` 路径参数取 `help_users` / `total_compares` / `total_saved`。 + +### 入参 `OpsStatUpdateRequest`(字段均可选,只改传了的) +| 字段 | 类型 | 说明 | +|---|---|---| +| `mode` | string | `real` / `manual` / `random` | +| `manual_value` | int | manual 固定值(基础单位,≥0) | +| `random_mult_min` | int | 倍率下限(千分比,1000~1500) | +| `random_mult_max` | int | 倍率上限(千分比,≥下限,≤1500) | +| `random_tick_seconds` | int | 更新间隔(秒,≥60) | +| `random_anchor_minutes` | int | 触发时刻对齐偏移(分钟,0~1439) | +| `random_kind` | string | 自增长方式:`mult` / `add` | +| `random_step_min` | int | add 增量下限(基础单位,≥0) | +| `random_step_max` | int | add 增量上限(基础单位,≥下限) | +| `real_offset` | int | real 基数偏移(基础单位,≥0) | +| `allow_decrease` | bool | 允许展示值下降(默认 false=只增不减) | +| `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** | +| `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) | + +### 出参 +响应 `200`:更新后的 `OpsStatItemOut`(同上)。 + +### 错误 +- `400`:mode/kind 非法 / 倍率越界(<1000 或 >1500)/ 上限<下限 / 增量负值或上限<下限 / 偏移负值 / tick<60 / 更新时间越界 / manual 负值。 +- `403`:角色不足(需 operator 或 super_admin)。 +- `404`:无效 metric(不在三指标内,作为 400 文案返回)。 + +### 说明 +- **统一定时刷新**:三模式的展示值(`random_current`)只在「更新时间(按更新间隔)」对齐的北京钟点刷新一次——real 快照查库(+偏移)/ manual 取固定值 / random 按 `random_kind` 走一档(`mult` ×倍率 / `add` +增量)。`random_tick_seconds`+`random_anchor_minutes` 三模式通用。 +- **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时新目标值低于当前展示值则保持不降;要下调须显式传 `allow_decrease=true`。 +- 改 mode/manual/倍率/增量/偏移等到**下个更新钟点**才在客户端生效(展示值不立即变);`random_initial` 立即设起点并重置;`apply_now=true` 立即刷新一次。首次无展示值时按当前模式播种。 +- 每次改动写 `admin_audit_log`(action=`ops_stat_config.set`,detail 含改前/改后快照)。 +- 客户端在**进首页 / 回前台时**重拉 `/stats` 取最新展示值。 diff --git a/docs/api/admin-marquee-seeds.md b/docs/api/admin-marquee-seeds.md new file mode 100644 index 0000000..a0b9566 --- /dev/null +++ b/docs/api/admin-marquee-seeds.md @@ -0,0 +1,45 @@ +# Admin 首页轮播种子管理 + +> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md) + +管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。 + +## 复用结构 OpsMarqueeSeedOut +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | | +| `masked_user` | string \| null | 脱敏用户名;`null` = feed 随机合成 | +| `min_cents` | int | 金额区间下限(分) | +| `max_cents` | int | 金额区间上限(分);固定金额则与下限相等 | +| `enabled` | bool | 停用不参与混播 | +| `sort_order` | int | 仅后台列表排序(小在前) | +| `created_at` | string \| null | 创建时间(ISO) | + +## GET /admin/api/marquee-seeds — 种子列表 +出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。 + +## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed +预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。 +- 入参:`limit`(query,1~30,默认 8) +- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md)) + +## POST /admin/api/marquee-seeds — 新增(带审计) +入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut`。`400`=金额非法。 + +## POST /admin/api/marquee-seeds/bulk — 批量生成(带审计) +冷启动铺量:一次生成 `count` 条「自动合成名」(masked_user=null)种子,金额均为给定区间,`sort_order` 续在当前最大值之后。 +- 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents`、`max_cents`、`enabled`(默认 true) +- 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。 + +## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计) +入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`。 +- `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。 +- 只改单边 `min_cents` / `max_cents` 时,与现有的另一边一起做「上限≥下限」校验。 +出参:更新后的 `OpsMarqueeSeedOut`。`404`=种子不存在;`400`=金额非法。 + +## DELETE /admin/api/marquee-seeds/{seed_id} — 删(带审计) +出参 `200`:`{"deleted": true}`。`404`=种子不存在。 + +## 说明 +- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。 +- 改动客户端**进首页 / 回前台**重拉 feed 时生效。 diff --git a/docs/api/compare-stats.md b/docs/api/compare-stats.md new file mode 100644 index 0000000..454f5a7 --- /dev/null +++ b/docs/api/compare-stats.md @@ -0,0 +1,25 @@ +# GET /api/v1/compare/stats — 比价口径战绩(「我的」页省钱战绩卡) + +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) + +## 入参 +无(用户由 token 确定)。 + +## 出参 +响应 `200`:`CompareStatsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `compare_count` | int | 累计完成比价次数(`comparison_record` 中 `status='success'` 的记录数) | +| `discovered_saved_cents` | int | 累计发现可省额(分;各成功比价 `saved_amount_cents`=源价−最低价 之和,null 不计) | + +## 说明 +「我的」页**省钱战绩卡**的数据源(**比价口径**): + +- 卡主额「累计发现可省 ¥X」← `discovered_saved_cents` +- 卡三列之「完成比价(次)」← `compare_count` +- 卡「未比价锁定态」判据:`compare_count == 0`(没比过价 → 盖锁定蒙层,引导「立即开始」去首页比价) + +> ⚠️ **与下单口径解耦**:本接口只看「比过价」(`comparison_record`),**不要求下单**。区别于 `savings_record`(下单成交记账,见 [savings-summary](./savings-summary.md) / [savings-records](./savings-records.md))——那是订单记录/省钱明细的源。两表互不喂数据。 + +> 卡另两项数据源:「赚取金币」← `GET /wallet/account.total_coin_earned`;「领取优惠券」后端暂无累计字段(前端先占位)。 diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md new file mode 100644 index 0000000..156fa1e --- /dev/null +++ b/docs/api/platform-savings-feed.md @@ -0,0 +1,27 @@ +# GET /api/v1/platform/savings-feed — 首页轮播 feed(真实+种子混播) + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无** | [← 返回 API 索引](./README.md) + +客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 + +## 入参 +| 参数 | 类型 | 说明 | +|---|---|---| +| `limit` | int, query | 返回条数,默认 8,范围 1~30 | + +## 出参 +响应 `200`:`SavingsFeedOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `items` | list | 轮播条目数组(最多 `limit` 条) | +| `items[].masked_user` | string | 脱敏用户名,如 `用户********a52` | +| `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 | +| `items[].time` | string | 北京时间 `HH:MM:SS` | + +## 说明 +- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` 哈希脱敏(`用户********`+3 位)。 +- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空则按脱敏格式**随机合成并避开同屏撞名**,金额在 `[min_cents, max_cents]` 区间**随机取值**(固定金额则 min==max)。 +- **`time` 为合成的「最近」时间**(从当前北京时间往前递减,首条约 20 秒前,其余每条 2~6 分钟):社会证明轮播保证永远像刚发生,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 +- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 +- 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 diff --git a/docs/api/platform-stats.md b/docs/api/platform-stats.md new file mode 100644 index 0000000..25ced25 --- /dev/null +++ b/docs/api/platform-stats.md @@ -0,0 +1,28 @@ +# GET /api/v1/platform/stats — 首页三统计(全平台门面数字) + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无**(登录前首页也要展示) | [← 返回 API 索引](./README.md) + +客户端首页顶部「帮助用户 / 完成比价 / 累计节省」三个平台级数字。每个指标的展示模式由运营后台配(见 [admin-dashboard-display](./admin-dashboard-display.md)),本接口只返回算好的结果值。 + +## 入参 +无。 + +## 出参 +响应 `200`:`PlatformStatsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `help_users` | int | 帮助用户(人) | +| `total_compares` | int | 完成比价(次) | +| `total_saved_cents` | int | 累计节省(**分**),客户端 ÷100 显示「元」 | + +## 说明 +三指标各自独立选模式,且**统一「定时刷新」**:客户端看到的展示值只在「更新时间(按更新间隔)」对齐的北京钟点(`anchor + k*间隔`)刷新一次,平时不变。每次刷新按模式算新值: +- **real**:重新快照查库 + 基数偏移。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和(**只计 `0 ⚠️ **2026-06 起停用 demo 兜底**:只计真实比价下单上报(`source='compare'`)。下单上报链路已通(Android `PriceBotService` → `POST /api/v1/order/report`)。 + +> ⚠️ **「我的」页省钱战绩卡已不读本接口**:#23 起卡改为**比价口径**,「完成比价 / 累计发现可省」走 [`GET /api/v1/compare/stats`](./compare-stats.md)(`comparison_record`,比过价就计、不要求下单),「赚取金币」走 `/wallet/account.total_coin_earned`。本 `/battle`(及 `compare_count`/`week_saved_cents`/`beat_percent`/`streak_days`)现仅供订单口径/历史用途,不上战绩卡。 diff --git a/docs/api/signin-boost.md b/docs/api/signin-boost.md new file mode 100644 index 0000000..3085338 --- /dev/null +++ b/docs/api/signin-boost.md @@ -0,0 +1,32 @@ +# POST /api/v1/signin/boost — 签到后看广告膨胀金币 + +用户当天已签到后,看完一条激励视频,补发一笔等额签到金币。 + +## 鉴权 + +需要 Bearer token。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `ad_ref_id` | string\|null | 否 | 广告会话/交易号。当前开发期可空,后续接 S2S 后可回填 | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `coin_awarded` | int | 本次膨胀补发金币 | +| `coin_balance` | int | 补发后的金币余额 | +| `signin_date` | string | 被膨胀的签到日期,格式 `YYYY-MM-DD` | + +## 错误 + +- `401`: 未登录 +- `409`: 当天未签到,或当天已经膨胀过 + +## 数据写入 + +- `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复。 +- `coin_account` 增加余额。 +- `coin_transaction` 写入 `biz_type=signin_boost`。 diff --git a/docs/api/signin-do.md b/docs/api/signin-do.md index 513ba60..3bb6504 100644 --- a/docs/api/signin-do.md +++ b/docs/api/signin-do.md @@ -11,7 +11,7 @@ | 字段 | 类型 | 说明 | |---|---|---| | `coin_awarded` | int | 本次签到发放金币 | -| `cycle_day` | int | 本次签到落在循环第几天 | +| `cycle_day` | int | 本次签到落在 14 天循环第几天 | | `streak` | int | 签到后的连续天数 | | `coin_balance` | int | 签到后金币余额 | @@ -20,3 +20,4 @@ ## 说明 发金币已计入 [wallet-account](./wallet-account.md) 的余额(客户端就地刷新即可,不必另叠)。 +断签后从第 1 天重新开始;第 15 天回到第 1 天。 diff --git a/docs/api/signin-status.md b/docs/api/signin-status.md index 8e09a54..541d6c8 100644 --- a/docs/api/signin-status.md +++ b/docs/api/signin-status.md @@ -1,4 +1,4 @@ -# GET /api/v1/signin/status — 今日签到状态 + 7 天档位 +# GET /api/v1/signin/status — 今日签到状态 + 14 天档位 > 所属:Signin 组(前缀 `/api/v1/signin`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) @@ -12,18 +12,18 @@ |---|---|---| | `today_signed` | bool | 今日是否已签 | | `consecutive_days` | int | 当前连续签到天数 | -| `today_cycle_day` | int | 今日处于循环内第几天(1..7) | +| `today_cycle_day` | int | 今日处于循环内第几天(1..14) | | `today_coin` | int | 今日签到可得金币 | | `can_claim` | bool | 今日是否可领(= 未签) | -| `steps` | SigninStepOut[] | 7 天档位 | +| `steps` | SigninStepOut[] | 14 天档位 | **SigninStepOut** | 字段 | 类型 | 说明 | |---|---|---| -| `day` | int | 循环内第几天 1..7 | +| `day` | int | 循环内第几天 1..14 | | `coin` | int | 该档金币 | | `status` | string | `claimed`(已领) / `today`(今日待领) / `locked`(未到) | ## 说明 -福利页签到行 + 签到弹窗 7 档 timeline 数据源。 +福利页签到行 + 签到弹窗 14 档 timeline 数据源。断签后从第 1 天重新开始,第 15 天回到第 1 天。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md new file mode 100644 index 0000000..65605ac --- /dev/null +++ b/docs/database/OVERVIEW.md @@ -0,0 +1,153 @@ +# 数据库总览 — 表 × 功能 × 关系 + +> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 +> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。 + +--- + +## 一、按功能域看表(每张表用在 App 哪里) + +### 比价(核心业务) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 | +| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 | +| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 | +| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 | +| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 | + +### 钱包 / 福利(看广告赚钱闭环) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 | +| 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 | +| 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) | +| 每日签到 | [`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_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 唯一,一微信一账号 | + +### 账号 / 反馈 +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 | +| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | + +### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) +| 后台模块 | 表 | 说明 | +|---|---|---| +| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC | +| 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 | +| 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 | +| 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 | + +--- + +## 二、写入路径(什么操作 → 写哪张表) + +> C=插入新行 · U=更新已有行 · 同一行内多张表表示**同一事务原子写**。 + +### C 端(App 用户触发) +| 触发(用户动作 / endpoint / 回调) | 写入 | 操作 | +|---|---|---| +| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`) | +| 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U | +| 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_`、`status=deleted`) | +| 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U | +| 签到 `POST /signin/do` | `signin_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin`) | 同事务 | +| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 | +| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_`) | 同事务 | +| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` −) + `cash_transaction`(C `exchange_in` +) | 同事务 | +| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` −) | 同事务,**不打款** | +| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | | +| 穿山甲发奖 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)` 幂等 | +| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | +| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | +| 提交反馈 `POST /feedback` | `feedback`(C) | | + +### admin 端(管理员触发,均额外写一条 `admin_audit_log`) +| 后台操作 | 写入 | 操作 | +|---|---|---| +| 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 | +| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 | +| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | | +| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | | +| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 | +| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 | +| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | | + +> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。 + +--- + +## 三、表间关系 & Join Key + +### 硬外键(数据库 FK 约束) +- **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`(可空:关联记录被删后仍留上报历史)。 + +### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看) +- **`coin_transaction.ref_id` 指向随 `biz_type` 变**: + + | biz_type | ref_id 指向 | amount 符号 | + |---|---|---| + | `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | + | `signin_boost` | 当天日期串(= `signin_boost_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | + | `task_` | `user_task.task_key` | + | + | `ad_reward` | `ad_reward_record.trans_id` | + | + | `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + | + | `exchange_out` | null(兑现金,无单据) | − | + | `admin_grant` / `admin_deduct` | null(原因记在 `remark`=`admin:`) | + / − | + +- **`cash_transaction.ref_id`**: + + | biz_type | ref_id 指向 | amount 符号 | + |---|---|---| + | `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | − / + | + | `exchange_in` | null | + | + +- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 +- **广告流互不关联**:`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 关系(文字版) +``` +user ─1:1─ coin_account +user ─1:1─ wechat_transfer_authorization +user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, 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 } +comparison_record ─1:N─ price_report (comparison_record_id, 可空) +admin_user ─1:N─ admin_audit_log +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 / 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` 对账兜底。 +- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。 + +--- + +## 五、通用约定 + +见 [README → 通用约定](./README.md#通用约定):金额存整数(金币计数 / 现金存分)、业务「今天」按北京时间日期串等值聚合(不在 SQL 跨时区比 date)、JSON 列 PG 用 JSONB·SQLite 退化 JSON、改表必写 alembic 迁移并保持单 head + 同步更新本目录文档。 diff --git a/docs/database/README.md b/docs/database/README.md index 8197285..9220687 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,37 +3,65 @@ > 数据库: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-04(+ admin_user / admin_audit_log) +> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md)) + +> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览 +## 表总览(22 张业务表 + `alembic_version` 框架表) -| 表 | 用途 | 模型 | 关联模块 | 文档 | -|---|---|---|---|---| -| `user` | 用户(登录主体) | `models/user.py` | 登录/鉴权 | [详情](./user.md) | -| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | 钱包 | [详情](./coin_account.md) | -| `coin_transaction` | 金币流水账本 | `models/wallet.py` | 钱包 | [详情](./coin_transaction.md) | -| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | 钱包/提现 | [详情](./cash_transaction.md) | -| `withdraw_order` | 提现单(现金→微信零钱) | `models/wallet.py` | 提现 | [详情](./withdraw_order.md) | -| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | 签到 | [详情](./signin_record.md) | -| `user_task` | 一次性任务领取去重 | `models/task.py` | 任务 | [详情](./user_task.md) | -| `savings_record` | 省钱记录(profile 省钱战绩源) | `models/savings.py` | 省钱 | [详情](./savings_record.md) | -| `ad_reward_record` | 看激励视频发奖记录(S2S 回调) | `models/ad_reward.py` | 看广告发奖 | [详情](./ad_reward_record.md) | -| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | 看广告 | [详情](./ad_ecpm_record.md) | -| `feedback` | 用户帮助与反馈 | `models/feedback.py` | 反馈 | [详情](./feedback.md) | -| `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | 比价记录 | [详情](./comparison_record.md) | -| `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | 比价记录/福利 | [详情](./comparison_milestone_claim.md) | -| `admin_user` | 运营后台管理员账号(独立鉴权) | `models/admin.py` | Admin 后台 | [详情](./admin_user.md) | -| `admin_audit_log` | 运营后台操作审计日志(只追加) | `models/admin.py` | Admin 后台 | [详情](./admin_audit_log.md) | +### 账号 / 反馈 +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) | +| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | + +### 钱包 / 福利(看广告赚钱闭环) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | [详情](./coin_account.md) | +| `coin_transaction` | 金币流水账本 | `models/wallet.py` | [详情](./coin_transaction.md) | +| `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` | 签到记录(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` | 看广告观看时长(旧版兼容字段) | `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) | + +### 比价 / 省钱 +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | [详情](./comparison_record.md) | +| `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | [详情](./comparison_milestone_claim.md) | +| `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/`,独立鉴权) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `admin_user` | 管理员账号(独立 JWT + RBAC) | `models/admin.py` | [详情](./admin_user.md) | +| `admin_audit_log` | 操作审计日志(只追加) | `models/admin.py` | [详情](./admin_audit_log.md) | +| `app_config` | 运营可配置项(覆盖 rewards 常量) | `models/app_config.py` | [详情](./app_config.md) | --- ## 通用约定 -- **主键**:`id` Integer autoincrement(`coin_account` 例外:`user_id` 既是主键也是外键,一用户一行)。 -- **外键**:所有用户维度表 `user_id` → `user.id`,且建 index。 -- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。 -- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(见 `core/rewards.cn_today`),跨天计数用「日期串」列(如 `reward_date`/`report_date`)等值查,不在 SQL 里做跨时区 date 比较(SQLite 不可靠)。 -- **JSON 列**:用 `JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错)。 -- **迁移**:改表必写 alembic 迁移并保持单 head;改表/建表同时更新本目录对应文档(一表一文件)。 +- **主键**:`id` Integer autoincrement;例外:`coin_account` / `wechat_transfer_authorization` 的 `user_id` 既是主键也是外键(一用户一行),`app_config` 用 `key` 作主键。 +- **外键**:所有用户维度表 `user_id` → `user.id` 且建 index;`admin_audit_log.admin_id` → `admin_user.id`;`price_report.comparison_record_id` → `comparison_record.id`。 +- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。汇率 `10000 金币 = 1 元 = 100 分`。 +- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(`core/rewards.cn_today`),跨天计数用「日期串」列(`reward_date` / `watch_date` / `report_date`,`String(10)` 存 `YYYY-MM-DD`)等值查,**不在 SQL 里做跨时区 date 比较**(SQLite 不可靠)。 +- **JSON 列**:`JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错);`feedback.images` 例外,用通用 `JSON`。 +- **分页**:列表接口统一**游标式**(`id` 倒序,`cursor`=上页最后一条 id,返回 `next_cursor`)。 +- **迁移**:改表必写 alembic 迁移并保持单 head;改表/建表同时更新本目录对应文档(一表一文件)+ [OVERVIEW.md](./OVERVIEW.md)。 diff --git a/docs/database/ad_ecpm_record.md b/docs/database/ad_ecpm_record.md index 4464e36..b75a0b4 100644 --- a/docs/database/ad_ecpm_record.md +++ b/docs/database/ad_ecpm_record.md @@ -1,26 +1,32 @@ # ad_ecpm_record — 广告展示 eCPM 上报(收益对账) -> 模型 `app/models/ad_ecpm.py` | 关联接口 [ad-ecpm-report](../api/ad-ecpm-report.md) | [← 表索引](./README.md) +> 模型 `app/models/ad_ecpm.py` · 仓库 `app/repositories/ad_ecpm.py` · 接口 [ad-ecpm-report](../api/ad-ecpm-report.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。与发奖记录 `ad_reward_record` 是**两条独立数据流**(发奖走 S2S 有 trans_id 无 ecpm;eCPM 走客户端有 ecpm 无 trans_id),无公共键,只用于**按用户/按天聚合**收益对账,不做逐条精确关联。穿山甲后台报表才是结算权威。 +每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。看广告三条数据流之一(**收益对账**),与 `ad_reward`(发奖,有 trans_id 无 ecpm)、`ad_watch_log`(时长)并列,**无公共键**——只用于**按用户/按天聚合**估算内部广告收益,不做"这条发奖 = 这条展示"的逐条精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。 +- **U / D**:无。 +- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `ad_type` | String(32) | NOT NULL | 广告类型:`reward_video`(激励视频)/ `draw`(Draw 信息流)等 | -| `adn` | String(32) | nullable | 实际投放 ADN(`getSdkName`,如 pangle/gdt) | -| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) | -| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM 原始串(单位待确认,原样存) | +| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 | +| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | +| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | +| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(单位待确认,原样存) | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- 与 `ad_reward_record` / `ad_watch_log` **无公共键**(独立数据流),只按 `(user_id, report_date)` 聚合。 + ## 索引与约束 -- PK: `id`;index: `user_id`、`report_date`、`created_at` +- PK `id`;index `user_id`、`report_date`、`created_at`。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 +## 注意 - ⚠️ `ecpm_raw` 单位(分/元)截至 2026-05-31 未最终确认;确认后再加一列解析好的数值,在此之前对账按"待定单位"处理。 diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md new file mode 100644 index 0000000..ecea866 --- /dev/null +++ b/docs/database/ad_feed_reward_record.md @@ -0,0 +1,28 @@ +# ad_feed_reward_record — 信息流广告奖励记录 + +点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。 + +## 字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `id` | Integer | PK | 自增主键 | +| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id | +| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | +| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | +| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 | +| `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 | +| `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 | +| `adn` | String(32) | nullable | 实际投放 ADN | +| `slot_id` | String(64) | nullable | 实际展示代码位 | +| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 | +| `status` | String(16) | NOT NULL | `granted` / `capped` | +| `created_at` | DateTime | index, NOT NULL | 创建时间 | + +## 约束 + +- `UNIQUE(client_event_id)`:同一完成事件重试不重复发奖。 + +## 关联 + +- 入账时写 `coin_transaction.biz_type=feed_ad_reward`,`ref_id=client_event_id`。 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 14e0fff..16ac5c3 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -1,28 +1,35 @@ # ad_reward_record — 看激励视频发奖记录(S2S 回调) -> 模型 `app/models/ad_reward.py` | 关联接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) | [← 表索引](./README.md) +> 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每条 = 穿山甲一次发奖回调。`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用,按日期串等值查。 +每条 = 穿山甲一次**服务端发奖回调**(用户看完激励视频)。是看广告三条数据流之一(**发奖**;另两条:`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`,默认 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`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键) | -| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回) | -| `coin` | Integer | NOT NULL, default 0 | 实发金币(capped 时为 0) | -| `status` | String(16) | NOT NULL, default `granted` | `granted`(已发)/ `capped`(当日超限未发) | -| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它统计当日次数 | +| `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`(当日次数超限,记录但不发) | +| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- `trans_id` ← 被 `coin_transaction.ref_id` 引用(`granted` 那条发币流水);`capped` 行不发币、无对应流水。 +- 与 `ad_watch_log` / `ad_ecpm_record` **无公共键**;当前不逐条关联 eCPM 和发奖回调。 + ## 索引与约束 -- PK: `id`;UNIQUE + index: `trans_id`;index: `user_id`、`reward_date`、`created_at` +- PK `id`;UNIQUE+index `trans_id`;index `user_id`、`reward_date`、`created_at`。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 -- 三道闸:验签不过 403 → `trans_id` 唯一幂等(并发 catch IntegrityError)→ 当日次数 ≥ `DAILY_AD_REWARD_LIMIT` 记 `capped` 不发币。 -- 发币复用 `grant_coins(biz_type='ad_reward', ref_id=trans_id)`。 +## 注意 +- 当前公网回调尚未接入前,激励视频精确 eCPM 绑定发奖先搁置;实发金币仍以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改)。 +- 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。 diff --git a/docs/database/ad_watch_log.md b/docs/database/ad_watch_log.md new file mode 100644 index 0000000..7f783bb --- /dev/null +++ b/docs/database/ad_watch_log.md @@ -0,0 +1,32 @@ +# 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)` 得当日总观看时长。当前产品只保留每日 500 次上限,`rewards.DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示不启用时长闸;本表保留用于旧客户端兼容和排查。 + +> 防刷设计:前端上报时长不可信,不能作为唯一发奖依据;当前发奖闸口以 `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` 读取;当 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 时不触发 capped。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | +| `watch_seconds` | Integer | NOT NULL, default 0 | 本次实际观看秒数(服务端已夹 `[0,120]`) | +| `watch_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值做"当日总时长"聚合 | +| `created_at` | DateTime(tz) | server_default now(), index | 时间 | + +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- 与 `ad_reward_record` / `ad_ecpm_record` **无公共键**:不做逐条关联,只按 `(user_id, watch_date)` 聚合。 + +## 索引与约束 +- PK `id`;index `user_id`、`watch_date`、`created_at`。 + +## 注意 +- 当前 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示时长闸停用;`MAX_SINGLE_WATCH_SECONDS=120` 仍用于夹单次上报秒数。 +- 按日期串等值查,不在 SQL 跨时区比 date(SQLite 不可靠)。 diff --git a/docs/database/admin_audit_log.md b/docs/database/admin_audit_log.md index 7058a3c..754b659 100644 --- a/docs/database/admin_audit_log.md +++ b/docs/database/admin_audit_log.md @@ -1,30 +1,34 @@ # admin_audit_log — 运营后台操作审计日志 -> 模型 `app/models/admin.py` | 关联接口 [admin-audit-logs](../api/admin-audit-logs.md) | [← 表索引](./README.md) +> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/audit_log.py`(写 `app/admin/audit.py`) · 接口 [admin-audit-logs](../api/admin-audit-logs.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每个**写操作**(改钱/改状态/处理反馈等)落一条,记录"谁在何时、对谁、做了什么、前后值"。仅追加、不可删,用于追溯。`admin_username` / `target_id` 冗余存字符串,即使关联对象被删/改名也能追溯。 +每个 admin **写操作**(改钱/改状态/处理反馈/改配置等)落一条,记"谁在何时、对谁、做了什么、前后值"。**只追加、不可改不可删**,用于追溯。`admin_username` / `target_id` 冗余存字符串,即使关联对象被删/改名也能追溯。 + +## 用在哪 / 增删改查 +- **C(插入)**:任何 admin 写操作经 `audit.write_audit`(`add_audit_log`)落一条,**与业务写操作同事务**(`commit=False` 让 router 一起 commit:改了就有审计、有审计就真改了)。覆盖:手动增减金币、改用户状态、提现审核通过/拒绝/刷新、处理反馈、改配置等。 +- **U / D**:**无,永久不可变**(无更新/删除接口)。 +- **R**:`GET /admin/audit-logs`(审计列表,可按 `action`/`target_type`/`admin_id` 筛,`id` 倒序游标)。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `admin_id` | Integer | FK→admin_user.id, index, NOT NULL | 操作者 | | `admin_username` | String(64) | NOT NULL | 冗余操作者用户名(改名/禁用后仍可追溯) | -| `action` | String(64) | index, NOT NULL | 操作类型,如 `user.coins.grant` / `user.status.set` / `withdraw.refresh` / `feedback.handle` | -| `target_type` | String(32) | NOT NULL | 被操作对象类型,如 `user` / `withdraw` / `feedback` | -| `target_id` | String(64) | nullable | 被操作对象 id(用字符串以兼容 `out_bill_no` 等非整型主键) | -| `detail` | JSON | nullable | 上下文 + 前后值,如 `{"amount":1000,"reason":"...","before":{...},"after":{...}}` | -| `ip` | String(64) | nullable | 操作者客户端 IP(取自 `X-Forwarded-For` 首段,仅记录不鉴权) | +| `action` | String(64) | index, NOT NULL | 操作类型,取值如 `user.coins.grant` / `user.status.set` / `withdraw.refresh` / `withdraw.approve` / `withdraw.reject` / `feedback.handle` / `config.set` | +| `target_type` | String(32) | NOT NULL | 被操作对象类型,取值如 `user` / `withdraw` / `feedback` / `config` | +| `target_id` | String(64) | nullable | 被操作对象 id(**字符串**以兼容 `out_bill_no` 等非整型主键)。指向随 `target_type` 变:`user`→user.id / `withdraw`→withdraw_order.out_bill_no / `feedback`→feedback.id / `config`→app_config.key | +| `detail` | JSON(PG: JSONB) | nullable | 上下文 + 前后值,如 `{"amount":1000,"reason":"...","before":{...},"after":{...}}` | +| `ip` | String(64) | nullable | 操作者 IP(取自 `X-Forwarded-For` 首段,仅记录不鉴权) | | `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | 操作时间 | +## 关系 / Join Key +- `admin_id` → `admin_user.id`(多对一)。 +- `target_id` 是**软关联**(无 FK,字符串),目标表随 `target_type`(见上表 `target_id` 行)。 + ## 索引与约束 -- PK: `id` -- index: `ix_admin_audit_log_admin_id`(`admin_id`)、`ix_admin_audit_log_action`(`action`)、`ix_admin_audit_log_created_at`(`created_at`) +- PK `id`;index `admin_id`、`action`、`created_at`。 -## 关系 -- `admin_id` → [`admin_user`](./admin_user.md).`id`(多对一) - -## 说明 -- **JSON 列**:`detail` 用 `JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB,SQLite 退化为通用 JSON(同 `comparison_record.raw_payload`)。 -- **只追加**:无更新/删除接口,审计不可篡改。 -- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须用 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅作记录、不参与鉴权。 +## 注意 +- `detail` 用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。 +- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅记录、不参与鉴权。 diff --git a/docs/database/admin_user.md b/docs/database/admin_user.md index c893782..7e21f68 100644 --- a/docs/database/admin_user.md +++ b/docs/database/admin_user.md @@ -1,28 +1,33 @@ # admin_user — 运营后台管理员账号 -> 模型 `app/models/admin.py` | 关联接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) | [← 表索引](./README.md) +> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) / [admin-admin-create](../api/admin-admin-create.md) / [admin-admin-update](../api/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -运营后台的管理员账号,与 App 用户(`user` 表)**完全隔离**:走独立 JWT secret、独立鉴权链(见 `app/admin/`)。密码 bcrypt 存哈希,带角色做权限分级。 +运营后台(`app/admin/` 子应用,端口 8771)的管理员账号,与 App 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链。密码 bcrypt 存哈希,带角色做 RBAC 权限分级。 + +## 用在哪 / 增删改查 +- **C(插入)**:① 首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册);② `super_admin` 在后台「管理员管理」`POST` 新建子管理员。 +- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码(`admin-admin-update`)。 +- **D**:无(禁用走 `status='disabled'`,token 立即失效)。 +- **R**:每个 admin 请求经 `admin/deps` 解 admin token 查本表(校验 `status=='active'` + 角色守卫);管理员列表。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `id` | Integer | PK, autoincrement | | -| `username` | String(64) | unique, index, NOT NULL | 登录名 | -| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库) | -| `role` | String(20) | NOT NULL, default `operator` | `super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) | -| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled`(禁用后 token 立即失效) | +| `id` | Integer | PK, autoincrement | 被 `admin_audit_log.admin_id` 引用 | +| `username` | String(64) | UNIQUE, index, NOT NULL | 登录名 | +| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库;⚠️ bcrypt 72 字节截断) | +| `role` | String(20) | NOT NULL, default `operator` | 取值:`super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) | +| `status` | String(20) | NOT NULL, default `active` | 取值:`active` / `disabled`(禁用后 token 立即失效) | | `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | | `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) | +## 关系 / Join Key +- ← 被 `admin_audit_log.admin_id` 引用(一管理员多条审计)。 +- 与 C 端 `user` **无任何关联**(两套独立体系)。 + ## 索引与约束 -- PK: `id` -- unique index: `ix_admin_user_username`(`username` 唯一) +- PK `id`;UNIQUE+index `username`(`ix_admin_user_username`)。 -## 关系 -- 被 [`admin_audit_log`](./admin_audit_log.md).`admin_id` 引用(一管理员多条审计)。 - -## 说明 -- **角色权限**:`super_admin` 恒通过所有角色守卫(`require_role`);`finance` 管钱(提现/金币),`operator` 管用户/反馈/大盘。具体守卫见各接口文档。 -- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用。admin 无 refresh,过期(默认 12h)重新登录。 -- **初始化**:首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册接口)。 +## 注意 +- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用;admin 无 refresh,过期(默认 12h)重登。 +- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘。具体守卫见各接口文档。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md new file mode 100644 index 0000000..947831c --- /dev/null +++ b/docs/database/app_config.md @@ -0,0 +1,31 @@ +# app_config — 运营可配置项(覆盖 rewards 常量) + +> 模型 `app/models/app_config.py` · 仓库 `app/repositories/app_config.py` · 注册表 `app/core/config_schema.py` · 接口 admin [admin-stats-overview](../api/admin-stats-overview.md) 同组的配置页(`app/admin/routers/config.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +把原本硬编码在 `core/rewards.py` 的**产品规则常量**(签到档位、任务奖励、提现额度、看广告金币/上限/冷却等)挪到 DB,运营后台可改、跨进程即时生效。`key` 主键,`value` 用 JSON 存任意值(int / list / dict)。 + +> **核心约定**:表里**没有**的 key,业务读配置时 fallback 到 `config_schema.CONFIG_DEFS` 的默认值(= 原 rewards 常量)。所以**空表 = 现有行为完全不变**;admin 改某项 = 写一行 → 业务下次 `get_value` 读到新值。 + +## 用在哪 / 增删改查 +- **C / U(upsert)**:admin 配置页改某项 `set_value(key, value, admin_id)`:无该 key 行→插,有→更新 `value`+`updated_by_admin_id`(同事务写 `admin_audit_log`,`action='config.set'`)。未知 key(不在 CONFIG_DEFS)抛 KeyError。 +- **D**:无(要恢复默认 = 删行即可回退到 CONFIG_DEFS,但当前无删除接口;改回默认值亦可)。 +- **R**:**业务侧**——`rewards.get_*(db)` 一族(`get_signin_rewards` / `get_withdraw_min_cents` / `get_ad_reward_coin` …)经 `get_value` 读,签到/任务/提现/看广告发奖处用;**admin 侧**——`list_all` 列出所有可配项(default + 当前值 + 元信息)给配置页渲染。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / 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` 如签到 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() | 最后修改时间 | + +## 关系 / Join Key +- 无外键。`updated_by_admin_id` 软指向 `admin_user.id`(仅记录谁改的)。 +- 逻辑上"覆盖"`core/rewards.py` 的常量:每个 key 的默认值/类型/分组/说明在 `config_schema.CONFIG_DEFS` 定义(配置项的 single source of truth)。 + +## 索引与约束 +- PK `key`(无自增 id)。 + +## 注意 +- 不缓存:配置读频率低(每次福利操作读一次,主键查极快),admin 改了立即生效、跨进程一致(多 worker 也对)。 +- 新增可配项 = 在 `CONFIG_DEFS` 加一条 + 业务处改用 `app_config.get_value(db, key)` 读;不需要建迁移(行是动态插的,表结构不变)。 diff --git a/docs/database/cash_transaction.md b/docs/database/cash_transaction.md index 87e096d..d379fee 100644 --- a/docs/database/cash_transaction.md +++ b/docs/database/cash_transaction.md @@ -1,26 +1,39 @@ # cash_transaction — 现金流水账本(分) -> 模型 `app/models/wallet.py` | 关联接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) | [← 表索引](./README.md) +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -现金每次变动一笔流水(单位:分)。金币兑现金、提现、提现退款都记这里。 +现金每变动一笔就记一行(单位:**分**,记变动后余额)。金币兑现金、提现、提现退款都落这里。**只增不改不删**。 + +## 用在哪 / 增删改查 +- **C(插入)**:三个来源,每次写一笔: + + | 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 | + |---|---|---|---| + | 金币兑现金 `POST /wallet/exchange` | `exchange_in` | + | null(配套 `coin_transaction.exchange_out`) | + | 发起提现 `POST /wallet/withdraw` | `withdraw` | −(扣现金) | `withdraw_order.out_bill_no` | + | 提现失败/取消/审核拒绝退款 | `withdraw_refund` | +(退回) | `withdraw_order.out_bill_no` | + +- **U / D**:无。账本只追加。 +- **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `amount_cents` | Integer | NOT NULL | 正=入账(兑入),负=出账(提现) | -| `balance_after_cents` | Integer | NOT NULL | 本笔变动后现金余额(分) | -| `biz_type` | String(32) | NOT NULL | `exchange_in`(兑入)/ `withdraw`(提现出账)/ `withdraw_refund`(提现退回) | -| `ref_id` | String(64) | nullable | 关联业务 id(如提现 `out_bill_no`) | -| `remark` | String(128) | nullable | 用户可见备注(如"提现未成功,金额已退回") | +| `amount_cents` | Integer | NOT NULL | 本笔变动现金(分);**正=入账(兑入/退款),负=出账(提现)** | +| `balance_after_cents` | Integer | NOT NULL | 本笔后现金余额(= 当时 `coin_account.cash_balance_cents`) | +| `biz_type` | String(32) | NOT NULL | 取值:`exchange_in`(兑入)/ `withdraw`(提现出账)/ `withdraw_refund`(提现退回) | +| `ref_id` | String(64) | nullable | `withdraw`/`withdraw_refund` 时 = **`withdraw_order.out_bill_no`**;`exchange_in` 为 null | +| `remark` | String(128) | nullable | 用户可见备注(如「提现到微信零钱(待审核)」「提现审核未通过,金额已退回」) | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- `ref_id` →(仅 withdraw/withdraw_refund)`withdraw_order.out_bill_no`(软关联,无 FK)。一笔提现正常对应两条流水:发起 `withdraw`(−),失败/拒绝时再 `withdraw_refund`(+)。 + ## 索引与约束 -- PK: `id`;index: `user_id`、`created_at` +- PK `id`;index `user_id`、`created_at`。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 -- 提现失败/取消退款写 `withdraw_refund`(+X);退款流水 `remark` 是用户可见文案,技术原因记在 `withdraw_order.fail_reason`。 +## 注意 +- 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。 diff --git a/docs/database/coin_account.md b/docs/database/coin_account.md index c039328..7401abd 100644 --- a/docs/database/coin_account.md +++ b/docs/database/coin_account.md @@ -1,24 +1,31 @@ # coin_account — 金币 + 现金余额快照 -> 模型 `app/models/wallet.py` | 关联接口 [wallet-account](../api/wallet-account.md) | [← 表索引](./README.md) +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -一个用户一行的余额快照,供读取展示用;每次余额变动都另写流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键。 +一用户一行的余额快照,App「资产卡 / 钱包」读它展示。每次余额变动都另写一笔流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键(一对一)。详见 [总览 §四 资金模型](./OVERVIEW.md#四资金模型金币--现金--提现三层)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`get_or_create_account` 懒建 —— 用户第一次发生金币动作(签到/任务/看广告/admin 发币/兑换/提现)时建一行空账户(全 0)。从未发生过金币动作的用户**没有这行**(读接口按 0 兜底)。 +- **U(更新)**:几乎所有钱相关动作都更新它 —— 发金币(签到/任务/看广告/admin)`coin_balance += `、兑现金 `coin_balance −= / cash_balance_cents += `、发起提现 `cash_balance_cents −= `(带条件原子 UPDATE 防超额)、提现退款 `cash_balance_cents += `。 +- **D**:无。 +- **R**:`GET /wallet/account`(钱包余额卡)、admin 用户 360 概览。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `user_id` | Integer | PK, FK→user.id | 用户(一用户一行) | -| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额 | -| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分) | -| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(只增不减) | +| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行);既是主键也是外键 | +| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额(个数);= 历次 `coin_transaction.amount` 之和 | +| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分);= 历次 `cash_transaction.amount_cents` 之和 | +| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(**只增不减**,仅正向 grant 累加),用于"历史总收益"展示 | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 | +## 关系 / Join Key +- `user_id` → `user.id`(一对一)。 +- 与 `coin_transaction` / `cash_transaction` 无外键直连,靠 `user_id` 关联;快照 = 流水累加值,流水的 `balance_after*` 应等于当时的快照余额(对账依据)。 + ## 索引与约束 -- PK: `user_id`(同时是 FK→user.id) +- PK `user_id`(同时是 FK→user.id)。 -## 关系 -- `user_id` → `user.id`(一对一) - -## 说明 -- **唯一发金币入口** `wallet.grant_coins` 更新本表余额快照 + 写 `coin_transaction`,不 commit,由调用方同事务提交。 -- 提现扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount` 原子扣减,防并发超额。 +## 注意 +- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。 +- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。 diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index 3ffb76a..0beefe8 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -1,27 +1,45 @@ # coin_transaction — 金币流水账本 -> 模型 `app/models/wallet.py` | 关联接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) | [← 表索引](./README.md) +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py`(写入口 `grant_coins`) · 接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -金币每次变动一笔流水,记变动后余额,用于对账与「金币明细」展示。 +金币每变动一笔就记一行(记变动后余额 `balance_after`),用于对账与 App「金币明细」展示。**只增不改不删**——账本不可篡改。 + +## 用在哪 / 增删改查 +- **C(插入)**:唯一来源是 `wallet.grant_coins`(发金币入口),被以下动作调用,每次写一笔: + + | 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 | + |---|---|---|---| + | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | + | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) | + | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | `user_task.task_key` | + | 看广告发奖回调 `POST /ad/pangle-callback` | `ad_reward` | + | `ad_reward_record.trans_id` | + | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | + | 金币兑现金 `POST /wallet/exchange` | `exchange_out` | − | null(配套 `cash_transaction.exchange_in`) | + | admin 手动加金币 | `admin_grant` | + | null(`remark`=`admin:`) | + | admin 手动扣金币 | `admin_deduct` | − | null | + + > ⚠️ 比价里程碑 `comparison_milestone_claim` **当前不发币、不写本表**(产品定暂不真发,详见该表文档)。 +- **U / D**:无。账本只追加。 +- **R**:`GET /wallet/coin-transactions`(金币明细,`id` 倒序游标分页);admin 跨用户金币流水(可按 `biz_type` 筛)。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `amount` | Integer | NOT NULL | 正=入账(赚),负=出账(花/兑换) | -| `balance_after` | Integer | NOT NULL | 本笔变动后金币余额(对账用) | -| `biz_type` | String(32) | NOT NULL | 业务类型:`signin` / `task_` / `exchange_out` / `ad_reward` / `compare_milestone` … | -| `ref_id` | String(64) | nullable | 关联业务 id(签到日期 / 任务 key / trans_id / 里程碑序号等) | -| `remark` | String(128) | nullable | 备注 | +| `amount` | Integer | NOT NULL | 本笔变动金币;**正=入账(赚),负=出账(花/兑换)** | +| `balance_after` | Integer | NOT NULL | 本笔后金币余额(= 当时 `coin_account.coin_balance`,对账用) | +| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `signin_boost` / `task_` / `ad_reward` / `feed_ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 | +| `ref_id` | String(64) | nullable | **关联业务键,指向随 `biz_type` 变(见上表)**;无关联时为 null | +| `remark` | String(128) | nullable | 备注(如签到「每日签到 第N天」、admin「admin:」) | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→签到日 / `task_`→`user_task.task_key` / `ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 + ## 索引与约束 -- PK: `id`;index: `user_id`、`created_at` +- PK `id`;index `user_id`、`created_at`。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 -- 明细接口按 `id` 倒序游标分页。 -- 各 `biz_type` 取值由各业务写入(签到/任务/兑换/看广告/比价里程碑),无独立枚举约束,靠写入方约定。 +## 注意 +- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward/feed_ad_reward 等)在**同一事务**写,不会只发币不留痕。 diff --git a/docs/database/comparison_milestone_claim.md b/docs/database/comparison_milestone_claim.md index 303dede..a9d6c39 100644 --- a/docs/database/comparison_milestone_claim.md +++ b/docs/database/comparison_milestone_claim.md @@ -1,26 +1,29 @@ # comparison_milestone_claim — 比价战绩里程碑领取记录 -> 模型 `app/models/comparison_milestone.py` | 关联接口 [compare-milestones](../api/compare-milestones.md) / [compare-milestone-claim](../api/compare-milestone-claim.md) | [← 表索引](./README.md) +> 模型 `app/models/comparison_milestone.py` · 仓库 `app/repositories/comparison_milestone.py` · 接口 [compare-milestones](../api/compare-milestones.md) / [compare-milestone-claim](../api/compare-milestone-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行;解锁进度由 `comparison_record` 里 `status='success'` 的条数决定,本表只记"哪几档已领"。仿 `user_task` 的一次性领取模型。 +App「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行去重。解锁进度**不存本表**——由 `comparison_record` 里 `status='success'` 的条数实时算(第 N 档在"成功比价 ≥ N 次"时解锁)。档位金额来自 `rewards.RECORD_MILESTONES`(默认 `(120,180,300,500,800,1200)`,运营后台 `app_config.record_milestones` 可改)。仿 `user_task` 的一次性领取模型。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /compare/milestone/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。 +- **U / D**:无。 +- **R**:`GET /compare/milestones`(战绩页:各档 claimed/active/locked + 成功次数),`get_status` 读本表已领集合 + `comparison_record` 成功计数对照。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `milestone` | Integer | NOT NULL | 档位序号(1-based),见 `rewards.RECORD_MILESTONES` | -| `coin_awarded` | Integer | NOT NULL, default 0 | 该档应发金币额;⚠️ 当前产品定暂不真发,实际恒为 0 | +| `milestone` | Integer | NOT NULL | 档位序号(1-based),取值 `1..len(RECORD_MILESTONES)`(当前 1..6) | +| `coin_awarded` | Integer | NOT NULL, default 0 | 该档应发金币;⚠️ **当前恒为 0**(产品定暂不真发,见下) | | `claimed_at` | DateTime(tz) | server_default now() | 领取时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- **解锁进度依赖 `comparison_record`**:进度 = `count_success(user)` = `comparison_record` 中本人 `status='success'` 的条数;本表只记"哪几档已领",不存进度数值。 + ## 索引与约束 -- PK: `id`;index: `user_id` -- UNIQUE(`user_id`, `milestone`) = `uq_compare_milestone_user`(防同档重复领) +- PK `id`;index `user_id`;UNIQUE(`user_id`, `milestone`) = `uq_compare_milestone_user`(防同档重复领)。 -## 关系 -- `user_id` → `user.id`(多对一) -- 解锁进度依赖 `comparison_record`(`status='success'` 计数),本表不存进度本身。 - -## 说明 -- ⚠️ 当前 claim 仅写本表标记已领(去重),**不调 `grant_coins`、不写 `coin_transaction`,`coin_awarded` 恒为 0、余额不变**(产品定暂不真发金币,后续整体删除该功能)。 -- 领取校验:档位越界 404、未解锁(成功数 < milestone)409、已领 409。 +## 注意 +- ⚠️ **当前 `claim` 只写本表标记已领(去重),不调 `grant_coins`、不写 `coin_transaction`,`coin_awarded` 恒为 0、余额不变**(产品定暂不真发金币,后续可能整体删除该功能)。若将来要真发币,在 `claim` 里补 `grant_coins` 即可(参考 `task.py`)。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index ab58252..ba47da1 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -1,47 +1,52 @@ # comparison_record — 比价记录(每次比价完整明细) -> 模型 `app/models/comparison.py` | 关联接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) | [← 表索引](./README.md) +> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每完成一次比价(外卖/电商/领券),客户端 done 帧后用带 JWT 通道上报一条。「我的比价记录」页数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数)。 +每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 > 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。 +## 用在哪 / 增删改查 +- **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` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。 + ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `id` | Integer | PK, autoincrement | | +| `id` | Integer | PK, autoincrement | 被 `price_report.comparison_record_id` 引用 | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `device_id` | String(64) | nullable | 设备号 | -| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(当前唯一接通)/ `ecom` / `coupon` | +| `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) | +| `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` | | `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) | -| `source_platform_id` | String(32) | nullable | 源平台代号 | -| `source_platform_name` | String(32) | nullable | 源平台中文名 | +| `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 | | `source_package` | String(128) | nullable | 源平台 Android 包名 | | `source_price_cents` | Integer | nullable | 源平台到手价(分) | -| `best_platform_id` | String(32) | nullable | 最优平台代号(= rank=1) | -| `best_platform_name` | String(32) | nullable | 最优平台中文名 | -| `best_price_cents` | Integer | nullable | 最优价(分) | -| `saved_amount_cents` | Integer | nullable | 源价 − 最优价(可 0/负) | -| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 没省到) | -| `store_name` | String(128) | nullable | 店铺名 | -| `total_dish_count` | Integer | nullable | 菜品总数 | -| `skipped_dish_count` | Integer | nullable | 跳过(没找到)菜品数 | -| `status` | String(16) | NOT NULL, default `success` | `success`(拿到有效对比)/ `failed`(出错/没采到目标价) | +| `best_platform_id` / `_name` | String(32) | nullable | 最优平台代号 / 中文名(= rank=1) | +| `best_price_cents` | Integer | nullable | 最优价(分)。**被 `price_report` 反查作"原最低价",校验上报价必须更低** | +| `best_deeplink` | String(1024) | nullable | 最优平台商家深链(再次比价/直达用);2026-06 起新比价才有,旧记录 null | +| `saved_amount_cents` | Integer | nullable | 源价 − 最优价(可 0/负:源平台本就最便宜) | +| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) | +| `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 | +| `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 | +| `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** | | `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) | | `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` | -| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved,coupon_name}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」;`coupon_name`=优惠来源名(展示用 best-effort,美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"),null 走前端通用"红包" | +| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) | | `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 | -| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) | +| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | +> `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。 + +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- ← 被 `price_report.comparison_record_id` 引用(选中某条比价记录上报更低价);`price_report` 同时读本表 `best_price_cents`/`best_platform_*`/`store_name`/`items` 做快照与校验。 +- ≈ `savings_record`(语义 join,无 FK):`store_name` == `savings_record.shop_name`(source='compare')→ 标「已下单」,店级。 +- 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。 + ## 索引与约束 -- PK: `id`;index: `user_id`、`business_type`、`created_at` -- UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(同次比价重试/重复上报幂等覆盖) +- PK `id`;index `user_id`、`business_type`、`created_at`;UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。 -## 关系 -- `user_id` → `user.id`(多对一) -- 被 `comparison_milestone_claim` 间接依赖:里程碑解锁进度 = 本表 `status='success'` 条数。 - -## 说明 -- `best_*` / `saved_amount_cents` / `is_source_best` / `status` 由 `repositories/comparison.py:_derive` 从 `comparison_results` 派生,客户端不用自己算。 -- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。金额结构化列存「分」,`comparison_results.price` 原样存「元」。 +## 注意 +- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。 diff --git a/docs/database/feedback.md b/docs/database/feedback.md index 6df7219..2261af2 100644 --- a/docs/database/feedback.md +++ b/docs/database/feedback.md @@ -1,25 +1,32 @@ # feedback — 用户帮助与反馈 -> 模型 `app/models/feedback.py` | 关联接口 [feedback](../api/feedback.md) | [← 表索引](./README.md) +> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每条 = 用户一次反馈提交。`content` 与 `contact` 必填,`images` 为可选截图 URL 列表。 +App「帮助与反馈」每次提交写一行。`content` 与 `contact` 必填,`images` 为可选截图。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media` 落 `/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`。 +- **U(更新)**:admin 处理反馈 `update_feedback_status` → `status='handled'`(同事务写 `admin_audit_log`)。 +- **D**:无。 +- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 | | `content` | Text | NOT NULL | 反馈正文 | | `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机,便于回访) | -| `images` | JSON | nullable | 截图 URL 列表(相对路径 `/media/feedback/...`);无图为 NULL | -| `status` | String(16) | NOT NULL, default `new` | `new`(待处理)/ `handled`(已处理) | +| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL | +| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) | | `created_at` | DateTime(tz) | server_default now(), index | 提交时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- admin 处理时被 `admin_audit_log` 记录(`target_type='feedback'`、`target_id`=本行 id)。 + ## 索引与约束 -- PK: `id`;index: `user_id`、`created_at` +- PK `id`;index `user_id`、`created_at`。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 -- `images` 用通用 `JSON`(本表未用 JSONB variant);截图先经 `/media/feedback/` 上传拿到相对路径再随反馈提交。 +## 注意 +- `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。 diff --git a/docs/database/ops_marquee_seed.md b/docs/database/ops_marquee_seed.md new file mode 100644 index 0000000..639ec2e --- /dev/null +++ b/docs/database/ops_marquee_seed.md @@ -0,0 +1,24 @@ +# ops_marquee_seed — 首页轮播种子(生成规则) + +> 模型 `app/models/ops_marquee_seed.py` | 关联接口 [platform-savings-feed](../api/platform-savings-feed.md) / [admin-marquee-seeds](../api/admin-marquee-seeds.md) | [← 表索引](./README.md) + +首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,如 `用户********a52`(整串存)。**留空 → feed 展示时按脱敏格式随机合成**(`用户********`+3 位 hex,避开同屏撞名),风格与真实条一致、永不穿帮 | +| `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) | +| `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | +| `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | +| `sort_order` | Integer | NOT NULL, default 0 | **仅后台列表排序**(小在前);feed 是公平随机抽取,不看此字段 | +| `created_at` | DateTime(tz) | server_default now() | 创建时间 | + +## 初始数据(migration 播种) +6 条 = 客户端原写死的轮播条目(金额转分、`min==max` 固定值):a52/18.60、k89/42.40、m71/7.90、p46/156.30、r93/23.50、c28/89.40。 +(`marquee_seed02` 迁移把旧单值列 `saved_amount_cents` 拆成 `min_cents=max_cents`,并把 `masked_user` 改为可空;`opsrename01` 迁移把表名由 `marquee_seed` 改为 `ops_marquee_seed`,与代码 ops_ 前缀对齐,路由 `/admin/api/marquee-seeds` 不变。) + +## 说明 +- feed 取真实(`comparison_record` success 且 `0 模型 `app/models/ops_stat_config.py` | 关联接口 [platform-stats](../api/platform-stats.md) / [admin-dashboard-display](../api/admin-dashboard-display.md) | [← 表索引](./README.md) + +客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示配置。一行一指标(主键=`metric`),每指标可独立选 real/manual/random 三种模式。计算逻辑见 `app/repositories/ops_stat.py`。 + +> **单位**:倍率用**千分比整数**(1.000→`1000`);`total_saved` 指标的 `manual_value` / `random_current` 单位是**分**,两个计数指标是**个数**。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `metric` | String(32) | PK | `help_users` / `total_compares` / `total_saved` | +| `mode` | String(16) | NOT NULL, default `real` | `real` 查库 / `manual` 手填 / `random` 随机增长 | +| `manual_value` | Integer | nullable | manual 模式固定值(基础单位) | +| `random_mult_min` | Integer | NOT NULL, default 1000 | 倍率下限(千分比,≥1000 保证只增不减) | +| `random_mult_max` | Integer | NOT NULL, default 1100 | 倍率上限(千分比) | +| `random_tick_seconds` | Integer | NOT NULL, default 86400 | 更新间隔(秒) | +| `random_anchor_minutes` | Integer | NOT NULL, default 0 | 触发时刻对齐偏移(距北京 0 点分钟数):天=每日时刻 h*60+m、小时=每小时第几分、分钟=0 | +| `random_current` | Integer | nullable | 当前展示值(所有模式,基础单位);惰性 tick 改写 | +| `random_last_tick_at` | DateTime(tz) | nullable | 上次 tick 时刻 | +| `random_kind` | String(8) | NOT NULL, default `mult` | 自增长方式:`mult` ×倍率 / `add` +绝对增量 | +| `random_step_min` | Integer | NOT NULL, default 0 | add 模式增量下限(基础单位) | +| `random_step_max` | Integer | NOT NULL, default 0 | add 模式增量上限(基础单位) | +| `real_offset` | Integer | NOT NULL, default 0 | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 | +| `allow_decrease` | Boolean | NOT NULL, default false | 是否允许展示值下降(默认 false = 只增不减) | +| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 | + +## 初始数据(migration 播种) +三行,默认 `mode='manual'` + 客户端原写死门面值,保证上线前后展示一致: + +| metric | manual_value | 含义 | +|---|---|---| +| `help_users` | 12847 | 12847 人 | +| `total_compares` | 86532 | 86532 次 | +| `total_saved` | 3762140 | 37621.40 元 | + +## 统一「定时刷新」(北京钟点对齐) +`random_current` 现是**所有模式**的「当前展示值」(不止 random),用户侧 `/stats` 直接返回它。 +- 触发边界 = 北京时间 `anchor + k*interval`(interval=`random_tick_seconds`,anchor=`random_anchor_minutes*60`,sub-day 间隔取 `anchor % interval` 作相位)。例:每天 09:00 / 每小时 :30 / 每 5 分刻度。 +- 读取(`get_display_values`/`get_config`)时跑 `_refresh`:统计 `random_last_tick_at`→`now` 跨过几个边界 N,N≥1 才刷新: + - **real** → `random_current` = 重新查库的真实值 + `real_offset`(跨多少边界都只取最新),**经只增不减护栏**。 + - **manual** → `random_current` = 当前 `manual_value`,**经只增不减护栏**。 + - **random** → 按 `random_kind` 走 N 个周期:`mult` 连乘随机倍率(`randint(min,max)/1000`,恒 ≥1.0)/ `add` 连加随机增量(`randint(step_min,step_max)`,≥0)。天然只增。 +- **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时若新目标值 < 当前 `random_current`,保持当前值不回退(门面忌缩水)。开 `allow_decrease` 才允许下降。 +- 用边界索引比较,`random_last_tick_at` 直接记 `now`,不重复计。 +- ⚠️ **接受刷新时刻的微小并发竞态**(不加行锁):纯门面数字,无业务后果。 +- `random_current` 首次为空时按当前模式播种(`update_config` 或 `_refresh` 兜底);`random_initial` 可立即设起点(不受护栏约束)。 + +## 说明 +- real 口径仅统计 `comparison_record` 中 `status='success'` 的记录(去重用户数 / 记录数)。`total_saved` 的求和**只计入 `0 < saved_amount_cents ≤ 300 元` 的条目**(剔除 bug 异常大值,与轮播口径一致,防一条异常配合「只增不减」永久撑高门面)。 +- `倍率上限收紧为 1.500`(单次 ×1.5,原 5.0 太大一跳就假)。 +- 表名 `ops_stat_config`(`opsrename01` 迁移由 `platform_stat_display` 改名,与代码 ops_ 前缀对齐;路由 `/admin/api/dashboard-display` 不变)。 +- 改配置走 admin `PATCH /admin/api/dashboard-display/{metric}`,带审计。 diff --git a/docs/database/price_report.md b/docs/database/price_report.md new file mode 100644 index 0000000..64d81c0 --- /dev/null +++ b/docs/database/price_report.md @@ -0,0 +1,42 @@ +# price_report — 上报更低价(众包纠偏) + +> 模型 `app/models/price_report.py` · 仓库 `app/repositories/report.py` · 接口 `POST /api/v1/report`(提交,multipart)/ `GET /api/v1/report/records`(列表)· [← 索引](./README.md) · [总览](./OVERVIEW.md) + +用户在「我的比价记录」里选一条,举报「某平台比我们算出的最低价更便宜」,带 1~4 张截图证明,人工审核通过后发金币奖励。与 `feedback`(自由文本)不同:本表**结构化**——关联具体比价记录、原最低价 vs 上报价、审核状态、奖励。是「省钱护城河」的众包纠偏入口。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /api/v1/report`(multipart:`comparison_record_id` + `reported_platform_id` + `reported_price`(元) + `images`)。后端 `create_report`:① 反查 `comparison_record`(须属本人)取 `best_price_cents` 作"原最低价";② **校验上报价 < 原最低价**(否则 400);③ 截图经 `core.media` 落 `/media/price_report/`;④ 写一行 `status='pending'`。**原最低价快照由后端反查,不信客户端传值**。 +- **U(更新,审核)**:人工审核后台改 `status`(approved/rejected)+ `reject_reason` / `reward_coins` / `reviewed_at`(当前审核动作在 admin 侧,提交一律 pending)。 +- **D**:无。关联的 `comparison_record` 若被删,本行 `comparison_record_id` 置空(可空 FK),上报历史保留。 +- **R**:`GET /api/v1/report/records`(用户上报列表,可按 `status` 筛,带各状态计数)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `user_id` | Integer | FK→user.id, index, NOT NULL | 上报用户 | +| `comparison_record_id` | Integer | FK→comparison_record.id, index, nullable | 选中的那条比价记录(删后置空保留历史) | +| `store_name` | String(128) | nullable | 门店名快照(反查 comparison_record 冗余存,防关联记录改/删) | +| `dish_summary` | String(256) | nullable | 菜品摘要(由比价记录 items 的菜名拼成) | +| `original_platform_id` / `_name` | String(32) | nullable | 原最低价平台快照(= 比价记录 best_platform_*) | +| `original_price_cents` | Integer | nullable | 原最低价(分,后端反查 `comparison_record.best_price_cents`) | +| `reported_platform_id` | String(32) | NOT NULL | 用户上报的更低价平台,取值 `meituan-waimai`/`jd-waimai`/`taobao-shanguang`(canonical id) | +| `reported_platform_name` | String(32) | NOT NULL | 上报平台展示名(美团外卖/京东外卖/淘宝闪购) | +| `reported_price_cents` | Integer | NOT NULL | 用户上报的更低价(分);**校验必须 < `original_price_cents`** | +| `images` | JSON | NOT NULL, default [] | 截图相对 URL 列表 `/media/price_report/...`(1~4 张) | +| `status` | String(16) | NOT NULL, default `pending`, index | 取值:`pending`(审核中)/ `approved`(通过)/ `rejected`(未通过) | +| `reject_reason` | String(256) | nullable | 驳回原因(审核填) | +| `reward_coins` | Integer | nullable | 通过后发的金币(人工审核后台动作,提交时为 null) | +| `reviewed_at` | DateTime(tz) | nullable | 审核时间 | +| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 | + +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- `comparison_record_id` → `comparison_record.id`(多对一,可空)。提交时**读** `comparison_record` 的 `best_price_cents`(校验)/ `best_platform_*` / `store_name` / `items`(冗余快照),之后即使原记录变动也对得上。 + +## 索引与约束 +- PK `id`;index `user_id`、`comparison_record_id`、`status`、`created_at`。 + +## 注意 +- 奖励发放(approved → 钱包 +`reward_coins`)是人工审核后台动作,提交一律 `pending`,本表只留字段。 +- 截图复用 `core.media`(魔数嗅探 + 随机文件名),与头像/反馈同一套上传。 diff --git a/docs/database/savings_record.md b/docs/database/savings_record.md index a30bbfd..bf7814d 100644 --- a/docs/database/savings_record.md +++ b/docs/database/savings_record.md @@ -1,31 +1,50 @@ # savings_record — 省钱记录(profile 省钱战绩源) -> 模型 `app/models/savings.py` | 关联接口 [savings-summary](../api/savings-summary.md) / [savings-battle](../api/savings-battle.md) / [savings-records](../api/savings-records.md) | [← 表索引](./README.md) +> 模型 `app/models/savings.py` · 仓库 `app/repositories/savings.py` · 接口 [savings-summary](../api/savings-summary.md) / [savings-battle](../api/savings-battle.md) / [savings-records](../api/savings-records.md) · 写入源 `POST /api/v1/order/report`([compare-record 相关](../api/compare-record-report.md)) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。 +profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。是「记账闭环」的落库终点——客户端比价后点结果链接跳走付款,无障碍 watcher 捕获支付成功 + 金额归因(±1 元/5 分钟窗口)→ 上报 `POST /order/report`。 -> ⚠️ **当前为 demo 假数据**:`crud/savings.py:ensure_seeded` 按 user_id 幂等灌 ~23 条(`source='demo'`),聚合(SUM/分组/连续天数)是生产级真实计算。真数据要靠**"用户真下单"信号**(app 目前无:AI 比价止于结算页/结果展示,付款用户手动)——**不是**把比价记录 `comparison_record` 喂过来(那是"比价行为",这是"成交省钱",两个维度)。详见 [[project_shaguabijia_app_server]]。 +> **来源(`source` 字段)**: +> - `source='compare'`:**真实下单上报**(经 `/order/report` 的 `create_from_report` 写,2026-06 已接通)。 +> - `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` demo 懒种子~~:**2026-06 移除**(不再灌 demo)。 +- **U / D**:无。 +- **R**:`GET /savings/summary`(累计省/单数/均省)、`/savings/battle`(本周省/连续天数/超过 X% 用户/比价次数)、`/savings/records`(明细分页);`comparison.py` 读本表 `shop_name`(source=compare)集合给比价记录打「已下单」。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `order_amount_cents` | Integer | NOT NULL | 订单到手价(分) | -| `saved_amount_cents` | Integer | NOT NULL | 本单省下(分,可为 0) | +| `order_amount_cents` | Integer | NOT NULL | 订单到手价/实付(分) | +| `saved_amount_cents` | Integer | NOT NULL | 本单省下(分,可为 0;= original − 实付,下限 0) | | `platform` | String(32) | nullable | 下单平台(美团外卖/淘宝闪购/京东外卖) | -| `title` | String(128) | nullable | 标题 | -| `shop_name` | String(128) | nullable | 店铺名 | +| `title` | String(128) | nullable | 明细卡标题(compare 来源 = 门店名) | +| `shop_name` | String(128) | nullable | 店铺名。**与 `comparison_record.store_name` 字符串相等关联**(打「已下单」) | | `dishes` | JSON(PG: JSONB) | NOT NULL, default [] | 菜名列表(前 2 道展示,其余"还有 N 道") | -| `source` | String(16) | NOT NULL, default `compare` | 来源:`demo`(演示)/ `compare`(真实下单上报,待启用) | +| `source` | String(16) | NOT NULL, default `compare` | 取值:`compare`(真实下单上报)/ `demo`(演示种子) | +| `original_price_cents` | Integer | nullable | 源平台原价(分);compare 上报带,demo 为 null | +| `compared_price_cents` | Integer | nullable | 我们当时给出的比价价(分,审计/备用) | +| `pay_channel` | String(16) | nullable | 支付渠道:`wechat` / `alipay`(归因 watcher 判定) | +| `platform_package` | String(128) | nullable | 实际下单平台包名 | +| `source_platform_name` | String(32) | nullable | 源平台展示名(如「美团」),用于"原价 ¥X(美团)" | +| `source_deeplink` | String(512) | nullable | 源平台重进链接(预留) | +| `client_event_id` | String(64) | nullable | 客户端幂等键(UUID);demo 行为 null | +| `device_id` | String(128) | nullable | 上报设备号 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- ≈ `comparison_record`(语义 join,无 FK):本表 `shop_name`(source=compare)== `comparison_record.store_name` → 给那条比价记录标「已下单」。注意是**店名对齐、店级**(下单上报不带 trace_id,无法逐条精确对应)。 + ## 索引与约束 -- PK: `id`;index: `user_id`、`created_at` +- PK `id`;index `user_id`、`created_at`;UNIQUE(`user_id`, `client_event_id`) = `uq_savings_user_event`(真实上报幂等;demo 行 `client_event_id=NULL` 不参与冲突,允许多 NULL)。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 +## 注意 - `dishes` 用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。 -- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位;为有可比人群造了 5 个种子用户(`register_channel='seed'`)。 +- `beat_percent`(超过百分之多少用户)按各用户**真实(compare)**累计省下金额做真实分位(demo 不计)。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md new file mode 100644 index 0000000..e7174a1 --- /dev/null +++ b/docs/database/signin_boost_record.md @@ -0,0 +1,22 @@ +# signin_boost_record — 签到膨胀记录 + +App 用户当天签到后,看完激励视频可把签到奖励膨胀一次。本表记录膨胀动作,并用唯一约束防重复补发。 + +## 字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `id` | Integer | PK | 自增主键 | +| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | +| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | +| `coin_awarded` | Integer | NOT NULL | 本次补发金币 | +| `ad_ref_id` | String(64) | nullable | 广告会话/交易号,开发期可空 | +| `created_at` | DateTime | NOT NULL | 创建时间 | + +## 约束 + +- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。 + +## 关联 + +- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id=signin_date`。 diff --git a/docs/database/signin_record.md b/docs/database/signin_record.md index abe7a96..786beab 100644 --- a/docs/database/signin_record.md +++ b/docs/database/signin_record.md @@ -1,26 +1,32 @@ -# signin_record — 签到记录(7 天循环) +# signin_record — 签到记录(14 天循环) -> 模型 `app/models/signin.py` | 关联接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) | [← 表索引](./README.md) +> 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每次签到一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(`rewards.SIGNIN_REWARDS`),断签重置回 1;`streak` 是连续签到天数(不封顶)用于展示。 +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`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `signin_date` | Date | NOT NULL | 签到日期(北京时间 date) | -| `cycle_day` | Integer | NOT NULL | 7 天循环里今天第几档(1..7),决定发币 | -| `streak` | Integer | NOT NULL | 连续签到天数(不封顶) | -| `coin_awarded` | Integer | NOT NULL | 本次发放金币 | +| `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) | +| `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() | 时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- 配套写 `coin_account`(U,加币)+ `coin_transaction`(C,`biz_type='signin'`、`ref_id=signin_date.isoformat()`)。`coin_transaction.ref_id` 反查即对应本表当天那条。 + ## 索引与约束 -- PK: `id`;index: `user_id` -- UNIQUE(`user_id`, `signin_date`) = `uq_signin_user_date`(防一天签两次) +- PK `id`;index `user_id`;UNIQUE(`user_id`, `signin_date`) = `uq_signin_user_date`(防一天签两次)。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 -- 签到"今天"按北京时间 `cn_today()`(`CN_TZ=UTC+8`);发币与写本表记录同事务(`grant_coins(biz_type='signin', ref_id=日期)`)。 +## 注意 +- "今天/昨天"全按北京时间(`CN_TZ=UTC+8`),不用 UTC(否则 0~8 点会算成前一天)。 +- 连续判定:`last.signin_date == today - 1天` 才算连续,否则首签/断签重置。 diff --git a/docs/database/user.md b/docs/database/user.md index 5c8595e..04b5adb 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -1,32 +1,37 @@ # user — 用户(登录主体) -> 模型 `app/models/user.py` | 关联接口 [auth-me](../api/auth-me.md) 等 auth 组 | [← 表索引](./README.md) +> 模型 `app/models/user.py` · 仓库 `app/repositories/user.py` · 接口 [auth-jverify-login](../api/auth-jverify-login.md) / [auth-sms-login](../api/auth-sms-login.md) / [user-profile](../api/user-profile.md) / [user-avatar](../api/user-avatar.md) / [user-delete](../api/user-delete.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -登录主体。极光一键登录与短信登录都映射到同一行,以 `phone` 唯一索引;注册即登录(phone 不存在则 insert,存在则更新 `last_login_at`)。后续加微信/Apple 登录新增 oauth_account 表,本表不动。 +整个 App 的用户主体,也是几乎所有业务表的外键宿主。极光一键登录与短信登录都映射到同一行、以 `phone` 唯一索引;**注册即登录**(phone 不存在则 insert,存在则更新 `last_login_at`)。后续加微信/Apple 登录会新增 oauth_account 表,本表不动。 + +## 用在哪 / 增删改查 +- **C(插入=注册)**:首次登录 `POST /auth/jverify-login`(极光)或 `/auth/sms/login`(短信)→ `upsert_user_for_login`,phone 不存在则建行(`register_channel='jverify'/'sms'`)。 +- **U(更新)**:① 每次登录刷 `last_login_at`;② 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar`;③ 绑/解绑微信 `POST /wallet/bind-wechat` / `/unbind-wechat`(写 `wechat_openid/nickname/avatar_url`);④ 注销 `DELETE /user`(软删,见下);⑤ admin 改状态(禁用/启用)。 +- **D(删除)**:无物理删除。注销是软删 = U:`status='deleted'` + `phone='deleted_'`(释放唯一约束,可同号重注册)+ 清 PII。 +- **R(读取)**:每个鉴权请求经 `api/deps.get_current_user` 解 JWT 的 `sub` 查本表(并校验 `status=='active'`);admin 用户管理列表/详情。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `id` | Integer | PK, autoincrement | 用户主键 | -| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键;注销后置 `deleted_` 释放唯一约束) | -| `register_channel` | String(20) | NOT NULL, default `jverify` | 注册渠道:`jverify` / `sms` | -| `nickname` | String(64) | nullable | 通用昵称(用户改资料设) | -| `avatar_url` | String(512) | nullable | 通用头像相对 URL(`/media/avatars/...`) | -| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(绑定后存,提现转账用);一微信一账号 | -| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡) | +| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 | +| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_` 占位释放唯一约束 | +| `register_channel` | String(20) | NOT NULL, default `jverify` | 取值:`jverify`(极光一键)/ `sms`(短信);`savings_record` 的对比人群另有种子用户用 `seed` | +| `nickname` | String(64) | nullable | 通用昵称(`PATCH /user/profile` 写) | +| `avatar_url` | String(512) | nullable | 通用头像相对 URL `/media/avatars/...`(`POST /user/avatar` 写) | +| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(提现转账用);**一微信一账号**,多个 NULL 允许(多个未绑用户) | +| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡;与通用 nickname 分开存) | | `wechat_avatar_url` | String(512) | nullable | 微信头像 URL | -| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled` / `deleted` | +| `status` | String(20) | NOT NULL, default `active` | 取值:`active`(可登录/鉴权)/ `disabled`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 | | `created_at` | DateTime(tz) | server_default now() | 注册时间 | -| `last_login_at` | DateTime(tz) | default utcnow(应用层) | 最近登录时间 | +| `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`、`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 后台管理员,两套体系)。 ## 索引与约束 -- PK: `id` -- UNIQUE + index: `phone` -- UNIQUE + index: `wechat_openid`(允许多个 NULL = 多个未绑定用户) +- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。 -## 关系 -- 被引用方:`coin_account` / `coin_transaction` / `cash_transaction` / `withdraw_order` / `signin_record` / `user_task` / `savings_record` / `ad_reward_record` / `ad_ecpm_record` / `feedback` / `comparison_record` / `comparison_milestone_claim` 的 `user_id` 均 → `user.id`。 - -## 说明 +## 注意 - `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。 -- 注销账号:phone 改占位串、status=deleted,不物理删行(保留外键完整性)。 +- 软删后旧 JWT 仍能用到自然过期(无 jti 黑名单,见 `待办与技术债.md`);鉴权查 `status` 拦得住绝大多数路径。 diff --git a/docs/database/user_task.md b/docs/database/user_task.md index fe4aeb6..c2acc22 100644 --- a/docs/database/user_task.md +++ b/docs/database/user_task.md @@ -1,26 +1,30 @@ # user_task — 一次性任务领取去重 -> 模型 `app/models/task.py` | 关联接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) | [← 表索引](./README.md) +> 模型 `app/models/task.py` · 仓库 `app/repositories/task.py` · 接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -像"打开消息提醒"这类只能领一次的任务,完成后写一行,`(user_id, task_key)` 唯一防重复领奖。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。 +像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /tasks/claim`(`claim_task`)。`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。 +- **U / D**:无。领过即终态。 +- **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `task_key` | String(48) | NOT NULL | 任务标识,见 `rewards.TASK_REWARDS`(如 `enable_notification`) | -| `status` | String(16) | NOT NULL, default `completed` | 任务状态 | -| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币 | -| `completed_at` | DateTime(tz) | server_default now() | 完成/领取时间 | +| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_`) | +| `status` | String(16) | NOT NULL, default `completed` | 当前恒为 `completed`(领即完成) | +| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币(= `TASK_REWARDS[task_key]`) | +| `completed_at` | DateTime(tz) | server_default now() | 领取时间 | + +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- 配套写 `coin_account`(U)+ `coin_transaction`(C,`biz_type=f"task_{task_key}"`、`ref_id=task_key`)。 ## 索引与约束 -- PK: `id`;index: `user_id` -- UNIQUE(`user_id`, `task_key`) = `uq_task_user_key`(防重复领) +- PK `id`;index `user_id`;UNIQUE(`user_id`, `task_key`) = `uq_task_user_key`(防重复领)。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 -- 领取:写本表 + `grant_coins(biz_type='task_', ref_id=task_key)` 同事务;重复领抛 409,未知 key 抛 404。 -- 比价战绩里程碑虽是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(因 task_key 是固定字典,里程碑是按次数解锁的序号)。 +## 注意 +- 比价里程碑虽也是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(task_key 是固定字典,里程碑是按成功次数解锁的序号)。 diff --git a/docs/database/wechat_transfer_authorization.md b/docs/database/wechat_transfer_authorization.md new file mode 100644 index 0000000..9f6e497 --- /dev/null +++ b/docs/database/wechat_transfer_authorization.md @@ -0,0 +1,45 @@ +# wechat_transfer_authorization — 微信「免确认转账」授权 + +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md)(打款时用)、绑定 [wallet-bind-wechat](../api/wallet-bind-wechat.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +微信商家转账的**免确认收款授权**。用户授权一次后,后续提现可走 `transfer_with_authorization` 免逐笔在微信里确认、直接到账;未授权则走确认模式(每笔都要用户在微信点确认)。**一个用户一行**(`user_id` 主键)。 + +**状态机**: +``` +(无) ──申请 / 首单转账顺带申请──▶ pending(微信 WAIT_USER_CONFIRM,待用户在微信确认授权) +pending ──用户确认授权──▶ active(微信 TAKING_EFFECT,此后转账免确认) +pending / active ──用户在微信关 / 商户解除 / 风控──▶ closed(终态,需重新开启,下次提现自动回退确认模式重授权) +``` + +## 用在哪 / 增删改查 +- **C(插入)**:`_ensure_pending_auth_no` —— 用户首次提现走"方式一(转账顺带申请授权)",或显式 `apply_transfer_auth`(开启免确认)时,建一行 `pending`(生成 `out_authorization_no`)。 +- **U(更新)**: + - `sync_transfer_auth`(仅 `pending` 时查微信):用户确认后 `TAKING_EFFECT` → `active` + 回填 `authorization_id`;超期未确认 `CLOSED` → `closed`。 + - `_refresh_active_auth`:免确认转账失败后回查,微信侧已失效 → `closed`。 + - `close_transfer_auth`(用户解除授权)→ `closed`。 + - `closed` 后重新开启:换新 `out_authorization_no`、清 `authorization_id`、回到 `pending`。 +- **D**:无(重开是改回 pending,不删行)。 +- **R**:`execute_withdraw_transfer` 打款前读它决定走免确认还是确认模式。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行) | +| `openid` | String(64) | NOT NULL | 微信 openid 快照(= `user.wechat_openid`,换绑时刷新) | +| `out_authorization_no` | String(64) | UNIQUE, index, NOT NULL | **我方生成**的授权单号(查授权/发起授权用);重开时换新值 | +| `authorization_id` | String(64) | nullable | **微信** active 后返回的授权号;免确认转账(`transfer_with_authorization`)必传 | +| `state` | String(16) | NOT NULL, default `pending` | 取值:`pending`(待用户确认)/ `active`(已生效可免确认)/ `closed`(已关闭需重开) | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +## 关系 / Join Key +- `user_id` → `user.id`(一对一)。 +- 逻辑配合 `withdraw_order`:打款时按本表 `state=='active' && authorization_id` 决定免确认 vs 确认模式(无 id 直连,按 `user_id` 关联)。 +- `openid` 是 `user.wechat_openid` 的快照(冗余,避免打款时再查 user)。 + +## 索引与约束 +- PK `user_id`;UNIQUE+index `out_authorization_no`。 + +## 注意 +- `out_authorization_no`(我方)与 `authorization_id`(微信)是**两个不同的号**,别混:前者贯穿整个授权生命周期,后者只在生效后才有。 +- 未配 `WXPAY_AUTH_NOTIFY_URL`(未启用免确认)时本表不参与,提现退化为原确认模式。 diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md index 7ab6eba..edb454c 100644 --- a/docs/database/withdraw_order.md +++ b/docs/database/withdraw_order.md @@ -1,30 +1,51 @@ # withdraw_order — 提现单(现金 → 微信零钱) -> 模型 `app/models/wallet.py` | 关联接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) | [← 表索引](./README.md) +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -提现状态机:`pending → success / failed`。扣现金 + 写 `cash_transaction(withdraw)` + 建本单在同一事务;失败/取消时退回现金并写 `cash_transaction(withdraw_refund)`。`wechat_state` 存微信侧原始状态,`status` 是归一化后的三态。 +用户把现金余额提到微信零钱的工单。**含人工审核**(2026-06 起):发起即扣现金、进 `reviewing` 待审核、**不打款**;管理员后台审核通过才真正发起微信商家转账,拒绝则退款。 + +**状态机**: +``` +发起 ──扣现金+建单──▶ reviewing +reviewing ──admin 审核通过──▶ pending(微信转账在途) ──▶ success / failed(失败自动退款) +reviewing ──admin 审核拒绝──▶ rejected(已退款) +``` + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /wallet/withdraw`(`create_withdraw`)→ 校验额度([10 分, 5,000,000 分])+ 必须已绑微信 → 原子扣现金 + 写 `cash_transaction(withdraw,−)` + 建单 `status='reviewing'`,同事务。`out_bill_no` 客户端可传作幂等键(同号重发返回现状,不重复扣款建单)。 +- **U(更新,改 `status` / 微信回执)**: + - admin 审核通过 `approve_withdraw` → `pending` → 调微信转账 → `success`(到账)/ 失败 `_settle_after_ambiguous`(查单后定夺)。 + - admin 审核拒绝 `reject_withdraw` → 退款 + `rejected`。 + - 用户/系统查单 `refresh_withdraw_status`(`GET /wallet/withdraw/status`)、对账 `reconcile_pending_withdraws`(定时任务扫 >15min 的 pending)→ 按微信侧状态归一化 `success`/`failed`。 + - 落微信回执:`wechat_state` / `transfer_bill_no` / `package_info`。 +- **D**:无。 +- **R**:`GET /wallet/withdraw/orders`(用户提现单列表);admin 提现管理列表/查单。 ## 字段 -| 列 | 类型 | 约束 / 默认 | 说明 | +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单);客户端可传,不传则服务端生成 | +| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** | | `amount_cents` | Integer | NOT NULL | 提现金额(分) | -| `status` | String(16) | NOT NULL, default `pending` | 归一化状态:`pending` / `success` / `failed` | -| `wechat_state` | String(32) | nullable | 微信原始状态:`WAIT_USER_CONFIRM` / `SUCCESS` / `FAIL` / `CANCELLED` … | -| `transfer_bill_no` | String(64) | nullable | 微信转账单号 | -| `package_info` | String(512) | nullable | 待用户确认时返回 App 拉起确认页用 | -| `fail_reason` | String(256) | nullable | 失败技术原因(不外露,用户只看流水 remark) | +| `user_name` | String(64) | nullable | 提现实名;微信**达额转账要求实名**,发起时存下、审核打款时传给微信 | +| `status` | String(16) | NOT NULL, default `reviewing` | 归一化状态:`reviewing`(待审核,已扣款未打款)/ `pending`(打款在途)/ `success` / `failed`(打款失败已退)/ `rejected`(审核拒绝已退) | +| `wechat_state` | String(32) | nullable | 微信侧原始状态:`WAIT_USER_CONFIRM` / `SUCCESS` / `FAIL` / `CANCELLED` / `CLOSED` … | +| `transfer_bill_no` | String(64) | nullable | 微信转账单号(转账成功后回填) | +| `package_info` | String(512) | nullable | 待用户确认时返回 App 拉起微信确认页用(确认模式有,免确认转账无) | +| `fail_reason` | String(256) | nullable | 失败/拒绝技术原因(不外露,用户只看流水 `remark`) | | `created_at` | DateTime(tz) | server_default now(), index | 发起时间 | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 | +## 关系 / Join Key +- `user_id` → `user.id`(多对一)。 +- `out_bill_no` ← 被 `cash_transaction.ref_id` 引用(发起 `withdraw` 一笔 −,失败/拒绝 `withdraw_refund` 一笔 +)。 +- 打款方式依赖 `wechat_transfer_authorization`(用户有生效授权 → 免确认转账,否则确认模式)。 + ## 索引与约束 -- PK: `id`;UNIQUE + index: `out_bill_no`;index: `user_id`、`created_at` +- PK `id`;UNIQUE+index `out_bill_no`;index `user_id`、`created_at`。 -## 关系 -- `user_id` → `user.id`(多对一) - -## 说明 -- 资金安全:原子扣款 + `out_bill_no` 幂等 + 模糊失败先查单再决定(绝不盲目退款)+ 孤儿单对账 `reconcile_pending_withdraws`。详见 [[project_shaguabijia_app_server]] 提现段。 -- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信地板)。 +## 注意 +- **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。 +- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信商家转账地板价),可经 `app_config.withdraw_min_cents` 后台调。 +- "待审核期间钱已扣减",防用户拿同一笔余额重复发起多笔提现。 diff --git a/docs/看广告赚金币上线清单.md b/docs/看广告赚金币上线清单.md index c410817..3f0ccd7 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/看广告赚金币上线清单.md @@ -8,7 +8,7 @@ - **发奖只走服务端**:激励视频播完,穿山甲服务器 S2S 回调后端 `/api/v1/ad/pangle-callback` 发金币。 客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。 - 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。 -- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)、`VIDEO_ROUND_REQUIRED_COUNT=3`、`VIDEO_ROUND_COOLDOWN_SECONDS=600`(本轮 3 次后冷却 10 分钟)。 +- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=500`、`VIDEO_ROUND_REQUIRED_COUNT=1`、`VIDEO_ROUND_COOLDOWN_SECONDS=3`(每次广告关闭后 3 秒短冷却)。旧的观看时长闸 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示停用。 - **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。 --- @@ -57,7 +57,7 @@ ## C. 部署 + 包名 - [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代) -- [ ] **跑迁移**:`alembic upgrade head`(建 `ad_reward_record` 表,迁移 `c8d9e0f1a2b3`) +- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`signin_boost_record`、`ad_feed_reward_record` 等表) - [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换 - 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释) - [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善 @@ -74,9 +74,9 @@ - [ ] 真机看完一条**真实**激励视频 → 穿山甲 S2S 回调 → 后端发奖 → 客户端余额真到账 - [ ] **幂等**:同一 `trans_id` 不重复发 - [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」(Capped 态) -- [ ] **本轮冷却**(PR #5):连看 3 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 10 分钟后自动恢复 Normal -- [ ] **弹窗 limit note**:`round_count==0 && cooldown_until!=null` → "本轮视频已看完,10分钟后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来" -- [ ] **跨设备/杀进程一致**:在手机 A 看完 3 张进入冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地) +- [ ] **短冷却**:看完 1 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 3 秒后自动恢复 Normal +- [ ] **弹窗 limit note**:`cooldown_until!=null` → "广告冷却中,3秒后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来" +- [ ] **跨设备/杀进程一致**:在手机 A 看完 1 张进入短冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地) - [ ] 中途退出广告 → 客户端提示「未看完视频,本次没有奖励哦」,且不发奖 - [ ] 收益明细显示「看广告奖励」(biz_type=`ad_reward`) diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index c72bb39..2b30f3e 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -15,6 +15,7 @@ from app.core.rewards import ( MAX_AD_REWARD_COIN, VIDEO_ROUND_COOLDOWN_SECONDS, VIDEO_ROUND_REQUIRED_COUNT, + calculate_ad_reward_coin, ) from app.db.session import SessionLocal from app.models.ad_reward import AdRewardRecord @@ -204,19 +205,19 @@ def test_reward_status_initial_round_state(client) -> None: def test_reward_status_mid_round(client) -> None: - """看 1 次(未到一轮)→ round_count=1, cooldown_until 仍 None。""" + """当前 round_size=1:看 1 次 → round_count=0, cooldown_until 进入 3 秒短冷却。""" phone = "13800003102" token = _login(client, phone) uid = _user_id(phone) _callback(client, _signed(uid, "trans_mid_1")) st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() assert st["used_today"] == 1 - assert st["round_count"] == 1 - assert st["cooldown_until"] is None + assert st["round_count"] == 0 + assert st["cooldown_until"] is not None def test_reward_status_round_complete_enters_cooldown(client) -> None: - """刚看完一轮 N 次 → round_count=0(下一轮起点) + cooldown_until 是未来 ~10 分钟。""" + """刚看完一条广告 → round_count=0(下一轮起点) + cooldown_until 是未来 ~3 秒。""" from datetime import datetime, timezone phone = "13800003103" @@ -234,12 +235,12 @@ def test_reward_status_round_complete_enters_cooldown(client) -> None: cd = cd.replace(tzinfo=timezone.utc) now = datetime.now(timezone.utc) delta = (cd - now).total_seconds() - # 配置 600s,允许 ±30s 容差(本机 / CI 慢 IO) + # 配置 3s,允许 ±30s 容差(本机 / CI 慢 IO) assert 0 < delta <= VIDEO_ROUND_COOLDOWN_SECONDS + 30 def test_reward_status_cooldown_expired(client) -> None: - """看完一轮后冷却已过(手动改 created_at 到 11 分钟前)→ cooldown_until 回到 None。""" + """看完一条广告后冷却已过(手动改 created_at 到冷却前)→ cooldown_until 回到 None。""" from datetime import datetime, timedelta, timezone from sqlalchemy import select, update @@ -268,6 +269,61 @@ def test_reward_status_cooldown_expired(client) -> None: assert st["cooldown_until"] is None +def test_feed_reward_grants_by_10_second_units(client) -> None: + """信息流广告完成后:每满 10 秒一份奖励,同一 client_event_id 幂等。""" + phone = "13800003301" + token = _login(client, phone) + + payload = { + "client_event_id": "feed_evt_0001", + "ecpm": "200", + "duration_seconds": 30, + "adn": "pangle", + "slot_id": "slot_feed", + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4)) + assert body["granted"] is True + assert body["status"] == "granted" + assert body["unit_count"] == 3 + assert body["coin"] == expected + assert _coin_balance(client, token) == expected + + # 重试同一个事件不重复发 + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["coin"] == expected + assert _coin_balance(client, token) == expected + + +def test_feed_reward_daily_display_cap(client, monkeypatch) -> None: + """信息流展示次数到每日上限后继续完成 → capped,不发金币。""" + monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 1) + phone = "13800003302" + token = _login(client, phone) + + first = { + "client_event_id": "feed_evt_cap_1", + "ecpm": "201", + "duration_seconds": 10, + } + second = { + "client_event_id": "feed_evt_cap_2", + "ecpm": "201", + "duration_seconds": 10, + } + assert client.post("/api/v1/ad/feed-reward", json=first, headers=_auth(token)).status_code == 200 + before = _coin_balance(client, token) + r = client.post("/api/v1/ad/feed-reward", json=second, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["granted"] is False + assert r.json()["status"] == "capped" + assert r.json()["coin"] == 0 + assert _coin_balance(client, token) == before + + def test_callback_disabled_returns_503(client, monkeypatch) -> None: """未配置回调(开关关)时 → 503。""" monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False) diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 1a5fead..12797bc 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -69,14 +69,17 @@ def test_list_config(admin_client: TestClient, token: str) -> None: assert r.status_code == 200, r.text items = {i["key"]: i for i in r.json()} assert "signin_rewards" in items and "ad_daily_limit" in items - assert items["signin_rewards"]["value"] == [10, 20, 30, 50, 80, 120, 200] + assert items["signin_rewards"]["value"] == [ + 200, 120, 150, 180, 200, 250, 500, + 200, 250, 300, 350, 400, 500, 3000, + ] assert items["signin_rewards"]["overridden"] is False def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None: r = admin_client.patch( "/admin/api/config/signin_rewards", - json={"value": [100, 200, 300, 400, 500, 600, 700]}, + json={"value": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400]}, headers=_auth(token), ) assert r.status_code == 200, r.text @@ -116,7 +119,7 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N def test_config_validation(admin_client: TestClient, token: str) -> None: - # 签到档位长度≠7 + # 签到档位长度≠14 assert admin_client.patch( "/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token) ).status_code == 400 diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 1569d2f..85696f0 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -193,11 +193,38 @@ def test_detail_cross_user_404(client) -> None: assert r.status_code == 404 +def test_stats_compare_count_and_saved(client) -> None: + """比价口径战绩:完成比价数 + 累计发现可省(各成功比价 saved 之和)。""" + token = _login(client, "13800002011") + # 新用户:无记录 → 全 0(配合「我的」页未比价锁定态) + s0 = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s0["compare_count"] == 0 + assert s0["discovered_saved_cents"] == 0 + + # 报 2 条成功比价(各省 128.50−123.50=5.00 元=500 分) + client.post("/api/v1/compare/record", json=_food_payload("stat-1"), headers=_auth(token)) + client.post("/api/v1/compare/record", json=_food_payload("stat-2"), headers=_auth(token)) + s = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s["compare_count"] == 2 + assert s["discovered_saved_cents"] == 1000 + + # 失败帧(只有源平台)不计入成功比价数 + fail = _food_payload("stat-fail") + fail["comparison_results"] = [ + {"platform_id": "taobao_flash", "platform_name": "淘宝闪购", + "package": "com.taobao.taobao", "price": 128.50, "is_source": True, "rank": 1}, + ] + client.post("/api/v1/compare/record", json=fail, headers=_auth(token)) + s2 = client.get("/api/v1/compare/stats", headers=_auth(token)).json() + assert s2["compare_count"] == 2 # 仍 2(failed 不计) + + def test_requires_auth(client) -> None: """不带 token 统一 401。""" assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401 assert client.get("/api/v1/compare/records").status_code == 401 assert client.get("/api/v1/compare/records/1").status_code == 401 + assert client.get("/api/v1/compare/stats").status_code == 401 def test_trace_id_required(client) -> None: diff --git a/tests/test_order_savings.py b/tests/test_order_savings.py index d6b1226..9adc684 100644 --- a/tests/test_order_savings.py +++ b/tests/test_order_savings.py @@ -1,7 +1,7 @@ """记账测试:比价后下单上报 → 写 savings_record(source='compare')。 覆盖:上报落库 + 门店/菜品/原价随之入库、(user,client_event_id) 幂等、 -真实(compare)记录优先于 demo seeder、省额 = 源平台原价 − 实付。 +纯真实口径(demo 兜底已停用:新用户空、上报后才有数)、省额 = 源平台原价 − 实付。 """ from __future__ import annotations @@ -78,12 +78,21 @@ def test_order_report_idempotent(client) -> None: assert s["order_count"] == 1 -def test_real_overrides_demo_seed(client) -> None: - """先触发 demo seeder(多笔),再上报一笔真实 → 统计切换为只算真实那笔。""" +def test_fresh_user_empty_until_report(client) -> None: + """纯真实(demo 兜底已停用):新用户 summary/battle/records 全空,上报后才有数。""" token = _login(client, "13800002003") - demo = client.get("/api/v1/savings/summary", headers=_auth(token)).json() - assert demo["order_count"] > 1 # demo 是多笔 + # 未上报:无任何 demo,统计全 0、明细空 + s0 = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert s0["order_count"] == 0 + assert s0["total_saved_cents"] == 0 + b0 = client.get("/api/v1/savings/battle", headers=_auth(token)).json() + assert b0["compare_count"] == 0 + assert b0["streak_days"] == 0 + p0 = client.get("/api/v1/savings/records", headers=_auth(token)).json() + assert p0["items"] == [] + + # 上报一笔真实 → 统计只算这一笔 client.post( "/api/v1/order/report", json=_report_body( @@ -92,7 +101,7 @@ def test_real_overrides_demo_seed(client) -> None: headers=_auth(token), ) s = client.get("/api/v1/savings/summary", headers=_auth(token)).json() - assert s["order_count"] == 1 # demo 被忽略 + assert s["order_count"] == 1 assert s["total_saved_cents"] == 800 # 5000 − 4200 page = client.get("/api/v1/savings/records", headers=_auth(token)).json() diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 79743ba..ffe0331 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -90,6 +90,32 @@ def test_signin_flow(client) -> None: assert txn["balance_after"] == SIGNIN_REWARDS[0] +def test_signin_boost_flow(client) -> None: + """签到后看广告膨胀 → 补发一笔等额签到金币,每天只能膨胀一次。""" + token = _login(client, "13800001011") + + r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token)) + assert r.status_code == 409 + + r = client.post("/api/v1/signin", headers=_auth(token)) + assert r.status_code == 200, r.text + first_coin = r.json()["coin_awarded"] + + r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["coin_awarded"] == first_coin + assert body["coin_balance"] == first_coin * 2 + + r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token)) + assert r.status_code == 409 + + r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) + types = [t["biz_type"] for t in r.json()["items"]] + assert "signin" in types + assert "signin_boost" in types + + def test_task_claim_flow(client) -> None: """任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。""" token = _login(client, "13800001003") @@ -253,38 +279,62 @@ def test_exchange_insufficient_and_invalid(client) -> None: assert r.status_code == 400 +def _report_savings(client, token: str, n: int, *, original=5000, paid=4200) -> None: + """上报 n 笔真实比价记录(source='compare'),省额 = original − paid。""" + for i in range(n): + client.post( + "/api/v1/order/report", + json={ + "client_event_id": f"evt-{token[-6:]}-{i}", + "platform": "美团外卖", + "pay_channel": "wechat", + "compared_price_cents": paid, + "paid_amount_cents": paid, + "original_price_cents": original, + "shop_name": f"测试门店{i}", + "dishes": ["招牌菜"], + }, + headers=_auth(token), + ) + + def test_savings_summary_and_battle(client) -> None: - """首次访问触发 demo seeder,聚合接口返回真实算出的数字。""" + """聚合接口对真实上报记录返回真实算出的数字(demo 兜底已停用)。""" token = _login(client, "13800001007") + _report_savings(client, token, 2) # 2 笔,各省 5000−4200=800 r = client.get("/api/v1/savings/summary", headers=_auth(token)) assert r.status_code == 200, r.text s = r.json() - assert s["order_count"] > 0 - assert s["total_saved_cents"] > 0 + assert s["order_count"] == 2 + assert s["total_saved_cents"] == 1600 # 平均每单 = 总额 // 单数 assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"] r = client.get("/api/v1/savings/battle", headers=_auth(token)) assert r.status_code == 200, r.text b = r.json() - assert b["streak_days"] >= 1 + assert b["streak_days"] >= 1 # 今日上报 → 至少连续 1 天 assert b["week_saved_cents"] >= 0 assert 0 <= b["beat_percent"] <= 100 - # 完成比价次数与 summary 的 order_count 同源(均 = 有效记录数) + # 完成比价次数与 summary 的 order_count 同源(均 = 真实记录数) assert b["compare_count"] == s["order_count"] -def test_savings_seeder_idempotent(client) -> None: - """重复访问不应重复灌数据:两次 summary 完全一致。""" +def test_savings_empty_for_new_user(client) -> None: + """新用户无真实上报时全 0(demo 兜底已停用),两次读一致。""" token = _login(client, "13800001008") first = client.get("/api/v1/savings/summary", headers=_auth(token)).json() second = client.get("/api/v1/savings/summary", headers=_auth(token)).json() assert first == second + assert first["order_count"] == 0 + assert first["total_saved_cents"] == 0 def test_savings_records_pagination(client) -> None: token = _login(client, "13800001009") + _report_savings(client, token, 12) # 12 笔真实记录撑起分页 + # 第一页 r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token)) assert r.status_code == 200, r.text